mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]>
455 lines
17 KiB
React
455 lines
17 KiB
React
// 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);
|