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:
bflatastic
2026-10-09 21:03:12 +07:00
co-authored by Claude Opus 5.5
parent 9da33c157e
commit da165724e9
29 changed files with 1113 additions and 1060 deletions
+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);
}