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} }}")); +}