mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
18 files changed
+1020
-7
No files matched your search
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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ヘルプ..."),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user