M11: interchange in the engine — file.import detects and merges EDL / FCP7 XML / FCPXML / OTIO (links media on disk, rebases EDL source timecode); File ▸ Export ▸ EDL, Final Cut Pro XML, FCPXML, OpenTimelineIO

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 22:45:22 -04:00
parent 13beb09833
commit 0594cdf8b6
6 changed files with 178 additions and 2 deletions

1
Cargo.lock generated
View File

@ -1398,6 +1398,7 @@ dependencies = [
"filmcraft-export",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-interchange",
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",

View File

@ -20,6 +20,7 @@ filmcraft-project = { workspace = true }
filmcraft-edit = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-export = { workspace = true }
filmcraft-interchange = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@ -492,8 +492,20 @@ fn build() -> Vec<CommandSpec> {
}
let mut ids = Vec::new();
let mut errors = Vec::new();
let mut sequences = Vec::new();
let mut reports = Vec::new();
for path in paths {
match s.services.read_file(&path) {
Ok(b) if crate::interchange::detect(&path, &b).is_some() => {
let fmt = crate::interchange::detect(&path, &b).expect("detected");
match crate::interchange::import(s, &path, &b, fmt) {
Ok(r) => {
sequences.extend(r["sequences"].as_array().cloned().unwrap_or_default());
reports.push(r);
}
Err(e) => errors.push(format!("{path}: {e}")),
}
}
Ok(b) => match import_bytes(s, &path, b.into(), None) {
Ok(id) => ids.push(id.0),
Err(e) => errors.push(format!("{path}: {e}")),
@ -501,10 +513,37 @@ fn build() -> Vec<CommandSpec> {
Err(e) => errors.push(format!("{path}: {e}")),
}
}
if ids.is_empty() && !errors.is_empty() {
if ids.is_empty() && sequences.is_empty() && !errors.is_empty() {
return Err(EngineError::Other(errors.join("; ")));
}
Ok(json!({"items": ids, "errors": errors}))
if reports.is_empty() {
Ok(json!({"items": ids, "errors": errors}))
} else {
Ok(json!({"items": ids, "sequences": sequences, "documents": reports, "errors": errors}))
}
}),
cmd!("file.exportInterchange", "Export Interchange", [], None, r#"{"format":"edl|xml|fcpxml|otio","path":str,"sequence":id?}"#, has_seq, |s, p| {
crate::interchange::export(s, p)
}),
cmd!("file.exportEdl", "EDL…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("edl");
crate::interchange::export(s, &q)
}),
cmd!("file.exportFcp7Xml", "Final Cut Pro XML…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("xml");
crate::interchange::export(s, &q)
}),
cmd!("file.exportFcpxml", "FCPXML…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("fcpxml");
crate::interchange::export(s, &q)
}),
cmd!("file.exportOtio", "OpenTimelineIO…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("otio");
crate::interchange::export(s, &q)
}),
cmd!("file.save", "Save", ["File"], Some("Cmd+S"), r#"{"path":str?}"#, always, |s, p| {
let path = str_p(p, "path").map(str::to_string).or_else(|| s.path.clone()).ok_or_else(|| bad("file.save", "no path (use Save As)"))?;

View File

@ -0,0 +1,113 @@
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO) ↔ the session's project.
use serde_json::{Value, json};
use filmcraft_interchange::{ExportOptions, Format, ImportOptions};
use filmcraft_project::{ItemId, ItemKind, MediaRef};
use crate::{EngineError, Result, Session};
/// The interchange format of a file, if it is one (by content, with the extension as a hint).
pub fn detect(path: &str, bytes: &[u8]) -> Option<Format> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio")) {
return None;
}
filmcraft_interchange::detect(bytes, ext.as_deref())
}
fn report_json(r: &filmcraft_interchange::Report) -> Vec<String> {
r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect()
}
/// Import a document: merge its bins, media and sequences into the project (one undo step), then
/// link each media file that exists on disk. Returns the new sequences.
pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result<Value> {
let p = std::path::Path::new(path);
let opts = ImportOptions {
base_dir: p.parent().map(|d| d.to_string_lossy().to_string()),
name: p.file_stem().map(|n| n.to_string_lossy().to_string()),
..Default::default()
};
let (fragment, report) = filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();
let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());
let seqs = s.edit(&format!("Import {name}"), |proj, _| Ok(filmcraft_interchange::merge_into(proj, fragment, None)))?;
// Link media: probe each new file-backed item that exists.
let new_media: Vec<(ItemId, String)> = s
.project
.items
.values()
.filter(|i| !before.contains(&i.id))
.filter_map(|i| match &i.kind {
ItemKind::Media(m) => match &m.media {
MediaRef::File { path } => Some((i.id, path.clone())),
_ => None,
},
_ => None,
})
.collect();
let mut linked = 0;
let mut offline = Vec::new();
for (id, mpath) in new_media {
let opened = s.services.read_file(&mpath).ok().and_then(|b| {
let fname = std::path::Path::new(&mpath).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
s.media.open_bytes(&fname, b.into()).ok()
});
match opened {
Some(src) => {
let info = src.info().clone();
let rebase = (format == Format::Edl).then_some(info.start_timecode).flatten();
s.edit("Link Media", |proj, _| {
if let Some(item) = proj.items.get_mut(&id)
&& let ItemKind::Media(m) = &mut item.kind
{
let rate = info.video.as_ref().map(|v| v.frame_rate);
m.info = info.clone();
m.offline = false;
if let (Some(tc), Some(rate)) = (rebase, rate) {
filmcraft_interchange::rebase_source_timecode(proj, id, tc, rate);
}
}
Ok(())
})?;
s.media.insert(id, src);
linked += 1;
}
None => offline.push(mpath),
}
}
if let Some(&first) = seqs.first() {
s.state.active_sequence = Some(first);
if !s.state.open_sequences.contains(&first) {
s.state.open_sequences.push(first);
}
}
Ok(json!({
"format": format.name(),
"sequences": seqs.iter().map(|i| i.0).collect::<Vec<_>>(),
"linkedMedia": linked,
"offlineMedia": offline,
"report": report_json(&report),
}))
}
/// `file.exportInterchange {format: "edl"|"xml"|"fcpxml"|"otio", path, sequence?}`
pub fn export(s: &mut Session, p: &Value) -> Result<Value> {
let format = match p.get("format").and_then(Value::as_str).unwrap_or("xml") {
"edl" => Format::Edl,
"fcpxml" => Format::Fcpxml,
"otio" => Format::Otio,
_ => Format::Fcp7Xml,
};
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::Other("need `path`".into()))?.to_string();
let seq = p.get("sequence").and_then(Value::as_u64).map(ItemId).or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
let opts = ExportOptions {
relative_to: std::path::Path::new(&path).parent().map(|d| d.to_string_lossy().to_string()),
name: s.project.item(seq).map(|i| i.name.clone()),
..Default::default()
};
let (bytes, report) = filmcraft_interchange::export(&s.project, seq, format, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(e.to_string()))?;
Ok(json!({"path": path, "bytes": bytes.len(), "report": report_json(&report)}))
}

View File

@ -10,6 +10,7 @@
pub mod commands;
pub mod demo;
pub mod interchange;
pub mod media_pool;
pub mod trim;

View File

@ -205,3 +205,24 @@ fn ripple_trim_to_playhead_q_w() {
assert_eq!(s.active_sequence().unwrap().duration(), d1 - rate.tick_of(6));
assert_eq!(s.playhead(), start2, "playhead parks on the new edit");
}
#[test]
fn interchange_roundtrip_through_files() {
let dir = std::env::temp_dir().join(format!("fc-ix-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = demo();
let n0 = clip_ids(&s, 0).len();
for (cmd, ext) in [("file.exportFcp7Xml", "xml"), ("file.exportOtio", "otio"), ("file.exportFcpxml", "fcpxml"), ("file.exportEdl", "edl")] {
let path = dir.join(format!("demo.{ext}")).to_string_lossy().to_string();
s.execute(cmd, json!({"path": path})).unwrap();
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
let seq = r["sequences"][0].as_u64().unwrap_or_else(|| panic!("{ext}: no sequence in {r}"));
assert_eq!(s.state.active_sequence, Some(ItemId(seq)), "{ext}: imported sequence is opened");
// FCP7 XML and OTIO carry our generator (synthetic) media; FCPXML/EDL only file media.
if matches!(ext, "xml" | "otio") {
assert_eq!(clip_ids(&s, 0).len(), n0, "{ext}: V1 clip count survives the round trip");
}
s.execute("sequence.open", json!({"item": s.state.open_sequences[0].0})).unwrap();
}
let _ = std::fs::remove_dir_all(&dir);
}