From 30d6c6e1d345d3d4b35929b2a6eea3090b47057a Mon Sep 17 00:00:00 2001 From: Nazar <120062675+nazar-gavrylyk@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:46:39 +0100 Subject: [PATCH] Frames cache: edits keep the frames they can't reach (#2795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * frames: keep cached frames an edit can't reach Nudging, trimming or splitting a layer re-rendered the whole comp. The RAM preview now carries every frame whose stack of active layers is unchanged to the new comp identity; only the segments an edit touches re-render. * disk cache: never drop frame writes; save frames kept after edits Frames get their own write lane (bounded by bytes, written before layer buffers), so a burst of layer writes no longer drops frames and leaves gaps in the blue bar after reopening. Frames an edit kept in RAM are written to disk under the comp's new content key when idle. * frames: music and hidden layers keep the caches Layers that can't change pixels (audio-only footage, AV layers with Video off, when nothing reads them and there are no expressions) no longer count for the RAM preview's dirty segments or the disk cache's comp and footage keys. Adding, trimming or moving music keeps both caches. RENDER_VERSION bumps to effectcraft-render-3 for the new key formula. * ui-egui: background caching benchmark cache_bench fills a comp's RAM preview the way idle caching does and prints frames/s with decoder, footage-cache and disk-cache counters. * frames: soloed music and hidden layers aren't silent Solo hides every other layer, so soloing a music track or a layer with Video off changes the picture. silent_layers no longer drops soloed layers, so the RAM preview and the disk cache see Solo toggles. * disk cache: audio files a layer reads stay in the layer salt Audio-only footage left the disk layer salt so importing music keeps the keys, but Audio Spectrum and Audio Waveform draw from an audio layer. Footage another layer reads (or any, when a read layer is a precomp) counts again, so overwriting that file doesn't reuse stale buffers. * disk cache: retry frame writes the queue refused put_frame reports whether the frame is on disk or queued. persist_missing forgets the key of a refused write, so a full frame lane no longer leaves RAM frames that are never written. * ui-egui: cache_bench measures idle caching; checks --comp and --slots Frames go through request_background with one slot per frame thread, as Cache Frames When Idle does. An unknown --comp is an error (the active comp is used only without --comp), --slots is at least 1, and the disk report flushes queued writes instead of sleeping. * disk cache: RENDER_VERSION effectcraft-render-6 This branch changes the cache-key formula (silent layers, Solo, read audio in the footage salt), and upstream #2759 already took render-5 for the clip-clock change, so entries from either must not be reused. * frames: rewrite RAM frames the disk cache evicted persist_missing kept every disk key it ever queued, so a frame the disk cache evicted later was never written back. The disk cache now tracks frame writes still queued or in progress (frame_pending), and persist_missing only remembers keys while it converts them. * frames: idle passes continue while RAM frames wait for disk persist_missing reports whether there may be more to write (a content key being computed, frames being converted). Cache Frames When Idle then schedules another pass, so a refused write is retried without waiting for a UI event, and the passes stop once every frame is on disk or queued. * frames: rewrite an evicted frame once, not on every idle pass A disk cache smaller than the RAM preview, or a frame too large for it, made idle passes write, evict and rewrite the same frames every 500 ms. Each disk key is now written at most twice under one disk limit (the write and one rewrite after an eviction); a new limit resets the count. The persist test waits for the conversion to forget its keys before checking that no work is left. * frames: the work area doesn't change pixels Setting the work area (B, N) cleared every cached frame, in RAM and on disk: it was compared with the comp's pixel settings and hashed into the disk key. It is a preview and render range only, like the Resolution, so it no longer counts. * frames: audio edits keep frames unless something draws the audio Changing a clip's Audio Levels, its audio effects or its Audio switch cleared the clip's cached frames, and the comp's disk cache, although nothing drew that audio. The audio now counts only for the layers whose audio is heard (Project::heard_layers): those a layer picks in an effect (an Audio Waveform or Spectrum, even on itself), the layers of a picked precomp, and every layer while an expression (which can read Audio Levels) exists. Music in a precomp that a waveform in the outer comp picks is no longer treated as silent either. * frames: an expression only reaches the frames of its own layer One expression anywhere in the project made every edit clear the comp's whole cache, and so did editing a layer another layer reads (its parent, matte, or a layer it picks). Expressions can read anything, but they only draw in their own layer: an edit now re-renders the frames where it changed something, plus those of the layers with an expression and of the layers that read a reached layer. With expressions, an edit elsewhere (another comp, an item's name, a marker, music) reaches only the expression layers' frames. * frames: an edit inside a precomp re-renders only where the precomp shows it Editing anything inside a precomp cleared every cached frame of the comps that nest it, even a music level in it. Project::dirty_spans now follows precomps: it takes the spans their comp changed in, shifts and scales them by the precomp layer's start and stretch (its whole span under a time remap or Responsive Design timing), and keeps the outer comp's other frames. Layers that read a changed precomp (a matte, a waveform) re-render where they are active. * disk cache: keep the frames an edit can't reach The disk cache keys frames on a hash of the whole comp, so every edit lost the frames that were only on disk (the blue bar), even a rename; only those still in RAM were written again under the new key. When an edit keeps RAM frames, it now also queues a disk carry: on the frame pool, each kept frame of every disk series in use for that comp is hard linked (or copied) to its new key with DiskCache::link_frame, oldest edit first. The old entries stay, so undo finds them too. * frames: a keyframe edit re-renders only around the keys it changed Changing one keyframe (its value, interpolation or ease, in the Timeline or the Graph Editor) re-rendered the layer's whole length, so a key on a full-length layer cleared the whole cache. A layer that differs only in keyframes of its Transform properties now re-renders from two keys before the first changed key to two after the last (auto-Bezier and continuous tangents reach the neighbouring segments), to its in or out point past the first or last key. Static values, expressions, roving keys, time remap and time effects still reach the whole layer; effect keyframes do too for now (simulations and time effects read other frames). * frames: music volume keeps frames while no expression reads audio Any expression in the project (a wiggle on another layer, even a disabled one) made every layer's audio count as read, so changing a music layer's Audio Levels re-rendered every expression layer and changed the comp's disk key. Audio now counts for expressions only when one may read it (Heard::audio_exprs): its text names audio or walks properties by index. An audio-only change nothing reads no longer counts as a change elsewhere in dirty_spans either. * frames: a keyframe edit with few keys or beside a self-contained expression keeps the other frames Changing a Scale key of a layer with two or three keys re-rendered the layer's whole length: with fewer than two keys before (or after) the changed one, the span fell back to the in (or out) point instead of the first (or last) key, past which the value holds. And any layer with an expression re-rendered on every edit, so a wiggle on a full-length layer cleared the whole cache. An expression layer is now reached by edits elsewhere only when its text may read outside its layer (it names a comp, layer, footage, the project or its source); its parent and the layers it picks are still read through referenced_layers. * viewer: the caching chip stays up between uncached frames, with a spinner The "Caching frames…" chip showed only while playback waited for a frame, so during caching it flickered on and off with every frame that arrived. It now stays up 0.6 s after the last wait (and hides at once when playback stops). It is also smaller and quieter: a spinner, then "Caching frames" without the ellipsis in dimmed smaller text, on a fainter chip sized to its text. * preview: the Preview panel's cache bar counts the preview's Resolution With a preview Resolution set (Half, Third…), the frames a preview plays are cached at that scale, but the Preview panel's bar counted the series the viewer shows. When playback stopped, the viewer went back to the comp's Resolution and the bar dropped from full to the frames cached there. It now counts the preview's own series (Auto still follows the viewer). The viewer and the Timeline's cache bar are unchanged. * Contributors: add nazar-gavrylyk's name * frames: an expression reading its own layer's markers or audio re-renders that layer With expressions in the project, a layer's markers count when comparing it, and its audio counts when an expression may read audio, so a marker or volume edit re-renders the edited layer over its own span. Comp markers still count only through layers whose expressions name thisComp. * frames: a keyframe edit on a layer with an expression re-renders the layer An expression on the keyed layer can read the keyed property at other times (Rotation reading Position with valueAtTime), so the frames past the changed keys may change too. * cache_bench: read decoder counters through effectcraft-media Only crates/media, crates/export and crates/interchange may depend on the FilmCraft crates. * cache_bench: clips end where the next one starts * frames: write-back uses the worker's disk keys while a Roto Brush model is active persist_missing keyed disk frames on FrameKey::opts, which also carries the Roto Brush model stamp; the worker and disk_frames key on the render opts' hash alone. With a model active every RAM frame looked missing, was written again under keys nobody reads, and recorded a second disk series. * frames: an edit two precomps down reaches the outer comp A precomp layer whose comp Arc didn't change was skipped, but the comps nested in it may have (an edit in Inner leaves Mid's Arc as it was), so Outer kept every frame. Every nested comp is walked now, each once per edit. * cache_bench: stop when the disk cache can't be cleared * frames: one disk carry job at a time carry_disk runs on every timeline paint and idle pass, and spawned a pool job whenever carries were queued; during a drag the extra jobs waited on the carry lock and held frame-pool threads from the viewer. A scheduled flag keeps one job, which drains the queue in order until it's empty. * frames: a panicking disk carry is skipped, not left holding the job The carry job clears carry_scheduled only on its normal exit; a panic in a carry would have left it set for the session, so no later carry ran. Each carry is caught like the content key's hashing, so the job always reaches its exit. * frames: an expression reading its own in point or index re-renders its layer A trim or rename kept the layer's frames outside the cut when the layer drew the same apart from its span, but an expression on it can read its own inPoint, outPoint or name (a fade from inPoint), so every frame of the layer changes. Such a layer is no longer treated as only respanned. An expression reading `index` reads the layers above it too: a layer added or removed there shifts it, so `index` now counts as reading outside the layer. Co-Authored-By: Claude Opus 5.5 * frames: an edit of a comp reaches the expressions of the precomps it nests An expression in a precomp can read the comp that nests it (`comp("Main").layer("Controls")`), but the walk into nested comps only counted changes outside the edited top comp. Editing a hidden Controls layer there (or any layer, when the precomp is time-shifted or reads it at other times) kept every precomp frame in RAM and linked them all on disk under the new key. Nested comps now see an edit of the comp above them as an edit elsewhere. Co-Authored-By: Claude Opus 5.5 * frames: an edit in a precomp under a time effect reaches its whole layer A precomp layer running Echo, Posterize Time, Time Displacement or another effect that reads the layer at other times shows a nested change far from where it maps, but only the mapped span (a nested frame either side) re-rendered. Such a layer now gives its whole span, like a time remap. `reads_other_frames` lists those effects (every `ec.time.` and `ec.sim.` effect and the others that call `self_at`); keyframe spans use it too. Co-Authored-By: Claude Opus 5.5 * frames: a keyframe edit beside muted keys re-renders the whole layer Muted keys are skipped when a property is evaluated, so the keys that play around an edited key can be any distance away, but the span counted two stored keys either side. A segment from an earlier playing key kept its stale frames. Muted keys now give the layer's whole span, like roving keys. Co-Authored-By: Claude Opus 5.5 * frames: one echo in the precomp time-effect test (three saturate alike), rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + contributors/people.toml | 3 + crates/effects/src/audio_fx.rs | 2 +- crates/media/src/lib.rs | 2 + crates/project/src/comp.rs | 186 +++- crates/project/src/dirty.rs | 112 +++ crates/project/src/item.rs | 105 ++ crates/project/src/layer.rs | 242 ++++- crates/project/src/lib.rs | 8 +- crates/project/src/tests.rs | 72 ++ crates/render/src/disk_cache.rs | 273 +++++- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/examples/cache_bench.rs | 177 ++++ crates/ui-egui/src/app/prefetch.rs | 6 + crates/ui-egui/src/app/viewer_frames.rs | 9 + crates/ui-egui/src/frames.rs | 263 ++++- crates/ui-egui/src/frames/tests.rs | 1160 ++++++++++++++++++++++- crates/ui-egui/src/i18n/ui.rs | 22 +- crates/ui-egui/src/panels/misc.rs | 2 +- crates/ui-egui/src/panels/viewer.rs | 20 +- 20 files changed, 2536 insertions(+), 130 deletions(-) create mode 100644 crates/project/src/dirty.rs create mode 100644 crates/ui-egui/examples/cache_bench.rs 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));