From 5f37f781174f7b785252afc4d582163e59bba6d3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 03:43:01 +0100 Subject: [PATCH 1/4] aep export: one key-time loop, key-list rewrite, tdmn decoder and byte writer for the listed modules (#3278) In masks, shapes, styles, synth, fxsynth, base, camlight, skeleton, textanim and tree (effects.rs, plan.rs and emit.rs keep their copies for the follow-up work): - New export/keys.rs: the key-time loop (timed_keys), the key-list rewrite (fill_key_list, set_key_list, replace_cdat) and the spatial key record, shared by masks, shapes and styles. - One tdmn decoder (synth::tdmn_name, lossy UTF-8 up to the first NUL, as the importer reads it) and one encoder (synth::tdmn) instead of the copies in masks, styles, shapes, textanim, camlight and skeleton. - One byte writer: tree::put, which errors. base::bytes, synth::tdb4, synth::key_list, fxsynth::pard, masks::shph and the keyed path records write through it; their fixed layouts keep the infallible signatures behind tree::fixed, which logs, and a test builds every layout and requires no error. - camlight::put_group uses the shared group split (tree::split_group), which errors on a match name without a node instead of stopping quietly. Export output is byte-identical on the corpus and the synthetic fixtures. --- Cargo.lock | 1 + crates/aep/Cargo.toml | 1 + crates/aep/src/export.rs | 1 + crates/aep/src/export/base.rs | 60 ++++++------- crates/aep/src/export/camlight.rs | 39 ++------- crates/aep/src/export/fxsynth.rs | 97 ++++++++++++--------- crates/aep/src/export/keys.rs | 139 ++++++++++++++++++++++++++++++ crates/aep/src/export/masks.rs | 120 ++++++++------------------ crates/aep/src/export/shapes.rs | 89 +++---------------- crates/aep/src/export/skeleton.rs | 12 +-- crates/aep/src/export/styles.rs | 49 ++--------- crates/aep/src/export/synth.rs | 103 ++++++++++++++++------ crates/aep/src/export/textanim.rs | 13 +-- crates/aep/src/export/tree.rs | 102 ++++++++++++++++++++-- 14 files changed, 476 insertions(+), 350 deletions(-) create mode 100644 crates/aep/src/export/keys.rs diff --git a/Cargo.lock b/Cargo.lock index f897737db..8e06b3afe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1453,6 +1453,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 b1bf3f9fe..d1fbc4a3a 100644 --- a/crates/aep/Cargo.toml +++ b/crates/aep/Cargo.toml @@ -17,6 +17,7 @@ effectcraft-project = { workspace = true } effectcraft-effects = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } +log = { workspace = true } [dev-dependencies] serde_json = { workspace = true } 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..3e2b043ff 100644 --- a/crates/aep/src/export/base.rs +++ b/crates/aep/src/export/base.rs @@ -6,17 +6,21 @@ //! (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::{R, fixed, put}; -/// 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 { + fixed("a record", try_bytes(len, fields), || vec![0u8; len]) +} + +fn try_bytes(len: usize, fields: &[(usize, &[u8])]) -> R> { let mut b = vec![0u8; len]; for (at, v) in fields { - if let Some(dst) = b.get_mut(*at..at.saturating_add(v.len())) { - dst.copy_from_slice(v); - } + put(&mut b, *at, v)?; } - b + Ok(b) } pub(crate) fn utf8(text: &str) -> Chunk<'static> { @@ -52,12 +56,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 +157,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 +280,13 @@ pub(crate) fn solid_item() -> Chunk<'static> { mod tests { use super::*; + #[test] + fn a_field_out_of_range_is_an_error() { + assert_eq!(try_bytes(4, &[(0, &[1]), (3, &[2])]).ok(), Some(vec![1, 0, 0, 2])); + assert!(try_bytes(4, &[(3, &[1, 2])]).is_err()); + assert!(try_bytes(4, &[(usize::MAX, &[1])]).is_err()); + } + /// 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/fxsynth.rs b/crates/aep/src/export/fxsynth.rs index 4fa02f0c5..e4bf002b9 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::{R, fixed as fixed_layout, put}; 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,87 +48,91 @@ 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 { + fixed_layout("pard", try_pard(p, popup_items), || vec![0u8; 148]) +} + +fn try_pard(p: &PDef, popup_items: Option) -> R> { let mut b = vec![0u8; 148]; - put(&mut b, 12, &u32::from(p.code).to_be_bytes()); + put(&mut b, 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()); + put(&mut b, 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()); + put(&mut b, 120, &(d(0) as f32).to_be_bytes())?; + put(&mut b, 124, &p.display.to_be_bytes())?; } - 2 => put(&mut b, 56, &fixed(d(0))), + 2 => put(&mut b, 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))); + put(&mut b, 56, &fixed(d(0)))?; + put(&mut b, 60, &fixed(d(0)))?; } - 1 => put(&mut b, 56, &(d(0).round() as i32).to_be_bytes()), + 1 => put(&mut b, 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)]); + 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)])?; } // 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()); + put(&mut b, 56, &(sel as u32).to_be_bytes())?; + put(&mut b, 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)); + 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))?; } 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); + put(&mut b, 56, &c)?; + put(&mut b, 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()); + put(&mut b, 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()); + put(&mut b, 104, &(m as f32).to_be_bytes())?; } if let Some(m) = p.valid_max { - put(&mut b, 108, &(m as f32).to_be_bytes()); + put(&mut b, 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()); + put(&mut b, 112, &(lo as f32).to_be_bytes())?; + put(&mut b, 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)); + put(&mut b, 124, &enc(m))?; } if let Some(m) = p.valid_max { - put(&mut b, 128, &enc(m)); + put(&mut b, 128, &enc(m))?; } if let Some((lo, hi)) = p.range { - put(&mut b, 132, &enc(lo)); - put(&mut b, 136, &enc(hi)); + put(&mut b, 132, &enc(lo))?; + put(&mut b, 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()); + put(&mut b, 140, &enc(d(0)))?; + put(&mut b, 144, &p.display.to_be_bytes())?; } _ => {} } - b + Ok(b) } /// The default arbitrary data of an effect (its type-11 parameter), from the decoded layouts @@ -224,7 +223,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 +234,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 +264,22 @@ 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() { + assert_eq!(try_pard(p, popup_items(fx, i)).map(|b| b.len()).ok(), Some(148), "{} {}", fx.match_name, i); + } + } + } +} diff --git a/crates/aep/src/export/keys.rs b/crates/aep/src/export/keys.rs new file mode 100644 index 000000000..fdcad87c2 --- /dev/null +++ b/crates/aep/src/export/keys.rs @@ -0,0 +1,139 @@ +//! 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; + + #[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()); + } + + #[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..eb61b0a09 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::{R, child_data, child_mut, fixed, kids_mut, list_mut, put, 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,10 +365,26 @@ 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 { + fixed("shph", try_shph(), || vec![0u8; 24]) +} + +fn try_shph() -> R> { 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); - b + put(&mut b, 0, &[0xb3, 0xde, 0x02, 0x01])?; + put_u8(&mut b, 20, 1)?; + Ok(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() -> R> { + let mut first = vec![0u8; 64]; + let mut later = vec![0u8; 64]; + for r in [&mut first, &mut later] { + put_u16(r, 6, 0x0001)?; + } + put_f64(&mut first, 16, 1.0)?; + Ok([first, later].concat()) } impl MaskProtos { @@ -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, fixed("keyed path", path_key_records(), || vec![0u8; 128]))); } 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,24 +464,16 @@ 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)?)?; + 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)?; @@ -540,7 +511,6 @@ 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. 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..3517c41d2 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::{R, fixed, put, 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,12 @@ 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 { + fixed("tdb4", try_tdb4(spec, keyed, time_scale), || vec![0u8; 124]) +} + +fn try_tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> R> { 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]); + put(&mut b, 0, &[0xdb, 0x99, 0x00, spec.dims])?; let spatial = match spec.template { KeyTemplate::Plain => 0, KeyTemplate::Spatial(t) => t, @@ -115,36 +137,40 @@ 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()); + put(&mut b, 5, &[byte5])?; + put(&mut b, 7, &[spatial])?; + put(&mut b, 8, &spec.class.to_be_bytes())?; + put(&mut b, 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()); + put(&mut b, 16, &1e-4f64.to_be_bytes())?; for at in [24, 32, 40, 48] { - set(&mut b, at, &1.0f64.to_be_bytes()); + put(&mut b, at, &1.0f64.to_be_bytes())?; } - set(&mut b, 68, &[u8::from(keyed)]); + put(&mut b, 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(&mut b, 79, &[u8::from(spatial != 0 && !keyed)])?; + Ok(b) } /// A key list (`LIST list`: `lhd3` + `ldat`) of `count` records of `size` bytes (aep-format.md /// 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 = fixed("lhd3", key_list_header(size, count), || vec![0u8; 52]); Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", records)]) } +fn key_list_header(size: u32, count: u32) -> R> { + let mut lhd3 = vec![0u8; 52]; + put_u32(&mut lhd3, 0, 0x00d0_0bee)?; + put_u32(&mut lhd3, 24, 1)?; + put_key_counts(&mut lhd3, count, size)?; + Ok(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 +179,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 +229,34 @@ mod tests { assert_eq!(rec.child(b"cdat").and_then(|c| c.bytes()).map(<[u8]>::len), Some(40)); } + #[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!(try_tdb4(&spec, false, u32::MAX).is_ok() && try_tdb4(&spec, true, 0).is_ok()); + } + } + } + for (size, count) in [(0, 0), (u32::MAX, u32::MAX), (48, 5)] { + assert_eq!(key_list_header(size, count).map(|h| h.len()).ok(), Some(52)); + } + } + + #[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/textanim.rs b/crates/aep/src/export/textanim.rs index 760217cb8..1ceb869e4 100644 --- a/crates/aep/src/export/textanim.rs +++ b/crates/aep/src/export/textanim.rs @@ -21,6 +21,7 @@ use effectcraft_project::{Layer, Node, PropGroup}; use crate::export::plan::CompPlan; use crate::export::report::{Collector, LossCategory}; use crate::export::shapes::{Conv, Layout, RecVal, ShCx, build_record, c_color, c_popup, c_scalar, rec_value}; +use crate::export::synth::{GROUP_END, is_tdmn}; use crate::export::tree::{R, kids_mut, set_utf8, tpl}; /// More animators or selectors than this on one layer are not written (and reported). @@ -219,14 +220,8 @@ fn plan_selector(s: &PropGroup, animator: &str, cx: &mut ShCx<'_>) -> SelPlan { SelPlan { name: crate::export::plan::cut_name(&s.name, 255, &here, cx.col), records, advanced } } -fn tdmn_name(c: &Chunk<'_>) -> Option { - let b = c.bytes()?; - let end = b.iter().position(|x| *x == 0).unwrap_or(b.len()); - std::str::from_utf8(b.get(..end)?).ok().map(str::to_string) -} - fn pos(kids: &[Chunk<'static>], n: &str) -> Option { - kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(n)) + kids.iter().position(|c| is_tdmn(c, n)) } /// The item's user name (`tdsn/Utf8`). @@ -296,7 +291,7 @@ pub(crate) fn put_animators(layr: &mut Chunk<'static>, t: &AnimProtos, anims: &[ } { let gk = kids_mut(&mut group)?; - let end = pos(gk, "ADBE Group End").unwrap_or(gk.len()); + let end = pos(gk, GROUP_END).unwrap_or(gk.len()); gk.splice(end..end, built); } // Into the layer's `ADBE Text Properties`, before its Group End. @@ -305,7 +300,7 @@ pub(crate) fn put_animators(layr: &mut Chunk<'static>, t: &AnimProtos, anims: &[ let ti = pos(rk, "ADBE Text Properties").ok_or_else(|| tpl("text layer without text properties"))?; let tp = rk.get_mut(ti + 1).ok_or_else(|| tpl("text layer without text properties"))?; let tk = kids_mut(tp)?; - let end = pos(tk, "ADBE Group End").unwrap_or(tk.len()); + let end = pos(tk, GROUP_END).unwrap_or(tk.len()); tk.splice(end..end, [gm, group]); Ok(()) } diff --git a/crates/aep/src/export/tree.rs b/crates/aep/src/export/tree.rs index 275d2a82f..4c2a28b75 100644 --- a/crates/aep/src/export/tree.rs +++ b/crates/aep/src/export/tree.rs @@ -40,6 +40,7 @@ pub(crate) fn child_data<'a>(c: &'a mut Chunk<'static>, id: &[u8; 4]) -> R<&'a m data_mut(child_mut(c, id)?) } +/// The one byte writer of the exporter: `bytes` at `at`, or an error when they do not fit. pub(crate) fn put(d: &mut [u8], at: usize, bytes: &[u8]) -> R<()> { let end = at.checked_add(bytes.len()).ok_or_else(|| tpl("offset overflow"))?; let dst = d.get_mut(at..end).ok_or_else(|| tpl("chunk shorter than its layout"))?; @@ -90,20 +91,109 @@ pub(crate) fn put_cstr(d: &mut [u8], at: usize, len: usize, text: &str) -> R<()> break; } let mut buf = [0u8; 4]; - let enc = ch.encode_utf8(&mut buf); - for (i, b) in enc.as_bytes().iter().enumerate() { - if let Some(slot) = dst.get_mut(n + i) { - *slot = *b; - } - } + put(dst, n, ch.encode_utf8(&mut buf).as_bytes())?; n += l; } Ok(()) } +/// How many fixed layouts did not fit their buffer (see [`fixed`]); the tests keep it at zero. +static FIXED_LAYOUT_ERRORS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// The result of a builder whose layout is fixed in code (constant sizes and offsets, no input): +/// its writes go through [`put`], which errors when one does not fit. Such an error is a bug in +/// the layout, never something a project can cause, and the tests build every one of these +/// layouts and require no error. The builders keep their infallible signatures because files +/// outside this module call them without a `Result`; if a layout ever broke, `fallback` is used +/// and the error goes to the log. +pub(crate) fn fixed(builder: &'static str, built: R, fallback: impl FnOnce() -> T) -> T { + built.unwrap_or_else(|e| { + FIXED_LAYOUT_ERRORS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + log::error!("aep export: the fixed layout of {builder} does not fit: {e}"); + fallback() + }) +} + +/// How many fixed layouts have failed in this process (for the tests). +#[cfg(test)] +pub(crate) fn fixed_layout_errors() -> usize { + FIXED_LAYOUT_ERRORS.load(std::sync::atomic::Ordering::Relaxed) +} + +/// The entries of a property group by match name (see [`split_group`]). +pub(crate) type Entries = Vec<(String, Vec>)>; + +/// Splits the children of a property group into the header (up to the first `tdmn`) and its +/// entries by match name: `[tdmn, node]`, or `[tdmn]` for the closing `ADBE Group End`. A match +/// name without a node is an error. +pub(crate) fn split_group(kids: &mut Vec>) -> R<(Vec>, Entries)> { + let first = kids.iter().position(|c| c.is(b"tdmn")).unwrap_or(kids.len()); + let header: Vec> = kids.drain(..first).collect(); + let mut entries = Entries::new(); + let mut it = std::mem::take(kids).into_iter(); + while let Some(m) = it.next() { + let name = crate::export::synth::tdmn_name(&m).unwrap_or_default(); + if name == crate::export::synth::GROUP_END { + entries.push((name, vec![m])); + continue; + } + let node = it.next().ok_or_else(|| tpl("match name without a node"))?; + entries.push((name, vec![m, node])); + } + Ok((header, entries)) +} + /// Replaces the `Utf8` child of an item or layer with `text`. pub(crate) fn set_utf8(c: &mut Chunk<'static>, text: &str) -> R<()> { let k = child_mut(c, b"Utf8")?; *k = Chunk::data(*b"Utf8", text.as_bytes().to_vec()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_writer_errors_instead_of_dropping() { + let mut d = [0u8; 4]; + assert!(put(&mut d, 2, &[1, 2]).is_ok()); + assert_eq!(d, [0, 0, 1, 2]); + // Out of range, partly or wholly, and an offset that overflows: an error, nothing written. + assert!(put(&mut d, 3, &[9, 9]).is_err()); + assert!(put(&mut d, 8, &[9]).is_err()); + assert!(put(&mut d, usize::MAX, &[9]).is_err()); + assert_eq!(d, [0, 0, 1, 2]); + } + + #[test] + fn a_cut_string_keeps_a_nul_and_whole_characters() { + let mut d = [0xffu8; 6]; + put_cstr(&mut d, 0, 6, "ab\u{e9}\u{e9}").unwrap(); + assert_eq!(d, [b'a', b'b', 0xc3, 0xa9, 0, 0]); + assert!(put_cstr(&mut d, 4, 6, "x").is_err()); + } + + /// Every layout fixed in code fits its buffer: the prototypes of every export (the project + /// skeleton, layers, Transform, masks, effects of the whole table, footage of every kind, + /// text, cameras, lights, shapes, styles) are built and no write went out of range. + #[test] + fn every_fixed_layout_fits() { + assert!(crate::export::template::Template::load().is_ok()); + assert_eq!(fixed_layout_errors(), 0); + } + + #[test] + fn a_group_splits_into_header_and_entries() { + use crate::export::synth::{tdmn, tdsn}; + let rec = || Chunk::list(*b"tdbs", Vec::new()); + let mut kids = vec![tdsn("h"), tdmn("A"), rec(), tdmn("ADBE Group End")]; + let (header, entries) = split_group(&mut kids).unwrap(); + assert_eq!(header.len(), 1); + let names: Vec<(&str, usize)> = entries.iter().map(|(n, v)| (n.as_str(), v.len())).collect(); + assert_eq!(names, [("A", 2), ("ADBE Group End", 1)]); + // A match name without its node is an error, not a quiet stop. + let mut broken = vec![tdmn("A")]; + assert!(split_group(&mut broken).is_err()); + } +} From a4f9a975250a75f7b87ce3f7be764578dc651aa1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 03:32:08 +0100 Subject: [PATCH 2/4] aep export: pin the key-time loop's report, name the layout in the base::bytes log (#3278) --- crates/aep/src/export/base.rs | 6 +++++- crates/aep/src/export/keys.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/aep/src/export/base.rs b/crates/aep/src/export/base.rs index 3e2b043ff..9da1db2ba 100644 --- a/crates/aep/src/export/base.rs +++ b/crates/aep/src/export/base.rs @@ -12,7 +12,11 @@ use crate::export::tree::{R, fixed, put}; /// 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 { - fixed("a record", try_bytes(len, fields), || vec![0u8; len]) + fixed("base::bytes", try_bytes(len, fields), || { + // Which layout: its length and fields (the builders are named in the comments above). + log::error!("aep export: base::bytes layout of {len} bytes, fields at {:?}", fields.iter().map(|(at, v)| (*at, v.len())).collect::>()); + vec![0u8; len] + }) } fn try_bytes(len: usize, fields: &[(usize, &[u8])]) -> R> { diff --git a/crates/aep/src/export/keys.rs b/crates/aep/src/export/keys.rs index fdcad87c2..b3cd1158c 100644 --- a/crates/aep/src/export/keys.rs +++ b/crates/aep/src/export/keys.rs @@ -90,6 +90,38 @@ mod tests { use super::*; use crate::export::synth::key_list; + #[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()); + } + #[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])]); From 9e82a5e7049af1c6c118e966a5c7bce56a3b43be Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 03:56:50 +0100 Subject: [PATCH 3/4] aep export: review fixes for #3278 - tree::Fixed replaces tree::fixed: a fixed layout writes every field that fits (as the old code did) and keeps the first error; finish() logs it once with the layout's description and counts it per thread, so the every-layout test cannot race with other tests. - base::bytes logs one line naming its length and fields. - masks::build_atom errors when the prototype has no Mask Shape instead of dropping the path of every mask quietly. - log sorted into the dependency list. --- crates/aep/Cargo.toml | 2 +- crates/aep/src/export/base.rs | 26 +++++----- crates/aep/src/export/fxsynth.rs | 72 ++++++++++++++-------------- crates/aep/src/export/masks.rs | 67 ++++++++++++++++++++------ crates/aep/src/export/synth.rs | 48 ++++++++++--------- crates/aep/src/export/tree.rs | 81 +++++++++++++++++++++++++------- 6 files changed, 191 insertions(+), 105 deletions(-) diff --git a/crates/aep/Cargo.toml b/crates/aep/Cargo.toml index d1fbc4a3a..3deb09de2 100644 --- a/crates/aep/Cargo.toml +++ b/crates/aep/Cargo.toml @@ -15,9 +15,9 @@ 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 } -log = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/aep/src/export/base.rs b/crates/aep/src/export/base.rs index 9da1db2ba..dfaca7321 100644 --- a/crates/aep/src/export/base.rs +++ b/crates/aep/src/export/base.rs @@ -7,24 +7,20 @@ use crate::Chunk; use crate::export::synth::{GROUP_END, tdmn, tdsn}; -use crate::export::tree::{R, fixed, put}; +use crate::export::tree::Fixed; /// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it. The layouts are -/// fixed in code ([`fixed`]). +/// fixed in code ([`Fixed`]). pub(crate) fn bytes(len: usize, fields: &[(usize, &[u8])]) -> Vec { - fixed("base::bytes", try_bytes(len, fields), || { - // Which layout: its length and fields (the builders are named in the comments above). - log::error!("aep export: base::bytes layout of {len} bytes, fields at {:?}", fields.iter().map(|(at, v)| (*at, v.len())).collect::>()); - vec![0u8; len] - }) + layout(len, fields).finish(|| format!("base::bytes ({len} bytes, fields at {:?})", fields.iter().map(|(at, v)| (*at, v.len())).collect::>())) } -fn try_bytes(len: usize, fields: &[(usize, &[u8])]) -> R> { - let mut b = vec![0u8; len]; +fn layout(len: usize, fields: &[(usize, &[u8])]) -> Fixed { + let mut b = Fixed::zeroed(len); for (at, v) in fields { - put(&mut b, *at, v)?; + b.put(*at, v); } - Ok(b) + b } pub(crate) fn utf8(text: &str) -> Chunk<'static> { @@ -286,9 +282,11 @@ mod tests { #[test] fn a_field_out_of_range_is_an_error() { - assert_eq!(try_bytes(4, &[(0, &[1]), (3, &[2])]).ok(), Some(vec![1, 0, 0, 2])); - assert!(try_bytes(4, &[(3, &[1, 2])]).is_err()); - assert!(try_bytes(4, &[(usize::MAX, &[1])]).is_err()); + 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 diff --git a/crates/aep/src/export/fxsynth.rs b/crates/aep/src/export/fxsynth.rs index e4bf002b9..cce25b6f2 100644 --- a/crates/aep/src/export/fxsynth.rs +++ b/crates/aep/src/export/fxsynth.rs @@ -12,7 +12,7 @@ use crate::Chunk; use crate::effect_map::{Conv, by_match_name}; use crate::export::fx_defs::{BUILT_IN, FxDef, PDef}; use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn, utf8_in}; -use crate::export::tree::{R, fixed as fixed_layout, put}; +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 @@ -48,91 +48,91 @@ 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 { - fixed_layout("pard", try_pard(p, popup_items), || vec![0u8; 148]) + pard_layout(p, popup_items).finish(|| format!("pard (type {})", p.code)) } -fn try_pard(p: &PDef, popup_items: Option) -> R> { - let mut b = vec![0u8; 148]; - put(&mut b, 12, &u32::from(p.code).to_be_bytes())?; +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()); } _ => {} } - Ok(b) + b } /// The default arbitrary data of an effect (its type-11 parameter), from the decoded layouts @@ -278,7 +278,9 @@ mod tests { fn every_parameter_definition_fits() { for fx in FX_DEFS { for (i, p) in fx.params.iter().enumerate() { - assert_eq!(try_pard(p, popup_items(fx, i)).map(|b| b.len()).ok(), Some(148), "{} {}", fx.match_name, i); + 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/masks.rs b/crates/aep/src/export/masks.rs index eb61b0a09..b46811500 100644 --- a/crates/aep/src/export/masks.rs +++ b/crates/aep/src/export/masks.rs @@ -21,7 +21,7 @@ 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::synth::GROUP_END; -use crate::export::tree::{R, child_data, child_mut, fixed, kids_mut, list_mut, put, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, split_group, tpl}; +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; @@ -365,26 +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 { - fixed("shph", try_shph(), || vec![0u8; 24]) + shph_layout().finish(|| "shph".to_string()) } -fn try_shph() -> R> { - let mut b = vec![0u8; 24]; - put(&mut b, 0, &[0xb3, 0xde, 0x02, 0x01])?; - put_u8(&mut b, 20, 1)?; - Ok(b) +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() -> R> { - let mut first = vec![0u8; 64]; - let mut later = vec![0u8; 64]; - for r in [&mut first, &mut later] { - put_u16(r, 6, 0x0001)?; +fn path_key_records() -> Fixed { + let mut b = Fixed::zeroed(128); + for at in [6, 64 + 6] { + b.put(at, &0x0001u16.to_be_bytes()); } - put_f64(&mut first, 16, 1.0)?; - Ok([first, later].concat()) + b.put(16, &1.0f64.to_be_bytes()); + b } impl MaskProtos { @@ -400,7 +399,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 { - kids.push(key_list(64, 2, fixed("keyed path", path_key_records(), || vec![0u8; 128]))); + 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])); } @@ -465,6 +464,7 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R; 2] = match entry.try_into() { @@ -477,6 +477,7 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R { build_shape(t, &mut node, m, size)?; + has_shape = true; true } "ADBE Mask Feather" => { @@ -511,6 +512,10 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R, 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, + } + } + + #[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/synth.rs b/crates/aep/src/export/synth.rs index 3517c41d2..80678baa3 100644 --- a/crates/aep/src/export/synth.rs +++ b/crates/aep/src/export/synth.rs @@ -4,7 +4,7 @@ //! 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::{R, fixed, put, put_u32}; +use crate::export::tree::{Fixed, R, put_u32}; use crate::{Body, Chunk}; /// `tdmn`: a match name padded to 40 bytes (at most 39 bytes of the name, so that a NUL remains). @@ -123,12 +123,12 @@ 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 { - fixed("tdb4", try_tdb4(spec, keyed, time_scale), || vec![0u8; 124]) + tdb4_layout(spec, keyed, time_scale).finish(|| "tdb4".to_string()) } -fn try_tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> R> { - let mut b = vec![0u8; 124]; - put(&mut b, 0, &[0xdb, 0x99, 0x00, spec.dims])?; +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, @@ -137,36 +137,36 @@ fn try_tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> R> { Some(b) => (b & !0x01) | u8::from(!keyed), None => (if spatial == 0 { 0 } else { spatial_byte5(spatial) }) | u8::from(!keyed), }; - put(&mut b, 5, &[byte5])?; - put(&mut b, 7, &[spatial])?; - put(&mut b, 8, &spec.class.to_be_bytes())?; - put(&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). - put(&mut b, 16, &1e-4f64.to_be_bytes())?; + b.put(16, &1e-4f64.to_be_bytes()); for at in [24, 32, 40, 48] { - put(&mut b, at, &1.0f64.to_be_bytes())?; + b.put(at, &1.0f64.to_be_bytes()); } - put(&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). - put(&mut b, 79, &[u8::from(spatial != 0 && !keyed)])?; - Ok(b) + b.put(79, &[u8::from(spatial != 0 && !keyed)]); + b } /// A key list (`LIST list`: `lhd3` + `ldat`) of `count` records of `size` bytes (aep-format.md /// 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 lhd3 = fixed("lhd3", key_list_header(size, count), || vec![0u8; 52]); + 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)]) } -fn key_list_header(size: u32, count: u32) -> R> { - let mut lhd3 = vec![0u8; 52]; - put_u32(&mut lhd3, 0, 0x00d0_0bee)?; - put_u32(&mut lhd3, 24, 1)?; - put_key_counts(&mut lhd3, count, size)?; - Ok(lhd3) +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. @@ -235,12 +235,14 @@ mod tests { 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!(try_tdb4(&spec, false, u32::MAX).is_ok() && try_tdb4(&spec, true, 0).is_ok()); + 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)] { - assert_eq!(key_list_header(size, count).map(|h| h.len()).ok(), Some(52)); + let h = key_list_header(size, count); + assert!(h.error().is_none()); + assert_eq!(h.finish(String::new).len(), 52); } } diff --git a/crates/aep/src/export/tree.rs b/crates/aep/src/export/tree.rs index 4c2a28b75..f975e1668 100644 --- a/crates/aep/src/export/tree.rs +++ b/crates/aep/src/export/tree.rs @@ -97,27 +97,61 @@ pub(crate) fn put_cstr(d: &mut [u8], at: usize, len: usize, text: &str) -> R<()> Ok(()) } -/// How many fixed layouts did not fit their buffer (see [`fixed`]); the tests keep it at zero. -static FIXED_LAYOUT_ERRORS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - -/// The result of a builder whose layout is fixed in code (constant sizes and offsets, no input): -/// its writes go through [`put`], which errors when one does not fit. Such an error is a bug in -/// the layout, never something a project can cause, and the tests build every one of these -/// layouts and require no error. The builders keep their infallible signatures because files -/// outside this module call them without a `Result`; if a layout ever broke, `fallback` is used -/// and the error goes to the log. -pub(crate) fn fixed(builder: &'static str, built: R, fallback: impl FnOnce() -> T) -> T { - built.unwrap_or_else(|e| { - FIXED_LAYOUT_ERRORS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - log::error!("aep export: the fixed layout of {builder} does not fit: {e}"); - fallback() - }) +thread_local! { + /// How many fixed layouts on this thread did not fit their buffer (see [`Fixed`]); the tests + /// keep it at zero. + static FIXED_LAYOUT_ERRORS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// How many fixed layouts have failed in this process (for the tests). +/// A buffer whose layout is fixed in code (constant size and offsets, no input from a project). +/// Every write goes through [`put`]; a write that does not fit is skipped and its error kept, the +/// others are written. Such an error is a bug in the layout, never something a project can cause: +/// the tests build every one of these layouts and require no error. The builders keep their +/// infallible signatures because files outside this module call them without a `Result`; if a +/// layout ever broke, [`Fixed::finish`] logs it once and returns what fit. +pub(crate) struct Fixed { + bytes: Vec, + error: Option, +} + +impl Fixed { + /// `len` zero bytes. + pub(crate) fn zeroed(len: usize) -> Fixed { + Fixed { bytes: vec![0u8; len], error: None } + } + + /// Writes `bytes` at `at`, or keeps the error (the first one) when they do not fit. + pub(crate) fn put(&mut self, at: usize, bytes: &[u8]) { + self.apply(|d| put(d, at, bytes)); + } + + /// Runs a writer on the buffer, keeping its error (the first one). + pub(crate) fn apply(&mut self, write: impl FnOnce(&mut [u8]) -> R<()>) { + if let Err(e) = write(&mut self.bytes) { + self.error.get_or_insert(e); + } + } + + /// The error of the first write that did not fit (for the tests). + #[cfg(test)] + pub(crate) fn error(&self) -> Option<&AepExportError> { + self.error.as_ref() + } + + /// The bytes; a write that did not fit is logged once, with what `layout` says. + pub(crate) fn finish(self, layout: impl FnOnce() -> String) -> Vec { + if let Some(e) = self.error { + FIXED_LAYOUT_ERRORS.with(|n| n.set(n.get().saturating_add(1))); + log::error!("aep export: the fixed layout of {} does not fit: {e}", layout()); + } + self.bytes + } +} + +/// How many fixed layouts have failed on this thread (for the tests). #[cfg(test)] pub(crate) fn fixed_layout_errors() -> usize { - FIXED_LAYOUT_ERRORS.load(std::sync::atomic::Ordering::Relaxed) + FIXED_LAYOUT_ERRORS.with(std::cell::Cell::get) } /// The entries of a property group by match name (see [`split_group`]). @@ -179,10 +213,23 @@ mod tests { /// text, cameras, lights, shapes, styles) are built and no write went out of range. #[test] fn every_fixed_layout_fits() { + assert_eq!(fixed_layout_errors(), 0); assert!(crate::export::template::Template::load().is_ok()); assert_eq!(fixed_layout_errors(), 0); } + #[test] + fn a_broken_fixed_layout_writes_what_fits_and_is_counted() { + let before = fixed_layout_errors(); + let mut b = Fixed::zeroed(4); + b.put(0, &[1]); + b.put(3, &[2, 2]); + b.put(2, &[3]); + assert!(b.error().is_some()); + assert_eq!(b.finish(|| "a test layout".to_string()), [1, 0, 3, 0]); + assert_eq!(fixed_layout_errors(), before + 1); + } + #[test] fn a_group_splits_into_header_and_entries() { use crate::export::synth::{tdmn, tdsn}; From d2359d607ea345420e5883843018ec2c5583588a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 04:41:46 +0100 Subject: [PATCH 4/4] aep export: the every-layout test counts from a baseline, not from zero (#3278) --- crates/aep/src/export/tree.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/aep/src/export/tree.rs b/crates/aep/src/export/tree.rs index f975e1668..9c71a27b8 100644 --- a/crates/aep/src/export/tree.rs +++ b/crates/aep/src/export/tree.rs @@ -213,9 +213,10 @@ mod tests { /// text, cameras, lights, shapes, styles) are built and no write went out of range. #[test] fn every_fixed_layout_fits() { - assert_eq!(fixed_layout_errors(), 0); + // A delta: other tests on this thread may have counted a broken layout on purpose. + let before = fixed_layout_errors(); assert!(crate::export::template::Template::load().is_ok()); - assert_eq!(fixed_layout_errors(), 0); + assert_eq!(fixed_layout_errors(), before); } #[test]