From 549a4d05400ec15564c8344ccbd1e4386de6f1d6 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Fri, 6 Nov 2026 17:19:14 +0100 Subject: [PATCH] Import: EDL clips without a SOURCE FILE link the file beside the list (#2836) (#3019) * interchange: probe EDL media the reader flags offline (#2836) An EDL line without a SOURCE FILE comment only names its clip. The reader guesses the path beside the list and flags the media offline, and the importer never probed offline media, so the file next to the EDL stayed missing. Offline media is probed like the rest; a path that is not there still becomes a placeholder. Co-Authored-By: Claude Sonnet 5.5 * interchange: probe guessed paths of EDL media only (#2836) An OTIO missing reference also gets a path built from the clip name and is flagged offline, so a same-named file beside the document would have replaced the intended clip silently. Only EDL imports probe offline media. Co-Authored-By: Claude Sonnet 5.5 * interchange: confine guessed EDL paths to the list's folder before probing (review point) CodeRabbit: an EDL clip without SOURCE FILE gets a path from its clip name; a name like ../x.mov or an absolute one could link a file outside the EDL folder, and rebase_to_file_timecode probed it too. Such guessed paths are now not probed and stay placeholders; SOURCE FILE paths are kept as before. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/interchange/src/import.rs | 61 +++++++++++++++++++++++--- crates/interchange/src/tests.rs | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index b7095ae1f..9789ceb89 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -159,7 +159,9 @@ pub fn import( 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(); + // An EDL's guessed paths that leave the list's folder are not probed (see `guessed_outside`). + let outside = if format == TimelineFormat::Edl { guessed_outside(&imported.project, opts.base_dir.as_deref()) } else { HashSet::new() }; + let mut probe_once = |path: &str| if outside.contains(path) { None } else { probed.entry(path.to_string()).or_insert_with(|| probe(path)).clone() }; 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 { @@ -175,7 +177,16 @@ 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() }; + let mut im = Importer { + src: &imported.project, + project, + res, + items: HashMap::new(), + solids: HashMap::new(), + solids_folder: None, + bins: HashMap::new(), + probe_offline: format == TimelineFormat::Edl, + }; im.run(&imported.sequences, &mut probe_once); Ok(im.res) } @@ -188,6 +199,35 @@ const EDL_MEDIA_TIME_NOTE: &str = "EDL source timecodes were imported as media t /// The largest start timecode, in frames, a file may have: a day at 1000 fps. const MAX_FILE_TIMECODE: i64 = 24 * 3600 * 1000; +/// The paths an EDL reader guessed (media it flags offline: a line without `SOURCE FILE` names +/// only its clip) that are not inside the list's folder: a clip name of `../x.mov` or an absolute +/// one would link a file elsewhere on the disk. They stay placeholders. A `SOURCE FILE` path is +/// the list's own choice and is kept, also when another clip guesses the same path. +fn guessed_outside(project: &fp::Project, base_dir: Option<&str>) -> HashSet { + let inside = |path: &str| { + let p = std::path::Path::new(path); + let rest = match base_dir.filter(|b| !b.is_empty()) { + Some(b) => p.strip_prefix(b).ok(), + None => Some(p), + }; + rest.is_some_and(|r| r.components().all(|c| matches!(c, std::path::Component::Normal(_)))) + }; + let (mut guessed, mut named) = (HashSet::new(), HashSet::new()); + for it in project.items.values() { + if let fp::ItemKind::Media(m) = &it.kind + && let fp::MediaRef::File { path } = &m.media + { + if m.offline && !inside(path) { + guessed.insert(path.clone()); + } else { + named.insert(path.clone()); + } + } + } + guessed.retain(|p| !named.contains(p)); + guessed +} + /// 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. 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). @@ -201,20 +241,20 @@ fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str 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 + let files: Vec<(fp::ItemId, String, String)> = project .items .values() .filter_map(|it| match &it.kind { fp::ItemKind::Media(m) => match &m.media { - fp::MediaRef::File { path } => Some((it.id, it.name.clone(), path.clone(), m.offline)), + fp::MediaRef::File { path } => Some((it.id, it.name.clone(), path.clone())), _ => None, }, _ => None, }) .collect(); let mut left = Vec::new(); - for (id, name, path, offline) in files { - match if offline { None } else { probe(&path) } { + for (id, name, path) in files { + match probe(&path) { // A corrupt timecode past a day of frames (at up to 1000 fps) would overflow the tick // arithmetic; such a file stays on timecode. Some(Footage { file_timecode: Some(tc), native_rate, frame_rate, .. }) if (0..=MAX_FILE_TIMECODE).contains(&tc) => { @@ -240,6 +280,11 @@ struct Importer<'a> { solids_folder: Option, /// Folder of each source item (from the bins). bins: HashMap, + /// Probe media the reader flags offline too. An EDL line without `SOURCE FILE` names its clip and + /// FilmCraft guesses the path beside the list; other readers flag media offline for other reasons + /// (an OTIO missing reference also gets a guessed path), and a same-named file there is not + /// the intended clip. + probe_offline: bool, } impl Importer<'_> { @@ -312,7 +357,9 @@ impl Importer<'_> { } fn footage(&mut self, name: &str, path: &str, m: &fp::MediaClip, probe: &mut dyn FnMut(&str) -> Option) -> Footage { - if !m.offline + // An EDL's offline media is probed: the clip is named and the file may sit beside the list. + // A path that is not there probes to nothing and stays a placeholder. + if (!m.offline || self.probe_offline) && let Some(mut f) = probe(path) { if let Some(r) = m.interpret.frame_rate diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 7fec3840b..f4c9158f8 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -408,6 +408,81 @@ fn edl_media_left_on_timecode_is_named() { 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); } +/// An EDL line without `SOURCE FILE` only names its clip; the file beside the list is still found +/// (the reader flags such media offline, but the guess is probed all the same). +#[test] +fn edl_clip_without_a_source_file_links_the_file_beside_the_list() { + let edl = "TITLE: CUT +FCM: NON-DROP FRAME + +001 A002C003 V C 10:00:31:00 10:00:33:00 02:00:00:00 02:00:02:00 +* FROM CLIP NAME: A002C003.mov + +002 A001C001 V C 10:00:24:00 10:00:26:00 02:00:02:00 02:00:04:00 +* FROM CLIP NAME: A001C001.mov +"; + let mut probe_beside = |path: &str| (path == "/work/A002C003.mov").then(|| probe(path)).flatten(); + let mut p = Project::default(); + let opts = ImportOptions { name: "hand.edl".into(), edl_frame_rate: Some(25.0), base_dir: Some("/work".into()) }; + let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_beside).unwrap(); + assert_eq!(r.missing, ["/work/A001C001.mov"], "only the file that is not there is missing"); + let linked = p.items.values().find_map(|it| match &it.kind { + ItemKind::Footage(f) if f.path == "/work/A002C003.mov" => Some(f.missing), + _ => None, + }); + assert_eq!(linked, Some(false)); +} + +/// A clip name that leaves the list's folder (`../x.mov`, an absolute path) is a guess too far: +/// the file there is neither probed nor linked, and its timecode does not rebase the clip. A +/// `SOURCE FILE` path outside the folder is the list's own choice and still links. +#[test] +fn edl_guessed_paths_outside_the_list_folder_are_not_probed() { + let edl = "TITLE: CUT +FCM: NON-DROP FRAME + +001 A001 V C 10:00:31:00 10:00:33:00 02:00:00:00 02:00:02:00 +* FROM CLIP NAME: ../up.mov + +002 A002 V C 10:00:24:00 10:00:26:00 02:00:02:00 02:00:04:00 +* FROM CLIP NAME: /elsewhere/abs.mov + +003 A003 V C 10:00:24:00 10:00:26:00 02:00:04:00 02:00:06:00 +* FROM CLIP NAME: inside.mov + +004 A004 V C 10:00:24:00 10:00:26:00 02:00:06:00 02:00:08:00 +* FROM CLIP NAME: named.mov +* SOURCE FILE: /elsewhere/named.mov +"; + let mut asked = Vec::new(); + let mut probe_all = |path: &str| { + asked.push(path.to_string()); + probe(path) + }; + let mut p = Project::default(); + let opts = ImportOptions { name: "hand.edl".into(), edl_frame_rate: Some(25.0), base_dir: Some("/work".into()) }; + let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_all).unwrap(); + asked.sort(); + assert_eq!(asked, ["/elsewhere/named.mov", "/work/inside.mov"], "only the file inside the folder and the named one"); + let mut missing = r.missing.clone(); + missing.sort(); + assert_eq!(missing.len(), 2, "{missing:?}"); + assert!(missing.iter().all(|m| m.ends_with("up.mov") || m.ends_with("abs.mov")), "{missing:?}"); +} + +/// Only an EDL's guessed paths are probed: an OTIO missing reference also gets a path built from +/// the clip name, and a same-named file beside the document is not the intended clip. +#[test] +fn otio_missing_reference_stays_a_placeholder_even_with_a_file_of_that_name() { + let otio = r#"{"OTIO_SCHEMA":"Timeline.1","name":"t","global_start_time":null,"tracks":{"OTIO_SCHEMA":"Stack.1","name":"tracks","children":[{"OTIO_SCHEMA":"Track.1","name":"V1","kind":"Video","children":[{"OTIO_SCHEMA":"Clip.2","name":"a.mov","source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":25.0,"value":0.0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":25.0,"value":50.0}},"media_references":{"DEFAULT_MEDIA":{"OTIO_SCHEMA":"MissingReference.1","name":"","available_range":null}},"active_media_reference_key":"DEFAULT_MEDIA"}]}]}}"#; + let mut p = Project::default(); + let opts = ImportOptions { name: "t.otio".into(), edl_frame_rate: None, base_dir: Some("/work".into()) }; + let mut probe_any = |path: &str| probe(path); + let r = import(&mut p, otio.as_bytes(), None, &opts, &mut probe_any).unwrap(); + assert_eq!(r.format, Some(TimelineFormat::Otio)); + assert!(!r.missing.is_empty(), "the missing reference stays a placeholder: {:?}", r.warnings); +} + /// A drop-frame list counts its hour in timecode: a clip cut at 01:00:00;00 (3599.996 s at 29.97 /// fps) from a missing file is still named. #[test]