Merge remote-tracking branch 'origin/aep-audit-3300' into aep-import-bounds-3299

# Conflicts:
#	crates/aep/src/export/emit.rs
This commit is contained in:
vangel76 committed 2026-11-08 17:21:44 +01:00
commit 2c6fe5f108
41 files changed
+3193 -363

No files matched your search

+2
View File
@@ -23,6 +23,7 @@ mod effects;
mod error;
mod footage;
mod header;
pub mod index;
mod keys;
mod masks;
mod matte;
@@ -46,6 +47,7 @@ pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, Laye
pub use error::AepError;
pub use footage::{Footage, 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};
+124 -8
View File
@@ -227,7 +227,7 @@ impl Reader<'_> {
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 mut attrs = Vec::new();
let self_closing = loop {
self.skip_ws();
let rest = self.rest();
@@ -259,8 +259,15 @@ 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));
};
@@ -466,6 +473,15 @@ fn decode_entities(raw: &[u8], offset: usize, out: &mut Vec<u8>) -> Result<(), A
/// Decodes a `bdata` value (hex digits, two per byte).
fn decode_hex(text: &[u8], offset: usize) -> Result<Vec<u8>, AepError> {
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> {
if text.len() % 2 == 1 {
return Err(xml(offset, "bdata has an odd number of hex digits"));
}
@@ -476,11 +492,25 @@ fn decode_hex(text: &[u8], offset: usize) -> Result<Vec<u8>, AepError> {
_ => 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"))
if pairs.len() > max {
return Err(AepError::TooLarge);
}
// Nothing is reserved for a value that does not decode: 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"));
}
// 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 mut out = Vec::new();
out.try_reserve_exact(pairs.len()).map_err(|_| xml(offset, "bdata is too large to hold in memory"))?;
for [hi, lo] in pairs {
match (digit(*hi), digit(*lo)) {
(Some(h), Some(l)) => out.push((h << 4) | l),
_ => return Err(xml(offset, "bdata holds a character that is not a hex digit")),
}
}
Ok(out)
}
/// The JSON body of an `alas` chunk from the attributes of a `fileReference`, keys in document
@@ -628,6 +658,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));
+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)
}
}
+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 -8
View File
@@ -2,8 +2,8 @@
//! of a layer's source.
use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text};
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`).
@@ -201,14 +201,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();
}
@@ -240,9 +239,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")) {
@@ -252,7 +250,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`).
+978
View File
@@ -0,0 +1,978 @@
//! A read-only, flat index of the chunk tree for readers that only look (the importer).
//!
//! [`TreeIndex`] holds every chunk of a file in one `Vec` in depth-first order (a chunk, then its
//! whole subtree, then its next sibling). Each entry keeps its id, list type, pad byte, the bytes of
//! its body (borrowed from the input) and the index one past its subtree, so:
//! - a chunk's subtree is the index range `at + 1 .. end`,
//! - its direct children are found by stepping from `at + 1` to each child's `end`,
//! - the whole tree is one array, grown a few times while it is built and freed at once; it never
//! holds more slots than the input has room for chunk headers or the chunk limit allows.
//!
//! [`crate::container::parse`] builds the owned [`Chunk`] tree instead (one `Vec` per list), which
//! the exporter and editors need. [`TreeIndex::to_file`] and [`Node::to_chunk`] convert the index
//! into that tree where an owned one is needed.
//!
//! [`TreeIndex::parse_with_limits`] walks the input exactly as the [`Chunk`] parser does: the same
//! chunks are counted against [`Limits::max_chunks`] in the same order, the same lists are kept
//! opaque at [`Limits::max_depth`] or when damaged, and the same errors come back. A test compares
//! the two on the corpus and on generated and damaged trees. Two errors are its own, and both are
//! stricter than the [`Chunk`] parser (which would abort or could not address the tree):
//! [`AepError::TooLarge`] when the array cannot grow (a failed `try_reserve`), and
//! [`AepError::TooManyChunks`] for a tree of more than `u32::MAX` chunks.
//!
//! [`Node`] is the handle the typed view ([`crate::container::Project`] and the types below it)
//! reads through: a chunk of either tree. The view works the same on both.
use std::borrow::Cow;
use std::fmt;
use crate::container::bytes::be_u32;
use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
use crate::container::error::AepError;
use crate::container::read::{Limits, container_end, id4};
/// What an entry's body is (the [`Body`] variants).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Data,
/// [`Body::Chunks`].
Nested,
List,
OpaqueList,
}
/// One chunk of a [`TreeIndex`].
#[derive(Debug, Clone, Copy)]
struct Entry<'a> {
/// The body bytes of a data chunk, or the bytes after the list type of an opaque list; empty
/// for a list or nested chunk (their content is their children).
data: &'a [u8],
id: [u8; 4],
/// The list type of a `LIST` (parsed or opaque); zero otherwise.
list_type: [u8; 4],
/// One past the last entry of this chunk's subtree.
end: u32,
kind: Kind,
pad: Option<u8>,
}
impl<'a> Entry<'a> {
/// An entry for a chunk whose body is not read yet.
fn placeholder(id: [u8; 4]) -> Self {
Self { data: &[], id, list_type: [0; 4], end: 0, kind: Kind::Data, pad: None }
}
}
/// The chunk tree of a file as one flat, read-only array (see the module documentation).
#[derive(Clone)]
pub struct TreeIndex<'a> {
nodes: Vec<Entry<'a>>,
trailer: &'a [u8],
}
impl fmt::Debug for TreeIndex<'_> {
/// The sizes only: the chunk count and the trailer length.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TreeIndex").field("chunks", &self.nodes.len()).field("trailer", &self.trailer.len()).finish()
}
}
/// An index that does not fit a `u32` (more than 4 billion chunks) is refused as too many chunks.
fn index_u32(at: usize) -> Result<u32, AepError> {
u32::try_from(at).map_err(|_| AepError::TooManyChunks)
}
impl<'a> TreeIndex<'a> {
/// Indexes an `.aep` with the default [`Limits`].
///
/// # Errors
/// The errors of [`crate::container::parse`], for the same inputs.
pub fn parse(data: &'a [u8]) -> Result<Self, AepError> {
Self::parse_with_limits(data, Limits::default())
}
/// Indexes an `.aep` with explicit [`Limits`].
///
/// # Errors
/// The errors of [`crate::container::parse_with_limits`], for the same inputs and limits; also
/// [`AepError::TooManyChunks`] for a tree of more than `u32::MAX` chunks and
/// [`AepError::TooLarge`] when the array cannot be allocated.
pub fn parse_with_limits(data: &'a [u8], limits: Limits) -> Result<Self, AepError> {
let end = container_end(data)?;
// Every chunk header takes 8 bytes of its own and is counted once, so the tree never has
// more chunks than this.
let bound = (end.saturating_sub(12) / 8).min(limits.max_chunks);
let mut builder = Builder { data, limits, count: 0, nodes: Vec::new(), bound };
// A small start; [`Builder::push`] doubles it as chunks arrive, never past the bound, so
// the array stays proportional to the chunk count (a file of a few large chunks gets a few
// slots, not one per 32 bytes). `try_reserve_exact` so it fails nothing here.
let _ = builder.nodes.try_reserve_exact(bound.min(INITIAL_SLOTS));
builder.sequence(12, end, 1)?;
let trailer = data.get(end..).unwrap_or(&[]);
Ok(Self { nodes: builder.nodes, trailer })
}
/// Indexes an already parsed (or built, or `.aepx`) chunk tree. The index borrows the file's
/// bytes.
///
/// # Errors
/// [`AepError::TooManyChunks`] for a tree of more than `u32::MAX` chunks.
pub fn from_file(file: &'a AepFile<'_>) -> Result<Self, AepError> {
// Sized once, to the chunks the tree holds.
let mut nodes: Vec<Entry<'a>> = Vec::new();
nodes.try_reserve_exact(file.walk().count()).map_err(|_| AepError::TooLarge)?;
// Each frame: the siblings still to visit and the entry whose subtree they fill.
let mut stack: Vec<(std::slice::Iter<'a, Chunk<'_>>, Option<usize>)> = vec![(file.chunks.iter(), None)];
while let Some((iter, parent)) = stack.last_mut() {
let parent = *parent;
match iter.next() {
Some(chunk) => {
let at = nodes.len();
// The entry after this one must still have a `u32` index.
let next = index_u32(at.checked_add(1).ok_or(AepError::TooManyChunks)?)?;
let (kind, data, list_type) = match &chunk.body {
Body::Data(d) => (Kind::Data, &d[..], [0; 4]),
Body::Chunks(_) => (Kind::Nested, &[][..], [0; 4]),
Body::List { list_type, .. } => (Kind::List, &[][..], *list_type),
Body::OpaqueList { list_type, data } => (Kind::OpaqueList, &data[..], *list_type),
};
nodes.push(Entry { data, id: chunk.id, list_type, end: next, kind, pad: chunk.pad });
let kids = chunk.children();
if !kids.is_empty() {
stack.push((kids.iter(), Some(at)));
}
}
None => {
stack.pop();
if let Some(p) = parent {
let end = index_u32(nodes.len())?;
if let Some(e) = nodes.get_mut(p) {
e.end = end;
}
}
}
}
}
Ok(Self { nodes, trailer: &file.trailer[..] })
}
/// The number of chunks in the tree (at every depth).
pub fn len(&self) -> usize {
self.nodes.len()
}
/// True when the container holds no chunks.
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// The bytes after the container's declared end (see [`AepFile::trailer`]).
pub fn trailer(&self) -> &'a [u8] {
self.trailer
}
/// The top-level chunks.
pub fn chunks(&self) -> Nodes<'_, 'a> {
Nodes(NodesRepr::Index { tree: self, next: 0, end: u32::try_from(self.nodes.len()).unwrap_or(u32::MAX) })
}
/// The first top-level chunk with this id.
pub fn chunk(&self, id: &[u8; 4]) -> Option<Node<'_, 'a>> {
self.chunks().find(|c| c.is(id))
}
/// The first top-level `LIST` of this type.
pub fn list(&self, list_type: &[u8; 4]) -> Option<Node<'_, 'a>> {
self.chunks().find(|c| c.is_list(list_type))
}
/// Visits every chunk depth-first with its nesting depth (the top level is 1), as
/// [`AepFile::walk`] does.
pub fn walk(&self) -> NodeWalk<'_, 'a> {
NodeWalk(WalkRepr::Index { tree: self, next: 0, end: u32::try_from(self.nodes.len()).unwrap_or(u32::MAX), open: Vec::new() })
}
/// The owned chunk tree (the bodies stay borrowed from the input): what
/// [`crate::container::parse`] gives for the same file.
pub fn to_file(&self) -> AepFile<'a> {
AepFile { chunks: self.build(0, u32::try_from(self.nodes.len()).unwrap_or(u32::MAX)), trailer: Cow::Borrowed(self.trailer) }
}
/// The entry at `at`, `None` past the end.
fn entry(&self, at: u32) -> Option<&Entry<'a>> {
self.nodes.get(usize::try_from(at).ok()?)
}
/// The end of the subtree at `at`; never before `at + 1`, so walks always move on.
fn end_of(&self, at: u32) -> u32 {
self.entry(at).map_or(u32::MAX, |e| e.end).max(at.saturating_add(1))
}
/// The owned chunks for the siblings that start at `next` and fill `next..end`, without
/// recursion.
fn build(&self, next: u32, end: u32) -> Vec<Chunk<'a>> {
struct Frame<'a> {
/// The list or nested chunk being filled; `None` for the outermost range.
at: Option<u32>,
kids: Vec<Chunk<'a>>,
next: u32,
end: u32,
}
let mut stack = vec![Frame { at: None, kids: Vec::new(), next, end }];
loop {
let Some(top) = stack.last_mut() else { return Vec::new() };
if top.next < top.end {
let at = top.next;
top.next = self.end_of(at);
let Some(e) = self.entry(at).copied() else {
top.next = top.end;
continue;
};
match e.kind {
Kind::List | Kind::Nested => {
let sub_end = self.end_of(at);
stack.push(Frame { at: Some(at), kids: Vec::new(), next: at.saturating_add(1), end: sub_end });
}
Kind::Data => top.kids.push(Chunk { id: e.id, body: Body::Data(Cow::Borrowed(e.data)), pad: e.pad }),
Kind::OpaqueList => {
top.kids.push(Chunk { id: e.id, body: Body::OpaqueList { list_type: e.list_type, data: Cow::Borrowed(e.data) }, pad: e.pad });
}
}
} else {
let Some(frame) = stack.pop() else { return Vec::new() };
let Some(at) = frame.at else { return frame.kids };
let Some(e) = self.entry(at).copied() else { continue };
let body = match e.kind {
Kind::Nested => Body::Chunks(frame.kids),
Kind::List | Kind::Data | Kind::OpaqueList => Body::List { list_type: e.list_type, children: frame.kids },
};
let chunk = Chunk { id: e.id, body, pad: e.pad };
match stack.last_mut() {
Some(parent) => parent.kids.push(chunk),
None => return vec![chunk],
}
}
}
}
}
/// The slots a [`TreeIndex`] starts with (32 KiB); it doubles from there.
const INITIAL_SLOTS: usize = 1024;
/// Builds a [`TreeIndex`]; mirrors `read::Parser` step for step (see the module documentation).
struct Builder<'a> {
data: &'a [u8],
limits: Limits,
count: usize,
nodes: Vec<Entry<'a>>,
/// The most chunks the input can hold under the limits: `nodes` never reserves past it.
bound: usize,
}
impl<'a> Builder<'a> {
/// Appends an entry. A full array 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 push(&mut self, entry: Entry<'a>) -> Result<(), AepError> {
let len = self.nodes.len();
if len == self.nodes.capacity() {
let more = len.max(16).min(self.bound.saturating_sub(len)).max(1);
self.nodes.try_reserve_exact(more).map_err(|_| AepError::TooLarge)?;
}
self.nodes.push(entry);
Ok(())
}
/// Appends the sibling chunks that exactly fill `start..end`, each followed by its subtree.
fn sequence(&mut self, start: usize, end: usize, depth: usize) -> Result<(), AepError> {
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 = 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 });
}
self.count = self.count.saturating_add(1);
if self.count > self.limits.max_chunks {
return Err(AepError::TooManyChunks);
}
let at = self.nodes.len();
// This entry and the one after it must have `u32` indices.
index_u32(at.checked_add(1).ok_or(AepError::TooManyChunks)?)?;
self.push(Entry::placeholder(id))?;
let body_end = body_start + size;
self.body(at, id, body_start, body_end, depth)?;
let mut next = body_end;
let mut pad = None;
if size % 2 == 1 && next < end {
pad = Some(self.data.get(next).copied().ok_or(AepError::Malformed { offset: next, what: "missing pad byte" })?);
next += 1;
}
let subtree_end = index_u32(self.nodes.len())?;
if let Some(e) = self.nodes.get_mut(at) {
e.pad = pad;
e.end = subtree_end;
}
pos = next;
}
Ok(())
}
/// Like [`Self::sequence`] but a structural problem yields `false` and removes what the
/// attempt appended (the caller keeps bytes). The chunk count keeps the attempt's chunks, as
/// the [`Chunk`] parser's does.
fn tentative(&mut self, start: usize, end: usize, depth: usize) -> Result<bool, AepError> {
let mark = self.nodes.len();
match self.sequence(start, end, depth) {
Ok(()) => Ok(true),
Err(e) if e.is_structural() => {
self.nodes.truncate(mark);
Ok(false)
}
Err(e) => Err(e),
}
}
/// The input bytes `start..end` (empty when out of range).
fn slice(&self, start: usize, end: usize) -> &'a [u8] {
self.data.get(start..end).unwrap_or(&[])
}
/// Fills in the body of the entry at `at` once the parser knows what it is.
fn set(&mut self, at: usize, kind: Kind, data: &'a [u8], list_type: [u8; 4]) {
if let Some(e) = self.nodes.get_mut(at) {
e.kind = kind;
e.data = data;
e.list_type = list_type;
}
}
/// Fills in the entry at `at` for the body `start..end` and appends its subtree.
fn body(&mut self, at: usize, id: [u8; 4], start: usize, end: usize, depth: usize) -> Result<(), AepError> {
let len = end - start;
if id == LIST && len >= 4 {
let list_type = id4(self.data, start).ok_or(AepError::Malformed { offset: start, what: "short read" })?;
if depth < self.limits.max_depth && self.tentative(start + 4, end, depth + 1)? {
self.set(at, Kind::List, &[], list_type);
} else {
let data = self.slice(start + 4, end);
self.set(at, Kind::OpaqueList, data, list_type);
}
return Ok(());
}
if NESTED_IDS.contains(&id) && len >= 8 && depth < self.limits.max_depth {
let mark = self.nodes.len();
if self.tentative(start, end, depth + 1)? && self.nodes.len() > mark {
self.set(at, Kind::Nested, &[], [0; 4]);
return Ok(());
}
}
let data = self.slice(start, end);
self.set(at, Kind::Data, data, [0; 4]);
Ok(())
}
}
/// A chunk of either tree: an owned [`Chunk`] or an entry of a [`TreeIndex`]. The typed view
/// reads through it, so it works the same on both. Cheap to copy.
#[derive(Clone, Copy)]
pub struct Node<'f, 'a>(NodeRepr<'f, 'a>);
#[derive(Clone, Copy)]
enum NodeRepr<'f, 'a> {
Chunk(&'f Chunk<'a>),
Index(&'f TreeIndex<'a>, u32),
}
impl<'f, 'a> From<&'f Chunk<'a>> for Node<'f, 'a> {
/// The handle for this value.
fn from(chunk: &'f Chunk<'a>) -> Self {
Node(NodeRepr::Chunk(chunk))
}
}
/// Prints the chunk as [`Chunk`]'s `Debug` does, whichever tree it is in, without copying the
/// subtree.
impl fmt::Debug for Node<'_, '_> {
/// The chunk as `Chunk`'s `Debug` prints it.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
NodeRepr::Chunk(c) => fmt::Debug::fmt(c, f),
NodeRepr::Index(..) => f.debug_struct("Chunk").field("id", &self.id()).field("body", &BodyDebug(*self)).field("pad", &self.pad()).finish(),
}
}
}
/// The body of an index [`Node`], printed as [`Body`]'s `Debug` prints it.
struct BodyDebug<'f, 'a>(Node<'f, 'a>);
/// The children of a [`Node`], printed as a list.
struct ChildrenDebug<'f, 'a>(Node<'f, 'a>);
impl fmt::Debug for BodyDebug<'_, '_> {
/// The body as `Body`'s `Debug` prints it.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some(e) = self.0.entry() else { return f.write_str("?") };
match e.kind {
Kind::Data => f.debug_tuple("Data").field(&e.data).finish(),
Kind::Nested => f.debug_tuple("Chunks").field(&ChildrenDebug(self.0)).finish(),
Kind::List => f.debug_struct("List").field("list_type", &e.list_type).field("children", &ChildrenDebug(self.0)).finish(),
Kind::OpaqueList => f.debug_struct("OpaqueList").field("list_type", &e.list_type).field("data", &e.data).finish(),
}
}
}
impl fmt::Debug for ChildrenDebug<'_, '_> {
/// The children as a list.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.0.children()).finish()
}
}
impl<'f, 'a> Node<'f, 'a> {
/// The entry at `at`, `None` past the end.
fn entry(self) -> Option<&'f Entry<'a>> {
match self.0 {
NodeRepr::Index(tree, at) => tree.entry(at),
NodeRepr::Chunk(_) => None,
}
}
/// The [`Chunk`] when this node is one of an owned tree; `None` for an index entry (see
/// [`Self::to_chunk`]).
pub fn as_chunk(self) -> Option<&'f Chunk<'a>> {
match self.0 {
NodeRepr::Chunk(c) => Some(c),
NodeRepr::Index(..) => None,
}
}
/// The chunk and its subtree as an owned [`Chunk`] (bodies stay borrowed).
pub fn to_chunk(self) -> Chunk<'a> {
match self.0 {
NodeRepr::Chunk(c) => c.clone(),
NodeRepr::Index(tree, at) => tree.build(at, tree.end_of(at)).into_iter().next().unwrap_or_else(|| Chunk::data(self.id(), &[][..])),
}
}
/// The 4-byte id (see [`Chunk::id`]).
pub fn id(self) -> [u8; 4] {
match self.0 {
NodeRepr::Chunk(c) => c.id,
NodeRepr::Index(..) => self.entry().map_or([0; 4], |e| e.id),
}
}
/// The pad byte (see [`Chunk::pad`]).
pub fn pad(self) -> Option<u8> {
match self.0 {
NodeRepr::Chunk(c) => c.pad,
NodeRepr::Index(..) => self.entry().and_then(|e| e.pad),
}
}
/// The raw bytes of a data chunk, or the bytes after the list type of an opaque list (see
/// [`Chunk::bytes`]).
pub fn bytes(self) -> Option<&'f [u8]> {
match self.0 {
NodeRepr::Chunk(c) => c.bytes(),
NodeRepr::Index(..) => self.entry().filter(|e| matches!(e.kind, Kind::Data | Kind::OpaqueList)).map(|e| e.data),
}
}
/// The child chunks of a list or nested chunk; none for data.
pub fn children(self) -> Nodes<'f, 'a> {
match self.0 {
NodeRepr::Chunk(c) => Nodes(NodesRepr::Slice(c.children().iter())),
NodeRepr::Index(tree, at) => {
let end = match tree.entry(at) {
Some(e) if matches!(e.kind, Kind::List | Kind::Nested) => tree.end_of(at),
_ => at.saturating_add(1),
};
Nodes(NodesRepr::Index { tree, next: at.saturating_add(1), end })
}
}
}
/// The list type when this is a `LIST` (parsed or opaque).
pub fn list_type(self) -> Option<[u8; 4]> {
match self.0 {
NodeRepr::Chunk(c) => c.list_type(),
NodeRepr::Index(..) => self.entry().filter(|e| matches!(e.kind, Kind::List | Kind::OpaqueList)).map(|e| e.list_type),
}
}
/// True when this is a `LIST` of the given type.
pub fn is_list(self, list_type: &[u8; 4]) -> bool {
match self.0 {
NodeRepr::Chunk(c) => c.is_list(list_type),
NodeRepr::Index(..) => self.entry().is_some_and(|e| e.id == LIST && matches!(e.kind, Kind::List | Kind::OpaqueList) && &e.list_type == list_type),
}
}
/// True when this is a `LIST` whose content could not be read as chunks.
pub fn is_opaque_list(self) -> bool {
match self.0 {
NodeRepr::Chunk(c) => c.is_opaque_list(),
NodeRepr::Index(..) => self.entry().is_some_and(|e| e.kind == Kind::OpaqueList),
}
}
/// True when this chunk has the given id.
pub fn is(self, id: &[u8; 4]) -> bool {
self.id() == *id
}
/// The first child chunk with this id.
pub fn child(self, id: &[u8; 4]) -> Option<Self> {
self.children().find(|c| c.is(id))
}
/// The first child `LIST` of this type.
pub fn child_list(self, list_type: &[u8; 4]) -> Option<Self> {
self.children().find(|c| c.is_list(list_type))
}
/// Every chunk below this one depth-first (children before later siblings), with the depth
/// counted from 1 for the children of this chunk.
pub fn descendants(self) -> NodeWalk<'f, 'a> {
match self.0 {
NodeRepr::Chunk(c) => NodeWalk(WalkRepr::Chunk(c.descendants())),
NodeRepr::Index(tree, at) => {
let end = match tree.entry(at) {
Some(e) if matches!(e.kind, Kind::List | Kind::Nested) => tree.end_of(at),
_ => at.saturating_add(1),
};
NodeWalk(WalkRepr::Index { tree, next: at.saturating_add(1), end, open: Vec::new() })
}
}
}
/// The first descendant `LIST` of this type, searching depth-first.
pub fn find_list(self, list_type: &[u8; 4]) -> Option<Self> {
match self.0 {
NodeRepr::Chunk(c) => c.find_list(list_type).map(Node::from),
// The subtree is a contiguous range in depth-first order: a plain scan.
NodeRepr::Index(tree, at) => {
let first = usize::try_from(at).ok()?.checked_add(1)?;
let end = usize::try_from(tree.end_of(at)).ok()?.min(tree.nodes.len());
let range = tree.nodes.get(first..end)?;
let i = range.iter().position(|e| e.id == LIST && matches!(e.kind, Kind::List | Kind::OpaqueList) && &e.list_type == list_type)?;
let found = u32::try_from(first.checked_add(i)?).ok()?;
Some(Node(NodeRepr::Index(tree, found)))
}
}
}
}
/// The children of a [`Node`] (or the top-level chunks of a [`TreeIndex`]), in file order.
#[derive(Clone)]
pub struct Nodes<'f, 'a>(NodesRepr<'f, 'a>);
#[derive(Clone)]
enum NodesRepr<'f, 'a> {
Slice(std::slice::Iter<'f, Chunk<'a>>),
Index { tree: &'f TreeIndex<'a>, next: u32, end: u32 },
}
impl<'f, 'a> Iterator for Nodes<'f, 'a> {
type Item = Node<'f, 'a>;
/// The next item, `None` at the end of the range.
fn next(&mut self) -> Option<Self::Item> {
match &mut self.0 {
NodesRepr::Slice(it) => it.next().map(Node::from),
NodesRepr::Index { tree, next, end } => {
if *next >= *end {
return None;
}
let (tree, at) = (*tree, *next);
tree.entry(at)?;
*next = tree.end_of(at);
Some(Node(NodeRepr::Index(tree, at)))
}
}
}
}
/// A depth-first walk over [`Node`]s with their depth; see [`Node::descendants`] and
/// [`TreeIndex::walk`].
pub struct NodeWalk<'f, 'a>(WalkRepr<'f, 'a>);
enum WalkRepr<'f, 'a> {
Chunk(Walk<'f, 'a>),
/// `open` holds the subtree ends of the entries above the next one (its ancestors inside the
/// walked range); their count is the next entry's depth minus one.
Index {
tree: &'f TreeIndex<'a>,
next: u32,
end: u32,
open: Vec<u32>,
},
}
impl<'f, 'a> Iterator for NodeWalk<'f, 'a> {
type Item = (usize, Node<'f, 'a>);
/// The next item, `None` at the end of the range.
fn next(&mut self) -> Option<Self::Item> {
match &mut self.0 {
WalkRepr::Chunk(w) => w.next().map(|(d, c)| (d, Node::from(c))),
WalkRepr::Index { tree, next, end, open } => {
if *next >= *end {
return None;
}
let (tree, at) = (*tree, *next);
tree.entry(at)?;
while open.last().is_some_and(|e| *e <= at) {
open.pop();
}
let depth = open.len().saturating_add(1);
let sub_end = tree.end_of(at);
if sub_end > at.saturating_add(1) {
open.push(sub_end);
}
*next = at.saturating_add(1);
Some((depth, Node(NodeRepr::Index(tree, at))))
}
}
}
}
/// The top level of either tree: what the project settings and the version are read from.
#[derive(Clone, Copy)]
pub(crate) enum Root<'f, 'a> {
File(&'f AepFile<'a>),
Index(&'f TreeIndex<'a>),
}
impl<'f, 'a> From<&'f AepFile<'a>> for Root<'f, 'a> {
/// The handle for this value.
fn from(file: &'f AepFile<'a>) -> Self {
Root::File(file)
}
}
impl<'f, 'a> From<&'f TreeIndex<'a>> for Root<'f, 'a> {
/// The handle for this value.
fn from(index: &'f TreeIndex<'a>) -> Self {
Root::Index(index)
}
}
impl<'f, 'a> Root<'f, 'a> {
/// The top-level chunks.
pub(crate) fn chunks(self) -> Nodes<'f, 'a> {
match self {
Root::File(f) => Nodes(NodesRepr::Slice(f.chunks.iter())),
Root::Index(t) => t.chunks(),
}
}
/// The first top-level chunk with this id.
pub(crate) fn chunk(self, id: &[u8; 4]) -> Option<Node<'f, 'a>> {
self.chunks().find(|c| c.is(id))
}
/// The first top-level `LIST` of this type.
pub(crate) fn list(self, list_type: &[u8; 4]) -> Option<Node<'f, 'a>> {
self.chunks().find(|c| c.is_list(list_type))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::container::{parse_with_limits, write};
/// A tiny deterministic generator (xorshift64*), so the tests need no extra crate.
struct Rng(u64);
impl Rng {
/// The next pseudo-random number (xorshift64*).
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: u64) -> u64 {
if n == 0 { 0 } else { self.next() % n }
}
}
/// A random tree: lists, nested-id chunks holding a `Utf8`, data of odd and even sizes, opaque
/// lists, empty lists, deep chains.
fn tree(rng: &mut Rng, depth: usize, budget: &mut usize) -> Vec<Chunk<'static>> {
let n = rng.below(6);
let mut out = Vec::new();
for _ in 0..n {
if *budget == 0 {
break;
}
*budget -= 1;
let pick = rng.below(10);
let chunk = if depth < 12 && pick < 4 {
let types: [&[u8; 4]; 4] = [b"Fold", b"tdgp", b"tdbs", b"Layr"];
let t = *types[usize::try_from(rng.below(4)).expect("small")];
Chunk::list(t, tree(rng, depth + 1, budget))
} else if pick == 4 {
let ids: [&[u8; 4]; 3] = [b"tdsn", b"pdnm", b"fnam"];
let id = *ids[usize::try_from(rng.below(3)).expect("small")];
let text = vec![b'a'; usize::try_from(rng.below(5)).expect("small")];
Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text)]), pad: None }
} else if pick == 5 {
let raw = vec![0x5a; usize::try_from(rng.below(9)).expect("small")];
Chunk { id: LIST, body: Body::OpaqueList { list_type: *b"btdk", data: raw.into() }, pad: None }
} else {
let ids: [&[u8; 4]; 4] = [b"cdat", b"tdmn", b"Utf8", b"idta"];
let id = *ids[usize::try_from(rng.below(4)).expect("small")];
Chunk::data(id, vec![u8::try_from(rng.below(256)).expect("byte"); usize::try_from(rng.below(13)).expect("small")])
};
out.push(chunk);
}
out
}
/// Both readers on the same bytes and limits: the same result, errors included; and the
/// index's walk visits the same chunks at the same depths as the tree's.
fn same(bytes: &[u8], limits: Limits) {
let old = parse_with_limits(bytes, limits);
let new = TreeIndex::parse_with_limits(bytes, limits);
match (&old, &new) {
(Ok(file), Ok(index)) => {
assert_eq!(&index.to_file(), file, "the index converts to the parsed tree");
let a: Vec<(usize, Chunk<'_>)> = file.walk().map(|(d, c)| (d, c.clone())).collect();
let b: Vec<(usize, Chunk<'_>)> = index.walk().map(|(d, n)| (d, n.to_chunk())).collect();
assert_eq!(a, b, "the walks agree");
assert_eq!(index.len(), a.len());
// Indexing the parsed tree gives the same index.
let again = TreeIndex::from_file(file).expect("index");
assert_eq!(again.to_file(), *file);
}
(Err(a), Err(b)) => assert_eq!(a, b, "the same error"),
_ => panic!("one reader failed: {:?} vs {:?}", old.as_ref().err(), new.as_ref().err()),
}
}
/// The default limits and four tight ones (depth 1 and 3, 7 and 40 chunks).
fn limits() -> [Limits; 5] {
[
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 matches the chunk parser on generated and damaged trees.
#[test]
fn the_index_matches_the_chunk_parser_on_generated_and_damaged_trees() {
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
for _ in 0..400 {
let mut budget = 120;
let chunks = tree(&mut rng, 0, &mut budget);
let file = AepFile { chunks, trailer: Cow::Borrowed(b"<?xpacket begin?>x<?xpacket end?>") };
let bytes = write(&file).expect("write");
for l in limits() {
same(&bytes, l);
}
// Damage: overwrite a few bytes past the container header (sizes, ids, list types).
for _ in 0..4 {
let mut bad = bytes.clone();
for _ in 0..1 + rng.below(3) {
let at = 12 + usize::try_from(rng.below(u64::try_from(bad.len().saturating_sub(12)).expect("len"))).expect("index");
if let Some(b) = bad.get_mut(at) {
*b = u8::try_from(rng.below(256)).expect("byte");
}
}
for l in limits() {
same(&bad, l);
}
}
// Truncated inputs (the container size then no longer fits).
let cut = bytes.len().saturating_sub(usize::try_from(rng.below(8)).expect("small") + 1);
same(&bytes[..cut], Limits::default());
}
}
/// Header errors are the same.
#[test]
fn header_errors_are_the_same() {
for bytes in [&b""[..], b"RIFX", b"RIFX\0\0\0\x04Egg", b"RIFX\0\0\0\x04Fnord", b"RIFX\0\0\0\x08Egg!", b"RIFX\0\0\0\x03Egg!", b"RIFF\0\0\0\x04Egg!"] {
same(bytes, Limits::default());
}
}
/// The typed accessors give the same answers through either tree.
#[test]
fn nodes_answer_alike_on_both_trees() {
let mut rng = Rng(42);
for _ in 0..100 {
let mut budget = 200;
let file = AepFile { chunks: tree(&mut rng, 0, &mut budget), trailer: Cow::Borrowed(&[]) };
let bytes = write(&file).expect("write");
let parsed = crate::container::parse(&bytes).expect("parse");
let index = TreeIndex::parse(&bytes).expect("index");
let a: Vec<Node<'_, '_>> = parsed.walk().map(|(_, c)| Node::from(c)).collect();
let b: Vec<Node<'_, '_>> = index.walk().map(|(_, n)| n).collect();
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert_eq!((x.id(), x.pad(), x.bytes(), x.list_type(), x.is_opaque_list()), (y.id(), y.pad(), y.bytes(), y.list_type(), y.is_opaque_list()));
assert_eq!(x.children().map(Node::to_chunk).collect::<Vec<_>>(), y.children().map(Node::to_chunk).collect::<Vec<_>>());
for t in [b"Fold", b"tdgp", b"tdbs", b"Layr", b"btdk"] {
assert_eq!(x.find_list(t).map(Node::to_chunk), y.find_list(t).map(Node::to_chunk));
assert_eq!(x.child_list(t).map(Node::to_chunk), y.child_list(t).map(Node::to_chunk));
assert_eq!(x.is_list(t), y.is_list(t));
}
for id in [b"cdat", b"Utf8", b"tdsn"] {
assert_eq!(x.child(id).map(Node::to_chunk), y.child(id).map(Node::to_chunk));
}
let dx: Vec<(usize, Chunk<'_>)> = x.descendants().map(|(d, n)| (d, n.to_chunk())).collect();
let dy: Vec<(usize, Chunk<'_>)> = y.descendants().map(|(d, n)| (d, n.to_chunk())).collect();
assert_eq!(dx, dy);
assert_eq!(format!("{x:?}"), format!("{y:?}"));
}
}
}
/// The index never reserves more than the input or the limit allows.
#[test]
fn the_index_never_reserves_more_than_the_input_or_the_limit_allows() {
// 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 index = TreeIndex::parse(&data).expect("index");
assert_eq!(index.len(), 1000);
// The start (1024 slots) is cut to what the input can hold.
assert!(index.nodes.capacity() <= 1000, "{} slots for at most 1000 headers", index.nodes.capacity());
// A short tail or a header that runs past the end does not raise the bound.
let mut big = data.clone();
big[16..20].copy_from_slice(&u32::MAX.to_be_bytes());
assert!(TreeIndex::parse(&big).is_err() && crate::container::parse(&big).is_err());
// Under a tighter limit the parse fails as the chunk parser does, and the guess never
// exceeds the limit.
let tight = Limits { max_depth: 64, max_chunks: 10 };
assert_eq!(TreeIndex::parse_with_limits(&data, tight).err(), Some(AepError::TooManyChunks));
let small = Limits { max_depth: 64, max_chunks: 1000 };
let index = TreeIndex::parse_with_limits(&data, small).expect("exactly at the limit");
assert_eq!(index.len(), 1000);
// A limit below what the input holds bounds the reservation: 100 chunks, never more slots.
let mut wide = b"RIFX\0\0\0\0Egg!".to_vec();
wide.extend_from_slice(b"LIST");
wide.extend_from_slice(&u32::try_from(4 + 8 * 99).expect("small").to_be_bytes());
wide.extend_from_slice(b"Fold");
wide.extend(std::iter::repeat_n(*b"abcd\0\0\0\0", 99).flatten());
let declared = u32::try_from(wide.len() - 8).expect("small");
wide[4..8].copy_from_slice(&declared.to_be_bytes());
let index = TreeIndex::parse_with_limits(&wide, Limits { max_depth: 64, max_chunks: 100 }).expect("100 chunks");
assert_eq!(index.len(), 100);
assert!(index.nodes.capacity() <= 100);
// `from_file` is sized exactly.
let file = crate::container::parse(&wide).expect("parse");
assert_eq!(TreeIndex::from_file(&file).expect("index").nodes.capacity(), 100);
}
/// A file of `n` data chunks of `size` bytes each.
fn flat(n: usize, size: usize) -> Vec<u8> {
let mut data = b"RIFX\0\0\0\0Egg!".to_vec();
for _ in 0..n {
data.extend_from_slice(b"cdat");
data.extend_from_slice(&u32::try_from(size).expect("small").to_be_bytes());
data.extend(std::iter::repeat_n(0u8, size));
}
let declared = u32::try_from(data.len() - 8).expect("small");
data[4..8].copy_from_slice(&declared.to_be_bytes());
data
}
/// The index grows with the chunk count not the byte size.
#[test]
fn the_index_grows_with_the_chunk_count_not_the_byte_size() {
// 4 MB in 4 chunks: a few slots, not one per 32 bytes (that would be 131 072 slots, 4 MB).
let big = flat(4, 1 << 20);
let index = TreeIndex::parse(&big).expect("index");
assert_eq!(index.len(), 4);
assert!(index.nodes.capacity() <= INITIAL_SLOTS, "{} slots for 4 chunks", index.nodes.capacity());
// Many small chunks: at most twice the count (it doubles).
let many = flat(10_000, 2);
let index = TreeIndex::parse(&many).expect("index");
assert_eq!(index.len(), 10_000);
assert!(index.nodes.capacity() <= 2 * 10_000, "{} slots for 10 000 chunks", index.nodes.capacity());
same(&big, Limits::default());
same(&many, Limits::default());
}
/// `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
}
/// Failed attempts count once and leave nothing behind.
#[test]
fn failed_attempts_count_once_and_leave_nothing_behind() {
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());
let headers = 1 + 50 + 1000;
let index = TreeIndex::parse_with_limits(&data, Limits { max_depth: 64, max_chunks: headers }).expect("parse");
assert_eq!(TreeIndex::parse_with_limits(&data, Limits { max_depth: 64, max_chunks: headers - 1 }).err(), Some(AepError::TooManyChunks));
// Only the outermost list is left, opaque: every failed attempt was rolled back.
assert_eq!(index.len(), 1);
let top = index.chunks().next().expect("one top-level chunk");
assert!(top.is_opaque_list() && top.bytes().map(<[u8]>::len) == Some(level.len() - 12));
assert_eq!(write(&index.to_file()).expect("write"), data);
same(&data, Limits::default());
}
/// Depth beyond the limit stays opaque without recursion past it.
#[test]
fn depth_beyond_the_limit_stays_opaque_without_recursion_past_it() {
// 5000 nested lists: far deeper than the limit; everything below depth 64 stays bytes.
let mut level = b"LIST\0\0\0\x04tdgp".to_vec();
for _ in 0..5000 {
let mut out = b"LIST".to_vec();
out.extend_from_slice(&u32::try_from(level.len() + 4).expect("small").to_be_bytes());
out.extend_from_slice(b"tdgp");
out.extend_from_slice(&level);
level = out;
}
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());
let index = TreeIndex::parse(&data).expect("parse");
assert_eq!(index.len(), 64);
assert_eq!(index.walk().map(|(d, _)| d).max(), Some(64));
assert!(index.walk().last().is_some_and(|(_, n)| n.is_opaque_list()));
same(&data, Limits::default());
// Converting and indexing a deep owned tree uses no recursion either.
let file = index.to_file();
assert_eq!(TreeIndex::from_file(&file).expect("index").len(), 64);
}
}
+7 -5
View File
@@ -6,8 +6,8 @@
//! 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::index::Node;
use crate::container::typed::{KeyList, Layer, PropGroup, PropNode, Property};
use crate::container::values::{Tracked, read};
@@ -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,7 +189,7 @@ 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
@@ -236,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)
}
+1 -1
View File
@@ -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 {
File diff suppressed because it is too large. Load diff
+79 -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`).
@@ -158,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> {
@@ -172,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
}
@@ -214,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") {
@@ -242,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
}
@@ -273,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) });
@@ -294,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));
}
@@ -323,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
}
@@ -445,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 })
}
@@ -509,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];
@@ -545,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 -1
View File
@@ -10,6 +10,7 @@ use std::fmt;
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,13 +72,35 @@ 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> {
Root::from(self).saved_by_version()
}
}
impl TreeIndex<'_> {
/// [`AepFile::file_version`] of the indexed file.
pub fn file_version(&self) -> Option<AeVersion> {
Root::from(self).file_version()
}
/// [`AepFile::saved_by_version`] of the indexed file.
pub fn saved_by_version(&self) -> Option<AeVersion> {
Root::from(self).saved_by_version()
}
}
impl Root<'_, '_> {
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));
}
}
+2
View File
@@ -15,6 +15,7 @@ pub(crate) fn bytes(len: usize, fields: &[(usize, &[u8])]) -> Vec<u8> {
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 {
@@ -280,6 +281,7 @@ 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])]);
+359 -76
View File
@@ -6,9 +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::synth::tdmn_name;
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,
};
@@ -59,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(),
@@ -78,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)) => {
@@ -126,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();
@@ -405,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)
}
@@ -421,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)?;
{
@@ -455,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;
}
@@ -617,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(())
}
@@ -634,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(())
}
@@ -667,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;
}
@@ -690,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));
@@ -739,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)?;
@@ -814,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());
}
}
+1
View File
@@ -61,6 +61,7 @@ pub(crate) fn pard(p: &PDef, popup_items: Option<u16>) -> Vec<u8> {
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());
+3
View File
@@ -90,6 +90,7 @@ 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;
@@ -122,6 +123,7 @@ mod tests {
assert!(col.finish().losses.is_empty());
}
/// 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])]);
@@ -142,6 +144,7 @@ mod tests {
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 {
+3
View File
@@ -368,6 +368,7 @@ pub(crate) fn shph() -> Vec<u8> {
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]);
@@ -634,6 +635,7 @@ 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 {
@@ -649,6 +651,7 @@ mod tests {
}
}
/// An atom without its path is an error.
#[test]
fn an_atom_without_its_path_is_an_error() {
let mut t = MaskProtos::build();
+3
View File
@@ -262,6 +262,7 @@ impl Collector {
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;
@@ -286,6 +287,7 @@ mod tests {
assert_eq!(back, collected());
}
/// The queries read the losses.
#[test]
fn the_queries_read_the_losses() {
let report = collected();
@@ -296,6 +298,7 @@ mod tests {
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();
+4
View File
@@ -126,6 +126,7 @@ pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec<u8> {
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]);
@@ -161,6 +162,7 @@ pub(crate) fn key_list(size: u32, count: u32, records: Vec<u8>) -> Chunk<'static
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());
@@ -229,6 +231,7 @@ 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 {
@@ -246,6 +249,7 @@ mod tests {
}
}
/// 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");
+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
View File
@@ -188,6 +188,7 @@ pub(crate) fn set_utf8(c: &mut Chunk<'static>, text: &str) -> R<()> {
mod tests {
use super::*;
/// The writer errors instead of dropping.
#[test]
fn the_writer_errors_instead_of_dropping() {
let mut d = [0u8; 4];
@@ -200,6 +201,7 @@ mod tests {
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];
@@ -219,6 +221,7 @@ mod tests {
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();
@@ -231,6 +234,7 @@ mod tests {
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};
+91 -4
View File
@@ -42,6 +42,8 @@ mod time;
use effectcraft_project::{ItemId, Project, ProjectSettings};
use crate::container::index::{Root, TreeIndex};
pub use effects::supported_effects;
pub use report::{AeVersionInfo, ImportReport, Imported, LIMITATIONS, Loss, LossCategory, MAX_DETAILS, TESTED_MAJORS, UNKNOWN};
@@ -115,17 +117,29 @@ 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 read into the chunk tree, which the view reads directly.
if crate::is_aepx(bytes) {
let file = crate::read_aepx(bytes)?;
import_root(project, Root::from(&file), 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);
@@ -157,3 +171,76 @@ 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};
/// 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 a = import_root(&mut Project::default(), Root::from(&file), &options);
let b = import_root(&mut Project::default(), Root::from(&index), &options);
assert!(a == b, "{name}: the import differs through the index");
}
(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());
}
}
}
+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 {
+8
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;
@@ -247,6 +248,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()),
@@ -337,6 +343,7 @@ 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;
@@ -361,6 +368,7 @@ mod tests {
assert_eq!(back, collected());
}
/// The queries read the losses.
#[test]
fn the_queries_read_the_losses() {
let report = collected();
+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"),
+1 -1
View File
@@ -132,7 +132,7 @@ pub fn resolve(name: &str) -> Option<PathBuf> {
/// 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().iter().filter(|c| c.is(b"idpi")).filter_map(|c| Some(u32::from_le_bytes(c.bytes()?.get(..4)?.try_into().ok()?))).collect()
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.
+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;
+2 -2
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()))));
}));
}
@@ -386,6 +386,7 @@ 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_keeps_the_auto_position() {
// 341: Zoom 2000 is stored (checked on import); the auto Position keeps [0, 0, -2666.67].
@@ -170,6 +170,7 @@ fn cameras_auto_orient_and_position() {
assert_eq!(pos.stored, [100.0, 200.0, -1000.0]);
}
/// Lights type and position.
#[test]
fn lights_type_and_position() {
// 254: a spot light (its Intensity 50 is checked on import).
+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}");
}
+3 -3
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"]);
+1
View File
@@ -83,6 +83,7 @@ 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
+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) =
+4 -3
View File
@@ -493,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);
}
}
@@ -618,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);
}
}
@@ -853,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");
+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![]);
+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;