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