Image sequences: a gap in the numbering holds the previous frame, as After Effects (#3137)

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

Split out of #1876 (originally 7850cbf4), because it reverses the colour bars
documented in #297 and needs its own discussion.

After Effects 2026 shows the last existing file across a gap in a sequence's
numbering (files 1, 2, 4, 5 show frame 2 at frame 3; read back from After
Effects). 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>

* Image sequences: hold the first file of the preceding number across a gap; fix the import warning

A gap now holds the file sequence_file picks for the preceding number, so
files sharing a number do not switch at the gap. The Report Missing Frames
warning says the previous frame is held instead of "shown as placeholders".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Image sequences: Interpret Footage says a gap holds the previous frame

The Interpret Footage note for a numbered sequence with gaps still said the
missing frames are shown as colour bars; the media pool holds the previous
frame there now. The note, its comment and two test doc comments say so,
and a forms test checks the note's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
Valentin Klein
2026-11-07 00:40:24 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 FredApps
parent 23c1f33c88
commit 52bca964f5
9 changed files with 66 additions and 22 deletions
+1 -1
View File
@@ -327,7 +327,7 @@ impl Prober {
&& self.report_missing_frames
&& let Some(gaps) = crate::sequence::missing_report(&f.sequence)
{
warning = Some(format!("{name}: {gaps} (shown as placeholders)"));
warning = Some(format!("{name}: {gaps} (the previous frame is held)"));
}
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its
// own alpha, and audio and data have none).
+3 -2
View File
@@ -1,6 +1,6 @@
//! Image sequences (#297): File ▸ Import with the "<format> Sequence" option, picked ranges,
//! folders, Force Alphabetical Order, the import frame rate, Replace Footage, missing frames
//! (placeholders, as in After Effects) and Interpret Footage ▸ Start Timecode.
//! (they hold the previous frame) and Interpret Footage ▸ Start Timecode.
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_time::{FrameRate, Tick};
@@ -62,12 +62,13 @@ fn one_picked_numbered_file_imports_its_run_as_one_sequence() {
assert_eq!(name, "shot_[0001-0005].png");
assert_eq!((f.kind, f.sequence.len(), f.alphabetical), (FootageKind::Sequence, 4, false));
assert_eq!(f.path, at(&dir, "shot_0001.png"));
// Frames 1–5 at Settings ▸ Import ▸ Sequence Footage (30 fps); frame 3 is a placeholder.
// Frames 1–5 at Settings ▸ Import ▸ Sequence Footage (30 fps); frame 3 holds frame 2.
assert_eq!(f.duration, FrameRate::FPS_30.tick_of(5));
assert_eq!(f.sequence_file(2), None);
// Settings ▸ Import ▸ Report Missing Frames.
let w = r["warnings"][0].as_str().unwrap();
assert!(w.contains("1 missing frame (3)"), "{w}");
assert!(w.contains("previous frame is held") && !w.contains("placeholder"), "{w}");
}
/// Picking every frame of a sequence used to make one item per frame (each a whole sequence on
+2 -2
View File
@@ -736,9 +736,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 },
}
}
+6 -8
View File
@@ -710,10 +710,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.
@@ -731,11 +732,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();
+27
View File
@@ -114,6 +114,22 @@ 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));
// The preceding number, then the file `sequence_file` picks for it (the first with that
// number), so files sharing a number (`shot_0002`, `shot_002`) don't switch at a gap.
let prev = self.file_number(k.checked_sub(1)?)?;
let j = self.first_file_from(prev);
self.sequence.get(j).filter(|p| frame_number(p) == Some(prev)).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) {
@@ -131,6 +147,17 @@ mod tests {
Footage { kind: FootageKind::Sequence, sequence: numbers.iter().map(|n| format!("C:\\shots\\a_{n:04}.png")).collect(), ..Default::default() }
}
#[test]
fn a_gap_holds_the_file_shown_for_the_preceding_number() {
let mut f = seq(&[]);
f.sequence = ["a_0001.png", "a_0002.png", "a_002.png", "a_0005.png"].map(String::from).to_vec();
assert_eq!(f.sequence_file(1), Some("a_0002.png"));
// Frames 3 and 4 (numbers 3, 4) hold what number 2 shows: the first file with it.
assert_eq!(f.sequence_file_held(2), Some("a_0002.png"));
assert_eq!(f.sequence_file_held(3), Some("a_0002.png"));
assert_eq!(f.sequence_file_held(4), Some("a_0005.png"));
}
#[test]
fn numbers_and_names() {
assert_eq!(split_frame_number("shot_0012"), Some(("shot_", "0012")));
+21 -3
View File
@@ -310,13 +310,13 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &m
m.insert("pointUnits".into(), json!(c.units.id()));
}
}
// After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
// After Effects), then Start Timecode.
// After Frame Rate: an image sequence's frames (a missing one holds the previous frame),
// then Start Timecode.
let mut timing = Vec::new();
if f.kind == effectcraft_engine::project::FootageKind::Sequence {
let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
_ if f.alphabetical => "files in alphabetical order".into(),
Some(gaps) => format!("{gaps} shown as color bars"),
Some(gaps) => format!("{gaps} (the previous frame is held)"),
None => "no missing frames".into(),
};
timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
@@ -1172,6 +1172,24 @@ mod tests {
}
}
/// Interpret Footage on an image sequence with a gap says the previous frame is held there
/// (what the media pool shows), not colour bars.
#[test]
fn interpret_footage_says_a_sequence_gap_holds_the_previous_frame() {
use effectcraft_engine::project::{Footage, FootageKind, ItemKind};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let sequence: Vec<String> = ["/m/a_001.png", "/m/a_002.png", "/m/a_005.png"].map(String::from).to_vec();
let footage = Footage { path: sequence[0].clone(), kind: FootageKind::Sequence, sequence, width: 4, height: 4, has_video: true, ..Default::default() };
let project = std::sync::Arc::make_mut(&mut app.session.project);
let item = project.add_item("a_[001-005].png", Default::default(), None, ItemKind::Footage(footage));
app.session.state.project_selection = vec![item];
assert!(open_form(&mut app, "file.interpretFootage", &json!({})));
let note = app.dialog_state.form.fields.iter().find(|f| f.key == "sequenceInfo").map(|f| f.kind.clone());
let Some(FieldKind::Note(text)) = note else { panic!("no sequence note: {note:?}") };
assert!(text.contains("2 missing frames (3–4)") && text.contains("the previous frame is held"), "{text}");
assert!(!text.contains("color bars"), "{text}");
}
/// Interpret Footage shows Cineon Settings for DPX footage only. The converted points show in
/// the footage's Units, and choosing other Units reads the shown values in the old ones.
#[test]
+2 -2
View File
@@ -667,8 +667,8 @@ fn new_composition_from_footage_makes_a_comp_of_the_picked_file() {
}
/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret
/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in
/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one
/// Footage shows its frames and Start Timecode (a missing frame always holds the previous one:
/// there is no Missing Frames choice). #431: dropped files import as stills, one
/// item each, as in After Effects.
#[test]
fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() {
+2 -2
View File
@@ -302,8 +302,8 @@ build real projects through these interfaces; they are worked examples of everyt
file's whole numbered run as one item (several picked frames: that range; a folder: each run in
it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports
as one still, 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
+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.
**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}`.