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:
parent
13beb09833
commit
0594cdf8b6
|
|
@ -1398,6 +1398,7 @@ dependencies = [
|
|||
"filmcraft-export",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)"))?;
|
||||
|
|
|
|||
|
|
@ -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)}))
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
pub mod commands;
|
||||
pub mod demo;
|
||||
pub mod interchange;
|
||||
pub mod media_pool;
|
||||
pub mod trim;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue