Compare commits
9 Commits
ci-packagi
...
m13-audio-
| Author | SHA1 | Date |
|---|---|---|
|
|
5afc25d0d8 | |
|
|
9607be471b | |
|
|
f013bdd8d7 | |
|
|
1ac55c33a3 | |
|
|
97390f15fb | |
|
|
6a5abad753 | |
|
|
26c714db7f | |
|
|
4f7723a7b6 | |
|
|
0f1cad8bbd |
|
|
@ -10,7 +10,7 @@ once.
|
|||
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
|
||||
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
|
||||
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content, content-keyed RAM preview frames) |
|
||||
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
|
||||
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
|
||||
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
|
||||
|
|
@ -33,6 +33,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026)
|
|||
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
|
||||
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
|
||||
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
|
||||
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
|
||||
Brush and face tracking ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
|
||||
|
||||
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
fe!("playback.cacheWhenIdle", "Cache Frames When Idle", ["Composition", "Preview"], None, "{value?}", always),
|
||||
fe!("playback.audio", "Audio", ["Composition", "Preview"], None, "{value?: include audio in previews}", always),
|
||||
fe!(
|
||||
"playback.scrubAudio",
|
||||
"Scrub Audio",
|
||||
[],
|
||||
None,
|
||||
"{time? (s, default the current time)} — plays one frame of the comp's audio there (Ctrl/Cmd-drag the current time)",
|
||||
has_comp
|
||||
),
|
||||
// Viewer.
|
||||
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
|
||||
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
|
||||
|
|
@ -64,6 +72,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
|
||||
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
|
||||
fe!(
|
||||
"view.newViewer",
|
||||
"New Viewer",
|
||||
["View"],
|
||||
Some("Alt+Shift+N"),
|
||||
"{} — another Composition viewer of the active comp; the one in use is locked",
|
||||
has_comp
|
||||
),
|
||||
// Panels and workspaces.
|
||||
fe!(
|
||||
"window.panel",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! comp seconds; layer markers are stored in layer time. All commands are undoable.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Comp, CuePoint, ItemId, LayerId, LayerSource, Marker};
|
||||
use effectcraft_project::{Comp, CuePoint, ItemId, Layer, LayerId, LayerSource, Marker, Project};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -85,6 +85,56 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Array(ms.iter().enumerate().map(|(i, m)| marker_json(c, o, i, m)).collect()))
|
||||
}
|
||||
|
||||
/// The nested comp's markers on a precomp layer, at their times in the containing comp `comp`
|
||||
/// and within the layer's In–Out range, as After Effects shows them on the precomp layer's bar:
|
||||
/// `(comp time, index in the nested comp, marker)`. The layer's start time and stretch map them;
|
||||
/// with time remapping, or a stretch over protected regions (Responsive Design), a marker sits at
|
||||
/// the first frame the layer shows its time.
|
||||
pub fn nested_markers<'a>(project: &'a Project, comp_id: ItemId, comp: &Comp, layer: &Layer) -> Vec<(Tick, usize, &'a Marker)> {
|
||||
let LayerSource::Comp { item } = layer.source else { return vec![] };
|
||||
let Some(nc) = project.comp(item) else { return vec![] };
|
||||
if nc.markers.is_empty() || item == comp_id {
|
||||
return vec![];
|
||||
}
|
||||
let shown = |t: Tick| t >= layer.in_point && t < layer.out_point;
|
||||
let stretched_regions = (layer.stretch - 100.0).abs() > 1e-9 && nc.markers.iter().any(|m| m.protected);
|
||||
if layer.props.get("timeRemap").is_none() && !stretched_regions {
|
||||
return nc.markers.iter().enumerate().map(|(i, m)| (layer.comp_time(m.time), i, m)).filter(|(t, _, _)| shown(*t)).collect();
|
||||
}
|
||||
// The layer's source time at every frame of its range; each marker at the first frame that
|
||||
// reaches it (within rounding: a thousandth of a nested frame).
|
||||
let fr = comp.frame_rate;
|
||||
let (f0, f1) = (fr.frame_at(layer.in_point), fr.frame_at(layer.out_point));
|
||||
let frames: Vec<(Tick, Tick)> = (f0..f1.min(f0.saturating_add(100_000)))
|
||||
.map(|f| {
|
||||
let t = fr.tick_of(f);
|
||||
(t, effectcraft_render::EvalCtx::new(project, comp_id, comp, t).source_time(layer))
|
||||
})
|
||||
.collect();
|
||||
let eps = nc.frame_duration().0 / 1000;
|
||||
nc.markers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, m)| frames.iter().find(|(_, src)| src.0.saturating_add(eps) >= m.time.0).map(|(t, _)| (*t, i, m)))
|
||||
.filter(|(t, _, _)| shown(*t))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `markers.nested`: the nested comp's markers on a precomp layer (see [`nested_markers`]).
|
||||
fn nested(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "markers.nested";
|
||||
let cid = comp_id(s, p)?;
|
||||
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let lid = p.get("layer").and_then(|v| resolve_layer(c, v)).ok_or_else(|| bad(cmd, "missing `layer` (a precomp layer)"))?;
|
||||
let l = c.layer(lid).ok_or_else(|| bad(cmd, "no such layer"))?;
|
||||
let LayerSource::Comp { item } = l.source else { return Err(bad(cmd, "not a precomp layer")) };
|
||||
let v: Vec<Value> = nested_markers(&s.project, cid, c, l)
|
||||
.into_iter()
|
||||
.map(|(t, i, m)| json!({"time": t.seconds(), "comp": item.0, "index": i, "nestedTime": m.time.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "label": m.label.name()}))
|
||||
.collect();
|
||||
Ok(json!(v))
|
||||
}
|
||||
|
||||
fn locked(c: &Comp, o: Owner) -> bool {
|
||||
matches!(o, Owner::Layer(l) if c.layer(l).is_some_and(|l| l.markers_locked))
|
||||
}
|
||||
|
|
@ -299,6 +349,12 @@ fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("markers.list", "List Markers", "{layer? (omit for composition markers)}", list),
|
||||
query!(
|
||||
"markers.nested",
|
||||
"Nested Comp Markers",
|
||||
"{layer: a precomp layer, comp?} — its comp's markers at their times in this comp (as on the layer bar)",
|
||||
nested
|
||||
),
|
||||
cmd!(
|
||||
"markers.set",
|
||||
"Marker Settings",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ mod layer_time;
|
|||
pub(crate) mod link;
|
||||
mod liquify;
|
||||
mod lottie;
|
||||
mod markers;
|
||||
pub mod markers;
|
||||
mod mask;
|
||||
pub mod mask_interp;
|
||||
pub(crate) mod model3d;
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,7 @@ Animation
|
|||
Reveal Properties with Animation | anim.reveal {"kind":"animation"}
|
||||
Reveal All Modified Properties | anim.reveal {"kind":"modified"}
|
||||
View
|
||||
New Viewer | view.newViewer
|
||||
Split with New Locked Viewer | view.splitLockedViewer
|
||||
---
|
||||
Zoom In | view.zoomIn
|
||||
|
|
|
|||
|
|
@ -119,4 +119,13 @@ fn footage_changes_change_the_salt() {
|
|||
// Unrelated comps do not change it.
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 10, "height": 10, "frameRate": 30, "duration": 1})).unwrap();
|
||||
assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2);
|
||||
// A proxy, and its Use Proxy switch, change the pixels.
|
||||
let set_proxy = |s: &mut Session, enabled: bool| {
|
||||
let it = std::sync::Arc::make_mut(&mut s.project).items.get_mut(&cid).unwrap();
|
||||
it.proxy = Some(Box::new(effectcraft_project::Proxy { footage: effectcraft_project::Footage::default(), enabled }));
|
||||
effectcraft_render::disk_cache::comp_content_key(&s.project, cid)
|
||||
};
|
||||
let k3 = set_proxy(&mut s, true);
|
||||
assert_ne!(k3, k2);
|
||||
assert_ne!(set_proxy(&mut s, false), k3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,3 +147,37 @@ fn precompose_leave_and_move_attributes() {
|
|||
assert_eq!((nl.in_point.seconds(), nl.out_point.seconds()), (1.0, 3.0));
|
||||
assert_eq!(s.active_comp_id(), Some(inner));
|
||||
}
|
||||
|
||||
/// A precomp layer shows its comp's markers on its bar, mapped through its timing.
|
||||
#[test]
|
||||
fn nested_comp_markers_follow_the_precomp_layers_timing() {
|
||||
let mut s = crate::Session::default();
|
||||
let pre = s.execute("comp.new", json!({"name": "Pre", "width": 32, "height": 32, "frameRate": 10, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
for (t, c) in [(1.0, "beat"), (3.0, "drop")] {
|
||||
s.execute("markers.set", json!({"new": true, "time": t, "comment": c})).unwrap();
|
||||
}
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 32, "height": 32, "frameRate": 10, "duration": 20})).unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": pre})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [l], "start": 2.0})).unwrap();
|
||||
let times = |s: &mut crate::Session| -> Vec<(f64, String)> {
|
||||
let v = s.execute_checked("markers.nested", json!({"layer": l})).unwrap();
|
||||
v.as_array().unwrap().iter().map(|m| (m["time"].as_f64().unwrap(), m["comment"].as_str().unwrap().to_string())).collect()
|
||||
};
|
||||
assert_eq!(times(&mut s), vec![(3.0, "beat".to_string()), (5.0, "drop".to_string())]);
|
||||
// Stretched to 200 %: twice as far from the start.
|
||||
s.execute("layer.timeStretch", json!({"layers": [l], "percent": 200})).unwrap();
|
||||
assert_eq!(times(&mut s), vec![(4.0, "beat".to_string()), (8.0, "drop".to_string())]);
|
||||
s.execute("layer.timeStretch", json!({"layers": [l], "percent": 100})).unwrap();
|
||||
// Trimmed: markers outside the In–Out range are not on the bar.
|
||||
s.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap();
|
||||
assert_eq!(times(&mut s), vec![(3.0, "beat".to_string())]);
|
||||
s.execute("layer.timing", json!({"layers": [l], "out": 6.0})).unwrap();
|
||||
// Time remapping at half speed: nested 1 s is reached 2 s into the layer.
|
||||
s.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "timeRemap", "time": 4.0, "value": 2.0})).unwrap();
|
||||
let t = times(&mut s);
|
||||
assert_eq!(t.first(), Some(&(4.0, "beat".to_string())), "{t:?}");
|
||||
// Not a precomp layer.
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("markers.nested", json!({"layer": solid})).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -778,8 +778,9 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
|
|||
}
|
||||
|
||||
/// Content key of a composition's pixels: project render settings, the comp and everything it
|
||||
/// uses (nested comps, footage, solids, footage files' size and time). With expressions in play
|
||||
/// (which can read any comp) the whole project counts.
|
||||
/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy
|
||||
/// files' size and time). With expressions in play (which can read any comp) the whole project
|
||||
/// counts.
|
||||
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
|
||||
use effectcraft_project::{ItemKind, LayerSource};
|
||||
let mut h = Hash128::default();
|
||||
|
|
@ -813,6 +814,10 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
|
|||
if let ItemKind::Footage(f) = &it.kind {
|
||||
footage_stamp(&mut h, f);
|
||||
}
|
||||
hash_debug(&mut h, &it.proxy);
|
||||
if let Some(px) = &it.proxy {
|
||||
footage_stamp(&mut h, &px.footage);
|
||||
}
|
||||
}
|
||||
h.write_u64(comp.0);
|
||||
h.finish()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@
|
|||
//! than drifting.
|
||||
//!
|
||||
//! The feed also tracks per-channel peaks for the Audio panel's VU meters ([`Meter`]).
|
||||
//!
|
||||
//! **Scrubbing:** Ctrl/Cmd-dragging the current-time indicator plays a short snippet of the mix
|
||||
//! at each new frame ([`AudioScrub`], `playback.scrubAudio`), as in After Effects.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
|
|
@ -93,6 +96,13 @@ impl AudioFeed {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drop what is still queued (scrubbing replaces the snippet still playing).
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
q.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, samples: &[f32]) {
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
q.extend(samples.iter().copied());
|
||||
|
|
@ -278,18 +288,82 @@ fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpa
|
|||
cursor = span.wa;
|
||||
}
|
||||
let n = ((span.wb - cursor) as usize).min(BLOCK);
|
||||
let t = sample_time(cursor, rate);
|
||||
let buf = if mix == rate {
|
||||
effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, n, rate)
|
||||
} else {
|
||||
let m = (n as u64 * mix as u64).div_ceil(rate as u64) as usize + 1;
|
||||
let b = effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, m, mix);
|
||||
resample_stereo(&b, mix, rate, n)
|
||||
};
|
||||
feed.push(&buf);
|
||||
feed.push(&mix_block(src, comp, sample_time(cursor, rate), n, rate, mix));
|
||||
cursor + n as i64
|
||||
}
|
||||
|
||||
/// `n` stereo frames of `comp`'s mix from comp time `t` at the device `rate`, mixed at `mix` Hz
|
||||
/// (resampled when they differ).
|
||||
fn mix_block(src: &RenderSource, comp: ItemId, t: Tick, n: usize, rate: u32, mix: u32) -> Vec<f32> {
|
||||
let render =
|
||||
|frames: usize, at: u32| effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, frames, at);
|
||||
if mix == rate {
|
||||
return render(n, rate);
|
||||
}
|
||||
let m = (n as u64 * mix as u64).div_ceil(rate as u64) as usize + 1;
|
||||
resample_stereo(&render(m, mix), mix, rate, n)
|
||||
}
|
||||
|
||||
/// Audio scrubbing: an open output that plays a short, faded snippet of the comp's mix each time
|
||||
/// the current time moves to another frame (each snippet replaces what is still queued, so the
|
||||
/// sound follows the pointer without lagging behind).
|
||||
pub struct AudioScrub {
|
||||
pub feed: Arc<AudioFeed>,
|
||||
device: Box<dyn AudioDevice>,
|
||||
pub rate: u32,
|
||||
/// The comp and frame last played (holding still repeats nothing).
|
||||
last: Option<(ItemId, i64)>,
|
||||
/// When it last played (UI seconds): an idle scrub closes the device.
|
||||
pub last_used: f64,
|
||||
}
|
||||
|
||||
impl AudioScrub {
|
||||
pub fn open(mut device: Box<dyn AudioDevice>, now: f64) -> Result<AudioScrub, String> {
|
||||
let rate = device.sample_rate().max(8000);
|
||||
let feed = Arc::new(AudioFeed::default());
|
||||
device.start(feed.clone())?;
|
||||
Ok(AudioScrub { feed, device, rate, last: None, last_used: now })
|
||||
}
|
||||
|
||||
/// Play the frame of `comp` at `t` (one frame long, 30–100 ms) unless it was the last one
|
||||
/// played. Returns whether a snippet was queued.
|
||||
pub fn play(&mut self, src: &RenderSource, comp: ItemId, t: Tick, mix_rate: u32, now: f64) -> bool {
|
||||
self.last_used = now;
|
||||
let Some(c) = src.project.comp(comp) else { return false };
|
||||
let frame = c.frame_rate.frame_at(t);
|
||||
if self.last == Some((comp, frame)) {
|
||||
return false;
|
||||
}
|
||||
self.last = Some((comp, frame));
|
||||
let rate = self.rate;
|
||||
let mix = if mix_rate == 0 { rate } else { mix_rate.clamp(8000, 192_000) };
|
||||
let n = (c.frame_duration().seconds().clamp(0.03, 0.1) * rate as f64) as usize;
|
||||
let mut buf = mix_block(src, comp, c.frame_rate.tick_of(frame), n, rate, mix);
|
||||
// 3 ms fades: no clicks where snippets meet.
|
||||
let fade = (rate as usize * 3 / 1000).clamp(1, n / 2 + 1);
|
||||
let frames = buf.len() / 2;
|
||||
for i in 0..frames {
|
||||
let g = (i.min(frames - 1 - i) as f32 / fade as f32).min(1.0);
|
||||
buf[2 * i] *= g;
|
||||
buf[2 * i + 1] *= g;
|
||||
}
|
||||
self.feed.clear();
|
||||
self.feed.push(&buf);
|
||||
true
|
||||
}
|
||||
|
||||
/// Every UI frame while open (Web Audio moves queued samples to the output here).
|
||||
pub fn pump(&mut self) {
|
||||
self.device.pump();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioScrub {
|
||||
fn drop(&mut self) {
|
||||
self.device.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear resampling of interleaved stereo from `from` Hz to `n` frames at `to` Hz.
|
||||
pub fn resample_stereo(src: &[f32], from: u32, to: u32, n: usize) -> Vec<f32> {
|
||||
let frames = src.len() / 2;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ pub enum PanelKind {
|
|||
EssentialGraphics,
|
||||
/// A dockable ScriptUI panel (a script from the ScriptUI Panels folder), by script window id.
|
||||
ScriptPanel(u32),
|
||||
/// Another Composition viewer (View ▸ New Viewer), by viewer id (`panels::viewers`).
|
||||
Viewer(u32),
|
||||
LumetriScopes,
|
||||
Footage,
|
||||
MediaBrowser,
|
||||
|
|
@ -116,6 +118,7 @@ impl PanelKind {
|
|||
PanelKind::ScriptConsole => "Script Console",
|
||||
PanelKind::EssentialGraphics => "Essential Graphics",
|
||||
PanelKind::ScriptPanel(_) => "ScriptUI Panel",
|
||||
PanelKind::Viewer(_) => "Composition",
|
||||
PanelKind::LumetriScopes => "Lumetri Scopes",
|
||||
PanelKind::Footage => "Footage",
|
||||
PanelKind::MediaBrowser => "Media Browser",
|
||||
|
|
|
|||
|
|
@ -25,9 +25,13 @@ impl EffectcraftApp {
|
|||
out.push((PanelKind::ScriptPanel(w.id), t));
|
||||
}
|
||||
}
|
||||
// Each Composition viewer is named after the comp it shows.
|
||||
for id in std::iter::once(0).chain(self.ui.viewers.keys().copied().filter(|v| *v != 0)) {
|
||||
if let Some(t) = panels::viewers::title(self, id) {
|
||||
out.push((panels::viewers::panel(id), t));
|
||||
}
|
||||
}
|
||||
let Some(comp) = self.session.active_comp() else { return out };
|
||||
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
out.push((PanelKind::Composition, format!("Composition {cname}")));
|
||||
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
|
||||
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
|
||||
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
|
||||
|
|
@ -64,10 +68,14 @@ impl EffectcraftApp {
|
|||
fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> {
|
||||
let Some(cid) = self.session.active_comp_id() else { return vec![] };
|
||||
let swatch = self.comp_swatch(cid);
|
||||
let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline]
|
||||
.into_iter()
|
||||
.map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true }))
|
||||
.collect();
|
||||
let mut v: Vec<(PanelKind, dock::TabDeco)> =
|
||||
vec![(PanelKind::Timeline, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&PanelKind::Timeline.id()), viewer: true })];
|
||||
// Every Composition viewer: its comp's swatch and its own lock.
|
||||
for id in std::iter::once(0).chain(self.ui.viewers.keys().copied().filter(|v| *v != 0)) {
|
||||
let p = panels::viewers::panel(id);
|
||||
let swatch = panels::viewers::comp_of(self, id).map_or(self.tokens.text_faint, |c| self.comp_swatch(c));
|
||||
v.push((p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true }));
|
||||
}
|
||||
// Effect Controls carries the selected layer's label colour.
|
||||
if let Some(l) = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|id| c.layer(*id)))
|
||||
&& l.label != effectcraft_engine::color::Label::None
|
||||
|
|
@ -123,6 +131,7 @@ impl EffectcraftApp {
|
|||
}
|
||||
self.ui.dock.close(p);
|
||||
self.edit_layout(|l| l.unfloat(p));
|
||||
panels::viewers::on_close(self, p);
|
||||
if self.ui.maximized == Some(p) {
|
||||
self.ui.maximized = None;
|
||||
}
|
||||
|
|
@ -141,7 +150,8 @@ impl EffectcraftApp {
|
|||
fn panel_body(&mut self, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
|
||||
self.auto.add(&format!("panel.{}", p.id()), rect, p.title());
|
||||
let mut content = rect;
|
||||
if p == PanelKind::Composition && !self.ui.start_screen && panels::precomp::has_flow(self) {
|
||||
let viewer = p == panels::viewers::active_panel(self);
|
||||
if viewer && !self.ui.start_screen && panels::precomp::has_flow(self) {
|
||||
// Composition Navigator: the flow of nested comps above the viewer.
|
||||
let nav = Rect::from_min_size(content.min, vec2(content.width(), panels::precomp::NAV_H));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(nav).id_salt("comp-navigator"));
|
||||
|
|
@ -152,7 +162,7 @@ impl EffectcraftApp {
|
|||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())));
|
||||
child.set_clip_rect(content.intersect(ui.clip_rect()));
|
||||
panels::show(self, &mut child, p, content);
|
||||
if p == PanelKind::Composition && !self.ui.start_screen {
|
||||
if viewer && !self.ui.start_screen {
|
||||
panels::anim_tools::sketch_overlay(self, &mut child);
|
||||
}
|
||||
}
|
||||
|
|
@ -220,7 +230,13 @@ impl EffectcraftApp {
|
|||
DockAction::ToggleStacked(p) => {
|
||||
self.ui.dock.toggle_stacked(p);
|
||||
}
|
||||
DockAction::Focus(p) => self.ui.focused = p,
|
||||
DockAction::Focus(p) => {
|
||||
self.ui.focused = p;
|
||||
// A click in (or on the tab of) another Composition viewer makes it active.
|
||||
if let Some(id) = panels::viewers::id_of(p) {
|
||||
panels::viewers::activate(self, &ctx, id);
|
||||
}
|
||||
}
|
||||
DockAction::Close(p) => self.close_panel(p),
|
||||
DockAction::PanelMenu(p, pos) => {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos)));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
//! Background frame rendering and the RAM preview cache.
|
||||
//!
|
||||
//! Frames render on a dedicated thread pool from immutable `Arc<Project>` snapshots, so the UI
|
||||
//! never waits for the compositor. Results land in a memory-budgeted cache keyed by (project
|
||||
//! revision, comp, frame, scale, view, render options); the timeline draws the cached range as
|
||||
//! the green cache bar. When the budget is full the least recently shown frames go first, and
|
||||
//! frames of an older revision are dropped as soon as the project changes (they can never be
|
||||
//! shown again).
|
||||
//! never waits for the compositor. Results land in a memory-budgeted cache keyed by the comp's
|
||||
//! *content* ([`comp_content`]: the comp and everything it uses), frame, scale, view and render
|
||||
//! options; the timeline draws the cached range as the green cache bar. An edit keeps the frames
|
||||
//! of every comp it doesn't touch, and undo finds the frames of the state it returns to, as in
|
||||
//! After Effects. When the budget is full the least recently shown frames go first.
|
||||
//!
|
||||
//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] hands them
|
||||
//! to a [`RemoteFrames`] (the browser's frame worker, a second engine instance fed with project
|
||||
|
|
@ -22,16 +22,19 @@
|
|||
//! registers with egui-wgpu and draws directly; pixels are read back only when something needs
|
||||
//! them (Info panel, eyedroppers, histograms).
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::project::{ItemId, Project};
|
||||
use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Node, Project, PropGroup};
|
||||
use effectcraft_engine::render::disk_cache::{self, DiskCache};
|
||||
use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_gpu::{DisplayFrame, Gpu};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of.
|
||||
const MAX_KEEPERS: usize = 64;
|
||||
|
||||
/// GPU frames kept for RAM preview (bytes of video memory).
|
||||
const GPU_BUDGET: usize = 1 << 30;
|
||||
|
||||
|
|
@ -57,9 +60,14 @@ impl FrameImage {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
/// A viewer frame: its comp's content, the frame, scale, view and render options. `revision` is
|
||||
/// the project revision it was asked for at (job bookkeeping); it is not part of the identity, so
|
||||
/// a later revision with the same content finds the frame.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FrameKey {
|
||||
pub revision: u64,
|
||||
/// The comp's content key ([`Frames::content_of`]).
|
||||
pub content: u64,
|
||||
pub comp: u64,
|
||||
pub frame: i64,
|
||||
/// Render scale × 1000.
|
||||
|
|
@ -71,12 +79,31 @@ pub struct FrameKey {
|
|||
}
|
||||
|
||||
impl FrameKey {
|
||||
/// Whether `self` is a frame of the same comp, revision, scale, view and options as `base`.
|
||||
/// Everything but the revision.
|
||||
fn id(&self) -> (u64, u64, i64, u32, u64, u64) {
|
||||
(self.content, self.comp, self.frame, self.scale, self.view, self.opts)
|
||||
}
|
||||
|
||||
/// Whether `self` is a frame of the same comp content, scale, view and options as `base`.
|
||||
pub fn same_series(&self, base: &FrameKey) -> bool {
|
||||
FrameKey { frame: base.frame, ..*self } == *base
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for FrameKey {
|
||||
fn eq(&self, o: &FrameKey) -> bool {
|
||||
self.id() == o.id()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for FrameKey {}
|
||||
|
||||
impl std::hash::Hash for FrameKey {
|
||||
fn hash<H: std::hash::Hasher>(&self, h: &mut H) {
|
||||
self.id().hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
struct Cache {
|
||||
map: HashMap<FrameKey, FrameImage>,
|
||||
order: VecDeque<FrameKey>,
|
||||
|
|
@ -129,6 +156,14 @@ impl Cache {
|
|||
self.order.push_back(k);
|
||||
}
|
||||
}
|
||||
/// Drop every frame of the identities in `gone`.
|
||||
fn drop_contents(&mut self, gone: &HashSet<u64>) {
|
||||
let keys: Vec<FrameKey> = self.map.keys().filter(|k| gone.contains(&k.content)).copied().collect();
|
||||
for k in &keys {
|
||||
self.remove(k);
|
||||
}
|
||||
self.order.retain(|k| !gone.contains(&k.content));
|
||||
}
|
||||
fn evict_to_budget(&mut self) {
|
||||
while self.bytes > self.budget {
|
||||
let Some(old) = self.order.pop_front() else { break };
|
||||
|
|
@ -173,6 +208,54 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64)
|
|||
k
|
||||
}
|
||||
|
||||
/// The identity of a comp's pixels in `project`, cheap enough to take at every revision: the
|
||||
/// addresses of the comps it draws (an edit replaces a comp's `Arc`: `Arc::make_mut` copies it
|
||||
/// while another snapshot holds it, and [`Frames::content_of`] keeps one for every identity it
|
||||
/// hands out), and by value the project settings and the footage, solids and proxies it uses.
|
||||
/// With expressions in its comps (which can read anything) every item counts.
|
||||
pub fn comp_content(project: &Project, comp: ItemId) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
fn has_expression(g: &PropGroup) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
Node::Prop(p) => p.expr.is_some(),
|
||||
Node::Group(sub) => has_expression(sub),
|
||||
})
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
format!("{:?}", project.settings).hash(&mut h);
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut stack = vec![comp];
|
||||
let mut expressions = false;
|
||||
while let Some(id) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(ItemKind::Comp(c)) = project.item(id).map(|i| &i.kind) else { continue };
|
||||
for l in &c.layers {
|
||||
if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source {
|
||||
stack.push(item);
|
||||
}
|
||||
expressions = expressions || has_expression(&l.props);
|
||||
}
|
||||
}
|
||||
let ids: Vec<ItemId> = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() };
|
||||
for id in ids {
|
||||
let Some(it) = project.item(id) else { continue };
|
||||
id.0.hash(&mut h);
|
||||
if expressions {
|
||||
// (`comp("Name")`, `footage("Name")`)
|
||||
it.name.hash(&mut h);
|
||||
}
|
||||
match &it.kind {
|
||||
ItemKind::Comp(c) => (Arc::as_ptr(c) as usize).hash(&mut h),
|
||||
k => format!("{k:?}").hash(&mut h),
|
||||
}
|
||||
format!("{:?}", it.proxy).hash(&mut h);
|
||||
}
|
||||
comp.0.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Hash of render options (part of [`FrameKey`] and of the disk key).
|
||||
pub fn opts_hash(o: &RenderOpts) -> u64 {
|
||||
let mut h = disk_cache::Hash128::default();
|
||||
|
|
@ -259,6 +342,11 @@ pub struct Frames {
|
|||
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
|
||||
pub last_ms: Arc<Mutex<f64>>,
|
||||
content_keys: ContentKeys,
|
||||
/// [`comp_content`] identities by (revision, comp).
|
||||
identities: Mutex<HashMap<(u64, u64), u64>>,
|
||||
/// A project snapshot for each identity handed out, with when it was last asked for (see
|
||||
/// [`Frames::content_of`]); the counter.
|
||||
keepers: Mutex<(u64, HashMap<u64, (u64, Arc<Project>)>)>,
|
||||
/// Where frames render instead of the UI thread (wasm32), and how many it is rendering.
|
||||
remote: Option<Arc<dyn RemoteFrames>>,
|
||||
remote_busy: Arc<Mutex<usize>>,
|
||||
|
|
@ -284,6 +372,8 @@ impl Default for Frames {
|
|||
ctx: None,
|
||||
last_ms: Arc::new(Mutex::new(0.0)),
|
||||
content_keys: Arc::default(),
|
||||
identities: Mutex::default(),
|
||||
keepers: Mutex::default(),
|
||||
remote: None,
|
||||
remote_busy: Arc::default(),
|
||||
}
|
||||
|
|
@ -325,16 +415,44 @@ impl Frames {
|
|||
Some(img)
|
||||
}
|
||||
|
||||
/// Drop frames of project revisions before `revision` (and their queued jobs): an edit
|
||||
/// makes them unreachable, so they only take memory from frames that can still be shown.
|
||||
pub fn drop_stale(&self, revision: u64) {
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
let stale: Vec<FrameKey> = c.map.keys().filter(|k| k.revision < revision).copied().collect();
|
||||
for k in &stale {
|
||||
c.remove(k);
|
||||
/// The [`comp_content`] identity of `comp` in `project` at `revision` (taken once per
|
||||
/// revision). The project is kept while frames of that identity are cached or rendering, so
|
||||
/// the comp addresses it hashes can't be reused by other comps meanwhile.
|
||||
pub fn content_of(&self, project: &Arc<Project>, revision: u64, comp: ItemId) -> u64 {
|
||||
let known = self.identities.lock().ok().and_then(|m| m.get(&(revision, comp.0)).copied());
|
||||
let id = known.unwrap_or_else(|| {
|
||||
let id = comp_content(project, comp);
|
||||
if let Ok(mut m) = self.identities.lock() {
|
||||
if m.len() > 256 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert((revision, comp.0), id);
|
||||
}
|
||||
id
|
||||
});
|
||||
if let Ok(mut guard) = self.keepers.lock() {
|
||||
let (clock, k) = &mut *guard;
|
||||
*clock += 1;
|
||||
let now = *clock;
|
||||
k.entry(id).or_insert_with(|| (now, project.clone())).0 = now;
|
||||
if k.len() > MAX_KEEPERS {
|
||||
let inflight: HashSet<u64> = self.inflight.lock().map(|s| s.iter().map(|f| f.content).collect()).unwrap_or_default();
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
// Snapshots of identities without frames go; past the limit the least
|
||||
// recently asked for go too, with their frames (a long drag leaves many).
|
||||
let cached: HashSet<u64> = c.map.keys().map(|f| f.content).collect();
|
||||
k.retain(|i, _| *i == id || cached.contains(i) || inflight.contains(i));
|
||||
if k.len() > MAX_KEEPERS {
|
||||
let mut ages: Vec<(u64, u64)> = k.iter().filter(|(i, _)| **i != id && !inflight.contains(i)).map(|(i, (t, _))| (*t, *i)).collect();
|
||||
ages.sort_unstable();
|
||||
let gone: HashSet<u64> = ages.into_iter().take(k.len().saturating_sub(MAX_KEEPERS / 2)).map(|(_, i)| i).collect();
|
||||
k.retain(|i, _| !gone.contains(i));
|
||||
c.drop_contents(&gone);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.order.retain(|k| k.revision >= revision);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
pub fn is_cached(&self, k: &FrameKey) -> bool {
|
||||
|
|
@ -345,7 +463,7 @@ impl Frames {
|
|||
self.inflight.lock().map(|s| s.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cached frame numbers of the series of `base` (its comp, revision, scale, view and render
|
||||
/// Cached frame numbers of the series of `base` (its comp content, scale, view and render
|
||||
/// options) — for the cache bar.
|
||||
pub fn cached_frames(&self, base: &FrameKey) -> Vec<i64> {
|
||||
let Ok(c) = self.cache.lock() else { return vec![] };
|
||||
|
|
@ -744,8 +862,8 @@ impl Queue {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(revision: u64, frame: i64, opts: u64) -> FrameKey {
|
||||
FrameKey { revision, comp: 1, frame, scale: 1000, view: 0, opts }
|
||||
fn key(content: u64, frame: i64, opts: u64) -> FrameKey {
|
||||
FrameKey { revision: content, content, comp: 1, frame, scale: 1000, view: 0, opts }
|
||||
}
|
||||
|
||||
fn img() -> FrameImage {
|
||||
|
|
@ -771,17 +889,73 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn stale_revisions_are_dropped_and_series_are_kept_apart() {
|
||||
fn frames_are_keyed_by_content_and_series_are_kept_apart() {
|
||||
let f = Frames::default();
|
||||
insert(&f, key(1, 0, 0));
|
||||
insert(&f, key(2, 0, 0));
|
||||
insert(&f, key(2, 1, 7));
|
||||
// Other render options (Draft, shadows…) are another series.
|
||||
// Other content or render options (Draft, shadows…) are another series.
|
||||
assert_eq!(f.cached_frames(&key(2, 0, 0)), vec![0]);
|
||||
assert_eq!(f.cached_frames(&key(2, 0, 7)), vec![1]);
|
||||
f.drop_stale(2);
|
||||
assert!(!f.is_cached(&key(1, 0, 0)) && f.is_cached(&key(2, 0, 0)));
|
||||
let c = f.cache.lock().unwrap();
|
||||
assert_eq!((c.order.len(), c.bytes), (2, 2 * 16), "the memory is given back");
|
||||
// A later revision with the same content finds the frame (an edit elsewhere, an undo).
|
||||
assert!(f.is_cached(&FrameKey { revision: 99, ..key(1, 0, 0) }));
|
||||
assert!(!f.is_cached(&key(3, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_keeps_the_frames_of_comps_it_does_not_touch() {
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let mut s = Session::default();
|
||||
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
|
||||
let b = ItemId(s.execute("comp.new", json!({"name": "B", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
|
||||
let f = Frames::default();
|
||||
let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c);
|
||||
let (a0, b0) = (at(&s, a), at(&s, b));
|
||||
// Edit B: A's content (and frames) stay, B's change.
|
||||
s.execute("layer.newSolid", json!({"comp": b.0, "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(at(&s, a), a0);
|
||||
assert_ne!(at(&s, b), b0);
|
||||
// Undo: B is back to the content its old frames were rendered for.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(at(&s, b), b0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_drag_keeps_a_bounded_number_of_states() {
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let mut s = Session::default();
|
||||
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
|
||||
let f = Frames::default();
|
||||
let mut last = None;
|
||||
for i in 0..200 {
|
||||
s.execute("comp.settings", json!({"width": 8 + i, "merge": "drag"})).unwrap();
|
||||
let k = FrameKey { content: f.content_of(&s.project, s.revision, a), ..key(0, 0, 0) };
|
||||
insert(&f, k);
|
||||
last = Some(k);
|
||||
}
|
||||
// The snapshots and the frames kept stay bounded (a dropped identity's frames go with it,
|
||||
// so a later state that reuses its addresses can't be shown an old frame).
|
||||
assert!(f.keepers.lock().unwrap().1.len() <= MAX_KEEPERS);
|
||||
assert!(f.cache.lock().unwrap().map.len() <= MAX_KEEPERS);
|
||||
assert!(f.is_cached(&last.unwrap()), "the current state's frame stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_identity_keeps_its_comps_so_an_edit_in_place_still_changes_it() {
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let mut s = Session::default();
|
||||
let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
|
||||
// A project nothing else holds (no undo history): make_mut would edit the comp in place,
|
||||
// at the same address, if the identity hadn't kept a snapshot holding it.
|
||||
let mut p = Arc::new((*s.project).clone());
|
||||
drop(s);
|
||||
let f = Frames::default();
|
||||
let before = f.content_of(&p, 1, a);
|
||||
let comp = Arc::make_mut(&mut p).comp_mut(a).unwrap();
|
||||
comp.width = 16;
|
||||
assert_ne!(f.content_of(&p, 2, a), before);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,8 @@ pub struct EffectcraftApp {
|
|||
pub(crate) toast: Option<(String, f64)>,
|
||||
/// Texture of the last CPU frame shown in the viewer and the key it came from.
|
||||
pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey)>,
|
||||
/// The frames the other (passive) Composition viewers show, by viewer id.
|
||||
pub(crate) passive_tex: std::collections::HashMap<u32, (FrameKey, panels::viewers::PassiveTexture)>,
|
||||
/// The last GPU frame shown: its egui texture id (registered with egui-wgpu), key and texture.
|
||||
pub(crate) viewer_native: Option<(egui::TextureId, FrameKey, Arc<effectcraft_gpu::DisplayFrame>)>,
|
||||
/// The texture the viewer draws and its frame key (CPU or GPU frame).
|
||||
|
|
@ -209,6 +211,8 @@ pub struct EffectcraftApp {
|
|||
pub integrated_titlebar: bool,
|
||||
/// Audio preview while playing (audio clock drives playback).
|
||||
pub audio: Option<audio::AudioPlayback>,
|
||||
/// Audio scrubbing's open output (Ctrl/Cmd-drag the current time, `playback.scrubAudio`).
|
||||
pub scrub: Option<audio::AudioScrub>,
|
||||
/// Audio panel VU meters.
|
||||
pub meter: audio::Meter,
|
||||
/// Waveform peak summaries per footage item (see `panels::waveform`).
|
||||
|
|
@ -261,6 +265,7 @@ impl EffectcraftApp {
|
|||
last_activity: (0.0, 0),
|
||||
toast: None,
|
||||
viewer_tex: None,
|
||||
passive_tex: std::collections::HashMap::new(),
|
||||
viewer_native: None,
|
||||
viewer_shown: None,
|
||||
viewer_image: None,
|
||||
|
|
@ -273,6 +278,7 @@ impl EffectcraftApp {
|
|||
dialog_state: Default::default(),
|
||||
integrated_titlebar: false,
|
||||
audio: None,
|
||||
scrub: None,
|
||||
meter: Default::default(),
|
||||
waveforms: Default::default(),
|
||||
last_reveal: None,
|
||||
|
|
@ -377,7 +383,7 @@ impl EffectcraftApp {
|
|||
}
|
||||
if !self.ui.dock.contains(p) {
|
||||
let near = match p {
|
||||
PanelKind::Layer | PanelKind::Flowchart => PanelKind::Composition,
|
||||
PanelKind::Layer | PanelKind::Flowchart | PanelKind::Viewer(_) => PanelKind::Composition,
|
||||
PanelKind::RenderQueue => PanelKind::Timeline,
|
||||
PanelKind::EffectControls | PanelKind::History => PanelKind::Project,
|
||||
_ => PanelKind::EffectsPresets,
|
||||
|
|
@ -511,11 +517,12 @@ impl EffectcraftApp {
|
|||
FrameKey { frame, ..self.frame_series(comp, scale) }
|
||||
}
|
||||
|
||||
/// The key of frame 0 of `comp`'s viewer frames at `scale` (the revision, view and render
|
||||
/// The key of frame 0 of `comp`'s viewer frames at `scale` (the content, view and render
|
||||
/// options are the same for every frame: loops compute this once).
|
||||
pub fn frame_series(&self, comp: ItemId, scale: f64) -> FrameKey {
|
||||
FrameKey {
|
||||
revision: self.session.revision,
|
||||
content: self.frames.content_of(&self.session.project, self.session.revision, comp),
|
||||
comp: comp.0,
|
||||
frame: 0,
|
||||
scale: (scale * 1000.0).round() as u32,
|
||||
|
|
@ -524,14 +531,11 @@ impl EffectcraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// The frame series the viewer of `comp` shows now (its scale, view and options, at the
|
||||
/// current revision): what the cache bars count.
|
||||
/// The frame series the viewer of `comp` shows now (its scale, view and options, for the
|
||||
/// comp's current content): what the cache bars count.
|
||||
pub fn shown_series(&self, comp: ItemId) -> FrameKey {
|
||||
match self.viewer_shown.as_ref().map(|(_, k)| *k) {
|
||||
Some(k) if k.comp == comp.0 && k.revision == self.session.revision => FrameKey { frame: 0, ..k },
|
||||
Some(k) => self.frame_series(comp, k.scale as f64 / 1000.0),
|
||||
None => self.frame_series(comp, 1.0),
|
||||
}
|
||||
let scale = self.viewer_shown.as_ref().filter(|(_, k)| k.comp == comp.0).map_or(1000, |(_, k)| k.scale);
|
||||
self.frame_series(comp, scale as f64 / 1000.0)
|
||||
}
|
||||
|
||||
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
|
||||
|
|
@ -621,8 +625,9 @@ impl EffectcraftApp {
|
|||
self.session.set_time(fr.tick_of(plan.first));
|
||||
}
|
||||
let restore_max = preset.full_screen.then_some(self.ui.maximized);
|
||||
if preset.full_screen && self.ui.dock.contains(PanelKind::Composition) {
|
||||
self.ui.maximized = Some(PanelKind::Composition);
|
||||
let viewer = panels::viewers::active_panel(self);
|
||||
if preset.full_screen && self.ui.dock.contains(viewer) {
|
||||
self.ui.maximized = Some(viewer);
|
||||
}
|
||||
self.playback = Playback {
|
||||
playing: true,
|
||||
|
|
@ -675,6 +680,30 @@ impl EffectcraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Audio scrubbing: play the frame of `comp` at `t` (see [`audio::AudioScrub`]), opening the
|
||||
/// output on first use. Silent comps and missing devices do nothing.
|
||||
pub fn scrub_audio(&mut self, comp: ItemId, t: Tick, now: f64) {
|
||||
if self.playback.playing || !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, comp) {
|
||||
return;
|
||||
}
|
||||
if self.scrub.is_none() {
|
||||
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
|
||||
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
|
||||
match audio::AudioScrub::open(dev, now) {
|
||||
Ok(s) => self.scrub = Some(s),
|
||||
Err(e) => {
|
||||
log::warn!("audio scrubbing: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let src = self.render_source();
|
||||
let mix = self.session.prefs.audio.preview_sample_rate;
|
||||
if let Some(s) = &mut self.scrub {
|
||||
s.play(&src, comp, t, mix, now);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
let was = std::mem::replace(&mut self.playback.playing, false);
|
||||
self.audio = None;
|
||||
|
|
@ -843,6 +872,14 @@ impl EffectcraftApp {
|
|||
if let Some(a) = &mut self.audio {
|
||||
a.pump();
|
||||
self.meter.update(a.feed.take_peaks(), now);
|
||||
} else if let Some(s) = &mut self.scrub {
|
||||
// Scrubbing: the meters follow the snippets; an idle second closes the output.
|
||||
s.pump();
|
||||
self.meter.update(s.feed.take_peaks(), now);
|
||||
if now - s.last_used > 1.0 {
|
||||
self.scrub = None;
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
} else if self.meter.active() {
|
||||
self.meter.update([0.0; 2], now);
|
||||
ctx.request_repaint();
|
||||
|
|
@ -1120,19 +1157,14 @@ impl EffectcraftApp {
|
|||
fn handle_events(&mut self, ctx: &egui::Context) {
|
||||
for ev in self.session.drain_events() {
|
||||
match ev {
|
||||
effectcraft_engine::Event::OpenComp(_) => {
|
||||
// The comp's viewer and its Timeline tab come to the front.
|
||||
for p in [PanelKind::Composition, PanelKind::Timeline] {
|
||||
if !self.ui.locked_tabs.contains(&p.id()) {
|
||||
self.ui.dock.activate(p);
|
||||
}
|
||||
}
|
||||
effectcraft_engine::Event::OpenComp(c) => {
|
||||
panels::viewers::on_open_comp(self, c);
|
||||
self.ui.timeline.pps = None;
|
||||
}
|
||||
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
|
||||
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
// Frames of earlier revisions can't be shown again.
|
||||
effectcraft_engine::Event::ProjectChanged { revision } => self.frames.drop_stale(revision),
|
||||
// Frames are keyed by content: an edit keeps those of comps it doesn't touch.
|
||||
effectcraft_engine::Event::ProjectChanged { .. } => {}
|
||||
effectcraft_engine::Event::Frontend { command, params } => {
|
||||
if let Err(e) = crate::menus::frontend(self, ctx, &command, params) {
|
||||
self.ui.status = e;
|
||||
|
|
|
|||
|
|
@ -562,6 +562,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
Value::Null
|
||||
}
|
||||
// Window ▸ <ScriptUI panel>: dock (or bring forward) the panel the script built.
|
||||
// Audio scrubbing: one frame of audio at `time` (default the current time).
|
||||
"playback.scrubAudio" => {
|
||||
let cid = app.session.active_comp_id().ok_or("no composition")?;
|
||||
let t = p.get("time").and_then(Value::as_f64).map_or(app.session.time(), effectcraft_engine::time::Tick::from_seconds_f64);
|
||||
app.scrub_audio(cid, t, now);
|
||||
json!({"playing": app.scrub.is_some()})
|
||||
}
|
||||
// View ▸ New Viewer.
|
||||
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
|
||||
"window.scriptPanel" => {
|
||||
let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32;
|
||||
app.show_panel(PanelKind::ScriptPanel(id));
|
||||
|
|
|
|||
|
|
@ -530,7 +530,7 @@ mod tests {
|
|||
assert_eq!(format_ago(now, now - 3 * 86_400), "3 days ago");
|
||||
assert_eq!(format_ago(now, now - 30 * 86_400), "2026-08-22");
|
||||
assert_eq!(format_ago(now, now + 100), "Just now");
|
||||
assert_eq!((format_size(900), format_size(12_800), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
|
||||
assert_eq!((format_size(900), format_size(13_000), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
|
||||
assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -367,14 +367,61 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C
|
|||
}
|
||||
}
|
||||
|
||||
/// Layer markers on a layer's row `r`.
|
||||
/// Layer markers on a layer's row `r`; on a precomp layer, its comp's markers too (read-only,
|
||||
/// outlined: hover names them, a double-click opens the nested comp at the marker).
|
||||
pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: Rect, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) {
|
||||
let p = ui.painter().with_clip_rect(clip);
|
||||
nested_markers(app, ui, &p, comp, layer, tm, r);
|
||||
for (i, m) in layer.markers.iter().enumerate() {
|
||||
marker(app, ui, &p, comp, tm, m, layer.comp_time(m.time).seconds(), MarkerRef { layer: Some(layer.id.0), index: i }, r.min.y + 3.0, r.max.y - 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The nested comp's markers on a precomp layer's bar (`markers.nested`).
|
||||
fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) {
|
||||
let effectcraft_engine::project::LayerSource::Comp { item } = layer.source else { return };
|
||||
let Some(cid) = app.session.active_comp_id() else { return };
|
||||
let project = app.session.project.clone();
|
||||
let Some(nc) = project.comp(item) else { return };
|
||||
if nc.markers.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Where they land, once per revision (with time remapping that samples the layer's frames).
|
||||
let (key, rev) = (egui::Id::new(("nested-markers", cid.0, layer.id.0)), app.session.revision);
|
||||
let cached = ui.ctx().data(|d| d.get_temp::<(u64, Vec<(Tick, usize)>)>(key)).filter(|(r, _)| *r == rev);
|
||||
let at = cached.map(|(_, v)| v).unwrap_or_else(|| {
|
||||
let v: Vec<(Tick, usize)> =
|
||||
effectcraft_engine::commands::markers::nested_markers(&project, cid, comp, layer).into_iter().map(|(t, i, _)| (t, i)).collect();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(key, (rev, v.clone())));
|
||||
v
|
||||
});
|
||||
let list: Vec<(Tick, usize, &Marker)> = at.into_iter().filter_map(|(t, i)| Some((t, i, nc.markers.get(i)?))).collect();
|
||||
let t = app.tokens;
|
||||
let name = project.item(item).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let (y0, h) = (r.min.y + 3.0, (r.height() - 6.0).min(10.0));
|
||||
for (ct, i, m) in list {
|
||||
let x = tm.x(ct.seconds());
|
||||
let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) }.gamma_multiply(0.75);
|
||||
let ym = y0 + h * 0.6;
|
||||
let pts = vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)];
|
||||
p.add(egui::Shape::closed_line(pts, Stroke::new(1.2, col)));
|
||||
if !m.comment.is_empty() {
|
||||
p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text_dim);
|
||||
}
|
||||
let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, r.max.y - 3.0));
|
||||
let tip = if m.comment.is_empty() { format!("Marker in {name}") } else { format!("{} (marker in {name})", m.comment) };
|
||||
let resp = ui.interact(hit, egui::Id::new(("nested-marker", layer.id.0, i)), Sense::click()).on_hover_text(&tip);
|
||||
app.auto.add(&format!("timeline.layer.{}.nestedMarker.{i}", layer.id.0), hit, &tip);
|
||||
if resp.double_clicked() {
|
||||
let opened =
|
||||
app.session.execute("comp.open", json!({"comp": item.0})).and_then(|_| app.session.execute("time.set", json!({"time": m.time.seconds()})));
|
||||
if let Err(e) = opened {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ pub mod viewer;
|
|||
pub mod viewer_overlays;
|
||||
pub mod viewer_text;
|
||||
pub mod viewer_tools;
|
||||
pub mod viewers;
|
||||
pub mod waveform;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
|
@ -82,7 +83,8 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
|
|||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
|
||||
match p {
|
||||
PanelKind::Composition => viewer::show(app, ui, rect),
|
||||
PanelKind::Composition => viewers::show(app, ui, 0, rect),
|
||||
PanelKind::Viewer(n) => viewers::show(app, ui, n, rect),
|
||||
PanelKind::Timeline => timeline::show(app, ui, rect),
|
||||
PanelKind::Project => project::show(app, ui, rect),
|
||||
PanelKind::EffectControls => effect_controls::show(app, ui, rect),
|
||||
|
|
@ -161,8 +163,9 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
|||
close = true;
|
||||
}
|
||||
}
|
||||
PanelKind::Composition => {
|
||||
PanelKind::Composition | PanelKind::Viewer(_) => {
|
||||
for (label, cmd) in [
|
||||
("New Viewer", "view.newViewer"),
|
||||
("Composition Settings…", "app.compSettings"),
|
||||
("View Options…", "view.layerControls"),
|
||||
("Show Grid", "view.grid"),
|
||||
|
|
|
|||
|
|
@ -1048,6 +1048,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let tt = Tick::from_seconds_f64(secs);
|
||||
app.session.set_time(tt);
|
||||
app.stop();
|
||||
// Ctrl/Cmd-drag scrubs the audio too (After Effects).
|
||||
if rresp.dragged()
|
||||
&& ui.input(|i| i.modifiers.command)
|
||||
&& let Some(cid) = app.session.active_comp_id()
|
||||
{
|
||||
let now = ui.input(|i| i.time);
|
||||
let t = app.session.time();
|
||||
app.scrub_audio(cid, t, now);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- column headers (right-click: show/hide columns; click the name header: Source/Layer Name).
|
||||
|
|
|
|||
|
|
@ -520,6 +520,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key);
|
||||
if stale {
|
||||
show_frame(app, &ctx, key, img);
|
||||
} else if let Some((_, k)) = app.viewer_shown.as_mut() {
|
||||
// The same frame at a later revision (an edit elsewhere, an undo): it shows that one.
|
||||
k.revision = key.revision;
|
||||
}
|
||||
}
|
||||
if app.ui.viewer.transparency_grid {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,253 @@
|
|||
//! Several Composition viewers (View ▸ New Viewer), as in After Effects. Viewer 0 is the
|
||||
//! Composition panel, the others are `PanelKind::Viewer(n)` panels. One viewer is *active*: it
|
||||
//! is the full interactive viewer and shows the active comp. The others show their own comp's
|
||||
//! frame at that comp's current time; a click (or their tab) makes them active and their comp
|
||||
//! the active one. New Viewer locks the viewer in use, so it keeps its comp. Opening a comp while
|
||||
//! the active viewer is locked shows it in an unlocked viewer, or in a new one if every viewer is
|
||||
//! locked.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::dock::PanelKind;
|
||||
use crate::frames::{FrameImage, FrameKey};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// A passive viewer's texture: the frame it shows.
|
||||
pub enum PassiveTexture {
|
||||
Cpu(egui::TextureHandle),
|
||||
Gpu(egui::TextureId, Arc<effectcraft_gpu::DisplayFrame>),
|
||||
}
|
||||
|
||||
impl PassiveTexture {
|
||||
fn id(&self) -> egui::TextureId {
|
||||
match self {
|
||||
PassiveTexture::Cpu(h) => h.id(),
|
||||
PassiveTexture::Gpu(id, _) => *id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dock panel of viewer `id` (0 = the Composition panel).
|
||||
pub fn panel(id: u32) -> PanelKind {
|
||||
if id == 0 { PanelKind::Composition } else { PanelKind::Viewer(id) }
|
||||
}
|
||||
|
||||
/// The viewer a panel is, if it is one.
|
||||
pub fn id_of(p: PanelKind) -> Option<u32> {
|
||||
match p {
|
||||
PanelKind::Composition => Some(0),
|
||||
PanelKind::Viewer(n) => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The active viewer's panel.
|
||||
pub fn active_panel(app: &EffectcraftApp) -> PanelKind {
|
||||
panel(app.ui.active_viewer)
|
||||
}
|
||||
|
||||
pub fn locked(app: &EffectcraftApp, id: u32) -> bool {
|
||||
app.ui.locked_tabs.contains(&panel(id).id())
|
||||
}
|
||||
|
||||
/// Viewer `id`'s panel is docked or floating.
|
||||
fn open(app: &EffectcraftApp, id: u32) -> bool {
|
||||
let p = panel(id);
|
||||
app.ui.dock.contains(p) || app.ui.floating.iter().any(|f| f.panels.contains(&p))
|
||||
}
|
||||
|
||||
/// The comp viewer `id` shows: the active comp in the active viewer, else its own.
|
||||
pub fn comp_of(app: &EffectcraftApp, id: u32) -> Option<ItemId> {
|
||||
if id == app.ui.active_viewer {
|
||||
return app.session.active_comp_id();
|
||||
}
|
||||
app.ui.viewers.get(&id).copied().flatten().map(ItemId).filter(|c| app.session.project.comp(*c).is_some())
|
||||
}
|
||||
|
||||
/// Draw viewer `id`: the active one is the full viewer; another shows its comp's frame and
|
||||
/// becomes the active viewer when clicked.
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
|
||||
if id == app.ui.active_viewer {
|
||||
let c = app.session.active_comp_id().map(|c| c.0);
|
||||
app.ui.viewers.insert(id, c);
|
||||
super::viewer::show(app, ui, rect);
|
||||
return;
|
||||
}
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
p.rect_filled(rect, 0.0, t.pasteboard);
|
||||
let resp = ui.interact(rect, egui::Id::new(("passive-viewer", id)), Sense::click()).on_hover_text("Click to make this viewer active");
|
||||
app.auto.add(&format!("viewers.{id}"), rect, "Composition viewer (click to make it active)");
|
||||
match comp_of(app, id).and_then(|cid| Some((cid, app.session.project.comp(cid)?.clone()))) {
|
||||
Some((cid, c)) => {
|
||||
let (cw, ch) = ((c.width.max(1) as f64 * c.pixel_aspect.max(0.01)) as f32, c.height.max(1) as f32);
|
||||
let fit = ((rect.width() - 24.0) / cw).min((rect.height() - 24.0) / ch).max(0.01);
|
||||
let r = Rect::from_center_size(rect.center(), vec2(cw * fit, ch * fit));
|
||||
// Render at the size it is drawn (eighths, so resizing reuses frames), at most 100%.
|
||||
let scale = ((fit * ctx.pixels_per_point()) as f64 * 8.0).ceil().clamp(1.0, 8.0) / 8.0;
|
||||
let frame = c.frame_rate.frame_at(app.session.time_of(cid));
|
||||
app.request_frame(cid, frame, scale);
|
||||
let key = app.frame_key(cid, frame, scale);
|
||||
if let Some(img) = app.frames.get(&key) {
|
||||
set_texture(app, &ctx, id, key, img);
|
||||
}
|
||||
match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) {
|
||||
Some((_, tex)) => {
|
||||
p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
let bg = c.background;
|
||||
p.rect_filled(r, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside);
|
||||
}
|
||||
None => {
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, "Click to show the active composition here", Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
activate(app, &ctx, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the frame viewer `id` shows in its texture.
|
||||
fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: FrameKey, img: FrameImage) {
|
||||
if app.passive_tex.get(&id).is_some_and(|(k, _)| *k == key) {
|
||||
return;
|
||||
}
|
||||
let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
|
||||
let tex = match (img, app.passive_tex.remove(&id)) {
|
||||
(FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) if h.size() == img.size => {
|
||||
h.set((*img).clone(), opts);
|
||||
PassiveTexture::Cpu(h)
|
||||
}
|
||||
(FrameImage::Cpu(img), old) => {
|
||||
free(app, old.map(|(_, t)| t));
|
||||
PassiveTexture::Cpu(ctx.load_texture(format!("viewer-{id}"), (*img).clone(), opts))
|
||||
}
|
||||
(FrameImage::Gpu(f), old) => {
|
||||
let Some(rs) = &app.wgpu else { return };
|
||||
let view = f.texture.create_view(&Default::default());
|
||||
let filter = if app.session.prefs.viewer_zoom_smooth() { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest };
|
||||
let mut ren = rs.renderer.write();
|
||||
let tid = match old {
|
||||
Some((_, PassiveTexture::Gpu(tid, _))) => {
|
||||
ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, tid);
|
||||
tid
|
||||
}
|
||||
_ => ren.register_native_texture(&rs.device, &view, filter),
|
||||
};
|
||||
PassiveTexture::Gpu(tid, f)
|
||||
}
|
||||
};
|
||||
app.passive_tex.insert(id, (key, tex));
|
||||
}
|
||||
|
||||
/// Give a passive viewer's texture back (GPU textures are registered with the renderer).
|
||||
fn free(app: &EffectcraftApp, tex: Option<PassiveTexture>) {
|
||||
if let (Some(PassiveTexture::Gpu(id, _)), Some(rs)) = (tex, &app.wgpu) {
|
||||
rs.renderer.write().free_texture(&id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make viewer `id` the active one: its comp becomes the active comp.
|
||||
pub fn activate(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32) {
|
||||
if id == app.ui.active_viewer || !open(app, id) {
|
||||
return;
|
||||
}
|
||||
let active = app.session.active_comp_id();
|
||||
let target = comp_of(app, id);
|
||||
app.ui.viewers.insert(app.ui.active_viewer, active.map(|c| c.0));
|
||||
app.ui.active_viewer = id;
|
||||
match target {
|
||||
Some(c) if Some(c) != active => {
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "comp.open", json!({"comp": c.0})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
app.ui.viewers.insert(id, active.map(|c| c.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A comp was opened (comp.open, a Timeline tab, a precomp double-click): it shows in the active
|
||||
/// viewer, unless that one is locked to another comp: then in an unlocked viewer, or a new one.
|
||||
pub fn on_open_comp(app: &mut EffectcraftApp, comp: ItemId) {
|
||||
let av = app.ui.active_viewer;
|
||||
let shown = app.ui.viewers.get(&av).copied().flatten().filter(|s| app.session.project.comp(ItemId(*s)).is_some());
|
||||
if locked(app, av) && shown.is_some_and(|s| s != comp.0) {
|
||||
let free = app.ui.viewers.keys().copied().find(|v| *v != av && !locked(app, *v) && open(app, *v));
|
||||
app.ui.active_viewer = match free {
|
||||
Some(v) => v,
|
||||
None => add_viewer(app, av),
|
||||
};
|
||||
}
|
||||
app.ui.viewers.insert(app.ui.active_viewer, Some(comp.0));
|
||||
// The comp's viewer and its Timeline tab come to the front (unless locked).
|
||||
for p in [active_panel(app), PanelKind::Timeline] {
|
||||
if !app.ui.locked_tabs.contains(&p.id()) {
|
||||
app.ui.dock.activate(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// View ▸ New Viewer: a new viewer of the active comp next to the one in use, which is locked
|
||||
/// (so it keeps its comp). Returns the new viewer's id.
|
||||
pub fn new_viewer(app: &mut EffectcraftApp) -> u32 {
|
||||
let prev = app.ui.active_viewer;
|
||||
let active = app.session.active_comp_id().map(|c| c.0);
|
||||
app.ui.viewers.insert(prev, active);
|
||||
app.ui.locked_tabs.insert(panel(prev).id());
|
||||
let id = add_viewer(app, prev);
|
||||
app.ui.viewers.insert(id, active);
|
||||
app.ui.active_viewer = id;
|
||||
app.show_panel(panel(id));
|
||||
id
|
||||
}
|
||||
|
||||
/// Dock a new viewer panel as a tab next to viewer `near`.
|
||||
fn add_viewer(app: &mut EffectcraftApp, near: u32) -> u32 {
|
||||
let id = app.ui.viewers.keys().copied().max().unwrap_or(0).saturating_add(1).max(1);
|
||||
app.ui.viewers.insert(id, None);
|
||||
let p = panel(id);
|
||||
let anchor = if app.ui.dock.contains(panel(near)) { panel(near) } else { PanelKind::Composition };
|
||||
app.ui.dock.open_near(p, anchor);
|
||||
id
|
||||
}
|
||||
|
||||
/// A viewer panel was closed: forget it (the Composition panel keeps its slot), and if it was the
|
||||
/// active viewer another open one takes over.
|
||||
pub fn on_close(app: &mut EffectcraftApp, p: PanelKind) {
|
||||
let Some(id) = id_of(p) else { return };
|
||||
let tex = app.passive_tex.remove(&id).map(|(_, t)| t);
|
||||
free(app, tex);
|
||||
if id != 0 {
|
||||
app.ui.viewers.remove(&id);
|
||||
app.ui.locked_tabs.remove(&p.id());
|
||||
}
|
||||
if app.ui.active_viewer == id {
|
||||
let next = std::iter::once(0).chain(app.ui.viewers.keys().copied()).find(|v| *v != id && open(app, *v)).unwrap_or(0);
|
||||
app.ui.active_viewer = next;
|
||||
// The viewer taking over keeps its comp (which becomes the active comp).
|
||||
if let Some(c) = app.ui.viewers.get(&next).copied().flatten().map(ItemId).filter(|c| app.session.project.comp(*c).is_some())
|
||||
&& app.session.active_comp_id() != Some(c)
|
||||
{
|
||||
app.session.open_comp(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab label of viewer `id` ("Composition Intro").
|
||||
pub fn title(app: &EffectcraftApp, id: u32) -> Option<String> {
|
||||
let c = comp_of(app, id)?;
|
||||
Some(format!("Composition {}", app.session.project.item(c)?.name))
|
||||
}
|
||||
|
|
@ -542,6 +542,13 @@ pub struct UiState {
|
|||
/// bring a locked panel forward (the tab's lock icon, as After Effects' viewer lock).
|
||||
#[serde(default)]
|
||||
pub locked_tabs: BTreeSet<String>,
|
||||
/// The comp each Composition viewer shows, by viewer id (0 = the Composition panel; see
|
||||
/// `panels::viewers`).
|
||||
#[serde(default)]
|
||||
pub viewers: std::collections::BTreeMap<u32, Option<u64>>,
|
||||
/// The active viewer (the interactive one, showing the active comp).
|
||||
#[serde(default)]
|
||||
pub active_viewer: u32,
|
||||
pub focused: PanelKind,
|
||||
pub viewer: ViewerState,
|
||||
pub timeline: TimelineState,
|
||||
|
|
@ -650,6 +657,8 @@ impl Default for UiState {
|
|||
saved_floating: Default::default(),
|
||||
maximized: None,
|
||||
locked_tabs: BTreeSet::new(),
|
||||
viewers: Default::default(),
|
||||
active_viewer: 0,
|
||||
focused: PanelKind::Composition,
|
||||
viewer: ViewerState::default(),
|
||||
timeline: TimelineState::default(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
//! Audio scrubbing: Ctrl/Cmd-dragging the current time plays one frame of the comp's mix at each
|
||||
//! new frame (each snippet replacing what is still queued); holding still repeats nothing; silent
|
||||
//! comps open no output; an idle scrub closes it.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||||
use egui::{Event, Modifiers, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// An output that records the feed it is given and whether it was stopped.
|
||||
#[derive(Clone, Default)]
|
||||
struct Fake(Arc<Mutex<(Option<Arc<AudioFeed>>, bool)>>);
|
||||
|
||||
impl AudioDevice for Fake {
|
||||
fn sample_rate(&self) -> u32 {
|
||||
48000
|
||||
}
|
||||
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String> {
|
||||
self.0.lock().unwrap().0 = Some(feed);
|
||||
Ok(())
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
self.0.lock().unwrap().1 = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A 30 fps comp whose solid sounds a tone (Audio switch on).
|
||||
fn setup(audible: bool, fake: &Fake) -> (EffectcraftApp, ItemId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
if audible {
|
||||
s.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap();
|
||||
}
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
let f = fake.clone();
|
||||
app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(f.clone()) as Box<dyn AudioDevice>)));
|
||||
(app, cid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_plays_one_frame_per_new_frame() {
|
||||
let fake = Fake::default();
|
||||
let (mut app, cid) = setup(true, &fake);
|
||||
let t = |s: f64| Tick::from_seconds_f64(s);
|
||||
app.scrub_audio(cid, t(0.5), 0.0);
|
||||
let feed = fake.0.lock().unwrap().0.clone().expect("the output opened");
|
||||
// One 30 fps frame at 48 kHz, and not silence.
|
||||
assert_eq!(feed.queued_frames(), 1600);
|
||||
let mut out = vec![0.0f32; 3200];
|
||||
feed.pull(&mut out);
|
||||
assert!(out.iter().any(|v| v.abs() > 0.01), "the tone is there");
|
||||
// Faded in: the first sample is silent.
|
||||
assert_eq!(out[0], 0.0);
|
||||
// The same frame again plays nothing; the next frame replaces what is left.
|
||||
app.scrub_audio(cid, t(0.51), 0.1);
|
||||
assert_eq!(feed.queued_frames(), 0);
|
||||
feed.push(&[0.0; 1000]);
|
||||
app.scrub_audio(cid, t(0.6), 0.2);
|
||||
assert_eq!(feed.queued_frames(), 1600, "replaced, not piled up");
|
||||
// A silent comp opens nothing.
|
||||
let silent = Fake::default();
|
||||
let (mut quiet, qid) = setup(false, &silent);
|
||||
quiet.scrub_audio(qid, t(0.5), 0.0);
|
||||
assert!(silent.0.lock().unwrap().0.is_none() && quiet.scrub.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() {
|
||||
let fake = Fake::default();
|
||||
let (app, _) = setup(true, &fake);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app);
|
||||
h.run_steps(3);
|
||||
let cti = h.state().auto.find("timeline.cti").expect("current time indicator").clone();
|
||||
let from = pos2(cti.rect[0] + cti.rect[2] / 2.0, cti.rect[1] + cti.rect[3] / 2.0);
|
||||
let cmd = Modifiers::COMMAND;
|
||||
h.event(Event::ModifiersChanged(cmd));
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: cmd });
|
||||
h.step();
|
||||
for k in 1..=4 {
|
||||
h.event(Event::PointerMoved(from + egui::vec2(40.0 * k as f32, 0.0)));
|
||||
h.step();
|
||||
}
|
||||
assert!(h.state().scrub.is_some(), "Cmd-drag opened the scrub output");
|
||||
assert!(fake.0.lock().unwrap().0.as_ref().is_some_and(|f| f.queued_frames() > 0));
|
||||
h.event(Event::PointerButton { pos: from + egui::vec2(160.0, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers: cmd });
|
||||
h.step();
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.step();
|
||||
// An idle second closes the output.
|
||||
for _ in 0..90 {
|
||||
h.step();
|
||||
std::thread::sleep(std::time::Duration::from_millis(15));
|
||||
if h.state().scrub.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(h.state().scrub.is_none() && fake.0.lock().unwrap().1, "closed and stopped");
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
//! RAM preview frames are keyed by content: editing one comp keeps the cached frames of another,
|
||||
//! and undo finds the frames of the state it returns to.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
for _ in 0..600 {
|
||||
h.step();
|
||||
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn shown_cached(h: &Harness<'_, EffectcraftApp>, comp: u64) -> bool {
|
||||
let app = h.state();
|
||||
let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..app.shown_series(ItemId(comp)) };
|
||||
app.frames.is_cached(&key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_in_another_comp_keeps_the_cached_frames_and_undo_finds_them_again() {
|
||||
let mut s = Session::default();
|
||||
let b = s.execute("comp.new", json!({"name": "B", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
let a = s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
settle(&mut h);
|
||||
assert!(shown_cached(&h, a), "A's frame rendered");
|
||||
// Edit B (open it, add a layer), then come back to A: its frame is still in RAM.
|
||||
h.state_mut().session.execute("comp.open", json!({"comp": b})).unwrap();
|
||||
h.state_mut().session.execute("layer.newSolid", json!({"color": "#ff8030"})).unwrap();
|
||||
settle(&mut h);
|
||||
let b_frame = h.state().shown_series(ItemId(b));
|
||||
h.state_mut().session.execute("comp.open", json!({"comp": a})).unwrap();
|
||||
assert!(shown_cached(&h, a), "A's frame survived the edit in B");
|
||||
// Edit A, then undo: the frame of the state undo returns to is found again.
|
||||
let before = h.state().shown_series(ItemId(a));
|
||||
h.state_mut().session.execute("layer.newSolid", json!({"color": "#20c040"})).unwrap();
|
||||
settle(&mut h);
|
||||
assert_ne!(h.state().shown_series(ItemId(a)), before, "A's content changed");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.state().shown_series(ItemId(a)), before);
|
||||
assert!(shown_cached(&h, a), "undo finds the earlier frame");
|
||||
// B's frame is still there too.
|
||||
assert!(h.state().frames.is_cached(&effectcraft_ui_egui::frames::FrameKey { frame: 0, ..b_frame }));
|
||||
}
|
||||
|
|
@ -144,3 +144,25 @@ fn tab_drag_snapshot() {
|
|||
std::fs::create_dir_all(dir).unwrap();
|
||||
h.render().expect("render").save(format!("{dir}/tab-drag.png")).unwrap();
|
||||
}
|
||||
|
||||
/// A precomp layer's bar shows its comp's markers; a double-click opens that comp at the marker.
|
||||
#[test]
|
||||
fn nested_comp_markers_show_on_the_precomp_bar() {
|
||||
let (mut h, pre, main) = harness();
|
||||
{
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("comp.open", json!({"comp": pre})).unwrap();
|
||||
s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "beat"})).unwrap();
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
}
|
||||
h.run_steps(4);
|
||||
let l = h.state().session.project.comp(ItemId(main)).unwrap().layers[0].id.0;
|
||||
let id = format!("timeline.layer.{l}.nestedMarker.0");
|
||||
let m = rect(&h, &id);
|
||||
let e = h.state().auto.find(&id).unwrap().clone();
|
||||
assert_eq!(e.label, "beat (marker in Pre)");
|
||||
click_n(&mut h, m.center(), 2);
|
||||
assert_eq!(h.state().session.active_comp_id(), Some(ItemId(pre)));
|
||||
// (29.97 fps: the marker's frame is at 1.001 s)
|
||||
assert!((h.state().session.time().seconds() - 1.0).abs() < 0.034, "{}", h.state().session.time().seconds());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
//! View ▸ New Viewer: several Composition viewers. New Viewer locks the viewer in use; opening a
|
||||
//! comp goes to an unlocked viewer (a new one when every viewer is locked); a click on another
|
||||
//! viewer makes it the active one; closing a viewer forgets it.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::{PanelKind, Zone};
|
||||
use effectcraft_ui_egui::panels::viewers;
|
||||
use egui::{Event, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, p: Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, p).unwrap();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
||||
let p = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn shows(h: &Harness<'_, EffectcraftApp>, viewer: u32) -> Option<u64> {
|
||||
viewers::comp_of(h.state(), viewer).map(|c| c.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_viewers_lock_route_comps_and_activate_on_click() {
|
||||
let mut s = Session::default();
|
||||
let new =
|
||||
|s: &mut Session, name: &str| s.execute("comp.new", json!({"name": name, "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
let c = new(&mut s, "C");
|
||||
let b = new(&mut s, "B");
|
||||
let a = new(&mut s, "A");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
// View ▸ New Viewer: viewer 1 shows A too, viewer 0 (the Composition panel) is locked.
|
||||
invoke(&mut h, "view.newViewer", json!({}));
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Viewer(1)));
|
||||
assert!(viewers::locked(h.state(), 0) && !viewers::locked(h.state(), 1));
|
||||
assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(a)));
|
||||
// Side by side, so both draw.
|
||||
assert!(h.state_mut().edit_layout(|l| l.dock(PanelKind::Viewer(1), PanelKind::Composition, Zone::Right)));
|
||||
h.state_mut().ui.dock.activate(PanelKind::Composition);
|
||||
h.run_steps(3);
|
||||
// Opening B shows it in the active (unlocked) viewer; the locked one keeps A.
|
||||
invoke(&mut h, "comp.open", json!({"comp": b}));
|
||||
assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(b)));
|
||||
assert!(h.state().auto.find("viewers.0").is_some(), "the other viewer draws passively");
|
||||
// A click on viewer 0 makes it active, and A the active comp.
|
||||
click(&mut h, "viewers.0");
|
||||
assert_eq!(h.state().ui.active_viewer, 0);
|
||||
assert_eq!(h.state().session.active_comp_id(), Some(ItemId(a)));
|
||||
assert_eq!(shows(&h, 1), Some(b));
|
||||
// Viewer 0 is locked to A: opening C goes to the unlocked viewer 1.
|
||||
invoke(&mut h, "comp.open", json!({"comp": c}));
|
||||
assert_eq!((h.state().ui.active_viewer, shows(&h, 0), shows(&h, 1)), (1, Some(a), Some(c)));
|
||||
// Every viewer locked: opening B makes a new viewer.
|
||||
h.state_mut().ui.locked_tabs.insert(PanelKind::Viewer(1).id());
|
||||
invoke(&mut h, "comp.open", json!({"comp": b}));
|
||||
assert_eq!(h.state().ui.active_viewer, 2);
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Viewer(2)));
|
||||
assert_eq!((shows(&h, 0), shows(&h, 1), shows(&h, 2)), (Some(a), Some(c), Some(b)));
|
||||
// Closing the active viewer forgets it; another open viewer takes over.
|
||||
h.state_mut().close_panel(PanelKind::Viewer(2));
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().ui.viewers.contains_key(&2));
|
||||
// Viewer 0 takes over with its own comp (it is locked to A), which becomes the active comp.
|
||||
assert_eq!(h.state().ui.active_viewer, 0);
|
||||
assert_eq!((shows(&h, 0), h.state().session.active_comp_id()), (Some(a), Some(ItemId(a))));
|
||||
// The tab names the comp each viewer shows.
|
||||
let titles: Vec<String> = h
|
||||
.state()
|
||||
.auto
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.id.starts_with("panel.tab.Composition") || e.id.starts_with("panel.tab.Viewer"))
|
||||
.map(|e| e.label.clone())
|
||||
.collect();
|
||||
assert!(titles.iter().any(|t| t == "Composition A") && titles.iter().any(|t| t == "Composition C"), "{titles:?}");
|
||||
}
|
||||
|
||||
/// `cargo test -p effectcraft-ui-egui --test ui_viewers -- --ignored`: two viewers side by side
|
||||
/// (`target/test-out/viewers.png`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn viewers_snapshot() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let other = s.project.items.values().filter(|i| i.as_comp().is_some()).map(|i| i.id).find(|id| Some(*id) != s.active_comp_id()).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
invoke(&mut h, "view.newViewer", json!({}));
|
||||
assert!(h.state_mut().edit_layout(|l| l.dock(PanelKind::Viewer(1), PanelKind::Composition, Zone::Right)));
|
||||
h.state_mut().ui.dock.activate(PanelKind::Composition);
|
||||
invoke(&mut h, "comp.open", json!({"comp": other.0}));
|
||||
for _ in 0..300 {
|
||||
h.step();
|
||||
if h.state().frames.inflight() == 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
h.run_steps(4);
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out");
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
h.render().unwrap().save(format!("{dir}/viewers.png")).unwrap();
|
||||
}
|
||||
|
|
@ -137,7 +137,15 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
|
|||
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
|
||||
let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Gpu, ..Default::default() };
|
||||
let t = effectcraft_engine::time::Tick::ZERO;
|
||||
let key = |comp: effectcraft_engine::project::ItemId| FrameKey { revision: s.revision, comp: comp.0, frame: 0, scale: 1000, view: 0, opts: 0 };
|
||||
let key = |comp: effectcraft_engine::project::ItemId| FrameKey {
|
||||
revision: s.revision,
|
||||
content: effectcraft_ui_egui::frames::comp_content(&s.project, comp),
|
||||
comp: comp.0,
|
||||
frame: 0,
|
||||
scale: 1000,
|
||||
view: 0,
|
||||
opts: 0,
|
||||
};
|
||||
let disk = Arc::new(Mutex::new(HashMap::new()));
|
||||
let worker = Arc::new(GpuWorker::new(&s, worker_gpu.clone(), disk.clone()));
|
||||
let mut frames = Frames::default();
|
||||
|
|
|
|||
|
|
@ -105,16 +105,24 @@ fn viewer_frames_render_remotely_from_project_diffs() {
|
|||
let comp = s.active_comp_id().unwrap();
|
||||
let c = s.project.comp(comp).unwrap().clone();
|
||||
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
|
||||
let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0, opts: 0 };
|
||||
let key = |s: &Session, f: i64| FrameKey {
|
||||
revision: s.revision,
|
||||
content: effectcraft_ui_egui::frames::comp_content(&s.project, comp),
|
||||
comp: comp.0,
|
||||
frame: f,
|
||||
scale: 250,
|
||||
view: 0,
|
||||
opts: 0,
|
||||
};
|
||||
let worker = Arc::new(FakeWorker::new(&s, 2));
|
||||
let mut frames = Frames::default();
|
||||
frames.set_remote(Some(worker.clone()));
|
||||
assert!(frames.remote_active());
|
||||
// Three prefetch frames, then the viewer's frame.
|
||||
for f in 1..=3 {
|
||||
frames.request(&source(&s), key(s.revision, f), comp, c.frame_rate.tick_of(f), opts);
|
||||
frames.request(&source(&s), key(&s, f), comp, c.frame_rate.tick_of(f), opts);
|
||||
}
|
||||
frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts);
|
||||
frames.request_urgent(&source(&s), key(&s, 10), comp, c.frame_rate.tick_of(10), opts);
|
||||
assert_eq!(frames.inflight(), 4);
|
||||
// Nothing renders until dispatched (no frame threads with a remote renderer); the viewer's
|
||||
// frame goes first, then prefetch in request order.
|
||||
|
|
@ -126,15 +134,15 @@ fn viewer_frames_render_remotely_from_project_diffs() {
|
|||
// Frames arrive: the cache holds exactly what the page would have rendered.
|
||||
worker.deliver();
|
||||
assert_eq!(frames.remote_busy(), 0);
|
||||
let img = match frames.get(&key(s.revision, 10)).expect("viewer frame cached") {
|
||||
let img = match frames.get(&key(&s, 10)).expect("viewer frame cached") {
|
||||
FrameImage::Cpu(c) => c,
|
||||
FrameImage::Gpu(_) => unreachable!(),
|
||||
};
|
||||
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
|
||||
assert_eq!(img.size, local.size);
|
||||
assert_eq!(img.pixels, local.pixels);
|
||||
assert!(frames.is_cached(&key(s.revision, 1)));
|
||||
assert!(!frames.is_cached(&key(s.revision, 2)));
|
||||
assert!(frames.is_cached(&key(&s, 1)));
|
||||
assert!(!frames.is_cached(&key(&s, 2)));
|
||||
// The rest; an edit later travels as a patch.
|
||||
frames.dispatch_remote();
|
||||
worker.deliver();
|
||||
|
|
@ -142,19 +150,19 @@ fn viewer_frames_render_remotely_from_project_diffs() {
|
|||
worker.deliver();
|
||||
assert_eq!(frames.inflight(), 0);
|
||||
s.execute("layer.newSolid", json!({"color": "#ff8800"})).unwrap();
|
||||
frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts);
|
||||
frames.request_urgent(&source(&s), key(&s, 10), comp, c.frame_rate.tick_of(10), opts);
|
||||
frames.dispatch_remote();
|
||||
worker.deliver();
|
||||
assert_eq!(worker.syncs.lock().unwrap().last(), Some(&"patch"));
|
||||
let FrameImage::Cpu(img) = frames.get(&key(s.revision, 10)).unwrap() else { unreachable!() };
|
||||
let FrameImage::Cpu(img) = frames.get(&key(&s, 10)).unwrap() else { unreachable!() };
|
||||
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
|
||||
assert_eq!(img.pixels, local.pixels);
|
||||
// A lost frame is released (requested again later), not cached.
|
||||
frames.request(&source(&s), key(s.revision, 20), comp, c.frame_rate.tick_of(20), opts);
|
||||
frames.request(&source(&s), key(&s, 20), comp, c.frame_rate.tick_of(20), opts);
|
||||
frames.dispatch_remote();
|
||||
worker.lose();
|
||||
assert_eq!(frames.inflight(), 0);
|
||||
assert!(!frames.is_cached(&key(s.revision, 20)));
|
||||
assert!(!frames.is_cached(&key(&s, 20)));
|
||||
// Without slots the remote renderer is unusable (frames render here again).
|
||||
frames.set_remote(Some(Arc::new(FakeWorker::new(&s, 0))));
|
||||
assert!(!frames.remote_active());
|
||||
|
|
|
|||
|
|
@ -86,9 +86,10 @@ Points are logical window coordinates. A target is `{id}` (the element's centre;
|
|||
|
||||
Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`,
|
||||
`panel.tab.Timeline.<comp>` (one Timeline tab per open comp; `panel.tab.Timeline` is the shown
|
||||
one), `header.workspace.Animation`, `project.item.<id>` (a double-click opens the item, Enter
|
||||
one), `panel.tab.Viewer(<n>)` and `viewers.<n>` (View ▸ New Viewer's other Composition viewers;
|
||||
`viewers.0` is the Composition panel while another viewer is active), `header.workspace.Animation`, `project.item.<id>` (a double-click opens the item, Enter
|
||||
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
|
||||
`timeline.layer.<id>.bar`, `timeline.layer.<id>.nestedMarker.<i>` (a precomp's comp markers), `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
|
||||
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
|
||||
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
|
||||
`effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |
|
||||
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
|
||||
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs; View ▸ New Viewer (several unlocked viewers) (an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
|
||||
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
|
|
@ -422,6 +422,35 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra
|
|||
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
|
||||
Editor keys are small squares whatever their interpolation, as in After Effects.
|
||||
|
||||
Since (M13.34): audio scrubbing. Ctrl-dragging the current-time indicator (Cmd on macOS) plays
|
||||
one frame (30–100 ms) of the comp's mix at each new frame, as in After Effects. Snippets fade in
|
||||
and out and replace what is still queued, so the sound follows the pointer without lagging.
|
||||
Holding still repeats nothing, the Audio panel meters follow, and the output closes after an idle
|
||||
second. Agents call `playback.scrubAudio {time}`.
|
||||
|
||||
Since (M13.33): a precomp layer's bar shows its comp's markers, as outlined, read-only markers.
|
||||
They are mapped through the layer's timing (start, stretch, time remapping, Responsive Design
|
||||
stretch) and shown within its In–Out range. Hovering names them ("beat (marker in Pre)"), and a
|
||||
double-click opens the nested comp at the marker. Agents read the same list with
|
||||
`markers.nested {layer}`.
|
||||
|
||||
Since (M13.32): View ▸ New Viewer (Alt+Shift+N, also in the Composition panel menu) opens
|
||||
another Composition viewer as its own panel, as in After Effects. The viewer in use is locked,
|
||||
so it keeps its comp. One viewer is active: it is the interactive one and shows the active comp.
|
||||
The others show their own comp's frame at that comp's current time, and a click (or their tab)
|
||||
makes them active. Opening a comp while the active viewer is locked shows it in an unlocked
|
||||
viewer, or in a new one if every viewer is locked. Closing a viewer hands over to another one,
|
||||
with that viewer's comp. Each viewer's tab is named after its comp, with its own lock.
|
||||
|
||||
Since (M4.14): RAM preview frames are keyed by their comp's content, not the project revision. An
|
||||
edit keeps the cached frames (the green bar) of every comp it doesn't touch, and undo finds the
|
||||
frames of the state it returns to, as in After Effects. The identity costs about 0.1 ms on the
|
||||
1500-layer test project: it hashes the addresses of the comps a comp draws, and the RAM cache
|
||||
keeps a project snapshot for every identity with frames, so those addresses can't be reused
|
||||
meanwhile. Footage, solids, proxies and the project settings are hashed by value. At most 64
|
||||
states are kept, so a long drag doesn't hold on to every intermediate state. The disk cache's
|
||||
content key now also covers proxies and Use Proxy.
|
||||
|
||||
Since (M13.30–M13.31): the Tools bar and About dialog carry the ArtCraft mark. The
|
||||
Home screen is laid out like After Effects' and covers the whole workspace. A left rail holds New
|
||||
Project / Open Project, the Home, Templates and Learn pages and, at its foot, the community
|
||||
|
|
@ -732,9 +761,10 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r
|
|||
15. ~~Preferences and a shortcut editor that can rebind; real Wiggler, Smoother and Motion Sketch; the marker dialog.~~ (second and third waves)
|
||||
16. ~~Motion blur of collapsed precomps and of animated content inside a layer (shape paths, text
|
||||
animators).~~ (M4.13)
|
||||
17. Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch.
|
||||
18. View ▸ New Viewer (several unlocked Composition viewers), nested comp markers on the
|
||||
precomp layer bar, audio scrubbing.
|
||||
17. ~~Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch.~~
|
||||
(M4.14)
|
||||
18. ~~View ▸ New Viewer (several Composition viewers)~~ (M13.32); ~~nested comp markers on the
|
||||
precomp layer bar~~ (M13.33), ~~audio scrubbing~~ (M13.34).
|
||||
|
||||
At the original audit the engine underneath (keyframes, expressions, shape operators, text
|
||||
animators, Classic 3D, all 38 blend modes, the render queue) was already deep, and most of what was
|
||||
|
|
|
|||
Loading…
Reference in New Issue