Interpret Footage: looped footage lasts its loops on new and re-timed layers (fixes #1045)

This commit is contained in:
Marco Arnulfo 2026-10-12 17:13:14 +02:00
parent e2f314f753
commit a50e49df73
4 changed files with 26 additions and 5 deletions

View File

@ -771,9 +771,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
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;
}

View File

@ -283,7 +283,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
&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<Value> {
}
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),

View File

@ -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)));
}

View File

@ -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<Tick> {
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,
}
}