From 54cf31f841b4cd9b5eb7f61b75f08484633b10f0 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:10:23 +0530 Subject: [PATCH] #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())); +}