Import: EDL source timecodes land on each file's own time (#2832)

FilmCraft's EDL reader keeps source timecodes as media time and leaves the
rebase to the caller after probing. The probe now keeps the file's start
timecode (Footage::file_timecode), and an EDL import rebases each found
file's clips with filmcraft_interchange::rebase_source_timecode before the
layers are made, as FilmCraft's own import does.
This commit is contained in:
JonRC committed 2026-11-02 11:46:03 -03:00
1 parent 9d7baea5cc
commit 0de8d57cf8
6 files changed
+97 -4

No files matched your search

+32 -2
View File
@@ -156,7 +156,13 @@ pub fn import(
}),
name: Some(opts.name.trim_end_matches(|c| c != '.').trim_end_matches('.').to_string()).filter(|s| !s.is_empty()),
};
let (imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?;
let (mut imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?;
// Each file is probed once, though an EDL's media are probed before the layers are made.
let mut probed: HashMap<String, Option<Footage>> = HashMap::new();
let mut probe_once = |path: &str| probed.entry(path.to_string()).or_insert_with(|| probe(path)).clone();
if format == TimelineFormat::Edl {
rebase_to_file_timecode(&mut imported.project, &mut probe_once);
}
let mut res = ImportResult { format: Some(format), ..Default::default() };
for e in &report.entries {
if e.count > 1 { res.warnings.push(format!("{} (×{})", e.message, e.count)) } else { res.warnings.push(e.message.clone()) }
@@ -164,10 +170,34 @@ pub fn import(
let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() };
res.folder = project.add_item(&folder_name, Label::None, None, ItemKind::Folder);
let mut im = Importer { src: &imported.project, project, res, items: HashMap::new(), solids: HashMap::new(), solids_folder: None, bins: HashMap::new() };
im.run(&imported.sequences, probe);
im.run(&imported.sequences, &mut probe_once);
Ok(im.res)
}
/// An EDL's source times are its source timecodes, which FilmCraft's reader keeps as media time.
/// Put the clips of each file that has a start timecode on the file's own time, as FilmCraft does
/// when it links the media of an imported EDL.
fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option<Footage>) {
let files: Vec<(fp::ItemId, String)> = project
.items
.values()
.filter_map(|it| match &it.kind {
fp::ItemKind::Media(m) if !m.offline => match &m.media {
fp::MediaRef::File { path } => Some((it.id, path.clone())),
_ => None,
},
_ => None,
})
.collect();
for (id, path) in files {
if let Some(f) = probe(&path)
&& let Some(tc) = f.file_timecode
{
filmcraft_interchange::rebase_source_timecode(project, id, tc, crate::rate_out(f.native_rate.unwrap_or(f.frame_rate)));
}
}
}
struct Importer<'a> {
src: &'a fp::Project,
project: &'a mut Project,
+15
View File
@@ -365,6 +365,21 @@ fn edl_imports_one_track() {
assert!(c.layers.len() >= 2);
}
/// An EDL's source timecodes are footage time once the file's own start timecode is known:
/// 01:00:01:00 in a file that starts at 01:00:00:00 is one second in, not 3601 s.
#[test]
fn edl_source_timecode_lands_on_the_files_own_time() {
let edl = "TITLE: CUT\nFCM: NON-DROP FRAME\n\n001 A001 V C 01:00:01:00 01:00:02:00 01:00:00:00 01:00:01:00\n* FROM CLIP NAME: a.mov\n* SOURCE FILE: /media/a.mov\n";
let mut probe_tc = |path: &str| probe(path).map(|f| Footage { file_timecode: Some(3600 * 25), ..f });
let mut p = Project::default();
let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() };
let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_tc).unwrap();
let c = p.comp(r.comps[0]).unwrap();
let l = layer(c, "a.mov");
assert!(near(l.layer_time(l.in_point), t(1.0)), "{}", l.layer_time(l.in_point).seconds());
assert!(near(l.start_time, t(-1.0)), "{}", l.start_time.seconds());
}
#[test]
fn rejects_unknown_documents() {
let mut p = Project::default();
+2
View File
@@ -198,6 +198,8 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -
missing: false,
sequence: Vec::new(),
color_profile: v.and_then(|v| profile_of(&v.color)),
// counted in video frames, so only with a video stream
file_timecode: v.and(info.start_timecode),
..Default::default()
}
}
+38
View File
@@ -0,0 +1,38 @@
//! The start timecode a movie records is read into its footage (`Footage::file_timecode`).
use std::io::Cursor;
use effectcraft_project::FootageKind;
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TimecodeConfig, TrackConfig, WriteSample, WriterOptions};
/// A two-frame 24 fps Motion JPEG MOV, with a timecode track from `start_frame` when given.
fn mov(start_frame: Option<u32>) -> Vec<u8> {
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(16, 16, image::Rgb([200, 40, 90])).write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).expect("jpeg");
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer");
let v = w.add_track(TrackConfig::new(SampleEntry::jpeg(16, 16), 24)).expect("video track");
if let Some(start) = start_frame {
let tc = TimecodeConfig { timescale: 24, frame_duration: 1, frames_per_second: 24, ..Default::default() };
w.add_timecode_track(v, tc, start).expect("timecode track");
}
for _ in 0..2 {
w.write_sample(v, WriteSample { data: &jpeg, duration: 1, composition_offset: 0, is_sync: true }).expect("frame");
}
w.finish().expect("finish").into_inner()
}
#[test]
fn a_movies_start_timecode_is_read() {
let dir = std::env::temp_dir().join(format!("effectcraft-file-timecode-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
// 10:00:00:00 at 24 fps, and a movie with no timecode
for (name, start) in [("tc.mov", Some(10 * 3600 * 24)), ("none.mov", None)] {
let p = dir.join(name);
std::fs::write(&p, mov(start)).expect("write");
let f = effectcraft_media::probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Video, "{name}");
assert_eq!(f.file_timecode, start.map(i64::from), "{name}");
assert_eq!(f.start_timecode, None, "{name}: Interpret Footage still uses the source file timecode");
}
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -3,6 +3,7 @@
mod aac_layout;
mod downmix;
mod exif_orientation;
mod file_timecode;
mod footage;
mod open_in_place;
mod tga;
+9 -2
View File
@@ -255,10 +255,16 @@ pub struct Footage {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub alphabetical: bool,
/// 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.
/// rate) its timecode shows at its first frame. `None` = Use Source File Timecode (shown
/// from 0: [`Footage::file_timecode`] isn't used here yet). It only relabels source time, as
/// in After Effects.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_timecode: Option<i64>,
/// The start timecode recorded in the file, in frames at its own frame rate (`native_rate`,
/// else `frame_rate`). Read when probing; importing an EDL uses it to turn the list's source
/// timecodes into footage time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_timecode: Option<i64>,
/// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer
/// made from the footage starts with them, and Update Markers From Source copies new ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -299,6 +305,7 @@ impl Default for Footage {
page: 0,
alphabetical: false,
start_timecode: None,
file_timecode: None,
markers: vec![],
}
}