mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-13 03:44:30 +00:00
163 lines
9.0 KiB
Rust
163 lines
9.0 KiB
Rust
//! File ▸ Import ▸ Adobe After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸
|
|
//! Adobe After Effects Project… (`effectcraft_aep::export`).
|
|
//!
|
|
//! Adds the items of an `.aep` (or its XML form, `.aepx`) to the project in a folder named after
|
|
//! the file (`docs/aep-import.md` lists what arrives). What the importer cannot bring over (effects
|
|
//! outside its table, undecoded text and style settings, …) is returned in the report; with losses and without
|
|
//! `allowLosses` the import is refused and changes nothing. Fonts that are not installed are listed
|
|
//! in the report's `missingFonts` (not a loss: the text keeps the font name and is drawn with the
|
|
//! fallback font).
|
|
|
|
use effectcraft_aep::export::{AepExportError, ExportOptions};
|
|
use effectcraft_aep::import::{AepImportError, ImportOptions};
|
|
use effectcraft_project::{FootageKind, ItemKind};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, always, b_p, bad, str_p};
|
|
use crate::{EngineError, Result, Session, cmd};
|
|
|
|
/// Whether `path` is an After Effects project (`.aep`, or its XML form `.aepx`): imported as a
|
|
/// project (`file.importAep`), not as footage.
|
|
pub fn is_aep_path(path: &str) -> bool {
|
|
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("aep") || e.eq_ignore_ascii_case("aepx"))
|
|
}
|
|
|
|
/// `file.importAep`: the `.aep` / `.aepx` at `path` into the open project, in a folder named after
|
|
/// the file; refused ([`EngineError::Refused`]) when parts would be lost and `allowLosses` is not set.
|
|
fn import_aep(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let path = str_p(p, "path").ok_or_else(|| bad("file.importAep", "missing `path`"))?.to_string();
|
|
let allow_losses = b_p(p, "allowLosses").unwrap_or(false);
|
|
let dry_run = b_p(p, "dryRun").unwrap_or(false);
|
|
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
|
// Footage paths stored in the file reach the file system only through the document-path policy
|
|
// (`doc_path`): relative ones are taken from the file's folder, paths to other computers are
|
|
// never looked at (the footage arrives missing).
|
|
let services = s.services.clone();
|
|
let doc = path.clone();
|
|
let exists = move |f: &str| crate::doc_path::resolve(f, Some(&doc)).ok().and_then(|p| p.to_str().map(|p| services.exists(p))).unwrap_or(false);
|
|
// The renderer's own font lookup (family, full or PostScript name); a miss falls back.
|
|
let has_font = |font: &str| !effectcraft_text::resolve(font, "Regular").missing;
|
|
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "After Effects Project".into());
|
|
// Into a project without compositions the file's settings come along (it is opened as a
|
|
// project); into one with compositions only when asked (`applySettings`). Settings that are not
|
|
// taken over cannot be lost, so the dry run and the refusal leave them out.
|
|
let apply = b_p(p, "applySettings").unwrap_or_else(|| s.project.comps().next().is_none());
|
|
let options = ImportOptions { allow_losses, exists: &exists, has_font: &has_font, folder_name: &name, settings: apply };
|
|
if dry_run {
|
|
let report = effectcraft_aep::import::analyze_with(&bytes, &options).map_err(|e| EngineError::Other(format!("cannot import {path}: {e}")))?;
|
|
return Ok(json!({"dryRun": true, "report": report}));
|
|
}
|
|
let importer = s.importer.clone();
|
|
let res = s.edit("Import Adobe After Effects Project", None, |proj, st| {
|
|
let r = effectcraft_aep::import::import(proj, &bytes, &options).map_err(|e| match e {
|
|
AepImportError::Refused(_) => EngineError::Refused {
|
|
losses: e.to_string(),
|
|
advice: "nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report",
|
|
},
|
|
other => EngineError::Other(format!("cannot import {path}: {other}")),
|
|
})?;
|
|
if apply {
|
|
proj.settings = r.settings.clone();
|
|
}
|
|
// The new footage keeps the file as its origin: resolved against it, a refused path missing.
|
|
let created: std::collections::HashSet<_> = r.items.iter().copied().collect();
|
|
crate::doc_path::resolve_footage_of(proj, |id| created.contains(&id), Some(&path));
|
|
// A video file that is there says whether it carries sound, and it is right when the
|
|
// project file's stored sample rate is not (the file changed since, or the project is not
|
|
// After Effects's own). The path is the one the document-path policy resolved above; a
|
|
// refused or missing one is not read and keeps what the project file says.
|
|
if let Some(importer) = &importer {
|
|
for item in proj.items.values_mut().filter(|i| created.contains(&i.id)) {
|
|
if let ItemKind::Footage(f) = &mut item.kind
|
|
&& f.kind == FootageKind::Video
|
|
&& !f.missing
|
|
&& let Ok(probed) = importer.probe(&f.path)
|
|
{
|
|
f.has_audio = probed.has_audio;
|
|
}
|
|
}
|
|
}
|
|
st.project_selection = vec![r.folder];
|
|
Ok(r)
|
|
})?;
|
|
let created: std::collections::HashSet<_> = res.items.iter().copied().collect();
|
|
s.stored_paths.add_items(&s.project, |id| created.contains(&id), Some(&path));
|
|
if let Some(first) = res.comps.first() {
|
|
s.open_comp(*first);
|
|
}
|
|
let losses = res.report.total_losses();
|
|
s.toast(if losses == 0 {
|
|
format!("Imported {name}")
|
|
} else {
|
|
format!("Imported {name} ({losses} unsupported item{} not imported)", if losses == 1 { "" } else { "s" })
|
|
});
|
|
Ok(json!({
|
|
"folder": res.folder.0,
|
|
"comps": res.comps.iter().map(|c| c.0).collect::<Vec<_>>(),
|
|
"items": res.items.iter().map(|i| i.0).collect::<Vec<_>>(),
|
|
"report": res.report,
|
|
}))
|
|
}
|
|
|
|
/// `file.exportAep`: the open project as an `.aep` at `path`; refused ([`EngineError::Refused`])
|
|
/// when parts would be lost and `allowLosses` is not set.
|
|
fn export_aep(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let path = str_p(p, "path").ok_or_else(|| bad("file.exportAep", "missing `path`"))?.to_string();
|
|
let allow_losses = b_p(p, "allowLosses").unwrap_or(false);
|
|
let dry_run = b_p(p, "dryRun").unwrap_or(false);
|
|
if dry_run {
|
|
return Ok(json!({"dryRun": true, "report": effectcraft_aep::export::analyze(&s.project)}));
|
|
}
|
|
// Image-sequence folders keep their stored numbering in After Effects only with their actual
|
|
// modification time (otherwise it rescans them and warns about every gap on open). The folder
|
|
// is looked at only when the sequence's file passes the document-path policy (a path that came
|
|
// from a document is followed as import followed it; a refused one leaves the time unset).
|
|
let services = s.services.clone();
|
|
let stored = s.stored_paths.clone();
|
|
let folder_time = move |file: &str| {
|
|
let file = stored.follow(file).ok()?;
|
|
let file = file.to_str()?;
|
|
// Either separator, as the exporter splits the alias (a Windows path on another system).
|
|
let dir = file.get(..file.rfind(['/', '\\'])?).filter(|d| !d.is_empty())?;
|
|
services.stamp(dir).and_then(|(_, ns)| u64::try_from(ns / 1_000_000_000).ok())
|
|
};
|
|
let res = effectcraft_aep::export::export_with(&s.project, &ExportOptions { allow_losses }, &folder_time).map_err(|e| match e {
|
|
AepExportError::Refused(_) => {
|
|
EngineError::Refused { losses: e.to_string(), advice: "nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report" }
|
|
}
|
|
other => EngineError::Other(format!("cannot export {path}: {other}")),
|
|
})?;
|
|
s.services.write_file(&path, &res.bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
|
let losses = res.report.total_losses();
|
|
s.toast(if losses == 0 {
|
|
format!("Exported After Effects project to {path}")
|
|
} else {
|
|
format!("Exported After Effects project to {path} ({losses} unsupported item{} not exported)", if losses == 1 { "" } else { "s" })
|
|
});
|
|
Ok(json!({"path": path, "bytes": res.bytes.len(), "report": res.report}))
|
|
}
|
|
|
|
/// File ▸ Import / Export ▸ Adobe After Effects Project….
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"file.importAep",
|
|
"Adobe After Effects Project...",
|
|
["File", "Import"],
|
|
None,
|
|
"{path (.aep or .aepx), allowLosses?: bool (import although parts cannot be imported), dryRun?: bool (only return the report), applySettings?: bool (take over the file's project settings; default: when the project has no compositions)}",
|
|
always,
|
|
import_aep
|
|
),
|
|
cmd!(
|
|
"file.exportAep",
|
|
"Adobe After Effects Project...",
|
|
["File", "Export"],
|
|
None,
|
|
"{path (.aep), allowLosses?: bool (export although parts cannot be exported), dryRun?: bool (only return the report)}",
|
|
always,
|
|
export_aep
|
|
),
|
|
]
|
|
}
|