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:
parent
cc594a7385
commit
0717f12246
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue