diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 28f638a90..64dbeb3da 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -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> = 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) { + 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, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 97efd76cd..e3d5ade36 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -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(); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6bae86f5b..33928eb92 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -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() } } diff --git a/crates/media/tests/it/file_timecode.rs b/crates/media/tests/it/file_timecode.rs new file mode 100644 index 000000000..e5cbfa92d --- /dev/null +++ b/crates/media/tests/it/file_timecode.rs @@ -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) -> Vec { + 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); +} diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs index 69bed4a9f..cc0f5b987 100644 --- a/crates/media/tests/it/main.rs +++ b/crates/media/tests/it/main.rs @@ -3,6 +3,7 @@ mod aac_layout; mod downmix; mod exif_orientation; +mod file_timecode; mod footage; mod open_in_place; mod tga; diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 8cf07ea2e..2cf887b1f 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -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, + /// 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, /// 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![], } }