Image sequences: a gap in the numbering holds the previous frame, as After Effects

After Effects 2026 shows the last existing file across a gap in a sequence's
numbering (case 860: files 1, 2, 4, 5 show frame 2 at frame 3; VERIFY
read-back). EffectCraft drew colour bars there. The media pool now holds the
previous file (Footage::sequence_file_held); colour bars remain only where no
file precedes the frame. Missing-frame reports on import are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-19 16:47:38 +02:00
1 parent 4c65271c72
commit 7850cbf45b
5 files changed
+24 -14

No files matched your search

+2 -2
View File
@@ -682,9 +682,9 @@ impl Inner {
match footage.kind { match footage.kind {
FootageKind::Sequence if !footage.sequence.is_empty() => { FootageKind::Sequence if !footage.sequence.is_empty() => {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); 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 }, 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 }, None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None, float },
} }
} }
+6 -8
View File
@@ -546,10 +546,11 @@ fn png_sequence_import() {
assert_eq!(*img, load_png(&first.with_file_name("seq_0004.png"))); 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 /// A gap in a sequence's numbering holds the previous frame, as After Effects 2026 does (files
/// Alphabetical Order the files play back to back (#297). /// 1, 2, 4, 5 show frame 2 at frame 3); in Force Alphabetical Order the files play back to back
/// (#297).
#[test] #[test]
fn sequence_gaps_show_placeholders() { fn sequence_gaps_hold_the_previous_frame() {
let dir = fixtures().join(format!("gap-seq-{}", std::process::id())); let dir = fixtures().join(format!("gap-seq-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir"); 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. // 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 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; let level = |img: &Image| (img.get(0, 0)[0] * 255.0).round() as u32;
assert_eq!(level(&at(&f, 1)), 100); assert_eq!(level(&at(&f, 1)), 100);
// The placeholder: colour bars across the frame, starting with 75 % white. // The gap holds frame 2.
let bars = at(&f, 2); assert_eq!(level(&at(&f, 2)), 100);
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");
assert_eq!(level(&at(&f, 3)), 200); assert_eq!(level(&at(&f, 3)), 200);
f.alphabetical = true; f.alphabetical = true;
f.sync_sequence_duration(); f.sync_sequence_duration();
+12
View File
@@ -107,6 +107,18 @@ impl Footage {
self.sequence.get(k).filter(|p| frame_number(p) == Some(n)).map(String::as_str) 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 /// Set an image sequence's duration to its frames at its frame rate (after its files or
/// frame rate changed). /// frame rate changed).
pub fn sync_sequence_duration(&mut self) { pub fn sync_sequence_duration(&mut self) {
+2 -2
View File
@@ -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; 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": false`: one still; dropped files, `"drag": true`, import as stills unless
`"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files `"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 play one after another) at `frameRate` or 30 fps (Settings ▸ Import). A gap in the numbering
colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, 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 "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). length). See [footage.md](footage.md).
* **Colour of footage**: `file.interpretFootage {"items":[id], "preserveRgb":true}` is Interpret * **Colour of footage**: `file.interpretFootage {"items":[id], "preserveRgb":true}` is Interpret
+2 -2
View File
@@ -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. 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 **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 `0002` and `0004`), the sequence holds the previous frame (`0002`) there, as After Effects 2026
Effects does, so every other frame stays at its number. Render or copy the missing files into the 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. folder and choose File ▸ Reload Footage to fill the gaps.
Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`. Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`.