diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 4aa0f393a..40da07178 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -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}; diff --git a/crates/aep/src/container/version.rs b/crates/aep/src/container/version.rs new file mode 100644 index 000000000..bf3fbf4c4 --- /dev/null +++ b/crates/aep/src/container/version.rs @@ -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 { + 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::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::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 { + 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)); + } +} diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index 1ffe3ec48..956e3b1c5 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -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) { diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 4c85288b7..129fcf143 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -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, /// What the file reader cannot see at all, so it can be neither imported nor counted. pub limitations: Vec, + /// 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 = 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 { + (!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(), } } } diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs index 8120fa0d6..19fe273a9 100644 --- a/crates/aep/src/lib.rs +++ b/crates/aep/src/lib.rs @@ -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, }; diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 178dd2bb3..3f3311588 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -27,3 +27,4 @@ mod synthetic; mod text; mod text_anim; mod timing; +mod versions; diff --git a/crates/aep/tests/it/import/versions.rs b/crates/aep/tests/it/import/versions.rs new file mode 100644 index 000000000..cac6a9a6b --- /dev/null +++ b/crates/aep/tests/it/import/versions.rs @@ -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 `-26.aep` and its copies +//! `-25x.aep` (File ▸ Save a Copy As 25.x) and `-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 { + 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 { + 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 (-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 (-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}"); + } + } +} diff --git a/docs/aep-format.md b/docs/aep-format.md index 3097a98dc..9f2c009c5 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -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** diff --git a/docs/aep-import.md b/docs/aep-import.md index c05bd6d14..1f7c2d364 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -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 · **Target:** Adobe After Effects 2026 +> **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 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 · **Target:** Adobe After Effects 2026 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