diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9a8ec5c28..258243fd8 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -682,9 +682,9 @@ impl Inner { match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); - match footage.sequence_file(i) { + match footage.sequence_file_held(i) { Some(path) => Loc { key: key(path, 0), media_t: None, float }, - // A gap in the numbering: colour bars, as in After Effects. + // No file at or before the frame: colour bars. None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None, float }, } } diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..e455750a6 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -546,10 +546,11 @@ fn png_sequence_import() { assert_eq!(*img, load_png(&first.with_file_name("seq_0004.png"))); } -/// A gap in a sequence's numbering shows colour bars, as in After Effects; in Force -/// Alphabetical Order the files play back to back (#297). +/// A gap in a sequence's numbering holds the previous frame, as After Effects 2026 does (files +/// 1, 2, 4, 5 show frame 2 at frame 3); in Force Alphabetical Order the files play back to back +/// (#297). #[test] -fn sequence_gaps_show_placeholders() { +fn sequence_gaps_hold_the_previous_frame() { let dir = fixtures().join(format!("gap-seq-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("mkdir"); // Frames 1, 2 and 4: frame 3 is missing. Each frame is a flat grey of its number. @@ -567,11 +568,8 @@ fn sequence_gaps_show_placeholders() { let at = |f: &Footage, n: i64| pool.frame_at(f, t_of(f.frame_rate, n)).expect("frame"); let level = |img: &Image| (img.get(0, 0)[0] * 255.0).round() as u32; assert_eq!(level(&at(&f, 1)), 100); - // The placeholder: colour bars across the frame, starting with 75 % white. - let bars = at(&f, 2); - assert_eq!((bars.width, bars.height), (8, 4)); - assert_eq!(bars.get(0, 0), [0.75, 0.75, 0.75, 1.0]); - assert_eq!(bars.get(7, 3), [0.0, 0.0, 0.75, 1.0], "blue on the right"); + // The gap holds frame 2. + assert_eq!(level(&at(&f, 2)), 100); assert_eq!(level(&at(&f, 3)), 200); f.alphabetical = true; f.sync_sequence_duration(); diff --git a/crates/project/src/sequence.rs b/crates/project/src/sequence.rs index fa3d522b5..8070ca707 100644 --- a/crates/project/src/sequence.rs +++ b/crates/project/src/sequence.rs @@ -107,6 +107,18 @@ impl Footage { self.sequence.get(k).filter(|p| frame_number(p) == Some(n)).map(String::as_str) } + /// The file an image sequence shows at its frame `i`, holding the last existing file across a + /// gap in the numbering (After Effects 2026: files 1, 2, 4, 5 show frame 2 at frame 3). + /// `None` only when no file precedes the frame (or there are no files). + pub fn sequence_file_held(&self, i: i64) -> Option<&str> { + if let Some(p) = self.sequence_file(i) { + return Some(p); + } + let first = self.numbered()?; + let k = self.first_file_from(first.saturating_add(i)); + self.sequence.get(k.checked_sub(1)?).map(String::as_str) + } + /// Set an image sequence's duration to its frames at its frame rate (after its files or /// frame rate changed). pub fn sync_sequence_duration(&mut self) { diff --git a/docs/agents.md b/docs/agents.md index c47bfb603..dac95f324 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -275,8 +275,8 @@ build real projects through these interfaces; they are worked examples of everyt numbered run as one item (several picked frames: that range; a folder: each run in it; `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files - play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show - colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, + play one after another) at `frameRate` or 30 fps (Settings ▸ Import). A gap in the numbering + holds the previous frame, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new length). See [footage.md](footage.md). * **Colour of footage**: `file.interpretFootage {"items":[id], "preserveRgb":true}` is Interpret diff --git a/docs/footage.md b/docs/footage.md index 84d4c49e6..4b597ad49 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -47,8 +47,8 @@ Stills of any format EffectCraft reads can form a sequence: PNG, JPEG, TIFF, Ope WebP. Layered and vector documents (PSD, SVG, PDF) import one by one. **Missing frames.** Where the numbering skips a number (`shot_0003.png` is missing between -`0002` and `0004`), the sequence shows a placeholder of colour bars for that frame, as After -Effects does, so every other frame stays at its number. Render or copy the missing files into the +`0002` and `0004`), the sequence holds the previous frame (`0002`) there, as After Effects 2026 +does, so every other frame stays at its number. Render or copy the missing files into the folder and choose File ▸ Reload Footage to fill the gaps. Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`.