Skip to content

Commit 3e5afdc

Browse files
committed
feat(studio): marquee selection, AE presets, per-keyframe ease + velocity fitting
Marquee selection: click+drag on empty canvas draws a dashed selection rectangle. All elements whose OBB intersects are group-selected via SAT. Shift+marquee adds to selection. Click on empty deselects. Per-keyframe easing: each keyframe segment has its own ease, editable via expandable bezier curve editor in the Animation panel. Parser preserves per-keyframe ease through round-trips (ease-only updates preserve existing properties). AE Easy Ease presets: correct After Effects bezier values (0.333, 0, 0.667, 1) in the preset grid. Velocity-based curve fitting: gesture recordings analyze velocity profile and assign per-keyframe custom eases automatically. Gesture smoothing: Gaussian-weighted moving average (from PR #1658) + easeEach support for keyframed tweens + fetch-cancellation race fix in useGsapAnimationsForElement.
1 parent 20d7200 commit 3e5afdc

34 files changed

Lines changed: 1110 additions & 176 deletions

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: 26 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);
@@ -2216,6 +2221,23 @@ export function updateKeyframeInScript(
22162221
const match = findKeyframePropByPct(kfNode, percentage);
22172222
if (!match) return script;
22182223

2224+
if (Object.keys(properties).length === 0 && ease) {
2225+
// Ease-only update: preserve existing properties, just add/replace ease
2226+
const existing = match.prop.value;
2227+
if (existing?.type === "ObjectExpression") {
2228+
const props = (existing.properties ?? []) as AstNode[];
2229+
const easeIdx = props.findIndex(
2230+
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease",
2231+
);
2232+
const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
2233+
if (easeIdx >= 0) {
2234+
props[easeIdx] = easeNode;
2235+
} else {
2236+
props.push(easeNode);
2237+
}
2238+
return recast.print(loc.parsed.ast).code;
2239+
}
2240+
}
22192241
match.prop.value = buildKeyframeValueNode(properties, ease);
22202242
return recast.print(loc.parsed.ast).code;
22212243
}

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/init.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ export function initSandboxRuntimeModular(): void {
7272
}
7373

7474
window.__timelines = window.__timelines || {};
75+
76+
// Agents often write `window.__timelines = [tl]` (array) instead of the
77+
// keyed-by-composition-id object the runtime expects. Normalize at init so
78+
// the rest of the pipeline can assume a Record<string, timeline>.
79+
if (Array.isArray(window.__timelines)) {
80+
const arr = window.__timelines as unknown[];
81+
const rootId =
82+
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ??
83+
"root";
84+
const normalized: Record<string, unknown> = {};
85+
if (arr.length === 1) {
86+
normalized[rootId] = arr[0];
87+
} else {
88+
for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i];
89+
}
90+
(window as Record<string, unknown>).__timelines = normalized;
91+
}
92+
93+
// Agents sometimes omit data-start on the root composition element. The
94+
// runtime skips timed-visibility for elements without it, making clips
95+
// invisible and timelines non-seekable. Default to 0 for the root.
96+
const rootComp = document.querySelector("[data-composition-id]");
97+
if (rootComp && !rootComp.hasAttribute("data-start")) {
98+
rootComp.setAttribute("data-start", "0");
99+
}
100+
75101
const registerRuntimeCleanup = (callback: () => void) => {
76102
runtimeCleanupCallbacks.push(callback);
77103
};

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/StudioPreviewArea.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
STUDIO_PREVIEW_SELECTION_ENABLED,
1818
} from "./editor/manualEditingAvailability";
1919
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
20-
import { useDomEditContext } from "../contexts/DomEditContext";
20+
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
2121
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
2222
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
2323
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
@@ -117,6 +117,9 @@ export function StudioPreviewArea({
117117
domEditHoverSelection,
118118
domEditSelection,
119119
domEditGroupSelections,
120+
selectedGsapAnimations,
121+
} = useDomEditSelectionContext();
122+
const {
120123
handleTimelineElementSelect,
121124
handlePreviewCanvasMouseDown,
122125
handlePreviewCanvasPointerMove,
@@ -128,15 +131,16 @@ export function StudioPreviewArea({
128131
handleDomGroupPathOffsetCommit,
129132
handleDomBoxSizeCommit,
130133
handleDomRotationCommit,
131-
selectedGsapAnimations,
132134
handleGsapRemoveKeyframe,
133135
handleGsapUpdateMeta,
134136
handleGsapAddKeyframe,
135137
handleGsapConvertToKeyframes,
136138
handleGsapDeleteAllForElement,
137139
buildDomSelectionForTimelineElement,
138-
} = useDomEditContext();
140+
applyMarqueeSelection,
141+
} = useDomEditActionsContext();
139142

143+
// fallow-ignore-next-line complexity
140144
const [snapPrefs, setSnapPrefs] = useState(() => {
141145
const p = readStudioUiPreferences();
142146
return {
@@ -160,6 +164,7 @@ export function StudioPreviewArea({
160164
const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
161165
handleGsapDeleteAllForElement(`#${rawId}`);
162166
},
167+
// fallow-ignore-next-line complexity
163168
onDeleteKeyframe: (_elId: string, pct: number) => {
164169
const cacheKey = domEditSelection?.id ?? "";
165170
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
@@ -215,6 +220,7 @@ export function StudioPreviewArea({
215220
}
216221
}
217222
},
223+
// fallow-ignore-next-line complexity
218224
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
219225
const currentTime = usePlayerStore.getState().currentTime;
220226
const pct =
@@ -339,6 +345,7 @@ export function StudioPreviewArea({
339345
gridSpacing={snapPrefs.gridSpacing}
340346
recordingState={recordingState}
341347
onToggleRecording={onToggleRecording}
348+
onMarqueeSelect={applyMarqueeSelection}
342349
/>
343350
<SnapToolbar onSnapChange={setSnapPrefs} />
344351
{STUDIO_KEYFRAMES_ENABLED && (

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export function StudioRightPanel({
121121
handleSetArcPath,
122122
handleUpdateArcSegment,
123123
handleUnroll,
124+
handleUpdateKeyframeEase,
124125
handleGsapAddKeyframe,
125126
handleGsapRemoveKeyframe,
126127
handleGsapConvertToKeyframes,
@@ -274,6 +275,7 @@ export function StudioRightPanel({
274275
onSetArcPath={handleSetArcPath}
275276
onUpdateArcSegment={handleUpdateArcSegment}
276277
onUnroll={handleUnroll}
278+
onUpdateKeyframeEase={handleUpdateKeyframeEase}
277279
recordingState={recordingState}
278280
recordingDuration={recordingDuration}
279281
onToggleRecording={onToggleRecording}

0 commit comments

Comments
 (0)