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>
This commit is contained in:
bflatastic 2026-10-08 17:17:11 +07:00
parent cc594a7385
commit 0717f12246
5 changed files with 448 additions and 1 deletions

View File

@ -206,6 +206,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(crate::storage::specs());
v.extend(crate::learn::specs());
v.extend(crate::templates::specs());
v.extend(crate::ease_presets::specs());
v.extend(crate::preview::specs());
v
})

View File

@ -0,0 +1,272 @@
//! Ease presets (Window ▸ Ease Presets): easing curves kept by name and applied to pairs of
//! keyframes. A few original built-in curves ship with EffectCraft; user presets live in the config
//! store (`ease_presets.json`), read leniently: a corrupt file or entry is skipped, never fatal.
//!
//! Commands: `keys.easePreset.apply {preset | curve}` eases every pair of neighbouring selected
//! keyframes of each property (one undo step), `keys.easePreset.capture` reads the curve between
//! the first selected pair, `keys.easePreset.save {name, curve?}`, `keys.easePreset.list`,
//! `keys.easePreset.rename {name, newName}` and `keys.easePreset.delete {name}`.
use std::collections::BTreeMap;
use effectcraft_keyframe::{EaseCurve, key_at};
use effectcraft_project::{LayerId, Uid};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, has_keys, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// User presets in the config store.
pub const EASE_PRESETS_FILE: &str = "ease_presets.json";
/// Longest preset name, in characters.
const MAX_NAME: usize = 64;
const NEED_PAIR: &str = "select two or more neighbouring keyframes of a property";
/// A user preset.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EasePreset {
pub name: String,
pub curve: EaseCurve,
}
const fn curve(out_influence: f64, out_speed: f64, in_influence: f64, in_speed: f64) -> EaseCurve {
EaseCurve { out_influence, out_speed, in_influence, in_speed }
}
/// Built-in presets (our own values). "Accelerate" starts slowly and "Decelerate" settles slowly.
const BUILT_IN: [(&str, EaseCurve); 12] = [
("Linear", EaseCurve::LINEAR),
("Smooth", curve(40.0, 0.25, 40.0, 0.25)),
("Ease In-Out Soft", curve(25.0, 0.0, 25.0, 0.0)),
("Ease In-Out", curve(50.0, 0.0, 50.0, 0.0)),
("Ease In-Out Strong", curve(75.0, 0.0, 75.0, 0.0)),
("Accelerate", curve(50.0, 0.0, 20.0, 1.5)),
("Accelerate Strong", curve(80.0, 0.0, 15.0, 2.5)),
("Decelerate", curve(20.0, 1.5, 50.0, 0.0)),
("Decelerate Strong", curve(15.0, 2.5, 80.0, 0.0)),
("Expo In-Out", curve(88.0, 0.0, 88.0, 0.0)),
("Expo Accelerate", curve(75.0, 0.0, 12.0, 7.0)),
("Expo Decelerate", curve(12.0, 7.0, 75.0, 0.0)),
];
/// Every preset: (name, curve, built in), the built-in ones first.
pub fn presets(s: &Session) -> impl Iterator<Item = (&str, EaseCurve, bool)> {
BUILT_IN.iter().map(|(n, c)| (*n, *c, true)).chain(s.ease_presets.iter().map(|p| (p.name.as_str(), p.curve, false)))
}
/// A preset's curve by name (any case).
pub fn find(s: &Session, name: &str) -> Option<EaseCurve> {
presets(s).find(|(n, ..)| n.eq_ignore_ascii_case(name)).map(|(_, c, _)| c)
}
fn is_built_in(name: &str) -> bool {
BUILT_IN.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))
}
/// Pairs of neighbouring selected keyframes of the active comp: (layer, property, index of the
/// earlier key), by property and time.
pub fn selected_pairs(s: &Session) -> Vec<(LayerId, Uid, usize)> {
let Some(comp) = s.active_comp() else { return vec![] };
let mut groups: BTreeMap<(LayerId, Uid), Vec<usize>> = BTreeMap::new();
for k in &s.state.selected_keys {
let Some(pr) = comp.layer(k.layer).and_then(|l| l.props.find(k.prop)).filter(|pr| pr.value.interpolates()) else { continue };
if let Some(i) = key_at(&pr.keys, k.time) {
groups.entry((k.layer, k.prop)).or_default().push(i);
}
}
let mut out = vec![];
for ((lid, uid), mut idx) in groups {
idx.sort_unstable();
idx.dedup();
out.extend(idx.windows(2).filter_map(|w| match *w {
[a, b] if b == a + 1 => Some((lid, uid, a)),
_ => None,
}));
}
out
}
/// Read user presets: entries that don't parse, have no usable name or repeat a name are
/// skipped (and logged); a file that isn't JSON gives none.
pub fn parse(text: &str) -> Vec<EasePreset> {
let doc: Value = serde_json::from_str(text).unwrap_or_else(|e| {
log::warn!("{EASE_PRESETS_FILE} is not valid JSON ({e}); no user ease presets loaded");
Value::Null
});
let mut out: Vec<EasePreset> = vec![];
for entry in doc.get("presets").and_then(Value::as_array).into_iter().flatten() {
let preset = serde_json::from_value::<EasePreset>(entry.clone())
.ok()
.and_then(|p| Some(EasePreset { name: clean_name(&p.name)?, curve: p.curve.sanitized()? }))
.filter(|p| !is_built_in(&p.name) && !out.iter().any(|q| q.name.eq_ignore_ascii_case(&p.name)));
match preset {
Some(p) => out.push(p),
None => log::warn!("{EASE_PRESETS_FILE}: skipped the unusable entry {entry}"),
}
}
out
}
/// The file as written.
#[derive(Serialize)]
struct PresetFile<'a> {
version: u32,
presets: &'a [EasePreset],
}
/// Write `list` to the config store, then make it the session's user presets.
fn store(s: &mut Session, list: Vec<EasePreset>) -> Result<()> {
if let Some(cfg) = &s.config {
let text = serde_json::to_string_pretty(&PresetFile { version: 1, presets: &list }).map_err(|e| EngineError::Other(format!("ease presets: {e}")))?;
cfg.write(EASE_PRESETS_FILE, &text).map_err(|e| EngineError::Other(format!("cannot save ease presets: {e}")))?;
}
s.ease_presets = list;
Ok(())
}
fn clean_name(name: &str) -> Option<String> {
let name = name.trim();
(!name.is_empty() && name.chars().count() <= MAX_NAME).then(|| name.to_string())
}
fn name_p(p: &Value, key: &str, c: &str) -> Result<String> {
let raw = str_p(p, key).ok_or_else(|| bad(c, format!("missing `{key}`")))?;
clean_name(raw).ok_or_else(|| bad(c, format!("`{key}` must be 1–{MAX_NAME} characters")))
}
/// `curve`: `{outInfluence, outSpeed, inInfluence, inSpeed}` or handles `[x1, y1, x2, y2]`.
fn curve_v(v: &Value, c: &str) -> Result<EaseCurve> {
let parsed = match v.as_array() {
Some(a) => {
let h: Option<Vec<f64>> = a.iter().map(Value::as_f64).collect();
let h: [f64; 4] = h.and_then(|h| h.try_into().ok()).ok_or_else(|| bad(c, "`curve` handles are four numbers [x1, y1, x2, y2]"))?;
EaseCurve::from_handles(h)
}
None => serde_json::from_value::<EaseCurve>(v.clone()).map_err(|e| bad(c, format!("`curve`: {e}")))?.sanitized(),
};
parsed.ok_or_else(|| bad(c, "`curve` numbers must be finite"))
}
/// The curve between the first selected pair of keyframes.
fn captured(s: &Session, c: &str) -> Result<EaseCurve> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let (lid, uid, i) = selected_pairs(s).into_iter().next().ok_or_else(|| bad(c, NEED_PAIR))?;
let pr = comp.layer(lid).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, "no property"))?;
EaseCurve::measure(&pr.keys, i, pr.spatial).ok_or_else(|| bad(c, "the selected keyframes have no curve to keep (a hold, or no change in value)"))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.easePreset.apply";
let curve = match (str_p(p, "preset"), p.get("curve")) {
(Some(name), _) => find(s, name).ok_or_else(|| bad(C, format!("no ease preset `{name}` (see keys.easePreset.list)")))?,
(None, Some(v)) => curve_v(v, C)?,
(None, None) => return Err(bad(C, "pass `preset` (a name) or `curve`")),
};
let pairs = selected_pairs(s);
if pairs.is_empty() {
return Err(bad(C, NEED_PAIR));
}
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let n = s.edit("Apply Ease Preset", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut n = 0;
for (lid, uid, i) in &pairs {
if let Some(pr) = comp.layer_mut(*lid).and_then(|l| l.props.find_mut(*uid))
&& curve.apply(&mut pr.keys, *i, pr.spatial)
{
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"pairs": n, "curve": curve}))
}
fn capture(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(captured(s, "keys.easePreset.capture")?))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.easePreset.save";
let name = name_p(p, "name", C)?;
if is_built_in(&name) {
return Err(bad(C, format!("`{name}` is a built-in preset; choose another name")));
}
let curve = match p.get("curve") {
Some(v) => curve_v(v, C)?,
None => captured(s, C)?,
};
let preset = EasePreset { name, curve };
let mut list = s.ease_presets.clone();
// Saving under an existing name replaces that preset.
match list.iter_mut().find(|q| q.name.eq_ignore_ascii_case(&preset.name)) {
Some(q) => *q = preset.clone(),
None => list.push(preset.clone()),
}
store(s, list)?;
Ok(json!(preset))
}
/// Index of the user preset `name`; built-in presets can't be changed.
fn user_preset(s: &Session, name: &str, c: &str) -> Result<usize> {
s.ease_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name)).ok_or_else(|| {
if is_built_in(name) { bad(c, format!("`{name}` is a built-in preset and can't be changed")) } else { bad(c, format!("no user ease preset `{name}`")) }
})
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.easePreset.rename";
let (name, new_name) = (name_p(p, "name", C)?, name_p(p, "newName", C)?);
let i = user_preset(s, &name, C)?;
if is_built_in(&new_name) || s.ease_presets.iter().enumerate().any(|(j, q)| j != i && q.name.eq_ignore_ascii_case(&new_name)) {
return Err(bad(C, format!("an ease preset named `{new_name}` exists")));
}
let mut list = s.ease_presets.clone();
if let Some(q) = list.get_mut(i) {
q.name = new_name.clone();
}
store(s, list)?;
Ok(json!({"name": new_name}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.easePreset.delete";
let name = name_p(p, "name", C)?;
user_preset(s, &name, C)?;
let mut list = s.ease_presets.clone();
list.retain(|q| !q.name.eq_ignore_ascii_case(&name));
store(s, list)?;
Ok(json!({"deleted": name}))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(presets(s).map(|(name, curve, built_in)| json!({"name": name, "builtIn": built_in, "curve": curve})).collect())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"keys.easePreset.apply",
"Apply Ease Preset",
[],
None,
"{preset (name) | curve: {outInfluence (%), outSpeed, inInfluence (%), inSpeed} | [x1, y1, x2, y2]} — eases every pair of neighbouring selected keyframes; speeds are relative to the segment's average speed",
has_keys,
apply
),
cmd!(
"keys.easePreset.save",
"Save Ease Preset",
[],
None,
"{name, curve? (as for apply; default: the curve between the first selected pair of keyframes)} — replaces a user preset of that name",
always,
save
),
cmd!("keys.easePreset.rename", "Rename Ease Preset", [], None, "{name, newName}", always, rename),
cmd!("keys.easePreset.delete", "Delete Ease Preset", [], None, "{name}", always, delete),
query!("keys.easePreset.list", "Ease Presets", "{} — [{name, builtIn, curve}]", list),
query!("keys.easePreset.capture", "Ease Curve of Selected Keyframes", "{} — the curve between the first selected pair of keyframes", capture),
]
}

View File

@ -15,6 +15,7 @@ pub mod camera_track;
pub mod commands;
pub mod config;
pub mod demo;
pub mod ease_presets;
pub mod footage_check;
pub mod guard;
pub mod history;
@ -384,6 +385,8 @@ pub struct Session {
pub config: Option<Arc<dyn config::ConfigStore>>,
/// Keyboard shortcut presets.
pub keymaps: shortcuts::Keymaps,
/// User ease presets (Window ▸ Ease Presets; the built-in ones are in [`ease_presets`]).
pub ease_presets: Vec<ease_presets::EasePreset>,
/// Frontend-only commands offered for binding.
pub ui_commands: Vec<shortcuts::UiCommand>,
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
@ -493,6 +496,7 @@ impl Default for Session {
prefs_revision: 0,
config: None,
keymaps: shortcuts::Keymaps::default(),
ease_presets: vec![],
ui_commands: vec![],
shortcut_table: std::sync::OnceLock::new(),
autosave: autosave::AutoSaveState::default(),
@ -963,6 +967,8 @@ mod tests_color_view;
#[cfg(test)]
mod tests_disk_cache;
#[cfg(test)]
mod tests_ease_presets;
#[cfg(test)]
mod tests_effects;
#[cfg(test)]
mod tests_essential;

View File

@ -3,12 +3,13 @@
use std::path::Path;
use crate::autosave::{self, Sentinel};
use crate::ease_presets::EASE_PRESETS_FILE;
use crate::prefs::{PREFS_FILE, Prefs};
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, ShortcutTable, UiCommand};
use crate::{EngineError, Result, Session};
impl Session {
/// Load settings and shortcut presets from the config store and apply them.
/// Load settings, shortcut and ease presets from the config store and apply them.
pub fn load_settings(&mut self) {
if let Some(c) = &self.config {
if let Some(t) = c.read(PREFS_FILE) {
@ -17,6 +18,9 @@ impl Session {
if let Some(t) = c.read(SHORTCUTS_FILE) {
self.keymaps = Keymaps::from_json(&t);
}
if let Some(t) = c.read(EASE_PRESETS_FILE) {
self.ease_presets = crate::ease_presets::parse(&t);
}
}
self.prefs_changed();
self.shortcuts_changed();

View File

@ -0,0 +1,164 @@
//! 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);
}