diff --git a/Cargo.lock b/Cargo.lock index 2c64ce2f5..599834c0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1574,6 +1574,7 @@ dependencies = [ "effectcraft-keyframe", "effectcraft-project", "effectcraft-time", + "log", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/aep/Cargo.toml b/crates/aep/Cargo.toml index 2686c1f02..0523edb9b 100644 --- a/crates/aep/Cargo.toml +++ b/crates/aep/Cargo.toml @@ -16,6 +16,7 @@ effectcraft-color = { workspace = true } effectcraft-keyframe = { workspace = true } effectcraft-project = { workspace = true } effectcraft-effects = { workspace = true } +log = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/aep/README.md b/crates/aep/README.md index fa82deaa2..0e65d86f8 100644 --- a/crates/aep/README.md +++ b/crates/aep/README.md @@ -23,7 +23,9 @@ tree; pad bytes, odd bodies and the bytes after the container, an XMP packet, ar The XML form of a project (`.aepx`) is read into the same tree by `read_aepx` (a small bounded XML tokenizer, no dependency); `parse_any` picks the reader by content, so the typed view and the -importer take either form. The mapping is in `docs/aep-format.md`, section 1. +importer take either form. The mapping is in `docs/aep-format.md`, section 1. The importer, which +only reads, does not build the owned tree: it reads either form through the flat index +`TreeIndex`, an `.aep` in place and an `.aepx` decoded into one buffer first. Bodies that look like lists but are not chunk sequences (for example `LIST btdk`, raw text) stay opaque rather than failing the parse. Input is untrusted: sizes are checked against the remaining diff --git a/crates/aep/src/container/style_map.rs b/crates/aep/src/container/style_map.rs index fdb28c875..bc3169c9e 100644 --- a/crates/aep/src/container/style_map.rs +++ b/crates/aep/src/container/style_map.rs @@ -73,10 +73,6 @@ pub struct StyleMap { } impl StyleMap { - /// The switch group's match name (`dropShadow/enabled`). - pub fn switch_name(&self) -> String { - format!("{}/enabled", self.ae) - } /// A parameter's full match name (`dropShadow/opacity`). pub fn param_name(&self, p: &StyleParam) -> String { format!("{}/{}", self.ae, p.ae) diff --git a/crates/aep/src/export.rs b/crates/aep/src/export.rs index 12f10aa0c..992dfecb3 100644 --- a/crates/aep/src/export.rs +++ b/crates/aep/src/export.rs @@ -26,6 +26,7 @@ mod footage; mod fx_defs; mod fxsynth; pub(crate) mod icc; +mod keys; mod markers; mod masks; mod plan; diff --git a/crates/aep/src/export/base.rs b/crates/aep/src/export/base.rs index d483cefb7..05aa15891 100644 --- a/crates/aep/src/export/base.rs +++ b/crates/aep/src/export/base.rs @@ -6,15 +6,20 @@ //! (read-backs t-no, t-zcdta, u-zsspc, u-noclrs, u-noftgi, u-nofrec, u-nocdrp). use crate::Chunk; -use crate::export::synth::{tdmn, tdsn}; +use crate::export::synth::{GROUP_END, tdmn, tdsn}; +use crate::export::tree::Fixed; -/// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it. +/// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it. The layouts are +/// fixed in code ([`Fixed`]). pub(crate) fn bytes(len: usize, fields: &[(usize, &[u8])]) -> Vec { - let mut b = vec![0u8; len]; + layout(len, fields).finish(|| format!("base::bytes ({len} bytes, fields at {:?})", fields.iter().map(|(at, v)| (*at, v.len())).collect::>())) +} + +/// The fixed layout of `len` zero bytes with `fields` written at their offsets. +fn layout(len: usize, fields: &[(usize, &[u8])]) -> Fixed { + let mut b = Fixed::zeroed(len); for (at, v) in fields { - if let Some(dst) = b.get_mut(*at..at.saturating_add(v.len())) { - dst.copy_from_slice(v); - } + b.put(*at, v); } b } @@ -52,12 +57,8 @@ pub(crate) fn project_chunks() -> Vec> { // colour depth (2: 32 bits); the save noise after the code page is left zero. let nhed = bytes(32, &[(7, &[5]), (9, &[1]), (12, &[0x1e, 16, 2, 2]), (18, &UTF8_CODE_PAGE)]); let nnhd = bytes(40, &[(7, &[5]), (9, &[1]), (15, &[0x1e]), (19, &[16, 2]), (24, &[2]), (26, &UTF8_CODE_PAGE)]); - let mut lhd3 = bytes(52, &[(0, &0x00d0_0bee_u32.to_be_bytes()), (12, &1u32.to_be_bytes()), (16, &RQ_ITEM_LEN.to_be_bytes())]); - for at in [20, 24, 28] { - if let Some(dst) = lhd3.get_mut(at..at + 4) { - dst.copy_from_slice(&1u32.to_be_bytes()); - } - } + let one = 1u32.to_be_bytes(); + let lhd3 = bytes(52, &[(0, &0x00d0_0bee_u32.to_be_bytes()), (12, &one), (16, &RQ_ITEM_LEN.to_be_bytes()), (20, &one), (24, &one), (28, &one)]); let wsnm: Vec = "Default".encode_utf16().flat_map(u16::to_be_bytes).collect(); vec![ Chunk::data(*b"svap", VERSION.to_vec()), @@ -157,30 +158,17 @@ fn view_layer(ty: &[u8; 4], name: &str, label: u8, kind: u8) -> Vec = + vec![(8, &one), (16, &base), (24, &scale), (28, &out), (32, &scale), (61, &label), (64, &name_copy), (108, &one), (128, &kind_word)]; + fields.extend_from_slice(flags); + let ldta = bytes(164, &fields); + let root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn(GROUP_END)]; let layer = Chunk::list(*ty, vec![Chunk::data(*b"ldta", ldta), utf8(name), Chunk::list(*b"tdgp", root), guides()]); let mut out = vec![layer, Chunk::list(*b"Ewst", Vec::new())]; out.extend(crate::export::skeleton::f_records()); @@ -293,6 +281,16 @@ pub(crate) fn solid_item() -> Chunk<'static> { mod tests { use super::*; + /// A field out of range is an error. + #[test] + fn a_field_out_of_range_is_an_error() { + let ok = layout(4, &[(0, &[1]), (3, &[2])]); + assert!(ok.error().is_none()); + assert_eq!(ok.finish(String::new), [1, 0, 0, 2]); + assert!(layout(4, &[(3, &[1, 2])]).error().is_some()); + assert!(layout(4, &[(usize::MAX, &[1])]).error().is_some()); + } + /// The chunks After Effects refuses a file without, or crashes on (aep-export.md, "What After /// Effects needs"). #[test] diff --git a/crates/aep/src/export/camlight.rs b/crates/aep/src/export/camlight.rs index 597bf800a..a8958025f 100644 --- a/crates/aep/src/export/camlight.rs +++ b/crates/aep/src/export/camlight.rs @@ -13,7 +13,8 @@ use effectcraft_keyframe::Value; use effectcraft_project::{Layer, LightKind}; use crate::export::report::{Collector, LossCategory}; -use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, tpl}; +use crate::export::synth::{GROUP_END, is_tdmn, tdmn}; +use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, split_group, tpl}; /// A camera's options in the file's units (pixels, percent, a switch). #[derive(Clone, Debug, PartialEq)] @@ -183,50 +184,24 @@ const LIGHT_ORDER: &[&str] = &[ "ADBE Casts Shadows", ]; -pub(crate) fn tdmn_chunk(name: &str) -> Chunk<'static> { - let mut b = vec![0u8; 40]; - for (i, c) in name.bytes().take(39).enumerate() { - b[i] = c; - } - Chunk::data(*b"tdmn", b) -} - -fn tdmn_name(c: &Chunk<'_>) -> Option { - let b = c.bytes()?; - let end = b.iter().position(|x| *x == 0).unwrap_or(b.len()); - std::str::from_utf8(b.get(..end)?).ok().map(str::to_string) -} - /// Sets (or inserts) the records of an options group (`tdmn` named `group`) of a layer. fn put_group(layr: &mut Chunk<'static>, group: &str, order: &[&str], set: Vec<(&str, Chunk<'static>)>, remove: &[&str]) -> R<()> { let root = list_mut(layr, b"tdgp")?; let kids = kids_mut(root)?; - let at = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(group)).ok_or_else(|| tpl("options group missing"))?; + let at = kids.iter().position(|c| is_tdmn(c, group)).ok_or_else(|| tpl("options group missing"))?; let g = kids.get_mut(at + 1).ok_or_else(|| tpl("options group without a list"))?; let gk = kids_mut(g)?; - let first = gk.iter().position(|c| c.is(b"tdmn")).unwrap_or(gk.len()); - let header: Vec> = gk.drain(..first).collect(); - let mut pairs: Vec<(String, Vec>)> = Vec::new(); - let mut it = std::mem::take(gk).into_iter(); - while let Some(m) = it.next() { - let name = tdmn_name(&m).unwrap_or_default(); - if name == "ADBE Group End" { - pairs.push((name, vec![m])); - continue; - } - let Some(node) = it.next() else { break }; - // An atom group (Light Env Atom) carries its own Group End inside the list. - pairs.push((name, vec![m, node])); - } + // An atom group (Light Env Atom) carries its own Group End inside its list. + let (header, mut pairs) = split_group(gk)?; pairs.retain(|(n, _)| !remove.contains(&n.as_str())); for (name, rec) in set { - let entry = vec![tdmn_chunk(name), rec]; + let entry = vec![tdmn(name), rec]; match pairs.iter_mut().find(|(n, _)| n == name) { Some(slot) => slot.1 = entry, None => pairs.push((name.to_string(), entry)), } } - let rank = |n: &str| if n == "ADBE Group End" { usize::MAX } else { order.iter().position(|g| *g == n).unwrap_or(order.len()) }; + let rank = |n: &str| if n == GROUP_END { usize::MAX } else { order.iter().position(|g| *g == n).unwrap_or(order.len()) }; pairs.sort_by_key(|(n, _)| rank(n)); gk.extend(header); gk.extend(pairs.into_iter().flat_map(|(_, v)| v)); diff --git a/crates/aep/src/export/emit.rs b/crates/aep/src/export/emit.rs index e7423efe9..f7b0ec6b8 100644 --- a/crates/aep/src/export/emit.rs +++ b/crates/aep/src/export/emit.rs @@ -6,8 +6,10 @@ use std::collections::HashMap; use crate::{AepFile, Chunk}; use effectcraft_project::{ItemId, LayerId}; +use crate::export::keys::fill_key_list; use crate::export::plan::{CompPlan, Entry, ItemPlan, KeyPlan, LayerPlan, LayerSrc, ProjectPlan, PropKind, PropPlan, SolidPlan, natural_key}; -use crate::export::template::Template; +use crate::export::synth::{is_tdmn, tdmn_name}; +use crate::export::template::{self, Template}; use crate::export::tree::{ R, child_data, data_mut, kids_mut, list_mut, put, put_bits, put_cstr, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, set_utf8, tpl, }; @@ -58,10 +60,14 @@ struct Cx<'t> { type Sorted = (String, u64, Vec>); pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R> { - 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> { let special = plan.root.iter().find(|e| e.name == "Solids" && matches!(e.plan, ItemPlan::Folder(_))).map(|e| e.ec); let mut cx = Cx { - t: &t, + t, ids: Ids { next: FIRST_ID }, solid_ids: HashMap::new(), comp_ids: HashMap::new(), @@ -77,16 +83,14 @@ pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R> { let mut root: Vec = 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 = 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, Vec) = root.into_iter().partition(|(_, k, _)| *k & ROOT_SOLID != 0); + root.retain(|(_, _, c)| !c.is_empty()); + for (i, (name, chunk)) in std::mem::take(&mut cx.extra_solids).into_iter().enumerate() { + let mut v = vec![chunk]; v.extend(cx.t.fset.iter().cloned()); - solids_children.push((name.clone(), i as u64, v)); + solids_children.push((name, i as u64, v)); } let mut folder = match cx.special_slot.take() { Some((item, inner)) => { @@ -125,26 +129,57 @@ pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R> { /// 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 { + 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) -> R<()> { // Iterative to bound the stack by the folder depth the plan already limits. let mut stack: Vec<&Entry> = entries.iter().rev().collect(); @@ -404,8 +439,7 @@ fn put_used_comps(cx: &Cx<'_>, item: &mut Chunk<'static>, cp: &CompPlan) -> R<() fn put_time_remap_max(layr: &mut Chunk<'static>, seconds: f64) -> R<()> { let root = list_mut(layr, b"tdgp")?; let kids = kids_mut(root)?; - let at = - kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?; + let at = kids.iter().position(|c| is_tdmn(c, "ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?; let tdbs = kids.get_mut(at + 1).ok_or_else(|| tpl("Time Remapping without a record"))?; put_f64(child_data(tdbs, b"tduM")?, 0, seconds) } @@ -420,8 +454,7 @@ fn put_time_remap_max(layr: &mut Chunk<'static>, seconds: f64) -> R<()> { fn put_time_remap(cx: &Cx<'_>, layr: &mut Chunk<'static>, remap: &PropPlan) -> R<()> { let root = list_mut(layr, b"tdgp")?; let kids = kids_mut(root)?; - let at = - kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?; + let at = kids.iter().position(|c| is_tdmn(c, "ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?; let tdbs = kids.get_mut(at + 1).ok_or_else(|| tpl("Time Remapping without a record"))?; put_u32(child_data(tdbs, b"tdsb")?, 0, 1)?; { @@ -454,21 +487,12 @@ fn put_time_remap(cx: &Cx<'_>, layr: &mut Chunk<'static>, remap: &PropPlan) -> R .find(|k| matches!(&k.body, crate::Body::List { list_type, .. } if list_type == b"list")) .cloned() .ok_or_else(|| tpl("keyed prototype without a list"))?; - let n = u32::try_from(remap.keys.len()).map_err(|_| tpl("too many keys"))?; let size = 48usize; - { - let lhd3 = child_data(&mut list, b"lhd3")?; - crate::export::synth::put_key_counts(lhd3, n, 48)?; - } let mut ldat = Vec::with_capacity(size * remap.keys.len()); for k in &remap.keys { ldat.extend(plain_record(1, k, size)?); } - let ld = kids_mut(&mut list)?; - match ld.iter_mut().find(|c| c.is(b"ldat")) { - Some(c) => *c = Chunk::data(*b"ldat", ldat), - None => ld.push(Chunk::data(*b"ldat", ldat)), - } + fill_key_list(&mut list, ldat, remap.keys.len(), size)?; if let Some(slot) = rk.get_mut(cdat_at) { *slot = list; } @@ -616,10 +640,7 @@ fn put_masks(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan) -> R<()> { let parade = crate::export::masks::build_parade(&cx.t.masks, &lp.masks, lp.source_size)?; let root = list_mut(layr, b"tdgp")?; let kids = kids_mut(root)?; - let at = kids - .iter() - .position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group")) - .ok_or_else(|| tpl("layer without a Transform group"))?; + let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?; kids.splice(at..at, parade); Ok(()) } @@ -633,10 +654,7 @@ fn put_effects(cx: &mut Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, layer let parade = crate::export::effects::build_parade(&cx.t.effects, &lp.fx, layer_id, layer_ids, lp.source_size, &mut cx.fx_use)?; let root = list_mut(layr, b"tdgp")?; let kids = kids_mut(root)?; - let at = kids - .iter() - .position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group")) - .ok_or_else(|| tpl("layer without a Transform group"))?; + let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?; kids.splice(at..at, parade); Ok(()) } @@ -659,11 +677,6 @@ const GROUP_ORDER: [&str; 13] = [ "ADBE Group End", ]; -fn tdmn_name(c: &Chunk<'_>) -> Option { - let d = c.bytes()?; - Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect()) -} - /// Inserts the properties of `lp` into the layer's Transform group, in the order the file uses. fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &CompPlan) -> R<()> { if lp.props.is_empty() { @@ -671,20 +684,19 @@ fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &C } let root = list_mut(layr, b"tdgp")?; let kids = kids_mut(root)?; - let at = kids - .iter() - .position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group")) - .ok_or_else(|| tpl("layer without a Transform group"))?; + let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?; let group = kids.get_mut(at + 1).ok_or_else(|| tpl("Transform group without a list"))?; let gk = kids_mut(group)?; // Split the group into its header (up to the first match name) and (tdmn, node) pairs. let first = gk.iter().position(|c| c.is(b"tdmn")).ok_or_else(|| tpl("empty Transform group"))?; let header: Vec> = gk.drain(..first).collect(); - let mut pairs: Vec<(String, Vec>)> = Vec::new(); + let mut pairs: Vec<(Vec, Vec>)> = Vec::new(); let mut it = std::mem::take(gk).into_iter(); while let Some(m) = it.next() { - let name = tdmn_name(&m).unwrap_or_default(); - if name == "ADBE Group End" { + // The match name as the shared decoder reads it (lossy UTF-8 up to the first NUL). Every + // name looked up below is ASCII, so a name matches exactly when its bytes do. + let name = tdmn_name(&m).unwrap_or_default().into_bytes(); + if name == b"ADBE Group End" { pairs.push((name, vec![m])); continue; } @@ -694,22 +706,19 @@ fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &C for p in &lp.props { let name = p.kind.match_name(); let built = build_prop(cx, p, lp, cp)?; - let tdmn = pairs.iter().find(|(n, _)| n == name).and_then(|(_, v)| v.first().cloned()).unwrap_or_else(|| { - cx.t.stat - .iter() - .chain(cx.t.keyd.iter()) - .find(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name)) - .cloned() - .unwrap_or_else(|| Chunk::data(*b"tdmn", Vec::new())) + let tdmn = pairs.iter().find(|(n, _)| n == name.as_bytes()).and_then(|(_, v)| v.first().cloned()).unwrap_or_else(|| { + cx.t.stat.iter().chain(cx.t.keyd.iter()).find(|c| is_tdmn(c, name)).cloned().unwrap_or_else(|| Chunk::data(*b"tdmn", Vec::new())) }); let entry = vec![tdmn, built]; - if let Some(slot) = pairs.iter_mut().find(|(n, _)| n == name) { + if let Some(slot) = pairs.iter_mut().find(|(n, _)| n == name.as_bytes()) { slot.1 = entry; } else { - pairs.push((name.to_string(), entry)); + pairs.push((name.as_bytes().to_vec(), entry)); } } - let rank = |n: &str| if n == "ADBE Group End" { usize::MAX } else { GROUP_ORDER.iter().position(|g| *g == n).unwrap_or(GROUP_ORDER.len()) }; + let rank = |n: &[u8]| { + if n == b"ADBE Group End" { usize::MAX } else { GROUP_ORDER.iter().position(|g| g.as_bytes() == n).unwrap_or(GROUP_ORDER.len()) } + }; pairs.sort_by_key(|(n, _)| rank(n)); gk.extend(header); gk.extend(pairs.into_iter().flat_map(|(_, v)| v)); @@ -743,22 +752,12 @@ fn build_prop(cx: &Cx<'_>, p: &PropPlan, lp: &LayerPlan, cp: &CompPlan) -> R *c = Chunk::data(*b"ldat", ldat), - None => ld.push(Chunk::data(*b"ldat", ldat)), - } + fill_key_list(list_mut(&mut tdbs, b"list")?, ldat, p.keys.len(), size)?; } if let Some((text, _)) = &p.expr { let kids = kids_mut(&mut tdbs)?; @@ -818,3 +817,283 @@ pub(crate) fn plain_record(n: usize, k: &KeyPlan, size: usize) -> R> { } 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 = ["", " x", " y", " z"].iter().map(|t| plan_of(&varied(t))).collect(); + let want: Vec> = plans.iter().map(|plan| emit_with(&Template::load().expect("template"), plan, &[]).expect("emit")).collect(); + let got: Vec>> = 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::>()) + }) + .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 = (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::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 { + 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> = 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()); + } +} diff --git a/crates/aep/src/export/fxsynth.rs b/crates/aep/src/export/fxsynth.rs index 4fa02f0c5..611ef7310 100644 --- a/crates/aep/src/export/fxsynth.rs +++ b/crates/aep/src/export/fxsynth.rs @@ -11,7 +11,8 @@ use crate::Chunk; use crate::effect_map::{Conv, by_match_name}; use crate::export::fx_defs::{BUILT_IN, FxDef, PDef}; -use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn}; +use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn, utf8_in}; +use crate::export::tree::Fixed; use crate::{CURVE_IDENTITY, CurvesData, MeshData}; /// The layer a prototype's layer parameters name when their default is the owning layer; the @@ -37,12 +38,6 @@ pub(crate) fn value_spec(code: u8, range: Option<(f64, f64)>) -> Option }) } -fn put(b: &mut [u8], at: usize, bytes: &[u8]) { - if let Some(dst) = b.get_mut(at..at + bytes.len()) { - dst.copy_from_slice(bytes); - } -} - fn fixed(v: f64) -> [u8; 4] { ((v * 65536.0).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32).to_be_bytes() } @@ -53,83 +48,88 @@ fn fixed(v: f64) -> [u8; 4] { /// display 124; fixed and integer: 124 / 128 / 132 / 136, default 140, display 144). Every other /// byte is zero (the switches at 48 are not needed: s12-e2-nofl). pub(crate) fn pard(p: &PDef, popup_items: Option) -> Vec { - let mut b = vec![0u8; 148]; - put(&mut b, 12, &u32::from(p.code).to_be_bytes()); + pard_layout(p, popup_items).finish(|| format!("pard (type {})", p.code)) +} + +/// The 148-byte `pard` layout of a parameter definition: type, name and defaults. +fn pard_layout(p: &PDef, popup_items: Option) -> Fixed { + let mut b = Fixed::zeroed(148); + b.put(12, &u32::from(p.code).to_be_bytes()); let d = |i: usize| p.default.get(i).copied().unwrap_or(0.0); // After Effects takes the default of a parameter without a value record from here (s8-e1: // the copies below are the ones it reads). match p.code { 10 => { - put(&mut b, 56, &d(0).to_be_bytes()); + b.put(56, &d(0).to_be_bytes()); // The default again as `f32` at 120, the display word (decimals) at 124. - put(&mut b, 120, &(d(0) as f32).to_be_bytes()); - put(&mut b, 124, &p.display.to_be_bytes()); + b.put(120, &(d(0) as f32).to_be_bytes()); + b.put(124, &p.display.to_be_bytes()); } - 2 => put(&mut b, 56, &fixed(d(0))), + 2 => b.put(56, &fixed(d(0))), // An angle: the default again at 60. 3 => { - put(&mut b, 56, &fixed(d(0))); - put(&mut b, 60, &fixed(d(0))); + b.put(56, &fixed(d(0))); + b.put(60, &fixed(d(0))); } - 1 => put(&mut b, 56, &(d(0).round() as i32).to_be_bytes()), + 1 => b.put(56, &(d(0).round() as i32).to_be_bytes()), // A checkbox: the default again as a byte at 60. 4 => { - put(&mut b, 56, &(d(0).round().max(0.0) as u32).to_be_bytes()); - put(&mut b, 60, &[u8::from(d(0) != 0.0)]); + b.put(56, &(d(0).round().max(0.0) as u32).to_be_bytes()); + b.put(60, &[u8::from(d(0) != 0.0)]); } // A popup: the default again as `u16` at 62 (the item count is at 60). 7 => { let sel = d(0).round().clamp(0.0, f64::from(u16::MAX)); - put(&mut b, 56, &(sel as u32).to_be_bytes()); - put(&mut b, 62, &(sel as u16).to_be_bytes()); + b.put(56, &(sel as u32).to_be_bytes()); + b.put(62, &(sel as u16).to_be_bytes()); } 6 => { // Fractions of the layer at 56 / 60 and the same in percent at 68 / 72. - put(&mut b, 56, &fixed(d(0))); - put(&mut b, 60, &fixed(d(1))); - put(&mut b, 68, &fixed(d(0) * 100.0)); - put(&mut b, 72, &fixed(d(1) * 100.0)); + b.put(56, &fixed(d(0))); + b.put(60, &fixed(d(1))); + b.put(68, &fixed(d(0) * 100.0)); + b.put(72, &fixed(d(1) * 100.0)); } 5 => { // ARGB at 56 and the same at 60. let c: Vec = (0..4).map(|i| d(i).round().clamp(0.0, 255.0) as u8).collect(); - put(&mut b, 56, &c); - put(&mut b, 60, &c); + b.put(56, &c); + b.put(60, &c); } _ => {} } if p.code == 7 { let n = popup_items.unwrap_or_else(|| u16::try_from(p.display).unwrap_or(0)); - put(&mut b, 60, &n.to_be_bytes()); + b.put(60, &n.to_be_bytes()); } match p.code { 10 => { if let Some(m) = p.valid_min { - put(&mut b, 104, &(m as f32).to_be_bytes()); + b.put(104, &(m as f32).to_be_bytes()); } if let Some(m) = p.valid_max { - put(&mut b, 108, &(m as f32).to_be_bytes()); + b.put(108, &(m as f32).to_be_bytes()); } if let Some((lo, hi)) = p.range { - put(&mut b, 112, &(lo as f32).to_be_bytes()); - put(&mut b, 116, &(hi as f32).to_be_bytes()); + b.put(112, &(lo as f32).to_be_bytes()); + b.put(116, &(hi as f32).to_be_bytes()); } } 1 | 2 => { let enc = |v: f64| if p.code == 2 { fixed(v) } else { (v.round() as i32).to_be_bytes() }; if let Some(m) = p.valid_min { - put(&mut b, 124, &enc(m)); + b.put(124, &enc(m)); } if let Some(m) = p.valid_max { - put(&mut b, 128, &enc(m)); + b.put(128, &enc(m)); } if let Some((lo, hi)) = p.range { - put(&mut b, 132, &enc(lo)); - put(&mut b, 136, &enc(hi)); + b.put(132, &enc(lo)); + b.put(136, &enc(hi)); } // The default again at 140, the display word (decimals, percent) at 144. - put(&mut b, 140, &enc(d(0))); - put(&mut b, 144, &p.display.to_be_bytes()); + b.put(140, &enc(d(0))); + b.put(144, &p.display.to_be_bytes()); } _ => {} } @@ -224,7 +224,7 @@ fn data_record(data: Vec) -> [Chunk<'static>; 2] { /// group (records only for layer parameters, groups, buttons and arbitrary data, the built-in /// group) and `pgui` (zero). pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> { - let fnam = Chunk { id: *b"fnam", body: crate::Body::Chunks(vec![Chunk::data(*b"Utf8", fx.name.as_bytes().to_vec())]), pad: None }; + let fnam = utf8_in(*b"fnam", fx.name); let count = u32::try_from(fx.params.len()).unwrap_or(u32::MAX); let mut part = vec![Chunk::data(*b"parn", count.to_be_bytes().to_vec())]; let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME)]; @@ -235,11 +235,11 @@ pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> { if matches!(p.code, 4 | 7 | 15) { // Checkboxes, popups and buttons have a `pdnm` (their label or items); its text may // be empty. - part.push(Chunk { id: *b"pdnm", body: crate::Body::Chunks(vec![Chunk::data(*b"Utf8", Vec::new())]), pad: None }); + part.push(utf8_in(*b"pdnm", "")); } if p.id == BUILT_IN { values.push(tdmn(&record)); - values.push(Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME), tdmn("ADBE Group End")])); + values.push(Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME), tdmn(GROUP_END)])); continue; } match p.code { @@ -265,6 +265,24 @@ pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> { _ => {} } } - values.push(tdmn("ADBE Group End")); + values.push(tdmn(GROUP_END)); Chunk::list(*b"sspc", vec![fnam, Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values), Chunk::data(*b"pgui", vec![0u8; 16])]) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::export::fx_defs::FX_DEFS; + + /// The `pard` of every parameter of the effect table fits its 148 bytes. + #[test] + fn every_parameter_definition_fits() { + for fx in FX_DEFS { + for (i, p) in fx.params.iter().enumerate() { + let b = pard_layout(p, popup_items(fx, i)); + assert!(b.error().is_none(), "{} {}", fx.match_name, i); + assert_eq!(b.finish(String::new).len(), 148); + } + } + } +} diff --git a/crates/aep/src/export/keys.rs b/crates/aep/src/export/keys.rs new file mode 100644 index 000000000..f484276fc --- /dev/null +++ b/crates/aep/src/export/keys.rs @@ -0,0 +1,198 @@ +//! Key lists shared by the exporters: the times of a property's keys in the file's units, the +//! spatial key record, and the rewrite of a key list (`LIST list`: `lhd3` + `ldat`, aep-format.md +//! 3.5). + +use effectcraft_keyframe::Keyframe; + +use crate::Chunk; +use crate::export::plan::KeyPlan; +use crate::export::report::{Collector, LossCategory}; +use crate::export::timebase::TimeBase; +use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, tpl}; + +/// The keys of a property at their times in the file's units, in time order. A key that does not +/// fall on the time base is reported as moved; keys that end up at the same time are merged (the +/// first one is kept) and reported once. `noun` names the key in the report ("key", "path key"). +pub(crate) fn timed_keys<'k>(keys: &'k [Keyframe], tb: &TimeBase, what: &str, noun: &str, col: &mut Collector) -> Vec<(i64, &'k Keyframe)> { + let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(keys.len()); + for k in keys { + let (u, inexact) = tb.units(k.time); + if inexact { + col.lose(LossCategory::RoundedTimes, format!("{what}: {noun} at {:.4} s moved to the time base", k.time.seconds())); + } + timed.push((u, k)); + } + timed.sort_by_key(|(u, _)| *u); + let before = timed.len(); + timed.dedup_by_key(|(u, _)| *u); + if timed.len() != before { + col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: {noun}s at the same time were merged")); + } + timed +} + +/// A key of a point (flags `0x0007`, `u32@8` the automatic tangent switch; the spatial layout of +/// a 2D Position, 853) or a colour (flags `0x0001`, 854): the temporal ease of a point at 24..55, +/// then the `n` values at 56 and, for a point, the spatial in and out tangents after them. +pub(crate) fn spatial_record(k: &KeyPlan, n: usize, point: bool, size: usize) -> R> { + let mut r = vec![0u8; size]; + put(&mut r, 0, &k.time.to_be_bytes())?; + put_u8(&mut r, 4, k.in_interp)?; + put_u8(&mut r, 5, k.out_interp)?; + let base: u16 = if point { 0x0007 } else { 0x0001 }; + put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?; + if point { + put_u32(&mut r, 8, u32::from(k.spatial_auto))?; + let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default()); + put_f64(&mut r, 24, i.speed)?; + put_f64(&mut r, 32, i.influence)?; + put_f64(&mut r, 40, o.speed)?; + put_f64(&mut r, 48, o.influence)?; + } + for j in 0..n { + put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?; + if point { + put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?; + put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?; + } + } + Ok(r) +} + +/// Fills a key list (`LIST list`) with `count` records of `size` bytes: the count words of `lhd3` +/// and `ldat` (added when the list has none). +pub(crate) fn fill_key_list(list: &mut Chunk<'static>, records: Vec, count: usize, size: usize) -> R<()> { + let n = u32::try_from(count).map_err(|_| tpl("too many keys"))?; + let size = u32::try_from(size).map_err(|_| tpl("record size"))?; + crate::export::synth::put_key_counts(child_data(list, b"lhd3")?, n, size)?; + let kids = kids_mut(list)?; + match kids.iter_mut().find(|c| c.is(b"ldat")) { + Some(c) => *c = Chunk::data(*b"ldat", records), + None => kids.push(Chunk::data(*b"ldat", records)), + } + Ok(()) +} + +/// Rewrites the key list of a keyed property record (`LIST tdbs`), see [`fill_key_list`]. +pub(crate) fn set_key_list(tdbs: &mut Chunk<'static>, records: Vec, count: usize, size: usize) -> R<()> { + fill_key_list(list_mut(tdbs, b"list")?, records, count, size) +} + +/// Puts the key list `list` in the place of the static value (`cdat`) of a property record. +pub(crate) fn replace_cdat(tdbs: &mut Chunk<'static>, list: Chunk<'static>) -> R<()> { + let slot = kids_mut(tdbs)?.iter_mut().find(|c| c.is(b"cdat")).ok_or_else(|| tpl("record without cdat"))?; + *slot = list; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::export::synth::key_list; + + /// Keys are timed sorted merged and reported. + #[test] + fn keys_are_timed_sorted_merged_and_reported() { + use effectcraft_keyframe::Value; + use effectcraft_time::{TICKS_PER_SECOND, Tick}; + let tb = TimeBase { frame_duration: 1024, timescale: 25600 }; + // Out of order; 1/3 s is not on the time base; two keys meet at 1 s (the first one kept). + let keys = [ + Keyframe::new(Tick(TICKS_PER_SECOND), Value::Scalar(1.0)), + Keyframe::new(Tick(TICKS_PER_SECOND / 3), Value::Scalar(2.0)), + Keyframe::new(Tick(0), Value::Scalar(3.0)), + Keyframe::new(Tick(TICKS_PER_SECOND), Value::Scalar(4.0)), + ]; + let mut col = Collector::default(); + let timed = timed_keys(&keys, &tb, "L", "path key", &mut col); + let got: Vec<(i64, f64)> = timed.iter().map(|(u, k)| (*u, if let Value::Scalar(v) = k.value { v } else { f64::NAN })).collect(); + assert_eq!(got, [(0, 3.0), (8533, 2.0), (25600, 1.0)]); + // The report lists its categories in a fixed order; one merge report for the pair. + let report = col.finish(); + let lines: Vec<(LossCategory, &str)> = report.losses.iter().flat_map(|l| l.details.iter().map(move |d| (l.category, d.as_str()))).collect(); + assert_eq!( + lines, + [ + (LossCategory::ApproximatedKeyframes, "L: path keys at the same time were merged"), + (LossCategory::RoundedTimes, "L: path key at 0.3333 s moved to the time base"), + ] + ); + // No keys, no report. + let mut col = Collector::default(); + assert!(timed_keys(&[], &tb, "L", "key", &mut col).is_empty()); + assert!(col.finish().losses.is_empty()); + } + + /// Two keys at different times that round to the same time unit are merged too: the first + /// one is kept, and both the rounding and the merge are reported. + #[test] + fn keys_that_round_to_the_same_time_are_merged() { + use effectcraft_keyframe::Value; + use effectcraft_time::Tick; + let tb = TimeBase { frame_duration: 1024, timescale: 25600 }; + // One tick is far below one unit (1/25600 s): it rounds to unit 0, where the first key is. + let keys = [Keyframe::new(Tick(0), Value::Scalar(1.0)), Keyframe::new(Tick(1), Value::Scalar(2.0))]; + let mut col = Collector::default(); + let timed = timed_keys(&keys, &tb, "L", "key", &mut col); + let got: Vec<(i64, f64)> = timed.iter().map(|(u, k)| (*u, if let Value::Scalar(v) = k.value { v } else { f64::NAN })).collect(); + assert_eq!(got, [(0, 1.0)]); + let report = col.finish(); + let lines: Vec<(LossCategory, &str)> = report.losses.iter().flat_map(|l| l.details.iter().map(move |d| (l.category, d.as_str()))).collect(); + assert_eq!( + lines, + [ + (LossCategory::ApproximatedKeyframes, "L: keys at the same time were merged"), + (LossCategory::RoundedTimes, "L: key at 0.0000 s moved to the time base"), + ] + ); + } + + /// A key list is filled and replaces the value. + #[test] + fn a_key_list_is_filled_and_replaces_the_value() { + let mut list = Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", vec![0; 52])]); + fill_key_list(&mut list, vec![7; 96], 2, 48).unwrap(); + let h = list.child(b"lhd3").and_then(|c| c.bytes()).unwrap(); + assert_eq!((h[11], h[19]), (2, 48)); + assert_eq!(list.child(b"ldat").and_then(|c| c.bytes()).map(<[u8]>::len), Some(96)); + // A second fill replaces the records instead of adding a second `ldat`. + fill_key_list(&mut list, vec![1; 48], 1, 48).unwrap(); + assert_eq!(list.children().iter().filter(|c| c.is(b"ldat")).count(), 1); + + let mut rec = Chunk::list(*b"tdbs", vec![Chunk::data(*b"tdb4", vec![0; 124]), Chunk::data(*b"cdat", vec![0; 8])]); + replace_cdat(&mut rec, key_list(48, 0, Vec::new())).unwrap(); + assert!(rec.child(b"cdat").is_none() && rec.child_list(b"list").is_some()); + // A record without a value is an error, not a quiet no-op. + assert!(replace_cdat(&mut rec, key_list(48, 0, Vec::new())).is_err()); + set_key_list(&mut rec, vec![0; 48], 1, 48).unwrap(); + assert!(set_key_list(&mut Chunk::list(*b"tdbs", Vec::new()), Vec::new(), 0, 48).is_err()); + } + + /// A spatial record holds the point layout. + #[test] + fn a_spatial_record_holds_the_point_layout() { + let k = KeyPlan { + time: 5, + value: vec![1.0, 2.0], + in_interp: 2, + out_interp: 1, + in_ease: Vec::new(), + out_ease: Vec::new(), + spatial_in: [3.0; 3], + spatial_out: [4.0; 3], + spatial_auto: true, + roving: false, + label: 1, + }; + let r = spatial_record(&k, 2, true, 56 + 24 * 2).unwrap(); + assert_eq!(&r[..12], &[0, 0, 0, 5, 2, 1, 0, 0x47, 0, 0, 0, 1]); + assert_eq!(&r[64..72], &2.0f64.to_be_bytes()); + assert_eq!(&r[96..104], &4.0f64.to_be_bytes()); + // A colour has no tangents and flags 0x0001. + let c = spatial_record(&k, 2, false, 56 + 24 * 2).unwrap(); + assert_eq!((c[7], c[11]), (0x41, 0)); + assert!(c[72..].iter().all(|b| *b == 0)); + // A record shorter than its layout is an error. + assert!(spatial_record(&k, 2, true, 64).is_err()); + } +} diff --git a/crates/aep/src/export/masks.rs b/crates/aep/src/export/masks.rs index 61d8634c4..9b235eb28 100644 --- a/crates/aep/src/export/masks.rs +++ b/crates/aep/src/export/masks.rs @@ -17,9 +17,11 @@ use crate::Chunk; use effectcraft_keyframe::{Ease, Interp, ShapePath, Value}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur, PropGroup}; +use crate::export::keys::{set_key_list, timed_keys}; use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT, cut_name}; use crate::export::report::{Collector, LossCategory}; -use crate::export::tree::{R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, tpl}; +use crate::export::synth::GROUP_END; +use crate::export::tree::{Fixed, R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, split_group, tpl}; /// The most vertices one mask path may have (as the importer). pub(crate) const MAX_VERTICES: usize = 65_536; @@ -179,20 +181,7 @@ fn plan_mask(g: &PropGroup, size: (u32, u32), cp: &CompPlan, what: &str, col: &m } // The path and its keys. let mut path_keys: Vec = Vec::new(); - let mut keys: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::new(); - for k in &path_prop.keys { - let (u, inexact) = cp.tb.units(k.time); - if inexact { - col.lose(LossCategory::RoundedTimes, format!("{what}: path key at {:.4} s moved to the time base", k.time.seconds())); - } - keys.push((u, k)); - } - keys.sort_by_key(|(u, _)| *u); - let before = keys.len(); - keys.dedup_by_key(|(u, _)| *u); - if keys.len() != before { - col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: path keys at the same time were merged")); - } + let keys = timed_keys(&path_prop.keys, &cp.tb, what, "path key", col); let side = |i: Interp| -> u8 { match i { Interp::Linear => 1, @@ -288,20 +277,7 @@ fn plan_mask(g: &PropGroup, size: (u32, u32), cp: &CompPlan, what: &str, col: &m /// Feather keys (plain template, two components, speed in pixels per second). fn plan_feather_keys(src: &[effectcraft_keyframe::Keyframe], cp: &CompPlan, what: &str, col: &mut Collector) -> Vec { - let mut keys: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::with_capacity(src.len()); - for k in src { - let (u, inexact) = cp.tb.units(k.time); - if inexact { - col.lose(LossCategory::RoundedTimes, format!("{what}: feather key at {:.4} s moved to the time base", k.time.seconds())); - } - keys.push((u, k)); - } - keys.sort_by_key(|(u, _)| *u); - let before = keys.len(); - keys.dedup_by_key(|(u, _)| *u); - if keys.len() != before { - col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: feather keys at the same time were merged")); - } + let keys = timed_keys(src, &cp.tb, what, "feather key", col); let mut out = Vec::with_capacity(keys.len()); let beyond = std::cell::Cell::new(false); for (u, k) in &keys { @@ -381,11 +357,6 @@ pub(crate) struct MaskProtos { pub keyed_feather: Chunk<'static>, } -pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option { - let d = c.bytes()?; - Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect()) -} - /// The limit of a mask's feather and expansion (`tduM` 32 000, 091). const MASK_LIMIT: f64 = 32_000.0; @@ -394,9 +365,25 @@ const MASK_LIMIT: f64 = 32_000.0; /// s13-m1), byte 3 `0x01` (`0x09` once the path is open), the box at 4..19 (written per shape), /// byte 20 1. pub(crate) fn shph() -> Vec { - let mut b = vec![0u8; 24]; - let _ = crate::export::tree::put(&mut b, 0, &[0xb3, 0xde, 0x02, 0x01]); - let _ = put_u8(&mut b, 20, 1); + shph_layout().finish(|| "shph".to_string()) +} + +/// The 24-byte `shph` layout of a closed path with a unit box. +fn shph_layout() -> Fixed { + let mut b = Fixed::zeroed(24); + b.put(0, &[0xb3, 0xde, 0x02, 0x01]); + b.put(20, &[1]); + b +} + +/// The two prototype records of a keyed path (093): 64 bytes each, flags `0x0001`, the `f64` at +/// 16 1 in the first (the rest is zero: its last bytes were uninitialised). +fn path_key_records() -> Fixed { + let mut b = Fixed::zeroed(128); + for at in [6, 64 + 6] { + b.put(at, &0x0001u16.to_be_bytes()); + } + b.put(16, &1.0f64.to_be_bytes()); b } @@ -413,15 +400,7 @@ impl MaskProtos { let path_rec = |keyed: bool| { let mut kids = vec![state(), tdsn(NO_NAME), Chunk::data(*b"tdb4", tdb4(&path, keyed, 25_600))]; if keyed { - // Path keys are 64-byte records: flags `0x0001`, the `f64` at 16 1 on the first - // key (093; the rest of the record is zero: its last bytes were uninitialised). - let mut first = vec![0u8; 64]; - let mut later = vec![0u8; 64]; - for r in [&mut first, &mut later] { - let _ = put_u16(r, 6, 0x0001); - } - let _ = put_f64(&mut first, 16, 1.0); - kids.push(key_list(64, 2, [first, later].concat())); + kids.push(key_list(64, 2, path_key_records().finish(|| "the keyed path records".to_string()))); } else { kids.push(Chunk::data(*b"cdat", vec![0u8; 4])); } @@ -444,7 +423,7 @@ impl MaskProtos { tdbs(&opacity, false, 25_600), tdmn("ADBE Mask Offset"), tdbs(&offset, false, 25_600), - tdmn("ADBE Group End"), + tdmn(GROUP_END), ], ); MaskProtos { @@ -453,7 +432,7 @@ impl MaskProtos { atom_name: tdmn("ADBE Mask Atom"), mkif: Chunk::data(*b"mkif", vec![0u8; 48]), atom, - group_end: tdmn("ADBE Group End"), + group_end: tdmn(GROUP_END), keyed_path: path_rec(true), keyed_feather: tdbs(&feather, true, 25_600), } @@ -485,27 +464,21 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R> = kids.drain(..first).collect(); - let mut pairs: Vec<(String, [Chunk<'static>; 2])> = Vec::new(); - let mut end: Option> = None; - let mut it = std::mem::take(kids).into_iter(); - while let Some(c) = it.next() { - let name = tdmn_name(&c).unwrap_or_default(); - if name == "ADBE Group End" { - end = Some(c); - continue; - } - let node = it.next().ok_or_else(|| tpl("mask match name without a node"))?; - pairs.push((name, [c, node])); - } - let end = end.ok_or_else(|| tpl("mask atom without its end"))?; - let mut out = header; - for (name, [tdmn, mut node]) in pairs { + let (mut out, entries) = split_group(kids_mut(&mut atom)?)?; + let mut has_shape = false; + for (name, entry) in entries { + // The closing Group End (no node) is kept as it is. + let [tdmn, mut node]: [Chunk<'static>; 2] = match entry.try_into() { + Ok(pair) => pair, + Err(end) => { + out.extend(end); + continue; + } + }; let keep = match name.as_str() { "ADBE Mask Shape" => { build_shape(t, &mut node, m, size)?; + has_shape = true; true } "ADBE Mask Feather" => { @@ -540,7 +513,10 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R, values: &[f64]) -> R<()> { Ok(()) } -/// Rewrites a key list (`lhd3` count words and `ldat`). -fn set_key_list(tdbs: &mut Chunk<'static>, records: Vec, count: usize, size: usize) -> R<()> { - let list = list_mut(tdbs, b"list")?; - let n = u32::try_from(count).map_err(|_| tpl("too many keys"))?; - { - let lhd3 = child_data(list, b"lhd3")?; - crate::export::synth::put_key_counts(lhd3, n, u32::try_from(size).map_err(|_| tpl("record size"))?)?; - } - let ld = kids_mut(list)?; - match ld.iter_mut().find(|c| c.is(b"ldat")) { - Some(c) => *c = Chunk::data(*b"ldat", records), - None => ld.push(Chunk::data(*b"ldat", records)), - } - Ok(()) -} - fn keyed_feather(t: &MaskProtos, keys: &[KeyPlan]) -> R> { let mut tdbs = t.keyed_feather.clone(); // The plain template with two components (208): 8 + 40 * 2 bytes. @@ -669,3 +629,37 @@ pub(crate) fn build_shap(proto: &Chunk<'static>, s: &ShapePlan) -> R MaskPlan { + let shape = ShapePlan { bbox: [0.0, 0.0, 1.0, 1.0], open: false, points: vec![[0.0, 0.0]; 3] }; + MaskPlan { + name: "M".into(), + mode: 1, + inverted: false, + shape, + path_keys: Vec::new(), + feather: None, + feather_keys: Vec::new(), + opacity: None, + expansion: None, + } + } + + /// An atom without its path is an error. + #[test] + fn an_atom_without_its_path_is_an_error() { + let mut t = MaskProtos::build(); + assert!(build_atom(&t, &mask(), (100, 100)).is_ok()); + // Without the Mask Shape pair the path of every mask would be dropped quietly. + let kids = kids_mut(&mut t.atom).unwrap(); + let at = kids.iter().position(|c| is_tdmn(c, "ADBE Mask Shape")).unwrap(); + kids.drain(at..at + 2); + assert!(build_atom(&t, &mask(), (100, 100)).is_err()); + } +} diff --git a/crates/aep/src/export/report.rs b/crates/aep/src/export/report.rs index 7caba0e7d..22145956d 100644 --- a/crates/aep/src/export/report.rs +++ b/crates/aep/src/export/report.rs @@ -7,8 +7,9 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -/// Most example lines kept per loss category (the count is always exact). -pub const MAX_DETAILS: usize = 20; +/// Most example lines kept per loss category (the count is always exact); the same as the import +/// report's. +pub const MAX_DETAILS: usize = crate::import::MAX_DETAILS; /// Why a piece of the project did not arrive in the `.aep`, or arrived changed. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -130,15 +131,9 @@ impl LossCategory { } } -/// One category of things that were not exported. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Loss { - pub category: LossCategory, - /// How many occurrences (exact). - pub count: usize, - /// Up to [`MAX_DETAILS`] example lines. - pub details: Vec, -} +/// One category of things that were not exported: the import report's loss entry with the +/// export categories. +pub type Loss = crate::import::Loss; /// What was written to the file. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -222,7 +217,7 @@ pub struct ExportReport { impl ExportReport { /// The sum of all loss counts. pub fn total_losses(&self) -> usize { - self.losses.iter().map(|l| l.count).sum() + Loss::total(&self.losses) } /// True when nothing was lost. @@ -232,12 +227,12 @@ impl ExportReport { /// The loss count of a category. pub fn count(&self, category: LossCategory) -> usize { - self.losses.iter().find(|l| l.category == category).map_or(0, |l| l.count) + Loss::count_of(&self.losses, category) } /// `effects: 3, masks: 1` (empty when lossless). pub fn summary(&self) -> String { - self.losses.iter().map(|l| format!("{}: {}", l.category.label(), l.count)).collect::>().join(", ") + Loss::summary(&self.losses, LossCategory::label) } } @@ -251,11 +246,7 @@ pub(crate) struct Collector { impl Collector { /// Records one loss. pub fn lose(&mut self, category: LossCategory, detail: impl Into) { - let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() }); - loss.count = loss.count.saturating_add(1); - if loss.details.len() < MAX_DETAILS { - loss.details.push(detail.into()); - } + Loss::record(&mut self.losses, category, move || detail.into()); } pub fn finish(self) -> ExportReport { @@ -266,3 +257,56 @@ impl Collector { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A report with losses in two categories, as the collector finishes it. + fn collected() -> ExportReport { + let mut col = Collector::default(); + col.exported.layers = 2; + col.lose(LossCategory::RoundedTimes, "time 1"); + col.lose(LossCategory::Effects, String::from("fx 1")); + col.lose(LossCategory::Effects, "fx 2"); + let mut report = col.finish(); + report.limitations.truncate(1); + report.limitations[0] = "limit".into(); + report + } + + /// The JSON shape agents read: field names, category spelling, order and the loss entries. + #[test] + fn the_json_shape_is_pinned() { + let json = serde_json::to_string(&collected()).unwrap(); + assert_eq!( + json, + r#"{"exported":{"folders":0,"compositions":0,"solids":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"properties":0,"keyframes":0,"expressions":0,"masks":0,"precompLayers":0,"effects":0,"footage":0,"footageLayers":0,"textLayers":0,"markers":0,"cameras":0,"lights":0,"shapeLayers":0,"timeRemaps":0,"textAnimators":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"],"unlisted":0},{"category":"rounded_times","count":1,"details":["time 1"],"unlisted":0}],"limitations":["limit"]}"# + ); + let back: ExportReport = serde_json::from_str(&json).unwrap(); + assert_eq!(back, collected()); + } + + /// The queries read the losses. + #[test] + fn the_queries_read_the_losses() { + let report = collected(); + assert_eq!((report.total_losses(), report.is_lossless()), (3, false)); + assert_eq!((report.count(LossCategory::Effects), report.count(LossCategory::Lights)), (2, 0)); + assert_eq!(report.summary(), "effects: 2, rounded times: 1"); + let empty = Collector::default().finish(); + assert_eq!((empty.total_losses(), empty.is_lossless(), empty.summary()), (0, true, String::new())); + } + + /// Details stop at the cap. + #[test] + fn details_stop_at_the_cap() { + let mut col = Collector::default(); + for i in 0..MAX_DETAILS + 5 { + col.lose(LossCategory::Masks, format!("mask {i}")); + } + let report = col.finish(); + assert_eq!(report.count(LossCategory::Masks), MAX_DETAILS + 5); + assert_eq!(report.losses[0].details.len(), MAX_DETAILS); + } +} diff --git a/crates/aep/src/export/shapes.rs b/crates/aep/src/export/shapes.rs index 74cd49d5b..981286604 100644 --- a/crates/aep/src/export/shapes.rs +++ b/crates/aep/src/export/shapes.rs @@ -24,10 +24,12 @@ use crate::Chunk; use effectcraft_keyframe::{Gradient, Interp, Keyframe, Value}; use effectcraft_project::{Node, PropGroup, Property}; +use crate::export::keys::{fill_key_list, replace_cdat, spatial_record, timed_keys}; use crate::export::masks::ShapePlan; use crate::export::plan::{CompPlan, EaseSlot, KeyPlan}; use crate::export::report::{Collector, LossCategory}; -use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, set_utf8, tpl}; +use crate::export::synth::{GROUP_END, is_tdmn, tdmn}; +use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, put_u8, set_utf8, tpl}; /// The deepest group nesting written. const MAX_DEPTH: usize = 16; @@ -192,7 +194,7 @@ impl ShapeProtos { put_f64(child_data(&mut node, b"tdum")?, 0, lo)?; put_f64(child_data(&mut node, b"tduM")?, 0, hi)?; } - Ok([crate::export::camlight::tdmn_chunk(name), node]) + Ok([tdmn(name), node]) } } @@ -267,20 +269,7 @@ pub(crate) struct ShCx<'a> { /// The keys of `p` in the file's units. fn plan_keys(p: &Property, conv: Conv, layout: Layout, what: &str, cx: &mut ShCx<'_>) -> Vec { - let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(p.keys.len()); - for k in &p.keys { - let (u, inexact) = cx.cp.tb.units(k.time); - if inexact { - cx.col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds())); - } - timed.push((u, k)); - } - timed.sort_by_key(|(u, _)| *u); - let before = timed.len(); - timed.dedup_by_key(|(u, _)| *u); - if timed.len() != before { - cx.col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged")); - } + let timed = timed_keys(&p.keys, &cx.cp.tb, what, "key", cx.col); let all: Vec = timed.iter().map(|(_, k)| (*k).clone()).collect(); let mut out = Vec::with_capacity(timed.len()); let beyond = std::cell::Cell::new(false); @@ -618,38 +607,6 @@ pub(crate) fn plan_contents(contents: &PropGroup, what: &str, depth: usize, cx: out } -fn tdmn_name(c: &Chunk<'_>) -> Option { - let b = c.bytes()?; - let end = b.iter().position(|x| *x == 0).unwrap_or(b.len()); - std::str::from_utf8(b.get(..end)?).ok().map(str::to_string) -} - -/// A key of a point (the spatial layout of a 2D Position, 853) or a colour (854). -fn spatial_key(k: &KeyPlan, n: usize, point: bool, size: usize) -> R> { - let mut r = vec![0u8; size]; - put(&mut r, 0, &k.time.to_be_bytes())?; - put_u8(&mut r, 4, k.in_interp)?; - put_u8(&mut r, 5, k.out_interp)?; - let base: u16 = if point { 0x0007 } else { 0x0001 }; - put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?; - if point { - put_u32(&mut r, 8, u32::from(k.spatial_auto))?; - let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default()); - put_f64(&mut r, 24, i.speed)?; - put_f64(&mut r, 32, i.influence)?; - put_f64(&mut r, 40, o.speed)?; - put_f64(&mut r, 48, o.influence)?; - } - for j in 0..n { - put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?; - if point { - put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?; - put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?; - } - } - Ok(r) -} - /// The value record of `name` holding `v`. fn value_record(t: &ShapeProtos, name: &'static str, v: &RecVal) -> R<[Chunk<'static>; 2]> { let [m, node] = t.record(name)?; @@ -681,32 +638,17 @@ pub(crate) fn build_record(m: Chunk<'static>, mut node: Chunk<'static>, layout: put_u8(tdb4, 79, 0)?; } } - let mut list = key_list.clone(); - let count = u32::try_from(v.keys.len()).map_err(|_| tpl("too many keys"))?; - { - let lhd3 = child_data(&mut list, b"lhd3")?; - crate::export::synth::put_key_counts(lhd3, count, u32::try_from(size).map_err(|_| tpl("record size"))?)?; - } let mut ldat = Vec::with_capacity(size * v.keys.len()); for k in &v.keys { ldat.extend(match layout { - Layout::Point => spatial_key(k, n, true, size)?, - Layout::Color => spatial_key(k, n, false, size)?, + Layout::Point => spatial_record(k, n, true, size)?, + Layout::Color => spatial_record(k, n, false, size)?, _ => crate::export::emit::plain_record(n, k, size)?, }); } - { - let lk = kids_mut(&mut list)?; - match lk.iter_mut().find(|c| c.is(b"ldat")) { - Some(c) => *c = Chunk::data(*b"ldat", ldat), - None => lk.push(Chunk::data(*b"ldat", ldat)), - } - } - let kids = kids_mut(&mut node)?; - let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("shape record without cdat"))?; - if let Some(slot) = kids.get_mut(at) { - *slot = list; - } + let mut list = key_list.clone(); + fill_key_list(&mut list, ldat, v.keys.len(), size)?; + replace_cdat(&mut node, list)?; } if let Some((text, enabled)) = &v.expr { // As on the Transform properties (R15; 856). @@ -746,7 +688,7 @@ pub(crate) fn build_items(t: &ShapeProtos, items: &[ShItem], depth: usize) -> R< if depth > MAX_DEPTH { return Ok(out); } - let pos = |kids: &[Chunk<'static>], n: &str| kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(n)); + let pos = |kids: &[Chunk<'static>], n: &str| kids.iter().position(|c| is_tdmn(c, n)); for item in items { match item { ShItem::Off(inner) => { @@ -766,7 +708,7 @@ pub(crate) fn build_items(t: &ShapeProtos, items: &[ShItem], depth: usize) -> R< let vg = pos(kids, "ADBE Vectors Group").ok_or_else(|| tpl("group prototype without contents"))?; let contents = kids.get_mut(vg + 1).ok_or_else(|| tpl("group prototype without contents"))?; let ck = kids_mut(contents)?; - let end = ck.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Group End")).unwrap_or(ck.len()); + let end = ck.iter().position(|c| is_tdmn(c, GROUP_END)).unwrap_or(ck.len()); ck.splice(end..end, children); let tg = pos(kids, "ADBE Vector Transform Group").ok_or_else(|| tpl("group prototype without transform"))?; let tnode = kids.get_mut(tg + 1).ok_or_else(|| tpl("group prototype without transform"))?; @@ -815,13 +757,10 @@ pub(crate) fn put_contents(layr: &mut Chunk<'static>, t: &ShapeProtos, items: &[ let built = build_items(t, items, 0)?; let root = list_mut(layr, b"tdgp")?; let kids = kids_mut(root)?; - let at = kids - .iter() - .position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Root Vectors Group")) - .ok_or_else(|| tpl("shape layer without contents"))?; + let at = kids.iter().position(|c| is_tdmn(c, "ADBE Root Vectors Group")).ok_or_else(|| tpl("shape layer without contents"))?; let rv = kids.get_mut(at + 1).ok_or_else(|| tpl("shape layer without contents"))?; let rk = kids_mut(rv)?; - let end = rk.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Group End")).unwrap_or(rk.len()); + let end = rk.iter().position(|c| is_tdmn(c, GROUP_END)).unwrap_or(rk.len()); rk.splice(end..end, built); Ok(()) } diff --git a/crates/aep/src/export/skeleton.rs b/crates/aep/src/export/skeleton.rs index 037b4d865..38a2c17ab 100644 --- a/crates/aep/src/export/skeleton.rs +++ b/crates/aep/src/export/skeleton.rs @@ -4,14 +4,14 @@ //! e1-sparse). use crate::Chunk; -use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdbs, tdmn, tdsn}; +use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdbs, tdmn, tdmn_name, tdsn}; /// A group: its match name and a `tdgp` with the state `state` (`tdsb`), no user name, `kids` and /// the closing `ADBE Group End`. pub(crate) fn group(name: &str, state: u32, kids: Vec>) -> [Chunk<'static>; 2] { let mut body = vec![Chunk::data(*b"tdsb", state.to_be_bytes().to_vec()), tdsn(NO_NAME)]; body.extend(kids); - body.push(tdmn("ADBE Group End")); + body.push(tdmn(GROUP_END)); [tdmn(name), Chunk::list(*b"tdgp", body)] } @@ -61,7 +61,7 @@ pub(crate) fn av_layer() -> Vec> { let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn("ADBE Time Remapping"), time_remap()]; root.extend(group("ADBE Transform Group", 1, crate::export::tree_spec::build(crate::export::tree_defs::AV_TRANSFORM))); root.extend(layer_styles()); - root.push(tdmn("ADBE Group End")); + root.push(tdmn(GROUP_END)); let layr = Chunk::list( *b"Layr", vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()], @@ -78,7 +78,7 @@ pub(crate) fn av_layer() -> Vec> { pub(crate) fn layer_block(nodes: &[crate::export::tree_spec::Node]) -> Vec> { let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn("")]; root.extend(crate::export::tree_spec::build(nodes)); - root.push(tdmn("ADBE Group End")); + root.push(tdmn(GROUP_END)); let layr = Chunk::list( *b"Layr", vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()], @@ -125,7 +125,7 @@ pub(crate) fn text_layer(doc: &[u8]) -> Vec> { root.extend(group("ADBE Text Properties", 1, props)); root.extend(group("ADBE Transform Group", 1, crate::export::tree_spec::build(crate::export::tree_defs::AV_TRANSFORM))); root.extend(layer_styles()); - root.push(tdmn("ADBE Group End")); + root.push(tdmn(GROUP_END)); let layr = Chunk::list( *b"Layr", vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()], @@ -141,7 +141,7 @@ pub(crate) fn pairs(nodes: &[crate::export::tree_spec::Node]) -> Vec<(String, [C let mut out = Vec::new(); let mut it = crate::export::tree_spec::build(nodes).into_iter(); while let (Some(m), Some(n)) = (it.next(), it.next()) { - let name = String::from_utf8_lossy(m.bytes().unwrap_or_default().split(|b| *b == 0).next().unwrap_or_default()).into_owned(); + let name = tdmn_name(&m).unwrap_or_default(); out.push((name, [m, n])); } out diff --git a/crates/aep/src/export/styles.rs b/crates/aep/src/export/styles.rs index 4b8cb51f1..1533de7b9 100644 --- a/crates/aep/src/export/styles.rs +++ b/crates/aep/src/export/styles.rs @@ -23,8 +23,10 @@ use effectcraft_project::build::Ids; use effectcraft_project::styles::{self as st, STYLE_BLEND_MODES}; use effectcraft_project::{Comp, Layer, PropGroup, Property}; +use crate::export::keys::{fill_key_list, replace_cdat, timed_keys}; use crate::export::plan::{CompPlan, EaseSlot, KeyPlan}; use crate::export::report::{Collector, LossCategory}; +use crate::export::synth::{GROUP_END, is_tdmn}; use crate::export::tree::{R, child_data, kids_mut, put_f64, put_u8, put_u32, tpl}; /// A record's value. @@ -128,20 +130,7 @@ fn plan_record(spec: &StyleParam, full: String, prop: &Property, cp: &CompPlan, /// The keys of a scalar in the layer's time base: linear, hold and bezier sides with the ease in /// stored units per second (the plain template of 976). fn plan_number_keys(prop: &Property, cp: &CompPlan, what: &str, col: &mut Collector) -> Vec { - let mut timed: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::with_capacity(prop.keys.len()); - for k in &prop.keys { - let (u, inexact) = cp.tb.units(k.time); - if inexact { - col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds())); - } - timed.push((u, k)); - } - timed.sort_by_key(|(u, _)| *u); - let before = timed.len(); - timed.dedup_by_key(|(u, _)| *u); - if timed.len() != before { - col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged")); - } + let timed = timed_keys(&prop.keys, &cp.tb, what, "key", col); let mut out = Vec::with_capacity(timed.len()); let beyond = std::cell::Cell::new(false); for (u, k) in timed { @@ -279,11 +268,6 @@ pub(crate) struct StyleProtos { keyed: Chunk<'static>, } -fn tdmn_name(c: &Chunk<'_>) -> Option { - let d = c.bytes()?; - Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect()) -} - impl StyleProtos { /// The records built in code from the style table (`style_defs`); the keyed form of a scalar /// takes `tdb4` bytes 5, 68 and 79 and the key list of a keyed one-component record. @@ -322,28 +306,13 @@ impl StyleProtos { } } let mut list = self.keyed.child_list(b"list").cloned().ok_or_else(|| tpl("keyed style prototype without keys"))?; - let n = u32::try_from(keys.len()).map_err(|_| tpl("too many keys"))?; let size = 48usize; - { - let lhd3 = child_data(&mut list, b"lhd3")?; - crate::export::synth::put_key_counts(lhd3, n, 48)?; - } let mut ldat = Vec::with_capacity(size * keys.len()); for k in keys { ldat.extend(crate::export::emit::plain_record(1, k, size)?); } - { - let lk = kids_mut(&mut list)?; - match lk.iter_mut().find(|c| c.is(b"ldat")) { - Some(c) => *c = Chunk::data(*b"ldat", ldat), - None => lk.push(Chunk::data(*b"ldat", ldat)), - } - } - let kids = kids_mut(&mut rec)?; - let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("style record without cdat"))?; - if let Some(slot) = kids.get_mut(at) { - *slot = list; - } + fill_key_list(&mut list, ldat, keys.len(), size)?; + replace_cdat(&mut rec, list)?; } } if let Some((text, enabled)) = &r.expr { @@ -362,7 +331,7 @@ impl StyleProtos { /// The node after the `tdmn` named `name` among `kids`. fn named_mut<'a>(kids: &'a mut [Chunk<'static>], name: &str) -> R<&'a mut Chunk<'static>> { - let i = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name)).ok_or_else(|| tpl("layer styles group incomplete"))?; + let i = kids.iter().position(|c| is_tdmn(c, name)).ok_or_else(|| tpl("layer styles group incomplete"))?; kids.get_mut(i + 1).ok_or_else(|| tpl("layer styles group incomplete")) } @@ -377,7 +346,7 @@ fn insert_records(protos: &StyleProtos, g: &mut Chunk<'static>, recs: &[Record], built.extend(protos.build(r)?); } let kids = kids_mut(g)?; - let at = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(before)).ok_or_else(|| tpl("layer styles group incomplete"))?; + let at = kids.iter().position(|c| is_tdmn(c, before)).ok_or_else(|| tpl("layer styles group incomplete"))?; kids.splice(at..at, built); Ok(()) } @@ -392,12 +361,12 @@ pub(crate) fn put_styles(protos: &StyleProtos, layr: &mut Chunk<'static>, plan: set_flags(bo, sm::FLAGS_ON)?; insert_records(protos, bo, &plan.global, sm::ADV_BLEND)?; let adv = named_mut(kids_mut(bo)?, sm::ADV_BLEND)?; - insert_records(protos, adv, &plan.adv, "ADBE Group End")?; + insert_records(protos, adv, &plan.adv, GROUP_END)?; } for (prefix, recs, on) in &plan.styles { let g = named_mut(kids_mut(ls)?, &format!("{prefix}/enabled"))?; set_flags(g, if *on { sm::FLAGS_ON } else { sm::FLAGS_OFF })?; - insert_records(protos, g, recs, "ADBE Group End")?; + insert_records(protos, g, recs, GROUP_END)?; } Ok(()) } diff --git a/crates/aep/src/export/synth.rs b/crates/aep/src/export/synth.rs index 07d75c194..8af515f24 100644 --- a/crates/aep/src/export/synth.rs +++ b/crates/aep/src/export/synth.rs @@ -4,9 +4,10 @@ //! evidence, or zero where After Effects was shown to accept zero (the read-backs listed in //! `docs/aep-export.md`, "Built in code"). +use crate::export::tree::{Fixed, R, put_u32}; use crate::{Body, Chunk}; -/// `tdmn`: a match name padded to 40 bytes. +/// `tdmn`: a match name padded to 40 bytes (at most 39 bytes of the name, so that a NUL remains). pub(crate) fn tdmn(name: &str) -> Chunk<'static> { let mut b = vec![0u8; 40]; for (slot, c) in b.iter_mut().zip(name.bytes().take(39)) { @@ -15,12 +16,34 @@ pub(crate) fn tdmn(name: &str) -> Chunk<'static> { Chunk::data(*b"tdmn", b) } +/// The match name that closes a property group. +pub(crate) const GROUP_END: &str = "ADBE Group End"; + +/// The match name in a `tdmn`: the bytes up to the first NUL as UTF-8, invalid sequences +/// replaced, as the importer reads it (`container::typed`). After Effects writes ASCII here +/// (aep-format.md 3.4; every `tdmn` of the corpus is ASCII); `None` when `c` is not a data chunk. +pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option { + let b = c.bytes()?; + let end = b.iter().position(|x| *x == 0).unwrap_or(b.len()); + Some(String::from_utf8_lossy(b.get(..end)?).into_owned()) +} + +/// Whether `c` is a `tdmn` naming `name`. +pub(crate) fn is_tdmn(c: &Chunk<'_>, name: &str) -> bool { + c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name) +} + /// The user name of a property that has none (`-_0_/-`, aep-format.md 3.4). pub(crate) const NO_NAME: &str = "-_0_/-"; /// `tdsn`: the user name, a nested chunk holding one `Utf8`. pub(crate) fn tdsn(name: &str) -> Chunk<'static> { - Chunk { id: *b"tdsn", body: Body::Chunks(vec![Chunk::data(*b"Utf8", name.as_bytes().to_vec())]), pad: None } + utf8_in(*b"tdsn", name) +} + +/// A nested chunk `id` holding one `Utf8` with `text` (`tdsn`, `fnam`, `pdnm`). +pub(crate) fn utf8_in(id: [u8; 4], text: &str) -> Chunk<'static> { + Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.as_bytes().to_vec())]), pad: None } } /// How a property's keys are stored (`tdb4` byte 7, aep-format.md 3.5). @@ -100,13 +123,13 @@ impl RecSpec { /// word and the constant doubles at 16..56 are written; After Effects reads the other bytes as /// zero (read-back e1-ztdb4d). pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec { - let mut b = vec![0u8; 124]; - let set = |b: &mut Vec, at: usize, bytes: &[u8]| { - if let Some(dst) = b.get_mut(at..at + bytes.len()) { - dst.copy_from_slice(bytes); - } - }; - set(&mut b, 0, &[0xdb, 0x99, 0x00, spec.dims]); + tdb4_layout(spec, keyed, time_scale).finish(|| "tdb4".to_string()) +} + +/// The 124-byte `tdb4` property header layout for a record spec. +fn tdb4_layout(spec: &RecSpec, keyed: bool, time_scale: u32) -> Fixed { + let mut b = Fixed::zeroed(124); + b.put(0, &[0xdb, 0x99, 0x00, spec.dims]); let spatial = match spec.template { KeyTemplate::Plain => 0, KeyTemplate::Spatial(t) => t, @@ -115,19 +138,19 @@ pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec { Some(b) => (b & !0x01) | u8::from(!keyed), None => (if spatial == 0 { 0 } else { spatial_byte5(spatial) }) | u8::from(!keyed), }; - set(&mut b, 5, &[byte5]); - set(&mut b, 7, &[spatial]); - set(&mut b, 8, &spec.class.to_be_bytes()); - set(&mut b, 12, &time_scale.to_be_bytes()); + b.put(5, &[byte5]); + b.put(7, &[spatial]); + b.put(8, &spec.class.to_be_bytes()); + b.put(12, &time_scale.to_be_bytes()); // Every record After Effects writes carries 0.0001 and four 1.0 here; without them a roving // key reads back at time zero instead of its retimed place (read-back s14-re-keyframes). - set(&mut b, 16, &1e-4f64.to_be_bytes()); + b.put(16, &1e-4f64.to_be_bytes()); for at in [24, 32, 40, 48] { - set(&mut b, at, &1.0f64.to_be_bytes()); + b.put(at, &1.0f64.to_be_bytes()); } - set(&mut b, 68, &[u8::from(keyed)]); + b.put(68, &[u8::from(keyed)]); // Set on a static spatial property, cleared once it is keyed (aep-format.md 3.4, byte 79). - set(&mut b, 79, &[u8::from(spatial != 0 && !keyed)]); + b.put(79, &[u8::from(spatial != 0 && !keyed)]); b } @@ -135,16 +158,21 @@ pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec { /// 3.5): word 0 `0x00d00bee`, word 2 the count, word 3 the blocks of four (at least one), word 4 /// the size, words 5 and 7 the capacity (the count rounded up to four, at least two), word 6 one. pub(crate) fn key_list(size: u32, count: u32, records: Vec) -> Chunk<'static> { - let mut lhd3 = vec![0u8; 52]; - // Cannot fail: the header is 52 bytes. - let _ = crate::export::tree::put_u32(&mut lhd3, 0, 0x00d0_0bee); - let _ = crate::export::tree::put_u32(&mut lhd3, 24, 1); - let _ = put_key_counts(&mut lhd3, count, size); + let lhd3 = key_list_header(size, count).finish(|| "lhd3".to_string()); Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", records)]) } +/// The 52-byte `lhd3` key list header for `count` records of `size` bytes. +fn key_list_header(size: u32, count: u32) -> Fixed { + let mut lhd3 = Fixed::zeroed(52); + lhd3.put(0, &0x00d0_0bee_u32.to_be_bytes()); + lhd3.put(24, &1u32.to_be_bytes()); + lhd3.apply(|d| put_key_counts(d, count, size)); + lhd3 +} + /// Sets the count words of a key list header for `count` keys of `size` bytes. -pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> crate::export::tree::R<()> { +pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> R<()> { let capacity = count.div_ceil(4).saturating_mul(4).max(2); put_counts(lhd3, count, count.div_ceil(4).max(1), size, capacity, capacity) } @@ -153,8 +181,7 @@ pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> crate::e /// record size, word 5 `w5`, word 7 the capacity. A key list has `w5` = the capacity; a path /// list counts vertices: `blocks` = `w5` = the vertex count, the capacity four times it (090: /// 12 points, 4, 4 and 16; aep-format.md 3.5). -pub(crate) fn put_counts(lhd3: &mut [u8], count: u32, blocks: u32, size: u32, w5: u32, capacity: u32) -> crate::export::tree::R<()> { - use crate::export::tree::put_u32; +pub(crate) fn put_counts(lhd3: &mut [u8], count: u32, blocks: u32, size: u32, w5: u32, capacity: u32) -> R<()> { put_u32(lhd3, 8, count)?; put_u32(lhd3, 12, blocks)?; put_u32(lhd3, 16, size)?; @@ -204,6 +231,38 @@ mod tests { assert_eq!(rec.child(b"cdat").and_then(|c| c.bytes()).map(<[u8]>::len), Some(40)); } + /// Every record header fits. + #[test] + fn every_record_header_fits() { + for dims in 0..=u8::MAX { + for template in [KeyTemplate::Plain, KeyTemplate::Spatial(1), KeyTemplate::Spatial(3), KeyTemplate::Spatial(u8::MAX)] { + for byte5 in [None, Some(0xff)] { + let spec = RecSpec { dims, template, class: u32::MAX, limits: None, byte5 }; + assert!(tdb4_layout(&spec, false, u32::MAX).error().is_none() && tdb4_layout(&spec, true, 0).error().is_none()); + } + } + } + for (size, count) in [(0, 0), (u32::MAX, u32::MAX), (48, 5)] { + let h = key_list_header(size, count); + assert!(h.error().is_none()); + assert_eq!(h.finish(String::new).len(), 52); + } + } + + /// A match name reads as the importer reads it. + #[test] + fn a_match_name_reads_as_the_importer_reads_it() { + let m = tdmn("ADBE Group End"); + assert_eq!(tdmn_name(&m).as_deref(), Some(GROUP_END)); + assert!(is_tdmn(&m, GROUP_END) && !is_tdmn(&m, "ADBE Group") && !is_tdmn(&tdsn(GROUP_END), GROUP_END)); + // At most 39 bytes, so that a NUL remains. + let long = "x".repeat(50); + assert_eq!(tdmn_name(&tdmn(&long)).map(|n| n.len()), Some(39)); + // Not ASCII (After Effects writes ASCII): UTF-8, invalid bytes replaced, as on import. + let odd = Chunk::data(*b"tdmn", vec![b'A', 0xc3, 0xa9, 0xff, 0, b'B']); + assert_eq!(tdmn_name(&odd).as_deref(), Some("A\u{e9}\u{fffd}")); + } + #[test] fn a_key_list_header_counts_and_rounds() { let l = key_list(48, 5, vec![0; 240]); diff --git a/crates/aep/src/export/template.rs b/crates/aep/src/export/template.rs index 91daba636..f26be4c84 100644 --- a/crates/aep/src/export/template.rs +++ b/crates/aep/src/export/template.rs @@ -81,6 +81,24 @@ fn transform_records(keyed: bool) -> Vec> { .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