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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Sonnet 5.5 authored and GitHub committed 2026-11-06 17:19:14 +01:00
1 parent ae068ff931
commit 549a4d0540
2 files changed
+129 -7

No files matched your search

+54 -7
View File
@@ -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<String, Option<Footage>> = 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<String> {
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<ItemId>,
/// Folder of each source item (from the bins).
bins: HashMap<fp::ItemId, ItemId>,
/// 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>) -> 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
+75
View File
@@ -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]