From 57990d9a47843b2d316e242e571b3d4e29cb9427 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:48:30 +0530 Subject: [PATCH 1/4] #3535: Warp Stabilizer and 3D Camera Tracker keys cover the layer's masks and the effects above them Their analyses are made from the layer's input to the effect (masks, then the effects above it), but the key covered only the layer's frames, so a mask or effect edit kept a stale analysis. frames_key::analysis_key adds the masks, the effects above the instance and the Effects switch. Keys saved before (frames only) stay valid while each edit keeps the input. --- crates/engine/src/analysis/camera_track.rs | 24 ++-- crates/engine/src/analysis/frames_key.rs | 47 ++++++++ crates/engine/src/analysis/warp.rs | 13 ++- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/analysis_inputs.rs | 128 +++++++++++++++++++++ crates/engine/src/tests/mask_warp.rs | 9 ++ 6 files changed, 205 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/tests/analysis_inputs.rs diff --git a/crates/engine/src/analysis/camera_track.rs b/crates/engine/src/analysis/camera_track.rs index 43f7e1746..8209af543 100644 --- a/crates/engine/src/analysis/camera_track.rs +++ b/crates/engine/src/analysis/camera_track.rs @@ -26,7 +26,7 @@ use effectcraft_track::camtrack::linalg::{self, Similarity, V3}; use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer}; use serde::Serialize; -use crate::analysis::frames_key::{self, signature}; +use crate::analysis::frames_key; use crate::offload::JobKind; use crate::tracking::lock; use crate::warp::analysis_times; @@ -82,7 +82,7 @@ pub struct CameraJob { pub comp: ItemId, pub layer: LayerId, pub effect: Uid, - /// The layer signature the tracks are made from, and the solve settings key. + /// What the tracks are made from (`tracks_key`), and the solve settings key. key: String, solve_key: String, } @@ -123,19 +123,21 @@ fn str_of(g: &PropGroup, m: &str) -> String { } } -/// What a tracker's tracks are made from: the layer's frames and Detailed Analysis. +/// What a tracker's tracks are made from: its input (the layer's frames under its masks and the +/// effects above it, `frames_key::analysis_key`) and Detailed Analysis. fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String { - tracks_key_of(signature(project, cid, l), g) + tracks_key_of(frames_key::analysis_key(project, cid, l, g.uid), g) } -fn tracks_key_of(signature: String, g: &PropGroup) -> String { +fn tracks_key_of(input: String, g: &PropGroup) -> String { let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool); - format!("{signature}{}", if detailed { "+detailed" } else { "" }) + format!("{input}{}", if detailed { "+detailed" } else { "" }) } -/// Whether the tracker's saved tracks are made from the layer's current frames and settings. -fn tracks_match(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> bool { - frames_key::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s, _| tracks_key_of(s, g)) +/// Whether the tracker's saved tracks are made from its current input and settings, after an +/// edit from `before` to `project` ([`frames_key::analysis_key_matches`]). +fn tracks_match(before: &Project, project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> bool { + frames_key::analysis_key_matches(before, project, cid, l, g.uid, &str_of(g, TRACKS_KEY), |k| tracks_key_of(k, g)) } /// Parameters of an instance from static values (the tracker's settings are not animated). @@ -175,7 +177,7 @@ pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id)); for g in fx.groups().filter(|g| is_tracker(g)) { let tracked = !str_of(g, TRACKS).is_empty(); - if tracked && !tracks_match(after, *cid, l, g) { + if tracked && !tracks_match(before, after, *cid, l, g) { clears.push((*cid, l.id, g.uid, true)); todo.push((*cid, l.id, g.uid)); } else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) { @@ -335,7 +337,7 @@ impl Session { let key = tracks_key(&self.project, comp, l, g); let settings = ct::settings(¶ms); let solve_key = ct::solve_key(&settings); - let tracks = ct::tracks(¶ms).filter(|_| tracks_match(&self.project, comp, l, g)); + let tracks = ct::tracks(¶ms).filter(|_| tracks_match(&self.project, &self.project, comp, l, g)); let n = times.len(); let work = CamWork { project: self.project.clone(), diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs index 44eb93d57..7fc97f89e 100644 --- a/crates/engine/src/analysis/frames_key.rs +++ b/crates/engine/src/analysis/frames_key.rs @@ -83,6 +83,53 @@ pub(crate) fn input_key_of(signature: String, layer: &Layer, uid: Uid) -> String hex(&t.0) } +/// The first word of an [`analysis_key`]'s text: a later change to what it writes bumps it. +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. +pub(crate) fn analysis_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { + let mut t = Text::new(); + t.word(ANALYSIS_VERSION); + t.word(&signature(p, comp, layer)); + // The Effects switch turns the effects above on or off (it has none when it is the first). + if layer.effects().and_then(|fx| fx.groups().next()).is_some_and(|g| g.uid != uid) { + t.field("effectsSwitch", layer.switches.effects); + } + t.inputs_above(layer, uid); + hex(&t.0) +} + +/// Whether `saved`, the key saved with the analysis of instance `uid` of `layer` in `after`, is +/// for its current input ([`analysis_key`]). `key` makes the saved form from an input key or a +/// frames signature (the tracker adds its Detailed Analysis). +/// +/// Keys saved before #3535 hashed only the layer's frames ([`signature`], or a [`legacy`] one). +/// Such a key is taken to be for the input the layer had when it was opened, and stays valid +/// while each edit (from `before` to `after`) keeps the input: the first edit that changes it +/// clears the analysis. A layer or instance new in `after` (pasted, opened) keeps such a key +/// while its frames match. +pub(crate) fn analysis_key_matches( + before: &Project, + after: &Project, + comp: ItemId, + layer: &Layer, + uid: Uid, + saved: &str, + key: impl Fn(String) -> String, +) -> bool { + let now = analysis_key(after, comp, layer, uid); + if key(now.clone()) == saved { + return true; + } + let old = before.comp(comp).and_then(|c| c.layer(layer.id)).filter(|l| l.props.find_group(uid).is_some()); + match old { + Some(old) => key_matches(before, comp, old, saved, |s, _| key(s)) && analysis_key(before, comp, old, uid) == now, + None => key_matches(after, comp, layer, saved, |s, _| key(s)), + } +} + /// Whether `saved`, a key saved with an analysis of `layer`, is for the layer's current frames. /// `key` makes such a key from a signature of the given era. A key an earlier release saved for /// the same frames matches too: keep it rather than rewrite it (analysing writes today's). diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 610e994ca..10b9a0ac1 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -6,8 +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; the key is -//! `analysis::frames_key`'s, shared with the 3D Camera Tracker and Roto Brush). Edits that change +//! 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 //! re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses //! automatically too). Footage going offline is not such a change. @@ -25,7 +26,7 @@ use effectcraft_time::Tick; use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis}; use serde::Serialize; -use crate::analysis::frames_key::{self, signature}; +use crate::analysis::frames_key; use crate::tracking::lock; use crate::{Event, Session}; @@ -71,7 +72,7 @@ pub struct WarpJob { pub comp: ItemId, pub layer: LayerId, pub effect: Uid, - /// The layer signature the analysis is made from. + /// What the analysis is made from (`frames_key::analysis_key`). key: String, } @@ -126,7 +127,7 @@ pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id)); for g in fx.groups().filter(|g| is_warp(g)) { let analysed = !str_of(g, ANALYSIS).is_empty(); - if analysed && !frames_key::key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s, _| s) { + if analysed && !frames_key::analysis_key_matches(before, after, *cid, l, g.uid, &str_of(g, ANALYSIS_KEY), |k| k) { clear.push((*cid, l.id, g.uid)); todo.push((*cid, l.id, g.uid)); } else if !analysed && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() { @@ -305,7 +306,7 @@ impl Session { detailed, times, }; - let key = signature(&self.project, comp, l); + let key = frames_key::analysis_key(&self.project, comp, l, effect); if !wait && self.offloads() { drop(work); self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eddeeb7ef..b2964916f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,7 @@ mod add_item_options; mod aep_export; mod aep_import; +mod analysis_inputs; mod analysis_signature; mod anim_presets; mod anim_tools; diff --git a/crates/engine/src/tests/analysis_inputs.rs b/crates/engine/src/tests/analysis_inputs.rs new file mode 100644 index 000000000..414ff0939 --- /dev/null +++ b/crates/engine/src/tests/analysis_inputs.rs @@ -0,0 +1,128 @@ +//! Warp Stabilizer and 3D Camera Tracker analyses follow their input: the layer's frames, its +//! masks, the effects above them and a precomp's contents (#3535). Edits that leave the input +//! as it was (effects below, names, labels) keep them. + +use effectcraft_effects::camera_tracker::{TRACKS, TRACKS_KEY}; +use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY}; +use effectcraft_keyframe::Value; +use effectcraft_project::{ItemId, LayerId, Project}; +use serde_json::{Value as Json, json}; + +use crate::Session; + +const COMP: ItemId = ItemId(2); +const CLIP: LayerId = LayerId(3); +const WARP: u64 = 26; +const TRACKER: u64 = 54; + +/// `tracked-0.22.ecproj`'s clip with a mask and, in order, a Warp Stabilizer, a 3D Camera +/// Tracker, a Gaussian Blur and a Roto Brush. +const ANALYSED: &str = include_str!("fixtures/analysed-0.24.ecproj"); + +fn open(json: &str) -> Session { + let mut s = Session::default(); + s.replace_project(Project::from_json(json).unwrap(), None); + s.open_comp(COMP); + s +} + +/// Which keys an analysis saves. +#[derive(Clone, Copy, Debug)] +enum Saved { + /// Today's: the input to the instance. + Input, + /// Before #3535: the layer's frames only (still recognised). + Frames, +} + +/// The key an analysis of instance `uid` (no Detailed Analysis) saves. +fn key(p: &Project, uid: u64, saved: Saved) -> String { + let layer = p.comp(COMP).unwrap().layer(CLIP).unwrap(); + match saved { + Saved::Input => crate::analysis::frames_key::analysis_key(p, COMP, layer, uid), + Saved::Frames => crate::analysis::frames_key::signature(p, COMP, layer), + } +} + +/// Give the Warp Stabilizer and the tracker an analysis of the clip as it is now, saved with +/// `saved` keys, and reopen the project. +fn analyse(s: &mut Session, saved: Saved) { + let mut p = (*s.project).clone(); + let set = |p: &mut Project, uid: u64, data: &str, id: &str, key: &str| { + let g = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(uid).unwrap(); + g.prop_mut(data).unwrap().value = Value::Str(format!("saved {data}")); + g.prop_mut(id).unwrap().value = Value::Str(key.into()); + }; + // The Warp Stabilizer is above the tracker: its analysis is part of the tracker's input. + let k = key(&p, WARP, saved); + set(&mut p, WARP, ANALYSIS, ANALYSIS_KEY, &k); + let k = key(&p, TRACKER, saved); + set(&mut p, TRACKER, TRACKS, TRACKS_KEY, &k); + *s = open(&p.to_json()); + run(s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})); + assert_eq!(analysed(s), [true, true], "{saved:?}"); +} + +/// Whether the Warp Stabilizer and the tracker still have their analysis. +fn analysed(s: &Session) -> [bool; 2] { + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let has = |uid: u64, id: &str| matches!(&layer.props.find_group(uid).unwrap().prop(id).unwrap().value, Value::Str(s) if !s.is_empty()); + [has(WARP, ANALYSIS), has(TRACKER, TRACKS)] +} + +fn run(s: &mut Session, cmd: &str, params: Json) -> Json { + s.execute_checked(cmd, params).unwrap() +} + +fn undo(s: &mut Session) { + run(s, "edit.undo", json!({})); +} + +/// An edit, its effect on the two analyses, and that undo brings them back. +fn check(s: &mut Session, cmd: &str, params: Json, kept: [bool; 2]) { + assert_eq!(analysed(s), [true, true], "before {cmd} {params}"); + run(s, cmd, params.clone()); + assert_eq!(analysed(s), kept, "{cmd} {params}"); + undo(s); + assert_eq!(analysed(s), [true, true], "undo of {cmd} {params}"); +} + +#[test] +fn masks_and_effects_above_are_part_of_the_input() { + for saved in [Saved::Input, Saved::Frames] { + masks_and_effects_above(saved); + } +} + +fn masks_and_effects_above(saved: Saved) { + let mut s = open(ANALYSED); + analyse(&mut s, saved); + // The layer's mask is under every effect. + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]); + // The Warp Stabilizer is above the tracker: its settings change the tracker's input only. + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/stabilization/smoothness", "value": 20}), [true, false]); + // The Gaussian Blur and the Roto Brush are below both. + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#3/blurriness", "value": 4}), [true, true]); + run(&mut s, "layer.rename", json!({"layer": CLIP.0, "name": "Renamed"})); + assert_eq!(analysed(&s), [true, true]); + // The Effects switch turns the Warp Stabilizer (above the tracker) off. + check(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": false}), [true, false]); + + // An effect moved above the Warp Stabilizer is part of both inputs, and its settings too. + s.edit("blur first", None, |p, _| { + let fx = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.sub_mut("effects").unwrap(); + let blur = fx.children.remove(2); + fx.children.insert(0, blur); + Ok(()) + }) + .unwrap(); + assert_eq!(analysed(&s), [false, false]); + analyse(&mut s, saved); + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/blurriness", "value": 4}), [false, false]); + + // Saved and reopened, they still match, and a mask edit 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!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]); +} diff --git a/crates/engine/src/tests/mask_warp.rs b/crates/engine/src/tests/mask_warp.rs index 5387fe562..e884091a5 100644 --- a/crates/engine/src/tests/mask_warp.rs +++ b/crates/engine/src/tests/mask_warp.rs @@ -391,6 +391,15 @@ fn warp_analysis_invalidates_and_cancels() { let fx = r["effect"].as_u64().unwrap(); let cid = s.active_comp_id().unwrap(); assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true); + // A mask is part of the analysed input (#3535): drawing one clears the analysis, undo keeps it. + s.execute_checked("mask.new", json!({"layer": clip.0, "vertices": [[10, 10], [200, 10], [200, 150]]})).unwrap(); + assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true); + // An effect below it isn't. + s.execute_checked("effect.apply", json!({"layers": [clip.0], "effect": "ec.blur.gaussian"})).unwrap(); + assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true); + s.execute_checked("edit.undo", json!({})).unwrap(); // Trimming the layer clears the analysis and queues it again. s.execute("layer.timing", json!({"layers": [clip.0], "out": 0.8})).unwrap(); let st = s.execute("warp.status", json!({})).unwrap(); From 385b9530a1f6310556351e0cdb84f6f49deee4e6 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:00:14 +0530 Subject: [PATCH 2/4] #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 From 64c28b56d74855428ab7707e636847aa0bf5a8f3 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:50 +0530 Subject: [PATCH 3/4] #3535: docs: which key covers what, why markers and audio are or aren't keyed --- crates/engine/src/analysis/frames_key.rs | 16 ++++++++++------ crates/engine/src/tests/analysis_inputs.rs | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs index b6c542666..c43b3e92b 100644 --- a/crates/engine/src/analysis/frames_key.rs +++ b/crates/engine/src/analysis/frames_key.rs @@ -1,5 +1,6 @@ -//! What an analysis of a layer is made from, as one key shared by the Warp Stabilizer, the 3D -//! Camera Tracker and Roto Brush / Object Matte. +//! What an analysis of a layer is made from. One writer keys the Warp Stabilizer and the 3D +//! Camera Tracker ([`analysis_key`]: frames, masks, effects above, a precomp's contents) and Roto +//! Brush / Object Matte ([`input_key`]: frames, masks, effects above). //! //! The key hashes a text this module writes field by field. The source item (footage and its //! settings, solid), the Time Remap property, the masks and effect groups (their properties, @@ -9,7 +10,8 @@ //! 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 (colour labels, UI hints, markers), so they never drop saved +//! data that never changes pixels (colour labels, UI hints, markers but a comp's timing markers; +//! only expressions read the others), so they never drop saved //! analyses (#3534, #3536). //! //! Keys saved by 0.22–0.24 hashed derived `Debug` text instead. [`legacy`] still recognises @@ -510,7 +512,8 @@ impl Text { preserve_transparency, track_matte, parent, - // Layer markers annotate it; Lock Markers protects them. + // Layer markers annotate it (only expressions read them, as for the analysed layer); + // Lock Markers protects them. markers: _, markers_locked: _, auto_orient, @@ -549,7 +552,8 @@ impl Text { } fn switches(&mut self, s: &Switches) { - // Lock and Shy only protect and hide the layer in the Timeline. + // Lock and Shy only protect and hide the layer in the Timeline. Audio is written (as are + // the Audio group and audio effects, in the property tree): a waveform effect can draw it. 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); @@ -558,7 +562,7 @@ impl Text { } /// A comp's protected markers with a duration: they set its timing (Responsive Design). - /// Other markers annotate it. + /// Other markers annotate it; only expressions read them. 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); diff --git a/crates/engine/src/tests/analysis_inputs.rs b/crates/engine/src/tests/analysis_inputs.rs index 5eb75c115..a6722a731 100644 --- a/crates/engine/src/tests/analysis_inputs.rs +++ b/crates/engine/src/tests/analysis_inputs.rs @@ -15,7 +15,7 @@ const CLIP: LayerId = LayerId(3); const WARP: u64 = 26; const TRACKER: u64 = 54; -/// `tracked-0.22.ecproj`'s clip with a mask and, in order, a Warp Stabilizer, a 3D Camera +/// `analysed-0.24.ecproj`: the clip of `tracked-0.22.ecproj` with a mask and, in order, a Warp Stabilizer, a 3D Camera /// Tracker, a Gaussian Blur and a Roto Brush. const ANALYSED: &str = include_str!("fixtures/analysed-0.24.ecproj"); From 28c63ce72807015de358d39f4c4c99cee09d3149 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 16:30:24 +0530 Subject: [PATCH 4/4] #3535: the analysis key leaves out the effects above while the Effects switch is off The renderer skips the whole effect stack then, so editing those effects doesn't change the analysed frames. Roto Brush Input Keys keep their text. --- crates/engine/src/analysis/frames_key.rs | 11 +++++++---- crates/engine/src/tests/analysis_inputs.rs | 7 +++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs index c43b3e92b..abbafde67 100644 --- a/crates/engine/src/analysis/frames_key.rs +++ b/crates/engine/src/analysis/frames_key.rs @@ -88,7 +88,7 @@ pub(crate) fn input_key_of(signature: String, layer: &Layer, uid: Uid) -> String let mut t = Text::new(); t.word(VERSION); t.word(&signature); - t.inputs_above(layer, uid); + t.inputs_above(layer, uid, true); hex(&t.0) } @@ -107,7 +107,8 @@ pub(crate) fn analysis_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) - if layer.effects().and_then(|fx| fx.groups().next()).is_some_and(|g| g.uid != uid) { t.field("effectsSwitch", layer.switches.effects); } - t.inputs_above(layer, uid); + // While it is off the renderer skips the whole effect stack: the effects above draw nothing. + t.inputs_above(layer, uid, layer.switches.effects); t.precomp(p, comp, layer); hex(&t.0) } @@ -392,14 +393,16 @@ impl Text { /// The layer's masks and the effects above the instance `uid`: what the effect's input is /// made of besides the layer's frames. - fn inputs_above(&mut self, layer: &Layer, uid: Uid) { + /// `effects`: whether to write the effects (a Roto Brush Input Key always does: frozen mattes + /// hash the saved key, so it keeps its text). + fn inputs_above(&mut self, layer: &Layer, uid: Uid, effects: bool) { if let Some(m) = layer.masks() { self.word("masks"); self.group(m, 0); } if let Some(fx) = layer.effects() { self.word("effects"); - for g in fx.groups().take_while(|g| g.uid != uid) { + for g in fx.groups().take_while(|g| effects && g.uid != uid) { self.group(g, 0); } } diff --git a/crates/engine/src/tests/analysis_inputs.rs b/crates/engine/src/tests/analysis_inputs.rs index a6722a731..b45b38eda 100644 --- a/crates/engine/src/tests/analysis_inputs.rs +++ b/crates/engine/src/tests/analysis_inputs.rs @@ -107,6 +107,13 @@ fn masks_and_effects_above(saved: Saved) { assert_eq!(analysed(&s), [true, true]); // The Effects switch turns the Warp Stabilizer (above the tracker) off. check(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": false}), [true, false]); + // While it is off, the effects above draw nothing: editing them keeps the analyses. + run(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": false})); + analyse(&mut s, saved); + check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/stabilization/smoothness", "value": 20}), [true, true]); + run(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": true})); + assert_eq!(analysed(&s), [true, false]); + analyse(&mut s, saved); // An effect moved above the Warp Stabilizer is part of both inputs, and its settings too. s.edit("blur first", None, |p, _| {