From 6c3a08e7802c7a71b496aaf75d8e9f23f34f0bf2 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Sat, 17 Oct 2026 02:38:31 +0530 Subject: [PATCH] Preview: an edit keeps frames only outside Solo changes and the shutter (#1641 review) Turning Solo on or off hides or shows every other layer at every frame, so it now recaches the whole comp. The kept span is widened by the comp's shutter (phase and angle), since a frame's motion blur samples cameras, lights and transforms up to three frames away. Comp::any_solo is shared with the renderer so both read Solo the same way. Co-Authored-By: Claude Opus 5.5 --- crates/project/src/lib.rs | 25 +++++++--- crates/render/src/lib.rs | 2 +- crates/render/src/three_d/adv/scene.rs | 2 +- crates/render/src/three_d/compose.rs | 2 +- crates/ui-egui/src/frames.rs | 64 ++++++++++++++++++++++++-- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index d80939f2..2d01a313 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -898,11 +898,11 @@ impl Comp { && *essential == o.essential } - /// 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. + /// 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 { g.children.iter().any(|c| match c { @@ -915,7 +915,8 @@ impl Comp { Node::Group(g) => reads(g, changed), }) } - if !self.same_settings(o) { + // Solo hides every other layer at every frame. + if !self.same_settings(o) || 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::>(); @@ -939,7 +940,17 @@ impl Comp { if self.layers.iter().chain(&o.layers).any(reaches) { return None; } - span + // 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. + 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)) + } + + /// Some layer is soloed: only the soloed layers draw. + pub fn any_solo(&self) -> bool { + self.layers.iter().any(|l| l.switches.solo && l.source.is_av()) } /// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cfefd02d..c2c2f71b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2100,7 +2100,7 @@ impl<'a> Renderer<'a> { /// applied). pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> { let (comp, t) = (ctx.comp, ctx.time); - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); + let any_solo = comp.any_solo(); comp.layers .iter() .rev() diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 34517933..2e6ffcb0 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -873,7 +873,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; - let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); + let any_solo = nctx.comp.any_solo(); for l in nctx.comp.layers.iter().rev() { if !l.is_active_at(t) || l.is_light() diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 831ab3ec..ec7f76fe 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -497,7 +497,7 @@ fn collapsed_items<'a>( let op = ctx.opacity(layer) as f32 * r.opacity_mul(); let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); + let any_solo = nctx.comp.any_solo(); let nested: Vec<&'a Layer> = nctx .comp .layers diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 039955fc..57d4c99d 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -747,8 +747,8 @@ 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 + // 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). if let Some((base_id, base)) = bases.get(&comp.0) && let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp)) @@ -2058,6 +2058,61 @@ mod tests { assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo"); } + /// Every frame an edit keeps renders as a fresh render of the edited project does: Solo + /// hides the other layers at every frame, and a camera reaches the frames whose shutter + /// samples it (#1641 review). + #[test] + fn the_frames_an_edit_keeps_match_a_fresh_render() { + use effectcraft_engine::Session; + use effectcraft_engine::render::NoFootage; + use serde_json::json; + // Cache every frame of `comp`, run `edit`, check the frames kept against fresh renders. + fn kept(s: &mut Session, comp: ItemId, edit: serde_json::Value) -> Vec { + let (fr, n) = s.project.comp(comp).map(|c| (c.frame_rate, c.frame_rate.frame_at(c.duration))).unwrap(); + let f = Frames::default(); + let before = f.content_of(&s.project, s.revision, comp); + (0..n).for_each(|i| insert(&f, key(before, i, 0))); + let old = s.project.clone(); + s.execute(edit["cmd"].as_str().unwrap(), edit["params"].clone()).unwrap(); + let after = f.content_of(&s.project, s.revision, comp); + let kept: Vec = (0..n).filter(|&i| f.is_cached(&key(after, i, 0))).collect(); + let render = |p, i| Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(comp, fr.tick_of(i)); + for &i in &kept { + assert_eq!(render(&old, i), render(&s.project, i), "kept frame {i} draws other pixels now"); + } + kept + } + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let mut s = Session::default(); + + // Solo on a 2–3 s layer hides the 10 s underlay everywhere; turning it off shows it again. + let comp = ItemId(id(s.execute("comp.new", json!({"name": "Solo", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp")); + s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(); + let short = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer"); + s.execute("layer.timing", json!({"layers": [short], "in": 2, "out": 3})).unwrap(); + let solo = |on: bool| json!({"cmd": "layer.setSwitch", "params": {"layers": [short], "switch": "solo", "value": on}}); + assert_eq!(kept(&mut s, comp, solo(true)), Vec::::new()); + assert_eq!(kept(&mut s, comp, solo(false)), Vec::::new()); + + // 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( + s.execute("comp.new", json!({"name": "Cam", "width": 16, "height": 16, "duration": 4, "frameRate": 30, "shutterAngle": 720, "shutterPhase": 360})) + .unwrap(), + "comp", + )); + let plane = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer"); + for sw in ["threeD", "motionBlur"] { + s.execute("layer.setSwitch", json!({"layers": [plane], "switch": sw, "value": true})).unwrap(); + } + let cam = id(s.execute("layer.newCamera", json!({"comp": comp.0})).unwrap(), "layer"); + s.execute("layer.timing", json!({"layers": [cam], "in": 2, "out": 3})).unwrap(); + let moved = + kept(&mut s, comp, json!({"cmd": "prop.set", "params": {"comp": comp.0, "layer": cam, "path": "transform/position", "value": [40, 8, -30]}})); + assert!(moved.contains(&0) && moved.contains(&100), "frames far from the camera stay: {moved:?}"); + assert!(!(56..=91).any(|f| moved.contains(&f)), "frames whose shutter samples the camera render again: {moved:?}"); + } + #[test] fn short_solid_additions_and_removals_keep_unaffected_frames() { use effectcraft_engine::Session; @@ -2071,11 +2126,12 @@ mod tests { (0..600).for_each(|n| insert(&f, key(content, n, 0))); }; let cached = |s: &Session| f.cached_frames(&key(f.content_of(&s.project, s.revision, comp), 0, 0)); - let kept: Vec = (0..=58).chain(92..600).collect(); + // 2–3 s, a quarter frame of the default shutter and a frame of rounding either side. + let kept: Vec = (0..=57).chain(92..600).collect(); cache_all(&s); let solid = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap(); - assert_eq!(cached(&s), kept, "adding a source invalidates only frames 59..=91"); + assert_eq!(cached(&s), kept, "adding a source invalidates only frames 58..=91"); cache_all(&s); s.execute("edit.undo", json!({})).unwrap(); s.execute("edit.undo", json!({})).unwrap();