From e95780ca1425ca3fc7cfe73f711c31e66bb0e7a5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 20:50:13 +0700 Subject: [PATCH] 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 --- crates/engine/src/commands/file_more.rs | 73 +++++++++++++++------ crates/engine/src/commands/footage_panel.rs | 15 ++++- crates/engine/src/sequence.rs | 1 - crates/engine/src/tests_sequence.rs | 46 ++++++------- crates/project/src/lib.rs | 15 +++-- crates/project/src/sequence.rs | 69 +++++++------------ crates/ui-egui/src/panels/footage_panel.rs | 2 +- crates/ui-egui/src/panels/forms.rs | 25 ++++--- crates/ui-egui/tests/ui_drag_drop.rs | 4 +- docs/agents.md | 2 +- docs/footage.md | 5 +- docs/parity.md | 2 +- 12 files changed, 146 insertions(+), 113 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index ec7c3d37..e42fe911 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -553,8 +553,38 @@ pub(crate) struct Interpretation { fields: Option, profile: Option>, linear: Option, - /// Image sequences: Start Frame (`Some(None)` = the first file's number). - start: Option>, + /// Start Timecode. + start: Option, +} + +/// 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> { + 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 { + 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 { 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 { 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 { "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 ), diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs index 4d11a35a..f984d53f 100644 --- a/crates/engine/src/commands/footage_panel.rs +++ b/crates/engine/src/commands/footage_panel.rs @@ -47,6 +47,15 @@ fn source_timing(s: &Session, item: ItemId) -> (Option, 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 { 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 { 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()}), ) } diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs index af7f0b71..22180350 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/sequence.rs @@ -197,7 +197,6 @@ pub fn probe(importer: &dyn Importer, src: &Source, rate: FrameRate) -> Result 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)); } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 6729b4b4..5fb7d8df 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -969,10 +969,11 @@ pub struct Footage { /// Effects; [`sequence`]). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub alphabetical: bool, - /// Image sequences: Interpret Footage ▸ Start Frame, the frame number at the footage's first - /// frame (`None` = the first file's). + /// Interpret Footage ▸ Start Timecode ▸ Override Start: the frame (at the footage's frame + /// rate) its timecode shows at its first frame. `None` = Use Source File Timecode (0: file + /// timecode isn't read). It only relabels source time, as in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] - pub start_frame: Option, + pub start_timecode: Option, } fn is_default(v: &T) -> bool { @@ -1006,12 +1007,18 @@ impl Default for Footage { layer: None, page: 0, alphabetical: false, - start_frame: None, + start_timecode: None, } } } impl Footage { + /// Source time as the footage's timecode: its frame `frame` from Start Timecode + /// (`0:00:41:20`), as the Footage panel shows it. + pub fn timecode(&self, frame: i64) -> String { + effectcraft_time::format_timecode_ae(frame.saturating_add(self.start_timecode.unwrap_or(0)), self.frame_rate, false) + } + /// The data format of data footage: `json`, `csv` or `tsv` (from the file extension). pub fn data_format(&self) -> Option<&'static str> { if self.kind != FootageKind::Data { diff --git a/crates/project/src/sequence.rs b/crates/project/src/sequence.rs index a1293d77..fa3d522b 100644 --- a/crates/project/src/sequence.rs +++ b/crates/project/src/sequence.rs @@ -1,11 +1,10 @@ //! Image sequences: numbered stills (`shot_0001.png`, `shot_0002.png`, …) played as one footage //! item ([`FootageKind::Sequence`]). //! -//! A sequence's files are kept in frame-number order ([`Footage::sequence`]). How its frames map -//! to the files follows Interpret Footage: **Start Frame** ([`Footage::start_frame`]) is the -//! frame number shown at the footage's first frame. A gap in the numbering shows a placeholder -//! (colour bars), as in After Effects, except in a sequence imported with Force Alphabetical -//! Order ([`Footage::alphabetical`]), whose files play one after another. +//! A sequence's files are kept in frame-number order ([`Footage::sequence`]). Its frames run from +//! the first file's number to the last's; a gap in the numbering shows a placeholder (colour +//! bars), as in After Effects, except in a sequence imported with Force Alphabetical Order +//! ([`Footage::alphabetical`]), whose files play one after another. use crate::{Footage, FootageKind}; @@ -78,15 +77,8 @@ impl Footage { self.sequence.partition_point(|p| frame_number(p).is_some_and(|x| x < n)) } - /// The frame number at the footage's first frame: Start Frame, or the first file's number - /// (0 when unnumbered). - pub fn first_frame_number(&self) -> i64 { - self.start_frame.or_else(|| self.file_number(0)).unwrap_or(0) - } - - /// How many frames an image sequence has: the span of its numbering from Start Frame, or its - /// files from Start Frame on (Force Alphabetical Order, or unnumbered files). At least 1; 0 - /// for other footage. + /// How many frames an image sequence has: the span of its numbering, or its files (Force + /// Alphabetical Order, or unnumbered files). At least 1; 0 for other footage. pub fn sequence_frames(&self) -> i64 { if !self.is_file_sequence() { return 0; @@ -94,12 +86,9 @@ impl Footage { let n = match self.numbered() { Some(first) => { let last = self.sequence.len().checked_sub(1).and_then(|i| self.file_number(i)).unwrap_or(first); - last.saturating_sub(self.start_frame.unwrap_or(first)).saturating_add(1) - } - None => { - let skip = self.start_frame.map_or(0, |s| self.first_file_from(s)); - self.sequence.len().saturating_sub(skip) as i64 + last.saturating_sub(first).saturating_add(1) } + None => self.sequence.len() as i64, }; n.clamp(1, MAX_SEQUENCE_FRAMES) } @@ -111,18 +100,15 @@ impl Footage { return None; } let Some(first) = self.numbered() else { - let skip = self.start_frame.map_or(0, |s| self.first_file_from(s)); - let k = usize::try_from(i).ok()?.saturating_add(skip); - // Past the last file (Start Frame after it): the last file. - return self.sequence.get(k).or(self.sequence.last()).map(String::as_str); + return self.sequence.get(usize::try_from(i).ok()?).map(String::as_str); }; - let n = self.start_frame.unwrap_or(first).saturating_add(i); + let n = first.saturating_add(i); let k = self.first_file_from(n); self.sequence.get(k).filter(|p| frame_number(p) == Some(n)).map(String::as_str) } - /// Set an image sequence's duration to its frames at its frame rate (after its files, - /// frame rate or Start Frame changed). + /// 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) { if self.is_file_sequence() { self.duration = self.frame_rate.tick_of(self.sequence_frames()); @@ -179,27 +165,20 @@ mod tests { } #[test] - fn start_frame_trims_or_pads_the_head() { + fn start_timecode_only_relabels_source_time() { + // Override Start changes the timecode shown, not the frames (After Effects). let at = |f: &Footage, i| f.sequence_file(i).map(|p| frame_number(p).unwrap_or(-1)); - let mut f = seq(&[1001, 1002, 1003]); - assert_eq!(f.first_frame_number(), 1001); - f.start_frame = Some(1002); - assert_eq!((f.sequence_frames(), at(&f, 0), at(&f, 1)), (2, Some(1002), Some(1003))); - f.start_frame = Some(999); - assert_eq!((f.sequence_frames(), at(&f, 0), at(&f, 2)), (5, None, Some(1001))); + let mut f = Footage { frame_rate: effectcraft_time::FrameRate::from_f64(24.0), ..seq(&[1001, 1002, 1003]) }; + assert_eq!(f.timecode(0), "0:00:00:00"); + f.start_timecode = Some(1001); + assert_eq!((f.sequence_frames(), at(&f, 0), at(&f, 2)), (3, Some(1001), Some(1003))); + assert_eq!((f.timecode(0).as_str(), f.timecode(2).as_str()), ("0:00:41:17", "0:00:41:19")); + // Hostile values never panic. + f.start_timecode = Some(i64::MAX); + let _ = f.timecode(i64::MAX); + assert_eq!((f.sequence_file(i64::MAX), f.sequence_file(-1)), (None, None)); f.alphabetical = true; - f.start_frame = Some(1003); - assert_eq!((f.sequence_frames(), at(&f, 0)), (1, Some(1003))); - // Hostile values never panic and stay bounded. - f.alphabetical = false; - f.start_frame = Some(i64::MIN); - assert_eq!(f.sequence_frames(), MAX_SEQUENCE_FRAMES); - assert_eq!(f.sequence_file(i64::MAX), None); - f.start_frame = Some(i64::MAX); - assert_eq!(f.sequence_frames(), 1); - f.alphabetical = true; - assert_eq!(f.sequence_file(-1), None); - assert!(f.sequence_file(5).is_some()); + assert_eq!((f.sequence_frames(), f.sequence_file(-1), f.sequence_file(3)), (3, None, None)); } #[test] diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index 70ba94b5..57f2f500 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -115,7 +115,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text( pos2(x, bar.center().y), Align2::LEFT_CENTER, - effectcraft_engine::time::format_timecode_ae(fr.frame_at(view.time), fr, false), + effectcraft_engine::commands::footage_panel::timecode(Some(&item), fr, fr.frame_at(view.time)), Tokens::mono(12.0), t.timecode, ); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 061f3309..b20413ed 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -447,23 +447,28 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ), Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), ]; - // Image sequences: their frames (missing ones show colour bars, as in After Effects) - // and the first frame's number. + // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in + // After Effects), then Start Timecode. + let mut timing = Vec::new(); if f.kind == effectcraft_engine::project::FootageKind::Sequence { - let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1); 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"), None => "no missing frames".into(), }; - fields.splice( - at..at, - [ - Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())), - Field::num("startFrame", "Start Frame", f.first_frame_number() as f64), - ], - ); + timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames()))); } + if f.kind != effectcraft_engine::project::FootageKind::Still { + timing.push(Field::choice( + "overrideStart", + "Start Timecode", + &[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))], + usize::from(f.start_timecode.is_some()), + )); + timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0))); + } + let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1); + fields.splice(at..at, timing); (if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields) } "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => { diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 6c603ebe..db891256 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -340,7 +340,7 @@ fn import_dialog_offers_the_image_sequence_option() { } /// #297: every frame of a sequence dropped on the window arrives as one image sequence (it used -/// to make one item per frame), and Interpret Footage shows its Start Frame (missing frames +/// to make one item per frame), 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). #[test] fn dropped_frames_import_as_one_sequence_with_sequence_interpretation() { @@ -359,7 +359,7 @@ fn dropped_frames_import_as_one_sequence_with_sequence_interpretation() { let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.interpretFootage", json!({})).unwrap(); h.run_steps(2); - for id in ["form.field.sequenceInfo", "form.field.startFrame", "form.field.frameRate", "form.field.alpha"] { + for id in ["form.field.sequenceInfo", "form.field.overrideStart", "form.field.startTimecode", "form.field.frameRate", "form.field.alpha"] { assert!(h.state().auto.find(id).is_some(), "{id}"); } assert!(h.state().auto.find("form.field.missingFrames").is_none()); diff --git a/docs/agents.md b/docs/agents.md index b97a979d..bd1914f6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -210,7 +210,7 @@ build real projects through these interfaces; they are worked examples of everyt `"sequence": false`: one still; `"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, - "startFrame":1001}` conforms them (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). * **Multi-layer OpenEXR**: `layer.channels {layer}` lists a footage layer's EXR layers and channels (`{"layers":[{"name":"diffuse","rgba":["diffuse.R","diffuse.G","diffuse.B",""]}]}`); diff --git a/docs/footage.md b/docs/footage.md index 831b9c1f..82558484 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -61,7 +61,7 @@ used, and layers that ran to the end of the footage keep doing so. |---|---| | **Alpha** | *Straight - Unmatted*, *Premultiplied - Matted With Color* (with the matte colour), *Ignore* (opaque), or *Guess* (EffectCraft looks at the first frame). *Invert Alpha* flips it. | | **Assume this frame rate** | The rate the frames play at. A 250-frame sequence at 25 fps lasts 10 seconds; at 24 fps, 10.4 seconds. | -| **Start Frame** (sequences) | The frame number at the footage's first frame. It defaults to the first file's number. A higher number trims the head (start a 1001–1250 render at 1010); a lower one adds placeholder frames before the first file. | +| **Start Timecode** | *Use Source File Timecode* (0:00:00:00), or *Override Start*: the timecode the footage's first frame shows in the Footage panel. It relabels source time only; the frames stay the same. For a 1001–1250 render at 24 fps, Override Start 0:00:41:17 (frame 1001) makes source time count from the render's own frame numbers. | | Fields, pixel aspect, loop, colour profile, linear light | As in After Effects. | Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers @@ -71,7 +71,8 @@ when a sequence imports; Interpret Footage lists them too. fields, pixel aspect, loop and colour settings to other footage. Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", -"startFrame": 1001}` (`"startFrame": "file"` goes back to the first file's number). +"startTimecode": 1001}` (timecode or a frame number; `"overrideStart": false` goes back to Use +Source File Timecode). ## Replacing and reloading diff --git a/docs/parity.md b/docs/parity.md index c316f5ce..9a6bdc9c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -172,7 +172,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | -| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Frame landed: #297) | +| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 96% | 0.3 | face tracking's trained model (MediaPipe Face Landmarker, M13.36) is an optional download; without it the classical (skin model + feature components + shape model) fitter is weak on profile views and occluded faces; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) |