mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 14:49:46 +00:00
305 lines
15 KiB
Rust
305 lines
15 KiB
Rust
//! After Effects project (`.aep`) import: the typed view of [`effectcraft_aep`] mapped into an
|
|
//! EffectCraft [`Project`]. The XML form (`.aepx`) is accepted as well: both are read into the same
|
|
//! flat chunk index (an `.aep` in place, an `.aepx` decoded into one buffer first).
|
|
//!
|
|
//! What arrives: the project item tree (folders), project settings (depth, working space, time
|
|
//! display), compositions (size, exact frame rate, duration, work area, background, pixel
|
|
//! aspect, motion blur, shutter, renderer, markers), solid, null, adjustment, footage,
|
|
//! precomposition, text, shape, camera and light layers (name, timing, stretch, switches, label,
|
|
//! blend mode, parent, track matte, markers, time remapping), the Transform properties (static
|
|
//! values and keyframes with interpolation, ease and spatial tangents) and expressions as source
|
|
//! text, layer masks, the Source Text and text animators of text layers, shape groups and their
|
|
//! items, layer styles, the effects of [`supported_effects`] with their parameters and keyframes
|
|
//! in stack order, solids, and footage files and image sequences as links (missing files become
|
|
//! missing-footage placeholders). `docs/aep-import.md` has the full list.
|
|
//!
|
|
//! What does not arrive is **reported**, never dropped silently: [`ImportReport`] counts other
|
|
//! effects (by match name; they arrive as placeholders that render nothing, with their parameters
|
|
//! for expressions) and unimported effect parameters, unusable masks, layer style
|
|
//! features, text layer features, shape items, unsupported blend modes and transform
|
|
//! properties, simplified keyframes and unreadable data per [`LossCategory`]. By default the import
|
|
//! is refused when anything would be lost ([`ImportOptions::allow_losses`] lifts that); a refusal
|
|
//! changes nothing. A damaged file yields an error or a report, never a panic.
|
|
//!
|
|
//! The crate sits at L3 (above `effectcraft-project`, as `effectcraft-lottie` does) so it builds
|
|
//! for wasm32; it reads no files itself: the caller passes the bytes, a way to ask whether a
|
|
//! footage path exists and one to ask whether a font is installed.
|
|
|
|
pub(crate) mod build;
|
|
mod camera;
|
|
mod effects;
|
|
mod lumetri;
|
|
mod markers;
|
|
mod masks;
|
|
mod placeholder;
|
|
mod props;
|
|
mod puppet;
|
|
mod report;
|
|
mod settings;
|
|
mod shapes;
|
|
mod styles;
|
|
mod text;
|
|
mod text_anim;
|
|
mod time;
|
|
|
|
use effectcraft_project::{ItemId, Project, ProjectSettings};
|
|
|
|
use crate::container::index::{Root, TreeIndex};
|
|
use crate::container::{AepxDrafts, Limits, index_aepx};
|
|
|
|
pub use effects::supported_effects;
|
|
pub use report::{AeVersionInfo, ImportReport, Imported, LIMITATIONS, Loss, LossCategory, MAX_DETAILS, TESTED_MAJORS, UNKNOWN};
|
|
|
|
/// Why an `.aep` was not imported.
|
|
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
|
pub enum AepImportError {
|
|
/// The file is not a readable After Effects project container.
|
|
#[error("{0}")]
|
|
Container(#[from] crate::AepError),
|
|
/// The container has no project (no `Fold` list).
|
|
#[error("the file holds no After Effects project")]
|
|
NoProject,
|
|
/// Parts of the project cannot be imported and losses were not allowed. The project is
|
|
/// unchanged; the report says what would be lost.
|
|
#[error("importing would lose {}; allow losses to import anyway", .0.summary())]
|
|
Refused(Box<ImportReport>),
|
|
}
|
|
|
|
/// Options of [`import`].
|
|
pub struct ImportOptions<'a> {
|
|
/// Import even when [`ImportReport::losses`] is not empty.
|
|
pub allow_losses: bool,
|
|
/// Whether a footage path exists (the caller's file access); a path that does not becomes a
|
|
/// missing-footage placeholder.
|
|
pub exists: &'a dyn Fn(&str) -> bool,
|
|
/// Whether a font (a PostScript name such as `TimesNewRomanPSMT`) is installed; fonts it
|
|
/// rejects are listed in [`ImportReport::missing_fonts`]. Text keeps the font name either way.
|
|
pub has_font: &'a dyn Fn(&str) -> bool,
|
|
/// The folder every imported item goes into (After Effects names it after the file).
|
|
pub folder_name: &'a str,
|
|
/// Whether the caller takes over the file's project settings ([`ImportResult::settings`]).
|
|
/// When not, a setting EffectCraft has no counterpart for is no loss (nothing is changed), so it
|
|
/// is not reported.
|
|
pub settings: bool,
|
|
}
|
|
|
|
impl<'a> ImportOptions<'a> {
|
|
/// Losses refused, every font taken as installed, folder `After Effects Project`, the file's
|
|
/// settings taken over.
|
|
pub fn new(exists: &'a dyn Fn(&str) -> bool) -> Self {
|
|
Self { allow_losses: false, exists, has_font: &any_font, folder_name: "After Effects Project", settings: true }
|
|
}
|
|
}
|
|
|
|
/// The font check of [`ImportOptions::new`]: every font counts as installed.
|
|
fn any_font(_: &str) -> bool {
|
|
true
|
|
}
|
|
|
|
/// What an import created.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ImportResult {
|
|
/// The folder holding the imported items.
|
|
pub folder: ItemId,
|
|
/// The compositions, in file order.
|
|
pub comps: Vec<ItemId>,
|
|
/// Every item created, the folder first.
|
|
pub items: Vec<ItemId>,
|
|
pub report: ImportReport,
|
|
/// The project's settings with those the file stores (colour depth, working space, working
|
|
/// gamma, the linear-light switches, time display, frame count start) put in. [`import`]
|
|
/// leaves the project's own settings alone; a caller opening the file as a new project
|
|
/// applies these.
|
|
pub settings: ProjectSettings,
|
|
}
|
|
|
|
/// Imports the `.aep` or `.aepx` in `bytes` into `project` (as new items; nothing existing changes).
|
|
///
|
|
/// # Errors
|
|
/// [`AepImportError::Container`] / [`AepImportError::NoProject`] for a file that is not a project,
|
|
/// [`AepImportError::Refused`] when something would be lost and `allow_losses` is not set. On any
|
|
/// error `project` is unchanged.
|
|
pub fn import(project: &mut Project, bytes: &[u8], options: &ImportOptions<'_>) -> Result<ImportResult, AepImportError> {
|
|
// The importer only reads: an `.aep` is indexed in place (one flat array that borrows the
|
|
// bytes); an `.aepx` is decoded into one buffer, which the index borrows.
|
|
if crate::is_aepx(bytes) {
|
|
let AepxDrafts { buffer, nodes, trailer } = index_aepx(bytes, Limits::default())?;
|
|
let index = TreeIndex::from_drafts(&buffer, nodes, trailer);
|
|
import_root(project, Root::from(&index), options)
|
|
} else {
|
|
let index = TreeIndex::parse(bytes)?;
|
|
import_root(project, Root::from(&index), options)
|
|
}
|
|
}
|
|
|
|
/// [`import`] of a file read into either tree.
|
|
fn import_root(project: &mut Project, file: Root<'_, '_>, options: &ImportOptions<'_>) -> Result<ImportResult, AepImportError> {
|
|
let view = crate::Project::of(file).ok_or(AepImportError::NoProject)?;
|
|
// Work on a copy so a refusal leaves the project untouched (compositions are shared, cheap).
|
|
let mut work = project.clone();
|
|
let (folder, comps, items, mut report) = build::run(&mut work, &view, options);
|
|
report.ae_version = report::AeVersionInfo::of_root(file);
|
|
if let Some(line) = report.ae_version.limitation() {
|
|
report.limitations.push(line);
|
|
}
|
|
let mut col = report::Collector::default();
|
|
let settings = settings::map(file, &work.settings, &mut col);
|
|
for loss in col.finish().losses.into_iter().filter(|_| options.settings) {
|
|
let at = report.losses.iter().position(|l| l.category > loss.category).unwrap_or(report.losses.len());
|
|
report.losses.insert(at, loss);
|
|
}
|
|
if !options.allow_losses && !report.is_lossless() {
|
|
return Err(AepImportError::Refused(Box::new(report)));
|
|
}
|
|
work.fix_next_id();
|
|
*project = work;
|
|
Ok(ImportResult { folder, comps, items, report, settings })
|
|
}
|
|
|
|
/// The report an import would produce, without touching any project (a dry run); every font
|
|
/// counts as installed (see [`analyze_with`]).
|
|
///
|
|
/// # Errors
|
|
/// As [`import`], except that losses are never refused.
|
|
pub fn analyze(bytes: &[u8], exists: &dyn Fn(&str) -> bool) -> Result<ImportReport, AepImportError> {
|
|
analyze_with(bytes, &ImportOptions::new(exists))
|
|
}
|
|
|
|
/// The report an import with `options` would produce (losses are always allowed), without touching
|
|
/// any project.
|
|
///
|
|
/// # Errors
|
|
/// As [`import`], except that losses are never refused.
|
|
pub fn analyze_with(bytes: &[u8], options: &ImportOptions<'_>) -> Result<ImportReport, AepImportError> {
|
|
let mut scratch = Project::default();
|
|
let options = ImportOptions { allow_losses: true, ..*options };
|
|
Ok(import(&mut scratch, bytes, &options)?.report)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::container::{AepFile, Body, Chunk, parse, write};
|
|
|
|
/// Asserts the two destination projects are structurally equal and serialize identically (layers,
|
|
/// properties and values included, which `ImportResult` does not carry). Nothing is normalized:
|
|
/// the import is deterministic, ids and timestamps included.
|
|
fn same_project(a: &Project, b: &Project, name: &str) {
|
|
assert!(a == b, "{name}: the imported projects differ through the index");
|
|
let (ja, jb) = (serde_json::to_string(a).ok(), serde_json::to_string(b).ok());
|
|
assert!(ja.is_some() && ja == jb, "{name}: the serialized projects differ through the index");
|
|
}
|
|
|
|
/// Imports `bytes` through the owned chunk tree and through the index: the same project, report
|
|
/// and settings, or the same error.
|
|
fn both(bytes: &[u8], name: &str) {
|
|
let exists = |_: &str| true;
|
|
let options = ImportOptions { allow_losses: true, ..ImportOptions::new(&exists) };
|
|
match (parse(bytes), TreeIndex::parse(bytes)) {
|
|
(Ok(file), Ok(index)) => {
|
|
let (mut pa, mut pb) = (Project::default(), Project::default());
|
|
let a = import_root(&mut pa, Root::from(&file), &options);
|
|
let b = import_root(&mut pb, Root::from(&index), &options);
|
|
assert!(a == b, "{name}: the import differs through the index");
|
|
same_project(&pa, &pb, name);
|
|
}
|
|
(Err(a), Err(b)) => assert_eq!(a, b, "{name}"),
|
|
(a, b) => panic!("{name}: {:?} vs {:?}", a.err(), b.err()),
|
|
}
|
|
}
|
|
|
|
/// A 40-byte NUL-padded match name.
|
|
fn tdmn(name: &str) -> Chunk<'static> {
|
|
let mut b = name.as_bytes().to_vec();
|
|
b.resize(40, 0);
|
|
Chunk::data(*b"tdmn", b)
|
|
}
|
|
|
|
/// A synthetic project imports the same through the index.
|
|
#[test]
|
|
fn a_synthetic_project_imports_the_same_through_the_index() {
|
|
// A composition with a layer whose effect stores arbitrary data and a named value group.
|
|
let utf8 = |id: [u8; 4], t: &str| Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", t.as_bytes().to_vec())]), pad: None };
|
|
let record = Chunk::list(*b"tdbs", vec![Chunk::data(*b"tdsb", vec![0, 0, 0, 1]), Chunk::data(*b"cdat", vec![0, 0, 0, 1])]);
|
|
let values = Chunk::list(
|
|
*b"tdgp",
|
|
vec![
|
|
utf8(*b"tdsn", "Mine"),
|
|
tdmn("ADBE Easy Levels2-0001"),
|
|
record,
|
|
Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![0u8; 108])]),
|
|
tdmn("ADBE Group End"),
|
|
],
|
|
);
|
|
let part = Chunk::list(*b"parT", vec![Chunk::data(*b"parn", vec![0, 0, 0, 2]), tdmn("ADBE Easy Levels2-0001"), Chunk::data(*b"pard", vec![0u8; 148])]);
|
|
let sspc = Chunk::list(*b"sspc", vec![utf8(*b"fnam", "Levels"), part, values]);
|
|
let parade = Chunk::list(*b"tdgp", vec![tdmn("ADBE Easy Levels2"), sspc, tdmn("ADBE Group End")]);
|
|
let root = Chunk::list(*b"tdgp", vec![tdmn("ADBE Effect Parade"), parade, tdmn("ADBE Group End")]);
|
|
let layer = Chunk::list(*b"Layr", vec![Chunk::data(*b"ldta", vec![0u8; 160]), Chunk::data(*b"Utf8", b"Layer".to_vec()), root]);
|
|
let mut idta = vec![0u8; 64];
|
|
idta[..2].copy_from_slice(&4u16.to_be_bytes());
|
|
let mut cdta = vec![0u8; 200];
|
|
cdta[4..12].copy_from_slice(&[0, 0, 0, 1, 0, 0, 0, 30]);
|
|
let item = Chunk::list(*b"Item", vec![Chunk::data(*b"idta", idta), Chunk::data(*b"Utf8", b"Comp".to_vec()), Chunk::data(*b"cdta", cdta), layer]);
|
|
let file = AepFile { chunks: vec![Chunk::data(*b"head", vec![0u8; 20]), Chunk::list(*b"Fold", vec![item])], trailer: std::borrow::Cow::Borrowed(&[]) };
|
|
both(&write(&file).expect("write"), "synthetic");
|
|
}
|
|
|
|
/// Every `.aep` of the fixture directory (skipped without `EFFECTCRAFT_AEP_FIXTURES`).
|
|
#[test]
|
|
fn every_fixture_imports_the_same_through_the_index() {
|
|
let Some(dir) = std::env::var_os("EFFECTCRAFT_AEP_FIXTURES").filter(|d| !d.is_empty()) else { return };
|
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
|
let mut paths: Vec<std::path::PathBuf> =
|
|
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aep")).collect();
|
|
paths.sort();
|
|
for path in paths {
|
|
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
|
both(&bytes, &path.display().to_string());
|
|
}
|
|
}
|
|
|
|
/// Imports an `.aepx` through the owned tree ([`crate::read_aepx`]) and through the index
|
|
/// ([`import`]): the same project, report and settings, or the same error.
|
|
fn both_aepx(bytes: &[u8], name: &str) -> Result<ImportResult, AepImportError> {
|
|
let exists = |_: &str| true;
|
|
let options = ImportOptions { allow_losses: true, ..ImportOptions::new(&exists) };
|
|
let (mut pa, mut pb) = (Project::default(), Project::default());
|
|
let a = crate::read_aepx(bytes).map_err(AepImportError::from).and_then(|file| import_root(&mut pa, Root::from(&file), &options));
|
|
let b = import(&mut pb, bytes, &options);
|
|
assert!(a == b, "{name}: the .aepx import differs through the index");
|
|
same_project(&pa, &pb, name);
|
|
b
|
|
}
|
|
|
|
/// A synthetic `.aepx` imports the same through the index as through the owned tree.
|
|
#[test]
|
|
fn a_synthetic_aepx_imports_the_same_through_the_index() {
|
|
// The comp of the owned-tree test above (`idta` type 4, `cdta` 1/30 s frame duration).
|
|
let idta = format!("0004{}", "00".repeat(62));
|
|
let cdta = format!("00000000000000010000001e{}", "00".repeat(188));
|
|
let x = format!(
|
|
"<?xml version=\"1.0\"?><AfterEffectsProject><head bdata=\"{}\"/><Fold><Item><idta bdata=\"{idta}\"/><string>Comp</string><cdta bdata=\"{cdta}\"/></Item></Fold><ProjectXMPMetadata>x</ProjectXMPMetadata></AfterEffectsProject>",
|
|
"00".repeat(20)
|
|
);
|
|
let imported = both_aepx(x.as_bytes(), "synthetic").expect("the synthetic project imports");
|
|
assert_eq!(imported.comps.len(), 1);
|
|
assert_eq!(both_aepx(b"<AfterEffectsProject/>", "empty").err(), Some(AepImportError::NoProject));
|
|
assert!(matches!(both_aepx(b"<AfterEffectsProject><a>", "broken"), Err(AepImportError::Container(crate::AepError::Xml { .. }))));
|
|
}
|
|
|
|
/// Every `.aepx` of the fixture directory (skipped without `EFFECTCRAFT_AEP_FIXTURES`).
|
|
#[test]
|
|
fn every_fixture_aepx_imports_the_same_through_the_index() {
|
|
let Some(dir) = std::env::var_os("EFFECTCRAFT_AEP_FIXTURES").filter(|d| !d.is_empty()) else { return };
|
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
|
let mut paths: Vec<std::path::PathBuf> =
|
|
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aepx")).collect();
|
|
paths.sort();
|
|
for path in paths {
|
|
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
|
let _ = both_aepx(&bytes, &path.display().to_string());
|
|
}
|
|
}
|
|
}
|