Files
2026-11-04 19:47:19 -05: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);
}