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();