mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 14:22:14 +00:00
Merge remote-tracking branch 'origin/main' into codex/778-effect-migration
# Conflicts: # crates/aep/src/container.rs # crates/aep/src/container/aepx.rs # crates/aep/src/container/arbitrary.rs # crates/aep/src/container/camera.rs # crates/aep/src/container/effects.rs # crates/aep/src/container/footage.rs # crates/aep/src/container/header.rs # crates/aep/src/container/keys.rs # crates/aep/src/container/masks.rs # crates/aep/src/container/read.rs # crates/aep/src/container/settings.rs # crates/aep/src/container/shapes.rs # crates/aep/src/container/textdoc.rs # crates/aep/src/container/typed.rs # crates/aep/src/container/values.rs # crates/aep/src/container/version.rs # crates/aep/src/container/write.rs # crates/aep/src/import/markers.rs # crates/aep/tests/it/container.rs # crates/aep/tests/it/container/batch3_core.rs # crates/aep/tests/it/container/batch3_objects.rs # crates/aep/tests/it/container/cases_600.rs # crates/aep/tests/it/container/header.rs # crates/aep/tests/it/container/helpers.rs # crates/aep/tests/it/container/keyframes.rs # crates/aep/tests/it/container/layer_switches.rs # crates/aep/tests/it/container/matte.rs # crates/aep/tests/it/container/robustness.rs # crates/aep/tests/it/container/text_markers_footage.rs # crates/aep/tests/it/container/typed.rs # crates/aep/tests/it/container/values.rs
This commit is contained in:
commit
8dca7d4e8a
29 files changed
+1588
-499
No files matched your search
Generated
+1
@@ -1574,6 +1574,7 @@ dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-project",
|
||||
"effectcraft-time",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -16,6 +16,7 @@ effectcraft-color = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
effectcraft-project = { workspace = true }
|
||||
effectcraft-effects = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ tree; pad bytes, odd bodies and the bytes after the container, an XMP packet, ar
|
||||
|
||||
The XML form of a project (`.aepx`) is read into the same tree by `read_aepx` (a small bounded
|
||||
XML tokenizer, no dependency); `parse_any` picks the reader by content, so the typed view and the
|
||||
importer take either form. The mapping is in `docs/aep-format.md`, section 1.
|
||||
importer take either form. The mapping is in `docs/aep-format.md`, section 1. The importer, which
|
||||
only reads, does not build the owned tree: it reads either form through the flat index
|
||||
`TreeIndex`, an `.aep` in place and an `.aepx` decoded into one buffer first.
|
||||
|
||||
Bodies that look like lists but are not chunk sequences (for example `LIST btdk`, raw text) stay
|
||||
opaque rather than failing the parse. Input is untrusted: sizes are checked against the remaining
|
||||
|
||||
@@ -73,10 +73,6 @@ pub struct StyleMap {
|
||||
}
|
||||
|
||||
impl StyleMap {
|
||||
/// The switch group's match name (`dropShadow/enabled`).
|
||||
pub fn switch_name(&self) -> String {
|
||||
format!("{}/enabled", self.ae)
|
||||
}
|
||||
/// A parameter's full match name (`dropShadow/opacity`).
|
||||
pub fn param_name(&self, p: &StyleParam) -> String {
|
||||
format!("{}/{}", self.ae, p.ae)
|
||||
|
||||
@@ -26,6 +26,7 @@ mod footage;
|
||||
mod fx_defs;
|
||||
mod fxsynth;
|
||||
pub(crate) mod icc;
|
||||
mod keys;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod plan;
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
//! (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::Fixed;
|
||||
|
||||
/// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it.
|
||||
/// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it. The layouts are
|
||||
/// fixed in code ([`Fixed`]).
|
||||
pub(crate) fn bytes(len: usize, fields: &[(usize, &[u8])]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; len];
|
||||
layout(len, fields).finish(|| format!("base::bytes ({len} bytes, fields at {:?})", fields.iter().map(|(at, v)| (*at, v.len())).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// The fixed layout of `len` zero bytes with `fields` written at their offsets.
|
||||
fn layout(len: usize, fields: &[(usize, &[u8])]) -> Fixed {
|
||||
let mut b = Fixed::zeroed(len);
|
||||
for (at, v) in fields {
|
||||
if let Some(dst) = b.get_mut(*at..at.saturating_add(v.len())) {
|
||||
dst.copy_from_slice(v);
|
||||
}
|
||||
b.put(*at, v);
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -52,12 +57,8 @@ pub(crate) fn project_chunks() -> Vec<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 +158,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 +281,16 @@ pub(crate) fn solid_item() -> Chunk<'static> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A field out of range is an error.
|
||||
#[test]
|
||||
fn a_field_out_of_range_is_an_error() {
|
||||
let ok = layout(4, &[(0, &[1]), (3, &[2])]);
|
||||
assert!(ok.error().is_none());
|
||||
assert_eq!(ok.finish(String::new), [1, 0, 0, 2]);
|
||||
assert!(layout(4, &[(3, &[1, 2])]).error().is_some());
|
||||
assert!(layout(4, &[(usize::MAX, &[1])]).error().is_some());
|
||||
}
|
||||
|
||||
/// The chunks After Effects refuses a file without, or crashes on (aep-export.md, "What After
|
||||
/// Effects needs").
|
||||
#[test]
|
||||
|
||||
@@ -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));
|
||||
|
||||
+359
-80
@@ -6,8 +6,10 @@ use std::collections::HashMap;
|
||||
use crate::{AepFile, Chunk};
|
||||
use effectcraft_project::{ItemId, LayerId};
|
||||
|
||||
use crate::export::keys::fill_key_list;
|
||||
use crate::export::plan::{CompPlan, Entry, ItemPlan, KeyPlan, LayerPlan, LayerSrc, ProjectPlan, PropKind, PropPlan, SolidPlan, natural_key};
|
||||
use crate::export::template::Template;
|
||||
use crate::export::synth::{is_tdmn, tdmn_name};
|
||||
use crate::export::template::{self, Template};
|
||||
use crate::export::tree::{
|
||||
R, child_data, data_mut, kids_mut, list_mut, put, put_bits, put_cstr, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, set_utf8, tpl,
|
||||
};
|
||||
@@ -58,10 +60,14 @@ struct Cx<'t> {
|
||||
type Sorted = (String, u64, Vec<Chunk<'static>>);
|
||||
|
||||
pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
|
||||
let t = Template::load()?;
|
||||
emit_with(template::shared()?, plan, extra)
|
||||
}
|
||||
|
||||
/// [`emit`] with the prototypes of `t`.
|
||||
fn emit_with(t: &Template, plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
|
||||
let special = plan.root.iter().find(|e| e.name == "Solids" && matches!(e.plan, ItemPlan::Folder(_))).map(|e| e.ec);
|
||||
let mut cx = Cx {
|
||||
t: &t,
|
||||
t,
|
||||
ids: Ids { next: FIRST_ID },
|
||||
solid_ids: HashMap::new(),
|
||||
comp_ids: HashMap::new(),
|
||||
@@ -77,16 +83,14 @@ pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
|
||||
|
||||
let mut root: Vec<Sorted> = Vec::new();
|
||||
build_level(&plan.root, &mut cx, &ids_by_entry, &mut |name, key, chunks| root.push((name, key, chunks)))?;
|
||||
// The Solids folder: root-level solids and the hidden solids of null layers.
|
||||
let mut solids_children: Vec<Sorted> = Vec::new();
|
||||
for (name, key, chunks) in root.iter().filter(|(_, k, _)| *k & ROOT_SOLID != 0) {
|
||||
solids_children.push((name.clone(), *key, chunks.clone()));
|
||||
}
|
||||
root.retain(|(_, k, c)| *k & ROOT_SOLID == 0 && !c.is_empty());
|
||||
for (i, (name, chunk)) in cx.extra_solids.iter().enumerate() {
|
||||
let mut v = vec![chunk.clone()];
|
||||
// The Solids folder: root-level solids and the hidden solids of null layers (moved, not
|
||||
// copied: the root keeps neither).
|
||||
let (mut solids_children, mut root): (Vec<Sorted>, Vec<Sorted>) = root.into_iter().partition(|(_, k, _)| *k & ROOT_SOLID != 0);
|
||||
root.retain(|(_, _, c)| !c.is_empty());
|
||||
for (i, (name, chunk)) in std::mem::take(&mut cx.extra_solids).into_iter().enumerate() {
|
||||
let mut v = vec![chunk];
|
||||
v.extend(cx.t.fset.iter().cloned());
|
||||
solids_children.push((name.clone(), i as u64, v));
|
||||
solids_children.push((name, i as u64, v));
|
||||
}
|
||||
let mut folder = match cx.special_slot.take() {
|
||||
Some((item, inner)) => {
|
||||
@@ -125,26 +129,57 @@ pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
|
||||
/// Marks a root entry that goes into the `Solids` folder.
|
||||
const ROOT_SOLID: u64 = 1 << 63;
|
||||
|
||||
/// Sorts by name in natural order, then by the tie-break key. Each name's sort key is built once,
|
||||
/// not on every comparison; the sort is stable, as `sort_by` is.
|
||||
fn sort_named(v: &mut [Sorted]) {
|
||||
v.sort_by(|a, b| natural_cmp(&a.0, &b.0).then(a.1.cmp(&b.1)));
|
||||
v.sort_by_cached_key(|(name, key, _)| (natural_bytes(name), *key));
|
||||
}
|
||||
|
||||
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let (ka, kb) = (natural_key(a), natural_key(b));
|
||||
for (x, y) in ka.iter().zip(kb.iter()) {
|
||||
let c = match (x.0, y.0) {
|
||||
(true, true) => x.2.cmp(&y.2).then_with(|| x.1.cmp(&y.1)),
|
||||
(false, false) => x.1.cmp(&y.1),
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
};
|
||||
if c != std::cmp::Ordering::Equal {
|
||||
return c;
|
||||
/// The [`natural_key`] of `name` as one byte string whose plain byte order is the natural order:
|
||||
/// segment by segment, a number before text, numbers by value and then by their digit strings
|
||||
/// byte by byte (`01` before `1`), text by its lowercase string, and a key that is a prefix of
|
||||
/// the other first.
|
||||
///
|
||||
/// - A number: `0x01`, the count of significant bytes of its value, those bytes big-endian, its
|
||||
/// digits, `0x00` (digits are never `0x00`, so a digit string that is a prefix of another, as
|
||||
/// `0` of `00`, sorts first).
|
||||
/// - Text: `0x02`, its lowercase UTF-8 with each `0x00` written as `0x00 0x01`, then `0x00 0x00`.
|
||||
///
|
||||
/// At most 5 bytes per byte of `name` (`NATURAL_BYTES_PER_NAME_BYTE`), so the cached keys stay
|
||||
/// within a small multiple of the names they sort (one segment as a `(bool, String, u128)` takes
|
||||
/// 48 bytes plus its string, which is why the segments themselves are not cached).
|
||||
fn natural_bytes(name: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(name.len().saturating_add(4));
|
||||
for (number, text, value) in natural_key(name) {
|
||||
if number {
|
||||
out.push(0x01);
|
||||
let be = value.to_be_bytes();
|
||||
let zeros = be.iter().take_while(|b| **b == 0).count();
|
||||
let significant = be.get(zeros..).unwrap_or_default();
|
||||
out.push(significant.len() as u8);
|
||||
out.extend_from_slice(significant);
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out.push(0x00);
|
||||
} else {
|
||||
out.push(0x02);
|
||||
for b in text.bytes() {
|
||||
out.push(b);
|
||||
if b == 0 {
|
||||
out.push(0x01);
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&[0x00, 0x00]);
|
||||
}
|
||||
}
|
||||
ka.len().cmp(&kb.len())
|
||||
out
|
||||
}
|
||||
|
||||
/// The bound on [`natural_bytes`]: a run of `d` digits takes at most `3 + 2d` bytes (its value
|
||||
/// never needs more bytes than digits), a text run of `b` bytes at most `3 + 2b` (lowercasing and
|
||||
/// the NUL escape at most double a character), and every run is at least one byte long.
|
||||
#[cfg(test)]
|
||||
const NATURAL_BYTES_PER_NAME_BYTE: usize = 5;
|
||||
|
||||
fn assign_ids(entries: &[Entry], cx: &mut Cx<'_>, map: &mut HashMap<ItemId, u32>) -> R<()> {
|
||||
// Iterative to bound the stack by the folder depth the plan already limits.
|
||||
let mut stack: Vec<&Entry> = entries.iter().rev().collect();
|
||||
@@ -404,8 +439,7 @@ fn put_used_comps(cx: &Cx<'_>, item: &mut Chunk<'static>, cp: &CompPlan) -> R<()
|
||||
fn put_time_remap_max(layr: &mut Chunk<'static>, seconds: f64) -> R<()> {
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at =
|
||||
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
|
||||
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
|
||||
let tdbs = kids.get_mut(at + 1).ok_or_else(|| tpl("Time Remapping without a record"))?;
|
||||
put_f64(child_data(tdbs, b"tduM")?, 0, seconds)
|
||||
}
|
||||
@@ -420,8 +454,7 @@ fn put_time_remap_max(layr: &mut Chunk<'static>, seconds: f64) -> R<()> {
|
||||
fn put_time_remap(cx: &Cx<'_>, layr: &mut Chunk<'static>, remap: &PropPlan) -> R<()> {
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at =
|
||||
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
|
||||
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
|
||||
let tdbs = kids.get_mut(at + 1).ok_or_else(|| tpl("Time Remapping without a record"))?;
|
||||
put_u32(child_data(tdbs, b"tdsb")?, 0, 1)?;
|
||||
{
|
||||
@@ -454,21 +487,12 @@ fn put_time_remap(cx: &Cx<'_>, layr: &mut Chunk<'static>, remap: &PropPlan) -> R
|
||||
.find(|k| matches!(&k.body, crate::Body::List { list_type, .. } if list_type == b"list"))
|
||||
.cloned()
|
||||
.ok_or_else(|| tpl("keyed prototype without a list"))?;
|
||||
let n = u32::try_from(remap.keys.len()).map_err(|_| tpl("too many keys"))?;
|
||||
let size = 48usize;
|
||||
{
|
||||
let lhd3 = child_data(&mut list, b"lhd3")?;
|
||||
crate::export::synth::put_key_counts(lhd3, n, 48)?;
|
||||
}
|
||||
let mut ldat = Vec::with_capacity(size * remap.keys.len());
|
||||
for k in &remap.keys {
|
||||
ldat.extend(plain_record(1, k, size)?);
|
||||
}
|
||||
let ld = kids_mut(&mut list)?;
|
||||
match ld.iter_mut().find(|c| c.is(b"ldat")) {
|
||||
Some(c) => *c = Chunk::data(*b"ldat", ldat),
|
||||
None => ld.push(Chunk::data(*b"ldat", ldat)),
|
||||
}
|
||||
fill_key_list(&mut list, ldat, remap.keys.len(), size)?;
|
||||
if let Some(slot) = rk.get_mut(cdat_at) {
|
||||
*slot = list;
|
||||
}
|
||||
@@ -616,10 +640,7 @@ fn put_masks(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan) -> R<()> {
|
||||
let parade = crate::export::masks::build_parade(&cx.t.masks, &lp.masks, lp.source_size)?;
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at = kids
|
||||
.iter()
|
||||
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
|
||||
.ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
kids.splice(at..at, parade);
|
||||
Ok(())
|
||||
}
|
||||
@@ -633,10 +654,7 @@ fn put_effects(cx: &mut Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, layer
|
||||
let parade = crate::export::effects::build_parade(&cx.t.effects, &lp.fx, layer_id, layer_ids, lp.source_size, &mut cx.fx_use)?;
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at = kids
|
||||
.iter()
|
||||
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
|
||||
.ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
kids.splice(at..at, parade);
|
||||
Ok(())
|
||||
}
|
||||
@@ -659,11 +677,6 @@ const GROUP_ORDER: [&str; 13] = [
|
||||
"ADBE Group End",
|
||||
];
|
||||
|
||||
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
|
||||
let d = c.bytes()?;
|
||||
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
|
||||
}
|
||||
|
||||
/// Inserts the properties of `lp` into the layer's Transform group, in the order the file uses.
|
||||
fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &CompPlan) -> R<()> {
|
||||
if lp.props.is_empty() {
|
||||
@@ -671,20 +684,19 @@ fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &C
|
||||
}
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at = kids
|
||||
.iter()
|
||||
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
|
||||
.ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Transform Group")).ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
let group = kids.get_mut(at + 1).ok_or_else(|| tpl("Transform group without a list"))?;
|
||||
let gk = kids_mut(group)?;
|
||||
// Split the group into its header (up to the first match name) and (tdmn, node) pairs.
|
||||
let first = gk.iter().position(|c| c.is(b"tdmn")).ok_or_else(|| tpl("empty Transform group"))?;
|
||||
let header: Vec<Chunk<'static>> = gk.drain(..first).collect();
|
||||
let mut pairs: Vec<(String, Vec<Chunk<'static>>)> = Vec::new();
|
||||
let mut pairs: Vec<(Vec<u8>, Vec<Chunk<'static>>)> = Vec::new();
|
||||
let mut it = std::mem::take(gk).into_iter();
|
||||
while let Some(m) = it.next() {
|
||||
let name = tdmn_name(&m).unwrap_or_default();
|
||||
if name == "ADBE Group End" {
|
||||
// The match name as the shared decoder reads it (lossy UTF-8 up to the first NUL). Every
|
||||
// name looked up below is ASCII, so a name matches exactly when its bytes do.
|
||||
let name = tdmn_name(&m).unwrap_or_default().into_bytes();
|
||||
if name == b"ADBE Group End" {
|
||||
pairs.push((name, vec![m]));
|
||||
continue;
|
||||
}
|
||||
@@ -694,22 +706,19 @@ fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &C
|
||||
for p in &lp.props {
|
||||
let name = p.kind.match_name();
|
||||
let built = build_prop(cx, p, lp, cp)?;
|
||||
let tdmn = pairs.iter().find(|(n, _)| n == name).and_then(|(_, v)| v.first().cloned()).unwrap_or_else(|| {
|
||||
cx.t.stat
|
||||
.iter()
|
||||
.chain(cx.t.keyd.iter())
|
||||
.find(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Chunk::data(*b"tdmn", Vec::new()))
|
||||
let tdmn = pairs.iter().find(|(n, _)| n == name.as_bytes()).and_then(|(_, v)| v.first().cloned()).unwrap_or_else(|| {
|
||||
cx.t.stat.iter().chain(cx.t.keyd.iter()).find(|c| is_tdmn(c, name)).cloned().unwrap_or_else(|| Chunk::data(*b"tdmn", Vec::new()))
|
||||
});
|
||||
let entry = vec![tdmn, built];
|
||||
if let Some(slot) = pairs.iter_mut().find(|(n, _)| n == name) {
|
||||
if let Some(slot) = pairs.iter_mut().find(|(n, _)| n == name.as_bytes()) {
|
||||
slot.1 = entry;
|
||||
} else {
|
||||
pairs.push((name.to_string(), entry));
|
||||
pairs.push((name.as_bytes().to_vec(), entry));
|
||||
}
|
||||
}
|
||||
let rank = |n: &str| if n == "ADBE Group End" { usize::MAX } else { GROUP_ORDER.iter().position(|g| *g == n).unwrap_or(GROUP_ORDER.len()) };
|
||||
let rank = |n: &[u8]| {
|
||||
if n == b"ADBE Group End" { usize::MAX } else { GROUP_ORDER.iter().position(|g| g.as_bytes() == n).unwrap_or(GROUP_ORDER.len()) }
|
||||
};
|
||||
pairs.sort_by_key(|(n, _)| rank(n));
|
||||
gk.extend(header);
|
||||
gk.extend(pairs.into_iter().flat_map(|(_, v)| v));
|
||||
@@ -743,22 +752,12 @@ fn build_prop(cx: &Cx<'_>, p: &PropPlan, lp: &LayerPlan, cp: &CompPlan) -> R<Chu
|
||||
put_f64(cdat, i * 8, *v)?;
|
||||
}
|
||||
} else {
|
||||
let list = list_mut(&mut tdbs, b"list")?;
|
||||
let n = u32::try_from(p.keys.len()).map_err(|_| tpl("too many keys"))?;
|
||||
let size = if p.kind.spatial() { 56 + 24 * p.kind.dims() } else { 8 + 40 * p.kind.dims() };
|
||||
{
|
||||
let lhd3 = child_data(list, b"lhd3")?;
|
||||
crate::export::synth::put_key_counts(lhd3, n, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
|
||||
}
|
||||
let mut ldat = Vec::with_capacity(size * p.keys.len());
|
||||
for k in &p.keys {
|
||||
ldat.extend(key_record(p.kind, k, size)?);
|
||||
}
|
||||
let ld = kids_mut(list)?;
|
||||
match ld.iter_mut().find(|c| c.is(b"ldat")) {
|
||||
Some(c) => *c = Chunk::data(*b"ldat", ldat),
|
||||
None => ld.push(Chunk::data(*b"ldat", ldat)),
|
||||
}
|
||||
fill_key_list(list_mut(&mut tdbs, b"list")?, ldat, p.keys.len(), size)?;
|
||||
}
|
||||
if let Some((text, _)) = &p.expr {
|
||||
let kids = kids_mut(&mut tdbs)?;
|
||||
@@ -818,3 +817,283 @@ pub(crate) fn plain_record(n: usize, k: &KeyPlan, size: usize) -> R<Vec<u8>> {
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::{Comp, Expression, ItemKind, LayerSource, Project, Property, Solid, build};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
||||
/// Seconds as ticks.
|
||||
fn secs(s: f64) -> Tick {
|
||||
Tick((s * TICKS_PER_SECOND as f64).round() as i64)
|
||||
}
|
||||
|
||||
/// Adds a layer to `comp`, changed by `edit` first.
|
||||
fn add_layer(p: &mut Project, comp: ItemId, name: &str, source: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
let mut c = p.comp(comp).cloned().expect("comp");
|
||||
let mut l = build::layer(p, &c, name, source, (640, 360), None);
|
||||
l.name = name.to_string();
|
||||
edit(&mut l);
|
||||
c.layers.insert(0, l);
|
||||
if let Some(i) = p.item_mut(comp) {
|
||||
i.kind = ItemKind::Comp(Arc::new(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives the property two keys (0 s and 1.5 s) holding its value.
|
||||
fn two_keys(prop: Option<&mut Property>) {
|
||||
let prop = prop.expect("property");
|
||||
let v = prop.value.clone();
|
||||
prop.keys = vec![Keyframe::new(secs(0.0), v.clone()), Keyframe::new(secs(1.5), v)];
|
||||
}
|
||||
|
||||
/// A project that reaches every part of `emit`: folders (one named `Solids`), root and nested
|
||||
/// solids with names that sort naturally, a precomposition, null layers (hidden solids), keyed
|
||||
/// Transform properties, an expression and a keyed time remap.
|
||||
fn varied(tag: &str) -> Project {
|
||||
let mut p = Project::default();
|
||||
let solid = |w| ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: w, height: 360, pixel_aspect: 1.0 });
|
||||
let solids = p.add_item("Solids", Label::None, None, ItemKind::Folder);
|
||||
let nested = p.add_item("Folder 10", Label::None, None, ItemKind::Folder);
|
||||
p.add_item("folder 9", Label::None, Some(nested), ItemKind::Folder);
|
||||
let mut ids = Vec::new();
|
||||
for name in ["Solid 10", "Solid 2", "solid 2", "Solid 02", "Solid", "Ölung 1", ""] {
|
||||
ids.push(p.add_item(&format!("{name}{tag}"), Label::Red, None, solid(640)));
|
||||
}
|
||||
p.add_item("In Solids 3", Label::None, Some(solids), solid(320));
|
||||
p.add_item("In folder", Label::None, Some(nested), solid(100));
|
||||
let pre = p.add_item("Pre 1", Label::None, Some(nested), ItemKind::Comp(Arc::new(Comp::new(640, 360, FrameRate::new(25, 1), secs(3.0)))));
|
||||
add_layer(&mut p, pre, "S", LayerSource::Solid { item: ids[0] }, |_| {});
|
||||
let main = p.add_item("Main", Label::None, None, ItemKind::Comp(Arc::new(Comp::new(1920, 1080, FrameRate::new(30, 1), secs(5.0)))));
|
||||
add_layer(&mut p, main, "Null A", LayerSource::Null, |_| {});
|
||||
add_layer(&mut p, main, "Null B", LayerSource::Null, |l| two_keys(l.props.prop_mut("transform/position")));
|
||||
add_layer(&mut p, main, "Solid layer", LayerSource::Solid { item: ids[1] }, |l| {
|
||||
two_keys(l.props.prop_mut("transform/opacity"));
|
||||
two_keys(l.props.prop_mut("transform/anchor"));
|
||||
if let Some(r) = l.props.prop_mut("transform/rotation") {
|
||||
r.expr = Some(Expression { text: "time * 10".into(), enabled: true });
|
||||
}
|
||||
});
|
||||
add_layer(&mut p, main, "Precomp", LayerSource::Comp { item: pre }, |l| {
|
||||
let mut r = Property::new(9_000, "timeRemap", "Time Remap", Value::Scalar(0.0));
|
||||
r.keys = vec![Keyframe::new(secs(0.0), Value::Scalar(3.0)), Keyframe::new(secs(3.0), Value::Scalar(0.0))];
|
||||
l.props.children.insert(0, r.into());
|
||||
});
|
||||
p
|
||||
}
|
||||
|
||||
/// The export plan of `p`.
|
||||
fn plan_of(p: &Project) -> ProjectPlan {
|
||||
let mut col = crate::export::report::Collector::default();
|
||||
crate::export::plan::plan(p, &mut col)
|
||||
}
|
||||
|
||||
/// The template is built once and every later export uses the same one.
|
||||
#[test]
|
||||
fn the_template_is_built_once() {
|
||||
let a = template::shared().expect("template");
|
||||
let b = template::shared().expect("template");
|
||||
assert!(std::ptr::eq(a, b));
|
||||
}
|
||||
|
||||
/// Exports with the shared template give the bytes a freshly built template gives, also after
|
||||
/// other exports have used it (nothing an export does reaches the template).
|
||||
#[test]
|
||||
fn the_shared_template_exports_what_a_fresh_one_does() {
|
||||
let (a, b) = (plan_of(&varied("")), plan_of(&varied(" x")));
|
||||
let fresh = |plan: &ProjectPlan| emit_with(&Template::load().expect("template"), plan, b"trailer").expect("emit");
|
||||
let (want_a, want_b) = (fresh(&a), fresh(&b));
|
||||
assert!(want_a != want_b);
|
||||
// The project exports without losses and the remap is written.
|
||||
let r = crate::export::export(&varied(""), &crate::export::ExportOptions { allow_losses: false }).expect("lossless");
|
||||
assert_eq!(r.report.exported.time_remaps, 1);
|
||||
// The null layers' hidden solids and the root solids went into the `Solids` folder.
|
||||
let file = crate::parse(&want_a).expect("parse");
|
||||
let folder = file.walk().map(|(_, c)| c).filter(|c| c.is_list(b"Sfdr")).map(|c| c.children().iter().filter(|k| k.is_list(b"Item")).count()).max();
|
||||
assert_eq!(folder, Some(10));
|
||||
for _ in 0..3 {
|
||||
assert!(emit(&a, b"trailer").expect("emit") == want_a);
|
||||
assert!(emit(&b, b"trailer").expect("emit") == want_b);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports running at the same time on several threads all give the single-threaded bytes.
|
||||
/// This checks concurrent exports from a template that is already built: other tests in this
|
||||
/// binary usually fill the shared template first, so the race on the first build is not
|
||||
/// reliably exercised here (the `static OnceLock` makes that race safe by construction).
|
||||
#[test]
|
||||
fn concurrent_exports_give_the_same_bytes() {
|
||||
let plans: Vec<ProjectPlan> = ["", " x", " y", " z"].iter().map(|t| plan_of(&varied(t))).collect();
|
||||
let want: Vec<Vec<u8>> = plans.iter().map(|plan| emit_with(&Template::load().expect("template"), plan, &[]).expect("emit")).collect();
|
||||
let got: Vec<Vec<Vec<u8>>> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|i| {
|
||||
let plans = &plans;
|
||||
s.spawn(move || (0..4).map(|k| emit(&plans[(i + k) % plans.len()], &[]).expect("emit")).collect::<Vec<_>>())
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().map(|h| h.join().expect("thread")).collect()
|
||||
});
|
||||
for (i, bytes) in got.iter().enumerate() {
|
||||
for (k, b) in bytes.iter().enumerate() {
|
||||
assert!(b == &want[(i + k) % want.len()], "thread {i}, export {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The comparator `sort_named` used before its keys were cached: `natural_key` built on every
|
||||
/// comparison.
|
||||
fn old_sort_named(v: &mut [Sorted]) {
|
||||
/// The natural name order the cached sort keys replaced, as the reference.
|
||||
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let (ka, kb) = (natural_key(a), natural_key(b));
|
||||
for (x, y) in ka.iter().zip(kb.iter()) {
|
||||
let c = match (x.0, y.0) {
|
||||
(true, true) => x.2.cmp(&y.2).then_with(|| x.1.cmp(&y.1)),
|
||||
(false, false) => x.1.cmp(&y.1),
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
};
|
||||
if c != std::cmp::Ordering::Equal {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
ka.len().cmp(&kb.len())
|
||||
}
|
||||
v.sort_by(|a, b| natural_cmp(&a.0, &b.0).then(a.1.cmp(&b.1)));
|
||||
}
|
||||
|
||||
/// The cached-key sort orders names exactly as the comparator it replaced, and keeps entries
|
||||
/// with the same name and key in their order (each carries its position as a marker chunk).
|
||||
#[test]
|
||||
fn sort_named_orders_as_the_old_comparator() {
|
||||
let parts = [
|
||||
"",
|
||||
"0",
|
||||
"00",
|
||||
"1",
|
||||
"01",
|
||||
"2",
|
||||
"10",
|
||||
"a",
|
||||
"A",
|
||||
"b",
|
||||
" ",
|
||||
"-",
|
||||
".",
|
||||
"é",
|
||||
"É",
|
||||
"ß",
|
||||
"İ",
|
||||
"\u{0}",
|
||||
"\u{1}",
|
||||
"a\u{0}",
|
||||
"Ⱥ",
|
||||
"K",
|
||||
"ẞ",
|
||||
"Solid ",
|
||||
"solid ",
|
||||
"x9",
|
||||
"9x",
|
||||
"340282366920938463463374607431768211455",
|
||||
"340282366920938463463374607431768211456",
|
||||
"999999999999999999999999999999999999999999",
|
||||
];
|
||||
// A small deterministic generator (splitmix64).
|
||||
let mut state = 0x2545_f491_4f6c_dd1d_u64;
|
||||
let mut next = move || {
|
||||
state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
(z ^ (z >> 31)) as usize
|
||||
};
|
||||
for round in 0..2000 {
|
||||
let n = 1 + next() % 40;
|
||||
let v: Vec<Sorted> = (0..n)
|
||||
.map(|i| {
|
||||
let name: String = (0..next() % 4).map(|_| parts[next() % parts.len()]).collect();
|
||||
let key = (next() % 3) as u64 | if next() % 2 == 0 { ROOT_SOLID } else { 0 };
|
||||
(name, key, vec![Chunk::data(*b"mark", (i as u32).to_le_bytes().to_vec())])
|
||||
})
|
||||
.collect();
|
||||
let (mut old, mut new) = (v.clone(), v);
|
||||
old_sort_named(&mut old);
|
||||
sort_named(&mut new);
|
||||
assert_eq!(old, new, "round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached sort keys stay within [`NATURAL_BYTES_PER_NAME_BYTE`] bytes per name byte, also
|
||||
/// for the worst name (255 bytes of alternating one-character text and digit runs: 255
|
||||
/// segments, which as `(bool, String, u128)` segments took about 14 KB), and the per-character
|
||||
/// premise of the bound holds for every character.
|
||||
#[test]
|
||||
fn sort_keys_are_bounded_by_the_name() {
|
||||
for c in (0..=0x10_ffff_u32).filter_map(char::from_u32) {
|
||||
let lower: usize = c.to_lowercase().map(char::len_utf8).sum::<usize>() + usize::from(c == '\0');
|
||||
assert!(lower <= 2 * c.len_utf8(), "{c:?}");
|
||||
}
|
||||
let worst: String = (0..255).map(|i| if i % 2 == 0 { 'a' } else { '1' }).collect();
|
||||
let others = ["", "1", "a", "\u{0}", "İ", "Ⱥ1", "99999999999999999999999999999999999999999999", &"\u{0}".repeat(255), &"Ⱥ".repeat(127)];
|
||||
for name in std::iter::once(worst.as_str()).chain(others) {
|
||||
let k = natural_bytes(name);
|
||||
assert!(k.len() <= NATURAL_BYTES_PER_NAME_BYTE * name.len(), "{name:?}: {} bytes", k.len());
|
||||
}
|
||||
assert_eq!(natural_key(&worst).len(), 255);
|
||||
assert!(natural_bytes(&worst).len() <= 1_275);
|
||||
}
|
||||
|
||||
/// The shared `is_tdmn` / `tdmn_name` the emitter uses agree with the Latin-1 decoding it used
|
||||
/// before for every name it looks up, which holds because those names are ASCII.
|
||||
#[test]
|
||||
fn match_names_compare_as_the_latin1_decoding_did() {
|
||||
/// The Latin-1 decoding the emitter used before.
|
||||
fn old_tdmn_name(c: &Chunk<'_>) -> Option<String> {
|
||||
let d = c.bytes()?;
|
||||
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
|
||||
}
|
||||
let mut wanted: Vec<&str> = GROUP_ORDER.to_vec();
|
||||
wanted.extend(PropKind::ALL.iter().map(|k| k.match_name()));
|
||||
wanted.extend(["ADBE Time Remapping", "ADBE Transform Group", "ADBE Group End"]);
|
||||
assert!(wanted.iter().all(|n| n.is_ascii()));
|
||||
let mut chunks: Vec<Chunk<'static>> = wanted.iter().map(|n| Chunk::data(*b"tdmn", format!("{n}\0\0junk").into_bytes())).collect();
|
||||
chunks.extend([
|
||||
Chunk::data(*b"tdmn", Vec::new()),
|
||||
Chunk::data(*b"tdmn", b"ADBE Position".to_vec()),
|
||||
Chunk::data(*b"tdmn", b"ADBE Position_0\0".to_vec()),
|
||||
Chunk::data(*b"tdmn", b"ADBE Opacit\xe9\0".to_vec()),
|
||||
Chunk::data(*b"tdmn", b"\xc3\x84DBE Scale\0".to_vec()),
|
||||
Chunk::data(*b"tdmn", b"\0ADBE Scale".to_vec()),
|
||||
Chunk::list(*b"tdmn", Vec::new()),
|
||||
]);
|
||||
for c in &chunks {
|
||||
for n in &wanted {
|
||||
assert_eq!(is_tdmn(c, n), old_tdmn_name(c).as_deref() == Some(*n), "{c:?} {n}");
|
||||
let key = tdmn_name(c).unwrap_or_default().into_bytes();
|
||||
assert_eq!(key == n.as_bytes(), old_tdmn_name(c).as_deref() == Some(*n), "{c:?} {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `fill_key_list`, which the time remap and the Transform keys use, writes the counts and
|
||||
/// replaces the records, or adds them when the list has none.
|
||||
#[test]
|
||||
fn fill_key_list_writes_counts_and_records() {
|
||||
let lhd3 = || Chunk::data(*b"lhd3", vec![0; 52]);
|
||||
let mut want_lhd3 = vec![0; 52];
|
||||
crate::export::synth::put_key_counts(&mut want_lhd3, 3, 48).expect("counts");
|
||||
for start in [vec![lhd3(), Chunk::data(*b"ldat", vec![9; 4])], vec![lhd3()]] {
|
||||
let mut list = Chunk::list(*b"list", start);
|
||||
fill_key_list(&mut list, vec![7; 144], 3, 48).expect("set");
|
||||
assert_eq!(list.children(), &[Chunk::data(*b"lhd3", want_lhd3.clone()), Chunk::data(*b"ldat", vec![7; 144])]);
|
||||
}
|
||||
let mut no_header = Chunk::list(*b"list", Vec::new());
|
||||
assert!(fill_key_list(&mut no_header, Vec::new(), 0, 48).is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
use crate::Chunk;
|
||||
use crate::effect_map::{Conv, by_match_name};
|
||||
use crate::export::fx_defs::{BUILT_IN, FxDef, PDef};
|
||||
use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn};
|
||||
use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn, utf8_in};
|
||||
use crate::export::tree::Fixed;
|
||||
use crate::{CURVE_IDENTITY, CurvesData, MeshData};
|
||||
|
||||
/// The layer a prototype's layer parameters name when their default is the owning layer; the
|
||||
@@ -37,12 +38,6 @@ pub(crate) fn value_spec(code: u8, range: Option<(f64, f64)>) -> Option<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,83 +48,88 @@ fn fixed(v: f64) -> [u8; 4] {
|
||||
/// display 124; fixed and integer: 124 / 128 / 132 / 136, default 140, display 144). Every other
|
||||
/// byte is zero (the switches at 48 are not needed: s12-e2-nofl).
|
||||
pub(crate) fn pard(p: &PDef, popup_items: Option<u16>) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 148];
|
||||
put(&mut b, 12, &u32::from(p.code).to_be_bytes());
|
||||
pard_layout(p, popup_items).finish(|| format!("pard (type {})", p.code))
|
||||
}
|
||||
|
||||
/// The 148-byte `pard` layout of a parameter definition: type, name and defaults.
|
||||
fn pard_layout(p: &PDef, popup_items: Option<u16>) -> 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<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);
|
||||
b.put(56, &c);
|
||||
b.put(60, &c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if p.code == 7 {
|
||||
let n = popup_items.unwrap_or_else(|| u16::try_from(p.display).unwrap_or(0));
|
||||
put(&mut b, 60, &n.to_be_bytes());
|
||||
b.put(60, &n.to_be_bytes());
|
||||
}
|
||||
match p.code {
|
||||
10 => {
|
||||
if let Some(m) = p.valid_min {
|
||||
put(&mut b, 104, &(m as f32).to_be_bytes());
|
||||
b.put(104, &(m as f32).to_be_bytes());
|
||||
}
|
||||
if let Some(m) = p.valid_max {
|
||||
put(&mut b, 108, &(m as f32).to_be_bytes());
|
||||
b.put(108, &(m as f32).to_be_bytes());
|
||||
}
|
||||
if let Some((lo, hi)) = p.range {
|
||||
put(&mut b, 112, &(lo as f32).to_be_bytes());
|
||||
put(&mut b, 116, &(hi as f32).to_be_bytes());
|
||||
b.put(112, &(lo as f32).to_be_bytes());
|
||||
b.put(116, &(hi as f32).to_be_bytes());
|
||||
}
|
||||
}
|
||||
1 | 2 => {
|
||||
let enc = |v: f64| if p.code == 2 { fixed(v) } else { (v.round() as i32).to_be_bytes() };
|
||||
if let Some(m) = p.valid_min {
|
||||
put(&mut b, 124, &enc(m));
|
||||
b.put(124, &enc(m));
|
||||
}
|
||||
if let Some(m) = p.valid_max {
|
||||
put(&mut b, 128, &enc(m));
|
||||
b.put(128, &enc(m));
|
||||
}
|
||||
if let Some((lo, hi)) = p.range {
|
||||
put(&mut b, 132, &enc(lo));
|
||||
put(&mut b, 136, &enc(hi));
|
||||
b.put(132, &enc(lo));
|
||||
b.put(136, &enc(hi));
|
||||
}
|
||||
// The default again at 140, the display word (decimals, percent) at 144.
|
||||
put(&mut b, 140, &enc(d(0)));
|
||||
put(&mut b, 144, &p.display.to_be_bytes());
|
||||
b.put(140, &enc(d(0)));
|
||||
b.put(144, &p.display.to_be_bytes());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -224,7 +224,7 @@ fn data_record(data: Vec<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 +235,11 @@ pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> {
|
||||
if matches!(p.code, 4 | 7 | 15) {
|
||||
// Checkboxes, popups and buttons have a `pdnm` (their label or items); its text may
|
||||
// be empty.
|
||||
part.push(Chunk { id: *b"pdnm", body: crate::Body::Chunks(vec![Chunk::data(*b"Utf8", Vec::new())]), pad: None });
|
||||
part.push(utf8_in(*b"pdnm", ""));
|
||||
}
|
||||
if p.id == BUILT_IN {
|
||||
values.push(tdmn(&record));
|
||||
values.push(Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME), tdmn("ADBE Group End")]));
|
||||
values.push(Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME), tdmn(GROUP_END)]));
|
||||
continue;
|
||||
}
|
||||
match p.code {
|
||||
@@ -265,6 +265,24 @@ pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
values.push(tdmn("ADBE Group End"));
|
||||
values.push(tdmn(GROUP_END));
|
||||
Chunk::list(*b"sspc", vec![fnam, Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values), Chunk::data(*b"pgui", vec![0u8; 16])])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::export::fx_defs::FX_DEFS;
|
||||
|
||||
/// The `pard` of every parameter of the effect table fits its 148 bytes.
|
||||
#[test]
|
||||
fn every_parameter_definition_fits() {
|
||||
for fx in FX_DEFS {
|
||||
for (i, p) in fx.params.iter().enumerate() {
|
||||
let b = pard_layout(p, popup_items(fx, i));
|
||||
assert!(b.error().is_none(), "{} {}", fx.match_name, i);
|
||||
assert_eq!(b.finish(String::new).len(), 148);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Key lists shared by the exporters: the times of a property's keys in the file's units, the
|
||||
//! spatial key record, and the rewrite of a key list (`LIST list`: `lhd3` + `ldat`, aep-format.md
|
||||
//! 3.5).
|
||||
|
||||
use effectcraft_keyframe::Keyframe;
|
||||
|
||||
use crate::Chunk;
|
||||
use crate::export::plan::KeyPlan;
|
||||
use crate::export::report::{Collector, LossCategory};
|
||||
use crate::export::timebase::TimeBase;
|
||||
use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, tpl};
|
||||
|
||||
/// The keys of a property at their times in the file's units, in time order. A key that does not
|
||||
/// fall on the time base is reported as moved; keys that end up at the same time are merged (the
|
||||
/// first one is kept) and reported once. `noun` names the key in the report ("key", "path key").
|
||||
pub(crate) fn timed_keys<'k>(keys: &'k [Keyframe], tb: &TimeBase, what: &str, noun: &str, col: &mut Collector) -> Vec<(i64, &'k Keyframe)> {
|
||||
let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(keys.len());
|
||||
for k in keys {
|
||||
let (u, inexact) = tb.units(k.time);
|
||||
if inexact {
|
||||
col.lose(LossCategory::RoundedTimes, format!("{what}: {noun} at {:.4} s moved to the time base", k.time.seconds()));
|
||||
}
|
||||
timed.push((u, k));
|
||||
}
|
||||
timed.sort_by_key(|(u, _)| *u);
|
||||
let before = timed.len();
|
||||
timed.dedup_by_key(|(u, _)| *u);
|
||||
if timed.len() != before {
|
||||
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: {noun}s at the same time were merged"));
|
||||
}
|
||||
timed
|
||||
}
|
||||
|
||||
/// A key of a point (flags `0x0007`, `u32@8` the automatic tangent switch; the spatial layout of
|
||||
/// a 2D Position, 853) or a colour (flags `0x0001`, 854): the temporal ease of a point at 24..55,
|
||||
/// then the `n` values at 56 and, for a point, the spatial in and out tangents after them.
|
||||
pub(crate) fn spatial_record(k: &KeyPlan, n: usize, point: bool, size: usize) -> R<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;
|
||||
|
||||
/// Keys are timed sorted merged and reported.
|
||||
#[test]
|
||||
fn keys_are_timed_sorted_merged_and_reported() {
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
let tb = TimeBase { frame_duration: 1024, timescale: 25600 };
|
||||
// Out of order; 1/3 s is not on the time base; two keys meet at 1 s (the first one kept).
|
||||
let keys = [
|
||||
Keyframe::new(Tick(TICKS_PER_SECOND), Value::Scalar(1.0)),
|
||||
Keyframe::new(Tick(TICKS_PER_SECOND / 3), Value::Scalar(2.0)),
|
||||
Keyframe::new(Tick(0), Value::Scalar(3.0)),
|
||||
Keyframe::new(Tick(TICKS_PER_SECOND), Value::Scalar(4.0)),
|
||||
];
|
||||
let mut col = Collector::default();
|
||||
let timed = timed_keys(&keys, &tb, "L", "path key", &mut col);
|
||||
let got: Vec<(i64, f64)> = timed.iter().map(|(u, k)| (*u, if let Value::Scalar(v) = k.value { v } else { f64::NAN })).collect();
|
||||
assert_eq!(got, [(0, 3.0), (8533, 2.0), (25600, 1.0)]);
|
||||
// The report lists its categories in a fixed order; one merge report for the pair.
|
||||
let report = col.finish();
|
||||
let lines: Vec<(LossCategory, &str)> = report.losses.iter().flat_map(|l| l.details.iter().map(move |d| (l.category, d.as_str()))).collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
(LossCategory::ApproximatedKeyframes, "L: path keys at the same time were merged"),
|
||||
(LossCategory::RoundedTimes, "L: path key at 0.3333 s moved to the time base"),
|
||||
]
|
||||
);
|
||||
// No keys, no report.
|
||||
let mut col = Collector::default();
|
||||
assert!(timed_keys(&[], &tb, "L", "key", &mut col).is_empty());
|
||||
assert!(col.finish().losses.is_empty());
|
||||
}
|
||||
|
||||
/// Two keys at different times that round to the same time unit are merged too: the first
|
||||
/// one is kept, and both the rounding and the merge are reported.
|
||||
#[test]
|
||||
fn keys_that_round_to_the_same_time_are_merged() {
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_time::Tick;
|
||||
let tb = TimeBase { frame_duration: 1024, timescale: 25600 };
|
||||
// One tick is far below one unit (1/25600 s): it rounds to unit 0, where the first key is.
|
||||
let keys = [Keyframe::new(Tick(0), Value::Scalar(1.0)), Keyframe::new(Tick(1), Value::Scalar(2.0))];
|
||||
let mut col = Collector::default();
|
||||
let timed = timed_keys(&keys, &tb, "L", "key", &mut col);
|
||||
let got: Vec<(i64, f64)> = timed.iter().map(|(u, k)| (*u, if let Value::Scalar(v) = k.value { v } else { f64::NAN })).collect();
|
||||
assert_eq!(got, [(0, 1.0)]);
|
||||
let report = col.finish();
|
||||
let lines: Vec<(LossCategory, &str)> = report.losses.iter().flat_map(|l| l.details.iter().map(move |d| (l.category, d.as_str()))).collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
(LossCategory::ApproximatedKeyframes, "L: keys at the same time were merged"),
|
||||
(LossCategory::RoundedTimes, "L: key at 0.0000 s moved to the time base"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A key list is filled and replaces the value.
|
||||
#[test]
|
||||
fn a_key_list_is_filled_and_replaces_the_value() {
|
||||
let mut list = Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", vec![0; 52])]);
|
||||
fill_key_list(&mut list, vec![7; 96], 2, 48).unwrap();
|
||||
let h = list.child(b"lhd3").and_then(|c| c.bytes()).unwrap();
|
||||
assert_eq!((h[11], h[19]), (2, 48));
|
||||
assert_eq!(list.child(b"ldat").and_then(|c| c.bytes()).map(<[u8]>::len), Some(96));
|
||||
// A second fill replaces the records instead of adding a second `ldat`.
|
||||
fill_key_list(&mut list, vec![1; 48], 1, 48).unwrap();
|
||||
assert_eq!(list.children().iter().filter(|c| c.is(b"ldat")).count(), 1);
|
||||
|
||||
let mut rec = Chunk::list(*b"tdbs", vec![Chunk::data(*b"tdb4", vec![0; 124]), Chunk::data(*b"cdat", vec![0; 8])]);
|
||||
replace_cdat(&mut rec, key_list(48, 0, Vec::new())).unwrap();
|
||||
assert!(rec.child(b"cdat").is_none() && rec.child_list(b"list").is_some());
|
||||
// A record without a value is an error, not a quiet no-op.
|
||||
assert!(replace_cdat(&mut rec, key_list(48, 0, Vec::new())).is_err());
|
||||
set_key_list(&mut rec, vec![0; 48], 1, 48).unwrap();
|
||||
assert!(set_key_list(&mut Chunk::list(*b"tdbs", Vec::new()), Vec::new(), 0, 48).is_err());
|
||||
}
|
||||
|
||||
/// A spatial record holds the point layout.
|
||||
#[test]
|
||||
fn a_spatial_record_holds_the_point_layout() {
|
||||
let k = KeyPlan {
|
||||
time: 5,
|
||||
value: vec![1.0, 2.0],
|
||||
in_interp: 2,
|
||||
out_interp: 1,
|
||||
in_ease: Vec::new(),
|
||||
out_ease: Vec::new(),
|
||||
spatial_in: [3.0; 3],
|
||||
spatial_out: [4.0; 3],
|
||||
spatial_auto: true,
|
||||
roving: false,
|
||||
label: 1,
|
||||
};
|
||||
let r = spatial_record(&k, 2, true, 56 + 24 * 2).unwrap();
|
||||
assert_eq!(&r[..12], &[0, 0, 0, 5, 2, 1, 0, 0x47, 0, 0, 0, 1]);
|
||||
assert_eq!(&r[64..72], &2.0f64.to_be_bytes());
|
||||
assert_eq!(&r[96..104], &4.0f64.to_be_bytes());
|
||||
// A colour has no tangents and flags 0x0001.
|
||||
let c = spatial_record(&k, 2, false, 56 + 24 * 2).unwrap();
|
||||
assert_eq!((c[7], c[11]), (0x41, 0));
|
||||
assert!(c[72..].iter().all(|b| *b == 0));
|
||||
// A record shorter than its layout is an error.
|
||||
assert!(spatial_record(&k, 2, true, 64).is_err());
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,11 @@ use crate::Chunk;
|
||||
use effectcraft_keyframe::{Ease, Interp, ShapePath, Value};
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur, PropGroup};
|
||||
|
||||
use crate::export::keys::{set_key_list, timed_keys};
|
||||
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT, cut_name};
|
||||
use crate::export::report::{Collector, LossCategory};
|
||||
use crate::export::tree::{R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, tpl};
|
||||
use crate::export::synth::GROUP_END;
|
||||
use crate::export::tree::{Fixed, R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, split_group, tpl};
|
||||
|
||||
/// The most vertices one mask path may have (as the importer).
|
||||
pub(crate) const MAX_VERTICES: usize = 65_536;
|
||||
@@ -179,20 +181,7 @@ fn plan_mask(g: &PropGroup, size: (u32, u32), cp: &CompPlan, what: &str, col: &m
|
||||
}
|
||||
// The path and its keys.
|
||||
let mut path_keys: Vec<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,9 +365,25 @@ const MASK_LIMIT: f64 = 32_000.0;
|
||||
/// s13-m1), byte 3 `0x01` (`0x09` once the path is open), the box at 4..19 (written per shape),
|
||||
/// byte 20 1.
|
||||
pub(crate) fn shph() -> Vec<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);
|
||||
shph_layout().finish(|| "shph".to_string())
|
||||
}
|
||||
|
||||
/// The 24-byte `shph` layout of a closed path with a unit box.
|
||||
fn shph_layout() -> Fixed {
|
||||
let mut b = Fixed::zeroed(24);
|
||||
b.put(0, &[0xb3, 0xde, 0x02, 0x01]);
|
||||
b.put(20, &[1]);
|
||||
b
|
||||
}
|
||||
|
||||
/// The two prototype records of a keyed path (093): 64 bytes each, flags `0x0001`, the `f64` at
|
||||
/// 16 1 in the first (the rest is zero: its last bytes were uninitialised).
|
||||
fn path_key_records() -> Fixed {
|
||||
let mut b = Fixed::zeroed(128);
|
||||
for at in [6, 64 + 6] {
|
||||
b.put(at, &0x0001u16.to_be_bytes());
|
||||
}
|
||||
b.put(16, &1.0f64.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
@@ -413,15 +400,7 @@ impl MaskProtos {
|
||||
let path_rec = |keyed: bool| {
|
||||
let mut kids = vec![state(), tdsn(NO_NAME), Chunk::data(*b"tdb4", tdb4(&path, keyed, 25_600))];
|
||||
if keyed {
|
||||
// Path keys are 64-byte records: flags `0x0001`, the `f64` at 16 1 on the first
|
||||
// key (093; the rest of the record is zero: its last bytes were uninitialised).
|
||||
let mut first = vec![0u8; 64];
|
||||
let mut later = vec![0u8; 64];
|
||||
for r in [&mut first, &mut later] {
|
||||
let _ = put_u16(r, 6, 0x0001);
|
||||
}
|
||||
let _ = put_f64(&mut first, 16, 1.0);
|
||||
kids.push(key_list(64, 2, [first, later].concat()));
|
||||
kids.push(key_list(64, 2, path_key_records().finish(|| "the keyed path records".to_string())));
|
||||
} else {
|
||||
kids.push(Chunk::data(*b"cdat", vec![0u8; 4]));
|
||||
}
|
||||
@@ -444,7 +423,7 @@ impl MaskProtos {
|
||||
tdbs(&opacity, false, 25_600),
|
||||
tdmn("ADBE Mask Offset"),
|
||||
tdbs(&offset, false, 25_600),
|
||||
tdmn("ADBE Group End"),
|
||||
tdmn(GROUP_END),
|
||||
],
|
||||
);
|
||||
MaskProtos {
|
||||
@@ -453,7 +432,7 @@ impl MaskProtos {
|
||||
atom_name: tdmn("ADBE Mask Atom"),
|
||||
mkif: Chunk::data(*b"mkif", vec![0u8; 48]),
|
||||
atom,
|
||||
group_end: tdmn("ADBE Group End"),
|
||||
group_end: tdmn(GROUP_END),
|
||||
keyed_path: path_rec(true),
|
||||
keyed_feather: tdbs(&feather, true, 25_600),
|
||||
}
|
||||
@@ -485,27 +464,21 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R<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)?)?;
|
||||
let mut has_shape = false;
|
||||
for (name, entry) in entries {
|
||||
// The closing Group End (no node) is kept as it is.
|
||||
let [tdmn, mut node]: [Chunk<'static>; 2] = match entry.try_into() {
|
||||
Ok(pair) => pair,
|
||||
Err(end) => {
|
||||
out.extend(end);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let keep = match name.as_str() {
|
||||
"ADBE Mask Shape" => {
|
||||
build_shape(t, &mut node, m, size)?;
|
||||
has_shape = true;
|
||||
true
|
||||
}
|
||||
"ADBE Mask Feather" => {
|
||||
@@ -540,7 +513,10 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R<Chunk<'static
|
||||
out.push(node);
|
||||
}
|
||||
}
|
||||
out.push(end);
|
||||
// A prototype without the path would drop every mask's shape.
|
||||
if !has_shape {
|
||||
return Err(tpl("mask atom without its path"));
|
||||
}
|
||||
*kids_mut(&mut atom)? = out;
|
||||
Ok(atom)
|
||||
}
|
||||
@@ -554,22 +530,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.
|
||||
@@ -669,3 +629,37 @@ pub(crate) fn build_shap(proto: &Chunk<'static>, s: &ShapePlan) -> R<Chunk<'stat
|
||||
}
|
||||
Ok(shap)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::export::synth::is_tdmn;
|
||||
|
||||
/// A closed three-point mask.
|
||||
fn mask() -> MaskPlan {
|
||||
let shape = ShapePlan { bbox: [0.0, 0.0, 1.0, 1.0], open: false, points: vec![[0.0, 0.0]; 3] };
|
||||
MaskPlan {
|
||||
name: "M".into(),
|
||||
mode: 1,
|
||||
inverted: false,
|
||||
shape,
|
||||
path_keys: Vec::new(),
|
||||
feather: None,
|
||||
feather_keys: Vec::new(),
|
||||
opacity: None,
|
||||
expansion: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An atom without its path is an error.
|
||||
#[test]
|
||||
fn an_atom_without_its_path_is_an_error() {
|
||||
let mut t = MaskProtos::build();
|
||||
assert!(build_atom(&t, &mask(), (100, 100)).is_ok());
|
||||
// Without the Mask Shape pair the path of every mask would be dropped quietly.
|
||||
let kids = kids_mut(&mut t.atom).unwrap();
|
||||
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Mask Shape")).unwrap();
|
||||
kids.drain(at..at + 2);
|
||||
assert!(build_atom(&t, &mask(), (100, 100)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most example lines kept per loss category (the count is always exact).
|
||||
pub const MAX_DETAILS: usize = 20;
|
||||
/// Most example lines kept per loss category (the count is always exact); the same as the import
|
||||
/// report's.
|
||||
pub const MAX_DETAILS: usize = crate::import::MAX_DETAILS;
|
||||
|
||||
/// Why a piece of the project did not arrive in the `.aep`, or arrived changed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -130,15 +131,9 @@ impl LossCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// One category of things that were not exported.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Loss {
|
||||
pub category: LossCategory,
|
||||
/// How many occurrences (exact).
|
||||
pub count: usize,
|
||||
/// Up to [`MAX_DETAILS`] example lines.
|
||||
pub details: Vec<String>,
|
||||
}
|
||||
/// One category of things that were not exported: the import report's loss entry with the
|
||||
/// export categories.
|
||||
pub type Loss = crate::import::Loss<LossCategory>;
|
||||
|
||||
/// What was written to the file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -222,7 +217,7 @@ pub struct ExportReport {
|
||||
impl ExportReport {
|
||||
/// The sum of all loss counts.
|
||||
pub fn total_losses(&self) -> usize {
|
||||
self.losses.iter().map(|l| l.count).sum()
|
||||
Loss::total(&self.losses)
|
||||
}
|
||||
|
||||
/// True when nothing was lost.
|
||||
@@ -232,12 +227,12 @@ impl ExportReport {
|
||||
|
||||
/// The loss count of a category.
|
||||
pub fn count(&self, category: LossCategory) -> usize {
|
||||
self.losses.iter().find(|l| l.category == category).map_or(0, |l| l.count)
|
||||
Loss::count_of(&self.losses, category)
|
||||
}
|
||||
|
||||
/// `effects: 3, masks: 1` (empty when lossless).
|
||||
pub fn summary(&self) -> String {
|
||||
self.losses.iter().map(|l| format!("{}: {}", l.category.label(), l.count)).collect::<Vec<_>>().join(", ")
|
||||
Loss::summary(&self.losses, LossCategory::label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,11 +246,7 @@ pub(crate) struct Collector {
|
||||
impl Collector {
|
||||
/// Records one loss.
|
||||
pub fn lose(&mut self, category: LossCategory, detail: impl Into<String>) {
|
||||
let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() });
|
||||
loss.count = loss.count.saturating_add(1);
|
||||
if loss.details.len() < MAX_DETAILS {
|
||||
loss.details.push(detail.into());
|
||||
}
|
||||
Loss::record(&mut self.losses, category, move || detail.into());
|
||||
}
|
||||
|
||||
pub fn finish(self) -> ExportReport {
|
||||
@@ -266,3 +257,56 @@ impl Collector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A report with losses in two categories, as the collector finishes it.
|
||||
fn collected() -> ExportReport {
|
||||
let mut col = Collector::default();
|
||||
col.exported.layers = 2;
|
||||
col.lose(LossCategory::RoundedTimes, "time 1");
|
||||
col.lose(LossCategory::Effects, String::from("fx 1"));
|
||||
col.lose(LossCategory::Effects, "fx 2");
|
||||
let mut report = col.finish();
|
||||
report.limitations.truncate(1);
|
||||
report.limitations[0] = "limit".into();
|
||||
report
|
||||
}
|
||||
|
||||
/// The JSON shape agents read: field names, category spelling, order and the loss entries.
|
||||
#[test]
|
||||
fn the_json_shape_is_pinned() {
|
||||
let json = serde_json::to_string(&collected()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"exported":{"folders":0,"compositions":0,"solids":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"properties":0,"keyframes":0,"expressions":0,"masks":0,"precompLayers":0,"effects":0,"footage":0,"footageLayers":0,"textLayers":0,"markers":0,"cameras":0,"lights":0,"shapeLayers":0,"timeRemaps":0,"textAnimators":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"],"unlisted":0},{"category":"rounded_times","count":1,"details":["time 1"],"unlisted":0}],"limitations":["limit"]}"#
|
||||
);
|
||||
let back: ExportReport = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, collected());
|
||||
}
|
||||
|
||||
/// The queries read the losses.
|
||||
#[test]
|
||||
fn the_queries_read_the_losses() {
|
||||
let report = collected();
|
||||
assert_eq!((report.total_losses(), report.is_lossless()), (3, false));
|
||||
assert_eq!((report.count(LossCategory::Effects), report.count(LossCategory::Lights)), (2, 0));
|
||||
assert_eq!(report.summary(), "effects: 2, rounded times: 1");
|
||||
let empty = Collector::default().finish();
|
||||
assert_eq!((empty.total_losses(), empty.is_lossless(), empty.summary()), (0, true, String::new()));
|
||||
}
|
||||
|
||||
/// Details stop at the cap.
|
||||
#[test]
|
||||
fn details_stop_at_the_cap() {
|
||||
let mut col = Collector::default();
|
||||
for i in 0..MAX_DETAILS + 5 {
|
||||
col.lose(LossCategory::Masks, format!("mask {i}"));
|
||||
}
|
||||
let report = col.finish();
|
||||
assert_eq!(report.count(LossCategory::Masks), MAX_DETAILS + 5);
|
||||
assert_eq!(report.losses[0].details.len(), MAX_DETAILS);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -4,9 +4,10 @@
|
||||
//! evidence, or zero where After Effects was shown to accept zero (the read-backs listed in
|
||||
//! `docs/aep-export.md`, "Built in code").
|
||||
|
||||
use crate::export::tree::{Fixed, R, put_u32};
|
||||
use crate::{Body, Chunk};
|
||||
|
||||
/// `tdmn`: a match name padded to 40 bytes.
|
||||
/// `tdmn`: a match name padded to 40 bytes (at most 39 bytes of the name, so that a NUL remains).
|
||||
pub(crate) fn tdmn(name: &str) -> Chunk<'static> {
|
||||
let mut b = vec![0u8; 40];
|
||||
for (slot, c) in b.iter_mut().zip(name.bytes().take(39)) {
|
||||
@@ -15,12 +16,34 @@ pub(crate) fn tdmn(name: &str) -> Chunk<'static> {
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
/// The match name that closes a property group.
|
||||
pub(crate) const GROUP_END: &str = "ADBE Group End";
|
||||
|
||||
/// The match name in a `tdmn`: the bytes up to the first NUL as UTF-8, invalid sequences
|
||||
/// replaced, as the importer reads it (`container::typed`). After Effects writes ASCII here
|
||||
/// (aep-format.md 3.4; every `tdmn` of the corpus is ASCII); `None` when `c` is not a data chunk.
|
||||
pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option<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,13 @@ impl RecSpec {
|
||||
/// word and the constant doubles at 16..56 are written; After Effects reads the other bytes as
|
||||
/// zero (read-back e1-ztdb4d).
|
||||
pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec<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]);
|
||||
tdb4_layout(spec, keyed, time_scale).finish(|| "tdb4".to_string())
|
||||
}
|
||||
|
||||
/// The 124-byte `tdb4` property header layout for a record spec.
|
||||
fn tdb4_layout(spec: &RecSpec, keyed: bool, time_scale: u32) -> Fixed {
|
||||
let mut b = Fixed::zeroed(124);
|
||||
b.put(0, &[0xdb, 0x99, 0x00, spec.dims]);
|
||||
let spatial = match spec.template {
|
||||
KeyTemplate::Plain => 0,
|
||||
KeyTemplate::Spatial(t) => t,
|
||||
@@ -115,19 +138,19 @@ pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec<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());
|
||||
b.put(5, &[byte5]);
|
||||
b.put(7, &[spatial]);
|
||||
b.put(8, &spec.class.to_be_bytes());
|
||||
b.put(12, &time_scale.to_be_bytes());
|
||||
// Every record After Effects writes carries 0.0001 and four 1.0 here; without them a roving
|
||||
// key reads back at time zero instead of its retimed place (read-back s14-re-keyframes).
|
||||
set(&mut b, 16, &1e-4f64.to_be_bytes());
|
||||
b.put(16, &1e-4f64.to_be_bytes());
|
||||
for at in [24, 32, 40, 48] {
|
||||
set(&mut b, at, &1.0f64.to_be_bytes());
|
||||
b.put(at, &1.0f64.to_be_bytes());
|
||||
}
|
||||
set(&mut b, 68, &[u8::from(keyed)]);
|
||||
b.put(68, &[u8::from(keyed)]);
|
||||
// Set on a static spatial property, cleared once it is keyed (aep-format.md 3.4, byte 79).
|
||||
set(&mut b, 79, &[u8::from(spatial != 0 && !keyed)]);
|
||||
b.put(79, &[u8::from(spatial != 0 && !keyed)]);
|
||||
b
|
||||
}
|
||||
|
||||
@@ -135,16 +158,21 @@ pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec<u8> {
|
||||
/// 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 = key_list_header(size, count).finish(|| "lhd3".to_string());
|
||||
Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", records)])
|
||||
}
|
||||
|
||||
/// The 52-byte `lhd3` key list header for `count` records of `size` bytes.
|
||||
fn key_list_header(size: u32, count: u32) -> Fixed {
|
||||
let mut lhd3 = Fixed::zeroed(52);
|
||||
lhd3.put(0, &0x00d0_0bee_u32.to_be_bytes());
|
||||
lhd3.put(24, &1u32.to_be_bytes());
|
||||
lhd3.apply(|d| put_key_counts(d, count, size));
|
||||
lhd3
|
||||
}
|
||||
|
||||
/// Sets the count words of a key list header for `count` keys of `size` bytes.
|
||||
pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> crate::export::tree::R<()> {
|
||||
pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> R<()> {
|
||||
let capacity = count.div_ceil(4).saturating_mul(4).max(2);
|
||||
put_counts(lhd3, count, count.div_ceil(4).max(1), size, capacity, capacity)
|
||||
}
|
||||
@@ -153,8 +181,7 @@ pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> crate::e
|
||||
/// record size, word 5 `w5`, word 7 the capacity. A key list has `w5` = the capacity; a path
|
||||
/// list counts vertices: `blocks` = `w5` = the vertex count, the capacity four times it (090:
|
||||
/// 12 points, 4, 4 and 16; aep-format.md 3.5).
|
||||
pub(crate) fn put_counts(lhd3: &mut [u8], count: u32, blocks: u32, size: u32, w5: u32, capacity: u32) -> crate::export::tree::R<()> {
|
||||
use crate::export::tree::put_u32;
|
||||
pub(crate) fn put_counts(lhd3: &mut [u8], count: u32, blocks: u32, size: u32, w5: u32, capacity: u32) -> R<()> {
|
||||
put_u32(lhd3, 8, count)?;
|
||||
put_u32(lhd3, 12, blocks)?;
|
||||
put_u32(lhd3, 16, size)?;
|
||||
@@ -204,6 +231,38 @@ mod tests {
|
||||
assert_eq!(rec.child(b"cdat").and_then(|c| c.bytes()).map(<[u8]>::len), Some(40));
|
||||
}
|
||||
|
||||
/// Every record header fits.
|
||||
#[test]
|
||||
fn every_record_header_fits() {
|
||||
for dims in 0..=u8::MAX {
|
||||
for template in [KeyTemplate::Plain, KeyTemplate::Spatial(1), KeyTemplate::Spatial(3), KeyTemplate::Spatial(u8::MAX)] {
|
||||
for byte5 in [None, Some(0xff)] {
|
||||
let spec = RecSpec { dims, template, class: u32::MAX, limits: None, byte5 };
|
||||
assert!(tdb4_layout(&spec, false, u32::MAX).error().is_none() && tdb4_layout(&spec, true, 0).error().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (size, count) in [(0, 0), (u32::MAX, u32::MAX), (48, 5)] {
|
||||
let h = key_list_header(size, count);
|
||||
assert!(h.error().is_none());
|
||||
assert_eq!(h.finish(String::new).len(), 52);
|
||||
}
|
||||
}
|
||||
|
||||
/// A match name reads as the importer reads it.
|
||||
#[test]
|
||||
fn a_match_name_reads_as_the_importer_reads_it() {
|
||||
let m = tdmn("ADBE Group End");
|
||||
assert_eq!(tdmn_name(&m).as_deref(), Some(GROUP_END));
|
||||
assert!(is_tdmn(&m, GROUP_END) && !is_tdmn(&m, "ADBE Group") && !is_tdmn(&tdsn(GROUP_END), GROUP_END));
|
||||
// At most 39 bytes, so that a NUL remains.
|
||||
let long = "x".repeat(50);
|
||||
assert_eq!(tdmn_name(&tdmn(&long)).map(|n| n.len()), Some(39));
|
||||
// Not ASCII (After Effects writes ASCII): UTF-8, invalid bytes replaced, as on import.
|
||||
let odd = Chunk::data(*b"tdmn", vec![b'A', 0xc3, 0xa9, 0xff, 0, b'B']);
|
||||
assert_eq!(tdmn_name(&odd).as_deref(), Some("A\u{e9}\u{fffd}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_list_header_counts_and_rounds() {
|
||||
let l = key_list(48, 5, vec![0; 240]);
|
||||
|
||||
@@ -81,6 +81,24 @@ fn transform_records(keyed: bool) -> Vec<Chunk<'static>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The template every export clones from: built by the first export of the process and kept, as
|
||||
/// [`Template::load`] always builds the same prototypes and nothing changes them afterwards (the
|
||||
/// exporter only reads them through `&Template` and clones what it edits).
|
||||
///
|
||||
/// Concurrent exports are safe: the `static` requires `Template: Send + Sync`, which the compiler
|
||||
/// checks. Exports that race on the first call each build a template; one is kept and the others
|
||||
/// are dropped, and all of them are equal. A failed build is not kept, so the next export tries
|
||||
/// again and reports the same error.
|
||||
pub(crate) fn shared() -> R<&'static Template> {
|
||||
static SHARED: std::sync::OnceLock<Template> = std::sync::OnceLock::new();
|
||||
if let Some(t) = SHARED.get() {
|
||||
return Ok(t);
|
||||
}
|
||||
// A failed build returns here, before `get_or_init`, so it is never kept.
|
||||
let t = Template::load()?;
|
||||
Ok(SHARED.get_or_init(|| t))
|
||||
}
|
||||
|
||||
impl Template {
|
||||
pub fn load() -> R<Template> {
|
||||
let file = AepFile { chunks: crate::export::base::project_chunks(), trailer: std::borrow::Cow::Borrowed(&[]) };
|
||||
|
||||
@@ -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).
|
||||
@@ -220,14 +221,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`).
|
||||
@@ -297,7 +292,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.
|
||||
@@ -306,7 +301,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(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! `rate x frame duration >= 24 576` (25 fps: 1024 and 25 600; 12 fps: 2048 and 24 576; 1 fps:
|
||||
//! 32 768 and 32 768; 240 fps: 128 and 30 720).
|
||||
|
||||
use crate::container::TimeBase as ContainerTimeBase;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
||||
/// Frame duration and time scale in units; one frame lasts `frame_duration` units and a second
|
||||
@@ -27,10 +28,7 @@ pub(crate) fn fixed_rate(rate: FrameRate) -> u32 {
|
||||
/// The typed value of an NTSC rate (23.976, 29.97, 59.94, 119.88), which After Effects stores as
|
||||
/// a float32 of exactly these decimals.
|
||||
pub(crate) fn ntsc_typed(rate: FrameRate) -> Option<f32> {
|
||||
[(FrameRate::FPS_23_976, 23.976f32), (FrameRate::FPS_29_97, 29.97), (FrameRate::FPS_59_94, 59.94), (FrameRate::FPS_119_88, 119.88)]
|
||||
.into_iter()
|
||||
.find(|(r, _)| *r == rate)
|
||||
.map(|(_, v)| v)
|
||||
ContainerTimeBase::NTSC.into_iter().find(|((num, den), _)| FrameRate::new(i64::from(*num), i64::from(*den)) == rate).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// The time base for `rate`, or `None` for a rate After Effects cannot hold (non-positive, or not
|
||||
|
||||
@@ -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,161 @@ 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(())
|
||||
}
|
||||
|
||||
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<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
error: Option<AepExportError>,
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
|
||||
/// The writer errors instead of dropping.
|
||||
#[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]);
|
||||
}
|
||||
|
||||
/// A cut string keeps a nul and whole characters.
|
||||
#[test]
|
||||
fn a_cut_string_keeps_a_nul_and_whole_characters() {
|
||||
let mut d = [0xffu8; 6];
|
||||
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() {
|
||||
// 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(), before);
|
||||
}
|
||||
|
||||
/// A broken fixed layout writes what fits and is counted.
|
||||
#[test]
|
||||
fn a_broken_fixed_layout_writes_what_fits_and_is_counted() {
|
||||
let before = fixed_layout_errors();
|
||||
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);
|
||||
}
|
||||
|
||||
/// A group splits into header and entries.
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
+149
-6
@@ -1,6 +1,6 @@
|
||||
//! After Effects project (`.aep`) import: the typed view of [`effectcraft_aep`] mapped into an
|
||||
//! EffectCraft [`Project`]. The XML form (`.aepx`) is accepted as well: it is read into the same
|
||||
//! chunk tree ([`crate::parse_any`] picks the reader by content).
|
||||
//! EffectCraft [`Project`]. The XML form (`.aepx`) is accepted as well: both are read into the same
|
||||
//! flat chunk index (an `.aep` in place, an `.aepx` decoded into one buffer first).
|
||||
//!
|
||||
//! What arrives: the project item tree (folders), project settings (depth, working space, time
|
||||
//! display), compositions (size, exact frame rate, duration, work area, background, pixel
|
||||
@@ -44,6 +44,9 @@ mod time;
|
||||
|
||||
use effectcraft_project::{ItemId, Project, ProjectSettings};
|
||||
|
||||
use crate::container::index::{Root, TreeIndex};
|
||||
use crate::container::{AepxDrafts, Limits, index_aepx};
|
||||
|
||||
pub use effects::supported_effects;
|
||||
pub use report::{AeVersionInfo, ImportReport, Imported, LIMITATIONS, Loss, LossCategory, MAX_DETAILS, TESTED_MAJORS, UNKNOWN};
|
||||
|
||||
@@ -117,17 +120,30 @@ pub struct ImportResult {
|
||||
/// [`AepImportError::Refused`] when something would be lost and `allow_losses` is not set. On any
|
||||
/// error `project` is unchanged.
|
||||
pub fn import(project: &mut Project, bytes: &[u8], options: &ImportOptions<'_>) -> Result<ImportResult, AepImportError> {
|
||||
let file = crate::parse_any(bytes)?;
|
||||
let view = crate::Project::new(&file).ok_or(AepImportError::NoProject)?;
|
||||
// The importer only reads: an `.aep` is indexed in place (one flat array that borrows the
|
||||
// bytes); an `.aepx` is decoded into one buffer, which the index borrows.
|
||||
if crate::is_aepx(bytes) {
|
||||
let AepxDrafts { buffer, nodes, trailer } = index_aepx(bytes, Limits::default())?;
|
||||
let index = TreeIndex::from_drafts(&buffer, nodes, trailer);
|
||||
import_root(project, Root::from(&index), options)
|
||||
} else {
|
||||
let index = TreeIndex::parse(bytes)?;
|
||||
import_root(project, Root::from(&index), options)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`import`] of a file read into either tree.
|
||||
fn import_root(project: &mut Project, file: Root<'_, '_>, options: &ImportOptions<'_>) -> Result<ImportResult, AepImportError> {
|
||||
let view = crate::Project::of(file).ok_or(AepImportError::NoProject)?;
|
||||
// Work on a copy so a refusal leaves the project untouched (compositions are shared, cheap).
|
||||
let mut work = project.clone();
|
||||
let (folder, comps, items, mut report) = build::run(&mut work, &view, options);
|
||||
report.ae_version = report::AeVersionInfo::of(&file);
|
||||
report.ae_version = report::AeVersionInfo::of_root(file);
|
||||
if let Some(line) = report.ae_version.limitation() {
|
||||
report.limitations.push(line);
|
||||
}
|
||||
let mut col = report::Collector::default();
|
||||
let settings = settings::map(&file, &work.settings, &mut col);
|
||||
let settings = settings::map(file, &work.settings, &mut col);
|
||||
for loss in col.finish().losses.into_iter().filter(|_| options.settings) {
|
||||
let at = report.losses.iter().position(|l| l.category > loss.category).unwrap_or(report.losses.len());
|
||||
report.losses.insert(at, loss);
|
||||
@@ -159,3 +175,130 @@ pub fn analyze_with(bytes: &[u8], options: &ImportOptions<'_>) -> Result<ImportR
|
||||
let options = ImportOptions { allow_losses: true, ..*options };
|
||||
Ok(import(&mut scratch, bytes, &options)?.report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::container::{AepFile, Body, Chunk, parse, write};
|
||||
|
||||
/// Asserts the two destination projects are structurally equal and serialize identically (layers,
|
||||
/// properties and values included, which `ImportResult` does not carry). Nothing is normalized:
|
||||
/// the import is deterministic, ids and timestamps included.
|
||||
fn same_project(a: &Project, b: &Project, name: &str) {
|
||||
assert!(a == b, "{name}: the imported projects differ through the index");
|
||||
let (ja, jb) = (serde_json::to_string(a).ok(), serde_json::to_string(b).ok());
|
||||
assert!(ja.is_some() && ja == jb, "{name}: the serialized projects differ through the index");
|
||||
}
|
||||
|
||||
/// Imports `bytes` through the owned chunk tree and through the index: the same project, report
|
||||
/// and settings, or the same error.
|
||||
fn both(bytes: &[u8], name: &str) {
|
||||
let exists = |_: &str| true;
|
||||
let options = ImportOptions { allow_losses: true, ..ImportOptions::new(&exists) };
|
||||
match (parse(bytes), TreeIndex::parse(bytes)) {
|
||||
(Ok(file), Ok(index)) => {
|
||||
let (mut pa, mut pb) = (Project::default(), Project::default());
|
||||
let a = import_root(&mut pa, Root::from(&file), &options);
|
||||
let b = import_root(&mut pb, Root::from(&index), &options);
|
||||
assert!(a == b, "{name}: the import differs through the index");
|
||||
same_project(&pa, &pb, name);
|
||||
}
|
||||
(Err(a), Err(b)) => assert_eq!(a, b, "{name}"),
|
||||
(a, b) => panic!("{name}: {:?} vs {:?}", a.err(), b.err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40-byte NUL-padded match name.
|
||||
fn tdmn(name: &str) -> Chunk<'static> {
|
||||
let mut b = name.as_bytes().to_vec();
|
||||
b.resize(40, 0);
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
/// A synthetic project imports the same through the index.
|
||||
#[test]
|
||||
fn a_synthetic_project_imports_the_same_through_the_index() {
|
||||
// A composition with a layer whose effect stores arbitrary data and a named value group.
|
||||
let utf8 = |id: [u8; 4], t: &str| Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", t.as_bytes().to_vec())]), pad: None };
|
||||
let record = Chunk::list(*b"tdbs", vec![Chunk::data(*b"tdsb", vec![0, 0, 0, 1]), Chunk::data(*b"cdat", vec![0, 0, 0, 1])]);
|
||||
let values = Chunk::list(
|
||||
*b"tdgp",
|
||||
vec![
|
||||
utf8(*b"tdsn", "Mine"),
|
||||
tdmn("ADBE Easy Levels2-0001"),
|
||||
record,
|
||||
Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![0u8; 108])]),
|
||||
tdmn("ADBE Group End"),
|
||||
],
|
||||
);
|
||||
let part = Chunk::list(*b"parT", vec![Chunk::data(*b"parn", vec![0, 0, 0, 2]), tdmn("ADBE Easy Levels2-0001"), Chunk::data(*b"pard", vec![0u8; 148])]);
|
||||
let sspc = Chunk::list(*b"sspc", vec![utf8(*b"fnam", "Levels"), part, values]);
|
||||
let parade = Chunk::list(*b"tdgp", vec![tdmn("ADBE Easy Levels2"), sspc, tdmn("ADBE Group End")]);
|
||||
let root = Chunk::list(*b"tdgp", vec![tdmn("ADBE Effect Parade"), parade, tdmn("ADBE Group End")]);
|
||||
let layer = Chunk::list(*b"Layr", vec![Chunk::data(*b"ldta", vec![0u8; 160]), Chunk::data(*b"Utf8", b"Layer".to_vec()), root]);
|
||||
let mut idta = vec![0u8; 64];
|
||||
idta[..2].copy_from_slice(&4u16.to_be_bytes());
|
||||
let mut cdta = vec![0u8; 200];
|
||||
cdta[4..12].copy_from_slice(&[0, 0, 0, 1, 0, 0, 0, 30]);
|
||||
let item = Chunk::list(*b"Item", vec![Chunk::data(*b"idta", idta), Chunk::data(*b"Utf8", b"Comp".to_vec()), Chunk::data(*b"cdta", cdta), layer]);
|
||||
let file = AepFile { chunks: vec![Chunk::data(*b"head", vec![0u8; 20]), Chunk::list(*b"Fold", vec![item])], trailer: std::borrow::Cow::Borrowed(&[]) };
|
||||
both(&write(&file).expect("write"), "synthetic");
|
||||
}
|
||||
|
||||
/// Every `.aep` of the fixture directory (skipped without `EFFECTCRAFT_AEP_FIXTURES`).
|
||||
#[test]
|
||||
fn every_fixture_imports_the_same_through_the_index() {
|
||||
let Some(dir) = std::env::var_os("EFFECTCRAFT_AEP_FIXTURES").filter(|d| !d.is_empty()) else { return };
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut paths: Vec<std::path::PathBuf> =
|
||||
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aep")).collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
both(&bytes, &path.display().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports an `.aepx` through the owned tree ([`crate::read_aepx`]) and through the index
|
||||
/// ([`import`]): the same project, report and settings, or the same error.
|
||||
fn both_aepx(bytes: &[u8], name: &str) -> Result<ImportResult, AepImportError> {
|
||||
let exists = |_: &str| true;
|
||||
let options = ImportOptions { allow_losses: true, ..ImportOptions::new(&exists) };
|
||||
let (mut pa, mut pb) = (Project::default(), Project::default());
|
||||
let a = crate::read_aepx(bytes).map_err(AepImportError::from).and_then(|file| import_root(&mut pa, Root::from(&file), &options));
|
||||
let b = import(&mut pb, bytes, &options);
|
||||
assert!(a == b, "{name}: the .aepx import differs through the index");
|
||||
same_project(&pa, &pb, name);
|
||||
b
|
||||
}
|
||||
|
||||
/// A synthetic `.aepx` imports the same through the index as through the owned tree.
|
||||
#[test]
|
||||
fn a_synthetic_aepx_imports_the_same_through_the_index() {
|
||||
// The comp of the owned-tree test above (`idta` type 4, `cdta` 1/30 s frame duration).
|
||||
let idta = format!("0004{}", "00".repeat(62));
|
||||
let cdta = format!("00000000000000010000001e{}", "00".repeat(188));
|
||||
let x = format!(
|
||||
"<?xml version=\"1.0\"?><AfterEffectsProject><head bdata=\"{}\"/><Fold><Item><idta bdata=\"{idta}\"/><string>Comp</string><cdta bdata=\"{cdta}\"/></Item></Fold><ProjectXMPMetadata>x</ProjectXMPMetadata></AfterEffectsProject>",
|
||||
"00".repeat(20)
|
||||
);
|
||||
let imported = both_aepx(x.as_bytes(), "synthetic").expect("the synthetic project imports");
|
||||
assert_eq!(imported.comps.len(), 1);
|
||||
assert_eq!(both_aepx(b"<AfterEffectsProject/>", "empty").err(), Some(AepImportError::NoProject));
|
||||
assert!(matches!(both_aepx(b"<AfterEffectsProject><a>", "broken"), Err(AepImportError::Container(crate::AepError::Xml { .. }))));
|
||||
}
|
||||
|
||||
/// Every `.aepx` of the fixture directory (skipped without `EFFECTCRAFT_AEP_FIXTURES`).
|
||||
#[test]
|
||||
fn every_fixture_aepx_imports_the_same_through_the_index() {
|
||||
let Some(dir) = std::env::var_os("EFFECTCRAFT_AEP_FIXTURES").filter(|d| !d.is_empty()) else { return };
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut paths: Vec<std::path::PathBuf> =
|
||||
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aepx")).collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
let _ = both_aepx(&bytes, &path.display().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
//! properties, where the engine's Enable Time Remapping puts it.
|
||||
|
||||
use crate::{CueType, Layer as AepLayer, Marker as AepMarker, PropNode, Property as AepProp};
|
||||
use craft_aep::Node as AepNode;
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
@@ -23,12 +24,12 @@ use crate::import::report::{Collector, LossCategory};
|
||||
use crate::import::time::TimeMap;
|
||||
|
||||
/// The time scale of the marker list below `holder` (the layer, or the composition's `SecL`).
|
||||
fn marker_scale(holder: craft_aep::Node<'_, '_>) -> Option<u32> {
|
||||
holder.find_list(b"mrst")?.child_list(b"tdbs").and_then(AepProp::from_chunk)?.time_scale().filter(|s| *s != 0)
|
||||
fn marker_scale(holder: AepNode<'_, '_>) -> Option<u32> {
|
||||
holder.marker_list()?.child_list(b"tdbs").and_then(AepProp::from_chunk)?.time_scale().filter(|s| *s != 0)
|
||||
}
|
||||
|
||||
/// The markers of a layer (`holder` = the layer chunk) or a composition (`holder` = its `SecL`).
|
||||
pub(crate) fn import_markers(list: Vec<AepMarker>, holder: craft_aep::Node<'_, '_>, what: &str, tm: &TimeMap, col: &mut Collector) -> Vec<Marker> {
|
||||
pub(crate) fn import_markers(list: Vec<AepMarker>, holder: AepNode<'_, '_>, what: &str, tm: &TimeMap, col: &mut Collector) -> Vec<Marker> {
|
||||
let scale = marker_scale(holder).unwrap_or(tm.timescale);
|
||||
let mut out: Vec<Marker> = Vec::with_capacity(list.len());
|
||||
for m in list {
|
||||
|
||||
+102
-11
@@ -8,6 +8,7 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::container::AepFile;
|
||||
use crate::container::index::Root;
|
||||
|
||||
/// Most example lines kept per loss category (the count is always exact).
|
||||
pub const MAX_DETAILS: usize = 20;
|
||||
@@ -93,10 +94,11 @@ impl LossCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// One category of things that were not imported.
|
||||
/// One category of things that were not imported. The export report uses the same type with its
|
||||
/// own categories (`export::Loss`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Loss {
|
||||
pub category: LossCategory,
|
||||
pub struct Loss<C = LossCategory> {
|
||||
pub category: C,
|
||||
/// How many occurrences (exact).
|
||||
pub count: usize,
|
||||
/// Example lines: up to [`MAX_DETAILS`], and for effects with no counterpart one more line per
|
||||
@@ -107,6 +109,34 @@ pub struct Loss {
|
||||
pub unlisted: usize,
|
||||
}
|
||||
|
||||
/// The loss bookkeeping the import and the export report share.
|
||||
impl<C: Copy + Ord> Loss<C> {
|
||||
/// Counts one loss of `category`; `detail` is only called while the category holds fewer than
|
||||
/// [`MAX_DETAILS`] lines.
|
||||
pub(crate) fn record(losses: &mut BTreeMap<C, Self>, category: C, detail: impl FnOnce() -> String) {
|
||||
let loss = losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 });
|
||||
loss.count = loss.count.saturating_add(1);
|
||||
if loss.details.len() < MAX_DETAILS {
|
||||
loss.details.push(detail());
|
||||
}
|
||||
}
|
||||
|
||||
/// The sum of all loss counts.
|
||||
pub(crate) fn total(losses: &[Self]) -> usize {
|
||||
losses.iter().map(|l| l.count).sum()
|
||||
}
|
||||
|
||||
/// The loss count of a category.
|
||||
pub(crate) fn count_of(losses: &[Self], category: C) -> usize {
|
||||
losses.iter().find(|l| l.category == category).map_or(0, |l| l.count)
|
||||
}
|
||||
|
||||
/// `effects: 3, masks: 1` (empty without losses).
|
||||
pub(crate) fn summary(losses: &[Self], label: impl Fn(C) -> &'static str) -> String {
|
||||
losses.iter().map(|l| format!("{}: {}", label(l.category), l.count)).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// What arrived in the project.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -227,6 +257,11 @@ pub const UNKNOWN: &str = "unknown";
|
||||
impl AeVersionInfo {
|
||||
/// Reads the versions of a parsed file.
|
||||
pub fn of(file: &AepFile<'_>) -> Self {
|
||||
Self::of_root(Root::from(file))
|
||||
}
|
||||
|
||||
/// Reads the versions from the top-level chunks of either tree.
|
||||
pub(crate) fn of_root(file: Root<'_, '_>) -> Self {
|
||||
let file_version = file.file_version();
|
||||
Self {
|
||||
file_version: file_version.map_or_else(|| UNKNOWN.to_string(), |v| v.text()),
|
||||
@@ -252,7 +287,7 @@ impl AeVersionInfo {
|
||||
impl ImportReport {
|
||||
/// The sum of all loss counts.
|
||||
pub fn total_losses(&self) -> usize {
|
||||
self.losses.iter().map(|l| l.count).sum()
|
||||
Loss::total(&self.losses)
|
||||
}
|
||||
|
||||
/// True when nothing was lost.
|
||||
@@ -262,12 +297,12 @@ impl ImportReport {
|
||||
|
||||
/// The loss count of a category.
|
||||
pub fn count(&self, category: LossCategory) -> usize {
|
||||
self.losses.iter().find(|l| l.category == category).map_or(0, |l| l.count)
|
||||
Loss::count_of(&self.losses, category)
|
||||
}
|
||||
|
||||
/// `effects: 3, masks: 1` (empty when lossless).
|
||||
pub fn summary(&self) -> String {
|
||||
self.losses.iter().map(|l| format!("{}: {}", l.category.label(), l.count)).collect::<Vec<_>>().join(", ")
|
||||
Loss::summary(&self.losses, LossCategory::label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,11 +328,13 @@ const MAX_UNMAPPED_EFFECTS: usize = 100;
|
||||
impl Collector {
|
||||
/// Records one loss.
|
||||
pub fn lose(&mut self, category: LossCategory, detail: impl Into<String>) {
|
||||
let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 });
|
||||
loss.count = loss.count.saturating_add(1);
|
||||
if loss.details.len() < MAX_DETAILS {
|
||||
loss.details.push(detail.into());
|
||||
}
|
||||
self.lose_with(category, move || detail.into());
|
||||
}
|
||||
|
||||
/// Records one loss whose detail line is built only while the category holds fewer than
|
||||
/// [`MAX_DETAILS`] lines (for a detail that needs `format!`).
|
||||
pub fn lose_with(&mut self, category: LossCategory, detail: impl FnOnce() -> String) {
|
||||
Loss::record(&mut self.losses, category, detail);
|
||||
}
|
||||
|
||||
/// Records one instance of an effect with no counterpart. The loss is counted now; its line is
|
||||
@@ -357,6 +394,60 @@ pub const LIMITATIONS: &[&str] = &[
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A report with losses in two categories and a missing file, as the collector finishes it.
|
||||
fn collected() -> ImportReport {
|
||||
let mut col = Collector::default();
|
||||
col.imported.layers = 2;
|
||||
col.missing_footage.push("a.png".into());
|
||||
col.lose(LossCategory::Masks, "mask 1");
|
||||
col.lose(LossCategory::Effects, String::from("fx 1"));
|
||||
col.lose_with(LossCategory::Effects, || "fx 2".to_string());
|
||||
let mut report = col.finish();
|
||||
report.limitations.truncate(1);
|
||||
report
|
||||
}
|
||||
|
||||
/// The JSON shape agents read: field names, category spelling, order and the loss entries.
|
||||
#[test]
|
||||
fn the_json_shape_is_pinned() {
|
||||
let json = serde_json::to_string(&collected()).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"imported":{"folders":0,"compositions":0,"solids":0,"footage":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"footageLayers":0,"precompLayers":0,"textLayers":0,"keyframes":0,"expressions":0,"masks":0,"trackMattes":0,"markers":0,"timeRemaps":0,"shapeLayers":0,"shapeItems":0,"cameras":0,"lights":0,"textAnimators":0,"effects":0,"placeholderEffects":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"],"unlisted":0},{"category":"masks","count":1,"details":["mask 1"],"unlisted":0}],"missingFootage":["a.png"],"missingFonts":[],"limitations":["Frame blending, 3D material options, audio levels, guides are not decoded and not imported."],"aeVersion":{"fileVersion":"unknown","savedBy":"unknown","tested":false}}"#
|
||||
);
|
||||
let back: ImportReport = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, collected());
|
||||
}
|
||||
|
||||
/// The queries read the losses.
|
||||
#[test]
|
||||
fn the_queries_read_the_losses() {
|
||||
let report = collected();
|
||||
assert_eq!((report.total_losses(), report.is_lossless()), (3, false));
|
||||
assert_eq!((report.count(LossCategory::Effects), report.count(LossCategory::Lights)), (2, 0));
|
||||
assert_eq!(report.summary(), "effects: 2, masks: 1");
|
||||
let empty = Collector::default().finish();
|
||||
assert_eq!((empty.total_losses(), empty.is_lossless(), empty.summary()), (0, true, String::new()));
|
||||
}
|
||||
|
||||
/// The count stays exact past the detail cap, and a detail past the cap is never built.
|
||||
#[test]
|
||||
fn details_stop_at_the_cap_and_later_ones_are_not_built() {
|
||||
let mut col = Collector::default();
|
||||
let mut built = 0;
|
||||
for i in 0..MAX_DETAILS + 5 {
|
||||
col.lose_with(LossCategory::Masks, || {
|
||||
built += 1;
|
||||
format!("mask {i}")
|
||||
});
|
||||
}
|
||||
let report = col.finish();
|
||||
assert_eq!(report.count(LossCategory::Masks), MAX_DETAILS + 5);
|
||||
assert_eq!(report.losses[0].details.len(), MAX_DETAILS);
|
||||
assert_eq!(report.losses[0].details.last().map(String::as_str), Some("mask 19"));
|
||||
assert_eq!(built, MAX_DETAILS);
|
||||
}
|
||||
|
||||
/// #3365: with other losses filling the 20 example lines, an effect with no counterpart still
|
||||
/// gets its own line, with how many instances were lost. The category's count is unchanged.
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Project settings (colour depth, working space, working gamma, linear-light switches, time
|
||||
//! display, frame count start) from [`crate::ProjectSettings`] (cases 620-634).
|
||||
|
||||
use crate::{AepFile, TimeDisplay, WorkingSpace};
|
||||
use crate::container::index::{Node, Root};
|
||||
use crate::{TimeDisplay, WorkingSpace};
|
||||
use effectcraft_color::ColorSpace;
|
||||
use effectcraft_project::{BitDepth, ProjectSettings, TimeDisplayStyle};
|
||||
|
||||
@@ -22,9 +23,9 @@ pub(crate) fn color_space(name: &str) -> Option<ColorSpace> {
|
||||
/// holds in an unknown form) keeps the current value. The two linear-light switches are always
|
||||
/// held: After Effects writes their chunk only when on (626, 627), so a file without it has them off. A working space EffectCraft does not have is
|
||||
/// reported.
|
||||
pub(crate) fn map(file: &AepFile<'_>, current: &ProjectSettings, col: &mut Collector) -> ProjectSettings {
|
||||
let s = crate::ProjectSettings::read(file);
|
||||
let nhed = file.chunk(b"nhed").and_then(|c| c.bytes());
|
||||
pub(crate) fn map(file: Root<'_, '_>, current: &ProjectSettings, col: &mut Collector) -> ProjectSettings {
|
||||
let s = crate::ProjectSettings::of(file);
|
||||
let nhed = file.chunk(b"nhed").and_then(Node::bytes);
|
||||
let mut out = current.clone();
|
||||
match s.bits_per_channel {
|
||||
Some(8) => out.bit_depth = BitDepth::Bpc8,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! stay frames at every rate (29.97 fps is `23976 / 800` in the file and `30000 / 1001` in the
|
||||
//! project). Any other time is rounded to the nearest tick.
|
||||
|
||||
use crate::container::TimeBase as ContainerTimeBase;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
||||
/// The composition's frame structure: the stored time base and the rate the project gets.
|
||||
@@ -27,7 +28,8 @@ pub(crate) fn frame_rate(timescale: u32, frame_duration: u32) -> Option<FrameRat
|
||||
}
|
||||
let exact = FrameRate::new(i64::from(timescale), i64::from(frame_duration));
|
||||
let fps = exact.as_f64();
|
||||
for ntsc in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] {
|
||||
for ((num, den), _) in ContainerTimeBase::NTSC {
|
||||
let ntsc = FrameRate::new(i64::from(num), i64::from(den));
|
||||
if (ntsc.as_f64() - fps).abs() < 0.0005 {
|
||||
return Some(ntsc);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Integration checks for the shared AEP tree exposed through EffectCraft.
|
||||
mod tree_index;
|
||||
@@ -0,0 +1,217 @@
|
||||
//! The flat tree index the importer reads ([`TreeIndex`]) against the owned chunk tree, on every
|
||||
//! file of the fixture directory: the same tree, and the same answers from the typed view.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use effectcraft_aep::container::index::TreeIndex;
|
||||
use effectcraft_aep::{
|
||||
AepFile, Chunk, CryptomatteData, CurvesData, Effect, HueSaturationData, LevelsData, Limits, MeshData, Project, PropGroup, PropNode, is_aepx, parse,
|
||||
parse_with_limits, read_aepx, write,
|
||||
};
|
||||
|
||||
/// Every `.aep` and `.aepx` in the fixture directory (the real projects included), sorted.
|
||||
fn every_file() -> Vec<std::path::PathBuf> {
|
||||
let Some(dir) = crate::common::dir() else {
|
||||
crate::common::skipped("*.aep");
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
crate::common::skipped("*.aep");
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<std::path::PathBuf> =
|
||||
entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aep" || x == "aepx")).collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// What the typed view reads from a project, as text: items, compositions, layers with their
|
||||
/// headers, transforms, masks, effects with every parameter value, text, markers, the whole
|
||||
/// property tree with static values and key records, settings and versions.
|
||||
fn dump(view: &Project<'_, '_>) -> String {
|
||||
let mut s = String::new();
|
||||
let defs = view.effect_definition_index();
|
||||
for item in view.all_items() {
|
||||
let _ = writeln!(s, "item {:?} {:?} {:?} {:?} {:?}", item.kind(), item.name(), item.display_name(), item.id(), item.label());
|
||||
let _ = writeln!(s, " footage {:?} {:?} {:?}", item.footage(), item.sequence_info(), item.conform_frame_rate());
|
||||
let _ = writeln!(s, " folder {} {}", item.has_folder_contents(), item.folder_contents_unreadable());
|
||||
let Some(comp) = item.comp() else { continue };
|
||||
let _ = writeln!(s, " comp {:?} {:?} {:?}", comp.time_base(), comp.header(), comp.markers());
|
||||
for layer in comp.layers() {
|
||||
let _ = writeln!(s, " layer {:?} {:?} {:?} {:?}", layer.name(), layer.id(), layer.header(), layer.track_matte());
|
||||
let _ = writeln!(s, " transform {:?}", layer.transform(1920, 1080));
|
||||
let _ = writeln!(s, " time remap {:?} orientation {:?} {:?}", layer.time_remap(), layer.orientation(), layer.orientation_keys());
|
||||
let _ = writeln!(s, " masks {:?}", layer.mask_atoms().iter().map(|m| (&m.mask, &m.path_shapes)).collect::<Vec<_>>());
|
||||
let _ = writeln!(s, " shapes {:?}", layer.shape_groups());
|
||||
let _ =
|
||||
writeln!(s, " text {:?} {} {:?}", layer.text_document(), layer.text_document_count(), layer.source_text_property().map(|p| p.is_animated()));
|
||||
let _ = writeln!(s, " markers {:?} source {:?}", layer.markers(), view.layer_source(&layer).map(|i| i.id()));
|
||||
for fx in layer.effects() {
|
||||
let _ =
|
||||
writeln!(s, " fx {} {:?} {:?} {:?} {:?}", fx.match_name(), fx.display_name(), fx.enabled(), defs.source(&fx), fx.value_record_names());
|
||||
let _ = writeln!(s, " names {:?} {:?} {:?} {:?}", fx.name(), fx.user_name(), fx.declared_param_count(), fx.has_own_definitions());
|
||||
for p in defs.params(&fx) {
|
||||
let _ = writeln!(s, " {} {:?} {:?} {:?} {:?}", p.match_name, p.kind, p.name, p.items, p.value());
|
||||
arbitrary(&fx, &p.match_name, &mut s);
|
||||
}
|
||||
for name in fx.value_record_names() {
|
||||
arbitrary(&fx, &name, &mut s);
|
||||
}
|
||||
}
|
||||
if let Some(root) = layer.properties() {
|
||||
tree(&root, 0, &mut s);
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The arbitrary data stored for a parameter and what each decoder reads from it.
|
||||
fn arbitrary(fx: &Effect<'_, '_>, name: &str, s: &mut String) {
|
||||
let Some(data) = fx.arbitrary_data(name) else { return };
|
||||
let _ = writeln!(s, " arbitrary {name} {data:?}");
|
||||
let _ = writeln!(s, " {:?} {:?}", HueSaturationData::parse(data), LevelsData::parse(data));
|
||||
let _ = writeln!(s, " {:?} {:?} {:?}", CurvesData::parse(data), MeshData::parse(data), CryptomatteData::parse(data));
|
||||
}
|
||||
|
||||
/// Appends a group and everything below it to the dump, depth-limited.
|
||||
fn tree(group: &PropGroup<'_, '_>, depth: usize, s: &mut String) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(s, "{:depth$}group {:?} {:?}", "", group.user_name(), group.flags());
|
||||
for e in group.entries() {
|
||||
match e.node {
|
||||
PropNode::Group(g) => tree(&g, depth + 1, s),
|
||||
PropNode::Property(p) => {
|
||||
let keys: Option<Vec<Vec<u8>>> = p.keys().map(|k| k.records().map(<[u8]>::to_vec).collect());
|
||||
let _ = writeln!(s, "{:depth$} {} {:?} {:?} {:?} {:?}", "", e.match_name, p.user_name(), p.header(), p.static_value(), keys);
|
||||
}
|
||||
PropNode::Other(c) => {
|
||||
let _ = writeln!(s, "{:depth$} {} other {:?} {}", "", e.match_name, c.list_type(), c.descendants().count());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The index is the parsed tree and the view reads the same on every file.
|
||||
#[test]
|
||||
fn the_index_is_the_parsed_tree_and_the_view_reads_the_same_on_every_file() {
|
||||
for path in every_file() {
|
||||
let name = path.display().to_string();
|
||||
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
// `.aepx` is read into the chunk tree and indexed from it; `.aep` is indexed in place.
|
||||
// A file that does not read (a damaged sample) must fail the index the same way.
|
||||
let file: AepFile<'_> = match if is_aepx(&bytes) { read_aepx(&bytes) } else { parse(&bytes) } {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
if !is_aepx(&bytes) {
|
||||
assert_eq!(TreeIndex::parse(&bytes).err(), Some(e), "{name}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let index = (if is_aepx(&bytes) { TreeIndex::from_file(&file) } else { TreeIndex::parse(&bytes) }).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(index.to_file() == file, "{name}: the index is not the parsed tree");
|
||||
assert_eq!(index.len(), file.walk().count(), "{name}");
|
||||
assert_eq!(index.file_version(), file.file_version(), "{name}");
|
||||
assert_eq!(index.saved_by_version(), file.saved_by_version(), "{name}");
|
||||
assert_eq!(effectcraft_aep::ProjectSettings::read_index(&index), effectcraft_aep::ProjectSettings::read(&file), "{name}");
|
||||
let (Some(a), Some(b)) = (Project::new(&file), Project::from_index(&index)) else {
|
||||
assert!(Project::new(&file).is_none() && Project::from_index(&index).is_none(), "{name}");
|
||||
continue;
|
||||
};
|
||||
let (da, db) = (dump(&a), dump(&b));
|
||||
assert!(da == db, "{name}: the view reads differently through the index");
|
||||
}
|
||||
}
|
||||
|
||||
/// The index and the parser fail alike under tight limits.
|
||||
#[test]
|
||||
fn the_index_and_the_parser_fail_alike_under_tight_limits() {
|
||||
for path in every_file().into_iter().filter(|p| p.extension().is_some_and(|x| x == "aep")) {
|
||||
let name = path.display().to_string();
|
||||
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let total = parse(&bytes).map(|f| f.walk().count()).unwrap_or(0);
|
||||
for limits in [
|
||||
Limits { max_depth: 2, max_chunks: 10_000_000 },
|
||||
Limits { max_depth: 6, max_chunks: 10_000_000 },
|
||||
Limits { max_depth: 64, max_chunks: total },
|
||||
Limits { max_depth: 64, max_chunks: total.saturating_sub(1) },
|
||||
Limits { max_depth: 64, max_chunks: total / 2 },
|
||||
] {
|
||||
let old = parse_with_limits(&bytes, limits);
|
||||
let new = TreeIndex::parse_with_limits(&bytes, limits);
|
||||
match (old, new) {
|
||||
(Ok(f), Ok(i)) => assert!(i.to_file() == f, "{name}: {limits:?}"),
|
||||
(Err(a), Err(b)) => assert_eq!(a, b, "{name}: {limits:?}"),
|
||||
(a, b) => panic!("{name}: {limits:?}: {:?} vs {:?}", a.err(), b.err()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40-byte NUL-padded match name.
|
||||
fn tdmn(name: &str) -> Chunk<'static> {
|
||||
let mut b = name.as_bytes().to_vec();
|
||||
b.resize(40, 0);
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
/// A nested chunk `id` holding one `Utf8` with `text`.
|
||||
fn utf8(id: [u8; 4], text: &str) -> Chunk<'static> {
|
||||
Chunk { id, body: effectcraft_aep::Body::Chunks(vec![Chunk::data(*b"Utf8", text.as_bytes().to_vec())]), pad: None }
|
||||
}
|
||||
|
||||
/// A project with one composition, one layer and one effect whose five parameters carry arbitrary
|
||||
/// data in the sizes the decoders read (Hue/Saturation, Levels, Curves, a 2 x 2 mesh, Cryptomatte),
|
||||
/// so the arbitrary-data path runs without the fixture directory.
|
||||
fn synthetic() -> Vec<u8> {
|
||||
let mut mesh = vec![0u8; 180];
|
||||
mesh[..8].copy_from_slice(&[0, 0, 0, 2, 0, 0, 0, 2]);
|
||||
let mut cry = vec![0u8; 200];
|
||||
cry[..4].copy_from_slice(b"cry1");
|
||||
cry[32..37].copy_from_slice(b"Layer");
|
||||
let mut levels = vec![0u8; 108];
|
||||
levels[8..16].copy_from_slice(&1.0f64.to_be_bytes());
|
||||
let payloads: [Vec<u8>; 5] = [vec![7u8; 180], levels, vec![0u8; CurvesData::SIZE], mesh, cry];
|
||||
let (mut part, mut values) =
|
||||
(vec![Chunk::data(*b"parn", 6u32.to_be_bytes().to_vec())], vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), utf8(*b"tdsn", "Mine")]);
|
||||
for (i, data) in payloads.into_iter().enumerate() {
|
||||
let name = format!("FX-{:04}", i + 1);
|
||||
let mut pard = vec![0u8; 148];
|
||||
pard[12..16].copy_from_slice(&11u32.to_be_bytes());
|
||||
part.extend([tdmn(&name), Chunk::data(*b"pard", pard)]);
|
||||
let record = Chunk::list(*b"tdbs", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), Chunk::data(*b"cdat", vec![0, 0, 0, 1])]);
|
||||
values.extend([tdmn(&name), record, Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", data)])]);
|
||||
}
|
||||
values.push(tdmn("ADBE Group End"));
|
||||
let sspc = Chunk::list(*b"sspc", vec![utf8(*b"fnam", "Effect"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]);
|
||||
let parade = Chunk::list(*b"tdgp", vec![tdmn("FX"), sspc, tdmn("ADBE Group End")]);
|
||||
let root = Chunk::list(*b"tdgp", vec![tdmn("ADBE Effect Parade"), parade, tdmn("ADBE Group End")]);
|
||||
let layer = Chunk::list(*b"Layr", vec![Chunk::data(*b"ldta", vec![0u8; 160]), Chunk::data(*b"Utf8", b"Layer".to_vec()), root]);
|
||||
let mut idta = vec![0u8; 64];
|
||||
idta[..2].copy_from_slice(&4u16.to_be_bytes());
|
||||
idta[16..20].copy_from_slice(&2u32.to_be_bytes());
|
||||
let mut cdta = vec![0u8; 200];
|
||||
cdta[4..12].copy_from_slice(&[0, 0, 0, 1, 0, 0, 0, 30]);
|
||||
let item = Chunk::list(*b"Item", vec![Chunk::data(*b"idta", idta), Chunk::data(*b"Utf8", b"Comp".to_vec()), Chunk::data(*b"cdta", cdta), layer]);
|
||||
let file = AepFile { chunks: vec![Chunk::data(*b"head", vec![0u8; 20]), Chunk::list(*b"Fold", vec![item])], trailer: std::borrow::Cow::Borrowed(&[]) };
|
||||
write(&file).expect("write")
|
||||
}
|
||||
|
||||
/// The view reads arbitrary effect data the same through the index.
|
||||
#[test]
|
||||
fn the_view_reads_arbitrary_effect_data_the_same_through_the_index() {
|
||||
let bytes = synthetic();
|
||||
let file = parse(&bytes).expect("parse");
|
||||
let index = TreeIndex::parse(&bytes).expect("index");
|
||||
let (a, b) = (Project::new(&file).expect("project"), Project::from_index(&index).expect("project"));
|
||||
let (da, db) = (dump(&a), dump(&b));
|
||||
assert_eq!(da, db);
|
||||
// The synthetic effect really reaches every decoder (the dump is not empty of them).
|
||||
assert_eq!(da.matches("arbitrary FX-").count(), 10, "{da}");
|
||||
assert!(da.contains("Some(HueSaturationData") && da.contains("Some(LevelsData") && da.contains("Some(CurvesData"), "{da}");
|
||||
assert!(da.contains("Some(MeshData") && da.contains("Some(CryptomatteData"), "{da}");
|
||||
assert!(da.contains("Some(\"Mine\")"), "the user name is read: {da}");
|
||||
}
|
||||
@@ -4,5 +4,6 @@
|
||||
#[macro_use]
|
||||
mod common;
|
||||
|
||||
mod container;
|
||||
mod export;
|
||||
mod import;
|
||||
+20
-16
@@ -119,6 +119,11 @@ counterpart (a name that is not 1 to 4 characters) are skipped with their conten
|
||||
with the byte offset. It runs without recursion; the nesting depth (`Limits::max_depth`) and the
|
||||
element count (`Limits::max_chunks`) are bounded, and everything it allocates is bounded by the
|
||||
size of the input.
|
||||
- There are two readers. `read_aepx` builds the owned tree element by element. The importer, which
|
||||
only reads, uses a second reader that decodes every body (hex, text, the alias JSON, the
|
||||
numbers) into one buffer and records the chunks as a flat depth-first list whose bodies are
|
||||
ranges of that buffer; it indexes that buffer (`TreeIndex`) as it indexes an `.aep`. The tests
|
||||
hold both readers to the same tree and the same errors.
|
||||
- Tests: `tests/aepx.rs` compares the five fixture pairs chunk by chunk and byte by byte, compares
|
||||
the typed view of both forms, and runs truncated, bit-flipped, deeply nested and oversized
|
||||
inputs; `crates/aep/tests/it/import/aepx.rs` imports both forms of each pair and expects the same
|
||||
@@ -138,7 +143,7 @@ write on their own.
|
||||
| `svap` (4 bytes, the first top-level chunk) is the version of the After Effects that **wrote** the file: `0f 11 0e 02` in all 36 (a down-saved copy keeps the 2026 word) and in all 1 192 other `.aep` files of the local corpus folders. After Effects opens a file without `svap` (`t-nosvap`). | All 36; corpus scan. |
|
||||
| `head` bytes 4 to 7 are the version the file is **written for**: `0f 11 0e 02` (= `svap`) in the 2026 saves, `0f 0b 06 61` in the 25.x copies, `0f 03 06 41` in the 24.x copies, the same word in every family. After Effects refuses a file whose word is zero ("created with an older version [0.0x0]", `u-zhead`). | All 36; `u-zhead`. |
|
||||
| The word is packed (bit 31 first): bits 25-24 and 21-19 the major, 18-15 the minor, 14-11 the bug-fix, 10-8 a stage (6 in all), 7-0 the build; bits 31-26 (`000011`) and 23-22 (`00`) are the same in all samples and not decoded. `0f110e02` = **26.2.1x2**, which is what the saving After Effects reports for itself (`app.version` recorded by the corpus scripts) - this one sample fixes minor, bug-fix and build. The copies read as **25.6.0x97** and **24.6.0x65** (major **V** from three consecutive majors; minor, bug-fix and build of the copies **E**: one other word decides their positions). `effectcraft_aep::AeVersion`, `AepFile::file_version`, `AepFile::saved_by_version`. | Tests `decodes_the_sampled_words`, `the_container_of_a_copy_states_its_version_in_head_and_the_writer_in_svap`. |
|
||||
| `head` bytes 0 to 1 (a `u16`) step with the major: 97 (`0x61`) in the 2026 saves, 96 in 25.x, 95 in 24.x; bytes 2 to 3 are 5, 9 and 6 (**?**). Bytes 8 to 15 differ between families, not between versions; bytes 16 to 19 are the save counter (one higher in each copy). Nothing is derived from bytes 0 to 3 (`AepFile::head_revision` exposes the `u16`). | All 36. |
|
||||
| `head` bytes 0 to 1 (a `u16`) step with the major: 97 (`0x61`) in the 2026 saves, 96 in 25.x, 95 in 24.x; bytes 2 to 3 are 5, 9 and 6 (**?**). Bytes 8 to 15 differ between families, not between versions; bytes 16 to 19 are the save counter (one higher in each copy). Nothing is derived from bytes 0 to 3, and the reader does not expose them. | All 36. |
|
||||
| The XMP packet after the container is no guide: its `CreatorTool` is "Adobe After Effects 2026" in the copies too. | All 36. |
|
||||
| **26 against 25.x** (per family, chunk tree aligned by item and layer id): besides `head`, `nhed` / `nnhd` differ in 6 bytes (`nhed` 20-23 and 26-27: `827fa930` / `0015` in the 2026 save, `00000000` / `011b` in both copies; session state, see section 4). Every **solid's** `Pin /CLRS` changes: the 2026 save has `dcui` (1 byte, 1), `apid` all `0xff` and an empty `Utf8`; the copies drop `dcui`, set `apid` to a GUID (`601e9973...`, the same in all) and embed a 935-byte JSON with an ICC profile (`baseColorProfile/colorProfileData`). A **footage** item that the 2026 save stores with an assigned profile (`mcsp` = 1 and a profile JSON) loses `mcsp` and that JSON in the copies, and its `epid` becomes all `0xff`. The rest is save noise: the footage alias `ascendcount_base` (the copies were saved in another folder), `sspc` byte 112 of a footage item (a modification time), the `oEXR` `opti` of an EXR item (the copies hold the display device name `\.\DISPLAY1` as UTF-16 at byte 126 and, in the 24.x copy, a few non-zero bytes from byte 2 102 that look like uninitialised memory; the 2026 save has zeros), the plug-ins' sequence data `sdat` of Lumetri Color (16 instances and the project's `EfdG`), the mask counter `mkif` byte 8. No chunk is added or reordered, and no `pard`, `cdat`, `ldta`, `cdta`, `tdb4`, keyframe, text or shape chunk differs. | All 12 families. |
|
||||
| **25.x against 24.x**: only `head` (bytes 1, 3, 5 and 7), and the session noise in `opti` and `sdat` above. | All 12 families. |
|
||||
@@ -216,12 +221,12 @@ the value from the corpus manifest (test file in parentheses at the end of each
|
||||
|
||||
API: `LayerHeader` (`is_video_enabled`/`is_enabled`, `is_audio_enabled`, `is_effects_enabled`,
|
||||
`is_auto_oriented`, `light_type`, `blend_mode`, `stretch` / `stretch_den`, `stretch_ratio`,
|
||||
`stretch_percent`, `in_point`, `out_point`); the same switches on `Layer`; `Layer::track_matte`
|
||||
`in_point`, `out_point`); the same switches on `Layer`; `Layer::track_matte`
|
||||
(`TrackMatte`, `MatteMode`; test `tests/matte.rs`). `stretch` is now the
|
||||
numerator of the ratio (it was the whole multiplier before 50 % and 150 % were sampled). Tests:
|
||||
`tests/header.rs`, `tests/layer_switches.rs`. The layer name itself is the `Utf8` child of the
|
||||
`Layr` (empty while the name is the source's); the layer then shows the name of its source item
|
||||
(`Project::layer_display_name`).
|
||||
(`Project::layer_source(...)` then `Item::display_name`, the rule the importer uses).
|
||||
|
||||
### 3.2 Composition header (`Item/cdta`, 204 bytes; the decoder needs the first 176) and renderer
|
||||
|
||||
@@ -246,9 +251,9 @@ numerator of the ratio (it was the whole multiplier before 50 % and 150 % were s
|
||||
|
||||
The inert `ADBE Time Remapping` record of every AV layer has `tduM` = the source's duration in seconds: 5 for 5 s compositions (124, 257, 258), 3 and 2 for a 3 s and a 2 s composition (600, 601), 1 for the 1 s WAV (351), 0.1 for the three-frame sequence (353), 0 on solids and stills (000, 126). Every property's keyframe time scale (`tdb4` bytes 12 to 15) follows the composition (23 976 in every
|
||||
property of `L` after 229 and 230). The corpus manifest calls the classic renderer
|
||||
`ADBE Advanced 3d`; the file stores `ADBE Escher` / `Classic 3D`. API: `CompHeader::frame_rate_exact`
|
||||
(exact fraction), `duration_frames`, `shutter_phase`, `display_start`, `display_start_seconds`,
|
||||
`display_start_frame`. Tests: `tests/header.rs`, `tests/layer_switches.rs`.
|
||||
`ADBE Advanced 3d`; the file stores `ADBE Escher` / `Classic 3D`. API: `CompHeader::{time_base,
|
||||
frame_rate_fixed, duration_units, shutter_phase, display_start}` (`display_start` is the exact
|
||||
fraction). Tests: `tests/header.rs`, `tests/layer_switches.rs`.
|
||||
|
||||
### 3.3 Items, footage and precomps
|
||||
|
||||
@@ -293,7 +298,8 @@ name. A footage layer shows the item's name while it has no name of its own (351
|
||||
|
||||
Test: `tests/header.rs` (precomp), `tests/text_markers_footage.rs` and `tests/batch3_objects.rs`
|
||||
(footage), `tests/layer_switches.rs` (solid colour). API: `Footage::{duration, native_frame_rate,
|
||||
bits_per_pixel, file_size, audio_sample_rate, is_sequence, sequence_frame_count}`.
|
||||
bits_per_pixel, file_size, audio_sample_rate, is_sequence}`; `Item::sequence_info` (with
|
||||
`SequenceInfo::frame_count`).
|
||||
|
||||
### 3.4 Properties: sparse storage, static values and units
|
||||
|
||||
@@ -402,8 +408,8 @@ out speed, at 40 out influence**). Scale key 1 of 305 (`n = 3`, in 0 / 33 %, out
|
||||
15 / 66) is `0,0, .33,.33, 15,15, .66,.66`. Component `i` uses slot `i` of each block (**V** for the
|
||||
block layout and the units, Scale speed in fractions per second and Mask Feather speed in pixels
|
||||
per second; **E** that different values per dimension sit in the slot of their dimension, because
|
||||
the tool that made the corpus applies one ease to all dimensions; **V** since the exporter wrote three different eases for the three Scale dimensions and After Effects read the same three back, aep-export.md R13). `Keyframe::plain_ease_slots`
|
||||
returns the doubles as stored, `Keyframe::ease_component(i)` one component's `KeyEase`. Layout of
|
||||
the tool that made the corpus applies one ease to all dimensions; **V** since the exporter wrote three different eases for the three Scale dimensions and After Effects read the same three back, aep-export.md R13). `Keyframe::ease_component(i)`
|
||||
returns one component's `KeyEase`, `Keyframe::ease_components` all of them. Layout of
|
||||
the spatial template: `u32` at 8 (1 normally, 0 after the spatial auto-bezier and continuous
|
||||
switches were turned off together, 054), `f64` arc length of the segment to the next key at 16
|
||||
(100 in 204 key 1, 0 on the last key and right after creation; safe to ignore), **ease at 24 in
|
||||
@@ -428,7 +434,7 @@ in tangent (`n` x `f64`, relative offset from the value) at `56 + 8 n`, out tang
|
||||
| **ease, colour switched to bezier** | spatial n=4 @24..55 | keyframe.set_interpolation bezier on two colour keys stores speed 0 and influence 0 on both sides, and After Effects interpolates them linearly (598 Start Color red to blue: 0.75 / 0.5 / 0.25 red at 0.5 / 1 / 1.5 s); a custom colour ease cannot be set by script | 598 | V |
|
||||
| **ease, mask path** | 64-byte record @24..55 | in speed, in influence, out speed, out influence as in the spatial template: 599 out / in influence 0.75, 700 0.2 / 0.5, speeds 0 (the path at 0.5 s is 5.42 % / 28.0 % of the way). The `f64` at 16 is 1 (093) or 2 (599, 700) on the first key and 0 on the last (not decoded) | 093, 599, 700 | V (influence), ? (speed unit) |
|
||||
|
||||
API: `Keyframe::{interpolation_in, interpolation_out, kind, value, ease, ease_component, ease_components, plain_ease_slots, color_distance, spatial}`;
|
||||
API: `Keyframe::{interpolation_in, interpolation_out, kind, value, ease, ease_component, ease_components, spatial}` (the `f64` at byte 16 of a colour key is `SpatialKey::segment_length`);
|
||||
`Property::keys()` hands the property's template to its records. Marker record (16 bytes): time,
|
||||
`03 03` hold bytes, 8 stale bytes. Mask path record (64 bytes): the common fields and the ease at byte 24 (599, 700). Test:
|
||||
`tests/values.rs`, `tests/keyframes.rs`.
|
||||
@@ -765,7 +771,7 @@ pins are placed by hand or by a preset.
|
||||
328), so mode and inverted are reported per mask. `mkif` byte 8 is the running mask id counter of the
|
||||
**layer** in the session (1, 2 for the two masks of 328; 329 then gets 3; undo does not reset it; a new
|
||||
layer starts at 1 again): exposed as `Mask::counter`, a session counter and not a stable id. API:
|
||||
`Mask::{mode, inverted, counter}`, `Project::mask_vertices_px` (vertices in layer pixels), `Layer::mask_atoms` (`MaskAtom`: the mask with its atom group, its path property and its shapes by position, for the keys and expressions of the mask properties). Tests:
|
||||
`Mask::{mode, inverted, counter}`, `ShapePath::vertices(size)` (vertices in layer pixels: `size` is the size of the layer's source, `Project::layer_source_size`; a text or shape layer has no source size and its mask boxes are already pixels, so the importer passes 1 x 1), `Layer::mask_atoms` (`MaskAtom`: the mask with its atom group, its path property and its shapes by position, for the keys and expressions of the mask properties). Tests:
|
||||
`tests/masks.rs` (with a synthetic check of the handle order), `tests/batch3_objects.rs`.
|
||||
|
||||
### 3.9 Shape layers (`tdgp[ADBE Root Vectors Group]`)
|
||||
@@ -893,7 +899,7 @@ groups as on other layers, with these conventions:
|
||||
|
||||
| What | Path | Stored | Evidence | Conf. |
|
||||
|---|---|---|---|---|
|
||||
| camera Position, default (auto state) | `ADBE Transform Group/ADBE Position`, `tdb4` 76 and 77 = 1 although no expression exists | `[0, 0, -2666.67]` (x and y are 0 whatever the composition size; z = -zoom). A **one-node** camera reports `[0, 0, -2666.67]` (251). **Correction (S16): a reopened two-node camera reports `[0, 0, -2666.67]` too (256 read back), and After Effects renders it there**: the default two-node camera of 537 above the 3D solid of 486 renders from `[0, 0, -2666.67]` towards the point of interest, not like the composition's default view (537 differs from 486 in 24 % of the grid points, and EffectCraft's import with the stored position matches 537 but for 4 edge points). The `[960, 540, ...]` of the batch-3 notes was not reproduced (535 and 537, created by script, read back the stored value too); `CameraPosition::reported` keeps the old rule and is marked as contradicted | 256, 251, 537 | V |
|
||||
| camera Position, default (auto state) | `ADBE Transform Group/ADBE Position`, `tdb4` 76 and 77 = 1 although no expression exists | `[0, 0, -2666.67]` (x and y are 0 whatever the composition size; z = -zoom). A **one-node** camera reports `[0, 0, -2666.67]` (251). **Correction (S16): a reopened two-node camera reports `[0, 0, -2666.67]` too (256 read back), and After Effects renders it there**: the default two-node camera of 537 above the 3D solid of 486 renders from `[0, 0, -2666.67]` towards the point of interest, not like the composition's default view (537 differs from 486 in 24 % of the grid points, and EffectCraft's import with the stored position matches 537 but for 4 edge points). The `[960, 540, ...]` of the batch-3 notes was not reproduced (535 and 537, created by script, read back the stored value too); the reader keeps only the stored value (`CameraPosition::stored`) | 256, 251, 537 | V |
|
||||
| camera Position, set by the user | same, `tdb4` 76 and 77 = 0 | the absolute value `[100, 200, -1000]` | 253 | V |
|
||||
| camera point of interest | `ADBE Anchor Point` | `[960, 540, 0]` in **pixels** (not a fraction); the record's `tdsb` is 1 on a two-node and 3 on the one-node camera (not asserted) | 251, 253, 256 | V (value), E (`tdsb`) |
|
||||
| light Position, default | same as the camera | `[0, 0, -1333.33]`, as After Effects reports it (255 read back); the point of interest `[960, 540, 0]` | 254, 255 | V |
|
||||
@@ -909,10 +915,8 @@ groups as on other layers, with these conventions:
|
||||
|
||||
A point light made by converting a spot light has a different property set (`Orientation`, `Rotate
|
||||
X/Y/Z`, `Cone Angle`, `Cone Feather 2` in the default state) that a spot light lacks (252); whether that
|
||||
is an artefact of the conversion is untested. API: `Layer::camera_position`,
|
||||
`CameraPosition::reported`, `Layer::camera_point_of_interest`, `Layer::light_intensity`,
|
||||
`Layer::{camera_zoom, camera_depth_of_field, camera_focus_distance, camera_aperture, light_color,
|
||||
light_cone_angle, light_cone_feather}`, `DEFAULT_CAMERA_ZOOM` (2 666.67 is verified for the 1920 px
|
||||
is an artefact of the conversion is untested. API: `Layer::camera_position`; the camera and light
|
||||
options are read by the importer's table (`import/camera.rs`); `DEFAULT_CAMERA_ZOOM` (2 666.67 is verified for the 1920 px
|
||||
composition only), `Layer::orientation` (`Orientation`; test `orientation_is_otst_otda` in
|
||||
`tests/batch5.rs`). Render checks against After Effects (20 px grid, 5 184 points): 486 1 point
|
||||
off, 535 2, 536 0, 537 4 (anti-aliased edges); **488 (Orientation `[10, 20, 30]`): 272 points
|
||||
|
||||
Reference in new issue
Block a user