Files
craft-libs/crates/aep/src/version.rs
T
Dylan M. Taylorandvangel76 4a7024cd69 craft-aep: flat read-only tree index and the Pard decoder (ports of effectcraft#3302 and #3319) (#948)
* craft-aep: a flat read-only tree index, borrowed text documents, fewer allocations

The container part of storytold/effectcraft#3302:

- `TreeIndex`: the chunk tree as one flat array in depth-first order,
  borrowed from the input, for readers that only look. Same chunks counted
  against the same limits and the same errors as `parse`. An `.aepx` is
  indexed by `index_aepx` into one buffer. The typed view reads through
  either tree (`Node`, `Root`, `Project::from_index`).
- Chunk parser: each list of siblings is counted, then reserved once.
  `.aepx`: hex decoded into an output sized once, attribute values without
  an entity borrowed. `write` reserves the exact output size.
- Text documents are parsed into a tree that borrows from the body; the
  style and paragraph defaults are built once.
- Layer markers and Source Text are read where they are stored instead of
  searched for.
- Container accessors that only tests used are removed.

What the importer and exporter in EffectCraft reach across the crate
boundary is public here: `Root`, `Draft`, `AepxDrafts`, `index_aepx`,
`TreeIndex::from_drafts`, `Project::of`, `ProjectSettings::of`,
`Node::marker_list`, `Root::file_version` and `saved_by_version`,
`StyleRun::SAMPLED`. `TimeBase::NTSC` holds the rates as plain
numerator and denominator instead of an EffectCraft type.

* craft-aep: one decoder for an effect parameter definition (`Pard`)

The container part of storytold/effectcraft#3319: the fields of a `pard`
record (type code, name, default, valid range, slider range, popup item
count) are read by one type, `Pard`, which `EffectParam` now reads
through. It is public so that an exporter's tests can read back what
they encode instead of keeping a second decoder.

The rest of that PR (import and export bounds, loss reports) is in
EffectCraft's importer and exporter.

---------

Co-authored-by: vangel76 <vangel1976@googlemail.com>
2026-11-11 19:01:12 -05:00

139 lines
5.1 KiB
Rust

//! 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::bytes::be_u32;
use crate::chunk::AepFile;
use crate::index::{Root, TreeIndex};
/// 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> {
Root::from(self).file_version()
}
/// 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> {
Root::from(self).saved_by_version()
}
}
impl TreeIndex<'_> {
/// [`AepFile::file_version`] of the indexed file.
pub fn file_version(&self) -> Option<AeVersion> {
Root::from(self).file_version()
}
/// [`AepFile::saved_by_version`] of the indexed file.
pub fn saved_by_version(&self) -> Option<AeVersion> {
Root::from(self).saved_by_version()
}
}
impl Root<'_, '_> {
/// The version the file is written for (`head` bytes 4 to 7).
pub fn file_version(self) -> Option<AeVersion> {
AeVersion::from_word(be_u32(self.chunk(b"head")?.bytes()?, 4)?)
}
/// The version of the application that wrote the file (`svap`).
pub fn saved_by_version(self) -> Option<AeVersion> {
AeVersion::from_word(be_u32(self.chunk(b"svap")?.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));
}
}