Files
effectcraft/crates/aep/tests/it/container/keyframes.rs
T
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

231 lines
13 KiB
Rust

//! Keyframe interpolation sides and ease, the record layouts per template, the anchor point
//! normalisation and the disabled-expression flag, checked against the values the corpus was made
//! with (its MANIFEST, batch 2). The solid `L` is 1920x1080 in a 1920x1080 composition.
use crate::container::helpers::{assert_near, base_layer, first_comp, layer_shown_as, open};
use effectcraft_aep::{Interpolation, KeyEase, KeyframeKind, Project, PropNode, Property, ValueSource};
fn prop_of<R>(name: &str, path: &[&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(path) else { panic!("{name}: no {path:?}") };
f(p)
})
}
const POSITION: &[&str] = &["ADBE Transform Group", "ADBE Position"];
const OPACITY: &[&str] = &["ADBE Transform Group", "ADBE Opacity"];
fn ease(in_speed: f64, in_influence: f64, out_speed: f64, out_influence: f64) -> KeyEase {
KeyEase { in_speed, in_influence, out_speed, out_influence }
}
#[test]
fn in_and_out_interpolation_are_separate_bytes() {
// 202: Position keys at 0 s [0,0,0] and 1 s [100,0,0]; key 1 inType LINEAR, outType HOLD (record bytes 01 03).
skip!(prop_of("202-key-out-hold-in-linear.aep", POSITION, |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
assert_eq!(keys.len(), 2);
assert_eq!(&keys[0].raw()[4..6], [1, 3]);
assert_eq!((keys[0].interpolation_in(), keys[0].interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Hold)));
assert_eq!((keys[1].interpolation_in(), keys[1].interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear)));
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 1.0]);
assert_eq!(keys[1].spatial_value(), Some([100.0, 0.0, 0.0]));
}));
// 203: key 1 inType HOLD, outType LINEAR (bytes 03 01): byte 4 is the incoming side.
skip!(prop_of("203-key-in-hold-out-linear.aep", POSITION, |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
assert_eq!(&keys[0].raw()[4..6], [3, 1]);
assert_eq!((keys[0].interpolation_in(), keys[0].interpolation_out()), (Some(Interpolation::Hold), Some(Interpolation::Linear)));
assert_eq!((keys[1].interpolation_in(), keys[1].interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear)));
}));
}
#[test]
fn position_ease_slots_in_speed_influence_out_speed_influence() {
// 204: key 1 inSpeed 5 / inInfluence 20, outSpeed 50 / outInfluence 80; key 2 in 7 / 60, out 9 / 40.
// After Effects reads key 1's in speed and key 2's out speed back as 0; the file keeps 5 and 9.
skip!(prop_of("204-key-ease-asymmetric.aep", POSITION, |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
assert_eq!(keys[0].ease(), Some(ease(5.0, 0.2, 50.0, 0.8)));
assert_eq!(keys[1].ease(), Some(ease(7.0, 0.6, 9.0, 0.4)));
for k in &keys {
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Bezier), Some(Interpolation::Bezier)));
assert_eq!(k.kind(), KeyframeKind::Spatial { components: 3 });
}
// The spatial record gives the same four slots.
assert_eq!(keys[0].spatial().unwrap().ease, [5.0, 0.2, 50.0, 0.8]);
// The arc length of the segment to the next key (100 px) is on key 1, none on the last key.
assert_eq!(keys.iter().map(|k| k.spatial().unwrap().segment_length).collect::<Vec<_>>(), [100.0, 0.0]);
}));
}
#[test]
fn opacity_ease_speed_is_in_stored_units_per_second() {
// 205: Opacity 0 at 0 s, 100 at 2 s; key 1 in 5 / 20 %, out 20 / 80 %; key 2 in 30 / 60 %, out 9 / 40 %.
// The speed is in stored value units (fractions), 100 times smaller than the UI value; the influence is a fraction.
skip!(prop_of("205-key-opacity-ease-asymmetric.aep", OPACITY, |p| {
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
assert!(keys.iter().all(|k| k.kind() == KeyframeKind::Plain { components: 1 } && k.raw().len() == 48));
assert_eq!(keys.iter().map(|k| k.scalar_value().unwrap()).collect::<Vec<_>>(), [0.0, 1.0]);
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 2.0]);
let e0 = keys[0].ease().unwrap();
assert_near(&[e0.in_speed, e0.in_influence, e0.out_speed, e0.out_influence], &[0.05, 0.2, 0.2, 0.8], 1e-12);
let e1 = keys[1].ease().unwrap();
assert_near(&[e1.in_speed, e1.in_influence, e1.out_speed, e1.out_influence], &[0.3, 0.6, 0.09, 0.4], 1e-12);
assert_eq!(keys[0].plain_ease_slots().unwrap().len(), 4);
for k in &keys {
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Bezier), Some(Interpolation::Bezier)));
}
}));
}
#[test]
fn scale_keys_use_the_plain_template_with_three_components() {
// 206: Scale keys at 0 s [100,100,100] and 1 s [200,150,100], linear: 128-byte plain records.
skip!(prop_of("206-key-scale-2key-linear.aep", &["ADBE Transform Group", "ADBE Scale"], |p| {
assert_eq!(p.dims(), Some(3));
let keys: Vec<_> = p.keys().unwrap().keyframes().collect();
for k in &keys {
assert_eq!(k.raw().len(), 128);
// A 128-byte record is not always spatial: the header's template flag decides.
assert_eq!(k.kind(), KeyframeKind::Plain { components: 3 });
assert_eq!(k.spatial(), None);
assert_eq!(k.plain_ease_slots(), Some(vec![0.0; 12]));
assert_eq!(k.ease(), None);
assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear)));
}
assert_eq!(keys.iter().map(|k| k.value().unwrap()).collect::<Vec<_>>(), [vec![1.0, 1.0, 1.0], vec![2.0, 1.5, 1.0]]);
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 1.0]);
assert_eq!(p.first_key_value(), Some(vec![1.0, 1.0, 1.0]));
}));
// The transform reports the first key as percent (before the template flag was used it read the wrong bytes).
let bytes = fixture!("206-key-scale-2key-linear.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
let t = layer.transform(1920, 1080).unwrap();
assert_eq!((t.scale_percent.source, t.scale_percent.value), (ValueSource::Keyframed, [100.0, 100.0, 100.0]));
}
#[test]
fn two_dimensional_mask_feather_keys() {
// 208: Mask Feather keys at 0 s [0,0] and 1 s [40,20], linear: 88-byte plain records with two components.
let bytes = fixture!("208-key-mask-feather-2key-linear.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
let Some(PropNode::Group(parade)) = layer.properties().unwrap().get("ADBE Mask Parade") else { panic!("no mask parade") };
let Some(PropNode::Group(atom)) = parade.get("ADBE Mask Atom") else { panic!("no mask atom") };
let Some(PropNode::Property(feather)) = atom.get("ADBE Mask Feather") else { panic!("no feather") };
let keys: Vec<_> = feather.keys().unwrap().keyframes().collect();
assert!(keys.iter().all(|k| k.raw().len() == 88 && k.kind() == KeyframeKind::Plain { components: 2 }));
assert_eq!(keys.iter().map(|k| k.value().unwrap()).collect::<Vec<_>>(), [vec![0.0, 0.0], vec![40.0, 20.0]]);
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 1.0]);
assert_eq!(keys[0].plain_ease_slots(), Some(vec![0.0; 8]));
let mask = &layer.masks()[0];
assert!(mask.feather.source == ValueSource::Keyframed && mask.feather.value == [0.0, 0.0]);
}
#[test]
fn colour_keys_use_the_spatial_template_with_four_components() {
// 210: Fill colour keys at 0 s [1,0,0] and 1 s [0,0.5,1], linear: 152-byte records, ARGB doubles 0..255 at byte 56.
let bytes = fixture!("210-key-fill-color-2key-linear.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
let effects = layer.effects();
assert_eq!(effects.len(), 1);
let params = effects[0].params();
let color = params.iter().find(|p| p.name == "Color").unwrap();
let keys: Vec<_> = color.stored().unwrap().keys().unwrap().keyframes().collect();
assert_eq!(keys.len(), 2);
for k in &keys {
assert_eq!(k.raw().len(), 152);
assert_eq!(k.kind(), KeyframeKind::Spatial { components: 4 });
assert_eq!(k.flags(), Some(0x0001));
let s = k.spatial().unwrap();
assert_eq!(s.in_tangent, [0.0; 4]);
assert_eq!(s.out_tangent, [0.0; 4]);
}
assert_eq!(keys[0].value(), Some(vec![255.0, 255.0, 0.0, 0.0]));
assert_eq!(keys[1].value(), Some(vec![255.0, 0.0, 127.5, 255.0]));
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 1.0]);
// A keyframed parameter has no single stored value.
assert_eq!(color.stored_value(), None);
}
#[test]
fn keyed_slider_uses_48_byte_records() {
// 221: Slider Control, Slider keyed 10 at 0 s and 90 at 2 s (linear).
let bytes = fixture!("221-fx-slider-2key.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
let params = project.effect_params(&layer.effects()[0]);
let slider = params.iter().find(|p| p.name == "Slider").unwrap();
let stored = slider.stored().unwrap();
assert!(stored.is_animated());
assert_eq!(stored.static_value(), None);
let keys: Vec<_> = stored.keys().unwrap().keyframes().collect();
assert!(keys.iter().all(|k| k.raw().len() == 48));
assert_eq!(keys.iter().filter_map(|k| k.scalar_value()).collect::<Vec<_>>(), [10.0, 90.0]);
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [0.0, 2.0]);
}
#[test]
fn anchor_is_a_fraction_of_the_layer_source_size() {
// 211: a new 100x100 solid in the 1920x1080 composition, Anchor Point [50,50,0] -> [10,20,0], stored [0.1, 0.2, 0].
// 212: a 400x300 solid, Anchor Point [100,50,0], stored [0.25, 1/6, 0]. The composition size is not the divisor.
for (name, solid, size, fraction, px) in [
("211-anchor-100x100-solid-10-20.aep", "S100", (100, 100), [0.1, 0.2, 0.0], [10.0, 20.0, 0.0]),
("212-anchor-400x300-solid-100-50.aep", "S400", (400, 300), [0.25, 1.0 / 6.0, 0.0], [100.0, 50.0, 0.0]),
] {
let bytes = fixture!(name);
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
let layer = layer_shown_as(&project, &comp, solid);
assert_eq!(project.layer_source_size(&layer), Some(size), "{name}");
let t = layer.transform(1920, 1080).unwrap();
assert_eq!(t.anchor_fraction.source, ValueSource::Stored);
assert_near(&t.anchor_fraction.value, &fraction, 1e-12);
let anchor = project.layer_anchor_px(&layer).unwrap();
assert_eq!(anchor.source, ValueSource::Stored);
assert_near(&anchor.value, &px, 1e-9);
// Position stays at the default, the composition centre.
assert_eq!((t.position.source, t.position.value), (ValueSource::Default, [960.0, 540.0, 0.0]));
// L is unchanged: the anchor is its centre.
let l = base_layer(&comp);
let a = project.layer_anchor_px(&l).unwrap();
assert_eq!((a.source, a.value), (ValueSource::Default, [960.0, 540.0, 0.0]));
}
}
fn expression_state(name: &str) -> Option<(Option<String>, Option<bool>, bool, bool)> {
Some({
prop_of(name, OPACITY, |p| {
let f = p.expression_flags().unwrap();
// Byte 120 marks the expression (bytes 76 and 77 are not always set with it).
(p.expression(), p.expression_enabled(), f.byte_120 == 1, f.disabled())
})?
})
}
#[test]
fn disabled_expression_keeps_its_text() {
// 060: Opacity wiggle(1,10), enabled; 213: the same expression after expression.disable_all.
assert_eq!(skip!(expression_state("060-expr-opacity-wiggle.aep")), (Some("wiggle(1,10)".to_string()), Some(true), true, false));
assert_eq!(skip!(expression_state("213-expr-opacity-wiggle-disabled.aep")), (Some("wiggle(1,10)".to_string()), Some(false), true, true));
// Only byte 119 differs between the two property headers.
let header = |name: &str| prop_of(name, OPACITY, |p| p.header().unwrap().to_vec());
let (enabled, disabled) = (skip!(header("060-expr-opacity-wiggle.aep")), skip!(header("213-expr-opacity-wiggle-disabled.aep")));
let differing: Vec<_> = (0..enabled.len()).filter(|i| enabled[*i] != disabled[*i]).collect();
assert_eq!(differing, [119]);
// A property without an expression has no enabled state.
assert_eq!(skip!(prop_of("058-key-opacity-2key.aep", OPACITY, |p| p.expression_enabled())), None);
}