From 57990d9a47843b2d316e242e571b3d4e29cb9427 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:48:30 +0530 Subject: [PATCH] #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();