From 70adad55876483dc18532b0d93aa5489d9ef267c Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:26:37 -0300 Subject: [PATCH] Import: an EDL's "rebase after probing" note names only the media left on timecode (#2832) The import now does the rebase FilmCraft's reader asks for, so passing the reader's note on told people to do something already done. It is dropped when every file the list uses at an hour or more of timecode was rebased, and replaced by a warning naming the ones that weren't (the file is missing or has no start timecode). --- crates/interchange/src/import.rs | 52 +++++++++++++++++++++++--------- crates/interchange/src/tests.rs | 28 +++++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 64dbeb3da..8a15e874e 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -1,7 +1,7 @@ //! Timeline documents → compositions (File ▸ Import ▸ Adobe Premiere Pro Project / Timeline //! Interchange…). -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{Keyframe, Value}; @@ -160,11 +160,17 @@ pub fn import( // 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 left_on_timecode = if format == TimelineFormat::Edl { rebase_to_file_timecode(&mut imported.project, &mut probe_once) } else { Vec::new() }; let mut res = ImportResult { format: Some(format), ..Default::default() }; for e in &report.entries { + // The reader's "rebase after probing" note is done above: name only the media it wasn't. + if format == TimelineFormat::Edl && e.message.starts_with(EDL_MEDIA_TIME_NOTE) { + if !left_on_timecode.is_empty() { + res.warnings + .push(format!("EDL source timecodes stay media time for {}: the file is missing or has no start timecode", left_on_timecode.join(", "))); + } + continue; + } if e.count > 1 { res.warnings.push(format!("{} (×{})", e.message, e.count)) } else { res.warnings.push(e.message.clone()) } } let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() }; @@ -174,28 +180,46 @@ pub fn import( Ok(im.res) } +/// The start of the note FilmCraft's EDL reader adds when source timecodes look like real +/// timecode (an hour or more) and it has kept them as media time. +const EDL_MEDIA_TIME_NOTE: &str = "EDL source timecodes were imported as media time"; + /// 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 +/// when it links the media of an imported EDL. Returns the names of the media that stay on +/// timecode though their clips use an hour or more of it (the file is missing or has none). +fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option) -> Vec { + let hour = filmcraft_time::Tick(3600 * filmcraft_time::TICKS_PER_SECOND); + let mut on_timecode: HashSet = HashSet::new(); + for it in project.items.values() { + if let fp::ItemKind::Sequence(seq) = &it.kind { + on_timecode.extend(seq.all_tracks().flat_map(|t| &t.items).filter(|ti| ti.source_in >= hour).map(|ti| ti.item)); + } + } + let files: Vec<(fp::ItemId, String, String, bool)> = 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())), + fp::ItemKind::Media(m) => match &m.media { + fp::MediaRef::File { path } => Some((it.id, it.name.clone(), path.clone(), m.offline)), _ => 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))); + let mut left = Vec::new(); + for (id, name, path, offline) in files { + match if offline { None } else { probe(&path) } { + Some(Footage { file_timecode: Some(tc), native_rate, frame_rate, .. }) => { + filmcraft_interchange::rebase_source_timecode(project, id, tc, crate::rate_out(native_rate.unwrap_or(frame_rate))); + } + _ if on_timecode.contains(&id) => left.push(name), + _ => {} } } + left.sort(); + left.dedup(); + left } struct Importer<'a> { diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index e3d5ade36..6e738e86c 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -378,6 +378,34 @@ fn edl_source_timecode_lands_on_the_files_own_time() { 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()); + assert!(!r.warnings.iter().any(|w| w.contains("media time")), "the rebase is done: {:?}", r.warnings); +} + +/// The reader's note to rebase after probing is replaced by the media that couldn't be rebased: +/// a file with no start timecode and a missing file. Media cut from 00:00:00:00 needs no rebase. +#[test] +fn edl_media_left_on_timecode_is_named() { + let event = |n: u32, reel: &str, ch: &str, src: (&str, &str), rec: (&str, &str), file: &str| { + format!("{n:03} {reel:<8} {ch:<5} C {} {} {} {}\n* FROM CLIP NAME: {file}\n* SOURCE FILE: /media/{file}\n\n", src.0, src.1, rec.0, rec.1) + }; + let edl = [ + "TITLE: CUT\nFCM: NON-DROP FRAME\n\n".to_string(), + event(1, "A", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:00:00", "01:00:01:00"), "a.mov"), + event(2, "B", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:01:00", "01:00:02:00"), "b.mov"), + event(3, "C", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:02:00", "01:00:03:00"), "c.mov"), + event(4, "D", "A", ("00:00:00:00", "00:00:03:00"), ("01:00:00:00", "01:00:03:00"), "d.wav"), + ] + .concat(); + let mut probe_some = |path: &str| match path { + "/media/a.mov" => probe(path).map(|f| Footage { file_timecode: Some(3600 * 25), ..f }), + "/media/c.mov" => None, + _ => probe(path), + }; + 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_some).unwrap(); + let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("media time")).collect(); + assert_eq!(notes, ["EDL source timecodes stay media time for b.mov, c.mov: the file is missing or has no start timecode"], "{:?}", r.warnings); } #[test]