From 0717f12246684ed50da5dd56d53baf748f4e39fb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 17:17:11 +0700 Subject: [PATCH] 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 --- crates/engine/src/commands/mod.rs | 1 + crates/engine/src/ease_presets.rs | 272 ++++++++++++++++++++++++ crates/engine/src/lib.rs | 6 + crates/engine/src/session_settings.rs | 6 +- crates/engine/src/tests_ease_presets.rs | 164 ++++++++++++++ 5 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/ease_presets.rs create mode 100644 crates/engine/src/tests_ease_presets.rs diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index bce740b6..bf18063d 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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 }) diff --git a/crates/engine/src/ease_presets.rs b/crates/engine/src/ease_presets.rs new file mode 100644 index 00000000..f7be97d2 --- /dev/null +++ b/crates/engine/src/ease_presets.rs @@ -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 { + 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 { + 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> = 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 { + 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 = vec![]; + for entry in doc.get("presets").and_then(Value::as_array).into_iter().flatten() { + let preset = serde_json::from_value::(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) -> 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 { + 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 { + 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 { + let parsed = match v.as_array() { + Some(a) => { + let h: Option> = 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::(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 { + 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 { + 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 { + Ok(json!(captured(s, "keys.easePreset.capture")?)) +} + +fn save(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { + Ok(presets(s).map(|(name, curve, built_in)| json!({"name": name, "builtIn": built_in, "curve": curve})).collect()) +} + +pub fn specs() -> Vec { + 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), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4da7ca83..db21709a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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>, /// 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, /// Frontend-only commands offered for binding. pub ui_commands: Vec, /// 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; diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 85bc5150..281c5ab4 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -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(); diff --git a/crates/engine/src/tests_ease_presets.rs b/crates/engine/src/tests_ease_presets.rs new file mode 100644 index 00000000..5f064181 --- /dev/null +++ b/crates/engine/src/tests_ease_presets.rs @@ -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 { + 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::>(), ["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); +}