Files
effectcraft/crates/aep/src/export.rs
T
Valentin Klein ce9eaec650 Add After Effects project export (#2594)
Add After Effects project export with chunks built in code, explicit loss reporting, engine and UI integration, and regression coverage.
2026-11-06 09:23:16 +01:00

126 lines
5.3 KiB
Rust

//! After Effects project (`.aep`) export: an EffectCraft [`Project`] written as an `.aep` that
//! After Effects 2026 opens.
//!
//! Every chunk is built in code: the project skeleton, the item, layer and property records are
//! prototypes in the layout After Effects writes (`template.rs` gathers them), and the exporter
//! clones them and writes only the fields whose layout is verified (`docs/aep-format.md` section 3, `docs/aep-export.md`):
//! item and layer ids, project settings, the composition header, the layer header (switches
//! including 3D, time stretch, blend modes, track mattes), solid colour and size, the Transform
//! properties (static values, keyframes with interpolation and ease, expressions), layer masks,
//! markers, time remapping, precomposition, footage, text (with animators), shape, camera and
//! light layers, footage items and image sequences, layer styles, and the effects of the table
//! shared with the importer (`crate::effect_map`). `docs/aep-export.md` has the full
//! list and the rules After Effects needs.
//!
//! What cannot be written is **reported**, never dropped silently ([`ExportReport`]); by default an
//! export that would lose anything is refused ([`ExportOptions::allow_losses`]).
//!
//! The crate sits at L3 (above `effectcraft-project`, next to the importer) and builds for wasm32:
//! it does no file access, the caller gets the bytes.
mod base;
mod camlight;
mod effects;
mod emit;
mod footage;
mod fx_defs;
mod fxsynth;
pub(crate) mod icc;
mod markers;
mod masks;
mod plan;
mod report;
mod settings;
mod shapes;
mod skeleton;
mod style_defs;
mod styles;
mod synth;
mod template;
mod text;
mod textanim;
mod textdoc;
mod textproto;
mod timebase;
mod tree;
mod tree_defs;
mod tree_spec;
use effectcraft_project::Project;
pub use report::{ExportReport, Exported, LIMITATIONS, Loss, LossCategory, MAX_DETAILS};
/// Why an `.aep` was not written.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum AepExportError {
/// Parts of the project cannot be exported and losses were not allowed. Nothing was written;
/// the report says what would be lost.
#[error("exporting would lose {}; allow losses to export anyway", .0.summary())]
Refused(Box<ExportReport>),
/// A prototype built in code does not have the shape the exporter expects (a bug in the crate).
#[error("internal error: {0}")]
Internal(&'static str),
/// The container could not be written (a chunk or the file exceeds the format's 4 GB limit).
#[error("{0}")]
Container(#[from] crate::AepError),
}
/// Options of [`export`].
#[derive(Clone, Copy, Debug, Default)]
pub struct ExportOptions {
/// Export even when [`ExportReport::losses`] is not empty.
pub allow_losses: bool,
}
/// An exported project.
#[derive(Clone, Debug, PartialEq)]
pub struct ExportResult {
/// The `.aep` file.
pub bytes: Vec<u8>,
pub report: ExportReport,
}
/// A neutral XMP packet for the end of the file (After Effects writes one; it only holds metadata).
const XMP: &[u8] = b"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n <dc:format>application/vnd.adobe.aftereffects.project</dc:format>\n </rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
/// Exports `project` as an `.aep`.
///
/// # Errors
/// [`AepExportError::Refused`] when something would be lost and `allow_losses` is not set (nothing
/// is written), [`AepExportError::Container`] for a file beyond the container's size limits.
pub fn export(project: &Project, options: &ExportOptions) -> Result<ExportResult, AepExportError> {
export_with(project, options, &|_| None)
}
/// [`export`] with the modification times of the image-sequence folders: `folder_time` is asked
/// with a sequence's first file path as the project stores it and answers with the modification
/// time of the folder that holds it, in whole seconds since 1970 (UTC), or `None`. The caller
/// decides whether that path may be followed (the engine applies its document-path policy) before
/// it looks at the folder.
///
/// After Effects keeps a sequence's stored numbering only while the stored folder time
/// (`sspc@118`) equals the folder's; otherwise it scans the folder again when the project opens
/// and warns about every gap in the numbering in a modal dialog (860, `export-tests` x1-x3).
/// Without a time it is written as 0, and such a folder is scanned again.
///
/// # Errors
/// As [`export`].
pub fn export_with(project: &Project, options: &ExportOptions, folder_time: &dyn Fn(&str) -> Option<u64>) -> Result<ExportResult, AepExportError> {
let mut col = report::Collector::default();
let mut plan = plan::plan(project, &mut col);
let report = col.finish();
if !options.allow_losses && !report.is_lossless() {
return Err(AepExportError::Refused(Box::new(report)));
}
footage::stamp_sequences(&mut plan.root, folder_time);
let bytes = emit::emit(&plan, XMP)?;
Ok(ExportResult { bytes, report })
}
/// The report an export would produce, without writing anything (a dry run).
pub fn analyze(project: &Project) -> ExportReport {
let mut col = report::Collector::default();
let _ = plan::plan(project, &mut col);
col.finish()
}