Ease Presets: ease the incoming, outgoing or both sides of selected keyframes (#1329)

The panel eased only pairs of neighbouring selected keyframes, so a single
selected key (or keys that aren't neighbours) changed nothing.

An Ease selector (In, Out, In-Out; In-Out by default) now chooses the
sides of each selected key to ease. In eases a key's incoming side with
the curve's in side, Out its outgoing side with the curve's out side. A
side is eased when it has a neighbouring key, selected or not; the
neighbour is left as it is. Each side's speeds scale to the segment on
that side, so a key between a +100/s and a -40/s segment gets each its
own. The side not eased keeps playing as it did (easeSide, unchanged).

Sides are measured before any key changes, and no undo step opens when
no selected key has a segment on the chosen side; the status says which
neighbour is missing. A selected pair in In-Out still gets the whole
curve; its outer sides now ease too when they face an unselected key.
From Keys still reads the curve of a selected pair.
This commit is contained in:
tylerjenningsw
2026-10-13 14:28:49 -04:00
parent 549e9d8251
commit 3a953859f2
2 changed files with 61 additions and 27 deletions
+3 -2
View File
@@ -180,7 +180,8 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
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}");
// In-Out (the default) on every key: each pair gets the whole curve, as before.
assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) 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");
@@ -203,7 +204,7 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
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!(status(&mut s, win), "Select keyframes with another keyframe next to it");
assert_eq!(s.history.undo.len(), steps);
}
+58 -25
View File
@@ -1,4 +1,4 @@
// Ease Presets: easing curves kept by name and applied to pairs of neighbouring selected keyframes.
// Ease Presets: easing curves kept by name and applied to the sides of 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,
@@ -12,6 +12,11 @@
// speeds are scaled to it: per dimension, along the motion path for spatial properties, or as
// overall progress for shapes.
//
// Applied to selected keyframes, In eases each key's incoming side with the curve's in side, Out
// its outgoing side with the curve's out side, In-Out both: a side is eased when it has a
// neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the
// segment on that side. So a selected pair gets the whole curve, and a single key works too.
//
// User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON:
// {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence",
// "inSpeed"}}]}.
@@ -98,9 +103,8 @@
// ------------------------------------------------------------------ keyframes
// Pairs of neighbouring selected keyframes of the active comp: {prop, key} (the earlier key's
// index), by property.
function selectedPairs() {
// Selected properties of the active comp whose keys can ease: {prop, keys} (their selected keys).
function selectedKeyed() {
var comp = app.project.activeItem;
var out = [];
if (!(comp instanceof CompItem)) return out;
@@ -109,7 +113,17 @@
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] });
if (keys.length) out.push({ prop: p, keys: keys });
}
return out;
}
// Pairs of neighbouring selected keyframes: {prop, key} (the earlier key's index), by property.
function selectedPairs() {
var keyed = selectedKeyed(), out = [];
for (var i = 0; i < keyed.length; i++) {
var keys = keyed[i].keys;
for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: keyed[i].prop, key: keys[k] });
}
return out;
}
@@ -175,29 +189,39 @@
}
}
// 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;
// The eases of one side with `speed` (relative to the segment's average) and `influence`.
function eases(s, speed, influence) {
var out = [];
for (var d = 0; d < s.length; d++) out.push(new KeyframeEase(speed * s[d], influence));
return out;
}
// Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in
// one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the
// number of keyframes eased.
function applyCurve(c, mode) {
var keyed = selectedKeyed(), sides = [], keys = 0;
var doIn = mode !== "Out", doOut = mode !== "In";
// Every side and its eases first: the segments are measured before any key changes.
for (var i = 0; i < keyed.length; i++) {
var p = keyed[i].prop;
for (var j = 0; j < keyed[i].keys.length; j++) {
var k = keyed[i].keys[j], before = sides.length;
var s = doIn && k > 1 ? slopes(p, k - 1) : null;
if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) });
s = doOut && k < p.numKeys ? slopes(p, k) : null;
if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) });
if (sides.length > before) keys++;
}
}
if (!sides.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++;
}
for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases);
} finally {
app.endUndoGroup();
}
return n;
return keys;
}
// The curve between the first selected pair of keyframes (measured on the dimension that
@@ -288,6 +312,13 @@
var inInfluence = field(inRow, "In %", "inInfluence");
var inSpeed = field(inRow, "Speed", "inSpeed");
var modeRow = ui.add("group");
modeRow.add("statictext", undefined, "Ease");
var MODES = ["In", "Out", "In-Out"];
var mode = modeRow.add("dropdownlist", undefined, MODES, { name: "mode" });
mode.selection = 2;
mode.helpTip = "The sides of each selected keyframe to ease: incoming, outgoing or both";
var actions = ui.add("group");
var applyButton = actions.add("button", undefined, "Apply", { name: "apply" });
var fromKeys = actions.add("button", undefined, "From Keys", { name: "fromKeys" });
@@ -346,8 +377,10 @@
}
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";
var m = mode.selection ? mode.selection.text : "In-Out";
var n = applyCurve(c, m);
var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it";
status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side;
return n;
}