Files
Dylan M. Taylorandvangel76 4a7024cd69 craft-aep: flat read-only tree index and the Pard decoder (ports of effectcraft#3302 and #3319) (#948)
* craft-aep: a flat read-only tree index, borrowed text documents, fewer allocations

The container part of storytold/effectcraft#3302:

- `TreeIndex`: the chunk tree as one flat array in depth-first order,
  borrowed from the input, for readers that only look. Same chunks counted
  against the same limits and the same errors as `parse`. An `.aepx` is
  indexed by `index_aepx` into one buffer. The typed view reads through
  either tree (`Node`, `Root`, `Project::from_index`).
- Chunk parser: each list of siblings is counted, then reserved once.
  `.aepx`: hex decoded into an output sized once, attribute values without
  an entity borrowed. `write` reserves the exact output size.
- Text documents are parsed into a tree that borrows from the body; the
  style and paragraph defaults are built once.
- Layer markers and Source Text are read where they are stored instead of
  searched for.
- Container accessors that only tests used are removed.

What the importer and exporter in EffectCraft reach across the crate
boundary is public here: `Root`, `Draft`, `AepxDrafts`, `index_aepx`,
`TreeIndex::from_drafts`, `Project::of`, `ProjectSettings::of`,
`Node::marker_list`, `Root::file_version` and `saved_by_version`,
`StyleRun::SAMPLED`. `TimeBase::NTSC` holds the rates as plain
numerator and denominator instead of an EffectCraft type.

* craft-aep: one decoder for an effect parameter definition (`Pard`)

The container part of storytold/effectcraft#3319: the fields of a `pard`
record (type code, name, default, valid range, slider range, popup item
count) are read by one type, `Pard`, which `EffectParam` now reads
through. It is public so that an exporter's tests can read back what
they encode instead of keeping a second decoder.

The rest of that PR (import and export bounds, loss reports) is in
EffectCraft's importer and exporter.

---------

Co-authored-by: vangel76 <vangel1976@googlemail.com>
2026-11-11 19:01:12 -05:00

250 lines
12 KiB
Rust

//! Static transform values with their units, the "absent = default" rule, keyframe records and
//! expressions, checked against the values the corpus was made with (its MANIFEST).
use crate::container::helpers::{base_layer, first_comp, open};
use craft_aep::{Interpolation, KeyEase, KeyframeKind, Project, PropNode, Property, Transform, ValueSource};
/// The transform of the solid `L` (comp-size defaults) in a fixture.
fn transform(name: &str) -> Option<(Transform, (u32, u32))> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
let layer = base_layer(&comp);
let ch = comp.header().unwrap();
let t = layer.transform(u32::from(ch.width), u32::from(ch.height)).unwrap();
(t, project.layer_source_size(&layer).unwrap())
})
}
fn approx(a: [f64; 3], b: [f64; 3]) {
for (x, y) in a.iter().zip(b) {
assert!((x - y).abs() < 1e-9, "{a:?} != {b:?}");
}
}
#[test]
fn absent_properties_are_the_defaults() {
let (t, size) = skip!(transform("000-base.aep"));
assert_eq!(size, (1920, 1080));
// The base stores none of the five: Position is the comp centre, Anchor the layer centre.
assert_eq!(t.position.value, [960.0, 540.0, 0.0]);
assert_eq!(t.anchor_fraction.value, [0.5, 0.5, 0.0]);
assert_eq!(t.anchor_px(1920.0, 1080.0), [960.0, 540.0, 0.0]);
assert_eq!(t.scale_percent.value, [100.0, 100.0, 100.0]);
assert_eq!(t.rotation_degrees.value, 0.0);
assert_eq!(t.opacity_percent.value, 100.0);
for source in [t.position.source, t.anchor_fraction.source, t.scale_percent.source, t.rotation_degrees.source, t.opacity_percent.source] {
assert_eq!(source, ValueSource::Default);
}
}
#[test]
fn one_static_value_per_case() {
// 040 Position [960,540,0] -> [100,200,0].
let (t, _) = skip!(transform("040-static-position-100-200.aep"));
assert_eq!(t.position.value, [100.0, 200.0, 0.0]);
assert_eq!(t.position.source, ValueSource::Stored);
assert_eq!(t.scale_percent.value, [100.0, 100.0, 100.0]);
assert_eq!(t.scale_percent.source, ValueSource::Default);
// 041 Scale [100,100,100] -> [50,50,100]: stored as fractions.
let (t, _) = skip!(transform("041-static-scale-50.aep"));
assert_eq!(t.scale_percent.value, [50.0, 50.0, 100.0]);
assert_eq!(t.scale_percent.source, ValueSource::Stored);
assert_eq!(t.position.value, [960.0, 540.0, 0.0]);
// 042 Rotation 0 -> 45 degrees.
let (t, _) = skip!(transform("042-static-rotation-45.aep"));
assert_eq!(t.rotation_degrees.value, 45.0);
assert_eq!(t.rotation_degrees.source, ValueSource::Stored);
// 043 Opacity 100 -> 25 percent.
let (t, _) = skip!(transform("043-static-opacity-25.aep"));
assert_eq!(t.opacity_percent.value, 25.0);
assert_eq!(t.opacity_percent.source, ValueSource::Stored);
// 044 Anchor Point [960,540,0] -> [10,20,0]: a fraction of the layer size, 10/1920 and 20/1080.
let (t, (w, h)) = skip!(transform("044-static-anchor-10-20.aep"));
assert_eq!(t.anchor_fraction.source, ValueSource::Stored);
approx(t.anchor_fraction.value, [10.0 / 1920.0, 20.0 / 1080.0, 0.0]);
approx(t.anchor_px(f64::from(w), f64::from(h)), [10.0, 20.0, 0.0]);
}
#[test]
fn a_composition_resize_materialises_the_old_centre() {
// 030: the comp becomes 1280x720 and L (1920x1080) is not moved: its Position, which was
// "absent = centre" in the base, is now stored as [960,540,0] (the new centre would be
// [640,360]); the layer size is unchanged.
let (t, size) = skip!(transform("030-comp-size-1280x720.aep"));
assert_eq!(size, (1920, 1080));
assert_eq!(t.position.value, [960.0, 540.0, 0.0]);
assert_eq!(t.position.source, ValueSource::Stored);
assert_eq!(t.anchor_fraction.source, ValueSource::Default);
}
// ------------------------------------------------------------------------------------ keyframes
/// The Position property of `L` in a fixture, handed to `f`.
fn with_position<R>(name: &str, f: impl FnOnce(Property<'_, '_>) -> R) -> Option<R> {
with_transform_property(name, "ADBE Position", f)
}
fn with_transform_property<R>(name: &str, prop: &str, f: impl FnOnce(Property<'_, '_>) -> R) -> Option<R> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
let Some(PropNode::Property(p)) = layer.properties().unwrap().find(&["ADBE Transform Group", prop]) else { panic!("{name}: no {prop}") };
f(p)
})
}
#[test]
fn linear_position_keys() {
skip!(with_position("055-key-position-3key-inserted-t2.5.aep", |p| {
assert_eq!(p.time_scale(), Some(25600));
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
assert_eq!(keys.len(), 3);
// Keys added through the scripting interface are linear in and out, uncoloured, not roving.
for k in &keys {
assert_eq!(k.kind(), KeyframeKind::Spatial { components: 3 });
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear)));
assert_eq!((k.label(), k.is_roving()), (Some(0), Some(false)));
}
let secs: Vec<_> = keys.iter().map(|k| k.seconds(25600).unwrap()).collect();
assert_eq!(secs, [0.0, 1.0, 2.5]);
let values: Vec<_> = keys.iter().map(|k| k.spatial().unwrap().value).collect();
assert_eq!(values, [[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [200.0, 100.0, 0.0]]);
assert_eq!(p.first_key_value(), Some(vec![0.0, 0.0, 0.0]));
assert!(p.is_animated());
}));
// The transform of the layer reports the animated Position as keyframed (value at the first key).
let (t, _) = skip!(transform("055-key-position-3key-inserted-t2.5.aep"));
assert_eq!(t.position.source, ValueSource::Keyframed);
assert_eq!(t.position.value, [0.0, 0.0, 0.0]);
}
#[test]
fn ease_preset_sets_bezier_and_influence() {
// 052: both keys use the ease preset: bezier in and out, speed 0, influence 0.33 (33 %).
skip!(with_position("052-key-position-2key-ease.aep", |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
assert_eq!(keys.len(), 2);
for k in &keys {
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Bezier), Some(Interpolation::Bezier)));
let s = k.spatial().unwrap();
// The speeds sit at slots 0 and 2, the influences at slots 1 and 3; which pair is "in"
// is not decided, so the slots are exposed as stored.
assert_eq!((s.ease[0], s.ease[2]), (0.0, 0.0));
assert_eq!((s.ease[1], s.ease[3]), (0.33, 0.33));
}
let secs: Vec<_> = keys.iter().map(|k| k.seconds(25600).unwrap()).collect();
assert_eq!(secs, [0.0, 1.0]);
}));
}
#[test]
fn hold_keyframe() {
// 053: key 1 is a hold keyframe on both sides (bytes 3, 3), key 2 stays linear; the values are unchanged.
skip!(with_position("053-key-position-2key-hold-key1.aep", |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
assert_eq!((keys[0].interpolation_in(), keys[0].interpolation_out()), (Some(Interpolation::Hold), Some(Interpolation::Hold)));
assert_eq!((keys[1].interpolation_in(), keys[1].interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear)));
assert_eq!(keys[0].spatial().unwrap().value, [0.0, 0.0, 0.0]);
assert_eq!(keys[1].spatial().unwrap().value, [100.0, 0.0, 0.0]);
}));
}
#[test]
fn spatial_tangents() {
// 054: key 1 spatial out tangent [30,60,0], key 2 spatial in tangent [-30,-60,0]; the
// auto-bezier / continuous switches were turned off together (the flag word becomes 0).
skip!(with_position("054-key-position-2key-spatial-bezier.aep", |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().map(|k| k.spatial().unwrap()).collect();
assert_eq!(keys[0].out_tangent, [30.0, 60.0, 0.0]);
assert_eq!(keys[1].in_tangent, [-30.0, -60.0, 0.0]);
assert_eq!(keys[0].in_tangent, [0.0, 0.0, 0.0]);
assert_eq!(keys[1].out_tangent, [0.0, 0.0, 0.0]);
assert_eq!((keys[0].auto_bezier_flag, keys[1].auto_bezier_flag), (0, 0));
}));
}
#[test]
fn roving_and_label_flags() {
// 056: key 2 roving (AE moved it from 1 s to 26509/25600 s, the value is kept).
skip!(with_position("056-key-position-3key-roving-key2.aep", |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
let roving: Vec<_> = keys.iter().map(|k| k.is_roving().unwrap()).collect();
assert_eq!(roving, [false, true, false]);
assert_eq!(keys[1].time_units(), Some(26509));
assert_eq!(keys[1].seconds(25600), Some(1.0355078125));
assert_eq!(keys[1].value(), Some(vec![100.0, 0.0, 0.0]));
assert_eq!(keys[1].flags(), Some(0x0027));
assert_eq!(keys[1].label(), Some(0));
}));
// 057: key 2 gets keyframe label 9; flags 0x0007 become 0x0247.
skip!(with_position("057-key-position-3key-label9-key2.aep", |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
let labels: Vec<_> = keys.iter().map(|k| k.label().unwrap()).collect();
assert_eq!(labels, [0, 9, 0]);
assert_eq!(keys[1].flags(), Some(0x0247));
assert_eq!(keys[0].flags(), Some(0x0007));
assert_eq!(keys[1].is_roving(), Some(false));
assert_eq!(keys[1].value(), Some(vec![100.0, 0.0, 0.0]));
}));
}
#[test]
fn scalar_opacity_keys() {
// 058: Opacity 0 % at 0 s, 100 % at 2 s, linear: 48-byte records, the value is a fraction.
skip!(with_transform_property("058-key-opacity-2key.aep", "ADBE Opacity", |p| {
assert!(p.is_animated());
assert_eq!(p.dims(), Some(1));
assert_eq!(p.time_scale(), Some(25600));
let list = p.keys().unwrap();
assert_eq!(list.header.record_size, 48);
let keys: Vec<_> = list.keyframes().collect();
assert_eq!(keys.len(), 2);
assert!(keys.iter().all(|k| k.kind() == KeyframeKind::Plain { components: 1 }));
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 2.0]);
assert_eq!(keys.iter().map(|k| k.scalar_value().unwrap()).collect::<Vec<_>>(), [0.0, 1.0]);
for k in &keys {
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear)));
assert_eq!(k.ease(), Some(KeyEase { in_speed: 0.0, in_influence: 0.0, out_speed: 0.0, out_influence: 0.0 }));
assert_eq!(k.flags(), Some(0));
}
assert_eq!(keys[0].spatial(), None);
}));
let (t, _) = skip!(transform("058-key-opacity-2key.aep"));
assert_eq!(t.opacity_percent.source, ValueSource::Keyframed);
assert_eq!(t.opacity_percent.value, 0.0);
}
// ---------------------------------------------------------------------------------- expressions
#[test]
fn expression_text_and_flag_bytes() {
// 060: Opacity expression `wiggle(1,10)`; the static value 1.0 is kept.
skip!(with_transform_property("060-expr-opacity-wiggle.aep", "ADBE Opacity", |p| {
assert_eq!(p.expression().as_deref(), Some("wiggle(1,10)"));
let flags = p.expression_flags().unwrap();
// Byte 120 marks the expression. Bytes 76 and 77 were 1 too in the single-case file and
// are 0 in the combined one, so they do not mark it.
assert_eq!(flags.byte_120, 1);
assert_eq!(p.static_value(), Some(vec![1.0]));
assert!(!p.is_animated());
}));
// A property without an expression has none of the three bytes set.
skip!(with_position("040-static-position-100-200.aep", |p| {
assert_eq!(p.expression(), None);
let flags = p.expression_flags().unwrap();
assert_eq!((flags.byte_76, flags.byte_77, flags.byte_120), (0, 0, 0));
// A written static property: tdsb 1, byte 5 of tdb4 has bit 0 set (15 for Position).
assert_eq!(p.flags(), Some(1));
assert_eq!(p.header().and_then(|h| h.get(5).copied()), Some(15));
}));
}