From a50e49df73984f3cd5c986d3a764fe45b2caccea Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 17:13:14 +0200 Subject: [PATCH] Interpret Footage: looped footage lasts its loops on new and re-timed layers (fixes #1045) --- crates/engine/src/commands/file_more.rs | 4 ++-- crates/engine/src/commands/layer.rs | 4 ++-- crates/engine/src/tests_menu_cmds.rs | 16 ++++++++++++++++ crates/project/src/lib.rs | 7 ++++++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index ef26d59b..12f673fc 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -771,9 +771,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result { s.edit("Interpret Footage", None, |proj, _| { for (i, g) in items.iter().zip(&guesses) { let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) else { continue }; - let old = f.duration; + let old = f.looped_duration(); it.apply(f, *g); - let new = f.duration; + let new = f.looped_duration(); if new == old { continue; } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90..b8340800 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -283,7 +283,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result { &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}), ); } - ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)), + ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.looped_duration())), ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), }; @@ -972,7 +972,7 @@ fn slip(s: &mut Session, p: &Value) -> Result { } let dur = match &l.source { LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) { - Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(Tick(f.duration.0 * f.loop_count.max(1) as i64)), + Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(f.looped_duration()), _ => None, }, LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration), diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cb83c163..0459e012 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1464,3 +1464,19 @@ fn interpret_footage_loop_from_form_float() { _ => panic!(), } } + +/// Looped video footage lasts its loops: a 2 s clip looped 3 times is a 6 s layer, and a layer +/// that ran to the clip's end is re-timed when the loop count changes (#1045). +#[test] +fn interpret_footage_loop_extends_video_layers() { + let mut s = comp(); + let p = s.execute("file.importPlaceholder", json!({"name": "Clip", "width": 320, "height": 240, "duration": 2})).unwrap()["item"].as_u64().unwrap(); + let before = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap(); + s.state.project_selection = vec![effectcraft_project::ItemId(p)]; + s.execute("file.interpretFootage", json!({"loop": 3.0})).unwrap(); + let after = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap(); + let out = |s: &Session, l: u64| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().out_point.seconds(); + assert_eq!(out(&s, after), 6.0, "a new layer plays every loop"); + assert_eq!(out(&s, before), 6.0, "a layer that ran to the clip's end still does"); + assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0))); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce..072ae7fd 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1158,6 +1158,11 @@ impl Footage { self.linear_light && !self.preserve_rgb } + /// How long the footage plays: its duration times Interpret Footage ▸ Loop. + pub fn looped_duration(&self) -> Tick { + Tick(self.duration.0.saturating_mul(self.loop_count.max(1) as i64)) + } + /// Source time as the footage's timecode: its frame `frame` from Start Timecode /// (`0:00:41:20`), as the Footage panel shows it. pub fn timecode(&self, frame: i64) -> String { @@ -1281,7 +1286,7 @@ impl Item { pub fn duration(&self) -> Option { match &self.kind { ItemKind::Comp(c) => Some(c.duration), - ItemKind::Footage(f) if f.kind != FootageKind::Still => Some(f.duration), + ItemKind::Footage(f) if f.kind != FootageKind::Still => Some(f.looped_duration()), _ => None, } }