Files
Valentin KleinandFredApps a4bde72bca .aep: import twice as fast, faster export (#3302)
* aep: a layer's markers and Source Text are read where they are stored, not searched for (#3276)

A layer's marker list is a direct child of its root tdgp, and its Source Text record
btds a direct child of the ADBE Text Properties group. Markers walked the whole layer
subtree twice per layer (most layers have no marker list, so the walk always ran to the
end), and the three text-document accessors walked it once each.

* aep export: build the prototype template once, sort names with cached keys (#3277)

The template every export clones from is immutable; it is now built by the
first export of the process and kept (about 0.93 MB). The name sort builds
each natural key once instead of on every comparison. Root solids move into
the Solids folder instead of being copied. The two key-list rewrites share one
emit-local helper and match names compare as bytes instead of a decoded String.
Output is byte-identical.

* aep container: size sibling lists, hex and write output once; borrow plain .aepx attributes (#3275)

- read.rs: count the chunk headers of a sequence first and allocate the
  children Vec once (bounded by the input and by the chunk limit).
- aepx.rs: decode_hex sizes its output once; attribute values without
  an entity are borrowed instead of copied through decode_entities.
- typed.rs: PropGroup::get compares match names without building a
  String (same result as the lossy compare, invalid UTF-8 included).
- write.rs: reserve the exact output size up front (try_reserve, so a
  hand-built tree cannot abort on the allocation).
- read.rs, settings.rs, values.rs: use the shared bytes.rs readers.

Output is identical on the corpus (tree, written bytes, import).

* aep container: review fixes (#3275)

- read.rs: reserve with try_reserve_exact, so a raised chunk limit on a
  small target cannot abort before parsing; the capped reservation and a
  deep, retry-heavy nest of broken lists are pinned by tests.
- write.rs: a tree past the 32-bit container size fails with TooLarge
  before anything is serialised.
- aepx.rs: drop an unneeded local and lifetime annotation.

* aep export: note what the concurrency test covers and why a failed template build is not kept (#3277)

* aep: text documents are read without a String per token, defaults built once (#3276)

TextDocument::parse reads a borrowed tree: names, bare words and strings without
escapes point into the body, keys are borrowed when they are /names, numbers parse from
the borrowed bytes, and dictionaries and arrays are moved into vectors of their exact size
when they close. The style and paragraph defaults are built once instead of per document.
parse_text_dict keeps its public TextValue result (the borrowed tree turned owned).

The old owning parser stays as a test-only oracle: a differential test compares both on
40 000 generated bodies (every key form the owned keys canonicalise, non-UTF-8 names,
escapes, unclosed strings, non-finite numbers), every cut of a realistic document and
nesting around the depth limit.

* aep: text-dict test builds its prefix without repeat(1) (#3276)

* aep: an escaped text string drops its byte order mark without a range drain (#3276)

* aep export: one key-time loop, key-list rewrite, tdmn decoder and byte writer for the listed modules (#3278)

In masks, shapes, styles, synth, fxsynth, base, camlight, skeleton,
textanim and tree (effects.rs, plan.rs and emit.rs keep their copies for
the follow-up work):
- New export/keys.rs: the key-time loop (timed_keys), the key-list rewrite
  (fill_key_list, set_key_list, replace_cdat) and the spatial key record,
  shared by masks, shapes and styles.
- One tdmn decoder (synth::tdmn_name, lossy UTF-8 up to the first NUL, as the
  importer reads it) and one encoder (synth::tdmn) instead of the copies in
  masks, styles, shapes, textanim, camlight and skeleton.
- One byte writer: tree::put, which errors. base::bytes, synth::tdb4,
  synth::key_list, fxsynth::pard, masks::shph and the keyed path records write
  through it; their fixed layouts keep the infallible signatures behind
  tree::fixed, which logs, and a test builds every layout and requires no error.
- camlight::put_group uses the shared group split (tree::split_group), which
  errors on a match name without a node instead of stopping quietly.

Export output is byte-identical on the corpus and the synthetic fixtures.

* aep export: pin the key-time loop's report, name the layout in the base::bytes log (#3278)

* aep export: review fixes for #3278

- tree::Fixed replaces tree::fixed: a fixed layout writes every field that
  fits (as the old code did) and keeps the first error; finish() logs it
  once with the layout's description and counts it per thread, so the
  every-layout test cannot race with other tests.
- base::bytes logs one line naming its length and fields.
- masks::build_atom errors when the prototype has no Mask Shape instead of
  dropping the path of every mask quietly.
- log sorted into the dependency list.

* aep: review fixes for #3276 text documents and markers

text_document reads the first btdk in the Source Text record, the documents
text_document_count counts. The old slot checks (same, is_neutral, undecoded_fields)
join the test oracle and are compared on 20 000 generated slot dictionaries; mixed
dictionary/array chains and a document left open at the depth limit join the parser
differential. An escaped string's byte order mark is drained without a copy.

* aep: remove test-only container accessors, share report and time-base code (#3281)

- Remove 35 public container accessors that only tests called (header,
  camera/light, keys, footage, masks, shapes, version) and the dead
  StyleMap::switch_name. Tests now check the importer's own reading of the
  same bytes, or the decoder it uses; three import tests gain the values
  the removed checks covered.
- Import and export reports share one loss entry (Loss<C>) with record,
  total, count and summary; the JSON shape is pinned for both.
- Collector::lose_with builds a detail only below the detail cap (callers
  switch later).
- The export uses the container's TimeBase; one NTSC table serves import
  and export; TimeBase::frames saturates like units instead of returning 0.

* aep: review fixes for #3281: NTSC table beside TimeBase, real-path NTSC round trip, light and camera defaults on import, doc wording

* aep: drop a local check tool committed by mistake (#3281)

* aep export: sort keys as one compact byte string, bounded by the name (#3277)

The cached natural keys were Vecs of (bool, String, u128) segments: a 255-byte
name of alternating letters and digits took about 14 KB, held for every item at
once. Each key is now one byte string whose byte order is the natural order,
at most 5 bytes per name byte. Order and output are unchanged.

* aep container: check bdata digits and size before reserving (#3275)

decode_hex reserved the decoded size before it knew the value decodes,
so a large bdata with a bad digit reserved half its length first. It now
refuses a value larger than a chunk's 32-bit size field (TooLarge), checks
every digit in one vectorisable pass, and only then reserves, with
try_reserve_exact so a value that does not fit in memory is an error
instead of an abort.

* aep export: the every-layout test counts from a baseline, not from zero (#3278)

* aep export: correct the order the natural sort key doc gives for equal numbers (#3277)

* aep: the format notes name the accessors that remain (#3281)

* aep: the format notes name the 1 x 1 mask scale of text and shape layers (#3281)

* aep: read imports through a flat tree index (#3300)

Import reads an .aep through TreeIndex, one depth-first array of chunks that
borrows the file's bytes, instead of building and freeing the owned Chunk tree
(one Vec per list). The parser mirrors read::Parser step for step: the same
chunk count order against max_chunks, the same depth limit and opaque
fallbacks, the same errors. The typed view reads through Node, a handle to a
chunk of either tree, so the accessors work unchanged on both; .aepx keeps its
owned tree. Chunk, parse and write stay for export and editing.

The array starts small and doubles, never past what the input can hold or the
chunk limit allows; a failed allocation is TooLarge, a tree past u32 indices
TooManyChunks.

* aep export: the time remap and Transform keys use the shared key-list and match-name helpers; doc comments for the audit's functions (#3300)

* #3300: import .aepx through the flat index

The .aepx reader no longer builds the owned chunk tree element by
element. It decodes every body (hex, text, alias JSON, numbers) into
one buffer and records the chunks as depth-first entries whose bodies
are ranges of that buffer; TreeIndex::from_drafts turns them into the
index the importer reads, which borrows the buffer. read_aepx converts
that index into the owned tree in one pass.

The tree and the errors are those of the previous reader, which the
tests keep as the reference: generated and damaged documents under
seven limit sets, invalid UTF-8, hostile input, the fixture directory,
and imports through both. Bound tests pin the chunk list to the input
and the limit, and check that dropped content leaves nothing behind.

* #3300: read_aepx keeps its own owned-tree reader

read_aepx, read_aepx_with_limits and parse_any build the owned chunk
tree element by element again, as before the .aepx index, instead of
converting the index (which held the decoded buffer and the owned tree
at the same time). Import keeps the index reader. The tests now
compare the two production readers.

* #3300: test keys that round to the same time

* aep: compare the destination projects in the index-equivalence tests

both() and both_aepx() kept only the ImportResult of each path; they now keep
both destination Projects and assert structural and serialized equality, so a
layer or property value regression cannot pass on equal ids and reports.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-13 00:54:29 +01:00

752 lines
36 KiB
Rust

//! Effects (Tint, Gradient Ramp, Exposure): every effect fixture is imported and exported, and the
//! exported effects are parsed back with `effectcraft-aep` and compared with the ones After
//! Effects wrote, parameter by parameter (stored values, keys, definitions); projects edited in
//! code check the parameters no fixture changes, several instances, and what is reported.
use crate::export::helpers::*;
use effectcraft_aep::export::LossCategory;
use effectcraft_aep::import::{ImportOptions, import};
use effectcraft_aep::{AepFile, ParamValue, parse};
use effectcraft_keyframe::{Interp, Value};
use effectcraft_project::{ItemKind, Node, Project, PropGroup};
fn fixtures_with_effects() -> Option<Vec<String>> {
Some({
let mut v: Vec<String> = crate::common::names("aep")?
.into_iter()
// Every fixture with an effect of the table (the dropdown and layer controls are not in it).
.filter(|n| {
n.ends_with(".aep")
&& (n.contains("-fx-") || n.contains("key-fill") || n.contains("key-ramp") || n.contains("adj-ramp"))
&& !n.contains("dropdown")
&& !n.contains("layercontrol")
})
.collect();
v.sort();
v
})
}
fn import_bytes(bytes: &[u8]) -> Project {
let mut p = Project::default();
let none = |_: &str| false;
let opts = ImportOptions { allow_losses: true, ..ImportOptions::new(&none) };
import(&mut p, bytes, &opts).expect("import");
p
}
/// One effect as the reader sees it: match name, instance name, and per parameter index the
/// stored value (None = default) and the keys `(time units, value)`.
#[derive(Debug, PartialEq)]
struct Seen {
match_name: String,
name: Option<String>,
params: Vec<(usize, Option<ParamValue>, Vec<(i32, Vec<f64>, (u8, u8))>)>,
}
/// The effects of every layer, by (composition name, layer index).
fn effects_of(file: &AepFile<'_>) -> Vec<((String, usize), Vec<Seen>)> {
let view = effectcraft_aep::Project::new(file).expect("project");
let mut out = Vec::new();
for item in view.all_items() {
let Some(comp) = item.comp() else { continue };
let cname = item.display_name().unwrap_or_default();
for (i, layer) in comp.layers().enumerate() {
let mut fx = Vec::new();
// Effects outside the table are reported by the importer, not written.
for e in layer.effects().into_iter().filter(|e| effectcraft_aep::effect_map::by_match_name(e.match_name()).is_some()) {
let mut params = Vec::new();
for p in view.effect_params(&e) {
let keys = p
.keys()
.map(|k| {
k.keyframes()
.map(|r| {
let interp = (
r.interpolation_in().map_or(0, |x| match x {
effectcraft_aep::Interpolation::Linear => 1,
effectcraft_aep::Interpolation::Bezier => 2,
effectcraft_aep::Interpolation::Hold => 3,
effectcraft_aep::Interpolation::Other(c) => c,
}),
r.interpolation_out().map_or(0, |x| match x {
effectcraft_aep::Interpolation::Linear => 1,
effectcraft_aep::Interpolation::Bezier => 2,
effectcraft_aep::Interpolation::Hold => 3,
effectcraft_aep::Interpolation::Other(c) => c,
}),
);
(r.time_units().unwrap_or(-1), r.value().unwrap_or_default(), interp)
})
.collect()
})
.unwrap_or_default();
let value = if p.is_animated() { None } else { p.stored_value() };
params.push((p.index, value, keys));
}
fx.push(Seen { match_name: e.match_name().to_string(), name: e.display_name(), params });
}
if !fx.is_empty() {
out.push(((cname.clone(), i), fx));
}
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn close_value(a: &Option<ParamValue>, b: &Option<ParamValue>) -> bool {
let comps = |v: &ParamValue| -> Vec<f64> {
match v {
ParamValue::Number(n) => vec![*n],
ParamValue::Point(p) => p.to_vec(),
ParamValue::Argb(c) => c.iter().skip(1).copied().collect(),
ParamValue::Index(i) => vec![f64::from(*i)],
ParamValue::Bool(b) => vec![if *b { 1.0 } else { 0.0 }],
other => panic!("unexpected {other:?}"),
}
};
match (a, b) {
(None, None) => true,
// The input layer names the owning layer by its id, which differs between the files.
(Some(ParamValue::Layer(_)), Some(ParamValue::Layer(_))) => true,
(Some(x), Some(y)) => comps(x).iter().zip(comps(y)).all(|(p, q)| (p - q).abs() < 1e-6),
_ => false,
}
}
#[test]
fn every_effect_fixture_is_written_as_after_effects_wrote_it() {
let names = skip!(fixtures_with_effects());
assert!(names.len() >= 20, "{names:?}");
let mut compared = 0;
for name in &names {
let bytes = fixture!(name);
let original = parse(&bytes).expect("parse");
let mut p = Project::default();
let none = |_: &str| false;
let imported = import(&mut p, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("import");
let lossy: Vec<String> = imported.report.losses.iter().flat_map(|l| l.details.clone()).collect();
let r = export_all(&p);
assert_eq!(r.report.count(LossCategory::Effects), 0, "{name}: {:?}", r.report.losses);
let exported = parse(&r.bytes).expect("parse export");
let (a, b) = (effects_of(&original), effects_of(&exported));
assert_eq!(a.len(), b.len(), "{name}: layers with effects");
for ((ka, fa), (kb, fb)) in a.iter().zip(&b) {
assert_eq!(ka, kb, "{name}");
assert_eq!(fa.len(), fb.len(), "{name}: effect count");
for (x, y) in fa.iter().zip(fb) {
assert_eq!((&x.match_name, &x.name), (&y.match_name, &y.name), "{name}");
// Written as the importer took it over (712: CC Radial Blur's Type Scratch has no
// EffectCraft item, reported on import).
if lossy.iter().any(|d| d.contains(&format!("({})", x.match_name))) {
continue;
}
assert_eq!(x.params.len(), y.params.len(), "{name}: parameters of {}", x.match_name);
for (px, py) in x.params.iter().zip(&y.params) {
assert_eq!(px.0, py.0, "{name}");
assert!(close_value(&px.1, &py.1), "{name} {} #{}: {:?} vs {:?}", x.match_name, px.0, px.1, py.1);
assert_eq!(px.2.len(), py.2.len(), "{name} #{} keys", px.0);
// A colour key switched to bezier stores speed 0 / influence 0, which After
// Effects interpolates linearly (598): it is imported and written linear.
let colour = matches!(px.2.first().map(|k| k.1.len()), Some(4));
let lin = |i: (u8, u8)| if colour { (i.0.min(1), i.1.min(1)) } else { i };
for (kx, ky) in px.2.iter().zip(&py.2) {
assert_eq!((kx.0, lin(kx.2)), (ky.0, lin(ky.2)), "{name} #{} key", px.0);
assert!(kx.1.iter().zip(&ky.1).all(|(p, q)| (p - q).abs() < 1e-6), "{name} #{} key value {:?} {:?}", px.0, kx.1, ky.1);
}
}
compared += 1;
}
}
// The input layer of every exported instance names the layer that owns it (320).
let view = effectcraft_aep::Project::new(&exported).expect("project");
for item in view.all_items() {
let Some(comp) = item.comp() else { continue };
for layer in comp.layers() {
let id = layer.header().map(|h| h.id).expect("header");
for e in layer.effects() {
let input = view.effect_params(&e).into_iter().find(|p| p.index == 0).and_then(|p| p.stored_value());
assert_eq!(input, Some(ParamValue::Layer(id)), "{name}: input layer of {}", e.match_name());
}
}
}
// The container is consistent and the result imports to the same effect values.
assert_eq!(effectcraft_aep::write(&exported).expect("write"), r.bytes, "{name}");
let q = import_bytes(&r.bytes);
assert_eq!(effect_values(&p), effect_values(&q), "{name}: values after a second import");
}
assert!(compared >= 20, "{compared}");
}
/// Every effect parameter of the project: (comp, layer, effect, param path, value, key count).
fn effect_values(p: &Project) -> Vec<(String, String, String, String, Vec<i64>, usize)> {
let mut out = Vec::new();
for item in p.items.values() {
let ItemKind::Comp(c) = &item.kind else { continue };
for l in &c.layers {
let Some(fx) = l.effects() else { continue };
for g in fx.groups() {
let mut stack: Vec<(&PropGroup, String)> = vec![(g, String::new())];
while let Some((grp, pre)) = stack.pop() {
for n in &grp.children {
match n {
Node::Group(s) => stack.push((s, format!("{pre}{}/", s.match_id))),
Node::Prop(pr) => out.push((
item.name.clone(),
l.name.clone(),
g.name.clone(),
format!("{pre}{}", pr.match_id),
pr.value.components().iter().map(|v| (v * 1e6).round() as i64).collect(),
pr.keys.len(),
)),
}
}
}
}
}
}
out.sort();
out
}
/// The Tint of 356 imported, edited by `f`.
fn tint(f: impl FnOnce(&mut PropGroup)) -> Option<Project> {
Some({
let mut p = import_bytes(&crate::common::try_fixture("356-fx-tint-default.aep")?);
let id = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
with_layer(&mut p, id, 0, |l| {
let g =
l.props.sub_mut("effects").and_then(|e| e.children.iter_mut().find_map(|n| if let Node::Group(g) = n { Some(g) } else { None })).expect("tint");
f(g);
});
p
})
}
#[test]
fn edited_parameters_and_keys_no_fixture_has() {
let p = skip!(tint(|g| {
g.get_mut("black").expect("black").value = Value::Color([0.25, 0.5, 0.75, 1.0]);
g.get_mut("white").expect("white").keys = vec![key(0.0, Value::Color([1.0, 0.0, 0.0, 1.0])), key(1.0, Value::Color([0.0, 0.0, 1.0, 1.0]))];
// A keyed property shows its first key's value.
g.get_mut("white").expect("white").value = Value::Color([1.0, 0.0, 0.0, 1.0]);
g.get_mut("amount").expect("amount").value = Value::Scalar(10.0);
let mut k0 = key(0.0, Value::Scalar(10.0));
k0.out_interp = Interp::Hold;
g.get_mut("amount").expect("amount").keys = vec![k0, key(2.0, Value::Scalar(90.0))];
g.name = "My Tint".into();
}));
let r = export_all(&p);
assert_eq!(r.report.exported.effects, 1);
assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses);
let file = parse(&r.bytes).expect("parse");
let seen = effects_of(&file);
let fx = &seen.first().expect("layer").1[0];
assert_eq!(fx.name.as_deref(), Some("My Tint"));
// As After Effects stores a renamed instance (610): fnam keeps the effect's name.
let view = effectcraft_aep::Project::new(&file).expect("project");
let e = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).map(|l| l.effects()).expect("effects");
assert_eq!((e[0].name().as_deref(), e[0].user_name().as_deref()), (Some("Tint"), Some("My Tint")));
let param = |i: usize| fx.params.iter().find(|p| p.0 == i).expect("param");
match &param(1).1 {
Some(ParamValue::Argb(c)) => assert!((c[1] - 0.25).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6 && (c[3] - 0.75).abs() < 1e-6, "{c:?}"),
other => panic!("{other:?}"),
}
let white = &param(2).2;
assert_eq!(white.len(), 2);
assert_eq!(white[1].0, 25_600);
assert_eq!(white[1].1, vec![255.0, 0.0, 0.0, 255.0]);
let amount = &param(3).2;
assert_eq!(amount.iter().map(|k| (k.0, k.1[0], k.2)).collect::<Vec<_>>(), vec![(0, 10.0, (1, 3)), (51_200, 90.0, (1, 1))]);
// And back.
let q = import_bytes(&r.bytes);
assert_eq!(effect_values(&p).into_iter().filter(|v| v.2 == "My Tint").count(), 3);
assert_eq!(
effect_values(&p)
.into_iter()
.map(|mut v| {
v.1.clear();
v
})
.collect::<Vec<_>>(),
effect_values(&q)
.into_iter()
.map(|mut v| {
v.1.clear();
v
})
.collect::<Vec<_>>()
);
}
#[test]
fn several_instances_share_one_definition_copy() {
// Two Tints and a Ramp on one layer, a Ramp on a second layer.
let mut p = import_bytes(&fixture!("383-fx-ramp-on-100x100-start-10-20.aep"));
let tint_src = import_bytes(&fixture!("356-fx-tint-default.aep"));
let tint_group = tint_src
.items
.values()
.find_map(|i| if let ItemKind::Comp(c) = &i.kind { c.layers.first().and_then(|l| l.effects()).and_then(|e| e.groups().next().cloned()) } else { None })
.expect("tint");
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
with_layer(&mut p, c, 0, |l| {
let fx = l.props.sub_mut("effects").expect("effects");
fx.children.push(Node::Group(tint_group.clone()));
fx.children.push(Node::Group(tint_group));
});
let r = export_all(&p);
assert_eq!(r.report.exported.effects, 4, "{:?}", r.report.losses);
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let mut full = Vec::new();
for item in view.all_items() {
let Some(comp) = item.comp() else { continue };
for layer in comp.layers() {
for e in layer.effects() {
full.push((e.match_name().to_string(), e.has_own_definitions()));
}
}
}
// The first instance of each type in the file holds the definitions.
assert_eq!(full.iter().filter(|(_, own)| *own).count(), 2, "{full:?}");
assert_eq!(full.len(), 4);
let defs: Vec<String> = view.effect_definitions().into_iter().map(|(n, _)| n).collect();
assert_eq!(defs, vec!["ADBE Ramp".to_string(), "ADBE Tint".to_string()]);
let pefl: Vec<String> =
file.list(b"Pefl").expect("Pefl").children().iter().filter_map(|c| c.bytes()).map(|b| String::from_utf8_lossy(b).to_string()).collect();
assert_eq!(pefl, vec!["ADBE Ramp".to_string(), "ADBE Tint".to_string()]);
let efdc = file.list(b"EfdG").and_then(|g| g.child(b"EfDC")).and_then(|c| c.bytes()).expect("EfDC");
assert_eq!(efdc, &2u32.to_le_bytes());
// Every instance reads its parameters (borrowed definitions included) and imports again.
let q = import_bytes(&r.bytes);
assert_eq!(effect_values(&q).iter().map(|v| v.2.clone()).collect::<std::collections::BTreeSet<_>>().len(), 2);
let n: usize = q
.items
.values()
.filter_map(|i| {
if let ItemKind::Comp(c) = &i.kind { Some(c.layers.iter().filter_map(|l| l.effects()).map(|e| e.groups().count()).sum::<usize>()) } else { None }
})
.sum();
assert_eq!(n, 4);
}
#[test]
fn what_cannot_be_written_is_reported() {
let p = skip!(tint(|g| {
g.enabled = false;
g.get_mut("amount").expect("amount").expr = Some(effectcraft_project::Expression { text: "50".into(), enabled: true });
}));
let r = export_all(&p);
// The switch (fx-switch cases) and the expression (534) are written; a second import gets the
// effect switched off.
assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses);
let back = import_bytes(&r.bytes);
let off = back
.comps()
.flat_map(|(_, c)| c.layers.iter())
.filter_map(|l| l.props.sub("effects"))
.flat_map(|e| e.children.iter())
.any(|n| matches!(n, Node::Group(g) if !g.enabled));
assert!(off, "the switched-off effect comes back switched off");
// An effect outside the table.
let mut q = import_bytes(&fixture!("356-fx-tint-default.aep"));
let c = q.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
with_layer(&mut q, c, 0, |l| {
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
g.kind = effectcraft_project::GroupKind::Effect { effect: "ec.distort.notmapped".into() };
}
});
let r = export_all(&q);
assert_eq!(r.report.count(LossCategory::Effects), 1);
assert_eq!(r.report.exported.effects, 0);
parse(&r.bytes).expect("still a valid file");
}
/// Lumetri Color is imported but not written (no prototype to write from): the export reports it
/// as a loss and still produces a valid file.
#[test]
fn lumetri_color_is_reported_not_written() {
let mut p = skip!(tint(|_| {}));
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
with_layer(&mut p, c, 0, |l| {
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
g.kind = effectcraft_project::GroupKind::Effect { effect: "ec.color.lumetri".into() };
}
});
let r = export_all(&p);
assert_eq!(r.report.count(LossCategory::Effects), 1, "{:?}", r.report.losses);
assert_eq!(r.report.exported.effects, 0);
parse(&r.bytes).expect("still a valid file");
}
/// CC Color Neutralizer, EXtractoR and IDentifier are in the import table but have no prototype
/// to write from: each is reported, not written.
#[test]
fn imported_plug_in_effects_are_reported_not_written() {
for effect in ["ec.color.cccolorneutralizer", "ec.3d.extractor", "ec.3d.identifier"] {
let mut p = skip!(tint(|_| {}));
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
with_layer(&mut p, c, 0, |l| {
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
g.kind = effectcraft_project::GroupKind::Effect { effect: effect.into() };
}
});
let r = export_all(&p);
assert_eq!(r.report.count(LossCategory::Effects), 1, "{effect}: {:?}", r.report.losses);
assert_eq!(r.report.exported.effects, 0, "{effect}");
parse(&r.bytes).expect("still a valid file");
}
}
#[test]
fn hostile_effect_values_never_panic() {
for v in [Value::Scalar(f64::NAN), Value::Scalar(1e300), Value::Color([f32::NAN as f64, 2.0, -1.0, 1.0]), Value::Bool(true), Value::Text(Box::default())] {
let p = skip!(tint(|g| {
g.get_mut("black").expect("black").value = v.clone();
g.get_mut("amount").expect("amount").value = v.clone();
g.get_mut("white").expect("white").keys = vec![key(0.0, v.clone()), key(0.0, v.clone()), key(1e12, v.clone())];
}));
let r = export_all(&p);
let file = parse(&r.bytes).expect("parse");
assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes);
let _ = import_bytes(&r.bytes);
}
}
/// The static value records (`tdbs` with a `cdat`) of the mapped parameters of every effect
/// instance, by (composition, layer index, effect index, record name).
fn static_records(file: &AepFile<'_>) -> std::collections::BTreeMap<(String, usize, usize, String), effectcraft_aep::Chunk<'static>> {
let view = effectcraft_aep::Project::new(file).expect("project");
let mut out = std::collections::BTreeMap::new();
for item in view.all_items() {
let Some(comp) = item.comp() else { continue };
let cname = item.display_name().unwrap_or_default();
for (li, layer) in comp.layers().enumerate() {
for (ei, e) in layer.effects().into_iter().enumerate() {
let Some(spec) = effectcraft_aep::effect_map::by_match_name(e.match_name()) else { continue };
let Some(values) = e.chunk().child_list(b"tdgp").map(|v| v.to_chunk()) else { continue };
let k = values.children();
for (i, c) in k.iter().enumerate() {
let Some(name) =
c.bytes().filter(|_| c.is(b"tdmn")).map(|b| String::from_utf8_lossy(b.split(|x| *x == 0).next().unwrap_or(b"")).to_string())
else {
continue;
};
let Some(index) = name.rsplit('-').next().and_then(|s| s.parse::<usize>().ok()) else { continue };
if !spec.params.iter().any(|p| p.index == index) {
continue;
}
if let Some(rec) = k.get(i + 1).filter(|r| r.is_list(b"tdbs") && r.child(b"cdat").is_some()) {
let mut rec = rec.clone().into_owned();
// A layer parameter's `tdpi` is a layer id, which differs between the
// files (`layer_parameters_name_the_same_layers` checks the layers).
if let effectcraft_aep::Body::List { children, .. } = &mut rec.body {
for c in children.iter_mut().filter(|c| c.is(b"tdpi")) {
*c = effectcraft_aep::Chunk::data(*b"tdpi", vec![0; 4]);
}
}
out.insert((cname.clone(), li, ei, name), rec);
}
}
}
}
}
out
}
#[test]
fn every_static_value_record_is_rebuilt_in_its_decoded_bytes() {
// The records are built in code from the parameter's type and slider range (R45): every
// static record After Effects wrote for a mapped parameter of an effect fixture comes back
// with the same decoded bytes (the rest of the header is zero, the user name the placeholder).
let mut compared = 0;
for name in skip!(fixtures_with_effects()) {
let bytes = fixture!(&name);
let original = parse(&bytes).expect("parse");
let mut p = Project::default();
let none = |_: &str| false;
let imported = import(&mut p, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("import");
// An effect the importer could not take over completely (712: CC Radial Blur's Type
// Scratch has no EffectCraft item) is written as EffectCraft has it.
let lossy: Vec<String> = imported.report.losses.iter().flat_map(|l| l.details.clone()).collect();
let r = export_all(&p);
let exported = parse(&r.bytes).expect("parse export");
let skip = |k: &(String, usize, usize, String)| lossy.iter().any(|d| d.contains(k.3.rsplit_once('-').map_or("", |x| x.0)));
let (mut a, mut b) = (static_records(&original), static_records(&exported));
a.retain(|k, _| !skip(k));
b.retain(|k, _| !skip(k));
for (key, rec) in &a {
let got = b.get(key).unwrap_or_else(|| panic!("{name}: {key:?} not written"));
assert_eq!(decoded(got), decoded(rec), "{name}: {key:?}");
compared += 1;
}
for key in b.keys() {
assert!(a.contains_key(key), "{name}: {key:?} written but After Effects has none");
}
}
assert!(compared >= 60, "{compared}");
}
#[test]
fn hue_saturation_and_levels_data_is_written_as_after_effects_wrote_it() {
// Master Hue / Lightness (444, 446) and Levels' RGB input black, gamma, output white (512,
// 514, 516) live in the effect's arbitrary data: the exported `aRbp` equals After Effects'.
for (name, param) in [
("444-fx-huesat-hue-90.aep", "ADBE HUE SATURATION-0003"),
("446-fx-huesat-light-m30.aep", "ADBE HUE SATURATION-0003"),
("512-fx-levels-inblack-0.1.aep", "ADBE Easy Levels2-0002"),
("514-fx-levels-gamma-2.aep", "ADBE Easy Levels2-0002"),
("516-fx-levels-outwhite-0.9.aep", "ADBE Easy Levels2-0002"),
] {
let bytes = fixture!(name);
let data = |b: &[u8]| -> Vec<u8> {
let f = parse(b).expect("parse");
let view = effectcraft_aep::Project::new(&f).expect("project");
let mut out = Vec::new();
for item in view.all_items() {
let Some(comp) = item.comp() else { continue };
for layer in comp.layers() {
for e in layer.effects() {
if let Some(d) = e.arbitrary_data(param) {
out = d.to_vec();
}
}
}
}
out
};
let r = export_all(&import_bytes(&bytes));
assert!(!data(&bytes).is_empty(), "{name}");
assert_eq!(data(&r.bytes), data(&bytes), "{name}");
}
}
/// The layer each layer parameter names, by layer index (comp, layer, effect,
/// parameter index, named layer or "None").
fn layer_params(file: &AepFile<'_>) -> Vec<(String, String, String, usize, String)> {
let view = effectcraft_aep::Project::new(file).expect("project");
let mut out = Vec::new();
for item in view.all_items() {
let Some(comp) = item.comp() else { continue };
let cname = item.display_name().unwrap_or_default();
let names: std::collections::HashMap<u32, String> = comp.layers().enumerate().filter_map(|(i, l)| Some((l.header()?.id, format!("#{i}")))).collect();
for (i, layer) in comp.layers().enumerate() {
let lname = format!("#{i}");
for e in layer.effects() {
for p in view.effect_params(&e).into_iter().filter(|p| p.index > 0 && p.kind == effectcraft_aep::ParamKind::Layer) {
let named = match p.stored_value() {
Some(ParamValue::Layer(0)) => "None".to_string(),
Some(ParamValue::Layer(id)) => names.get(&id).cloned().unwrap_or_else(|| format!("? {id}")),
other => format!("{other:?}"),
};
out.push((cname.clone(), lname.clone(), e.match_name().to_string(), p.index, named));
}
}
}
}
out.sort();
out
}
#[test]
fn layer_parameters_name_the_same_layers() {
// 905: Compound Blur, Displacement Map and Set Matte name the grey solid R (904 E22-E24);
// 220 / 320 / 321: Layer Control on another layer, on itself, None.
let mut checked = 0;
for name in
["905-fx-24-effects-set-one-comp.aep", "220-fx-layercontrol-to-layer1.aep", "320-fx-layercontrol-self-explicit.aep", "321-fx-layercontrol-none.aep"]
{
let bytes = fixture!(name);
let original = parse(&bytes).expect("parse");
let p = import_bytes(&bytes);
let r = export_all(&p);
assert_eq!(r.report.count(LossCategory::Effects), 0, "{name}: {:?}", r.report.losses);
let exported = parse(&r.bytes).expect("parse export");
let (a, b) = (layer_params(&original), layer_params(&exported));
assert!(!a.is_empty(), "{name}");
assert_eq!(a, b, "{name}");
checked += 1;
}
assert_eq!(checked, 4);
}
/// Sets every property with this match id (in every effect of every layer) to `v`.
fn set_all(p: &mut Project, id: &str, v: Value) -> usize {
fn walk(g: &mut PropGroup, id: &str, v: &Value, n: &mut usize) {
for c in &mut g.children {
match c {
Node::Prop(pr) if pr.match_id == id => {
pr.value = v.clone();
*n += 1;
}
Node::Group(sub) => walk(sub, id, v, n),
_ => {}
}
}
}
let mut n = 0;
let ids: Vec<_> = p.comps().map(|(i, _)| *i).collect();
for cid in ids {
let Some(mut c) = p.comp(cid).cloned() else { continue };
for l in &mut c.layers {
walk(&mut l.props, id, &v, &mut n);
}
if let Some(it) = p.item_mut(cid) {
it.kind = ItemKind::Comp(std::sync::Arc::new(c));
}
}
n
}
fn find_id<'a>(g: &'a PropGroup, id: &str) -> Option<&'a effectcraft_project::Property> {
g.children.iter().find_map(|c| match c {
Node::Prop(pr) if pr.match_id == id => Some(pr),
Node::Group(sub) => find_id(sub, id),
_ => None,
})
}
#[test]
fn an_explicit_none_is_written_as_none() {
// After Effects' None on Compound Blur / Displacement Map / Set Matte (`tdpi` 0) differs from
// the owning layer (fx27 `layer-none`); it is EffectCraft's `NO_LAYER` both ways.
let mut p = import_bytes(&fixture!("905-fx-24-effects-set-one-comp.aep"));
let none = Value::Layer(Some(effectcraft_project::NO_LAYER));
let n: usize = ["blurLayer", "displacementMapLayer", "takeMatteFromLayer"].iter().map(|id| set_all(&mut p, id, none.clone())).sum();
assert_eq!(n, 3);
let r = export_all(&p);
assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses);
let named: Vec<_> = layer_params(&parse(&r.bytes).expect("parse")).into_iter().filter(|x| x.2 != "ADBE Layer Control").map(|x| x.4).collect();
assert_eq!(named, ["None", "None", "None"]);
let q = import_bytes(&r.bytes);
let mut back = 0;
for (_, c) in q.comps() {
for l in &c.layers {
for id in ["blurLayer", "displacementMapLayer", "takeMatteFromLayer"] {
if let Some(pr) = find_id(&l.props, id) {
assert_eq!(pr.value, none, "{id}");
back += 1;
}
}
}
}
assert_eq!(back, 3);
}
#[test]
fn layer_control_on_a_named_layer_needs_no_source_choice() {
// Layer Control has no source choice in After Effects: a companion other than Source (a
// Layer Control made here defaults to Effects & Masks) is no loss.
let mut p = import_bytes(&fixture!("220-fx-layercontrol-to-layer1.aep"));
assert_eq!(set_all(&mut p, "layerSource", Value::Enum(2)), 1);
let r = export_all(&p);
assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses);
}
#[test]
fn unmapped_layer_parameters_keep_their_default() {
// Timewarp's Matte Layer and Warp Layer are not in the table; their default is None (`tdpi`
// 0), which the export keeps: the re-import reports nothing (529-533). Written as the owning
// layer, the re-import reported both as "not imported".
for name in ["529-fx-timewarp-default.aep", "531-fx-timewarp-speed-keyed-50-200.aep"] {
let p = import_bytes(&fixture!(name));
let r = export_all(&p);
let mut q = Project::default();
let none = |_: &str| false;
let back = import(&mut q, &r.bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("re-import");
assert_eq!(back.report.count(effectcraft_aep::import::LossCategory::Effects), 0, "{name}: {:?}", back.report.losses);
}
}
/// The arbitrary data of the first instance of `match_name` in a file.
fn arb(bytes: &[u8], match_name: &str, record: &str) -> Vec<u8> {
let file = parse(bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let comp = view.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
let fx = comp.layers().flat_map(|l| l.effects()).find(|e| e.match_name() == match_name).expect("effect");
fx.arbitrary_data(record).expect("data").to_vec()
}
#[test]
fn curves_and_mesh_warp_are_written_as_after_effects_stores_them() {
// Curves: the points and the tables rebuilt from them are After Effects' bytes (963, 964).
for name in ["963-curves-red-mid-down-on-S.aep", "964-curves-rgb-s-curve-on-S.aep"] {
let bytes = fixture!(name);
let r = export_all(&import_bytes(&bytes));
assert_eq!(r.report.count(LossCategory::Effects), 0, "{name}: {:?}", r.report.losses);
assert_eq!(arb(&r.bytes, "ADBE CurvesCustom", "ADBE CurvesCustom-0001"), arb(&bytes, "ADBE CurvesCustom", "ADBE CurvesCustom-0001"), "{name}");
}
// Mesh Warp (985): the same grid, every coordinate within 1e-5 of the layer.
let bytes = fixture!("985-meshwarp-3x3-vertex-moved-on-S.aep");
let r = export_all(&import_bytes(&bytes));
assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses);
let (a, b) = (arb(&r.bytes, "ADBE MESH WARP", "ADBE MESH WARP-0004"), arb(&bytes, "ADBE MESH WARP", "ADBE MESH WARP-0004"));
let (ma, mb) = (effectcraft_aep::MeshData::parse(&a).expect("written"), effectcraft_aep::MeshData::parse(&b).expect("stored"));
assert_eq!((ma.columns, ma.rows, ma.tail), (mb.columns, mb.rows, mb.tail));
for (x, y) in ma.vertices.iter().zip(&mb.vertices) {
for (p, q) in x.iter().zip(y) {
assert!((p[0] - q[0]).abs() < 1e-5 && (p[1] - q[1]).abs() < 1e-5, "{p:?} vs {q:?}");
}
}
}
#[test]
fn mesh_handles_moved_on_their_own_survive_the_round_trip() {
// 985 imported, then the right handle of vertex (2, 2) moved by 30 px on its own: the importer
// keeps such moves as extra numbers of the vertex (EffectCraft draws a bilinear mesh).
let mut p = import_bytes(&fixture!("985-meshwarp-3x3-vertex-moved-on-S.aep"));
let id: Vec<_> = p.comps().map(|(i, _)| *i).collect();
let mut tokens = Vec::new();
for cid in id {
let Some(mut c) = p.comp(cid).cloned() else { continue };
for l in &mut c.layers {
fn walk(g: &mut PropGroup, tokens: &mut Vec<String>) {
for n in &mut g.children {
match n {
Node::Prop(pr) if pr.match_id == "mesh" => {
if let Value::Str(s) = &pr.value {
let mut t: Vec<String> = s.split(' ').map(str::to_string).collect();
t[10] = format!("{},0,0,30,0,0,0,0,0", t[10]);
*tokens = t.clone();
pr.value = Value::Str(t.join(" "));
}
}
Node::Group(sub) => walk(sub, tokens),
_ => {}
}
}
}
walk(&mut l.props, &mut tokens);
}
if let Some(it) = p.item_mut(cid) {
it.kind = ItemKind::Comp(std::sync::Arc::new(c));
}
}
assert_eq!(tokens.len(), 16);
let r = export_all(&p);
let data = arb(&r.bytes, "ADBE MESH WARP", "ADBE MESH WARP-0004");
let m = effectcraft_aep::MeshData::parse(&data).expect("mesh");
let d = effectcraft_aep::MeshData::default_vertex(3, 3, 2, 2);
let v = m.vertices[10];
// The right handle: default + the vertex's move + 30 px.
assert!((v[2][0] - (d[2][0] + (132.0 + 30.0) / 1920.0)).abs() < 1e-5, "{:?}", v[2]);
assert!((v[1][0] - (d[1][0] + 132.0 / 1920.0)).abs() < 1e-5, "{:?}", v[1]);
// Re-imported, the vertex keeps the handle move.
let q = import_bytes(&r.bytes);
let mut found = false;
for (_, c) in q.comps() {
for l in &c.layers {
if let Some(Value::Str(s)) = find_id(&l.props, "mesh").map(|p| p.value.clone()) {
let t: Vec<&str> = s.split(' ').collect();
let n: Vec<f64> = t[10].split(',').map(|x| x.parse().expect("number")).collect();
assert_eq!(n.len(), 10, "{}", t[10]);
assert!((n[4] - 30.0).abs() < 0.01 && (n[0] - 132.0).abs() < 0.01, "{n:?}");
found = true;
}
}
}
assert!(found);
}