148 lines
7.5 KiB
Rust
148 lines
7.5 KiB
Rust
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO, AAF, OMF) ↔ 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" | "aaf" | "omf" | "omfi")) {
|
||
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()
|
||
}
|
||
|
||
fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool {
|
||
matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. }))
|
||
}
|
||
|
||
/// 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) = match format {
|
||
// AAF / OMF may embed audio: write it next to the document (the media items point there)
|
||
Format::Aaf | Format::Omf => {
|
||
let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) };
|
||
let (fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||
for m in extracted {
|
||
if let Some(dir) = std::path::Path::new(&m.path).parent().filter(|_| !s.services.export_in_memory()) {
|
||
let _ = std::fs::create_dir_all(dir);
|
||
}
|
||
s.services.write_file(&m.path, &m.wav).map_err(|e| EngineError::Other(format!("{}: {e}", m.path)))?;
|
||
}
|
||
(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, _| {
|
||
let seqs = filmcraft_interchange::merge_into(proj, fragment, None);
|
||
// A document that sets only some Motion parameters leaves the others at their "auto"
|
||
// (NaN) defaults. Resolve them as placing a clip does, for every format. A media file's
|
||
// picture size is only what the document says so far (an EDL says nothing: the importer
|
||
// assumes the sequence size), so its clips' anchors wait for Link Media below.
|
||
proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source));
|
||
Ok(seqs)
|
||
})?;
|
||
// 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 identity = crate::relink::identity_of(&*s.services, &mpath).ok();
|
||
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;
|
||
m.identity = identity;
|
||
if let (Some(tc), Some(rate)) = (rebase, rate) {
|
||
filmcraft_interchange::rebase_source_timecode(proj, id, tc, rate);
|
||
}
|
||
}
|
||
// the picture size is now the file's own: centre the anchors of its clips in it
|
||
proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| source.id == id);
|
||
Ok(())
|
||
})?;
|
||
s.media.insert_file(id, &mpath, 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"|"aaf"|"omf" = "xml", path, sequence?}`
|
||
pub fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||
// FCP7 XML only when no format is asked for: a format we don't know is an error, not XML.
|
||
// The names are the documented ones (each format's extension, in either case) and nothing
|
||
// else, so the error can list exactly what is accepted.
|
||
let format = match p.get("format") {
|
||
None | Some(Value::Null) => Format::Fcp7Xml,
|
||
Some(v) => v.as_str().and_then(|name| Format::ALL.into_iter().find(|f| f.extension().eq_ignore_ascii_case(name))).ok_or_else(|| {
|
||
let names: Vec<&str> = Format::ALL.iter().map(|f| f.extension()).collect();
|
||
crate::commands::bad("file.exportInterchange", format!("unknown format {v}: use one of {}", names.join(", ")))
|
||
})?,
|
||
};
|
||
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)}))
|
||
}
|