diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 3fe049e20..16cbb08cd 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -219,7 +219,15 @@ fn fnv(s: &str) -> u64 { /// 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 { - let mut s = crate::warp::signature(p, comp, layer); + input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid) +} + +pub(crate) fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { + let signature = crate::warp::signature_before_placed_page(p, comp, layer)?; + Some(input_key_with_signature(signature, 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:?}")); } @@ -247,7 +255,9 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) { let key = input_key(after, *cid, l, g.uid); let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); - if str_of(g, INPUT_KEY) != key { + let saved_key = str_of(g, INPUT_KEY); + // Keep a matching old key: the frozen cache itself also hashes that key. + if saved_key != key && input_key_before_placed_page(after, *cid, l, g.uid).as_deref() != Some(saved_key.as_str()) { 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 71ee45ee6..0ab576d2c 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -119,6 +119,20 @@ pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { }, _ => String::new(), }; + signature_with_source(p, comp, layer, &src) +} + +/// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the +/// same Debug representation without that last field. Only ordinary footage can match it. +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} }}"))) +} + +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}", diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 99c155265..8fc8fb479 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -603,6 +603,47 @@ fn object_matte_background_job_cancel_and_freeze() { assert_eq!(status(&mut s)["frozen"], true); } +/// A queue edit preserves a frozen matte made before the footage signature gained placed_page. +#[test] +fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { + use effectcraft_effects::{object_matte, roto::{FROZEN, FrozenCache, INPUT_KEY}}; + use effectcraft_keyframe::Value::Str; + use std::sync::Arc; + + hold_roto_cache_test_lock(); + let (mut s, clip, _) = setup(|f| draw(center(f))); + let cid = s.active_comp_id().unwrap(); + let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": center(0), "before": 0, "after": 2})).unwrap(); + let uid = r["effect"].as_u64().unwrap(); + s.execute_checked("objectMatte.freeze", json!({"layer": clip.0, "wait": true})).unwrap(); + + let layer = s.project.comp(cid).unwrap().layer(clip).unwrap(); + let legacy = crate::roto::input_key_before_placed_page(&s.project, cid, layer, uid).unwrap(); + assert_ne!(legacy, crate::roto::input_key(&s.project, cid, layer, uid)); + let fps = s.project.comp(cid).unwrap().frame_rate.as_f64(); + // Recreate the two keys serialized by 0.22, leaving the real frozen alpha data alone. + let p = Arc::make_mut(&mut s.project); + let g = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().props.find_group_mut(uid).unwrap(); + g.get_mut(INPUT_KEY).unwrap().value = Str(legacy.clone()); + let params = crate::roto::params_static(g); + let Some(Str(value)) = params.get(FROZEN) else { panic!("missing frozen matte") }; + let mut frozen: FrozenCache = serde_json::from_str(value).unwrap(); + assert_eq!(frozen.frames.len(), 3); + frozen.key = object_matte::frozen_key(¶ms, [f64::from(W), f64::from(H)], fps, None); + let saved = Str(serde_json::to_string(&frozen).unwrap()); + g.get_mut(FROZEN).unwrap().value = saved.clone(); + + s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); + let params = |s: &Session| { + crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()) + }; + assert_eq!(params(&s).get(INPUT_KEY), Some(&Str(legacy))); + assert_eq!(params(&s).get(FROZEN), Some(&saved)); + + s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.5})).unwrap(); + assert_eq!(params(&s).get(FROZEN), Some(&Str(String::new()))); +} + /// Computed Object Matte segmentations go to the persistent disk cache: a second session on the /// same folder and project gets them back without segmenting (its frames cannot even be read /// here), and another prompt (other keys) finds nothing.