.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:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-11-06 17:36:41 +01:00
1 parent ab3674cc2b
commit 93cce09f3c
9 files changed
+434 -13

No files matched your search

+2
View File
@@ -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};
+120
View File
@@ -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));
}
}
+5 -1
View File
@@ -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) {
+62
View File
@@ -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(),
}
}
}
+8 -8
View File
@@ -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,
};
+1
View File
@@ -27,3 +27,4 @@ mod synthetic;
mod text;
mod text_anim;
mod timing;
mod versions;
+181
View File
@@ -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
View File
@@ -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
View File
@@ -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