Files
Valentin Klein bb78d55825 After Effects project import (.aep / .aepx); export follows (#2558)
Add After Effects project import with bounded parsing, explicit loss reports, and regression coverage.

Fetch the cleaned external test corpus at commit 97262276f625afdeeb61787e4cbb74b3f17af510 and verify each fixture against the pinned SHA-256 manifest.
2026-11-04 12:06:08 +01:00

253 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 effectcraft_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_eq!(p.is_static_flag_set(), Some(false));
}));
// 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].spatial_value(), Some([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].spatial_value(), Some([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));
assert_eq!(p.is_static_flag_set(), Some(false));
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));
assert!(!flags.all_set());
// 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.is_static_flag_set(), Some(true));
assert_eq!(p.header().and_then(|h| h.get(5).copied()), Some(15));
}));
}