Files
Brandon Thomas 5bc07026c7 Merge pull request #43 from punkhop/fix/import-auto-points
engine: a project saved after an interchange import opens again
2026-10-06 11:36:46 -04:00

148 lines
7.5 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)}))
}