mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* 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>
139 lines
5.1 KiB
Rust
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));
|
|
}
|
|
}
|