Merge pull request #267 from storytold/feat/254-ease-presets

Keyframes: ease presets apply saved curves to keyframe pairs (#254)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-08 17:44:02 +07:00
commit 0d3b5ed286
18 files changed
+1020 -7

No files matched your search

+1 -1
View File
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show Panel",
[],
None,
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor}",
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor|easePresets}",
always
),
fe!(
+1
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
})
+272
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),
]
}
+6
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;
+1
View File
@@ -1200,6 +1200,7 @@ Window
@dynamic:openViewers
---
Create Nulls From Paths | window.panel {"panel":"createNullsFromPaths"}
Ease Presets | window.panel {"panel":"easePresets"}
VR Comp Editor | window.panel {"panel":"vrCompEditor"}
@dynamic:scriptPanels
Help
+5 -1
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();
+164
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);
}
+226
View File
@@ -0,0 +1,226 @@
//! Ease curves: the temporal ease of one keyframe segment, independent of its duration and values,
//! so it can be kept as a preset and applied to any pair of keyframes.
//!
//! A curve is the out side of the first key and the in side of the second, in the Keyframe Velocity
//! dialog's terms: influence in percent of the segment, and speed relative to the segment's average
//! speed (1 = linear, 0 = at rest). In the normalised value graph (time and value 0 → 1) its handles
//! are the points (x1, y1) = (out influence, out speed × out influence) and
//! (x2, y2) = (1 − in influence, 1 − in speed × in influence).
use serde::{Deserialize, Serialize};
use crate::{Ease, Interp, Keyframe, Value, effective_ease, secs, spatial_segment_length};
/// Smallest influence (percent), as in the Keyframe Velocity dialog.
const MIN_INFLUENCE: f64 = 0.1;
/// Relative speeds are kept within ± this (a handle 100 times steeper than linear).
const MAX_SPEED: f64 = 100.0;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EaseCurve {
pub out_influence: f64,
pub out_speed: f64,
pub in_influence: f64,
pub in_speed: f64,
}
impl EaseCurve {
/// Constant speed: both handles a third of the way along the straight line.
pub const LINEAR: EaseCurve = EaseCurve { out_influence: 100.0 / 3.0, out_speed: 1.0, in_influence: 100.0 / 3.0, in_speed: 1.0 };
/// The curve with influences in 0.1–100 % and speeds within ±100; `None` when a number isn't
/// finite.
pub fn sanitized(self) -> Option<EaseCurve> {
if ![self.out_influence, self.out_speed, self.in_influence, self.in_speed].iter().all(|v| v.is_finite()) {
return None;
}
let inf = |v: f64| v.clamp(MIN_INFLUENCE, 100.0);
let speed = |v: f64| v.clamp(-MAX_SPEED, MAX_SPEED);
Some(EaseCurve {
out_influence: inf(self.out_influence),
out_speed: speed(self.out_speed),
in_influence: inf(self.in_influence),
in_speed: speed(self.in_speed),
})
}
/// Handles in the normalised value graph: `[x1, y1, x2, y2]`.
pub fn handles(&self) -> [f64; 4] {
let (o, i) = (self.out_influence / 100.0, self.in_influence / 100.0);
[o, self.out_speed * o, 1.0 - i, 1.0 - self.in_speed * i]
}
/// The curve with handles `[x1, y1, x2, y2]` (each influence at least 0.1 %); `None` when a
/// number isn't finite.
pub fn from_handles([x1, y1, x2, y2]: [f64; 4]) -> Option<EaseCurve> {
let o = x1.clamp(MIN_INFLUENCE / 100.0, 1.0);
let i = (1.0 - x2).clamp(MIN_INFLUENCE / 100.0, 1.0);
EaseCurve { out_influence: o * 100.0, out_speed: y1 / o, in_influence: i * 100.0, in_speed: (1.0 - y2) / i }.sanitized()
}
/// Ease segment `i` → `i + 1` with this curve: the out side of key `i` and the in side of key
/// `i + 1` become Bezier, scaled to the segment per dimension (along the motion path for
/// spatial values, as overall progress for paths and gradients), as Easy Ease shapes them.
/// Auto-Bezier keys keep their other side as it plays. False when there is no such segment or
/// its values don't interpolate.
pub fn apply(&self, keys: &mut [Keyframe], i: usize, spatial: bool) -> bool {
let Some(slopes) = segment_slopes(keys, i, spatial) else { return false };
let side = |influence: f64, speed: f64| -> Vec<Ease> { slopes.iter().map(|s| Ease { speed: speed * s, influence: influence / 100.0 }).collect() };
let (out, inn) = (side(self.out_influence, self.out_speed), side(self.in_influence, self.in_speed));
freeze_auto(keys, i, spatial);
freeze_auto(keys, i + 1, spatial);
if let Some(a) = keys.get_mut(i) {
a.out_interp = Interp::Bezier;
a.out_ease = out;
}
if let Some(b) = keys.get_mut(i + 1) {
b.in_interp = Interp::Bezier;
b.in_ease = inn;
}
true
}
/// The curve segment `i` → `i + 1` plays (a linear side counts as speed 1 at 33.3 %), measured
/// on the dimension that changes most. `None` for hold segments, values that don't
/// interpolate and segments whose value doesn't change.
pub fn measure(keys: &[Keyframe], i: usize, spatial: bool) -> Option<EaseCurve> {
let slopes = segment_slopes(keys, i, spatial)?;
let (a, b) = (keys.get(i)?, keys.get(i + 1)?);
if a.out_interp == Interp::Hold {
return None;
}
let (d, slope) = slopes.iter().copied().enumerate().max_by(|x, y| x.1.abs().total_cmp(&y.1.abs()))?;
if slope.abs() < 1e-12 {
return None;
}
let is_spatial = is_spatial(&a.value, spatial);
let d = if is_spatial { 0 } else { d };
let side =
|k: usize, bezier: bool, out: bool| if bezier { effective_ease(keys, k, d, is_spatial, out) } else { Ease { speed: slope, influence: 1.0 / 3.0 } };
let out = side(i, a.out_interp == Interp::Bezier, true);
let inn = side(i + 1, b.in_interp == Interp::Bezier, false);
EaseCurve { out_influence: out.influence * 100.0, out_speed: out.speed / slope, in_influence: inn.influence * 100.0, in_speed: inn.speed / slope }
.sanitized()
}
}
fn is_spatial(v: &Value, spatial: bool) -> bool {
spatial && matches!(v, Value::Vec2(_) | Value::Vec3(_))
}
/// Average speed of segment `i` → `i + 1` for each ease entry of its keys: per dimension, along
/// the motion path (spatial), or 1 / duration (paths and gradients ease their overall progress).
fn segment_slopes(keys: &[Keyframe], i: usize, spatial: bool) -> Option<Vec<f64>> {
let (a, b) = (keys.get(i)?, keys.get(i.checked_add(1)?)?);
let dur = secs(b.time) - secs(a.time);
if !a.value.interpolates() || !dur.is_finite() || dur <= 0.0 {
return None;
}
let n = a.value.dims().max(1);
let (ca, cb) = (a.value.components(), b.value.components());
Some(if is_spatial(&a.value, spatial) {
vec![spatial_segment_length(keys, i) / dur; n]
} else if ca.is_empty() || ca.len() != cb.len() {
vec![1.0 / dur; n]
} else {
ca.iter().zip(&cb).map(|(x, y)| (y - x) / dur).collect()
})
}
/// Store the eases an Auto-Bezier key `k` plays and turn Auto-Bezier off, so editing one side
/// leaves the other as it was.
fn freeze_auto(keys: &mut [Keyframe], k: usize, spatial: bool) {
let Some(key) = keys.get(k).filter(|key| key.auto_bezier) else { return };
let is_spatial = is_spatial(&key.value, spatial);
let n = key.value.dims().max(1);
let side = |out: bool| -> Vec<Ease> { (0..n).map(|d| effective_ease(keys, k, if is_spatial { 0 } else { d }, is_spatial, out)).collect() };
let (inn, out) = (side(false), side(true));
if let Some(key) = keys.get_mut(k) {
key.in_ease = inn;
key.out_ease = out;
key.auto_bezier = false;
}
}
#[cfg(test)]
mod tests {
use effectcraft_time::Tick;
use super::*;
use crate::{ease_progress, evaluate};
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
const CURVE: EaseCurve = EaseCurve { out_influence: 60.0, out_speed: 0.0, in_influence: 20.0, in_speed: 2.0 };
#[test]
fn handles_round_trip_and_reject_non_finite_numbers() {
let h = CURVE.handles();
assert!(h.iter().zip([0.6, 0.0, 0.8, 0.6]).all(|(a, b)| (a - b).abs() < 1e-12), "{h:?}");
let back = EaseCurve::from_handles(h).unwrap();
for (a, b) in [(back.out_influence, 60.0), (back.out_speed, 0.0), (back.in_influence, 20.0), (back.in_speed, 2.0)] {
assert!((a - b).abs() < 1e-9, "{back:?}");
}
// Influences stay ≥ 0.1 % even with a handle on the key.
assert!((EaseCurve::from_handles([0.0, 0.0, 1.0, 1.0]).unwrap().out_influence - MIN_INFLUENCE).abs() < 1e-12);
assert!(EaseCurve::from_handles([f64::NAN, 0.0, 0.5, 1.0]).is_none());
assert!(EaseCurve { out_speed: f64::INFINITY, ..CURVE }.sanitized().is_none());
assert_eq!(EaseCurve { in_speed: 1e9, ..CURVE }.sanitized().unwrap().in_speed, MAX_SPEED);
}
#[test]
fn applied_curve_scales_to_each_dimension_and_measures_back() {
let mut keys = vec![Keyframe::new(s(1.0), Value::Vec2([0.0, 100.0])), Keyframe::new(s(3.0), Value::Vec2([200.0, 50.0]))];
assert!(CURVE.apply(&mut keys, 0, false));
assert_eq!((keys[0].out_interp, keys[1].in_interp), (Interp::Bezier, Interp::Bezier));
// X goes 200 in 2 s (100/s), Y −50 (−25/s): speeds are relative to those.
assert_eq!(keys[1].in_ease, vec![Ease { speed: 200.0, influence: 0.2 }, Ease { speed: -50.0, influence: 0.2 }]);
// Every dimension follows the curve's progress.
let p = evaluate(&keys, s(2.0), false).unwrap().as_vec2();
let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5);
assert!((p[0] - 200.0 * f).abs() < 1e-6 && (p[1] - (100.0 - 50.0 * f)).abs() < 1e-6, "{p:?}");
let m = EaseCurve::measure(&keys, 0, false).unwrap();
assert!((m.out_influence - 60.0).abs() < 1e-9 && (m.in_speed - 2.0).abs() < 1e-9, "{m:?}");
// No such segment / nothing to ease.
assert!(!CURVE.apply(&mut keys, 1, false));
let mut held = vec![Keyframe::new(s(0.0), Value::Bool(false)), Keyframe::new(s(1.0), Value::Bool(true))];
assert!(!CURVE.apply(&mut held, 0, false));
}
#[test]
fn spatial_curve_follows_the_motion_path() {
let mut keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))];
assert!(CURVE.apply(&mut keys, 0, true));
// 500 px along the path in 1 s.
assert!(keys[1].in_ease.iter().all(|e| (e.speed - 1000.0).abs() < 1e-6), "{:?}", keys[1].in_ease);
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5);
// (within the arc-length table's precision)
assert!((p[0] - 300.0 * f).abs() < 0.05 && (p[1] - 400.0 * f).abs() < 0.05, "{p:?} {f}");
}
#[test]
fn linear_and_auto_bezier_sides_measure_as_they_play() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(10.0))];
let m = EaseCurve::measure(&keys, 0, false).unwrap();
assert!((m.out_speed - 1.0).abs() < 1e-9 && (m.in_influence - 100.0 / 3.0).abs() < 1e-9, "{m:?}");
// Easing the second segment keeps the auto-Bezier middle key's incoming side.
let mut keys = vec![
Keyframe::new(s(0.0), Value::Scalar(0.0)),
Keyframe::new(s(1.0), Value::Scalar(10.0)).eased(),
Keyframe::new(s(2.0), Value::Scalar(30.0)).eased(),
];
keys[1].auto_bezier = true;
let before = evaluate(&keys, s(0.5), false);
assert!(CURVE.apply(&mut keys, 1, false));
assert!(!keys[1].auto_bezier);
assert_eq!(evaluate(&keys, s(0.5), false), before);
let held = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).hold(), Keyframe::new(s(1.0), Value::Scalar(10.0))];
assert!(EaseCurve::measure(&held, 0, false).is_none());
let flat = vec![Keyframe::new(s(0.0), Value::Scalar(5.0)), Keyframe::new(s(1.0), Value::Scalar(5.0))];
assert!(EaseCurve::measure(&flat, 0, false).is_none());
}
}
+2
View File
@@ -10,9 +10,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod ease_curve;
pub mod text_doc;
pub mod value;
pub use ease_curve::EaseCurve;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun};
+6 -1
View File
@@ -50,10 +50,12 @@ pub enum PanelKind {
CreateNullsFromPaths,
/// Window ▸ VR Comp Editor (our own panel).
VrCompEditor,
/// Window ▸ Ease Presets (our own panel).
EasePresets,
}
impl PanelKind {
pub const ALL: [PanelKind; 34] = [
pub const ALL: [PanelKind; 35] = [
PanelKind::Project,
PanelKind::EffectControls,
PanelKind::Composition,
@@ -88,6 +90,7 @@ impl PanelKind {
PanelKind::ContentAwareFill,
PanelKind::CreateNullsFromPaths,
PanelKind::VrCompEditor,
PanelKind::EasePresets,
];
pub fn title(self) -> &'static str {
match self {
@@ -127,6 +130,7 @@ impl PanelKind {
PanelKind::ContentAwareFill => "Content-Aware Fill",
PanelKind::CreateNullsFromPaths => "Create Nulls From Paths",
PanelKind::VrCompEditor => "VR Comp Editor",
PanelKind::EasePresets => "Ease Presets",
}
}
pub fn id(self) -> String {
@@ -367,6 +371,7 @@ pub fn workspace(name: &str) -> DockNode {
ContentAwareFill,
CreateNullsFromPaths,
VrCompEditor,
EasePresets,
],
),
),
+1
View File
@@ -674,6 +674,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("window.panel", "Render Queue", "レンダーキュー"),
("window.panel", "Timeline", "タイムライン"),
("window.panel", "Create Nulls From Paths", "パスからヌルを作成"),
("window.panel", "Ease Presets", "イーズプリセット"),
("window.panel", "VR Comp Editor", "VRコンポジションエディター"),
("", "Help", "ヘルプ"),
("help.docs", "EffectCraft Help...", "EffectCraftヘルプ..."),
+249
View File
@@ -0,0 +1,249 @@
//! Ease Presets panel (Window ▸ Ease Presets): easing curves kept by name and applied to pairs of
//! keyframes (our own design and presets). The value graph at the top edits a working curve by
//! dragging its two handles, and Apply eases the selected keyframes with it; From Keys reads the
//! curve between the first selected pair. Clicking a preset's thumbnail loads it and applies it.
//! Save Current keeps the working curve under the name in the field; Rename and Delete act on the
//! selected user preset. Every action is a `keys.easePreset.*` command.
//!
//! Automation ids: `easePresets.graph`, `easePresets.handle.out` / `.in`, `easePresets.apply`,
//! `easePresets.fromKeys`, `easePresets.name`, `easePresets.save`, `easePresets.rename`,
//! `easePresets.delete`, `easePresets.preset.<n>` (1-based, labelled with the preset's name).
use effectcraft_engine::ease_presets;
use effectcraft_engine::keyframe::EaseCurve;
use egui::epaint::CubicBezierShape;
use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::panel_kit as kit;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// Panel state (serde in the UI state).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct EasePanelState {
/// The curve the graph shows and Apply uses.
pub curve: EaseCurve,
/// The preset clicked last (Rename and Delete act on it).
pub selected: Option<String>,
/// Name for Save Current and Rename.
pub name: String,
}
impl Default for EasePanelState {
fn default() -> Self {
EasePanelState { curve: EaseCurve::LINEAR, selected: None, name: String::new() }
}
}
/// Value range of the graph: room for handles above and below the keys.
const V_MIN: f64 = -0.35;
const V_MAX: f64 = 1.35;
const TILE: egui::Vec2 = vec2(84.0, 64.0);
const GAP: f32 = 6.0;
const ROW: f32 = 30.0;
/// Height of the controls under the graph (numbers, three button rows, status, "Presets").
const CONTROLS: f32 = 154.0;
/// Panels at least this wide put the presets beside the graph and controls.
const SIDE_BY_SIDE: f32 = 560.0;
/// Screen point of normalised time `x` and value `v` in `r`, values `lo..hi` bottom to top.
fn to_screen(r: Rect, lo: f64, hi: f64, x: f64, v: f64) -> Pos2 {
pos2(r.min.x + r.width() * x as f32, r.max.y - r.height() * ((v - lo) / (hi - lo)) as f32)
}
/// The curve from (0, 0) to (1, 1) through its handles.
fn curve_shape(c: &EaseCurve, at: impl Fn(f64, f64) -> Pos2, stroke: Stroke) -> CubicBezierShape {
let [x1, y1, x2, y2] = c.handles();
CubicBezierShape::from_points_stroke([at(0.0, 0.0), at(x1, y1), at(x2, y2), at(1.0, 1.0)], false, Color32::TRANSPARENT, stroke)
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let mut st = app.ui.ease_presets.clone();
let pairs = ease_presets::selected_pairs(&app.session).len();
let x0 = rect.min.x + 10.0;
// Wide panels: graph and controls on the left, presets on the right; else presets below,
// with room for at least one row of them.
let side = rect.width() >= SIDE_BY_SIDE;
let w = if side { 300.0 } else { (rect.width() - 20.0).max(210.0) };
let room = rect.height() - 16.0 - CONTROLS - if side { 0.0 } else { TILE.y + GAP };
let g = Rect::from_min_size(pos2(x0, rect.min.y + 10.0), vec2(w, (w * 0.6).min(room).clamp(90.0, 220.0)));
graph(app, ui, g, &mut st.curve);
let c = st.curve;
let mut y = g.max.y + 4.0;
let numbers = format!("Out {:.1} % · {:.2} In {:.1} % · {:.2}", c.out_influence, c.out_speed, c.in_influence, c.in_speed);
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, numbers, Tokens::mono(11.0), t.text_faint);
y += 22.0;
let at = |x: f32, y: f32, width: f32| Rect::from_min_size(pos2(x, y), vec2(width, 24.0));
if kit::button(app, ui, at(x0, y, 90.0), "easePresets.apply", "Apply", pairs > 0) {
apply(app, json!({"curve": c}), pairs, "the curve");
}
if kit::button(app, ui, at(x0 + 96.0, y, 100.0), "easePresets.fromKeys", "From Keys", false)
&& let Some(v) = kit::exec(app, "keys.easePreset.capture", json!({}))
&& let Ok(curve) = serde_json::from_value(v)
{
st.curve = curve;
st.selected = None;
}
y += ROW;
let field = at(x0, y, (w - 116.0).max(80.0));
widgets::text_field(ui, field, &mut st.name, "Preset name", &t);
app.auto.add("easePresets.name", field, "Preset name");
let name = st.name.trim().to_string();
if kit::button(app, ui, at(field.max.x + 6.0, y, 110.0), "easePresets.save", "Save Current", false) {
if name.is_empty() {
app.ui.status = "Type a name for the preset first".into();
} else if let Some(v) = kit::exec(app, "keys.easePreset.save", json!({"name": name, "curve": c})) {
st.selected = v["name"].as_str().map(str::to_string);
app.ui.status = format!("Saved the ease preset “{}”", st.selected.as_deref().unwrap_or(&name));
}
}
y += ROW;
// Rename and Delete act on the selected user preset.
let user = st.selected.clone().filter(|n| app.session.ease_presets.iter().any(|q| &q.name == n));
let rename = kit::button(app, ui, at(x0, y, 90.0), "easePresets.rename", "Rename", false);
let delete = kit::button(app, ui, at(x0 + 96.0, y, 90.0), "easePresets.delete", "Delete", false);
match (&user, rename, delete) {
(None, true, _) | (None, _, true) => app.ui.status = "Select one of your own presets first".into(),
(Some(old), true, _) if name.is_empty() => app.ui.status = format!("Type a new name for “{old}” first"),
(Some(old), true, _) => {
if let Some(v) = kit::exec(app, "keys.easePreset.rename", json!({"name": old, "newName": name})) {
st.selected = v["name"].as_str().map(str::to_string);
}
}
(Some(old), _, true) => {
let deleted = kit::exec(app, "keys.easePreset.delete", json!({"name": old})).is_some();
if deleted {
st.selected = None;
}
}
_ => {}
}
y += ROW;
let msg = if pairs > 0 {
format!("{pairs} keyframe pair{} selected", if pairs == 1 { "" } else { "s" })
} else {
"Select two or more neighbouring keyframes".into()
};
p.text(pos2(x0, y + 4.0), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_faint);
y += 16.0;
let head = if side { pos2(x0 + w + 16.0, rect.min.y + 6.0) } else { pos2(x0, y) };
kit::label(ui, head, "Presets", &t);
let list = Rect::from_min_max(head + vec2(0.0, 22.0), pos2(rect.max.x - 10.0, rect.max.y - 6.0));
if let Some((name, curve)) = grid(app, ui, list, st.selected.as_deref()) {
st.curve = curve;
if pairs > 0 {
apply(app, json!({"preset": name}), pairs, &format!("“{name}”"));
}
st.selected = Some(name);
}
if st != app.ui.ease_presets {
app.ui.ease_presets = st;
}
}
fn apply(app: &mut EffectcraftApp, params: serde_json::Value, pairs: usize, what: &str) {
if pairs == 0 {
app.ui.status = "Select two or more neighbouring keyframes of a property".into();
} else if let Some(r) = kit::exec(app, "keys.easePreset.apply", params) {
app.ui.status = format!("Eased {} keyframe pair(s) with {what}", r["pairs"]);
}
}
/// The working curve's value graph: keys at (0, 0) and (1, 1), the linear ease for reference, and
/// the two handles, dragged to edit the curve.
fn graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Rect, curve: &mut EaseCurve) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(g);
p.rect_filled(g, 3.0, t.field_bg);
p.rect_stroke(g, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
app.auto.add("easePresets.graph", g, "Ease curve");
let plot = g.shrink2(vec2(16.0, 6.0));
let at = |x: f64, v: f64| to_screen(plot, V_MIN, V_MAX, x, v);
for v in [0.0, 1.0] {
p.line_segment([at(0.0, v), at(1.0, v)], Stroke::new(1.0, t.separator));
}
p.add(egui::Shape::dashed_line(&[at(0.0, 0.0), at(1.0, 1.0)], Stroke::new(1.0, t.separator), 4.0, 4.0));
p.add(curve_shape(curve, at, Stroke::new(2.0, t.accent)));
let [x1, y1, x2, y2] = curve.handles();
let mut next = None;
for (out, key, h) in [(true, at(0.0, 0.0), at(x1, y1)), (false, at(1.0, 1.0), at(x2, y2))] {
p.line_segment([key, h], Stroke::new(1.0, t.text_dim));
p.rect_filled(Rect::from_center_size(key, vec2(7.0, 7.0)), 1.0, t.keyframe);
let r = Rect::from_center_size(h, vec2(14.0, 14.0));
let resp = ui.interact(r, egui::Id::new(("easePresets.handle", out)), Sense::drag());
app.auto.add(if out { "easePresets.handle.out" } else { "easePresets.handle.in" }, r, if out { "Out handle" } else { "In handle" });
let hot = resp.hovered() || resp.dragged();
if hot {
ui.ctx().set_cursor_icon(if resp.dragged() { CursorIcon::Grabbing } else { CursorIcon::Grab });
}
p.circle_filled(h, if hot { 5.5 } else { 4.5 }, if hot { t.keyframe_selected } else { t.text });
if resp.dragged() {
let to = h + resp.drag_delta();
let x = f64::from((to.x - plot.min.x) / plot.width()).clamp(0.0, 1.0);
let v = (V_MIN + f64::from((plot.max.y - to.y) / plot.height()) * (V_MAX - V_MIN)).clamp(V_MIN, V_MAX);
next = EaseCurve::from_handles(if out { [x, v, x2, y2] } else { [x1, y1, x, v] });
}
}
if let Some(c) = next {
*curve = c;
}
}
/// Preset thumbnails in a wrapping grid that scrolls with the wheel; returns the clicked one.
fn grid(app: &mut EffectcraftApp, ui: &mut egui::Ui, list: Rect, selected: Option<&str>) -> Option<(String, EaseCurve)> {
let t = app.tokens;
let p = ui.painter().with_clip_rect(list);
let scroll_id = egui::Id::new("easePresets.scroll");
let mut scroll: f32 = ui.ctx().data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
if ui.rect_contains_pointer(list) {
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
}
let cols = (((list.width() + GAP) / (TILE.x + GAP)).floor() as usize).max(1);
let (mut clicked, mut count) = (None, 0);
for (n, (name, curve, built_in)) in ease_presets::presets(&app.session).enumerate() {
count = n + 1;
let r = Rect::from_min_size(list.min + vec2((n % cols) as f32 * (TILE.x + GAP), (n / cols) as f32 * (TILE.y + GAP) - scroll), TILE);
if !r.intersects(list) {
continue;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("easePresets.preset", n)), Sense::click());
app.auto.add(&format!("easePresets.preset.{count}"), r, name);
let on = selected == Some(name);
p.rect_filled(
r,
4.0,
if on {
t.row_selected
} else if resp.hovered() {
t.hover
} else {
t.field_bg
},
);
let thumb = Rect::from_min_size(r.min, vec2(TILE.x, TILE.y - 16.0)).shrink2(vec2(12.0, 7.0));
let [_, y1, _, y2] = curve.handles();
let (lo, hi) = (y1.min(y2).min(0.0), y1.max(y2).max(1.0));
p.add(curve_shape(&curve, |x, v| to_screen(thumb, lo, hi, x, v), Stroke::new(1.5, if on { t.text } else { t.text_dim })));
widgets::text_fit(
&p,
pos2(r.center().x, r.max.y - 9.0),
Align2::CENTER_CENTER,
name,
Tokens::ui(11.0),
TILE.x - 6.0,
if built_in { t.text_dim } else { t.text },
);
if resp.on_hover_text(format!("{name}: click to apply it to the selected keyframes")).clicked() {
clicked = Some((name.to_string(), curve));
}
}
let content = count.div_ceil(cols) as f32 * (TILE.y + GAP);
scroll = scroll.min((content - list.height()).max(0.0));
ui.ctx().data_mut(|d| d.insert_temp(scroll_id, scroll));
clicked
}
+2
View File
@@ -7,6 +7,7 @@ pub mod content_fill_panel;
pub mod delete_items;
pub mod dialogs;
pub mod dialogs_3d;
pub mod ease_presets;
pub mod effect_controls;
pub mod effects_presets;
pub mod essential;
@@ -127,6 +128,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect),
PanelKind::CreateNullsFromPaths => path_vr_panels::create_nulls(app, ui, rect),
PanelKind::VrCompEditor => path_vr_panels::vr_editor(app, ui, rect),
PanelKind::EasePresets => ease_presets::show(app, ui, rect),
}
}
+4
View File
@@ -667,6 +667,9 @@ pub struct UiState {
/// Lumetri Scopes panel options.
#[serde(default)]
pub scopes: crate::panels::scopes_panel::ScopesState,
/// Ease Presets panel: working curve, selected preset, name field.
#[serde(default)]
pub ease_presets: crate::panels::ease_presets::EasePanelState,
}
impl Default for UiState {
@@ -721,6 +724,7 @@ impl Default for UiState {
mini_flowchart: None,
flowchart: Default::default(),
scopes: Default::default(),
ease_presets: Default::default(),
}
}
}
+13 -2
View File
@@ -193,11 +193,22 @@ pub fn text_fit(p: &egui::Painter, pos: egui::Pos2, align: Align2, text: &str, f
/// Rounded search field with a magnifier icon.
pub fn search_field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens) -> Response {
field(ui, rect, text, hint, t, true)
}
/// Rounded text field.
pub fn text_field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens) -> Response {
field(ui, rect, text, hint, t, false)
}
fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, search: bool) -> Response {
let h = rect.height();
ui.painter().rect_filled(rect, h / 2.0, t.field_bg);
ui.painter().rect_stroke(rect, h / 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(12.0, 12.0)), Icon::Search, t.text_dim);
let inner = Rect::from_min_max(pos2(rect.min.x + 22.0, rect.min.y + 1.0), pos2(rect.max.x - 8.0, rect.max.y - 1.0));
if search {
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(12.0, 12.0)), Icon::Search, t.text_dim);
}
let inner = Rect::from_min_max(pos2(rect.min.x + if search { 22.0 } else { 9.0 }, rect.min.y + 1.0), pos2(rect.max.x - 8.0, rect.max.y - 1.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
child.add(
egui::TextEdit::singleline(text)
+42 -2
View File
@@ -1,6 +1,7 @@
//! Headless checks for Timeline keyframes as a person uses them: dragging keys over many frames
//! in one gesture (the drag used to end after the first frame), Shift-snapping, and Ctrl+C /
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses.
//! in one gesture (the drag used to end after the first frame), Shift-snapping, Ctrl+C /
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses, and
//! easing selected keys from the Ease Presets panel.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
@@ -253,3 +254,42 @@ fn k_follows_the_revealed_properties_and_auto_select_picks_the_speed_graph_for_p
h.run_steps(3);
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_some(), "value graph: X and Y");
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
#[test]
fn clicking_an_ease_preset_eases_the_selected_keys_and_handles_edit_the_curve() {
use effectcraft_engine::keyframe::{Ease, Interp};
use effectcraft_ui_egui::dock::PanelKind;
let (mut h, id, _) = harness();
h.state_mut().session.execute("prop.select", json!({"layer": id.0, "path": "transform/opacity"})).unwrap();
h.state_mut().show_panel(PanelKind::EasePresets);
h.state_mut().ui.maximized = Some(PanelKind::EasePresets);
h.run_steps(4);
let opacity =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().clone();
let tile = h.state().auto.query("easePresets.preset.").into_iter().find(|e| e.label == "Ease In-Out").expect("the Ease In-Out thumbnail").id.clone();
let steps = h.state().session.history.undo.len();
let at = center(&h, &tile);
click_with(&mut h, at, Default::default());
let op = opacity(&h);
let eased = Ease { speed: 0.0, influence: 0.5 };
assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease.clone()), (Interp::Bezier, vec![eased]));
assert_eq!((op.keys[1].in_interp, op.keys[1].in_ease.clone()), (Interp::Bezier, vec![eased]));
assert_eq!(h.state().session.history.undo.len(), steps + 1);
assert_eq!(h.state().ui.ease_presets.selected.as_deref(), Some("Ease In-Out"));
// Dragging the out handle to the right lengthens its influence; Apply uses the edited curve.
let graph = h.state().auto.find("easePresets.graph").unwrap().rect;
let from = center(&h, "easePresets.handle.out");
drag(&mut h, from, from + egui::vec2(graph[2] * 0.2, 0.0), Default::default());
let out = h.state().ui.ease_presets.curve.out_influence;
assert!(out > 60.0 && out < 75.0, "{out}");
let apply = center(&h, "easePresets.apply");
click_with(&mut h, apply, Default::default());
let applied = opacity(&h).keys[0].out_ease[0].influence;
assert!((applied * 100.0 - out).abs() < 1e-9, "{applied} vs {out}");
assert_eq!(h.state().session.history.undo.len(), steps + 2);
}
+3
View File
@@ -185,6 +185,9 @@ The most important missing piece: it turns every other estimate here into a meas
- After Effects SDK plug-ins cannot run in EffectCraft. Our own WebAssembly plug-in API exists
([plugins.md](plugins.md)). Grow it: documentation, examples, and original effects that cover
what the most common third-party plug-ins are used for (particles, glows, 3D objects, sabers).
- Done so far: ease presets (Window ▸ Ease Presets, `keys.easePreset.*`, #254) cover what
ease-curve plug-ins are used for: easing curves kept by name, drawn and edited in a value graph,
and applied to pairs of keyframes ([parity.md](parity.md#update-ease-presets-254)).
### G9. Reach
+22
View File
@@ -400,6 +400,28 @@ Browser in the web app (landed in M13.10: browser storage and File System Access
wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas.
## Update: ease presets (#254)
**Window ▸ Ease Presets** is our own panel for what ease-curve plug-ins are used for in After
Effects (G8): keep easing curves by name and apply them to pairs of keyframes. A curve is the out
handle of the first key and the in handle of the second, in the Keyframe Velocity dialog's terms:
influence in percent, and speed relative to the segment's average speed, so one curve fits any
duration and change of value. Applying it eases every pair of neighbouring selected keyframes of
each property in one undo step, per dimension, and along the motion path for spatial properties
(as Easy Ease does); an Auto Bezier key keeps its other side. Twelve built-in curves (Linear,
Smooth, Ease In-Out Soft / plain / Strong, Accelerate and Decelerate with Strong variants, Expo
In-Out / Accelerate / Decelerate) are our own numbers; user presets are kept in the settings store
(`ease_presets.json`; a corrupt file or entry is skipped, never fatal). The panel draws each preset
as a thumbnail (a click applies it) and the working curve in a value graph whose two handles drag;
Apply, From Keys (the curve between the first selected pair), Save Current, Rename and Delete.
Commands: `keys.easePreset.apply {preset | curve}` (`curve` as `{outInfluence, outSpeed,
inInfluence, inSpeed}` or cubic-bezier handles `[x1, y1, x2, y2]`), `keys.easePreset.capture`,
`keys.easePreset.save {name, curve?}`, `keys.easePreset.list`, `keys.easePreset.rename {name,
newName}`, `keys.easePreset.delete {name}`; automation ids `easePresets.*`.
Not yet: curves with more than one bend (bounce and elastic shapes, which need keyframes in
between), preset import / export files, and a live preview on the keys while dragging the handles.
## Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness
- **The app froze every 10 s on Windows** (M12.8): the cache budgets' memory reading started