feat(studio): per-keyframe ease presets, velocity fitting, gesture smoothing (#1694)

Per-keyframe speed-curve editing, velocity-based ease fitting, and Gaussian gesture smoothing. Easy Ease presets, per-segment KeyframeEaseList with a bezier editor, AE-convention ease fitting, position-only set-tween rows, and AnimationCard extraction.
This commit is contained in:
Miguel Ángel
2026-06-24 18:43:37 -04:00
committed by GitHub
parent 8ae010bf51
commit 97db811a2f
18 changed files with 779 additions and 322 deletions
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { smoothGestureKeyframes } from "./gestureSmoother";
describe("smoothGestureKeyframes", () => {
it("returns input unchanged for ≤2 keyframes", () => {
const kfs = [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 100 } },
];
expect(smoothGestureKeyframes(kfs, 3)).toEqual(kfs);
});
it("pins first and last keyframes", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 999 } },
{ percentage: 100, properties: { x: 200 } },
];
const result = smoothGestureKeyframes(kfs, 3);
expect(result[0].properties.x).toBe(0);
expect(result[result.length - 1].properties.x).toBe(200);
});
it("smooths a zigzag into a gentler curve", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 25, properties: { x: 100 } },
{ percentage: 50, properties: { x: 0 } },
{ percentage: 75, properties: { x: 100 } },
{ percentage: 100, properties: { x: 0 } },
];
const result = smoothGestureKeyframes(kfs, 2);
const mid = result[2].properties.x as number;
// The sharp 0→100→0 zigzag should be softened — mid should be
// pulled toward the neighbors, not stay at exactly 0.
expect(mid).toBeGreaterThan(0);
expect(mid).toBeLessThan(100);
});
it("returns input unchanged with radius 0", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 999 } },
{ percentage: 100, properties: { x: 0 } },
];
expect(smoothGestureKeyframes(kfs, 0)).toEqual(kfs);
});
});
@@ -0,0 +1,46 @@
// ponytail: Gaussian-weighted moving average over gesture keyframes.
// Rounds off jittery corners from raw pointer input while preserving
// overall path shape. First/last keyframes are pinned (never moved).
// Upgrade path: Catmull-Rom spline if users need curve-fitted paths.
interface Keyframe {
percentage: number;
properties: Record<string, number | string>;
}
function gaussianWeight(distance: number, sigma: number): number {
return Math.exp(-(distance * distance) / (2 * sigma * sigma));
}
export function smoothGestureKeyframes(keyframes: Keyframe[], radius: number): Keyframe[] {
if (keyframes.length <= 2 || radius <= 0) return keyframes;
const sigma = radius / 2;
const numericKeys = new Set<string>();
for (const kf of keyframes) {
for (const [k, v] of Object.entries(kf.properties)) {
if (typeof v === "number") numericKeys.add(k);
}
}
if (numericKeys.size === 0) return keyframes;
return keyframes.map((kf, i) => {
if (i === 0 || i === keyframes.length - 1) return kf;
const smoothed: Record<string, number | string> = { ...kf.properties };
for (const key of numericKeys) {
let weightSum = 0;
let valueSum = 0;
for (let j = Math.max(0, i - radius); j <= Math.min(keyframes.length - 1, i + radius); j++) {
const v = keyframes[j].properties[key];
if (typeof v !== "number") continue;
// Weight by index distance, not time. Samples here are roughly evenly
// spaced, so for the small radius (3) this is fine; switch to a
// percentage-domain distance if the window ever grows much larger.
const w = gaussianWeight(j - i, sigma);
weightSum += w;
valueSum += v * w;
}
if (weightSum > 0) smoothed[key] = Math.round((valueSum / weightSum) * 1000) / 1000;
}
return { percentage: kf.percentage, properties: smoothed };
});
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { fitEasesFromVelocity, type FittedKeyframe } from "./velocityEaseFitter";
function makeSamples(
count: number,
duration: number,
velocityFn: (t: number) => number,
): { time: number; properties: Record<string, number> }[] {
const samples = [];
let pos = 0;
for (let i = 0; i <= count; i++) {
const t = (i / count) * duration;
const v = velocityFn(t / duration);
pos += v * (duration / count);
samples.push({ time: t, properties: { x: pos } });
}
return samples;
}
describe("fitEasesFromVelocity", () => {
it("constant speed → no ease assigned", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
const samples = makeSamples(60, 1, () => 100);
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBeUndefined();
});
it("decelerate at end → AE Easy Ease In (slow-end curve)", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
// Start fast, end slow → playback must also be slow at the end (CP2 y=1).
const samples = makeSamples(60, 1, (t) => Math.max(0, 200 * (1 - t)));
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBe("custom(M0,0 C0.333,0.333 0.667,1 1,1)");
});
it("accelerate from start → AE Easy Ease Out (slow-start curve)", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
// Start slow, end fast → playback must also be slow at the start (CP1 y=0).
const samples = makeSamples(60, 1, (t) => 200 * t);
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBe("custom(M0,0 C0.333,0 0.667,0.667 1,1)");
});
it("single keyframe → returns unchanged", () => {
const kfs: FittedKeyframe[] = [{ percentage: 0, properties: { x: 0 } }];
const result = fitEasesFromVelocity(kfs, [], 1);
expect(result).toEqual(kfs);
});
});
@@ -0,0 +1,121 @@
interface TimedSample {
time: number;
value: number;
}
// After Effects convention (ease named by the keyframe side it acts on):
// Easy Ease — slow at both ends (cubic-bezier 0.333,0 0.667,1)
// Easy Ease In — eases *into* the keyframe → decelerates → slow at the END
// Easy Ease Out — eases *out of* the keyframe → accelerates → slow at the START
// The control-point y values must match that polarity (a flat tangent at the
// slow side): slow-end pins CP2 at y=1, slow-start pins CP1 at y=0.
const AE_EASE = "custom(M0,0 C0.333,0 0.667,1 1,1)";
const AE_EASE_IN = "custom(M0,0 C0.333,0.333 0.667,1 1,1)";
const AE_EASE_OUT = "custom(M0,0 C0.333,0 0.667,0.667 1,1)";
const VELOCITY_THRESHOLD = 0.3;
function averageSpeed(samples: TimedSample[], from: number, to: number): number {
const seg = samples.filter((s) => s.time >= from && s.time <= to);
if (seg.length < 2) return 0;
let total = 0;
for (let i = 1; i < seg.length; i++) {
const dt = seg[i].time - seg[i - 1].time;
if (dt > 0) total += Math.abs(seg[i].value - seg[i - 1].value) / dt;
}
return total / (seg.length - 1);
}
function speedAtEdge(
samples: TimedSample[],
t: number,
window: number,
side: "start" | "end",
): number {
const near = samples.filter((s) =>
side === "start" ? s.time >= t && s.time <= t + window : s.time >= t - window && s.time <= t,
);
if (near.length < 2) return 0;
let total = 0;
for (let i = 1; i < near.length; i++) {
const dt = near[i].time - near[i - 1].time;
if (dt > 0) total += Math.abs(near[i].value - near[i - 1].value) / dt;
}
return total / (near.length - 1);
}
export interface FittedKeyframe {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
/**
* Analyze velocity profile of raw samples between keyframes and assign
* per-keyframe eases based on deceleration/acceleration patterns.
*
* For each segment between consecutive keyframes:
* - Constant speed → linear ("none")
* - Decelerates at end → Easy Ease In
* - Accelerates from start → Easy Ease Out
* - Both → Easy Ease (full)
*/
// fallow-ignore-next-line complexity
export function fitEasesFromVelocity(
keyframes: FittedKeyframe[],
rawSamples: { time: number; properties: Record<string, number> }[],
totalDuration: number,
): FittedKeyframe[] {
if (keyframes.length < 2 || rawSamples.length < 3) return keyframes;
const result = [...keyframes.map((kf) => ({ ...kf }))];
for (let i = 1; i < result.length; i++) {
const prevPct = result[i - 1].percentage;
const currPct = result[i].percentage;
const segStart = (prevPct / 100) * totalDuration;
const segEnd = (currPct / 100) * totalDuration;
const segDur = segEnd - segStart;
if (segDur <= 0) continue;
// Use the dominant property (largest range) for velocity analysis
const props = Object.keys(result[i].properties);
let bestProp = props[0] ?? "x";
let bestRange = 0;
for (const p of props) {
const startVal = Number(result[i - 1].properties[p] ?? 0);
const endVal = Number(result[i].properties[p] ?? 0);
const range = Math.abs(endVal - startVal);
if (range > bestRange) {
bestRange = range;
bestProp = p;
}
}
const propSamples: TimedSample[] = rawSamples
.filter((s) => s.time >= segStart && s.time <= segEnd)
.map((s) => ({ time: s.time, value: s.properties[bestProp] ?? 0 }));
if (propSamples.length < 3) continue;
const edgeWindow = segDur * 0.25;
const avgSpd = averageSpeed(propSamples, segStart, segEnd);
if (avgSpd < 1e-6) continue;
const startSpd = speedAtEdge(propSamples, segStart, edgeWindow, "start");
const endSpd = speedAtEdge(propSamples, segEnd, edgeWindow, "end");
const slowStart = startSpd / avgSpd < VELOCITY_THRESHOLD;
const slowEnd = endSpd / avgSpd < VELOCITY_THRESHOLD;
if (slowStart && slowEnd) {
result[i].ease = AE_EASE;
} else if (slowEnd) {
result[i].ease = AE_EASE_IN;
} else if (slowStart) {
result[i].ease = AE_EASE_OUT;
}
// Otherwise leave ease undefined → linear (constant speed)
}
return result;
}