From d361a6cb4e09b80471dd6cf2b2edc98c36526bed Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 13:56:45 +0530 Subject: [PATCH 1/7] #3534, #3536: pin analysis keys saved by 0.24.1 A fixture keyed by the current release: Warp Stabilizer, 3D Camera Tracker and Roto Brush keys keep matching through edits that keep the frames, undo and save/reopen, and real changes still clear them. --- ATTRIBUTION.md | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/analysis_signature.rs | 94 +++++++++++++++++++ .../src/tests/fixtures/analysed-0.24.ecproj | 1 + .../fixtures/analysed-0.24.ecproj.attribution | 8 ++ 5 files changed, 105 insertions(+) create mode 100644 crates/engine/src/tests/analysis_signature.rs create mode 100644 crates/engine/src/tests/fixtures/analysed-0.24.ecproj create mode 100644 crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3aa429a53..f1ea2a95d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -133,6 +133,7 @@ configuration from these Apache-2.0 projects: | Asset | Author | Source | Licence | |---|---|---|---| +| `crates/engine/src/tests/fixtures/analysed-0.24.ecproj` | arnav-hiwarkar | Original work, synthetic project keyed by EffectCraft 0.24.1 | MIT OR Apache-2.0 | | `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | | `crates/engine/src/tests/fixtures/tracked-0.22.ecproj` | arnav-hiwarkar | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ab4b5b8da..0422e95b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -27,6 +27,7 @@ mod key_metadata_order; mod key_time; mod keyframe_text; mod keylight; +mod analysis_signature; mod legacy_analysis_keys; mod limits; mod lottie; diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs new file mode 100644 index 000000000..7429c4035 --- /dev/null +++ b/crates/engine/src/tests/analysis_signature.rs @@ -0,0 +1,94 @@ +//! Warp Stabilizer, 3D Camera Tracker and Roto Brush keys saved by 0.24 keep matching their +//! layer's frames after edits that don't change them, and a real change still clears them. + +use effectcraft_effects::camera_tracker::{TRACKS, TRACKS_KEY}; +use effectcraft_effects::roto::{FROZEN, INPUT_KEY}; +use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY}; +use effectcraft_keyframe::Value; +use effectcraft_project::{ItemId, LayerId, Project}; +use serde_json::json; + +use crate::Session; + +const COMP: ItemId = ItemId(2); +const CLIP: LayerId = LayerId(3); +const WARP: u64 = 26; +const TRACKER: u64 = 54; +const ROTO: u64 = 86; + +/// Saved by 0.24.1 (before #3534/#3536): the clip of `tracked-0.22.ecproj` with Time Remap, a +/// mask, a Gaussian Blur and a Roto Brush, its analyses replaced by short stand-ins. The keys +/// are the ones that version computed for it. +const ANALYSED_0_24: &str = include_str!("fixtures/analysed-0.24.ecproj"); +const SIGNATURE_0_24: &str = "03efb7c4b0f27b65"; +const ROTO_INPUT_0_24: &str = "7b319ce93027617c"; + +fn open(json: &str) -> Session { + let mut s = Session::default(); + s.replace_project(Project::from_json(json).unwrap(), None); + s.open_comp(COMP); + s +} + +/// The (data, key) of the Warp Stabilizer, the 3D Camera Tracker and the Roto Brush. +fn saved(s: &Session) -> [(String, String); 3] { + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let get = |uid: u64, data: &str, key: &str| { + let params = crate::camera_track::static_params(layer.props.find_group(uid).unwrap()); + let text = |id: &str| match params.get(id) { + Some(Value::Str(s)) => s.clone(), + other => panic!("{id}: {other:?}"), + }; + (text(data), text(key)) + }; + [get(WARP, ANALYSIS, ANALYSIS_KEY), get(TRACKER, TRACKS, TRACKS_KEY), get(ROTO, FROZEN, INPUT_KEY)] +} + +fn add_to_render_queue(s: &mut Session) { + s.execute_checked("renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})).unwrap(); +} + +#[test] +fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() { + let mut s = open(ANALYSED_0_24); + let before = saved(&s); + assert_eq!(before[0], ("saved analysis".into(), SIGNATURE_0_24.into())); + assert_eq!(before[1], ("saved tracks".into(), SIGNATURE_0_24.into())); + assert_eq!(before[2], ("saved frozen".into(), ROTO_INPUT_0_24.into())); + + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + + // Saved and reopened, the keys still match. + let mut s = open(&s.project.to_json()); + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + + // Other frames: all three are cleared (the Roto Brush gets a new key), and undo brings them back. + s.execute_checked("layer.timing", json!({"layer": CLIP.0, "out": 0.25})).unwrap(); + let after = saved(&s); + assert_eq!(after[0], (String::new(), String::new())); + assert_eq!(after[1], (String::new(), String::new())); + assert_eq!(after[2].0, ""); + assert_ne!(after[2].1, ROTO_INPUT_0_24); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + + // Time Remap is part of the frames. + s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "timeRemap", "value": 0.3})).unwrap(); + assert!(saved(&s).iter().all(|(data, _)| data.is_empty())); + s.execute_checked("edit.undo", json!({})).unwrap(); + + // The Roto Brush's input also has the layer's masks and the effects above it. + s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "effects/#3/blurriness", "value": 4})).unwrap(); + let after = saved(&s); + assert_eq!(after[2].0, ""); + assert_ne!(after[2].1, ROTO_INPUT_0_24); + s.execute_checked("edit.undo", json!({})).unwrap(); + s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3})).unwrap(); + assert_eq!(saved(&s)[2].0, ""); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); +} diff --git a/crates/engine/src/tests/fixtures/analysed-0.24.ecproj b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj new file mode 100644 index 000000000..e99b4acc0 --- /dev/null +++ b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj @@ -0,0 +1 @@ +{"schema":2,"settings":{"bit_depth":"Bpc8","linearize":false,"working_space":null,"blend_linear":false,"time_display":"Timecode","frame_start":0,"audio_sample_rate":48000,"gpu_acceleration":true,"color_engine":"Adobe","hdr":"Clip","output_space":null,"working_gamma":2.2},"items":{"1":{"id":1,"name":"effectcraft-3456-shake.mp4","label":"Aqua","comment":"","parent":null,"kind":{"type":"Footage","path":"/tmp/effectcraft-3456-shake.mp4","kind":"Video","width":160,"height":120,"pixel_aspect":1.0,"frame_rate":{"num":12,"den":1},"native_rate":null,"duration":254016000000,"has_video":true,"has_audio":false,"alpha":"Ignore","premul_color":[0.0,0.0,0.0],"loop_count":1,"codec":"H.264 (High)","missing":false,"color_profile":"Rec709"}},"2":{"id":2,"name":"effectcraft-3456-shake","label":"Sandstone","comment":"","parent":null,"kind":{"type":"Comp","width":160,"height":120,"pixel_aspect":1.0,"frame_rate":{"num":12,"den":1},"duration":254016000000,"display_start":0,"background":[0.0,0.0,0.0],"work_area":[0,254016000000],"layers":[{"id":3,"name":"effectcraft-3456-shake.mp4","source":{"type":"Footage","item":1},"label":"Aqua","comment":"","start_time":0,"in_point":0,"out_point":254016000000,"stretch":100.0,"switches":{"video":true,"audio":true,"solo":false,"locked":false,"shy":false,"collapse":false,"quality":"Best","sampling":"Bilinear","effects":true,"frame_blend":"Off","motion_blur":false,"adjustment":false,"three_d":false,"guide":false},"blend_mode":"Normal","preserve_transparency":false,"track_matte":null,"parent":null,"markers":[],"auto_orient":"Off","props":{"uid":4,"match":"layer","name":"effectcraft-3456-shake.mp4","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":76,"match":"timeRemap","name":"Time Remap","value":{"t":"Scalar","v":0.0},"keys":[{"time":0,"value":{"t":"Scalar","v":0.2},"in_interp":"Linear","out_interp":"Linear","in_ease":[],"out_ease":[],"continuous":false,"auto_bezier":false,"spatial_in":[0.0,0.0,0.0],"spatial_out":[0.0,0.0,0.0],"spatial_auto":true,"spatial_continuous":false,"roving":false},{"time":254016000000,"value":{"t":"Scalar","v":1.0},"in_interp":"Linear","out_interp":"Linear","in_ease":[],"out_ease":[],"continuous":false,"auto_bezier":false,"spatial_in":[0.0,0.0,0.0],"spatial_out":[0.0,0.0,0.0],"spatial_auto":true,"spatial_continuous":false,"roving":false}],"ui":{"kind":"Number"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":5,"match":"masks","name":"Masks","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Group","uid":77,"match":"mask","name":"Mask 1","kind":{"kind":"Mask","mode":"Add","inverted":false,"color":[177,180,79],"locked":false,"motion_blur":"SameAsLayer","feather_falloff":"Smooth","roto_bezier":false},"enabled":true,"children":[{"node":"Prop","uid":78,"match":"path","name":"Mask Path","value":{"t":"Path","v":{"vertices":[[10.0,10.0],[120.0,10.0],[120.0,100.0],[10.0,100.0]],"in_tangents":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"out_tangents":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"closed":false}},"ui":{"kind":"Path"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":79,"match":"feather","name":"Mask Feather","value":{"t":"Vec2","v":[0.0,0.0]},"ui":{"kind":"Pixels"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":80,"match":"opacity","name":"Mask Opacity","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":81,"match":"expansion","name":"Mask Expansion","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Pixels"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Group","uid":6,"match":"effects","name":"Effects","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Group","uid":26,"match":"ec.distort.warpstabilizer","name":"Warp Stabilizer","kind":{"kind":"Effect","effect":"ec.distort.warpstabilizer"},"enabled":true,"children":[{"node":"Group","uid":28,"match":"stabilization","name":"Stabilization","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":27,"match":"result","name":"Result","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Smooth Motion","No Motion"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":29,"match":"smoothness","name":"Smoothness","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":30,"match":"method","name":"Method","value":{"t":"Enum","v":3},"ui":{"kind":"Popup","options":["Position","Position, Scale, Rotation","Perspective","Subspace Warp"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":31,"match":"preserveScale","name":"Preserve Scale","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":33,"match":"borders","name":"Borders","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":32,"match":"framing","name":"Framing","value":{"t":"Enum","v":2},"ui":{"kind":"Popup","options":["Stabilize Only","Stabilize, Crop","Stabilize, Crop, Auto-scale","Stabilize, Synthesize Edges"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":35,"match":"autoScale","name":"Auto-scale","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":34,"match":"maximumScale","name":"Maximum Scale","value":{"t":"Scalar","v":150.0},"ui":{"kind":"Slider","min":100.0,"max":1000.0,"slider_min":100.0,"slider_max":200.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":36,"match":"actionSafeMargin","name":"Action-safe Margin","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":45.0,"slider_min":0.0,"slider_max":20.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":37,"match":"additionalScale","name":"Additional Scale","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":1.0,"max":1000.0,"slider_min":50.0,"slider_max":150.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":39,"match":"advanced","name":"Advanced","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":38,"match":"detailedAnalysis","name":"Detailed Analysis","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":40,"match":"rollingShutterRipple","name":"Rolling Shutter Ripple","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Automatic Reduction","Enhanced Reduction"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":41,"match":"cropLessSmoothMore","name":"Crop Less <-> Smooth More","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":42,"match":"synthesizeInputRange","name":"Synthesis Input Range (seconds)","value":{"t":"Scalar","v":1.0},"ui":{"kind":"Slider","min":0.0,"max":10.0,"slider_min":0.0,"slider_max":5.0,"decimals":2},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":43,"match":"synthesizeEdgeFeather","name":"Synthesis Edge Feather","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":45,"match":"edgeCropping","name":"Synthesis Edge Cropping","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":44,"match":"left","name":"Left","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":46,"match":"top","name":"Top","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":47,"match":"right","name":"Right","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":48,"match":"bottom","name":"Bottom","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":49,"match":"hideWarningBanner","name":"Hide Warning Banner","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":50,"match":"showTrackPoints","name":"Show Track Points","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":51,"match":"trackPointSize","name":"Track Point Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":200.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":52,"match":"analysis","name":"Analysis","value":{"t":"Str","v":"saved analysis"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":53,"match":"analysisKey","name":"Analysis Key","value":{"t":"Str","v":"03efb7c4b0f27b65"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":54,"match":"ec.perspective.cameratracker","name":"3D Camera Tracker","kind":{"kind":"Effect","effect":"ec.perspective.cameratracker"},"enabled":true,"children":[{"node":"Prop","uid":55,"match":"shotType","name":"Shot Type","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Fixed Angle of View","Variable Zoom","Specify Angle of View"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":56,"match":"horizontalAngleOfView","name":"Horizontal Angle of View","value":{"t":"Scalar","v":40.0},"ui":{"kind":"Slider","min":1.0,"max":170.0,"slider_min":10.0,"slider_max":120.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":57,"match":"showTrackPoints","name":"Show Track Points","value":{"t":"Enum","v":1},"ui":{"kind":"Popup","options":["2D Source","3D Solved"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":58,"match":"renderTrackPoints","name":"Render Track Points","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":59,"match":"trackPointSize","name":"Track Point Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":300.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":60,"match":"targetSize","name":"Target Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":1.0,"max":1000.0,"slider_min":10.0,"slider_max":300.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":62,"match":"advanced","name":"Advanced","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":61,"match":"solveMethod","name":"Solve Method","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Auto Detect","Typical","Mostly Flat Scene","Tripod Pan"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":63,"match":"methodUsed","name":"Method Used","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":64,"match":"averageError","name":"Average Error","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":65,"match":"detailedAnalysis","name":"Detailed Analysis","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":66,"match":"lensDistortion","name":"Solve Lens Distortion","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":67,"match":"undistort","name":"Undistort Footage","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":68,"match":"autoDeletePoints","name":"Auto-delete Points Across Time","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":69,"match":"hideWarningBanner","name":"Hide Warning Banner","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":70,"match":"tracks","name":"Tracks","value":{"t":"Str","v":"saved tracks"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":71,"match":"tracksKey","name":"Tracks Key","value":{"t":"Str","v":"03efb7c4b0f27b65"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":72,"match":"solve","name":"Solve","value":{"t":"Str","v":"saved solve"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":73,"match":"solveKey","name":"Solve Key","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":74,"match":"deleted","name":"Deleted Points","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":75,"match":"camera","name":"Camera Layer","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":82,"match":"ec.blur.gaussian","name":"Gaussian Blur","kind":{"kind":"Effect","effect":"ec.blur.gaussian"},"enabled":true,"children":[{"node":"Prop","uid":83,"match":"blurriness","name":"Blurriness","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":3000.0,"slider_min":0.0,"slider_max":50.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":84,"match":"dimensions","name":"Blur Dimensions","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Horizontal and Vertical","Horizontal","Vertical"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":85,"match":"repeatEdge","name":"Repeat Edge Pixels","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":86,"match":"ec.matte.rotobrush","name":"Roto Brush & Refine Edge","kind":{"kind":"Effect","effect":"ec.matte.rotobrush"},"enabled":true,"children":[{"node":"Prop","uid":87,"match":"version","name":"Version","value":{"t":"Enum","v":2},"ui":{"kind":"Popup","options":["1.0","2.0","3.0"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":88,"match":"quality","name":"Quality","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Standard","Best"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":90,"match":"rotoBrushPropagation","name":"Roto Brush Propagation","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":89,"match":"searchRadius","name":"Search Radius","value":{"t":"Scalar","v":15.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":50.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":91,"match":"motionThreshold","name":"Motion Threshold","value":{"t":"Scalar","v":10.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":92,"match":"motionDamping","name":"Motion Damping","value":{"t":"Scalar","v":20.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":93,"match":"viewSearchRegion","name":"View Search Region","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":95,"match":"rotoBrushMatte","name":"Roto Brush Matte","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":94,"match":"feather","name":"Feather","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":96,"match":"contrast","name":"Contrast","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":97,"match":"shiftEdge","name":"Shift Edge","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":-100.0,"max":100.0,"slider_min":-100.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":98,"match":"reduceChatter","name":"Reduce Chatter","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":99,"match":"object","name":"Show Object","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":32.0,"slider_min":0.0,"slider_max":32.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":101,"match":"refineEdgeMatte","name":"Refine Edge Matte","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":100,"match":"smooth","name":"Smooth","value":{"t":"Scalar","v":1.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":10.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":102,"match":"feather","name":"Feather","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":103,"match":"contrast","name":"Contrast","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":104,"match":"shiftEdge","name":"Shift Edge","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":-100.0,"max":100.0,"slider_min":-100.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":105,"match":"reduceChatter","name":"Reduce Chatter","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":106,"match":"useMotionBlur","name":"Use Motion Blur","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":108,"match":"motionBlur","name":"Motion Blur","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":107,"match":"motionBlurSamples","name":"Samples","value":{"t":"Scalar","v":11.0},"ui":{"kind":"Slider","min":1.0,"max":64.0,"slider_min":1.0,"slider_max":32.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":109,"match":"shutterAngle","name":"Shutter Angle","value":{"t":"Scalar","v":180.0},"ui":{"kind":"Slider","min":0.0,"max":720.0,"slider_min":0.0,"slider_max":360.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":110,"match":"higherQuality","name":"Higher Quality","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":111,"match":"decontaminateEdgeColors","name":"Decontaminate Edge Colors","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":113,"match":"decontamination","name":"Decontamination","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":112,"match":"decontaminationAmount","name":"Decontamination Amount","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":114,"match":"extendWhereSmoothed","name":"Extend Where Smoothed","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":115,"match":"increaseDecontaminationRadius","name":"Increase Decontamination Radius","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":116,"match":"viewDecontaminationMap","name":"View Decontamination Map","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Prop","uid":117,"match":"strokes","name":"Strokes","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":118,"match":"inputKey","name":"Input Key","value":{"t":"Str","v":"7b319ce93027617c"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":119,"match":"frozen","name":"Frozen","value":{"t":"Str","v":"saved frozen"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":120,"match":"tracked","name":"Tracked","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Group","uid":15,"match":"transform","name":"Transform","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":7,"match":"anchor","name":"Anchor Point","value":{"t":"Vec3","v":[80.0,60.0,0.0]},"ui":{"kind":"Point"},"spatial":true,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":8,"match":"position","name":"Position","value":{"t":"Vec3","v":[80.0,60.0,0.0]},"ui":{"kind":"Point"},"spatial":true,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":9,"match":"scale","name":"Scale","value":{"t":"Vec3","v":[100.0,100.0,100.0]},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":10,"match":"orientation","name":"Orientation","value":{"t":"Vec3","v":[0.0,0.0,0.0]},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":11,"match":"rotationX","name":"X Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":12,"match":"rotationY","name":"Y Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":13,"match":"rotation","name":"Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":14,"match":"opacity","name":"Opacity","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":16,"match":"materialOptions","name":"Material Options","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":17,"match":"castsShadows","name":"Casts Shadows","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Off","On","Only"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":18,"match":"lightTransmission","name":"Light Transmission","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":19,"match":"acceptsShadows","name":"Accepts Shadows","value":{"t":"Enum","v":1},"ui":{"kind":"Popup","options":["Off","On","Only"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":20,"match":"acceptsLights","name":"Accepts Lights","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":21,"match":"ambient","name":"Ambient","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":22,"match":"diffuse","name":"Diffuse","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":23,"match":"specularIntensity","name":"Specular Intensity","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":24,"match":"specularShininess","name":"Specular Shininess","value":{"t":"Scalar","v":5.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":25,"match":"metal","name":"Metal","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]}}],"markers":[],"shutter_angle":180.0,"shutter_phase":-90.0,"motion_blur_samples":16,"motion_blur_adaptive_limit":128,"renderer":"Classic3D","hide_shy":false,"enable_motion_blur":true,"enable_frame_blending":true,"draft_3d":false,"preserve_frame_rate":false,"preserve_resolution":false,"poster_time":0,"global_light":{"angle":{"uid":0,"match":"globalLightAngle","name":"Global Light Angle","value":{"t":"Scalar","v":120.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},"altitude":{"uid":0,"match":"globalLightAltitude","name":"Global Light Altitude","value":{"t":"Scalar","v":30.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}}}}},"next_id":121} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution new file mode 100644 index 000000000..06e8915f4 --- /dev/null +++ b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution @@ -0,0 +1,8 @@ +asset: analysed-0.24.ecproj +title: The tracked-0.22 clip with Time Remap, a mask, Gaussian Blur and Roto Brush, keyed by EffectCraft 0.24.1 +author: arnav-hiwarkar +source: original work +license: MIT OR Apache-2.0 +license-file: ../../../../../LICENSE-MIT +added: 2026-11-12 by arnav-hiwarkar +notes: Made from tracked-0.22.ecproj with 0.24.1 engine commands (enable Time Remap, a rectangle mask, Gaussian Blur and Roto Brush), its saved analyses replaced by short stand-in strings, and the Warp Stabilizer, 3D Camera Tracker and Roto Brush keys that 0.24.1 computed for it. Written as compact JSON. No media is needed: the tests only check that the keys are kept or cleared. From 54d53fff766352a2e60f48f563a7a4fc0b1468c2 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:08:57 +0530 Subject: [PATCH 2/7] #3536: one explicit analysis signature for Warp Stabilizer, 3D Camera Tracker and Roto Brush analysis::frames_key writes what a layer's frames are made of field by field: every struct and enum it writes is destructured or matched in full and each variant is a fixed word, so a new field doesn't compile until it is decided and a rename doesn't change keys. Names, labels, UI hints and markers are left out. Roto Brush's Input Key writes masks and the effects above it the same way, instead of their Debug text. Keys saved by 0.22-0.24 are recognised by the old code, moved unchanged to frames_key::legacy, and kept rather than rewritten (frozen mattes hash them). --- crates/engine/src/analysis.rs | 1 + crates/engine/src/analysis/camera_track.rs | 5 +- crates/engine/src/analysis/frames_key.rs | 547 ++++++++++++++++++ .../engine/src/analysis/frames_key/legacy.rs | 144 +++++ crates/engine/src/analysis/roto.rs | 33 +- crates/engine/src/analysis/warp.rs | 147 +---- crates/engine/src/tests.rs | 2 +- crates/engine/src/tests/analysis_signature.rs | 68 +++ .../engine/src/tests/legacy_analysis_keys.rs | 6 +- 9 files changed, 777 insertions(+), 176 deletions(-) create mode 100644 crates/engine/src/analysis/frames_key.rs create mode 100644 crates/engine/src/analysis/frames_key/legacy.rs diff --git a/crates/engine/src/analysis.rs b/crates/engine/src/analysis.rs index fa2b8b4ec..a1adb6dac 100644 --- a/crates/engine/src/analysis.rs +++ b/crates/engine/src/analysis.rs @@ -1,4 +1,5 @@ pub mod camera_track; +pub(crate) mod frames_key; pub mod mask_track; pub mod models; pub mod roto; diff --git a/crates/engine/src/analysis/camera_track.rs b/crates/engine/src/analysis/camera_track.rs index 378f42dd1..43f7e1746 100644 --- a/crates/engine/src/analysis/camera_track.rs +++ b/crates/engine/src/analysis/camera_track.rs @@ -26,9 +26,10 @@ 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::offload::JobKind; use crate::tracking::lock; -use crate::warp::{analysis_times, signature}; +use crate::warp::analysis_times; use crate::{Event, Session}; /// Live analysis progress (serde for agents / the control channel). @@ -134,7 +135,7 @@ fn tracks_key_of(signature: String, g: &PropGroup) -> String { /// 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 { - crate::warp::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s| tracks_key_of(s, g)) + frames_key::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s, _| tracks_key_of(s, g)) } /// Parameters of an instance from static values (the tracker's settings are not animated). diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs new file mode 100644 index 000000000..e093c8aed --- /dev/null +++ b/crates/engine/src/analysis/frames_key.rs @@ -0,0 +1,547 @@ +//! 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. +//! +//! The key hashes a text this module writes field by field. Every struct and enum it writes is +//! 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. 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::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, +}; +use effectcraft_time::{FrameRate, Tick}; + +pub(crate) mod legacy; + +/// The version of the text, its first word: a later change to what it writes bumps it. +const VERSION: &str = "frames/1"; + +/// Property groups nest a few levels (effect ▸ group ▸ property); deeper trees are cut here. +const MAX_DEPTH: usize = 32; + +fn fnv(s: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + h +} + +fn hex(s: &str) -> String { + format!("{:016x}", fnv(s)) +} + +/// Which signature a key is made from: today's, or one an earlier release saved. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Era { + Current, + Legacy, +} + +/// 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 +/// made from it. +pub(crate) fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { + hex(&signature_text(p, comp, layer)) +} + +/// The text [`signature`] hashes. +pub(crate) fn signature_text(p: &Project, comp: ItemId, layer: &Layer) -> String { + let mut t = Text::new(); + t.word(VERSION); + t.layer_frames(p, comp, layer); + t.0 +} + +/// A Roto Brush's (or Object Matte's) Input Key: the layer's frames, its masks and the effects +/// above the instance `uid`. +pub(crate) fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { + input_key_of(signature(p, comp, layer), layer, uid) +} + +/// An Input Key from a [`signature`]. +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); + hex(&t.0) +} + +/// 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). +pub(crate) fn key_matches(p: &Project, comp: ItemId, layer: &Layer, saved: &str, key: impl Fn(String, Era) -> String) -> bool { + key(signature(p, comp, layer), Era::Current) == saved || legacy::signatures(p, comp, layer).into_iter().any(|s| key(s, Era::Legacy) == saved) +} + +/// The text a key is hashed from. Fields are `name=value;`; strings are quoted and escaped, so +/// no value can end its field early. +struct Text(String); + +impl Text { + fn new() -> Text { + Text(String::new()) + } + + fn word(&mut self, w: &str) { + self.0.push_str(w); + self.0.push(';'); + } + + fn field(&mut self, name: &str, value: impl std::fmt::Display) { + let _ = write!(self.0, "{name}={value};"); + } + + fn str(&mut self, name: &str, s: &str) { + let _ = write!(self.0, "{name}={s:?};"); + } + + fn num(&mut self, name: &str, v: f64) { + let _ = write!(self.0, "{name}={v:?};"); + } + + fn nums(&mut self, name: &str, v: &[f64]) { + let _ = write!(self.0, "{name}={v:?};"); + } + + fn floats(&mut self, name: &str, v: &[f32]) { + let _ = write!(self.0, "{name}={v:?};"); + } + + fn opt(&mut self, name: &str, v: Option<&T>, write: impl FnOnce(&mut Text, &T)) { + match v { + Some(v) => { + let _ = write!(self.0, "{name}{{"); + write(self, v); + self.0.push_str("};"); + } + None => self.word(&format!("{name}=none")), + } + } + + fn tick(&mut self, name: &str, t: Tick) { + self.field(name, t.0); + } + + fn rate(&mut self, name: &str, r: FrameRate) { + let FrameRate { num, den } = r; + let _ = write!(self.0, "{name}={num}/{den};"); + } + + /// What a layer's frames are made of, before its masks and effects. + fn layer_frames(&mut self, p: &Project, comp: ItemId, layer: &Layer) { + self.layer_source(&layer.source); + if let Some(item) = layer.source.item() { + 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). + Some(ItemKind::Comp(c)) => { + self.word("comp"); + self.field("w", c.width); + self.field("h", c.height); + self.rate("rate", c.frame_rate); + self.tick("duration", c.duration); + } + Some(ItemKind::Folder) | None => self.word("item=none"), + } + } + self.tick("in", layer.in_point); + self.tick("out", layer.out_point); + self.tick("start", layer.start_time); + self.num("stretch", layer.stretch); + self.opt("timeRemap", layer.props.get("timeRemap"), |t, pr| t.property(pr)); + self.opt("compRate", p.comp(comp).map(|c| &c.frame_rate), |t, r| t.rate("rate", *r)); + } + + fn layer_source(&mut self, s: &LayerSource) { + match s { + LayerSource::Footage { item } => self.field("footage", item.0), + LayerSource::Comp { item } => self.field("comp", item.0), + LayerSource::Solid { item } => self.field("solid", item.0), + LayerSource::Model { item } => self.field("model", item.0), + LayerSource::Text => self.word("text"), + LayerSource::Shape => self.word("shape"), + LayerSource::Null => self.word("null"), + LayerSource::Camera => self.word("camera"), + LayerSource::Light { kind } => self.field( + "light", + match kind { + LightKind::Parallel => "parallel", + LightKind::Spot => "spot", + LightKind::Point => "point", + LightKind::Ambient => "ambient", + LightKind::Environment => "environment", + }, + ), + LayerSource::Primitive { kind } => self.field( + "primitive", + match kind { + PrimitiveKind::Cube => "cube", + PrimitiveKind::Sphere => "sphere", + PrimitiveKind::Plane => "plane", + PrimitiveKind::Torus => "torus", + PrimitiveKind::Cone => "cone", + PrimitiveKind::Cylinder => "cylinder", + }, + ), + } + } + + fn solid(&mut self, s: &Solid) { + let Solid { color, width, height, pixel_aspect } = s; + self.word("solid"); + self.floats("color", color); + self.field("w", width); + self.field("h", height); + self.num("par", *pixel_aspect); + } + + fn footage(&mut self, f: &Footage) { + let Footage { + path, + kind, + width, + height, + pixel_aspect, + frame_rate, + native_rate, + duration, + has_video, + has_audio, + alpha, + premul_color, + loop_count, + codec, + missing, + sequence, + color_profile, + layer, + fields, + invert_alpha, + linear_light, + preserve_rgb, + cineon, + data, + page, + alphabetical, + start_timecode, + file_timecode, + // Markers annotate the footage; they never change its frames. + markers: _, + placed_page, + } = f; + self.word("footage"); + self.str("path", path); + self.field( + "kind", + match kind { + FootageKind::Video => "video", + FootageKind::Still => "still", + FootageKind::Sequence => "sequence", + FootageKind::Audio => "audio", + FootageKind::Model => "model", + FootageKind::Data => "data", + }, + ); + self.field("w", width); + self.field("h", height); + self.num("par", *pixel_aspect); + self.rate("rate", *frame_rate); + self.opt("nativeRate", native_rate.as_ref(), |t, r| t.rate("rate", *r)); + self.tick("duration", *duration); + self.field("video", has_video); + self.field("audio", has_audio); + self.field( + "alpha", + match alpha { + AlphaMode::Straight => "straight", + AlphaMode::Premultiplied => "premultiplied", + AlphaMode::Ignore => "ignore", + }, + ); + self.floats("matte", premul_color); + self.field("loop", loop_count); + self.str("codec", codec); + self.field("missing", missing); + self.field("frames", sequence.len()); + for s in sequence { + self.str("file", s); + } + self.opt("profile", color_profile.as_ref(), |t, c| t.word(color_space(*c))); + self.opt("layer", layer.as_ref(), |t, l| t.source_layer(l)); + self.field( + "fields", + match fields { + FieldOrder::Off => "off", + FieldOrder::UpperFirst => "upper", + FieldOrder::LowerFirst => "lower", + }, + ); + self.field("invertAlpha", invert_alpha); + self.field("linearLight", linear_light); + self.field("preserveRgb", preserve_rgb); + self.cineon(cineon); + self.opt("data", data.as_ref(), |t, d| t.str("text", d)); + self.field("page", page); + self.field("alphabetical", alphabetical); + self.opt("startTimecode", start_timecode.as_ref(), |t, v| t.field("frame", v)); + self.opt("fileTimecode", file_timecode.as_ref(), |t, v| t.field("frame", v)); + self.field("placedPage", placed_page); + } + + fn source_layer(&mut self, l: &SourceLayer) { + let SourceLayer { index, name, layer_size, embedded, placed } = l; + self.field("index", index); + // The layer is found by its index; the name only labels it. + let _ = name; + self.field("layerSize", layer_size); + self.opt("embedded", embedded.as_ref(), |t, e| t.str("id", e)); + self.field("placed", placed); + } + + fn cineon(&mut self, c: &CineonSettings) { + let CineonSettings { log_conversion, converted_black, converted_white, black, white, gamma, rolloff, units } = c; + self.word("cineon"); + self.field("log", log_conversion); + self.floats("points", &[*converted_black, *converted_white, *black, *white, *gamma, *rolloff]); + self.field( + "units", + match units { + CineonUnits::TenBit => "10bit", + CineonUnits::EightBit => "8bit", + CineonUnits::Float => "float", + }, + ); + } + + /// 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) { + 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) { + self.group(g, 0); + } + } + } + + 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; + if depth >= MAX_DEPTH { + self.word("tooDeep"); + return; + } + self.str("group", match_id); + self.group_kind(kind); + self.field("enabled", enabled); + for c in children { + match c { + Node::Prop(p) => self.property(p), + Node::Group(g) => self.group(g, depth + 1), + } + } + self.word("end"); + } + + fn group_kind(&mut self, k: &GroupKind) { + match k { + GroupKind::Plain => self.word("plain"), + GroupKind::Indexed => self.word("indexed"), + // A mask's colour and lock only show it in the UI. + GroupKind::Mask { mode, inverted, color: _, locked: _, motion_blur, feather_falloff, roto_bezier } => { + self.field( + "mask", + match mode { + MaskMode::None => "none", + MaskMode::Add => "add", + MaskMode::Subtract => "subtract", + MaskMode::Intersect => "intersect", + MaskMode::Lighten => "lighten", + MaskMode::Darken => "darken", + MaskMode::Difference => "difference", + }, + ); + self.field("inverted", inverted); + self.field( + "motionBlur", + match motion_blur { + MaskMotionBlur::SameAsLayer => "layer", + MaskMotionBlur::On => "on", + MaskMotionBlur::Off => "off", + }, + ); + self.field( + "falloff", + match feather_falloff { + FeatherFalloff::Smooth => "smooth", + FeatherFalloff::Linear => "linear", + }, + ); + self.field("rotoBezier", roto_bezier); + } + GroupKind::Effect { effect } => self.str("effect", effect), + // A motion tracker's settings say how to track, not how to draw the layer. + GroupKind::Tracker { settings: _ } => self.word("tracker"), + GroupKind::Essential { overridden } => { + self.word("essential"); + for u in overridden { + self.field("overridden", u); + } + } + } + } + + fn property(&mut self, pr: &Property) { + // The uid, name and UI hints identify and show the property; the 2D/3D-only and + // hold-only flags say where and how it can be edited, not what it evaluates to. + let Property { + uid: _, + match_id, + name: _, + value, + keys, + expr, + ui: _, + spatial, + hold_only: _, + three_d_only: _, + two_d_only: _, + shown_dims: _, + static_only: _, + } = pr; + self.str("prop", match_id); + self.value(value); + self.field("spatial", spatial); + for k in keys { + self.keyframe(k); + } + self.opt("expr", expr.as_ref(), |t, e| { + let Expression { text, enabled } = e; + t.str("text", text); + t.field("enabled", enabled); + }); + } + + fn keyframe(&mut self, k: &Keyframe) { + // A key's colour label only shows it in the UI. + let Keyframe { + time, + value, + in_interp, + out_interp, + in_ease, + out_ease, + continuous, + auto_bezier, + spatial_in, + spatial_out, + spatial_auto, + spatial_continuous, + roving, + label: _, + muted, + } = k; + self.tick("key", *time); + self.value(value); + self.field("interp", format_args!("{}/{}", interp(*in_interp), interp(*out_interp))); + for (side, eases) in [("inEase", in_ease), ("outEase", out_ease)] { + for e in eases { + let Ease { speed, influence } = e; + self.nums(side, &[*speed, *influence]); + } + } + self.field("continuous", continuous); + self.field("autoBezier", auto_bezier); + self.nums("spatialIn", spatial_in); + self.nums("spatialOut", spatial_out); + self.field("spatialAuto", spatial_auto); + self.field("spatialContinuous", spatial_continuous); + self.field("roving", roving); + self.field("muted", muted); + } + + fn value(&mut self, v: &Value) { + match v { + Value::Scalar(x) => self.num("scalar", *x), + Value::Vec2(x) => self.nums("vec2", x), + Value::Vec3(x) => self.nums("vec3", x), + Value::Color(x) => self.nums("color", x), + Value::Bool(b) => self.field("bool", b), + Value::Enum(i) => self.field("enum", i), + Value::Path(p) => self.shape_path(p), + // A text document has dozens of fields and no place in what a key covers today (a + // text layer's Source Text is not under its masks or effects): its project-file form. + Value::Text(d) => self.str("text", &serde_json::to_string(d).unwrap_or_default()), + Value::Layer(l) => self.opt("layer", l.as_ref(), |t, id| t.field("id", id)), + Value::Gradient(g) => { + let Gradient { colors, opacities } = g; + self.word("gradient"); + for (at, c) in colors { + self.num("stop", *at); + self.floats("rgba", c); + } + for (at, o) in opacities { + self.num("stop", *at); + self.floats("opacity", &[*o]); + } + } + Value::Str(s) => self.str("str", s), + } + } + + fn shape_path(&mut self, p: &ShapePath) { + let ShapePath { vertices, in_tangents, out_tangents, closed, feather } = p; + self.word("path"); + for (name, points) in [("v", vertices), ("in", in_tangents), ("out", out_tangents)] { + for pt in points { + self.nums(name, pt); + } + } + self.field("closed", closed); + for f in feather { + let FeatherPoint { segment, t, radius, tension } = f; + self.field("featherSegment", segment); + self.nums("feather", &[*t, *radius, *tension]); + } + } +} + +fn interp(i: Interp) -> &'static str { + match i { + Interp::Linear => "linear", + Interp::Bezier => "bezier", + Interp::Hold => "hold", + } +} + +fn color_space(c: ColorSpace) -> &'static str { + match c { + ColorSpace::Srgb => "srgb", + ColorSpace::Rec709 => "rec709", + ColorSpace::Rec2020 => "rec2020", + ColorSpace::DisplayP3 => "p3", + ColorSpace::AcesCg => "acescg", + ColorSpace::Aces2065 => "aces2065", + ColorSpace::Rec2100Pq => "rec2100pq", + ColorSpace::Rec2100Hlg => "rec2100hlg", + } +} diff --git a/crates/engine/src/analysis/frames_key/legacy.rs b/crates/engine/src/analysis/frames_key/legacy.rs new file mode 100644 index 000000000..6147883ae --- /dev/null +++ b/crates/engine/src/analysis/frames_key/legacy.rs @@ -0,0 +1,144 @@ +//! Signatures as 0.22–0.24 wrote them, to recognise the keys those releases saved. Nothing new is +//! keyed with them. They hash derived `Debug` text, so `tests::analysis_signature` pins keys +//! those releases saved: if a type change alters this text, write that part out here. + +use effectcraft_project::{Footage, ItemId, ItemKind, Layer, LayerSource, Project, Uid}; + +use super::fnv; + +/// The signatures a saved key of `layer` may have been made from. Before `Footage::placed_page` +/// was added in 0.23, the footage text ended without that field. +pub(crate) fn signatures(p: &Project, comp: ItemId, layer: &Layer) -> Vec { + let src = match &layer.source { + LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => { + let mut out = Vec::with_capacity(2); + out.push(signature_with_source(p, comp, layer, &footage_text(f, f.missing, true))); + if matches!(layer.source, LayerSource::Footage { .. }) && !f.placed_page { + out.push(signature_with_source(p, comp, layer, &footage_text(f, f.missing, false))); + } + return out; + } + Some(ItemKind::Solid(s)) => format!("{s:?}"), + Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration), + _ => String::new(), + }, + _ => String::new(), + }; + vec![signature_with_source(p, comp, layer, &src)] +} + +/// A Roto Brush Input Key as 0.22–0.24 made it from a [`signatures`] entry. +pub(crate) fn input_key_of(mut s: String, layer: &Layer, uid: Uid) -> String { + if let Some(m) = layer.masks() { + s.push_str(&format!("{m:?}")); + } + if let Some(fx) = layer.effects() { + for g in fx.groups() { + if g.uid == uid { + break; + } + s.push_str(&format!("{g:?}")); + } + } + format!("{:016x}", fnv(&s)) +} + +/// A footage item as 0.23–0.24 wrote it into a signature (without `placed_page` before 0.23), +/// with `missing` in place of the item's. +pub(crate) fn footage_text(f: &Footage, missing: bool, placed_page: bool) -> String { + format!("{:?}", FootageText { f, missing, placed_page }) +} + +struct FootageText<'a> { + f: &'a Footage, + missing: bool, + placed_page: bool, +} + +impl std::fmt::Debug for FootageText<'_> { + fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Every field is named so that a new one doesn't compile until it is decided here: a new + // field was never in these keys, so it stays out. + let Footage { + path, + kind, + width, + height, + pixel_aspect, + frame_rate, + native_rate, + duration, + has_video, + has_audio, + alpha, + premul_color, + loop_count, + codec, + missing: _, + sequence, + color_profile, + layer, + fields, + invert_alpha, + linear_light, + preserve_rgb, + cineon, + data, + page, + alphabetical, + start_timecode, + file_timecode, + markers, + placed_page, + } = self.f; + let mut d = out.debug_struct("Footage"); + d.field("path", path) + .field("kind", kind) + .field("width", width) + .field("height", height) + .field("pixel_aspect", pixel_aspect) + .field("frame_rate", frame_rate) + .field("native_rate", native_rate) + .field("duration", duration) + .field("has_video", has_video) + .field("has_audio", has_audio) + .field("alpha", alpha) + .field("premul_color", premul_color) + .field("loop_count", loop_count) + .field("codec", codec) + .field("missing", &self.missing) + .field("sequence", sequence) + .field("color_profile", color_profile) + .field("layer", layer) + .field("fields", fields) + .field("invert_alpha", invert_alpha) + .field("linear_light", linear_light) + .field("preserve_rgb", preserve_rgb) + .field("cineon", cineon) + .field("data", data) + .field("page", page) + .field("alphabetical", alphabetical) + .field("start_timecode", start_timecode) + .field("file_timecode", file_timecode) + .field("markers", markers); + if self.placed_page { + d.field("placed_page", placed_page); + } + d.finish() + } +} + +fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String { + let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default(); + let s = format!( + "{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}", + layer.source, + layer.in_point, + layer.out_point, + layer.start_time, + layer.stretch, + layer.props.get("timeRemap") + ); + format!("{:016x}", fnv(&s)) +} diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 89d3e1609..4a79f5aa0 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -31,6 +31,7 @@ use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpt use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; +use crate::analysis::frames_key::{self, Era}; use crate::tracking::lock; use crate::{Event, Session}; @@ -207,34 +208,10 @@ fn set_str(g: &mut PropGroup, m: &str, v: String) { } } -fn fnv(s: &str) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in s.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - /// What the effect's input frames are made of: the layer's source and timing, its masks and the /// effects above the Roto Brush. pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { - input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid) -} - -fn input_key_with_signature(mut s: String, layer: &Layer, uid: Uid) -> String { - if let Some(m) = layer.masks() { - s.push_str(&format!("{m:?}")); - } - if let Some(fx) = layer.effects() { - for g in fx.groups() { - if g.uid == uid { - break; - } - s.push_str(&format!("{g:?}")); - } - } - format!("{:016x}", fnv(&s)) + frames_key::input_key(p, comp, layer, uid) } /// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from @@ -252,7 +229,11 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); let saved_key = str_of(g, INPUT_KEY); // Keep a matching old key: the frozen cache itself also hashes that key. - if !crate::warp::key_matches(after, *cid, l, &saved_key, |s| input_key_with_signature(s, l, g.uid)) { + let key_of = |s, era| match era { + Era::Current => frames_key::input_key_of(s, l, g.uid), + Era::Legacy => frames_key::legacy::input_key_of(s, l, g.uid), + }; + if !frames_key::key_matches(after, *cid, l, &saved_key, key_of) { set.push((*cid, l.id, g.uid, key)); todo.push((*cid, l.id, g.uid)); } else if old.is_none_or(|o| guard_of(o) != guard_of(g)) { diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 20b88d45d..1985f0ca0 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -17,13 +17,14 @@ use std::sync::{Arc, Mutex}; use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY}; use effectcraft_keyframe::Value; -use effectcraft_project::{Footage, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid}; +use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid}; use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis}; use serde::Serialize; +use crate::analysis::frames_key::{self, signature}; use crate::tracking::lock; use crate::{Event, Session}; @@ -98,148 +99,6 @@ pub(crate) struct WarpWork { pub times: Vec, } -fn fnv(s: &str) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in s.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - -/// What an analysis of `layer` depends on (its frames): source (and the footage item's -/// interpretation), In/Out, start time, stretch, Time Remap and the comp frame rate. -pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { - let src = match &layer.source { - LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => footage_text(f, true), - Some(ItemKind::Solid(s)) => format!("{s:?}"), - Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration), - _ => String::new(), - }, - _ => String::new(), - }; - signature_with_source(p, comp, layer, &src) -} - -/// Whether `saved`, a key an analysis of `layer` was saved with, is for the layer's current frames. -/// `key` makes such a key from a signature. A key made from the signature an earlier release wrote -/// for the same frames matches too: keep it rather than rewrite it (analysing writes today's). -pub(crate) fn key_matches(p: &Project, comp: ItemId, layer: &Layer, saved: &str, key: impl Fn(String) -> String) -> bool { - key(signature(p, comp, layer)) == saved || signature_before_placed_page(p, comp, layer).is_some_and(|s| key(s) == saved) -} - -/// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the -/// same footage text without that last field. Only ordinary footage can match it. -fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option { - let LayerSource::Footage { item } = &layer.source else { return None }; - let ItemKind::Footage(footage) = &p.item(*item)?.kind else { return None }; - if footage.placed_page { - return None; - } - Some(signature_with_source(p, comp, layer, &footage_text(footage, false))) -} - -/// A footage item in a signature: its fields as `Debug` wrote them in 0.23, the text saved -/// analysis keys were hashed from (without `placed_page` before 0.23). It is written out here so -/// that a new `Footage` field doesn't change every signature and drop every saved analysis. -pub(crate) fn footage_text(f: &Footage, placed_page: bool) -> String { - format!("{:?}", FootageText { f, placed_page }) -} - -struct FootageText<'a> { - f: &'a Footage, - placed_page: bool, -} - -impl std::fmt::Debug for FootageText<'_> { - fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Every field is named, so a new one doesn't compile until it is decided here. A new field - // that changes the frames goes last, written only when it isn't its default, so keys saved - // before it still match. A new field that doesn't change the frames stays out. - let Footage { - path, - kind, - width, - height, - pixel_aspect, - frame_rate, - native_rate, - duration, - has_video, - has_audio, - alpha, - premul_color, - loop_count, - codec, - missing, - sequence, - color_profile, - layer, - fields, - invert_alpha, - linear_light, - preserve_rgb, - cineon, - data, - page, - alphabetical, - start_timecode, - file_timecode, - markers, - placed_page, - } = self.f; - let mut d = out.debug_struct("Footage"); - d.field("path", path) - .field("kind", kind) - .field("width", width) - .field("height", height) - .field("pixel_aspect", pixel_aspect) - .field("frame_rate", frame_rate) - .field("native_rate", native_rate) - .field("duration", duration) - .field("has_video", has_video) - .field("has_audio", has_audio) - .field("alpha", alpha) - .field("premul_color", premul_color) - .field("loop_count", loop_count) - .field("codec", codec) - .field("missing", missing) - .field("sequence", sequence) - .field("color_profile", color_profile) - .field("layer", layer) - .field("fields", fields) - .field("invert_alpha", invert_alpha) - .field("linear_light", linear_light) - .field("preserve_rgb", preserve_rgb) - .field("cineon", cineon) - .field("data", data) - .field("page", page) - .field("alphabetical", alphabetical) - .field("start_timecode", start_timecode) - .field("file_timecode", file_timecode) - .field("markers", markers); - if self.placed_page { - d.field("placed_page", placed_page); - } - d.finish() - } -} - -fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String { - let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default(); - let s = format!( - "{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}", - layer.source, - layer.in_point, - layer.out_point, - layer.start_time, - layer.stretch, - layer.props.get("timeRemap") - ); - format!("{:016x}", fnv(&s)) -} - fn is_warp(g: &PropGroup) -> bool { matches!(&g.kind, GroupKind::Effect { effect } if effect == warp_stab::ID) } @@ -266,7 +125,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 && !key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s| s) { + if analysed && !frames_key::key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s, _| s) { 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() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0422e95b4..eddeeb7ef 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_signature; mod anim_presets; mod anim_tools; mod aux_footage; @@ -27,7 +28,6 @@ mod key_metadata_order; mod key_time; mod keyframe_text; mod keylight; -mod analysis_signature; mod legacy_analysis_keys; mod limits; mod lottie; diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs index 7429c4035..98612bee4 100644 --- a/crates/engine/src/tests/analysis_signature.rs +++ b/crates/engine/src/tests/analysis_signature.rs @@ -92,3 +92,71 @@ fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() { s.execute_checked("edit.undo", json!({})).unwrap(); assert_eq!(saved(&s), before); } + +/// The text keys are made from today, written field by field (#3536). It changes only when this +/// test is changed with it: a new field of a type it writes doesn't compile until it is decided +/// in `analysis::frames_key`, and renaming a field or variant doesn't change it. +#[test] +fn signature_text_is_pinned() { + let s = open(ANALYSED_0_24); + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let key = r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=video;w=160;h=120;par=1.0;rate=12/1;nativeRate=none;duration=254016000000;video=true;audio=false;alpha=ignore;matte=[0.0, 0.0, 0.0];loop=1;codec="H.264 (High)";missing=false;frames=0;profile{rec709;};layer=none;fields=off;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=false;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=10bit;data=none;page=0;alphabetical=false;startTimecode=none;fileTimecode=none;placedPage=false;in=0;out=254016000000;start=0;stretch=100.0;timeRemap{prop="timeRemap";scalar=0.0;spatial=false;key=0;scalar=0.2;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;key=254016000000;scalar=1.0;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;expr=none;};compRate{rate=12/1;};"#; + assert_eq!(crate::analysis::frames_key::signature_text(&s.project, COMP, layer), key); +} + +/// Keys made today (after analysing again) match through edits, undo and save/reopen too, and +/// renaming or relabelling never drops them. +#[test] +fn todays_keys_survive_edits_that_keep_their_frames() { + let mut s = open(ANALYSED_0_24); + // Analysing again writes today's keys. + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let signature = crate::analysis::frames_key::signature(&s.project, COMP, layer); + let input = crate::roto::input_key(&s.project, COMP, layer, ROTO); + assert_ne!(signature, SIGNATURE_0_24); + s.edit("analyse", None, |p, _| { + let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap(); + for (uid, id, key) in [(WARP, ANALYSIS_KEY, &signature), (TRACKER, TRACKS_KEY, &signature), (ROTO, INPUT_KEY, &input)] { + l.props.find_group_mut(uid).unwrap().get_mut(id).unwrap().value = Value::Str(key.clone()); + } + Ok(()) + }) + .unwrap(); + // The Roto Brush's key covers the effects above it: the Warp Stabilizer's new key changed it. + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let input = crate::roto::input_key(&s.project, COMP, layer, ROTO); + s.edit("roto key", None, |p, _| { + let g = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(ROTO).unwrap(); + g.get_mut(INPUT_KEY).unwrap().value = Value::Str(input.clone()); + g.get_mut(FROZEN).unwrap().value = Value::Str("saved frozen".into()); + Ok(()) + }) + .unwrap(); + let before = saved(&s); + assert_eq!(before[0].1, signature); + assert_eq!(before[2], ("saved frozen".into(), input)); + + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + let mut s = open(&s.project.to_json()); + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + // Names and labels only show things. + s.execute_checked("layer.rename", json!({"layer": CLIP.0, "name": "Renamed"})).unwrap(); + s.edit("rename", None, |p, _| { + let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap(); + l.props.find_group_mut(82).unwrap().name = "Soft".into(); + if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.markers.push(effectcraft_project::Marker::default()); + } + Ok(()) + }) + .unwrap(); + assert_eq!(saved(&s), before); + + // A real change still clears them. + s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap(); + assert!(saved(&s).iter().all(|(data, _)| data.is_empty())); +} diff --git a/crates/engine/src/tests/legacy_analysis_keys.rs b/crates/engine/src/tests/legacy_analysis_keys.rs index 06a05cc77..6cdb125d3 100644 --- a/crates/engine/src/tests/legacy_analysis_keys.rs +++ b/crates/engine/src/tests/legacy_analysis_keys.rs @@ -50,7 +50,7 @@ fn analyses_saved_by_0_22_survive_edits_that_keep_their_frames() { for (len, _, key) in &before { assert!(*len > 0); // The key predates Footage::placed_page, so it isn't today's signature. - assert_ne!(key, &crate::warp::signature(&s.project, COMP, layer)); + assert_ne!(key, &crate::analysis::frames_key::signature(&s.project, COMP, layer)); } // Adding to the render queue edits the project, as `effectcraft-cli render` does. @@ -122,6 +122,6 @@ fn footage_signature_text_is_pinned() { ..Footage::default() }; let fields = r#"path: "clip.mp4", kind: Video, width: 160, height: 120, pixel_aspect: 1.0, frame_rate: FrameRate { num: 30, den: 1 }, native_rate: None, duration: Tick(0), has_video: false, has_audio: false, alpha: Straight, premul_color: [0.0, 0.0, 0.0], loop_count: 1, codec: "", missing: false, sequence: [], color_profile: None, layer: None, fields: Off, invert_alpha: true, linear_light: false, preserve_rgb: false, cineon: CineonSettings { log_conversion: false, converted_black: 0.0, converted_white: 1023.0, black: 95.0, white: 685.0, gamma: 1.7, rolloff: 20.0, units: TenBit }, data: None, page: 2, alphabetical: false, start_timecode: None, file_timecode: None, markers: []"#; - assert_eq!(crate::warp::footage_text(&f, true), format!("Footage {{ {fields}, placed_page: true }}")); - assert_eq!(crate::warp::footage_text(&f, false), format!("Footage {{ {fields} }}")); + assert_eq!(crate::analysis::frames_key::legacy::footage_text(&f, false, true), format!("Footage {{ {fields}, placed_page: true }}")); + assert_eq!(crate::analysis::frames_key::legacy::footage_text(&f, false, false), format!("Footage {{ {fields} }}")); } From 54cf31f841b4cd9b5eb7f61b75f08484633b10f0 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:10:23 +0530 Subject: [PATCH 3/7] #3534: footage going offline no longer drops saved analyses Whether a footage file is there is an observation (footage.check sets Footage::missing without an edit), not part of the footage. The signature leaves it out, and keys saved by 0.22-0.24, which hashed it, are recognised with the file either online or offline. Warp Stabilizer, 3D Camera Tracker and Roto Brush analyses are kept through the next edit while the footage is offline and after it comes back. --- crates/engine/src/analysis/frames_key.rs | 5 ++- .../engine/src/analysis/frames_key/legacy.rs | 15 ++++--- crates/engine/src/tests/analysis_signature.rs | 40 ++++++++++++++++++- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs index e093c8aed..1b141c53e 100644 --- a/crates/engine/src/analysis/frames_key.rs +++ b/crates/engine/src/analysis/frames_key.rs @@ -226,7 +226,9 @@ impl Text { premul_color, loop_count, codec, - missing, + // Whether the file is there is an observation (`footage.check`), not an edit: an + // offline file is the same footage, and its analyses stay valid (#3534). + missing: _, sequence, color_profile, layer, @@ -276,7 +278,6 @@ impl Text { self.floats("matte", premul_color); self.field("loop", loop_count); self.str("codec", codec); - self.field("missing", missing); self.field("frames", sequence.len()); for s in sequence { self.str("file", s); diff --git a/crates/engine/src/analysis/frames_key/legacy.rs b/crates/engine/src/analysis/frames_key/legacy.rs index 6147883ae..882c82d80 100644 --- a/crates/engine/src/analysis/frames_key/legacy.rs +++ b/crates/engine/src/analysis/frames_key/legacy.rs @@ -6,16 +6,19 @@ use effectcraft_project::{Footage, ItemId, ItemKind, Layer, LayerSource, Project use super::fnv; -/// The signatures a saved key of `layer` may have been made from. Before `Footage::placed_page` -/// was added in 0.23, the footage text ended without that field. +/// The signatures a saved key of `layer` may have been made from. The footage's availability is +/// tried both ways: those releases hashed it, though it isn't part of the footage (#3534). Before +/// `Footage::placed_page` was added in 0.23, the footage text ended without that field. pub(crate) fn signatures(p: &Project, comp: ItemId, layer: &Layer) -> Vec { let src = match &layer.source { LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) { Some(ItemKind::Footage(f)) => { - let mut out = Vec::with_capacity(2); - out.push(signature_with_source(p, comp, layer, &footage_text(f, f.missing, true))); - if matches!(layer.source, LayerSource::Footage { .. }) && !f.placed_page { - out.push(signature_with_source(p, comp, layer, &footage_text(f, f.missing, false))); + let mut out = Vec::with_capacity(4); + for missing in [false, true] { + out.push(signature_with_source(p, comp, layer, &footage_text(f, missing, true))); + if matches!(layer.source, LayerSource::Footage { .. }) && !f.placed_page { + out.push(signature_with_source(p, comp, layer, &footage_text(f, missing, false))); + } } return out; } diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs index 98612bee4..58a930998 100644 --- a/crates/engine/src/tests/analysis_signature.rs +++ b/crates/engine/src/tests/analysis_signature.rs @@ -100,7 +100,7 @@ fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() { fn signature_text_is_pinned() { let s = open(ANALYSED_0_24); let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); - let key = r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=video;w=160;h=120;par=1.0;rate=12/1;nativeRate=none;duration=254016000000;video=true;audio=false;alpha=ignore;matte=[0.0, 0.0, 0.0];loop=1;codec="H.264 (High)";missing=false;frames=0;profile{rec709;};layer=none;fields=off;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=false;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=10bit;data=none;page=0;alphabetical=false;startTimecode=none;fileTimecode=none;placedPage=false;in=0;out=254016000000;start=0;stretch=100.0;timeRemap{prop="timeRemap";scalar=0.0;spatial=false;key=0;scalar=0.2;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;key=254016000000;scalar=1.0;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;expr=none;};compRate{rate=12/1;};"#; + let key = r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=video;w=160;h=120;par=1.0;rate=12/1;nativeRate=none;duration=254016000000;video=true;audio=false;alpha=ignore;matte=[0.0, 0.0, 0.0];loop=1;codec="H.264 (High)";frames=0;profile{rec709;};layer=none;fields=off;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=false;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=10bit;data=none;page=0;alphabetical=false;startTimecode=none;fileTimecode=none;placedPage=false;in=0;out=254016000000;start=0;stretch=100.0;timeRemap{prop="timeRemap";scalar=0.0;spatial=false;key=0;scalar=0.2;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;key=254016000000;scalar=1.0;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;expr=none;};compRate{rate=12/1;};"#; assert_eq!(crate::analysis::frames_key::signature_text(&s.project, COMP, layer), key); } @@ -143,6 +143,14 @@ fn todays_keys_survive_edits_that_keep_their_frames() { let mut s = open(&s.project.to_json()); add_to_render_queue(&mut s); assert_eq!(saved(&s), before); + // Offline and back online (#3534). + for missing in [true, false] { + if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.missing = missing; + } + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + } // Names and labels only show things. s.execute_checked("layer.rename", json!({"layer": CLIP.0, "name": "Renamed"})).unwrap(); s.edit("rename", None, |p, _| { @@ -160,3 +168,33 @@ fn todays_keys_survive_edits_that_keep_their_frames() { s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap(); assert!(saved(&s).iter().all(|(data, _)| data.is_empty())); } + +/// Footage going offline (or coming back) is an observation, not an edit: the next edit keeps +/// every analysis (#3534). +#[test] +fn offline_footage_keeps_analyses() { + let mut s = open(ANALYSED_0_24); + let before = saved(&s); + let missing = |s: &Session| matches!(&s.project.item(ItemId(1)).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.missing); + // The fixture's footage path doesn't exist here: the check marks it missing. + let r = s.execute_checked("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"], 1, "{r}"); + assert!(missing(&s)); + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + + // Saved and reopened while offline, then back online. + let mut s = open(&s.project.to_json()); + assert!(missing(&s)); + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.missing = false; + } + add_to_render_queue(&mut s); + assert_eq!(saved(&s), before); + + // A real change while offline still clears them. + s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap(); + assert!(saved(&s).iter().all(|(data, _)| data.is_empty())); +} From a19ffbc0db668bc64b3338db7585fbc8736272a7 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:10:55 +0530 Subject: [PATCH 4/7] #3534, #3536: docs: the Warp Stabilizer and Roto Brush keys come from analysis::frames_key --- crates/engine/src/analysis/roto.rs | 4 ++-- crates/engine/src/analysis/warp.rs | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 4a79f5aa0..cb67005f8 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -8,8 +8,8 @@ //! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same //! pass and stores the final mattes in the effect (one undo step). `sync`, run after every //! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made -//! of (the layer's source and timing, its masks and the effects above it) and drops frozen -//! mattes made from other frames. +//! of (the layer's source and timing, its masks and the effects above it: `analysis::frames_key`), +//! and drops frozen mattes made from other frames. Footage going offline keeps them. //! //! Object Matte instances run through the same jobs (`objectMatte.propagate` / `.freeze`): the //! frames are [`om::job_frames`] around the reference frame, each segmented through diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 1985f0ca0..610e994ca 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -6,10 +6,11 @@ //! 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). 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). +//! 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 +//! re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses +//! automatically too). Footage going offline is not such a change. use crate::offload::{JobKind, rate}; use std::sync::atomic::{AtomicBool, Ordering}; From 1c8d20a12fde6cb53a424b699e4dabfb00ca2cec Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:19:24 +0530 Subject: [PATCH 5/7] #3534: test a key 0.24.1 saved while the footage was offline; the frames key doc says what is read in full --- crates/engine/src/analysis/frames_key.rs | 11 +++--- crates/engine/src/tests/analysis_signature.rs | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs index 1b141c53e..44eb93d57 100644 --- a/crates/engine/src/analysis/frames_key.rs +++ b/crates/engine/src/analysis/frames_key.rs @@ -1,10 +1,13 @@ //! 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. //! -//! The key hashes a text this module writes field by field. Every struct and enum it writes is -//! 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. The +//! 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). //! diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs index 58a930998..96323e6be 100644 --- a/crates/engine/src/tests/analysis_signature.rs +++ b/crates/engine/src/tests/analysis_signature.rs @@ -198,3 +198,39 @@ fn offline_footage_keeps_analyses() { s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap(); assert!(saved(&s).iter().all(|(data, _)| data.is_empty())); } + +/// A key 0.24.1 saved while the footage was offline (it hashed the availability) matches too, +/// online or offline. 0.24.1 gave the Roto Brush this Input Key when the fixture's footage went +/// offline and an edit cleared the other two analyses. +#[test] +fn keys_saved_by_0_24_while_offline_still_match() { + const ROTO_INPUT_0_24_OFFLINE: &str = "6183fdd46b514259"; + let mut s = open(ANALYSED_0_24); + s.edit("offline", None, |p, _| { + if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.missing = true; + } + let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap(); + // What 0.24.1's edit cleared then. + use effectcraft_effects::camera_tracker::{AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY}; + for (uid, id) in + [(WARP, ANALYSIS), (WARP, ANALYSIS_KEY), (TRACKER, SOLVE), (TRACKER, SOLVE_KEY), (TRACKER, METHOD_USED), (TRACKER, TRACKS), (TRACKER, TRACKS_KEY)] + { + l.props.find_group_mut(uid).unwrap().prop_mut(id).unwrap().value = Value::Str(String::new()); + } + l.props.find_group_mut(TRACKER).unwrap().prop_mut(AVERAGE_ERROR).unwrap().value = Value::Scalar(0.0); + let g = l.props.find_group_mut(ROTO).unwrap(); + g.get_mut(INPUT_KEY).unwrap().value = Value::Str(ROTO_INPUT_0_24_OFFLINE.into()); + Ok(()) + }) + .unwrap(); + let roto = |s: &Session| saved(s)[2].clone(); + assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into())); + for missing in [true, false] { + if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.missing = missing; + } + add_to_render_queue(&mut s); + assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into())); + } +} From e04ae226a915d7d50eb869174a082ef533f87c5b Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:37:02 +0530 Subject: [PATCH 6/7] #3536: the frames key writes group and property names, and the uid of a property with an expression Expressions find effects by name and read names (thisProperty.name, propertyGroup(1).name), and seed random/wiggle with the property uid, so both can change the frames. The Debug-based key had them. --- crates/engine/src/analysis/frames_key.rs | 34 +++++++---------- crates/engine/src/tests/analysis_signature.rs | 38 ++++++++++++++++--- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs index 44eb93d57..248b3b4f5 100644 --- a/crates/engine/src/analysis/frames_key.rs +++ b/crates/engine/src/analysis/frames_key.rs @@ -9,7 +9,7 @@ //! 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). +//! (colour 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 @@ -348,13 +348,15 @@ impl Text { } 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; + // Expressions find effects and groups by name (`effect("Blur")`, `propertyGroup(1).name`). + // The uid and colour label never change pixels. + let PropGroup { uid: _, match_id, name, kind, enabled, label: _, children } = g; if depth >= MAX_DEPTH { self.word("tooDeep"); return; } self.str("group", match_id); + self.str("name", name); self.group_kind(kind); self.field("enabled", enabled); for c in children { @@ -415,24 +417,16 @@ impl Text { } fn property(&mut self, pr: &Property) { - // The uid, name and UI hints identify and show the property; the 2D/3D-only and - // hold-only flags say where and how it can be edited, not what it evaluates to. - let Property { - uid: _, - match_id, - name: _, - value, - keys, - expr, - ui: _, - spatial, - hold_only: _, - three_d_only: _, - two_d_only: _, - shown_dims: _, - static_only: _, - } = pr; + // Expressions read the name (`thisProperty.name`) and seed `random`/`wiggle` with the + // uid: the uid is written for a property with an expression. UI hints and the 2D/3D-only + // and hold-only flags say where and how it can be edited, not what it evaluates to. + let Property { uid, match_id, name, value, keys, expr, ui: _, spatial, hold_only: _, three_d_only: _, two_d_only: _, shown_dims: _, static_only: _ } = + pr; self.str("prop", match_id); + self.str("name", name); + if expr.is_some() { + self.field("uid", uid); + } self.value(value); self.field("spatial", spatial); for k in keys { diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs index 96323e6be..b73c79edd 100644 --- a/crates/engine/src/tests/analysis_signature.rs +++ b/crates/engine/src/tests/analysis_signature.rs @@ -100,7 +100,7 @@ fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() { fn signature_text_is_pinned() { let s = open(ANALYSED_0_24); let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); - let key = r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=video;w=160;h=120;par=1.0;rate=12/1;nativeRate=none;duration=254016000000;video=true;audio=false;alpha=ignore;matte=[0.0, 0.0, 0.0];loop=1;codec="H.264 (High)";frames=0;profile{rec709;};layer=none;fields=off;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=false;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=10bit;data=none;page=0;alphabetical=false;startTimecode=none;fileTimecode=none;placedPage=false;in=0;out=254016000000;start=0;stretch=100.0;timeRemap{prop="timeRemap";scalar=0.0;spatial=false;key=0;scalar=0.2;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;key=254016000000;scalar=1.0;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;expr=none;};compRate{rate=12/1;};"#; + let key = r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=video;w=160;h=120;par=1.0;rate=12/1;nativeRate=none;duration=254016000000;video=true;audio=false;alpha=ignore;matte=[0.0, 0.0, 0.0];loop=1;codec="H.264 (High)";frames=0;profile{rec709;};layer=none;fields=off;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=false;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=10bit;data=none;page=0;alphabetical=false;startTimecode=none;fileTimecode=none;placedPage=false;in=0;out=254016000000;start=0;stretch=100.0;timeRemap{prop="timeRemap";name="Time Remap";scalar=0.0;spatial=false;key=0;scalar=0.2;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;key=254016000000;scalar=1.0;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;expr=none;};compRate{rate=12/1;};"#; assert_eq!(crate::analysis::frames_key::signature_text(&s.project, COMP, layer), key); } @@ -151,11 +151,10 @@ fn todays_keys_survive_edits_that_keep_their_frames() { add_to_render_queue(&mut s); assert_eq!(saved(&s), before); } - // Names and labels only show things. - s.execute_checked("layer.rename", json!({"layer": CLIP.0, "name": "Renamed"})).unwrap(); - s.edit("rename", None, |p, _| { + // Colour labels and footage markers only show things. + s.edit("label", None, |p, _| { let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap(); - l.props.find_group_mut(82).unwrap().name = "Soft".into(); + l.props.find_group_mut(82).unwrap().label = Some(effectcraft_color::Label::Yellow); if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) { f.markers.push(effectcraft_project::Marker::default()); } @@ -163,6 +162,16 @@ fn todays_keys_survive_edits_that_keep_their_frames() { }) .unwrap(); assert_eq!(saved(&s), before); + // Expressions find effects by name: renaming the effect above the Roto Brush is a change for it. + s.edit("rename", None, |p, _| { + p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(82).unwrap().name = "Soft".into(); + Ok(()) + }) + .unwrap(); + assert_eq!(saved(&s)[..2], before[..2]); + assert_eq!(saved(&s)[2].0, ""); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); // A real change still clears them. s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap(); @@ -234,3 +243,22 @@ fn keys_saved_by_0_24_while_offline_still_match() { assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into())); } } + +/// Expressions seed `random` and `wiggle` with the property's uid: a Time Remap with an expression +/// keys its uid (a duplicated layer gets new uids), one without doesn't. +#[test] +fn an_expression_keys_its_property_uid() { + let s = open(ANALYSED_0_24); + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap().clone(); + let text = |l: &effectcraft_project::Layer| crate::analysis::frames_key::signature_text(&s.project, COMP, l); + let renumbered = |l: &effectcraft_project::Layer| { + let mut l = l.clone(); + l.props.get_mut("timeRemap").unwrap().uid += 1000; + l + }; + assert_eq!(text(&renumbered(&layer)), text(&layer)); + let mut with_expr = layer.clone(); + with_expr.props.get_mut("timeRemap").unwrap().expr = + Some(effectcraft_project::Expression { text: "seedRandom(0, true); random(0, 1)".into(), enabled: true }); + assert_ne!(text(&renumbered(&with_expr)), text(&with_expr)); +} From 94ab5d77cf2e4a1fb2dd4775deffa5f7aef6ce3e Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:47:05 +0530 Subject: [PATCH 7/7] #3536: pin the key text of non-default footage, source layer, Cineon, colour space and solid values; every mask mode keys apart --- crates/engine/src/tests/analysis_signature.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs index b73c79edd..045e279de 100644 --- a/crates/engine/src/tests/analysis_signature.rs +++ b/crates/engine/src/tests/analysis_signature.rs @@ -262,3 +262,54 @@ fn an_expression_keys_its_property_uid() { Some(effectcraft_project::Expression { text: "seedRandom(0, true); random(0, 1)".into(), enabled: true }); assert_ne!(text(&renumbered(&with_expr)), text(&with_expr)); } + +/// Non-default values of the types the key writes are written as their fixed words (#3536). +#[test] +fn nested_values_are_written_as_fixed_words() { + use effectcraft_project::{ + AlphaMode, CineonUnits, FieldOrder, FootageKind, GroupKind, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Solid, SourceLayer, + }; + let mut s = open(ANALYSED_0_24); + let p = std::sync::Arc::make_mut(&mut s.project); + if let Some(ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) { + f.kind = FootageKind::Sequence; + f.sequence = vec!["a_0001.png".into(), "a_0002.png".into()]; + f.alpha = AlphaMode::Premultiplied; + f.premul_color = [0.5, 0.25, 1.0]; + f.fields = FieldOrder::UpperFirst; + f.cineon.units = CineonUnits::Float; + f.cineon.log_conversion = true; + f.color_profile = Some(effectcraft_color::ColorSpace::DisplayP3); + f.layer = Some(SourceLayer { index: 3, name: "Sky".into(), layer_size: true, embedded: Some("id-7".into()), placed: true }); + f.native_rate = Some(effectcraft_time::FrameRate { num: 30000, den: 1001 }); + f.start_timecode = Some(42); + f.data = Some("{\"k\":1}".into()); + } + let layer = p.comp(COMP).unwrap().layer(CLIP).unwrap().clone(); + let text = crate::analysis::frames_key::signature_text(p, COMP, &layer); + let footage = text.split(";in=").next().unwrap(); + assert_eq!( + footage, + r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=sequence;w=160;h=120;par=1.0;rate=12/1;nativeRate{rate=30000/1001;};duration=254016000000;video=true;audio=false;alpha=premultiplied;matte=[0.5, 0.25, 1.0];loop=1;codec="H.264 (High)";frames=2;file="a_0001.png";file="a_0002.png";profile{p3;};layer{index=3;layerSize=true;embedded{id="id-7";};placed=true;};fields=upper;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=true;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=float;data{text="{\"k\":1}";};page=0;alphabetical=false;startTimecode{frame=42;};fileTimecode=none;placedPage=false"# + ); + + let solid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 64, height: 48, pixel_aspect: 2.0 })); + let mut on_solid = layer.clone(); + on_solid.source = LayerSource::Solid { item: solid }; + let text = crate::analysis::frames_key::signature_text(p, COMP, &on_solid); + assert_eq!(text.split(";in=").next().unwrap(), format!("frames/1;solid={};solid;color=[1.0, 0.5, 0.0];w=64;h=48;par=2.0", solid.0)); + + // Every mask mode and motion blur setting gives its own Input Key. + let mask = layer.masks().unwrap().groups().next().unwrap().uid; + let mut keys = std::collections::BTreeSet::new(); + for mode in MaskMode::ALL { + for blur in [MaskMotionBlur::SameAsLayer, MaskMotionBlur::On, MaskMotionBlur::Off] { + let mut l = layer.clone(); + if let GroupKind::Mask { mode: m, motion_blur, .. } = &mut l.props.find_group_mut(mask).unwrap().kind { + (*m, *motion_blur) = (mode, blur); + } + keys.insert(crate::analysis::frames_key::input_key(p, COMP, &l, ROTO)); + } + } + assert_eq!(keys.len(), MaskMode::ALL.len() * 3); +}