diff --git a/Cargo.lock b/Cargo.lock index e92eb3a16..66bb36943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1958,6 +1958,7 @@ dependencies = [ "effectcraft-gpu", "effectcraft-host", "effectcraft-keyframe", + "effectcraft-media", "effectcraft-path", "effectcraft-project", "effectcraft-render", diff --git a/contributors/people.toml b/contributors/people.toml index 2494d9238..defb83972 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -131,3 +131,6 @@ sync_github_name = false [people.JonRC] real_name = "Jonathan Cardoso" display_name = "Jonathan Cardoso" + +[people.nazar-gavrylyk] +sync_github_name = true diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index 2aab70309..c4cb50de2 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -193,7 +193,7 @@ pub fn specs() -> Vec { /// Is `id` an audio effect (applied by the mixdown rather than the compositor)? pub fn is_audio_effect(id: &str) -> bool { - id.starts_with("ec.audio.") + effectcraft_project::is_audio_effect(id) } /// Does the effect produce sound on its own (so a layer without audio becomes audible)? diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index e985985b7..31478f111 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -29,6 +29,8 @@ mod stream; mod wav_markers; mod webp_anim; +/// Video decoder counters (time in decoders, samples decoded, seeks, cache hits), for benchmarks. +pub use filmcraft_codecs::{GopStats, gop_stats}; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model}; diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index 63edc65be..d8d7cda16 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -1,7 +1,7 @@ use super::item::LayerId; use super::layer::{Layer, LayerSource}; use super::settings::{Marker, yes}; -use super::{Deserialize, FrameRate, Node, PropGroup, Serialize, Tick, Value, essential, styles}; +use super::{Deserialize, FrameRate, Node, PropGroup, Serialize, Tick, essential, styles}; /// 3D renderer of a composition. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -284,20 +284,22 @@ fn timing_markers(m: &[Marker]) -> impl Iterator + '_ { } impl Comp { - /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide - /// Shy Layers and the viewer's Resolution. + /// Draws the same pixels as `o`: equal but for its layers' Lock and Shy switches, Hide Shy + /// Layers, the viewer's Resolution, the work area (a preview and render range only) and, as + /// nothing draws their audio, its layers' Audio switches, Audio groups and audio effects. pub fn same_pixels(&self, o: &Comp) -> bool { - self.same_pixels_with(o, true) + self.same_pixels_with(o, true, &|_| false) } /// [`Self::same_pixels`], with the comp's and its layers' markers counting only when /// `with_markers`: only expressions read them, so while none does, adding or moving a marker /// (even during playback) leaves every rendered frame valid. Protected time markers with a - /// duration set the timing (Responsive Design), so those always count. - pub fn same_pixels_with(&self, o: &Comp, with_markers: bool) -> bool { + /// duration set the timing (Responsive Design), so those always count. A layer's audio counts + /// when `heard` says something draws it ([`crate::Project::heard_layers`]). + pub fn same_pixels_with(&self, o: &Comp, with_markers: bool, heard: &dyn Fn(LayerId) -> bool) -> bool { self.same_settings(o, with_markers) && self.layers.len() == o.layers.len() - && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers)) + && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers, heard(a.id))) } /// [`Self::same_pixels_with`] but for the layers. @@ -311,7 +313,7 @@ impl Comp { duration, display_start, background, - work_area, + work_area: _, layers: _, markers, shutter_angle, @@ -338,7 +340,6 @@ impl Comp { && *duration == o.duration && *display_start == o.display_start && *background == o.background - && *work_area == o.work_area && (if with_markers { *markers == o.markers } else { timing_markers(markers).eq(timing_markers(&o.markers)) }) && *shutter_angle == o.shutter_angle && *shutter_phase == o.shutter_phase @@ -356,54 +357,139 @@ impl Comp { && *essential == o.essential } - /// The comp-time span (`in_point..out_point` of every layer changed, added or removed, - /// widened by the shutter) outside which `self` draws the same pixels as `o`, so cached - /// frames there stay valid (#1641). `None` when the edit can reach any frame: a comp setting, - /// a layer reorder, Solo turning on or off, an expression (it reads anything), or a changed - /// layer another layer parents to, mattes with or picks in an effect. - pub fn changed_span(&self, o: &Comp) -> Option<(Tick, Tick)> { - fn reads(g: &PropGroup, changed: &[LayerId]) -> bool { + /// The comp-time spans where `self` may draw other pixels than `o` after an edit of some of + /// its layers (moved, trimmed, split, edited, added, removed): where the active layers differ + /// or a changed one draws differently, widened by the shutter. A trim reaches only the part it + /// cut, a split only the copy's half (#1641). A layer that reads a changed one (its parent, + /// track matte, a layer it picks in an effect or tracker) draws differently wherever it is + /// active, and so does a layer with an expression that may read other layers or items when anything + /// changed: here or, `elsewhere`, outside these layers (markers, layers that draw nothing, + /// other comps and items). `None` when the edit can reach any frame: a comp setting, a layer + /// reorder, Solo turning on or off, an environment layer. A layer's audio counts when `heard` + /// says something draws it, a layer's markers when `with_markers`. `touched` layers draw differently in spans the caller adds (a + /// precomp whose comp changed): they reach the layers that read them, not their own frames. + pub fn changed_spans( + &self, + o: &Comp, + with_markers: bool, + heard: &dyn Fn(LayerId) -> bool, + elsewhere: bool, + touched: &[LayerId], + ) -> Option> { + // An expression that may read outside its own layer: it names another comp, layer, + // footage, the project or its precomp's source, or its layer's `index` (a layer added or + // removed above shifts it). Its parent and the layers it picks in an effect are read + // through `referenced_layers` below. + // ponytail: text heuristic; misses another layer reached through a variable from a call + // without those words. Upgrade: record reads in the expression host. + fn reads_outside(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { - Node::Prop(p) => { - p.expr.is_some() - || std::iter::once(&p.value) - .chain(p.keys.iter().map(|k| &k.value)) - .any(|v| matches!(v, Value::Layer(Some(id)) if changed.contains(&LayerId(*id)))) - } - Node::Group(g) => reads(g, changed), + Node::Prop(p) => p.expr.as_ref().is_some_and(|e| { + let t = e.text.to_lowercase().replace("thislayer", ""); + ["comp", "layer", "footage", "project", "source", "index"].iter().any(|w| t.contains(w)) + }), + Node::Group(g) => reads_outside(g), }) } - // Solo hides every other layer at every frame. - if !self.same_settings(o, true) || self.any_solo() != o.any_solo() { + fn any_expression(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.expr.is_some(), + Node::Group(g) => any_expression(g), + }) + } + // Solo hides every other layer at every frame. (Comp markers count through `elsewhere`: + // an expression reading them names `thisComp`.) + if !self.same_settings(o, false) || self.any_solo() != o.any_solo() { return None; } let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::>(); if common(self, o) != common(o, self) { return None; } + // Changed, added or removed layers (each from both sides: its old and its new span), and + // those that draw the same apart from their span (a trim or split). Ids count: they seed + // randomness (noise, text randomisers), so a split's copy may draw differently. let mut changed = Vec::new(); - let mut span: Option<(Tick, Tick)> = None; - // Each changed layer comes up from both sides: its old and its new span. + let mut respanned = Vec::new(); + let mut keyed = Vec::new(); + let mut cuts = Vec::new(); for (l, other) in self.layers.iter().map(|l| (l, o.layer(l.id))).chain(o.layers.iter().map(|l| (l, self.layer(l.id)))) { - if other.is_some_and(|b| l.same_pixels(b)) { + if other.is_some_and(|b| l.same_pixels_with(b, with_markers, heard(l.id))) { continue; } + if l.environment || l.environment_background { + return None; + } + // (an expression on the layer can read its own in and out points and name, `inPoint`, + // `thisLayer.name`: then a trim or rename reaches all of it) + if let Some(b) = other + && !any_expression(&l.props) + && !any_expression(&b.props) + && (Layer { in_point: b.in_point, out_point: b.out_point, name: b.name.clone(), label: b.label, comment: b.comment.clone(), ..l.clone() }) + .same_pixels_with(b, with_markers, heard(l.id)) + { + respanned.push(l.id); + } else if let Some(b) = other + && let Some(spans) = l.keyframe_spans(b, with_markers, heard(l.id)) + { + // (drawn the same outside the keys it changed) + respanned.push(l.id); + keyed.extend(spans); + } changed.push(l.id); - let (s, e) = (l.in_point.min(l.out_point), l.in_point.max(l.out_point)); - span = Some(span.map_or((s, e), |(a, b)| (a.min(s), b.max(e)))); + cuts.extend([l.in_point, l.out_point]); } - let reaches = |l: &Layer| { - l.parent.is_some_and(|p| changed.contains(&p)) || l.track_matte.as_ref().is_some_and(|m| changed.contains(&m.layer)) || reads(&l.props, &changed) - }; - if self.layers.iter().chain(&o.layers).any(reaches) { - return None; + // The layers the change reaches besides: expressions, then whatever reads a reached layer. + let both = || self.layers.iter().chain(&o.layers); + let mut reached: Vec = Vec::new(); + if !changed.is_empty() || elsewhere || !touched.is_empty() { + reached.extend(both().filter(|l| reads_outside(&l.props)).map(|l| l.id)); } - // A frame at `t` samples cameras, lights and transforms over its shutter, - // `t + (phase + angle * 0..=1) / 360` frames, and draws the layers active at `t` itself. + // (each pass adds a layer or stops: at most one pass per layer) + loop { + let read = |l: &&Layer| { + !reached.contains(&l.id) + && l.referenced_layers().iter().any(|r| *r != l.id && (changed.contains(r) || reached.contains(r) || touched.contains(r))) + }; + let more: Vec = both().filter(read).map(|l| l.id).collect(); + if more.is_empty() { + break; + } + reached.extend(more); + } + reached.sort_unstable(); + reached.dedup(); + for l in both().filter(|l| reached.contains(&l.id)) { + if l.environment || l.environment_background { + return None; + } + cuts.extend([l.in_point, l.out_point]); + } + let (before, after) = self.shutter_margin(); + // Between two cuts the same layers are active; outside them no changed layer is. + cuts.sort_unstable(); + cuts.dedup(); + let draws_same = |l: &Layer| (!changed.contains(&l.id) || respanned.contains(&l.id)) && !reached.contains(&l.id); + let spans = cuts + .windows(2) + .filter_map(|w| { + let (&s, &e) = (w.first()?, w.last()?); + let active = |c: &Comp| c.layers.iter().filter(|l| l.is_active_at(s)).map(|l| l.id).collect::>(); + let same = active(self) == active(o) && self.layers.iter().filter(|l| l.is_active_at(s)).all(draws_same); + (!same).then(|| (s - before, e + after)) + }) + .chain(keyed.into_iter().map(|(s, e)| (s - before, e + after))) + .collect(); + Some(spans) + } + + /// How far a change at a time reaches the frames before and after it through the shutter: a + /// frame at `t` samples cameras, lights and transforms over its shutter, + /// `t + (phase + angle * 0..=1) / 360` frames, and draws the layers active at `t` itself. + pub fn shutter_margin(&self) -> (Tick, Tick) { let (a, p) = (self.shutter_angle / 360.0, self.shutter_phase / 360.0); let fd = self.frame_duration().seconds(); - let (before, after) = (Tick::from_seconds_f64(p.max(p + a).max(0.0) * fd), Tick::from_seconds_f64(-p.min(p + a).min(0.0) * fd)); - span.map(|(s, e)| (s - before, e + after)) + (Tick::from_seconds_f64(p.max(p + a).max(0.0) * fd), Tick::from_seconds_f64(-p.min(p + a).min(0.0) * fd)) } /// Some layer is soloed: only the soloed layers draw. @@ -411,18 +497,21 @@ impl Comp { self.layers.iter().any(|l| l.switches.solo && l.source.is_av()) } - /// The comp with those switches and the Resolution at their defaults (borrowed when they - /// already are), for keys that hash the whole comp. + /// The comp with those switches, the Resolution and the work area at their defaults (borrowed + /// when they already are), for keys that hash the whole comp. pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> { - self.pixel_form_with(true) + self.pixel_form_with(true, &|_| false) } /// [`Self::pixel_form`], also without the comp's plain markers and its layers' markers when not - /// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]). - pub fn pixel_form_with(&self, with_markers: bool) -> std::borrow::Cow<'_, Comp> { + /// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]), and with the + /// audio of the layers `heard` doesn't name at its defaults ([`Layer::picture_form`]). + pub fn pixel_form_with(&self, with_markers: bool, heard: &dyn Fn(LayerId) -> bool) -> std::borrow::Cow<'_, Comp> { + let pictures: Vec> = self.layers.iter().map(|l| l.picture_form(heard(l.id))).collect(); if !self.hide_shy && self.resolution.is_auto() - && self.layers.iter().all(|l| l.switches == l.switches.pixels()) + && self.work_area == (Tick::ZERO, Tick::ZERO) + && pictures.iter().all(Option::is_none) && (with_markers || (self.markers.iter().all(is_timing_marker) && self.layers.iter().all(|l| l.markers.is_empty()))) { return std::borrow::Cow::Borrowed(self); @@ -430,11 +519,14 @@ impl Comp { let mut c = self.clone(); c.hide_shy = false; c.resolution = Resolution::Auto; + c.work_area = (Tick::ZERO, Tick::ZERO); if !with_markers { c.markers.retain(is_timing_marker); } - for l in &mut c.layers { - l.switches = l.switches.pixels(); + for (l, picture) in c.layers.iter_mut().zip(pictures) { + if let Some(p) = picture { + *l = p; + } if !with_markers { l.markers.clear(); } diff --git a/crates/project/src/dirty.rs b/crates/project/src/dirty.rs new file mode 100644 index 000000000..a83ca35d9 --- /dev/null +++ b/crates/project/src/dirty.rs @@ -0,0 +1,112 @@ +//! Which frames of a comp an edit can change: frame caches keep the others. + +use super::comp::Comp; +use super::item::{Heard, ItemId}; +use super::layer::{Layer, LayerSource, reads_other_frames}; +use super::{Arc, GroupKind, Node, Project, Tick}; + +/// Precomps nested deeper than this count as changed wherever they show. +const MAX_DEPTH: usize = 32; + +type Seen = std::collections::HashMap>>; + +impl Project { + /// The comp-time spans of `comp` where `self` may draw other pixels than `old` (the project + /// before an edit): [`Comp::changed_spans`] of its layers, and for a precomp whose comp + /// changed, the spans that comp changed in, where the precomp shows them (#1641). Layers that + /// can't change the picture ([`Project::silent_layers`]) don't count, nor does the audio of + /// layers no effect draws it from (`heard`, best of both states). Expressions can read + /// anything, so any change (`outside` these comps too: another comp, an item's name) reaches + /// the layers that have one. `None` when the edit can reach any frame. + pub fn dirty_spans(&self, old: &Project, comp: ItemId, heard: &Heard, outside: bool) -> Option> { + self.dirty_spans_at(old, comp, heard, outside, 0, &mut std::collections::HashMap::new()) + } + + /// `seen`: the spans of the comps already walked in this call, so a precomp shared by many + /// layers is walked once. + fn dirty_spans_at(&self, old: &Project, comp: ItemId, heard: &Heard, outside: bool, depth: usize, seen: &mut Seen) -> Option> { + if depth > MAX_DEPTH { + return None; + } + if let Some(spans) = seen.get(&comp) { + return spans.clone(); + } + let spans = self.comp_dirty_spans(old, comp, heard, outside, depth, seen); + seen.insert(comp, spans.clone()); + spans + } + + fn comp_dirty_spans(&self, old: &Project, comp: ItemId, heard: &Heard, outside: bool, depth: usize, seen: &mut Seen) -> Option> { + let (new_c, old_c) = (self.comp_arc(comp)?, old.comp_arc(comp)?); + // Expressions may read what changed elsewhere, even from comps that didn't change. + let reads_elsewhere = heard.expressions && outside; + let quiet = self.silent_layers(comp, &new_c, heard); + // An edit of this comp is outside the comps it nests: their expressions may read it + // (`comp("Main").layer("Controls")`, at their own time). + let nested_outside = outside || !Arc::ptr_eq(&new_c, &old_c); + // Precomps (drawing the same comp before and after) whose comp changed: where they show it. + let mut touched = Vec::new(); + let mut nested = Vec::new(); + for l in new_c.layers.iter().filter(|l| !quiet.contains(&l.id)) { + let LayerSource::Comp { item } = l.source else { continue }; + if old_c.layer(l.id).is_none_or(|before| before.source != l.source) { + continue; + } + // (an unchanged `Arc` says nothing of the comps nested in it: walk it anyway) + let (Some(n), Some(_)) = (self.comp_arc(item), old.comp_arc(item)) else { continue }; + let inner = self.dirty_spans_at(old, item, heard, nested_outside, depth + 1, seen); + if inner.as_ref().is_some_and(Vec::is_empty) { + continue; + } + touched.push(l.id); + nested.extend(shown(l, &new_c, &n, inner)); + } + if Arc::ptr_eq(&new_c, &old_c) && touched.is_empty() && !reads_elsewhere { + return Some(vec![]); + } + let picture = |p: &Project, c: &Comp| { + let mut c = c.clone(); + let quiet = p.silent_layers(comp, &c, heard); + c.layers.retain(|l| !quiet.contains(&l.id)); + c + }; + let (a, b) = (picture(old, &old_c), picture(self, &new_c)); + let elsewhere = heard.expressions && (outside || !new_c.same_pixels_with(&old_c, true, &|l| heard.contains(comp, l)) || !touched.is_empty()); + let mut spans = b.changed_spans(&a, heard.expressions, &|l| heard.contains(comp, l), elsewhere, &touched)?; + spans.extend(nested); + Some(spans) + } +} + +/// The comp-time spans of `outer` where its precomp layer `l` shows the spans `inner` of its comp +/// `n` (`None`: all of it), widened by `outer`'s shutter. A time remap, Responsive Design +/// timing or an effect reading the layer at other times (Echo, Posterize Time…: [`reads_other_frames`], +/// or an effect picking the layer itself) can show any nested time anywhere: then the layer's +/// whole span. +fn shown(l: &Layer, outer: &Comp, n: &Comp, inner: Option>) -> Vec<(Tick, Tick)> { + let (before, after) = outer.shutter_margin(); + let whole = vec![(l.in_point - before, l.out_point + after)]; + let responsive = (l.stretch - 100.0).abs() > 1e-9 && n.markers.iter().any(|m| m.protected && m.duration > Tick::ZERO); + let Some(inner) = inner else { return whole }; + if l.props.get("timeRemap").is_some() || responsive || reads_itself_elsewhen(l) { + return whole; + } + // (a nested frame either side: Preserve Frame Rate holds nested frames, Frame Blending mixes + // neighbours) + let fd = n.frame_duration(); + inner + .into_iter() + .filter_map(|(s, e)| { + let (x, y) = (l.comp_time(s - fd), l.comp_time(e + fd)); + let (lo, hi) = (x.min(y).max(l.in_point), x.max(y).min(l.out_point)); + (lo <= hi).then(|| (lo - before, hi + after)) + }) + .collect() +} + +/// Some effect of `l` reads the layer at other times ([`reads_other_frames`]) or picks the layer +/// itself (a layer parameter set to its own layer). +fn reads_itself_elsewhen(l: &Layer) -> bool { + let time_fx = |c: &Node| matches!(c, Node::Group(f) if matches!(&f.kind, GroupKind::Effect { effect } if reads_other_frames(effect))); + l.picked_layers().contains(&l.id) || l.props.group("effects").is_some_and(|e| e.children.iter().any(time_fx)) +} diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index cc55397ec..cb43de143 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -131,6 +131,78 @@ impl Project { self.comps().any(|(_, c)| c.layers.iter().any(|l| any_expr(&l.props))) } + /// The layers whose audio something draws ([`Heard`]): those a layer picks in an effect (an + /// Audio Spectrum or Audio Waveform's Audio Layer, even on itself), and the layers inside a + /// picked precomp, whose audio it mixes. With an expression anywhere (it can read any layer's + /// Audio Levels) every layer may be heard ([`Heard::contains`]). + pub fn heard_layers(&self) -> Heard { + let mut layers = std::collections::BTreeSet::new(); + let mut stack: Vec<(ItemId, LayerId)> = + self.comps().flat_map(|(id, c)| c.layers.iter().flat_map(move |l| l.picked_layers().into_iter().map(move |r| (*id, r)))).collect(); + // (bounded: each (comp, layer) is visited once) + while let Some((cid, lid)) = stack.pop() { + if !layers.insert((cid, lid)) { + continue; + } + let inner = self.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| match l.source { + LayerSource::Comp { item } => Some(item), + _ => None, + }); + if let Some(inner) = inner + && let Some(c) = self.comp(inner) + { + stack.extend(c.layers.iter().map(|l| (inner, l.id))); + } + } + Heard { expressions: self.reads_markers(), audio_exprs: self.reads_audio(), layers } + } + + /// Some expression may read a layer's audio: its text names it (`audio`, `"Audio Levels"`, + /// `"ADBE Audio Group"`) or walks properties by index (`layer(1)(3)`). + // ponytail: text heuristic; misses an index walk through a variable (`g = layer(1); g(3)`). + // Upgrade: record audio reads in the expression host. + fn reads_audio(&self) -> bool { + fn reads(text: &str) -> bool { + let t: String = text.chars().filter(|c| !c.is_whitespace()).collect::().to_lowercase(); + t.contains("audio") || t.match_indices(")(").any(|(i, _)| t.get(i + 2..).and_then(|r| r.chars().next()).is_some_and(|c| c.is_ascii_digit())) + } + fn any(g: &crate::PropGroup) -> bool { + g.children.iter().any(|c| match c { + crate::Node::Prop(p) => p.expr.as_ref().is_some_and(|e| reads(&e.text)), + crate::Node::Group(sub) => any(sub), + }) + } + self.comps().any(|(_, c)| c.layers.iter().any(|l| any(&l.props))) + } + + /// Layers of `comp` (item `id`) that can't change its pixels: footage layers of files without + /// video (music, voice-over) and layers with the Video switch off, unless another layer reads + /// them (a hidden track matte) or something draws their audio ([`Self::heard_layers`]: the + /// Audio Spectrum's Audio Layer, also from a comp that nests this one). Cameras, lights, + /// nulls, environment layers and soloed layers (Solo hides every other layer) are never + /// silent. Expressions, which can read any layer, are the caller's concern. + pub fn silent_layers(&self, id: ItemId, comp: &Comp, heard: &Heard) -> std::collections::BTreeSet { + let read: std::collections::BTreeSet = + comp.layers.iter().flat_map(|l| l.referenced_layers().into_iter().filter(move |r| *r != l.id)).collect(); + comp.layers + .iter() + .filter(|l| { + let no_picture = match &l.source { + LayerSource::Footage { item } => matches!(self.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if !f.has_video), + _ => false, + }; + // (environment layers light Advanced 3D scenes without being drawn) + (no_picture || l.source.is_av() && !l.switches.video) + && !l.switches.solo + && !l.environment + && !l.environment_background + && !read.contains(&l.id) + && !heard.picked(id, l.id) + }) + .map(|l| l.id) + .collect() + } + /// Allocate an id (items, layers and property uids share one counter). pub fn alloc(&mut self) -> u64 { let id = self.next_id; @@ -324,3 +396,36 @@ impl Project { Ok((p, notes)) } } + +/// The layers whose audio something draws ([`Project::heard_layers`]): their Audio switch, Audio +/// group and audio effects change pixels. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Heard { + /// Some layer has an expression (it can read anything). + pub expressions: bool, + /// Some expression may read a layer's audio ([`Project::reads_audio`]). + audio_exprs: bool, + layers: std::collections::BTreeSet<(ItemId, LayerId)>, +} + +impl Heard { + /// Something may draw the audio of layer `layer` of comp `comp`: a layer picks it + /// ([`Self::picked`]) or an expression may read it. + pub fn contains(&self, comp: ItemId, layer: LayerId) -> bool { + self.audio_exprs || self.picked(comp, layer) + } + + /// A layer picks layer `layer` of comp `comp` in an effect (or a precomp of it), so draws its + /// audio. Expressions aside: they draw in their own layers. + pub fn picked(&self, comp: ItemId, layer: LayerId) -> bool { + self.layers.contains(&(comp, layer)) + } + + /// Heard in `self` or in `o` (the states before and after an edit). + pub fn union(mut self, o: &Heard) -> Heard { + self.expressions |= o.expressions; + self.audio_exprs |= o.audio_exprs; + self.layers.extend(o.layers.iter().copied()); + self + } +} diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 3d7676b28..d40120621 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -1,6 +1,6 @@ use super::item::{ItemId, LayerId}; use super::settings::Marker; -use super::{BlendMode, Deserialize, Label, ParamUi, PropGroup, Property, Serialize, Tick}; +use super::{BlendMode, Deserialize, GroupKind, Label, Node, ParamUi, PropGroup, Property, Serialize, Tick, Value}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum LightKind { @@ -182,25 +182,118 @@ impl Default for Switches { } } -// What a frame's pixels depend on. Caches key on it: the Audio, Lock and Shy switches and Hide -// Shy Layers don't change pixels, so toggling them keeps cached frames (#103). +// What a frame's pixels depend on. Caches key on it: the Lock and Shy switches and Hide Shy +// Layers never change pixels, and the Audio switch, the Audio group and audio effects change them +// only when something draws the layer's audio (a waveform or spectrum picks it, see +// [`crate::Project::heard_layers`]), so editing those keeps cached frames (#103). + +/// Is `id` an audio effect (applied by the audio mix rather than the compositor)? +pub fn is_audio_effect(id: &str) -> bool { + id.starts_with("ec.audio.") +} + +/// Does effect `id` read its own layer at other times (`EffectHost::self_at`, `layer_at` on +/// itself): the time effects (Echo, Posterize Time, Timewarp…), simulations, temporal smoothing +/// and analysis (Remove Grain, Auto Levels, Color Stabilizer's reference frame, Warp Stabilizer, +/// Roto Brush…) and Clone strokes with a time offset? On a precomp layer, a change at one nested +/// time then reaches frames of the layer far from where it shows. +// ponytail: hand-kept list of the effects that call `self_at`; a new one must be added here. +pub fn reads_other_frames(id: &str) -> bool { + id.starts_with("ec.time.") || id.starts_with("ec.sim.") || READS_OTHER_FRAMES.contains(&id) +} + +/// The effects outside `ec.time.` and `ec.sim.` that read their own layer at other times. +const READS_OTHER_FRAMES: &[&str] = &[ + "ec.noise.removegrain", + "ec.color.autolevels", + "ec.color.autocontrast", + "ec.color.autocolor", + "ec.color.shadowhighlight", + "ec.color.colorstabilizer", + "ec.matte.refinesoft", + "ec.matte.refinehard", + "ec.matte.rotobrush", + "ec.matte.objectmatte", + "ec.blur.camerashakedeblur", + "ec.distort.rollingshutterrepair", + "ec.distort.warpstabilizer", + "ec.key.ccsimplewireremoval", + "ec.paint.paint", +]; impl Switches { /// These switches with Audio, Lock and Shy at their defaults. pub fn pixels(&self) -> Switches { Switches { audio: true, locked: false, shy: false, ..self.clone() } } + + /// The switches that change the pixels of a layer: [`Self::pixels`], keeping Audio when + /// something draws the layer's audio (`heard`). + pub fn picture(&self, heard: bool) -> Switches { + Switches { audio: if heard { self.audio } else { true }, ..self.pixels() } + } +} + +/// A node only the audio mix reads: the layer's Audio group, an audio effect. +fn audio_only(n: &Node, root: bool) -> bool { + match n { + Node::Group(g) => (root && g.match_id == "audio") || matches!(&g.kind, GroupKind::Effect { effect } if is_audio_effect(effect)), + Node::Prop(_) => false, + } +} + +/// `a` and `b` are equal but for their audio-only nodes ([`audio_only`]: the root's Audio group, +/// the effects in its Effects group that are audio effects). +fn same_picture_props(a: &PropGroup, b: &PropGroup) -> bool { + fn head(a: &PropGroup, b: &PropGroup) -> bool { + // Every field (no `..`: a new field must be considered here). + let PropGroup { uid, match_id, name, kind, enabled, label, children: _ } = a; + *uid == b.uid && *match_id == b.match_id && *name == b.name && *kind == b.kind && *enabled == b.enabled && *label == b.label + } + fn children(a: &PropGroup, b: &PropGroup, root: bool) -> bool { + let mut x = a.children.iter().filter(|n| !audio_only(n, root)); + let mut y = b.children.iter().filter(|n| !audio_only(n, root)); + loop { + match (x.next(), y.next()) { + (None, None) => return true, + (Some(Node::Group(g)), Some(Node::Group(h))) if root && g.match_id == "effects" && h.match_id == "effects" => { + if !(head(g, h) && children(g, h, false)) { + return false; + } + } + (Some(m), Some(n)) if m == n => {} + _ => return false, + } + } + } + head(a, b) && children(a, b, true) +} + +/// `g` (a layer's property tree) without its audio-only nodes ([`same_picture_props`]). +fn picture_props(g: &PropGroup) -> PropGroup { + let mut g = g.clone(); + g.children.retain(|n| !audio_only(n, true)); + for n in &mut g.children { + if let Node::Group(e) = n + && e.match_id == "effects" + { + e.children.retain(|n| !audio_only(n, false)); + } + } + g } impl Layer { - /// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches. + /// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches and what only + /// the audio mix reads (nothing draws its audio). pub fn same_pixels(&self, o: &Layer) -> bool { - self.same_pixels_with(o, true) + self.same_pixels_with(o, true, false) } /// [`Self::same_pixels`], with the markers counting only when `with_markers` (they are read by - /// expressions alone, so a project without any can edit them freely). - pub fn same_pixels_with(&self, o: &Layer, with_markers: bool) -> bool { + /// expressions alone, so a project without any can edit them freely) and the audio (the Audio + /// switch, the Audio group, audio effects) only when `heard`: something draws it. + pub fn same_pixels_with(&self, o: &Layer, with_markers: bool, heard: bool) -> bool { // Every field (no `..`: a new field must be considered here); the property tree last. let Layer { id, @@ -233,7 +326,7 @@ impl Layer { && *in_point == o.in_point && *out_point == o.out_point && *stretch == o.stretch - && switches.pixels() == o.switches.pixels() + && switches.picture(heard) == o.switches.picture(heard) && *blend_mode == o.blend_mode && *preserve_transparency == o.preserve_transparency && *track_matte == o.track_matte @@ -243,7 +336,100 @@ impl Layer { && *auto_orient == o.auto_orient && *environment == o.environment && *environment_background == o.environment_background - && *props == o.props + && (if heard { *props == o.props } else { same_picture_props(props, &o.props) }) + } + + /// The comp-time spans where `self` draws differently than `o` when the two differ only in + /// the keyframes of Transform properties (a key's value, ease or interpolation moved in the + /// Timeline or Graph Editor): from two keys before the first changed key to two after the + /// last (auto-Bezier and continuous tangents reach the neighbouring segments), to the + /// layer's in or out point past the first or last key (values hold there). `None` for any + /// other difference, a static value, an expression on the layer (it can read the keyed + /// property at other times), roving or muted keys, a time remap or a time effect (it reads other + /// times): those reach the layer's whole span. + pub fn keyframe_spans(&self, o: &Layer, with_markers: bool, heard: bool) -> Option> { + let transform = |l: &Layer| l.props.group("transform").cloned(); + let (t1, t0) = (transform(self)?, transform(o)?); + let reads_time = |l: &Layer| { + l.props.get("timeRemap").is_some() + || l.props.group("effects").is_some_and(|e| { + e.children.iter().any(|n| matches!(n, Node::Group(f) if matches!(&f.kind, GroupKind::Effect { effect } if reads_other_frames(effect)))) + }) + }; + let has_expr = |l: &Layer| { + let mut any = false; + l.props.walk("", &mut |_, p| any |= p.has_expression()); + any + }; + if reads_time(self) || reads_time(o) || has_expr(self) || has_expr(o) { + return None; + } + // Everything but the Transform group draws the same. + let rest = |l: &Layer| { + let mut l = l.clone(); + l.props.children.retain(|n| !matches!(n, Node::Group(g) if g.match_id == "transform")); + l + }; + let head = |g: &PropGroup| PropGroup { children: vec![], ..g.clone() }; + if !rest(self).same_pixels_with(&rest(o), with_markers, heard) || head(&t1) != head(&t0) || t1.children.len() != t0.children.len() { + return None; + } + let mut spans = Vec::new(); + for (n1, n0) in t1.children.iter().zip(&t0.children) { + if n1 == n0 { + continue; + } + let (Node::Prop(p1), Node::Prop(p0)) = (n1, n0) else { return None }; + let bare = |p: &Property| Property { keys: vec![], value: Value::Scalar(0.0), ..p.clone() }; + // (muted keys are skipped: the keys around an edit that play can be any distance away) + if bare(p1) != bare(p0) || p1.keys.is_empty() || p0.keys.is_empty() || p1.keys.iter().chain(&p0.keys).any(|k| k.roving || k.muted) { + return None; + } + // Keys only one side has as they are (added, removed, edited, moved). + let changed: Vec = + p1.keys.iter().filter(|k| !p0.keys.contains(k)).chain(p0.keys.iter().filter(|k| !p1.keys.contains(k))).map(|k| k.time).collect(); + let (Some(first), Some(last)) = (changed.iter().min(), changed.iter().max()) else { continue }; + // Two keys before the first changed one and two after the last, on either side (the + // first or last key when there are fewer: values hold beyond it). Past the first or + // last key the change reaches the in or out point (`None`). + let mut lo = Some(Tick(i64::MAX)); + let mut hi = Some(Tick(i64::MIN)); + for keys in [&p1.keys, &p0.keys] { + let before = keys.iter().filter(|k| k.time < *first).count(); + let back = if before == 0 { None } else { keys.get(before.saturating_sub(2)) }; + lo = lo.zip(back).map(|(a, k)| a.min(k.time)); + let upto = keys.iter().filter(|k| k.time <= *last).count(); + let ahead = if upto >= keys.len() { None } else { keys.get(upto.saturating_add(1).min(keys.len().saturating_sub(1))) }; + hi = hi.zip(ahead).map(|(a, k)| a.max(k.time)); + } + let (x, y) = (lo.map_or(self.in_point, |t| self.comp_time(t)), hi.map_or(self.out_point, |t| self.comp_time(t))); + let (x, y) = (x.min(y).max(self.in_point), x.max(y).min(self.out_point)); + if x <= y { + spans.push((x, y)); + } + } + Some(spans) + } + + /// This layer with what doesn't change its pixels at its defaults (see + /// [`Self::same_pixels_with`]), for keys that hash it; `None` when it already is. + pub fn picture_form(&self, heard: bool) -> Option { + let switches = self.switches.picture(heard); + let audio = !heard + && (self.props.children.iter().any(|n| audio_only(n, true)) + || self + .props + .children + .iter() + .any(|n| matches!(n, Node::Group(e) if e.match_id == "effects" && e.children.iter().any(|n| audio_only(n, false))))); + if switches == self.switches && !audio { + return None; + } + let mut l = Layer { switches, ..self.clone() }; + if audio { + l.props = picture_props(&self.props); + } + Some(l) } } @@ -326,6 +512,44 @@ pub struct Layer { } impl Layer { + /// The layers this layer reads: its parent, track matte, layer parameters (Displacement Map, + /// Audio Spectrum's Audio Layer…) and tracker targets. + pub fn referenced_layers(&self) -> Vec { + fn walk(g: &PropGroup, depth: usize, out: &mut Vec) { + if depth > 64 { + return; + } + if let GroupKind::Tracker { settings } = &g.kind + && let Some(t) = settings.target + { + out.push(t); + } + for c in &g.children { + match c { + Node::Prop(p) => { + for v in std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)) { + if let Value::Layer(Some(id)) = v { + out.push(LayerId(*id)); + } + } + } + Node::Group(sub) => walk(sub, depth + 1, out), + } + } + } + let mut out: Vec = self.parent.iter().copied().chain(self.track_matte.iter().map(|m| m.layer)).collect(); + walk(&self.props, 0, &mut out); + out + } + + /// Layers this one picks in an effect or tracker (an Audio Spectrum's Audio Layer, a + /// Displacement Map's map layer…), itself included: [`Self::referenced_layers`] without the + /// parent and the track matte. + pub fn picked_layers(&self) -> Vec { + let skip = usize::from(self.parent.is_some()) + usize::from(self.track_matte.is_some()); + self.referenced_layers().into_iter().skip(skip).collect() + } + pub fn is_active_at(&self, t: Tick) -> bool { t >= self.in_point && t < self.out_point } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 67b846cd6..079b81765 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -30,6 +30,7 @@ pub use effectcraft_keyframe as keyframe; pub use effectcraft_keyframe::{Keyframe, Value}; mod comp; +mod dirty; mod footage; mod item; mod layer; @@ -39,8 +40,11 @@ mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, VIDEO_EXTENSIONS, cineon_log_to_lin}; pub use item::NO_LAYER; -pub use item::{Item, ItemId, ItemKind, LayerId}; -pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; +pub use item::{Heard, Item, ItemId, ItemKind, LayerId}; +pub use layer::{ + AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect, + reads_other_frames, +}; pub use layers::Layers; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index ed0d05588..9d007e3e0 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -410,3 +410,75 @@ fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() { let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect(); assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]); } + +/// Music in a precomp is heard, and so not silent, when a layer of a comp nesting it picks the +/// precomp layer (an Audio Waveform drawing the precomp's mix). +#[test] +fn music_in_a_precomp_a_waveform_picks_is_not_silent() { + use crate::{Footage, FootageKind, GroupKind}; + let (mut p, inner) = project_with_layer(); + let song = p.add_item( + "song.wav", + effectcraft_color::Label::None, + None, + ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }), + ); + let comp = p.comp(inner).unwrap().clone(); + let music = build::layer(&mut p, &comp, "Music", LayerSource::Footage { item: song }, (1920, 1080), None); + let music_id = music.id; + p.comp_mut(inner).unwrap().layers.push(music); + let outer = p.add_item("Outer", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into())); + let pre = build::layer(&mut p, &comp, "Pre", LayerSource::Comp { item: inner }, (1920, 1080), None); + let pre_id = pre.id; + p.comp_mut(outer).unwrap().layers.push(pre); + let silent = |p: &Project| p.silent_layers(inner, p.comp(inner).unwrap(), &p.heard_layers()).contains(&music_id); + assert!(silent(&p), "nothing draws the precomp's audio"); + let mut n = 1_000_000; + let mut ids = build::Ids(&mut n); + let mut g = ids.group("waveform", "Audio Waveform"); + g.kind = GroupKind::Effect { effect: "ec.generate.audiowaveform".into() }; + g.children.push(ids.prop("audioLayer", "Audio Layer", Value::Layer(Some(pre_id.0))).into()); + let solid = p.comp(outer).unwrap().layers[0].id; + p.comp_mut(outer).unwrap().layer_mut(solid).unwrap().props.group_mut("effects").unwrap().children.push(g.into()); + assert!(!silent(&p), "the outer comp's waveform draws the music"); +} + +/// Layers that can't change the picture: music, hidden layers nothing reads. A hidden matte is +/// read by the layer it mattes; nulls and cameras are never silent. +#[test] +fn silent_layers_are_music_and_unread_hidden_layers() { + use crate::{Footage, FootageKind, LayerId, MatteKind, TrackMatte}; + let (mut p, cid) = project_with_layer(); + let comp = p.comp(cid).unwrap().clone(); + let song = p.add_item( + "song.wav", + effectcraft_color::Label::None, + None, + ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }), + ); + let add = |p: &mut Project, name: &str, source: LayerSource| { + let l = build::layer(p, &comp, name, source, (1920, 1080), None); + let id = l.id; + p.comp_mut(cid).unwrap().layers.push(l); + id + }; + let music = add(&mut p, "Music", LayerSource::Footage { item: song }); + let solid = p.comp(cid).unwrap().layers[0].source.clone(); + let hidden = add(&mut p, "Hidden", solid.clone()); + let matte = add(&mut p, "Matte", solid); + let null = add(&mut p, "Null", LayerSource::Null); + let first = p.comp(cid).unwrap().layers[0].id; + let c = p.comp_mut(cid).unwrap(); + for id in [hidden, matte, null] { + c.layer_mut(id).unwrap().switches.video = false; + } + c.layer_mut(first).unwrap().track_matte = Some(TrackMatte { layer: matte, kind: MatteKind::Alpha }); + let c = p.comp(cid).unwrap(); + assert_eq!(p.silent_layers(cid, c, &p.heard_layers()).into_iter().collect::>(), vec![music, hidden]); + // Solo hides every other layer, so a soloed music or hidden layer changes the picture. + for (soloed, rest) in [(music, hidden), (hidden, music)] { + let mut q = p.clone(); + q.comp_mut(cid).unwrap().layer_mut(soloed).unwrap().switches.solo = true; + assert_eq!(q.silent_layers(cid, q.comp(cid).unwrap(), &q.heard_layers()).into_iter().collect::>(), vec![rest]); + } +} diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 9cf5694f0..e05ff739f 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -33,9 +33,14 @@ use effectcraft_raster::Image; /// File format version (part of the folder layout). const VERSION: &str = "v1"; const MAGIC: &[u8; 4] = b"ECDC"; -/// Pending writes before new ones are dropped. +/// Pending layer and matte writes before new ones are dropped. #[cfg(not(target_arch = "wasm32"))] const QUEUE_LIMIT: usize = 64; +/// Pending frame bytes before new frames are dropped. Frames have their own lane, written +/// before layer buffers: a burst of large layer writes used to fill the shared queue and drop +/// frames, so a fully cached comp came back with gaps in its blue bar. +#[cfg(not(target_arch = "wasm32"))] +const FRAME_QUEUE_BYTES: usize = 1 << 30; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Kind { @@ -225,8 +230,30 @@ enum Job { } struct Queue { + /// Frame writes (taken first) and their payload bytes. + frames: std::collections::VecDeque, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + frame_bytes: usize, + /// Layer and matte writes. jobs: std::collections::VecDeque, busy: bool, + /// Keys of frames queued or being written. + pending_frames: std::collections::HashSet, +} + +impl Queue { + #[cfg(not(target_arch = "wasm32"))] + fn pop(&mut self) -> Option { + if let Some(j) = self.frames.pop_front() { + let Job::Write(_, _, p) = &j; + self.frame_bytes = self.frame_bytes.saturating_sub(p.len()); + return Some(j); + } + self.jobs.pop_front() + } + fn is_empty(&self) -> bool { + self.frames.is_empty() && self.jobs.is_empty() + } } pub struct DiskCache { @@ -320,7 +347,16 @@ impl DiskCache { dropped: AtomicU64::new(0), failed: AtomicU64::new(0), tmp_counter: AtomicU64::new(0), - queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")) }), Condvar::new())), + queue: Arc::new(( + Mutex::new(Queue { + frames: Default::default(), + frame_bytes: 0, + jobs: Default::default(), + busy: cfg!(not(target_arch = "wasm32")), + pending_frames: Default::default(), + }), + Condvar::new(), + )), min_layer_ms: AtomicU64::new(30), }); dc.rescan(); @@ -428,6 +464,11 @@ impl DiskCache { self.index.lock().is_ok_and(|g| g.contains(kind, key)) } + /// A frame write is queued or in progress. + pub fn frame_pending(&self, key: u128) -> bool { + self.queue.0.lock().is_ok_and(|q| q.pending_frames.contains(&key)) + } + /// Keys of one kind (unordered). pub fn keys(&self, kind: Kind) -> Vec { self.index.lock().map(|g| g.keys(kind)).unwrap_or_default() @@ -467,8 +508,10 @@ impl DiskCache { #[cfg(target_arch = "wasm32")] { q.jobs.clear(); + q.frames.clear(); + q.pending_frames.clear(); } - while !q.jobs.is_empty() || q.busy { + while !q.is_empty() || q.busy { q = match cv.wait(q) { Ok(q) => q, Err(_) => return, @@ -515,21 +558,34 @@ impl DiskCache { } } - fn enqueue(&self, kind: Kind, key: u128, payload: Vec) { + /// Queue a write; `false` when it was dropped (a full queue). + fn enqueue(&self, kind: Kind, key: u128, payload: Vec) -> bool { #[cfg(target_arch = "wasm32")] { self.write_now(kind, key, &payload); + true } #[cfg(not(target_arch = "wasm32"))] { let (m, cv) = &*self.queue; - let Ok(mut q) = m.lock() else { return }; - if q.jobs.len() >= QUEUE_LIMIT { + let Ok(mut q) = m.lock() else { return false }; + let full = match kind { + Kind::Frame => q.frame_bytes.saturating_add(payload.len()) > FRAME_QUEUE_BYTES, + _ => q.jobs.len() >= QUEUE_LIMIT, + }; + if full { self.dropped.fetch_add(1, Ordering::Relaxed); - return; + return false; + } + if kind == Kind::Frame { + q.pending_frames.insert(key); + q.frame_bytes = q.frame_bytes.saturating_add(payload.len()); + q.frames.push_back(Job::Write(kind, key, payload)); + } else { + q.jobs.push_back(Job::Write(kind, key, payload)); } - q.jobs.push_back(Job::Write(kind, key, payload)); cv.notify_all(); + true } } @@ -595,12 +651,13 @@ impl DiskCache { decode_frame(&p) } - /// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes). - pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) { - if self.contains(Kind::Frame, key) || rgba.len() != width as usize * height as usize * 4 { - return; + /// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes). `true` + /// when it is on disk or queued; `false` when refused (wrong size, a full queue). + pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) -> bool { + if rgba.len() != width as usize * height as usize * 4 { + return false; } - self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba)); + self.contains(Kind::Frame, key) || self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba)) } pub fn get_layer(&self, key: u128) -> Option { @@ -613,7 +670,7 @@ impl DiskCache { if self.contains(Kind::Layer, key) { return; } - self.enqueue(Kind::Layer, key, encode_layer(buf)); + let _ = self.enqueue(Kind::Layer, key, encode_layer(buf)); } /// Write a layer buffer synchronously (tests). @@ -634,7 +691,39 @@ impl DiskCache { if self.contains(Kind::Matte, k) { return; } - self.enqueue(Kind::Matte, k, lz4_flex::compress_prepend_size(&seg.to_bytes())); + let _ = self.enqueue(Kind::Matte, k, lz4_flex::compress_prepend_size(&seg.to_bytes())); + } + + /// Make the frame at key `from` the frame at key `to` too (an edit that can't change it gave + /// its comp a new content key): a hard link to the same file, or a copy where the file system + /// has no links. `false` when `from` isn't cached, `to` already is, or the file system + /// refused. (Entries don't contain their key, so the same file serves both.) + pub fn link_frame(&self, from: u128, to: u128) -> bool { + if !self.contains(Kind::Frame, from) || self.contains(Kind::Frame, to) { + return false; + } + let (src, dst) = (self.path(Kind::Frame, from), self.path(Kind::Frame, to)); + let Some(parent) = dst.parent() else { return false }; + let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed); + let tmp = parent.join(format!("{to:032x}.tmp.{}.{n}", std::process::id())); + let linked = std::fs::create_dir_all(parent) + .and_then(|()| std::fs::hard_link(&src, &tmp).or_else(|_| std::fs::copy(&src, &tmp).map(|_| ()))) + .and_then(|()| std::fs::metadata(&tmp)) + .and_then(|m| std::fs::rename(&tmp, &dst).map(|()| m.len())); + let bytes = match linked { + Ok(bytes) => bytes, + Err(_) => { + // (gone meanwhile: another process evicted it) + let _ = std::fs::remove_file(&tmp); + return false; + } + }; + // (a link shares its file, but counts in full: the cache only ever errs small) + self.evict_to(self.limit_bytes().saturating_sub(bytes)); + if let Ok(mut g) = self.index.lock() { + g.insert(Kind::Frame, to, bytes); + } + true } /// Write a frame synchronously (tests). @@ -659,7 +748,7 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { let job = { let Ok(mut g) = m.lock() else { return }; loop { - if let Some(j) = g.jobs.pop_front() { + if let Some(j) = g.pop() { g.busy = true; break j; } @@ -673,12 +762,14 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { } }; let Some(cache) = dc.upgrade() else { return }; - match job { - Job::Write(kind, key, payload) => cache.write_now(kind, key, &payload), - } + let Job::Write(kind, key, payload) = job; + cache.write_now(kind, key, &payload); drop(cache); if let Ok(mut g) = m.lock() { g.busy = false; + if kind == Kind::Frame { + g.pending_frames.remove(&key); + } } cv.notify_all(); } @@ -841,7 +932,7 @@ fn decode_layer(p: &[u8]) -> Option { // ---------------------------------------------------------------- content keys /// Bump when rendering changes in a way that makes old disk entries wrong. -const RENDER_VERSION: &str = "effectcraft-render-5"; +const RENDER_VERSION: &str = "effectcraft-render-6"; fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(format!("{v:?}").as_bytes()); @@ -889,8 +980,26 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option>) -> u64 { let mut h = Hash128::default(); h.write(RENDER_VERSION.as_bytes()); + // Footage another layer reads (Audio Spectrum's Audio Layer); a read precomp can hold any. + let mut read = std::collections::BTreeSet::new(); + let mut read_comp = false; + for (_, c) in project.comps() { + for l in c.layers.iter() { + for r in l.referenced_layers().into_iter().filter(|r| *r != l.id) { + match c.layer(r).map(|x| &x.source) { + Some(effectcraft_project::LayerSource::Footage { item }) => _ = read.insert(*item), + Some(effectcraft_project::LayerSource::Comp { .. }) => read_comp = true, + _ => {} + } + } + } + } for (id, it) in &project.items { - if let effectcraft_project::ItemKind::Footage(f) = &it.kind { + // (files without video make layer buffers only through a layer that reads them: + // importing music keeps the keys) + if let effectcraft_project::ItemKind::Footage(f) = &it.kind + && (f.has_video || read_comp || read.contains(id)) + { h.write_u64(id.0); hash_debug(&mut h, f); footage_stamp(&mut h, f, virtual_id); @@ -924,34 +1033,46 @@ fn comp_content_key_for_renderer( let mut h = Hash128::default(); h.write(renderer.as_bytes()); hash_debug(&mut h, &project.settings); + // Expressions can read anything (every item counts); without them, layers that can't + // change the picture (music, hidden layers nothing reads) and their files don't count. + let expressions = project.reads_markers(); + let heard = project.heard_layers(); + let silent = |id, c: &effectcraft_project::Comp| if expressions { Default::default() } else { project.silent_layers(id, c, &heard) }; let mut seen = std::collections::BTreeSet::new(); let mut stack = vec![comp]; - let mut expressions = false; while let Some(id) = stack.pop() { if !seen.insert(id) { continue; } let Some(it) = project.item(id) else { continue }; if let ItemKind::Comp(c) = &it.kind { - for l in &c.layers { + let quiet = silent(id, c); + for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) { if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { stack.push(item); } - if !expressions { - l.props.walk("", &mut |_, p| expressions |= p.expr.is_some()); - } } } } // Markers are pixels only to the expressions that read them (#1083). - let markers = project.reads_markers(); + let markers = expressions; let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; for id in ids { let Some(it) = project.item(id) else { continue }; h.write_u64(id.0); h.write(it.name.as_bytes()); match &it.kind { - ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form_with(markers)), + ItemKind::Comp(c) => { + let quiet = silent(id, c); + let heard = |l| heard.contains(id, l); + if quiet.is_empty() { + hash_debug(&mut h, &c.pixel_form_with(markers, &heard)); + } else { + let mut pic = (**c).clone(); + pic.layers.retain(|l| !quiet.contains(&l.id)); + hash_debug(&mut h, &pic.pixel_form_with(markers, &heard)); + } + } k => hash_debug(&mut h, k), } if let ItemKind::Footage(f) = &it.kind { @@ -986,6 +1107,26 @@ mod tests { d } + /// A linked frame reads back as the original under its new key, survives the original's + /// eviction, and is never linked over an existing entry or from a missing one. + #[test] + fn link_frame_serves_a_frame_under_a_new_key() { + let dc = DiskCache::open(tmpdir("link"), 1 << 30).unwrap(); + let rgba: Vec = (0..16).collect(); + dc.put_frame_now(1, 2, 2, &rgba); + assert!(dc.link_frame(1, 2)); + assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone())); + assert!(!dc.link_frame(1, 2), "already there"); + assert!(!dc.link_frame(3, 4), "nothing to link"); + assert!(!dc.contains(Kind::Frame, 4)); + dc.put_frame_now(5, 2, 2, &[7; 16]); + assert!(!dc.link_frame(5, 2), "an existing entry stays"); + assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba.clone())); + let _ = std::fs::remove_file(dc.path(Kind::Frame, 1)); + dc.forget(Kind::Frame, 1); + assert_eq!(dc.get_frame(2).map(|f| f.rgba), Some(rgba), "the link outlives the original"); + } + fn buf(seed: u32, w: u32, h: u32) -> Buf { let mut img = Image::new(w, h); for (i, p) in img.data.iter_mut().enumerate() { @@ -1073,7 +1214,8 @@ mod tests { assert_eq!(back.img, b.img); assert_eq!((back.offset, back.scale), (b.offset, b.scale)); let rgba: Vec = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect(); - dc.put_frame(7, 20, 10, &rgba); + assert!(dc.put_frame(7, 20, 10, &rgba)); + assert!(!dc.put_frame(8, 2, 2, &[0; 3]), "a wrong-size frame is refused"); dc.flush(); assert_eq!(dc.get_frame(7).unwrap().rgba, rgba); let st = dc.stats(); @@ -1082,6 +1224,51 @@ mod tests { assert!(dc.get_frame(42).is_none()); } + /// Music doesn't change a comp's disk keys (adding it to the timeline kept no blue frame); + /// hiding a clip does. Importing music keeps the layer-cache salt. + #[test] + fn music_keeps_the_disk_keys() { + use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, Solid, build}; + use effectcraft_time::{FrameRate, Tick}; + let mut p = Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Default::default(), None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 })); + let clip = build::layer(&mut p, &comp, "Clip", LayerSource::Solid { item: sid }, (64, 64), None); + let clip_id = clip.id; + p.comp_mut(cid).unwrap().layers.push(clip); + let (key, salt) = (comp_content_key(&p, cid), footage_salt(&p)); + let song = p.add_item( + "song.wav", + Default::default(), + None, + ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }), + ); + assert_eq!(footage_salt(&p), salt, "importing music"); + let music = build::layer(&mut p, &comp, "Music", LayerSource::Footage { item: song }, (64, 64), None); + p.comp_mut(cid).unwrap().layers.insert(0, music); + assert_eq!(comp_content_key(&p, cid), key, "music on the timeline"); + p.comp_mut(cid).unwrap().layer_mut(clip_id).unwrap().switches.video = false; + assert_ne!(comp_content_key(&p, cid), key, "a hidden clip"); + } + + /// A burst of layer writes never crowds out frames: every frame of a fully cached comp is + /// on disk afterwards (the blue bar came back with gaps). + #[test] + fn frames_are_kept_when_layer_writes_fill_the_queue() { + let dir = tmpdir("burst"); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + for k in 0..300 { + dc.put_layer(10_000 + k, &buf(1, 64, 64)); + } + let rgba = vec![7u8; 32 * 32 * 4]; + for k in 0..300 { + dc.put_frame(k, 32, 32, &rgba); + } + dc.flush(); + assert!((0..300).all(|k| dc.contains(Kind::Frame, k)), "every frame written"); + } + #[test] fn previous_renderer_frames_are_not_reused() { use effectcraft_project::{Comp, ItemKind, Project}; @@ -1091,7 +1278,7 @@ mod tests { let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); let key = frame_key(comp_content_key(&project, id), 0, 1000, 0); - let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-4"), 0, 1000, 0); + let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-5"), 0, 1000, 0); let dir = tmpdir("previous-renderer"); { let cache = DiskCache::open(&dir, 1 << 30).unwrap(); @@ -1267,6 +1454,32 @@ mod tests { } } + /// An audio file nothing reads stays out of the layer salt; one a layer reads (Audio + /// Spectrum) is in it, so overwriting it doesn't reuse that layer's buffers. + #[test] + fn read_audio_counts_in_the_footage_salt() { + use effectcraft_project::build; + use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, MatteKind, Project, TrackMatte}; + use effectcraft_time::{FrameRate, Tick}; + + let mut p = Project::default(); + let f = Footage { path: "/song.wav".into(), kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }; + let song = p.add_item("song.wav", effectcraft_color::Label::None, None, ItemKind::Footage(f)); + let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let music = build::layer(&mut p, &comp, "music", LayerSource::Footage { item: song }, (16, 9), None); + let mut reader = build::layer(&mut p, &comp, "reader", LayerSource::Null, (16, 9), None); + let music_id = music.id; + comp.layers = vec![reader.clone(), music].into(); + let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + + let id = |mark: u64| move |path: &str| (path == "/song.wav").then_some((8, mark)); + let (one, two) = (id(1), id(2)); + assert_eq!(footage_salt_with(&p, Some(&one)), footage_salt_with(&p, Some(&two)), "unread music stays out"); + reader.track_matte = Some(TrackMatte { layer: music_id, kind: MatteKind::Alpha }); + *p.comp_mut(cid).unwrap().layers.first_mut().unwrap() = reader; + assert_ne!(footage_salt_with(&p, Some(&one)), footage_salt_with(&p, Some(&two)), "read audio counts"); + } + /// A path with no native metadata takes its key from the caller's size and content hash (#1253). #[test] fn virtual_file_bytes_change_content_keys() { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index e150b2f2f..59d437b26 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -48,6 +48,7 @@ egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } pollster = "1" effectcraft-segment = { workspace = true } effectcraft-host = { workspace = true } +effectcraft-media = { workspace = true } exr = { workspace = true } [build-dependencies] diff --git a/crates/ui-egui/examples/cache_bench.rs b/crates/ui-egui/examples/cache_bench.rs new file mode 100644 index 000000000..828075249 --- /dev/null +++ b/crates/ui-egui/examples/cache_bench.rs @@ -0,0 +1,177 @@ +//! Background caching benchmark: how fast the RAM preview fills a comp, and where the time goes. +//! +//! `cargo run --release -p effectcraft-ui-egui --example cache_bench -- [options]` +//! +//! Without `--project` it builds a comp like a typical edit: eight 1080p30 H.264 clips one after +//! another (encoded once into `target/cache-bench/media` with the Render Queue, no committed +//! media) under an adjustment layer with Gaussian Blur. It then fills the comp's RAM preview the +//! way idle caching does, through [`Frames`], and prints frames per second with the decoder, +//! footage-cache and disk-cache counters. +//! +//! Options: `--project P --comp C` (a saved project's comp instead), `--scale S` (render scale, +//! default 0.5: a viewer at half size), `--frames N` (frames to cache, default the whole comp), +//! `--slots N` (frames in flight, at least 1, default one per frame thread as the app does when +//! idle), +//! `--disk` (with a fresh disk cache in `target/cache-bench/disk`), `--no-adjustment`. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use effectcraft_engine::Session; +use effectcraft_engine::project::ItemId; +use effectcraft_engine::render::RenderOpts; +use effectcraft_ui_egui::frames::{FrameKey, Frames, RenderSource, opts_hash}; +use serde_json::{Value, json}; + +fn arg(name: &str) -> Option { + let a: Vec = std::env::args().collect(); + a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned()) +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// Eight copies of one generated clip (separate files: separate decoders, as with real clips). +fn clips(dir: &std::path::Path) -> Vec { + let media = dir.join("media"); + std::fs::create_dir_all(&media).expect("media folder"); + let first = media.join("clip0.mp4"); + if !first.exists() { + eprintln!("encoding the source clip (once)…"); + let mut s = effectcraft_host::session(); + run(&mut s, "comp.new", json!({"name": "Src", "width": 1920, "height": 1080, "frameRate": 30, "duration": 4.25})); + let l = run(&mut s, "layer.newSolid", json!({"color": "#808080"}))["layer"].clone(); + run(&mut s, "effect.apply", json!({"layers": [l], "effect": "Fractal Noise"})); + run(&mut s, "prop.addKey", json!({"layer": l, "path": "effects/#1/evolution", "time": 0.0, "value": 0.0})); + run(&mut s, "prop.addKey", json!({"layer": l, "path": "effects/#1/evolution", "time": 4.25, "value": 720.0})); + run(&mut s, "renderQueue.add", json!({"comp": "Src", "format": "h264", "output": first.to_string_lossy()})); + run(&mut s, "renderQueue.render", json!({"wait": true})); + } + (0..8) + .map(|i| { + let p = media.join(format!("clip{i}.mp4")); + if !p.exists() { + std::fs::copy(&first, &p).expect("copy clip"); + } + p.to_string_lossy().to_string() + }) + .collect() +} + +fn main() { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/cache-bench"); + let mut s = effectcraft_host::session(); + let pool = Arc::new(effectcraft_media::MediaPool::new()); + s.footage = pool.clone(); + if std::env::args().any(|a| a == "--disk") { + let disk = dir.join("disk"); + if let Err(e) = std::fs::remove_dir_all(&disk) + && e.kind() != std::io::ErrorKind::NotFound + { + eprintln!("can't clear the disk cache at {}: {e}", disk.display()); + std::process::exit(2); + } + s.prefs.disk.disk_cache_enabled = true; + s.prefs.disk.disk_cache_folder = disk.to_string_lossy().to_string(); + s.configure_disk_cache(); + } + let cid = match arg("--project") { + Some(p) => { + run(&mut s, "file.open", json!({"path": p})); + let comp = match arg("--comp") { + Some(c) => s.project.comps().find(|(id, _)| s.project.item(**id).is_some_and(|it| it.name == c) || id.0.to_string() == c).map(|(id, _)| *id), + None => s.active_comp_id(), + }; + comp.unwrap_or_else(|| { + eprintln!("no such composition: {}", arg("--comp").unwrap_or_else(|| "(no active comp; pass --comp)".into())); + std::process::exit(2) + }) + } + None => { + let paths = clips(&dir); + let cid = ItemId( + run(&mut s, "comp.new", json!({"name": "Edit", "width": 1920, "height": 1080, "frameRate": 30, "duration": 34}))["comp"] + .as_u64() + .expect("comp"), + ); + let items = run(&mut s, "file.import", json!({"paths": paths}))["items"].clone(); + for (i, item) in items.as_array().expect("items").iter().enumerate() { + let l = run(&mut s, "layer.addItem", json!({"item": item}))["layer"].as_u64().expect("layer"); + let c = Arc::make_mut(&mut s.project).comp_mut(cid).expect("comp"); + let fr = c.frame_rate; + let l = c.layer_mut(effectcraft_engine::project::LayerId(l)).expect("layer"); + let at = fr.tick_of(i as i64 * 1275 / 10); + (l.start_time, l.in_point, l.out_point) = (at, at, fr.tick_of((i as i64 + 1) * 1275 / 10)); + } + if !std::env::args().any(|a| a == "--no-adjustment") { + let adj = run(&mut s, "layer.newAdjustment", json!({}))["layer"].clone(); + run(&mut s, "effect.apply", json!({"layers": [adj], "effect": "Gaussian Blur"})); + run(&mut s, "prop.set", json!({"layer": adj, "path": "effects/#1/blurriness", "value": 4.0})); + } + cid + } + }; + let comp = s.project.comp(cid).expect("comp").clone(); + let total = comp.frame_rate.frame_at(comp.duration); + let n = arg("--frames").and_then(|v| v.parse().ok()).unwrap_or(total).min(total); + let scale: f64 = arg("--scale").and_then(|v| v.parse().ok()).unwrap_or(0.5); + + let frames = Frames::default(); + // (as Cache Frames When Idle: one frame per frame thread, see `app/prefetch.rs`) + let slots: usize = arg("--slots").and_then(|v| v.parse().ok()).map(|n: usize| n.max(1)).unwrap_or(frames.parallelism().max(1)); + // Room for every frame: this measures speed, not eviction. + frames.set_budget(64 << 30); + let opts = RenderOpts { scale, motion_blur: true, ..Default::default() }; + let src = RenderSource { + project: s.project.clone(), + footage: s.footage.clone(), + expr: s.expr.clone(), + layer_cache: s.layer_cache.clone(), + gpu: None, + gpu_display: false, + disk: s.disk_cache.clone(), + }; + let series = FrameKey { + revision: 1, + content: frames.content_of(&src.project, 1, cid), + comp: cid.0, + frame: 0, + scale: (scale * 1000.0).round() as u32, + view: 0, + opts: opts_hash(&opts), + }; + + eprintln!("caching {n} frames of {}×{} at scale {scale} with {slots} in flight…", comp.width, comp.height); + let (gop0, t0) = (effectcraft_media::gop_stats(), Instant::now()); + let mut next = 0; + loop { + while next < n && frames.inflight() < slots { + frames.request_background(&src, FrameKey { frame: next, ..series }, cid, comp.frame_rate.tick_of(next), opts); + next += 1; + } + if next >= n && frames.inflight() == 0 { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + let secs = t0.elapsed().as_secs_f64(); + let gop = effectcraft_media::gop_stats(); + let cached = frames.cached_frames(&series).len(); + println!("frames cached {cached}/{n} in {secs:.2} s: {:.1} frames/s, {:.1} ms each", cached as f64 / secs, secs * 1000.0 / cached.max(1) as f64); + println!( + "video decoding {:.2} s in decoders, {} samples decoded, {} seeks, {} cache hits", + (gop.decode_ns - gop0.decode_ns) as f64 / 1e9, + gop.decoded - gop0.decoded, + gop.seeks - gop0.seeks, + gop.hits - gop0.hits + ); + let p = pool.stats(); + println!("footage cache {} hits, {} misses, {} frames ({} MB) kept", p.hits, p.misses, p.frames, p.bytes >> 20); + if let Some(dc) = &s.disk_cache { + // (writes finish on the disk cache's own thread) + dc.flush(); + let d = dc.stats(); + println!("disk cache {} written, {} dropped, {} entries ({} MB)", d.writes, d.dropped, d.entries, d.bytes >> 20); + } +} diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 54bf07250..f00d44014 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -399,6 +399,12 @@ impl EffectcraftApp { ctx.request_repaint_after(std::time::Duration::from_secs_f64(1.0 - quiet)); return; } + // Frames an edit kept in RAM are on disk under the comp's previous content only. + // Again soon while frames wait to be written (a refused write is retried then). + #[cfg(not(target_arch = "wasm32"))] + if self.frames.persist_missing(&self.render_source(), series, &self.frame_opts(ItemId(series.comp), series.scale as f64 / 1000.0)) { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } // An empty work area has nothing to cache. let n = wb - wa + 1; if n < 1 { diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index f87db615c..98a78ff75 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -105,6 +105,15 @@ impl EffectcraftApp { self.frame_series(comp, scale as f64 / 1000.0) } + /// The frame series a preview with options `o` plays: at its Resolution, or the one the + /// viewer shows with Auto (it follows the zoom). The Preview panel's cache bar counts it. + pub fn preview_series(&self, comp: ItemId, o: &effectcraft_engine::preview::PreviewPreset) -> FrameKey { + match o.resolution.factor(o.custom_resolution) { + Some(n) => self.frame_series(comp, 1.0 / f64::from(n)), + None => self.shown_series(comp), + } + } + /// Hash of the comp viewer's 3D view camera (0 for the active camera view). pub fn view_hash(&self, comp: ItemId) -> u64 { use std::hash::{Hash, Hasher}; diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 9aad2a81c..788af49e8 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -28,7 +28,7 @@ use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; +use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; @@ -337,12 +337,26 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64, /// 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 { - comp_content_with(project, comp, project, |_, c| c) + comp_content_with(project, comp, project, |_, c| c, true) +} + +/// Frame ranges (inclusive) of `comp` in `new` that may draw differently than in `old` +/// ([`Project::dirty_spans`]), a frame wider either side for rounding. +fn dirty_frames(old: &Project, new: &Project, comp: ItemId, heard: &Heard, outside: bool) -> Option> { + let fr = new.comp(comp)?.frame_rate; + Some(new.dirty_spans(old, comp, heard, outside)?.into_iter().map(|(s, e)| (fr.frame_at(s).saturating_sub(1), fr.frame_at(e).saturating_add(1))).collect()) } /// [`comp_content`] with each comp's version selected for both its sources and its address. -/// Sources removed by undo are read from `base`. -fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, version: impl Fn(ItemId, &'a Arc) -> &'a Arc) -> u64 { +/// Sources removed by undo are read from `base`. Only the comps and items it draws count unless +/// `everything` and an expression (which can read any item) is among them. +fn comp_content_with<'a>( + project: &'a Project, + comp: ItemId, + base: &'a Project, + version: impl Fn(ItemId, &'a Arc) -> &'a Arc, + everything: bool, +) -> u64 { use std::hash::{Hash, Hasher}; fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { @@ -366,7 +380,7 @@ fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { stack.push(item); } - expressions = expressions || has_expression(&l.props); + expressions = expressions || (everything && has_expression(&l.props)); } } let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; @@ -505,8 +519,41 @@ pub struct Frames { /// Where frames render instead of the UI thread (wasm32), and how many it is rendering. remote: Option>, remote_busy: Arc>, + /// Disk keys [`Frames::persist_missing`] is converting (the disk cache tracks them once + /// queued, so an evicted or refused frame is written again). + #[cfg(not(target_arch = "wasm32"))] + persisted: Arc>>, + /// The disk limit and how often each disk key was written under it: a frame is rewritten + /// once after an eviction, not again (a disk cache smaller than the RAM preview, or a frame + /// that can't fit, would otherwise be rewritten on every idle pass). A new limit resets it. + #[cfg(not(target_arch = "wasm32"))] + written: Arc)>>, + /// Edits whose kept frames are still to be found on disk under the new content key + /// ([`Frames::carry_disk`]), oldest first. + #[cfg(not(target_arch = "wasm32"))] + disk_carries: Arc>>, + /// A carry job is scheduled or running: at most one at a time, draining the edits in order. + #[cfg(not(target_arch = "wasm32"))] + carry_scheduled: Arc, + /// The disk series (scale, view and options hash) each comp's frames are cached in. + #[cfg(not(target_arch = "wasm32"))] + disk_series: Arc>>>, } +/// An edit's kept frames, to find on disk under the comp's new content key: those of `comp` in +/// `from` outside `dirty` (inclusive frame ranges) also draw `to`'s. +#[cfg(not(target_arch = "wasm32"))] +struct DiskCarry { + comp: u64, + from: Arc, + to: Arc, + dirty: Vec<(i64, i64)>, +} + +/// Disk series remembered per comp (see [`Frames::disk_series`]). +#[cfg(not(target_arch = "wasm32"))] +const MAX_DISK_SERIES: usize = 8; + impl Default for Frames { fn default() -> Self { #[cfg(not(target_arch = "wasm32"))] @@ -554,6 +601,16 @@ impl Default for Frames { keepers: Mutex::default(), remote: None, remote_busy: Arc::default(), + #[cfg(not(target_arch = "wasm32"))] + persisted: Arc::default(), + #[cfg(not(target_arch = "wasm32"))] + written: Arc::default(), + #[cfg(not(target_arch = "wasm32"))] + disk_carries: Arc::default(), + #[cfg(not(target_arch = "wasm32"))] + carry_scheduled: Arc::default(), + #[cfg(not(target_arch = "wasm32"))] + disk_series: Arc::default(), } } } @@ -789,29 +846,51 @@ impl Frames { let Ok(mut bases) = self.bases.lock() else { return (plain, None) }; // Markers are pixels only to the expressions that read them (#1083). let markers = project.reads_markers(); + // Whose audio is drawn, before or after the edit (a waveform added or removed counts). + let heard = bases.get(&comp.0).map_or_else(|| project.heard_layers(), |(_, base)| project.heard_layers().union(&base.heard_layers())); if let Some((base_id, base)) = bases.get(&comp.0) && *base_id != plain - && comp_content_with(project, comp, project, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b, - _ => c, - }) == *base_id + && comp_content_with( + project, + comp, + project, + |id, c| match base.item(id).map(|i| &i.kind) { + Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers, &|l| heard.contains(id, l)) => b, + _ => c, + }, + true, + ) == *base_id { return (*base_id, Some(base.clone())); } - // Only layers of this comp changed: its frames outside their span (and a frame either - // side for rounding) keep drawing the same pixels, so a cut or trim recaches only the - // layers it touched (#1641). + // Only comps changed (not the footage, solids or settings they draw): its frames outside + // the spans the edit reaches (and a frame either side for rounding) keep drawing the same + // pixels, so a nudge, cut or trim, here or in a precomp, recaches only the frames it + // touched (#1641). Expressions can read anything at any time, so with them a change + // elsewhere in the project (another comp, an item's name) reaches the frames of the + // layers that have one. + let base_comp = |id: ItemId| bases.get(&comp.0).and_then(|(_, base)| base.item(id)).map(|i| &i.kind); + let base_tree = |id, c| match base_comp(id) { + Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers, &|l| heard.contains(id, l)) => b, + _ => c, + }; + let base_comps = |id, c| match base_comp(id) { + Some(ItemKind::Comp(b)) => b, + _ => c, + }; if let Some((base_id, base)) = bases.get(&comp.0) - && let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp)) - && comp_content_with(project, comp, base, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b, - _ => c, - }) == *base_id - && let Some((s, e)) = new.changed_span(old) + && comp_content_with(project, comp, base, base_comps, false) == comp_content_with(base, comp, base, |_, c| c, false) + && let Some(dirty) = dirty_frames(base, project, comp, &heard, comp_content_with(project, comp, base, base_tree, true) != *base_id) && let Ok(mut cache) = self.cache.lock() { - let (first, last) = (new.frame_rate.frame_at(s).saturating_sub(1), new.frame_rate.frame_at(e).saturating_add(1)); - cache.carry(*base_id, plain, |f| f < first || f > last); + cache.carry(*base_id, plain, |f| !dirty.iter().any(|(lo, hi)| (*lo..=*hi).contains(&f))); + #[cfg(not(target_arch = "wasm32"))] + if let Ok(mut q) = self.disk_carries.lock() { + if q.len() >= MAX_KEEPERS { + q.pop_front(); + } + q.push_back(DiskCarry { comp: comp.0, from: base.clone(), to: project.clone(), dirty }); + } } if bases.len() > MAX_KEEPERS { bases.clear(); @@ -857,6 +936,11 @@ impl Frames { } let epoch = self.virtual_epoch.load(Ordering::Relaxed); #[cfg(not(target_arch = "wasm32"))] + { + self.note_disk_series(base.comp, base.scale, base.view ^ opts_hash(opts)); + self.carry_disk(src); + } + #[cfg(not(target_arch = "wasm32"))] let content = { let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied(); let Some(content) = known else { @@ -872,6 +956,145 @@ impl Frames { (start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect() } + /// Write the RAM frames of `base`'s series that the disk cache lacks: frames an edit kept + /// (written under the comp's previous content key) and writes the disk cache dropped. They + /// are converted on the frame pool, each queued once. `true` while there may be more to + /// write (a content key being computed, frames being converted): call again soon, since a + /// refused write is only retried by a later call. Disk keys hash `opts` the way the worker + /// writes them (`base.opts` also carries the Roto Brush model stamp). + #[cfg(not(target_arch = "wasm32"))] + pub fn persist_missing(&self, src: &RenderSource, base: &FrameKey, opts: &RenderOpts) -> bool { + let Some(dc) = src.disk.clone() else { return false }; + let oh = opts_hash(opts); + self.note_disk_series(base.comp, base.scale, base.view ^ oh); + self.carry_disk(src); + let epoch = self.virtual_epoch.load(Ordering::Relaxed); + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied(); + let Some(content) = known else { + self.request_content_key(src.project.clone(), base.revision, base.comp); + return true; + }; + let (missing, converting): (Vec<(u128, Arc)>, bool) = { + let (Ok(c), Ok(mut queued), Ok(mut written)) = (self.cache.lock(), self.persisted.lock(), self.written.lock()) else { return false }; + if queued.len() > 100_000 { + queued.clear(); + } + let limit = dc.limit_bytes(); + if written.0 != limit || written.1.len() > 100_000 { + *written = (limit, HashMap::new()); + } + let missing = c + .map + .iter() + .filter(|(k, _)| k.same_series(base)) + .filter_map(|(k, img)| { + let FrameImage::Cpu(img) = img else { return None }; + let dk = disk_cache::frame_key(content, k.frame, k.scale, k.view ^ oh); + let tries = written.1.get(&dk).copied().unwrap_or(0); + (tries < 2 && !dc.contains(disk_cache::Kind::Frame, dk) && !dc.frame_pending(dk) && queued.insert(dk)).then(|| (dk, img.clone())) + }) + .collect(); + (missing, !queued.is_empty()) + }; + if missing.is_empty() { + return converting; + } + let (persisted, written) = (self.persisted.clone(), self.written.clone()); + let work = move || { + for (dk, img) in missing { + let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); + // (accepted: the disk cache tracks it until written, and it counts as a try; + // refused by a full queue: retried, without counting) + if dc.put_frame(dk, img.size[0] as u32, img.size[1] as u32, &rgba) + && let Ok(mut w) = written.lock() + { + let n = w.1.entry(dk).or_default(); + *n = n.saturating_add(1); + } + if let Ok(mut q) = persisted.lock() { + q.remove(&dk); + } + } + }; + match &self.pool { + Some(pool) => pool.spawn(work), + None => rayon::spawn(work), + } + true + } + + /// Remember that `comp`'s frames are cached on disk at `scale` with view and options hash + /// `view` (the most recent [`MAX_DISK_SERIES`]). + #[cfg(not(target_arch = "wasm32"))] + fn note_disk_series(&self, comp: u64, scale: u32, view: u64) { + if let Ok(mut m) = self.disk_series.lock() { + let v = m.entry(comp).or_default(); + if v.first() != Some(&(scale, view)) { + v.retain(|s| *s != (scale, view)); + v.insert(0, (scale, view)); + v.truncate(MAX_DISK_SERIES); + } + } + } + + /// Find the frames edits kept ([`Frames::identity`]) on disk under their comps' new content + /// keys: link each kept frame of every disk series of the comp to its new key, on the frame + /// pool, oldest edit first. + #[cfg(not(target_arch = "wasm32"))] + fn carry_disk(&self, src: &RenderSource) { + let Some(dc) = src.disk.clone() else { return }; + let pending = |q: &Mutex>| q.lock().is_ok_and(|q| !q.is_empty()); + // (called on every paint: one job drains every carry queued meanwhile) + if !pending(&self.disk_carries) || self.carry_scheduled.swap(true, Ordering::AcqRel) { + return; + } + let (carries, scheduled, series, ctx) = (self.disk_carries.clone(), self.carry_scheduled.clone(), self.disk_series.clone(), self.ctx.clone()); + let virtual_id = self.virtual_id.clone(); + let work = move || { + let mut linked = false; + loop { + let todo: Vec = carries.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default(); + if todo.is_empty() { + scheduled.store(false, Ordering::Release); + // (a carry queued after the drain and before the store: keep it, unless a new job took it) + if pending(&carries) && !scheduled.swap(true, Ordering::AcqRel) { + continue; + } + break; + } + for c in todo { + // (a panicking carry is skipped: the others still carry, and the flag is + // still cleared below) + let carry = || { + let Some(series) = series.lock().ok().and_then(|m| m.get(&c.comp).cloned()) else { return false }; + let comp = ItemId(c.comp); + let n = c.to.comp(comp).map_or(0, |k| k.frame_rate.frame_at(k.duration)).clamp(0, 100_000); + let key = |p: &Project| disk_cache::comp_content_key_with(p, comp, virtual_id.as_deref()); + let (a, b) = (key(&c.from), key(&c.to)); + if a == b { + return false; + } + let mut linked = false; + for f in (0..n).filter(|f| !c.dirty.iter().any(|(lo, hi)| (*lo..=*hi).contains(f))) { + for (scale, view) in &series { + linked |= dc.link_frame(disk_cache::frame_key(a, f, *scale, *view), disk_cache::frame_key(b, f, *scale, *view)); + } + } + linked + }; + linked |= std::panic::catch_unwind(std::panic::AssertUnwindSafe(carry)).unwrap_or(false); + } + } + if linked && let Some(ctx) = &ctx { + ctx.request_repaint(); + } + }; + match &self.pool { + Some(pool) => pool.spawn(work), + None => rayon::spawn(work), + } + } + #[cfg(not(target_arch = "wasm32"))] fn request_content_key(&self, project: Arc, revision: u64, comp: u64) { let mut jobs = self.content_key_jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index ace51b0bb..4099aa4c6 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -908,8 +908,8 @@ fn audio_lock_and_shy_switches_keep_the_frames() { assert_ne!(at(&s), o1); } -/// A cut or trim recaches only the frames of the layers it touched (#1641), and none are -/// kept when the change reaches other layers (here a child of the trimmed layer). +/// A cut or trim recaches only the frames of the layers it touched (#1641), and those of the +/// layers the change reaches (here a child of the trimmed layer). #[test] fn an_edit_keeps_the_frames_outside_the_layers_it_changed() { use effectcraft_engine::Session; @@ -935,14 +935,18 @@ fn an_edit_keeps_the_frames_outside_the_layers_it_changed() { s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap(); let after = f.content_of(&s.project, s.revision, comp); assert_ne!(after, before); - let kept: Vec = (fr.frame_at(out) + 2..frames.len() as i64).collect(); - assert_eq!(cached(after), kept, "the cut layer's frames (and one either side) render again; the rest stay"); + let kept: Vec = (0..fr.frame_at(Tick::from_seconds_f64(2.0)) - 1).chain(fr.frame_at(out) + 2..frames.len() as i64).collect(); + assert_eq!(cached(after), kept, "the copy's half (and one frame either side) renders again; the rest stay"); s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap(); let parented = cache_all(&s); s.execute("layer.timing", json!({"layers": [a], "out": 1})).unwrap(); - assert_eq!(cached(f.content_of(&s.project, s.revision, comp)), Vec::::new(), "the trimmed layer's child moves everywhere"); - assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo"); + // The cut (1–2 s) and the trimmed layer's child (4–10 s) render again, a frame either side; + // the split's copy (2–4 s) stays. + assert_eq!(cached(f.content_of(&s.project, s.revision, comp)), (0..=27).chain(62..=117).collect::>(), "the trimmed layer's child moves where it is"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(f.content_of(&s.project, s.revision, comp), parented); + assert_eq!(cached(parented).len(), frames.len(), "undo finds every earlier frame"); } /// Every frame an edit keeps renders as a fresh render of the edited project does: Solo @@ -981,6 +985,16 @@ fn the_frames_an_edit_keeps_match_a_fresh_render() { assert_eq!(kept(&mut s, comp, solo(true)), Vec::::new()); assert_eq!(kept(&mut s, comp, solo(false)), Vec::::new()); + // The work area is a preview and render range: setting it (B, N) keeps every frame, on + // disk too. + let n = s.project.comp(comp).map(|c| c.frame_rate.frame_at(c.duration)).unwrap(); + let disk = |s: &Session| effectcraft_engine::render::disk_cache::comp_content_key_with(&s.project, comp, None); + let d0 = disk(&s); + let work_area = |start: f64, end: f64| json!({"cmd": "comp.workArea", "params": {"comp": comp.0, "start": start, "end": end}}); + assert_eq!(kept(&mut s, comp, work_area(1.0, 4.0)), (0..n).collect::>()); + assert_eq!(kept(&mut s, comp, work_area(2.0, 3.0)), (0..n).collect::>()); + assert_eq!(disk(&s), d0, "the disk cache keeps them"); + // A 2–3 s camera over a 4 s motion-blurred 3D layer, shutter 720° at phase 360°: a frame // samples 1 to 3 frames ahead, so moving the camera reaches the 3 frames before it. let comp = ItemId(id( @@ -1113,3 +1127,1137 @@ fn timing_markers_and_expressions_still_change_the_frames() { s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.75})).unwrap(); assert_ne!(at(&s), o2, "an expression can read the marker, so the frames change"); } + +/// The oracle for [`Comp::changed_spans`]: every frame an edit keeps renders the same in the edited +/// project. Moves, trims, splits, deletes and duplicates of moving clips, under adjustment +/// layers, with seeded noise, motion blur, mattes, parents and effects that read other times. +#[test] +fn kept_frames_render_the_same() { + use effectcraft_engine::Session; + use effectcraft_engine::render::render_frame; + use serde_json::{Value as Json, json}; + fn run(s: &mut Session, cmd: &str, p: Json) -> Json { + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")) + } + /// Set a layer's start, in and out (frames). + fn timing(s: &mut Session, cid: ItemId, l: u64, start: i64, inp: i64, out: i64) { + let c = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); + let fr = c.frame_rate; + let l = c.layer_mut(effectcraft_engine::project::LayerId(l)).unwrap(); + (l.start_time, l.in_point, l.out_point) = (fr.tick_of(start), fr.tick_of(inp), fr.tick_of(out)); + } + /// A 10×10 solid moving across the comp. + fn solid(s: &mut Session, color: &str) -> u64 { + let l = run(s, "layer.newSolid", json!({"color": color, "width": 10, "height": 10}))["layer"].as_u64().unwrap(); + run(s, "prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [0.0, 4.0, 0.0]})); + run(s, "prop.addKey", json!({"layer": l, "path": "transform/position", "time": 1.5, "value": [16.0, 12.0, 0.0]})); + l + } + fn split(s: &mut Session, cid: ItemId, l: u64, at: i64) { + let fr = s.project.comp(cid).unwrap().frame_rate; + s.set_time(fr.tick_of(at)); + run(s, "edit.splitLayer", json!({"layers": [l]})); + } + fn adjustment(s: &mut Session, cid: ItemId, effect: &str) -> u64 { + let l = run(s, "layer.newAdjustment", json!({}))["layer"].as_u64().unwrap(); + run(s, "effect.apply", json!({"layers": [l], "effect": effect})); + timing(s, cid, l, 0, 10, 35); + l + } + /// Echo reaching 9 frames back, three times (past the shutter margin). + fn long_echo(s: &mut Session, l: u64) { + run(s, "effect.apply", json!({"layers": [l], "effect": "Echo"})); + run(s, "prop.set", json!({"layer": l, "path": "effects/#1/echoTime", "value": -0.3})); + run(s, "prop.set", json!({"layer": l, "path": "effects/#1/numberOfEchoes", "value": 3.0})); + } + // Clip a on frames 0–20, clip b on 20–40 of a 45-frame comp. + type Step = fn(&mut Session, ItemId, &mut Vec); + let none: Step = |_, _, _| {}; + /// An audio-only file (music) and a layer of it over the whole comp. + fn music(s: &mut Session, cid: ItemId) -> u64 { + use effectcraft_engine::project::{Footage, FootageKind}; + let song = Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }; + let song = Arc::make_mut(&mut s.project).add_item("song.wav", Default::default(), None, ItemKind::Footage(song)); + let l = run(s, "layer.addItem", json!({"item": song.0}))["layer"].as_u64().unwrap(); + timing(s, cid, l, 0, 0, 45); + l + } + /// What an edit keeps: every frame, some, or none. + #[derive(Debug, PartialEq)] + enum Keep { + All, + Part, + Nothing, + } + let scenarios: Vec<(&str, Step, Step, Keep)> = vec![ + ("nudge", none, |s, c, l| timing(s, c, l[1], 21, 21, 41), Keep::Part), + ("trim out", none, |s, c, l| timing(s, c, l[1], 20, 20, 32), Keep::Part), + ("trim in", none, |s, c, l| timing(s, c, l[1], 20, 28, 40), Keep::Part), + ("split", none, |s, c, l| split(s, c, l[1], 30), Keep::Part), + ("delete", none, |s, _, l| _ = run(s, "edit.clear", json!({"layers": [l[1]]})), Keep::Part), + ("duplicate", none, |s, _, l| _ = run(s, "edit.duplicate", json!({"layers": [l[0]]})), Keep::Part), + ("adjustment trim", |s, c, l| l.push(adjustment(s, c, "Gaussian Blur")), |s, c, l| timing(s, c, l[2], 0, 10, 25), Keep::Part), + ("adjustment split", |s, c, l| l.push(adjustment(s, c, "Invert")), |s, c, l| split(s, c, l[2], 22), Keep::Part), + ( + "adjustment fx off", + |s, c, l| l.push(adjustment(s, c, "Invert")), + |s, _, l| _ = run(s, "layer.setSwitch", json!({"layers": [l[2]], "switch": "fx", "value": false})), + Keep::Part, + ), + // Music changes no picture; neither does it when imported with the same action. + ("add music", none, |s, c, _| _ = music(s, c), Keep::All), + ("trim music", |s, c, l| l.push(music(s, c)), |s, c, l| timing(s, c, l[2], 0, 0, 20), Keep::All), + ("move music", |s, c, l| l.push(music(s, c)), |s, c, l| timing(s, c, l[2], 5, 5, 45), Keep::All), + ("delete music", |s, c, l| l.push(music(s, c)), |s, _, l| _ = run(s, "edit.clear", json!({"layers": [l[2]]})), Keep::All), + ("hide a clip", none, |s, _, l| _ = run(s, "layer.setSwitch", json!({"layers": [l[1]], "switch": "video", "value": false})), Keep::Part), + // Music another layer reads isn't silent: like any layer another one reads, editing + // it re-renders everything. + ( + "trim music an audio spectrum reads", + |s, c, l| { + let m = music(s, c); + run(s, "effect.apply", json!({"layers": [l[0]], "effect": "Audio Spectrum"})); + run(s, "prop.set", json!({"layer": l[0], "path": "effects/#1/audioLayer", "value": m})); + l.push(m); + }, + |s, c, l| timing(s, c, l[2], 0, 0, 20), + Keep::Nothing, + ), + ( + "noise split", + |s, _, l| _ = run(s, "effect.apply", json!({"layers": [l[1]], "effect": "Fractal Noise"})), + |s, c, l| split(s, c, l[1], 30), + Keep::Part, + ), + ( + "motion blur trim", + |s, c, l| { + Arc::make_mut(&mut s.project).comp_mut(c).unwrap().enable_motion_blur = true; + run(s, "layer.setSwitch", json!({"layers": [l[1]], "switch": "motionBlur", "value": true})); + }, + |s, c, l| timing(s, c, l[1], 20, 20, 32), + Keep::Part, + ), + ( + "matted clip trim", + |s, c, l| { + let m = solid(s, "#808080"); + timing(s, c, m, 0, 0, 45); + run(s, "layer.setTrackMatte", json!({"layer": l[1], "matte": m})); + }, + |s, c, l| timing(s, c, l[1], 20, 20, 32), + Keep::Part, + ), + ( + "parent move", + |s, _, l| _ = run(s, "layer.setParent", json!({"layers": [l[0]], "parent": l[1]})), + |s, c, l| timing(s, c, l[1], 21, 21, 41), + // (the child re-renders where it is active, the rest stays) + Keep::Part, + ), + ("echo trim in", |s, _, l| long_echo(s, l[1]), |s, c, l| timing(s, c, l[1], 20, 28, 40), Keep::Part), + ("echo trim out", |s, _, l| long_echo(s, l[1]), |s, c, l| timing(s, c, l[1], 20, 20, 32), Keep::Part), + ( + "posterize time trim in", + |s, _, l| { + run(s, "effect.apply", json!({"layers": [l[1]], "effect": "Posterize Time"})); + run(s, "prop.set", json!({"layer": l[1], "path": "effects/#1/frameRate", "value": 3.0})); + }, + |s, c, l| timing(s, c, l[1], 20, 28, 40), + Keep::Part, + ), + ( + "time displacement trim in", + |s, _, l| _ = run(s, "effect.apply", json!({"layers": [l[1]], "effect": "Time Displacement"})), + |s, c, l| timing(s, c, l[1], 20, 28, 40), + Keep::Part, + ), + // (Echo on an adjustment layer doesn't read other times yet, `FxHost::self_at`; + // when it does, an edit under it reaches frames after the edit.) + ( + "echo adjustment nudge", + |s, _, l| { + let a = run(s, "layer.newAdjustment", json!({}))["layer"].as_u64().unwrap(); + long_echo(s, a); + l.push(a); + }, + |s, c, l| timing(s, c, l[1], 21, 21, 41), + Keep::Part, + ), + ]; + for (name, setup, edit, expect) in scenarios { + let mut s = Session::default(); + let cid = ItemId(run(&mut s, "comp.new", json!({"name": "A", "width": 16, "height": 16, "duration": 1.5}))["comp"].as_u64().unwrap()); + let mut l = vec![solid(&mut s, "#ff0000"), solid(&mut s, "#00ff00")]; + timing(&mut s, cid, l[0], 0, 0, 20); + timing(&mut s, cid, l[1], 20, 20, 40); + setup(&mut s, cid, &mut l); + let p0 = s.project.clone(); + edit(&mut s, cid, &mut l); + let p1 = s.project.clone(); + let fr = p1.comp(cid).unwrap().frame_rate; + let frames = fr.frame_at(p1.comp(cid).unwrap().duration); + let dirty = dirty_frames(&p0, &p1, cid, &p1.heard_layers().union(&p0.heard_layers()), false); + let kept: Vec = match &dirty { + Some(d) => (0..frames).filter(|f| !d.iter().any(|(lo, hi)| (*lo..=*hi).contains(f))).collect(), + None => vec![], + }; + let got = match kept.len() as i64 { + 0 => Keep::Nothing, + n if n == frames => Keep::All, + _ => Keep::Part, + }; + assert_eq!(got, expect, "{name}: dirty {dirty:?}"); + for f in kept { + let (a, b) = (render_frame(&p0, cid, fr.tick_of(f), 1.0), render_frame(&p1, cid, fr.tick_of(f), 1.0)); + assert!(a.data == b.data, "{name}: kept frame {f} renders differently after the edit"); + } + } +} + +/// An edit keeps the cached frames it can't reach: nudging a clip re-renders only where it +/// was and is, a trim only the part it cut, a split only the copy's half (its new ids seed +/// randomness). Undo finds them all again; a clip another layer reads (its parent here) also +/// drops the frames of that layer. +#[test] +fn an_edit_keeps_the_frames_it_cant_reach() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Layer, LayerId}; + use serde_json::json; + type Edit = fn(&mut Comp, LayerId, &dyn Fn(i64) -> Tick); + let nudge: Edit = |c, b, t| { + let l = c.layer_mut(b).unwrap(); + (l.start_time, l.in_point, l.out_point) = (l.start_time + t(1), l.in_point + t(1), l.out_point + t(1)); + }; + let trim: Edit = |c, b, t| c.layer_mut(b).unwrap().out_point = t(75); + let split: Edit = |c, b, t| { + let i = c.layers.iter().position(|l| l.id == b).unwrap(); + let mut copy: Layer = c.layers[i].clone(); + copy.id = LayerId(1000); + copy.props.reassign_uids(&mut 1000); + copy.in_point = t(75); + c.layers[i].out_point = t(75); + c.layers.insert(i, copy); + }; + let cut: Vec = (0..=72).chain(92..=95).collect(); + for (edit, kept) in [(nudge, (0..=57).chain(93..=95).collect::>()), (trim, cut.clone()), (split, cut)] { + for parented in [false, true] { + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + s.execute("layer.newSolid", json!({})).unwrap(); + let fr = s.project.comp(cid).unwrap().frame_rate; + let t = |f: i64| fr.tick_of(f); + let b = { + let c = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); + let (a, b): (LayerId, LayerId) = (c.layers[0].id, c.layers[1].id); + let l = c.layer_mut(a).unwrap(); + (l.in_point, l.out_point) = (t(0), t(30)); + l.parent = parented.then_some(b); + let l = c.layer_mut(b).unwrap(); + (l.start_time, l.in_point, l.out_point) = (t(60), t(60), t(90)); + b + }; + let p0 = s.project.clone(); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + for fr in 0..=95 { + insert(&f, k(id0, fr)); + } + let mut p1 = p0.clone(); + edit(Arc::make_mut(&mut p1).comp_mut(cid).unwrap(), b, &t); + let id1 = f.content_of(&p1, 2, cid); + assert_ne!(id0, id1); + let got = f.cached_frames(&k(id1, 0)); + // (the child, 0–30, renders again too) + assert_eq!(got, if parented { kept.iter().copied().filter(|f| *f > 31).collect() } else { kept.clone() }); + assert_eq!(f.content_of(&p0, 3, cid), id0, "undo"); + assert_eq!(f.cached_frames(&k(id0, 0)), (0..=95).collect::>()); + } + } +} + +/// A saved and reopened project finds its frames on disk: the content key is the same. +#[test] +fn disk_keys_survive_save_and_reopen() { + use serde_json::json; + let dir = std::env::temp_dir().join(format!("ec-disk-keys-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("still.png"); + image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 100, 50, 255])).save(&png).unwrap(); + let mut s = effectcraft_host::session(); + let cid = ItemId(s.execute("comp.new", json!({"name": "Keys", "width": 16, "height": 16, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + let item = s.execute("file.import", json!({"paths": [png.to_string_lossy()]})).unwrap()["items"][0].clone(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 8, "height": 8})).unwrap(); + let adj = s.execute("layer.newAdjustment", json!({})).unwrap()["layer"].clone(); + s.execute("effect.apply", json!({"layers": [adj], "effect": "Gaussian Blur"})).unwrap(); + let before = disk_cache::comp_content_key(&s.project, cid); + let path = dir.join("keys.ecproj"); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut again = effectcraft_host::session(); + again.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(disk_cache::comp_content_key(&again.project, cid), before); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Frames in RAM that the disk lacks (an edit kept them; a write was dropped) are written +/// when idle, so the reopened project shows them all as the blue bar. The RAM series carries a +/// Roto Brush model stamp in its opts; the disk keys are the worker's, without it. +#[test] +fn ram_frames_missing_on_disk_are_written() { + use effectcraft_engine::Session; + use serde_json::json; + let dir = std::env::temp_dir().join(format!("ec-persist-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let disk = DiskCache::open(&dir, 1 << 30).unwrap(); + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + let f = Frames::default(); + let series = + FrameKey { revision: 1, content: f.content_of(&src.project, 1, cid), comp: cid.0, opts: opts_hash(&RenderOpts::default()) ^ 1, ..key(0, 0, 0) }; + for fr in 0..10 { + insert(&f, FrameKey { frame: fr, ..series }); + } + let on_disk = |fr: i64| { + disk.contains( + disk_cache::Kind::Frame, + frame_disk_key( + &f.content_keys, + &src.project, + &FrameKey { frame: fr, ..series }, + opts_hash(&RenderOpts::default()), + f.virtual_epoch.load(Ordering::Relaxed), + None, + ), + ) + }; + let persist = || { + // The content key is computed on the frame pool first. + for _ in 0..1000 { + f.persist_missing(&src, &series, &RenderOpts::default()); + disk.flush(); + // (`flush` waits for the disk writes, not for the conversion to forget its keys) + if (0..10).all(on_disk) && f.persisted.lock().unwrap().is_empty() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false + }; + let evict = || { + disk.set_max_bytes(1); + disk.set_max_bytes(1 << 30); + assert!(!(0..10).any(on_disk)); + }; + assert!(persist(), "every RAM frame written"); + assert!(!f.persist_missing(&src, &series, &RenderOpts::default()), "nothing left to write: no more idle passes"); + // Evicted from disk: the RAM frames are written again, once. + evict(); + assert!(persist(), "evicted frames written again"); + // Reopened: a new preview cache over the same disk shows every frame in blue. + let reopened = Frames::default(); + let reopened_series = FrameKey { content: reopened.content_of(&src.project, 1, cid), ..series }; + let mut blue = vec![]; + for _ in 0..1000 { + blue = reopened.disk_frames(&src, &reopened_series, 0..10, &RenderOpts::default()); + if blue.len() == 10 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(blue, (0..10).collect::>()); + // Evicted again (a disk cache too small for them): not rewritten on every idle pass. + evict(); + assert!(!f.persist_missing(&src, &series, &RenderOpts::default()), "evicted twice: no more idle passes"); + disk.flush(); + assert!(!(0..10).any(on_disk)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Audio changes pixels only where something draws it: a clip's Audio Levels, audio effects and +/// Audio switch keep every frame, in RAM and on disk, until a waveform picks the clip (on another +/// layer, on itself, or through a precomp of it); then the frames of the reader render again. +#[test] +fn audio_edits_keep_the_frames_until_something_draws_the_audio() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, GroupKind, LayerId, Value, build}; + use effectcraft_engine::render::disk_cache::comp_content_key_with; + use serde_json::json; + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({})).unwrap(); + let mut p = (*s.project).clone(); + let clip = + p.add_item("clip.mov", effectcraft_color::Label::None, None, ItemKind::Footage(Footage { has_video: true, has_audio: true, ..Default::default() })); + let comp = p.comp(cid).unwrap().clone(); + let fr = comp.frame_rate; + let t = move |f: i64| fr.tick_of(f); + let v = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: clip }, (8, 8), None); + let (clip_layer, solid) = (v.id, comp.layers[0].id); + p.comp_mut(cid).unwrap().layers.push(v); + for (id, a, b) in [(solid, 0, 30), (clip_layer, 90, 120)] { + let l = p.comp_mut(cid).unwrap().layer_mut(id).unwrap(); + (l.start_time, l.in_point, l.out_point) = (t(a), t(a), t(b)); + } + // The outer comp shows the whole of A. + let outer = p.add_item("Outer", effectcraft_color::Label::None, None, ItemKind::Comp(Arc::new(Comp { layers: Default::default(), ..comp.clone() }))); + let oc = p.comp(outer).unwrap().clone(); + let mut pre = build::layer(&mut p, &oc, "Pre", LayerSource::Comp { item: cid }, (8, 8), None); + (pre.start_time, pre.in_point, pre.out_point) = (t(0), t(0), t(120)); + let (pre_layer, reader) = (pre.id, oc.layers.first().map(|l| l.id)); + assert!(reader.is_none()); + p.comp_mut(outer).unwrap().layers.push(pre); + // An effect on `on` (of comp `c`) that picks layer `pick`. + let pick = |p: &mut Project, c: ItemId, on: LayerId, pick: LayerId| { + let mut n = 1_000_000; + let mut ids = build::Ids(&mut n); + let mut g = ids.group("waveform", "Audio Waveform"); + g.kind = GroupKind::Effect { effect: "ec.generate.audiowaveform".into() }; + g.children.push(ids.prop("audioLayer", "Audio Layer", Value::Layer(Some(pick.0))).into()); + p.comp_mut(c).unwrap().layer_mut(on).unwrap().props.group_mut("effects").unwrap().children.push(g.into()); + }; + let levels = |p: &mut Project| { + p.comp_mut(cid).unwrap().layer_mut(clip_layer).unwrap().props.prop_mut("audio/levels").unwrap().set_value_at(t(0), Value::Vec2([-6.0, -6.0])) + }; + let backwards = |p: &mut Project| { + let mut n = 2_000_000; + let mut g = build::Ids(&mut n).group("backwards", "Backwards"); + g.kind = GroupKind::Effect { effect: "ec.audio.backwards".into() }; + p.comp_mut(cid).unwrap().layer_mut(clip_layer).unwrap().props.group_mut("effects").unwrap().children.push(g.into()); + }; + let mute = |p: &mut Project| p.comp_mut(cid).unwrap().layer_mut(clip_layer).unwrap().switches.audio = false; + // The frames of `view` an edit keeps, and whether its disk key stays. + let kept = |p0: &Project, view: ItemId, edit: &dyn Fn(&mut Project)| { + let p0 = Arc::new(p0.clone()); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: view.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, view); + (0..120).for_each(|n| insert(&f, k(id0, n))); + let mut p1 = p0.clone(); + edit(Arc::make_mut(&mut p1)); + let id1 = f.content_of(&p1, 2, view); + (f.cached_frames(&k(id1, 0)), comp_content_key_with(&p0, view, None) == comp_content_key_with(&p1, view, None)) + }; + let all: Vec = (0..120).collect(); + let edits: [(&str, &dyn Fn(&mut Project)); 3] = [("levels", &levels), ("audio effect", &backwards), ("audio switch", &mute)]; + for (name, edit) in edits { + assert_eq!(kept(&p, cid, edit), (all.clone(), true), "{name}: nothing draws the clip's audio"); + assert_eq!(kept(&p, outer, edit), (all.clone(), true), "{name}: nor through the precomp"); + + let mut q = p.clone(); + pick(&mut q, cid, solid, clip_layer); + let (frames, disk) = kept(&q, cid, edit); + assert!(!disk, "{name}: the solid draws the clip's audio"); + assert!(frames.contains(&60) && !frames.contains(&10) && !frames.contains(&100), "{name}: the reader's and the clip's frames go: {frames:?}"); + + let mut q = p.clone(); + pick(&mut q, cid, clip_layer, clip_layer); + let (frames, disk) = kept(&q, cid, edit); + assert!(!disk, "{name}: the clip draws its own audio"); + assert!(frames.contains(&10) && frames.contains(&60) && !frames.contains(&100), "{name}: the clip's frames go: {frames:?}"); + + let mut q = p.clone(); + let r = q.comp(cid).unwrap().layers[0].clone(); + let reader = LayerId(r.id.0 + 500_000); + q.comp_mut(outer).unwrap().layers.insert(0, effectcraft_engine::project::Layer { id: reader, ..r }); + pick(&mut q, outer, reader, pre_layer); + let (frames, disk) = kept(&q, outer, edit); + assert!( + !disk && frames.contains(&60) && !frames.contains(&10) && !frames.contains(&100), + "{name}: a waveform in the outer comp draws the precomp's audio: {frames:?}" + ); + } +} + +/// An expression reaches only the frames of its own layer: with one in the comp, an edit (here +/// or elsewhere in the project: a trim, a rename, a marker, another comp) re-renders that layer's +/// frames besides the edit's own, and every frame kept renders as before, expressions evaluated, +/// also when the expression reads the edited layer. +#[test] +fn an_expression_reaches_only_the_frames_of_its_layer() { + use effectcraft_engine::project::{LayerId, Marker}; + use effectcraft_engine::render::NoFootage; + use serde_json::json; + let mut s = effectcraft_host::session(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 16, "height": 16, "duration": 1.5})).unwrap()["comp"].as_u64().unwrap()); + let other = ItemId(s.execute("comp.new", json!({"name": "Other", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + s.execute("comp.open", json!({"comp": cid.0})).ok(); + let mut solid = |name: &str| { + let l = s.execute("layer.newSolid", json!({"comp": cid.0, "color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.rename", json!({"comp": cid.0, "layer": l, "name": name})).unwrap(); + for (t, x) in [(0.0, 0.0), (1.5, 16.0)] { + s.execute("prop.addKey", json!({"comp": cid.0, "layer": l, "path": "transform/position", "time": t, "value": [x, 8.0, 0.0]})).unwrap(); + } + LayerId(l) + }; + let (a, b, c) = (solid("A"), solid("B"), solid("C")); + s.execute( + "prop.setExpression", + json!({"comp": cid.0, "layer": c.0, "path": "transform/position", "expression": "thisComp.layer(\"B\").transform.position + [0, time * 4]"}), + ) + .unwrap(); + let fr = s.project.comp(cid).unwrap().frame_rate; + let n = fr.frame_at(s.project.comp(cid).unwrap().duration); + let mut p0 = (*s.project).clone(); + for (id, i, o) in [(a, 0, 20), (b, 20, 40), (c, 40, 45)] { + let l = p0.comp_mut(cid).unwrap().layer_mut(id).unwrap(); + (l.start_time, l.in_point, l.out_point) = (fr.tick_of(i), fr.tick_of(i), fr.tick_of(o)); + } + let p0 = Arc::new(p0); + let host = s.expr.clone().unwrap(); + let render = |p: &Project, i: i64| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&*host); + r.comp_frame(cid, fr.tick_of(i)) + }; + type Edit = fn(&mut Project, ItemId, ItemId, [LayerId; 3]); + let edits: [(&str, Edit, i64); 4] = [ + ("trim B", |p, c, _, l| p.comp_mut(c).unwrap().layer_mut(l[1]).unwrap().out_point = p.comp(c).unwrap().frame_rate.tick_of(32), 30), + ("rename A", |p, c, _, l| p.comp_mut(c).unwrap().layer_mut(l[0]).unwrap().name = "Renamed".into(), 38), + ("comp marker", |p, c, _, _| p.comp_mut(c).unwrap().markers.push(Marker { time: Tick::ZERO, ..Default::default() }), 38), + ("another comp", |p, _, o, _| p.comp_mut(o).unwrap().width = 9, 38), + ]; + for (name, edit, kept_from) in edits { + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..n).for_each(|i| insert(&f, k(id0, i))); + let mut p1 = p0.clone(); + edit(Arc::make_mut(&mut p1), cid, other, [a, b, c]); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, cid), 0)); + // C (frames 40–45) reads anything: it renders again, the frames before it (and before a + // trim of B) stay. + assert!((0..kept_from).all(|i| kept.contains(&i)) && !kept.contains(&42), "{name}: {kept:?}"); + for i in kept { + assert_eq!(render(&p0, i), render(&p1, i), "{name}: kept frame {i} draws other pixels now"); + } + } +} + +/// An edit inside a precomp re-renders only the frames where the precomp shows what it changed: +/// shifted by the precomp layer's start, scaled by its stretch, and its whole span under a time +/// remap. Every frame kept renders as before. +#[test] +fn an_edit_in_a_precomp_reaches_only_where_it_shows() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{LayerId, Value, build}; + use effectcraft_engine::render::render_frame; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap(), "comp")); + let solid = |s: &mut Session| { + let l = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000", "width": 6, "height": 6})).unwrap(), "layer"); + for (t, x) in [(0.0, 0.0), (4.0, 16.0)] { + s.execute("prop.addKey", json!({"comp": inner.0, "layer": l, "path": "transform/position", "time": t, "value": [x, 8.0, 0.0]})).unwrap(); + } + LayerId(l) + }; + let (a, b) = (solid(&mut s), solid(&mut s)); + let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "duration": 6, "frameRate": 30})).unwrap(), "comp")); + let pre = LayerId(id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer")); + let fr = s.project.comp(outer).unwrap().frame_rate; + let t = |f: i64| fr.tick_of(f); + let mut p0 = (*s.project).clone(); + for (l, i, o) in [(a, 0, 30), (b, 30, 60)] { + let l = p0.comp_mut(inner).unwrap().layer_mut(l).unwrap(); + (l.start_time, l.in_point, l.out_point) = (t(i), t(i), t(o)); + } + let n = fr.frame_at(p0.comp(outer).unwrap().duration); + // The precomp starts at frame 15; `layout` sets its stretch and time remap. + let place = |p: &mut Project, stretch: f64, remap: bool| { + let l = p.comp_mut(outer).unwrap().layer_mut(pre).unwrap(); + (l.start_time, l.in_point, l.out_point, l.stretch) = (t(15), t(15), t(15) + Tick((t(120).0 as f64 * stretch / 100.0) as i64), stretch); + if remap { + let mut uid = 9_000_000; + l.props.children.push(build::Ids(&mut uid).prop("timeRemap", "Time Remap", Value::Scalar(1.5)).into()); + } + }; + // Trim B (inner frames 30–60) to end at 45. + let trim = |p: &mut Project| p.comp_mut(inner).unwrap().layer_mut(b).unwrap().out_point = t(45); + for (name, stretch, remap, kept_frames, dirty_frames) in [ + ("offset", 100.0, false, vec![30, 55, 80, 130], vec![60, 75]), + ("stretched", 200.0, false, vec![30, 100, 140, 179], vec![105, 135]), + ("time remap", 100.0, true, vec![5], vec![30, 100]), + ] { + let mut q = p0.clone(); + place(&mut q, stretch, remap); + let q = Arc::new(q); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: outer.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&q, 1, outer); + (0..n).for_each(|i| insert(&f, k(id0, i))); + let mut q1 = q.clone(); + trim(Arc::make_mut(&mut q1)); + let kept = f.cached_frames(&k(f.content_of(&q1, 2, outer), 0)); + assert!(kept_frames.iter().all(|i| kept.contains(i)) && !dirty_frames.iter().any(|i| kept.contains(i)), "{name}: {kept:?}"); + for i in kept { + assert!( + render_frame(&q, outer, t(i), 1.0).data == render_frame(&q1, outer, t(i), 1.0).data, + "{name}: kept frame {i} renders differently after the edit" + ); + } + } +} + +/// Frames only on disk (the blue bar) outlive an edit that can't change them: a rename keeps them +/// all, a trim all but the frames it cut, and undo finds the earlier ones again. +#[test] +fn disk_frames_outlive_an_edit_that_cant_change_them() { + use effectcraft_engine::Session; + use effectcraft_engine::project::LayerId; + use serde_json::json; + let dir = std::env::temp_dir().join(format!("ec-disk-carry-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let disk = DiskCache::open(&dir, 1 << 30).unwrap(); + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); + let a = LayerId(s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap()); + let fr = s.project.comp(cid).unwrap().frame_rate; + let mut p0 = (*s.project).clone(); + for (l, i, o) in [(a, 0, 15), (b, 15, 30)] { + let l = p0.comp_mut(cid).unwrap().layer_mut(l).unwrap(); + (l.start_time, l.in_point, l.out_point) = (fr.tick_of(i), fr.tick_of(i), fr.tick_of(o)); + } + let p0 = Arc::new(p0); + let opts = RenderOpts::default(); + let oh = opts_hash(&opts); + let src = |p: &Arc| RenderSource { + project: p.clone(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(disk.clone()), + }; + // Every frame of `p0` on disk, none in RAM. + let content0 = disk_cache::comp_content_key_with(&p0, cid, None); + for f in 0..30 { + disk.put_frame_now(disk_cache::frame_key(content0, f, 1000, oh), 1, 1, &[1, 2, 3, 255]); + } + let rename: fn(&mut Project, ItemId, LayerId) = |p, c, l| p.comp_mut(c).unwrap().layer_mut(l).unwrap().name = "Renamed".into(); + let trim: fn(&mut Project, ItemId, LayerId) = |p, c, l| { + let fr = p.comp(c).unwrap().frame_rate; + p.comp_mut(c).unwrap().layer_mut(l).unwrap().out_point = fr.tick_of(22); + }; + for (name, edit, layer, kept) in [("rename", rename, a, (0..30).collect::>()), ("trim", trim, b, (0..20).collect())] { + let f = Frames::default(); + let series = |p: &Arc, rev: u64| FrameKey { revision: rev, content: f.content_of(p, rev, cid), comp: cid.0, opts: oh, ..key(0, 0, 0) }; + // Blue frames of `p` once the disk carries and the content key are done. + let blue = |p: &Arc, rev: u64, want: &[i64]| { + let base = series(p, rev); + let mut got = vec![]; + for _ in 0..1000 { + got = f.disk_frames(&src(p), &base, 0..30, &opts); + if got == want { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + got + }; + assert_eq!(blue(&p0, 1, &(0..30).collect::>()), (0..30).collect::>(), "{name}: before"); + let mut p1 = p0.clone(); + edit(Arc::make_mut(&mut p1), cid, layer); + assert_eq!(blue(&p1, 2, &kept), kept, "{name}: after"); + assert_eq!(blue(&p0, 3, &(0..30).collect::>()), (0..30).collect::>(), "{name}: undo"); + // A drag: an edit per paint, each paint asking for the blue bar. One carry job drains + // them in order, and none is left scheduled. + let mut p = p0.clone(); + for rev in 4..20 { + Arc::make_mut(&mut p).comp_mut(cid).unwrap().layer_mut(a).unwrap().name = format!("Drag {rev}"); + let _ = f.disk_frames(&src(&p), &series(&p, rev), 0..30, &opts); + } + assert_eq!(blue(&p, 19, &(0..30).collect::>()), (0..30).collect::>(), "{name}: dragged"); + for _ in 0..1000 { + if !f.carry_scheduled.load(Ordering::Acquire) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!f.carry_scheduled.load(Ordering::Acquire) && f.disk_carries.lock().unwrap().is_empty(), "{name}: carry job finished"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A keyframe edit re-renders only around the keys it changed: two keys either side (auto-Bezier +/// and continuous tangents reach the neighbouring segments), and from the layer's in point when +/// the first key changes. A static value or an expression still reaches the whole layer. Every +/// frame kept renders as before. +#[test] +fn a_keyframe_edit_reaches_only_the_keys_around_it() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{LayerId, Value}; + use effectcraft_engine::render::render_frame; + use effectcraft_keyframe::Interp; + use serde_json::json; + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 4, "height": 4})).unwrap()["layer"].as_u64().unwrap(); + let fr = s.project.comp(cid).unwrap().frame_rate; + // Keys at frames 0, 20, 40, 60, 80, 100, zig-zagging, all auto-Bezier and continuous. + for (i, f) in (0..6).map(|i| (i, i * 20)) { + let y = if i % 2 == 0 { 3.0 } else { 13.0 }; + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": fr.tick_of(f).seconds(), "value": [2.0 + i as f64 * 2.0, y, 0.0]})) + .unwrap(); + } + let mut p0 = (*s.project).clone(); + for k in &mut p0.comp_mut(cid).unwrap().layer_mut(LayerId(l)).unwrap().props.prop_mut("transform/position").unwrap().keys { + (k.in_interp, k.out_interp, k.auto_bezier, k.continuous, k.spatial_continuous) = (Interp::Bezier, Interp::Bezier, true, true, true); + } + let p0 = Arc::new(p0); + let n = 120; + type Edit = fn(&mut effectcraft_engine::project::Property); + let edits: [(&str, Edit, Vec, Vec); 5] = [ + ("middle value", |p| p.keys[3].value = Value::Vec3([8.0, 8.0, 0.0]), vec![0, 17, 103, 119], vec![22, 60, 98]), + ("middle interpolation", |p| p.keys[3].out_interp = Interp::Hold, vec![0, 17, 103, 119], vec![22, 60, 98]), + ("first value", |p| p.keys[0].value = Value::Vec3([1.0, 1.0, 0.0]), vec![43, 119], vec![0, 20, 39]), + ("static", |p| p.keys.clear(), vec![], vec![0, 60, 119]), + ("expression", |p| p.expr = Some(effectcraft_engine::project::Expression { text: "value".into(), enabled: true }), vec![], vec![0, 60, 119]), + ]; + for (name, edit, kept_frames, dirty_frames) in edits { + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..n).for_each(|i| insert(&f, k(id0, i))); + let mut p1 = p0.clone(); + edit(Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(LayerId(l)).unwrap().props.prop_mut("transform/position").unwrap()); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, cid), 0)); + assert!(kept_frames.iter().all(|i| kept.contains(i)) && !dirty_frames.iter().any(|i| kept.contains(i)), "{name}: {kept:?}"); + let same = |i: i64| render_frame(&p0, cid, fr.tick_of(i), 1.0).data == render_frame(&p1, cid, fr.tick_of(i), 1.0).data; + // (the edit shows: the check below compares something) + assert!(name == "expression" || !(0..n).all(same), "{name}: the edit changes no pixels"); + for i in kept { + assert!(same(i), "{name}: kept frame {i} renders differently after the edit"); + } + } +} + +/// Music's Audio Levels keep every frame, in RAM and on disk, while the project's expressions +/// can't read audio (a wiggle); an expression naming the music's Audio Levels re-renders its layer. +#[test] +fn music_volume_keeps_the_frames_with_expressions_that_dont_read_audio() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Expression, Footage, FootageKind, LayerId, Value, build}; + use effectcraft_engine::render::disk_cache::comp_content_key_with; + use serde_json::json; + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); + let solid = LayerId(s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap()); + let mut p = (*s.project).clone(); + let song = p.add_item( + "song.wav", + effectcraft_color::Label::None, + None, + ItemKind::Footage(Footage { kind: FootageKind::Audio, has_video: false, has_audio: true, ..Default::default() }), + ); + let comp = p.comp(cid).unwrap().clone(); + let music = build::layer(&mut p, &comp, "Music", LayerSource::Footage { item: song }, (8, 8), None); + let music_id = music.id; + p.comp_mut(cid).unwrap().layers.push(music); + let kept = |text: &str| { + let mut p0 = p.clone(); + p0.comp_mut(cid).unwrap().layer_mut(solid).unwrap().props.prop_mut("transform/position").unwrap().expr = + Some(Expression { text: text.into(), enabled: true }); + let p0 = Arc::new(p0); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..120).for_each(|n| insert(&f, k(id0, n))); + let mut p1 = p0.clone(); + Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(music_id).unwrap().props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-6.0, -6.0]); + let id1 = f.content_of(&p1, 2, cid); + (f.cached_frames(&k(id1, 0)).len(), comp_content_key_with(&p0, cid, None) == comp_content_key_with(&p1, cid, None)) + }; + assert_eq!(kept("wiggle(2, 20)"), (120, true), "a wiggle reads no audio"); + let (frames, disk) = kept("value + thisComp.layer(\"Music\")(\"Audio\")(\"Audio Levels\")[0]"); + assert!(frames < 120 && !disk, "the expression reads the music's levels: {frames} frames kept, disk kept {disk}"); +} + +/// A Scale edit with only two keys re-renders from the other key on (values hold past it), not +/// the layer's whole length; a full-length layer whose expression reads only itself (a wiggle) +/// keeps its frames. Every frame kept renders as before. +#[test] +fn a_two_key_edit_beside_a_wiggle_keeps_the_other_frames() { + use effectcraft_engine::project::{Expression, LayerId, Value}; + use effectcraft_engine::render::NoFootage; + use serde_json::json; + let mut s = effectcraft_host::session(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); + let wig = LayerId(s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 4, "height": 4})).unwrap()["layer"].as_u64().unwrap()); + let l = LayerId(s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 4, "height": 4})).unwrap()["layer"].as_u64().unwrap()); + let fr = s.project.comp(cid).unwrap().frame_rate; + for (f, v) in [(20, 50.0), (80, 150.0)] { + s.execute("prop.addKey", json!({"layer": l.0, "path": "transform/scale", "time": fr.tick_of(f).seconds(), "value": [v, v, 100.0]})).unwrap(); + } + let mut p0 = (*s.project).clone(); + p0.comp_mut(cid).unwrap().layer_mut(wig).unwrap().props.prop_mut("transform/position").unwrap().expr = + Some(Expression { text: "wiggle(2, 3)".into(), enabled: true }); + let p0 = Arc::new(p0); + let host = s.expr.clone().unwrap(); + let render = |p: &Project, i: i64| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&*host); + r.comp_frame(cid, fr.tick_of(i)) + }; + // (key index, frames that must stay, frames that must go) + for (n, keep, go) in [(1, 0..18, [21, 60, 119]), (0, 82..120, [0, 20, 79])] { + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let mut p1 = p0.clone(); + Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(l).unwrap().props.prop_mut("transform/scale").unwrap().keys[n].value = + Value::Vec3([90.0, 90.0, 100.0]); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, cid), 0)); + assert!(keep.clone().all(|i| kept.contains(&i)) && !go.iter().any(|i| kept.contains(i)), "key {n}: {kept:?}"); + for i in kept { + assert_eq!(render(&p0, i), render(&p1, i), "key {n}: kept frame {i} draws other pixels now"); + } + } +} + +/// An expression that reads its own layer's markers (no other layer or comp named) re-renders +/// that layer when one of its markers changes. Every frame kept renders as before. +#[test] +fn a_marker_read_by_its_own_layer_re_renders_it() { + use effectcraft_engine::render::NoFootage; + use serde_json::json; + let mut s = effectcraft_host::session(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 4, "height": 4})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "marker.numKeys > 0 ? 0 : 100"})).unwrap(); + let p0 = s.project.clone(); + s.execute("markers.set", json!({"comp": cid.0, "layer": l, "new": true, "time": 3.0})).unwrap(); + let p1 = s.project.clone(); + let host = s.expr.clone().unwrap(); + let fr = p0.comp(cid).unwrap().frame_rate; + let render = |p: &Project, i: i64| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&*host); + r.comp_frame(cid, fr.tick_of(i)) + }; + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, cid), 0)); + assert!(!kept.contains(&0), "the layer's frames must go: {kept:?}"); + for i in kept { + assert_eq!(render(&p0, i), render(&p1, i), "kept frame {i} draws other pixels now"); + } +} + +/// An expression that reads its own layer's audio re-renders that layer when its volume +/// changes. +#[test] +fn a_volume_read_by_its_own_layer_re_renders_it() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Expression, Footage, FootageKind, Value, build}; + use serde_json::json; + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); + let mut p = (*s.project).clone(); + let clip = p.add_item( + "clip.mp4", + effectcraft_color::Label::None, + None, + ItemKind::Footage(Footage { kind: FootageKind::Video, has_video: true, has_audio: true, width: 8, height: 8, ..Default::default() }), + ); + let comp = p.comp(cid).unwrap().clone(); + let mut layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: clip }, (8, 8), None); + layer.props.prop_mut("transform/opacity").unwrap().expr = + Some(Expression { text: "100 + thisLayer(\"Audio\")(\"Audio Levels\")[0]".into(), enabled: true }); + let id = layer.id; + p.comp_mut(cid).unwrap().layers.push(layer); + let p0 = Arc::new(p); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let mut p1 = p0.clone(); + Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(id).unwrap().props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-6.0, -6.0]); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, cid), 0)); + assert!(!kept.contains(&0), "the clip's frames must go: {kept:?}"); +} + +/// An expression on the keyed layer can read the keyed property at other times (Rotation +/// reading Position a second earlier): a key edit re-renders the layer's whole length. Every +/// frame kept renders as before. +#[test] +fn a_keyframe_edit_read_at_other_times_by_its_layer_re_renders_it() { + use effectcraft_engine::project::{LayerId, Value}; + use effectcraft_engine::render::NoFootage; + use serde_json::json; + let mut s = effectcraft_host::session(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap()["comp"].as_u64().unwrap()); + let l = LayerId(s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 4, "height": 4})).unwrap()["layer"].as_u64().unwrap()); + let fr = s.project.comp(cid).unwrap().frame_rate; + for (f, x) in [(20, 2.0), (80, 14.0)] { + s.execute("prop.addKey", json!({"layer": l.0, "path": "transform/position", "time": fr.tick_of(f).seconds(), "value": [x, 8.0, 0.0]})).unwrap(); + } + s.execute("prop.setExpression", json!({"layer": l.0, "path": "transform/rotation", "expression": "transform.position.valueAtTime(time - 1)[0] * 3"})) + .unwrap(); + let p0 = s.project.clone(); + let host = s.expr.clone().unwrap(); + let render = |p: &Project, i: i64| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&*host); + r.comp_frame(cid, fr.tick_of(i)) + }; + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let mut p1 = p0.clone(); + Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(l).unwrap().props.prop_mut("transform/position").unwrap().keys[0].value = + Value::Vec3([8.0, 2.0, 0.0]); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, cid), 0)); + assert!(!kept.contains(&100), "Rotation at frame 100 reads Position at frame 70: {kept:?}"); + for i in kept { + assert_eq!(render(&p0, i), render(&p1, i), "kept frame {i} draws other pixels now"); + } +} + +/// An edit two precomps down (Outer → Mid → Inner) re-renders Outer where it shows, although +/// Mid's own comp didn't change. Every frame kept renders as before. +#[test] +fn an_edit_two_precomps_down_reaches_the_outer_comp() { + use effectcraft_engine::Session; + use effectcraft_engine::project::LayerId; + use effectcraft_engine::render::render_frame; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let comp = |s: &mut Session, name: &str| { + ItemId(id(s.execute("comp.new", json!({"name": name, "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap(), "comp")) + }; + let inner = comp(&mut s, "Inner"); + let solid = |s: &mut Session, color: &str| { + LayerId(id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": color, "width": 6, "height": 6})).unwrap(), "layer")) + }; + let (a, b) = (solid(&mut s, "#ff0000"), solid(&mut s, "#00ff00")); + let mid = comp(&mut s, "Mid"); + s.execute("layer.addItem", json!({"comp": mid.0, "item": inner.0})).unwrap(); + let outer = comp(&mut s, "Outer"); + s.execute("layer.addItem", json!({"comp": outer.0, "item": mid.0})).unwrap(); + let fr = s.project.comp(outer).unwrap().frame_rate; + let t = |f: i64| fr.tick_of(f); + let mut p0 = (*s.project).clone(); + for (l, i, o) in [(a, 0, 30), (b, 30, 60)] { + let l = p0.comp_mut(inner).unwrap().layer_mut(l).unwrap(); + (l.start_time, l.in_point, l.out_point) = (t(i), t(i), t(o)); + } + let p0 = Arc::new(p0); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: outer.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, outer); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let mut p1 = p0.clone(); + Arc::make_mut(&mut p1).comp_mut(inner).unwrap().layer_mut(b).unwrap().out_point = t(45); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, outer), 0)); + assert!(kept.contains(&10) && kept.contains(&100) && !kept.contains(&50), "{kept:?}"); + for i in kept { + assert!(render_frame(&p0, outer, t(i), 1.0).data == render_frame(&p1, outer, t(i), 1.0).data, "kept frame {i} renders differently after the edit"); + } +} + +/// An expression reading its own layer's in point (a fade from `inPoint`) re-renders the layer +/// when it is trimmed, and one reading its `index` re-renders it when a short layer is added +/// above it. Every frame kept renders as before. +#[test] +fn expressions_reading_their_own_layer_re_render_on_a_trim_or_a_layer_above() { + use effectcraft_engine::Session; + use effectcraft_engine::project::LayerId; + use effectcraft_engine::render::NoFootage; + use serde_json::json; + /// Trim the layer to start at frame 10. + fn trim(s: &mut Session, cid: ItemId, l: LayerId) { + let fr = s.project.comp(cid).unwrap().frame_rate; + Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(l).unwrap().in_point = fr.tick_of(10); + } + /// Add a solid above the layer on frames 0–5. + fn layer_above(s: &mut Session, cid: ItemId, _: LayerId) { + let fr = s.project.comp(cid).unwrap().frame_rate; + let a = s.execute("layer.newSolid", json!({"comp": cid.0, "color": "#00ff00", "width": 2, "height": 2})).unwrap()["layer"].as_u64().unwrap(); + let a = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap(); + (a.start_time, a.in_point, a.out_point) = (fr.tick_of(0), fr.tick_of(0), fr.tick_of(5)); + } + type Edit = fn(&mut Session, ItemId, LayerId); + let edits: [(&str, &str, &str, Edit); 2] = + [("trim", "transform/opacity", "linear(time, inPoint, inPoint + 2, 20, 100)", trim), ("layer above", "transform/rotation", "index * 20", layer_above)]; + for (name, path, expression, edit) in edits { + let mut s = effectcraft_host::session(); + let new = json!({"name": "A", "width": 16, "height": 16, "duration": 4, "frameRate": 30}); + let cid = ItemId(s.execute("comp.new", new).unwrap()["comp"].as_u64().unwrap()); + let l = LayerId(s.execute("layer.newSolid", json!({"comp": cid.0, "color": "#ff0000", "width": 6, "height": 6})).unwrap()["layer"].as_u64().unwrap()); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": l.0, "path": path, "expression": expression})).unwrap(); + let p0 = s.project.clone(); + edit(&mut s, cid, l); + let p1 = s.project.clone(); + let fr = p0.comp(cid).unwrap().frame_rate; + let host = s.expr.clone().unwrap(); + let render = |p: &Project, i: i64| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&*host); + r.comp_frame(cid, fr.tick_of(i)) + }; + assert_ne!(render(&p0, 60), render(&p1, 60), "{name}: the edit changes frame 60"); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, cid), 0)); + assert!(!kept.contains(&60), "{name}: frame 60 must go: {kept:?}"); + for i in kept { + assert_eq!(render(&p0, i), render(&p1, i), "{name}: kept frame {i} draws other pixels now"); + } + } +} + +/// An expression inside a precomp reading a layer of the comp nesting it (a hidden Controls +/// layer, `comp("Main").layer("Controls")`) re-renders where the precomp shows it when that +/// layer changes. Every frame kept renders as before. +#[test] +fn an_edit_read_by_a_precomp_expression_reaches_the_precomp() { + use effectcraft_engine::project::LayerId; + use effectcraft_engine::render::NoFootage; + use serde_json::json; + let mut s = effectcraft_host::session(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let new_comp = |s: &mut effectcraft_engine::Session, name: &str| { + ItemId(id(s.execute("comp.new", json!({"name": name, "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap(), "comp")) + }; + let inner = new_comp(&mut s, "Inner"); + let dot = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000", "width": 6, "height": 6})).unwrap(), "layer"); + s.execute( + "prop.setExpression", + json!({"comp": inner.0, "layer": dot, "path": "transform/opacity", "expression": "comp(\"Main\").layer(\"Controls\").transform.rotation"}), + ) + .unwrap(); + let main = new_comp(&mut s, "Main"); + s.execute("layer.addItem", json!({"comp": main.0, "item": inner.0})).unwrap(); + let controls = json!({"comp": main.0, "name": "Controls", "color": "#0000ff", "width": 2, "height": 2}); + let controls = LayerId(id(s.execute("layer.newSolid", controls).unwrap(), "layer")); + // (hidden, and short: the precomp reads it at every frame all the same) + let fr = s.project.comp(main).unwrap().frame_rate; + let c = Arc::make_mut(&mut s.project).comp_mut(main).unwrap().layer_mut(controls).unwrap(); + (c.switches.video, c.start_time, c.in_point, c.out_point) = (false, fr.tick_of(0), fr.tick_of(0), fr.tick_of(10)); + let p0 = s.project.clone(); + s.execute("prop.set", json!({"comp": main.0, "layer": controls.0, "path": "transform/rotation", "value": 60.0})).unwrap(); + let p1 = s.project.clone(); + let host = s.expr.clone().unwrap(); + let render = |p: &Project, i: i64| { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.expr = Some(&*host); + r.comp_frame(main, fr.tick_of(i)) + }; + assert_ne!(render(&p0, 60), render(&p1, 60), "the dot's opacity follows Controls"); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: main.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, main); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, main), 0)); + assert!(!kept.contains(&60), "the precomp's frames must go: {kept:?}"); + for i in kept { + assert_eq!(render(&p0, i), render(&p1, i), "kept frame {i} draws other pixels now"); + } +} + +/// An edit inside a precomp whose layer runs Echo (it reads the precomp at earlier times) +/// re-renders the precomp layer's whole span, not only where the precomp shows the edit. Every +/// frame kept renders as before. +#[test] +fn an_edit_in_a_precomp_under_a_time_effect_reaches_its_whole_layer() { + use effectcraft_engine::Session; + use effectcraft_engine::project::LayerId; + use effectcraft_engine::render::render_frame; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap(), "comp")); + let solid = |s: &mut Session, color: &str| { + LayerId(id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": color, "width": 6, "height": 6})).unwrap(), "layer")) + }; + let (a, b) = (solid(&mut s, "#ff0000"), solid(&mut s, "#00ff00")); + let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "duration": 4, "frameRate": 30})).unwrap(), "comp")); + let pre = id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer"); + // Echo reaching 9 frames back, once (more echoes of one solid add up past full and look alike). + s.execute("effect.apply", json!({"comp": outer.0, "layers": [pre], "effect": "Echo"})).unwrap(); + s.execute("prop.set", json!({"comp": outer.0, "layer": pre, "path": "effects/#1/echoTime", "value": -0.3})).unwrap(); + let fr = s.project.comp(outer).unwrap().frame_rate; + let t = |f: i64| fr.tick_of(f); + let mut p0 = (*s.project).clone(); + for (l, i, o) in [(a, 0, 30), (b, 30, 60)] { + let l = p0.comp_mut(inner).unwrap().layer_mut(l).unwrap(); + (l.start_time, l.in_point, l.out_point) = (t(i), t(i), t(o)); + } + let p0 = Arc::new(p0); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: outer.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, outer); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let mut p1 = p0.clone(); + Arc::make_mut(&mut p1).comp_mut(inner).unwrap().layer_mut(b).unwrap().out_point = t(45); + let same = |i: i64| render_frame(&p0, outer, t(i), 1.0).data == render_frame(&p1, outer, t(i), 1.0).data; + assert!(!same(65), "Echo at frame 65 shows frame 56, which the trim removed"); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, outer), 0)); + assert!(!kept.contains(&65), "{kept:?}"); + for i in kept { + assert!(same(i), "kept frame {i} renders differently after the edit"); + } +} + +/// A keyframe edit beside muted keys reaches back to the nearest keys that play: muted keys are +/// skipped, so the segment the edited key ends can start far before the two keys before it. +/// Every frame kept renders as before. +#[test] +fn a_keyframe_edit_beside_muted_keys_reaches_the_keys_that_play() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{LayerId, Value}; + use effectcraft_engine::render::render_frame; + use serde_json::json; + let mut s = Session::default(); + let new = json!({"name": "A", "width": 16, "height": 16, "duration": 4, "frameRate": 30}); + let cid = ItemId(s.execute("comp.new", new).unwrap()["comp"].as_u64().unwrap()); + let l = LayerId(s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 4, "height": 4})).unwrap()["layer"].as_u64().unwrap()); + let fr = s.project.comp(cid).unwrap().frame_rate; + // Keys at frames 0, 20, 40, 60, 80, 100; the ones at 20 and 40 muted. + for i in 0..6 { + let add = json!({"layer": l.0, "path": "transform/position", "time": fr.tick_of(i * 20).seconds(), "value": [2.0 + i as f64 * 2.0, 8.0, 0.0]}); + s.execute("prop.addKey", add).unwrap(); + } + let mut p0 = (*s.project).clone(); + for k in p0.comp_mut(cid).unwrap().layer_mut(l).unwrap().props.prop_mut("transform/position").unwrap().keys.iter_mut().skip(1).take(2) { + k.muted = true; + } + let p0 = Arc::new(p0); + let mut p1 = p0.clone(); + Arc::make_mut(&mut p1).comp_mut(cid).unwrap().layer_mut(l).unwrap().props.prop_mut("transform/position").unwrap().keys[3].value = + Value::Vec3([8.0, 2.0, 0.0]); + let same = |i: i64| render_frame(&p0, cid, fr.tick_of(i), 1.0).data == render_frame(&p1, cid, fr.tick_of(i), 1.0).data; + assert!(!same(10), "the edited key's segment starts at frame 0"); + let f = Frames::default(); + let k = |content: u64, frame: i64| FrameKey { content, comp: cid.0, ..key(0, frame, 0) }; + let id0 = f.content_of(&p0, 1, cid); + (0..120).for_each(|i| insert(&f, k(id0, i))); + let kept = f.cached_frames(&k(f.content_of(&p1, 2, cid), 0)); + assert!(!kept.contains(&10), "{kept:?}"); + for i in kept { + assert!(same(i), "kept frame {i} renders differently after the edit"); + } +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f324041d6..2546a473e 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -240,7 +240,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Browser Storage", "ブラウザーストレージ"), ("Browse…", "参照…"), ("Brush Dynamics", "ブラシのダイナミクス"), - ("Caching frames…", "フレームをキャッシュ中…"), + ("Caching frames", "フレームをキャッシュ中"), ("Cancel", "キャンセル"), ("Import After Effects Project", "After Effectsプロジェクトを読み込み"), ("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"), @@ -1371,7 +1371,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Browser Storage", "浏览器存储"), ("Browse…", "浏览…"), ("Brush Dynamics", "画笔动态"), - ("Caching frames…", "正在缓存帧…"), + ("Caching frames", "正在缓存帧"), ("Cancel", "取消"), ("Import After Effects Project", "导入 After Effects 项目"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"), @@ -2481,7 +2481,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("Browser Storage", "Webbläsarlagring"), ("Browse…", "Bläddra…"), ("Brush Dynamics", "Penseldynamik"), - ("Caching frames…", "Cachelagrar bildrutor…"), + ("Caching frames", "Cachelagrar bildrutor"), ("Cancel", "Avbryt"), ("Import After Effects Project", "Importera After Effects-projekt"), ( @@ -3745,7 +3745,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Browser Storage", "瀏覽器儲存"), ("Browse…", "瀏覽…"), ("Brush Dynamics", "筆刷動態"), - ("Caching frames…", "正在快取幀…"), + ("Caching frames", "正在快取幀"), ("Cancel", "取消"), ("Import After Effects Project", "匯入 After Effects 專案"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"), @@ -4803,7 +4803,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Browser Storage", "Сховище браузера"), ("Browse…", "Огляд…"), ("Brush Dynamics", "Динаміка пензля"), - ("Caching frames…", "Кешування кадрів…"), + ("Caching frames", "Кешування кадрів"), ("Cancel", "Скасувати"), ("Import After Effects Project", "Імпортувати проєкт After Effects"), ( @@ -5378,7 +5378,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Browser Storage", "Armazenamento do navegador"), ("Browse…", "Procurar…"), ("Brush Dynamics", "Dinâmica do pincel"), - ("Caching frames…", "Armazenando quadros em cache…"), + ("Caching frames", "Armazenando quadros em cache"), ("Cancel", "Cancelar"), ("Import After Effects Project", "Importar projeto do After Effects"), ( @@ -6144,7 +6144,7 @@ const POLISH: &[(&str, &str)] = &[ ("Browser Storage", "Pamięć przeglądarki"), ("Browse…", "Przeglądaj…"), ("Brush Dynamics", "Dynamika pędzla"), - ("Caching frames…", "Buforowanie klatek…"), + ("Caching frames", "Buforowanie klatek"), ("Cancel", "Anuluj"), ("Import After Effects Project", "Importuj projekt After Effects"), ( @@ -6894,7 +6894,7 @@ const CZECH: &[(&str, &str)] = &[ ("Browser Storage", "Úložiště prohlížeče"), ("Browse…", "Procházet…"), ("Brush Dynamics", "Dynamika štětce"), - ("Caching frames…", "Ukládání snímků do mezipaměti…"), + ("Caching frames", "Ukládání snímků do mezipaměti"), ("Cancel", "Zrušit"), ("Import After Effects Project", "Importovat projekt After Effects"), ( @@ -7463,7 +7463,7 @@ const SPANISH: &[(&str, &str)] = &[ ("Browser Storage", "Almacenamiento del navegador"), ("Browse…", "Examinar…"), ("Brush Dynamics", "Dinámica del pincel"), - ("Caching frames…", "Almacenando fotogramas en caché…"), + ("Caching frames", "Almacenando fotogramas en caché"), ("Cancel", "Cancelar"), ("Import After Effects Project", "Importar proyecto de After Effects"), ( @@ -8023,7 +8023,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Browser Storage", "ბრაუზერის მეხსიერება"), ("Browse…", "არჩევა…"), ("Brush Dynamics", "ფუნჯის დინამიკა"), - ("Caching frames…", "კადრების ქეშირება…"), + ("Caching frames", "კადრების ქეშირება"), ("Cancel", "გაუქმება"), ("Import After Effects Project", "After Effects-ის პროექტის იმპორტი"), ( @@ -8991,7 +8991,7 @@ const FRENCH: &[(&str, &str)] = &[ ("Browser Storage", "Stockage du navigateur"), ("Browse…", "Parcourir…"), ("Brush Dynamics", "Dynamique du pinceau"), - ("Caching frames…", "Mise en cache des images…"), + ("Caching frames", "Mise en cache des images"), ("Cancel", "Annuler"), ("Import After Effects Project", "Importer un projet After Effects"), ( diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index fb3b9063c..0c87721db 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -240,7 +240,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(pl) => pl, None => effectcraft_engine::preview::plan(&o, &c, app.session.time()), }; - let cached_set = app.frames.cached_frames(&app.shown_series(cid)); + let cached_set = app.frames.cached_frames(&app.preview_series(cid, &o)); let total = pl.frames().count(); let cached = pl.frames().filter(|f| cached_set.binary_search(f).is_ok()).count(); let bar = Rect::from_min_size(pos2(rect.min.x + 12.0, yy), vec2(rect.width() - 24.0, 6.0)); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bf921535a..3da62d82b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1896,10 +1896,24 @@ fn phase_tail(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0); // Status chips (render time / caching). + // The caching chip stays up a moment after the last wait, so gaps between uncached frames + // don't make it flicker. + // ponytail: fixed 0.6 s hold; tie it to the prefetch queue being empty if gaps outlast it. + let chip_id = egui::Id::unique("viewer-caching-chip"); + let now = ctx.input(|i| i.time); if app.playback.playing && app.playback.waiting { - let r = Rect::from_min_size(area.min + vec2(10.0, 10.0), vec2(150.0, 22.0)); - painter.rect_filled(r, 11.0, Color32::from_black_alpha(170)); - painter.text(r.center(), Align2::CENTER_CENTER, crate::i18n::tr("Caching frames…"), Tokens::ui(11.5), t.cache_green); + ctx.data_mut(|d| d.insert_temp(chip_id, now + 0.6)); + } + let until = ctx.data(|d| d.get_temp::(chip_id)).unwrap_or(0.0); + if app.playback.playing && now < until { + if !app.playback.waiting { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(until - now)); + } + let text = painter.layout_no_wrap(crate::i18n::tr("Caching frames").to_string(), Tokens::ui(10.5), t.text_dim); + let r = Rect::from_min_size(area.min + vec2(8.0, 8.0), vec2(text.size().x + 30.0, 18.0)); + painter.rect_filled(r, 9.0, Color32::from_black_alpha(140)); + ui.put(Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(10.0, 10.0)), egui::Spinner::new().size(10.0).color(t.cache_green)); + painter.galley(pos2(r.min.x + 22.0, r.center().y - text.size().y / 2.0), text, t.text_dim); } if app.video_preview_off().is_some() { let r = Rect::from_min_size(area.min + vec2(10.0, 10.0), vec2(330.0, 22.0));