Plug-ins: Ease Presets moves out of core into a bundled ScriptUI panel

The Ease Presets panel (#254) is modelled on a third-party panel, so,
as with third-party tools in After Effects, it isn't core any more: it
is `extensions/scriptui-panels/Ease Presets.jsx`, a ScriptUI panel
bundled as an optional extension (Window ▸ Ease Presets.jsx), written
only against the public scripting API. Clicking a preset eases every
pair of neighbouring selected keyframes in one undo step (per
dimension, along the motion path for spatial properties, the keys'
other sides as they play); the fields edit the working curve, which the
panel draws; From Keys, Save, Rename and Delete as before. The twelve
built-in curves are unchanged.

Scripting API fixes it needed (After Effects parity):
- `app.settings` lasts between runs (`script_settings.json` in the
  settings folder; `script.settings.get` / `script.settings.save`).
- Per-key methods (`setTemporalEaseAtKey`, `setInterpolationTypeAtKey`,
  `removeKey`, the continuous / auto-Bezier / roving setters) no longer
  change the user's key selection (they selected the key they edited).
- `keyInTemporalEase` / `keyOutTemporalEase` report linear and
  auto-Bezier sides as they play, `keyIn/OutSpatialTangent` the
  tangents the motion path plays.
- `selectedProperties` includes the properties of selected keyframes.
- `isInterpolationTypeValid` is false for Linear / Bezier on values that
  only hold.

Removed from core: Window ▸ Ease Presets, the `keys.easePreset.*`
commands, `engine::ease_presets` and the `ease_presets.json` store,
`keyframe::EaseCurve` (nothing else used it) and the egui panel. User
presets saved by the v0.6.0 panel move into the panel's app.settings
section the first time settings load (the old file is left as is).
Docs: plugins.md (bundled extensions), parity.md, gaps.md G8, agents.md.

Test: app_settings_last_between_runs,
per_key_methods_keep_the_key_selection_and_report_eases_as_they_play,
spatial_tangents_are_reported_as_the_path_plays (each fails before),
the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step,
a_typed_curve_scales_to_each_dimension_and_follows_motion_paths,
user_presets_save_rename_delete_and_persist,
presets_saved_by_the_old_core_panel_carry_over (script, through the
panel's controls), ease_presets_panel_eases_the_selected_keys_from_its_list
(UI: the panel docks and a click on a preset eases the keys).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 21:03:12 +07:00
1 parent 9da33c157e
commit da165724e9
29 files changed
+1113 -1060

No files matched your search

+2
View File
@@ -69,6 +69,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
(sidecars `.attribution`).
- Bundled extension (ScriptUI panel) with its twelve built-in ease presets:
`extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`).
- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
## Trained models (optional downloads, not bundled)
+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|easePresets}",
"{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}",
always
),
fe!(
-1
View File
@@ -204,7 +204,6 @@ 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
})
+105 -17
View File
@@ -3,11 +3,15 @@
//! * **Installed scripts** live in the settings store under `Scripts/` (File ▸ Scripts ▸ Install
//! Script File…) and `Scripts/ScriptUI Panels/` (Install ScriptUI Panel…), next to the
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
//! lists them with the bundled sample scripts ([`SAMPLES`]) and runs them by name; ScriptUI
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
//! panels appear at the bottom of the Window menu and open as dockable panels
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
//! section and key that last between runs ([`SCRIPT_SETTINGS_FILE`]).
use std::collections::BTreeMap;
use serde::Serialize;
use serde_json::{Value, json};
@@ -20,14 +24,88 @@ pub const SCRIPTS_DIR: &str = "Scripts";
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
/// keeps them in its preferences.
pub const SCRIPT_SETTINGS_FILE: &str = "script_settings.json";
/// User presets of the core Ease Presets panel of v0.6.0 (#254) and where the Ease Presets
/// ScriptUI panel that replaced it keeps them: (file, section, key). The file's text moves there
/// unchanged (the panel reads the same JSON) the first time settings load.
const LEGACY_EASE_PRESETS: (&str, &str, &str) = ("ease_presets.json", "Ease Presets", "userPresets");
/// Longest section, key and value a script may store (characters / bytes).
const MAX_SETTING_NAME: usize = 256;
const MAX_SETTING_VALUE: usize = 1 << 20;
/// Sample scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, code).
pub const SAMPLES: &[(&str, bool, &str)] = &[
("Create Null at Selected Layers.jsx", false, include_str!("../../scripts/Create Null at Selected Layers.jsx")),
("Rename Layers.jsx", false, include_str!("../../scripts/Rename Layers.jsx")),
("Render Queue Batch.jsx", false, include_str!("../../scripts/Render Queue Batch.jsx")),
("Sort Layers by In Point.jsx", false, include_str!("../../scripts/Sort Layers by In Point.jsx")),
("Layer Tools.jsx", true, include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")),
/// Scripts' `app.settings`: values by section and key.
pub type ScriptSettings = BTreeMap<String, BTreeMap<String, String>>;
/// Load scripts' settings from the settings store (a corrupt file gives none), moving user ease
/// presets saved before Ease Presets became a ScriptUI panel into its section.
pub(crate) fn load_script_settings(s: &mut Session) {
let Some(cfg) = s.config.clone() else { return };
s.script_settings = cfg
.read(SCRIPT_SETTINGS_FILE)
.and_then(|t| serde_json::from_str(&t).map_err(|e| log::warn!("{SCRIPT_SETTINGS_FILE} is not valid ({e}); scripts start without their settings")).ok())
.unwrap_or_default();
let (file, section, key) = LEGACY_EASE_PRESETS;
if let Some(text) = cfg.read(file)
&& !s.script_settings.get(section).is_some_and(|m| m.contains_key(key))
{
s.script_settings.entry(section.into()).or_default().insert(key.into(), text);
if let Err(e) = store_script_settings(s) {
log::warn!("cannot move {file} into the Ease Presets panel's settings: {e}");
}
}
}
fn store_script_settings(s: &Session) -> Result<()> {
let Some(cfg) = &s.config else { return Ok(()) };
let text = serde_json::to_string_pretty(&s.script_settings).map_err(|e| EngineError::Other(format!("script settings: {e}")))?;
cfg.write(SCRIPT_SETTINGS_FILE, &text).map_err(|e| EngineError::Other(format!("cannot save script settings: {e}")))
}
fn setting_name<'a>(p: &'a Value, k: &str, c: &str) -> Result<&'a str> {
let v = str_p(p, k).ok_or_else(|| bad(c, format!("missing `{k}`")))?;
if v.chars().count() > MAX_SETTING_NAME {
return Err(bad(c, format!("`{k}` is longer than {MAX_SETTING_NAME} characters")));
}
Ok(v)
}
/// `script.settings.get {section, key}` → `{have, value}` (`value` "" when there is none).
fn settings_get(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "script.settings.get";
let (section, key) = (setting_name(p, "section", C)?, setting_name(p, "key", C)?);
let v = s.script_settings.get(section).and_then(|m| m.get(key));
Ok(json!({"have": v.is_some(), "value": v.cloned().unwrap_or_default()}))
}
/// `script.settings.save {section, key, value}`: kept in the settings store.
fn settings_save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "script.settings.save";
let (section, key) = (setting_name(p, "section", C)?, setting_name(p, "key", C)?);
let value = match p.get("value") {
Some(Value::String(v)) => v.clone(),
Some(v) if !v.is_null() => v.to_string(),
_ => return Err(bad(C, "missing `value`")),
};
if value.len() > MAX_SETTING_VALUE {
return Err(bad(C, format!("`value` is larger than {MAX_SETTING_VALUE} bytes")));
}
s.script_settings.entry(section.into()).or_default().insert(key.into(), value);
store_script_settings(s)?;
Ok(Value::Null)
}
/// Scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, source, code).
/// `sample`s show how to script EffectCraft; `extension`s (in `extensions/`) are optional tools
/// built on the public scripting API, outside the core, like third-party scripts in After Effects.
pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
("Create Null at Selected Layers.jsx", false, "sample", include_str!("../../scripts/Create Null at Selected Layers.jsx")),
("Rename Layers.jsx", false, "sample", include_str!("../../scripts/Rename Layers.jsx")),
("Render Queue Batch.jsx", false, "sample", include_str!("../../scripts/Render Queue Batch.jsx")),
("Sort Layers by In Point.jsx", false, "sample", include_str!("../../scripts/Sort Layers by In Point.jsx")),
("Layer Tools.jsx", true, "sample", include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")),
("Ease Presets.jsx", true, "extension", include_str!("../../../../extensions/scriptui-panels/Ease Presets.jsx")),
];
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
@@ -37,7 +115,7 @@ pub struct ScriptEntry {
pub name: String,
/// In the ScriptUI Panels folder: opens as a dockable panel.
pub panel: bool,
/// `installed` or `sample`.
/// `installed`, `sample` or `extension` (bundled, see [`BUNDLED`]).
pub source: &'static str,
}
@@ -46,7 +124,7 @@ fn is_script(name: &str) -> bool {
l.ends_with(".jsx") || l.ends_with(".js")
}
/// Installed scripts and panels, then the samples not shadowed by an installed one, by name.
/// Installed scripts and panels, then the bundled ones not shadowed by an installed one, by name.
pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
let mut v: Vec<ScriptEntry> = vec![];
if let Some(cfg) = &s.config {
@@ -56,16 +134,16 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
}
}
}
for (name, panel, _) in SAMPLES {
for (name, panel, source, _) in BUNDLED {
if !v.iter().any(|e| e.name == *name && e.panel == *panel) {
v.push(ScriptEntry { name: name.to_string(), panel: *panel, source: "sample" });
v.push(ScriptEntry { name: name.to_string(), panel: *panel, source });
}
}
v.sort_by(|a, b| a.panel.cmp(&b.panel).then(a.name.to_lowercase().cmp(&b.name.to_lowercase())));
v
}
/// The code of an installed or sample script / panel.
/// The code of an installed or bundled script / panel.
pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> Option<(bool, String)> {
if let Some(cfg) = &s.config {
for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
@@ -77,7 +155,7 @@ pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> Option<(bool
}
}
}
SAMPLES.iter().find(|(n, p, _)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, c)| (*p, c.to_string()))
BUNDLED.iter().find(|(n, p, ..)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, _, c)| (*p, c.to_string()))
}
fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
@@ -120,10 +198,10 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
}
/// Run an installed or sample script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
/// Run an installed or bundled script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
/// this way gets `this` = a floating palette-like panel window.
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or sample script `{name}`")))?;
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
if panel {
return open_panel(s, &json!({"name": name}));
}
@@ -241,7 +319,7 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| install(s, p, true)
),
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample}]", list),
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
cmd!(
"window.scriptPanel",
"ScriptUI Panel",
@@ -260,6 +338,16 @@ pub fn specs() -> Vec<CommandSpec> {
always,
restore_panels
),
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
cmd!(
"script.settings.save",
"Save Script Setting",
[],
None,
"{section, key, value (text)} → kept in the settings store (app.settings.saveSetting)",
always,
settings_save
),
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
crate::query!(
"scriptui.get",
-272
View File
@@ -1,272 +0,0 @@
//! 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),
]
}
+4 -6
View File
@@ -15,7 +15,6 @@ 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;
@@ -406,8 +405,9 @@ 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>,
/// Scripts' `app.settings`, kept in the settings store (see
/// [`commands::scripts::SCRIPT_SETTINGS_FILE`]).
pub script_settings: commands::scripts::ScriptSettings,
/// Frontend-only commands offered for binding.
pub ui_commands: Vec<shortcuts::UiCommand>,
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
@@ -521,7 +521,7 @@ impl Default for Session {
prefs_revision: 0,
config: None,
keymaps: shortcuts::Keymaps::default(),
ease_presets: vec![],
script_settings: Default::default(),
ui_commands: vec![],
shortcut_table: std::sync::OnceLock::new(),
autosave: autosave::AutoSaveState::default(),
@@ -1008,8 +1008,6 @@ 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,7 +1200,6 @@ 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 -5
View File
@@ -3,13 +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, shortcut and ease presets from the config store and apply them.
/// Load settings, shortcut presets and scripts' settings 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) {
@@ -18,10 +18,8 @@ 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);
}
}
crate::commands::scripts::load_script_settings(self);
self.prefs_changed();
self.shortcuts_changed();
}
-164
View File
@@ -1,164 +0,0 @@
//! 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
@@ -1,226 +0,0 @@
//! 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,11 +10,9 @@
#![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};
+4 -1
View File
@@ -14,7 +14,8 @@
//! ```
//!
//! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups,
//! `executeCommand`/`findMenuCommandId`, `scheduleTask`), `Project`, `ItemCollection`,
//! `executeCommand`/`findMenuCommandId`, `scheduleTask`, `settings` kept in the settings
//! folder), `Project`, `ItemCollection`,
//! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/
//! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes,
//! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`,
@@ -82,6 +83,8 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_ease_presets;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_ui;
+35 -13
View File
@@ -92,6 +92,20 @@ fn label_index(l: Label) -> usize {
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
}
/// A comp's selected properties as `[layer, uid]`: the selected ones, then those with selected
/// keyframes (selecting a keyframe selects its property in After Effects, so scripts find
/// selected keys through `selectedProperties`).
fn selected_props(s: &Session, c: &effectcraft_project::Comp) -> Vec<J> {
let mut v: Vec<(u64, u64)> = vec![];
let keyed = s.state.selected_keys.iter().map(|k| (k.layer, k.prop));
for (l, p) in s.state.selected_props.iter().copied().chain(keyed) {
if c.layer(l).is_some() && !v.contains(&(l.0, p)) {
v.push((l.0, p));
}
}
v.into_iter().map(|(l, p)| json!([l, p])).collect()
}
fn item(s: &Session, a: &J) -> R {
let id = ItemId(u(a, "id").ok_or("missing id")?);
let it = s.project.item(id).ok_or("the item no longer exists (it was deleted)")?;
@@ -123,7 +137,7 @@ fn item(s: &Session, a: &J) -> R {
"duration": c.duration.seconds(), "frameRate": fps, "frameDuration": c.frame_duration().seconds(),
"layers": c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>(),
"selectedLayers": sel,
"selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::<Vec<_>>(),
"selectedProperties": selected_props(s, c),
"time": s.time_of(cid).seconds(),
"workAreaStart": c.work_area.0.seconds(), "workAreaDuration": (c.work_area.1 - c.work_area.0).seconds(),
"bgColor": c.background, "displayStartTime": c.display_start.seconds(),
@@ -321,6 +335,7 @@ fn node_json(c: &Comp, l: &Layer, uid: u64) -> R {
o["type"] = json!(p.value.kind_name());
o["dims"] = json!(p.value.dims());
o["spatial"] = json!(p.spatial);
o["interpolates"] = json!(p.value.interpolates());
o["numKeys"] = json!(p.keys.len());
o["expression"] = json!(p.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default());
o["expressionEnabled"] = json!(p.has_expression());
@@ -471,29 +486,36 @@ fn keys(s: &Session, a: &J) -> R {
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
let sel: Vec<Tick> = s.state.selected_keys.iter().filter(|k| k.layer == l.id && k.prop == uid).map(|k| k.time).collect();
let _ = cid;
let ease = |e: &[effectcraft_keyframe::Ease], n: usize| -> J {
let n = n.max(1);
// Influence is stored as a fraction; scripts see percent.
let mut v: Vec<J> = e.iter().map(|x| json!({"speed": x.speed, "influence": x.influence * 100.0})).collect();
let d = e.first().cloned().unwrap_or_default();
while v.len() < n {
v.push(json!({"speed": d.speed, "influence": d.influence * 100.0}));
}
let dims = if p.spatial { 1 } else { p.value.dims().max(1) };
// Each side's ease as it plays (After Effects reports linear and auto-Bezier sides this way
// too), else as stored; influence is stored as a fraction, scripts see percent.
let ease = |i: usize, out: bool| -> J {
let Some(k) = p.keys.get(i) else { return json!([]) };
let stored = if out { &k.out_ease } else { &k.in_ease };
let v: Vec<J> = (0..dims)
.map(|d| {
let e =
effectcraft_keyframe::side_ease(&p.keys, i, d, p.spatial, out).or_else(|| stored.get(d).or(stored.first()).copied()).unwrap_or_default();
json!({"speed": e.speed, "influence": e.influence * 100.0})
})
.collect();
J::Array(v)
};
let dims = if p.spatial { 1 } else { p.value.dims().max(1) };
let out: Vec<J> = p
.keys
.iter()
.map(|k| {
.enumerate()
.map(|(i, k)| {
// Spatial tangents as the motion path plays (auto-Bezier and continuous keys too).
let (spatial_in, spatial_out) = if p.spatial { effectcraft_keyframe::spatial_tangents(&p.keys, i) } else { (k.spatial_in, k.spatial_out) };
json!({
"time": l.comp_time(k.time).seconds(),
"layerTime": k.time.seconds(),
"value": js_value(c, &k.value),
"inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp),
"inEase": ease(&k.in_ease, dims), "outEase": ease(&k.out_ease, dims),
"inEase": ease(i, false), "outEase": ease(i, true),
"continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving,
"spatialIn": k.spatial_in, "spatialOut": k.spatial_out,
"spatialIn": spatial_in, "spatialOut": spatial_out,
"spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto,
"selected": sel.contains(&k.time),
})
+22 -23
View File
@@ -824,31 +824,30 @@ Property.prototype.property = function () { return null; };
};
P.keyTime = function (i) { return this.__key(i).time; };
P.keyValue = function (i) { return this.__fromEngine(this.__key(i).value, this.__info()); };
// Select key `i` (only) so the selection-based keyframe commands act on it.
P.__select = function (i) {
var k = this.__key(i);
__call("keys.select", { comp: this.__layer.__comp, keys: [{ layer: this.__layer.__id, prop: this.__uid, time: k.layerTime }] });
return k;
};
P.removeKey = function (i) {
this.__select(i);
__call("keys.delete", { comp: this.__layer.__comp });
// Run a keyframe command that acts on the selected keys on key `i` only; the user's key
// selection stays as it was, as with After Effects' per-key methods.
P.__keyCmd = function (i, id, params) {
this.__key(i);
params = params || {};
params.comp = this.__layer.__comp;
params.__keys = [{ layer: this.__layer.__id, prop: this.__uid, index: __num(i) - 1 }];
return __call(id, params);
};
P.removeKey = function (i) { this.__keyCmd(i, "keys.delete"); };
P.keyInInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).inInterp]; };
P.keyOutInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).outInterp]; };
P.isInterpolationTypeValid = function (t) { return t === 6612 || t === 6613 || t === 6614; };
// Values that don't interpolate (checkboxes, popups, text) only hold.
P.isInterpolationTypeValid = function (t) { return t === 6614 || ((t === 6612 || t === 6613) && this.__info().interpolates !== false); };
P.setInterpolationTypeAtKey = function (i, inType, outType) {
if (outType === undefined) outType = inType;
if (!__interpKey[inType] || !__interpKey[outType]) throw __err("Bad KeyframeInterpolationType");
this.__select(i);
__call("keys.interpolation", { comp: this.__layer.__comp, "in": __interpKey[inType], out: __interpKey[outType] });
this.__keyCmd(i, "keys.interpolation", { "in": __interpKey[inType], out: __interpKey[outType] });
};
P.keyInTemporalEase = function (i) { return this.__key(i).inEase.map(function (e) { return new KeyframeEase(e.speed, e.influence); }); };
P.keyOutTemporalEase = function (i) { return this.__key(i).outEase.map(function (e) { return new KeyframeEase(e.speed, e.influence); }); };
P.setTemporalEaseAtKey = function (i, inEase, outEase) {
if (outEase === undefined) outEase = inEase;
if (!(inEase instanceof Array) || !(outEase instanceof Array)) throw __err("setTemporalEaseAtKey: eases must be arrays of KeyframeEase");
this.__select(i);
function sp(a) { return a.map(function (e) { return __num(e.speed, "speed"); }); }
function inf(a) {
return a.map(function (e) {
@@ -857,14 +856,14 @@ Property.prototype.property = function () { return null; };
return x;
});
}
__call("keys.velocity", { comp: this.__layer.__comp, inSpeed: sp(inEase), inInfluence: inf(inEase), outSpeed: sp(outEase), outInfluence: inf(outEase) });
this.__keyCmd(i, "keys.velocity", { inSpeed: sp(inEase), inInfluence: inf(inEase), outSpeed: sp(outEase), outInfluence: inf(outEase) });
};
P.keyTemporalContinuous = function (i) { return !!this.__key(i).continuous; };
P.setTemporalContinuousAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, continuous: !!b }); };
P.setTemporalContinuousAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { continuous: !!b }); };
P.keyTemporalAutoBezier = function (i) { return !!this.__key(i).autoBezier; };
P.setTemporalAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, autoBezier: !!b }); };
P.setTemporalAutoBezierAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { autoBezier: !!b }); };
P.keyRoving = function (i) { return !!this.__key(i).roving; };
P.setRovingAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, roving: !!b }); };
P.setRovingAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { roving: !!b }); };
P.keyInSpatialTangent = function (i) { return this.__key(i).spatialIn.slice(0, this.__info().dims); };
P.keyOutSpatialTangent = function (i) { return this.__key(i).spatialOut.slice(0, this.__info().dims); };
P.keySpatialContinuous = function (i) { return !!this.__key(i).spatialContinuous; };
@@ -873,8 +872,8 @@ Property.prototype.property = function () { return null; };
var k = this.__key(i);
__call("keys.setSpatialTangents", this.__ref({ time: k.layerTime, "in": inT, out: outT === undefined ? inT : outT }));
};
P.setSpatialContinuousAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, spatial: b ? "continuousBezier" : "bezier" }); };
P.setSpatialAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, spatial: b ? "autoBezier" : "bezier" }); };
P.setSpatialContinuousAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { spatial: b ? "continuousBezier" : "bezier" }); };
P.setSpatialAutoBezierAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { spatial: b ? "autoBezier" : "bezier" }); };
P.keySelected = function (i) { return !!this.__key(i).selected; };
P.setSelectedAtKey = function (i, b) {
var k = this.__key(i);
@@ -1743,11 +1742,11 @@ var app = {
exitCode: 0,
saveProjectOnCrash: true,
onError: null,
// Kept in the settings folder, so they last between runs (`script.settings.*`).
settings: {
__s: {},
haveSetting: function (sec, key) { return (sec + "/" + key) in this.__s; },
getSetting: function (sec, key) { return this.__s[sec + "/" + key] || ""; },
saveSetting: function (sec, key, v) { this.__s[sec + "/" + key] = String(v); },
haveSetting: function (sec, key) { return __call("script.settings.get", { section: String(sec), key: String(key) }).have; },
getSetting: function (sec, key) { return __call("script.settings.get", { section: String(sec), key: String(key) }).value; },
saveSetting: function (sec, key, v) { __call("script.settings.save", { section: String(sec), key: String(key), value: String(v) }); },
},
preferences: {
havePref: function () { return false; },
+37 -2
View File
@@ -121,11 +121,17 @@ pub(crate) fn js_str(s: &str) -> JsValue {
/// target comp made active for its duration.
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let id = arg_string(args, 0, ctx)?;
let params = arg_json(args, 1, ctx)?;
let mut params = arg_json(args, 1, ctx)?;
if id == "script.run" || id == "file.runScript" {
return Err(throw("scripts can't run scripts"));
}
let r = with_active(|a| Ok(exec(&mut a.session, &id, params)));
let keys = params.as_object_mut().and_then(|o| o.remove("__keys"));
let r = with_active(|a| {
Ok(match &keys {
Some(k) => exec_on_keys(&mut a.session, &id, params, k),
None => exec(&mut a.session, &id, params),
})
});
match r {
Ok(Ok(v)) => Ok(js_str(&v.to_string())),
Ok(Err(e)) | Err(e) => Err(throw(e)),
@@ -164,6 +170,35 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
r.map_err(|e| e.to_string())
}
/// Run a keyframe command that acts on the selected keys (`keys.velocity`, `keys.interpolation`,
/// `keys.delete`) on the keys `targets` names instead (`[{layer, prop, index}]`, 0-based, in the
/// command's comp): After Effects' per-key methods (`setTemporalEaseAtKey`, `removeKey`…) leave
/// the user's key selection as it was. Selected keys that no longer exist drop out of it.
fn exec_on_keys(s: &mut Session, id: &str, params: J, targets: &J) -> Result<J, String> {
use effectcraft_engine::KeyRef;
use effectcraft_engine::project::{ItemId, LayerId};
let cid = params.get("comp").and_then(J::as_u64).map(ItemId).or(s.state.active_comp).ok_or("no composition is open")?;
let comp = s.project.comp(cid).ok_or("the composition no longer exists")?;
let mut keys = vec![];
for t in targets.as_array().into_iter().flatten() {
let (Some(layer), Some(prop), Some(index)) = (t["layer"].as_u64(), t["prop"].as_u64(), t["index"].as_u64()) else {
return Err(format!("bad keyframe reference {t}"));
};
let l = comp.layer(LayerId(layer)).ok_or("the layer no longer exists")?;
if l.switches.locked {
return Err(format!("Can not change keyframes of \"{}\": the layer is locked", l.name));
}
let key = l.props.find(prop).and_then(|pr| pr.keys.get(usize::try_from(index).ok()?)).ok_or("the keyframe no longer exists")?;
keys.push(KeyRef { layer: LayerId(layer), prop, time: key.time });
}
let saved = std::mem::replace(&mut s.state.selected_keys, keys);
let r = exec(s, id, params);
let comp = s.active_comp();
let exists = |k: &KeyRef| comp.and_then(|c| c.layer(k.layer)).and_then(|l| l.props.find(k.prop)).is_some_and(|pr| pr.keys.iter().any(|x| x.time == k.time));
s.state.selected_keys = saved.into_iter().filter(exists).collect();
r
}
/// `__query(kind, json)` → JSON.
fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let kind = arg_string(args, 0, ctx)?;
+321
View File
@@ -0,0 +1,321 @@
//! The scripting API the bundled Ease Presets ScriptUI panel uses (After Effects parity:
//! `app.settings` that last between runs, per-key methods that keep the key selection, eases and
//! spatial tangents reported as they play, `selectedProperties` with keyed properties,
//! `isInterpolationTypeValid`), and the panel itself (`extensions/scriptui-panels/Ease
//! Presets.jsx`) easing selected keyframes, keeping user presets and taking over the presets the
//! core panel of v0.6.0 saved.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::commands::scripts::SCRIPT_SETTINGS_FILE;
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
use effectcraft_engine::project::{LayerId, Property};
use effectcraft_keyframe::{Ease, Interp, ease_progress};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::run_code;
const PANEL: &str = "Ease Presets.jsx";
fn session(store: &Arc<MemoryConfig>) -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
crate::install(&mut s);
s.config = Some(store.clone());
s.load_settings();
s
}
/// A comp with one solid; returns its id.
fn setup(s: &mut Session) -> u64 {
s.execute("comp.new", json!({"name": "Ease", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap()
}
fn animate(s: &mut Session, l: u64, path: &str, keys: &[(f64, Value)]) {
for (t, v) in keys {
s.execute("prop.addKey", json!({"layer": l, "path": path, "time": t, "value": v})).unwrap();
}
}
fn prop(s: &Session, l: u64, path: &str) -> Property {
s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().clone()
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
fn ok(s: &mut Session, code: &str) -> Value {
let out = run_code(s, code, "test.jsx");
assert!(out.error.is_none(), "{:?}", out.error);
out.result
}
fn at(s: f64) -> Tick {
Tick::from_seconds_f64(s)
}
#[test]
fn app_settings_last_between_runs() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
ok(&mut s, r#"app.settings.saveSetting("My Tool", "size", 12); app.settings.saveSetting("My Tool", "name", "Box");"#);
// A new run (the next launch) reads them back.
let mut s = session(&store);
let r = ok(
&mut s,
r#"[app.settings.haveSetting("My Tool", "size"), app.settings.getSetting("My Tool", "size"), app.settings.getSetting("My Tool", "name"), app.settings.haveSetting("My Tool", "nope")]"#,
);
assert_eq!(r, json!([true, "12", "Box", false]));
assert_eq!(s.execute("script.settings.get", json!({"section": "My Tool", "key": "name"})).unwrap(), json!({"have": true, "value": "Box"}));
// A corrupt settings file loads as none, never an error.
store.write(SCRIPT_SETTINGS_FILE, "{oops").unwrap();
let mut s = session(&store);
assert_eq!(ok(&mut s, r#"app.settings.haveSetting("My Tool", "size")"#), json!(false));
assert!(s.execute("script.settings.save", json!({"section": "x"})).is_err());
}
#[test]
fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
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});
s.execute("edit.deselectAll", json!({})).unwrap();
s.execute("keys.select", json!({"keys": [key(0.0), key(2.0)]})).unwrap();
let before = s.state.selected_keys.clone();
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, p = comp.layer(1).transform.opacity;
// A linear side reports the speed it plays at (33.3 % influence).
var lin = p.keyInTemporalEase(3)[0];
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 50)], [new KeyframeEase(0, 75)]);
p.setInterpolationTypeAtKey(3, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD);
// Keyed properties are selected properties, as in After Effects.
var sel = comp.selectedProperties;
[lin.speed, lin.influence, p.selectedKeys, sel.length, sel.length ? sel[0].name : ""]"#,
);
assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 3.0, 1e-9), "{r}");
assert_eq!(r[2], json!([1, 3]), "the selection is the user's");
assert_eq!((r[3].clone(), r[4].clone()), (json!(1), json!("Opacity")));
assert_eq!(s.state.selected_keys, before);
let op = prop(&s, l, "transform/opacity");
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (Ease { speed: 0.0, influence: 0.5 }, Ease { speed: 0.0, influence: 0.75 }));
assert_eq!(op.keys[2].in_interp, Interp::Hold);
// removeKey drops the removed key from the selection, keeps the rest.
ok(&mut s, "app.project.activeItem.layer(1).transform.opacity.removeKey(3)");
assert_eq!(s.state.selected_keys, before[..1]);
// Values that don't interpolate only hold.
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, o = comp.layer(1).transform.opacity, t = comp.layers.addText("Hi").text.sourceText;
[t.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER), t.isInterpolationTypeValid(KeyframeInterpolationType.HOLD), o.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER)]"#,
);
assert_eq!(r, json!([false, true, true]));
}
#[test]
fn spatial_tangents_are_reported_as_the_path_plays() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([100, 100, 0]))]);
let pos = prop(&s, l, "transform/position");
let (inn, out) = effectcraft_keyframe::spatial_tangents(&pos.keys, 1);
let r = ok(&mut s, "var p = app.project.activeItem.layer(1).transform.position; [p.keyInSpatialTangent(2), p.keyOutSpatialTangent(2)]");
for (got, want) in [(&r[0], inn), (&r[1], out)] {
for d in 0..2 {
assert!(close(got[d].as_f64().unwrap(), want[d], 1e-9), "{r} vs {inn:?} {out:?}");
}
}
assert!(out[0].abs() + out[1].abs() > 1.0, "the middle key's path curves: {out:?}");
assert_ne!(pos.keys[1].spatial_out, out, "auto-Bezier: the stored tangent isn't the one that plays");
}
/// The panel's window and a control's description by name.
fn open_panel(s: &mut Session) -> u64 {
let r = s.execute("window.scriptPanel", json!({"name": PANEL})).unwrap();
r["window"].as_u64().unwrap()
}
fn control(s: &mut Session, win: u64, name: &str) -> Value {
fn find(w: &Value, name: &str) -> Option<Value> {
if w["name"] == name {
return Some(w.clone());
}
w["children"].as_array()?.iter().find_map(|c| find(c, name))
}
let w = s.execute("scriptui.get", json!({"window": win})).unwrap();
find(&w["root"], name).unwrap_or_else(|| panic!("no control {name}"))
}
fn set(s: &mut Session, win: u64, widget: &str, value: Value) {
s.execute("scriptui.set", json!({"window": win, "widget": widget, "value": value})).unwrap();
}
fn click(s: &mut Session, win: u64, widget: &str) {
s.execute("scriptui.click", json!({"window": win, "widget": widget})).unwrap();
}
fn status(s: &mut Session, win: u64) -> String {
control(s, win, "status")["text"].as_str().unwrap().to_string()
}
#[test]
fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
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 selected = s.state.selected_keys.clone();
let win = open_panel(&mut s);
// It is a bundled extension listed with the ScriptUI panels.
let list = s.execute("file.scripts.list", json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension"));
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Ease In-Out"));
assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}");
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset"));
assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset");
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[0]), (Interp::Bezier, eased));
assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (eased, eased));
assert_eq!((op.keys[2].in_interp, op.keys[2].in_ease[0]), (Interp::Bezier, 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(at(2.0)).as_f64(), 60.0, 1e-6));
let quarter = op.value_at(at(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_eq!(prop(&s, l, "transform/rotation").keys[1].in_interp, Interp::Bezier);
s.execute("edit.undo", json!({})).unwrap();
let op = prop(&s, l, "transform/opacity");
assert!(op.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
// Nothing selected: nothing changes, and the panel says why.
s.execute("edit.deselectAll", json!({})).unwrap();
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Linear"));
assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property");
assert_eq!(s.history.undo.len(), steps);
}
#[test]
fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
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, 0])), (2.0, json!([300, 400, 0]))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/scale"})).unwrap();
let win = open_panel(&mut s);
let typed = |s: &mut Session, c: [f64; 4]| {
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
set(s, win, name, json!(v.to_string()));
}
click(s, win, "apply");
};
typed(&mut s, [60.0, 0.0, 20.0, 2.0]);
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(at(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:?}");
// On Position the speed is along the 500 px path.
s.execute("prop.select", json!({"layer": l, "path": "transform/position"})).unwrap();
typed(&mut s, [25.0, 0.4, 75.0, 0.0]);
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(at(1.0)).as_vec2();
assert!(close(p[0], 300.0 * f, 0.05) && close(p[1], 400.0 * f, 0.05), "{p:?} {f}");
// From Keys reads the applied curve back into the fields.
for name in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"] {
set(&mut s, win, name, json!("1"));
}
click(&mut s, win, "fromKeys");
let fields: Vec<String> =
["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().map(|n| control(&mut s, win, n)["text"].as_str().unwrap().to_string()).collect();
assert_eq!(fields, ["25", "0.4", "75", "0"]);
}
#[test]
fn user_presets_save_rename_delete_and_persist() {
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
let l = setup(&mut s);
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();
let win = open_panel(&mut s);
set(&mut s, win, "presets", json!("Decelerate"));
// Save keeps the curve in the fields under the typed name.
set(&mut s, win, "name", json!(" Settle "));
click(&mut s, win, "save");
assert_eq!(status(&mut s, win), "Saved the ease preset \u{201c}Settle\u{201d}");
// Built-in names are refused.
set(&mut s, win, "name", json!("linear"));
click(&mut s, win, "save");
assert!(status(&mut s, win).contains("is a built-in preset"));
let items = |s: &mut Session| control(s, win, "presets")["items"].clone();
assert_eq!(items(&mut s).as_array().unwrap().last().unwrap(), "Settle");
// Rename and Delete act on the selected user preset; built-in presets can't change.
set(&mut s, win, "presets", json!("Linear"));
set(&mut s, win, "name", json!("Straight"));
click(&mut s, win, "rename");
assert_eq!(status(&mut s, win), "Select one of your own presets first");
set(&mut s, win, "presets", json!("Settle"));
set(&mut s, win, "name", json!("Soft Landing"));
click(&mut s, win, "rename");
let names = items(&mut s);
assert!(names.as_array().unwrap().contains(&json!("Soft Landing")) && !names.as_array().unwrap().contains(&json!("Settle")), "{names}");
// Kept with app.settings: a new run of the panel (the next launch) lists them.
let stored: Value = serde_json::from_str(&store.read(SCRIPT_SETTINGS_FILE).unwrap()).unwrap();
let presets: Value = serde_json::from_str(stored["Ease Presets"]["userPresets"].as_str().unwrap()).unwrap();
assert_eq!(presets["presets"][0]["name"], "Soft Landing");
assert!(close(presets["presets"][0]["curve"]["outSpeed"].as_f64().unwrap(), 1.5, 1e-9), "{presets}");
let mut s2 = session(&store);
setup(&mut s2);
let win2 = open_panel(&mut s2);
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 13);
set(&mut s2, win2, "presets", json!("Soft Landing"));
click(&mut s2, win2, "delete");
assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 12, "the built-in presets");
}
/// User presets the core Ease Presets panel of v0.6.0 saved (`ease_presets.json`) move to the
/// panel's settings when settings load, once; unusable entries are skipped by the panel.
#[test]
fn presets_saved_by_the_old_core_panel_carry_over() {
let store = Arc::new(MemoryConfig::default());
let c = json!({"outInfluence": 30, "outSpeed": 0, "inInfluence": 30, "inSpeed": 0});
let old = json!({"version": 1, "presets": [
{"name": "Snap", "curve": c},
{"name": "Linear", "curve": c},
{"name": "", "curve": c},
{"name": "No curve"},
{"name": "snap", "curve": c},
{"name": "Bad", "curve": {"outInfluence": "x", "outSpeed": 0, "inInfluence": 30, "inSpeed": 0}}
]});
store.write("ease_presets.json", &old.to_string()).unwrap();
let mut s = session(&store);
setup(&mut s);
let win = open_panel(&mut s);
let items = control(&mut s, win, "presets")["items"].clone();
assert_eq!(items.as_array().unwrap().len(), 13, "{items}");
assert_eq!(items[12], "Snap");
// Deleting it there is final: the old file doesn't come back.
set(&mut s, win, "presets", json!("Snap"));
click(&mut s, win, "delete");
let mut s = session(&store);
setup(&mut s);
let win = open_panel(&mut s);
assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12);
}
+1 -6
View File
@@ -50,12 +50,10 @@ 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; 35] = [
pub const ALL: [PanelKind; 34] = [
PanelKind::Project,
PanelKind::EffectControls,
PanelKind::Composition,
@@ -90,7 +88,6 @@ impl PanelKind {
PanelKind::ContentAwareFill,
PanelKind::CreateNullsFromPaths,
PanelKind::VrCompEditor,
PanelKind::EasePresets,
];
pub fn title(self) -> &'static str {
match self {
@@ -130,7 +127,6 @@ 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 {
@@ -371,7 +367,6 @@ pub fn workspace(name: &str) -> DockNode {
ContentAwareFill,
CreateNullsFromPaths,
VrCompEditor,
EasePresets,
],
),
),
-3
View File
@@ -754,7 +754,6 @@ const SIMPLIFIED_CHINESE: &[(&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 帮助..."),
@@ -1378,7 +1377,6 @@ const TRADITIONAL_CHINESE: &[(&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 說明..."),
@@ -1999,7 +1997,6 @@ 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ヘルプ..."),
-243
View File
@@ -1,243 +0,0 @@
//! 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 = widgets::PanelScroll::begin(ui, egui::Id::new("ease-presets-scroll"), list);
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.offset), 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));
}
}
scroll.end(ui, &mut app.auto, "easePresets.scroll", count.div_ceil(cols) as f32 * (TILE.y + GAP), &t);
clicked
}
-2
View File
@@ -7,7 +7,6 @@ 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;
@@ -128,7 +127,6 @@ 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,9 +667,6 @@ 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 {
@@ -724,7 +721,6 @@ impl Default for UiState {
mini_flowchart: None,
flowchart: Default::default(),
scopes: Default::default(),
ease_presets: Default::default(),
}
}
}
+1 -41
View File
@@ -1,7 +1,6 @@
//! 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, 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.
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
@@ -255,45 +254,6 @@ fn k_follows_the_revealed_properties_and_auto_select_picks_the_speed_graph_for_p
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);
}
/// #284: in the Graph Editor, dragging the handle of one selected key moves the same handle of
/// every selected key with it (After Effects), in one undo step.
#[test]
+36
View File
@@ -112,6 +112,42 @@ fn panels_open_at_quit_dock_again_at_launch() {
assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#));
}
/// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a
/// preset in its list eases the selected keyframes in one undo step.
#[test]
fn ease_presets_panel_eases_the_selected_keys_from_its_list() {
use effectcraft_engine::keyframe::{Ease, Interp};
use egui_kittest::kittest::Queryable;
let mut h = harness();
let s = &mut h.state_mut().session;
let l = s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
let cx = effectcraft_engine::menus::DynCtx::default();
let (panels, _) = effectcraft_engine::menus::dynamic(s, "scriptPanels", &cx);
assert!(panels.iter().any(|e| e.label == "Ease Presets.jsx"), "listed in the Window menu");
s.execute("window.scriptPanel", json!({"name": "Ease Presets.jsx"})).unwrap();
h.run_steps(3);
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Ease Presets.jsx").unwrap().id;
assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks");
h.state_mut().ui.maximized = Some(PanelKind::ScriptPanel(panel));
h.run_steps(3);
let steps = h.state().session.history.undo.len();
let item = h.query_by_label("Ease In-Out").expect("the Ease In-Out preset").rect().center();
h.input_mut().events.push(Event::PointerMoved(item));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(3);
let op = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().clone();
let eased = Ease { speed: 0.0, influence: 0.5 };
assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease[0]), (Interp::Bezier, eased));
assert_eq!((op.keys[1].in_interp, op.keys[1].in_ease[0]), (Interp::Bezier, eased));
assert_eq!(h.state().session.history.undo.len(), steps + 1);
}
#[test]
fn history_panel_jumps_between_branches() {
let mut h = harness();
+4 -1
View File
@@ -460,7 +460,10 @@ Scripts that build ScriptUI windows publish them to the session; agents drive th
A dialog's `show()` waits for the user: the `script.run` / `file.runScript` reply carries
`"waiting": true`, and the script continues (its final output arrives in the reply of the click
that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`,
`file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`.
`file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`,
`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`:
`script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`.
Extension points and the bundled extensions: [plugins.md](plugins.md).
### Timeline: layer order, properties and shortcuts
+6 -3
View File
@@ -200,9 +200,12 @@ The most important missing piece: it turns every other estimate here into a meas
panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel
and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard
shortcuts for scripts, ExtendScript's `#include` / `#target` directives and `.jsxbin`.
- 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)).
- Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept
by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: a
ScriptUI panel bundled as an optional extension (`extensions/scriptui-panels/Ease Presets.jsx`,
Window ▸ Ease Presets.jsx) on the public scripting API, which needed `app.settings` that last,
per-key methods that keep the key selection, and eases reported as they play
([plugins.md](plugins.md#bundled-extensions), [parity.md](parity.md)).
### G9. Reach
+32 -19
View File
@@ -400,27 +400,40 @@ 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)
## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel
**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.*`.
EffectCraft's core keeps After Effects parity; what After Effects users get from third parties is
an extension, on the same extension points After Effects has ([plugins.md](plugins.md) compares
them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel).
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.
- **ScriptUI panels** in `Scripts/ScriptUI Panels/` of the settings folder are listed at the
bottom of the Window menu and dock like any panel (as before); the panels open when the app quits
now open again at the next launch (`window.restoreScriptPanels`), as After Effects reopens its
workspace's panels.
- **Scripting API** (parity fixes the Ease Presets panel needed): `app.settings` lasts between
runs (`script_settings.json`, `script.settings.get` / `save`); per-key methods
(`setTemporalEaseAtKey`, `setInterpolationTypeAtKey`, `removeKey`, the continuous / auto-Bezier /
roving setters) no longer change the user's key selection; `keyInTemporalEase` /
`keyOutTemporalEase` report linear and auto-Bezier sides as they play, and
`keyIn/OutSpatialTangent` the tangents the motion path plays; `selectedProperties` includes the
properties of selected keyframes; `isInterpolationTypeValid` is false for Linear and Bezier on
values that only hold.
- **Ease presets** (#254), modelled on a third-party panel, moved out of the core: they are the
bundled ScriptUI panel `extensions/scriptui-panels/Ease Presets.jsx` (Window ▸ Ease
Presets.jsx), written only against the public scripting API. 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, speed relative to the segment's average speed); clicking a preset eases every pair of
neighbouring selected keyframes in one undo step, per dimension and along the motion path for
spatial properties, leaving the keys' other sides as they play. The twelve built-in curves are
unchanged; user presets (Save, Rename, Delete) are kept with `app.settings`, and those saved by
the core panel of v0.6.0 (`ease_presets.json`) move there the first time settings load. The
panel shows the working curve as a drawing and its four numbers as fields (the value graph's
handles don't drag: ScriptUI panels get no mouse-drag events here). The `keys.easePreset.*`
commands and `EaseCurve` are gone. Not yet: curves with more than one bend (bounce and elastic
shapes), preset import / export files.
- **Snapping options** (#265): the Tools bar's menu has After Effects' two options, Snap Edges
Extended and Snap to Features in Collapsed Compositions and Text Layers (the layers inside
collapsed precomp layers snap); the per-feature toggles are gone and every feature snaps.
## Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness
+38 -4
View File
@@ -19,7 +19,7 @@ Guide):
| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap |
| Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points |
| `app.settings` (kept in the preferences between sessions) | Kept for the run only | Gap |
| `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same |
| Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same |
The settings folder is `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft`
@@ -84,10 +84,44 @@ Supported controls: `panel`, `group`, `button`, `iconbutton`, `statictext`, `edi
`tabbedpanel` / `tab` and `image`, with ScriptUI's automatic layout (`orientation`,
`alignChildren`, `alignment`, `margins`, `spacing`, `preferredSize`), resource strings, event
handlers (`onClick`, `onChange`, `onChanging`, `onClose`, `addEventListener`) and `onDraw` with
ScriptUIGraphics. Agents use a panel like a user: `scriptui.list`, `scriptui.get` (its controls),
`scriptui.click`, `scriptui.set` and `scriptui.close`; every control has the automation id
ScriptUIGraphics. Mouse events (`mousedown`, `mousemove`…) aren't sent to scripts yet. Agents use
a panel like a user: `scriptui.list`, `scriptui.get` (its controls), `scriptui.click`,
`scriptui.set` and `scriptui.close`; every control has the automation id
`scriptui.<window>.<control id or name>`. The bundled sample `Layer Tools.jsx`
(`crates/engine/scripts/ScriptUI Panels/`) is a longer example.
(`crates/engine/scripts/ScriptUI Panels/`) and the Ease Presets extension below are longer
examples.
## Bundled extensions
Optional tools that ship with EffectCraft but are not part of its core: they are scripts on the
public scripting API, like the third-party scripts After Effects users install, and are listed
with the bundled samples (`file.scripts.list` reports them as `extension`). A script of the same
name in your Scripts folders takes their place.
### Ease Presets (`extensions/scriptui-panels/Ease Presets.jsx`)
Window ▸ Ease Presets.jsx: easing curves kept by name and applied to pairs of neighbouring
selected keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's
first key and the in side of its second, in the Keyframe Velocity dialog's terms: influence in
percent, and speed relative to the segment's average speed (1 = as fast as a straight line, 0 = at
rest), so one curve fits any duration and change of value.
* Click a preset to apply it to every pair of neighbouring selected keyframes of each selected
property, in one undo step (per dimension, and along the motion path for spatial properties;
the keys' other sides keep playing as they did). The keys stay selected, so you can try another.
* The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and
click Apply, or click From Keys to read the curve between the first selected pair.
* Save keeps the working curve under the typed name (replacing a preset of that name); Rename
and Delete act on the selected preset of your own. The twelve built-in presets can't change.
* User presets are kept with `app.settings` (section `Ease Presets`, key `userPresets`, in
`script_settings.json` in the settings folder). Presets saved by the v0.6.0 core panel
(`ease_presets.json`) move there the first time a later version loads its settings; the old
file is left as it was.
It uses only After Effects' documented scripting API (`comp.selectedProperties`,
`Property.selectedKeys`, `keyInTemporalEase` / `keyOutTemporalEase`, `setTemporalEaseAtKey` with
`KeyframeEase`, `setInterpolationTypeAtKey`, `app.settings`, ScriptUI), so it is also a worked
example of a keyframe tool.
## Effect plug-ins
+454
View File
@@ -0,0 +1,454 @@
// Ease Presets: easing curves kept by name and applied to pairs of neighbouring selected keyframes.
//
// A ScriptUI panel that ships with EffectCraft as an optional extension (Window ▸ Ease
// Presets.jsx). It uses only After Effects' documented scripting API: comp.selectedProperties,
// Property.selectedKeys, keyInTemporalEase / keyOutTemporalEase, setTemporalEaseAtKey with
// KeyframeEase, setInterpolationTypeAtKey, app.settings and ScriptUI. Original work by the
// EffectCraft contributors, MIT OR Apache-2.0.
//
// A curve is the out side of a segment's first key and the in side of its second, in the
// Keyframe Velocity dialog's terms: influence in percent of the segment, and speed relative to the
// segment's average speed (1 = as fast as a straight line, 0 = at rest). Applied to a segment, the
// speeds are scaled to it: per dimension, along the motion path for spatial properties, or as
// overall progress for shapes.
//
// User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON:
// {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence",
// "inSpeed"}}]}.
(function easePresets(thisObj) {
var SECTION = "Ease Presets";
var KEY = "userPresets";
var MAX_NAME = 64;
var MIN_INFLUENCE = 0.1;
var MAX_SPEED = 100;
var BEZIER = KeyframeInterpolationType.BEZIER;
var HOLD = KeyframeInterpolationType.HOLD;
function curve(outInfluence, outSpeed, inInfluence, inSpeed) {
return { outInfluence: outInfluence, outSpeed: outSpeed, inInfluence: inInfluence, inSpeed: inSpeed };
}
// Our own presets. "Accelerate" starts slowly, "Decelerate" settles slowly.
var BUILT_IN = [
{ name: "Linear", curve: curve(100 / 3, 1, 100 / 3, 1) },
{ name: "Smooth", curve: curve(40, 0.25, 40, 0.25) },
{ name: "Ease In-Out Soft", curve: curve(25, 0, 25, 0) },
{ name: "Ease In-Out", curve: curve(50, 0, 50, 0) },
{ name: "Ease In-Out Strong", curve: curve(75, 0, 75, 0) },
{ name: "Accelerate", curve: curve(50, 0, 20, 1.5) },
{ name: "Accelerate Strong", curve: curve(80, 0, 15, 2.5) },
{ name: "Decelerate", curve: curve(20, 1.5, 50, 0) },
{ name: "Decelerate Strong", curve: curve(15, 2.5, 80, 0) },
{ name: "Expo In-Out", curve: curve(88, 0, 88, 0) },
{ name: "Expo Accelerate", curve: curve(75, 0, 12, 7) },
{ name: "Expo Decelerate", curve: curve(12, 7, 75, 0) }
];
// ------------------------------------------------------------------ presets
function finite(v) {
return typeof v === "number" && isFinite(v);
}
// The curve with influences in 0.1–100 % and speeds within ±100; null when a number is missing.
function sanitized(c) {
if (!c || !finite(c.outInfluence) || !finite(c.outSpeed) || !finite(c.inInfluence) || !finite(c.inSpeed)) return null;
var inf = function (v) { return Math.min(100, Math.max(MIN_INFLUENCE, v)); };
var speed = function (v) { return Math.min(MAX_SPEED, Math.max(-MAX_SPEED, v)); };
return curve(inf(c.outInfluence), speed(c.outSpeed), inf(c.inInfluence), speed(c.inSpeed));
}
function cleanName(name) {
var n = String(name === undefined || name === null ? "" : name).replace(/^\s+|\s+$/g, "");
return n.length > 0 && n.length <= MAX_NAME ? n : null;
}
function same(a, b) {
return a.toLowerCase() === b.toLowerCase();
}
function find(list, name) {
for (var i = 0; i < list.length; i++) if (same(list[i].name, name)) return i;
return -1;
}
// User presets; entries that don't parse, have no usable name or repeat a name are skipped.
function loadUser() {
var out = [];
if (!app.settings.haveSetting(SECTION, KEY)) return out;
var doc;
try {
doc = JSON.parse(app.settings.getSetting(SECTION, KEY));
} catch (e) {
return out;
}
var list = doc && doc.presets instanceof Array ? doc.presets : [];
for (var i = 0; i < list.length; i++) {
var name = cleanName(list[i] && list[i].name);
var c = sanitized(list[i] && list[i].curve);
if (name && c && find(BUILT_IN, name) < 0 && find(out, name) < 0) out.push({ name: name, curve: c });
}
return out;
}
function saveUser(list) {
app.settings.saveSetting(SECTION, KEY, JSON.stringify({ version: 1, presets: list }));
}
// ------------------------------------------------------------------ keyframes
// Pairs of neighbouring selected keyframes of the active comp: {prop, key} (the earlier key's
// index), by property.
function selectedPairs() {
var comp = app.project.activeItem;
var out = [];
if (!(comp instanceof CompItem)) return out;
var props = comp.selectedProperties;
for (var i = 0; i < props.length; i++) {
var p = props[i];
if (p.propertyType !== PropertyType.PROPERTY || p.numKeys < 2 || !p.isInterpolationTypeValid(BEZIER)) continue;
var keys = p.selectedKeys;
for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: p, key: keys[k] });
}
return out;
}
// Length of the motion path from `a` to `b` with the tangents of their keys.
function pathLength(a, outTangent, inTangent, b) {
var p = [], n = Math.max(a.length, b.length), len = 0, prev = null;
for (var d = 0; d < n; d++) p.push([a[d] || 0, (a[d] || 0) + (outTangent[d] || 0), (b[d] || 0) + (inTangent[d] || 0), b[d] || 0]);
for (var s = 0; s <= 128; s++) {
var t = s / 128, u = 1 - t, pt = [];
for (var e = 0; e < n; e++) pt.push(u * u * u * p[e][0] + 3 * u * u * t * p[e][1] + 3 * u * t * t * p[e][2] + t * t * t * p[e][3]);
if (prev) {
var sq = 0;
for (var f = 0; f < n; f++) sq += (pt[f] - prev[f]) * (pt[f] - prev[f]);
len += Math.sqrt(sq);
}
prev = pt;
}
return len;
}
// The layer a property belongs to.
function layerOf(p) {
return p.propertyGroup(p.propertyDepth);
}
// Average speed of segment `a` → `a + 1` for each entry of its eases, in the units the eases use
// (keyframe times move with the layer, so the duration is in the layer's time). Null when the
// segment has no duration.
function slopes(p, a) {
var stretch = layerOf(p).stretch || 100;
var dur = (p.keyTime(a + 1) - p.keyTime(a)) * 100 / stretch;
if (!(dur > 0) || !isFinite(dur)) return null;
var n = Math.max(1, p.keyOutTemporalEase(a).length);
var v0 = p.keyValue(a), v1 = p.keyValue(a + 1), out = [];
var vt = p.propertyValueType;
if (vt === PropertyValueType.TwoD_SPATIAL || vt === PropertyValueType.ThreeD_SPATIAL) {
var speed = pathLength(v0, p.keyOutSpatialTangent(a), p.keyInSpatialTangent(a + 1), v1) / dur;
for (var i = 0; i < n; i++) out.push(speed);
} else if (typeof v0 === "number" && typeof v1 === "number") {
out.push((v1 - v0) / dur);
} else if (v0 instanceof Array && v1 instanceof Array && v0.length === v1.length) {
for (var d = 0; d < n; d++) out.push(((v1[d] || 0) - (v0[d] || 0)) / dur);
} else {
// Shapes and other values ease their overall progress.
for (var j = 0; j < n; j++) out.push(1 / dur);
}
return out;
}
// Ease one side of key `k` with `eases`, leaving its other side as it plays (auto-Bezier and
// linear sides keep their shape; a continuous key stops being continuous, its sides now differ).
function easeSide(p, k, out, eases) {
var inType = p.keyInInterpolationType(k), outType = p.keyOutInterpolationType(k);
var inEase = p.keyInTemporalEase(k), outEase = p.keyOutTemporalEase(k);
if (p.keyTemporalContinuous(k)) p.setTemporalContinuousAtKey(k, false);
if (out) {
p.setTemporalEaseAtKey(k, inEase, eases);
p.setInterpolationTypeAtKey(k, inType, BEZIER);
} else {
p.setTemporalEaseAtKey(k, eases, outEase);
p.setInterpolationTypeAtKey(k, BEZIER, outType);
}
}
// Ease every pair of neighbouring selected keyframes with `c` (one undo step). Returns the
// number of pairs eased.
function applyCurve(c) {
var pairs = selectedPairs(), n = 0;
if (!pairs.length) return 0;
app.beginUndoGroup("Apply Ease Preset");
try {
for (var i = 0; i < pairs.length; i++) {
var p = pairs[i].prop, a = pairs[i].key, s = slopes(p, a);
if (!s) continue;
var outE = [], inE = [];
for (var d = 0; d < s.length; d++) {
outE.push(new KeyframeEase(c.outSpeed * s[d], c.outInfluence));
inE.push(new KeyframeEase(c.inSpeed * s[d], c.inInfluence));
}
easeSide(p, a, true, outE);
easeSide(p, a + 1, false, inE);
n++;
}
} finally {
app.endUndoGroup();
}
return n;
}
// The curve between the first selected pair of keyframes (measured on the dimension that
// changes most); null for holds and segments whose value doesn't change.
function captureCurve() {
var pairs = selectedPairs();
if (!pairs.length) return null;
var p = pairs[0].prop, a = pairs[0].key;
if (p.keyOutInterpolationType(a) === HOLD) return null;
var s = slopes(p, a);
if (!s) return null;
var d = 0;
for (var i = 1; i < s.length; i++) if (Math.abs(s[i]) > Math.abs(s[d])) d = i;
if (Math.abs(s[d]) < 1e-12) return null;
var o = p.keyOutTemporalEase(a), n = p.keyInTemporalEase(a + 1);
var oe = o[d] || o[0], ne = n[d] || n[0];
if (!oe || !ne) return null;
return sanitized(curve(oe.influence, oe.speed / s[d], ne.influence, ne.speed / s[d]));
}
// ------------------------------------------------------------------ panel
var ui = thisObj instanceof Panel ? thisObj : new Window("palette", "Ease Presets", undefined, { resizeable: true });
ui.orientation = "column";
ui.alignChildren = ["fill", "top"];
ui.spacing = 6;
var user = loadUser();
var working = BUILT_IN[0].curve;
var refreshing = false;
var list = ui.add("listbox", undefined, [], { name: "presets" });
list.preferredSize = [220, 150];
list.helpTip = "Click a preset to apply it to the selected keyframes";
// The working curve in the normalised value graph: keys at (0, 0) and (1, 1).
var graph = ui.add("group");
graph.preferredSize = [220, 110];
graph.onDraw = function () {
var g = this.graphics, w = this.size.width, h = this.size.height;
var lo = -0.35, hi = 1.35, m = 10;
var at = function (x, v) { return [m + (w - 2 * m) * x, h - m - (h - 2 * m) * (v - lo) / (hi - lo)]; };
var frame = g.newPen(g.PenType.SOLID_COLOR, [0.5, 0.5, 0.5, 0.6], 1);
g.newPath();
g.rectPath(0, 0, w, h);
g.strokePath(frame);
g.newPath();
var a = at(0, 0), b = at(1, 0), c0 = at(0, 1), d = at(1, 1);
g.moveTo(a[0], a[1]);
g.lineTo(b[0], b[1]);
g.moveTo(c0[0], c0[1]);
g.lineTo(d[0], d[1]);
g.strokePath(frame);
// Handles: (out influence, out speed × out influence) and (1 − in influence, 1 − in speed × in influence).
var oi = working.outInfluence / 100, ii = working.inInfluence / 100;
var h1 = [oi, working.outSpeed * oi], h2 = [1 - ii, 1 - working.inSpeed * ii];
var handles = g.newPen(g.PenType.SOLID_COLOR, [0.8, 0.8, 0.8, 1], 1);
var p1 = at(h1[0], h1[1]), p2 = at(h2[0], h2[1]), k0 = at(0, 0), k1 = at(1, 1);
g.newPath();
g.moveTo(k0[0], k0[1]);
g.lineTo(p1[0], p1[1]);
g.moveTo(k1[0], k1[1]);
g.lineTo(p2[0], p2[1]);
g.strokePath(handles);
var accent = g.newPen(g.PenType.SOLID_COLOR, [0.29, 0.56, 1, 1], 2);
g.newPath();
for (var s = 0; s <= 48; s++) {
var t = s / 48, u = 1 - t;
var x = 3 * u * u * t * h1[0] + 3 * u * t * t * h2[0] + t * t * t;
var v = 3 * u * u * t * h1[1] + 3 * u * t * t * h2[1] + t * t * t;
var q = at(x, v);
if (s === 0) g.moveTo(q[0], q[1]);
else g.lineTo(q[0], q[1]);
}
g.strokePath(accent);
};
function field(row, label, name) {
row.add("statictext", undefined, label);
var f = row.add("edittext", undefined, "", { name: name });
f.characters = 5;
return f;
}
var outRow = ui.add("group");
var outInfluence = field(outRow, "Out %", "outInfluence");
var outSpeed = field(outRow, "Speed", "outSpeed");
var inRow = ui.add("group");
var inInfluence = field(inRow, "In %", "inInfluence");
var inSpeed = field(inRow, "Speed", "inSpeed");
var actions = ui.add("group");
var applyButton = actions.add("button", undefined, "Apply", { name: "apply" });
var fromKeys = actions.add("button", undefined, "From Keys", { name: "fromKeys" });
var nameRow = ui.add("group");
nameRow.alignChildren = ["fill", "center"];
var nameField = nameRow.add("edittext", undefined, "", { name: "name" });
nameField.characters = 14;
nameField.helpTip = "Preset name";
var saveButton = nameRow.add("button", undefined, "Save", { name: "save" });
var edits = ui.add("group");
var renameButton = edits.add("button", undefined, "Rename", { name: "rename" });
var deleteButton = edits.add("button", undefined, "Delete", { name: "delete" });
var status = ui.add("statictext", undefined, "", { name: "status" });
status.alignment = ["fill", "top"];
function round(v, places) {
var f = Math.pow(10, places);
return String(Math.round(v * f) / f);
}
function showCurve(c) {
working = c;
outInfluence.text = round(c.outInfluence, 1);
outSpeed.text = round(c.outSpeed, 2);
inInfluence.text = round(c.inInfluence, 1);
inSpeed.text = round(c.inSpeed, 2);
}
// The curve typed into the fields (null when one isn't a number).
function typedCurve() {
return sanitized(curve(parseFloat(outInfluence.text), parseFloat(outSpeed.text), parseFloat(inInfluence.text), parseFloat(inSpeed.text)));
}
function presets() {
return BUILT_IN.concat(user);
}
// The user preset selected in the list (null for built-in ones and no selection).
function selectedUser() {
var item = list.selection;
if (!item) return null;
var i = find(user, item.text);
return i < 0 ? null : user[i];
}
function refresh(select) {
refreshing = true;
list.removeAll();
var all = presets();
for (var i = 0; i < all.length; i++) list.add("item", all[i].name);
if (select) {
var j = find(all, select);
if (j >= 0) list.selection = j;
}
refreshing = false;
}
function apply(c, what) {
var n = applyCurve(c);
status.text = n > 0 ? "Eased " + n + " keyframe pair(s) with " + what : "Select two or more neighbouring keyframes of a property";
return n;
}
list.onChange = function () {
if (refreshing || !list.selection) return;
var all = presets(), i = find(all, list.selection.text);
if (i < 0) return;
showCurve(all[i].curve);
nameField.text = find(user, all[i].name) >= 0 ? all[i].name : "";
apply(all[i].curve, "“" + all[i].name + "”");
};
var onTyped = function () {
var c = typedCurve();
if (c) working = c;
};
outInfluence.onChange = outSpeed.onChange = inInfluence.onChange = inSpeed.onChange = onTyped;
applyButton.onClick = function () {
var c = typedCurve();
if (!c) {
status.text = "Type numbers for the influences and speeds";
return;
}
showCurve(c);
apply(c, "the curve");
};
fromKeys.onClick = function () {
var c = captureCurve();
if (!c) {
status.text = "Select two neighbouring keyframes whose value changes";
return;
}
showCurve(c);
status.text = "Read the curve of the selected keyframes";
};
saveButton.onClick = function () {
var name = cleanName(nameField.text), c = typedCurve();
if (!name) {
status.text = "Type a name for the preset first";
return;
}
if (find(BUILT_IN, name) >= 0) {
status.text = "“" + name + "” is a built-in preset; choose another name";
return;
}
if (!c) {
status.text = "Type numbers for the influences and speeds";
return;
}
// Saving under an existing name replaces that preset.
var i = find(user, name);
if (i >= 0) user[i] = { name: name, curve: c };
else user.push({ name: name, curve: c });
saveUser(user);
refresh(name);
status.text = "Saved the ease preset “" + name + "”";
};
renameButton.onClick = function () {
var p = selectedUser(), name = cleanName(nameField.text);
if (!p) {
status.text = "Select one of your own presets first";
return;
}
if (!name) {
status.text = "Type a new name for “" + p.name + "” first";
return;
}
var other = find(user, name);
if (find(BUILT_IN, name) >= 0 || (other >= 0 && user[other] !== p)) {
status.text = "An ease preset named “" + name + "” exists";
return;
}
p.name = name;
saveUser(user);
refresh(name);
status.text = "Renamed to “" + name + "”";
};
deleteButton.onClick = function () {
var p = selectedUser();
if (!p) {
status.text = "Select one of your own presets first";
return;
}
user.splice(find(user, p.name), 1);
saveUser(user);
refresh(null);
nameField.text = "";
status.text = "Deleted “" + p.name + "”";
};
refresh(null);
showCurve(working);
status.text = "Select keyframes, then click a preset";
if (ui instanceof Window) {
ui.show();
} else {
ui.layout.layout(true);
}
})(this);
@@ -0,0 +1,7 @@
asset: Ease Presets.jsx
title: EffectCraft bundled ScriptUI panel (Ease Presets) and its built-in ease presets
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-09 by EffectCraft contributors
notes: Written from scratch for EffectCraft against After Effects' documented scripting behaviour (Scripting Guide); not derived from any Adobe sample or third-party panel. The twelve built-in curves are our own values, moved unchanged from the core Ease Presets panel (#254).