From 6780c65671f70ca0828a9b584b0c33695927e910 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 10:44:57 +0530 Subject: [PATCH] #3456: write the footage in analysis signatures field by field The Warp Stabilizer, 3D Camera Tracker and Roto Brush signatures hashed the footage item's derived Debug text, so every new Footage field changed every signature and dropped every saved analysis (placed_page did in 0.23). Write the same text from a named list of fields instead: a new field no longer compiles until it is decided there. Pin the text in a test. --- crates/engine/src/analysis/warp.rs | 97 ++++++++++++++++++- crates/engine/src/tests.rs | 1 + .../engine/src/tests/legacy_analysis_keys.rs | 22 +++++ 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/tests/legacy_analysis_keys.rs diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 0ab576d2c..b88d9df38 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY}; use effectcraft_keyframe::Value; -use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid}; +use effectcraft_project::{Footage, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid}; use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; @@ -112,7 +112,7 @@ fn fnv(s: &str) -> u64 { 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)) => format!("{f:?}"), + 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(), @@ -127,9 +127,96 @@ pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { pub(crate) 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 }; - let src = format!("{footage:?}"); - let old = src.strip_suffix(", placed_page: false }")?; - Some(signature_with_source(p, comp, layer, &format!("{old} }}"))) + 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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 19953053e..8d33ee3d4 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 legacy_analysis_keys; mod limits; mod lottie; mod m137; diff --git a/crates/engine/src/tests/legacy_analysis_keys.rs b/crates/engine/src/tests/legacy_analysis_keys.rs new file mode 100644 index 000000000..f4e442050 --- /dev/null +++ b/crates/engine/src/tests/legacy_analysis_keys.rs @@ -0,0 +1,22 @@ +//! Warp Stabilizer and 3D Camera Tracker analyses saved by an earlier release keep matching +//! their footage after an edit that doesn't change it (#3456), and a real change still clears them. + +/// The footage text analysis keys are hashed from, as 0.23 saved it. A new `Footage` field must +/// not change it (or every saved analysis is dropped again). +#[test] +fn footage_signature_text_is_pinned() { + use effectcraft_project::{Footage, FootageKind}; + let f = Footage { + path: "clip.mp4".into(), + kind: FootageKind::Video, + width: 160, + height: 120, + invert_alpha: true, + page: 2, + placed_page: true, + ..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} }}")); +}