.aep: import twice as fast, faster export (#3302)

* aep: a layer's markers and Source Text are read where they are stored, not searched for (#3276)

A layer's marker list is a direct child of its root tdgp, and its Source Text record
btds a direct child of the ADBE Text Properties group. Markers walked the whole layer
subtree twice per layer (most layers have no marker list, so the walk always ran to the
end), and the three text-document accessors walked it once each.

* aep export: build the prototype template once, sort names with cached keys (#3277)

The template every export clones from is immutable; it is now built by the
first export of the process and kept (about 0.93 MB). The name sort builds
each natural key once instead of on every comparison. Root solids move into
the Solids folder instead of being copied. The two key-list rewrites share one
emit-local helper and match names compare as bytes instead of a decoded String.
Output is byte-identical.

* aep container: size sibling lists, hex and write output once; borrow plain .aepx attributes (#3275)

- read.rs: count the chunk headers of a sequence first and allocate the
  children Vec once (bounded by the input and by the chunk limit).
- aepx.rs: decode_hex sizes its output once; attribute values without
  an entity are borrowed instead of copied through decode_entities.
- typed.rs: PropGroup::get compares match names without building a
  String (same result as the lossy compare, invalid UTF-8 included).
- write.rs: reserve the exact output size up front (try_reserve, so a
  hand-built tree cannot abort on the allocation).
- read.rs, settings.rs, values.rs: use the shared bytes.rs readers.

Output is identical on the corpus (tree, written bytes, import).

* aep container: review fixes (#3275)

- read.rs: reserve with try_reserve_exact, so a raised chunk limit on a
  small target cannot abort before parsing; the capped reservation and a
  deep, retry-heavy nest of broken lists are pinned by tests.
- write.rs: a tree past the 32-bit container size fails with TooLarge
  before anything is serialised.
- aepx.rs: drop an unneeded local and lifetime annotation.

* aep export: note what the concurrency test covers and why a failed template build is not kept (#3277)

* aep: text documents are read without a String per token, defaults built once (#3276)

TextDocument::parse reads a borrowed tree: names, bare words and strings without
escapes point into the body, keys are borrowed when they are /names, numbers parse from
the borrowed bytes, and dictionaries and arrays are moved into vectors of their exact size
when they close. The style and paragraph defaults are built once instead of per document.
parse_text_dict keeps its public TextValue result (the borrowed tree turned owned).

The old owning parser stays as a test-only oracle: a differential test compares both on
40 000 generated bodies (every key form the owned keys canonicalise, non-UTF-8 names,
escapes, unclosed strings, non-finite numbers), every cut of a realistic document and
nesting around the depth limit.

* aep: text-dict test builds its prefix without repeat(1) (#3276)

* aep: an escaped text string drops its byte order mark without a range drain (#3276)

* aep export: one key-time loop, key-list rewrite, tdmn decoder and byte writer for the listed modules (#3278)

In masks, shapes, styles, synth, fxsynth, base, camlight, skeleton,
textanim and tree (effects.rs, plan.rs and emit.rs keep their copies for
the follow-up work):
- New export/keys.rs: the key-time loop (timed_keys), the key-list rewrite
  (fill_key_list, set_key_list, replace_cdat) and the spatial key record,
  shared by masks, shapes and styles.
- One tdmn decoder (synth::tdmn_name, lossy UTF-8 up to the first NUL, as the
  importer reads it) and one encoder (synth::tdmn) instead of the copies in
  masks, styles, shapes, textanim, camlight and skeleton.
- One byte writer: tree::put, which errors. base::bytes, synth::tdb4,
  synth::key_list, fxsynth::pard, masks::shph and the keyed path records write
  through it; their fixed layouts keep the infallible signatures behind
  tree::fixed, which logs, and a test builds every layout and requires no error.
- camlight::put_group uses the shared group split (tree::split_group), which
  errors on a match name without a node instead of stopping quietly.

Export output is byte-identical on the corpus and the synthetic fixtures.

* aep export: pin the key-time loop's report, name the layout in the base::bytes log (#3278)

* aep export: review fixes for #3278

- tree::Fixed replaces tree::fixed: a fixed layout writes every field that
  fits (as the old code did) and keeps the first error; finish() logs it
  once with the layout's description and counts it per thread, so the
  every-layout test cannot race with other tests.
- base::bytes logs one line naming its length and fields.
- masks::build_atom errors when the prototype has no Mask Shape instead of
  dropping the path of every mask quietly.
- log sorted into the dependency list.

* aep: review fixes for #3276 text documents and markers

text_document reads the first btdk in the Source Text record, the documents
text_document_count counts. The old slot checks (same, is_neutral, undecoded_fields)
join the test oracle and are compared on 20 000 generated slot dictionaries; mixed
dictionary/array chains and a document left open at the depth limit join the parser
differential. An escaped string's byte order mark is drained without a copy.

* aep: remove test-only container accessors, share report and time-base code (#3281)

- Remove 35 public container accessors that only tests called (header,
  camera/light, keys, footage, masks, shapes, version) and the dead
  StyleMap::switch_name. Tests now check the importer's own reading of the
  same bytes, or the decoder it uses; three import tests gain the values
  the removed checks covered.
- Import and export reports share one loss entry (Loss<C>) with record,
  total, count and summary; the JSON shape is pinned for both.
- Collector::lose_with builds a detail only below the detail cap (callers
  switch later).
- The export uses the container's TimeBase; one NTSC table serves import
  and export; TimeBase::frames saturates like units instead of returning 0.

* aep: review fixes for #3281: NTSC table beside TimeBase, real-path NTSC round trip, light and camera defaults on import, doc wording

* aep: drop a local check tool committed by mistake (#3281)

* aep export: sort keys as one compact byte string, bounded by the name (#3277)

The cached natural keys were Vecs of (bool, String, u128) segments: a 255-byte
name of alternating letters and digits took about 14 KB, held for every item at
once. Each key is now one byte string whose byte order is the natural order,
at most 5 bytes per name byte. Order and output are unchanged.

* aep container: check bdata digits and size before reserving (#3275)

decode_hex reserved the decoded size before it knew the value decodes,
so a large bdata with a bad digit reserved half its length first. It now
refuses a value larger than a chunk's 32-bit size field (TooLarge), checks
every digit in one vectorisable pass, and only then reserves, with
try_reserve_exact so a value that does not fit in memory is an error
instead of an abort.

* aep export: the every-layout test counts from a baseline, not from zero (#3278)

* aep export: correct the order the natural sort key doc gives for equal numbers (#3277)

* aep: the format notes name the accessors that remain (#3281)

* aep: the format notes name the 1 x 1 mask scale of text and shape layers (#3281)

* aep: read imports through a flat tree index (#3300)

Import reads an .aep through TreeIndex, one depth-first array of chunks that
borrows the file's bytes, instead of building and freeing the owned Chunk tree
(one Vec per list). The parser mirrors read::Parser step for step: the same
chunk count order against max_chunks, the same depth limit and opaque
fallbacks, the same errors. The typed view reads through Node, a handle to a
chunk of either tree, so the accessors work unchanged on both; .aepx keeps its
owned tree. Chunk, parse and write stay for export and editing.

The array starts small and doubles, never past what the input can hold or the
chunk limit allows; a failed allocation is TooLarge, a tree past u32 indices
TooManyChunks.

* aep export: the time remap and Transform keys use the shared key-list and match-name helpers; doc comments for the audit's functions (#3300)

* #3300: import .aepx through the flat index

The .aepx reader no longer builds the owned chunk tree element by
element. It decodes every body (hex, text, alias JSON, numbers) into
one buffer and records the chunks as depth-first entries whose bodies
are ranges of that buffer; TreeIndex::from_drafts turns them into the
index the importer reads, which borrows the buffer. read_aepx converts
that index into the owned tree in one pass.

The tree and the errors are those of the previous reader, which the
tests keep as the reference: generated and damaged documents under
seven limit sets, invalid UTF-8, hostile input, the fixture directory,
and imports through both. Bound tests pin the chunk list to the input
and the limit, and check that dropped content leaves nothing behind.

* #3300: read_aepx keeps its own owned-tree reader

read_aepx, read_aepx_with_limits and parse_any build the owned chunk
tree element by element again, as before the .aepx index, instead of
converting the index (which held the decoded buffer and the owned tree
at the same time). Import keeps the index reader. The tests now
compare the two production readers.

* #3300: test keys that round to the same time

* aep: compare the destination projects in the index-equivalence tests

both() and both_aepx() kept only the ImportResult of each path; they now keep
both destination Projects and assert structural and serialized equality, so a
layer or property value regression cannot pass on equal ids and reports.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
Valentin Klein
2026-11-13 00:54:29 +01:00
committed by GitHub
co-authored by FredApps
parent 10a34b854f
commit a4bde72bca
70 changed files with 5248 additions and 1428 deletions
Generated
+1
View File
@@ -1483,6 +1483,7 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-time",
"log",
"serde",
"serde_json",
"thiserror 2.0.21",
+1
View File
@@ -15,6 +15,7 @@ effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-effects = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
+3 -1
View File
@@ -21,7 +21,9 @@ tree; pad bytes, odd bodies and the bytes after the container, an XMP packet, ar
The XML form of a project (`.aepx`) is read into the same tree by `read_aepx` (a small bounded
XML tokenizer, no dependency); `parse_any` picks the reader by content, so the typed view and the
importer take either form. The mapping is in `docs/aep-format.md`, section 1.
importer take either form. The mapping is in `docs/aep-format.md`, section 1. The importer, which
only reads, does not build the owned tree: it reads either form through the flat index
`TreeIndex`, an `.aep` in place and an `.aepx` decoded into one buffer first.
Bodies that look like lists but are not chunk sequences (for example `LIST btdk`, raw text) stay
opaque rather than failing the parse. Input is untrusted: sizes are checked against the remaining
+3
View File
@@ -23,6 +23,7 @@ mod effects;
mod error;
mod footage;
mod header;
pub mod index;
mod keys;
mod masks;
mod matte;
@@ -36,6 +37,7 @@ mod values;
mod version;
mod write;
pub(crate) use aepx::{AepxDrafts, index_aepx};
pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
pub use arbitrary::{
CURVE_IDENTITY, CryptomatteData, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type,
@@ -46,6 +48,7 @@ pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, Laye
pub use error::AepError;
pub use footage::{Footage, PsdLayer, SequenceInfo};
pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer};
pub use index::{Node, NodeWalk, Nodes, TreeIndex};
pub use keys::{ExpressionFlags, Interpolation, KeyEase, KeyframeKind, SpatialKey};
pub use masks::{Mask, MaskAtom, MaskMode, PathVertex, ShapePath};
pub use matte::{MatteMode, TrackMatte};
+664 -100
View File
@@ -12,13 +12,23 @@
//! instructions. A document type declaration is refused (no entity expansion). It runs without
//! recursion; the nesting depth and the element count are bounded by [`Limits`], and everything
//! it allocates is bounded by the size of the input.
//!
//! There are two readers. [`read_aepx`] builds the owned tree element by element
//! (`aepx/tree.rs`). The importer, which only reads, uses `index_aepx`: it decodes every body
//! (hex, text, the alias JSON, the numbers) into one buffer and records the chunks as entries of
//! the flat index ([`Draft`]s, in depth-first order, bodies as ranges of the buffer), which
//! [`crate::container::TreeIndex::from_drafts`] turns into an index that borrows the buffer.
//! The tests hold both to the same tree and the same errors.
use std::borrow::Cow;
use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS};
use crate::container::chunk::{AepFile, LIST, NESTED_IDS};
use crate::container::error::AepError;
use crate::container::index::{Draft, Kind as EntryKind};
use crate::container::read::{Limits, parse};
mod tree;
/// The root element of an `.aepx`.
const ROOT: &[u8] = b"AfterEffectsProject";
/// The element that holds the XMP packet (the trailer of an `.aep`).
@@ -68,17 +78,59 @@ pub fn read_aepx(data: &[u8]) -> Result<AepFile<'static>, AepError> {
/// # Errors
/// See [`read_aepx`].
pub fn read_aepx_with_limits(data: &[u8], limits: Limits) -> Result<AepFile<'static>, AepError> {
tree::read(data, limits)
}
/// An `.aepx` read for the flat index: every body decoded into one buffer, and the chunks as
/// [`Draft`]s in depth-first order with their bodies as ranges of that buffer.
/// [`crate::container::TreeIndex::from_drafts`] makes the index, which borrows the buffer.
pub(crate) struct AepxDrafts {
/// The decoded bodies (hex, text, alias JSON, numbers) and the XMP packet.
pub(crate) buffer: Vec<u8>,
pub(crate) nodes: Vec<Draft>,
/// The XMP packet: a range of `buffer` (empty without one).
pub(crate) trailer: (usize, usize),
}
/// Reads an `.aepx` into [`AepxDrafts`]: the tree [`read_aepx_with_limits`] gives, with the same
/// errors, without one allocation per chunk.
///
/// # Errors
/// See [`read_aepx_with_limits`]. When memory runs out, two errors come up in more cases than
/// there: [`AepError::TooLarge`] when the chunk list cannot grow (the owned-tree reader would
/// abort), and [`AepError::Xml`] "bdata is too large to hold in memory" also when the shared body
/// buffer cannot grow (the owned-tree reader reserves each value on its own).
pub(crate) fn index_aepx(data: &[u8], limits: Limits) -> Result<AepxDrafts, AepError> {
let text = match std::str::from_utf8(data) {
Ok(t) => t.as_bytes(),
Err(e) => return Err(xml(e.valid_up_to(), "the file is not valid UTF-8")),
};
Reader { data: text, pos: 0, limits, count: 0, stack: Vec::new(), top: None, trailer: None }.run()
// Every counted element takes at least 3 bytes (`<a>` in a document cut short), so there are
// never more chunks than this.
let bound = (text.len() / 3).min(limits.max_chunks);
let mut nodes = Vec::new();
let _ = nodes.try_reserve_exact(bound.min(INITIAL_SLOTS));
// Hex is most of an `.aepx` and decodes to half its length: one buffer of that size usually
// holds everything. A failed reservation is not an error here; the buffer grows as it goes.
let mut buffer = Vec::new();
let _ = buffer.try_reserve_exact(text.len() / 2);
let reader = Reader { data: text, pos: 0, limits, count: 0, stack: Vec::new(), root_closed: false, trailer: None, buffer, nodes, bound, attrs: Vec::new() };
reader.run()
}
/// The slots the chunk list starts with; it doubles from there (as the array of
/// [`crate::container::TreeIndex`] does).
const INITIAL_SLOTS: usize = 1024;
fn xml(offset: usize, what: &'static str) -> AepError {
AepError::Xml { offset, what }
}
/// The pad byte of a body of `len` bytes, as [`crate::container::Chunk::data`] sets it.
fn pad_of(len: usize) -> Option<u8> {
if len % 2 == 1 { Some(0) } else { None }
}
/// What an open element becomes when it closes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
@@ -102,13 +154,14 @@ struct Frame {
at: usize,
/// The name as written, to match the end tag.
name: (usize, usize),
/// The decoded `bdata` attribute.
bdata: Option<Vec<u8>>,
/// The chunk's entry in the list (for `Chunk`, `Utf8` and `Alias`).
entry: Option<usize>,
/// The buffer length when the element opened: its body starts here.
mark: usize,
/// The end of the decoded `bdata` attribute in the buffer.
bdata_end: Option<usize>,
/// The attributes of a `fileReference`, in document order.
attrs: Vec<(String, String)>,
/// Collected text (only for `string` and the XMP packet).
text: Vec<u8>,
children: Vec<Chunk<'static>>,
}
struct Reader<'d> {
@@ -117,13 +170,19 @@ struct Reader<'d> {
limits: Limits,
count: usize,
stack: Vec<Frame>,
/// The top-level chunks once the root element has closed.
top: Option<Vec<Chunk<'static>>>,
trailer: Option<Vec<u8>>,
/// True once the root element has closed.
root_closed: bool,
trailer: Option<(usize, usize)>,
buffer: Vec<u8>,
nodes: Vec<Draft>,
/// The most chunks the input can hold under the limits: `nodes` never reserves past it.
bound: usize,
/// The attributes of the current start tag; kept to reuse its allocation.
attrs: Vec<((usize, usize), Cow<'d, [u8]>)>,
}
impl Reader<'_> {
fn run(mut self) -> Result<AepFile<'static>, AepError> {
impl<'d> Reader<'d> {
fn run(mut self) -> Result<AepxDrafts, AepError> {
if self.data.starts_with(&[0xef, 0xbb, 0xbf]) {
self.pos = 3;
}
@@ -137,11 +196,13 @@ impl Reader<'_> {
if let Some(open) = self.stack.last() {
return Err(xml(open.at, "an element is not closed before the end of the file"));
}
let chunks = self.top.ok_or_else(|| xml(self.data.len(), "no AfterEffectsProject root element"))?;
Ok(AepFile { chunks, trailer: Cow::Owned(self.trailer.unwrap_or_default()) })
if !self.root_closed {
return Err(xml(self.data.len(), "no AfterEffectsProject root element"));
}
Ok(AepxDrafts { buffer: self.buffer, nodes: self.nodes, trailer: self.trailer.unwrap_or((0, 0)) })
}
fn rest(&self) -> &[u8] {
fn rest(&self) -> &'d [u8] {
self.data.get(self.pos..).unwrap_or(&[])
}
@@ -181,14 +242,13 @@ impl Reader<'_> {
self.pos = start;
let end = self.skip_past(b"]]>", "a CDATA section is not closed")?;
let content = self.data.get(start..end).unwrap_or(&[]);
let at = start;
match self.stack.last_mut() {
match self.stack.last() {
Some(frame) => {
if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) {
frame.text.extend_from_slice(content);
self.buffer.extend_from_slice(content);
}
}
None => return Err(xml(at, "CDATA outside the root element")),
None => return Err(xml(start, "CDATA outside the root element")),
}
} else if rest.starts_with(b"<!") {
return Err(xml(self.pos, "document type declarations are not supported"));
@@ -219,25 +279,22 @@ impl Reader<'_> {
self.pos = self.pos.saturating_add(n);
}
fn slice(&self, (a, b): (usize, usize)) -> &[u8] {
fn slice(&self, (a, b): (usize, usize)) -> &'d [u8] {
self.data.get(a..b).unwrap_or(&[])
}
fn start_tag(&mut self) -> Result<(), AepError> {
let at = self.pos;
self.pos = self.pos.saturating_add(1);
let name = self.name()?;
let mut attrs: Vec<((usize, usize), Vec<u8>)> = Vec::new();
let self_closing = loop {
/// Reads the attributes of a start tag into `attrs`; true when the tag closes itself.
fn attributes(&mut self, at: usize, attrs: &mut Vec<((usize, usize), Cow<'d, [u8]>)>) -> Result<bool, AepError> {
loop {
self.skip_ws();
let rest = self.rest();
if rest.starts_with(b"/>") {
self.pos = self.pos.saturating_add(2);
break true;
return Ok(true);
}
if rest.starts_with(b">") {
self.pos = self.pos.saturating_add(1);
break false;
return Ok(false);
}
if rest.is_empty() {
return Err(xml(at, "a start tag is not closed"));
@@ -259,11 +316,27 @@ impl Reader<'_> {
if raw.contains(&b'<') {
return Err(xml(vstart, "`<` inside an attribute value"));
}
let mut value = Vec::new();
decode_entities(raw, vstart, &mut value)?;
// A value without `&` is its own decoding (most of them, every `bdata` After Effects
// writes): borrow it instead of copying it.
let value = if raw.contains(&b'&') {
let mut value = Vec::new();
decode_entities(raw, vstart, &mut value)?;
Cow::Owned(value)
} else {
Cow::Borrowed(raw)
};
self.pos = vend.saturating_add(1);
attrs.push((key, value));
};
}
}
fn start_tag(&mut self) -> Result<(), AepError> {
let at = self.pos;
self.pos = self.pos.saturating_add(1);
let name = self.name()?;
let mut attrs = std::mem::take(&mut self.attrs);
attrs.clear();
let self_closing = self.attributes(at, &mut attrs)?;
let kind = self.kind_of(name, at)?;
if kind != Kind::Root {
self.count = self.count.saturating_add(1);
@@ -275,14 +348,25 @@ impl Reader<'_> {
return Err(AepError::TooDeep);
}
}
let mut frame = Frame { kind, at, name, bdata: None, attrs: Vec::new(), text: Vec::new(), children: Vec::new() };
if matches!(kind, Kind::Chunk(_)) {
if let Some((key, value)) = attrs.iter().find(|(k, _)| self.slice(*k) == b"bdata") {
frame.bdata = Some(decode_hex(value, key.1)?);
let mark = self.buffer.len();
let mut frame = Frame { kind, at, name, entry: None, mark, bdata_end: None, attrs: Vec::new() };
match kind {
Kind::Chunk(id) => {
if let Some((key, value)) = attrs.iter().find(|(k, _)| self.slice(*k) == b"bdata") {
decode_hex_into(value, key.1, MAX_BDATA, &mut self.buffer)?;
frame.bdata_end = Some(self.buffer.len());
}
frame.entry = Some(self.open_entry(id)?);
}
} else if kind == Kind::Alias {
frame.attrs = attrs.iter().map(|(k, v)| (String::from_utf8_lossy(self.slice(*k)).into_owned(), String::from_utf8_lossy(v).into_owned())).collect();
Kind::Utf8 => frame.entry = Some(self.open_entry(*b"Utf8")?),
Kind::Alias => {
frame.attrs =
attrs.iter().map(|(k, v)| (String::from_utf8_lossy(self.slice(*k)).into_owned(), String::from_utf8_lossy(v).into_owned())).collect();
frame.entry = Some(self.open_entry(*b"alas")?);
}
Kind::Root | Kind::Xmp | Kind::Skip => {}
}
self.attrs = attrs;
self.stack.push(frame);
if self_closing {
self.close()?;
@@ -290,12 +374,38 @@ impl Reader<'_> {
Ok(())
}
/// Appends the entry of a chunk whose element just opened; its body is filled in when it
/// closes. The list grows by its length (at least 16) but never past [`Self::bound`]; an
/// allocation the target cannot make is an error, not an abort.
fn open_entry(&mut self, id: [u8; 4]) -> Result<usize, AepError> {
let at = self.nodes.len();
// This entry and the one after it must have `u32` indices.
u32::try_from(at.checked_add(1).ok_or(AepError::TooManyChunks)?).map_err(|_| AepError::TooManyChunks)?;
if at == self.nodes.capacity() {
// Past the bound (which only a wrong guess could reach) it keeps doubling.
let room = self.bound.saturating_sub(at);
let more = if room == 0 { at.max(16) } else { at.max(16).min(room) };
self.nodes.try_reserve_exact(more).map_err(|_| AepError::TooLarge)?;
}
self.nodes.push(Draft::placeholder(id));
Ok(at)
}
/// Fills in the entry at `at` once its element has closed; its subtree is what follows it.
fn finish(&mut self, at: usize, id: [u8; 4], kind: EntryKind, list_type: [u8; 4], body: (usize, usize), pad: Option<u8>) -> Result<(), AepError> {
let end = u32::try_from(self.nodes.len()).map_err(|_| AepError::TooManyChunks)?;
if let Some(d) = self.nodes.get_mut(at) {
*d = Draft { start: body.0, len: body.1.saturating_sub(body.0), id, list_type, end, kind, pad };
}
Ok(())
}
/// Classifies an element by its name and where it is.
fn kind_of(&self, name: (usize, usize), at: usize) -> Result<Kind, AepError> {
let n = self.slice(name);
let parent = match self.stack.last() {
None => {
if self.top.is_some() {
if self.root_closed {
return Err(xml(at, "a second root element"));
}
if n != ROOT {
@@ -339,39 +449,102 @@ impl Reader<'_> {
self.close()
}
/// Closes the innermost element and hands its chunk to the parent.
/// Closes the innermost element and fills in its chunk's entry.
fn close(&mut self) -> Result<(), AepError> {
let frame = self.stack.pop().ok_or(xml(self.pos, "an end tag without a start tag"))?;
let chunk = match frame.kind {
Kind::Root => {
self.top = Some(frame.children);
return Ok(());
}
Kind::Skip => return Ok(()),
match frame.kind {
Kind::Root => self.root_closed = true,
Kind::Skip => {}
Kind::Xmp => {
// Only the first packet is kept; a later one is dropped from the buffer (its
// children are skipped, so nothing else was written after it opened).
if self.trailer.is_none() {
self.trailer = Some(frame.text);
self.trailer = Some((frame.mark, self.buffer.len()));
} else {
self.buffer.truncate(frame.mark);
}
return Ok(());
}
Kind::Utf8 => Chunk::data(*b"Utf8", frame.text),
Kind::Alias => Chunk::data(*b"alas", alias_json(&frame.attrs).into_bytes()),
Kind::Chunk(id) => chunk_of(id, frame)?,
};
if let Some(parent) = self.stack.last_mut() {
parent.children.push(chunk);
Kind::Utf8 => {
let at = frame.entry.ok_or(xml(frame.at, "an element lost its chunk"))?;
let end = self.buffer.len();
self.finish(at, *b"Utf8", EntryKind::Data, [0; 4], (frame.mark, end), pad_of(end.saturating_sub(frame.mark)))?;
}
Kind::Alias => {
let at = frame.entry.ok_or(xml(frame.at, "an element lost its chunk"))?;
self.buffer.extend_from_slice(alias_json(&frame.attrs).as_bytes());
let end = self.buffer.len();
self.finish(at, *b"alas", EntryKind::Data, [0; 4], (frame.mark, end), pad_of(end.saturating_sub(frame.mark)))?;
}
Kind::Chunk(id) => self.close_chunk(id, &frame)?,
}
Ok(())
}
/// The chunk of a closed element named by a chunk id (what the owned reader's `chunk_of`
/// built): `bdata` wins over children, `numS` / `ppSn` hold their number, a nested id holds its
/// children (or nothing), any other name is a `LIST` of that type.
fn close_chunk(&mut self, id: [u8; 4], frame: &Frame) -> Result<(), AepError> {
let at = frame.entry.ok_or(xml(frame.at, "an element lost its chunk"))?;
let after = at.checked_add(1).ok_or(AepError::TooManyChunks)?;
if let Some(bdata_end) = frame.bdata_end {
// The children were read (and counted, and checked) but are not part of the chunk.
self.nodes.truncate(after);
self.buffer.truncate(bdata_end);
let body = (frame.mark, bdata_end);
let pad = pad_of(bdata_end.saturating_sub(frame.mark));
if OPAQUE_LIST_TYPES.contains(&id) {
return self.finish(at, LIST, EntryKind::OpaqueList, id, body, pad);
}
return self.finish(at, id, EntryKind::Data, [0; 4], body, pad);
}
// Numbers written as text: `<numS><string>0</string></numS>` and `<ppSn><string>150.000000000</string></ppSn>`.
if &id == b"numS" || &id == b"ppSn" {
let missing = || xml(frame.at, "a numS or ppSn element without its number");
// Exactly one child chunk, a `Utf8`: the entry after this one, whose subtree runs to the end.
let only = self.nodes.get(after).copied().filter(|c| usize::try_from(c.end).ok() == Some(self.nodes.len())).ok_or_else(missing)?;
if only.id != *b"Utf8" {
return Err(missing());
}
let text = match only.kind {
EntryKind::Data | EntryKind::OpaqueList => only.start.checked_add(only.len).and_then(|end| self.buffer.get(only.start..end)).unwrap_or(&[]),
EntryKind::List | EntryKind::Nested => &[],
};
let text = std::str::from_utf8(text).map_err(|_| xml(frame.at, "a number is not text"))?.trim();
let body: [u8; 8];
let bytes: &[u8] = if &id == b"numS" {
// Only `0` is sampled, so whether the field is signed is open: accept both readings of 4 bytes.
let n = text.parse::<i64>().ok().filter(|n| (i64::from(i32::MIN)..=i64::from(u32::MAX)).contains(n));
let n = n.ok_or(xml(frame.at, "numS is not a 32-bit integer"))?;
body = u64::from(n as u32).to_be_bytes();
body.get(4..).unwrap_or(&[])
} else {
body = text.parse::<f64>().map_err(|_| xml(frame.at, "ppSn is not a number"))?.to_be_bytes();
&body
};
self.nodes.truncate(after);
self.buffer.truncate(frame.mark);
self.buffer.extend_from_slice(bytes);
let end = self.buffer.len();
return self.finish(at, id, EntryKind::Data, [0; 4], (frame.mark, end), pad_of(bytes.len()));
}
let empty = (frame.mark, frame.mark);
if NESTED_IDS.contains(&id) {
if self.nodes.len() == after {
return self.finish(at, id, EntryKind::Data, [0; 4], empty, None);
}
return self.finish(at, id, EntryKind::Nested, [0; 4], empty, None);
}
self.finish(at, LIST, EntryKind::List, id, empty, None)
}
fn text(&mut self) -> Result<(), AepError> {
let start = self.pos;
let len = self.rest().iter().position(|b| *b == b'<').unwrap_or(self.rest().len());
let end = start.saturating_add(len);
self.pos = end;
let raw = self.data.get(start..end).unwrap_or(&[]);
match self.stack.last_mut() {
Some(frame) if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) => decode_entities(raw, start, &mut frame.text),
match self.stack.last() {
Some(frame) if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) => decode_entities(raw, start, &mut self.buffer),
Some(_) => Ok(()),
None if raw.iter().all(u8::is_ascii_whitespace) => Ok(()),
None => Err(xml(start, "text outside the root element")),
@@ -392,41 +565,6 @@ fn chunk_id(name: &[u8]) -> Option<[u8; 4]> {
Some(id)
}
/// The chunk of a closed element named by a chunk id.
fn chunk_of(id: [u8; 4], frame: Frame) -> Result<Chunk<'static>, AepError> {
if let Some(bytes) = frame.bdata {
if OPAQUE_LIST_TYPES.contains(&id) {
let pad = if bytes.len() % 2 == 1 { Some(0) } else { None };
return Ok(Chunk { id: LIST, body: Body::OpaqueList { list_type: id, data: Cow::Owned(bytes) }, pad });
}
return Ok(Chunk::data(id, bytes));
}
// Numbers written as text: `<numS><string>0</string></numS>` and `<ppSn><string>150.000000000</string></ppSn>`.
if &id == b"numS" || &id == b"ppSn" {
let text = match frame.children.as_slice() {
[only] if only.is(b"Utf8") => only.bytes().unwrap_or(&[]),
_ => return Err(xml(frame.at, "a numS or ppSn element without its number")),
};
let text = std::str::from_utf8(text).map_err(|_| xml(frame.at, "a number is not text"))?.trim();
let body = if &id == b"numS" {
// Only `0` is sampled, so whether the field is signed is open: accept both readings of 4 bytes.
let n = text.parse::<i64>().ok().filter(|n| (i64::from(i32::MIN)..=i64::from(u32::MAX)).contains(n));
let n = n.ok_or(xml(frame.at, "numS is not a 32-bit integer"))?;
(n as u32).to_be_bytes().to_vec()
} else {
text.parse::<f64>().map_err(|_| xml(frame.at, "ppSn is not a number"))?.to_be_bytes().to_vec()
};
return Ok(Chunk::data(id, body));
}
if NESTED_IDS.contains(&id) {
if frame.children.is_empty() {
return Ok(Chunk::data(id, Vec::new()));
}
return Ok(Chunk { id, body: Body::Chunks(frame.children), pad: None });
}
Ok(Chunk::list(id, frame.children))
}
/// Appends `raw` with the predefined entities and character references decoded.
fn decode_entities(raw: &[u8], offset: usize, out: &mut Vec<u8>) -> Result<(), AepError> {
let mut i = 0;
@@ -464,23 +602,74 @@ fn decode_entities(raw: &[u8], offset: usize, out: &mut Vec<u8>) -> Result<(), A
Ok(())
}
/// Decodes a `bdata` value (hex digits, two per byte).
/// Decodes a `bdata` value (hex digits, two per byte) into a new `Vec` of exactly its size.
fn decode_hex(text: &[u8], offset: usize) -> Result<Vec<u8>, AepError> {
if text.len() % 2 == 1 {
return Err(xml(offset, "bdata has an odd number of hex digits"));
}
decode_hex_at_most(text, offset, MAX_BDATA)
}
/// The most bytes one `bdata` may decode to: what the 32-bit size field of a chunk can hold (a
/// larger body could not be written back as an `.aep` either).
const MAX_BDATA: usize = u32::MAX as usize;
/// [`decode_hex`] with the decoded-size limit as a parameter (so tests can reach it).
fn decode_hex_at_most(text: &[u8], offset: usize, max: usize) -> Result<Vec<u8>, AepError> {
// Checked before anything is reserved, then sized once (collecting into `Option<Vec>` has no
// size hint and regrows step by step), and without aborting when the memory is not there.
let pairs = check_hex(text, offset, max)?;
let mut out = Vec::new();
out.try_reserve_exact(pairs).map_err(|_| xml(offset, "bdata is too large to hold in memory"))?;
decode_checked(text, offset, &mut out)?;
Ok(out)
}
/// Appends the bytes of a `bdata` value (hex digits, two per byte, at most `max` bytes) to `out`.
/// On an error `out` is unchanged.
fn decode_hex_into(text: &[u8], offset: usize, max: usize, out: &mut Vec<u8>) -> Result<(), AepError> {
let pairs = check_hex(text, offset, max)?;
// Grown at most once per value (amortised over a shared buffer), and without aborting when the
// memory is not there.
out.try_reserve(pairs).map_err(|_| xml(offset, "bdata is too large to hold in memory"))?;
decode_checked(text, offset, out)
}
/// Appends the bytes of a `bdata` value that [`check_hex`] accepted. On an error (a digit the
/// check let through, which it does not) `out` is unchanged.
fn decode_checked(text: &[u8], offset: usize, out: &mut Vec<u8>) -> Result<(), AepError> {
let digit = |b: u8| match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
};
let (pairs, _) = text.as_chunks::<2>();
pairs
.iter()
.map(|[hi, lo]| digit(*hi).zip(digit(*lo)).map(|(h, l)| (h << 4) | l))
.collect::<Option<Vec<u8>>>()
.ok_or(xml(offset, "bdata holds a character that is not a hex digit"))
let mark = out.len();
for [hi, lo] in text.as_chunks::<2>().0 {
match (digit(*hi), digit(*lo)) {
(Some(h), Some(l)) => out.push((h << 4) | l),
_ => {
out.truncate(mark);
return Err(xml(offset, "bdata holds a character that is not a hex digit"));
}
}
}
Ok(())
}
/// Checks a `bdata` value before anything is reserved for it: an even number of hex digits, at
/// most `max` bytes. Returns the number of bytes it decodes to.
fn check_hex(text: &[u8], offset: usize, max: usize) -> Result<usize, AepError> {
if text.len() % 2 == 1 {
return Err(xml(offset, "bdata has an odd number of hex digits"));
}
let pairs = text.len() / 2;
if pairs > max {
return Err(AepError::TooLarge);
}
// One cheap pass checks every digit first. Blocks without an early exit inside, so the check
// vectorises; it stops at the first bad block.
if !text.chunks(4096).all(|block| block.iter().fold(true, |ok, b| ok & b.is_ascii_hexdigit())) {
return Err(xml(offset, "bdata holds a character that is not a hex digit"));
}
Ok(pairs)
}
/// The JSON body of an `alas` chunk from the attributes of a `fileReference`, keys in document
@@ -528,6 +717,7 @@ fn json_str(out: &mut String, s: &str) {
#[cfg(test)]
mod tests {
use super::*;
use crate::container::index::TreeIndex;
fn doc(body: &str) -> String {
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<AfterEffectsProject xmlns=\"x\">{body}</AfterEffectsProject>\n")
@@ -628,6 +818,92 @@ mod tests {
assert!(matches!(read_aepx(b"<AfterEffectsProject>\xff</AfterEffectsProject>"), Err(AepError::Xml { .. })));
}
/// Attribute values decode entities only where they have them.
#[test]
fn attribute_values_decode_entities_only_where_they_have_them() {
// An entity inside `bdata` still decodes before the hex does.
let f = read_aepx(doc("<a bdata=\"&#x30;1&#65;b\"/>").as_bytes()).unwrap();
assert_eq!(f.chunks[0].bytes(), Some(&[0x01, 0xab][..]));
// A bad entity in any attribute, even of an element that is skipped, is still an error.
for bad in ["<a other=\"&nbsp;\"/>", "<Unknown x=\"&bogus;\"/>", "<a bdata=\"00\" note=\"&amp\"/>"] {
assert!(matches!(read_aepx(doc(bad).as_bytes()), Err(AepError::Xml { .. })), "{bad}");
}
// Without `&` the value is taken as written.
let f = read_aepx(doc("<a bdata=\"0aFf\" x=\"y\"/>").as_bytes()).unwrap();
assert_eq!(f.chunks[0].bytes(), Some(&[0x0a, 0xff][..]));
}
/// A large bdata with a bad digit is refused before it is sized.
#[test]
fn a_large_bdata_with_a_bad_digit_is_refused_before_it_is_sized() {
// 10 MB of hex that is bad from the first pair (and once more at the very end).
let mut text = b"zz".to_vec();
text.extend(std::iter::repeat_n(*b"00", 5_000_000).flatten());
assert_eq!(decode_hex(&text, 3), Err(xml(3, "bdata holds a character that is not a hex digit")));
let mut late = vec![b'0'; 10_000_000];
late.extend_from_slice(b"0g");
assert_eq!(decode_hex(&late, 3), Err(xml(3, "bdata holds a character that is not a hex digit")));
let x = doc(&format!("<a bdata=\"{}\"/>", std::str::from_utf8(&text).unwrap()));
assert!(matches!(read_aepx(x.as_bytes()), Err(AepError::Xml { .. })));
}
/// Bdata decodes up to the size limit and not past it.
#[test]
fn bdata_decodes_up_to_the_size_limit_and_not_past_it() {
assert_eq!(decode_hex_at_most(b"00ff10", 0, 3), Ok(vec![0x00, 0xff, 0x10]));
assert_eq!(decode_hex_at_most(b"00ff10", 0, 2), Err(AepError::TooLarge));
assert_eq!(decode_hex_at_most(b"", 0, 0), Ok(Vec::new()));
assert_eq!(decode_hex_at_most(b"00", 0, 0), Err(AepError::TooLarge));
// The odd-length error still comes first.
assert_eq!(decode_hex_at_most(b"001", 0, 0), Err(xml(0, "bdata has an odd number of hex digits")));
// The real limit is what a chunk's 32-bit size field holds.
assert_eq!(MAX_BDATA as u64, u64::from(u32::MAX));
}
/// Decode hex matches the collecting version.
#[test]
fn decode_hex_matches_the_collecting_version() {
/// The collecting decoder this one replaced, as the reference.
fn old(text: &[u8], offset: usize) -> Result<Vec<u8>, AepError> {
if text.len() % 2 == 1 {
return Err(xml(offset, "bdata has an odd number of hex digits"));
}
let digit = |b: u8| match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
};
let (pairs, _) = text.as_chunks::<2>();
pairs
.iter()
.map(|[hi, lo]| digit(*hi).zip(digit(*lo)).map(|(h, l)| (h << 4) | l))
.collect::<Option<Vec<u8>>>()
.ok_or(xml(offset, "bdata holds a character that is not a hex digit"))
}
let alphabet = b"0123456789abcdefABCDEFgG \0";
let mut seed = 0x2545_f491_4f6c_dd1du64;
for len in 0..200usize {
for _ in 0..20 {
let text: Vec<u8> = (0..len)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
// Mostly hex digits, now and then a byte that is not one.
let i = (seed % 1000) as usize;
alphabet[if i < 990 { i % 22 } else { 22 + i % 4 }]
})
.collect();
let new = decode_hex(&text, 7);
assert_eq!(new, old(&text, 7));
if let Ok(v) = new {
assert_eq!(v.capacity(), v.len());
}
}
}
}
#[test]
fn limits_hold() {
let deep = format!("<AfterEffectsProject>{}{}</AfterEffectsProject>", "<a>".repeat(100_000), "</a>".repeat(100_000));
@@ -637,4 +913,292 @@ mod tests {
let many = format!("<AfterEffectsProject>{}</AfterEffectsProject>", "<a/>".repeat(10));
assert_eq!(read_aepx_with_limits(many.as_bytes(), Limits { max_depth: 64, max_chunks: 9 }), Err(AepError::TooManyChunks));
}
/// A tiny deterministic generator (xorshift64*), so the tests need no extra crate.
struct Rng(u64);
impl Rng {
/// The next pseudo-random number.
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
/// A number below `n` (0 for `n == 0`).
fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { usize::try_from(self.next() % n as u64).unwrap() }
}
/// One of `items`.
fn pick<'s>(&mut self, items: &[&'s str]) -> &'s str {
items[self.below(items.len())]
}
}
/// `n` random bytes as hex, upper or lower case.
fn hex(rng: &mut Rng, n: usize) -> String {
let upper = rng.below(4) == 0;
(0..n).map(|_| if upper { format!("{:02X}", rng.below(256)) } else { format!("{:02x}", rng.below(256)) }).collect()
}
/// Random `.aepx` content: every element form the reader knows (data, lists, nested ids,
/// strings, numbers, `btdk`, file references, skipped elements, XMP packets), `bdata` next to
/// children, bodies that read as chunk sequences, entities, CDATA, comments and stray text.
fn content(rng: &mut Rng, depth: usize, budget: &mut usize, out: &mut String) {
for _ in 0..rng.below(6) {
if *budget == 0 {
return;
}
*budget -= 1;
match rng.below(20) {
0..=3 => {
let id = rng.pick(&["cdat", "tdmn", "head", "Utf8", "idta", "LIST", "a"]);
let n = rng.below(9);
out.push_str(&format!("<{id} bdata=\"{}\"/>", hex(rng, n)));
}
4..=6 if depth < 10 => {
let id = rng.pick(&["Fold", "tdgp", "Layr", "Pin", "LIST", "btdk", "sspc"]);
let attr = if rng.below(5) == 0 { format!(" bdata=\"{}\"", hex(rng, 3)) } else { String::new() };
out.push_str(&format!("<{id}{attr}>"));
content(rng, depth + 1, budget, out);
out.push_str(&format!("</{id}>"));
}
7 => {
let id = rng.pick(&["tdsn", "pdnm", "fnam"]);
match rng.below(4) {
0 => out.push_str(&format!("<{id}/>")),
1 => out.push_str(&format!("<{id}><string>name &amp; &#x41;</string></{id}>")),
// A body that is itself a chunk sequence (`Utf8`, 1 byte, pad): still data.
2 => out.push_str(&format!("<{id} bdata=\"557466380000000161{}\"/>", if rng.below(2) == 0 { "00" } else { "" })),
_ => out.push_str(&format!("<{id}><string>a</string><string>b</string></{id}>")),
}
}
8 => {
let text = rng.pick(&["plain", "A &lt; B", "<![CDATA[x<y]]>", "a<!-- c -->b", "", "&#233;t&#xE9;", "x<Skip>gone</Skip>y", "\r\n"]);
out.push_str(&format!("<string>{text}</string>"));
}
9 => {
let id = rng.pick(&["numS", "ppSn"]);
let inner = rng.pick(&[
"<string>0</string>",
"<string> 7 </string>",
"<string>-2147483648</string>",
"<string>150.000000000</string>",
"<string>x</string>",
"",
"<string>1</string><string>2</string>",
"<Utf8 bdata=\"31\"/>",
"<cdat bdata=\"31\"/>",
"<Comment>3</Comment><string>4</string>",
]);
let attr = if rng.below(6) == 0 { " bdata=\"00000002\"" } else { "" };
out.push_str(&format!("<{id}{attr}>{inner}</{id}>"));
}
10 => {
let n = rng.pick(&["", "41", "414243", "4c495354000000000000"]);
out.push_str(&format!("<btdk bdata=\"{n}\"/>"));
}
11 => {
let platform = rng.pick(&["Win", "1", "Mac"]);
out.push_str(&format!(
"<Als2><fileReference ascendcount_base=\"1\" fullpath=\"C:\\a\\&quot;b&#x9;.png\" platform=\"{platform}\" target_is_folder=\"{}\"/></Als2>",
rng.below(3)
));
}
12 if depth < 10 => {
out.push_str("<Comment note=\"&amp;\">text<a bdata=\"00\"/>");
content(rng, depth + 1, budget, out);
out.push_str("</Comment>");
}
13 => {
let packet = rng.pick(&["<![CDATA[<?xpacket begin?>x<?xpacket end?>]]>", "plain &amp; text", ""]);
out.push_str(&format!("<ProjectXMPMetadata>{packet}</ProjectXMPMetadata>"));
}
14 => out.push_str(rng.pick(&["<!-- comment -->", "<?pi x?>", "\n ", "stray"])),
_ => {
let n = rng.below(5);
out.push_str(&format!("<tdb4 bdata=\"{}\" x='y'></tdb4>", hex(rng, n)));
}
}
}
}
/// The default limits and six tight ones (depth 0, 1 and 3; 0, 7 and 40 chunks).
fn limit_sets() -> [Limits; 7] {
[
Limits { max_depth: 0, max_chunks: 10_000_000 },
Limits { max_depth: 64, max_chunks: 0 },
Limits::default(),
Limits { max_depth: 1, max_chunks: 10_000_000 },
Limits { max_depth: 3, max_chunks: 10_000_000 },
Limits { max_depth: 64, max_chunks: 7 },
Limits { max_depth: 64, max_chunks: 40 },
]
}
/// The index reader and the owned-tree reader give the same `Result` (the tree, or the error),
/// and the index walks like the index of the owned tree.
fn same(bytes: &[u8], limits: Limits) {
let old = read_aepx_with_limits(bytes, limits);
let new = index_aepx(bytes, limits).map(|d| TreeIndex::from_drafts(&d.buffer, d.nodes, d.trailer).to_file().into_owned());
assert_eq!(new, old, "{}", String::from_utf8_lossy(bytes));
if let Ok(file) = &old {
let AepxDrafts { buffer, nodes, trailer } = index_aepx(bytes, limits).unwrap();
let index = TreeIndex::from_drafts(&buffer, nodes, trailer);
let expected = TreeIndex::from_file(file).unwrap();
assert_eq!(index.len(), expected.len());
assert_eq!(index.trailer(), expected.trailer());
let a: Vec<String> = index.walk().map(|(d, n)| format!("{d} {n:?}")).collect();
let b: Vec<String> = expected.walk().map(|(d, n)| format!("{d} {n:?}")).collect();
assert_eq!(a, b);
}
}
/// The two readers give the same tree and errors on generated and damaged documents.
#[test]
fn the_two_readers_agree_on_generated_and_damaged_documents() {
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
let noise = b"<>/=\"'&;#x0aZ \n![]-?";
for _ in 0..600 {
let mut budget = 80;
let mut body = String::new();
content(&mut rng, 0, &mut budget, &mut body);
let x = doc(&body);
for l in limit_sets() {
same(x.as_bytes(), l);
}
for _ in 0..4 {
let mut bad = x.clone().into_bytes();
for _ in 0..1 + rng.below(3) {
let at = rng.below(bad.len());
bad[at] = noise[rng.below(noise.len())];
}
for l in limit_sets() {
same(&bad, l);
}
}
let cut = rng.below(x.len());
same(&x.as_bytes()[..cut], Limits::default());
// Bytes that are not UTF-8, and a multi-byte character cut in half.
let mut bad = x.clone().into_bytes();
let at = rng.below(bad.len());
bad.insert(at, 0xff);
same(&bad, Limits::default());
let open = format!("{}<string>é", x.trim_end().trim_end_matches("</AfterEffectsProject>")).into_bytes();
same(&open[..open.len() - 1], Limits::default());
}
}
/// The flat chunk list never reserves more slots than the input or the chunk limit allows.
#[test]
fn the_chunk_list_is_bounded_by_the_input_and_the_limit() {
let many = format!("<AfterEffectsProject>{}</AfterEffectsProject>", "<a/>".repeat(10_000));
let d = index_aepx(many.as_bytes(), Limits::default()).unwrap();
assert_eq!(d.nodes.len(), 10_000);
assert!(d.nodes.capacity() <= 2 * 10_000, "{} slots for 10 000 chunks", d.nodes.capacity());
// The buffer is reserved once, at half the input, and holds nothing here.
assert!(d.buffer.is_empty() && d.buffer.capacity() <= many.len() / 2);
// At the limit the list never holds more slots than the limit.
let d = index_aepx(many.as_bytes(), Limits { max_depth: 64, max_chunks: 10_000 }).unwrap();
assert!(d.nodes.capacity() <= 10_000);
let few = format!("<AfterEffectsProject>{}</AfterEffectsProject>", "<a/>".repeat(100));
let d = index_aepx(few.as_bytes(), Limits { max_depth: 64, max_chunks: 100 }).unwrap();
assert!(d.nodes.capacity() <= 100, "{} slots for at most 100 chunks", d.nodes.capacity());
assert_eq!(index_aepx(few.as_bytes(), Limits { max_depth: 64, max_chunks: 99 }).err(), Some(AepError::TooManyChunks));
// A tiny input cannot hold more than a third of its bytes in elements.
let tiny = doc("<a/>");
assert!(index_aepx(tiny.as_bytes(), Limits::default()).unwrap().nodes.capacity() <= tiny.len() / 3);
}
/// What a closed element drops is gone from the buffer and the chunk list, but was counted.
#[test]
fn dropped_content_leaves_nothing_behind_but_still_counts() {
// `bdata` wins over the children: they are read, counted and dropped.
let x = doc("<a bdata=\"01\"><b bdata=\"0203\"/><string>xyz</string><tdsn><string>q</string></tdsn></a>");
let d = index_aepx(x.as_bytes(), Limits::default()).unwrap();
assert_eq!((d.buffer.as_slice(), d.nodes.len()), (&[1u8][..], 1));
assert_eq!(index_aepx(x.as_bytes(), Limits { max_depth: 64, max_chunks: 4 }).err(), Some(AepError::TooManyChunks));
assert!(index_aepx(x.as_bytes(), Limits { max_depth: 64, max_chunks: 5 }).is_ok());
// A child's error is raised although the child would be dropped.
assert!(matches!(index_aepx(doc("<a bdata=\"01\"><numS></numS></a>").as_bytes(), Limits::default()), Err(AepError::Xml { .. })));
// Only the first XMP packet stays in the buffer.
let packets = doc(&"<ProjectXMPMetadata>packet</ProjectXMPMetadata>".repeat(1000));
let d = index_aepx(packets.as_bytes(), Limits::default()).unwrap();
assert_eq!((d.buffer.as_slice(), d.trailer), (&b"packet"[..], (0, 6)));
// A number keeps its 4 or 8 bytes, not its text.
let d = index_aepx(doc("<numS><string> 7 </string></numS><ppSn><string>1.5</string></ppSn>").as_bytes(), Limits::default()).unwrap();
assert_eq!(d.buffer, [&[0, 0, 0, 7][..], &1.5f64.to_be_bytes()[..]].concat());
assert_eq!(d.nodes.len(), 2);
}
/// Bodies keep what the element says they are, even where reading the same bytes as an `.aep`
/// would decide otherwise: a nested id whose `bdata` is a chunk sequence stays data, an empty
/// `btdk` stays an opaque list, an empty list at the deepest level stays a list.
#[test]
fn bodies_keep_the_kind_the_element_gives_them() {
let x = doc("<tdsn bdata=\"5574663800000001610000\"/><btdk bdata=\"\"/><btdk bdata=\"4c4953540000000474657374\"/>");
let AepxDrafts { buffer, nodes, trailer } = index_aepx(x.as_bytes(), Limits::default()).unwrap();
let index = TreeIndex::from_drafts(&buffer, nodes, trailer);
let top: Vec<_> = index.chunks().collect();
assert_eq!(top.len(), 3);
assert_eq!(top[0].bytes().map(<[u8]>::len), Some(11));
assert!(top[0].children().next().is_none());
assert!(top[1].is_opaque_list() && top[1].bytes() == Some(&[][..]));
assert!(top[2].is_opaque_list() && top[2].bytes().map(<[u8]>::len) == Some(12));
// 64 levels: the innermost (empty) list is a list, not opaque.
let deep = format!("<AfterEffectsProject>{}{}</AfterEffectsProject>", "<Fold>".repeat(64), "</Fold>".repeat(64));
let AepxDrafts { buffer, nodes, trailer } = index_aepx(deep.as_bytes(), Limits::default()).unwrap();
let index = TreeIndex::from_drafts(&buffer, nodes, trailer);
assert_eq!(index.walk().map(|(d, _)| d).max(), Some(64));
assert!(index.walk().all(|(_, n)| n.is_list(b"Fold") && !n.is_opaque_list()));
same(x.as_bytes(), Limits::default());
same(deep.as_bytes(), Limits::default());
}
/// Hostile input is refused by both readers with the same errors.
#[test]
fn hostile_input_is_refused_alike_by_both_readers() {
let hostile = [
format!("<AfterEffectsProject>{}{}</AfterEffectsProject>", "<a>".repeat(100_000), "</a>".repeat(100_000)),
format!("<AfterEffectsProject>{}", "<string>".repeat(1000)),
doc(&format!("<a bdata=\"{}0\"/>", "00".repeat(100_000))),
doc(&format!("<a bdata=\"{}zz\"/>", "00".repeat(100_000))),
doc("<numS><string>4294967296</string></numS>"),
doc("<ppSn><string>1e400x</string></ppSn>"),
doc("<string>&#xD800;</string>"),
doc("<string>&#99999999999;</string>"),
doc(&format!("<Fold>{}", "<b/>".repeat(1000))),
format!("<AfterEffectsProject>{}</AfterEffectsProject><x/>", "<a/>".repeat(10)),
"<AfterEffectsProject><a bdata=\"00".to_string(),
"<AfterEffectsProject><![CDATA[x".to_string(),
];
for x in &hostile {
assert!(read_aepx(x.as_bytes()).is_err(), "{}", &x[..x.len().min(80)]);
for l in limit_sets() {
same(x.as_bytes(), l);
}
}
}
/// Every `.aepx` of the fixture directory gives the same tree through both readers (skipped
/// without `EFFECTCRAFT_AEP_FIXTURES`).
#[test]
fn every_fixture_aepx_gives_the_same_tree_through_both_readers() {
let Some(dir) = std::env::var_os("EFFECTCRAFT_AEP_FIXTURES").filter(|d| !d.is_empty()) else { return };
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut paths: Vec<std::path::PathBuf> =
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aepx")).collect();
paths.sort();
for path in paths {
let bytes = std::fs::read(&path).unwrap();
for l in limit_sets() {
let index = index_aepx(&bytes, l).map(|d| TreeIndex::from_drafts(&d.buffer, d.nodes, d.trailer).to_file().into_owned());
assert_eq!(index, read_aepx_with_limits(&bytes, l), "{}", path.display());
}
}
}
}
+361
View File
@@ -0,0 +1,361 @@
//! The `.aepx` reader of the owned chunk tree ([`super::read_aepx`]): it builds the tree element by
//! element. The importer uses the index reader in `aepx.rs` instead; the tests compare the two on
//! generated, damaged and corpus input (the same tree and the same errors).
use std::borrow::Cow;
use super::{FILE_REFERENCE, OPAQUE_LIST_TYPES, ROOT, STRING, XMP, alias_json, chunk_id, decode_entities, decode_hex, xml};
use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS};
use crate::container::error::AepError;
use crate::container::read::Limits;
/// [`super::read_aepx_with_limits`].
pub(super) fn read(data: &[u8], limits: Limits) -> Result<AepFile<'static>, AepError> {
let text = match std::str::from_utf8(data) {
Ok(t) => t.as_bytes(),
Err(e) => return Err(xml(e.valid_up_to(), "the file is not valid UTF-8")),
};
Reader { data: text, pos: 0, limits, count: 0, stack: Vec::new(), top: None, trailer: None }.run()
}
/// What an open element becomes when it closes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
/// The root element: its children are the top-level chunks.
Root,
/// A chunk with this id (or a `LIST` with this list type).
Chunk([u8; 4]),
/// A `Utf8` chunk.
Utf8,
/// An `alas` chunk built from the attributes.
Alias,
/// The XMP packet (only directly below the root).
Xmp,
/// An element without a chunk counterpart (a name that is no chunk id); skipped with its content.
Skip,
}
struct Frame {
kind: Kind,
/// Offset of the start tag (for errors).
at: usize,
/// The name as written, to match the end tag.
name: (usize, usize),
/// The decoded `bdata` attribute.
bdata: Option<Vec<u8>>,
/// The attributes of a `fileReference`, in document order.
attrs: Vec<(String, String)>,
/// Collected text (only for `string` and the XMP packet).
text: Vec<u8>,
children: Vec<Chunk<'static>>,
}
struct Reader<'d> {
data: &'d [u8],
pos: usize,
limits: Limits,
count: usize,
stack: Vec<Frame>,
/// The top-level chunks once the root element has closed.
top: Option<Vec<Chunk<'static>>>,
trailer: Option<Vec<u8>>,
}
impl Reader<'_> {
fn run(mut self) -> Result<AepFile<'static>, AepError> {
if self.data.starts_with(&[0xef, 0xbb, 0xbf]) {
self.pos = 3;
}
while self.pos < self.data.len() {
if self.rest().starts_with(b"<") {
self.markup()?;
} else {
self.text()?;
}
}
if let Some(open) = self.stack.last() {
return Err(xml(open.at, "an element is not closed before the end of the file"));
}
let chunks = self.top.ok_or_else(|| xml(self.data.len(), "no AfterEffectsProject root element"))?;
Ok(AepFile { chunks, trailer: Cow::Owned(self.trailer.unwrap_or_default()) })
}
fn rest(&self) -> &[u8] {
self.data.get(self.pos..).unwrap_or(&[])
}
/// The offset of `needle` at or after `from`.
fn find(&self, from: usize, needle: &[u8]) -> Option<usize> {
let hay = self.data.get(from..)?;
if needle.is_empty() || hay.len() < needle.len() {
return None;
}
let first = *needle.first()?;
let mut i = 0;
while let Some(p) = hay.get(i..)?.iter().position(|b| *b == first) {
let at = i.checked_add(p)?;
if hay.get(at..)?.starts_with(needle) {
return from.checked_add(at);
}
i = at.checked_add(1)?;
}
None
}
/// Skips to just after `close`, which must follow.
fn skip_past(&mut self, close: &[u8], what: &'static str) -> Result<usize, AepError> {
let at = self.find(self.pos, close).ok_or(xml(self.pos, what))?;
self.pos = at.saturating_add(close.len());
Ok(at)
}
fn markup(&mut self) -> Result<(), AepError> {
let rest = self.rest();
if rest.starts_with(b"<?") {
self.skip_past(b"?>", "a processing instruction is not closed")?;
} else if rest.starts_with(b"<!--") {
self.skip_past(b"-->", "a comment is not closed")?;
} else if rest.starts_with(b"<![CDATA[") {
let start = self.pos.saturating_add(9);
self.pos = start;
let end = self.skip_past(b"]]>", "a CDATA section is not closed")?;
let content = self.data.get(start..end).unwrap_or(&[]);
let at = start;
match self.stack.last_mut() {
Some(frame) => {
if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) {
frame.text.extend_from_slice(content);
}
}
None => return Err(xml(at, "CDATA outside the root element")),
}
} else if rest.starts_with(b"<!") {
return Err(xml(self.pos, "document type declarations are not supported"));
} else if rest.starts_with(b"</") {
self.end_tag()?;
} else {
self.start_tag()?;
}
Ok(())
}
fn is_name_byte(b: u8) -> bool {
!(b.is_ascii_whitespace() || matches!(b, b'<' | b'>' | b'/' | b'=' | b'"' | b'\'' | b'&'))
}
fn name(&mut self) -> Result<(usize, usize), AepError> {
let start = self.pos;
let len = self.rest().iter().position(|b| !Self::is_name_byte(*b)).unwrap_or(self.rest().len());
if len == 0 {
return Err(xml(start, "a tag or attribute has no name"));
}
self.pos = start.saturating_add(len);
Ok((start, self.pos))
}
fn skip_ws(&mut self) {
let n = self.rest().iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(self.rest().len());
self.pos = self.pos.saturating_add(n);
}
fn slice(&self, (a, b): (usize, usize)) -> &[u8] {
self.data.get(a..b).unwrap_or(&[])
}
fn start_tag(&mut self) -> Result<(), AepError> {
let at = self.pos;
self.pos = self.pos.saturating_add(1);
let name = self.name()?;
let mut attrs = Vec::new();
let self_closing = loop {
self.skip_ws();
let rest = self.rest();
if rest.starts_with(b"/>") {
self.pos = self.pos.saturating_add(2);
break true;
}
if rest.starts_with(b">") {
self.pos = self.pos.saturating_add(1);
break false;
}
if rest.is_empty() {
return Err(xml(at, "a start tag is not closed"));
}
let key = self.name()?;
self.skip_ws();
if !self.rest().starts_with(b"=") {
return Err(xml(self.pos, "an attribute has no value"));
}
self.pos = self.pos.saturating_add(1);
self.skip_ws();
let quote = *self.rest().first().ok_or(xml(self.pos, "a start tag is not closed"))?;
if quote != b'"' && quote != b'\'' {
return Err(xml(self.pos, "an attribute value is not quoted"));
}
let vstart = self.pos.saturating_add(1);
let vend = self.find(vstart, &[quote]).ok_or(xml(vstart, "an attribute value is not closed"))?;
let raw = self.data.get(vstart..vend).unwrap_or(&[]);
if raw.contains(&b'<') {
return Err(xml(vstart, "`<` inside an attribute value"));
}
// A value without `&` is its own decoding (most of them, every `bdata` After Effects
// writes): borrow it instead of copying it.
let value = if raw.contains(&b'&') {
let mut value = Vec::new();
decode_entities(raw, vstart, &mut value)?;
Cow::Owned(value)
} else {
Cow::Borrowed(raw)
};
self.pos = vend.saturating_add(1);
attrs.push((key, value));
};
let kind = self.kind_of(name, at)?;
if kind != Kind::Root {
self.count = self.count.saturating_add(1);
if self.count > self.limits.max_chunks {
return Err(AepError::TooManyChunks);
}
// The root's children are depth 1, like the top-level chunks of an `.aep`.
if self.stack.len() > self.limits.max_depth {
return Err(AepError::TooDeep);
}
}
let mut frame = Frame { kind, at, name, bdata: None, attrs: Vec::new(), text: Vec::new(), children: Vec::new() };
if matches!(kind, Kind::Chunk(_)) {
if let Some((key, value)) = attrs.iter().find(|(k, _)| self.slice(*k) == b"bdata") {
frame.bdata = Some(decode_hex(value, key.1)?);
}
} else if kind == Kind::Alias {
frame.attrs = attrs.iter().map(|(k, v)| (String::from_utf8_lossy(self.slice(*k)).into_owned(), String::from_utf8_lossy(v).into_owned())).collect();
}
self.stack.push(frame);
if self_closing {
self.close()?;
}
Ok(())
}
/// Classifies an element by its name and where it is.
fn kind_of(&self, name: (usize, usize), at: usize) -> Result<Kind, AepError> {
let n = self.slice(name);
let parent = match self.stack.last() {
None => {
if self.top.is_some() {
return Err(xml(at, "a second root element"));
}
if n != ROOT {
return Err(xml(at, "the root element is not AfterEffectsProject"));
}
return Ok(Kind::Root);
}
Some(p) => p.kind,
};
Ok(match parent {
Kind::Skip | Kind::Utf8 | Kind::Xmp | Kind::Alias => Kind::Skip,
Kind::Root | Kind::Chunk(_) => {
if n == STRING {
Kind::Utf8
} else if n == FILE_REFERENCE {
Kind::Alias
} else if n == XMP && parent == Kind::Root {
Kind::Xmp
} else if let Some(id) = chunk_id(n) {
Kind::Chunk(id)
} else {
Kind::Skip
}
}
})
}
fn end_tag(&mut self) -> Result<(), AepError> {
let at = self.pos;
self.pos = self.pos.saturating_add(2);
let name = self.name()?;
self.skip_ws();
if !self.rest().starts_with(b">") {
return Err(xml(at, "an end tag is not closed"));
}
self.pos = self.pos.saturating_add(1);
let open = self.stack.last().ok_or(xml(at, "an end tag without a start tag"))?;
if self.slice(open.name) != self.slice(name) {
return Err(xml(at, "an end tag does not match its start tag"));
}
self.close()
}
/// Closes the innermost element and hands its chunk to the parent.
fn close(&mut self) -> Result<(), AepError> {
let frame = self.stack.pop().ok_or(xml(self.pos, "an end tag without a start tag"))?;
let chunk = match frame.kind {
Kind::Root => {
self.top = Some(frame.children);
return Ok(());
}
Kind::Skip => return Ok(()),
Kind::Xmp => {
if self.trailer.is_none() {
self.trailer = Some(frame.text);
}
return Ok(());
}
Kind::Utf8 => Chunk::data(*b"Utf8", frame.text),
Kind::Alias => Chunk::data(*b"alas", alias_json(&frame.attrs).into_bytes()),
Kind::Chunk(id) => chunk_of(id, frame)?,
};
if let Some(parent) = self.stack.last_mut() {
parent.children.push(chunk);
}
Ok(())
}
fn text(&mut self) -> Result<(), AepError> {
let start = self.pos;
let len = self.rest().iter().position(|b| *b == b'<').unwrap_or(self.rest().len());
let end = start.saturating_add(len);
self.pos = end;
let raw = self.data.get(start..end).unwrap_or(&[]);
match self.stack.last_mut() {
Some(frame) if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) => decode_entities(raw, start, &mut frame.text),
Some(_) => Ok(()),
None if raw.iter().all(u8::is_ascii_whitespace) => Ok(()),
None => Err(xml(start, "text outside the root element")),
}
}
}
/// The chunk of a closed element named by a chunk id.
fn chunk_of(id: [u8; 4], frame: Frame) -> Result<Chunk<'static>, AepError> {
if let Some(bytes) = frame.bdata {
if OPAQUE_LIST_TYPES.contains(&id) {
let pad = if bytes.len() % 2 == 1 { Some(0) } else { None };
return Ok(Chunk { id: LIST, body: Body::OpaqueList { list_type: id, data: Cow::Owned(bytes) }, pad });
}
return Ok(Chunk::data(id, bytes));
}
// Numbers written as text: `<numS><string>0</string></numS>` and `<ppSn><string>150.000000000</string></ppSn>`.
if &id == b"numS" || &id == b"ppSn" {
let text = match frame.children.as_slice() {
[only] if only.is(b"Utf8") => only.bytes().unwrap_or(&[]),
_ => return Err(xml(frame.at, "a numS or ppSn element without its number")),
};
let text = std::str::from_utf8(text).map_err(|_| xml(frame.at, "a number is not text"))?.trim();
let body = if &id == b"numS" {
// Only `0` is sampled, so whether the field is signed is open: accept both readings of 4 bytes.
let n = text.parse::<i64>().ok().filter(|n| (i64::from(i32::MIN)..=i64::from(u32::MAX)).contains(n));
let n = n.ok_or(xml(frame.at, "numS is not a 32-bit integer"))?;
(n as u32).to_be_bytes().to_vec()
} else {
text.parse::<f64>().map_err(|_| xml(frame.at, "ppSn is not a number"))?.to_be_bytes().to_vec()
};
return Ok(Chunk::data(id, body));
}
if NESTED_IDS.contains(&id) {
if frame.children.is_empty() {
return Ok(Chunk::data(id, Vec::new()));
}
return Ok(Chunk { id, body: Body::Chunks(frame.children), pad: None });
}
Ok(Chunk::list(id, frame.children))
}
+7 -7
View File
@@ -7,9 +7,9 @@
//! Hue/Saturation (444-446) or a Levels value (512-516) changes only bytes of this `aRbp` (and stale
//! UI bytes of `pard`); the visible parameters of the same names keep no record.
use crate::container::Chunk;
use crate::container::bytes::{be_f32, be_i32, be_u16, be_u32};
use crate::container::effects::Effect;
use crate::container::index::Node;
impl<'f, 'a> Effect<'f, 'a> {
/// The arbitrary data stored for the parameter with this match name (`<effect>-NNNN`): the
@@ -17,13 +17,13 @@ impl<'f, 'a> Effect<'f, 'a> {
/// none.
pub fn arbitrary_data(&self, match_name: &str) -> Option<&'f [u8]> {
let values = self.chunk().child_list(b"tdgp")?;
let kids = values.children();
let at = kids.iter().position(|c| c.is(b"tdmn") && c.bytes().is_some_and(|b| name_is(b, match_name)))?;
// The value record, then the data list (no match name of its own).
let rest = kids.get(at.checked_add(1)?..)?;
let mut it = rest.iter().take(3);
let mut kids = values.children();
// Up to the parameter's match name, then the value record, then the data list (no match
// name of its own).
kids.find(|c| c.is(b"tdmn") && c.bytes().is_some_and(|b| name_is(b, match_name)))?;
let mut it = kids.take(3);
let _record = it.next().filter(|c| c.is_list(b"tdbs"))?;
it.find(|c| c.is_list(b"aRbs")).and_then(|l| l.child(b"aRbp")).and_then(Chunk::bytes)
it.find(|c| c.is_list(b"aRbs")).and_then(|l| l.child(b"aRbp")).and_then(Node::bytes)
}
}
-89
View File
@@ -16,7 +16,6 @@
//! only when it is not the default 100.
use crate::container::typed::{Layer, PropNode};
use crate::container::values::{Tracked, ValueSource, read, read_opt};
/// The Position of a camera or light layer as stored.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -28,16 +27,6 @@ pub struct CameraPosition {
pub auto_state: bool,
}
impl CameraPosition {
/// The position as the batch-3 notes had After Effects report it: in the auto state of a
/// two-node camera (`two_node`) x and y are the composition centre; otherwise the stored
/// value. **Contradicted by 256 and 537 (session S16): After Effects reports and renders the
/// stored value**; use `stored` for what After Effects shows.
pub fn reported(&self, comp_width: u32, comp_height: u32, two_node: bool) -> [f64; 3] {
if self.auto_state && two_node { [f64::from(comp_width) / 2.0, f64::from(comp_height) / 2.0, self.stored[2]] } else { self.stored }
}
}
impl<'f, 'a> Layer<'f, 'a> {
/// The Position record of a camera or light layer.
pub fn camera_position(&self) -> Option<CameraPosition> {
@@ -49,87 +38,9 @@ impl<'f, 'a> Layer<'f, 'a> {
let derived = h.get(76) == Some(&1) && h.get(77) == Some(&1) && position.expression().is_none();
Some(CameraPosition { stored, auto_state: derived })
}
/// The point of interest of a camera in pixels (its Anchor Point record); `None` when absent.
pub fn camera_point_of_interest(&self) -> Option<[f64; 3]> {
let Some(PropNode::Group(transform)) = self.properties()?.get("ADBE Transform Group") else { return None };
let tracked = read(&transform, "ADBE Anchor Point", [0.0; 3], 1.0);
matches!(tracked.source, ValueSource::Stored | ValueSource::Keyframed).then_some(tracked.value)
}
/// A light's intensity in percent; the default 100 when the property is absent.
pub fn light_intensity(&self) -> Option<Tracked<f64>> {
let root = self.properties()?;
let Some(PropNode::Group(options)) = root.get("ADBE Light Options Group") else { return None };
let t = read(&options, "ADBE Light Intensity", [100.0], 1.0);
Some(Tracked { value: t.value[0], source: t.source })
}
}
/// The camera zoom After Effects uses when `ADBE Camera Zoom` is absent: 2666.67 px for the 50 mm
/// default camera in the 1920 px wide corpus composition. Whether it follows other composition
/// widths was not sampled.
pub const DEFAULT_CAMERA_ZOOM: f64 = 2_666.666_666_666_666_5;
impl<'f, 'a> Layer<'f, 'a> {
fn camera_options(&self) -> Option<crate::container::typed::PropGroup<'f, 'a>> {
match self.properties()?.get("ADBE Camera Options Group") {
Some(PropNode::Group(g)) => Some(g),
_ => None,
}
}
fn light_options(&self) -> Option<crate::container::typed::PropGroup<'f, 'a>> {
match self.properties()?.get("ADBE Light Options Group") {
Some(PropNode::Group(g)) => Some(g),
_ => None,
}
}
/// A camera's zoom in pixels (`ADBE Camera Options Group/ADBE Camera Zoom`); absent = the
/// default, [`DEFAULT_CAMERA_ZOOM`] (341: 2000 is stored). The auto Position keeps `-2666.67` and
/// is not recomputed from the zoom.
pub fn camera_zoom(&self) -> Option<Tracked<f64>> {
let t = read(&self.camera_options()?, "ADBE Camera Zoom", [DEFAULT_CAMERA_ZOOM], 1.0);
Some(Tracked { value: t.value[0], source: t.source })
}
/// The depth of field switch (`ADBE Camera Depth of Field`, a checkbox; off by default, 342).
pub fn camera_depth_of_field(&self) -> Option<Tracked<bool>> {
let t = read(&self.camera_options()?, "ADBE Camera Depth of Field", [0.0], 1.0);
Some(Tracked { value: t.value[0] != 0.0, source: t.source })
}
/// A camera's focus distance in pixels (`ADBE Camera Focus Distance`); `None` while absent (its
/// default is not known).
pub fn camera_focus_distance(&self) -> Option<Tracked<f64>> {
let t = read_opt::<1>(&self.camera_options()?, "ADBE Camera Focus Distance", 1.0)?;
Some(Tracked { value: t.value[0], source: t.source })
}
/// A camera's aperture (`ADBE Camera Aperture`); `None` while absent (its default is not known).
pub fn camera_aperture(&self) -> Option<Tracked<f64>> {
let t = read_opt::<1>(&self.camera_options()?, "ADBE Camera Aperture", 1.0)?;
Some(Tracked { value: t.value[0], source: t.source })
}
/// A light's colour as `[A, R, G, B]` fractions (`ADBE Light Options Group/ADBE Light Color`,
/// stored as doubles 0..255); white when absent (343 stores red).
pub fn light_color(&self) -> Option<Tracked<[f64; 4]>> {
Some(read(&self.light_options()?, "ADBE Light Color", [1.0; 4], 1.0 / 255.0))
}
/// A spot light's cone angle in degrees (`ADBE Light Cone Angle`); 90 when absent (344 stores 60).
/// A point light stores 90 with `tdsb` 3.
pub fn light_cone_angle(&self) -> Option<Tracked<f64>> {
let t = read(&self.light_options()?, "ADBE Light Cone Angle", [90.0], 1.0);
Some(Tracked { value: t.value[0], source: t.source })
}
/// A spot light's cone feather in percent (`ADBE Light Cone Feather 2`); 50 when absent (345
/// stores 30).
pub fn light_cone_feather(&self) -> Option<Tracked<f64>> {
let t = read(&self.light_options()?, "ADBE Light Cone Feather 2", [50.0], 1.0);
Some(Tracked { value: t.value[0], source: t.source })
}
}
+27 -25
View File
@@ -20,8 +20,8 @@
use std::collections::{HashMap, HashSet};
use crate::container::Chunk;
use crate::container::bytes::{array, be_f32, be_f64, be_i32, be_u16, be_u32, cstr, text, until_nul};
use crate::container::index::Node;
use crate::container::typed::{KeyList, Layer, Project, PropGroup, PropNode, Property};
/// The parameter type, the `u32` at byte 12 of `pard`. The numbering matches the plug-in
@@ -132,7 +132,7 @@ pub enum LayerRef {
#[derive(Debug, Clone)]
pub struct Effect<'f, 'a> {
match_name: String,
chunk: &'f Chunk<'a>,
chunk: Node<'f, 'a>,
}
/// Where the parameter definitions of an effect instance come from.
@@ -178,13 +178,13 @@ impl<'f, 'a> Effect<'f, 'a> {
}
/// The underlying `LIST sspc` chunk.
pub fn chunk(&self) -> &'f Chunk<'a> {
pub fn chunk(&self) -> Node<'f, 'a> {
self.chunk
}
/// The instance name shown in the effect controls (`fnam`).
pub fn name(&self) -> Option<String> {
self.chunk.child(b"fnam")?.children().iter().find(|c| c.is(b"Utf8"))?.bytes().map(text)
self.chunk.child(b"fnam")?.child(b"Utf8")?.bytes().map(text)
}
/// The name the user gave the instance: the `tdsn` of the instance's value group when it is
@@ -192,7 +192,7 @@ impl<'f, 'a> Effect<'f, 'a> {
/// `None` while the instance shows the effect's name.
pub fn user_name(&self) -> Option<String> {
let values = self.chunk.child_list(b"tdgp")?;
let name = values.child(b"tdsn")?.children().iter().find(|c| c.is(b"Utf8"))?.bytes().map(text)?;
let name = values.child(b"tdsn")?.child(b"Utf8")?.bytes().map(text)?;
(name != "-_0_/-" && !name.is_empty()).then_some(name)
}
@@ -250,7 +250,7 @@ impl<'f, 'a> Effect<'f, 'a> {
}
/// The parameters described by `part` (a `LIST parT`) with the values of this instance.
fn params_from(&self, part: &'f Chunk<'a>, borrowed: bool) -> Vec<EffectParam<'f, 'a>> {
fn params_from(&self, part: Node<'f, 'a>, borrowed: bool) -> Vec<EffectParam<'f, 'a>> {
let mut out: Vec<EffectParam<'f, 'a>> = Vec::new();
// The stored values by match name: the first entry of a name, as `PropGroup::get` finds it
// (a group of that name means no stored value). Indexed once: a lookup per parameter
@@ -280,7 +280,7 @@ impl<'f, 'a> Effect<'f, 'a> {
});
} else if c.is(b"pdnm")
&& let Some(last) = out.last_mut()
&& let Some(u) = c.children().iter().find(|u| u.is(b"Utf8"))
&& let Some(u) = c.child(b"Utf8")
&& let Some(b) = u.bytes()
// An empty `pdnm` names nothing (EffectCraft's exports write one: the effect
// supplies its items).
@@ -293,8 +293,9 @@ impl<'f, 'a> Effect<'f, 'a> {
}
}
fn has_pard(part: &Chunk<'_>) -> bool {
part.children().iter().any(|c| c.is(b"pard"))
/// True when a `LIST parT` holds parameter definitions (a `pard`).
fn has_pard(part: Node<'_, '_>) -> bool {
part.children().any(|c| c.is(b"pard"))
}
impl<'f, 'a> Project<'f, 'a> {
@@ -302,18 +303,18 @@ impl<'f, 'a> Project<'f, 'a> {
/// name and the `LIST parT` of the copied instance, in file order (the stack order of the
/// types, 365/366). The `EfDC` count before them is little-endian and is not needed to read
/// them. A type whose copy has no definitions is left out.
pub fn effect_definitions(&self) -> Vec<(String, &'f Chunk<'a>)> {
let mut out: Vec<(String, &'f Chunk<'a>)> = Vec::new();
pub fn effect_definitions(&self) -> Vec<(String, Node<'f, 'a>)> {
let mut out: Vec<(String, Node<'f, 'a>)> = Vec::new();
let Some(defs) = self.effect_defs else { return out };
let mut seen: HashSet<String> = HashSet::new();
for df in defs.children().iter().filter(|c| c.is_list(b"EfDf")) {
for df in defs.children().filter(|c| c.is_list(b"EfDf")) {
let mut name: Option<String> = None;
for c in df.children() {
if c.is(b"tdmn") {
name = c.bytes().map(|b| text(until_nul(b)));
} else if c.is_list(b"sspc")
&& let Some(n) = name.take()
&& let Some(part) = c.child_list(b"parT").filter(|p| has_pard(p))
&& let Some(part) = c.child_list(b"parT").filter(|p| has_pard(*p))
&& seen.insert(n.clone())
{
out.push((n, part));
@@ -328,8 +329,8 @@ impl<'f, 'a> Project<'f, 'a> {
/// For a type without a project-level copy it also keeps the own `parT` of the first instance
/// in the file (items, layers and effects in file order) that has one.
pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> {
let by_name: HashMap<String, &'f Chunk<'a>> = self.effect_definitions().into_iter().collect();
let mut instances: HashMap<String, &'f Chunk<'a>> = HashMap::new();
let by_name: HashMap<String, Node<'f, 'a>> = self.effect_definitions().into_iter().collect();
let mut instances: HashMap<String, Node<'f, 'a>> = HashMap::new();
for item in self.all_items() {
let Some(comp) = item.comp() else { continue };
for layer in comp.layers() {
@@ -337,7 +338,7 @@ impl<'f, 'a> Project<'f, 'a> {
if by_name.contains_key(&fx.match_name) || instances.contains_key(&fx.match_name) {
continue;
}
if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(p)) {
if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(*p)) {
instances.insert(fx.match_name, part);
}
}
@@ -363,9 +364,9 @@ impl<'f, 'a> Project<'f, 'a> {
#[derive(Debug, Clone, Default)]
pub struct EffectDefinitions<'f, 'a> {
/// The project-level copies (`EfdG`).
by_name: HashMap<String, &'f Chunk<'a>>,
by_name: HashMap<String, Node<'f, 'a>>,
/// For a type without a project-level copy: the own `parT` of the first instance that has one.
instances: HashMap<String, &'f Chunk<'a>>,
instances: HashMap<String, Node<'f, 'a>>,
}
impl<'f, 'a> EffectDefinitions<'f, 'a> {
@@ -392,7 +393,7 @@ impl<'f, 'a> EffectDefinitions<'f, 'a> {
return effect.params();
}
match self.by_name.get(effect.match_name()).or_else(|| self.instances.get(effect.match_name())) {
Some(part) => effect.params_from(part, true),
Some(part) => effect.params_from(*part, true),
None => Vec::new(),
}
}
@@ -638,6 +639,7 @@ impl<'f, 'a> Layer<'f, 'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::container::Chunk;
fn tdmn(name: &str) -> Chunk<'static> {
let mut b = name.as_bytes().to_vec();
@@ -670,11 +672,11 @@ mod tests {
("A".to_string(), Chunk::list(*b"tdbs", vec![Chunk::data(*b"cdat", vec![0u8; 8])])),
];
let chunk = instance(&names, &stored);
let fx = Effect { match_name: "X".into(), chunk: &chunk };
let fx = Effect { match_name: "X".into(), chunk: Node::from(&chunk) };
let params = fx.params();
assert_eq!(params.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), ["A", "B", "C"]);
let first = params.first().and_then(|p| p.stored).expect("A stored");
assert!(first.chunk.children().is_empty(), "the first A (the second has a cdat)");
assert!(first.chunk.children().next().is_none(), "the first A (the second has a cdat)");
assert!(params.get(1).is_some_and(|p| p.stored.is_none()), "a group of that name is no stored value");
assert!(params.get(2).is_some_and(|p| p.stored.is_none()));
}
@@ -687,7 +689,7 @@ mod tests {
let names: Vec<String> = (0..n).map(|i| format!("P{i}")).collect();
let stored: Vec<(String, Chunk<'static>)> = names.iter().map(|m| (m.clone(), tdbs())).collect();
let chunk = instance(&names, &stored);
let fx = Effect { match_name: "X".into(), chunk: &chunk };
let fx = Effect { match_name: "X".into(), chunk: Node::from(&chunk) };
let params = fx.params();
assert_eq!(params.len(), n);
assert!(params.iter().all(|p| p.stored.is_some()));
@@ -707,19 +709,19 @@ mod tests {
}
let efdg = Chunk::list(*b"EfdG", defs);
let fold = Chunk::list(*b"Fold", Vec::new());
let project = Project { fold: &fold, effect_defs: Some(&efdg) };
let project = Project { fold: Node::from(&fold), effect_defs: Some(Node::from(&efdg)) };
let all = project.effect_definitions();
assert_eq!(all.len(), n);
let index = project.effect_definition_index();
let bare = Chunk::list(*b"sspc", vec![Chunk::list(*b"parT", Vec::new())]);
for i in (0..n).step_by(50) {
let fx = Effect { match_name: format!("FX{i}"), chunk: &bare };
let fx = Effect { match_name: format!("FX{i}"), chunk: Node::from(&bare) };
assert_eq!(index.source(&fx), DefinitionSource::Project);
let params = index.params(&fx);
assert_eq!(params.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), ["R0"], "the first copy's definitions");
assert!(params.iter().all(|p| p.borrowed_definition));
}
let unknown = Effect { match_name: "nope".into(), chunk: &bare };
let unknown = Effect { match_name: "nope".into(), chunk: Node::from(&bare) };
assert_eq!(index.source(&unknown), DefinitionSource::Missing);
assert!(index.params(&unknown).is_empty());
assert_eq!(project.definition_source(&unknown), DefinitionSource::Missing);
+6 -34
View File
@@ -2,8 +2,8 @@
//! of a layer's source.
use crate::container::bytes::{be_f32, be_f64, be_i32, be_u32, cstr, le_u32, text, until_nul};
use crate::container::chunk::Chunk;
use crate::container::header::Rational;
use crate::container::index::Node;
use crate::container::typed::{Item, ItemKind, Layer, Project};
/// The footage data of an item (`LIST Pin ` with `sspc`, `opti` and, for files, `Als2/alas`).
@@ -101,21 +101,6 @@ impl PsdLayer {
}
impl Footage {
/// The number of frames of an image sequence (`duration * native_frame_rate`: 3/30 s at 30 fps is
/// 3); `None` for anything else.
pub fn sequence_frame_count(&self) -> Option<u32> {
if !self.is_sequence {
return None;
}
let d = self.duration?;
let frames = i128::from(d.num).checked_mul(i128::from(self.native_frame_rate))?;
let den = i128::from(d.den);
if den <= 0 || frames % den != 0 {
return None;
}
u32::try_from(frames / den).ok()
}
/// The solid's colour as fractions R, G, B.
pub fn solid_rgb(&self) -> Option<[f32; 3]> {
let [_, r, g, b] = self.solid_argb?;
@@ -269,14 +254,13 @@ impl SequenceInfo {
/// is unreadable (empty). A name that is not a plain file name (holding `/`, `\`, `:` or a control
/// character such as NUL, or being `.` or `..`) makes the whole list unreadable (empty): the names
/// are joined to the folder.
fn sequence_file_list(list: &Chunk<'_>) -> Vec<String> {
fn sequence_file_list(list: Node<'_, '_>) -> Vec<String> {
// After Effects writes the count first (the one sample); a list without a readable count is
// not read rather than guessed.
let Some(count) = list.child(b"StVS").and_then(|c| c.bytes()).and_then(|b| le_u32(b, 0)).and_then(|n| usize::try_from(n).ok()) else {
return Vec::new();
};
let names: Vec<String> =
list.children().iter().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).take(count).map(text).filter(|n| !n.is_empty()).collect();
let names: Vec<String> = list.children().filter(|c| c.is(b"Utf8")).filter_map(Node::bytes).take(count).map(text).filter(|n| !n.is_empty()).collect();
if names.iter().any(|n| n.contains(|c: char| matches!(c, '/' | '\\' | ':') || c.is_control()) || n == "." || n == "..") {
return Vec::new();
}
@@ -308,9 +292,8 @@ impl<'f, 'a> Item<'f, 'a> {
}
let pin = self.chunk.child_list(b"Pin ")?;
let sspc = pin.child(b"sspc")?.bytes()?;
let kids = pin.children();
let after = kids.iter().position(|c| c.is_list(b"Als2"))?;
let naming = || kids.iter().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti"));
let after = pin.children().position(|c| c.is_list(b"Als2"))?;
let naming = || pin.children().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti"));
let (start, end, digits) = (be_u32(sspc, 172)?, be_u32(sspc, 176)?, be_u32(sspc, 180)?);
// Force alphabetical order: the file list replaces the numbering.
if let Some(list) = naming().find(|c| c.is_list(b"StVc")) {
@@ -320,7 +303,7 @@ impl<'f, 'a> Item<'f, 'a> {
}
return Some(SequenceInfo { prefix: String::new(), suffix: String::new(), start, end, digits, files });
}
let names: Vec<String> = naming().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text).take(2).collect();
let names: Vec<String> = naming().filter(|c| c.is(b"Utf8")).filter_map(Node::bytes).map(text).take(2).collect();
let (prefix, suffix) = match names.as_slice() {
[prefix, suffix] => (prefix.clone(), suffix.clone()),
// An empty prefix is not stored (`0001.png` has only `.png`).
@@ -350,12 +333,6 @@ impl<'f, 'a> Item<'f, 'a> {
self.chunk.child(b"idta")?.bytes()?.get(58).copied()
}
/// The ids of the compositions this item uses as precomp layers: the little-endian `u32` of
/// each `idpi` chunk.
pub fn used_item_ids(&self) -> Vec<u32> {
self.chunk.children().iter().filter(|c| c.is(b"idpi")).filter_map(|c| le_u32(c.bytes()?, 0)).collect()
}
/// The footage data of a footage item.
pub fn footage(&self) -> Option<Footage> {
if self.kind()? != ItemKind::Footage {
@@ -405,11 +382,6 @@ impl<'f, 'a> Project<'f, 'a> {
self.item_by_id(layer.header()?.source_id?)
}
/// The name a layer shows: its own name, or its source's name when it has none.
pub fn layer_display_name(&self, layer: &Layer<'f, 'a>) -> Option<String> {
layer.name().or_else(|| self.layer_source(layer)?.display_name())
}
/// The pixel size of a layer's source: a footage item's width and height, or a precomp's
/// composition size.
pub fn layer_source_size(&self, layer: &Layer<'f, 'a>) -> Option<(u32, u32)> {
-81
View File
@@ -237,12 +237,6 @@ impl LayerHeader {
self.flag_bytes.get(byte).is_some_and(|b| b & mask != 0)
}
/// Byte 37 bit `0x01`: the layer has a name set by the user (clear on a default-named camera or
/// light and on a precomp layer, whose name is the source's).
pub fn name_is_user_set(&self) -> bool {
self.flag(0, 0x01)
}
/// Byte 37 bit `0x02`: guide layer.
pub fn is_guide(&self) -> bool {
self.flag(0, 0x02)
@@ -333,17 +327,6 @@ impl LayerHeader {
Rational::new(i64::from(self.stretch), i64::from(self.stretch_den))
}
/// The time stretch in percent (`stretch / stretch_den * 100`); a zero denominator reads as 1.
pub fn stretch_percent(&self) -> f64 {
let den = if self.stretch_den == 0 { 1.0 } else { f64::from(self.stretch_den) };
f64::from(self.stretch) / den * 100.0
}
/// The start time in seconds, `None` for a zero denominator.
pub fn start_seconds(&self) -> Option<f64> {
self.start.to_f64()
}
/// The absolute in point, `start + in * stretch`, exact and reduced.
pub fn in_point(&self) -> Option<Rational> {
self.start.plus_scaled(&self.in_relative, &self.stretch_ratio()?)
@@ -355,16 +338,6 @@ impl LayerHeader {
pub fn out_point(&self) -> Option<Rational> {
self.start.plus_scaled(&self.out_relative, &self.stretch_ratio()?)
}
/// The absolute in point in seconds.
pub fn in_point_seconds(&self) -> Option<f64> {
self.in_point()?.to_f64()
}
/// The absolute out point in seconds.
pub fn out_point_seconds(&self) -> Option<f64> {
self.out_point()?.to_f64()
}
}
impl<'f, 'a> Layer<'f, 'a> {
@@ -464,64 +437,10 @@ impl CompHeader {
self.time_base.frame_rate()
}
/// The frame rate from the 16.16 fixed-point field (25 is `0x0019_0000`).
pub fn frame_rate_from_fixed(&self) -> f64 {
f64::from(self.frame_rate_fixed) / 65536.0
}
/// The duration in seconds.
pub fn duration_seconds(&self) -> Option<f64> {
self.time_base.seconds(i32::try_from(self.duration_units).ok()?)
}
/// The work area start in seconds.
pub fn work_area_start_seconds(&self) -> Option<f64> {
self.time_base.seconds(i32::try_from(self.work_area_start_units).ok()?)
}
/// The work area end in seconds; the composition duration when the work area is the whole
/// composition.
pub fn work_area_end_seconds(&self) -> Option<f64> {
match self.work_area_end_units {
Some(end) => self.time_base.seconds(i32::try_from(end).ok()?),
None => self.duration_seconds(),
}
}
/// The background colour as fractions 0..1 (`byte / 255`).
pub fn background_rgb(&self) -> [f64; 3] {
self.background.map(|c| f64::from(c) / 255.0)
}
/// The pixel aspect ratio as a number.
pub fn pixel_aspect_ratio(&self) -> Option<f64> {
self.pixel_aspect.to_f64()
}
/// The display start time in seconds (the start timecode), `None` when absent or the
/// denominator is zero.
pub fn display_start_seconds(&self) -> Option<f64> {
self.display_start?.to_f64()
}
/// The display start as a frame number (`display start * frame rate`, rounded); 100 at 25 fps
/// for a display start of 4 s.
pub fn display_start_frame(&self) -> Option<i64> {
let rate = self.frame_rate()?;
Some((self.display_start_seconds()? * rate).round() as i64)
}
/// The frame rate as an exact fraction `timescale / frame_duration`, as typed: 29.97 fps is
/// `23976 / 800` (not `30000 / 1001`), 23.976 fps is `23976 / 1000`.
pub fn frame_rate_exact(&self) -> Option<Rational> {
Rational::new(i64::from(self.time_base.timescale), i64::from(self.time_base.frame_duration))
}
/// The duration in whole frames (`duration / frame_duration`). After Effects snaps a duration
/// to a whole number of frames: 5 s at 29.97 fps becomes 150 frames (5.005 s).
pub fn duration_frames(&self) -> Option<u32> {
self.duration_units.checked_div(self.time_base.frame_duration)
}
}
impl<'f, 'a> Comp<'f, 'a> {
File diff suppressed because it is too large Load Diff
+1 -40
View File
@@ -193,19 +193,10 @@ impl<'f> Keyframe<'f> {
}
}
/// The value of a 3-component spatial keyframe (Position), x, y and z in pixels.
pub fn spatial_value(&self) -> Option<[f64; 3]> {
let v = self.value()?;
if !matches!(self.kind(), KeyframeKind::Spatial { components: 3 }) {
return None;
}
Some([*v.first()?, *v.get(1)?, *v.get(2)?])
}
/// The ease of the key: the four slots at byte 16 of a one-component plain record or at byte 24
/// of a spatial record or a mask path record. For linear and hold keys all four are 0 (a bezier key stores what
/// `setTemporalEase` was given). Plain records with several components keep four slots per
/// component, see [`Keyframe::ease_component`] and [`Keyframe::plain_ease_slots`].
/// component, see [`Keyframe::ease_component`].
pub fn ease(&self) -> Option<KeyEase> {
match self.kind() {
KeyframeKind::Plain { components: 1 } => ease_at(self.record, 16),
@@ -214,16 +205,6 @@ impl<'f> Keyframe<'f> {
}
}
/// The `4 n` ease `f64` of a plain record as stored (from byte `8 + 8 n`). With one component
/// they are [`Keyframe::ease`]; with several they are type-major, see
/// [`Keyframe::ease_component`].
pub fn plain_ease_slots(&self) -> Option<Vec<f64>> {
match self.kind() {
KeyframeKind::Plain { components } => doubles(self.record, components.checked_mul(8)?.checked_add(8)?, components.checked_mul(4)?),
_ => None,
}
}
/// The ease of one value component. A plain record with `n` components stores the `4 n` ease
/// doubles **type-major**: `[in speed x n][in influence x n][out speed x n][out influence x n]`
/// starting at byte `8 + 8 n`, and component `i` uses slot `i` of each block (verified for the
@@ -260,16 +241,6 @@ impl<'f> Keyframe<'f> {
(0..n).map(|i| self.ease_component(i)).collect()
}
/// The Euclidean ARGB distance to the next key that After Effects writes at byte 16 of a colour
/// (4-component spatial) record whose interpolation is not linear: 255 sqrt(2) = 360.6245 for
/// red to blue; 0 on the last key and on linear keys. `None` for other records.
pub fn color_distance(&self) -> Option<f64> {
match self.kind() {
KeyframeKind::Spatial { components: 4 } => be_f64(self.record, 16),
_ => None,
}
}
/// The decoded spatial record (Position: 128 bytes, colour: 152 bytes).
pub fn spatial(&self) -> Option<SpatialKey> {
let KeyframeKind::Spatial { components: n } = self.kind() else { return None };
@@ -300,11 +271,6 @@ pub struct ExpressionFlags {
}
impl ExpressionFlags {
/// True when bytes 76, 77 and 120 are all 1.
pub fn all_set(&self) -> bool {
self.byte_76 == 1 && self.byte_77 == 1 && self.byte_120 == 1
}
/// True when byte 119 is 1.
pub fn disabled(&self) -> bool {
self.byte_119 == 1
@@ -324,11 +290,6 @@ impl<'f, 'a> Property<'f, 'a> {
be_u32(self.chunk.child(b"tdsb")?.bytes()?, 0)
}
/// True when `tdb4` byte 5 has bit 0 set: the property is static, it has no keyframes.
pub fn is_static_flag_set(&self) -> Option<bool> {
Some(self.header()?.get(5)? & 1 != 0)
}
/// The expression source: the raw UTF-8 text of the `Utf8` child that follows the value in the
/// property (no length prefix, no engine tag; the engine is project-wide).
pub fn expression(&self) -> Option<String> {
+8 -16
View File
@@ -6,9 +6,9 @@
//! property (`tdbs`, with a key list when animated) and `omks` with one `LIST shap` per keyframe (a
//! single one when the path is static).
use crate::container::Chunk;
use crate::container::bytes::{be_f32, be_u32, until_nul};
use crate::container::typed::{KeyList, Layer, Project, PropGroup, PropNode, Property};
use crate::container::index::Node;
use crate::container::typed::{KeyList, Layer, PropGroup, PropNode, Property};
use crate::container::values::{Tracked, read};
/// The mask mode (`mkif` `u32` at byte 4).
@@ -93,8 +93,10 @@ pub struct ShapePath {
}
impl ShapePath {
/// Decodes a `LIST shap`; `None` when `shph` or the point list is malformed.
pub fn from_chunk(shap: &Chunk<'_>) -> Option<Self> {
/// Decodes a `LIST shap` (a [`Chunk`](crate::container::Chunk) or a [`Node`]); `None` when
/// `shph` or the point list is malformed.
pub fn from_chunk<'f, 'a: 'f>(shap: impl Into<Node<'f, 'a>>) -> Option<Self> {
let shap = shap.into();
let shph = shap.child(b"shph")?.bytes()?;
let bbox = [be_f32(shph, 4)?, be_f32(shph, 8)?, be_f32(shph, 12)?, be_f32(shph, 16)?];
let closed_flag = *shph.get(20)?;
@@ -187,22 +189,12 @@ fn mask_from_atom(atom: &PropGroup<'_, '_>, mkif: Option<&[u8]>) -> Mask {
}
}
if let Some(omks) = oms.child_list(b"omks") {
mask.shapes = omks.children().iter().filter(|c| c.is_list(b"shap")).filter_map(ShapePath::from_chunk).collect();
mask.shapes = omks.children().filter(|c| c.is_list(b"shap")).filter_map(ShapePath::from_chunk).collect();
}
}
mask
}
impl<'f, 'a> Project<'f, 'a> {
/// The vertices of every path shape of a mask in pixels of the layer: mask boxes are normalised
/// to the **layer** size (the size of its source), not the composition's. `None` when the layer
/// has no source with a size.
pub fn mask_vertices_px(&self, layer: &Layer<'f, 'a>, mask: &Mask) -> Option<Vec<Vec<PathVertex>>> {
let (width, height) = self.layer_source_size(layer)?;
Some(mask.shapes.iter().map(|s| s.vertices((f64::from(width), f64::from(height)))).collect())
}
}
/// One mask with the views of its records, for readers that need more than [`Mask`]: the
/// keyframes and expressions of the mask properties (in [`MaskAtom::atom`]: `ADBE Mask Feather`,
/// `ADBE Mask Opacity`, `ADBE Mask Offset`) and the path keyframes paired with their shapes.
@@ -246,7 +238,7 @@ impl<'f, 'a> Layer<'f, 'a> {
if let Some(PropNode::Other(oms)) = atom.get("ADBE Mask Shape") {
path = oms.child_list(b"tdbs").and_then(Property::from_chunk);
if let Some(omks) = oms.child_list(b"omks") {
path_shapes = omks.children().iter().filter(|c| c.is_list(b"shap")).map(ShapePath::from_chunk).collect();
path_shapes = omks.children().filter(|c| c.is_list(b"shap")).map(ShapePath::from_chunk).collect();
}
}
masks.push(MaskAtom { mask, atom, path, path_shapes });
+150 -14
View File
@@ -2,6 +2,7 @@
use std::borrow::Cow;
use crate::container::bytes::{array, be_u32};
use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS};
use crate::container::error::AepError;
@@ -36,10 +37,20 @@ pub fn parse(data: &[u8]) -> Result<AepFile<'_>, AepError> {
/// # Errors
/// See [`parse`]; also fails with [`AepError::TooManyChunks`].
pub fn parse_with_limits(data: &[u8], limits: Limits) -> Result<AepFile<'_>, AepError> {
let end = container_end(data)?;
let mut parser = Parser { data, limits, count: 0 };
let chunks = parser.sequence(12, end, 1)?;
let trailer = data.get(end..).unwrap_or(&[]);
Ok(AepFile { chunks, trailer: Cow::Borrowed(trailer) })
}
/// Checks the `RIFX`/`Egg!` header and returns where the container ends (its chunks fill
/// `12..end`; the trailer follows). Shared with [`crate::container::index::TreeIndex::parse`].
pub(super) fn container_end(data: &[u8]) -> Result<usize, AepError> {
if id4(data, 0) != Some(*b"RIFX") {
return Err(AepError::NotRifx);
}
let declared = u64::from(be32(data, 4).ok_or(AepError::NotRifx)?);
let declared = u64::from(be_u32(data, 4).ok_or(AepError::NotRifx)?);
let form = id4(data, 8).ok_or(AepError::NotRifx)?;
if &form != b"Egg!" {
return Err(AepError::BadFormType(form));
@@ -49,20 +60,12 @@ pub fn parse_with_limits(data: &[u8], limits: Limits) -> Result<AepFile<'_>, Aep
return Err(AepError::Truncated { declared, available });
}
// `declared <= available < data.len()`, so the end is inside the input.
let end = usize::try_from(declared).map_err(|_| AepError::TooLarge)?.checked_add(8).ok_or(AepError::TooLarge)?;
let mut parser = Parser { data, limits, count: 0 };
let chunks = parser.sequence(12, end, 1)?;
let trailer = data.get(end..).unwrap_or(&[]);
Ok(AepFile { chunks, trailer: Cow::Borrowed(trailer) })
usize::try_from(declared).map_err(|_| AepError::TooLarge)?.checked_add(8).ok_or(AepError::TooLarge)
}
fn be32(data: &[u8], at: usize) -> Option<u32> {
id4(data, at).map(u32::from_be_bytes)
}
fn id4(data: &[u8], at: usize) -> Option<[u8; 4]> {
let b = data.get(at..at.checked_add(4)?)?;
Some([*b.first()?, *b.get(1)?, *b.get(2)?, *b.get(3)?])
/// The 4-byte id at `at`, `None` when the input is too short.
pub(super) fn id4(data: &[u8], at: usize) -> Option<[u8; 4]> {
array::<4>(data, at)
}
struct Parser<'a> {
@@ -74,13 +77,16 @@ struct Parser<'a> {
impl<'a> Parser<'a> {
/// Parses the sibling chunks that exactly fill `start..end`.
fn sequence(&mut self, start: usize, end: usize, depth: usize) -> Result<Vec<Chunk<'a>>, AepError> {
// Sized once: a well-formed sequence fills exactly this many slots. `try_reserve` so a
// limit raised past what the target can allocate fails nothing here (the Vec then grows).
let mut out = Vec::new();
let _ = out.try_reserve_exact(self.capacity_for(start, end));
let mut pos = start;
while pos < end {
let short = AepError::Malformed { offset: pos, what: "bytes left over that are too short for a chunk header" };
let body_start = pos.checked_add(8).filter(|b| *b <= end).ok_or(short)?;
let id = id4(self.data, pos).ok_or(AepError::Malformed { offset: pos, what: "short read" })?;
let size = be32(self.data, pos + 4).ok_or(AepError::Malformed { offset: pos, what: "short read" })? as usize;
let size = be_u32(self.data, pos + 4).ok_or(AepError::Malformed { offset: pos, what: "short read" })? as usize;
let avail = end - body_start;
if size > avail {
return Err(AepError::ChunkTooLarge { offset: pos, size: size as u64, available: avail as u64 });
@@ -103,6 +109,26 @@ impl<'a> Parser<'a> {
Ok(out)
}
/// The slots to reserve for the sequence `start..end`: its header count, bounded by the input
/// (one per 8 bytes at most) and by the chunks the limit still allows.
fn capacity_for(&self, start: usize, end: usize) -> usize {
self.count_siblings(start, end).min(self.limits.max_chunks.saturating_sub(self.count))
}
/// The number of chunk headers that tile `start..end` the way [`Self::sequence`] walks them,
/// stopping at the first that does not fit. Never more than `(end - start) / 8`.
fn count_siblings(&self, start: usize, end: usize) -> usize {
let mut n = 0usize;
let mut pos = start;
while let Some(body_start) = pos.checked_add(8).filter(|b| *b <= end) {
let Some(size) = be_u32(self.data, pos.saturating_add(4)) else { break };
let Some(next) = usize::try_from(size).ok().and_then(|s| body_start.checked_add(s)).filter(|e| *e <= end) else { break };
n = n.saturating_add(1);
pos = if size % 2 == 1 && next < end { next.saturating_add(1) } else { next };
}
n
}
/// Like [`Self::sequence`] but a structural problem yields `None` (the caller keeps bytes).
fn tentative(&mut self, start: usize, end: usize, depth: usize) -> Result<Option<Vec<Chunk<'a>>>, AepError> {
match self.sequence(start, end, depth) {
@@ -140,3 +166,113 @@ impl<'a> Parser<'a> {
Ok(Body::Data(Cow::Borrowed(self.slice(start, end))))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every children `Vec` in the tree, depth first.
fn child_vecs<'f, 'a>(chunks: &'f [Chunk<'a>], out: &mut Vec<&'f Vec<Chunk<'a>>>) {
for c in chunks {
match &c.body {
Body::List { children, .. } | Body::Chunks(children) => {
out.push(children);
child_vecs(children, out);
}
Body::Data(_) | Body::OpaqueList { .. } => {}
}
}
}
/// Every sibling list is allocated once at its exact length.
#[test]
fn every_sibling_list_is_allocated_once_at_its_exact_length() {
let inner: Vec<Chunk<'static>> = (0..37u8).map(|i| Chunk::data(*b"cdat", vec![i; usize::from(i % 5)])).collect();
let middle: Vec<Chunk<'static>> = (0..9).map(|_| Chunk::list(*b"tdbs", inner.clone())).collect();
let file = AepFile { chunks: vec![Chunk::data(*b"head", vec![0; 3]), Chunk::list(*b"Fold", middle)], trailer: Cow::Borrowed(&[]) };
let bytes = crate::container::write(&file).expect("write");
let parsed = parse(&bytes).expect("parse");
assert_eq!(crate::container::write(&parsed).expect("rewrite"), bytes);
let mut vecs = vec![&parsed.chunks];
child_vecs(&parsed.chunks, &mut vecs);
assert_eq!(vecs.len(), 11);
// `try_reserve_exact` gives exactly the requested capacity with std's allocator today; a
// Vec grown push by push would have rounded up to a power of two (37 -> 64, 9 -> 16).
for v in vecs {
assert_eq!(v.capacity(), v.len(), "a sibling Vec grew past its length");
}
}
/// The preallocation is bounded by the input and the chunk limit.
#[test]
fn the_preallocation_is_bounded_by_the_input_and_the_chunk_limit() {
// 1000 empty headers: the most chunks the bytes can hold.
let mut data = b"RIFX\0\0\0\0Egg!".to_vec();
data.extend(std::iter::repeat_n(*b"abcd\0\0\0\0", 1000).flatten());
let declared = u32::try_from(data.len() - 8).expect("small");
data[4..8].copy_from_slice(&declared.to_be_bytes());
let parser = Parser { data: &data, limits: Limits::default(), count: 0 };
assert_eq!(parser.count_siblings(12, data.len()), 1000);
assert!(parser.count_siblings(12, data.len()) <= (data.len() - 12) / 8);
// A header whose size runs past the end stops the count; a stray tail is not counted.
assert_eq!(parser.count_siblings(12, data.len() - 3), 999);
let mut big = data.clone();
big[16..20].copy_from_slice(&u32::MAX.to_be_bytes());
let parser = Parser { data: &big, limits: Limits::default(), count: 0 };
assert_eq!(parser.count_siblings(12, big.len()), 0);
let f = parse(&data).expect("parse");
assert_eq!((f.chunks.len(), f.chunks.capacity()), (1000, 1000));
// Under a tighter limit the parse fails as before, without sizing for more than allowed.
let tight = Limits { max_depth: 64, max_chunks: 10 };
assert_eq!(parse_with_limits(&data, tight), Err(AepError::TooManyChunks));
// The reservation never exceeds what the chunk limit still allows.
let mut parser = Parser { data: &data, limits: tight, count: 0 };
assert_eq!(parser.capacity_for(12, data.len()), 10);
parser.count = 7;
assert_eq!(parser.capacity_for(12, data.len()), 3);
parser.count = 11;
assert_eq!(parser.capacity_for(12, data.len()), 0);
let parser = Parser { data: &data, limits: Limits::default(), count: 0 };
assert_eq!(parser.capacity_for(12, data.len()), 1000);
}
/// `LIST Fold` around `inner`, followed by 6 bytes too short for a header, so the list's
/// sequence fails and the list is kept opaque.
fn broken_level(inner: &[u8]) -> Vec<u8> {
let mut body = b"Fold".to_vec();
body.extend_from_slice(inner);
body.extend_from_slice(&[0xee; 6]);
let mut out = b"LIST".to_vec();
out.extend_from_slice(&u32::try_from(body.len()).expect("small").to_be_bytes());
out.extend_from_slice(&body);
out
}
/// Nested lists with broken tails parse each header once.
#[test]
fn nested_lists_with_broken_tails_parse_each_header_once() {
// A wide, well-formed list at the bottom, then 50 levels that each fail and fall back to
// opaque: every level re-parses its child before giving up.
let mut wide = b"Fold".to_vec();
wide.extend(std::iter::repeat_n(*b"abcd\0\0\0\0", 1000).flatten());
let mut level = b"LIST".to_vec();
level.extend_from_slice(&u32::try_from(wide.len()).expect("small").to_be_bytes());
level.extend_from_slice(&wide);
for _ in 0..50 {
level = broken_level(&level);
}
let mut data = b"RIFX\0\0\0\0Egg!".to_vec();
data.extend_from_slice(&level);
let declared = u32::try_from(data.len() - 8).expect("small");
data[4..8].copy_from_slice(&declared.to_be_bytes());
// 1 + 50 + 1000 headers in the file: the parse counts each exactly once (the count spans
// every attempt), so the work stays linear in the input.
let headers = 1 + 50 + 1000;
let f = parse_with_limits(&data, Limits { max_depth: 64, max_chunks: headers }).expect("parse");
assert_eq!(parse_with_limits(&data, Limits { max_depth: 64, max_chunks: headers - 1 }), Err(AepError::TooManyChunks));
// The outermost list keeps its bytes, and the file writes back unchanged.
let [top] = f.chunks.as_slice() else { panic!("one top-level chunk") };
assert!(matches!(&top.body, Body::OpaqueList { list_type, data } if list_type == b"Fold" && data.len() + 12 == level.len()));
assert_eq!(crate::container::write(&f).expect("write"), data);
}
}
+24 -11
View File
@@ -2,8 +2,10 @@
//! linear-light switches, the time display and the frame count start. Cases 620-634: one change
//! each against the base project 620; every field below is the only byte (or chunk) that changes.
use crate::container::bytes::be_u32;
use crate::container::chunk::AepFile;
use crate::container::footage::json_string;
use crate::container::index::{Node, Root, TreeIndex};
/// The time display style (`nhed` byte 8, feet + frames byte 11, film type byte 13).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -54,7 +56,17 @@ pub struct ProjectSettings {
impl ProjectSettings {
/// Reads the settings from the top-level chunks.
pub fn read(file: &AepFile<'_>) -> ProjectSettings {
let nhed = file.chunk(b"nhed").and_then(|c| c.bytes());
Self::of(Root::from(file))
}
/// Reads the settings from the top-level chunks of an indexed file.
pub fn read_index(index: &TreeIndex<'_>) -> ProjectSettings {
Self::of(Root::from(index))
}
/// Reads the settings from the top-level chunks of either tree.
pub(crate) fn of(file: Root<'_, '_>) -> ProjectSettings {
let nhed = file.chunk(b"nhed").and_then(Node::bytes);
let byte = |i: usize| nhed.and_then(|b| b.get(i).copied());
let bits_per_channel = byte(15).and_then(|v| match v {
0 => Some(8),
@@ -73,9 +85,9 @@ impl ProjectSettings {
1 => Some(1),
_ => None,
});
let flag = |id: &[u8; 4]| file.chunk(id).and_then(|c| c.bytes()).and_then(|b| b.first().copied()) == Some(1);
let working_gamma_24 = file.chunk(b"dwga").and_then(|c| c.bytes()).and_then(|b| b.get(..4)).map(|b| b != [0, 0, 0, 0]);
let scene_compensation = file.chunk(b"acer").and_then(|c| c.bytes()).and_then(|b| b.first().copied()).map(|v| v != 0);
let flag = |id: &[u8; 4]| file.chunk(id).and_then(Node::bytes).and_then(|b| b.first().copied()) == Some(1);
let working_gamma_24 = file.chunk(b"dwga").and_then(Node::bytes).and_then(|b| b.get(..4)).map(|b| b != [0, 0, 0, 0]);
let scene_compensation = file.chunk(b"acer").and_then(Node::bytes).and_then(|b| b.first().copied()).map(|v| v != 0);
ProjectSettings {
bits_per_channel,
working_space: working_space(file),
@@ -90,9 +102,10 @@ impl ProjectSettings {
}
/// The working space: the `Utf8` right after `PwCs`.
fn working_space(file: &AepFile<'_>) -> Option<WorkingSpace> {
let at = file.chunks.iter().position(|c| c.is(b"PwCs"))?;
let json = file.chunks.get(at.checked_add(1)?).filter(|c| c.is(b"Utf8"))?.bytes()?;
fn working_space(file: Root<'_, '_>) -> Option<WorkingSpace> {
let mut chunks = file.chunks();
chunks.find(|c| c.is(b"PwCs"))?;
let json = chunks.next().filter(|c| c.is(b"Utf8"))?.bytes()?;
let trimmed: Vec<u8> = json.iter().copied().filter(|b| !b.is_ascii_whitespace() && *b != 0).collect();
if trimmed == b"{}" {
return Some(WorkingSpace::None);
@@ -130,7 +143,7 @@ fn base64_decode(text: &str) -> Option<Vec<u8>> {
/// The profile description (ICC.1 tag `desc`): a v2 `desc` (ASCII) or a v4 `mluc` (its first
/// record, UTF-16BE). `None` when the profile or the tag is cut short.
fn icc_description(icc: &[u8]) -> Option<String> {
let be32 = |at: usize| icc.get(at..at.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes);
let be32 = |at: usize| be_u32(icc, at);
let count = be32(128)?;
let tag = (0..count.min(1024)).find_map(|i| {
let at = 132usize.checked_add(usize::try_from(i).ok()?.checked_mul(12)?)?;
@@ -141,13 +154,13 @@ fn icc_description(icc: &[u8]) -> Option<String> {
let body = icc.get(off..off.checked_add(len)?)?;
let text = match body.get(..4)? {
b"desc" => {
let n = usize::try_from(u32::from_be_bytes(body.get(8..12)?.try_into().ok()?)).ok()?;
let n = usize::try_from(be_u32(body, 8)?).ok()?;
let ascii = body.get(12..12usize.checked_add(n)?)?;
String::from_utf8_lossy(ascii.split(|b| *b == 0).next().unwrap_or_default()).into_owned()
}
b"mluc" => {
let n = usize::try_from(u32::from_be_bytes(body.get(20..24)?.try_into().ok()?)).ok()?;
let at = usize::try_from(u32::from_be_bytes(body.get(24..28)?.try_into().ok()?)).ok()?;
let n = usize::try_from(be_u32(body, 20)?).ok()?;
let at = usize::try_from(be_u32(body, 24)?).ok()?;
let units: Vec<u16> = body.get(at..at.checked_add(n)?)?.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect();
String::from_utf16_lossy(&units)
}
+2 -11
View File
@@ -10,7 +10,7 @@
//! points are in pixels relative to the layer origin.
use crate::container::masks::ShapePath;
use crate::container::typed::{Layer, PropGroup, PropNode, Property};
use crate::container::typed::{Layer, PropGroup, PropNode};
use crate::container::values::{Tracked, read, read_opt};
/// The stroke line cap (`ADBE Vector Stroke Line Cap`, stored as a double; butt is the default and
@@ -140,7 +140,7 @@ fn number(group: &PropGroup<'_, '_>, name: &str) -> Option<Tracked<f64>> {
fn paths_of(group: &PropGroup<'_, '_>) -> Vec<ShapePath> {
let Some(PropNode::Other(oms)) = group.get("ADBE Vector Shape") else { return Vec::new() };
let Some(omks) = oms.child_list(b"omks") else { return Vec::new() };
omks.children().iter().filter(|c| c.is_list(b"shap")).filter_map(ShapePath::from_chunk).collect()
omks.children().filter(|c| c.is_list(b"shap")).filter_map(ShapePath::from_chunk).collect()
}
fn item(match_name: String, group: &PropGroup<'_, '_>) -> ShapeItem {
@@ -262,12 +262,3 @@ impl<'f, 'a> Layer<'f, 'a> {
.collect()
}
}
impl<'f, 'a> Property<'f, 'a> {
/// The ARGB fractions of a static colour property (4 components stored as doubles 0..255).
pub fn color_argb(&self) -> Option<[f64; 4]> {
let v = self.static_value()?;
let [a, r, g, b] = *v.as_slice().first_chunk::<4>()?;
Some([a / 255.0, r / 255.0, g / 255.0, b / 255.0])
}
}
-4
View File
@@ -73,10 +73,6 @@ pub struct StyleMap {
}
impl StyleMap {
/// The switch group's match name (`dropShadow/enabled`).
pub fn switch_name(&self) -> String {
format!("{}/enabled", self.ae)
}
/// A parameter's full match name (`dropShadow/opacity`).
pub fn param_name(&self, p: &StyleParam) -> String {
format!("{}/{}", self.ae, p.ae)
File diff suppressed because it is too large Load Diff
+88 -34
View File
@@ -4,42 +4,57 @@
//!
//! Only facts that were established from our own one-change-per-file corpus and are checked by
//! tests against it are decoded (see `docs/aep-format.md` for the evidence). Everything else
//! stays reachable through the raw [`Chunk`] tree. Views borrow the file and never fail loudly:
//! a chunk that does not have the expected shape yields `None` or is skipped.
//! stays reachable through the raw chunk tree ([`Node`]). Views borrow the file and never fail
//! loudly: a chunk that does not have the expected shape yields `None` or is skipped.
//!
//! A view reads either tree: the owned [`Chunk`](crate::container::Chunk) tree
//! ([`Project::new`]) or the flat [`TreeIndex`] the importer builds ([`Project::from_index`]);
//! both give the same answers.
use crate::container::bytes::{be_f64, be_u16, be_u32, text, until_nul};
use crate::container::chunk::{AepFile, Chunk};
use crate::container::chunk::AepFile;
use crate::container::index::{Node, Root, TreeIndex};
/// The name a user gave a property or group: the `Utf8` inside its `tdsn` chunk. `None` for the
/// sentinel `-_0_/-` ("no custom name") and for empty names.
pub(crate) fn user_name(chunk: &Chunk<'_>) -> Option<String> {
let name = chunk.child(b"tdsn")?.children().iter().find(|c| c.is(b"Utf8"))?.bytes().map(text)?;
pub(crate) fn user_name(chunk: Node<'_, '_>) -> Option<String> {
let name = chunk.child(b"tdsn")?.child(b"Utf8")?.bytes().map(text)?;
(!name.is_empty() && name != "-_0_/-").then_some(name)
}
/// The content of a `Utf8` child as text (not trimmed).
pub(crate) fn utf8_child(chunk: &Chunk<'_>) -> Option<String> {
chunk.child(b"Utf8").and_then(Chunk::bytes).map(text)
pub(crate) fn utf8_child(chunk: Node<'_, '_>) -> Option<String> {
chunk.child(b"Utf8").and_then(Node::bytes).map(text)
}
/// The project: the top-level `LIST Fold`, which holds the project items.
#[derive(Debug, Clone, Copy)]
pub struct Project<'f, 'a> {
pub(crate) fold: &'f Chunk<'a>,
pub(crate) fold: Node<'f, 'a>,
/// The top-level `LIST EfdG` (project-level effect definitions), when there is one.
pub(crate) effect_defs: Option<&'f Chunk<'a>>,
pub(crate) effect_defs: Option<Node<'f, 'a>>,
}
impl<'f, 'a> Project<'f, 'a> {
/// The project view of a file, or `None` when it has no `LIST Fold`.
pub fn new(file: &'f AepFile<'a>) -> Option<Self> {
file.list(b"Fold").map(|fold| Self { fold, effect_defs: file.list(b"EfdG") })
Self::of(Root::from(file))
}
/// The project view of an indexed file ([`TreeIndex`]), or `None` when it has no `LIST Fold`.
pub fn from_index(index: &'f TreeIndex<'a>) -> Option<Self> {
Self::of(Root::from(index))
}
/// The project view of either tree: its `LIST Fold` and `LIST EfdG`.
pub(crate) fn of(root: Root<'f, 'a>) -> Option<Self> {
root.list(b"Fold").map(|fold| Self { fold, effect_defs: root.list(b"EfdG") })
}
/// The top-level items (the project panel's root; folders hold their children, see
/// [`Item::children`]).
pub fn items(&self) -> impl Iterator<Item = Item<'f, 'a>> + 'f {
self.fold.children().iter().filter(|c| c.is_list(b"Item")).map(|chunk| Item { chunk })
self.fold.children().filter(|c| c.is_list(b"Item")).map(|chunk| Item { chunk })
}
/// True when the `LIST Fold` could not be read as chunks (damaged): its items cannot be seen.
@@ -78,12 +93,12 @@ pub enum ItemKind {
/// A project item (`LIST Item`).
#[derive(Debug, Clone, Copy)]
pub struct Item<'f, 'a> {
pub(crate) chunk: &'f Chunk<'a>,
pub(crate) chunk: Node<'f, 'a>,
}
impl<'f, 'a> Item<'f, 'a> {
/// The underlying chunk.
pub fn chunk(&self) -> &'f Chunk<'a> {
pub fn chunk(&self) -> Node<'f, 'a> {
self.chunk
}
@@ -106,7 +121,7 @@ impl<'f, 'a> Item<'f, 'a> {
/// The items inside a folder (`LIST Sfdr`).
pub fn children(&self) -> impl Iterator<Item = Item<'f, 'a>> + 'f {
self.chunk.child_list(b"Sfdr").into_iter().flat_map(|sfdr| sfdr.children().iter()).filter(|c| c.is_list(b"Item")).map(|chunk| Item { chunk })
self.chunk.child_list(b"Sfdr").into_iter().flat_map(Node::children).filter(|c| c.is_list(b"Item")).map(|chunk| Item { chunk })
}
/// Whether the item has the `LIST Sfdr` of a folder's contents (After Effects writes an empty
@@ -117,7 +132,7 @@ impl<'f, 'a> Item<'f, 'a> {
/// True when the folder's `LIST Sfdr` is there but could not be read as chunks (damaged).
pub fn folder_contents_unreadable(&self) -> bool {
self.chunk.child_list(b"Sfdr").is_some_and(Chunk::is_opaque_list)
self.chunk.child_list(b"Sfdr").is_some_and(Node::is_opaque_list)
}
/// The composition view when this item has composition data (`cdta`).
@@ -135,6 +150,15 @@ pub struct TimeBase {
}
impl TimeBase {
/// The NTSC rates and the value After Effects stores for each (a float32 of exactly these
/// decimals): the one table the importer and the exporter use.
pub(crate) const NTSC: [(effectcraft_time::FrameRate, f32); 4] = [
(effectcraft_time::FrameRate::FPS_23_976, 23.976),
(effectcraft_time::FrameRate::FPS_29_97, 29.97),
(effectcraft_time::FrameRate::FPS_59_94, 59.94),
(effectcraft_time::FrameRate::FPS_119_88, 119.88),
];
/// Frames per second (`timescale / frame_duration`), `None` for a zero duration.
pub fn frame_rate(&self) -> Option<f64> {
(self.frame_duration != 0).then(|| f64::from(self.timescale) / f64::from(self.frame_duration))
@@ -149,7 +173,7 @@ impl TimeBase {
/// A composition item (an [`Item`] with `cdta`).
#[derive(Debug, Clone, Copy)]
pub struct Comp<'f, 'a> {
pub(crate) item: &'f Chunk<'a>,
pub(crate) item: Node<'f, 'a>,
}
impl<'f, 'a> Comp<'f, 'a> {
@@ -163,19 +187,19 @@ impl<'f, 'a> Comp<'f, 'a> {
/// The layers, top layer first (`LIST Layr`; the `DLay`/`SLay`/`CLay`/`SecL` lists next to
/// them are not layers).
pub fn layers(&self) -> impl Iterator<Item = Layer<'f, 'a>> + 'f {
self.item.children().iter().filter(|c| c.is_list(b"Layr")).map(|chunk| Layer { chunk })
self.item.children().filter(|c| c.is_list(b"Layr")).map(|chunk| Layer { chunk })
}
}
/// A layer (`LIST Layr`).
#[derive(Debug, Clone, Copy)]
pub struct Layer<'f, 'a> {
pub(crate) chunk: &'f Chunk<'a>,
pub(crate) chunk: Node<'f, 'a>,
}
impl<'f, 'a> Layer<'f, 'a> {
/// The underlying chunk.
pub fn chunk(&self) -> &'f Chunk<'a> {
pub fn chunk(&self) -> Node<'f, 'a> {
self.chunk
}
@@ -205,12 +229,12 @@ pub enum PropNode<'f, 'a> {
/// A property (`LIST tdbs`).
Property(Property<'f, 'a>),
/// Any other list that follows a match name (observed: `otst`, the effect instance `sspc`).
Other(&'f Chunk<'a>),
Other(Node<'f, 'a>),
}
impl<'f, 'a> PropNode<'f, 'a> {
/// The node a `LIST` after a match name is.
fn of(c: &'f Chunk<'a>) -> Self {
fn of(c: Node<'f, 'a>) -> Self {
if c.is_list(b"tdgp") {
PropNode::Group(PropGroup { chunk: c })
} else if c.is_list(b"tdbs") {
@@ -233,17 +257,18 @@ pub struct PropEntry<'f, 'a> {
/// `ADBE Group End` closes the group and names nothing.
#[derive(Debug, Clone, Copy)]
pub struct PropGroup<'f, 'a> {
pub(crate) chunk: &'f Chunk<'a>,
pub(crate) chunk: Node<'f, 'a>,
}
impl<'f, 'a> PropGroup<'f, 'a> {
/// A group view of a `LIST tdgp` chunk.
pub fn from_chunk(chunk: &'f Chunk<'a>) -> Option<Self> {
/// A group view of a `LIST tdgp` chunk (a [`Chunk`](crate::container::Chunk) or a [`Node`]).
pub fn from_chunk(chunk: impl Into<Node<'f, 'a>>) -> Option<Self> {
let chunk = chunk.into();
chunk.is_list(b"tdgp").then_some(Self { chunk })
}
/// The underlying chunk.
pub fn chunk(&self) -> &'f Chunk<'a> {
pub fn chunk(&self) -> Node<'f, 'a> {
self.chunk
}
@@ -264,7 +289,7 @@ impl<'f, 'a> PropGroup<'f, 'a> {
for c in self.chunk.children() {
if c.is(b"tdmn") {
pending = c.bytes().map(match_name);
} else if c.id == crate::container::chunk::LIST
} else if c.is(&crate::container::chunk::LIST)
&& let Some(name) = pending.take()
{
out.push(PropEntry { match_name: name, node: PropNode::of(c) });
@@ -285,9 +310,9 @@ impl<'f, 'a> PropGroup<'f, 'a> {
for c in self.chunk.children() {
if c.is(b"tdmn") {
pending = c.bytes();
} else if c.id == crate::container::chunk::LIST
} else if c.is(&crate::container::chunk::LIST)
&& let Some(body) = pending.take()
&& match_name(body) == name
&& match_name_is(body, name)
{
return Some(PropNode::of(c));
}
@@ -314,21 +339,30 @@ fn match_name(body: &[u8]) -> String {
text(until_nul(body))
}
/// `match_name(body) == name` without building the `String`: equal bytes match; otherwise only
/// a body that is not valid UTF-8 can still match (through its U+FFFD replacements), and only a
/// name that holds U+FFFD.
fn match_name_is(body: &[u8], name: &str) -> bool {
let bytes = until_nul(body);
bytes == name.as_bytes() || (name.contains('\u{FFFD}') && String::from_utf8_lossy(bytes) == name)
}
/// A property (`LIST tdbs`): `tdsb`, `tdsn`, the 124-byte header `tdb4`, then either the static
/// value `cdat` or a keyframe `LIST list`.
#[derive(Debug, Clone, Copy)]
pub struct Property<'f, 'a> {
pub(crate) chunk: &'f Chunk<'a>,
pub(crate) chunk: Node<'f, 'a>,
}
impl<'f, 'a> Property<'f, 'a> {
/// A property view of a `LIST tdbs` chunk.
pub fn from_chunk(chunk: &'f Chunk<'a>) -> Option<Self> {
/// A property view of a `LIST tdbs` chunk (a [`Chunk`](crate::container::Chunk) or a [`Node`]).
pub fn from_chunk(chunk: impl Into<Node<'f, 'a>>) -> Option<Self> {
let chunk = chunk.into();
chunk.is_list(b"tdbs").then_some(Self { chunk })
}
/// The underlying chunk.
pub fn chunk(&self) -> &'f Chunk<'a> {
pub fn chunk(&self) -> Node<'f, 'a> {
self.chunk
}
@@ -436,12 +470,13 @@ pub struct KeyLayout {
impl<'f> KeyList<'f> {
/// A view of a `LIST list` chunk. `None` when `lhd3` is not 52 bytes or `ldat` is not exactly
/// `count * record_size` bytes (a list without `ldat` must have no records).
pub fn from_chunk<'a>(chunk: &'f Chunk<'a>) -> Option<Self> {
pub fn from_chunk<'a: 'f>(chunk: impl Into<Node<'f, 'a>>) -> Option<Self> {
let chunk = chunk.into();
if !chunk.is_list(b"list") {
return None;
}
let header = ListHeader::parse(chunk.child(b"lhd3")?.bytes()?)?;
let ldat: &[u8] = chunk.child(b"ldat").and_then(Chunk::bytes).unwrap_or(&[]);
let ldat: &[u8] = chunk.child(b"ldat").and_then(Node::bytes).unwrap_or(&[]);
let expected = usize::try_from(header.count).ok()?.checked_mul(usize::try_from(header.record_size).ok()?)?;
(ldat.len() == expected).then_some(Self { header, ldat, layout: None })
}
@@ -500,6 +535,7 @@ impl<'f> Keyframe<'f> {
#[cfg(test)]
mod tests {
use super::*;
use crate::container::Chunk;
fn key_list(count: u32, record_size: u32, ldat: &[u8]) -> Chunk<'static> {
let mut lhd3 = vec![0u8; 52];
@@ -536,4 +572,22 @@ mod tests {
assert!(g.get("C").is_none());
assert_eq!(g.names(), ["A", "B", "A"]);
}
/// Match name is agrees with comparing the decoded name.
#[test]
fn match_name_is_agrees_with_comparing_the_decoded_name() {
let bodies: [&[u8]; 9] =
[b"ADBE Position\0\0\0", b"ADBE Position", b"ADBE Pos", b"", b"\0ADBE", b"ADBE \xff Bad\0", b"\xff", b"\xef\xbf\xbd", "Gr\u{fc}n\0".as_bytes()];
let names = ["ADBE Position", "ADBE Pos", "", "ADBE \u{FFFD} Bad", "\u{FFFD}", "Gr\u{fc}n", "ADBE Position\0", "ADBE \u{FFFD}"];
for body in bodies {
for name in names {
assert_eq!(match_name_is(body, name), match_name(body) == name, "{body:?} vs {name:?}");
}
}
// Names read back from a group find their entries, a damaged one included.
let group = Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdmn", b"X\xffY\0".to_vec()), Chunk::list(*b"tdbs", Vec::new())]);
let g = PropGroup::from_chunk(&group).expect("group");
let names = g.names();
assert!(g.get(&names[0]).is_some());
}
}
+3 -5
View File
@@ -8,6 +8,7 @@
//! 100 %. These defaults are verified for AV layers (solids); cameras and lights use other
//! conventions (see the `camera` module).
use crate::container::bytes::be_f64;
use crate::container::typed::{Layer, Project, PropGroup, PropNode};
/// Where a [`Tracked`] value came from.
@@ -192,8 +193,7 @@ impl<'f, 'a> Layer<'f, 'a> {
let otda = otst.child_list(b"otky")?.child(b"otda")?.bytes()?;
let mut degrees = [0.0; 3];
for (i, slot) in degrees.iter_mut().enumerate() {
let b = otda.get(i * 8..i * 8 + 8)?;
*slot = f64::from_be_bytes(b.try_into().ok()?);
*slot = be_f64(otda, i * 8)?;
}
Some(Orientation {
degrees,
@@ -223,12 +223,10 @@ impl<'f, 'a> Layer<'f, 'a> {
.child_list(b"otky")
.map(|k| {
k.children()
.iter()
.filter(|c| c.is(b"otda"))
.map(|c| {
let b = c.bytes()?;
let at = |i: usize| b.get(i * 8..i * 8 + 8).and_then(|s| s.try_into().ok()).map(f64::from_be_bytes);
Some([at(0)?, at(1)?, at(2)?])
Some([be_f64(b, 0)?, be_f64(b, 8)?, be_f64(b, 16)?])
})
.collect()
})
+24 -8
View File
@@ -8,8 +8,9 @@
use std::fmt;
use crate::container::bytes::{be_u16, be_u32};
use crate::container::bytes::be_u32;
use crate::container::chunk::AepFile;
use crate::container::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`).
@@ -71,21 +72,36 @@ impl AepFile<'_> {
/// 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)?)
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> {
AeVersion::from_word(be_u32(self.chunk(b"svap")?.bytes()?, 0)?)
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()
}
/// 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)
/// [`AepFile::saved_by_version`] of the indexed file.
pub fn saved_by_version(&self) -> Option<AeVersion> {
Root::from(self).saved_by_version()
}
}
impl Root<'_, '_> {
pub(crate) fn file_version(self) -> Option<AeVersion> {
AeVersion::from_word(be_u32(self.chunk(b"head")?.bytes()?, 4)?)
}
pub(crate) fn saved_by_version(self) -> Option<AeVersion> {
AeVersion::from_word(be_u32(self.chunk(b"svap")?.bytes()?, 0)?)
}
}
+93
View File
@@ -17,6 +17,18 @@ const MAX_WRITE_DEPTH: usize = 256;
/// [`AepError::TooDeep`] for an absurdly deep hand-built tree.
pub fn write(file: &AepFile<'_>) -> Result<Vec<u8>, AepError> {
let mut out = Vec::new();
// Sized once to the exact output. A tree past the 32-bit container size fails at once (the
// same error the write below would give after serialising it). `try_reserve` so a hand-built
// tree that repeats one large borrowed body many times cannot abort on the allocation; the
// writer then grows as it goes, as before.
if let Some(len) = sequence_len(&file.chunks, 1) {
if u32::try_from(len.saturating_add(4)).is_err() {
return Err(AepError::TooLarge);
}
if let Some(total) = len.checked_add(12).and_then(|n| n.checked_add(file.trailer.len())) {
let _ = out.try_reserve_exact(total);
}
}
out.extend_from_slice(b"RIFX");
out.extend_from_slice(&[0; 4]);
out.extend_from_slice(b"Egg!");
@@ -56,6 +68,28 @@ fn write_sequence(out: &mut Vec<u8>, chunks: &[Chunk<'_>], depth: usize) -> Resu
Ok(())
}
/// The number of bytes [`write_sequence`] produces for `chunks`; `None` past the depth limit or
/// on overflow (both make the write fail).
fn sequence_len(chunks: &[Chunk<'_>], depth: usize) -> Option<usize> {
if depth > MAX_WRITE_DEPTH {
return None;
}
let count = chunks.len();
let mut total = 0usize;
for (i, chunk) in chunks.iter().enumerate() {
let body = match &chunk.body {
Body::Data(d) => d.len(),
Body::Chunks(c) => sequence_len(c, depth + 1)?,
Body::List { children, .. } => sequence_len(children, depth + 1)?.checked_add(4)?,
Body::OpaqueList { data, .. } => data.len().checked_add(4)?,
};
// The same pad rule as `write_chunk`.
let pad = usize::from(body % 2 == 1 && (chunk.pad.is_some() || i + 1 != count));
total = total.checked_add(8)?.checked_add(body)?.checked_add(pad)?;
}
Some(total)
}
fn write_chunk(out: &mut Vec<u8>, chunk: &Chunk<'_>, is_last: bool, depth: usize) -> Result<(), AepError> {
out.extend_from_slice(&chunk.id);
let size_at = out.len();
@@ -84,3 +118,62 @@ fn write_chunk(out: &mut Vec<u8>, chunk: &Chunk<'_>, is_last: bool, depth: usize
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use super::*;
/// The output is sized once to its exact length.
#[test]
fn the_output_is_sized_once_to_its_exact_length() {
let odd = |n: u8| Chunk::data(*b"cdat", vec![n; usize::from(n)]);
let mut padded = odd(3);
padded.pad = Some(0x7f);
let trees = [
AepFile { chunks: Vec::new(), trailer: Cow::Borrowed(&[]) },
AepFile {
chunks: vec![
odd(1),
Chunk::list(*b"Fold", vec![odd(2), odd(3), Chunk::list(*b"tdgp", vec![odd(5)])]),
Chunk { id: *b"LIST", body: Body::OpaqueList { list_type: *b"btdk", data: Cow::Owned(vec![1, 2, 3]) }, pad: None },
Chunk { id: *b"tdmn", body: Body::Chunks(vec![odd(7), padded]), pad: None },
odd(9),
],
trailer: Cow::Borrowed(b"<x:xmpmeta/>"),
},
];
for file in &trees {
let out = write(file).expect("write");
assert_eq!(sequence_len(&file.chunks, 1).map(|n| n + 12 + file.trailer.len()), Some(out.len()));
// Exact with std's allocator today; grown push by push it would be rounded up.
assert_eq!(out.capacity(), out.len(), "the output grew past its size");
}
}
/// A tree past the container size fails before serialising.
#[cfg(target_pointer_width = "64")]
#[test]
fn a_tree_past_the_container_size_fails_before_serialising() {
// 4097 chunks borrowing one 1 MiB body: over 4 GiB of output, none of it written.
let body = vec![0u8; 1 << 20];
let chunks: Vec<Chunk<'_>> = (0..4097).map(|_| Chunk::data(*b"cdat", &body[..])).collect();
let file = AepFile { chunks, trailer: Cow::Borrowed(&[]) };
assert_eq!(write(&file), Err(AepError::TooLarge));
// Just under the limit still sizes, and fails nothing up front.
assert!(sequence_len(&file.chunks[..4000], 1).is_some_and(|n| u32::try_from(n + 4).is_ok()));
}
/// A tree past the depth limit has no size.
#[test]
fn a_tree_past_the_depth_limit_has_no_size() {
let mut deep = Chunk::data(*b"cdat", vec![0]);
for _ in 0..=MAX_WRITE_DEPTH {
deep = Chunk::list(*b"tdgp", vec![deep]);
}
let file = AepFile { chunks: vec![deep], trailer: Cow::Borrowed(&[]) };
assert_eq!(sequence_len(&file.chunks, 1), None);
assert_eq!(write(&file), Err(AepError::TooDeep));
}
}
+1
View File
@@ -26,6 +26,7 @@ mod footage;
mod fx_defs;
mod fxsynth;
pub(crate) mod icc;
mod keys;
mod markers;
mod masks;
mod plan;
+31 -33
View File
@@ -6,15 +6,20 @@
//! (read-backs t-no<id>, t-zcdta, u-zsspc, u-noclrs, u-noftgi, u-nofrec, u-nocdrp).
use crate::Chunk;
use crate::export::synth::{tdmn, tdsn};
use crate::export::synth::{GROUP_END, tdmn, tdsn};
use crate::export::tree::Fixed;
/// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it.
/// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it. The layouts are
/// fixed in code ([`Fixed`]).
pub(crate) fn bytes(len: usize, fields: &[(usize, &[u8])]) -> Vec<u8> {
let mut b = vec![0u8; len];
layout(len, fields).finish(|| format!("base::bytes ({len} bytes, fields at {:?})", fields.iter().map(|(at, v)| (*at, v.len())).collect::<Vec<_>>()))
}
/// The fixed layout of `len` zero bytes with `fields` written at their offsets.
fn layout(len: usize, fields: &[(usize, &[u8])]) -> Fixed {
let mut b = Fixed::zeroed(len);
for (at, v) in fields {
if let Some(dst) = b.get_mut(*at..at.saturating_add(v.len())) {
dst.copy_from_slice(v);
}
b.put(*at, v);
}
b
}
@@ -52,12 +57,8 @@ pub(crate) fn project_chunks() -> Vec<Chunk<'static>> {
// colour depth (2: 32 bits); the save noise after the code page is left zero.
let nhed = bytes(32, &[(7, &[5]), (9, &[1]), (12, &[0x1e, 16, 2, 2]), (18, &UTF8_CODE_PAGE)]);
let nnhd = bytes(40, &[(7, &[5]), (9, &[1]), (15, &[0x1e]), (19, &[16, 2]), (24, &[2]), (26, &UTF8_CODE_PAGE)]);
let mut lhd3 = bytes(52, &[(0, &0x00d0_0bee_u32.to_be_bytes()), (12, &1u32.to_be_bytes()), (16, &RQ_ITEM_LEN.to_be_bytes())]);
for at in [20, 24, 28] {
if let Some(dst) = lhd3.get_mut(at..at + 4) {
dst.copy_from_slice(&1u32.to_be_bytes());
}
}
let one = 1u32.to_be_bytes();
let lhd3 = bytes(52, &[(0, &0x00d0_0bee_u32.to_be_bytes()), (12, &one), (16, &RQ_ITEM_LEN.to_be_bytes()), (20, &one), (24, &one), (28, &one)]);
let wsnm: Vec<u8> = "Default".encode_utf16().flat_map(u16::to_be_bytes).collect();
vec![
Chunk::data(*b"svap", VERSION.to_vec()),
@@ -157,30 +158,17 @@ fn view_layer(ty: &[u8; 4], name: &str, label: u8, kind: u8) -> Vec<Chunk<'stati
let markers = kind == 4;
let mut name_copy = name.as_bytes().to_vec();
name_copy.truncate(31);
let mut ldta = bytes(
164,
&[
(8, &1u32.to_be_bytes()),
(16, &600u32.to_be_bytes()),
(24, &25_600u32.to_be_bytes()),
(28, &VIEW_OUT.to_be_bytes()),
(32, &25_600u32.to_be_bytes()),
(61, &[label]),
(64, &name_copy),
(108, &1u32.to_be_bytes()),
(128, &u32::from(kind).to_be_bytes()),
],
);
// Byte 38: 3D and auto-orient on the cameras; byte 39: the video switch (and on the markers
// layer also effects, locked and collapse); quality Draft and blend mode Normal on the
// markers layer.
let flags: &[(usize, u8)] = if markers { &[(5, 2), (39, 0x87), (99, 2)] } else { &[(38, 0x44), (39, 1)] };
for (at, v) in flags {
if let Some(b) = ldta.get_mut(*at) {
*b = *v;
}
}
let root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn("ADBE Group End")];
let flags: &[(usize, &[u8])] = if markers { &[(5, &[2]), (39, &[0x87]), (99, &[2])] } else { &[(38, &[0x44]), (39, &[1])] };
let (one, scale, kind_word) = (1u32.to_be_bytes(), 25_600u32.to_be_bytes(), u32::from(kind).to_be_bytes());
let (base, out, label) = (600u32.to_be_bytes(), VIEW_OUT.to_be_bytes(), [label]);
let mut fields: Vec<(usize, &[u8])> =
vec![(8, &one), (16, &base), (24, &scale), (28, &out), (32, &scale), (61, &label), (64, &name_copy), (108, &one), (128, &kind_word)];
fields.extend_from_slice(flags);
let ldta = bytes(164, &fields);
let root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn(GROUP_END)];
let layer = Chunk::list(*ty, vec![Chunk::data(*b"ldta", ldta), utf8(name), Chunk::list(*b"tdgp", root), guides()]);
let mut out = vec![layer, Chunk::list(*b"Ewst", Vec::new())];
out.extend(crate::export::skeleton::f_records());
@@ -293,6 +281,16 @@ pub(crate) fn solid_item() -> Chunk<'static> {
mod tests {
use super::*;
/// A field out of range is an error.
#[test]
fn a_field_out_of_range_is_an_error() {
let ok = layout(4, &[(0, &[1]), (3, &[2])]);
assert!(ok.error().is_none());
assert_eq!(ok.finish(String::new), [1, 0, 0, 2]);
assert!(layout(4, &[(3, &[1, 2])]).error().is_some());
assert!(layout(4, &[(usize::MAX, &[1])]).error().is_some());
}
/// The chunks After Effects refuses a file without, or crashes on (aep-export.md, "What After
/// Effects needs").
#[test]
+7 -32
View File
@@ -13,7 +13,8 @@ use effectcraft_keyframe::Value;
use effectcraft_project::{Layer, LightKind};
use crate::export::report::{Collector, LossCategory};
use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, tpl};
use crate::export::synth::{GROUP_END, is_tdmn, tdmn};
use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, split_group, tpl};
/// A camera's options in the file's units (pixels, percent, a switch).
#[derive(Clone, Debug, PartialEq)]
@@ -183,50 +184,24 @@ const LIGHT_ORDER: &[&str] = &[
"ADBE Casts Shadows",
];
pub(crate) fn tdmn_chunk(name: &str) -> Chunk<'static> {
let mut b = vec![0u8; 40];
for (i, c) in name.bytes().take(39).enumerate() {
b[i] = c;
}
Chunk::data(*b"tdmn", b)
}
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let b = c.bytes()?;
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
std::str::from_utf8(b.get(..end)?).ok().map(str::to_string)
}
/// Sets (or inserts) the records of an options group (`tdmn` named `group`) of a layer.
fn put_group(layr: &mut Chunk<'static>, group: &str, order: &[&str], set: Vec<(&str, Chunk<'static>)>, remove: &[&str]) -> R<()> {
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(group)).ok_or_else(|| tpl("options group missing"))?;
let at = kids.iter().position(|c| is_tdmn(c, group)).ok_or_else(|| tpl("options group missing"))?;
let g = kids.get_mut(at + 1).ok_or_else(|| tpl("options group without a list"))?;
let gk = kids_mut(g)?;
let first = gk.iter().position(|c| c.is(b"tdmn")).unwrap_or(gk.len());
let header: Vec<Chunk<'static>> = gk.drain(..first).collect();
let mut pairs: Vec<(String, Vec<Chunk<'static>>)> = Vec::new();
let mut it = std::mem::take(gk).into_iter();
while let Some(m) = it.next() {
let name = tdmn_name(&m).unwrap_or_default();
if name == "ADBE Group End" {
pairs.push((name, vec![m]));
continue;
}
let Some(node) = it.next() else { break };
// An atom group (Light Env Atom) carries its own Group End inside the list.
pairs.push((name, vec![m, node]));
}
// An atom group (Light Env Atom) carries its own Group End inside its list.
let (header, mut pairs) = split_group(gk)?;
pairs.retain(|(n, _)| !remove.contains(&n.as_str()));
for (name, rec) in set {
let entry = vec![tdmn_chunk(name), rec];
let entry = vec![tdmn(name), rec];
match pairs.iter_mut().find(|(n, _)| n == name) {
Some(slot) => slot.1 = entry,
None => pairs.push((name.to_string(), entry)),
}
}
let rank = |n: &str| if n == "ADBE Group End" { usize::MAX } else { order.iter().position(|g| *g == n).unwrap_or(order.len()) };
let rank = |n: &str| if n == GROUP_END { usize::MAX } else { order.iter().position(|g| *g == n).unwrap_or(order.len()) };
pairs.sort_by_key(|(n, _)| rank(n));
gk.extend(header);
gk.extend(pairs.into_iter().flat_map(|(_, v)| v));
+359 -80
View File
@@ -6,8 +6,10 @@ use std::collections::HashMap;
use crate::{AepFile, Chunk};
use effectcraft_project::{ItemId, LayerId};
use crate::export::keys::fill_key_list;
use crate::export::plan::{CompPlan, Entry, ItemPlan, KeyPlan, LayerPlan, LayerSrc, ProjectPlan, PropKind, PropPlan, SolidPlan, natural_key};
use crate::export::template::Template;
use crate::export::synth::{is_tdmn, tdmn_name};
use crate::export::template::{self, Template};
use crate::export::tree::{
R, child_data, data_mut, kids_mut, list_mut, put, put_bits, put_cstr, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, set_utf8, tpl,
};
@@ -58,10 +60,14 @@ struct Cx<'t> {
type Sorted = (String, u64, Vec<Chunk<'static>>);
pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
let t = Template::load()?;
emit_with(template::shared()?, plan, extra)
}
/// [`emit`] with the prototypes of `t`.
fn emit_with(t: &Template, plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
let special = plan.root.iter().find(|e| e.name == "Solids" && matches!(e.plan, ItemPlan::Folder(_))).map(|e| e.ec);
let mut cx = Cx {
t: &t,
t,
ids: Ids { next: FIRST_ID },
solid_ids: HashMap::new(),
comp_ids: HashMap::new(),
@@ -77,16 +83,14 @@ pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
let mut root: Vec<Sorted> = Vec::new();
build_level(&plan.root, &mut cx, &ids_by_entry, &mut |name, key, chunks| root.push((name, key, chunks)))?;
// The Solids folder: root-level solids and the hidden solids of null layers.
let mut solids_children: Vec<Sorted> = Vec::new();
for (name, key, chunks) in root.iter().filter(|(_, k, _)| *k & ROOT_SOLID != 0) {
solids_children.push((name.clone(), *key, chunks.clone()));
}
root.retain(|(_, k, c)| *k & ROOT_SOLID == 0 && !c.is_empty());
for (i, (name, chunk)) in cx.extra_solids.iter().enumerate() {
let mut v = vec![chunk.clone()];
// The Solids folder: root-level solids and the hidden solids of null layers (moved, not
// copied: the root keeps neither).
let (mut solids_children, mut root): (Vec<Sorted>, Vec<Sorted>) = root.into_iter().partition(|(_, k, _)| *k & ROOT_SOLID != 0);
root.retain(|(_, _, c)| !c.is_empty());
for (i, (name, chunk)) in std::mem::take(&mut cx.extra_solids).into_iter().enumerate() {
let mut v = vec![chunk];
v.extend(cx.t.fset.iter().cloned());
solids_children.push((name.clone(), i as u64, v));
solids_children.push((name, i as u64, v));
}
let mut folder = match cx.special_slot.take() {
Some((item, inner)) => {
@@ -125,26 +129,57 @@ pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
/// Marks a root entry that goes into the `Solids` folder.
const ROOT_SOLID: u64 = 1 << 63;
/// Sorts by name in natural order, then by the tie-break key. Each name's sort key is built once,
/// not on every comparison; the sort is stable, as `sort_by` is.
fn sort_named(v: &mut [Sorted]) {
v.sort_by(|a, b| natural_cmp(&a.0, &b.0).then(a.1.cmp(&b.1)));
v.sort_by_cached_key(|(name, key, _)| (natural_bytes(name), *key));
}
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let (ka, kb) = (natural_key(a), natural_key(b));
for (x, y) in ka.iter().zip(kb.iter()) {
let c = match (x.0, y.0) {
(true, true) => x.2.cmp(&y.2).then_with(|| x.1.cmp(&y.1)),
(false, false) => x.1.cmp(&y.1),
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
};
if c != std::cmp::Ordering::Equal {
return c;
/// The [`natural_key`] of `name` as one byte string whose plain byte order is the natural order:
/// segment by segment, a number before text, numbers by value and then by their digit strings
/// byte by byte (`01` before `1`), text by its lowercase string, and a key that is a prefix of
/// the other first.
///
/// - A number: `0x01`, the count of significant bytes of its value, those bytes big-endian, its
/// digits, `0x00` (digits are never `0x00`, so a digit string that is a prefix of another, as
/// `0` of `00`, sorts first).
/// - Text: `0x02`, its lowercase UTF-8 with each `0x00` written as `0x00 0x01`, then `0x00 0x00`.
///
/// At most 5 bytes per byte of `name` (`NATURAL_BYTES_PER_NAME_BYTE`), so the cached keys stay
/// within a small multiple of the names they sort (one segment as a `(bool, String, u128)` takes
/// 48 bytes plus its string, which is why the segments themselves are not cached).
fn natural_bytes(name: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(name.len().saturating_add(4));
for (number, text, value) in natural_key(name) {
if number {
out.push(0x01);
let be = value.to_be_bytes();
let zeros = be.iter().take_while(|b| **b == 0).count();
let significant = be.get(zeros..).unwrap_or_default();
out.push(significant.len() as u8);
out.extend_from_slice(significant);
out.extend_from_slice(text.as_bytes());
out.push(0x00);
} else {
out.push(0x02);
for b in text.bytes() {
out.push(b);
if b == 0 {
out.push(0x01);
}
}
out.extend_from_slice(&[0x00, 0x00]);
}
}
ka.len().cmp(&kb.len())
out
}
/// The bound on [`natural_bytes`]: a run of `d` digits takes at most `3 + 2d` bytes (its value
/// never needs more bytes than digits), a text run of `b` bytes at most `3 + 2b` (lowercasing and
/// the NUL escape at most double a character), and every run is at least one byte long.
#[cfg(test)]
const NATURAL_BYTES_PER_NAME_BYTE: usize = 5;
fn assign_ids(entries: &[Entry], cx: &mut Cx<'_>, map: &mut HashMap<ItemId, u32>) -> R<()> {
// Iterative to bound the stack by the folder depth the plan already limits.
let mut stack: Vec<&Entry> = entries.iter().rev().collect();
@@ -404,8 +439,7 @@ fn put_used_comps(cx: &Cx<'_>, item: &mut Chunk<'static>, cp: &CompPlan) -> R<()
fn put_time_remap_max(layr: &mut Chunk<'static>, seconds: f64) -> R<()> {
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at =
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
let tdbs = kids.get_mut(at + 1).ok_or_else(|| tpl("Time Remapping without a record"))?;
put_f64(child_data(tdbs, b"tduM")?, 0, seconds)
}
@@ -420,8 +454,7 @@ fn put_time_remap_max(layr: &mut Chunk<'static>, seconds: f64) -> R<()> {
fn put_time_remap(cx: &Cx<'_>, layr: &mut Chunk<'static>, remap: &PropPlan) -> R<()> {
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at =
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
let tdbs = kids.get_mut(at + 1).ok_or_else(|| tpl("Time Remapping without a record"))?;
put_u32(child_data(tdbs, b"tdsb")?, 0, 1)?;
{
@@ -454,21 +487,12 @@ fn put_time_remap(cx: &Cx<'_>, layr: &mut Chunk<'static>, remap: &PropPlan) -> R
.find(|k| matches!(&k.body, crate::Body::List { list_type, .. } if list_type == b"list"))
.cloned()
.ok_or_else(|| tpl("keyed prototype without a list"))?;
let n = u32::try_from(remap.keys.len()).map_err(|_| tpl("too many keys"))?;
let size = 48usize;
{
let lhd3 = child_data(&mut list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, n, 48)?;
}
let mut ldat = Vec::with_capacity(size * remap.keys.len());
for k in &remap.keys {
ldat.extend(plain_record(1, k, size)?);
}
let ld = kids_mut(&mut list)?;
match ld.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", ldat),
None => ld.push(Chunk::data(*b"ldat", ldat)),
}
fill_key_list(&mut list, ldat, remap.keys.len(), size)?;
if let Some(slot) = rk.get_mut(cdat_at) {
*slot = list;
}
@@ -616,10 +640,7 @@ fn put_masks(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan) -> R<()> {
let parade = crate::export::masks::build_parade(&cx.t.masks, &lp.masks, lp.source_size)?;
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at = kids
.iter()
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
.ok_or_else(|| tpl("layer without a Transform group"))?;
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?;
kids.splice(at..at, parade);
Ok(())
}
@@ -633,10 +654,7 @@ fn put_effects(cx: &mut Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, layer
let parade = crate::export::effects::build_parade(&cx.t.effects, &lp.fx, layer_id, layer_ids, lp.source_size, &mut cx.fx_use)?;
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at = kids
.iter()
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
.ok_or_else(|| tpl("layer without a Transform group"))?;
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?;
kids.splice(at..at, parade);
Ok(())
}
@@ -659,11 +677,6 @@ const GROUP_ORDER: [&str; 13] = [
"ADBE Group End",
];
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let d = c.bytes()?;
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
}
/// Inserts the properties of `lp` into the layer's Transform group, in the order the file uses.
fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &CompPlan) -> R<()> {
if lp.props.is_empty() {
@@ -671,20 +684,19 @@ fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &C
}
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at = kids
.iter()
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
.ok_or_else(|| tpl("layer without a Transform group"))?;
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?;
let group = kids.get_mut(at + 1).ok_or_else(|| tpl("Transform group without a list"))?;
let gk = kids_mut(group)?;
// Split the group into its header (up to the first match name) and (tdmn, node) pairs.
let first = gk.iter().position(|c| c.is(b"tdmn")).ok_or_else(|| tpl("empty Transform group"))?;
let header: Vec<Chunk<'static>> = gk.drain(..first).collect();
let mut pairs: Vec<(String, Vec<Chunk<'static>>)> = Vec::new();
let mut pairs: Vec<(Vec<u8>, Vec<Chunk<'static>>)> = Vec::new();
let mut it = std::mem::take(gk).into_iter();
while let Some(m) = it.next() {
let name = tdmn_name(&m).unwrap_or_default();
if name == "ADBE Group End" {
// The match name as the shared decoder reads it (lossy UTF-8 up to the first NUL). Every
// name looked up below is ASCII, so a name matches exactly when its bytes do.
let name = tdmn_name(&m).unwrap_or_default().into_bytes();
if name == b"ADBE Group End" {
pairs.push((name, vec![m]));
continue;
}
@@ -694,22 +706,19 @@ fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &C
for p in &lp.props {
let name = p.kind.match_name();
let built = build_prop(cx, p, lp, cp)?;
let tdmn = pairs.iter().find(|(n, _)| n == name).and_then(|(_, v)| v.first().cloned()).unwrap_or_else(|| {
cx.t.stat
.iter()
.chain(cx.t.keyd.iter())
.find(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name))
.cloned()
.unwrap_or_else(|| Chunk::data(*b"tdmn", Vec::new()))
let tdmn = pairs.iter().find(|(n, _)| n == name.as_bytes()).and_then(|(_, v)| v.first().cloned()).unwrap_or_else(|| {
cx.t.stat.iter().chain(cx.t.keyd.iter()).find(|c| is_tdmn(c, name)).cloned().unwrap_or_else(|| Chunk::data(*b"tdmn", Vec::new()))
});
let entry = vec![tdmn, built];
if let Some(slot) = pairs.iter_mut().find(|(n, _)| n == name) {
if let Some(slot) = pairs.iter_mut().find(|(n, _)| n == name.as_bytes()) {
slot.1 = entry;
} else {
pairs.push((name.to_string(), entry));
pairs.push((name.as_bytes().to_vec(), entry));
}
}
let rank = |n: &str| if n == "ADBE Group End" { usize::MAX } else { GROUP_ORDER.iter().position(|g| *g == n).unwrap_or(GROUP_ORDER.len()) };
let rank = |n: &[u8]| {
if n == b"ADBE Group End" { usize::MAX } else { GROUP_ORDER.iter().position(|g| g.as_bytes() == n).unwrap_or(GROUP_ORDER.len()) }
};
pairs.sort_by_key(|(n, _)| rank(n));
gk.extend(header);
gk.extend(pairs.into_iter().flat_map(|(_, v)| v));
@@ -743,22 +752,12 @@ fn build_prop(cx: &Cx<'_>, p: &PropPlan, lp: &LayerPlan, cp: &CompPlan) -> R<Chu
put_f64(cdat, i * 8, *v)?;
}
} else {
let list = list_mut(&mut tdbs, b"list")?;
let n = u32::try_from(p.keys.len()).map_err(|_| tpl("too many keys"))?;
let size = if p.kind.spatial() { 56 + 24 * p.kind.dims() } else { 8 + 40 * p.kind.dims() };
{
let lhd3 = child_data(list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, n, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
}
let mut ldat = Vec::with_capacity(size * p.keys.len());
for k in &p.keys {
ldat.extend(key_record(p.kind, k, size)?);
}
let ld = kids_mut(list)?;
match ld.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", ldat),
None => ld.push(Chunk::data(*b"ldat", ldat)),
}
fill_key_list(list_mut(&mut tdbs, b"list")?, ldat, p.keys.len(), size)?;
}
if let Some((text, _)) = &p.expr {
let kids = kids_mut(&mut tdbs)?;
@@ -818,3 +817,283 @@ pub(crate) fn plain_record(n: usize, k: &KeyPlan, size: usize) -> R<Vec<u8>> {
}
Ok(r)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{Comp, Expression, ItemKind, LayerSource, Project, Property, Solid, build};
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
/// Seconds as ticks.
fn secs(s: f64) -> Tick {
Tick((s * TICKS_PER_SECOND as f64).round() as i64)
}
/// Adds a layer to `comp`, changed by `edit` first.
fn add_layer(p: &mut Project, comp: ItemId, name: &str, source: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
let mut c = p.comp(comp).cloned().expect("comp");
let mut l = build::layer(p, &c, name, source, (640, 360), None);
l.name = name.to_string();
edit(&mut l);
c.layers.insert(0, l);
if let Some(i) = p.item_mut(comp) {
i.kind = ItemKind::Comp(Arc::new(c));
}
}
/// Gives the property two keys (0 s and 1.5 s) holding its value.
fn two_keys(prop: Option<&mut Property>) {
let prop = prop.expect("property");
let v = prop.value.clone();
prop.keys = vec![Keyframe::new(secs(0.0), v.clone()), Keyframe::new(secs(1.5), v)];
}
/// A project that reaches every part of `emit`: folders (one named `Solids`), root and nested
/// solids with names that sort naturally, a precomposition, null layers (hidden solids), keyed
/// Transform properties, an expression and a keyed time remap.
fn varied(tag: &str) -> Project {
let mut p = Project::default();
let solid = |w| ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: w, height: 360, pixel_aspect: 1.0 });
let solids = p.add_item("Solids", Label::None, None, ItemKind::Folder);
let nested = p.add_item("Folder 10", Label::None, None, ItemKind::Folder);
p.add_item("folder 9", Label::None, Some(nested), ItemKind::Folder);
let mut ids = Vec::new();
for name in ["Solid 10", "Solid 2", "solid 2", "Solid 02", "Solid", "Ölung 1", ""] {
ids.push(p.add_item(&format!("{name}{tag}"), Label::Red, None, solid(640)));
}
p.add_item("In Solids 3", Label::None, Some(solids), solid(320));
p.add_item("In folder", Label::None, Some(nested), solid(100));
let pre = p.add_item("Pre 1", Label::None, Some(nested), ItemKind::Comp(Arc::new(Comp::new(640, 360, FrameRate::new(25, 1), secs(3.0)))));
add_layer(&mut p, pre, "S", LayerSource::Solid { item: ids[0] }, |_| {});
let main = p.add_item("Main", Label::None, None, ItemKind::Comp(Arc::new(Comp::new(1920, 1080, FrameRate::new(30, 1), secs(5.0)))));
add_layer(&mut p, main, "Null A", LayerSource::Null, |_| {});
add_layer(&mut p, main, "Null B", LayerSource::Null, |l| two_keys(l.props.prop_mut("transform/position")));
add_layer(&mut p, main, "Solid layer", LayerSource::Solid { item: ids[1] }, |l| {
two_keys(l.props.prop_mut("transform/opacity"));
two_keys(l.props.prop_mut("transform/anchor"));
if let Some(r) = l.props.prop_mut("transform/rotation") {
r.expr = Some(Expression { text: "time * 10".into(), enabled: true });
}
});
add_layer(&mut p, main, "Precomp", LayerSource::Comp { item: pre }, |l| {
let mut r = Property::new(9_000, "timeRemap", "Time Remap", Value::Scalar(0.0));
r.keys = vec![Keyframe::new(secs(0.0), Value::Scalar(3.0)), Keyframe::new(secs(3.0), Value::Scalar(0.0))];
l.props.children.insert(0, r.into());
});
p
}
/// The export plan of `p`.
fn plan_of(p: &Project) -> ProjectPlan {
let mut col = crate::export::report::Collector::default();
crate::export::plan::plan(p, &mut col)
}
/// The template is built once and every later export uses the same one.
#[test]
fn the_template_is_built_once() {
let a = template::shared().expect("template");
let b = template::shared().expect("template");
assert!(std::ptr::eq(a, b));
}
/// Exports with the shared template give the bytes a freshly built template gives, also after
/// other exports have used it (nothing an export does reaches the template).
#[test]
fn the_shared_template_exports_what_a_fresh_one_does() {
let (a, b) = (plan_of(&varied("")), plan_of(&varied(" x")));
let fresh = |plan: &ProjectPlan| emit_with(&Template::load().expect("template"), plan, b"trailer").expect("emit");
let (want_a, want_b) = (fresh(&a), fresh(&b));
assert!(want_a != want_b);
// The project exports without losses and the remap is written.
let r = crate::export::export(&varied(""), &crate::export::ExportOptions { allow_losses: false }).expect("lossless");
assert_eq!(r.report.exported.time_remaps, 1);
// The null layers' hidden solids and the root solids went into the `Solids` folder.
let file = crate::parse(&want_a).expect("parse");
let folder = file.walk().map(|(_, c)| c).filter(|c| c.is_list(b"Sfdr")).map(|c| c.children().iter().filter(|k| k.is_list(b"Item")).count()).max();
assert_eq!(folder, Some(10));
for _ in 0..3 {
assert!(emit(&a, b"trailer").expect("emit") == want_a);
assert!(emit(&b, b"trailer").expect("emit") == want_b);
}
}
/// Exports running at the same time on several threads all give the single-threaded bytes.
/// This checks concurrent exports from a template that is already built: other tests in this
/// binary usually fill the shared template first, so the race on the first build is not
/// reliably exercised here (the `static OnceLock` makes that race safe by construction).
#[test]
fn concurrent_exports_give_the_same_bytes() {
let plans: Vec<ProjectPlan> = ["", " x", " y", " z"].iter().map(|t| plan_of(&varied(t))).collect();
let want: Vec<Vec<u8>> = plans.iter().map(|plan| emit_with(&Template::load().expect("template"), plan, &[]).expect("emit")).collect();
let got: Vec<Vec<Vec<u8>>> = std::thread::scope(|s| {
let handles: Vec<_> = (0..8)
.map(|i| {
let plans = &plans;
s.spawn(move || (0..4).map(|k| emit(&plans[(i + k) % plans.len()], &[]).expect("emit")).collect::<Vec<_>>())
})
.collect();
handles.into_iter().map(|h| h.join().expect("thread")).collect()
});
for (i, bytes) in got.iter().enumerate() {
for (k, b) in bytes.iter().enumerate() {
assert!(b == &want[(i + k) % want.len()], "thread {i}, export {k}");
}
}
}
/// The comparator `sort_named` used before its keys were cached: `natural_key` built on every
/// comparison.
fn old_sort_named(v: &mut [Sorted]) {
/// The natural name order the cached sort keys replaced, as the reference.
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let (ka, kb) = (natural_key(a), natural_key(b));
for (x, y) in ka.iter().zip(kb.iter()) {
let c = match (x.0, y.0) {
(true, true) => x.2.cmp(&y.2).then_with(|| x.1.cmp(&y.1)),
(false, false) => x.1.cmp(&y.1),
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
};
if c != std::cmp::Ordering::Equal {
return c;
}
}
ka.len().cmp(&kb.len())
}
v.sort_by(|a, b| natural_cmp(&a.0, &b.0).then(a.1.cmp(&b.1)));
}
/// The cached-key sort orders names exactly as the comparator it replaced, and keeps entries
/// with the same name and key in their order (each carries its position as a marker chunk).
#[test]
fn sort_named_orders_as_the_old_comparator() {
let parts = [
"",
"0",
"00",
"1",
"01",
"2",
"10",
"a",
"A",
"b",
" ",
"-",
".",
"é",
"É",
"ß",
"İ",
"\u{0}",
"\u{1}",
"a\u{0}",
"Ⱥ",
"K",
"ẞ",
"Solid ",
"solid ",
"x9",
"9x",
"340282366920938463463374607431768211455",
"340282366920938463463374607431768211456",
"999999999999999999999999999999999999999999",
];
// A small deterministic generator (splitmix64).
let mut state = 0x2545_f491_4f6c_dd1d_u64;
let mut next = move || {
state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
(z ^ (z >> 31)) as usize
};
for round in 0..2000 {
let n = 1 + next() % 40;
let v: Vec<Sorted> = (0..n)
.map(|i| {
let name: String = (0..next() % 4).map(|_| parts[next() % parts.len()]).collect();
let key = (next() % 3) as u64 | if next() % 2 == 0 { ROOT_SOLID } else { 0 };
(name, key, vec![Chunk::data(*b"mark", (i as u32).to_le_bytes().to_vec())])
})
.collect();
let (mut old, mut new) = (v.clone(), v);
old_sort_named(&mut old);
sort_named(&mut new);
assert_eq!(old, new, "round {round}");
}
}
/// The cached sort keys stay within [`NATURAL_BYTES_PER_NAME_BYTE`] bytes per name byte, also
/// for the worst name (255 bytes of alternating one-character text and digit runs: 255
/// segments, which as `(bool, String, u128)` segments took about 14 KB), and the per-character
/// premise of the bound holds for every character.
#[test]
fn sort_keys_are_bounded_by_the_name() {
for c in (0..=0x10_ffff_u32).filter_map(char::from_u32) {
let lower: usize = c.to_lowercase().map(char::len_utf8).sum::<usize>() + usize::from(c == '\0');
assert!(lower <= 2 * c.len_utf8(), "{c:?}");
}
let worst: String = (0..255).map(|i| if i % 2 == 0 { 'a' } else { '1' }).collect();
let others = ["", "1", "a", "\u{0}", "İ", "Ⱥ1", "99999999999999999999999999999999999999999999", &"\u{0}".repeat(255), &"Ⱥ".repeat(127)];
for name in std::iter::once(worst.as_str()).chain(others) {
let k = natural_bytes(name);
assert!(k.len() <= NATURAL_BYTES_PER_NAME_BYTE * name.len(), "{name:?}: {} bytes", k.len());
}
assert_eq!(natural_key(&worst).len(), 255);
assert!(natural_bytes(&worst).len() <= 1_275);
}
/// The shared `is_tdmn` / `tdmn_name` the emitter uses agree with the Latin-1 decoding it used
/// before for every name it looks up, which holds because those names are ASCII.
#[test]
fn match_names_compare_as_the_latin1_decoding_did() {
/// The Latin-1 decoding the emitter used before.
fn old_tdmn_name(c: &Chunk<'_>) -> Option<String> {
let d = c.bytes()?;
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
}
let mut wanted: Vec<&str> = GROUP_ORDER.to_vec();
wanted.extend(PropKind::ALL.iter().map(|k| k.match_name()));
wanted.extend(["ADBE Time Remapping", "ADBE Transform Group", "ADBE Group End"]);
assert!(wanted.iter().all(|n| n.is_ascii()));
let mut chunks: Vec<Chunk<'static>> = wanted.iter().map(|n| Chunk::data(*b"tdmn", format!("{n}\0\0junk").into_bytes())).collect();
chunks.extend([
Chunk::data(*b"tdmn", Vec::new()),
Chunk::data(*b"tdmn", b"ADBE Position".to_vec()),
Chunk::data(*b"tdmn", b"ADBE Position_0\0".to_vec()),
Chunk::data(*b"tdmn", b"ADBE Opacit\xe9\0".to_vec()),
Chunk::data(*b"tdmn", b"\xc3\x84DBE Scale\0".to_vec()),
Chunk::data(*b"tdmn", b"\0ADBE Scale".to_vec()),
Chunk::list(*b"tdmn", Vec::new()),
]);
for c in &chunks {
for n in &wanted {
assert_eq!(is_tdmn(c, n), old_tdmn_name(c).as_deref() == Some(*n), "{c:?} {n}");
let key = tdmn_name(c).unwrap_or_default().into_bytes();
assert_eq!(key == n.as_bytes(), old_tdmn_name(c).as_deref() == Some(*n), "{c:?} {n}");
}
}
}
/// `fill_key_list`, which the time remap and the Transform keys use, writes the counts and
/// replaces the records, or adds them when the list has none.
#[test]
fn fill_key_list_writes_counts_and_records() {
let lhd3 = || Chunk::data(*b"lhd3", vec![0; 52]);
let mut want_lhd3 = vec![0; 52];
crate::export::synth::put_key_counts(&mut want_lhd3, 3, 48).expect("counts");
for start in [vec![lhd3(), Chunk::data(*b"ldat", vec![9; 4])], vec![lhd3()]] {
let mut list = Chunk::list(*b"list", start);
fill_key_list(&mut list, vec![7; 144], 3, 48).expect("set");
assert_eq!(list.children(), &[Chunk::data(*b"lhd3", want_lhd3.clone()), Chunk::data(*b"ldat", vec![7; 144])]);
}
let mut no_header = Chunk::list(*b"list", Vec::new());
assert!(fill_key_list(&mut no_header, Vec::new(), 0, 48).is_err());
}
}
+59 -41
View File
@@ -11,7 +11,8 @@
use crate::Chunk;
use crate::effect_map::{Conv, by_match_name};
use crate::export::fx_defs::{BUILT_IN, FxDef, PDef};
use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn};
use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn, utf8_in};
use crate::export::tree::Fixed;
use crate::{CURVE_IDENTITY, CurvesData, MeshData};
/// The layer a prototype's layer parameters name when their default is the owning layer; the
@@ -37,12 +38,6 @@ pub(crate) fn value_spec(code: u8, range: Option<(f64, f64)>) -> Option<RecSpec>
})
}
fn put(b: &mut [u8], at: usize, bytes: &[u8]) {
if let Some(dst) = b.get_mut(at..at + bytes.len()) {
dst.copy_from_slice(bytes);
}
}
fn fixed(v: f64) -> [u8; 4] {
((v * 65536.0).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32).to_be_bytes()
}
@@ -53,83 +48,88 @@ fn fixed(v: f64) -> [u8; 4] {
/// display 124; fixed and integer: 124 / 128 / 132 / 136, default 140, display 144). Every other
/// byte is zero (the switches at 48 are not needed: s12-e2-nofl).
pub(crate) fn pard(p: &PDef, popup_items: Option<u16>) -> Vec<u8> {
let mut b = vec![0u8; 148];
put(&mut b, 12, &u32::from(p.code).to_be_bytes());
pard_layout(p, popup_items).finish(|| format!("pard (type {})", p.code))
}
/// The 148-byte `pard` layout of a parameter definition: type, name and defaults.
fn pard_layout(p: &PDef, popup_items: Option<u16>) -> Fixed {
let mut b = Fixed::zeroed(148);
b.put(12, &u32::from(p.code).to_be_bytes());
let d = |i: usize| p.default.get(i).copied().unwrap_or(0.0);
// After Effects takes the default of a parameter without a value record from here (s8-e1:
// the copies below are the ones it reads).
match p.code {
10 => {
put(&mut b, 56, &d(0).to_be_bytes());
b.put(56, &d(0).to_be_bytes());
// The default again as `f32` at 120, the display word (decimals) at 124.
put(&mut b, 120, &(d(0) as f32).to_be_bytes());
put(&mut b, 124, &p.display.to_be_bytes());
b.put(120, &(d(0) as f32).to_be_bytes());
b.put(124, &p.display.to_be_bytes());
}
2 => put(&mut b, 56, &fixed(d(0))),
2 => b.put(56, &fixed(d(0))),
// An angle: the default again at 60.
3 => {
put(&mut b, 56, &fixed(d(0)));
put(&mut b, 60, &fixed(d(0)));
b.put(56, &fixed(d(0)));
b.put(60, &fixed(d(0)));
}
1 => put(&mut b, 56, &(d(0).round() as i32).to_be_bytes()),
1 => b.put(56, &(d(0).round() as i32).to_be_bytes()),
// A checkbox: the default again as a byte at 60.
4 => {
put(&mut b, 56, &(d(0).round().max(0.0) as u32).to_be_bytes());
put(&mut b, 60, &[u8::from(d(0) != 0.0)]);
b.put(56, &(d(0).round().max(0.0) as u32).to_be_bytes());
b.put(60, &[u8::from(d(0) != 0.0)]);
}
// A popup: the default again as `u16` at 62 (the item count is at 60).
7 => {
let sel = d(0).round().clamp(0.0, f64::from(u16::MAX));
put(&mut b, 56, &(sel as u32).to_be_bytes());
put(&mut b, 62, &(sel as u16).to_be_bytes());
b.put(56, &(sel as u32).to_be_bytes());
b.put(62, &(sel as u16).to_be_bytes());
}
6 => {
// Fractions of the layer at 56 / 60 and the same in percent at 68 / 72.
put(&mut b, 56, &fixed(d(0)));
put(&mut b, 60, &fixed(d(1)));
put(&mut b, 68, &fixed(d(0) * 100.0));
put(&mut b, 72, &fixed(d(1) * 100.0));
b.put(56, &fixed(d(0)));
b.put(60, &fixed(d(1)));
b.put(68, &fixed(d(0) * 100.0));
b.put(72, &fixed(d(1) * 100.0));
}
5 => {
// ARGB at 56 and the same at 60.
let c: Vec<u8> = (0..4).map(|i| d(i).round().clamp(0.0, 255.0) as u8).collect();
put(&mut b, 56, &c);
put(&mut b, 60, &c);
b.put(56, &c);
b.put(60, &c);
}
_ => {}
}
if p.code == 7 {
let n = popup_items.unwrap_or_else(|| u16::try_from(p.display).unwrap_or(0));
put(&mut b, 60, &n.to_be_bytes());
b.put(60, &n.to_be_bytes());
}
match p.code {
10 => {
if let Some(m) = p.valid_min {
put(&mut b, 104, &(m as f32).to_be_bytes());
b.put(104, &(m as f32).to_be_bytes());
}
if let Some(m) = p.valid_max {
put(&mut b, 108, &(m as f32).to_be_bytes());
b.put(108, &(m as f32).to_be_bytes());
}
if let Some((lo, hi)) = p.range {
put(&mut b, 112, &(lo as f32).to_be_bytes());
put(&mut b, 116, &(hi as f32).to_be_bytes());
b.put(112, &(lo as f32).to_be_bytes());
b.put(116, &(hi as f32).to_be_bytes());
}
}
1 | 2 => {
let enc = |v: f64| if p.code == 2 { fixed(v) } else { (v.round() as i32).to_be_bytes() };
if let Some(m) = p.valid_min {
put(&mut b, 124, &enc(m));
b.put(124, &enc(m));
}
if let Some(m) = p.valid_max {
put(&mut b, 128, &enc(m));
b.put(128, &enc(m));
}
if let Some((lo, hi)) = p.range {
put(&mut b, 132, &enc(lo));
put(&mut b, 136, &enc(hi));
b.put(132, &enc(lo));
b.put(136, &enc(hi));
}
// The default again at 140, the display word (decimals, percent) at 144.
put(&mut b, 140, &enc(d(0)));
put(&mut b, 144, &p.display.to_be_bytes());
b.put(140, &enc(d(0)));
b.put(144, &p.display.to_be_bytes());
}
_ => {}
}
@@ -224,7 +224,7 @@ fn data_record(data: Vec<u8>) -> [Chunk<'static>; 2] {
/// group (records only for layer parameters, groups, buttons and arbitrary data, the built-in
/// group) and `pgui` (zero).
pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> {
let fnam = Chunk { id: *b"fnam", body: crate::Body::Chunks(vec![Chunk::data(*b"Utf8", fx.name.as_bytes().to_vec())]), pad: None };
let fnam = utf8_in(*b"fnam", fx.name);
let count = u32::try_from(fx.params.len()).unwrap_or(u32::MAX);
let mut part = vec![Chunk::data(*b"parn", count.to_be_bytes().to_vec())];
let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME)];
@@ -235,11 +235,11 @@ pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> {
if matches!(p.code, 4 | 7 | 15) {
// Checkboxes, popups and buttons have a `pdnm` (their label or items); its text may
// be empty.
part.push(Chunk { id: *b"pdnm", body: crate::Body::Chunks(vec![Chunk::data(*b"Utf8", Vec::new())]), pad: None });
part.push(utf8_in(*b"pdnm", ""));
}
if p.id == BUILT_IN {
values.push(tdmn(&record));
values.push(Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME), tdmn("ADBE Group End")]));
values.push(Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME), tdmn(GROUP_END)]));
continue;
}
match p.code {
@@ -265,6 +265,24 @@ pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> {
_ => {}
}
}
values.push(tdmn("ADBE Group End"));
values.push(tdmn(GROUP_END));
Chunk::list(*b"sspc", vec![fnam, Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values), Chunk::data(*b"pgui", vec![0u8; 16])])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::export::fx_defs::FX_DEFS;
/// The `pard` of every parameter of the effect table fits its 148 bytes.
#[test]
fn every_parameter_definition_fits() {
for fx in FX_DEFS {
for (i, p) in fx.params.iter().enumerate() {
let b = pard_layout(p, popup_items(fx, i));
assert!(b.error().is_none(), "{} {}", fx.match_name, i);
assert_eq!(b.finish(String::new).len(), 148);
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Key lists shared by the exporters: the times of a property's keys in the file's units, the
//! spatial key record, and the rewrite of a key list (`LIST list`: `lhd3` + `ldat`, aep-format.md
//! 3.5).
use effectcraft_keyframe::Keyframe;
use crate::Chunk;
use crate::export::plan::KeyPlan;
use crate::export::report::{Collector, LossCategory};
use crate::export::timebase::TimeBase;
use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, tpl};
/// The keys of a property at their times in the file's units, in time order. A key that does not
/// fall on the time base is reported as moved; keys that end up at the same time are merged (the
/// first one is kept) and reported once. `noun` names the key in the report ("key", "path key").
pub(crate) fn timed_keys<'k>(keys: &'k [Keyframe], tb: &TimeBase, what: &str, noun: &str, col: &mut Collector) -> Vec<(i64, &'k Keyframe)> {
let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(keys.len());
for k in keys {
let (u, inexact) = tb.units(k.time);
if inexact {
col.lose(LossCategory::RoundedTimes, format!("{what}: {noun} at {:.4} s moved to the time base", k.time.seconds()));
}
timed.push((u, k));
}
timed.sort_by_key(|(u, _)| *u);
let before = timed.len();
timed.dedup_by_key(|(u, _)| *u);
if timed.len() != before {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: {noun}s at the same time were merged"));
}
timed
}
/// A key of a point (flags `0x0007`, `u32@8` the automatic tangent switch; the spatial layout of
/// a 2D Position, 853) or a colour (flags `0x0001`, 854): the temporal ease of a point at 24..55,
/// then the `n` values at 56 and, for a point, the spatial in and out tangents after them.
pub(crate) fn spatial_record(k: &KeyPlan, n: usize, point: bool, size: usize) -> R<Vec<u8>> {
let mut r = vec![0u8; size];
put(&mut r, 0, &k.time.to_be_bytes())?;
put_u8(&mut r, 4, k.in_interp)?;
put_u8(&mut r, 5, k.out_interp)?;
let base: u16 = if point { 0x0007 } else { 0x0001 };
put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?;
if point {
put_u32(&mut r, 8, u32::from(k.spatial_auto))?;
let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default());
put_f64(&mut r, 24, i.speed)?;
put_f64(&mut r, 32, i.influence)?;
put_f64(&mut r, 40, o.speed)?;
put_f64(&mut r, 48, o.influence)?;
}
for j in 0..n {
put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?;
if point {
put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?;
put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?;
}
}
Ok(r)
}
/// Fills a key list (`LIST list`) with `count` records of `size` bytes: the count words of `lhd3`
/// and `ldat` (added when the list has none).
pub(crate) fn fill_key_list(list: &mut Chunk<'static>, records: Vec<u8>, count: usize, size: usize) -> R<()> {
let n = u32::try_from(count).map_err(|_| tpl("too many keys"))?;
let size = u32::try_from(size).map_err(|_| tpl("record size"))?;
crate::export::synth::put_key_counts(child_data(list, b"lhd3")?, n, size)?;
let kids = kids_mut(list)?;
match kids.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", records),
None => kids.push(Chunk::data(*b"ldat", records)),
}
Ok(())
}
/// Rewrites the key list of a keyed property record (`LIST tdbs`), see [`fill_key_list`].
pub(crate) fn set_key_list(tdbs: &mut Chunk<'static>, records: Vec<u8>, count: usize, size: usize) -> R<()> {
fill_key_list(list_mut(tdbs, b"list")?, records, count, size)
}
/// Puts the key list `list` in the place of the static value (`cdat`) of a property record.
pub(crate) fn replace_cdat(tdbs: &mut Chunk<'static>, list: Chunk<'static>) -> R<()> {
let slot = kids_mut(tdbs)?.iter_mut().find(|c| c.is(b"cdat")).ok_or_else(|| tpl("record without cdat"))?;
*slot = list;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::export::synth::key_list;
/// Keys are timed sorted merged and reported.
#[test]
fn keys_are_timed_sorted_merged_and_reported() {
use effectcraft_keyframe::Value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
let tb = TimeBase { frame_duration: 1024, timescale: 25600 };
// Out of order; 1/3 s is not on the time base; two keys meet at 1 s (the first one kept).
let keys = [
Keyframe::new(Tick(TICKS_PER_SECOND), Value::Scalar(1.0)),
Keyframe::new(Tick(TICKS_PER_SECOND / 3), Value::Scalar(2.0)),
Keyframe::new(Tick(0), Value::Scalar(3.0)),
Keyframe::new(Tick(TICKS_PER_SECOND), Value::Scalar(4.0)),
];
let mut col = Collector::default();
let timed = timed_keys(&keys, &tb, "L", "path key", &mut col);
let got: Vec<(i64, f64)> = timed.iter().map(|(u, k)| (*u, if let Value::Scalar(v) = k.value { v } else { f64::NAN })).collect();
assert_eq!(got, [(0, 3.0), (8533, 2.0), (25600, 1.0)]);
// The report lists its categories in a fixed order; one merge report for the pair.
let report = col.finish();
let lines: Vec<(LossCategory, &str)> = report.losses.iter().flat_map(|l| l.details.iter().map(move |d| (l.category, d.as_str()))).collect();
assert_eq!(
lines,
[
(LossCategory::ApproximatedKeyframes, "L: path keys at the same time were merged"),
(LossCategory::RoundedTimes, "L: path key at 0.3333 s moved to the time base"),
]
);
// No keys, no report.
let mut col = Collector::default();
assert!(timed_keys(&[], &tb, "L", "key", &mut col).is_empty());
assert!(col.finish().losses.is_empty());
}
/// Two keys at different times that round to the same time unit are merged too: the first
/// one is kept, and both the rounding and the merge are reported.
#[test]
fn keys_that_round_to_the_same_time_are_merged() {
use effectcraft_keyframe::Value;
use effectcraft_time::Tick;
let tb = TimeBase { frame_duration: 1024, timescale: 25600 };
// One tick is far below one unit (1/25600 s): it rounds to unit 0, where the first key is.
let keys = [Keyframe::new(Tick(0), Value::Scalar(1.0)), Keyframe::new(Tick(1), Value::Scalar(2.0))];
let mut col = Collector::default();
let timed = timed_keys(&keys, &tb, "L", "key", &mut col);
let got: Vec<(i64, f64)> = timed.iter().map(|(u, k)| (*u, if let Value::Scalar(v) = k.value { v } else { f64::NAN })).collect();
assert_eq!(got, [(0, 1.0)]);
let report = col.finish();
let lines: Vec<(LossCategory, &str)> = report.losses.iter().flat_map(|l| l.details.iter().map(move |d| (l.category, d.as_str()))).collect();
assert_eq!(
lines,
[
(LossCategory::ApproximatedKeyframes, "L: keys at the same time were merged"),
(LossCategory::RoundedTimes, "L: key at 0.0000 s moved to the time base"),
]
);
}
/// A key list is filled and replaces the value.
#[test]
fn a_key_list_is_filled_and_replaces_the_value() {
let mut list = Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", vec![0; 52])]);
fill_key_list(&mut list, vec![7; 96], 2, 48).unwrap();
let h = list.child(b"lhd3").and_then(|c| c.bytes()).unwrap();
assert_eq!((h[11], h[19]), (2, 48));
assert_eq!(list.child(b"ldat").and_then(|c| c.bytes()).map(<[u8]>::len), Some(96));
// A second fill replaces the records instead of adding a second `ldat`.
fill_key_list(&mut list, vec![1; 48], 1, 48).unwrap();
assert_eq!(list.children().iter().filter(|c| c.is(b"ldat")).count(), 1);
let mut rec = Chunk::list(*b"tdbs", vec![Chunk::data(*b"tdb4", vec![0; 124]), Chunk::data(*b"cdat", vec![0; 8])]);
replace_cdat(&mut rec, key_list(48, 0, Vec::new())).unwrap();
assert!(rec.child(b"cdat").is_none() && rec.child_list(b"list").is_some());
// A record without a value is an error, not a quiet no-op.
assert!(replace_cdat(&mut rec, key_list(48, 0, Vec::new())).is_err());
set_key_list(&mut rec, vec![0; 48], 1, 48).unwrap();
assert!(set_key_list(&mut Chunk::list(*b"tdbs", Vec::new()), Vec::new(), 0, 48).is_err());
}
/// A spatial record holds the point layout.
#[test]
fn a_spatial_record_holds_the_point_layout() {
let k = KeyPlan {
time: 5,
value: vec![1.0, 2.0],
in_interp: 2,
out_interp: 1,
in_ease: Vec::new(),
out_ease: Vec::new(),
spatial_in: [3.0; 3],
spatial_out: [4.0; 3],
spatial_auto: true,
roving: false,
label: 1,
};
let r = spatial_record(&k, 2, true, 56 + 24 * 2).unwrap();
assert_eq!(&r[..12], &[0, 0, 0, 5, 2, 1, 0, 0x47, 0, 0, 0, 1]);
assert_eq!(&r[64..72], &2.0f64.to_be_bytes());
assert_eq!(&r[96..104], &4.0f64.to_be_bytes());
// A colour has no tangents and flags 0x0001.
let c = spatial_record(&k, 2, false, 56 + 24 * 2).unwrap();
assert_eq!((c[7], c[11]), (0x41, 0));
assert!(c[72..].iter().all(|b| *b == 0));
// A record shorter than its layout is an error.
assert!(spatial_record(&k, 2, true, 64).is_err());
}
}
+77 -83
View File
@@ -17,9 +17,11 @@ use crate::Chunk;
use effectcraft_keyframe::{Ease, Interp, ShapePath, Value};
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur, PropGroup};
use crate::export::keys::{set_key_list, timed_keys};
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT, cut_name};
use crate::export::report::{Collector, LossCategory};
use crate::export::tree::{R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, tpl};
use crate::export::synth::GROUP_END;
use crate::export::tree::{Fixed, R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, split_group, tpl};
/// The most vertices one mask path may have (as the importer).
pub(crate) const MAX_VERTICES: usize = 65_536;
@@ -179,20 +181,7 @@ fn plan_mask(g: &PropGroup, size: (u32, u32), cp: &CompPlan, what: &str, col: &m
}
// The path and its keys.
let mut path_keys: Vec<PathKeyPlan> = Vec::new();
let mut keys: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::new();
for k in &path_prop.keys {
let (u, inexact) = cp.tb.units(k.time);
if inexact {
col.lose(LossCategory::RoundedTimes, format!("{what}: path key at {:.4} s moved to the time base", k.time.seconds()));
}
keys.push((u, k));
}
keys.sort_by_key(|(u, _)| *u);
let before = keys.len();
keys.dedup_by_key(|(u, _)| *u);
if keys.len() != before {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: path keys at the same time were merged"));
}
let keys = timed_keys(&path_prop.keys, &cp.tb, what, "path key", col);
let side = |i: Interp| -> u8 {
match i {
Interp::Linear => 1,
@@ -288,20 +277,7 @@ fn plan_mask(g: &PropGroup, size: (u32, u32), cp: &CompPlan, what: &str, col: &m
/// Feather keys (plain template, two components, speed in pixels per second).
fn plan_feather_keys(src: &[effectcraft_keyframe::Keyframe], cp: &CompPlan, what: &str, col: &mut Collector) -> Vec<KeyPlan> {
let mut keys: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::with_capacity(src.len());
for k in src {
let (u, inexact) = cp.tb.units(k.time);
if inexact {
col.lose(LossCategory::RoundedTimes, format!("{what}: feather key at {:.4} s moved to the time base", k.time.seconds()));
}
keys.push((u, k));
}
keys.sort_by_key(|(u, _)| *u);
let before = keys.len();
keys.dedup_by_key(|(u, _)| *u);
if keys.len() != before {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: feather keys at the same time were merged"));
}
let keys = timed_keys(src, &cp.tb, what, "feather key", col);
let mut out = Vec::with_capacity(keys.len());
let beyond = std::cell::Cell::new(false);
for (u, k) in &keys {
@@ -381,11 +357,6 @@ pub(crate) struct MaskProtos {
pub keyed_feather: Chunk<'static>,
}
pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let d = c.bytes()?;
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
}
/// The limit of a mask's feather and expansion (`tduM` 32 000, 091).
const MASK_LIMIT: f64 = 32_000.0;
@@ -394,9 +365,25 @@ const MASK_LIMIT: f64 = 32_000.0;
/// s13-m1), byte 3 `0x01` (`0x09` once the path is open), the box at 4..19 (written per shape),
/// byte 20 1.
pub(crate) fn shph() -> Vec<u8> {
let mut b = vec![0u8; 24];
let _ = crate::export::tree::put(&mut b, 0, &[0xb3, 0xde, 0x02, 0x01]);
let _ = put_u8(&mut b, 20, 1);
shph_layout().finish(|| "shph".to_string())
}
/// The 24-byte `shph` layout of a closed path with a unit box.
fn shph_layout() -> Fixed {
let mut b = Fixed::zeroed(24);
b.put(0, &[0xb3, 0xde, 0x02, 0x01]);
b.put(20, &[1]);
b
}
/// The two prototype records of a keyed path (093): 64 bytes each, flags `0x0001`, the `f64` at
/// 16 1 in the first (the rest is zero: its last bytes were uninitialised).
fn path_key_records() -> Fixed {
let mut b = Fixed::zeroed(128);
for at in [6, 64 + 6] {
b.put(at, &0x0001u16.to_be_bytes());
}
b.put(16, &1.0f64.to_be_bytes());
b
}
@@ -413,15 +400,7 @@ impl MaskProtos {
let path_rec = |keyed: bool| {
let mut kids = vec![state(), tdsn(NO_NAME), Chunk::data(*b"tdb4", tdb4(&path, keyed, 25_600))];
if keyed {
// Path keys are 64-byte records: flags `0x0001`, the `f64` at 16 1 on the first
// key (093; the rest of the record is zero: its last bytes were uninitialised).
let mut first = vec![0u8; 64];
let mut later = vec![0u8; 64];
for r in [&mut first, &mut later] {
let _ = put_u16(r, 6, 0x0001);
}
let _ = put_f64(&mut first, 16, 1.0);
kids.push(key_list(64, 2, [first, later].concat()));
kids.push(key_list(64, 2, path_key_records().finish(|| "the keyed path records".to_string())));
} else {
kids.push(Chunk::data(*b"cdat", vec![0u8; 4]));
}
@@ -444,7 +423,7 @@ impl MaskProtos {
tdbs(&opacity, false, 25_600),
tdmn("ADBE Mask Offset"),
tdbs(&offset, false, 25_600),
tdmn("ADBE Group End"),
tdmn(GROUP_END),
],
);
MaskProtos {
@@ -453,7 +432,7 @@ impl MaskProtos {
atom_name: tdmn("ADBE Mask Atom"),
mkif: Chunk::data(*b"mkif", vec![0u8; 48]),
atom,
group_end: tdmn("ADBE Group End"),
group_end: tdmn(GROUP_END),
keyed_path: path_rec(true),
keyed_feather: tdbs(&feather, true, 25_600),
}
@@ -485,27 +464,21 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R<Chunk<'static
let tdsn = child_mut(&mut atom, b"tdsn")?;
crate::export::tree::set_utf8(tdsn, &m.name)?;
}
let kids = kids_mut(&mut atom)?;
let first = kids.iter().position(|c| c.is(b"tdmn")).ok_or_else(|| tpl("empty mask atom"))?;
let header: Vec<Chunk<'static>> = kids.drain(..first).collect();
let mut pairs: Vec<(String, [Chunk<'static>; 2])> = Vec::new();
let mut end: Option<Chunk<'static>> = None;
let mut it = std::mem::take(kids).into_iter();
while let Some(c) = it.next() {
let name = tdmn_name(&c).unwrap_or_default();
if name == "ADBE Group End" {
end = Some(c);
continue;
}
let node = it.next().ok_or_else(|| tpl("mask match name without a node"))?;
pairs.push((name, [c, node]));
}
let end = end.ok_or_else(|| tpl("mask atom without its end"))?;
let mut out = header;
for (name, [tdmn, mut node]) in pairs {
let (mut out, entries) = split_group(kids_mut(&mut atom)?)?;
let mut has_shape = false;
for (name, entry) in entries {
// The closing Group End (no node) is kept as it is.
let [tdmn, mut node]: [Chunk<'static>; 2] = match entry.try_into() {
Ok(pair) => pair,
Err(end) => {
out.extend(end);
continue;
}
};
let keep = match name.as_str() {
"ADBE Mask Shape" => {
build_shape(t, &mut node, m, size)?;
has_shape = true;
true
}
"ADBE Mask Feather" => {
@@ -540,7 +513,10 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R<Chunk<'static
out.push(node);
}
}
out.push(end);
// A prototype without the path would drop every mask's shape.
if !has_shape {
return Err(tpl("mask atom without its path"));
}
*kids_mut(&mut atom)? = out;
Ok(atom)
}
@@ -554,22 +530,6 @@ fn set_cdat(tdbs: &mut Chunk<'static>, values: &[f64]) -> R<()> {
Ok(())
}
/// Rewrites a key list (`lhd3` count words and `ldat`).
fn set_key_list(tdbs: &mut Chunk<'static>, records: Vec<u8>, count: usize, size: usize) -> R<()> {
let list = list_mut(tdbs, b"list")?;
let n = u32::try_from(count).map_err(|_| tpl("too many keys"))?;
{
let lhd3 = child_data(list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, n, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
}
let ld = kids_mut(list)?;
match ld.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", records),
None => ld.push(Chunk::data(*b"ldat", records)),
}
Ok(())
}
fn keyed_feather(t: &MaskProtos, keys: &[KeyPlan]) -> R<Chunk<'static>> {
let mut tdbs = t.keyed_feather.clone();
// The plain template with two components (208): 8 + 40 * 2 bytes.
@@ -669,3 +629,37 @@ pub(crate) fn build_shap(proto: &Chunk<'static>, s: &ShapePlan) -> R<Chunk<'stat
}
Ok(shap)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::export::synth::is_tdmn;
/// A closed three-point mask.
fn mask() -> MaskPlan {
let shape = ShapePlan { bbox: [0.0, 0.0, 1.0, 1.0], open: false, points: vec![[0.0, 0.0]; 3] };
MaskPlan {
name: "M".into(),
mode: 1,
inverted: false,
shape,
path_keys: Vec::new(),
feather: None,
feather_keys: Vec::new(),
opacity: None,
expansion: None,
}
}
/// An atom without its path is an error.
#[test]
fn an_atom_without_its_path_is_an_error() {
let mut t = MaskProtos::build();
assert!(build_atom(&t, &mask(), (100, 100)).is_ok());
// Without the Mask Shape pair the path of every mask would be dropped quietly.
let kids = kids_mut(&mut t.atom).unwrap();
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Mask Shape")).unwrap();
kids.drain(at..at + 2);
assert!(build_atom(&t, &mask(), (100, 100)).is_err());
}
}
+63 -19
View File
@@ -7,8 +7,9 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Most example lines kept per loss category (the count is always exact).
pub const MAX_DETAILS: usize = 20;
/// Most example lines kept per loss category (the count is always exact); the same as the import
/// report's.
pub const MAX_DETAILS: usize = crate::import::MAX_DETAILS;
/// Why a piece of the project did not arrive in the `.aep`, or arrived changed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -130,15 +131,9 @@ impl LossCategory {
}
}
/// One category of things that were not exported.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Loss {
pub category: LossCategory,
/// How many occurrences (exact).
pub count: usize,
/// Up to [`MAX_DETAILS`] example lines.
pub details: Vec<String>,
}
/// One category of things that were not exported: the import report's loss entry with the
/// export categories.
pub type Loss = crate::import::Loss<LossCategory>;
/// What was written to the file.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -222,7 +217,7 @@ pub struct ExportReport {
impl ExportReport {
/// The sum of all loss counts.
pub fn total_losses(&self) -> usize {
self.losses.iter().map(|l| l.count).sum()
Loss::total(&self.losses)
}
/// True when nothing was lost.
@@ -232,12 +227,12 @@ impl ExportReport {
/// The loss count of a category.
pub fn count(&self, category: LossCategory) -> usize {
self.losses.iter().find(|l| l.category == category).map_or(0, |l| l.count)
Loss::count_of(&self.losses, category)
}
/// `effects: 3, masks: 1` (empty when lossless).
pub fn summary(&self) -> String {
self.losses.iter().map(|l| format!("{}: {}", l.category.label(), l.count)).collect::<Vec<_>>().join(", ")
Loss::summary(&self.losses, LossCategory::label)
}
}
@@ -251,11 +246,7 @@ pub(crate) struct Collector {
impl Collector {
/// Records one loss.
pub fn lose(&mut self, category: LossCategory, detail: impl Into<String>) {
let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() });
loss.count = loss.count.saturating_add(1);
if loss.details.len() < MAX_DETAILS {
loss.details.push(detail.into());
}
Loss::record(&mut self.losses, category, move || detail.into());
}
pub fn finish(self) -> ExportReport {
@@ -266,3 +257,56 @@ impl Collector {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A report with losses in two categories, as the collector finishes it.
fn collected() -> ExportReport {
let mut col = Collector::default();
col.exported.layers = 2;
col.lose(LossCategory::RoundedTimes, "time 1");
col.lose(LossCategory::Effects, String::from("fx 1"));
col.lose(LossCategory::Effects, "fx 2");
let mut report = col.finish();
report.limitations.truncate(1);
report.limitations[0] = "limit".into();
report
}
/// The JSON shape agents read: field names, category spelling, order and the loss entries.
#[test]
fn the_json_shape_is_pinned() {
let json = serde_json::to_string(&collected()).unwrap();
assert_eq!(
json,
r#"{"exported":{"folders":0,"compositions":0,"solids":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"properties":0,"keyframes":0,"expressions":0,"masks":0,"precompLayers":0,"effects":0,"footage":0,"footageLayers":0,"textLayers":0,"markers":0,"cameras":0,"lights":0,"shapeLayers":0,"timeRemaps":0,"textAnimators":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"],"unlisted":0},{"category":"rounded_times","count":1,"details":["time 1"],"unlisted":0}],"limitations":["limit"]}"#
);
let back: ExportReport = serde_json::from_str(&json).unwrap();
assert_eq!(back, collected());
}
/// The queries read the losses.
#[test]
fn the_queries_read_the_losses() {
let report = collected();
assert_eq!((report.total_losses(), report.is_lossless()), (3, false));
assert_eq!((report.count(LossCategory::Effects), report.count(LossCategory::Lights)), (2, 0));
assert_eq!(report.summary(), "effects: 2, rounded times: 1");
let empty = Collector::default().finish();
assert_eq!((empty.total_losses(), empty.is_lossless(), empty.summary()), (0, true, String::new()));
}
/// Details stop at the cap.
#[test]
fn details_stop_at_the_cap() {
let mut col = Collector::default();
for i in 0..MAX_DETAILS + 5 {
col.lose(LossCategory::Masks, format!("mask {i}"));
}
let report = col.finish();
assert_eq!(report.count(LossCategory::Masks), MAX_DETAILS + 5);
assert_eq!(report.losses[0].details.len(), MAX_DETAILS);
}
}
+14 -75
View File
@@ -24,10 +24,12 @@ use crate::Chunk;
use effectcraft_keyframe::{Gradient, Interp, Keyframe, Value};
use effectcraft_project::{Node, PropGroup, Property};
use crate::export::keys::{fill_key_list, replace_cdat, spatial_record, timed_keys};
use crate::export::masks::ShapePlan;
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan};
use crate::export::report::{Collector, LossCategory};
use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, set_utf8, tpl};
use crate::export::synth::{GROUP_END, is_tdmn, tdmn};
use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, put_u8, set_utf8, tpl};
/// The deepest group nesting written.
const MAX_DEPTH: usize = 16;
@@ -192,7 +194,7 @@ impl ShapeProtos {
put_f64(child_data(&mut node, b"tdum")?, 0, lo)?;
put_f64(child_data(&mut node, b"tduM")?, 0, hi)?;
}
Ok([crate::export::camlight::tdmn_chunk(name), node])
Ok([tdmn(name), node])
}
}
@@ -267,20 +269,7 @@ pub(crate) struct ShCx<'a> {
/// The keys of `p` in the file's units.
fn plan_keys(p: &Property, conv: Conv, layout: Layout, what: &str, cx: &mut ShCx<'_>) -> Vec<KeyPlan> {
let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(p.keys.len());
for k in &p.keys {
let (u, inexact) = cx.cp.tb.units(k.time);
if inexact {
cx.col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds()));
}
timed.push((u, k));
}
timed.sort_by_key(|(u, _)| *u);
let before = timed.len();
timed.dedup_by_key(|(u, _)| *u);
if timed.len() != before {
cx.col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged"));
}
let timed = timed_keys(&p.keys, &cx.cp.tb, what, "key", cx.col);
let all: Vec<Keyframe> = timed.iter().map(|(_, k)| (*k).clone()).collect();
let mut out = Vec::with_capacity(timed.len());
let beyond = std::cell::Cell::new(false);
@@ -618,38 +607,6 @@ pub(crate) fn plan_contents(contents: &PropGroup, what: &str, depth: usize, cx:
out
}
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let b = c.bytes()?;
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
std::str::from_utf8(b.get(..end)?).ok().map(str::to_string)
}
/// A key of a point (the spatial layout of a 2D Position, 853) or a colour (854).
fn spatial_key(k: &KeyPlan, n: usize, point: bool, size: usize) -> R<Vec<u8>> {
let mut r = vec![0u8; size];
put(&mut r, 0, &k.time.to_be_bytes())?;
put_u8(&mut r, 4, k.in_interp)?;
put_u8(&mut r, 5, k.out_interp)?;
let base: u16 = if point { 0x0007 } else { 0x0001 };
put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?;
if point {
put_u32(&mut r, 8, u32::from(k.spatial_auto))?;
let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default());
put_f64(&mut r, 24, i.speed)?;
put_f64(&mut r, 32, i.influence)?;
put_f64(&mut r, 40, o.speed)?;
put_f64(&mut r, 48, o.influence)?;
}
for j in 0..n {
put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?;
if point {
put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?;
put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?;
}
}
Ok(r)
}
/// The value record of `name` holding `v`.
fn value_record(t: &ShapeProtos, name: &'static str, v: &RecVal) -> R<[Chunk<'static>; 2]> {
let [m, node] = t.record(name)?;
@@ -681,32 +638,17 @@ pub(crate) fn build_record(m: Chunk<'static>, mut node: Chunk<'static>, layout:
put_u8(tdb4, 79, 0)?;
}
}
let mut list = key_list.clone();
let count = u32::try_from(v.keys.len()).map_err(|_| tpl("too many keys"))?;
{
let lhd3 = child_data(&mut list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, count, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
}
let mut ldat = Vec::with_capacity(size * v.keys.len());
for k in &v.keys {
ldat.extend(match layout {
Layout::Point => spatial_key(k, n, true, size)?,
Layout::Color => spatial_key(k, n, false, size)?,
Layout::Point => spatial_record(k, n, true, size)?,
Layout::Color => spatial_record(k, n, false, size)?,
_ => crate::export::emit::plain_record(n, k, size)?,
});
}
{
let lk = kids_mut(&mut list)?;
match lk.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", ldat),
None => lk.push(Chunk::data(*b"ldat", ldat)),
}
}
let kids = kids_mut(&mut node)?;
let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("shape record without cdat"))?;
if let Some(slot) = kids.get_mut(at) {
*slot = list;
}
let mut list = key_list.clone();
fill_key_list(&mut list, ldat, v.keys.len(), size)?;
replace_cdat(&mut node, list)?;
}
if let Some((text, enabled)) = &v.expr {
// As on the Transform properties (R15; 856).
@@ -746,7 +688,7 @@ pub(crate) fn build_items(t: &ShapeProtos, items: &[ShItem], depth: usize) -> R<
if depth > MAX_DEPTH {
return Ok(out);
}
let pos = |kids: &[Chunk<'static>], n: &str| kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(n));
let pos = |kids: &[Chunk<'static>], n: &str| kids.iter().position(|c| is_tdmn(c, n));
for item in items {
match item {
ShItem::Off(inner) => {
@@ -766,7 +708,7 @@ pub(crate) fn build_items(t: &ShapeProtos, items: &[ShItem], depth: usize) -> R<
let vg = pos(kids, "ADBE Vectors Group").ok_or_else(|| tpl("group prototype without contents"))?;
let contents = kids.get_mut(vg + 1).ok_or_else(|| tpl("group prototype without contents"))?;
let ck = kids_mut(contents)?;
let end = ck.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Group End")).unwrap_or(ck.len());
let end = ck.iter().position(|c| is_tdmn(c, GROUP_END)).unwrap_or(ck.len());
ck.splice(end..end, children);
let tg = pos(kids, "ADBE Vector Transform Group").ok_or_else(|| tpl("group prototype without transform"))?;
let tnode = kids.get_mut(tg + 1).ok_or_else(|| tpl("group prototype without transform"))?;
@@ -815,13 +757,10 @@ pub(crate) fn put_contents(layr: &mut Chunk<'static>, t: &ShapeProtos, items: &[
let built = build_items(t, items, 0)?;
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at = kids
.iter()
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Root Vectors Group"))
.ok_or_else(|| tpl("shape layer without contents"))?;
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Root Vectors Group")).ok_or_else(|| tpl("shape layer without contents"))?;
let rv = kids.get_mut(at + 1).ok_or_else(|| tpl("shape layer without contents"))?;
let rk = kids_mut(rv)?;
let end = rk.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Group End")).unwrap_or(rk.len());
let end = rk.iter().position(|c| is_tdmn(c, GROUP_END)).unwrap_or(rk.len());
rk.splice(end..end, built);
Ok(())
}
+6 -6
View File
@@ -4,14 +4,14 @@
//! e1-sparse).
use crate::Chunk;
use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdbs, tdmn, tdsn};
use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdbs, tdmn, tdmn_name, tdsn};
/// A group: its match name and a `tdgp` with the state `state` (`tdsb`), no user name, `kids` and
/// the closing `ADBE Group End`.
pub(crate) fn group(name: &str, state: u32, kids: Vec<Chunk<'static>>) -> [Chunk<'static>; 2] {
let mut body = vec![Chunk::data(*b"tdsb", state.to_be_bytes().to_vec()), tdsn(NO_NAME)];
body.extend(kids);
body.push(tdmn("ADBE Group End"));
body.push(tdmn(GROUP_END));
[tdmn(name), Chunk::list(*b"tdgp", body)]
}
@@ -61,7 +61,7 @@ pub(crate) fn av_layer() -> Vec<Chunk<'static>> {
let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn("ADBE Time Remapping"), time_remap()];
root.extend(group("ADBE Transform Group", 1, crate::export::tree_spec::build(crate::export::tree_defs::AV_TRANSFORM)));
root.extend(layer_styles());
root.push(tdmn("ADBE Group End"));
root.push(tdmn(GROUP_END));
let layr = Chunk::list(
*b"Layr",
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
@@ -78,7 +78,7 @@ pub(crate) fn av_layer() -> Vec<Chunk<'static>> {
pub(crate) fn layer_block(nodes: &[crate::export::tree_spec::Node]) -> Vec<Chunk<'static>> {
let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn("")];
root.extend(crate::export::tree_spec::build(nodes));
root.push(tdmn("ADBE Group End"));
root.push(tdmn(GROUP_END));
let layr = Chunk::list(
*b"Layr",
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
@@ -125,7 +125,7 @@ pub(crate) fn text_layer(doc: &[u8]) -> Vec<Chunk<'static>> {
root.extend(group("ADBE Text Properties", 1, props));
root.extend(group("ADBE Transform Group", 1, crate::export::tree_spec::build(crate::export::tree_defs::AV_TRANSFORM)));
root.extend(layer_styles());
root.push(tdmn("ADBE Group End"));
root.push(tdmn(GROUP_END));
let layr = Chunk::list(
*b"Layr",
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
@@ -141,7 +141,7 @@ pub(crate) fn pairs(nodes: &[crate::export::tree_spec::Node]) -> Vec<(String, [C
let mut out = Vec::new();
let mut it = crate::export::tree_spec::build(nodes).into_iter();
while let (Some(m), Some(n)) = (it.next(), it.next()) {
let name = String::from_utf8_lossy(m.bytes().unwrap_or_default().split(|b| *b == 0).next().unwrap_or_default()).into_owned();
let name = tdmn_name(&m).unwrap_or_default();
out.push((name, [m, n]));
}
out
+9 -40
View File
@@ -23,8 +23,10 @@ use effectcraft_project::build::Ids;
use effectcraft_project::styles::{self as st, STYLE_BLEND_MODES};
use effectcraft_project::{Comp, Layer, PropGroup, Property};
use crate::export::keys::{fill_key_list, replace_cdat, timed_keys};
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan};
use crate::export::report::{Collector, LossCategory};
use crate::export::synth::{GROUP_END, is_tdmn};
use crate::export::tree::{R, child_data, kids_mut, put_f64, put_u8, put_u32, tpl};
/// A record's value.
@@ -128,20 +130,7 @@ fn plan_record(spec: &StyleParam, full: String, prop: &Property, cp: &CompPlan,
/// The keys of a scalar in the layer's time base: linear, hold and bezier sides with the ease in
/// stored units per second (the plain template of 976).
fn plan_number_keys(prop: &Property, cp: &CompPlan, what: &str, col: &mut Collector) -> Vec<KeyPlan> {
let mut timed: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::with_capacity(prop.keys.len());
for k in &prop.keys {
let (u, inexact) = cp.tb.units(k.time);
if inexact {
col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds()));
}
timed.push((u, k));
}
timed.sort_by_key(|(u, _)| *u);
let before = timed.len();
timed.dedup_by_key(|(u, _)| *u);
if timed.len() != before {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged"));
}
let timed = timed_keys(&prop.keys, &cp.tb, what, "key", col);
let mut out = Vec::with_capacity(timed.len());
let beyond = std::cell::Cell::new(false);
for (u, k) in timed {
@@ -279,11 +268,6 @@ pub(crate) struct StyleProtos {
keyed: Chunk<'static>,
}
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let d = c.bytes()?;
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
}
impl StyleProtos {
/// The records built in code from the style table (`style_defs`); the keyed form of a scalar
/// takes `tdb4` bytes 5, 68 and 79 and the key list of a keyed one-component record.
@@ -322,28 +306,13 @@ impl StyleProtos {
}
}
let mut list = self.keyed.child_list(b"list").cloned().ok_or_else(|| tpl("keyed style prototype without keys"))?;
let n = u32::try_from(keys.len()).map_err(|_| tpl("too many keys"))?;
let size = 48usize;
{
let lhd3 = child_data(&mut list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, n, 48)?;
}
let mut ldat = Vec::with_capacity(size * keys.len());
for k in keys {
ldat.extend(crate::export::emit::plain_record(1, k, size)?);
}
{
let lk = kids_mut(&mut list)?;
match lk.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", ldat),
None => lk.push(Chunk::data(*b"ldat", ldat)),
}
}
let kids = kids_mut(&mut rec)?;
let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("style record without cdat"))?;
if let Some(slot) = kids.get_mut(at) {
*slot = list;
}
fill_key_list(&mut list, ldat, keys.len(), size)?;
replace_cdat(&mut rec, list)?;
}
}
if let Some((text, enabled)) = &r.expr {
@@ -362,7 +331,7 @@ impl StyleProtos {
/// The node after the `tdmn` named `name` among `kids`.
fn named_mut<'a>(kids: &'a mut [Chunk<'static>], name: &str) -> R<&'a mut Chunk<'static>> {
let i = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name)).ok_or_else(|| tpl("layer styles group incomplete"))?;
let i = kids.iter().position(|c| is_tdmn(c, name)).ok_or_else(|| tpl("layer styles group incomplete"))?;
kids.get_mut(i + 1).ok_or_else(|| tpl("layer styles group incomplete"))
}
@@ -377,7 +346,7 @@ fn insert_records(protos: &StyleProtos, g: &mut Chunk<'static>, recs: &[Record],
built.extend(protos.build(r)?);
}
let kids = kids_mut(g)?;
let at = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(before)).ok_or_else(|| tpl("layer styles group incomplete"))?;
let at = kids.iter().position(|c| is_tdmn(c, before)).ok_or_else(|| tpl("layer styles group incomplete"))?;
kids.splice(at..at, built);
Ok(())
}
@@ -392,12 +361,12 @@ pub(crate) fn put_styles(protos: &StyleProtos, layr: &mut Chunk<'static>, plan:
set_flags(bo, sm::FLAGS_ON)?;
insert_records(protos, bo, &plan.global, sm::ADV_BLEND)?;
let adv = named_mut(kids_mut(bo)?, sm::ADV_BLEND)?;
insert_records(protos, adv, &plan.adv, "ADBE Group End")?;
insert_records(protos, adv, &plan.adv, GROUP_END)?;
}
for (prefix, recs, on) in &plan.styles {
let g = named_mut(kids_mut(ls)?, &format!("{prefix}/enabled"))?;
set_flags(g, if *on { sm::FLAGS_ON } else { sm::FLAGS_OFF })?;
insert_records(protos, g, recs, "ADBE Group End")?;
insert_records(protos, g, recs, GROUP_END)?;
}
Ok(())
}
+84 -25
View File
@@ -4,9 +4,10 @@
//! evidence, or zero where After Effects was shown to accept zero (the read-backs listed in
//! `docs/aep-export.md`, "Built in code").
use crate::export::tree::{Fixed, R, put_u32};
use crate::{Body, Chunk};
/// `tdmn`: a match name padded to 40 bytes.
/// `tdmn`: a match name padded to 40 bytes (at most 39 bytes of the name, so that a NUL remains).
pub(crate) fn tdmn(name: &str) -> Chunk<'static> {
let mut b = vec![0u8; 40];
for (slot, c) in b.iter_mut().zip(name.bytes().take(39)) {
@@ -15,12 +16,34 @@ pub(crate) fn tdmn(name: &str) -> Chunk<'static> {
Chunk::data(*b"tdmn", b)
}
/// The match name that closes a property group.
pub(crate) const GROUP_END: &str = "ADBE Group End";
/// The match name in a `tdmn`: the bytes up to the first NUL as UTF-8, invalid sequences
/// replaced, as the importer reads it (`container::typed`). After Effects writes ASCII here
/// (aep-format.md 3.4; every `tdmn` of the corpus is ASCII); `None` when `c` is not a data chunk.
pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let b = c.bytes()?;
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
Some(String::from_utf8_lossy(b.get(..end)?).into_owned())
}
/// Whether `c` is a `tdmn` naming `name`.
pub(crate) fn is_tdmn(c: &Chunk<'_>, name: &str) -> bool {
c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name)
}
/// The user name of a property that has none (`-_0_/-`, aep-format.md 3.4).
pub(crate) const NO_NAME: &str = "-_0_/-";
/// `tdsn`: the user name, a nested chunk holding one `Utf8`.
pub(crate) fn tdsn(name: &str) -> Chunk<'static> {
Chunk { id: *b"tdsn", body: Body::Chunks(vec![Chunk::data(*b"Utf8", name.as_bytes().to_vec())]), pad: None }
utf8_in(*b"tdsn", name)
}
/// A nested chunk `id` holding one `Utf8` with `text` (`tdsn`, `fnam`, `pdnm`).
pub(crate) fn utf8_in(id: [u8; 4], text: &str) -> Chunk<'static> {
Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.as_bytes().to_vec())]), pad: None }
}
/// How a property's keys are stored (`tdb4` byte 7, aep-format.md 3.5).
@@ -100,13 +123,13 @@ impl RecSpec {
/// word and the constant doubles at 16..56 are written; After Effects reads the other bytes as
/// zero (read-back e1-ztdb4d).
pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec<u8> {
let mut b = vec![0u8; 124];
let set = |b: &mut Vec<u8>, at: usize, bytes: &[u8]| {
if let Some(dst) = b.get_mut(at..at + bytes.len()) {
dst.copy_from_slice(bytes);
}
};
set(&mut b, 0, &[0xdb, 0x99, 0x00, spec.dims]);
tdb4_layout(spec, keyed, time_scale).finish(|| "tdb4".to_string())
}
/// The 124-byte `tdb4` property header layout for a record spec.
fn tdb4_layout(spec: &RecSpec, keyed: bool, time_scale: u32) -> Fixed {
let mut b = Fixed::zeroed(124);
b.put(0, &[0xdb, 0x99, 0x00, spec.dims]);
let spatial = match spec.template {
KeyTemplate::Plain => 0,
KeyTemplate::Spatial(t) => t,
@@ -115,19 +138,19 @@ pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec<u8> {
Some(b) => (b & !0x01) | u8::from(!keyed),
None => (if spatial == 0 { 0 } else { spatial_byte5(spatial) }) | u8::from(!keyed),
};
set(&mut b, 5, &[byte5]);
set(&mut b, 7, &[spatial]);
set(&mut b, 8, &spec.class.to_be_bytes());
set(&mut b, 12, &time_scale.to_be_bytes());
b.put(5, &[byte5]);
b.put(7, &[spatial]);
b.put(8, &spec.class.to_be_bytes());
b.put(12, &time_scale.to_be_bytes());
// Every record After Effects writes carries 0.0001 and four 1.0 here; without them a roving
// key reads back at time zero instead of its retimed place (read-back s14-re-keyframes).
set(&mut b, 16, &1e-4f64.to_be_bytes());
b.put(16, &1e-4f64.to_be_bytes());
for at in [24, 32, 40, 48] {
set(&mut b, at, &1.0f64.to_be_bytes());
b.put(at, &1.0f64.to_be_bytes());
}
set(&mut b, 68, &[u8::from(keyed)]);
b.put(68, &[u8::from(keyed)]);
// Set on a static spatial property, cleared once it is keyed (aep-format.md 3.4, byte 79).
set(&mut b, 79, &[u8::from(spatial != 0 && !keyed)]);
b.put(79, &[u8::from(spatial != 0 && !keyed)]);
b
}
@@ -135,16 +158,21 @@ pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec<u8> {
/// 3.5): word 0 `0x00d00bee`, word 2 the count, word 3 the blocks of four (at least one), word 4
/// the size, words 5 and 7 the capacity (the count rounded up to four, at least two), word 6 one.
pub(crate) fn key_list(size: u32, count: u32, records: Vec<u8>) -> Chunk<'static> {
let mut lhd3 = vec![0u8; 52];
// Cannot fail: the header is 52 bytes.
let _ = crate::export::tree::put_u32(&mut lhd3, 0, 0x00d0_0bee);
let _ = crate::export::tree::put_u32(&mut lhd3, 24, 1);
let _ = put_key_counts(&mut lhd3, count, size);
let lhd3 = key_list_header(size, count).finish(|| "lhd3".to_string());
Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", records)])
}
/// The 52-byte `lhd3` key list header for `count` records of `size` bytes.
fn key_list_header(size: u32, count: u32) -> Fixed {
let mut lhd3 = Fixed::zeroed(52);
lhd3.put(0, &0x00d0_0bee_u32.to_be_bytes());
lhd3.put(24, &1u32.to_be_bytes());
lhd3.apply(|d| put_key_counts(d, count, size));
lhd3
}
/// Sets the count words of a key list header for `count` keys of `size` bytes.
pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> crate::export::tree::R<()> {
pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> R<()> {
let capacity = count.div_ceil(4).saturating_mul(4).max(2);
put_counts(lhd3, count, count.div_ceil(4).max(1), size, capacity, capacity)
}
@@ -153,8 +181,7 @@ pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> crate::e
/// record size, word 5 `w5`, word 7 the capacity. A key list has `w5` = the capacity; a path
/// list counts vertices: `blocks` = `w5` = the vertex count, the capacity four times it (090:
/// 12 points, 4, 4 and 16; aep-format.md 3.5).
pub(crate) fn put_counts(lhd3: &mut [u8], count: u32, blocks: u32, size: u32, w5: u32, capacity: u32) -> crate::export::tree::R<()> {
use crate::export::tree::put_u32;
pub(crate) fn put_counts(lhd3: &mut [u8], count: u32, blocks: u32, size: u32, w5: u32, capacity: u32) -> R<()> {
put_u32(lhd3, 8, count)?;
put_u32(lhd3, 12, blocks)?;
put_u32(lhd3, 16, size)?;
@@ -204,6 +231,38 @@ mod tests {
assert_eq!(rec.child(b"cdat").and_then(|c| c.bytes()).map(<[u8]>::len), Some(40));
}
/// Every record header fits.
#[test]
fn every_record_header_fits() {
for dims in 0..=u8::MAX {
for template in [KeyTemplate::Plain, KeyTemplate::Spatial(1), KeyTemplate::Spatial(3), KeyTemplate::Spatial(u8::MAX)] {
for byte5 in [None, Some(0xff)] {
let spec = RecSpec { dims, template, class: u32::MAX, limits: None, byte5 };
assert!(tdb4_layout(&spec, false, u32::MAX).error().is_none() && tdb4_layout(&spec, true, 0).error().is_none());
}
}
}
for (size, count) in [(0, 0), (u32::MAX, u32::MAX), (48, 5)] {
let h = key_list_header(size, count);
assert!(h.error().is_none());
assert_eq!(h.finish(String::new).len(), 52);
}
}
/// A match name reads as the importer reads it.
#[test]
fn a_match_name_reads_as_the_importer_reads_it() {
let m = tdmn("ADBE Group End");
assert_eq!(tdmn_name(&m).as_deref(), Some(GROUP_END));
assert!(is_tdmn(&m, GROUP_END) && !is_tdmn(&m, "ADBE Group") && !is_tdmn(&tdsn(GROUP_END), GROUP_END));
// At most 39 bytes, so that a NUL remains.
let long = "x".repeat(50);
assert_eq!(tdmn_name(&tdmn(&long)).map(|n| n.len()), Some(39));
// Not ASCII (After Effects writes ASCII): UTF-8, invalid bytes replaced, as on import.
let odd = Chunk::data(*b"tdmn", vec![b'A', 0xc3, 0xa9, 0xff, 0, b'B']);
assert_eq!(tdmn_name(&odd).as_deref(), Some("A\u{e9}\u{fffd}"));
}
#[test]
fn a_key_list_header_counts_and_rounds() {
let l = key_list(48, 5, vec![0; 240]);
+18
View File
@@ -81,6 +81,24 @@ fn transform_records(keyed: bool) -> Vec<Chunk<'static>> {
.collect()
}
/// The template every export clones from: built by the first export of the process and kept, as
/// [`Template::load`] always builds the same prototypes and nothing changes them afterwards (the
/// exporter only reads them through `&Template` and clones what it edits).
///
/// Concurrent exports are safe: the `static` requires `Template: Send + Sync`, which the compiler
/// checks. Exports that race on the first call each build a template; one is kept and the others
/// are dropped, and all of them are equal. A failed build is not kept, so the next export tries
/// again and reports the same error.
pub(crate) fn shared() -> R<&'static Template> {
static SHARED: std::sync::OnceLock<Template> = std::sync::OnceLock::new();
if let Some(t) = SHARED.get() {
return Ok(t);
}
// A failed build returns here, before `get_or_init`, so it is never kept.
let t = Template::load()?;
Ok(SHARED.get_or_init(|| t))
}
impl Template {
pub fn load() -> R<Template> {
let file = AepFile { chunks: crate::export::base::project_chunks(), trailer: std::borrow::Cow::Borrowed(&[]) };
+4 -9
View File
@@ -21,6 +21,7 @@ use effectcraft_project::{Layer, Node, PropGroup};
use crate::export::plan::CompPlan;
use crate::export::report::{Collector, LossCategory};
use crate::export::shapes::{Conv, Layout, RecVal, ShCx, build_record, c_color, c_popup, c_scalar, rec_value};
use crate::export::synth::{GROUP_END, is_tdmn};
use crate::export::tree::{R, kids_mut, set_utf8, tpl};
/// More animators or selectors than this on one layer are not written (and reported).
@@ -220,14 +221,8 @@ fn plan_selector(s: &PropGroup, animator: &str, cx: &mut ShCx<'_>) -> SelPlan {
SelPlan { name: crate::export::plan::cut_name(&s.name, 255, &here, cx.col), records, advanced }
}
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let b = c.bytes()?;
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
std::str::from_utf8(b.get(..end)?).ok().map(str::to_string)
}
fn pos(kids: &[Chunk<'static>], n: &str) -> Option<usize> {
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(n))
kids.iter().position(|c| is_tdmn(c, n))
}
/// The item's user name (`tdsn/Utf8`).
@@ -297,7 +292,7 @@ pub(crate) fn put_animators(layr: &mut Chunk<'static>, t: &AnimProtos, anims: &[
}
{
let gk = kids_mut(&mut group)?;
let end = pos(gk, "ADBE Group End").unwrap_or(gk.len());
let end = pos(gk, GROUP_END).unwrap_or(gk.len());
gk.splice(end..end, built);
}
// Into the layer's `ADBE Text Properties`, before its Group End.
@@ -306,7 +301,7 @@ pub(crate) fn put_animators(layr: &mut Chunk<'static>, t: &AnimProtos, anims: &[
let ti = pos(rk, "ADBE Text Properties").ok_or_else(|| tpl("text layer without text properties"))?;
let tp = rk.get_mut(ti + 1).ok_or_else(|| tpl("text layer without text properties"))?;
let tk = kids_mut(tp)?;
let end = pos(tk, "ADBE Group End").unwrap_or(tk.len());
let end = pos(tk, GROUP_END).unwrap_or(tk.len());
tk.splice(end..end, [gm, group]);
Ok(())
}
+36 -29
View File
@@ -9,13 +9,9 @@
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
/// Frame duration and time scale in units; one frame lasts `frame_duration` units and a second
/// has `timescale` units.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TimeBase {
pub frame_duration: u32,
pub timescale: u32,
}
/// The container's time base (frame duration and time scale in units; one frame lasts
/// `frame_duration` units and a second has `timescale` units), which the importer reads.
pub(crate) use crate::container::TimeBase;
/// The 16.16 fixed-point rate After Effects stores next to the time base (a float32 rate).
pub(crate) fn fixed_rate(rate: FrameRate) -> u32 {
@@ -26,11 +22,8 @@ pub(crate) fn fixed_rate(rate: FrameRate) -> u32 {
/// The typed value of an NTSC rate (23.976, 29.97, 59.94, 119.88), which After Effects stores as
/// a float32 of exactly these decimals.
pub(crate) fn ntsc_typed(rate: FrameRate) -> Option<f32> {
[(FrameRate::FPS_23_976, 23.976f32), (FrameRate::FPS_29_97, 29.97), (FrameRate::FPS_59_94, 59.94), (FrameRate::FPS_119_88, 119.88)]
.into_iter()
.find(|(r, _)| *r == rate)
.map(|(_, v)| v)
fn ntsc_typed(rate: FrameRate) -> Option<f32> {
TimeBase::NTSC.into_iter().find(|(r, _)| *r == rate).map(|(_, v)| v)
}
/// The time base for `rate`, or `None` for a rate After Effects cannot hold (non-positive, or not
@@ -67,28 +60,27 @@ pub(crate) fn time_base(rate: FrameRate) -> Option<TimeBase> {
}
impl TimeBase {
/// `tick` in time units, rounded to the nearest unit; the second value tells whether it was
/// not exact.
pub fn units(&self, tick: Tick) -> (i64, bool) {
let n = i128::from(tick.0) * i128::from(self.timescale);
let d = i128::from(TICKS_PER_SECOND);
let q = n.div_euclid(d);
let r = n.rem_euclid(d);
let rounded = if r * 2 >= d { q + 1 } else { q };
(i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }), r != 0)
/// `tick` in time units, rounded to the nearest unit (saturated to `i64`); the second value
/// tells whether it was not exact.
pub(crate) fn units(&self, tick: Tick) -> (i64, bool) {
nearest(i128::from(tick.0).saturating_mul(i128::from(self.timescale)), i128::from(TICKS_PER_SECOND))
}
/// Whole frames of `tick` (nearest).
pub fn frames(&self, tick: Tick, rate: FrameRate) -> i64 {
let n = i128::from(tick.0) * i128::from(rate.num);
let d = i128::from(TICKS_PER_SECOND) * i128::from(rate.den.max(1));
let q = n.div_euclid(d);
let r = n.rem_euclid(d);
let f = if r * 2 >= d { q + 1 } else { q };
i64::try_from(f).unwrap_or(0)
/// Whole frames of `tick` (nearest, saturated to `i64` like [`TimeBase::units`]).
pub(crate) fn frames(&self, tick: Tick, rate: FrameRate) -> i64 {
let d = i128::from(TICKS_PER_SECOND).saturating_mul(i128::from(rate.den.max(1)));
nearest(i128::from(tick.0).saturating_mul(i128::from(rate.num)), d).0
}
}
/// `n / d` rounded to the nearest whole number (a half rounds up), saturated to `i64`, and whether
/// it was not exact. `d` is positive.
fn nearest(n: i128, d: i128) -> (i64, bool) {
let (q, r) = (n.div_euclid(d), n.rem_euclid(d));
let rounded = if r.saturating_mul(2) >= d { q.saturating_add(1) } else { q };
(i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }), r != 0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -131,4 +123,19 @@ mod tests {
assert!(tb.units(Tick(TICKS_PER_SECOND / 3)).1);
assert_eq!(tb.frames(Tick(TICKS_PER_SECOND * 2), FrameRate::new(25, 1)), 50);
}
/// Frames saturate like units (they used to become 0 past `i64`). No storable rate gets
/// there: [`time_base`] only accepts rates far below one frame per tick.
#[test]
fn frames_and_units_saturate_alike() {
let tb = TimeBase { frame_duration: 1024, timescale: u32::MAX };
let huge = FrameRate { num: i64::MAX, den: 1 };
assert_eq!(tb.frames(Tick(i64::MAX), huge), i64::MAX);
assert_eq!(tb.frames(Tick(i64::MIN), huge), i64::MIN);
// A half rounds up on the signed value, in frames and in units.
assert_eq!(tb.frames(Tick(TICKS_PER_SECOND / 2), FrameRate::new(1, 1)), 1);
assert_eq!(tb.frames(Tick(-TICKS_PER_SECOND / 2), FrameRate::new(1, 1)), 0);
let tb1 = TimeBase { frame_duration: 1, timescale: 1 };
assert_eq!((tb1.units(Tick(TICKS_PER_SECOND / 2)), tb1.units(Tick(-TICKS_PER_SECOND / 2))), ((1, true), (0, true)));
}
}
+148 -6
View File
@@ -40,6 +40,7 @@ pub(crate) fn child_data<'a>(c: &'a mut Chunk<'static>, id: &[u8; 4]) -> R<&'a m
data_mut(child_mut(c, id)?)
}
/// The one byte writer of the exporter: `bytes` at `at`, or an error when they do not fit.
pub(crate) fn put(d: &mut [u8], at: usize, bytes: &[u8]) -> R<()> {
let end = at.checked_add(bytes.len()).ok_or_else(|| tpl("offset overflow"))?;
let dst = d.get_mut(at..end).ok_or_else(|| tpl("chunk shorter than its layout"))?;
@@ -90,20 +91,161 @@ pub(crate) fn put_cstr(d: &mut [u8], at: usize, len: usize, text: &str) -> R<()>
break;
}
let mut buf = [0u8; 4];
let enc = ch.encode_utf8(&mut buf);
for (i, b) in enc.as_bytes().iter().enumerate() {
if let Some(slot) = dst.get_mut(n + i) {
*slot = *b;
}
}
put(dst, n, ch.encode_utf8(&mut buf).as_bytes())?;
n += l;
}
Ok(())
}
thread_local! {
/// How many fixed layouts on this thread did not fit their buffer (see [`Fixed`]); the tests
/// keep it at zero.
static FIXED_LAYOUT_ERRORS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A buffer whose layout is fixed in code (constant size and offsets, no input from a project).
/// Every write goes through [`put`]; a write that does not fit is skipped and its error kept, the
/// others are written. Such an error is a bug in the layout, never something a project can cause:
/// the tests build every one of these layouts and require no error. The builders keep their
/// infallible signatures because files outside this module call them without a `Result`; if a
/// layout ever broke, [`Fixed::finish`] logs it once and returns what fit.
pub(crate) struct Fixed {
bytes: Vec<u8>,
error: Option<AepExportError>,
}
impl Fixed {
/// `len` zero bytes.
pub(crate) fn zeroed(len: usize) -> Fixed {
Fixed { bytes: vec![0u8; len], error: None }
}
/// Writes `bytes` at `at`, or keeps the error (the first one) when they do not fit.
pub(crate) fn put(&mut self, at: usize, bytes: &[u8]) {
self.apply(|d| put(d, at, bytes));
}
/// Runs a writer on the buffer, keeping its error (the first one).
pub(crate) fn apply(&mut self, write: impl FnOnce(&mut [u8]) -> R<()>) {
if let Err(e) = write(&mut self.bytes) {
self.error.get_or_insert(e);
}
}
/// The error of the first write that did not fit (for the tests).
#[cfg(test)]
pub(crate) fn error(&self) -> Option<&AepExportError> {
self.error.as_ref()
}
/// The bytes; a write that did not fit is logged once, with what `layout` says.
pub(crate) fn finish(self, layout: impl FnOnce() -> String) -> Vec<u8> {
if let Some(e) = self.error {
FIXED_LAYOUT_ERRORS.with(|n| n.set(n.get().saturating_add(1)));
log::error!("aep export: the fixed layout of {} does not fit: {e}", layout());
}
self.bytes
}
}
/// How many fixed layouts have failed on this thread (for the tests).
#[cfg(test)]
pub(crate) fn fixed_layout_errors() -> usize {
FIXED_LAYOUT_ERRORS.with(std::cell::Cell::get)
}
/// The entries of a property group by match name (see [`split_group`]).
pub(crate) type Entries = Vec<(String, Vec<Chunk<'static>>)>;
/// Splits the children of a property group into the header (up to the first `tdmn`) and its
/// entries by match name: `[tdmn, node]`, or `[tdmn]` for the closing `ADBE Group End`. A match
/// name without a node is an error.
pub(crate) fn split_group(kids: &mut Vec<Chunk<'static>>) -> R<(Vec<Chunk<'static>>, Entries)> {
let first = kids.iter().position(|c| c.is(b"tdmn")).unwrap_or(kids.len());
let header: Vec<Chunk<'static>> = kids.drain(..first).collect();
let mut entries = Entries::new();
let mut it = std::mem::take(kids).into_iter();
while let Some(m) = it.next() {
let name = crate::export::synth::tdmn_name(&m).unwrap_or_default();
if name == crate::export::synth::GROUP_END {
entries.push((name, vec![m]));
continue;
}
let node = it.next().ok_or_else(|| tpl("match name without a node"))?;
entries.push((name, vec![m, node]));
}
Ok((header, entries))
}
/// Replaces the `Utf8` child of an item or layer with `text`.
pub(crate) fn set_utf8(c: &mut Chunk<'static>, text: &str) -> R<()> {
let k = child_mut(c, b"Utf8")?;
*k = Chunk::data(*b"Utf8", text.as_bytes().to_vec());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The writer errors instead of dropping.
#[test]
fn the_writer_errors_instead_of_dropping() {
let mut d = [0u8; 4];
assert!(put(&mut d, 2, &[1, 2]).is_ok());
assert_eq!(d, [0, 0, 1, 2]);
// Out of range, partly or wholly, and an offset that overflows: an error, nothing written.
assert!(put(&mut d, 3, &[9, 9]).is_err());
assert!(put(&mut d, 8, &[9]).is_err());
assert!(put(&mut d, usize::MAX, &[9]).is_err());
assert_eq!(d, [0, 0, 1, 2]);
}
/// A cut string keeps a nul and whole characters.
#[test]
fn a_cut_string_keeps_a_nul_and_whole_characters() {
let mut d = [0xffu8; 6];
put_cstr(&mut d, 0, 6, "ab\u{e9}\u{e9}").unwrap();
assert_eq!(d, [b'a', b'b', 0xc3, 0xa9, 0, 0]);
assert!(put_cstr(&mut d, 4, 6, "x").is_err());
}
/// Every layout fixed in code fits its buffer: the prototypes of every export (the project
/// skeleton, layers, Transform, masks, effects of the whole table, footage of every kind,
/// text, cameras, lights, shapes, styles) are built and no write went out of range.
#[test]
fn every_fixed_layout_fits() {
// A delta: other tests on this thread may have counted a broken layout on purpose.
let before = fixed_layout_errors();
assert!(crate::export::template::Template::load().is_ok());
assert_eq!(fixed_layout_errors(), before);
}
/// A broken fixed layout writes what fits and is counted.
#[test]
fn a_broken_fixed_layout_writes_what_fits_and_is_counted() {
let before = fixed_layout_errors();
let mut b = Fixed::zeroed(4);
b.put(0, &[1]);
b.put(3, &[2, 2]);
b.put(2, &[3]);
assert!(b.error().is_some());
assert_eq!(b.finish(|| "a test layout".to_string()), [1, 0, 3, 0]);
assert_eq!(fixed_layout_errors(), before + 1);
}
/// A group splits into header and entries.
#[test]
fn a_group_splits_into_header_and_entries() {
use crate::export::synth::{tdmn, tdsn};
let rec = || Chunk::list(*b"tdbs", Vec::new());
let mut kids = vec![tdsn("h"), tdmn("A"), rec(), tdmn("ADBE Group End")];
let (header, entries) = split_group(&mut kids).unwrap();
assert_eq!(header.len(), 1);
let names: Vec<(&str, usize)> = entries.iter().map(|(n, v)| (n.as_str(), v.len())).collect();
assert_eq!(names, [("A", 2), ("ADBE Group End", 1)]);
// A match name without its node is an error, not a quiet stop.
let mut broken = vec![tdmn("A")];
assert!(split_group(&mut broken).is_err());
}
}
+149 -6
View File
@@ -1,6 +1,6 @@
//! After Effects project (`.aep`) import: the typed view of [`effectcraft_aep`] mapped into an
//! EffectCraft [`Project`]. The XML form (`.aepx`) is accepted as well: it is read into the same
//! chunk tree ([`crate::parse_any`] picks the reader by content).
//! EffectCraft [`Project`]. The XML form (`.aepx`) is accepted as well: both are read into the same
//! flat chunk index (an `.aep` in place, an `.aepx` decoded into one buffer first).
//!
//! What arrives: the project item tree (folders), project settings (depth, working space, time
//! display), compositions (size, exact frame rate, duration, work area, background, pixel
@@ -44,6 +44,9 @@ mod time;
use effectcraft_project::{ItemId, Project, ProjectSettings};
use crate::container::index::{Root, TreeIndex};
use crate::container::{AepxDrafts, Limits, index_aepx};
pub use effects::supported_effects;
pub use report::{AeVersionInfo, ImportReport, Imported, LIMITATIONS, Loss, LossCategory, MAX_DETAILS, TESTED_MAJORS, UNKNOWN};
@@ -117,17 +120,30 @@ pub struct ImportResult {
/// [`AepImportError::Refused`] when something would be lost and `allow_losses` is not set. On any
/// error `project` is unchanged.
pub fn import(project: &mut Project, bytes: &[u8], options: &ImportOptions<'_>) -> Result<ImportResult, AepImportError> {
let file = crate::parse_any(bytes)?;
let view = crate::Project::new(&file).ok_or(AepImportError::NoProject)?;
// The importer only reads: an `.aep` is indexed in place (one flat array that borrows the
// bytes); an `.aepx` is decoded into one buffer, which the index borrows.
if crate::is_aepx(bytes) {
let AepxDrafts { buffer, nodes, trailer } = index_aepx(bytes, Limits::default())?;
let index = TreeIndex::from_drafts(&buffer, nodes, trailer);
import_root(project, Root::from(&index), options)
} else {
let index = TreeIndex::parse(bytes)?;
import_root(project, Root::from(&index), options)
}
}
/// [`import`] of a file read into either tree.
fn import_root(project: &mut Project, file: Root<'_, '_>, options: &ImportOptions<'_>) -> Result<ImportResult, AepImportError> {
let view = crate::Project::of(file).ok_or(AepImportError::NoProject)?;
// 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);
report.ae_version = report::AeVersionInfo::of_root(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);
let settings = settings::map(file, &work.settings, &mut col);
for loss in col.finish().losses.into_iter().filter(|_| options.settings) {
let at = report.losses.iter().position(|l| l.category > loss.category).unwrap_or(report.losses.len());
report.losses.insert(at, loss);
@@ -159,3 +175,130 @@ pub fn analyze_with(bytes: &[u8], options: &ImportOptions<'_>) -> Result<ImportR
let options = ImportOptions { allow_losses: true, ..*options };
Ok(import(&mut scratch, bytes, &options)?.report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::container::{AepFile, Body, Chunk, parse, write};
/// Asserts the two destination projects are structurally equal and serialize identically (layers,
/// properties and values included, which `ImportResult` does not carry). Nothing is normalized:
/// the import is deterministic, ids and timestamps included.
fn same_project(a: &Project, b: &Project, name: &str) {
assert!(a == b, "{name}: the imported projects differ through the index");
let (ja, jb) = (serde_json::to_string(a).ok(), serde_json::to_string(b).ok());
assert!(ja.is_some() && ja == jb, "{name}: the serialized projects differ through the index");
}
/// Imports `bytes` through the owned chunk tree and through the index: the same project, report
/// and settings, or the same error.
fn both(bytes: &[u8], name: &str) {
let exists = |_: &str| true;
let options = ImportOptions { allow_losses: true, ..ImportOptions::new(&exists) };
match (parse(bytes), TreeIndex::parse(bytes)) {
(Ok(file), Ok(index)) => {
let (mut pa, mut pb) = (Project::default(), Project::default());
let a = import_root(&mut pa, Root::from(&file), &options);
let b = import_root(&mut pb, Root::from(&index), &options);
assert!(a == b, "{name}: the import differs through the index");
same_project(&pa, &pb, name);
}
(Err(a), Err(b)) => assert_eq!(a, b, "{name}"),
(a, b) => panic!("{name}: {:?} vs {:?}", a.err(), b.err()),
}
}
/// A 40-byte NUL-padded match name.
fn tdmn(name: &str) -> Chunk<'static> {
let mut b = name.as_bytes().to_vec();
b.resize(40, 0);
Chunk::data(*b"tdmn", b)
}
/// A synthetic project imports the same through the index.
#[test]
fn a_synthetic_project_imports_the_same_through_the_index() {
// A composition with a layer whose effect stores arbitrary data and a named value group.
let utf8 = |id: [u8; 4], t: &str| Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", t.as_bytes().to_vec())]), pad: None };
let record = Chunk::list(*b"tdbs", vec![Chunk::data(*b"tdsb", vec![0, 0, 0, 1]), Chunk::data(*b"cdat", vec![0, 0, 0, 1])]);
let values = Chunk::list(
*b"tdgp",
vec![
utf8(*b"tdsn", "Mine"),
tdmn("ADBE Easy Levels2-0001"),
record,
Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![0u8; 108])]),
tdmn("ADBE Group End"),
],
);
let part = Chunk::list(*b"parT", vec![Chunk::data(*b"parn", vec![0, 0, 0, 2]), tdmn("ADBE Easy Levels2-0001"), Chunk::data(*b"pard", vec![0u8; 148])]);
let sspc = Chunk::list(*b"sspc", vec![utf8(*b"fnam", "Levels"), part, values]);
let parade = Chunk::list(*b"tdgp", vec![tdmn("ADBE Easy Levels2"), sspc, tdmn("ADBE Group End")]);
let root = Chunk::list(*b"tdgp", vec![tdmn("ADBE Effect Parade"), parade, tdmn("ADBE Group End")]);
let layer = Chunk::list(*b"Layr", vec![Chunk::data(*b"ldta", vec![0u8; 160]), Chunk::data(*b"Utf8", b"Layer".to_vec()), root]);
let mut idta = vec![0u8; 64];
idta[..2].copy_from_slice(&4u16.to_be_bytes());
let mut cdta = vec![0u8; 200];
cdta[4..12].copy_from_slice(&[0, 0, 0, 1, 0, 0, 0, 30]);
let item = Chunk::list(*b"Item", vec![Chunk::data(*b"idta", idta), Chunk::data(*b"Utf8", b"Comp".to_vec()), Chunk::data(*b"cdta", cdta), layer]);
let file = AepFile { chunks: vec![Chunk::data(*b"head", vec![0u8; 20]), Chunk::list(*b"Fold", vec![item])], trailer: std::borrow::Cow::Borrowed(&[]) };
both(&write(&file).expect("write"), "synthetic");
}
/// Every `.aep` of the fixture directory (skipped without `EFFECTCRAFT_AEP_FIXTURES`).
#[test]
fn every_fixture_imports_the_same_through_the_index() {
let Some(dir) = std::env::var_os("EFFECTCRAFT_AEP_FIXTURES").filter(|d| !d.is_empty()) else { return };
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut paths: Vec<std::path::PathBuf> =
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aep")).collect();
paths.sort();
for path in paths {
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
both(&bytes, &path.display().to_string());
}
}
/// Imports an `.aepx` through the owned tree ([`crate::read_aepx`]) and through the index
/// ([`import`]): the same project, report and settings, or the same error.
fn both_aepx(bytes: &[u8], name: &str) -> Result<ImportResult, AepImportError> {
let exists = |_: &str| true;
let options = ImportOptions { allow_losses: true, ..ImportOptions::new(&exists) };
let (mut pa, mut pb) = (Project::default(), Project::default());
let a = crate::read_aepx(bytes).map_err(AepImportError::from).and_then(|file| import_root(&mut pa, Root::from(&file), &options));
let b = import(&mut pb, bytes, &options);
assert!(a == b, "{name}: the .aepx import differs through the index");
same_project(&pa, &pb, name);
b
}
/// A synthetic `.aepx` imports the same through the index as through the owned tree.
#[test]
fn a_synthetic_aepx_imports_the_same_through_the_index() {
// The comp of the owned-tree test above (`idta` type 4, `cdta` 1/30 s frame duration).
let idta = format!("0004{}", "00".repeat(62));
let cdta = format!("00000000000000010000001e{}", "00".repeat(188));
let x = format!(
"<?xml version=\"1.0\"?><AfterEffectsProject><head bdata=\"{}\"/><Fold><Item><idta bdata=\"{idta}\"/><string>Comp</string><cdta bdata=\"{cdta}\"/></Item></Fold><ProjectXMPMetadata>x</ProjectXMPMetadata></AfterEffectsProject>",
"00".repeat(20)
);
let imported = both_aepx(x.as_bytes(), "synthetic").expect("the synthetic project imports");
assert_eq!(imported.comps.len(), 1);
assert_eq!(both_aepx(b"<AfterEffectsProject/>", "empty").err(), Some(AepImportError::NoProject));
assert!(matches!(both_aepx(b"<AfterEffectsProject><a>", "broken"), Err(AepImportError::Container(crate::AepError::Xml { .. }))));
}
/// Every `.aepx` of the fixture directory (skipped without `EFFECTCRAFT_AEP_FIXTURES`).
#[test]
fn every_fixture_aepx_imports_the_same_through_the_index() {
let Some(dir) = std::env::var_os("EFFECTCRAFT_AEP_FIXTURES").filter(|d| !d.is_empty()) else { return };
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut paths: Vec<std::path::PathBuf> =
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aepx")).collect();
paths.sort();
for path in paths {
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
let _ = both_aepx(&bytes, &path.display().to_string());
}
}
}
+5 -4
View File
@@ -11,7 +11,8 @@
//! EffectCraft's `timeRemap` property (seconds, keys in layer time) at the top of the layer's
//! properties, where the engine's Enable Time Remapping puts it.
use crate::{Chunk, CueType, Layer as AepLayer, Marker as AepMarker, PropNode, Property as AepProp};
use crate::container::index::Node as AepNode;
use crate::{CueType, Layer as AepLayer, Marker as AepMarker, PropNode, Property as AepProp};
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
@@ -23,12 +24,12 @@ use crate::import::report::{Collector, LossCategory};
use crate::import::time::TimeMap;
/// The time scale of the marker list below `holder` (the layer, or the composition's `SecL`).
fn marker_scale(holder: &Chunk<'_>) -> Option<u32> {
holder.find_list(b"mrst")?.child_list(b"tdbs").and_then(AepProp::from_chunk)?.time_scale().filter(|s| *s != 0)
fn marker_scale(holder: AepNode<'_, '_>) -> Option<u32> {
holder.marker_list()?.child_list(b"tdbs").and_then(AepProp::from_chunk)?.time_scale().filter(|s| *s != 0)
}
/// The markers of a layer (`holder` = the layer chunk) or a composition (`holder` = its `SecL`).
pub(crate) fn import_markers(list: Vec<AepMarker>, holder: &Chunk<'_>, what: &str, tm: &TimeMap, col: &mut Collector) -> Vec<Marker> {
pub(crate) fn import_markers(list: Vec<AepMarker>, holder: AepNode<'_, '_>, what: &str, tm: &TimeMap, col: &mut Collector) -> Vec<Marker> {
let scale = marker_scale(holder).unwrap_or(tm.timescale);
let mut out: Vec<Marker> = Vec::with_capacity(list.len());
for m in list {
+102 -11
View File
@@ -8,6 +8,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::container::AepFile;
use crate::container::index::Root;
/// Most example lines kept per loss category (the count is always exact).
pub const MAX_DETAILS: usize = 20;
@@ -93,10 +94,11 @@ impl LossCategory {
}
}
/// One category of things that were not imported.
/// One category of things that were not imported. The export report uses the same type with its
/// own categories (`export::Loss`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Loss {
pub category: LossCategory,
pub struct Loss<C = LossCategory> {
pub category: C,
/// How many occurrences (exact).
pub count: usize,
/// Example lines: up to [`MAX_DETAILS`], and for effects with no counterpart one more line per
@@ -107,6 +109,34 @@ pub struct Loss {
pub unlisted: usize,
}
/// The loss bookkeeping the import and the export report share.
impl<C: Copy + Ord> Loss<C> {
/// Counts one loss of `category`; `detail` is only called while the category holds fewer than
/// [`MAX_DETAILS`] lines.
pub(crate) fn record(losses: &mut BTreeMap<C, Self>, category: C, detail: impl FnOnce() -> String) {
let loss = losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 });
loss.count = loss.count.saturating_add(1);
if loss.details.len() < MAX_DETAILS {
loss.details.push(detail());
}
}
/// The sum of all loss counts.
pub(crate) fn total(losses: &[Self]) -> usize {
losses.iter().map(|l| l.count).sum()
}
/// The loss count of a category.
pub(crate) fn count_of(losses: &[Self], category: C) -> usize {
losses.iter().find(|l| l.category == category).map_or(0, |l| l.count)
}
/// `effects: 3, masks: 1` (empty without losses).
pub(crate) fn summary(losses: &[Self], label: impl Fn(C) -> &'static str) -> String {
losses.iter().map(|l| format!("{}: {}", label(l.category), l.count)).collect::<Vec<_>>().join(", ")
}
}
/// What arrived in the project.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -227,6 +257,11 @@ pub const UNKNOWN: &str = "unknown";
impl AeVersionInfo {
/// Reads the versions of a parsed file.
pub fn of(file: &AepFile<'_>) -> Self {
Self::of_root(Root::from(file))
}
/// Reads the versions from the top-level chunks of either tree.
pub(crate) fn of_root(file: Root<'_, '_>) -> Self {
let file_version = file.file_version();
Self {
file_version: file_version.map_or_else(|| UNKNOWN.to_string(), |v| v.text()),
@@ -252,7 +287,7 @@ impl AeVersionInfo {
impl ImportReport {
/// The sum of all loss counts.
pub fn total_losses(&self) -> usize {
self.losses.iter().map(|l| l.count).sum()
Loss::total(&self.losses)
}
/// True when nothing was lost.
@@ -262,12 +297,12 @@ impl ImportReport {
/// The loss count of a category.
pub fn count(&self, category: LossCategory) -> usize {
self.losses.iter().find(|l| l.category == category).map_or(0, |l| l.count)
Loss::count_of(&self.losses, category)
}
/// `effects: 3, masks: 1` (empty when lossless).
pub fn summary(&self) -> String {
self.losses.iter().map(|l| format!("{}: {}", l.category.label(), l.count)).collect::<Vec<_>>().join(", ")
Loss::summary(&self.losses, LossCategory::label)
}
}
@@ -293,11 +328,13 @@ const MAX_UNMAPPED_EFFECTS: usize = 100;
impl Collector {
/// Records one loss.
pub fn lose(&mut self, category: LossCategory, detail: impl Into<String>) {
let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 });
loss.count = loss.count.saturating_add(1);
if loss.details.len() < MAX_DETAILS {
loss.details.push(detail.into());
}
self.lose_with(category, move || detail.into());
}
/// Records one loss whose detail line is built only while the category holds fewer than
/// [`MAX_DETAILS`] lines (for a detail that needs `format!`).
pub fn lose_with(&mut self, category: LossCategory, detail: impl FnOnce() -> String) {
Loss::record(&mut self.losses, category, detail);
}
/// Records one instance of an effect with no counterpart. The loss is counted now; its line is
@@ -357,6 +394,60 @@ pub const LIMITATIONS: &[&str] = &[
mod tests {
use super::*;
/// A report with losses in two categories and a missing file, as the collector finishes it.
fn collected() -> ImportReport {
let mut col = Collector::default();
col.imported.layers = 2;
col.missing_footage.push("a.png".into());
col.lose(LossCategory::Masks, "mask 1");
col.lose(LossCategory::Effects, String::from("fx 1"));
col.lose_with(LossCategory::Effects, || "fx 2".to_string());
let mut report = col.finish();
report.limitations.truncate(1);
report
}
/// The JSON shape agents read: field names, category spelling, order and the loss entries.
#[test]
fn the_json_shape_is_pinned() {
let json = serde_json::to_string(&collected()).unwrap();
assert_eq!(
json,
r#"{"imported":{"folders":0,"compositions":0,"solids":0,"footage":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"footageLayers":0,"precompLayers":0,"textLayers":0,"keyframes":0,"expressions":0,"masks":0,"trackMattes":0,"markers":0,"timeRemaps":0,"shapeLayers":0,"shapeItems":0,"cameras":0,"lights":0,"textAnimators":0,"effects":0,"placeholderEffects":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"],"unlisted":0},{"category":"masks","count":1,"details":["mask 1"],"unlisted":0}],"missingFootage":["a.png"],"missingFonts":[],"limitations":["Frame blending, 3D material options, audio levels, guides are not decoded and not imported."],"aeVersion":{"fileVersion":"unknown","savedBy":"unknown","tested":false}}"#
);
let back: ImportReport = serde_json::from_str(&json).unwrap();
assert_eq!(back, collected());
}
/// The queries read the losses.
#[test]
fn the_queries_read_the_losses() {
let report = collected();
assert_eq!((report.total_losses(), report.is_lossless()), (3, false));
assert_eq!((report.count(LossCategory::Effects), report.count(LossCategory::Lights)), (2, 0));
assert_eq!(report.summary(), "effects: 2, masks: 1");
let empty = Collector::default().finish();
assert_eq!((empty.total_losses(), empty.is_lossless(), empty.summary()), (0, true, String::new()));
}
/// The count stays exact past the detail cap, and a detail past the cap is never built.
#[test]
fn details_stop_at_the_cap_and_later_ones_are_not_built() {
let mut col = Collector::default();
let mut built = 0;
for i in 0..MAX_DETAILS + 5 {
col.lose_with(LossCategory::Masks, || {
built += 1;
format!("mask {i}")
});
}
let report = col.finish();
assert_eq!(report.count(LossCategory::Masks), MAX_DETAILS + 5);
assert_eq!(report.losses[0].details.len(), MAX_DETAILS);
assert_eq!(report.losses[0].details.last().map(String::as_str), Some("mask 19"));
assert_eq!(built, MAX_DETAILS);
}
/// #3365: with other losses filling the 20 example lines, an effect with no counterpart still
/// gets its own line, with how many instances were lost. The category's count is unchanged.
#[test]
+5 -4
View File
@@ -1,7 +1,8 @@
//! Project settings (colour depth, working space, working gamma, linear-light switches, time
//! display, frame count start) from [`crate::ProjectSettings`] (cases 620-634).
use crate::{AepFile, TimeDisplay, WorkingSpace};
use crate::container::index::{Node, Root};
use crate::{TimeDisplay, WorkingSpace};
use effectcraft_color::ColorSpace;
use effectcraft_project::{BitDepth, ProjectSettings, TimeDisplayStyle};
@@ -22,9 +23,9 @@ pub(crate) fn color_space(name: &str) -> Option<ColorSpace> {
/// holds in an unknown form) keeps the current value. The two linear-light switches are always
/// held: After Effects writes their chunk only when on (626, 627), so a file without it has them off. A working space EffectCraft does not have is
/// reported.
pub(crate) fn map(file: &AepFile<'_>, current: &ProjectSettings, col: &mut Collector) -> ProjectSettings {
let s = crate::ProjectSettings::read(file);
let nhed = file.chunk(b"nhed").and_then(|c| c.bytes());
pub(crate) fn map(file: Root<'_, '_>, current: &ProjectSettings, col: &mut Collector) -> ProjectSettings {
let s = crate::ProjectSettings::of(file);
let nhed = file.chunk(b"nhed").and_then(Node::bytes);
let mut out = current.clone();
match s.bits_per_channel {
Some(8) => out.bit_depth = BitDepth::Bpc8,
+1 -1
View File
@@ -394,7 +394,7 @@ fn path(g: &AepGroup<'_, '_>, cx: &mut Cx<'_, '_>) -> Option<PropGroup> {
return None;
};
let shapes: Vec<Option<AepPath>> =
oms.child_list(b"omks").map(|k| k.children().iter().filter(|c| c.is_list(b"shap")).map(AepPath::from_chunk).collect()).unwrap_or_default();
oms.child_list(b"omks").map(|k| k.children().filter(|c| c.is_list(b"shap")).map(AepPath::from_chunk).collect()).unwrap_or_default();
let first = match shapes.first() {
Some(Some(s)) => to_path(s, (1, 1)),
Some(None) => Err("its path is unreadable"),
+4 -6
View File
@@ -7,6 +7,8 @@
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use crate::container::TimeBase;
/// The composition's frame structure: the stored time base and the rate the project gets.
#[derive(Clone, Copy, Debug)]
pub(crate) struct TimeMap {
@@ -27,12 +29,8 @@ pub(crate) fn frame_rate(timescale: u32, frame_duration: u32) -> Option<FrameRat
}
let exact = FrameRate::new(i64::from(timescale), i64::from(frame_duration));
let fps = exact.as_f64();
for ntsc in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] {
if (ntsc.as_f64() - fps).abs() < 0.0005 {
return Some(ntsc);
}
}
Some(exact)
let ntsc = TimeBase::NTSC.into_iter().map(|(rate, _)| rate).find(|ntsc| (ntsc.as_f64() - fps).abs() < 0.0005);
Some(ntsc.unwrap_or(exact))
}
impl TimeMap {
+7
View File
@@ -128,6 +128,13 @@ pub fn resolve(name: &str) -> Option<PathBuf> {
locate(name).map(|s| s.path().clone())
}
/// The ids of the compositions an item uses as precomposition layers, as the file stores them:
/// the little-endian `u32` of each `idpi` chunk (the importer does not read them; the exporter
/// writes them).
pub fn used_item_ids(item: &effectcraft_aep::Item<'_, '_>) -> Vec<u32> {
item.chunk().children().filter(|c| c.is(b"idpi")).filter_map(|c| Some(u32::from_le_bytes(c.bytes()?.get(..4)?.try_into().ok()?))).collect()
}
/// The bytes of a case (see the module comment), or `None` when it is missing.
pub fn try_fixture(name: &str) -> Option<Vec<u8>> {
match locate(name)? {
+1
View File
@@ -25,5 +25,6 @@ mod shapes;
mod text_fields;
mod text_markers_footage;
mod text_style_slots;
mod tree_index;
mod typed;
mod values;
+14 -20
View File
@@ -117,8 +117,8 @@ fn a_transform_set_to_its_default_adds_no_record() {
assert_eq!(t.opacity_percent.source, ValueSource::Default);
assert!(layer.properties().unwrap().find(&["ADBE Transform Group", "ADBE Position"]).is_none());
// The two cases are layers of one file: they differ in the layer id only.
let a = without_layer_id(layer.chunk());
skip!(with_layer("299-save-noise.aep", L, |_, other| assert_eq!(a, without_layer_id(other.chunk()))));
let a = without_layer_id(&layer.chunk().to_chunk());
skip!(with_layer("299-save-noise.aep", L, |_, other| assert_eq!(a, without_layer_id(&other.chunk().to_chunk()))));
}));
}
@@ -140,16 +140,13 @@ fn scale_keys_keep_their_ease_type_major() {
}
assert_near(&keys[0].value().unwrap(), &[1.0, 1.0, 1.0], 1e-9);
assert_near(&keys[1].value().unwrap(), &[2.0, 1.5, 1.0], 1e-9);
// The raw block: [in speed x3][in influence x3][out speed x3][out influence x3].
assert_near(&keys[0].plain_ease_slots().unwrap(), &[0.0, 0.0, 0.0, 0.33, 0.33, 0.33, 0.2, 0.2, 0.2, 0.66, 0.66, 0.66], 1e-9);
assert_near(&keys[1].plain_ease_slots().unwrap(), &[0.1, 0.1, 0.1, 0.66, 0.66, 0.66, 0.0, 0.0, 0.0, 0.33, 0.33, 0.33], 1e-9);
// The raw block is [in speed x3][in influence x3][out speed x3][out influence x3].
for i in 0..3 {
assert!(same_ease(&keys[0].ease_component(i).unwrap(), &ease(0.0, 0.33, 0.2, 0.66)), "key 1 component {i}");
assert!(same_ease(&keys[1].ease_component(i).unwrap(), &ease(0.1, 0.66, 0.0, 0.33)), "key 2 component {i}");
}
assert!(keys[0].ease_component(3).is_none());
assert_eq!(keys[0].ease_components().unwrap().len(), 3);
assert!(keys[0].color_distance().is_none());
}));
}
@@ -172,7 +169,6 @@ fn mask_feather_keys_keep_their_ease_type_major() {
}
assert_near(&keys[0].value().unwrap(), &[0.0, 0.0], 1e-9);
assert_near(&keys[1].value().unwrap(), &[50.0, 30.0], 1e-9);
assert_near(&keys[0].plain_ease_slots().unwrap(), &[0.0, 0.0, 0.33, 0.33, 15.0, 15.0, 0.66, 0.66], 1e-9);
for i in 0..2 {
assert!(same_ease(&keys[0].ease_component(i).unwrap(), &ease(0.0, 0.33, 15.0, 0.66)), "key 1 component {i}");
assert!(same_ease(&keys[1].ease_component(i).unwrap(), &ease(8.0, 0.66, 0.0, 0.33)), "key 2 component {i}");
@@ -213,14 +209,15 @@ fn bezier_colour_keys_carry_the_distance_to_the_next_key() {
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Bezier), Some(Interpolation::Bezier)));
assert!(same_ease(&k.ease_component(0).unwrap(), &ease(0.0, 0.0, 0.0, 0.0)));
}
assert!(near(keys[0].color_distance().unwrap(), 255.0 * 2f64.sqrt(), 1e-3), "{:?}", keys[0].color_distance());
assert!(near(keys[0].color_distance().unwrap(), 360.6245, 1e-3));
assert_eq!(keys[1].color_distance(), Some(0.0));
let distance = |k: &effectcraft_aep::Keyframe<'_>| k.spatial().unwrap().segment_length;
assert!(near(distance(&keys[0]), 255.0 * 2f64.sqrt(), 1e-3), "{}", distance(&keys[0]));
assert!(near(distance(&keys[0]), 360.6245, 1e-3));
assert_eq!(distance(&keys[1]), 0.0);
}));
skip!(with_layer("210-key-fill-color-2key-linear.aep", L, |_, layer| {
let p = layer.effects()[0].params().into_iter().find(|p| p.name == "Color").unwrap();
let k = p.stored().unwrap().keys().unwrap().keyframes().next().unwrap();
assert_eq!(k.color_distance(), Some(0.0));
assert_eq!(k.spatial().unwrap().segment_length, 0.0);
}));
}
@@ -243,21 +240,19 @@ fn time_stretch_is_an_exact_ratio() {
let (h, in_p, out_p, scale) = skip!(stretch_facts("316-layer-stretch50.aep"));
assert_eq!((h.stretch, h.stretch_den), (1, 2));
assert_eq!(h.stretch_ratio(), Some(rat(1, 2)));
assert_eq!(h.stretch_percent(), 50.0);
assert_eq!(h.out_relative, rat(128000, 25600));
assert_eq!((in_p, out_p, scale), (Some(rat(0, 1)), Some(rat(5, 2)), 25600));
// 317: 150 %: 3/2, ends at 7.5 s, the property time scale is 25600 x 3/2.
let (h, in_p, out_p, scale) = skip!(stretch_facts("317-layer-stretch150.aep"));
assert_eq!((h.stretch, h.stretch_den), (3, 2));
assert_eq!(h.stretch_ratio(), Some(rat(3, 2)));
assert_eq!(h.stretch_percent(), 150.0);
assert_eq!((in_p, out_p, scale), (Some(rat(0, 1)), Some(rat(15, 2)), 38400));
// 018: 200 %: 2/1, ends at 10 s, time scale 51200. The base is 1/1.
let (h, _, out_p, scale) = skip!(stretch_facts("018-layer-stretch200.aep"));
assert_eq!((h.stretch, h.stretch_den, h.stretch_ratio()), (2, 1, Some(rat(2, 1))));
assert_eq!((out_p, scale), (Some(rat(10, 1)), 51200));
let (h, _, out_p, scale) = skip!(stretch_facts("000-base.aep"));
assert_eq!((h.stretch, h.stretch_den, h.stretch_percent()), (1, 1, 100.0));
assert_eq!((h.stretch, h.stretch_den, h.stretch_ratio()), (1, 1, Some(rat(1, 1))));
assert_eq!((out_p, scale), (Some(rat(5, 1)), 25600));
}
@@ -269,9 +264,9 @@ fn fractional_times_are_exact_multiples_of_one_over_25600() {
assert_eq!(h.in_relative, rat(8532, 25600));
assert_eq!(h.out_relative, rat(136532, 25600));
assert_eq!(h.start, rat(0, 25600));
// 0.33328125 s and 5.33328125 s.
assert_eq!(h.in_point(), Some(rat(2133, 6400)));
assert_eq!(h.in_point_seconds(), Some(0.33328125));
assert_eq!(h.out_point_seconds(), Some(5.33328125));
assert_eq!(h.out_point(), Some(rat(34133, 6400)));
assert_eq!(h.stretch_ratio(), Some(rat(1, 1)));
}
@@ -364,18 +359,17 @@ fn pixel_aspect_shutter_phase_and_display_start() {
// 325: pixelAspect 0.9091 reads back 0.90909090909091 = the exact ratio 10/11.
let h = skip!(comp_header("325-comp-pixelaspect-0.9091.aep"));
assert_eq!(h.pixel_aspect.reduced(), Some(rat(10, 11)));
assert!(near(h.pixel_aspect_ratio().unwrap(), 0.90909090909091, 1e-12));
// 326: shutterPhase -90, the shutter angle stays 180.
let h = skip!(comp_header("326-comp-shutterphase-m90.aep"));
assert_eq!((h.shutter_phase, h.shutter_angle), (Some(-90), 180));
// 327: displayStartFrame 100 at 25 fps = 4 s = 102400/25600; the shutter phase is back to 0.
let h = skip!(comp_header("327-comp-displaystartframe-100.aep"));
assert_eq!(h.display_start, Some(rat(102400, 25600)));
assert_eq!((h.display_start_seconds(), h.display_start_frame(), h.shutter_phase), (Some(4.0), Some(100), Some(0)));
assert_eq!(h.shutter_phase, Some(0));
// The base: no phase, start 0.
let h = skip!(comp_header("000-base.aep"));
assert_eq!((h.shutter_phase, h.display_start_seconds(), h.display_start_frame()), (Some(0), Some(0.0), Some(0)));
assert_eq!(h.pixel_aspect_ratio(), Some(1.0));
assert_eq!((h.shutter_phase, h.display_start.and_then(|r| r.reduced())), (Some(0), Some(rat(0, 1))));
assert_eq!(h.pixel_aspect.reduced(), Some(rat(1, 1)));
// A short chunk still decodes the old fields.
let bytes = [0u8; 176];
let h = effectcraft_aep::CompHeader::parse(&bytes).unwrap();
+20 -78
View File
@@ -148,19 +148,21 @@ fn a_mask_box_is_normalised_to_the_layer_size() {
for (got, want) in m.shapes[0].bbox.iter().zip(want_box) {
assert!(near(f64::from(*got), want, 1e-6), "{:?}", m.shapes[0].bbox);
}
let shapes = project.mask_vertices_px(&layer, m).unwrap();
assert_eq!(shapes.len(), 1);
let got: Vec<[f64; 2]> = shapes[0].iter().map(|v| v.position).collect();
// The box scales by the size of the layer's source (the importer takes that size from the
// source item it matched, the same item `layer_source_size` reads).
let (w, h) = project.layer_source_size(&layer).unwrap();
assert_eq!(m.shapes.len(), 1);
let got: Vec<[f64; 2]> = m.shapes[0].vertices((f64::from(w), f64::from(h))).iter().map(|v| v.position).collect();
for (g, w) in got.iter().zip([[20.0, 30.0], [80.0, 30.0], [80.0, 70.0], [20.0, 70.0]]) {
assert_near(g, &w, 1e-3);
}
assert_eq!(got.len(), 4);
// A layer without a sized source has no pixel vertices.
// A layer without a sized source has no size to scale by.
let bytes = fixture!("333-shape-group-two-rects.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let shape = layer_named(&first_comp(&project), "Shape");
assert!(project.mask_vertices_px(&shape, m).is_none());
assert!(project.layer_source_size(&shape).is_none());
}
#[test]
@@ -178,12 +180,14 @@ fn one_mkif_per_mask_and_the_mode_none() {
assert_eq!((m.mode, m.inverted, m.counter), (Some(MaskMode::Add), Some(false), Some(counter)));
assert_eq!(m.shapes.len(), 1);
}
let v1 = project.mask_vertices_px(&layer, &masks[0]).unwrap();
let (w, h) = project.layer_source_size(&layer).unwrap();
let px = |m: &effectcraft_aep::Mask| m.shapes.iter().map(|s| s.vertices((f64::from(w), f64::from(h)))).collect::<Vec<_>>();
let v1 = px(&masks[0]);
for (v, w) in v1[0].iter().zip(ellipse_vertices()) {
assert_near(&v.position, &w, 1e-2);
}
// Mask 2 has its own box (500..900 x 350..650) normalised to the layer.
let v2 = project.mask_vertices_px(&layer, &masks[1]).unwrap();
let v2 = px(&masks[1]);
for (v, w) in v2[0].iter().zip([[500.0, 350.0], [900.0, 350.0], [900.0, 650.0], [500.0, 650.0]]) {
assert_near(&v.position, &w, 1e-2);
}
@@ -382,78 +386,16 @@ fn the_last_style_run_absorbs_the_final_carriage_return() {
// ------------------------------------------------------------------ camera and light (341-345)
/// Camera zoom keeps the auto position.
#[test]
fn camera_zoom_and_depth_of_field() {
let camera = |name: &str| {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = layer_of_type(&first_comp(&project), LayerType::Camera);
(
layer.camera_zoom(),
layer.camera_depth_of_field(),
layer.camera_focus_distance(),
layer.camera_aperture(),
layer.camera_position().map(|p| (p.stored, p.auto_state)),
)
})
};
// 341: Zoom 2000 is stored; the auto Position keeps [0, 0, -2666.67].
let (zoom, dof, focus, aperture, pos) = skip!(camera("341-camera-zoom-2000.aep"));
assert_eq!(zoom.map(|z| (z.value, z.source)), Some((2000.0, ValueSource::Stored)));
assert_eq!((dof.map(|d| (d.value, d.source)), focus, aperture), (Some((false, ValueSource::Default)), None, None));
let (stored, auto) = pos.unwrap();
assert!(auto && near(stored[2], -2666.67, 0.01) && stored[0] == 0.0, "{stored:?}");
// 342: DOF on, Focus Distance 800, Aperture 30; Zoom stays the default.
let (zoom, dof, focus, aperture, _) = skip!(camera("342-camera-dof-800-30.aep"));
assert_eq!(zoom.map(|z| (z.source, near(z.value, 2666.67, 0.01))), Some((ValueSource::Default, true)));
assert_eq!(dof.map(|d| (d.value, d.source)), Some((true, ValueSource::Stored)));
assert_eq!(focus.map(|f| (f.value, f.source)), Some((800.0, ValueSource::Stored)));
assert_eq!(aperture.map(|f| (f.value, f.source)), Some((30.0, ValueSource::Stored)));
// The default camera (256) has nothing but the default zoom.
let (zoom, dof, ..) = skip!(camera("256-camera-two-node-default.aep"));
assert_eq!((zoom.unwrap().source, dof.unwrap().value), (ValueSource::Default, false));
assert_eq!(zoom.unwrap().value, effectcraft_aep::DEFAULT_CAMERA_ZOOM);
}
#[test]
fn light_colour_cone_angle_and_feather() {
let light = |name: &str| {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = layer_of_type(&first_comp(&project), LayerType::Light);
assert_eq!(layer.header().unwrap().light_type(), Some(effectcraft_aep::LightType::Spot), "{name}");
let t = |x: Option<effectcraft_aep::Tracked<f64>>| x.map(|x| (x.value, x.source));
(layer.light_color().map(|c| (c.value, c.source)), t(layer.light_cone_angle()), t(layer.light_cone_feather()))
})
};
// The default spot light: white, 90, 50, all absent.
let (colour, angle, feather) = skip!(light("254-light-spot-intensity-50.aep"));
assert_eq!((colour, angle, feather), (Some(([1.0; 4], ValueSource::Default)), Some((90.0, ValueSource::Default)), Some((50.0, ValueSource::Default))));
// 343: colour [1,0,0,1] = ARGB doubles [255,255,0,0].
let (colour, angle, feather) = skip!(light("343-light-spot-color-red.aep"));
assert_eq!(colour, Some(([1.0, 1.0, 0.0, 0.0], ValueSource::Stored)));
assert_eq!((angle, feather), (Some((90.0, ValueSource::Default)), Some((50.0, ValueSource::Default))));
// 344: Cone Angle 60. 345: Cone Feather 30.
let (colour, angle, feather) = skip!(light("344-light-spot-coneangle-60.aep"));
assert_eq!((colour.unwrap().1, angle, feather), (ValueSource::Default, Some((60.0, ValueSource::Stored)), Some((50.0, ValueSource::Default))));
let (_, angle, feather) = skip!(light("345-light-spot-conefeather-30.aep"));
assert_eq!((angle, feather), (Some((90.0, ValueSource::Default)), Some((30.0, ValueSource::Stored))));
// A point light (252) stores Cone Angle 90 / Feather 50 in their default state.
let bytes = fixture!("252-light-point.aep");
fn camera_zoom_keeps_the_auto_position() {
// 341: Zoom 2000 is stored (checked on import); the auto Position keeps [0, 0, -2666.67].
let bytes = fixture!("341-camera-zoom-2000.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = layer_of_type(&first_comp(&project), LayerType::Light);
assert_eq!(layer.light_cone_angle().map(|a| (a.value, a.source)), Some((90.0, ValueSource::Stored)));
// Layers without light options have no light accessors.
let bytes = fixture!("000-base.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let solid = base_layer(&first_comp(&project));
assert!(solid.light_color().is_none() && solid.camera_zoom().is_none() && solid.camera_depth_of_field().is_none());
let layer = layer_of_type(&first_comp(&project), LayerType::Camera);
let pos = layer.camera_position().unwrap();
assert!(pos.auto_state && near(pos.stored[2], -2666.67, 0.01) && pos.stored[0] == 0.0, "{:?}", pos.stored);
}
// ------------------------------------------------------------------ marker data (347-349, 249)
@@ -503,7 +445,7 @@ fn audio_footage_item() {
assert_eq!((f.width, f.height, f.is_solid), (0, 0, false));
assert_eq!(f.duration, Some(rat(48000, 48000)));
assert_eq!((f.audio_sample_rate, f.file_size, f.bits_per_pixel, f.native_frame_rate), (Some(48000.0), 96044, 0, 0));
assert!(!f.is_sequence && f.sequence_frame_count().is_none());
assert!(!f.is_sequence && item.sequence_info().is_none());
// The combined file stores no display name for the item (the single-case file had
// "test-tone.wav"); the path names the file.
assert_eq!(item.label(), Some(7));
@@ -527,7 +469,7 @@ fn image_sequence_is_one_item() {
assert_eq!(f.duration, Some(rat(3, 30)), "{name}");
assert_eq!((f.native_frame_rate, f.bits_per_pixel, f.file_size, f.audio_sample_rate), (30, 24, 504, None), "{name}");
assert!(f.is_sequence, "{name}");
assert_eq!(f.sequence_frame_count(), Some(3), "{name}");
assert_eq!(item.sequence_info().map(|s| s.frame_count()), Some(3), "{name}");
// The first file name sits in opti; the alias points at the sequence folder.
assert_eq!(f.name.as_deref(), Some("seq_001.png"), "{name}");
assert!(f.path.as_deref().is_some_and(|p| p.ends_with("test-seq")), "{name}");
+2 -2
View File
@@ -38,7 +38,7 @@ fn a_composition_counts_the_compositions_it_shows() {
let id = |n: &str| items.iter().find(|i| i.display_name().as_deref() == Some(n)).and_then(|i| i.id()).expect(n);
let c = items.iter().find(|i| i.display_name().as_deref() == Some("C")).expect("C");
// The list holds each used composition once (in no particular order).
let mut used = c.used_item_ids();
let mut used = crate::common::used_item_ids(c);
used.sort();
let mut want = vec![id("P30"), id("Q")];
want.sort();
@@ -60,8 +60,8 @@ fn an_image_sequence_knows_its_numbering() {
let item = project.all_items().into_iter().find(|i| i.footage().is_some_and(|f| !f.is_solid)).expect("sequence");
let f = item.footage().expect("footage");
assert_eq!((f.file_type.as_str(), f.width, f.height, f.native_frame_rate, f.is_sequence), ("oEXR", 64, 48, 30, true));
assert_eq!(f.sequence_frame_count(), Some(3));
let s = item.sequence_info().expect("numbering");
assert_eq!(s.frame_count(), 3);
assert_eq!((s.prefix.as_str(), s.suffix.as_str(), s.start, s.end, s.digits), ("shot_", ".exr", 101, 103, 4));
assert_eq!(s.file_name(101), "shot_0101.exr");
// A still has no numbering (000: the solid; the base has no other footage).
+19 -38
View File
@@ -55,7 +55,6 @@ fn assert_plain(h: &LayerHeader) {
assert_eq!(h.label, 1);
// No name was set, so the copy of the name at byte 64 is empty (the layer shows its source name).
assert_eq!(h.name_copy, "");
assert!(!h.name_is_user_set());
assert!(!h.is_null_object());
assert_eq!(h.parent_id, None);
assert_eq!(h.source_id, Some(0x1a));
@@ -67,16 +66,13 @@ fn base_layer_header() {
assert_plain(&h);
assert_eq!(h.quality, Quality::Best);
assert_eq!(h.blend_mode, BlendMode::Normal);
assert_eq!(h.stretch, 1);
assert_eq!(h.stretch_percent(), 100.0);
assert_eq!((h.stretch, h.stretch_den), (1, 1));
assert_eq!(switches(&h), NONE);
// start 0 s, in 0 s, out 5 s; denominator = the composition timescale.
assert_eq!(h.start, Rational { num: 0, den: 25600 });
assert_eq!(h.in_relative, Rational { num: 0, den: 25600 });
assert_eq!(h.out_relative, Rational { num: 128_000, den: 25600 });
assert_eq!(h.start_seconds(), Some(0.0));
assert_eq!(h.in_point_seconds(), Some(0.0));
assert_eq!(h.out_point_seconds(), Some(5.0));
assert_eq!(h.in_point(), Rational::new(0, 1));
assert_eq!(h.out_point(), Rational::new(5, 1));
}
@@ -98,7 +94,7 @@ fn one_switch_per_case() {
assert_eq!(switches(&h), expected, "{name}");
assert_plain(&h);
assert_eq!((h.quality, h.blend_mode, h.stretch), (Quality::Best, BlendMode::Normal, 1), "{name}");
assert_eq!(h.out_point_seconds(), Some(5.0), "{name}");
assert_eq!(h.out_point(), Rational::new(5, 1), "{name}");
}
}
@@ -106,7 +102,6 @@ fn one_switch_per_case() {
fn name_blend_mode_and_quality() {
let h = skip!(l_header("010-layer-name.aep"));
assert_eq!(h.name_copy, "Renamed");
assert!(h.name_is_user_set());
let bytes = fixture!("010-layer-name.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
@@ -114,7 +109,6 @@ fn name_blend_mode_and_quality() {
// The layer name is the user's; the source footage keeps its own name.
assert_eq!(layer.name().as_deref(), Some("Renamed"));
assert_eq!(project.layer_source(&layer).and_then(|i| i.display_name()).as_deref(), Some("L"));
assert_eq!(project.layer_display_name(&layer).as_deref(), Some("Renamed"));
assert_eq!(skip!(l_header("016-layer-blend-multiply.aep")).blend_mode, BlendMode::Multiply);
assert_eq!(skip!(l_header("023-layer-quality-draft.aep")).quality, Quality::Draft);
@@ -127,7 +121,7 @@ fn display_name_falls_back_to_the_source_name() {
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
assert_eq!(layer.name(), None);
assert_eq!(project.layer_display_name(&layer).as_deref(), Some("L"));
assert_eq!(project.layer_source(&layer).and_then(|i| i.display_name()).as_deref(), Some("L"));
assert_eq!(project.layer_source_size(&layer), Some((1920, 1080)));
let source = project.layer_source(&layer).unwrap();
assert_eq!(source.id(), Some(0x1a));
@@ -139,26 +133,23 @@ fn time_stretch_and_the_in_out_start_convention() {
// 018: stretch 100 -> 200: the stored out point stays 5 s, the layer ends at 10 s.
let h = skip!(l_header("018-layer-stretch200.aep"));
assert_eq!(h.stretch, 2);
assert_eq!(h.stretch_percent(), 200.0);
assert_eq!(h.stretch_ratio(), Rational::new(2, 1));
assert_eq!(h.out_relative, Rational { num: 128_000, den: 25600 });
assert_eq!(h.out_point_seconds(), Some(10.0));
assert_eq!(h.in_point_seconds(), Some(0.0));
assert_eq!(h.out_point(), Rational::new(10, 1));
assert_eq!(h.in_point(), Rational::new(0, 1));
// 019: inPoint 0 -> 1; AE moved the out point to 6 s and left the start at 0.
let h = skip!(l_header("019-layer-inpoint1.aep"));
assert_eq!(h.start_seconds(), Some(0.0));
assert_eq!(h.start.num, 0);
assert_eq!(h.in_relative, Rational { num: 25600, den: 25600 });
assert_eq!(h.in_point(), Rational::new(1, 1));
assert_eq!(h.out_point_seconds(), Some(6.0));
assert_eq!(h.out_point(), Rational::new(6, 1));
// 021: startTime 0 -> 0.5: in 0.5 s and out 5.5 s although the stored in point stays 0.
let h = skip!(l_header("021-layer-starttime0.5.aep"));
assert_eq!(h.start, Rational { num: 12800, den: 25600 });
assert_eq!(h.start_seconds(), Some(0.5));
assert_eq!(h.in_relative.num, 0);
assert_eq!(h.in_point(), Rational::new(1, 2));
assert_eq!(h.in_point_seconds(), Some(0.5));
assert_eq!(h.out_point_seconds(), Some(5.5));
assert_eq!(h.out_point(), Rational::new(11, 2));
}
@@ -175,7 +166,6 @@ fn parent_link_and_null_object() {
let nh = null.header().unwrap();
assert_eq!(null.name().as_deref(), Some("Null"));
assert!(nh.is_null_object());
assert!(nh.name_is_user_set());
assert_eq!(nh.layer_type, LayerType::Av);
assert_eq!(nh.parent_id, None);
assert_eq!(project.layer_source(null).and_then(|i| i.display_name()).as_deref(), Some("Null 1"));
@@ -225,17 +215,14 @@ fn base_comp_header() {
assert_eq!((h.width, h.height), (1920, 1080));
assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 25600));
assert_eq!(h.frame_rate(), Some(25.0));
assert_eq!(h.frame_rate_from_fixed(), 25.0);
assert_eq!(h.frame_rate_fixed, 25 << 16);
assert_eq!(h.duration_units, 128_000);
assert_eq!(h.duration_seconds(), Some(5.0));
assert_eq!(h.background, [0, 0, 0]);
assert_eq!(h.background_rgb(), [0.0, 0.0, 0.0]);
assert_eq!(h.shutter_angle, 180);
assert_eq!(h.work_area_start_seconds(), Some(0.0));
assert_eq!(h.work_area_start_units, 0);
assert_eq!(h.work_area_end_units, None);
assert_eq!(h.work_area_end_seconds(), Some(5.0));
assert_eq!(h.pixel_aspect, Rational { num: 1, den: 1 });
assert_eq!(h.pixel_aspect_ratio(), Some(1.0));
assert!(!h.motion_blur);
// The manifest calls the classic renderer `ADBE Advanced 3d`; the file stores `ADBE Escher`
// and the display name `Classic 3D`.
@@ -255,7 +242,7 @@ fn one_comp_change_per_case() {
let (h, _, _) = skip!(comp_header("031-comp-fps30.aep"));
assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 30720));
assert_eq!(h.frame_rate(), Some(30.0));
assert_eq!(h.frame_rate_from_fixed(), 30.0);
assert_eq!(h.frame_rate_fixed, 30 << 16);
assert_eq!(h.duration_units, 153_600);
assert_eq!(h.duration_seconds(), Some(5.0));
@@ -263,12 +250,11 @@ fn one_comp_change_per_case() {
let (h, _, _) = skip!(comp_header("032-comp-duration10.aep"));
assert_eq!(h.duration_units, 256_000);
assert_eq!(h.duration_seconds(), Some(10.0));
assert_eq!(h.work_area_end_seconds(), Some(10.0));
assert_eq!(h.work_area_end_units, None);
// 033: bgColor [0,0,0] -> [0,1,0].
let (h, _, _) = skip!(comp_header("033-comp-bg-green.aep"));
assert_eq!(h.background, [0, 255, 0]);
assert_eq!(h.background_rgb(), [0.0, 1.0, 0.0]);
// 034: shutterAngle 180 -> 90.
let (h, _, _) = skip!(comp_header("034-comp-shutter90.aep"));
@@ -279,8 +265,6 @@ fn one_comp_change_per_case() {
let (h, _, _) = skip!(comp_header("035-comp-workarea-1-2.aep"));
assert_eq!(h.work_area_start_units, 25600);
assert_eq!(h.work_area_end_units, Some(76_800));
assert_eq!(h.work_area_start_seconds(), Some(1.0));
assert_eq!(h.work_area_end_seconds(), Some(3.0));
}
#[test]
@@ -290,7 +274,6 @@ fn one_comp_change_per_case_aspect_blur_renderer() {
// 037: pixelAspect 1 -> 2.
let (h, _, _) = skip!(comp_header("037-comp-pixelaspect2.aep"));
assert_eq!(h.pixel_aspect, Rational { num: 2, den: 1 });
assert_eq!(h.pixel_aspect_ratio(), Some(2.0));
// 038: comp motion blur on.
let (h, _, _) = skip!(comp_header("038-comp-motionblur.aep"));
@@ -328,17 +311,16 @@ fn precomp_item_ids_and_labels() {
assert_eq!(h.source_id, pre_item.id());
assert_eq!(project.layer_source(layer).unwrap().id(), pre_item.id());
assert_eq!(h.label, 15);
assert!(!h.name_is_user_set());
assert_eq!(layer.name(), None);
assert_eq!(project.layer_display_name(layer).as_deref(), Some("Pre"));
assert_eq!(project.layer_source(layer).and_then(|i| i.display_name()).as_deref(), Some("Pre"));
assert_eq!(project.layer_source_size(layer), Some((1920, 1080)));
// The precomp layer's start time has the denominator 1; the Pre comp is 1920x1080, 25 fps, 5 s.
assert_eq!(h.start.den, 1);
let ph = pre.header().unwrap();
assert_eq!((ph.width, ph.height, ph.frame_rate(), ph.duration_seconds()), (1920, 1080, Some(25.0), Some(5.0)));
// C records that it uses the comp Pre (chunk idpi, a little-endian u32).
assert_eq!(c_item.used_item_ids(), [pre_item.id().unwrap()]);
assert!(pre_item.used_item_ids().is_empty());
assert_eq!(crate::common::used_item_ids(c_item), [pre_item.id().unwrap()]);
assert!(crate::common::used_item_ids(pre_item).is_empty());
assert_eq!(pre_item.label(), Some(15));
// The layer inside Pre is a new layer made from the solid L (moving the layers into a precomp
// creates new layers).
@@ -347,7 +329,7 @@ fn precomp_item_ids_and_labels() {
let ih = inner[0].header().unwrap();
assert_ne!(ih.id, L);
assert_eq!(ih.source_id, Some(0x1a));
assert_eq!(project.layer_display_name(&inner[0]).as_deref(), Some("L"));
assert_eq!(project.layer_source(&inner[0]).and_then(|i| i.display_name()).as_deref(), Some("L"));
}
#[test]
@@ -356,9 +338,8 @@ fn a_negative_stretch_is_a_reversed_layer() {
// point 0 and out point 5 s relative, so the layer runs from 5 s back to 0 s; 983: -50 % = -1/2.
let h = skip!(l_header("981-layer-stretch-minus100-start5.aep"));
assert_eq!((h.stretch, h.stretch_den), (-1, 1));
assert_eq!(h.stretch_percent(), -100.0);
assert_eq!((h.in_point_seconds(), h.out_point_seconds()), (Some(5.0), Some(0.0)));
assert_eq!((h.in_point(), h.out_point()), (Rational::new(5, 1), Rational::new(0, 1)));
let h = skip!(l_header("983-layer-stretch-minus50-opacity-keys.aep"));
assert_eq!((h.stretch, h.stretch_den), (-1, 2));
assert_eq!((h.in_point_seconds(), h.out_point_seconds()), (Some(5.0), Some(2.5)));
assert_eq!((h.in_point(), h.out_point()), (Rational::new(5, 1), Rational::new(5, 2)));
}
+2 -1
View File
@@ -379,7 +379,8 @@ pub fn layer_named<'f, 'a>(comp: &Comp<'f, 'a>, name: &str) -> Layer<'f, 'a> {
/// The layer that shows this name: its own name, or its source's while it has none.
pub fn layer_shown_as<'f, 'a>(project: &Project<'f, 'a>, comp: &Comp<'f, 'a>, name: &str) -> Layer<'f, 'a> {
comp.layers().find(|l| project.layer_display_name(l).as_deref() == Some(name)).unwrap_or_else(|| panic!("no layer shown as {name}"))
let shown = |l: &Layer<'f, 'a>| l.name().or_else(|| project.layer_source(l)?.display_name());
comp.layers().find(|l| shown(l).as_deref() == Some(name)).unwrap_or_else(|| panic!("no layer shown as {name}"))
}
/// The first layer of this type in a composition.
+7 -4
View File
@@ -19,6 +19,9 @@ fn prop_of<R>(name: &str, path: &[&str], f: impl FnOnce(Property<'_, '_>) -> R)
const POSITION: &[&str] = &["ADBE Transform Group", "ADBE Position"];
const OPACITY: &[&str] = &["ADBE Transform Group", "ADBE Opacity"];
/// The ease of a linear key: all four slots 0.
const NO_EASE: KeyEase = KeyEase { in_speed: 0.0, in_influence: 0.0, out_speed: 0.0, out_influence: 0.0 };
fn ease(in_speed: f64, in_influence: f64, out_speed: f64, out_influence: f64) -> KeyEase {
KeyEase { in_speed, in_influence, out_speed, out_influence }
}
@@ -33,7 +36,7 @@ fn in_and_out_interpolation_are_separate_bytes() {
assert_eq!((keys[0].interpolation_in(), keys[0].interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Hold)));
assert_eq!((keys[1].interpolation_in(), keys[1].interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear)));
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 1.0]);
assert_eq!(keys[1].spatial_value(), Some([100.0, 0.0, 0.0]));
assert_eq!(keys[1].value(), Some(vec![100.0, 0.0, 0.0]));
}));
// 203: key 1 inType HOLD, outType LINEAR (bytes 03 01): byte 4 is the incoming side.
skip!(prop_of("203-key-in-hold-out-linear.aep", POSITION, |p| {
@@ -76,7 +79,7 @@ fn opacity_ease_speed_is_in_stored_units_per_second() {
assert_near(&[e0.in_speed, e0.in_influence, e0.out_speed, e0.out_influence], &[0.05, 0.2, 0.2, 0.8], 1e-12);
let e1 = keys[1].ease().unwrap();
assert_near(&[e1.in_speed, e1.in_influence, e1.out_speed, e1.out_influence], &[0.3, 0.6, 0.09, 0.4], 1e-12);
assert_eq!(keys[0].plain_ease_slots().unwrap().len(), 4);
assert_eq!(keys[0].ease_components(), Some(vec![e0]));
for k in &keys {
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Bezier), Some(Interpolation::Bezier)));
}
@@ -94,7 +97,7 @@ fn scale_keys_use_the_plain_template_with_three_components() {
// A 128-byte record is not always spatial: the header's template flag decides.
assert_eq!(k.kind(), KeyframeKind::Plain { components: 3 });
assert_eq!(k.spatial(), None);
assert_eq!(k.plain_ease_slots(), Some(vec![0.0; 12]));
assert_eq!(k.ease_components(), Some(vec![NO_EASE; 3]));
assert_eq!(k.ease(), None);
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear)));
}
@@ -125,7 +128,7 @@ fn two_dimensional_mask_feather_keys() {
assert!(keys.iter().all(|k| k.raw().len() == 88 && k.kind() == KeyframeKind::Plain { components: 2 }));
assert_eq!(keys.iter().map(|k| k.value().unwrap()).collect::<Vec<_>>(), [vec![0.0, 0.0], vec![40.0, 20.0]]);
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 1.0]);
assert_eq!(keys[0].plain_ease_slots(), Some(vec![0.0; 8]));
assert_eq!(keys[0].ease_components(), Some(vec![NO_EASE; 2]));
let mask = &layer.masks()[0];
assert!(mask.feather.source == ValueSource::Keyframed && mask.feather.value == [0.0, 0.0]);
}
+19 -28
View File
@@ -2,7 +2,7 @@
//! colours, checked against the values the corpus was made with (its MANIFEST, batch 2).
use crate::container::helpers::{assert_near, base_layer, first_comp, layer_of_type, layer_shown_as, open};
use effectcraft_aep::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Project, Rational, ValueSource};
use effectcraft_aep::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Project};
fn l_header(name: &str) -> Option<LayerHeader> {
Some({
@@ -80,22 +80,24 @@ fn ntsc_frame_rates_are_exact_fractions() {
// 229: frameRate 29.97 -> read back 29.9700012207031; the duration snaps from 5 s to 150 frames (5.005005 s).
let h = skip!(comp_header("229-comp-fps-29.97.aep"));
assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (800, 23976));
assert_eq!(h.frame_rate_exact(), Some(Rational { num: 2997, den: 100 }));
assert!((h.frame_rate().unwrap() - 29.97).abs() < 1e-12);
assert!((h.frame_rate_from_fixed() - 29.970_001_220_703_1).abs() < 1e-7);
assert_eq!((h.duration_units, h.duration_frames()), (120_000, Some(150)));
// The 16.16 rate holds the float32 29.97 (29.9700012207031).
assert_eq!(h.frame_rate_fixed, 1_964_114);
// 150 frames of 800 units.
assert_eq!(h.duration_units, 120_000);
assert!((h.duration_seconds().unwrap() - 5.005_005_005_005_01).abs() < 1e-9);
// 230: frameRate 23.976 -> read back 23.9759979248047; 120 frames.
let h = skip!(comp_header("230-comp-fps-23.976.aep"));
assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1000, 23976));
assert_eq!(h.frame_rate_exact(), Some(Rational { num: 2997, den: 125 }));
assert!((h.frame_rate().unwrap() - 23.976).abs() < 1e-12);
assert!((h.frame_rate_from_fixed() - 23.975_997_924_804_7).abs() < 1e-7);
assert_eq!((h.duration_units, h.duration_frames()), (120_000, Some(120)));
// The 16.16 rate holds the float32 23.976 (23.9759979248047).
assert_eq!(h.frame_rate_fixed, 1_571_291);
// 120 frames of 1000 units.
assert_eq!(h.duration_units, 120_000);
assert!((h.duration_seconds().unwrap() - 5.005_005_005_005_01).abs() < 1e-9);
// 25 fps stays 25.
let h = skip!(comp_header("000-base.aep"));
assert_eq!((h.frame_rate_exact(), h.duration_frames()), (Some(Rational { num: 25, den: 1 }), Some(125)));
assert_eq!((h.time_base.timescale, h.time_base.frame_duration, h.duration_units), (25600, 1024, 128_000));
}
#[test]
@@ -128,7 +130,6 @@ fn background_is_8_bits_per_channel() {
// 231: bgColor [0.5, 0.5, 0.5] is stored as 128 and reads back as 128/255 = 0.50196.
let h = skip!(comp_header("231-comp-bg-grey-0.5.aep"));
assert_eq!(h.background, [128, 128, 128]);
assert_near(&h.background_rgb(), &[0.501_960_784_313_73; 3], 1e-12);
// The frame rate stays 25 even though an earlier edit of the AE session left other numbers in the cache-like fields.
assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 25600));
}
@@ -143,12 +144,10 @@ fn cameras_auto_orient_and_position() {
let camera = layer_of_type(&comp, LayerType::Camera);
let h = camera.header().unwrap();
assert_eq!((h.flag_bytes[1], h.is_auto_oriented(), h.is_three_d(), h.source_id, h.light_type()), (0x44, true, true, None, None));
// The default Position is an "auto state" record that stores [0, 0, -2666.67]; the two-node camera reports the comp centre.
// The default Position is an "auto state" record that stores [0, 0, -2666.67].
let pos = camera.camera_position().unwrap();
assert!(pos.auto_state);
assert_near(&pos.stored, &[0.0, 0.0, -2666.67], 0.01);
assert_near(&pos.reported(1920, 1080, true), &[960.0, 540.0, -2666.67], 0.01);
assert_eq!(camera.camera_point_of_interest(), Some([960.0, 540.0, 0.0]));
let bytes = fixture!("251-camera-one-node.aep");
let file = open(&bytes);
@@ -159,10 +158,9 @@ fn cameras_auto_orient_and_position() {
// AE reports [0, 0, -2666.67] for the one-node camera: the stored value.
let pos = camera.camera_position().unwrap();
assert!(pos.auto_state);
assert_near(&pos.reported(1920, 1080, h.is_auto_oriented()), &[0.0, 0.0, -2666.67], 0.01);
assert_eq!(camera.camera_point_of_interest(), Some([960.0, 540.0, 0.0]));
assert_near(&pos.stored, &[0.0, 0.0, -2666.67], 0.01);
// 253: Position set to [100, 200, -1000]: stored as given, no auto state; the point of interest stays [960, 540, 0].
// 253: Position set to [100, 200, -1000]: stored as given, no auto state.
let bytes = fixture!("253-camera-position-100-200-m1000.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
@@ -170,35 +168,28 @@ fn cameras_auto_orient_and_position() {
let pos = camera.camera_position().unwrap();
assert!(!pos.auto_state);
assert_eq!(pos.stored, [100.0, 200.0, -1000.0]);
assert_eq!(pos.reported(1920, 1080, true), [100.0, 200.0, -1000.0]);
assert_eq!(camera.camera_point_of_interest(), Some([960.0, 540.0, 0.0]));
// A camera has no light intensity.
assert_eq!(camera.light_intensity(), None);
}
/// Lights type and position.
#[test]
fn lights_type_and_intensity() {
// 254: a spot light, Intensity 100 -> 50 (stored as 50.0 percent, absent at the default 100).
fn lights_type_and_position() {
// 254: a spot light (its Intensity 50 is checked on import).
let bytes = fixture!("254-light-spot-intensity-50.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let light = layer_of_type(&first_comp(&project), LayerType::Light);
let h = light.header().unwrap();
assert_eq!((h.light_type(), h.light_type_code, h.source_id, h.is_auto_oriented(), h.is_three_d()), (Some(LightType::Spot), 1, None, true, true));
let intensity = light.light_intensity().unwrap();
assert_eq!((intensity.value, intensity.source), (50.0, ValueSource::Stored));
assert!(light.camera_position().unwrap().auto_state);
assert_near(&light.camera_position().unwrap().stored, &[0.0, 0.0, -1333.33], 0.01);
// 252: a point light (light type 2) at the default intensity, which is not stored.
// 252: a point light (light type 2).
let bytes = fixture!("252-light-point.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let light = layer_of_type(&first_comp(&project), LayerType::Light);
let h = light.header().unwrap();
assert_eq!((h.light_type(), h.light_type_code), (Some(LightType::Point), 2));
let intensity = light.light_intensity().unwrap();
assert_eq!((intensity.value, intensity.source), (100.0, ValueSource::Default));
// The solid under it is no light.
let solid = base_layer(&first_comp(&project)).header().unwrap();
assert_eq!((solid.light_type(), solid.light_type_code), (None, 0));
@@ -220,6 +211,6 @@ fn solid_colour_is_four_float32_argb() {
// L is still red.
let red = project.layer_source(&base_layer(&comp)).unwrap().footage().unwrap();
assert_eq!(red.solid_rgb(), Some([1.0, 0.0, 0.0]));
// The new layer has no user-set name flag (it shows its source's name).
assert!(!blue.header().unwrap().name_is_user_set());
// The new layer has no name of its own (it shows its source's name).
assert_eq!(blue.name(), None);
}
+1 -1
View File
@@ -13,7 +13,7 @@ fn layers(name: &str) -> Option<Vec<(String, bool, Option<TrackMatte>, u32)>> {
comp.layers()
.map(|l| {
let h = l.header().expect("header");
(project.layer_display_name(&l).unwrap_or_default(), h.is_video_enabled(), l.track_matte(), h.id)
(l.name().or_else(|| project.layer_source(&l)?.display_name()).unwrap_or_default(), h.is_video_enabled(), l.track_matte(), h.id)
})
.collect()
})
+25 -59
View File
@@ -154,16 +154,15 @@ use effectcraft_aep::{AepFile, Body, Chunk, Comp, Layer, Project, PropGroup, Pro
/// Touches the property accessors; none may panic on a damaged property.
fn exercise_property(p: &Property<'_, '_>) {
let _ = (p.header(), p.dims(), p.is_animated(), p.static_value(), p.time_scale(), p.flags(), p.is_static_flag_set());
let _ = (p.expression(), p.expression_flags().map(|f| (f.all_set(), f.disabled())), p.expression_enabled(), p.user_name(), p.first_key_value());
let _ = p.color_argb();
let _ = (p.header(), p.dims(), p.is_animated(), p.static_value(), p.time_scale(), p.flags());
let _ = (p.expression(), p.expression_flags().map(|f| f.disabled()), p.expression_enabled(), p.user_name(), p.first_key_value());
if let Some(keys) = p.keys() {
let scale = p.time_scale().unwrap_or(0);
for k in keys.keyframes() {
let _ = (k.time_units(), k.spatial_value(), k.raw().len(), k.kind(), k.seconds(scale), k.seconds(0));
let _ = (k.time_units(), k.raw().len(), k.kind(), k.seconds(scale), k.seconds(0));
let _ = (k.interpolation_in(), k.interpolation_out(), k.flags(), k.label(), k.is_roving());
let _ = (k.scalar_value(), k.ease(), k.spatial(), k.components(), k.value(), k.plain_ease_slots());
let _ = (k.ease_components(), k.color_distance());
let _ = (k.scalar_value(), k.ease(), k.spatial(), k.components(), k.value());
let _ = k.ease_components();
for component in [0, 1, 2, 3, 7, 255, usize::MAX] {
let _ = k.ease_component(component);
}
@@ -193,17 +192,14 @@ fn exercise_group(g: &PropGroup<'_, '_>, depth: usize) {
fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Layer<'_, '_>) {
let _ = (layer.name(), layer.is_three_d(), layer.id(), layer.track_matte());
if let Some(h) = layer.header() {
let _ = (h.in_point(), h.out_point(), h.in_point_seconds(), h.out_point_seconds(), h.start_seconds(), h.stretch_percent());
let _ = (h.stretch_ratio(), h.stretch_den);
let _ = (h.in_point(), h.out_point(), h.stretch_ratio(), h.stretch_den);
let _ = (h.is_shy(), h.is_locked(), h.is_solo(), h.is_three_d(), h.is_motion_blur(), h.is_adjustment(), h.is_guide());
let _ = (h.is_null_object(), h.collapses_transformations(), h.name_is_user_set(), h.quality, h.blend_mode, h.layer_type);
let _ = (h.is_null_object(), h.collapses_transformations(), h.quality, h.blend_mode, h.layer_type);
let _ = (h.is_enabled(), h.is_video_enabled(), h.is_audio_enabled(), h.is_effects_enabled(), h.is_auto_oriented(), h.light_type(), h.blend_mode.code());
}
let _ = (layer.is_enabled(), layer.is_audio_enabled(), layer.is_effects_enabled());
let _ = (layer.camera_position().map(|p| p.reported(comp_size.0, comp_size.1, true)), layer.camera_point_of_interest(), layer.light_intensity());
let _ = layer.camera_position().map(|p| (p.stored, p.auto_state));
let _ = project.layer_anchor_px(layer);
let _ = (layer.camera_zoom(), layer.camera_depth_of_field(), layer.camera_focus_distance(), layer.camera_aperture());
let _ = (layer.light_color(), layer.light_cone_angle(), layer.light_cone_feather());
let _ = layer
.time_remap()
.map(|t| (t.enabled, t.keys.iter().fold(0.0, |acc, k| acc + k.value_seconds + k.time_seconds.unwrap_or(0.0) + f64::from(k.time_units))));
@@ -230,7 +226,7 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye
}
}
}
let _ = (project.layer_source(layer).map(|i| i.id()), project.layer_display_name(layer), project.layer_source_size(layer));
let _ = (project.layer_source(layer).map(|i| i.id()), project.layer_source(layer).and_then(|i| i.display_name()), project.layer_source_size(layer));
if let Some(t) = layer.transform(comp_size.0, comp_size.1) {
let _ = (t.anchor_px(f64::from(comp_size.0), f64::from(comp_size.1)), t.position.value, t.opacity_percent.value);
}
@@ -263,7 +259,6 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye
}
for m in layer.masks() {
let _ = (m.name.clone(), m.mode, m.inverted, m.counter, m.feather, m.opacity_percent, m.expansion, m.path_animated, m.path_key_seconds.len());
let _ = project.mask_vertices_px(layer, &m).map(|shapes| shapes.iter().map(Vec::len).sum::<usize>());
for s in &m.shapes {
let _ = (s.vertex_count(), s.vertices((1920.0, 1080.0)).len(), s.vertices((f64::NAN, f64::INFINITY)).len(), s.open);
}
@@ -291,10 +286,9 @@ fn exercise_comp(project: &Project<'_, '_>, comp: &Comp<'_, '_>) {
let mut size = (1920, 1080);
if let Some(h) = comp.header() {
size = (u32::from(h.width), u32::from(h.height));
let _ = (h.frame_rate(), h.frame_rate_from_fixed(), h.duration_seconds(), h.work_area_start_seconds(), h.work_area_end_seconds());
let _ = (h.frame_rate_exact(), h.duration_frames());
let _ = (h.background_rgb(), h.pixel_aspect_ratio(), h.pixel_aspect.reduced(), h.shutter_angle, h.motion_blur);
let _ = (h.shutter_phase, h.display_start, h.display_start_seconds(), h.display_start_frame());
let _ = (h.frame_rate(), h.frame_rate_fixed, h.duration_seconds(), h.work_area_start_units, h.work_area_end_units);
let _ = (h.background, h.pixel_aspect.to_f64(), h.pixel_aspect.reduced(), h.shutter_angle, h.motion_blur);
let _ = (h.shutter_phase, h.display_start.map(|r| r.to_f64()));
}
for layer in comp.layers() {
exercise_layer(project, size, &layer);
@@ -305,10 +299,8 @@ fn exercise_comp(project: &Project<'_, '_>, comp: &Comp<'_, '_>) {
fn exercise(file: &AepFile<'_>) {
let Some(project) = Project::new(file) else { return };
for item in project.all_items() {
let _ = (item.kind(), item.name(), item.id(), item.label(), item.used_item_ids(), item.footage(), item.display_name());
let _ = item
.footage()
.map(|f| (f.duration, f.native_frame_rate, f.bits_per_pixel, f.file_size, f.audio_sample_rate, f.is_sequence, f.sequence_frame_count()));
let _ = (item.kind(), item.name(), item.id(), item.label(), item.footage(), item.display_name());
let _ = item.footage().map(|f| (f.duration, f.native_frame_rate, f.bits_per_pixel, f.file_size, f.audio_sample_rate, f.is_sequence));
let _ = item.id().and_then(|id| project.item_by_id(id));
if let Some(comp) = item.comp() {
exercise_comp(&project, &comp);
@@ -561,12 +553,12 @@ fn extreme_values_in_the_decoded_headers() {
use effectcraft_aep::{CompHeader, LayerHeader, Rational, ShapePath};
// Every field at its maximum: no overflow, no division by zero, no panic.
let h = LayerHeader::parse(&[0xff; 164]).unwrap();
let _ = (h.in_point(), h.out_point(), h.in_point_seconds(), h.out_point_seconds(), h.start_seconds(), h.stretch_percent());
let _ = (h.in_point(), h.out_point(), h.start.to_f64(), h.stretch_ratio());
let mut zero_den = [0u8; 164];
zero_den[8..12].copy_from_slice(&u32::MAX.to_be_bytes());
zero_den[12..16].copy_from_slice(&i32::MIN.to_be_bytes());
let h = LayerHeader::parse(&zero_den).unwrap();
assert_eq!((h.in_point(), h.out_point(), h.start_seconds()), (None, None, None));
assert_eq!((h.in_point(), h.out_point(), h.start.to_f64()), (None, None, None));
let mut wide = [0u8; 164];
wide[8..12].copy_from_slice(&u32::MAX.to_be_bytes());
wide[12..16].copy_from_slice(&i32::MIN.to_be_bytes());
@@ -574,14 +566,12 @@ fn extreme_values_in_the_decoded_headers() {
wide[20..24].copy_from_slice(&i32::MAX.to_be_bytes());
wide[24..28].copy_from_slice(&u32::MAX.to_be_bytes());
let h = LayerHeader::parse(&wide).unwrap();
let _ = (h.in_point(), h.in_point_seconds());
let _ = h.in_point();
let c = CompHeader::parse(&[0xff; 204]).unwrap();
assert_eq!((c.duration_seconds(), c.work_area_start_seconds(), c.work_area_end_units), (None, None, None));
let _ = (c.frame_rate(), c.frame_rate_from_fixed(), c.background_rgb(), c.pixel_aspect_ratio(), c.frame_rate_exact());
assert_eq!(c.duration_frames(), Some(1));
assert_eq!((c.duration_seconds(), c.work_area_end_units), (None, None));
let _ = (c.frame_rate(), c.pixel_aspect.to_f64());
let zero = CompHeader::parse(&[0; 204]).unwrap();
assert_eq!((zero.frame_rate(), zero.pixel_aspect_ratio(), zero.duration_seconds()), (None, None, None));
assert_eq!((zero.frame_rate_exact(), zero.duration_frames()), (None, None));
assert_eq!((zero.frame_rate(), zero.pixel_aspect.to_f64(), zero.duration_seconds()), (None, None, None));
let all = LayerHeader::parse(&[0xff; 164]).unwrap();
let _ = (all.is_enabled(), all.is_auto_oriented(), all.light_type(), all.blend_mode.code());
assert_eq!(LayerHeader::parse(&[0u8; 164]).unwrap().light_type(), None);
@@ -680,7 +670,7 @@ fn hostile_text_dictionaries_and_marker_headers_never_panic() {
#[test]
fn extreme_values_in_the_batch_3_fields() {
use effectcraft_aep::{CompHeader, Footage, LayerHeader, Rational};
use effectcraft_aep::{CompHeader, LayerHeader};
// Time stretch: zero and maximal numerators and denominators (bytes 8 and 108), with extreme times.
for (num, den) in [(0u32, 0u32), (1, 0), (0, 1), (u32::MAX, 1), (1, u32::MAX), (u32::MAX, u32::MAX), (3, 2)] {
let mut ldta = [0u8; 164];
@@ -693,7 +683,7 @@ fn extreme_values_in_the_batch_3_fields() {
ldta[28..32].copy_from_slice(&i32::MIN.to_be_bytes());
ldta[32..36].copy_from_slice(&1u32.to_be_bytes());
let h = LayerHeader::parse(&ldta).unwrap();
let _ = (h.stretch_ratio(), h.stretch_percent(), h.in_point(), h.out_point(), h.in_point_seconds(), h.out_point_seconds());
let _ = (h.stretch_ratio(), h.in_point(), h.out_point());
if den == 0 {
assert_eq!((h.stretch_ratio(), h.in_point(), h.out_point()), (None, None, None));
}
@@ -701,32 +691,8 @@ fn extreme_values_in_the_batch_3_fields() {
// Display start and shutter phase from extreme bytes; a missing tail leaves them unknown.
for fill in [0x00u8, 0x7f, 0x80, 0xff] {
let c = CompHeader::parse(&[fill; 204]).unwrap();
let _ = (c.shutter_phase, c.display_start, c.display_start_seconds(), c.display_start_frame());
let _ = (c.shutter_phase, c.display_start.map(|r| r.to_f64()));
}
let zero_rate = CompHeader::parse(&[0; 204]).unwrap();
assert_eq!((zero_rate.display_start_seconds(), zero_rate.display_start_frame()), (None, None));
// Footage durations: zero denominator, negative and maximal numerators, a huge frame rate.
let base = Footage {
file_type: String::new(),
width: 0,
height: 0,
name: None,
is_solid: false,
solid_argb: None,
path: None,
duration: None,
native_frame_rate: 0,
bits_per_pixel: 0,
file_size: 0,
audio_sample_rate: None,
is_sequence: true,
psd_layer: None,
};
for (num, den, rate) in [(3i64, 30i64, 30u32), (3, 0, 30), (-3, 30, 30), (i64::MAX, 1, u32::MAX), (i64::MIN, -1, u32::MAX), (1, i64::MAX, 1), (0, 1, 0)] {
let f = Footage { duration: Some(Rational { num, den }), native_frame_rate: rate, ..base.clone() };
let _ = f.sequence_frame_count();
}
assert_eq!(Footage { duration: Some(Rational { num: 3, den: 30 }), native_frame_rate: 30, ..base.clone() }.sequence_frame_count(), Some(3));
assert_eq!(Footage { duration: Some(Rational { num: 3, den: 0 }), native_frame_rate: 30, ..base.clone() }.sequence_frame_count(), None);
assert_eq!(Footage { duration: None, ..base }.sequence_frame_count(), None);
// A zero denominator has no value.
assert_eq!(CompHeader::parse(&[0; 204]).unwrap().display_start.and_then(|r| r.to_f64()), None);
}
@@ -13,7 +13,6 @@ fn text_layer_document(name: &str) -> Option<TextDocument> {
let layer = comp.layers().find(|l| l.name().as_deref() == Some("T")).expect("the text layer T");
let h = layer.header().unwrap();
assert_eq!(h.layer_type, LayerType::Text);
assert!(h.name_is_user_set());
layer.text_document().expect("text document")
})
}
@@ -192,7 +191,6 @@ fn png_footage_layer() {
// The path comes from the alias JSON (the fixture holds a neutral replacement of the original).
assert!(f.path.as_deref().is_some_and(|p| p.ends_with("test8x8.png")), "{:?}", f.path);
assert_eq!(project.layer_source_size(&layer), Some((8, 8)));
assert_eq!(project.layer_display_name(&layer).as_deref(), Some("png"));
// L is still there below it.
assert_eq!(comp.layers().count(), 2);
}
+217
View File
@@ -0,0 +1,217 @@
//! The flat tree index the importer reads ([`TreeIndex`]) against the owned chunk tree, on every
//! file of the fixture directory: the same tree, and the same answers from the typed view.
use std::fmt::Write as _;
use effectcraft_aep::container::index::TreeIndex;
use effectcraft_aep::{
AepFile, Chunk, CryptomatteData, CurvesData, Effect, HueSaturationData, LevelsData, Limits, MeshData, Project, PropGroup, PropNode, is_aepx, parse,
parse_with_limits, read_aepx, write,
};
/// Every `.aep` and `.aepx` in the fixture directory (the real projects included), sorted.
fn every_file() -> Vec<std::path::PathBuf> {
let Some(dir) = crate::common::dir() else {
crate::common::skipped("*.aep");
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&dir) else {
crate::common::skipped("*.aep");
return Vec::new();
};
let mut out: Vec<std::path::PathBuf> =
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aep" || x == "aepx")).collect();
out.sort();
out
}
/// What the typed view reads from a project, as text: items, compositions, layers with their
/// headers, transforms, masks, effects with every parameter value, text, markers, the whole
/// property tree with static values and key records, settings and versions.
fn dump(view: &Project<'_, '_>) -> String {
let mut s = String::new();
let defs = view.effect_definition_index();
for item in view.all_items() {
let _ = writeln!(s, "item {:?} {:?} {:?} {:?} {:?}", item.kind(), item.name(), item.display_name(), item.id(), item.label());
let _ = writeln!(s, " footage {:?} {:?} {:?}", item.footage(), item.sequence_info(), item.conform_frame_rate());
let _ = writeln!(s, " folder {} {}", item.has_folder_contents(), item.folder_contents_unreadable());
let Some(comp) = item.comp() else { continue };
let _ = writeln!(s, " comp {:?} {:?} {:?}", comp.time_base(), comp.header(), comp.markers());
for layer in comp.layers() {
let _ = writeln!(s, " layer {:?} {:?} {:?} {:?}", layer.name(), layer.id(), layer.header(), layer.track_matte());
let _ = writeln!(s, " transform {:?}", layer.transform(1920, 1080));
let _ = writeln!(s, " time remap {:?} orientation {:?} {:?}", layer.time_remap(), layer.orientation(), layer.orientation_keys());
let _ = writeln!(s, " masks {:?}", layer.mask_atoms().iter().map(|m| (&m.mask, &m.path_shapes)).collect::<Vec<_>>());
let _ = writeln!(s, " shapes {:?}", layer.shape_groups());
let _ =
writeln!(s, " text {:?} {} {:?}", layer.text_document(), layer.text_document_count(), layer.source_text_property().map(|p| p.is_animated()));
let _ = writeln!(s, " markers {:?} source {:?}", layer.markers(), view.layer_source(&layer).map(|i| i.id()));
for fx in layer.effects() {
let _ =
writeln!(s, " fx {} {:?} {:?} {:?} {:?}", fx.match_name(), fx.display_name(), fx.enabled(), defs.source(&fx), fx.value_record_names());
let _ = writeln!(s, " names {:?} {:?} {:?} {:?}", fx.name(), fx.user_name(), fx.declared_param_count(), fx.has_own_definitions());
for p in defs.params(&fx) {
let _ = writeln!(s, " {} {:?} {:?} {:?} {:?}", p.match_name, p.kind, p.name, p.items, p.value());
arbitrary(&fx, &p.match_name, &mut s);
}
for name in fx.value_record_names() {
arbitrary(&fx, &name, &mut s);
}
}
if let Some(root) = layer.properties() {
tree(&root, 0, &mut s);
}
}
}
s
}
/// The arbitrary data stored for a parameter and what each decoder reads from it.
fn arbitrary(fx: &Effect<'_, '_>, name: &str, s: &mut String) {
let Some(data) = fx.arbitrary_data(name) else { return };
let _ = writeln!(s, " arbitrary {name} {data:?}");
let _ = writeln!(s, " {:?} {:?}", HueSaturationData::parse(data), LevelsData::parse(data));
let _ = writeln!(s, " {:?} {:?} {:?}", CurvesData::parse(data), MeshData::parse(data), CryptomatteData::parse(data));
}
/// Appends a group and everything below it to the dump, depth-limited.
fn tree(group: &PropGroup<'_, '_>, depth: usize, s: &mut String) {
if depth > 32 {
return;
}
let _ = writeln!(s, "{:depth$}group {:?} {:?}", "", group.user_name(), group.flags());
for e in group.entries() {
match e.node {
PropNode::Group(g) => tree(&g, depth + 1, s),
PropNode::Property(p) => {
let keys: Option<Vec<Vec<u8>>> = p.keys().map(|k| k.records().map(<[u8]>::to_vec).collect());
let _ = writeln!(s, "{:depth$} {} {:?} {:?} {:?} {:?}", "", e.match_name, p.user_name(), p.header(), p.static_value(), keys);
}
PropNode::Other(c) => {
let _ = writeln!(s, "{:depth$} {} other {:?} {}", "", e.match_name, c.list_type(), c.descendants().count());
}
}
}
}
/// The index is the parsed tree and the view reads the same on every file.
#[test]
fn the_index_is_the_parsed_tree_and_the_view_reads_the_same_on_every_file() {
for path in every_file() {
let name = path.display().to_string();
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
// `.aepx` is read into the chunk tree and indexed from it; `.aep` is indexed in place.
// A file that does not read (a damaged sample) must fail the index the same way.
let file: AepFile<'_> = match if is_aepx(&bytes) { read_aepx(&bytes) } else { parse(&bytes) } {
Ok(file) => file,
Err(e) => {
if !is_aepx(&bytes) {
assert_eq!(TreeIndex::parse(&bytes).err(), Some(e), "{name}");
}
continue;
}
};
let index = (if is_aepx(&bytes) { TreeIndex::from_file(&file) } else { TreeIndex::parse(&bytes) }).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(index.to_file() == file, "{name}: the index is not the parsed tree");
assert_eq!(index.len(), file.walk().count(), "{name}");
assert_eq!(index.file_version(), file.file_version(), "{name}");
assert_eq!(index.saved_by_version(), file.saved_by_version(), "{name}");
assert_eq!(effectcraft_aep::ProjectSettings::read_index(&index), effectcraft_aep::ProjectSettings::read(&file), "{name}");
let (Some(a), Some(b)) = (Project::new(&file), Project::from_index(&index)) else {
assert!(Project::new(&file).is_none() && Project::from_index(&index).is_none(), "{name}");
continue;
};
let (da, db) = (dump(&a), dump(&b));
assert!(da == db, "{name}: the view reads differently through the index");
}
}
/// The index and the parser fail alike under tight limits.
#[test]
fn the_index_and_the_parser_fail_alike_under_tight_limits() {
for path in every_file().into_iter().filter(|p| p.extension().is_some_and(|x| x == "aep")) {
let name = path.display().to_string();
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
let total = parse(&bytes).map(|f| f.walk().count()).unwrap_or(0);
for limits in [
Limits { max_depth: 2, max_chunks: 10_000_000 },
Limits { max_depth: 6, max_chunks: 10_000_000 },
Limits { max_depth: 64, max_chunks: total },
Limits { max_depth: 64, max_chunks: total.saturating_sub(1) },
Limits { max_depth: 64, max_chunks: total / 2 },
] {
let old = parse_with_limits(&bytes, limits);
let new = TreeIndex::parse_with_limits(&bytes, limits);
match (old, new) {
(Ok(f), Ok(i)) => assert!(i.to_file() == f, "{name}: {limits:?}"),
(Err(a), Err(b)) => assert_eq!(a, b, "{name}: {limits:?}"),
(a, b) => panic!("{name}: {limits:?}: {:?} vs {:?}", a.err(), b.err()),
}
}
}
}
/// A 40-byte NUL-padded match name.
fn tdmn(name: &str) -> Chunk<'static> {
let mut b = name.as_bytes().to_vec();
b.resize(40, 0);
Chunk::data(*b"tdmn", b)
}
/// A nested chunk `id` holding one `Utf8` with `text`.
fn utf8(id: [u8; 4], text: &str) -> Chunk<'static> {
Chunk { id, body: effectcraft_aep::Body::Chunks(vec![Chunk::data(*b"Utf8", text.as_bytes().to_vec())]), pad: None }
}
/// A project with one composition, one layer and one effect whose five parameters carry arbitrary
/// data in the sizes the decoders read (Hue/Saturation, Levels, Curves, a 2 x 2 mesh, Cryptomatte),
/// so the arbitrary-data path runs without the fixture directory.
fn synthetic() -> Vec<u8> {
let mut mesh = vec![0u8; 180];
mesh[..8].copy_from_slice(&[0, 0, 0, 2, 0, 0, 0, 2]);
let mut cry = vec![0u8; 200];
cry[..4].copy_from_slice(b"cry1");
cry[32..37].copy_from_slice(b"Layer");
let mut levels = vec![0u8; 108];
levels[8..16].copy_from_slice(&1.0f64.to_be_bytes());
let payloads: [Vec<u8>; 5] = [vec![7u8; 180], levels, vec![0u8; CurvesData::SIZE], mesh, cry];
let (mut part, mut values) =
(vec![Chunk::data(*b"parn", 6u32.to_be_bytes().to_vec())], vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), utf8(*b"tdsn", "Mine")]);
for (i, data) in payloads.into_iter().enumerate() {
let name = format!("FX-{:04}", i + 1);
let mut pard = vec![0u8; 148];
pard[12..16].copy_from_slice(&11u32.to_be_bytes());
part.extend([tdmn(&name), Chunk::data(*b"pard", pard)]);
let record = Chunk::list(*b"tdbs", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), Chunk::data(*b"cdat", vec![0, 0, 0, 1])]);
values.extend([tdmn(&name), record, Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", data)])]);
}
values.push(tdmn("ADBE Group End"));
let sspc = Chunk::list(*b"sspc", vec![utf8(*b"fnam", "Effect"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]);
let parade = Chunk::list(*b"tdgp", vec![tdmn("FX"), sspc, tdmn("ADBE Group End")]);
let root = Chunk::list(*b"tdgp", vec![tdmn("ADBE Effect Parade"), parade, tdmn("ADBE Group End")]);
let layer = Chunk::list(*b"Layr", vec![Chunk::data(*b"ldta", vec![0u8; 160]), Chunk::data(*b"Utf8", b"Layer".to_vec()), root]);
let mut idta = vec![0u8; 64];
idta[..2].copy_from_slice(&4u16.to_be_bytes());
idta[16..20].copy_from_slice(&2u32.to_be_bytes());
let mut cdta = vec![0u8; 200];
cdta[4..12].copy_from_slice(&[0, 0, 0, 1, 0, 0, 0, 30]);
let item = Chunk::list(*b"Item", vec![Chunk::data(*b"idta", idta), Chunk::data(*b"Utf8", b"Comp".to_vec()), Chunk::data(*b"cdta", cdta), layer]);
let file = AepFile { chunks: vec![Chunk::data(*b"head", vec![0u8; 20]), Chunk::list(*b"Fold", vec![item])], trailer: std::borrow::Cow::Borrowed(&[]) };
write(&file).expect("write")
}
/// The view reads arbitrary effect data the same through the index.
#[test]
fn the_view_reads_arbitrary_effect_data_the_same_through_the_index() {
let bytes = synthetic();
let file = parse(&bytes).expect("parse");
let index = TreeIndex::parse(&bytes).expect("index");
let (a, b) = (Project::new(&file).expect("project"), Project::from_index(&index).expect("project"));
let (da, db) = (dump(&a), dump(&b));
assert_eq!(da, db);
// The synthetic effect really reaches every decoder (the dump is not empty of them).
assert_eq!(da.matches("arbitrary FX-").count(), 10, "{da}");
assert!(da.contains("Some(HueSaturationData") && da.contains("Some(LevelsData") && da.contains("Some(CurvesData"), "{da}");
assert!(da.contains("Some(MeshData") && da.contains("Some(CryptomatteData"), "{da}");
assert!(da.contains("Some(\"Mine\")"), "the user name is read: {da}");
}
+4 -4
View File
@@ -111,7 +111,7 @@ fn match_names_come_from_the_tdmn_chunks() {
assert!(!names.iter().any(|n| n == "ADBE Group End"));
assert!(root.find(&["ADBE Transform Group", "ADBE Nonexistent"]).is_none());
// The raw tdmn body is 40 bytes, NUL padded.
let raw = root.chunk().children().iter().find(|c| c.is(b"tdmn")).unwrap();
let raw = root.chunk().children().find(|c| c.is(b"tdmn")).unwrap();
assert_eq!(raw.bytes().unwrap().len(), 40);
}
@@ -150,8 +150,8 @@ fn effect_parameter_value() {
assert!(instance.is_list(b"sspc"));
// The effect's name is in a nested fnam chunk.
let fnam = instance.child(b"fnam").unwrap();
assert!(matches!(fnam.body, Body::Chunks(_)));
assert_eq!(fnam.children()[0].bytes(), Some(&b"Slider Control"[..]));
assert!(matches!(fnam.to_chunk().body, Body::Chunks(_)));
assert_eq!(fnam.children().next().unwrap().bytes(), Some(&b"Slider Control"[..]));
// The parameters are a property group inside the instance.
let params = PropGroup::from_chunk(instance.child_list(b"tdgp").unwrap()).unwrap();
assert_eq!(params.names(), ["ADBE Slider Control-0000", "ADBE Slider Control-0001", "ADBE Effect Built In Params"]);
@@ -186,7 +186,7 @@ fn position_keyframes() {
assert_eq!(times, [0, 25_600, 64_000]);
let seconds: Vec<f64> = times.iter().map(|t| tb.seconds(*t).unwrap()).collect();
assert_eq!(seconds, [0.0, 1.0, 2.5]);
let values: Vec<[f64; 3]> = keys.keyframes().map(|k| k.spatial_value().unwrap()).collect();
let values: Vec<Vec<f64>> = keys.keyframes().map(|k| k.value().unwrap()).collect();
assert_eq!(values, [[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [200.0, 100.0, 0.0]]);
}
+3 -6
View File
@@ -119,7 +119,7 @@ fn linear_position_keys() {
let values: Vec<_> = keys.iter().map(|k| k.spatial().unwrap().value).collect();
assert_eq!(values, [[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [200.0, 100.0, 0.0]]);
assert_eq!(p.first_key_value(), Some(vec![0.0, 0.0, 0.0]));
assert_eq!(p.is_static_flag_set(), Some(false));
assert!(p.is_animated());
}));
// The transform of the layer reports the animated Position as keyframed (value at the first key).
let (t, _) = skip!(transform("055-key-position-3key-inserted-t2.5.aep"));
@@ -181,7 +181,7 @@ fn roving_and_label_flags() {
assert_eq!(roving, [false, true, false]);
assert_eq!(keys[1].time_units(), Some(26509));
assert_eq!(keys[1].seconds(25600), Some(1.0355078125));
assert_eq!(keys[1].spatial_value(), Some([100.0, 0.0, 0.0]));
assert_eq!(keys[1].value(), Some(vec![100.0, 0.0, 0.0]));
assert_eq!(keys[1].flags(), Some(0x0027));
assert_eq!(keys[1].label(), Some(0));
}));
@@ -193,7 +193,7 @@ fn roving_and_label_flags() {
assert_eq!(keys[1].flags(), Some(0x0247));
assert_eq!(keys[0].flags(), Some(0x0007));
assert_eq!(keys[1].is_roving(), Some(false));
assert_eq!(keys[1].spatial_value(), Some([100.0, 0.0, 0.0]));
assert_eq!(keys[1].value(), Some(vec![100.0, 0.0, 0.0]));
}));
}
@@ -204,7 +204,6 @@ fn scalar_opacity_keys() {
assert!(p.is_animated());
assert_eq!(p.dims(), Some(1));
assert_eq!(p.time_scale(), Some(25600));
assert_eq!(p.is_static_flag_set(), Some(false));
let list = p.keys().unwrap();
assert_eq!(list.header.record_size, 48);
let keys: Vec<_> = list.keyframes().collect();
@@ -243,10 +242,8 @@ fn expression_text_and_flag_bytes() {
assert_eq!(p.expression(), None);
let flags = p.expression_flags().unwrap();
assert_eq!((flags.byte_76, flags.byte_77, flags.byte_120), (0, 0, 0));
assert!(!flags.all_set());
// A written static property: tdsb 1, byte 5 of tdb4 has bit 0 set (15 for Position).
assert_eq!(p.flags(), Some(1));
assert_eq!(p.is_static_flag_set(), Some(true));
assert_eq!(p.header().and_then(|h| h.get(5).copied()), Some(15));
}));
}
+19 -2
View File
@@ -83,6 +83,24 @@ fn ntsc_and_odd_frame_rates_use_the_time_bases_after_effects_writes() {
}
}
/// Every ntsc rate reads back as the rate it was written for.
#[test]
fn every_ntsc_rate_reads_back_as_the_rate_it_was_written_for() {
// The export's time base for each NTSC rate and the import's rate for that time base use the
// one NTSC table; the real paths must agree both ways.
for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] {
let mut p = Project::default();
add_comp(&mut p, "R", 640, 360, rate, 2.0, None);
let r = export_all(&p);
let exists = |_: &str| true;
let options = effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&exists) };
let mut back = Project::default();
let imported = effectcraft_aep::import::import(&mut back, &r.bytes, &options).expect("import");
let comp = imported.comps.first().and_then(|c| back.comp(*c)).expect("comp");
assert_eq!(comp.frame_rate, rate, "{}", rate.label());
}
}
/// #3099: After Effects reads a null's absent Anchor Point as its top-left corner `[0, 0]`. A
/// null anchored at its centre is written (it was left out as "the default" and came back at the
/// corner); one at the corner is not, and both import back as they were.
@@ -178,8 +196,7 @@ fn layer_header_fields() {
assert!(h[2].is_motion_blur() && h[2].is_guide());
assert_eq!(h[2].blend_mode, AepBlend::Screen);
assert_eq!((h[2].start.num, h[2].start.den), (46_080, 30_720));
assert_eq!(h[2].in_point_seconds(), Some(2.0));
assert_eq!(h[2].out_point_seconds(), Some(6.0));
assert_eq!((h[2].in_point(), h[2].out_point()), (effectcraft_aep::Rational::new(2, 1), effectcraft_aep::Rational::new(6, 1)));
assert_eq!(h[2].name_copy, "Mid");
// Ids are unique.
let mut ids: Vec<u32> = h.iter().map(|x| x.id).collect();
+1 -1
View File
@@ -426,7 +426,7 @@ fn static_records(file: &AepFile<'_>) -> std::collections::BTreeMap<(String, usi
for (li, layer) in comp.layers().enumerate() {
for (ei, e) in layer.effects().into_iter().enumerate() {
let Some(spec) = effectcraft_aep::effect_map::by_match_name(e.match_name()) else { continue };
let Some(values) = e.chunk().child_list(b"tdgp") else { continue };
let Some(values) = e.chunk().child_list(b"tdgp").map(|v| v.to_chunk()) else { continue };
let k = values.children();
for (i, c) in k.iter().enumerate() {
let Some(name) =
+3 -2
View File
@@ -201,8 +201,9 @@ fn every_written_field_parses_back() {
for m in [&masks[0], &masks[2], &masks[4]] {
assert_eq!((m.feather.source, m.opacity_percent.source, m.expansion.source), (ValueSource::Default, ValueSource::Default, ValueSource::Default));
}
// Paths: vertices in layer pixels, open bit.
let px = view.mask_vertices_px(&layer, &masks[2]).expect("vertices");
// Paths: vertices in layer pixels (the box scaled by the source size, as the importer does), open bit.
let (w, h) = view.layer_source_size(&layer).expect("size");
let px: Vec<_> = masks[2].shapes.iter().map(|s| s.vertices((f64::from(w), f64::from(h)))).collect();
let open = open_path();
assert!(masks[2].shapes[0].open && !masks[0].shapes[0].open);
for (v, (want, (ti, to))) in px[0].iter().zip(open.vertices.iter().zip(open.in_tangents.iter().zip(&open.out_tangents))) {
+42 -16
View File
@@ -190,6 +190,20 @@ fn with_props(p: &mut Project, c: effectcraft_project::ItemId, idx: usize, set:
});
}
/// The layers of the first composition as the importer reads `bytes` back.
fn reimported_layers(bytes: &[u8]) -> Vec<effectcraft_project::Layer> {
let exists = |_: &str| true;
let options = effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&exists) };
let mut p = Project::default();
let r = effectcraft_aep::import::import(&mut p, bytes, &options).expect("import");
p.comp(r.comps[0]).expect("comp").layers.iter().cloned().collect()
}
/// The value at `path` of a read-back layer.
fn value(l: &effectcraft_project::Layer, path: &str) -> effectcraft_keyframe::Value {
l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).value.clone()
}
fn first_comp_layers(bytes: &[u8], mut f: impl FnMut(&effectcraft_aep::Layer<'_, '_>)) {
let file = parse(bytes).expect("parse");
assert_eq!(effectcraft_aep::write(&file).expect("write"), bytes);
@@ -229,12 +243,18 @@ fn cameras_are_written_with_position_point_of_interest_and_options() {
assert!(h.is_three_d() && h.is_auto_oriented(), "two-node camera");
let pos = l.camera_position().expect("position");
assert_eq!((pos.stored, pos.auto_state), ([100.0, 200.0, -1000.0], false));
assert_eq!(l.camera_point_of_interest(), Some([960.0, 540.0, 0.0]));
assert_eq!(l.camera_depth_of_field().map(|t| t.value), Some(true));
assert_eq!(l.camera_focus_distance().map(|t| t.value), Some(800.0));
assert_eq!(l.camera_aperture().map(|t| t.value), Some(30.0));
assert_eq!(l.camera_zoom().map(|t| t.value), Some(1500.0));
});
// The importer reads the written options back.
let back = reimported_layers(&r.bytes);
for (path, want) in [
("transform/poi", Value::Vec3([960.0, 540.0, 0.0])),
("cameraOptions/dof", Value::Bool(true)),
("cameraOptions/focusDistance", Value::Scalar(800.0)),
("cameraOptions/aperture", Value::Scalar(30.0)),
("cameraOptions/zoom", Value::Scalar(1500.0)),
] {
assert_eq!(value(&back[0], path), want, "{path}");
}
// One-node (auto-orient off): bit 0x40 clear (251).
with_layer(&mut p, c, 0, |l| l.auto_orient = effectcraft_project::AutoOrient::Off);
first_comp_layers(&export_all(&p).bytes, |l| assert!(!l.header().expect("h").is_auto_oriented()));
@@ -271,20 +291,25 @@ fn spot_and_point_lights_are_written_with_their_options() {
seen.push(h.light_type());
match h.light_type() {
Some(effectcraft_aep::LightType::Spot) => {
assert_eq!(l.light_intensity().map(|t| t.value), Some(50.0));
assert_eq!(l.light_color().map(|t| t.value), Some([1.0, 1.0, 0.0, 0.0]));
assert_eq!(l.light_cone_angle().map(|t| t.value), Some(60.0));
assert_eq!(l.light_cone_feather().map(|t| t.value), Some(30.0));
assert_eq!(l.camera_position().map(|p| p.stored), Some([100.0, 50.0, -500.0]));
assert_eq!(l.camera_point_of_interest(), Some([960.0, 540.0, 0.0]));
}
Some(effectcraft_aep::LightType::Point) => {
assert_eq!(l.light_intensity().map(|t| t.value), Some(100.0));
}
Some(effectcraft_aep::LightType::Point) => {}
other => panic!("{other:?}"),
}
});
assert_eq!(seen.len(), 2);
// The importer reads the written options back (layer 0 is the point light, 1 the spot).
let back = reimported_layers(&r.bytes);
assert_eq!(value(&back[0], "lightOptions/intensity"), Value::Scalar(100.0));
for (path, want) in [
("lightOptions/intensity", Value::Scalar(50.0)),
("lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])),
("lightOptions/coneAngle", Value::Scalar(60.0)),
("lightOptions/coneFeather", Value::Scalar(30.0)),
("transform/poi", Value::Vec3([960.0, 540.0, 0.0])),
] {
assert_eq!(value(&back[1], path), want, "{path}");
}
// Parallel and ambient lights, and unsampled options, are reported.
let mut q = Project::default();
let c = add_comp(&mut q, "C", 640, 360, FrameRate::new(25, 1), 1.0, None);
@@ -468,7 +493,7 @@ fn shape_records(bytes: &[u8]) -> std::collections::BTreeMap<(usize, String), ef
for item in view.all_items() {
let Some(comp) = item.comp() else { continue };
for (li, layer) in comp.layers().enumerate() {
if let Some(g) = layer.chunk().child_list(b"tdgp") {
if let Some(g) = layer.chunk().child_list(b"tdgp").map(|g| g.to_chunk()) {
walk(g.children(), "", li, false, &mut out);
}
}
@@ -593,7 +618,7 @@ fn keyed_shape_records(bytes: &[u8]) -> Vec<(String, Vec<u8>, Vec<u8>, Vec<u8>,
for item in view.all_items() {
let Some(comp) = item.comp() else { continue };
for layer in comp.layers() {
if let Some(g) = layer.chunk().child_list(b"tdgp") {
if let Some(g) = layer.chunk().child_list(b"tdgp").map(|g| g.to_chunk()) {
walk(g.children(), "", false, &mut out);
}
}
@@ -828,7 +853,8 @@ fn a_camera_transform_group_ends_with_its_group_end() {
let view = effectcraft_aep::Project::new(&file).expect("project");
let comp = view.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
let layer = comp.layers().next().expect("camera");
let root = layer.chunk().child_list(b"tdgp").expect("tree").children();
let tree = layer.chunk().child_list(b"tdgp").expect("tree").to_chunk();
let root = tree.children();
let name =
|c: &effectcraft_aep::Chunk<'_>| String::from_utf8_lossy(c.bytes().unwrap_or_default().split(|b| *b == 0).next().unwrap_or_default()).into_owned();
let at = root.iter().position(|c| c.is(b"tdmn") && name(c) == "ADBE Transform Group").expect("transform");
+3 -3
View File
@@ -47,10 +47,10 @@ fn a_precomposition_layer_points_at_its_composition() {
let (c, pre) = (by_name("C"), by_name("Pre"));
let pre_id = pre.id().expect("id");
// C records the composition it uses (idpi, little-endian), idpc byte 0 is 1.
assert_eq!(c.used_item_ids(), vec![pre_id]);
assert_eq!(crate::common::used_item_ids(&c), vec![pre_id]);
let idpc = c.chunk().child(b"idpc").and_then(|x| x.bytes()).expect("idpc");
assert_eq!(idpc.first(), Some(&1));
assert_eq!(pre.used_item_ids(), Vec::<u32>::new());
assert_eq!(crate::common::used_item_ids(&pre), Vec::<u32>::new());
assert_eq!(pre.chunk().child(b"idpc").and_then(|x| x.bytes()).and_then(|b| b.first().copied()), Some(0));
let layer = c.comp().expect("comp").layers().next().expect("layer");
let h = layer.header().expect("header");
@@ -107,7 +107,7 @@ fn one_composition_used_twice_is_recorded_once_and_chains_nest() {
let view = effectcraft_aep::Project::new(&file).expect("project");
let items = view.all_items();
let id = |n: &str| items.iter().find(|i| i.display_name().as_deref() == Some(n)).and_then(|i| i.id()).expect(n);
let used = |n: &str| items.iter().find(|i| i.display_name().as_deref() == Some(n)).expect(n).used_item_ids();
let used = |n: &str| items.iter().find(|i| i.display_name().as_deref() == Some(n)).map(crate::common::used_item_ids).expect(n);
assert_eq!(used("B"), vec![id("A")]);
// Ascending ids (601; the order carries no meaning), and idpc counts them.
let mut ab = vec![id("A"), id("B")];
+1 -1
View File
@@ -53,7 +53,7 @@ fn stored_group(bytes: &[u8]) -> Vec<u8> {
{
// As far as the exporter writes records (`decoded`).
let mut f2 = effectcraft_aep::AepFile {
chunks: vec![crate::export::helpers::decoded(&g.chunk().clone().into_owned())],
chunks: vec![crate::export::helpers::decoded(&g.chunk().to_chunk().into_owned())],
trailer: std::borrow::Cow::Owned(vec![]),
};
f2.trailer = std::borrow::Cow::Owned(vec![]);
+28 -8
View File
@@ -46,18 +46,24 @@ fn cameras() {
assert_eq!(c.auto_orient, AutoOrient::TowardsPointOfInterest);
close(&v(&c, "transform/poi"), &[960.0, 540.0, 0.0]);
close(&v(&c, "transform/position"), &[0.0, 0.0, -2666.6667]);
close(&v(&c, "cameraOptions/zoom"), &[2666.6667]);
close(&v(&c, "cameraOptions/zoom"), &[effectcraft_aep::DEFAULT_CAMERA_ZOOM]);
close(&v(&c, "cameraOptions/focusDistance"), &[2666.6667]);
close(&v(&c, "cameraOptions/aperture"), &[25.3093]);
assert_eq!(v(&c, "cameraOptions/dof"), Value::Bool(false));
// 251: one-node camera.
assert_eq!(skip!(layer("251-camera-one-node.aep", "Camera 1")).auto_orient, AutoOrient::Off);
// 253: position set by the user.
close(&v(&skip!(layer("253-camera-position-100-200-m1000.aep", "Camera 1")), "transform/position"), &[100.0, 200.0, -1000.0]);
// 251: one-node camera; its point of interest is stored at the centre too.
let c = skip!(layer("251-camera-one-node.aep", "Camera 1"));
assert_eq!(c.auto_orient, AutoOrient::Off);
close(&v(&c, "transform/poi"), &[960.0, 540.0, 0.0]);
// 253: position set by the user; the point of interest stays at the centre.
let c = skip!(layer("253-camera-position-100-200-m1000.aep", "Camera 1"));
close(&v(&c, "transform/position"), &[100.0, 200.0, -1000.0]);
close(&v(&c, "transform/poi"), &[960.0, 540.0, 0.0]);
// 341, 342: zoom, depth of field.
close(&v(&skip!(layer("341-camera-zoom-2000.aep", "Camera 1")), "cameraOptions/zoom"), &[2000.0]);
let c = skip!(layer("342-camera-dof-800-30.aep", "Camera 1"));
assert_eq!(v(&c, "cameraOptions/dof"), Value::Bool(true));
// The zoom it does not store stays the default.
close(&v(&c, "cameraOptions/zoom"), &[2666.6667]);
close(&v(&c, "cameraOptions/focusDistance"), &[800.0]);
close(&v(&c, "cameraOptions/aperture"), &[30.0]);
// 535-537: a camera above the 3D solid of 486.
@@ -75,13 +81,27 @@ fn spot_and_point_lights() {
close(&v(&l, "transform/poi"), &[960.0, 540.0, 0.0]);
close(&v(&l, "transform/position"), &[0.0, 0.0, -1333.3333]);
assert_eq!(l.auto_orient, AutoOrient::TowardsPointOfInterest);
close(&v(&skip!(layer("343-light-spot-color-red.aep", "Light 1")), "lightOptions/color"), &[1.0, 0.0, 0.0, 1.0]);
close(&v(&skip!(layer("344-light-spot-coneangle-60.aep", "Light 1")), "lightOptions/coneAngle"), &[60.0]);
close(&v(&skip!(layer("345-light-spot-conefeather-30.aep", "Light 1")), "lightOptions/coneFeather"), &[30.0]);
// The options it does not store keep After Effects' defaults: white, 90, 50.
close(&v(&l, "lightOptions/color"), &[1.0, 1.0, 1.0, 1.0]);
close(&v(&l, "lightOptions/coneAngle"), &[90.0]);
close(&v(&l, "lightOptions/coneFeather"), &[50.0]);
let l = skip!(layer("343-light-spot-color-red.aep", "Light 1"));
close(&v(&l, "lightOptions/color"), &[1.0, 0.0, 0.0, 1.0]);
close(&v(&l, "lightOptions/coneAngle"), &[90.0]);
close(&v(&l, "lightOptions/coneFeather"), &[50.0]);
let l = skip!(layer("344-light-spot-coneangle-60.aep", "Light 1"));
close(&v(&l, "lightOptions/coneAngle"), &[60.0]);
close(&v(&l, "lightOptions/coneFeather"), &[50.0]);
close(&v(&l, "lightOptions/color"), &[1.0, 1.0, 1.0, 1.0]);
let l = skip!(layer("345-light-spot-conefeather-30.aep", "Light 1"));
close(&v(&l, "lightOptions/coneFeather"), &[30.0]);
close(&v(&l, "lightOptions/coneAngle"), &[90.0]);
// 252: a point light stores its cone as default-state records, which hold nothing.
let l = skip!(layer("252-light-point.aep", "Light 1"));
assert!(matches!(l.source, LayerSource::Light { kind: LightKind::Point }));
close(&v(&l, "lightOptions/intensity"), &[100.0]);
// Its stored Cone Angle 90 (a default-state record) has no counterpart: a point light has no cone.
assert!(l.props.prop("lightOptions/coneAngle").is_none());
assert_eq!(l.auto_orient, AutoOrient::Off);
}
+1 -1
View File
@@ -67,7 +67,7 @@ fn graft<'a>(file: &mut AepFile<'a>, pair: &[Chunk<'a>]) {
/// target's project copy does not hold the donor's types.
fn own_definitions<'a>(pair: &mut [Chunk<'a>], donor: &AepFile<'_>) {
let defs: Vec<(String, Chunk<'static>)> = effectcraft_aep::Project::new(donor)
.map(|p| p.effect_definitions().into_iter().map(|(m, part)| (m, part.clone().into_owned())).collect())
.map(|p| p.effect_definitions().into_iter().map(|(m, part)| (m, part.to_chunk().into_owned())).collect())
.unwrap_or_default();
let Some(effectcraft_aep::Body::List { children, .. }) = pair.get_mut(1).map(|g| &mut g.body) else { return };
let mut name: Option<String> = None;
+1 -2
View File
@@ -169,13 +169,12 @@ fn copies_saved_as_25x_and_24x_import_like_the_2026_file() {
#[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)] {
for (suffix, version) in [("26", "26.2.1x2"), ("25x", "25.6.0x97"), ("24x", "24.6.0x65")] {
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}");
}
}
}
+20 -16
View File
@@ -119,6 +119,11 @@ counterpart (a name that is not 1 to 4 characters) are skipped with their conten
with the byte offset. It runs without recursion; the nesting depth (`Limits::max_depth`) and the
element count (`Limits::max_chunks`) are bounded, and everything it allocates is bounded by the
size of the input.
- There are two readers. `read_aepx` builds the owned tree element by element. The importer, which
only reads, uses a second reader that decodes every body (hex, text, the alias JSON, the
numbers) into one buffer and records the chunks as a flat depth-first list whose bodies are
ranges of that buffer; it indexes that buffer (`TreeIndex`) as it indexes an `.aep`. The tests
hold both readers to the same tree and the same errors.
- Tests: `tests/aepx.rs` compares the five fixture pairs chunk by chunk and byte by byte, compares
the typed view of both forms, and runs truncated, bit-flipped, deeply nested and oversized
inputs; `crates/aep/tests/it/import/aepx.rs` imports both forms of each pair and expects the same
@@ -138,7 +143,7 @@ write on their own.
| `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. |
| `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, and the reader does not expose them. | 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. |
@@ -216,12 +221,12 @@ the value from the corpus manifest (test file in parentheses at the end of each
API: `LayerHeader` (`is_video_enabled`/`is_enabled`, `is_audio_enabled`, `is_effects_enabled`,
`is_auto_oriented`, `light_type`, `blend_mode`, `stretch` / `stretch_den`, `stretch_ratio`,
`stretch_percent`, `in_point`, `out_point`); the same switches on `Layer`; `Layer::track_matte`
`in_point`, `out_point`); the same switches on `Layer`; `Layer::track_matte`
(`TrackMatte`, `MatteMode`; test `tests/matte.rs`). `stretch` is now the
numerator of the ratio (it was the whole multiplier before 50 % and 150 % were sampled). Tests:
`tests/header.rs`, `tests/layer_switches.rs`. The layer name itself is the `Utf8` child of the
`Layr` (empty while the name is the source's); the layer then shows the name of its source item
(`Project::layer_display_name`).
(`Project::layer_source(...)` then `Item::display_name`, the rule the importer uses).
### 3.2 Composition header (`Item/cdta`, 204 bytes; the decoder needs the first 176) and renderer
@@ -246,9 +251,9 @@ numerator of the ratio (it was the whole multiplier before 50 % and 150 % were s
The inert `ADBE Time Remapping` record of every AV layer has `tduM` = the source's duration in seconds: 5 for 5 s compositions (124, 257, 258), 3 and 2 for a 3 s and a 2 s composition (600, 601), 1 for the 1 s WAV (351), 0.1 for the three-frame sequence (353), 0 on solids and stills (000, 126). Every property's keyframe time scale (`tdb4` bytes 12 to 15) follows the composition (23 976 in every
property of `L` after 229 and 230). The corpus manifest calls the classic renderer
`ADBE Advanced 3d`; the file stores `ADBE Escher` / `Classic 3D`. API: `CompHeader::frame_rate_exact`
(exact fraction), `duration_frames`, `shutter_phase`, `display_start`, `display_start_seconds`,
`display_start_frame`. Tests: `tests/header.rs`, `tests/layer_switches.rs`.
`ADBE Advanced 3d`; the file stores `ADBE Escher` / `Classic 3D`. API: `CompHeader::{time_base,
frame_rate_fixed, duration_units, shutter_phase, display_start}` (`display_start` is the exact
fraction). Tests: `tests/header.rs`, `tests/layer_switches.rs`.
### 3.3 Items, footage and precomps
@@ -293,7 +298,8 @@ name. A footage layer shows the item's name while it has no name of its own (351
Test: `tests/header.rs` (precomp), `tests/text_markers_footage.rs` and `tests/batch3_objects.rs`
(footage), `tests/layer_switches.rs` (solid colour). API: `Footage::{duration, native_frame_rate,
bits_per_pixel, file_size, audio_sample_rate, is_sequence, sequence_frame_count}`.
bits_per_pixel, file_size, audio_sample_rate, is_sequence}`; `Item::sequence_info` (with
`SequenceInfo::frame_count`).
### 3.4 Properties: sparse storage, static values and units
@@ -402,8 +408,8 @@ out speed, at 40 out influence**). Scale key 1 of 305 (`n = 3`, in 0 / 33 %, out
15 / 66) is `0,0, .33,.33, 15,15, .66,.66`. Component `i` uses slot `i` of each block (**V** for the
block layout and the units, Scale speed in fractions per second and Mask Feather speed in pixels
per second; **E** that different values per dimension sit in the slot of their dimension, because
the tool that made the corpus applies one ease to all dimensions; **V** since the exporter wrote three different eases for the three Scale dimensions and After Effects read the same three back, aep-export.md R13). `Keyframe::plain_ease_slots`
returns the doubles as stored, `Keyframe::ease_component(i)` one component's `KeyEase`. Layout of
the tool that made the corpus applies one ease to all dimensions; **V** since the exporter wrote three different eases for the three Scale dimensions and After Effects read the same three back, aep-export.md R13). `Keyframe::ease_component(i)`
returns one component's `KeyEase`, `Keyframe::ease_components` all of them. Layout of
the spatial template: `u32` at 8 (1 normally, 0 after the spatial auto-bezier and continuous
switches were turned off together, 054), `f64` arc length of the segment to the next key at 16
(100 in 204 key 1, 0 on the last key and right after creation; safe to ignore), **ease at 24 in
@@ -428,7 +434,7 @@ in tangent (`n` x `f64`, relative offset from the value) at `56 + 8 n`, out tang
| **ease, colour switched to bezier** | spatial n=4 @24..55 | keyframe.set_interpolation bezier on two colour keys stores speed 0 and influence 0 on both sides, and After Effects interpolates them linearly (598 Start Color red to blue: 0.75 / 0.5 / 0.25 red at 0.5 / 1 / 1.5 s); a custom colour ease cannot be set by script | 598 | V |
| **ease, mask path** | 64-byte record @24..55 | in speed, in influence, out speed, out influence as in the spatial template: 599 out / in influence 0.75, 700 0.2 / 0.5, speeds 0 (the path at 0.5 s is 5.42 % / 28.0 % of the way). The `f64` at 16 is 1 (093) or 2 (599, 700) on the first key and 0 on the last (not decoded) | 093, 599, 700 | V (influence), ? (speed unit) |
API: `Keyframe::{interpolation_in, interpolation_out, kind, value, ease, ease_component, ease_components, plain_ease_slots, color_distance, spatial}`;
API: `Keyframe::{interpolation_in, interpolation_out, kind, value, ease, ease_component, ease_components, spatial}` (the `f64` at byte 16 of a colour key is `SpatialKey::segment_length`);
`Property::keys()` hands the property's template to its records. Marker record (16 bytes): time,
`03 03` hold bytes, 8 stale bytes. Mask path record (64 bytes): the common fields and the ease at byte 24 (599, 700). Test:
`tests/values.rs`, `tests/keyframes.rs`.
@@ -765,7 +771,7 @@ pins are placed by hand or by a preset.
328), so mode and inverted are reported per mask. `mkif` byte 8 is the running mask id counter of the
**layer** in the session (1, 2 for the two masks of 328; 329 then gets 3; undo does not reset it; a new
layer starts at 1 again): exposed as `Mask::counter`, a session counter and not a stable id. API:
`Mask::{mode, inverted, counter}`, `Project::mask_vertices_px` (vertices in layer pixels), `Layer::mask_atoms` (`MaskAtom`: the mask with its atom group, its path property and its shapes by position, for the keys and expressions of the mask properties). Tests:
`Mask::{mode, inverted, counter}`, `ShapePath::vertices(size)` (vertices in layer pixels: `size` is the size of the layer's source, `Project::layer_source_size`; a text or shape layer has no source size and its mask boxes are already pixels, so the importer passes 1 x 1), `Layer::mask_atoms` (`MaskAtom`: the mask with its atom group, its path property and its shapes by position, for the keys and expressions of the mask properties). Tests:
`tests/masks.rs` (with a synthetic check of the handle order), `tests/batch3_objects.rs`.
### 3.9 Shape layers (`tdgp[ADBE Root Vectors Group]`)
@@ -893,7 +899,7 @@ groups as on other layers, with these conventions:
| What | Path | Stored | Evidence | Conf. |
|---|---|---|---|---|
| camera Position, default (auto state) | `ADBE Transform Group/ADBE Position`, `tdb4` 76 and 77 = 1 although no expression exists | `[0, 0, -2666.67]` (x and y are 0 whatever the composition size; z = -zoom). A **one-node** camera reports `[0, 0, -2666.67]` (251). **Correction (S16): a reopened two-node camera reports `[0, 0, -2666.67]` too (256 read back), and After Effects renders it there**: the default two-node camera of 537 above the 3D solid of 486 renders from `[0, 0, -2666.67]` towards the point of interest, not like the composition's default view (537 differs from 486 in 24 % of the grid points, and EffectCraft's import with the stored position matches 537 but for 4 edge points). The `[960, 540, ...]` of the batch-3 notes was not reproduced (535 and 537, created by script, read back the stored value too); `CameraPosition::reported` keeps the old rule and is marked as contradicted | 256, 251, 537 | V |
| camera Position, default (auto state) | `ADBE Transform Group/ADBE Position`, `tdb4` 76 and 77 = 1 although no expression exists | `[0, 0, -2666.67]` (x and y are 0 whatever the composition size; z = -zoom). A **one-node** camera reports `[0, 0, -2666.67]` (251). **Correction (S16): a reopened two-node camera reports `[0, 0, -2666.67]` too (256 read back), and After Effects renders it there**: the default two-node camera of 537 above the 3D solid of 486 renders from `[0, 0, -2666.67]` towards the point of interest, not like the composition's default view (537 differs from 486 in 24 % of the grid points, and EffectCraft's import with the stored position matches 537 but for 4 edge points). The `[960, 540, ...]` of the batch-3 notes was not reproduced (535 and 537, created by script, read back the stored value too); the reader keeps only the stored value (`CameraPosition::stored`) | 256, 251, 537 | V |
| camera Position, set by the user | same, `tdb4` 76 and 77 = 0 | the absolute value `[100, 200, -1000]` | 253 | V |
| camera point of interest | `ADBE Anchor Point` | `[960, 540, 0]` in **pixels** (not a fraction); the record's `tdsb` is 1 on a two-node and 3 on the one-node camera (not asserted) | 251, 253, 256 | V (value), E (`tdsb`) |
| light Position, default | same as the camera | `[0, 0, -1333.33]`, as After Effects reports it (255 read back); the point of interest `[960, 540, 0]` | 254, 255 | V |
@@ -909,10 +915,8 @@ groups as on other layers, with these conventions:
A point light made by converting a spot light has a different property set (`Orientation`, `Rotate
X/Y/Z`, `Cone Angle`, `Cone Feather 2` in the default state) that a spot light lacks (252); whether that
is an artefact of the conversion is untested. API: `Layer::camera_position`,
`CameraPosition::reported`, `Layer::camera_point_of_interest`, `Layer::light_intensity`,
`Layer::{camera_zoom, camera_depth_of_field, camera_focus_distance, camera_aperture, light_color,
light_cone_angle, light_cone_feather}`, `DEFAULT_CAMERA_ZOOM` (2 666.67 is verified for the 1920 px
is an artefact of the conversion is untested. API: `Layer::camera_position`; the camera and light
options are read by the importer's table (`import/camera.rs`); `DEFAULT_CAMERA_ZOOM` (2 666.67 is verified for the 1920 px
composition only), `Layer::orientation` (`Orientation`; test `orientation_is_otst_otda` in
`tests/batch5.rs`). Render checks against After Effects (20 px grid, 5 184 points): 486 1 point
off, 535 2, 536 0, 537 4 (anti-aliased edges); **488 (Orientation `[10, 20, 30]`): 272 points