Skip to content

Commit 0c9e793

Browse files
committed
feat(studio): smooth gesture recordings + editable easeEach in Animation panel
Two-layer smoothing for recorded gestures: 1. Gaussian-weighted moving average (radius=3) rounds off jittery pointer corners after RDP simplification 2. easeEach: power1.inOut on committed keyframes uses bezier interpolation Animation panel Speed dropdown now correctly reads and writes easeEach for keyframed tweens. Both the recast and acorn writers handle easeEach in update-meta mutations.
1 parent 4976b5e commit 0c9e793

7 files changed

Lines changed: 126 additions & 20 deletions

File tree

packages/core/src/parsers/gsapParser.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,13 +1243,17 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void {
12431243
}
12441244
}
12451245

1246-
function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
1246+
function applyUpdatesToCall(
1247+
call: TweenCallInfo,
1248+
updates: Partial<GsapAnimation> & { easeEach?: string },
1249+
): void {
12471250
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
12481251
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
12491252
reconcileEditableProperties(call.fromArg, updates.fromProperties);
12501253
}
12511254
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
1252-
if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
1255+
if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach);
1256+
else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
12531257
if (updates.position !== undefined) {
12541258
const posIdx = call.method === "fromTo" ? 3 : 2;
12551259
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
@@ -1308,7 +1312,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
13081312
export function updateAnimationInScript(
13091313
script: string,
13101314
animationId: string,
1311-
updates: Partial<GsapAnimation>,
1315+
updates: Partial<GsapAnimation> & { easeEach?: string },
13121316
): string {
13131317
let parsed: ParsedGsapAst;
13141318
try {
@@ -1437,6 +1441,7 @@ export function addAnimationWithKeyframesToScript(
14371441
auto?: boolean;
14381442
}>,
14391443
ease?: string,
1444+
easeEach?: string,
14401445
): { script: string; id: string } {
14411446
let parsed: ParsedGsapAst;
14421447
try {
@@ -1450,7 +1455,7 @@ export function addAnimationWithKeyframesToScript(
14501455
}
14511456

14521457
const selector = JSON.stringify(targetSelector);
1453-
const kfCode = buildKeyframeObjectCode(keyframes);
1458+
const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined);
14541459
const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
14551460
if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`);
14561461
const posCode = valueToCode(position);

packages/core/src/parsers/gsapWriterAcorn.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
299299
export function updateAnimationInScript(
300300
script: string,
301301
animationId: string,
302-
updates: Partial<GsapAnimation>,
302+
updates: Partial<GsapAnimation> & { easeEach?: string },
303303
): string {
304304
if (!Object.keys(updates).length) return script;
305305
const parsed = parseGsapScriptAcornForWrite(script);
@@ -324,13 +324,11 @@ export function updateAnimationInScript(
324324
if (updates.duration !== undefined) {
325325
upsertProp(ms, call.varsArg, "duration", updates.duration);
326326
}
327-
if (updates.ease !== undefined) {
328-
// For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe),
329-
// not a top-level ease. Writing top-level ease would leave the per-keyframe
330-
// easing unchanged — the user's edit would silently do nothing.
327+
const easeValue = updates.easeEach ?? updates.ease;
328+
if (easeValue !== undefined) {
331329
const kfNode = keyframesObjectNode(call.varsArg);
332-
if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease);
333-
else upsertProp(ms, call.varsArg, "ease", updates.ease);
330+
if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue);
331+
else upsertProp(ms, call.varsArg, "ease", easeValue);
334332
}
335333
if (updates.extras) {
336334
for (const [key, value] of Object.entries(updates.extras)) {
@@ -1338,14 +1336,15 @@ export function addAnimationWithKeyframesToScript(
13381336
auto?: boolean;
13391337
}>,
13401338
ease?: string,
1339+
easeEach?: string,
13411340
): { script: string; id: string } {
13421341
const parsed = parseGsapScriptAcornForWrite(script);
13431342
if (!parsed) return { script, id: "" };
13441343
const insertionPoint = findInsertionPoint(parsed);
13451344
if (insertionPoint === null) return { script, id: "" };
13461345

13471346
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
1348-
const kfObjCode = buildKeyframeObjectCode(sorted);
1347+
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
13491348
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
13501349
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
13511350
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;

packages/core/src/studio-api/routes/files.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ type GsapMutationRequest =
437437
| {
438438
type: "update-meta";
439439
animationId: string;
440-
updates: { duration?: number; ease?: string; position?: number };
440+
updates: { duration?: number; ease?: string; easeEach?: string; position?: number };
441441
}
442442
| {
443443
type: "add";
@@ -552,6 +552,7 @@ type GsapMutationRequest =
552552
auto?: boolean;
553553
}>;
554554
ease?: string;
555+
easeEach?: string;
555556
}
556557
| {
557558
type: "replace-with-keyframes";
@@ -827,6 +828,7 @@ function executeGsapMutationAcorn(
827828
body.duration,
828829
body.keyframes,
829830
body.ease,
831+
body.easeEach,
830832
);
831833
return result.script;
832834
}

packages/studio/src/components/editor/AnimationCard.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,8 @@ export const AnimationCard = memo(function AnimationCard({
330330
const [copied, setCopied] = useState(false);
331331

332332
const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
333-
const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
333+
const easeName =
334+
(animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none";
334335
const easeLabel = easeName.startsWith("custom(")
335336
? "Custom curve"
336337
: (EASE_LABELS[easeName] ?? easeName);
@@ -440,23 +441,25 @@ export const AnimationCard = memo(function AnimationCard({
440441
value={easeName.startsWith("custom(") ? "custom" : easeName}
441442
options={[...SUPPORTED_EASES, "custom"]}
442443
onChange={(next) => {
444+
const easeKey = animation.keyframes ? "easeEach" : "ease";
443445
if (next === "custom") {
444446
const points = controlPointsForGsapEase(
445447
easeName !== "none" ? easeName : "power2.out",
446448
);
447449
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
448-
onUpdateMeta(animation.id, { ease: `custom(${path})` });
450+
onUpdateMeta(animation.id, { [easeKey]: `custom(${path})` });
449451
} else {
450-
onUpdateMeta(animation.id, { ease: next });
452+
onUpdateMeta(animation.id, { [easeKey]: next });
451453
}
452454
}}
453455
/>
454456
<EaseCurveSection
455457
ease={easeName}
456458
duration={animation.duration}
457-
onCustomEaseCommit={(customEase) =>
458-
onUpdateMeta(animation.id, { ease: customEase })
459-
}
459+
onCustomEaseCommit={(customEase) => {
460+
const easeKey = animation.keyframes ? "easeEach" : "ease";
461+
onUpdateMeta(animation.id, { [easeKey]: customEase });
462+
}}
460463
/>
461464
</>
462465
)}

packages/studio/src/hooks/useGestureCommit.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
66
import { editLog } from "../utils/editDebugLog";
77
import { useGestureRecording } from "./useGestureRecording";
88
import { simplifyGestureSamples } from "../utils/rdpSimplify";
9+
import { smoothGestureKeyframes } from "../utils/gestureSmoother";
910
import { usePlayerStore } from "../player";
1011
import type { DomEditSelection } from "../components/editor/domEditing";
1112
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
@@ -150,10 +151,13 @@ export function useGestureCommit({
150151
}
151152
if (liveSession.commitMutation) {
152153
const recStart = recordingStartTimeRef.current;
153-
const keyframes = sortedPcts.map((pct) => ({
154+
const rawKeyframes = sortedPcts.map((pct) => ({
154155
percentage: pct,
155156
properties: simplified.get(pct) as Record<string, number | string>,
156157
}));
158+
// Smooth jittery pointer input — Gaussian-weighted moving average
159+
// rounds off sharp corners while preserving overall path shape.
160+
const keyframes = smoothGestureKeyframes(rawKeyframes, 3);
157161
const hasPositionProps = keyframes.some((kf) =>
158162
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
159163
);
@@ -236,6 +240,7 @@ export function useGestureCommit({
236240
position: roundTo3(recStart),
237241
duration: roundTo3(duration),
238242
keyframes: groupKfs,
243+
easeEach: "power1.inOut",
239244
},
240245
{ label: "Gesture recording (new range)", softReload: true },
241246
);
@@ -252,6 +257,7 @@ export function useGestureCommit({
252257
position: roundTo3(recStart),
253258
duration: roundTo3(duration),
254259
keyframes: groupKfs,
260+
easeEach: "power1.inOut",
255261
},
256262
{ label: "Gesture recording", softReload: true },
257263
);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from "vitest";
2+
import { smoothGestureKeyframes } from "./gestureSmoother";
3+
4+
describe("smoothGestureKeyframes", () => {
5+
it("returns input unchanged for ≤2 keyframes", () => {
6+
const kfs = [
7+
{ percentage: 0, properties: { x: 0, y: 0 } },
8+
{ percentage: 100, properties: { x: 100, y: 100 } },
9+
];
10+
expect(smoothGestureKeyframes(kfs, 3)).toEqual(kfs);
11+
});
12+
13+
it("pins first and last keyframes", () => {
14+
const kfs = [
15+
{ percentage: 0, properties: { x: 0 } },
16+
{ percentage: 50, properties: { x: 999 } },
17+
{ percentage: 100, properties: { x: 200 } },
18+
];
19+
const result = smoothGestureKeyframes(kfs, 3);
20+
expect(result[0].properties.x).toBe(0);
21+
expect(result[result.length - 1].properties.x).toBe(200);
22+
});
23+
24+
it("smooths a zigzag into a gentler curve", () => {
25+
const kfs = [
26+
{ percentage: 0, properties: { x: 0 } },
27+
{ percentage: 25, properties: { x: 100 } },
28+
{ percentage: 50, properties: { x: 0 } },
29+
{ percentage: 75, properties: { x: 100 } },
30+
{ percentage: 100, properties: { x: 0 } },
31+
];
32+
const result = smoothGestureKeyframes(kfs, 2);
33+
const mid = result[2].properties.x as number;
34+
// The sharp 0→100→0 zigzag should be softened — mid should be
35+
// pulled toward the neighbors, not stay at exactly 0.
36+
expect(mid).toBeGreaterThan(0);
37+
expect(mid).toBeLessThan(100);
38+
});
39+
40+
it("returns input unchanged with radius 0", () => {
41+
const kfs = [
42+
{ percentage: 0, properties: { x: 0 } },
43+
{ percentage: 50, properties: { x: 999 } },
44+
{ percentage: 100, properties: { x: 0 } },
45+
];
46+
expect(smoothGestureKeyframes(kfs, 0)).toEqual(kfs);
47+
});
48+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// ponytail: Gaussian-weighted moving average over gesture keyframes.
2+
// Rounds off jittery corners from raw pointer input while preserving
3+
// overall path shape. First/last keyframes are pinned (never moved).
4+
// Upgrade path: Catmull-Rom spline if users need curve-fitted paths.
5+
6+
interface Keyframe {
7+
percentage: number;
8+
properties: Record<string, number | string>;
9+
}
10+
11+
function gaussianWeight(distance: number, sigma: number): number {
12+
return Math.exp(-(distance * distance) / (2 * sigma * sigma));
13+
}
14+
15+
export function smoothGestureKeyframes(keyframes: Keyframe[], radius: number): Keyframe[] {
16+
if (keyframes.length <= 2 || radius <= 0) return keyframes;
17+
const sigma = radius / 2;
18+
const numericKeys = new Set<string>();
19+
for (const kf of keyframes) {
20+
for (const [k, v] of Object.entries(kf.properties)) {
21+
if (typeof v === "number") numericKeys.add(k);
22+
}
23+
}
24+
if (numericKeys.size === 0) return keyframes;
25+
26+
return keyframes.map((kf, i) => {
27+
if (i === 0 || i === keyframes.length - 1) return kf;
28+
const smoothed: Record<string, number | string> = { ...kf.properties };
29+
for (const key of numericKeys) {
30+
let weightSum = 0;
31+
let valueSum = 0;
32+
for (let j = Math.max(0, i - radius); j <= Math.min(keyframes.length - 1, i + radius); j++) {
33+
const v = keyframes[j].properties[key];
34+
if (typeof v !== "number") continue;
35+
const w = gaussianWeight(j - i, sigma);
36+
weightSum += w;
37+
valueSum += v * w;
38+
}
39+
if (weightSum > 0) smoothed[key] = Math.round((valueSum / weightSum) * 1000) / 1000;
40+
}
41+
return { percentage: kf.percentage, properties: smoothed };
42+
});
43+
}

0 commit comments

Comments
 (0)