Files
effectcraft/crates/keyframe/src/tests.rs
T

398 lines
18 KiB
Rust

use super::*;
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
#[test]
fn missing_spatial_keys_have_no_tangents_or_length() {
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]))];
for i in [1, usize::MAX] {
assert_eq!(spatial_tangents(&keys, i), ([0.0; 3], [0.0; 3]));
assert_eq!(spatial_segment_length(&keys, i), 0.0);
}
assert_eq!(spatial_tangents(&[], 0), ([0.0; 3], [0.0; 3]));
assert_eq!(spatial_segment_length(&[], usize::MAX), 0.0);
}
#[test]
fn time_reverse_handles_deserialized_tick_extremes() {
// Tick's public serde representation can contain values beyond from_seconds_f64's cap.
for (first, last) in [(i64::MIN, i64::MIN + 10), (i64::MAX - 10, i64::MAX), (i64::MIN, i64::MAX)] {
let mut keys = vec![Keyframe::new(Tick(first), Value::Scalar(1.0)), Keyframe::new(Tick(last), Value::Scalar(2.0))];
time_reverse(&mut keys);
assert_eq!(keys[0].time, Tick(first));
assert_eq!(keys[0].value, Value::Scalar(2.0));
assert_eq!(keys[1].time, Tick(last));
assert_eq!(keys[1].value, Value::Scalar(1.0));
time_reverse(&mut keys);
assert_eq!(keys[0].value, Value::Scalar(1.0));
}
}
#[test]
fn roving_handles_deserialized_tick_extremes() {
let mut keys = vec![
Keyframe::new(Tick(i64::MIN), Value::Vec2([0.0, 0.0])),
Keyframe::new(Tick(10), Value::Vec2([50.0, 0.0])),
Keyframe::new(Tick(i64::MAX), Value::Vec2([100.0, 0.0])),
];
for key in &mut keys {
key.spatial_auto = false;
}
keys[1].roving = true;
assert!(retime_roving(&mut keys, true));
assert_eq!(keys[1].time, Tick::ZERO);
}
#[test]
fn replacing_key_preserves_spatial_metadata() {
let mut key = Keyframe::new(s(1.0), Value::Vec2([20.0, 30.0]));
key.roving = true;
key.spatial_continuous = true;
let mut keys = vec![key];
set_key(&mut keys, Keyframe::new(s(1.0), Value::Vec2([40.0, 50.0])));
assert!(keys[0].roving);
assert!(keys[0].spatial_continuous);
assert_eq!(keys[0].value, Value::Vec2([40.0, 50.0]));
}
#[test]
fn temporal_ease_matches_live_ae_with_overlapping_influences() {
// Original Rotation probe via AEsync 2.0.4, AE 26.3x87, 2026-10-05.
// AE retains each handle's requested influence even when their sum exceeds 100%.
for (from, to, out_speed, out_inf, in_speed, in_inf, samples) in [
(0.0, 100.0, 0.0, 0.8, 0.0, 0.8, [(0.1, 0.59243939036327), (0.25, 4.76438030608764), (0.4, 18.4284394271562), (0.75, 95.2356196939123)]),
(0.0, 100.0, 20.0, 0.8, 80.0, 0.6, [(0.1, 2.25754942621577), (0.25, 6.99841889412931), (0.4, 14.9334636959052), (0.75, 73.8196785378389)]),
(100.0, 0.0, -20.0, 0.6, -80.0, 0.8, [(0.1, 97.6395466334594), (0.25, 91.597048571292), (0.4, 74.5367957474338), (0.75, 22.3789127753126)]),
] {
let mut a = Keyframe::new(s(0.0), Value::Scalar(from));
let mut b = Keyframe::new(s(1.0), Value::Scalar(to));
a.out_interp = Interp::Bezier;
b.in_interp = Interp::Bezier;
a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }];
b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }];
for (time, expected) in samples {
let value = evaluate(&[a.clone(), b.clone()], s(time), false).unwrap().as_f64();
assert!((value - expected).abs() < 1e-7, "{time}: {value} != {expected}");
let normalized = ease_progress(out_speed / (to - from), out_inf, in_speed / (to - from), in_inf, time);
assert!((from + normalized * (to - from) - expected).abs() < 1e-7);
}
}
}
#[test]
fn maximum_influence_resolves_flat_midpoint_time_curve() {
for time in [0.5 - 1e-9, 0.5 - 1e-12, 0.5, 0.5 + 1e-12, 0.5 + 1e-9] {
// At 100%/100%, x = 0.5 + 4*(u - 0.5)^3 exactly.
let u = 0.5 + ((time - 0.5) / 4.0_f64).cbrt();
let expected = bez(0.0, 0.0, 1.0, 1.0, u);
let actual = ease_progress(0.0, 1.0, 0.0, 1.0, time);
assert!((actual - expected).abs() < 1e-10, "{time}: {actual} != {expected}");
}
}
/// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns.
#[test]
fn gradients_coerce_from_plain_json() {
let g = Value::Gradient(Gradient::default());
let plain = serde_json::json!({"colors": [[0, [0, 0.5, 1, 1]], [1, "#ffff00"]], "opacities": [[0, 1], [1, 0.5]]});
let Some(Value::Gradient(a)) = g.coerce_json(&plain) else { panic!("object form") };
assert_eq!(a.colors, vec![(0.0, [0.0, 0.5, 1.0, 1.0]), (1.0, [1.0, 1.0, 0.0, 1.0])]);
assert_eq!(a.opacities, vec![(0.0, 1.0), (1.0, 0.5)]);
let Some(Value::Gradient(b)) = g.coerce_json(&serde_json::json!(["#ff0000", "#00ff00", "#0000ff"])) else { panic!("list form") };
assert_eq!(b.colors.iter().map(|c| c.0).collect::<Vec<_>>(), vec![0.0, 0.5, 1.0]);
assert_eq!(b.opacities.len(), 3);
// The tagged form (what prop.get returns) still round-trips.
assert_eq!(g.coerce_json(&g.to_json()), Some(g.clone()));
assert_eq!(g.coerce_json(&serde_json::json!(5)), None);
}
/// #612: the gradient editor samples a new stop from its own track only. `sample` multiplies
/// in the opacity stops, so feeding its alpha into a colour stop would apply 50% twice (25%).
#[test]
fn gradient_tracks_sample_separately() {
let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 0.8]), (1.0, [0.0, 0.0, 1.0, 0.4])], opacities: vec![(0.0, 0.5), (1.0, 0.5)] };
let c = g.color_at(0.5);
assert!((c[0] - 0.5).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6, "{c:?}");
assert!((c[3] - 0.6).abs() < 1e-6, "the colour track's own alpha, without opacity: {c:?}");
assert!((g.opacity_at(0.5) - 0.5).abs() < 1e-6);
assert!((g.sample(0.5)[3] - 0.3).abs() < 1e-6, "sample still applies opacity once");
// A stop added at 50% from the opacity track keeps 50%: sampled again, it is still 50%.
let mut with_stop = g.clone();
with_stop.opacities.insert(1, (0.5, g.opacity_at(0.5)));
assert!((with_stop.opacity_at(0.5) - 0.5).abs() < 1e-6);
assert!((with_stop.sample(0.5)[3] - 0.3).abs() < 1e-6, "adding a stop doesn't change the look");
// Outside the stops, and with none: the ends hold, and an empty track is opaque white.
assert_eq!(g.color_at(-1.0), [1.0, 0.0, 0.0, 0.8]);
assert_eq!(g.color_at(2.0), [0.0, 0.0, 1.0, 0.4]);
let empty = Gradient { colors: vec![], opacities: vec![] };
assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0));
}
/// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser
/// rejects) and each stop's RGBA come back exactly.
#[test]
fn typed_gradient_round_trips_one_stop_and_rgba() {
let one = Value::Gradient(Gradient { colors: vec![(0.25, [0.1, 0.2, 0.3, 0.7])], opacities: vec![(0.0, 1.0), (0.6, 0.35)] });
let cur = Value::Gradient(Gradient::default());
assert_eq!(cur.coerce_json(&one.to_json()), Some(one.clone()));
assert_eq!(cur.coerce_json(&serde_json::json!({"colors": [[0.25, [0.1, 0.2, 0.3, 0.7]]]})), None, "plain JSON needs two colours");
}
#[test]
fn linear_and_hold() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))];
assert_eq!(evaluate(&keys, s(1.0), false), Some(Value::Scalar(50.0)));
assert_eq!(evaluate(&keys, s(-1.0), false), Some(Value::Scalar(0.0)));
assert_eq!(evaluate(&keys, s(3.0), false), Some(Value::Scalar(100.0)));
let held = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).hold(), Keyframe::new(s(2.0), Value::Scalar(100.0))];
assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0)));
}
#[test]
fn hold_key_keeps_its_incoming_segment_linear() {
// Hold on the second key (in and out, as KeyframeInterpolationType.HOLD sets it) holds
// what comes after it, not the linear motion into it.
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).hold()];
assert_eq!(evaluate(&keys, s(0.5), false), Some(Value::Scalar(25.0)));
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])).hold()];
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((p[0] - 25.0).abs() < 0.01, "{p:?}");
}
#[test]
fn easy_ease_is_symmetric_and_slow_at_ends() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()];
let mid = evaluate(&keys, s(0.5), false).unwrap().as_f64();
assert!((mid - 50.0).abs() < 1e-6, "{mid}");
let early = evaluate(&keys, s(0.1), false).unwrap().as_f64();
assert!(early < 10.0 && early > 0.0, "{early}");
let v0 = velocity(&keys, s(0.01), false)[0];
let vm = velocity(&keys, s(0.5), false)[0];
assert!(v0 < vm);
}
#[test]
fn bezier_with_linear_speed_matches_linear() {
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0));
let mut b = Keyframe::new(s(2.0), Value::Scalar(100.0));
a.out_interp = Interp::Bezier;
b.in_interp = Interp::Bezier;
a.out_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }];
b.in_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }];
let keys = vec![a, b];
for i in 0..=20 {
let t = i as f64 * 0.1;
let v = evaluate(&keys, s(t), false).unwrap().as_f64();
assert!((v - 50.0 * t).abs() < 1e-6, "{t} {v}");
}
}
#[test]
fn spatial_linear_constant_speed() {
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))];
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}");
}
/// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is
/// flat, not a sawtooth from a piecewise-linear arc-length table.
#[test]
fn spatial_curved_path_has_smooth_constant_speed() {
let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]));
let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0]));
a.spatial_auto = false;
b.spatial_auto = false;
a.spatial_out = [50.0, -300.0, 0.0];
b.spatial_in = [80.0, 250.0, 0.0];
let keys = vec![a, b];
let len = spatial_segment_length(&keys, 0);
let speeds: Vec<f64> = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect();
for v in &speeds {
assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0);
}
}
#[test]
fn spatial_auto_bezier_passes_through_keys() {
let keys = vec![
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
Keyframe::new(s(1.0), Value::Vec2([100.0, 100.0])),
Keyframe::new(s(2.0), Value::Vec2([200.0, 0.0])),
];
let p = evaluate(&keys, s(1.0), true).unwrap().as_vec2();
assert_eq!(p, [100.0, 100.0]);
// Curved: the midpoint of the first segment lies off the straight line.
let m = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((m[0] - m[1]).abs() > 1.0, "{m:?}");
}
#[test]
fn straight_auto_bezier_path_is_exact() {
// Auto-Bezier ends now have handles along the line; the chord is still hit exactly.
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([40.0, 50.0])), Keyframe::new(s(2.0), Value::Vec2([120.0, 50.0]))];
assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0]));
}
/// #880: a motion path whose arc length overflows (huge coordinates, as the Position field
/// accepts) or is NaN evaluates without panicking: scrubbing over it crashed the app.
#[test]
fn overflowing_or_nan_motion_paths_evaluate() {
let paths = [
[[-1.7e308, 0.0], [1.7e308, 0.0], [-1.7e308, 5.0]],
[[0.0, 0.0], [f64::NAN, 10.0], [20.0, 0.0]],
[[f64::INFINITY, 0.0], [0.0, f64::NEG_INFINITY], [20.0, 0.0]],
];
for path in paths {
let mut keys: Vec<Keyframe> = path.iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64 * 2.0), Value::Vec2(v))).collect();
for eased in [false, true] {
if eased {
keys = keys.into_iter().map(Keyframe::eased).collect();
}
for f in 0..=40 {
let t = s(f as f64 * 0.1);
assert!(evaluate(&keys, t, true).is_some());
let _ = speed(&keys, t, true);
}
}
}
}
#[test]
fn spatial_auto_bezier_tangents_follow_after_effects() {
// Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each
// auto-Bezier key's out tangent is (next - previous) / 6 and its in tangent the mirror,
// whatever the key times. End keys use their one neighbour: (neighbour - key) / 6.
let p = [[40.0, 300.0, 0.0], [260.0, 80.0, 20.0], [700.0, 420.0, -40.0], [1100.0, 150.0, 0.0]];
let mut keys: Vec<Keyframe> = [0.0, 0.2, 1.5, 1.8].iter().zip(p).map(|(&t, v)| Keyframe::new(s(t), Value::Vec3(v))).collect();
let sixth = |a: [f64; 3], b: [f64; 3]| [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0, (b[2] - a[2]) / 6.0];
let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]];
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9);
let want = [sixth(p[0], p[1]), sixth(p[0], p[2]), sixth(p[1], p[3]), sixth(p[2], p[3])];
for (i, out) in want.into_iter().enumerate() {
let (tin, tout) = spatial_tangents(&keys, i);
assert!(close(tout, out) && close(tin, neg(out)), "key {i}: {tin:?} {tout:?}");
}
// Moving a key in time leaves every tangent as it was.
keys[1].time = s(1.2);
assert!(close(spatial_tangents(&keys, 1).1, sixth(p[0], p[2])));
// A key between two coincident neighbours, and a lone key, have none.
let still = [[5.0, 5.0], [9.0, 1.0], [5.0, 5.0]].iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64), Value::Vec2(v))).collect::<Vec<_>>();
assert_eq!(spatial_tangents(&still, 1), ([0.0; 3], [0.0; 3]));
assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3]));
}
#[test]
fn auto_spatial_holds_between_identical_keys() {
let path = [(0.0, [0.0, 540.0]), (0.6, [400.0, 540.0]), (4.4, [400.0, 540.0]), (5.0, [0.0, 540.0])];
for eased in [false, true] {
let keys: Vec<Keyframe> = path
.iter()
.map(|&(t, v)| {
let k = Keyframe::new(s(t), Value::Vec2(v));
if eased { k.eased() } else { k }
})
.collect();
assert_eq!(spatial_tangents(&keys, 1).1, [0.0; 3]);
assert_eq!(spatial_tangents(&keys, 2).0, [0.0; 3]);
for t in [0.7, 1.0, 2.5, 4.0, 4.3] {
let p = evaluate(&keys, s(t), true).unwrap().as_vec3();
assert!((p[0] - 400.0).abs() < 1e-6 && (p[1] - 540.0).abs() < 1e-6, "eased={eased} t={t} p={p:?}");
}
}
}
#[test]
fn set_key_replaces_same_time() {
let mut keys = vec![];
set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(1.0)).eased());
set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(0.0)));
set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(5.0)));
assert_eq!(keys.len(), 2);
assert_eq!(keys[1].value, Value::Scalar(5.0));
assert_eq!(keys[1].in_interp, Interp::Bezier);
}
#[test]
fn colors_and_paths_interpolate() {
let keys = vec![Keyframe::new(s(0.0), Value::Color([0.0, 0.0, 0.0, 1.0])), Keyframe::new(s(1.0), Value::Color([1.0, 0.5, 0.0, 1.0]))];
assert_eq!(evaluate(&keys, s(0.5), false).unwrap().as_color(), [0.5, 0.25, 0.0, 1.0]);
let a = ShapePath::rect([0.0, 0.0], 10.0, 10.0);
let b = ShapePath::rect([0.0, 0.0], 20.0, 20.0);
let keys = vec![Keyframe::new(s(0.0), Value::Path(a)), Keyframe::new(s(1.0), Value::Path(b))];
let v = evaluate(&keys, s(0.5), false).unwrap();
assert_eq!(v.as_path().unwrap().vertices[0], [-7.5, -7.5]);
}
#[test]
fn roving_keys_get_constant_speed_times() {
let mut keys = vec![
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
Keyframe::new(s(0.2), Value::Vec2([300.0, 0.0])),
Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])),
];
for k in &mut keys {
k.spatial_auto = false;
}
keys[1].roving = true;
assert!(retime_roving(&mut keys, true));
// 300 of 400 px → 75 % of the 2 s span.
assert!((keys[1].time.seconds() - 1.5).abs() < 1e-6, "{}", keys[1].time.seconds());
let v0 = speed(&keys, s(0.5), true);
let v1 = speed(&keys, s(1.8), true);
assert!((v0 - 200.0).abs() < 1.0 && (v1 - 200.0).abs() < 1.0, "{v0} {v1}");
// First/last keys can't rove.
keys[0].roving = true;
retime_roving(&mut keys, true);
assert!(!keys[0].roving);
}
#[test]
fn roving_keys_never_share_a_time_with_a_fixed_key() {
// Zero-length last segment (issue #532) and zero-length first segment.
for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] {
let mut keys: Vec<Keyframe> = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect();
for k in &mut keys {
k.spatial_auto = false;
}
keys[1].roving = true;
keys[2].roving = true;
retime_roving(&mut keys, true);
assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::<Vec<_>>());
}
}
#[test]
fn side_ease_reports_linear_and_eased_sides() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()];
let out = side_ease(&keys, 0, 0, false, true).unwrap();
assert!((out.speed - 50.0).abs() < 1e-9);
let inn = side_ease(&keys, 1, 0, false, false).unwrap();
assert_eq!(inn, Ease::EASY);
assert!(side_ease(&keys, 0, 0, false, false).is_none());
}
#[test]
fn time_reverse_mirrors_keys() {
let mut keys =
vec![Keyframe::new(s(1.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(5.0)), Keyframe::new(s(4.0), Value::Scalar(10.0))];
keys[0].out_interp = Interp::Hold;
time_reverse(&mut keys);
assert_eq!(keys.iter().map(|k| k.time.seconds().round() as i64).collect::<Vec<_>>(), vec![1, 3, 4]);
assert_eq!(keys[0].value, Value::Scalar(10.0));
assert_eq!(keys[2].in_interp, Interp::Hold);
}
proptest::proptest! {
#[test]
fn eased_values_stay_in_range(t in 0.0f64..1.0, i0 in 0.01f64..1.0, i1 in 0.01f64..1.0) {
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0)).eased();
let mut b = Keyframe::new(s(1.0), Value::Scalar(10.0)).eased();
a.out_ease = vec![Ease { speed: 0.0, influence: i0 }];
b.in_ease = vec![Ease { speed: 0.0, influence: i1 }];
let v = evaluate(&[a, b], s(t), false).unwrap().as_f64();
proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v));
}
}