mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 22:33:09 +00:00
In masks, shapes, styles, synth, fxsynth, base, camlight, skeleton, textanim and tree (effects.rs, plan.rs and emit.rs keep their copies for the follow-up work): - New export/keys.rs: the key-time loop (timed_keys), the key-list rewrite (fill_key_list, set_key_list, replace_cdat) and the spatial key record, shared by masks, shapes and styles. - One tdmn decoder (synth::tdmn_name, lossy UTF-8 up to the first NUL, as the importer reads it) and one encoder (synth::tdmn) instead of the copies in masks, styles, shapes, textanim, camlight and skeleton. - One byte writer: tree::put, which errors. base::bytes, synth::tdb4, synth::key_list, fxsynth::pard, masks::shph and the keyed path records write through it; their fixed layouts keep the infallible signatures behind tree::fixed, which logs, and a test builds every layout and requires no error. - camlight::put_group uses the shared group split (tree::split_group), which errors on a match name without a node instead of stopping quietly. Export output is byte-identical on the corpus and the synthetic fixtures.
127 lines
5.3 KiB
Rust
127 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 keys;
|
|
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()
|
|
}
|