mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user