mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 22:33:09 +00:00
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:
commit
2c6fe5f108
41 files changed
+3193
-363
No files matched your search
@@ -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};
|
||||
|
||||
@@ -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=\"01Ab\"/>").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=\" \"/>", "<Unknown x=\"&bogus;\"/>", "<a bdata=\"00\" note=\"&\"/>"] {
|
||||
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,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+927
-143
File diff suppressed because it is too large.
Load diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(&[]) };
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,5 +25,6 @@ mod shapes;
|
||||
mod text_fields;
|
||||
mod text_markers_footage;
|
||||
mod text_style_slots;
|
||||
mod tree_index;
|
||||
mod typed;
|
||||
mod values;
|
||||
@@ -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).
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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![]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user