effectcraft/extensions/scriptui-panels/Ease Presets.jsx

455 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);