Merge pull request #1334

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:33 -04:00
3 changed files with 370 additions and 33 deletions
+291 -2
View File
@@ -180,7 +180,8 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension"));
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}");
// In-Out (the default) on every key: each pair gets the whole curve, as before.
assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}");
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset"));
assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset");
@@ -203,10 +204,298 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
s.execute("edit.deselectAll", json!({})).unwrap();
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Linear"));
assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property");
assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it");
assert_eq!(s.history.undo.len(), steps);
}
/// Select only the keys at `keys` (property path, time in seconds).
fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) {
s.execute("edit.deselectAll", json!({})).unwrap();
let keys: Vec<Value> = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
}
/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply.
fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) {
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
set(s, win, name, json!(v.to_string()));
}
set(s, win, "mode", json!(mode));
click(s, win, "apply");
}
fn eased(e: Ease, speed: f64, influence: f64) -> bool {
close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9)
}
/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both,
/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its
/// neighbours, key times, values or the selection; one undo step restores it.
#[test]
fn one_selected_keyframe_eases_its_in_out_or_both_sides() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
let win = open_panel(&mut s);
// In-Out is the default.
assert_eq!(control(&mut s, win, "mode")["selection"], json!([2]));
let before = prop(&s, l, "transform/opacity");
for mode in ["In", "Out", "In-Out"] {
select_keys(&mut s, l, &[("transform/opacity", 1.0)]);
let selected = s.state.selected_keys.clone();
let steps = s.history.undo.len();
apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]);
assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve"));
assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step");
assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays");
let op = prop(&s, l, "transform/opacity");
assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change");
assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}");
let k = &op.keys[1];
let (ins, outs) = (mode != "Out", mode != "In");
assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}");
assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}");
if ins {
assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease);
} else {
assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly");
}
if outs {
assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease);
} else {
assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly");
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone");
}
}
/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes
/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several
/// properties, each ease on their own.
#[test]
fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]);
let win = open_panel(&mut s);
let before = prop(&s, l, "transform/opacity");
let c = [30.0, 0.5, 60.0, 2.0];
for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] {
select_keys(&mut s, l, &[("transform/opacity", t)]);
let steps = s.history.undo.len();
apply_typed(&mut s, win, mode, c);
assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}"));
assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step");
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
}
// In-Out on the first key eases only its out side.
select_keys(&mut s, l, &[("transform/opacity", 0.0)]);
apply_typed(&mut s, win, "In-Out", c);
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier));
assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease);
assert_eq!(op.keys[1..], before.keys[1..]);
s.execute("edit.undo", json!({})).unwrap();
// The first and last Opacity keys (not neighbours) and the last Rotation key.
select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]);
let steps = s.history.undo.len();
apply_typed(&mut s, win, "In-Out", c);
assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve");
assert_eq!(s.history.undo.len(), steps + 1);
let op = prop(&s, l, "transform/opacity");
assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease);
assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease);
assert_eq!(op.keys[1], before.keys[1], "the unselected middle key");
let rot = prop(&s, l, "transform/rotation");
assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease);
assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
}
/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides
/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only
/// out sides.
#[test]
fn a_selected_pair_eases_the_sides_its_mode_names() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
let win = open_panel(&mut s);
let before = prop(&s, l, "transform/opacity");
let c = [30.0, 0.5, 60.0, 2.0];
select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]);
apply_typed(&mut s, win, "In-Out", c);
let op = prop(&s, l, "transform/opacity");
assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6));
assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease);
assert_eq!(op.keys[2], before.keys[2]);
s.execute("edit.undo", json!({})).unwrap();
apply_typed(&mut s, win, "Out", c);
let op = prop(&s, l, "transform/opacity");
assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3));
assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear));
}
/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an
/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too.
#[test]
fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]);
ok(
&mut s,
r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType;
t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD);
t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER);
t.rotation.setTemporalAutoBezierAtKey(2, true);"#,
);
let rot = prop(&s, l, "transform/rotation");
let after: Vec<f64> = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect();
let win = open_panel(&mut s);
set(&mut s, win, "mode", json!("In"));
select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]);
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}");
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold));
assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease);
assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds");
let eased_rot = prop(&s, l, "transform/rotation");
assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease);
for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) {
assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}");
}
assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed");
}
/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set
/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased.
#[test]
fn roving_keyframes_are_skipped_and_reported() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]);
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)");
let pos = prop(&s, l, "transform/position");
assert!(pos.keys[1].roving);
let roving_at = pos.keys[1].time.seconds();
let win = open_panel(&mut s);
// Only the roving key: nothing changes, no undo step.
select_keys(&mut s, l, &[("transform/position", roving_at)]);
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them");
assert_eq!(s.history.undo.len(), steps);
assert_eq!(prop(&s, l, "transform/position").keys, pos.keys);
// With an ordinary key: that one is eased, the roving one is counted as skipped.
select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]);
set(&mut s, win, "presets", json!("Linear"));
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(
status(&mut s, win),
"Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them"
);
let k = &prop(&s, l, "transform/position").keys[1];
assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}");
let op = prop(&s, l, "transform/opacity");
assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5));
}
/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state.
#[test]
fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]);
ok(
&mut s,
r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType;
r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER);
r.setTemporalAutoBezierAtKey(2, true);"#,
);
let before = prop(&s, l, "transform/rotation");
let win = open_panel(&mut s);
select_keys(&mut s, l, &[("transform/rotation", 1.0)]);
apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]);
let after = prop(&s, l, "transform/rotation");
assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease);
for t in [0.25, 0.5, 0.75] {
assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}");
}
assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys);
}
/// On a curved path, a single key's in and out speeds follow the length of the path on each side
/// (unequal lengths and durations), and the side not eased plays as it did.
#[test]
fn a_single_key_on_a_curved_path_eases_along_each_side() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]);
let before = prop(&s, l, "transform/position");
let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1));
let win = open_panel(&mut s);
select_keys(&mut s, l, &[("transform/position", 1.0)]);
apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]);
let pos = prop(&s, l, "transform/position");
let k = &pos.keys[1];
// In speed: twice the incoming segment's average (its path length over 1 s).
assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease);
// The out side (2 s along a longer path) plays as before.
for t in [1.5, 2.0, 2.5] {
let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2());
assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}");
}
// Playback on the in side: arriving at twice the average speed, the last tenth of a second
// covers more of the path than a constant speed would (len_in / 10).
let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2());
let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt();
assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}");
assert!(len_out > len_in, "{len_out} {len_in}");
}
/// On one key, each side scales to its own segment: per dimension, and along the motion path.
#[test]
fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
// Scale: +100/−50 per second into the key, 0/+50 per second out of it.
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]);
// Position on a straight path: 100 px/s into the key, 50 px/s out of it.
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]);
let win = open_panel(&mut s);
select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]);
apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]);
assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve");
let k = prop(&s, l, "transform/scale").keys[1].clone();
assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease);
assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease);
let k = prop(&s, l, "transform/position").keys[1].clone();
assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease);
assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease);
let pos = prop(&s, l, "transform/position");
assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays");
}
#[test]
fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() {
let store = Arc::new(MemoryConfig::default());