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.
This commit is contained in:
vangel76 committed 2026-11-08 03:43:01 +01:00
1 parent 84382487bb
commit 5f37f78117
14 files changed
+476 -350

No files matched your search

Generated
+1
View File
@@ -1453,6 +1453,7 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-time",
"log",
"serde",
"serde_json",
"thiserror 2.0.21",
+1
View File
@@ -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 }
+1
View File
@@ -26,6 +26,7 @@ mod footage;
mod fx_defs;
mod fxsynth;
pub(crate) mod icc;
mod keys;
mod markers;
mod masks;
mod plan;
+27 -33
View File
@@ -6,17 +6,21 @@
//! (read-backs t-no<id>, t-zcdta, u-zsspc, u-noclrs, u-noftgi, u-nofrec, u-nocdrp).
use crate::Chunk;
use crate::export::synth::{tdmn, tdsn};
use crate::export::synth::{GROUP_END, tdmn, tdsn};
use crate::export::tree::{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<u8> {
fixed("a record", try_bytes(len, fields), || vec![0u8; len])
}
fn try_bytes(len: usize, fields: &[(usize, &[u8])]) -> R<Vec<u8>> {
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<Chunk<'static>> {
// colour depth (2: 32 bits); the save noise after the code page is left zero.
let nhed = bytes(32, &[(7, &[5]), (9, &[1]), (12, &[0x1e, 16, 2, 2]), (18, &UTF8_CODE_PAGE)]);
let nnhd = bytes(40, &[(7, &[5]), (9, &[1]), (15, &[0x1e]), (19, &[16, 2]), (24, &[2]), (26, &UTF8_CODE_PAGE)]);
let mut lhd3 = bytes(52, &[(0, &0x00d0_0bee_u32.to_be_bytes()), (12, &1u32.to_be_bytes()), (16, &RQ_ITEM_LEN.to_be_bytes())]);
for at in [20, 24, 28] {
if let Some(dst) = lhd3.get_mut(at..at + 4) {
dst.copy_from_slice(&1u32.to_be_bytes());
}
}
let one = 1u32.to_be_bytes();
let lhd3 = bytes(52, &[(0, &0x00d0_0bee_u32.to_be_bytes()), (12, &one), (16, &RQ_ITEM_LEN.to_be_bytes()), (20, &one), (24, &one), (28, &one)]);
let wsnm: Vec<u8> = "Default".encode_utf16().flat_map(u16::to_be_bytes).collect();
vec![
Chunk::data(*b"svap", VERSION.to_vec()),
@@ -157,30 +157,17 @@ fn view_layer(ty: &[u8; 4], name: &str, label: u8, kind: u8) -> Vec<Chunk<'stati
let markers = kind == 4;
let mut name_copy = name.as_bytes().to_vec();
name_copy.truncate(31);
let mut ldta = bytes(
164,
&[
(8, &1u32.to_be_bytes()),
(16, &600u32.to_be_bytes()),
(24, &25_600u32.to_be_bytes()),
(28, &VIEW_OUT.to_be_bytes()),
(32, &25_600u32.to_be_bytes()),
(61, &[label]),
(64, &name_copy),
(108, &1u32.to_be_bytes()),
(128, &u32::from(kind).to_be_bytes()),
],
);
// Byte 38: 3D and auto-orient on the cameras; byte 39: the video switch (and on the markers
// layer also effects, locked and collapse); quality Draft and blend mode Normal on the
// markers layer.
let flags: &[(usize, u8)] = if markers { &[(5, 2), (39, 0x87), (99, 2)] } else { &[(38, 0x44), (39, 1)] };
for (at, v) in flags {
if let Some(b) = ldta.get_mut(*at) {
*b = *v;
}
}
let root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn("ADBE Group End")];
let flags: &[(usize, &[u8])] = if markers { &[(5, &[2]), (39, &[0x87]), (99, &[2])] } else { &[(38, &[0x44]), (39, &[1])] };
let (one, scale, kind_word) = (1u32.to_be_bytes(), 25_600u32.to_be_bytes(), u32::from(kind).to_be_bytes());
let (base, out, label) = (600u32.to_be_bytes(), VIEW_OUT.to_be_bytes(), [label]);
let mut fields: Vec<(usize, &[u8])> =
vec![(8, &one), (16, &base), (24, &scale), (28, &out), (32, &scale), (61, &label), (64, &name_copy), (108, &one), (128, &kind_word)];
fields.extend_from_slice(flags);
let ldta = bytes(164, &fields);
let root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn(GROUP_END)];
let layer = Chunk::list(*ty, vec![Chunk::data(*b"ldta", ldta), utf8(name), Chunk::list(*b"tdgp", root), guides()]);
let mut out = vec![layer, Chunk::list(*b"Ewst", Vec::new())];
out.extend(crate::export::skeleton::f_records());
@@ -293,6 +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]
+7 -32
View File
@@ -13,7 +13,8 @@ use effectcraft_keyframe::Value;
use effectcraft_project::{Layer, LightKind};
use crate::export::report::{Collector, LossCategory};
use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, tpl};
use crate::export::synth::{GROUP_END, is_tdmn, tdmn};
use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, split_group, tpl};
/// A camera's options in the file's units (pixels, percent, a switch).
#[derive(Clone, Debug, PartialEq)]
@@ -183,50 +184,24 @@ const LIGHT_ORDER: &[&str] = &[
"ADBE Casts Shadows",
];
pub(crate) fn tdmn_chunk(name: &str) -> Chunk<'static> {
let mut b = vec![0u8; 40];
for (i, c) in name.bytes().take(39).enumerate() {
b[i] = c;
}
Chunk::data(*b"tdmn", b)
}
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let b = c.bytes()?;
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
std::str::from_utf8(b.get(..end)?).ok().map(str::to_string)
}
/// Sets (or inserts) the records of an options group (`tdmn` named `group`) of a layer.
fn put_group(layr: &mut Chunk<'static>, group: &str, order: &[&str], set: Vec<(&str, Chunk<'static>)>, remove: &[&str]) -> R<()> {
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(group)).ok_or_else(|| tpl("options group missing"))?;
let at = kids.iter().position(|c| is_tdmn(c, group)).ok_or_else(|| tpl("options group missing"))?;
let g = kids.get_mut(at + 1).ok_or_else(|| tpl("options group without a list"))?;
let gk = kids_mut(g)?;
let first = gk.iter().position(|c| c.is(b"tdmn")).unwrap_or(gk.len());
let header: Vec<Chunk<'static>> = gk.drain(..first).collect();
let mut pairs: Vec<(String, Vec<Chunk<'static>>)> = Vec::new();
let mut it = std::mem::take(gk).into_iter();
while let Some(m) = it.next() {
let name = tdmn_name(&m).unwrap_or_default();
if name == "ADBE Group End" {
pairs.push((name, vec![m]));
continue;
}
let Some(node) = it.next() else { break };
// An atom group (Light Env Atom) carries its own Group End inside the list.
pairs.push((name, vec![m, node]));
}
// An atom group (Light Env Atom) carries its own Group End inside its list.
let (header, mut pairs) = split_group(gk)?;
pairs.retain(|(n, _)| !remove.contains(&n.as_str()));
for (name, rec) in set {
let entry = vec![tdmn_chunk(name), rec];
let entry = vec![tdmn(name), rec];
match pairs.iter_mut().find(|(n, _)| n == name) {
Some(slot) => slot.1 = entry,
None => pairs.push((name.to_string(), entry)),
}
}
let rank = |n: &str| if n == "ADBE Group End" { usize::MAX } else { order.iter().position(|g| *g == n).unwrap_or(order.len()) };
let rank = |n: &str| if n == GROUP_END { usize::MAX } else { order.iter().position(|g| *g == n).unwrap_or(order.len()) };
pairs.sort_by_key(|(n, _)| rank(n));
gk.extend(header);
gk.extend(pairs.into_iter().flat_map(|(_, v)| v));
+56 -41
View File
@@ -11,7 +11,8 @@
use crate::Chunk;
use crate::effect_map::{Conv, by_match_name};
use crate::export::fx_defs::{BUILT_IN, FxDef, PDef};
use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn};
use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn, utf8_in};
use crate::export::tree::{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<RecSpec>
})
}
fn put(b: &mut [u8], at: usize, bytes: &[u8]) {
if let Some(dst) = b.get_mut(at..at + bytes.len()) {
dst.copy_from_slice(bytes);
}
}
fn fixed(v: f64) -> [u8; 4] {
((v * 65536.0).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32).to_be_bytes()
}
@@ -53,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<u16>) -> Vec<u8> {
fixed_layout("pard", try_pard(p, popup_items), || vec![0u8; 148])
}
fn try_pard(p: &PDef, popup_items: Option<u16>) -> R<Vec<u8>> {
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<u8> = (0..4).map(|i| d(i).round().clamp(0.0, 255.0) as u8).collect();
put(&mut b, 56, &c);
put(&mut b, 60, &c);
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<u8>) -> [Chunk<'static>; 2] {
/// group (records only for layer parameters, groups, buttons and arbitrary data, the built-in
/// group) and `pgui` (zero).
pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> {
let fnam = Chunk { id: *b"fnam", body: crate::Body::Chunks(vec![Chunk::data(*b"Utf8", fx.name.as_bytes().to_vec())]), pad: None };
let fnam = utf8_in(*b"fnam", fx.name);
let count = u32::try_from(fx.params.len()).unwrap_or(u32::MAX);
let mut part = vec![Chunk::data(*b"parn", count.to_be_bytes().to_vec())];
let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME)];
@@ -235,11 +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);
}
}
}
}
+139
View File
@@ -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<Vec<u8>> {
let mut r = vec![0u8; size];
put(&mut r, 0, &k.time.to_be_bytes())?;
put_u8(&mut r, 4, k.in_interp)?;
put_u8(&mut r, 5, k.out_interp)?;
let base: u16 = if point { 0x0007 } else { 0x0001 };
put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?;
if point {
put_u32(&mut r, 8, u32::from(k.spatial_auto))?;
let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default());
put_f64(&mut r, 24, i.speed)?;
put_f64(&mut r, 32, i.influence)?;
put_f64(&mut r, 40, o.speed)?;
put_f64(&mut r, 48, o.influence)?;
}
for j in 0..n {
put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?;
if point {
put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?;
put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?;
}
}
Ok(r)
}
/// Fills a key list (`LIST list`) with `count` records of `size` bytes: the count words of `lhd3`
/// and `ldat` (added when the list has none).
pub(crate) fn fill_key_list(list: &mut Chunk<'static>, records: Vec<u8>, count: usize, size: usize) -> R<()> {
let n = u32::try_from(count).map_err(|_| tpl("too many keys"))?;
let size = u32::try_from(size).map_err(|_| tpl("record size"))?;
crate::export::synth::put_key_counts(child_data(list, b"lhd3")?, n, size)?;
let kids = kids_mut(list)?;
match kids.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", records),
None => kids.push(Chunk::data(*b"ldat", records)),
}
Ok(())
}
/// Rewrites the key list of a keyed property record (`LIST tdbs`), see [`fill_key_list`].
pub(crate) fn set_key_list(tdbs: &mut Chunk<'static>, records: Vec<u8>, count: usize, size: usize) -> R<()> {
fill_key_list(list_mut(tdbs, b"list")?, records, count, size)
}
/// Puts the key list `list` in the place of the static value (`cdat`) of a property record.
pub(crate) fn replace_cdat(tdbs: &mut Chunk<'static>, list: Chunk<'static>) -> R<()> {
let slot = kids_mut(tdbs)?.iter_mut().find(|c| c.is(b"cdat")).ok_or_else(|| tpl("record without cdat"))?;
*slot = list;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::export::synth::key_list;
#[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());
}
}
+37 -83
View File
@@ -17,9 +17,11 @@ use crate::Chunk;
use effectcraft_keyframe::{Ease, Interp, ShapePath, Value};
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur, PropGroup};
use crate::export::keys::{set_key_list, timed_keys};
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT, cut_name};
use crate::export::report::{Collector, LossCategory};
use crate::export::tree::{R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, tpl};
use crate::export::synth::GROUP_END;
use crate::export::tree::{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<PathKeyPlan> = Vec::new();
let mut keys: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::new();
for k in &path_prop.keys {
let (u, inexact) = cp.tb.units(k.time);
if inexact {
col.lose(LossCategory::RoundedTimes, format!("{what}: path key at {:.4} s moved to the time base", k.time.seconds()));
}
keys.push((u, k));
}
keys.sort_by_key(|(u, _)| *u);
let before = keys.len();
keys.dedup_by_key(|(u, _)| *u);
if keys.len() != before {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: path keys at the same time were merged"));
}
let keys = timed_keys(&path_prop.keys, &cp.tb, what, "path key", col);
let side = |i: Interp| -> u8 {
match i {
Interp::Linear => 1,
@@ -288,20 +277,7 @@ fn plan_mask(g: &PropGroup, size: (u32, u32), cp: &CompPlan, what: &str, col: &m
/// Feather keys (plain template, two components, speed in pixels per second).
fn plan_feather_keys(src: &[effectcraft_keyframe::Keyframe], cp: &CompPlan, what: &str, col: &mut Collector) -> Vec<KeyPlan> {
let mut keys: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::with_capacity(src.len());
for k in src {
let (u, inexact) = cp.tb.units(k.time);
if inexact {
col.lose(LossCategory::RoundedTimes, format!("{what}: feather key at {:.4} s moved to the time base", k.time.seconds()));
}
keys.push((u, k));
}
keys.sort_by_key(|(u, _)| *u);
let before = keys.len();
keys.dedup_by_key(|(u, _)| *u);
if keys.len() != before {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: feather keys at the same time were merged"));
}
let keys = timed_keys(src, &cp.tb, what, "feather key", col);
let mut out = Vec::with_capacity(keys.len());
let beyond = std::cell::Cell::new(false);
for (u, k) in &keys {
@@ -381,11 +357,6 @@ pub(crate) struct MaskProtos {
pub keyed_feather: Chunk<'static>,
}
pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let d = c.bytes()?;
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
}
/// The limit of a mask's feather and expansion (`tduM` 32 000, 091).
const MASK_LIMIT: f64 = 32_000.0;
@@ -394,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<u8> {
fixed("shph", try_shph(), || vec![0u8; 24])
}
fn try_shph() -> R<Vec<u8>> {
let mut b = vec![0u8; 24];
let _ = crate::export::tree::put(&mut b, 0, &[0xb3, 0xde, 0x02, 0x01]);
let _ = put_u8(&mut b, 20, 1);
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<Vec<u8>> {
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<Chunk<'static
let tdsn = child_mut(&mut atom, b"tdsn")?;
crate::export::tree::set_utf8(tdsn, &m.name)?;
}
let kids = kids_mut(&mut atom)?;
let first = kids.iter().position(|c| c.is(b"tdmn")).ok_or_else(|| tpl("empty mask atom"))?;
let header: Vec<Chunk<'static>> = kids.drain(..first).collect();
let mut pairs: Vec<(String, [Chunk<'static>; 2])> = Vec::new();
let mut end: Option<Chunk<'static>> = None;
let mut it = std::mem::take(kids).into_iter();
while let Some(c) = it.next() {
let name = tdmn_name(&c).unwrap_or_default();
if name == "ADBE Group End" {
end = Some(c);
continue;
}
let node = it.next().ok_or_else(|| tpl("mask match name without a node"))?;
pairs.push((name, [c, node]));
}
let end = end.ok_or_else(|| tpl("mask atom without its end"))?;
let mut out = header;
for (name, [tdmn, mut node]) in pairs {
let (mut out, entries) = split_group(kids_mut(&mut atom)?)?;
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<Chunk<'static
out.push(node);
}
}
out.push(end);
*kids_mut(&mut atom)? = out;
Ok(atom)
}
@@ -554,22 +524,6 @@ fn set_cdat(tdbs: &mut Chunk<'static>, values: &[f64]) -> R<()> {
Ok(())
}
/// Rewrites a key list (`lhd3` count words and `ldat`).
fn set_key_list(tdbs: &mut Chunk<'static>, records: Vec<u8>, count: usize, size: usize) -> R<()> {
let list = list_mut(tdbs, b"list")?;
let n = u32::try_from(count).map_err(|_| tpl("too many keys"))?;
{
let lhd3 = child_data(list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, n, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
}
let ld = kids_mut(list)?;
match ld.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", records),
None => ld.push(Chunk::data(*b"ldat", records)),
}
Ok(())
}
fn keyed_feather(t: &MaskProtos, keys: &[KeyPlan]) -> R<Chunk<'static>> {
let mut tdbs = t.keyed_feather.clone();
// The plain template with two components (208): 8 + 40 * 2 bytes.
+14 -75
View File
@@ -24,10 +24,12 @@ use crate::Chunk;
use effectcraft_keyframe::{Gradient, Interp, Keyframe, Value};
use effectcraft_project::{Node, PropGroup, Property};
use crate::export::keys::{fill_key_list, replace_cdat, spatial_record, timed_keys};
use crate::export::masks::ShapePlan;
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan};
use crate::export::report::{Collector, LossCategory};
use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, set_utf8, tpl};
use crate::export::synth::{GROUP_END, is_tdmn, tdmn};
use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, put_u8, set_utf8, tpl};
/// The deepest group nesting written.
const MAX_DEPTH: usize = 16;
@@ -192,7 +194,7 @@ impl ShapeProtos {
put_f64(child_data(&mut node, b"tdum")?, 0, lo)?;
put_f64(child_data(&mut node, b"tduM")?, 0, hi)?;
}
Ok([crate::export::camlight::tdmn_chunk(name), node])
Ok([tdmn(name), node])
}
}
@@ -267,20 +269,7 @@ pub(crate) struct ShCx<'a> {
/// The keys of `p` in the file's units.
fn plan_keys(p: &Property, conv: Conv, layout: Layout, what: &str, cx: &mut ShCx<'_>) -> Vec<KeyPlan> {
let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(p.keys.len());
for k in &p.keys {
let (u, inexact) = cx.cp.tb.units(k.time);
if inexact {
cx.col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds()));
}
timed.push((u, k));
}
timed.sort_by_key(|(u, _)| *u);
let before = timed.len();
timed.dedup_by_key(|(u, _)| *u);
if timed.len() != before {
cx.col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged"));
}
let timed = timed_keys(&p.keys, &cx.cp.tb, what, "key", cx.col);
let all: Vec<Keyframe> = timed.iter().map(|(_, k)| (*k).clone()).collect();
let mut out = Vec::with_capacity(timed.len());
let beyond = std::cell::Cell::new(false);
@@ -618,38 +607,6 @@ pub(crate) fn plan_contents(contents: &PropGroup, what: &str, depth: usize, cx:
out
}
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let b = c.bytes()?;
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
std::str::from_utf8(b.get(..end)?).ok().map(str::to_string)
}
/// A key of a point (the spatial layout of a 2D Position, 853) or a colour (854).
fn spatial_key(k: &KeyPlan, n: usize, point: bool, size: usize) -> R<Vec<u8>> {
let mut r = vec![0u8; size];
put(&mut r, 0, &k.time.to_be_bytes())?;
put_u8(&mut r, 4, k.in_interp)?;
put_u8(&mut r, 5, k.out_interp)?;
let base: u16 = if point { 0x0007 } else { 0x0001 };
put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?;
if point {
put_u32(&mut r, 8, u32::from(k.spatial_auto))?;
let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default());
put_f64(&mut r, 24, i.speed)?;
put_f64(&mut r, 32, i.influence)?;
put_f64(&mut r, 40, o.speed)?;
put_f64(&mut r, 48, o.influence)?;
}
for j in 0..n {
put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?;
if point {
put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?;
put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?;
}
}
Ok(r)
}
/// The value record of `name` holding `v`.
fn value_record(t: &ShapeProtos, name: &'static str, v: &RecVal) -> R<[Chunk<'static>; 2]> {
let [m, node] = t.record(name)?;
@@ -681,32 +638,17 @@ pub(crate) fn build_record(m: Chunk<'static>, mut node: Chunk<'static>, layout:
put_u8(tdb4, 79, 0)?;
}
}
let mut list = key_list.clone();
let count = u32::try_from(v.keys.len()).map_err(|_| tpl("too many keys"))?;
{
let lhd3 = child_data(&mut list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, count, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
}
let mut ldat = Vec::with_capacity(size * v.keys.len());
for k in &v.keys {
ldat.extend(match layout {
Layout::Point => spatial_key(k, n, true, size)?,
Layout::Color => spatial_key(k, n, false, size)?,
Layout::Point => spatial_record(k, n, true, size)?,
Layout::Color => spatial_record(k, n, false, size)?,
_ => crate::export::emit::plain_record(n, k, size)?,
});
}
{
let lk = kids_mut(&mut list)?;
match lk.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", ldat),
None => lk.push(Chunk::data(*b"ldat", ldat)),
}
}
let kids = kids_mut(&mut node)?;
let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("shape record without cdat"))?;
if let Some(slot) = kids.get_mut(at) {
*slot = list;
}
let mut list = key_list.clone();
fill_key_list(&mut list, ldat, v.keys.len(), size)?;
replace_cdat(&mut node, list)?;
}
if let Some((text, enabled)) = &v.expr {
// As on the Transform properties (R15; 856).
@@ -746,7 +688,7 @@ pub(crate) fn build_items(t: &ShapeProtos, items: &[ShItem], depth: usize) -> R<
if depth > MAX_DEPTH {
return Ok(out);
}
let pos = |kids: &[Chunk<'static>], n: &str| kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(n));
let pos = |kids: &[Chunk<'static>], n: &str| kids.iter().position(|c| is_tdmn(c, n));
for item in items {
match item {
ShItem::Off(inner) => {
@@ -766,7 +708,7 @@ pub(crate) fn build_items(t: &ShapeProtos, items: &[ShItem], depth: usize) -> R<
let vg = pos(kids, "ADBE Vectors Group").ok_or_else(|| tpl("group prototype without contents"))?;
let contents = kids.get_mut(vg + 1).ok_or_else(|| tpl("group prototype without contents"))?;
let ck = kids_mut(contents)?;
let end = ck.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Group End")).unwrap_or(ck.len());
let end = ck.iter().position(|c| is_tdmn(c, GROUP_END)).unwrap_or(ck.len());
ck.splice(end..end, children);
let tg = pos(kids, "ADBE Vector Transform Group").ok_or_else(|| tpl("group prototype without transform"))?;
let tnode = kids.get_mut(tg + 1).ok_or_else(|| tpl("group prototype without transform"))?;
@@ -815,13 +757,10 @@ pub(crate) fn put_contents(layr: &mut Chunk<'static>, t: &ShapeProtos, items: &[
let built = build_items(t, items, 0)?;
let root = list_mut(layr, b"tdgp")?;
let kids = kids_mut(root)?;
let at = kids
.iter()
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Root Vectors Group"))
.ok_or_else(|| tpl("shape layer without contents"))?;
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Root Vectors Group")).ok_or_else(|| tpl("shape layer without contents"))?;
let rv = kids.get_mut(at + 1).ok_or_else(|| tpl("shape layer without contents"))?;
let rk = kids_mut(rv)?;
let end = rk.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Group End")).unwrap_or(rk.len());
let end = rk.iter().position(|c| is_tdmn(c, GROUP_END)).unwrap_or(rk.len());
rk.splice(end..end, built);
Ok(())
}
+6 -6
View File
@@ -4,14 +4,14 @@
//! e1-sparse).
use crate::Chunk;
use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdbs, tdmn, tdsn};
use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdbs, tdmn, tdmn_name, tdsn};
/// A group: its match name and a `tdgp` with the state `state` (`tdsb`), no user name, `kids` and
/// the closing `ADBE Group End`.
pub(crate) fn group(name: &str, state: u32, kids: Vec<Chunk<'static>>) -> [Chunk<'static>; 2] {
let mut body = vec![Chunk::data(*b"tdsb", state.to_be_bytes().to_vec()), tdsn(NO_NAME)];
body.extend(kids);
body.push(tdmn("ADBE Group End"));
body.push(tdmn(GROUP_END));
[tdmn(name), Chunk::list(*b"tdgp", body)]
}
@@ -61,7 +61,7 @@ pub(crate) fn av_layer() -> Vec<Chunk<'static>> {
let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn("ADBE Time Remapping"), time_remap()];
root.extend(group("ADBE Transform Group", 1, crate::export::tree_spec::build(crate::export::tree_defs::AV_TRANSFORM)));
root.extend(layer_styles());
root.push(tdmn("ADBE Group End"));
root.push(tdmn(GROUP_END));
let layr = Chunk::list(
*b"Layr",
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
@@ -78,7 +78,7 @@ pub(crate) fn av_layer() -> Vec<Chunk<'static>> {
pub(crate) fn layer_block(nodes: &[crate::export::tree_spec::Node]) -> Vec<Chunk<'static>> {
let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn("")];
root.extend(crate::export::tree_spec::build(nodes));
root.push(tdmn("ADBE Group End"));
root.push(tdmn(GROUP_END));
let layr = Chunk::list(
*b"Layr",
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
@@ -125,7 +125,7 @@ pub(crate) fn text_layer(doc: &[u8]) -> Vec<Chunk<'static>> {
root.extend(group("ADBE Text Properties", 1, props));
root.extend(group("ADBE Transform Group", 1, crate::export::tree_spec::build(crate::export::tree_defs::AV_TRANSFORM)));
root.extend(layer_styles());
root.push(tdmn("ADBE Group End"));
root.push(tdmn(GROUP_END));
let layr = Chunk::list(
*b"Layr",
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
@@ -141,7 +141,7 @@ pub(crate) fn pairs(nodes: &[crate::export::tree_spec::Node]) -> Vec<(String, [C
let mut out = Vec::new();
let mut it = crate::export::tree_spec::build(nodes).into_iter();
while let (Some(m), Some(n)) = (it.next(), it.next()) {
let name = String::from_utf8_lossy(m.bytes().unwrap_or_default().split(|b| *b == 0).next().unwrap_or_default()).into_owned();
let name = tdmn_name(&m).unwrap_or_default();
out.push((name, [m, n]));
}
out
+9 -40
View File
@@ -23,8 +23,10 @@ use effectcraft_project::build::Ids;
use effectcraft_project::styles::{self as st, STYLE_BLEND_MODES};
use effectcraft_project::{Comp, Layer, PropGroup, Property};
use crate::export::keys::{fill_key_list, replace_cdat, timed_keys};
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan};
use crate::export::report::{Collector, LossCategory};
use crate::export::synth::{GROUP_END, is_tdmn};
use crate::export::tree::{R, child_data, kids_mut, put_f64, put_u8, put_u32, tpl};
/// A record's value.
@@ -128,20 +130,7 @@ fn plan_record(spec: &StyleParam, full: String, prop: &Property, cp: &CompPlan,
/// The keys of a scalar in the layer's time base: linear, hold and bezier sides with the ease in
/// stored units per second (the plain template of 976).
fn plan_number_keys(prop: &Property, cp: &CompPlan, what: &str, col: &mut Collector) -> Vec<KeyPlan> {
let mut timed: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::with_capacity(prop.keys.len());
for k in &prop.keys {
let (u, inexact) = cp.tb.units(k.time);
if inexact {
col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds()));
}
timed.push((u, k));
}
timed.sort_by_key(|(u, _)| *u);
let before = timed.len();
timed.dedup_by_key(|(u, _)| *u);
if timed.len() != before {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged"));
}
let timed = timed_keys(&prop.keys, &cp.tb, what, "key", col);
let mut out = Vec::with_capacity(timed.len());
let beyond = std::cell::Cell::new(false);
for (u, k) in timed {
@@ -279,11 +268,6 @@ pub(crate) struct StyleProtos {
keyed: Chunk<'static>,
}
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let d = c.bytes()?;
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
}
impl StyleProtos {
/// The records built in code from the style table (`style_defs`); the keyed form of a scalar
/// takes `tdb4` bytes 5, 68 and 79 and the key list of a keyed one-component record.
@@ -322,28 +306,13 @@ impl StyleProtos {
}
}
let mut list = self.keyed.child_list(b"list").cloned().ok_or_else(|| tpl("keyed style prototype without keys"))?;
let n = u32::try_from(keys.len()).map_err(|_| tpl("too many keys"))?;
let size = 48usize;
{
let lhd3 = child_data(&mut list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, n, 48)?;
}
let mut ldat = Vec::with_capacity(size * keys.len());
for k in keys {
ldat.extend(crate::export::emit::plain_record(1, k, size)?);
}
{
let lk = kids_mut(&mut list)?;
match lk.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", ldat),
None => lk.push(Chunk::data(*b"ldat", ldat)),
}
}
let kids = kids_mut(&mut rec)?;
let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("style record without cdat"))?;
if let Some(slot) = kids.get_mut(at) {
*slot = list;
}
fill_key_list(&mut list, ldat, keys.len(), size)?;
replace_cdat(&mut rec, list)?;
}
}
if let Some((text, enabled)) = &r.expr {
@@ -362,7 +331,7 @@ impl StyleProtos {
/// The node after the `tdmn` named `name` among `kids`.
fn named_mut<'a>(kids: &'a mut [Chunk<'static>], name: &str) -> R<&'a mut Chunk<'static>> {
let i = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name)).ok_or_else(|| tpl("layer styles group incomplete"))?;
let i = kids.iter().position(|c| is_tdmn(c, name)).ok_or_else(|| tpl("layer styles group incomplete"))?;
kids.get_mut(i + 1).ok_or_else(|| tpl("layer styles group incomplete"))
}
@@ -377,7 +346,7 @@ fn insert_records(protos: &StyleProtos, g: &mut Chunk<'static>, recs: &[Record],
built.extend(protos.build(r)?);
}
let kids = kids_mut(g)?;
let at = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(before)).ok_or_else(|| tpl("layer styles group incomplete"))?;
let at = kids.iter().position(|c| is_tdmn(c, before)).ok_or_else(|| tpl("layer styles group incomplete"))?;
kids.splice(at..at, built);
Ok(())
}
@@ -392,12 +361,12 @@ pub(crate) fn put_styles(protos: &StyleProtos, layr: &mut Chunk<'static>, plan:
set_flags(bo, sm::FLAGS_ON)?;
insert_records(protos, bo, &plan.global, sm::ADV_BLEND)?;
let adv = named_mut(kids_mut(bo)?, sm::ADV_BLEND)?;
insert_records(protos, adv, &plan.adv, "ADBE Group End")?;
insert_records(protos, adv, &plan.adv, GROUP_END)?;
}
for (prefix, recs, on) in &plan.styles {
let g = named_mut(kids_mut(ls)?, &format!("{prefix}/enabled"))?;
set_flags(g, if *on { sm::FLAGS_ON } else { sm::FLAGS_OFF })?;
insert_records(protos, g, recs, "ADBE Group End")?;
insert_records(protos, g, recs, GROUP_END)?;
}
Ok(())
}
+78 -25
View File
@@ -4,9 +4,10 @@
//! evidence, or zero where After Effects was shown to accept zero (the read-backs listed in
//! `docs/aep-export.md`, "Built in code").
use crate::export::tree::{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<String> {
let b = c.bytes()?;
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
Some(String::from_utf8_lossy(b.get(..end)?).into_owned())
}
/// Whether `c` is a `tdmn` naming `name`.
pub(crate) fn is_tdmn(c: &Chunk<'_>, name: &str) -> bool {
c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name)
}
/// The user name of a property that has none (`-_0_/-`, aep-format.md 3.4).
pub(crate) const NO_NAME: &str = "-_0_/-";
/// `tdsn`: the user name, a nested chunk holding one `Utf8`.
pub(crate) fn tdsn(name: &str) -> Chunk<'static> {
Chunk { id: *b"tdsn", body: Body::Chunks(vec![Chunk::data(*b"Utf8", name.as_bytes().to_vec())]), pad: None }
utf8_in(*b"tdsn", name)
}
/// A nested chunk `id` holding one `Utf8` with `text` (`tdsn`, `fnam`, `pdnm`).
pub(crate) fn utf8_in(id: [u8; 4], text: &str) -> Chunk<'static> {
Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.as_bytes().to_vec())]), pad: None }
}
/// How a property's keys are stored (`tdb4` byte 7, aep-format.md 3.5).
@@ -100,13 +123,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<u8> {
fixed("tdb4", try_tdb4(spec, keyed, time_scale), || vec![0u8; 124])
}
fn try_tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> R<Vec<u8>> {
let mut b = vec![0u8; 124];
let set = |b: &mut Vec<u8>, at: usize, bytes: &[u8]| {
if let Some(dst) = b.get_mut(at..at + bytes.len()) {
dst.copy_from_slice(bytes);
}
};
set(&mut b, 0, &[0xdb, 0x99, 0x00, spec.dims]);
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<u8> {
Some(b) => (b & !0x01) | u8::from(!keyed),
None => (if spatial == 0 { 0 } else { spatial_byte5(spatial) }) | u8::from(!keyed),
};
set(&mut b, 5, &[byte5]);
set(&mut b, 7, &[spatial]);
set(&mut b, 8, &spec.class.to_be_bytes());
set(&mut b, 12, &time_scale.to_be_bytes());
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<u8>) -> Chunk<'static> {
let mut lhd3 = vec![0u8; 52];
// Cannot fail: the header is 52 bytes.
let _ = crate::export::tree::put_u32(&mut lhd3, 0, 0x00d0_0bee);
let _ = crate::export::tree::put_u32(&mut lhd3, 24, 1);
let _ = put_key_counts(&mut lhd3, count, size);
let lhd3 = 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<Vec<u8>> {
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]);
+4 -9
View File
@@ -21,6 +21,7 @@ use effectcraft_project::{Layer, Node, PropGroup};
use crate::export::plan::CompPlan;
use crate::export::report::{Collector, LossCategory};
use crate::export::shapes::{Conv, Layout, RecVal, ShCx, build_record, c_color, c_popup, c_scalar, rec_value};
use crate::export::synth::{GROUP_END, is_tdmn};
use crate::export::tree::{R, kids_mut, set_utf8, tpl};
/// More animators or selectors than this on one layer are not written (and reported).
@@ -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<String> {
let b = c.bytes()?;
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
std::str::from_utf8(b.get(..end)?).ok().map(str::to_string)
}
fn pos(kids: &[Chunk<'static>], n: &str) -> Option<usize> {
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(n))
kids.iter().position(|c| is_tdmn(c, n))
}
/// The item's user name (`tdsn/Utf8`).
@@ -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(())
}
+96 -6
View File
@@ -40,6 +40,7 @@ pub(crate) fn child_data<'a>(c: &'a mut Chunk<'static>, id: &[u8; 4]) -> R<&'a m
data_mut(child_mut(c, id)?)
}
/// The one byte writer of the exporter: `bytes` at `at`, or an error when they do not fit.
pub(crate) fn put(d: &mut [u8], at: usize, bytes: &[u8]) -> R<()> {
let end = at.checked_add(bytes.len()).ok_or_else(|| tpl("offset overflow"))?;
let dst = d.get_mut(at..end).ok_or_else(|| tpl("chunk shorter than its layout"))?;
@@ -90,20 +91,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<T>(builder: &'static str, built: R<T>, 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<Chunk<'static>>)>;
/// Splits the children of a property group into the header (up to the first `tdmn`) and its
/// entries by match name: `[tdmn, node]`, or `[tdmn]` for the closing `ADBE Group End`. A match
/// name without a node is an error.
pub(crate) fn split_group(kids: &mut Vec<Chunk<'static>>) -> R<(Vec<Chunk<'static>>, Entries)> {
let first = kids.iter().position(|c| c.is(b"tdmn")).unwrap_or(kids.len());
let header: Vec<Chunk<'static>> = kids.drain(..first).collect();
let mut entries = Entries::new();
let mut it = std::mem::take(kids).into_iter();
while let Some(m) = it.next() {
let name = crate::export::synth::tdmn_name(&m).unwrap_or_default();
if name == crate::export::synth::GROUP_END {
entries.push((name, vec![m]));
continue;
}
let node = it.next().ok_or_else(|| tpl("match name without a node"))?;
entries.push((name, vec![m, node]));
}
Ok((header, entries))
}
/// Replaces the `Utf8` child of an item or layer with `text`.
pub(crate) fn set_utf8(c: &mut Chunk<'static>, text: &str) -> R<()> {
let k = child_mut(c, b"Utf8")?;
*k = Chunk::data(*b"Utf8", text.as_bytes().to_vec());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[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());
}
}