From 17edbbfffff084155706581cbbd6e9965f13834f Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Fri, 16 Oct 2026 16:43:57 +0530 Subject: [PATCH] Preview: an edit keeps the cached frames outside the layers it changes (#1641) The RAM preview keyed every frame by one identity of the whole comp, so any edit (a cut, a trim, a nudge) made every cached frame a miss and the whole comp recached. When only some of a comp's layers change, frames outside their In-Out span now move to the new identity. Settings changes, reorders, expressions, and edits that reach other layers (parents, track mattes, effect layer pickers) still recache everything. Co-Authored-By: Claude Opus 5.5 --- crates/project/src/lib.rs | 55 +++++++++++++++++++++++++++--- crates/ui-egui/src/frames.rs | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d78216..d80939f2 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -839,7 +839,12 @@ 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. pub fn same_pixels(&self, o: &Comp) -> bool { - // Every field (no `..`: a new field must be considered here); the layers last. + self.same_settings(o) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) + } + + /// [`Self::same_pixels`] but for the layers. + fn same_settings(&self, o: &Comp) -> bool { + // Every field (no `..`: a new field must be considered here). let Comp { width, height, @@ -849,7 +854,7 @@ impl Comp { display_start, background, work_area, - layers, + layers: _, markers, shutter_angle, shutter_phase, @@ -891,8 +896,50 @@ impl Comp { && *global_light == o.global_light && *guides == o.guides && *essential == o.essential - && layers.len() == o.layers.len() - && layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) + } + + /// The comp-time span (`in_point..out_point` of every layer changed, added or removed) + /// 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, 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 { + 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), + }) + } + if !self.same_settings(o) { + 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; + } + 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. + 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)) { + continue; + } + 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)))); + } + 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; + } + span } /// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a0..24dabf48 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -227,6 +227,19 @@ impl Cache { } self.order.retain(|k| !gone.contains(&k.content)); } + /// Move the frames of identity `from` that `keep` accepts to identity `to` (in place in the + /// eviction order), unless `to` already has them. + fn carry(&mut self, from: u64, to: u64, keep: impl Fn(i64) -> bool) { + for k in self.order.iter_mut().filter(|k| k.content == from && keep(k.frame)) { + let moved = FrameKey { content: to, ..*k }; + if !self.map.contains_key(&moved) + && let Some(img) = self.map.remove(k) + { + self.map.insert(moved, img); + *k = moved; + } + } + } fn evict_to_budget(&mut self) { while self.bytes > self.budget { let Some(old) = self.victim().and_then(|i| self.order.remove(i)) else { break }; @@ -731,6 +744,21 @@ impl Frames { { return (*base_id, Some(base.clone())); } + // Only layers of this comp changed: its frames outside their span (a frame either side + // for motion blur) keep drawing the same pixels, so a cut or trim recaches only the + // layers it touched (#1641). + 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, |id, c| match base.item(id).map(|i| &i.kind) { + Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => Arc::as_ptr(b), + _ => Arc::as_ptr(c), + }) == *base_id + && let Some((s, e)) = new.changed_span(old) + && 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); + } if bases.len() > MAX_KEEPERS { bases.clear(); } @@ -1990,6 +2018,43 @@ mod tests { 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). + #[test] + fn an_edit_keeps_the_frames_outside_the_layers_it_changed() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let comp = ItemId(id(s.execute("comp.new", json!({"name": "Edit", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp")); + let a = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer"); + let b = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer"); + s.execute("layer.timing", json!({"layers": [a], "in": 0, "out": 4})).unwrap(); + s.execute("layer.timing", json!({"layers": [b], "in": 4, "out": 10})).unwrap(); + let (fr, out) = s.project.comp(comp).map(|c| (c.frame_rate, c.layers.iter().find(|l| l.id.0 == a).unwrap().out_point)).unwrap(); + let frames: Vec = (0..fr.frame_at(Tick::from_seconds_f64(10.0))).collect(); + let f = Frames::default(); + let cache_all = |s: &Session| { + let c = f.content_of(&s.project, s.revision, comp); + frames.iter().for_each(|&n| insert(&f, key(c, n, 0))); + c + }; + let cached = |c: u64| frames.iter().filter(|&&n| f.is_cached(&key(c, n, 0))).copied().collect::>(); + let before = cache_all(&s); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + 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"); + + 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"); + } + #[test] fn markers_keep_the_frames() { use effectcraft_engine::Session;