Skip to content

Commit 5ae8a9b

Browse files
committed
feat(studio): smooth gesture recordings + editable easeEach in Animation panel
Gesture recordings now go through Gaussian smoothing (radius 3) before commit, eliminating jitter while preserving motion shape. The Animation panel's Speed dropdown writes easeEach for keyframed tweens (ease for flat tweens), and the selected preset highlights correctly. Fixed a fetch-cancellation race in useGsapAnimationsForElement — the target object reference in useEffect deps was unstable across re-renders, causing cleanup to cancel in-flight API calls before results arrived. Switched deps to target?.id and target?.selector primitives. Also adds a cache-buster query param on the gsap-animations fetch to prevent stale HTTP-cached responses after mutations.
1 parent b651a9d commit 5ae8a9b

14 files changed

Lines changed: 149 additions & 77 deletions

File tree

docs/contributing.mdx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,10 @@ bun run --filter '*' test
8989
| Package | Path | Description |
9090
|---------|------|-------------|
9191
| [`@hyperframes/core`](/packages/core) | `packages/core` | Types, HTML generation, runtime, linter |
92-
| [`@hyperframes/sdk`](/packages/sdk) | `packages/sdk` | Headless composition editing engine |
9392
| [`@hyperframes/engine`](/packages/engine) | `packages/engine` | Seekable page-to-video capture engine |
94-
| [`@hyperframes/player`](/packages/player) | `packages/player` | Embeddable composition player |
9593
| [`@hyperframes/producer`](/packages/producer) | `packages/producer` | Full rendering pipeline (capture + encode) |
96-
| [`@hyperframes/shader-transitions`](/packages/shader-transitions) | `packages/shader-transitions` | WebGL shader transition engine |
97-
| [`@hyperframes/aws-lambda`](/packages/aws-lambda) | `packages/aws-lambda` | AWS Lambda distributed rendering adapter |
98-
| [`@hyperframes/gcp-cloud-run`](/packages/gcp-cloud-run) | `packages/gcp-cloud-run` | GCP Cloud Run distributed rendering adapter |
9994
| [`@hyperframes/studio`](/packages/studio) | `packages/studio` | Composition editor UI |
10095
| [`hyperframes`](/packages/cli) | `packages/cli` | CLI for creating, previewing, and rendering |
101-
| `@hyperframes/sdk-playground` (private) | `packages/sdk-playground` | Local SDK playground app |
10296

10397
## What to Work On
10498

docs/docs.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,13 +302,9 @@
302302
"group": "Packages",
303303
"pages": [
304304
"packages/core",
305-
"packages/sdk",
306305
"packages/engine",
307306
"packages/player",
308307
"packages/producer",
309-
"packages/shader-transitions",
310-
"packages/aws-lambda",
311-
"packages/gcp-cloud-run",
312308
"packages/studio",
313309
"packages/cli"
314310
]

docs/introduction.mdx

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,27 +79,12 @@ Run `npx hyperframes render --output demo.mp4` and this produces an MP4 with det
7979
<Card title="@hyperframes/core" icon="cube" href="/packages/core">
8080
Types, HTML parsing, runtime, and composition linter — the foundation everything else builds on.
8181
</Card>
82-
<Card title="@hyperframes/sdk" icon="code" href="/packages/sdk">
83-
Headless composition editing engine for agents, custom editors, patch events, and persistence.
84-
</Card>
8582
<Card title="@hyperframes/engine" icon="gear" href="/packages/engine">
8683
Seekable page-to-video capture engine. Loads HTML in headless Chrome and captures frame-by-frame.
8784
</Card>
88-
<Card title="@hyperframes/player" icon="play" href="/packages/player">
89-
Embeddable web component for playing HyperFrames compositions in any web page.
90-
</Card>
9185
<Card title="@hyperframes/producer" icon="video" href="/packages/producer">
9286
Full rendering pipeline combining capture and FFmpeg encoding into a single API call.
9387
</Card>
94-
<Card title="@hyperframes/shader-transitions" icon="sparkles" href="/packages/shader-transitions">
95-
WebGL shader transitions for scene-to-scene motion and render-time compositing.
96-
</Card>
97-
<Card title="@hyperframes/aws-lambda" icon="cloud" href="/packages/aws-lambda">
98-
AWS Lambda and Step Functions adapter for distributed rendering.
99-
</Card>
100-
<Card title="@hyperframes/gcp-cloud-run" icon="cloud" href="/packages/gcp-cloud-run">
101-
Google Cloud Run and Workflows adapter for distributed rendering.
102-
</Card>
10388
<Card title="@hyperframes/studio" icon="palette" href="/packages/studio">
10489
Visual composition editor UI for building and previewing timelines interactively.
10590
</Card>

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/runtime/media.ts

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -290,33 +290,18 @@ export function syncRuntimeMedia(params: {
290290
}
291291
const forceSync = !isPlayingVideo && params.forceSync && drift > 0.02;
292292
if (hardSync || strictSync || forceSync) {
293-
// Skip the per-tick seek (and the `el.load()` drift-recovery retry
294-
// below) for `<video>` elements that have a sibling
295-
// `<img id="__render_frame_<id>__">`. The sibling is created only
296-
// by the producer's frame-injection pipeline during render — its
297-
// presence means the visual is painted from the `<img>` and the
298-
// `<video>` is `visibility: hidden`. Audio is mixed by ffmpeg from
299-
// source files in `runAudioStage`, never via Chrome's in-browser
300-
// audio path. So the `<video>`'s `currentTime` has no observable
301-
// effect during render, and the per-tick set just kicks Chrome's
302-
// media pipeline for nothing. Preview is unaffected (the sibling
303-
// only exists during render).
304-
const skipForInjectedVideo =
305-
el.tagName === "VIDEO" && el.id && !!document.getElementById(`__render_frame_${el.id}__`);
306-
if (!skipForInjectedVideo) {
293+
try {
294+
el.currentTime = relTime;
295+
} catch (err) {
296+
swallow("runtime.media.site2", err);
297+
}
298+
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
299+
seekLoadRetried.add(el);
300+
el.load();
307301
try {
308302
el.currentTime = relTime;
309303
} catch (err) {
310-
swallow("runtime.media.site2", err);
311-
}
312-
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
313-
seekLoadRetried.add(el);
314-
el.load();
315-
try {
316-
el.currentTime = relTime;
317-
} catch (err) {
318-
swallow("runtime.media.site3", err);
319-
}
304+
swallow("runtime.media.site3", err);
320305
}
321306
}
322307
playRequested.delete(el);

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: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,13 @@ export const AnimationCard = memo(function AnimationCard({
328328
);
329329

330330
const [copied, setCopied] = useState(false);
331+
const [optimisticEase, setOptimisticEase] = useState<string | null>(null);
331332

332333
const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
333-
const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
334+
const propEase =
335+
(animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none";
336+
const easeName = optimisticEase ?? propEase;
337+
if (optimisticEase && propEase === optimisticEase) setOptimisticEase(null);
334338
const easeLabel = easeName.startsWith("custom(")
335339
? "Custom curve"
336340
: (EASE_LABELS[easeName] ?? easeName);
@@ -440,23 +444,28 @@ export const AnimationCard = memo(function AnimationCard({
440444
value={easeName.startsWith("custom(") ? "custom" : easeName}
441445
options={[...SUPPORTED_EASES, "custom"]}
442446
onChange={(next) => {
447+
const easeKey = animation.keyframes ? "easeEach" : "ease";
443448
if (next === "custom") {
444449
const points = controlPointsForGsapEase(
445450
easeName !== "none" ? easeName : "power2.out",
446451
);
447452
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
448-
onUpdateMeta(animation.id, { ease: `custom(${path})` });
453+
setOptimisticEase(`custom(${path})`);
454+
onUpdateMeta(animation.id, { [easeKey]: `custom(${path})` });
449455
} else {
450-
onUpdateMeta(animation.id, { ease: next });
456+
setOptimisticEase(next);
457+
onUpdateMeta(animation.id, { [easeKey]: next });
451458
}
452459
}}
453460
/>
454461
<EaseCurveSection
455462
ease={easeName}
456463
duration={animation.duration}
457-
onCustomEaseCommit={(customEase) =>
458-
onUpdateMeta(animation.id, { ease: customEase })
459-
}
464+
onCustomEaseCommit={(customEase) => {
465+
const easeKey = animation.keyframes ? "easeEach" : "ease";
466+
setOptimisticEase(customEase);
467+
onUpdateMeta(animation.id, { [easeKey]: customEase });
468+
}}
460469
/>
461470
</>
462471
)}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useCallback, useRef, useState } from "react";
1+
import { useCallback, useRef, useState } from "react";
22
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
33
import { roundToCenti } from "../../utils/rounding";
44

@@ -40,7 +40,7 @@ function MiniCurveSvg({
4040
);
4141
}
4242

43-
const EasePresetGrid = memo(function EasePresetGrid({
43+
const EasePresetGrid = function EasePresetGrid({
4444
currentEase,
4545
onSelect,
4646
}: {
@@ -74,7 +74,7 @@ const EasePresetGrid = memo(function EasePresetGrid({
7474
})}
7575
</div>
7676
);
77-
});
77+
};
7878

7979
const round2 = roundToCenti;
8080

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
);

0 commit comments

Comments
 (0)