Import: Interpret Footage has Start Timecode, as in After Effects (#297)

After Effects' Interpret Footage has Start Timecode: Use Source File
Timecode, or Override Start, which changes the timecode the footage
shows and leaves its frames alone. The Start Frame field that trimmed or
padded a sequence's head isn't in After Effects, so it is replaced.

Footage.start_timecode (Override Start, in frames at the footage's
rate; None = Use Source, 0 as file timecode isn't read) relabels source
time: the Footage panel's current time and footage.info's `timecode`
count from it. file.interpretFootage takes `overrideStart` and
`startTimecode` (timecode, read at the footage's rate after any frame
rate change, or a frame number, 0 to 1e9). It applies to every footage
with time, not only sequences, and Reload keeps it. A sequence's frames
always run from its first file's number to its last.

Test: start_timecode_only_relabels_source_time (project),
interpret_footage_start_timecode_relabels_source_time (engine: the
reply, the Footage panel's timecode, frame numbers, Use Source, the
footage's rate, bad values, undo), Reload keeps it, 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 20:50:13 +07:00
co-authored by Claude Opus 5.5
parent 77af6d1a78
commit e95780ca14
12 changed files with 146 additions and 113 deletions
+52 -21
View File
@@ -553,8 +553,38 @@ pub(crate) struct Interpretation {
fields: Option<effectcraft_project::FieldOrder>,
profile: Option<Option<effectcraft_project::ColorSpace>>,
linear: Option<bool>,
/// Image sequences: Start Frame (`Some(None)` = the first file's number).
start: Option<Option<i64>>,
/// Start Timecode.
start: Option<StartTimecode>,
}
/// Interpret Footage ▸ Start Timecode.
#[derive(Clone, Debug)]
enum StartTimecode {
/// Use Source File Timecode.
Source,
/// Override Start: a frame number.
Frame(i64),
/// Override Start: timecode, read at the footage's frame rate.
Timecode(String),
}
impl StartTimecode {
/// The highest Override Start, in frames.
const MAX: i64 = 1_000_000_000;
/// The frame (at `rate`) to store, `Some(None)` for Use Source; `None` when the timecode
/// doesn't fit at `rate`.
fn frame(&self, rate: FrameRate) -> Option<Option<i64>> {
match self {
StartTimecode::Source => Some(None),
StartTimecode::Frame(n) => Some(Some(*n)),
StartTimecode::Timecode(t) => Self::read(t, rate).map(Some),
}
}
fn read(t: &str, rate: FrameRate) -> Option<i64> {
effectcraft_time::parse_timecode(t, rate, false, 0).ok().filter(|n| (0..=Self::MAX).contains(n))
}
}
impl Interpretation {
@@ -601,15 +631,19 @@ impl Interpretation {
None => None,
};
it.linear = super::b_p(p, "linearLight");
// Start Frame: a frame number, or "file" for the first file's.
it.start = match p.get("startFrame") {
None | Some(Value::Null) => None,
Some(Value::String(f)) if f == "file" => Some(None),
Some(v) => Some(Some(
v.as_f64()
.filter(|x| x.is_finite() && x.abs() <= 1.0e9)
.map(|x| x.round() as i64)
.ok_or_else(|| bad(cmd, "startFrame: a frame number (up to 1e9), or \"file\""))?,
// Start Timecode: Use Source File Timecode (`overrideStart: false`), or Override Start
// (timecode or a frame number).
let start_err = || bad(cmd, "startTimecode: timecode (0:00:41:20) or a frame number, 0 to 1e9");
it.start = match (super::b_p(p, "overrideStart"), p.get("startTimecode")) {
(Some(false), _) => Some(StartTimecode::Source),
(_, None | Some(Value::Null)) => None,
(_, Some(Value::String(t))) => {
// Read at 30 fps to check it; it's read again at the footage's rate.
StartTimecode::read(t, FrameRate::FPS_30).ok_or_else(start_err)?;
Some(StartTimecode::Timecode(t.clone()))
}
(_, Some(v)) => Some(StartTimecode::Frame(
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
)),
};
Ok(it)
@@ -618,7 +652,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 (Start Frame decides how many).
// A sequence's length is its frames.
if f.kind == FootageKind::Sequence && !f.sequence.is_empty() {
f.sync_sequence_duration();
return;
@@ -671,12 +705,9 @@ impl Interpretation {
if let Some(v) = self.linear {
f.linear_light = v;
}
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);
// After the frame rate: timecode is read at the footage's rate.
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
f.start_timecode = start;
}
}
}
@@ -754,7 +785,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, "startFrame": f.first_frame_number(),
"linearLight": f.linear_light, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
"frames": f.sequence_frames(),
})),
_ => None,
@@ -888,11 +919,11 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
nf.alpha = f.alpha;
nf.loop_count = f.loop_count;
nf.pixel_aspect = f.pixel_aspect;
nf.start_timecode = f.start_timecode;
if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence {
nf.frame_rate = f.frame_rate;
nf.native_rate = f.native_rate;
nf.alphabetical = f.alphabetical;
nf.start_frame = f.start_frame;
nf.sync_sequence_duration();
}
updates.push((*i, nf));
@@ -994,7 +1025,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?, 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?, overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
has_footage_selection,
interpret
),
+13 -2
View File
@@ -47,6 +47,15 @@ fn source_timing(s: &Session, item: ItemId) -> (Option<Tick>, effectcraft_time::
}
}
/// The Footage panel's current time: footage shows its timecode (Start Timecode), anything else
/// time from 0.
pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time::FrameRate, frame: i64) -> String {
match item.map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => f.timecode(frame),
_ => effectcraft_time::format_timecode_ae(frame, fr, false),
}
}
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
let (_, fr) = source_timing(s, item);
if let Some(t) = f_p(p, "time") {
@@ -194,9 +203,11 @@ fn can_edit(s: &Session) -> std::result::Result<(), String> {
fn info(s: &mut Session, _: &Value) -> Result<Value> {
let Some(v) = &s.state.footage_panel else { return Ok(Value::Null) };
let (dur, fr) = source_timing(s, v.item);
let name = s.project.item(v.item).map(|i| i.name.clone()).unwrap_or_default();
let item = s.project.item(v.item);
let name = item.map(|i| i.name.clone()).unwrap_or_default();
let frame = fr.frame_at(v.time);
Ok(
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": fr.frame_at(v.time), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
)
}
-1
View File
@@ -197,7 +197,6 @@ pub fn probe(importer: &dyn Importer, src: &Source, rate: FrameRate) -> Result<F
f.kind = FootageKind::Sequence;
f.sequence = files.clone();
f.alphabetical = *alphabetical;
f.start_frame = None;
f.frame_rate = rate;
f.native_rate = None;
f.sync_sequence_duration();
+23 -23
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 Frame.
//! (placeholders, as in After Effects) and Interpret Footage ▸ Start Timecode.
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_time::{FrameRate, Tick};
@@ -124,7 +124,7 @@ fn force_alphabetical_order_takes_every_image_of_the_type() {
}
#[test]
fn interpret_footage_sets_start_frame() {
fn interpret_footage_start_timecode_relabels_source_time() {
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();
@@ -132,30 +132,30 @@ fn interpret_footage_sets_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));
// 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: 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.
s.execute("file.interpretFootage", json!({"items": [item], "startFrame": 1})).unwrap();
assert_eq!(footage(&s, &item).1.start_frame, None);
s.execute("file.interpretFootage", json!({"items": [item], "startFrame": 3})).unwrap();
s.execute("file.interpretFootage", json!({"items": [item], "startFrame": "file"})).unwrap();
assert_eq!(footage(&s, &item).1.start_frame, None);
// 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!({"startFrame": "x"}), json!({"startFrame": 1e300})] {
// Override Start changes the timecode the Footage panel shows, not the frames (After Effects).
let r = s.execute("file.interpretFootage", json!({"items": [item], "overrideStart": true, "startTimecode": "0:00:01:10"})).unwrap();
assert_eq!((r["items"][0]["startTimecode"].as_str(), r["items"][0]["frames"].as_i64()), (Some("0:00:01:10"), Some(5)));
assert_eq!((footage(&s, &item).1.start_timecode, out(&s)), (Some(40), FrameRate::FPS_30.tick_of(5)));
s.execute("footage.open", json!({"item": item})).unwrap();
s.execute("footage.setTime", json!({"frame": 2})).unwrap();
assert_eq!(s.execute("footage.info", json!({})).unwrap()["timecode"], "0:00:01:12");
// A frame number works too; Use Source File Timecode goes back to 0.
s.execute("file.interpretFootage", json!({"items": [item], "startTimecode": 90})).unwrap();
assert_eq!(footage(&s, &item).1.start_timecode, Some(90));
let r = s.execute("file.interpretFootage", json!({"items": [item], "overrideStart": false, "startTimecode": "0:00:09:00"})).unwrap();
assert_eq!((r["items"][0]["startTimecode"].as_str(), footage(&s, &item).1.start_timecode), (Some("0:00:00:00"), None));
// Timecode is read at the footage's frame rate.
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 10, "startTimecode": "0:00:01:05"})).unwrap();
let f = footage(&s, &item).1;
assert_eq!((f.duration, f.start_timecode), (Tick::from_seconds_f64(0.5), Some(15)));
for bad in [json!({"startTimecode": "x"}), json!({"startTimecode": 1e300}), json!({"startTimecode": -5}), json!({"startTimecode": "-1:00"})] {
let mut p = bad.clone();
p["items"] = json!([item]);
assert!(s.execute("file.interpretFootage", p).is_err(), "{bad}");
}
// Undo puts the interpretation back.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(footage(&s, &item).1.frame_rate, FrameRate::FPS_30);
assert_eq!((footage(&s, &item).1.frame_rate, footage(&s, &item).1.start_timecode), (FrameRate::FPS_30, None));
}
#[test]
@@ -168,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, "startFrame": 2})).unwrap();
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 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.start_frame), (5, FrameRate::from_f64(12.0), Some(2)));
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(4));
assert_eq!((f.sequence.len(), f.frame_rate, f.start_timecode), (5, FrameRate::from_f64(12.0), Some(2)));
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5));
}