mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
.aep import: name the After Effects version in the report; 25.x and 24.x copies import like 2026 (#3136)
* AEP import: the report names the After Effects version of the file (#3115) svap holds the version of the After Effects that wrote a project, head bytes 4 to 7 the version the file is written for; Save a Copy As 25.x / 24.x of After Effects 2026 keeps svap and writes the older version into head. AeVersion decodes the packed word (26.2.1x2), AepFile gains file_version, saved_by_version and head_revision, and ImportReport gains aeVersion (fileVersion, savedBy, tested; "unknown" when the file does not say). A version outside 24 to 26 is imported as usual and gets a limitation line, not a loss. The 12 corpus families saved as 2026, 25.x and 24.x copies import to the same project and report; docs/aep-format.md lists what the copies change in the container. The fixture tests are ignored until the corpus holds the files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * AEP import: doc comments on the version tests and helpers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * AEP import docs: tested is a major-version classification from copies saved by After Effects 2026 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * AEP import docs: the importer reports detected losses, not every loss Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * aep tests: compare the imported settings across version copies too Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab3674cc2b
commit
93cce09f3c
9 files changed
+434
-13
No files matched your search
@@ -33,6 +33,7 @@ pub mod style_map;
|
||||
mod textdoc;
|
||||
mod typed;
|
||||
mod values;
|
||||
mod version;
|
||||
mod write;
|
||||
|
||||
pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
|
||||
@@ -54,4 +55,5 @@ pub use textdoc::{
|
||||
};
|
||||
pub use typed::{Comp, Item, ItemKind, KeyLayout, KeyList, Keyframe, Layer, ListHeader, Project, PropEntry, PropGroup, PropNode, Property, TimeBase};
|
||||
pub use values::{Orientation, OrientationKey, TimeRemap, TimeRemapKey, Tracked, Transform, ValueSource};
|
||||
pub use version::AeVersion;
|
||||
pub use write::{write, write_to};
|
||||
@@ -0,0 +1,120 @@
|
||||
//! The After Effects version a project is written for (`head` bytes 4 to 7) and the version of the
|
||||
//! After Effects that wrote it (`svap`).
|
||||
//!
|
||||
//! Both hold the same packed `u32` (see `docs/aep-format.md`, section 1, "Version"). A project saved
|
||||
//! normally has the same word in both; File ▸ Save a Copy As 25.x / 24.x of After Effects 2026 keeps
|
||||
//! `svap` (the 2026 version) and writes the older version into `head`. The XMP packet after the
|
||||
//! container names the application of the first save (`CreatorTool`) and is no guide to either.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::container::bytes::{be_u16, be_u32};
|
||||
use crate::container::chunk::AepFile;
|
||||
|
||||
/// An After Effects version as packed in `head` / `svap`: `26.2.1x2` is major 26, minor 2, bug-fix
|
||||
/// 1, build 2 (the form of the scripting API's `app.version`).
|
||||
///
|
||||
/// Bit layout of the big-endian word (bit 31 first), decoded from the samples (the 2026 word
|
||||
/// `0x0f11_0e02` is the version the saving After Effects reports, `26.2.1x2`; the down-saves give
|
||||
/// `0x0f0b_0661` and `0x0f03_0641`, read as `25.6.0x97` and `24.6.0x65`):
|
||||
///
|
||||
/// | bits | field |
|
||||
/// |---|---|
|
||||
/// | 31-26 | not decoded (`000011` in every sample) |
|
||||
/// | 25-24, 21-19 | major (high two bits, then low three bits) |
|
||||
/// | 23-22 | not decoded (`00` in every sample) |
|
||||
/// | 18-15 | minor |
|
||||
/// | 14-11 | bug-fix |
|
||||
/// | 10-8 | stage (6 in every sample) |
|
||||
/// | 7-0 | build |
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AeVersion {
|
||||
pub major: u8,
|
||||
pub minor: u8,
|
||||
pub bugfix: u8,
|
||||
/// The release stage (6 in every sample; not shown in [`AeVersion::text`]).
|
||||
pub stage: u8,
|
||||
pub build: u8,
|
||||
/// The word as stored.
|
||||
pub raw: u32,
|
||||
}
|
||||
|
||||
impl AeVersion {
|
||||
/// Decodes a stored word; `None` for a word that holds no version (zero, all ones, or major 0:
|
||||
/// After Effects itself refuses a file whose `head` version is zero, "[0.0x0]").
|
||||
pub fn from_word(raw: u32) -> Option<Self> {
|
||||
if raw == 0 || raw == u32::MAX {
|
||||
return None;
|
||||
}
|
||||
let [b0, b1, b2, b3] = raw.to_be_bytes();
|
||||
let major = ((b0 & 0x03) << 3) | ((b1 >> 3) & 0x07);
|
||||
if major == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Self { major, minor: ((b1 & 0x07) << 1) | (b2 >> 7), bugfix: (b2 >> 3) & 0x0f, stage: b2 & 0x07, build: b3, raw })
|
||||
}
|
||||
|
||||
/// `26.2.1x2`.
|
||||
pub fn text(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AeVersion {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}.{}.{}x{}", self.major, self.minor, self.bugfix, self.build)
|
||||
}
|
||||
}
|
||||
|
||||
impl AepFile<'_> {
|
||||
/// The version the file is written for: `head` bytes 4 to 7. After Effects 2026 writes its own
|
||||
/// version there, and the older version for File ▸ Save a Copy As 25.x / 24.x. `None` when the
|
||||
/// chunk is missing, short or holds no version.
|
||||
pub fn file_version(&self) -> Option<AeVersion> {
|
||||
AeVersion::from_word(be_u32(self.chunk(b"head")?.bytes()?, 4)?)
|
||||
}
|
||||
|
||||
/// The version of the After Effects that wrote the file: `svap` bytes 0 to 3 (the same word in a
|
||||
/// down-saved copy as in a normal save). `None` when the chunk is missing (After Effects opens such
|
||||
/// a file), short or holds no version.
|
||||
pub fn saved_by_version(&self) -> Option<AeVersion> {
|
||||
AeVersion::from_word(be_u32(self.chunk(b"svap")?.bytes()?, 0)?)
|
||||
}
|
||||
|
||||
/// The `u16` at `head` byte 0: 97 (`0x61`) in a 2026 file, 96 in a copy saved as 25.x, 95 as
|
||||
/// 24.x. It steps with the major version of [`AepFile::file_version`]; what it counts is not
|
||||
/// known, so nothing is derived from it.
|
||||
pub fn head_revision(&self) -> Option<u16> {
|
||||
be_u16(self.chunk(b"head")?.bytes()?, 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AeVersion;
|
||||
|
||||
#[test]
|
||||
fn decodes_the_sampled_words() {
|
||||
let v = AeVersion::from_word(0x0f11_0e02).unwrap();
|
||||
assert_eq!((v.major, v.minor, v.bugfix, v.stage, v.build), (26, 2, 1, 6, 2));
|
||||
assert_eq!(v.text(), "26.2.1x2");
|
||||
assert_eq!(AeVersion::from_word(0x0f0b_0661).unwrap().text(), "25.6.0x97");
|
||||
assert_eq!(AeVersion::from_word(0x0f03_0641).unwrap().text(), "24.6.0x65");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_without_a_version_is_none() {
|
||||
assert_eq!(AeVersion::from_word(0), None);
|
||||
assert_eq!(AeVersion::from_word(u32::MAX), None);
|
||||
// Major 0 (only the undecoded high bits set).
|
||||
assert_eq!(AeVersion::from_word(0x0c00_0000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_takes_its_extremes() {
|
||||
let v = AeVersion::from_word(0x03ff_ffff).unwrap();
|
||||
assert_eq!((v.major, v.minor, v.bugfix, v.stage, v.build), (31, 15, 15, 7, 255));
|
||||
let v = AeVersion::from_word(0x0000_0800 | 0x0008_0000).unwrap();
|
||||
assert_eq!((v.major, v.minor, v.bugfix, v.build), (1, 0, 1, 0));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ mod time;
|
||||
use effectcraft_project::{ItemId, Project, ProjectSettings};
|
||||
|
||||
pub use effects::supported_effects;
|
||||
pub use report::{ImportReport, Imported, LIMITATIONS, Loss, LossCategory, MAX_DETAILS};
|
||||
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)]
|
||||
@@ -118,6 +118,10 @@ pub fn import(project: &mut Project, bytes: &[u8], options: &ImportOptions<'_>)
|
||||
// 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) {
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::container::AepFile;
|
||||
|
||||
/// Most example lines kept per loss category (the count is always exact).
|
||||
pub const MAX_DETAILS: usize = 20;
|
||||
|
||||
@@ -177,6 +179,65 @@ pub struct ImportReport {
|
||||
pub missing_fonts: Vec<String>,
|
||||
/// What the file reader cannot see at all, so it can be neither imported nor counted.
|
||||
pub limitations: Vec<String>,
|
||||
/// The After Effects version the file is written for and the one that wrote it.
|
||||
#[serde(default)]
|
||||
pub ae_version: AeVersionInfo,
|
||||
}
|
||||
|
||||
/// The After Effects versions the importer is tested with (the major of the version a file is
|
||||
/// written for): files written for 24.x, 25.x and 26.x.
|
||||
pub const TESTED_MAJORS: std::ops::RangeInclusive<u8> = 24..=26;
|
||||
|
||||
/// The After Effects versions of a file, decoded from the container (never from the XMP packet,
|
||||
/// which names the application of the first save).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AeVersionInfo {
|
||||
/// The version the file is written for (`head`): `26.2.1x2` for a file saved by After Effects
|
||||
/// 26.2.1, `25.6.0x97` for a copy that After Effects 2026 saved as 25.x. `unknown` when the file
|
||||
/// does not say.
|
||||
pub file_version: String,
|
||||
/// The version of the After Effects that wrote the file (`svap`): `26.2.1x2` for that copy too.
|
||||
/// `unknown` when the file does not say.
|
||||
pub saved_by: String,
|
||||
/// Whether the importer is tested with files written for this version ([`TESTED_MAJORS`]).
|
||||
/// When not, what is read is still imported, and a line in
|
||||
/// [`ImportReport::limitations`] says the version is not tested.
|
||||
pub tested: bool,
|
||||
}
|
||||
|
||||
impl Default for AeVersionInfo {
|
||||
fn default() -> Self {
|
||||
Self { file_version: UNKNOWN.to_string(), saved_by: UNKNOWN.to_string(), tested: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a version the file does not state.
|
||||
pub const UNKNOWN: &str = "unknown";
|
||||
|
||||
impl AeVersionInfo {
|
||||
/// Reads the versions of a parsed file.
|
||||
pub fn of(file: &AepFile<'_>) -> Self {
|
||||
let file_version = file.file_version();
|
||||
Self {
|
||||
file_version: file_version.map_or_else(|| UNKNOWN.to_string(), |v| v.text()),
|
||||
saved_by: file.saved_by_version().map_or_else(|| UNKNOWN.to_string(), |v| v.text()),
|
||||
tested: file_version.is_some_and(|v| TESTED_MAJORS.contains(&v.major)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The limitation line for a version that is not tested; `None` for a tested one.
|
||||
pub fn limitation(&self) -> Option<String> {
|
||||
(!self.tested).then(|| {
|
||||
format!(
|
||||
"The file is written for After Effects {} (saved by {}); the importer is tested with files written for After Effects {} to {} only. What it reads is imported, but this version is not tested.",
|
||||
self.file_version,
|
||||
self.saved_by,
|
||||
TESTED_MAJORS.start(),
|
||||
TESTED_MAJORS.end()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportReport {
|
||||
@@ -227,6 +288,7 @@ impl Collector {
|
||||
missing_footage: self.missing_footage,
|
||||
missing_fonts: self.missing_fonts,
|
||||
limitations: LIMITATIONS.iter().map(|s| (*s).to_string()).collect(),
|
||||
ae_version: AeVersionInfo::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ pub mod export;
|
||||
pub mod import;
|
||||
|
||||
pub use container::{
|
||||
AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CueType, CurvesData, DEFAULT_CAMERA_ZOOM,
|
||||
DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, GradientType, HueSaturationData, Interpolation, Item,
|
||||
ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader, LayerRef, LayerType, LevelsData,
|
||||
LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation, OrientationKey, ParagraphRun, ParamKind,
|
||||
ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality, Rational, Renderer, SequenceInfo,
|
||||
ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox, TextDocument, TextValue, TimeBase,
|
||||
TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut, effect_map, is_aepx, parse, parse_any,
|
||||
parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to,
|
||||
AeVersion, AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CueType, CurvesData,
|
||||
DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, GradientType, HueSaturationData,
|
||||
Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader, LayerRef, LayerType,
|
||||
LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation, OrientationKey, ParagraphRun,
|
||||
ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality, Rational, Renderer,
|
||||
SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox, TextDocument, TextValue,
|
||||
TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut, effect_map, is_aepx, parse,
|
||||
parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to,
|
||||
};
|
||||
@@ -27,3 +27,4 @@ mod synthetic;
|
||||
mod text;
|
||||
mod text_anim;
|
||||
mod timing;
|
||||
mod versions;
|
||||
@@ -0,0 +1,181 @@
|
||||
//! The After Effects version of a file (`head`, `svap`) in the import report, and files that After
|
||||
//! Effects 2026 saved as copies for 25.x and 24.x (issue #3115).
|
||||
//!
|
||||
//! The fixture-free tests edit the committed `synthetic.aep` (saved by 26.2.1x2). The fixture tests
|
||||
//! read, per family of the corpus, the same-session 2026 save `<family>-26.aep` and its copies
|
||||
//! `<family>-25x.aep` (File ▸ Save a Copy As 25.x) and `<family>-24x.aep` (24.x) from
|
||||
//! `EFFECTCRAFT_AEP_FIXTURES`; they are ignored until the corpus holds those files.
|
||||
|
||||
use effectcraft_aep::import::{AeVersionInfo, ImportOptions, ImportReport, ImportResult, TESTED_MAJORS, UNKNOWN, analyze, import};
|
||||
use effectcraft_aep::{AepFile, Body, Chunk, parse, write};
|
||||
use effectcraft_project::Project;
|
||||
|
||||
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
|
||||
|
||||
/// The project with `edit` applied to every top-level chunk.
|
||||
fn mutate(edit: &mut dyn FnMut(&mut Chunk<'_>)) -> Vec<u8> {
|
||||
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
|
||||
for c in &mut file.chunks {
|
||||
edit(c);
|
||||
}
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// `synthetic.aep` with `head` bytes 4 to 7 replaced by `word`.
|
||||
fn with_head_word(word: u32) -> Vec<u8> {
|
||||
mutate(&mut |c| {
|
||||
if c.is(b"head")
|
||||
&& let Body::Data(d) = &mut c.body
|
||||
{
|
||||
d.to_mut().get_mut(4..8).unwrap().copy_from_slice(&word.to_be_bytes());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The report of an analysis of `bytes` (no footage on disk).
|
||||
fn report(bytes: &[u8]) -> ImportReport {
|
||||
analyze(bytes, &|_| false).unwrap()
|
||||
}
|
||||
|
||||
/// The limitation lines about an untested version.
|
||||
fn version_limitations(r: &ImportReport) -> Vec<&String> {
|
||||
r.limitations.iter().filter(|l| l.contains("is not tested")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_names_the_version_the_file_is_written_for_and_saved_by() {
|
||||
let r = report(SYNTHETIC);
|
||||
assert_eq!(r.ae_version, AeVersionInfo { file_version: "26.2.1x2".into(), saved_by: "26.2.1x2".into(), tested: true });
|
||||
assert!(version_limitations(&r).is_empty());
|
||||
let json = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(json["aeVersion"]["fileVersion"], "26.2.1x2");
|
||||
assert_eq!(json["aeVersion"]["savedBy"], "26.2.1x2");
|
||||
assert_eq!(json["aeVersion"]["tested"], true);
|
||||
}
|
||||
|
||||
/// A copy saved for 25.x or 24.x reports that version and the 2026 writer, and counts as tested.
|
||||
#[test]
|
||||
fn a_copy_saved_for_an_older_version_names_both() {
|
||||
// The words of the copies After Effects 2026 saved as 25.x and 24.x; `svap` stays 2026's.
|
||||
for (word, text) in [(0x0f0b_0661, "25.6.0x97"), (0x0f03_0641, "24.6.0x65")] {
|
||||
let r = report(&with_head_word(word));
|
||||
assert_eq!(r.ae_version.file_version, text);
|
||||
assert_eq!(r.ae_version.saved_by, "26.2.1x2");
|
||||
assert!(r.ae_version.tested);
|
||||
assert!(version_limitations(&r).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untested_version_is_imported_and_said_to_be_untested() {
|
||||
// 23.6.0x97: major 23 (bits 25-24 = 10, bits 21-19 = 111).
|
||||
let bytes = with_head_word(0x0e3b_0661);
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
// Not a loss: the losses are those of the unedited file.
|
||||
let r: ImportResult = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
assert_eq!(r.report.losses, report(SYNTHETIC).losses);
|
||||
assert_eq!(r.report.ae_version.file_version, "23.6.0x97");
|
||||
assert!(!r.report.ae_version.tested);
|
||||
assert!(!TESTED_MAJORS.contains(&23));
|
||||
let lines = version_limitations(&r.report);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].contains("After Effects 23.6.0x97 (saved by 26.2.1x2)"), "{}", lines[0]);
|
||||
assert!(!r.comps.is_empty());
|
||||
}
|
||||
|
||||
/// A missing, short or empty version word reads as `unknown` and the file still imports.
|
||||
#[test]
|
||||
fn a_file_without_a_version_reads_as_unknown_and_imports() {
|
||||
// A zero word (After Effects itself refuses it, "[0.0x0]") and all ones.
|
||||
for word in [0, u32::MAX] {
|
||||
let r = report(&with_head_word(word));
|
||||
assert_eq!(r.ae_version.file_version, UNKNOWN);
|
||||
assert!(!r.ae_version.tested);
|
||||
assert_eq!(version_limitations(&r).len(), 1);
|
||||
}
|
||||
// No `svap` (After Effects opens such a file): the id is renamed, the size kept.
|
||||
let bytes = mutate(&mut |c| {
|
||||
if c.is(b"svap") {
|
||||
c.id = *b"xsvp";
|
||||
}
|
||||
});
|
||||
let r = report(&bytes);
|
||||
assert_eq!(r.ae_version.saved_by, UNKNOWN);
|
||||
assert_eq!(r.ae_version.file_version, "26.2.1x2");
|
||||
// A `head` too short for the word, and none at all.
|
||||
for keep in [Some(5usize), None] {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if c.is(b"head") {
|
||||
match keep {
|
||||
Some(n) => {
|
||||
if let Body::Data(d) = &mut c.body {
|
||||
d.to_mut().truncate(n);
|
||||
}
|
||||
}
|
||||
None => c.id = *b"xhed",
|
||||
}
|
||||
}
|
||||
});
|
||||
let r = report(&bytes);
|
||||
assert_eq!(r.ae_version, AeVersionInfo { file_version: UNKNOWN.into(), saved_by: "26.2.1x2".into(), tested: false });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_without_the_field_still_reads() {
|
||||
let mut json = serde_json::to_value(report(SYNTHETIC)).unwrap();
|
||||
json.as_object_mut().unwrap().remove("aeVersion");
|
||||
let r: ImportReport = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(r.ae_version, AeVersionInfo::default());
|
||||
assert_eq!(r.ae_version.file_version, UNKNOWN);
|
||||
}
|
||||
|
||||
/// The corpus families saved three ways.
|
||||
const FAMILIES: &[&str] =
|
||||
&["1200-lumetri", "3d", "effects", "footage-precomp", "keyframes", "layers-comps", "markers", "masks", "settings-a", "shapes", "styles", "text"];
|
||||
|
||||
/// The import of a file: the project as JSON, the report and the parsed versions.
|
||||
fn import_file(name: &str) -> Option<(serde_json::Value, ImportReport, effectcraft_project::ProjectSettings)> {
|
||||
let bytes = crate::common::try_file(name)?;
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let opts = ImportOptions { allow_losses: true, ..ImportOptions::new(&none) };
|
||||
let r = import(&mut project, &bytes, &opts).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
Some((serde_json::to_value(&project).unwrap(), r.report, r.settings))
|
||||
}
|
||||
|
||||
/// Per corpus family, the 25.x and 24.x copies import to the same project and report as the 2026 save.
|
||||
#[test]
|
||||
#[ignore = "needs the version fixtures of the corpus (<family>-26/-25x/-24x.aep)"]
|
||||
fn copies_saved_as_25x_and_24x_import_like_the_2026_file() {
|
||||
for family in FAMILIES {
|
||||
let Some((project_26, report_26, settings_26)) = import_file(&format!("{family}-26.aep")) else { continue };
|
||||
assert_eq!(report_26.ae_version, AeVersionInfo { file_version: "26.2.1x2".into(), saved_by: "26.2.1x2".into(), tested: true }, "{family}");
|
||||
for (suffix, version) in [("25x", "25.6.0x97"), ("24x", "24.6.0x65")] {
|
||||
let name = format!("{family}-{suffix}.aep");
|
||||
let Some((project, report, settings)) = import_file(&name) else { continue };
|
||||
assert_eq!(report.ae_version, AeVersionInfo { file_version: version.into(), saved_by: "26.2.1x2".into(), tested: true }, "{name}");
|
||||
assert_eq!(project, project_26, "{name}: the imported project differs from the 2026 file's");
|
||||
assert_eq!(settings, settings_26, "{name}: the imported settings differ from the 2026 file's");
|
||||
let same_but_version = ImportReport { ae_version: report_26.ae_version.clone(), ..report };
|
||||
assert_eq!(same_but_version, report_26, "{name}: the report differs from the 2026 file's");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per corpus family, `head` holds the version each copy is written for and `svap` the writer.
|
||||
#[test]
|
||||
#[ignore = "needs the version fixtures of the corpus (<family>-26/-25x/-24x.aep)"]
|
||||
fn the_container_of_a_copy_states_its_version_in_head_and_the_writer_in_svap() {
|
||||
for family in FAMILIES {
|
||||
for (suffix, version, revision) in [("26", "26.2.1x2", 97), ("25x", "25.6.0x97", 96), ("24x", "24.6.0x65", 95)] {
|
||||
let name = format!("{family}-{suffix}.aep");
|
||||
let Some(bytes) = crate::common::try_file(&name) else { continue };
|
||||
let file = parse(&bytes).unwrap();
|
||||
assert_eq!(file.file_version().map(|v| v.text()).as_deref(), Some(version), "{name}");
|
||||
assert_eq!(file.saved_by_version().map(|v| v.text()).as_deref(), Some("26.2.1x2"), "{name}");
|
||||
assert_eq!(file.head_revision(), Some(revision), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -1,6 +1,6 @@
|
||||
# After Effects project format (`.aep`)
|
||||
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-05 · **Change:** versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026
|
||||
|
||||
What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence
|
||||
for each statement. Everything here was observed on project files we generated ourselves; no
|
||||
@@ -124,6 +124,31 @@ counterpart (a name that is not 1 to 4 characters) are skipped with their conten
|
||||
inputs; `crates/aep/tests/it/import/aepx.rs` imports both forms of each pair and expects the same
|
||||
project and report. An ignored test runs the comparison over the whole local corpus.
|
||||
|
||||
### Versions (`head`, `svap`) and files saved for 25.x and 24.x
|
||||
|
||||
Evidence: 12 families of the corpus (`layers-comps`, `footage-precomp`, `masks`, `shapes`, `text`,
|
||||
`effects`, `3d`, `keyframes`, `markers`, `styles`, `settings-a`, `1200-lumetri`), each saved three
|
||||
ways by After Effects 2026 (26.2.1x2) in one session: a normal save (`-26`), File ▸ Save a Copy As
|
||||
25.x (`-25x`) and Save a Copy As 24.x (`-24x`), 36 files. All of them are copies made by 2026; no
|
||||
file written by After Effects 24 or 25 itself was examined, so nothing below says what those versions
|
||||
write on their own.
|
||||
|
||||
| Fact | Evidence |
|
||||
|---|---|
|
||||
| `svap` (4 bytes, the first top-level chunk) is the version of the After Effects that **wrote** the file: `0f 11 0e 02` in all 36 (a down-saved copy keeps the 2026 word) and in all 1 192 other `.aep` files of the local corpus folders. After Effects opens a file without `svap` (`t-nosvap`). | All 36; corpus scan. |
|
||||
| `head` bytes 4 to 7 are the version the file is **written for**: `0f 11 0e 02` (= `svap`) in the 2026 saves, `0f 0b 06 61` in the 25.x copies, `0f 03 06 41` in the 24.x copies, the same word in every family. After Effects refuses a file whose word is zero ("created with an older version [0.0x0]", `u-zhead`). | All 36; `u-zhead`. |
|
||||
| The word is packed (bit 31 first): bits 25-24 and 21-19 the major, 18-15 the minor, 14-11 the bug-fix, 10-8 a stage (6 in all), 7-0 the build; bits 31-26 (`000011`) and 23-22 (`00`) are the same in all samples and not decoded. `0f110e02` = **26.2.1x2**, which is what the saving After Effects reports for itself (`app.version` recorded by the corpus scripts) - this one sample fixes minor, bug-fix and build. The copies read as **25.6.0x97** and **24.6.0x65** (major **V** from three consecutive majors; minor, bug-fix and build of the copies **E**: one other word decides their positions). `effectcraft_aep::AeVersion`, `AepFile::file_version`, `AepFile::saved_by_version`. | Tests `decodes_the_sampled_words`, `the_container_of_a_copy_states_its_version_in_head_and_the_writer_in_svap`. |
|
||||
| `head` bytes 0 to 1 (a `u16`) step with the major: 97 (`0x61`) in the 2026 saves, 96 in 25.x, 95 in 24.x; bytes 2 to 3 are 5, 9 and 6 (**?**). Bytes 8 to 15 differ between families, not between versions; bytes 16 to 19 are the save counter (one higher in each copy). Nothing is derived from bytes 0 to 3 (`AepFile::head_revision` exposes the `u16`). | All 36. |
|
||||
| The XMP packet after the container is no guide: its `CreatorTool` is "Adobe After Effects 2026" in the copies too. | All 36. |
|
||||
| **26 against 25.x** (per family, chunk tree aligned by item and layer id): besides `head`, `nhed` / `nnhd` differ in 6 bytes (`nhed` 20-23 and 26-27: `827fa930` / `0015` in the 2026 save, `00000000` / `011b` in both copies; session state, see section 4). Every **solid's** `Pin /CLRS` changes: the 2026 save has `dcui` (1 byte, 1), `apid` all `0xff` and an empty `Utf8`; the copies drop `dcui`, set `apid` to a GUID (`601e9973...`, the same in all) and embed a 935-byte JSON with an ICC profile (`baseColorProfile/colorProfileData`). A **footage** item that the 2026 save stores with an assigned profile (`mcsp` = 1 and a profile JSON) loses `mcsp` and that JSON in the copies, and its `epid` becomes all `0xff`. The rest is save noise: the footage alias `ascendcount_base` (the copies were saved in another folder), `sspc` byte 112 of a footage item (a modification time), the `oEXR` `opti` of an EXR item (the copies hold the display device name `\.\DISPLAY1` as UTF-16 at byte 126 and, in the 24.x copy, a few non-zero bytes from byte 2 102 that look like uninitialised memory; the 2026 save has zeros), the plug-ins' sequence data `sdat` of Lumetri Color (16 instances and the project's `EfdG`), the mask counter `mkif` byte 8. No chunk is added or reordered, and no `pard`, `cdat`, `ldta`, `cdta`, `tdb4`, keyframe, text or shape chunk differs. | All 12 families. |
|
||||
| **25.x against 24.x**: only `head` (bytes 1, 3, 5 and 7), and the session noise in `opti` and `sdat` above. | All 12 families. |
|
||||
| Lumetri Color in the copies has the parameter definitions (`pard`, valid ranges included) of the 2026 plug-in, byte for byte. | `1200-lumetri`. |
|
||||
|
||||
The importer reads none of the chunks that change (it reads the solid colour from `opti`, not
|
||||
`CLRS`), so the copies import to the same project and report as the 2026 save
|
||||
([aep-import.md](aep-import.md), "What is reported"; test
|
||||
`copies_saved_as_25x_and_24x_import_like_the_2026_file`).
|
||||
|
||||
## 2. Typed view (`Project`, `Item`, `Comp`, `Layer`, `PropGroup`, `Property`, `KeyList`)
|
||||
|
||||
The structure the typed view is built on; each row has a test in `crates/aep/tests/it/container/typed.rs` against
|
||||
@@ -819,7 +844,7 @@ another value).
|
||||
- Bytes 0 (**?**), 16 (600: **H**, the 1/600 s legacy time base), 24, 32, 40 (cache-like: they hold values the session used earlier, do not use them: **E**), 48 (**E**), 176 and 184 (360 in all files: **?**, probably the maximum shutter angle), 196 and 200 (128 and 16: **?**).
|
||||
- Not sampled: a 3840 x 2160 composition, other pixel aspect fractions (1.2121, 1.4587), the preserve-frame-rate and preserve-resolution switches, draft 3D, `Hide shy layers`, frame blending, the 8-versus-16-bit project depth with a coloured background, the display start at 30 fps and with drop frame (whether the denominator at 168 is always the timescale).
|
||||
- `PRin/prda` (12 bytes for Classic 3D, 52 for Advanced 3D: renderer parameters, **E**).
|
||||
- Item `idta` byte 23 (0x20 composition and sequences or clips, 0x30 stills and solids, 4 audio, 0 folder; needed: zeroed, renders differ) and `idpc` (**V** in the corpus: 1 when the composition contains a nested composition), `head`, `nhed`, `nnhd`, `Ewst`, the `f*` records, `Gide`, `PTRE`, `LRdr` and `LSIf` bodies. `head` bytes 16 to 19 are the save counter (a big-endian `u32`; only its low byte changed between most saves, `314` shows the carry) and are the only bytes of the container that change in a Save As without an edit; item `idta` bytes 80 to 83 are the modification time (seconds since 1904-01-01); `nhed` / `nnhd` hold the id of the After Effects session (six sessions seen); `mkif` byte 8 is a running mask counter; `pgui` chunks are random GUIDs. All of that is edit-history or session state (**V** in the corpus) and is not decoded, except the mask counter.
|
||||
- Item `idta` byte 23 (0x20 composition and sequences or clips, 0x30 stills and solids, 4 audio, 0 folder; needed: zeroed, renders differ) and `idpc` (**V** in the corpus: 1 when the composition contains a nested composition), `head`, `nhed`, `nnhd`, `Ewst`, the `f*` records, `Gide`, `PTRE`, `LRdr` and `LSIf` bodies. `head` bytes 4 to 7 are the version the file is written for and `svap` the version that wrote it (section 1, "Versions"); `head` bytes 16 to 19 are the save counter (a big-endian `u32`; only its low byte changed between most saves, `314` shows the carry) and are the only bytes of the container that change in a Save As without an edit; item `idta` bytes 80 to 83 are the modification time (seconds since 1904-01-01); `nhed` / `nnhd` hold the id of the After Effects session (six sessions seen); `mkif` byte 8 is a running mask counter; `pgui` chunks are random GUIDs. All of that is edit-history or session state (**V** in the corpus) and is not decoded, except the mask counter.
|
||||
|
||||
**Properties and keyframes**
|
||||
|
||||
|
||||
+28
-2
@@ -1,6 +1,6 @@
|
||||
# After Effects project import
|
||||
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **TaLine truncated
|
||||
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-05 · **Change:** the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers Line truncated
|
||||
|
||||
File ▸ Import ▸ After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx`
|
||||
and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project).
|
||||
@@ -267,7 +267,7 @@ parameter gets the file's value:
|
||||
|
||||
`ImportReport` (`serde`, returned as JSON) has `imported` (counts, including `textLayers`),
|
||||
`losses` (per category: `count` exact, up to 20 `details`), `missingFootage`, `missingFonts`
|
||||
(PostScript names; not losses) and `limitations`. The categories:
|
||||
(PostScript names; not losses), `limitations` and `aeVersion`. The categories:
|
||||
|
||||
| Category | Counted |
|
||||
|---|---|
|
||||
@@ -291,6 +291,32 @@ parameter gets the file's value:
|
||||
`limitations` states what the reader cannot see at all, so it can be neither imported nor counted:
|
||||
frame blending, 3D material options, audio levels, guides, the mask colour, lock, feather falloff, motion blur and RotoBezier switches, and for text per-character 3D, text on a path and style slots of unknown meaning (stored values of these that differ from the default are reported as text layer features). They are listed in every report.
|
||||
|
||||
`aeVersion` names the After Effects versions of the file, decoded from the container
|
||||
([aep-format.md](aep-format.md), section 1, "Versions"), never from the XMP packet (whose
|
||||
`CreatorTool` names the application of the first save, "Adobe After Effects 2026" even in a copy saved
|
||||
for 24.x):
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `fileVersion` | the version the file is written for (`head`): `26.2.1x2` for a file After Effects 26.2.1 saved, `25.6.0x97` / `24.6.0x65` for a copy it saved with File > Save a Copy As 25.x / 24.x; `unknown` when the chunk is missing, short or holds no version |
|
||||
| `savedBy` | the version of the After Effects that wrote the file (`svap`): `26.2.1x2` for those copies too; `unknown` likewise |
|
||||
| `tested` | whether the major of `fileVersion` is one the importer is tested with: 24, 25 and 26 (`TESTED_MAJORS`). A major-version classification only: the 24.x and 25.x samples are copies saved by After Effects 2026 (Save a Copy As), not files authored by those versions, and it does not guarantee that every older plug-in definition was verified |
|
||||
|
||||
A file written for another version (or an unknown one) is imported like any other: the importer
|
||||
reports the losses it detects and lists its general limitations (`limitations`), but it does not
|
||||
claim a complete import, since some content is skipped (environment lights, for one) and some
|
||||
features cannot be detected or counted (frame blending, for one). It is not refused for its version (the version is not a
|
||||
loss); `limitations` gets one more line saying that the version is not tested.
|
||||
|
||||
**Files written for 25.x and 24.x.** Every family of the corpus (12, including the Lumetri samples
|
||||
`1200`) saved by After Effects 2026 and copied with Save a Copy As 25.x and 24.x imports to the same
|
||||
project and the same report as the 2026 save, apart from `aeVersion`
|
||||
(`crates/aep/tests/it/import/versions.rs`; the fixture tests are ignored until the corpus holds the
|
||||
files). What the copies change in the container is not read by the importer (aep-format.md, section 1).
|
||||
These copies are written by After Effects 2026, so they show how 2026 writes an older file, not what
|
||||
After Effects 24 or 25 themselves write: an effect's parameter definitions, for example (the valid
|
||||
ranges of Lumetri Color among them), are those of the 2026 plug-in in all three files.
|
||||
|
||||
## How fidelity is checked
|
||||
|
||||
- `crates/aep/tests/it/import/aepx.rs`: the `.aepx` of five fixtures imports to the same project and
|
||||
|
||||
Reference in new issue
Block a user