From 385b9530a1f6310556351e0cdb84f6f49deee4e6 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:00:14 +0530 Subject: [PATCH] #3535: Warp Stabilizer and 3D Camera Tracker keys cover a precomp's contents On a precomp layer the key covered only the nested comp's size, rate and duration, so an edit inside the precomp kept a stale analysis. The key now writes the precomp layer's Essential Properties and what its comp draws, field by field and recursively. Each comp is written once per key and nesting is capped, so shared, deep or cyclic precomps are keyed in bounded time. Docs say what clears an analysis. --- crates/engine/src/analysis/frames_key.rs | 204 +++++++++++++++++++-- crates/engine/src/analysis/warp.rs | 7 +- crates/engine/src/tests/analysis_inputs.rs | 69 +++++++ crates/engine/src/tests/camera_track.rs | 9 + docs/agents.md | 8 +- 5 files changed, 276 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs index 7fc97f89e..9e80a9dc7 100644 --- a/crates/engine/src/analysis/frames_key.rs +++ b/crates/engine/src/analysis/frames_key.rs @@ -2,26 +2,29 @@ //! Camera Tracker and Roto Brush / Object Matte. //! //! The key hashes a text this module writes field by field. The source item (footage and its -//! settings, solid), the Time Remap property and the masks and effect groups (their properties, -//! keyframes and values) are destructured or matched in full, and each variant is written as a -//! fixed word: a new field or variant doesn't compile until it is decided here, written if it -//! changes the layer's frames, left out (with a reason) if it doesn't. Renaming a field or variant -//! doesn't change keys. Of the layer and of a precomp, only the fields named in `layer_frames` are -//! read (a precomp's contents are #3535). The -//! text leaves out observations (whether footage is available) and data that never changes pixels -//! (names, labels, UI hints, markers), so they never drop saved analyses (#3534, #3536). +//! settings, solid), the Time Remap property, the masks and effect groups (their properties, +//! keyframes and values) and a precomp's comp and layers are destructured or matched in full, and +//! each variant is written as a fixed word (or, for an enum the project file names, its name +//! there): a new field or variant doesn't compile until it is decided here, written if it changes +//! the layer's frames, left out (with a reason) if it doesn't. Renaming a field or variant doesn't +//! change keys. Of the analysed layer itself, only the fields named in `layer_frames` and +//! [`analysis_key`] are read. The text leaves out observations (whether footage is available) and +//! data that never changes pixels (names, labels, UI hints, markers), so they never drop saved +//! analyses (#3534, #3536). //! //! Keys saved by 0.22–0.24 hashed derived `Debug` text instead. [`legacy`] still recognises //! them, and a matching saved key is kept rather than rewritten: frozen Roto Brush mattes and //! segmentations hash the saved key itself. +use std::collections::HashSet; use std::fmt::Write as _; use effectcraft_color::ColorSpace; use effectcraft_keyframe::{Ease, FeatherPoint, Gradient, Interp, Keyframe, ShapePath, Value}; use effectcraft_project::{ - AlphaMode, CineonSettings, CineonUnits, Expression, FeatherFalloff, FieldOrder, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerSource, - LightKind, MaskMode, MaskMotionBlur, Node, PrimitiveKind, Project, PropGroup, Property, Solid, SourceLayer, Uid, + AlphaMode, CineonSettings, CineonUnits, Comp, Expression, FeatherFalloff, FieldOrder, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, + LayerSource, LightKind, Marker, MaskMode, MaskMotionBlur, Node, PrimitiveKind, Project, PropGroup, Property, Solid, SourceLayer, Switches, TrackMatte, Uid, + essential, styles::GlobalLight, }; use effectcraft_time::{FrameRate, Tick}; @@ -33,6 +36,10 @@ const VERSION: &str = "frames/1"; /// Property groups nest a few levels (effect ▸ group ▸ property); deeper trees are cut here. const MAX_DEPTH: usize = 32; +/// Precomps nest deeper than the renderer draws them (16) only in broken projects; deeper +/// contents are cut here. +const MAX_NESTING: usize = 32; + fn fnv(s: &str) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in s.as_bytes() { @@ -54,7 +61,7 @@ pub(crate) enum Era { } /// The signature of `layer`'s frames: its source (and the source item), In/Out, start time, -/// stretch, Time Remap and the comp frame rate. Warp Stabilizer and 3D Camera Tracker keys are +/// stretch, Time Remap and the comp frame rate. Roto Brush Input Keys and [`analysis_key`] are /// made from it. pub(crate) fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { hex(&signature_text(p, comp, layer)) @@ -87,8 +94,9 @@ pub(crate) fn input_key_of(signature: String, layer: &Layer, uid: Uid) -> String const ANALYSIS_VERSION: &str = "analysis/1"; /// What a Warp Stabilizer or 3D Camera Tracker, the instance `uid` of `layer`, analyses: its -/// input, the layer's frames ([`signature`]) under its masks and the effects above it (#3535). -/// The instance's own settings and the effects below it are not part of it. +/// input, the layer's frames ([`signature`]) under its masks and the effects above it, and a +/// precomp's contents (#3535). The instance's own settings and the effects below it are not part +/// of it. pub(crate) fn analysis_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { let mut t = Text::new(); t.word(ANALYSIS_VERSION); @@ -98,6 +106,7 @@ pub(crate) fn analysis_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) - t.field("effectsSwitch", layer.switches.effects); } t.inputs_above(layer, uid); + t.precomp(p, comp, layer); hex(&t.0) } @@ -198,7 +207,7 @@ impl Text { match p.item(item).map(|i| &i.kind) { Some(ItemKind::Footage(f)) => self.footage(f), Some(ItemKind::Solid(s)) => self.solid(s), - // A precomp's own contents aren't in the signature yet (#3535). + // Its contents are in an [`analysis_key`], not here: Roto Brush keys are made from this. Some(ItemKind::Comp(c)) => { self.word("comp"); self.field("w", c.width); @@ -394,6 +403,173 @@ impl Text { } } + /// A precomp layer's contents: its Essential Properties (they set values inside it) and what + /// its comp draws, nested precomps included. + fn precomp(&mut self, p: &Project, comp: ItemId, layer: &Layer) { + let LayerSource::Comp { item } = layer.source else { return }; + if let Some(e) = layer.props.group(essential::GROUP) { + self.word("essential"); + self.group(e, 0); + } + // A precomp never draws the comp it is in. + let mut seen = HashSet::from([comp]); + self.comp_contents(p, item, 0, &mut seen); + } + + /// What comp `id` draws. Each comp is written once per key, where it is first reached; a + /// later reference (a comp used twice, or a cycle) writes only its id, so the work is bounded + /// by the size of the project. + fn comp_contents(&mut self, p: &Project, id: ItemId, depth: usize, seen: &mut HashSet) { + self.field("contents", id.0); + // Before `seen`: a comp first reached too deep is written in full where it is shallower. + if depth >= MAX_NESTING { + self.word("tooDeep"); + return; + } + if !seen.insert(id) { + self.word("seen"); + return; + } + let Some(ItemKind::Comp(c)) = p.item(id).map(|i| &i.kind) else { + self.word("none"); + return; + }; + let Comp { + width, + height, + pixel_aspect, + frame_rate, + duration, + display_start, + background, + // The work area, Hide Shy, the viewer's Resolution and guides, and the poster frame + // only show the comp or say what to preview; they never change its frames. + work_area: _, + layers, + markers, + shutter_angle, + shutter_phase, + motion_blur_samples, + motion_blur_adaptive_limit, + renderer, + hide_shy: _, + enable_motion_blur, + enable_frame_blending, + draft_3d, + preserve_frame_rate, + preserve_resolution, + poster_time: _, + global_light, + guides: _, + essential, + resolution: _, + } = &**c; + self.field("w", width); + self.field("h", height); + self.num("par", *pixel_aspect); + self.rate("rate", *frame_rate); + self.tick("duration", *duration); + self.tick("displayStart", *display_start); + self.floats("background", background); + self.timing_markers(markers); + self.nums("shutter", &[*shutter_angle, *shutter_phase]); + self.field("samples", format_args!("{motion_blur_samples}/{motion_blur_adaptive_limit}")); + self.form("renderer", renderer); + self.field("motionBlur", enable_motion_blur); + self.field("frameBlending", enable_frame_blending); + self.field("draft3d", draft_3d); + self.field("preserveRate", preserve_frame_rate); + self.field("preserveResolution", preserve_resolution); + let GlobalLight { angle, altitude } = global_light; + self.property(angle); + self.property(altitude); + self.opt("essentialGraphics", essential.as_ref(), |t, e| t.form("definition", e)); + for l in layers.iter() { + self.nested_layer(p, l, depth, seen); + } + self.word("end"); + } + + /// A layer of a precomp. + fn nested_layer(&mut self, p: &Project, l: &Layer, depth: usize, seen: &mut HashSet) { + let Layer { + id, + // Names, labels and comments only show the layer. (An expression can find a layer + // by its name, as it can an effect, which the key leaves out too.) + name: _, + name_follows_text: _, + source, + label: _, + comment: _, + start_time, + in_point, + out_point, + stretch, + switches, + blend_mode, + preserve_transparency, + track_matte, + parent, + // Layer markers annotate it; Lock Markers protects them. + markers: _, + markers_locked: _, + auto_orient, + environment, + environment_background, + props, + } = l; + self.field("layer", id.0); + self.layer_source(source); + if let Some(item) = source.item() { + match p.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => self.footage(f), + Some(ItemKind::Solid(s)) => self.solid(s), + Some(ItemKind::Comp(_)) => self.comp_contents(p, item, depth.saturating_add(1), seen), + Some(ItemKind::Folder) | None => self.word("item=none"), + } + } + self.tick("in", *in_point); + self.tick("out", *out_point); + self.tick("start", *start_time); + self.num("stretch", *stretch); + self.switches(switches); + self.form("blend", blend_mode); + self.field("preserveTransparency", preserve_transparency); + self.opt("matte", track_matte.as_ref(), |t, m| { + let TrackMatte { layer, kind } = m; + t.field("layer", layer.0); + t.form("kind", kind); + }); + self.opt("parent", parent.as_ref(), |t, id| t.field("id", id.0)); + self.form("autoOrient", auto_orient); + self.field("environment", format_args!("{environment}/{environment_background}")); + self.group(props, 0); + } + + fn switches(&mut self, s: &Switches) { + // Lock and Shy only protect and hide the layer in the Timeline. + let Switches { video, audio, solo, locked: _, shy: _, collapse, quality, sampling, effects, frame_blend, motion_blur, adjustment, three_d, guide } = s; + self.field("switches", format_args!("{video}/{audio}/{solo}/{collapse}/{effects}/{motion_blur}/{adjustment}/{three_d}/{guide}")); + self.form("quality", quality); + self.form("sampling", sampling); + self.form("frameBlend", frame_blend); + } + + /// A comp's protected markers with a duration: they set its timing (Responsive Design). + /// Other markers annotate it. + fn timing_markers(&mut self, markers: &[Marker]) { + for m in markers.iter().filter(|m| m.protected && m.duration > Tick::ZERO) { + self.tick("timingMarker", m.time); + self.tick("for", m.duration); + } + } + + /// A value in its project-file form: enums the project file names, and the Essential Graphics + /// definition. + fn form(&mut self, name: &str, v: &T) { + self.str(name, &serde_json::to_string(v).unwrap_or_default()); + } + fn group(&mut self, g: &PropGroup, depth: usize) { // The uid, name and colour label identify and label the group; they never change pixels. let PropGroup { uid: _, match_id, name: _, kind, enabled, label: _, children } = g; diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 10b9a0ac1..45af7f172 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -6,10 +6,9 @@ //! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the //! stabilization plan once to validate and cache the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of -//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap, its masks and -//! the effects above the Warp Stabilizer: `analysis::frames_key::analysis_key`, shared with the 3D -//! Camera Tracker). Edits that change -//! those clear the analysis (`invalidate`, run after every edit) and queue the effect for +//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap, its masks, the +//! effects above the Warp Stabilizer and a precomp's contents: `analysis::frames_key::analysis_key`, +//! shared with the 3D Camera Tracker). Edits that change those clear the analysis (`invalidate`, run after every edit) and queue the effect for //! re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses //! automatically too). Footage going offline is not such a change. diff --git a/crates/engine/src/tests/analysis_inputs.rs b/crates/engine/src/tests/analysis_inputs.rs index 414ff0939..ee75bead2 100644 --- a/crates/engine/src/tests/analysis_inputs.rs +++ b/crates/engine/src/tests/analysis_inputs.rs @@ -126,3 +126,72 @@ fn masks_and_effects_above(saved: Saved) { assert_eq!(analysed(&s), [true, true]); check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]); } +#[test] +fn a_precomps_contents_are_part_of_the_input() { + for saved in [Saved::Input, Saved::Frames] { + precomp_contents(saved); + } +} + +fn precomp_contents(saved: Saved) { + let mut s = open(ANALYSED); + // Pre-compose the clip, leaving its masks and effects on the precomp layer. + let r = run(&mut s, "layer.precompose", json!({"layers": [CLIP.0], "mode": "leave"})); + let inner = ItemId(r["comp"].as_u64().unwrap()); + let inner_layer = |s: &Session, comp: ItemId| s.project.comp(comp).unwrap().layers[0].id.0; + let clip = inner_layer(&s, inner); + analyse(&mut s, saved); + // Edits inside the precomp change the frames. + check(&mut s, "prop.set", json!({"comp": inner.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]); + check(&mut s, "effect.apply", json!({"comp": inner.0, "layers": [clip], "effect": "ec.blur.gaussian"}), [false, false]); + check(&mut s, "layer.timing", json!({"comp": inner.0, "layers": [clip], "out": 0.25}), [false, false]); + // A name, label or comment inside it doesn't. + run(&mut s, "layer.rename", json!({"comp": inner.0, "layer": clip, "name": "Inner"})); + assert_eq!(analysed(&s), [true, true]); + + // A precomp inside the precomp: its contents too. + let r = run(&mut s, "layer.precompose", json!({"comp": inner.0, "layers": [clip], "mode": "leave"})); + let deeper = ItemId(r["comp"].as_u64().unwrap()); + assert_eq!(analysed(&s), [false, false], "pre-composing inside the precomp changes its contents"); + analyse(&mut s, saved); + let clip = inner_layer(&s, deeper); + check(&mut s, "prop.set", json!({"comp": deeper.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]); + + // Saved and reopened, they still match, and an edit inside still clears them. + let mut s = open(&s.project.to_json()); + run(&mut s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})); + assert_eq!(analysed(&s), [true, true]); + check(&mut s, "prop.set", json!({"comp": deeper.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]); +} + +/// Precomps used many times, nested deeply or (in a broken project) in a cycle are keyed in +/// bounded time, and an edit deep inside still changes the key. +#[test] +fn shared_deep_and_cyclic_precomps_are_keyed_in_bounded_time() { + use effectcraft_color::Label; + use effectcraft_project::{ItemKind, LayerSource}; + let mut s = open(ANALYSED); + let r = run(&mut s, "layer.precompose", json!({"layers": [CLIP.0], "mode": "leave"})); + let inner = ItemId(r["comp"].as_u64().unwrap()); + let mut p = (*s.project).clone(); + let template = p.comp(inner).unwrap().clone(); + // 40 comps, each drawing the next one twice (2^40 paths if each were walked), the last + // drawing the first. + let ids: Vec = (0..40).map(|i| p.add_item(&format!("Nest {i}"), Label::default(), None, ItemKind::Comp(template.clone().into()))).collect(); + for (i, id) in ids.iter().enumerate() { + let next = ids[(i + 1) % ids.len()]; + let mut layer = template.layers[0].clone(); + layer.source = LayerSource::Comp { item: next }; + let c = p.comp_mut(*id).unwrap(); + c.layers.push(layer.clone()); + layer.id = LayerId(layer.id.0 + 1000); + c.layers.push(layer); + } + p.comp_mut(inner).unwrap().layers[0].source = LayerSource::Comp { item: ids[0] }; + let key = |p: &Project| crate::analysis::frames_key::analysis_key(p, COMP, p.comp(COMP).unwrap().layer(CLIP).unwrap(), WARP); + let start = std::time::Instant::now(); + let before = key(&p); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed()); + p.comp_mut(ids[10]).unwrap().background = [1.0, 0.0, 0.0]; + assert_ne!(key(&p), before); +} diff --git a/crates/engine/src/tests/camera_track.rs b/crates/engine/src/tests/camera_track.rs index 3ca8578a9..77829e7f9 100644 --- a/crates/engine/src/tests/camera_track.rs +++ b/crates/engine/src/tests/camera_track.rs @@ -252,6 +252,15 @@ fn invalidation_resolve_cancel_and_undo() { assert_eq!(status(&mut s)["analyzed"], false); assert!(s.redo()); assert_eq!(status(&mut s)["solved"], true); + // A mask is part of the tracked input (#3535): drawing one clears the tracks, undo keeps them. + s.execute_checked("mask.new", json!({"layer": clip.0, "vertices": [[10, 10], [200, 10], [200, 150]]})).unwrap(); + assert_eq!(status(&mut s)["analyzed"], false); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(status(&mut s)["solved"], true); + // An effect below the tracker isn't. + s.execute_checked("effect.apply", json!({"layers": [clip.0], "effect": "ec.blur.gaussian"})).unwrap(); + assert_eq!(status(&mut s)["solved"], true); + s.execute_checked("edit.undo", json!({})).unwrap(); // Changing the Solve Method keeps the tracks and only re-solves. s.execute_checked("prop.set", json!({"layer": clip.0, "path": "effects/#1/advanced/solveMethod", "value": 1})).unwrap(); diff --git a/docs/agents.md b/docs/agents.md index 06f9bedae..66025b479 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -492,8 +492,9 @@ between the layer's In and Out points in the background ("Analyzing in backgroun then "Stabilizing..."; `warp.status` reports `progress`, `banner`, whether the effect is `analyzed`, and the current frame's `warp` matrix, `autoScale` and `crop`); `warp.cancel` stops it without writing anything. The analysis is stored in the effect (saved with the project) and is -one undo step; trimming, slipping, time-remapping or replacing the layer's source clears it and -the app re-analyses automatically (headless: run `warp.analyze` again). Settings are ordinary +one undo step; trimming, slipping, time-remapping or replacing the layer's source, editing its +masks, the effects above the Warp Stabilizer or, on a precomp layer, the precomp's contents +clears it and the app re-analyses automatically (headless: run `warp.analyze` again). Settings are ordinary properties under the instance's groups, e.g. `effects/#1/stabilization/result` (0 Smooth Motion, 1 No Motion), `effects/#1/stabilization/smoothness`, `effects/#1/stabilization/method`, `effects/#1/borders/framing` (0 Stabilize Only … 3 Stabilize, Synthesize Edges), @@ -540,7 +541,8 @@ Each edit is one undo step; the first create adds the one-node "3D Tracker Camer startFrame?: 1, includePoints?: true}` exports the solve as an executable Blender scene script: baked camera/lens/focus, synced video background, points and ground. See [Blender camera export](blender-camera-export.md) for framing, assumptions and limitations. -Changing the layer's frames clears the analysis; changing `effects/#1/shotType`, +Changing the layer's frames, masks, the effects above the tracker or a precomp's contents +clears the analysis; changing `effects/#1/shotType`, `effects/#1/horizontalAngleOfView`, `effects/#1/advanced/solveMethod` re-solves the stored tracks. ```sh