Files
effectcraft/crates/script/src/tests_ease_presets.rs
T
tylerjenningsw 4dddb34cba Ease Presets: tests for Out alone through undo and redo, and a single key on a curved path (#1329)
Out on an auto-Bezier key leaves its in side playing the same values,
and undo and redo restore each state. On a curved Position path with
unequal segment lengths and durations, In sets the key's speed from the
incoming path length, playback arrives faster than a constant speed, and
the out side plays as before.
2026-10-13 15:04:19 -04:00

611 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The scripting API the bundled Ease Presets ScriptUI panel uses (After Effects parity:
//! `app.settings` that last between runs, per-key methods that keep the key selection, eases and
//! spatial tangents reported as they play, `selectedProperties` with keyed properties,
//! `isInterpolationTypeValid`), and the panel itself (`extensions/scriptui-panels/Ease
//! Presets.jsx`) easing selected keyframes, keeping user presets and taking over the presets the
//! core panel of v0.6.0 saved.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::commands::scripts::SCRIPT_SETTINGS_FILE;
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
use effectcraft_engine::project::{LayerId, Property};
use effectcraft_keyframe::{Ease, Interp, ease_progress};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::run_code;
const PANEL: &str = "Ease Presets.jsx";
fn session(store: &Arc<MemoryConfig>) -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
crate::install(&mut s);
s.config = Some(store.clone());
s.load_settings();
s
}
/// A comp with one solid; returns its id.
fn setup(s: &mut Session) -> u64 {
s.execute("comp.new", json!({"name": "Ease", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap()
}
fn animate(s: &mut Session, l: u64, path: &str, keys: &[(f64, Value)]) {
for (t, v) in keys {
s.execute("prop.addKey", json!({"layer": l, "path": path, "time": t, "value": v})).unwrap();
}
}
fn prop(s: &Session, l: u64, path: &str) -> Property {
s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().clone()
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
fn ok(s: &mut Session, code: &str) -> Value {
let out = run_code(s, code, "test.jsx");
assert!(out.error.is_none(), "{:?}", out.error);
out.result
}
fn at(s: f64) -> Tick {
Tick::from_seconds_f64(s)
}
#[test]
fn app_settings_last_between_runs() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
ok(&mut s, r#"app.settings.saveSetting("My Tool", "size", 12); app.settings.saveSetting("My Tool", "name", "Box");"#);
// A new run (the next launch) reads them back.
let mut s = session(&store);
let r = ok(
&mut s,
r#"[app.settings.haveSetting("My Tool", "size"), app.settings.getSetting("My Tool", "size"), app.settings.getSetting("My Tool", "name"), app.settings.haveSetting("My Tool", "nope")]"#,
);
assert_eq!(r, json!([true, "12", "Box", false]));
assert_eq!(s.execute("script.settings.get", json!({"section": "My Tool", "key": "name"})).unwrap(), json!({"have": true, "value": "Box"}));
// A corrupt settings file loads as none, never an error.
store.write(SCRIPT_SETTINGS_FILE, "{oops").unwrap();
let mut s = session(&store);
assert_eq!(ok(&mut s, r#"app.settings.haveSetting("My Tool", "size")"#), json!(false));
assert!(s.execute("script.settings.save", json!({"section": "x"})).is_err());
}
#[test]
fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() {
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)), (2.0, json!(0))]);
let key = |t: f64| json!({"layer": l, "path": "transform/opacity", "time": t});
s.execute("edit.deselectAll", json!({})).unwrap();
s.execute("keys.select", json!({"keys": [key(0.0), key(2.0)]})).unwrap();
let before = s.state.selected_keys.clone();
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, p = comp.layer(1).transform.opacity;
// A linear side reports the speed it plays at (33.3 % influence).
var lin = p.keyInTemporalEase(3)[0];
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 50)], [new KeyframeEase(0, 75)]);
p.setInterpolationTypeAtKey(3, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD);
// Keyed properties are selected properties, as in After Effects.
var sel = comp.selectedProperties;
[lin.speed, lin.influence, p.selectedKeys, sel.length, sel.length ? sel[0].name : ""]"#,
);
assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 3.0, 1e-9), "{r}");
assert_eq!(r[2], json!([1, 3]), "the selection is the user's");
assert_eq!((r[3].clone(), r[4].clone()), (json!(1), json!("Opacity")));
assert_eq!(s.state.selected_keys, before);
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (Ease { speed: 0.0, influence: 0.5 }, Ease { speed: 0.0, influence: 0.75 }));
assert_eq!(op.keys[2].in_interp, Interp::Hold);
// removeKey drops the removed key from the selection, keeps the rest.
ok(&mut s, "app.project.activeItem.layer(1).transform.opacity.removeKey(3)");
assert_eq!(s.state.selected_keys, before[..1]);
// Values that don't interpolate only hold.
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, o = comp.layer(1).transform.opacity, t = comp.layers.addText("Hi").text.sourceText;
[t.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER), t.isInterpolationTypeValid(KeyframeInterpolationType.HOLD), o.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER)]"#,
);
assert_eq!(r, json!([false, true, true]));
}
#[test]
fn spatial_tangents_are_reported_as_the_path_plays() {
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!([100, 100, 0]))]);
let pos = prop(&s, l, "transform/position");
let (inn, out) = effectcraft_keyframe::spatial_tangents(&pos.keys, 1);
let r = ok(&mut s, "var p = app.project.activeItem.layer(1).transform.position; [p.keyInSpatialTangent(2), p.keyOutSpatialTangent(2)]");
for (got, want) in [(&r[0], inn), (&r[1], out)] {
for d in 0..2 {
assert!(close(got[d].as_f64().unwrap(), want[d], 1e-9), "{r} vs {inn:?} {out:?}");
}
}
assert!(out[0].abs() + out[1].abs() > 1.0, "the middle key's path curves: {out:?}");
assert_ne!(pos.keys[1].spatial_out, out, "auto-Bezier: the stored tangent isn't the one that plays");
}
/// The panel's window and a control's description by name.
fn open_panel(s: &mut Session) -> u64 {
let r = s.execute("window.scriptPanel", json!({"name": PANEL})).unwrap();
r["window"].as_u64().unwrap()
}
fn control(s: &mut Session, win: u64, name: &str) -> Value {
fn find(w: &Value, name: &str) -> Option<Value> {
if w["name"] == name {
return Some(w.clone());
}
w["children"].as_array()?.iter().find_map(|c| find(c, name))
}
let w = s.execute("scriptui.get", json!({"window": win})).unwrap();
find(&w["root"], name).unwrap_or_else(|| panic!("no control {name}"))
}
fn set(s: &mut Session, win: u64, widget: &str, value: Value) {
s.execute("scriptui.set", json!({"window": win, "widget": widget, "value": value})).unwrap();
}
fn click(s: &mut Session, win: u64, widget: &str) {
s.execute("scriptui.click", json!({"window": win, "widget": widget})).unwrap();
}
fn status(s: &mut Session, win: u64) -> String {
control(s, win, "status")["text"].as_str().unwrap().to_string()
}
#[test]
fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
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))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
s.execute("prop.select", json!({"layer": l, "path": "transform/rotation", "add": true})).unwrap();
let selected = s.state.selected_keys.clone();
let win = open_panel(&mut s);
// It is a bundled extension listed with the ScriptUI panels.
let list = s.execute("file.scripts.list", json!({})).unwrap();
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"));
// 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");
let eased = Ease { speed: 0.0, influence: 0.5 };
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease[0]), (Interp::Bezier, eased));
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (eased, eased));
assert_eq!((op.keys[2].in_interp, op.keys[2].in_ease[0]), (Interp::Bezier, eased));
// The first key's in side and the last key's out side are not part of a pair.
assert_eq!((op.keys[0].in_interp, op.keys[2].out_interp), (Interp::Linear, Interp::Linear));
// A symmetric curve passes the middle value at mid-time, slow near the keys.
assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6));
let quarter = op.value_at(at(1.5)).as_f64();
assert!(close(quarter, 100.0 - 80.0 * ease_progress(0.0, 0.5, 0.0, 0.5, 0.25), 1e-6), "{quarter}");
assert_eq!(prop(&s, l, "transform/rotation").keys[1].in_interp, Interp::Bezier);
s.execute("edit.undo", json!({})).unwrap();
let op = prop(&s, l, "transform/opacity");
assert!(op.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
// Nothing selected: nothing changes, and the panel says why.
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 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());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50]))]);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (2.0, json!([300, 400, 0]))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/scale"})).unwrap();
let win = open_panel(&mut s);
let typed = |s: &mut Session, c: [f64; 4]| {
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
set(s, win, name, json!(v.to_string()));
}
click(s, win, "apply");
};
typed(&mut s, [60.0, 0.0, 20.0, 2.0]);
let sc = prop(&s, l, "transform/scale");
// X rises 100/s and Y falls 50/s: the in speed (2× average) is per dimension.
assert_eq!(sc.keys[1].in_ease[..2], [Ease { speed: 200.0, influence: 0.2 }, Ease { speed: -100.0, influence: 0.2 }]);
let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5);
let v = sc.value_at(at(0.5)).as_vec2();
assert!(close(v[0], 100.0 + 100.0 * f, 1e-6) && close(v[1], 100.0 - 50.0 * f, 1e-6), "{v:?}");
// On Position the speed is along the 500 px path.
s.execute("prop.select", json!({"layer": l, "path": "transform/position"})).unwrap();
typed(&mut s, [25.0, 0.4, 75.0, 0.0]);
let pos = prop(&s, l, "transform/position");
assert!(pos.keys[0].out_ease.iter().all(|e| close(e.speed, 0.4 * 250.0, 1e-6) && close(e.influence, 0.25, 1e-9)), "{:?}", pos.keys[0].out_ease);
let f = ease_progress(0.4, 0.25, 0.0, 0.75, 0.5);
let p = pos.value_at(at(1.0)).as_vec2();
assert!(close(p[0], 300.0 * f, 0.05) && close(p[1], 400.0 * f, 0.05), "{p:?} {f}");
// From Keys reads the applied curve back into the fields.
for name in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"] {
set(&mut s, win, name, json!("1"));
}
click(&mut s, win, "fromKeys");
let fields: Vec<String> =
["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().map(|n| control(&mut s, win, n)["text"].as_str().unwrap().to_string()).collect();
assert_eq!(fields, ["25", "0.4", "75", "0"]);
}
#[test]
fn user_presets_save_rename_delete_and_persist() {
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))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
let win = open_panel(&mut s);
set(&mut s, win, "presets", json!("Decelerate"));
// Save keeps the curve in the fields under the typed name.
set(&mut s, win, "name", json!(" Settle "));
click(&mut s, win, "save");
assert_eq!(status(&mut s, win), "Saved the ease preset \u{201c}Settle\u{201d}");
// Built-in names are refused.
set(&mut s, win, "name", json!("linear"));
click(&mut s, win, "save");
assert!(status(&mut s, win).contains("is a built-in preset"));
let items = |s: &mut Session| control(s, win, "presets")["items"].clone();
assert_eq!(items(&mut s).as_array().unwrap().last().unwrap(), "Settle");
// Rename and Delete act on the selected user preset; built-in presets can't change.
set(&mut s, win, "presets", json!("Linear"));
set(&mut s, win, "name", json!("Straight"));
click(&mut s, win, "rename");
assert_eq!(status(&mut s, win), "Select one of your own presets first");
set(&mut s, win, "presets", json!("Settle"));
set(&mut s, win, "name", json!("Soft Landing"));
click(&mut s, win, "rename");
let names = items(&mut s);
assert!(names.as_array().unwrap().contains(&json!("Soft Landing")) && !names.as_array().unwrap().contains(&json!("Settle")), "{names}");
// Kept with app.settings: a new run of the panel (the next launch) lists them.
let stored: Value = serde_json::from_str(&store.read(SCRIPT_SETTINGS_FILE).unwrap()).unwrap();
let presets: Value = serde_json::from_str(stored["Ease Presets"]["userPresets"].as_str().unwrap()).unwrap();
assert_eq!(presets["presets"][0]["name"], "Soft Landing");
assert!(close(presets["presets"][0]["curve"]["outSpeed"].as_f64().unwrap(), 1.5, 1e-9), "{presets}");
let mut s2 = session(&store);
setup(&mut s2);
let win2 = open_panel(&mut s2);
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 13);
set(&mut s2, win2, "presets", json!("Soft Landing"));
click(&mut s2, win2, "delete");
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 12, "the built-in presets");
}
/// User presets the core Ease Presets panel of v0.6.0 saved (`ease_presets.json`) move to the
/// panel's settings when settings load, once; unusable entries are skipped by the panel.
#[test]
fn presets_saved_by_the_old_core_panel_carry_over() {
let store = Arc::new(MemoryConfig::default());
let c = json!({"outInfluence": 30, "outSpeed": 0, "inInfluence": 30, "inSpeed": 0});
let old = json!({"version": 1, "presets": [
{"name": "Snap", "curve": c},
{"name": "Linear", "curve": c},
{"name": "", "curve": c},
{"name": "No curve"},
{"name": "snap", "curve": c},
{"name": "Bad", "curve": {"outInfluence": "x", "outSpeed": 0, "inInfluence": 30, "inSpeed": 0}}
]});
store.write("ease_presets.json", &old.to_string()).unwrap();
let mut s = session(&store);
setup(&mut s);
let win = open_panel(&mut s);
let items = control(&mut s, win, "presets")["items"].clone();
assert_eq!(items.as_array().unwrap().len(), 13, "{items}");
assert_eq!(items[12], "Snap");
// Deleting it there is final: the old file doesn't come back.
set(&mut s, win, "presets", json!("Snap"));
click(&mut s, win, "delete");
let mut s = session(&store);
setup(&mut s);
let win = open_panel(&mut s);
assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12);
}