Import: missing sequence frames show a placeholder, as in After Effects (#297)

After Effects shows a missing frame of an image sequence as a
placeholder (colour bars) and has no Hold Previous Frame or Skip choice
in Interpret Footage, so EffectCraft doesn't either.

Interpret Footage loses its Missing Frames field, file.interpretFootage
its missingFrames parameter, and Footage its missing_frames setting. A
gap in a numbered sequence's numbering always shows colour bars. A
sequence imported with Force Alphabetical Order still plays its files
one after another (its numbering doesn't count), as in After Effects;
that is now an internal Footage.alphabetical flag, set on import and
kept by Reload. The Interpret Footage note lists the missing frames.

Projects saved before this have no such field and open with the After
Effects behaviour: a sequence with gaps shows placeholders where it
used to close them up.

Test: project sequence unit tests (placeholders, alphabetical order,
projects saved without the flag), tests_sequence (Start Frame instead
of Hold / Skip), sequence_gaps_show_placeholders (media) and the
Interpret Footage fields in ui_drag_drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-09 18:52:19 +07:00
co-authored by Claude Opus 5.5
parent 50c9549529
commit efd348335f
14 changed files with 108 additions and 169 deletions
+2 -2
View File
@@ -260,11 +260,11 @@ impl Prober {
let mut warning = None;
// Settings ▸ Import ▸ Report Missing Frames (numbered sequences).
if f.kind == FootageKind::Sequence
&& f.missing_frames != effectcraft_project::MissingFrames::Skip
&& !f.alphabetical
&& self.report_missing_frames
&& let Some(gaps) = crate::sequence::missing_report(&f.sequence)
{
warning = Some(format!("{name}: {gaps} (shown as placeholders; File ▸ Interpret Footage ▸ Missing Frames)"));
warning = Some(format!("{name}: {gaps} (shown as placeholders)"));
}
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its
// own alpha, and audio and data have none).
+10 -19
View File
@@ -553,8 +553,6 @@ pub(crate) struct Interpretation {
fields: Option<effectcraft_project::FieldOrder>,
profile: Option<Option<effectcraft_project::ColorSpace>>,
linear: Option<bool>,
/// Image sequences: Missing Frames.
missing: Option<effectcraft_project::MissingFrames>,
/// Image sequences: Start Frame (`Some(None)` = the first file's number).
start: Option<Option<i64>>,
}
@@ -603,10 +601,6 @@ impl Interpretation {
None => None,
};
it.linear = super::b_p(p, "linearLight");
it.missing = match str_p(p, "missingFrames") {
Some(m) => Some(effectcraft_project::MissingFrames::parse(m).ok_or_else(|| bad(cmd, format!("missingFrames: placeholder|hold|skip, not `{m}`")))?),
None => None,
};
// Start Frame: a frame number, or "file" for the first file's.
it.start = match p.get("startFrame") {
None | Some(Value::Null) => None,
@@ -624,7 +618,7 @@ impl Interpretation {
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
let before = f.frame_rate;
self.apply_settings(f, guessed);
// A sequence's length is its frames (Missing Frames and Start Frame decide how many).
// A sequence's length is its frames (Start Frame decides how many).
if f.kind == FootageKind::Sequence && !f.sequence.is_empty() {
f.sync_sequence_duration();
return;
@@ -677,15 +671,12 @@ impl Interpretation {
if let Some(v) = self.linear {
f.linear_light = v;
}
if f.kind == FootageKind::Sequence {
if let Some(m) = self.missing {
f.missing_frames = m;
}
if let Some(start) = self.start {
// The first file's own number needn't be stored (it follows the files).
let first = f.sequence.first().and_then(|p| effectcraft_project::sequence::frame_number(p));
f.start_frame = start.filter(|n| Some(*n) != first);
}
if f.kind == FootageKind::Sequence
&& let Some(start) = self.start
{
// The first file's own number needn't be stored (it follows the files).
let first = f.sequence.first().and_then(|p| effectcraft_project::sequence::frame_number(p));
f.start_frame = start.filter(|n| Some(*n) != first);
}
}
}
@@ -763,7 +754,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
Some(ItemKind::Footage(f)) => Some(json!({
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
"linearLight": f.linear_light, "missingFrames": f.missing_frames.id(), "startFrame": f.first_frame_number(),
"linearLight": f.linear_light, "startFrame": f.first_frame_number(),
"frames": f.sequence_frames(),
})),
_ => None,
@@ -900,7 +891,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence {
nf.frame_rate = f.frame_rate;
nf.native_rate = f.native_rate;
nf.missing_frames = f.missing_frames;
nf.alphabetical = f.alphabetical;
nf.start_frame = f.start_frame;
nf.sync_sequence_duration();
}
@@ -1003,7 +994,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, missingFrames?: placeholder|hold|skip (image sequences: what a gap in the numbering shows), startFrame?: number|\"file\" (image sequences: the frame number at the footage's first frame)}",
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, startFrame?: number|\"file\" (image sequences: the frame number at the footage's first frame)}",
has_footage_selection,
interpret
),
+4 -4
View File
@@ -12,7 +12,7 @@
//! picking every frame of a sequence imports it there too.
use effectcraft_project::sequence::{frame_number, missing_frame_ranges, sequence_name, split_frame_number};
use effectcraft_project::{Footage, FootageKind, MissingFrames};
use effectcraft_project::{Footage, FootageKind};
use effectcraft_time::FrameRate;
use crate::{Importer, Services};
@@ -186,8 +186,8 @@ pub fn sequences(services: &dyn Services, paths: &[String], alphabetical: bool)
}
/// Probe what a [`Source`] makes. A sequence is its first file's footage with every file, at
/// `rate`, showing gaps in its numbering as placeholders (or closing them up when
/// alphabetical).
/// `rate`, showing gaps in its numbering as placeholders, as in After Effects (in Force
/// Alphabetical Order the files play one after another).
pub fn probe(importer: &dyn Importer, src: &Source, rate: FrameRate) -> Result<Footage, String> {
let Source::Sequence { files, alphabetical } = src else { return importer.probe(src.path()) };
let mut f = importer.probe(src.path())?;
@@ -196,7 +196,7 @@ pub fn probe(importer: &dyn Importer, src: &Source, rate: FrameRate) -> Result<F
}
f.kind = FootageKind::Sequence;
f.sequence = files.clone();
f.missing_frames = if *alphabetical { MissingFrames::Skip } else { MissingFrames::Placeholder };
f.alphabetical = *alphabetical;
f.start_frame = None;
f.frame_rate = rate;
f.native_rate = None;
+16 -18
View File
@@ -1,8 +1,8 @@
//! Image sequences (#297): File ▸ Import with the "<format> Sequence" option, picked ranges,
//! folders, Force Alphabetical Order, the import frame rate, Replace Footage, and Interpret
//! Footage ▸ Missing Frames / Start Frame.
//! folders, Force Alphabetical Order, the import frame rate, Replace Footage, missing frames
//! (placeholders, as in After Effects) and Interpret Footage ▸ Start Frame.
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, MissingFrames};
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -58,7 +58,7 @@ fn one_picked_numbered_file_imports_its_run_as_one_sequence() {
assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}");
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!(name, "shot_[0001-0005].png");
assert_eq!((f.kind, f.sequence.len(), f.missing_frames), (FootageKind::Sequence, 4, MissingFrames::Placeholder));
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.
assert_eq!(f.duration, FrameRate::FPS_30.tick_of(5));
@@ -118,11 +118,13 @@ fn force_alphabetical_order_takes_every_image_of_the_type() {
let (name, f) = footage(&s, &r["items"][0]);
let files: Vec<String> = f.sequence.iter().map(|p| crate::sequence::file_name(p)).collect();
assert_eq!(files, ["other_7.png", "take_a.png", "take_b.png"]);
assert_eq!((name.as_str(), f.missing_frames), ("other_7.png", MissingFrames::Skip));
// Its numbering doesn't count: the files play one after another.
assert_eq!((name.as_str(), f.alphabetical, f.sequence_frames()), ("other_7.png", true, 3));
assert!(r["warnings"].as_array().is_none_or(Vec::is_empty), "{r}");
}
#[test]
fn interpret_footage_sets_missing_frames_and_start_frame() {
fn interpret_footage_sets_start_frame() {
let dir = folder(&RUN);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")]})).unwrap()["items"][0].clone();
@@ -130,15 +132,11 @@ fn interpret_footage_sets_missing_frames_and_start_frame() {
let layer = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let out = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().out_point;
assert_eq!(out(&s), FrameRate::FPS_30.tick_of(5));
// Hold: the gap shows frame 2; the length stays 5 frames.
let r = s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "hold"})).unwrap();
assert_eq!((r["items"][0]["missingFrames"].as_str(), r["items"][0]["frames"].as_i64()), (Some("hold"), Some(5)));
assert_eq!(footage(&s, &item).1.sequence_file(2).map(crate::sequence::file_name).as_deref(), Some("shot_0002.png"));
// Skip: 4 frames, and the layer that ran to the end still does.
s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "skip"})).unwrap();
// Start Frame 2 trims frame 1: 4 frames, and the layer that ran to the end still does.
s.execute("file.interpretFootage", json!({"items": [item], "startFrame": 2})).unwrap();
assert_eq!((footage(&s, &item).1.duration, out(&s)), (FrameRate::FPS_30.tick_of(4), FrameRate::FPS_30.tick_of(4)));
// Start Frame 0 with placeholders: frame 0 is missing, 6 frames in all.
let r = s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "placeholder", "startFrame": 0})).unwrap();
// Start Frame 0: frame 0 is missing (a placeholder), 6 frames in all.
let r = s.execute("file.interpretFootage", json!({"items": [item], "startFrame": 0})).unwrap();
assert_eq!((r["items"][0]["startFrame"].as_i64(), r["items"][0]["frames"].as_i64()), (Some(0), Some(6)));
assert_eq!(footage(&s, &item).1.sequence_file(0), None);
// The first file's own number isn't stored; "file" goes back to it.
@@ -150,7 +148,7 @@ fn interpret_footage_sets_missing_frames_and_start_frame() {
// The frame rate re-times the frames.
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 10})).unwrap();
assert_eq!(footage(&s, &item).1.duration, Tick::from_seconds_f64(0.5));
for bad in [json!({"missingFrames": "blank"}), json!({"startFrame": "x"}), json!({"startFrame": 1e300})] {
for bad in [json!({"startFrame": "x"}), json!({"startFrame": 1e300})] {
let mut p = bad.clone();
p["items"] = json!([item]);
assert!(s.execute("file.interpretFootage", p).is_err(), "{bad}");
@@ -170,10 +168,10 @@ fn replace_footage_and_reload_keep_sequences() {
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4));
// Reload finds a new frame and keeps the interpretation.
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "missingFrames": "hold"})).unwrap();
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startFrame": 2})).unwrap();
std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap();
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
let (_, f) = footage(&s, &item);
assert_eq!((f.sequence.len(), f.frame_rate, f.missing_frames), (5, FrameRate::from_f64(12.0), MissingFrames::Hold));
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5));
assert_eq!((f.sequence.len(), f.frame_rate, f.start_frame), (5, FrameRate::from_f64(12.0), Some(2)));
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(4));
}