diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e1f58a1cc..f5bae86d0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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). diff --git a/crates/engine/src/tests/sequence.rs b/crates/engine/src/tests/sequence.rs index cf6968e3f..c5faa232b 100644 --- a/crates/engine/src/tests/sequence.rs +++ b/crates/engine/src/tests/sequence.rs @@ -1,6 +1,6 @@ //! Image sequences (#297): File ▸ Import with the " 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 diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index b0b2f2539..9e10b4eb3 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -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 }, } } diff --git a/crates/media/tests/it/footage.rs b/crates/media/tests/it/footage.rs index 0cae33417..7f0de6ae4 100644 --- a/crates/media/tests/it/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -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(); diff --git a/crates/project/src/sequence.rs b/crates/project/src/sequence.rs index 716c9b4fd..0b23d528f 100644 --- a/crates/project/src/sequence.rs +++ b/crates/project/src/sequence.rs @@ -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"))); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 65d825b45..635ef9a43 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -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 = ["/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] diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index 42d0273f8..5a0d94ddf 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -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() { diff --git a/docs/agents.md b/docs/agents.md index 124db6b80..9831ec031 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/docs/footage.md b/docs/footage.md index e2effa996..7841dc84c 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}`.