Files
effectcraft/crates/aep/src/import.rs
T
vangel76 5f2d88ea5c Merge remote-tracking branch 'upstream/main' into aep-import-lumetri
# Conflicts:
#	crates/aep/src/container/effect_map.rs
#	crates/aep/src/export/effects.rs
#	crates/aep/src/import/effects.rs
#	crates/aep/tests/it/export/effects.rs
#	crates/aep/tests/it/import/effects.rs
#	docs/aep-import.md
2026-11-08 16:32:43 +01:00

160 lines
7.4 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: it is read into the same
//! chunk tree ([`crate::parse_any`] picks the reader by content).
//!
//! 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) 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.
mod build;
mod camera;
mod effects;
mod lumetri;
mod markers;
mod masks;
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};
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> {
let file = crate::parse_any(bytes)?;
let view = crate::Project::new(&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(&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)
}