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]