mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
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:
co-authored by
Claude Opus 5.5
parent
77af6d1a78
commit
e95780ca14
@@ -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
|
||||
),
|
||||
|
||||
@@ -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()}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user