mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04: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 <[email protected]>
This commit is contained in:
1 parent
77af6d1a78
commit
e95780ca14
12 files changed
+146
-113
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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<i64>,
|
||||
pub start_timecode: Option<i64>,
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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()) => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
+1
-1
@@ -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",""]}]}`);
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user