Files
effectcraft/crates/engine/src/tests_ease_presets.rs
T
bflatasticandClaude Opus 5.5 0717f12246 Keyframes: ease presets apply a saved curve to keyframe pairs (#254)
keys.easePreset.apply {preset | curve} eases every pair of neighbouring
selected keyframes of each selected property in one undo step: the out
side of the earlier key and the in side of the later one. A curve is
given as {outInfluence, outSpeed, inInfluence, inSpeed} or as handles
[x1, y1, x2, y2]. keys.easePreset.capture reads the curve between the
first selected pair; keys.easePreset.save {name, curve?} keeps it (or a
given curve), and list, rename and delete manage the user presets.

Twelve built-in curves are our own numbers (Linear, Smooth, Ease In-Out
Soft / plain / Strong, Accelerate and Decelerate with Strong variants,
Expo In-Out / Accelerate / Decelerate) and can't be renamed, deleted or
overwritten. User presets live in the config store (ease_presets.json),
loaded with the other settings. A file that isn't JSON gives no user
presets, and entries that don't parse, have no usable name or repeat a
name are skipped and logged. A failed write leaves the presets as they
were.

Test: a_preset_eases_every_selected_pair_in_one_undo_step,
a_curve_scales_to_each_dimension_and_follows_motion_paths,
apply_needs_neighbouring_keys_and_a_known_curve,
user_presets_save_rename_delete_and_persist,
a_corrupt_presets_file_loads_what_it_can (effectcraft-engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:25:00 +07:00

165 lines
9.7 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.
//! Ease presets (#254): a saved curve applied to every pair of neighbouring selected keyframes
//! (one undo step), save / rename / delete persisted through the config store, and lenient loading
//! of a corrupt presets file.
use std::sync::Arc;
use effectcraft_keyframe::{Ease, Interp, ease_progress};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::Session;
use crate::config::{ConfigStore, MemoryConfig};
use crate::ease_presets::EASE_PRESETS_FILE;
use crate::tests_timeline::{animate, prop, setup};
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
fn user_names(s: &mut Session) -> Vec<String> {
let list = s.execute("keys.easePreset.list", json!({})).unwrap();
list.as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn a_preset_eases_every_selected_pair_in_one_undo_step() {
let (mut s, l) = setup();
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 steps = s.history.undo.len();
let r = s.execute("keys.easePreset.apply", json!({"preset": "Ease In-Out"})).unwrap();
assert_eq!(r["pairs"], 3, "two opacity segments and one rotation segment");
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
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.clone()), (Interp::Bezier, vec![eased]));
assert_eq!((op.keys[1].in_ease.clone(), op.keys[1].out_ease.clone()), (vec![eased], vec![eased]));
assert_eq!((op.keys[2].in_interp, op.keys[2].in_ease.clone()), (Interp::Bezier, vec![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(Tick::from_seconds_f64(2.0)).as_f64(), 60.0, 1e-6));
let quarter = op.value_at(Tick::from_seconds_f64(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!(prop(&s, l, "transform/rotation").keys[1].in_interp == Interp::Bezier);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.history.redo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset"));
let op = prop(&s, l, "transform/opacity");
assert!(op.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
assert!(close(op.value_at(Tick::from_seconds_f64(1.5)).as_f64(), 80.0, 1e-6));
}
#[test]
fn a_curve_scales_to_each_dimension_and_follows_motion_paths() {
let (mut s, l) = setup();
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])), (2.0, json!([300, 400]))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/scale"})).unwrap();
let curve = json!({"outInfluence": 60, "outSpeed": 0, "inInfluence": 20, "inSpeed": 2});
s.execute("keys.easePreset.apply", json!({"curve": curve})).unwrap();
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(Tick::from_seconds_f64(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:?}");
// Handles [x1, y1, x2, y2] on Position: the speed is along the 500 px path.
s.execute("prop.select", json!({"layer": l, "path": "transform/position"})).unwrap();
s.execute("keys.easePreset.apply", json!({"curve": [0.25, 0.1, 0.25, 1.0]})).unwrap();
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(Tick::from_seconds_f64(1.0)).as_vec2();
assert!(close(p[0], 300.0 * f, 0.05) && close(p[1], 400.0 * f, 0.05), "{p:?} {f}");
// The captured curve is the applied one.
let c = s.execute("keys.easePreset.capture", json!({})).unwrap();
assert!(close(c["inInfluence"].as_f64().unwrap(), 75.0, 1e-6) && close(c["outSpeed"].as_f64().unwrap(), 0.4, 1e-6), "{c}");
}
#[test]
fn apply_needs_neighbouring_keys_and_a_known_curve() {
let (mut s, l) = setup();
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});
// Keys 1 and 3 aren't neighbours.
s.execute("keys.select", json!({"keys": [key(0.0), key(2.0)]})).unwrap();
let steps = s.history.undo.len();
let e = s.execute("keys.easePreset.apply", json!({"preset": "Linear"})).unwrap_err().to_string();
assert!(e.contains("neighbouring"), "{e}");
assert_eq!(s.history.undo.len(), steps, "nothing changed");
s.execute("keys.select", json!({"keys": [key(0.0), key(1.0)]})).unwrap();
for bad in
[json!({"preset": "Nope"}), json!({"curve": [0.1, 0.2]}), json!({"curve": [0.1, "a", 0.3, 0.4]}), json!({"curve": {"outInfluence": 50}}), json!({})]
{
assert!(s.execute("keys.easePreset.apply", bad.clone()).is_err(), "{bad}");
}
// Names are matched in any case.
assert_eq!(s.execute("keys.easePreset.apply", json!({"preset": "expo in-out"})).unwrap()["pairs"], 1);
}
#[test]
fn user_presets_save_rename_delete_and_persist() {
let store = Arc::new(MemoryConfig::default());
let (mut s, l) = setup();
s.config = Some(store.clone());
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();
s.execute("keys.easePreset.apply", json!({"preset": "Decelerate"})).unwrap();
// Without a curve, save keeps the one between the selected keys.
let saved = s.execute("keys.easePreset.save", json!({"name": " Settle "})).unwrap();
assert_eq!(saved["name"], "Settle");
assert!(close(saved["curve"]["outSpeed"].as_f64().unwrap(), 1.5, 1e-9) && close(saved["curve"]["inInfluence"].as_f64().unwrap(), 50.0, 1e-9), "{saved}");
s.execute("keys.easePreset.save", json!({"name": "Snap", "curve": [0.1, 0.5, 0.2, 1.0]})).unwrap();
assert_eq!(user_names(&mut s), ["Settle", "Snap"]);
// Built-in names and existing names are refused; built-in presets can't change.
assert!(s.execute("keys.easePreset.save", json!({"name": "linear", "curve": [0.3, 0.3, 0.7, 0.7]})).is_err());
assert!(s.execute("keys.easePreset.save", json!({"name": " "})).is_err());
assert!(s.execute("keys.easePreset.rename", json!({"name": "Settle", "newName": "snap"})).is_err());
assert!(s.execute("keys.easePreset.rename", json!({"name": "Linear", "newName": "Straight"})).is_err());
assert!(s.execute("keys.easePreset.delete", json!({"name": "Ease In-Out"})).is_err());
s.execute("keys.easePreset.rename", json!({"name": "settle", "newName": "Soft Landing"})).unwrap();
// Saving under an existing name replaces that preset.
s.execute("keys.easePreset.save", json!({"name": "SNAP", "curve": [0.2, 0.5, 0.2, 1.0]})).unwrap();
assert_eq!(user_names(&mut s), ["Soft Landing", "SNAP"]);
s.execute("keys.easePreset.delete", json!({"name": "snap"})).unwrap();
assert!(s.execute("keys.easePreset.delete", json!({"name": "snap"})).is_err());
// A new session reads them back from the store.
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
s2.load_settings();
assert_eq!(user_names(&mut s2), ["Soft Landing"]);
let c = crate::ease_presets::find(&s2, "soft landing").unwrap();
assert!(close(c.out_influence, 20.0, 1e-9) && close(c.out_speed, 1.5, 1e-9) && close(c.in_speed, 0.0, 1e-9), "{c:?}");
}
#[test]
fn a_corrupt_presets_file_loads_what_it_can() {
let store = Arc::new(MemoryConfig::default());
store.write(EASE_PRESETS_FILE, "{\"presets\": [ not json").unwrap();
let mut s = Session { config: Some(store.clone()), ..Default::default() };
s.load_settings();
assert!(s.ease_presets.is_empty());
assert_eq!(s.execute("keys.easePreset.list", json!({})).unwrap().as_array().unwrap().len(), 12, "the built-in presets");
let c = json!({"outInfluence": 30, "outSpeed": 0, "inInfluence": 30, "inSpeed": 0});
let doc = json!({"version": 1, "presets": [
{"name": "Good", "curve": c},
{"name": "Linear", "curve": c},
{"name": "", "curve": c},
{"name": "No curve"},
{"name": "good", "curve": c},
42,
{"name": "Wild", "curve": {"outInfluence": 1e9, "outSpeed": -1e9, "inInfluence": -5, "inSpeed": 0}},
]});
store.write(EASE_PRESETS_FILE, &doc.to_string()).unwrap();
s.load_settings();
assert_eq!(s.ease_presets.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Good", "Wild"]);
let wild = s.ease_presets[1].curve;
assert_eq!((wild.out_influence, wild.out_speed, wild.in_influence), (100.0, -100.0, 0.1));
// Saving rewrites a clean file.
s.execute("keys.easePreset.delete", json!({"name": "Wild"})).unwrap();
let text: Value = serde_json::from_str(&store.read(EASE_PRESETS_FILE).unwrap()).unwrap();
assert_eq!(text["presets"].as_array().unwrap().len(), 1);
}