455 lines
17 KiB
JavaScript
455 lines
17 KiB
JavaScript
// 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);
|