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 248b3b4f5..abbafde67 100644 --- a/crates/engine/src/analysis/frames_key.rs +++ b/crates/engine/src/analysis/frames_key.rs @@ -1,27 +1,32 @@ -//! 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 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 -//! (colour 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 (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 //! 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 +38,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 +63,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)) @@ -79,10 +88,60 @@ 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) } +/// 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, 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); + 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); + } + // 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) +} + +/// 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). @@ -151,7 +210,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); @@ -334,19 +393,192 @@ 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); } } } + /// 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, + name, + // Whether the name follows the Source Text changes nothing until the name changes. + name_follows_text: _, + source, + // Labels and comments only show the layer. + label: _, + comment: _, + start_time, + in_point, + out_point, + stretch, + switches, + blend_mode, + preserve_transparency, + track_matte, + parent, + // Layer markers annotate it (only expressions read them, as for the analysed layer); + // Lock Markers protects them. + markers: _, + markers_locked: _, + auto_orient, + environment, + environment_background, + props, + } = l; + self.field("layer", id.0); + // Expressions find layers by name (`thisComp.layer("Clip")`), as they find effects. + self.str("name", name); + 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. 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); + 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; 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); + 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) { // Expressions find effects and groups by name (`effect("Blur")`, `propertyGroup(1).name`). // The uid and colour label never change pixels. diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 610e994ca..45af7f172 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -6,9 +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 -//! 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. @@ -25,7 +25,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 +71,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 +126,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 +305,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..b45b38eda --- /dev/null +++ b/crates/engine/src/tests/analysis_inputs.rs @@ -0,0 +1,211 @@ +//! 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; + +/// `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"); + +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]); + // 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, _| { + 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]); +} +#[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 label or comment inside it doesn't. A name does: expressions find layers by name. + s.edit("label", None, |p, _| { + let l = p.comp_mut(inner).unwrap().layer_mut(LayerId(clip)).unwrap(); + l.label = effectcraft_color::Label::Pink; + l.comment = "a note".into(); + Ok(()) + }) + .unwrap(); + assert_eq!(analysed(&s), [true, true]); + check(&mut s, "layer.rename", json!({"comp": inner.0, "layer": clip, "name": "Inner"}), [false, false]); + + // 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/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(); 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