Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions docs/contributing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,10 @@ bun run --filter '*' test
| Package | Path | Description |
|---------|------|-------------|
| [`@hyperframes/core`](/packages/core) | `packages/core` | Types, HTML generation, runtime, linter |
| [`@hyperframes/sdk`](/packages/sdk) | `packages/sdk` | Headless composition editing engine |
| [`@hyperframes/engine`](/packages/engine) | `packages/engine` | Seekable page-to-video capture engine |
| [`@hyperframes/player`](/packages/player) | `packages/player` | Embeddable composition player |
| [`@hyperframes/producer`](/packages/producer) | `packages/producer` | Full rendering pipeline (capture + encode) |
| [`@hyperframes/shader-transitions`](/packages/shader-transitions) | `packages/shader-transitions` | WebGL shader transition engine |
| [`@hyperframes/aws-lambda`](/packages/aws-lambda) | `packages/aws-lambda` | AWS Lambda distributed rendering adapter |
| [`@hyperframes/gcp-cloud-run`](/packages/gcp-cloud-run) | `packages/gcp-cloud-run` | GCP Cloud Run distributed rendering adapter |
| [`@hyperframes/studio`](/packages/studio) | `packages/studio` | Composition editor UI |
| [`hyperframes`](/packages/cli) | `packages/cli` | CLI for creating, previewing, and rendering |
| `@hyperframes/sdk-playground` (private) | `packages/sdk-playground` | Local SDK playground app |

## What to Work On

Expand Down
4 changes: 0 additions & 4 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,13 +302,9 @@
"group": "Packages",
"pages": [
"packages/core",
"packages/sdk",
"packages/engine",
"packages/player",
"packages/producer",
"packages/shader-transitions",
"packages/aws-lambda",
"packages/gcp-cloud-run",
"packages/studio",
"packages/cli"
]
Expand Down
15 changes: 0 additions & 15 deletions docs/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,27 +79,12 @@ Run `npx hyperframes render --output demo.mp4` and this produces an MP4 with det
<Card title="@hyperframes/core" icon="cube" href="/packages/core">
Types, HTML parsing, runtime, and composition linter — the foundation everything else builds on.
</Card>
<Card title="@hyperframes/sdk" icon="code" href="/packages/sdk">
Headless composition editing engine for agents, custom editors, patch events, and persistence.
</Card>
<Card title="@hyperframes/engine" icon="gear" href="/packages/engine">
Seekable page-to-video capture engine. Loads HTML in headless Chrome and captures frame-by-frame.
</Card>
<Card title="@hyperframes/player" icon="play" href="/packages/player">
Embeddable web component for playing HyperFrames compositions in any web page.
</Card>
<Card title="@hyperframes/producer" icon="video" href="/packages/producer">
Full rendering pipeline combining capture and FFmpeg encoding into a single API call.
</Card>
<Card title="@hyperframes/shader-transitions" icon="sparkles" href="/packages/shader-transitions">
WebGL shader transitions for scene-to-scene motion and render-time compositing.
</Card>
<Card title="@hyperframes/aws-lambda" icon="cloud" href="/packages/aws-lambda">
AWS Lambda and Step Functions adapter for distributed rendering.
</Card>
<Card title="@hyperframes/gcp-cloud-run" icon="cloud" href="/packages/gcp-cloud-run">
Google Cloud Run and Workflows adapter for distributed rendering.
</Card>
<Card title="@hyperframes/studio" icon="palette" href="/packages/studio">
Visual composition editor UI for building and previewing timelines interactively.
</Card>
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1243,13 +1243,17 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void {
}
}

function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
function applyUpdatesToCall(
call: TweenCallInfo,
updates: Partial<GsapAnimation> & { easeEach?: string },
): void {
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
reconcileEditableProperties(call.fromArg, updates.fromProperties);
}
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach);
else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
Expand Down Expand Up @@ -1308,7 +1312,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation>,
updates: Partial<GsapAnimation> & { easeEach?: string },
): string {
let parsed: ParsedGsapAst;
try {
Expand Down Expand Up @@ -1437,6 +1441,7 @@ export function addAnimationWithKeyframesToScript(
auto?: boolean;
}>,
ease?: string,
easeEach?: string,
): { script: string; id: string } {
let parsed: ParsedGsapAst;
try {
Expand All @@ -1450,7 +1455,7 @@ export function addAnimationWithKeyframesToScript(
}

const selector = JSON.stringify(targetSelector);
const kfCode = buildKeyframeObjectCode(keyframes);
const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined);
const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`);
const posCode = valueToCode(position);
Expand Down
15 changes: 7 additions & 8 deletions packages/core/src/parsers/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation>,
updates: Partial<GsapAnimation> & { easeEach?: string },
): string {
if (!Object.keys(updates).length) return script;
const parsed = parseGsapScriptAcornForWrite(script);
Expand All @@ -324,13 +324,11 @@ export function updateAnimationInScript(
if (updates.duration !== undefined) {
upsertProp(ms, call.varsArg, "duration", updates.duration);
}
if (updates.ease !== undefined) {
// For a keyframe tween, easing lives at keyframes.easeEach (per-keyframe),
// not a top-level ease. Writing top-level ease would leave the per-keyframe
// easing unchanged — the user's edit would silently do nothing.
const easeValue = updates.easeEach ?? updates.ease;
if (easeValue !== undefined) {
const kfNode = keyframesObjectNode(call.varsArg);
if (kfNode) upsertProp(ms, kfNode, "easeEach", updates.ease);
else upsertProp(ms, call.varsArg, "ease", updates.ease);
if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue);
else upsertProp(ms, call.varsArg, "ease", easeValue);
}
if (updates.extras) {
for (const [key, value] of Object.entries(updates.extras)) {
Expand Down Expand Up @@ -1338,14 +1336,15 @@ export function addAnimationWithKeyframesToScript(
auto?: boolean;
}>,
ease?: string,
easeEach?: string,
): { script: string; id: string } {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, id: "" };
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) return { script, id: "" };

const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
const kfObjCode = buildKeyframeObjectCode(sorted);
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;
Expand Down
33 changes: 9 additions & 24 deletions packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,33 +290,18 @@ export function syncRuntimeMedia(params: {
}
const forceSync = !isPlayingVideo && params.forceSync && drift > 0.02;
if (hardSync || strictSync || forceSync) {
// Skip the per-tick seek (and the `el.load()` drift-recovery retry
// below) for `<video>` elements that have a sibling
// `<img id="__render_frame_<id>__">`. The sibling is created only
// by the producer's frame-injection pipeline during render — its
// presence means the visual is painted from the `<img>` and the
// `<video>` is `visibility: hidden`. Audio is mixed by ffmpeg from
// source files in `runAudioStage`, never via Chrome's in-browser
// audio path. So the `<video>`'s `currentTime` has no observable
// effect during render, and the per-tick set just kicks Chrome's
// media pipeline for nothing. Preview is unaffected (the sibling
// only exists during render).
const skipForInjectedVideo =
el.tagName === "VIDEO" && el.id && !!document.getElementById(`__render_frame_${el.id}__`);
if (!skipForInjectedVideo) {
try {
el.currentTime = relTime;
} catch (err) {
swallow("runtime.media.site2", err);
}
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
seekLoadRetried.add(el);
el.load();
try {
el.currentTime = relTime;
} catch (err) {
swallow("runtime.media.site2", err);
}
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
seekLoadRetried.add(el);
el.load();
try {
el.currentTime = relTime;
} catch (err) {
swallow("runtime.media.site3", err);
}
swallow("runtime.media.site3", err);
}
}
playRequested.delete(el);
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ type GsapMutationRequest =
| {
type: "update-meta";
animationId: string;
updates: { duration?: number; ease?: string; position?: number };
updates: { duration?: number; ease?: string; easeEach?: string; position?: number };
}
| {
type: "add";
Expand Down Expand Up @@ -552,6 +552,7 @@ type GsapMutationRequest =
auto?: boolean;
}>;
ease?: string;
easeEach?: string;
}
| {
type: "replace-with-keyframes";
Expand Down Expand Up @@ -827,6 +828,7 @@ function executeGsapMutationAcorn(
body.duration,
body.keyframes,
body.ease,
body.easeEach,
);
return result.script;
}
Expand Down
21 changes: 15 additions & 6 deletions packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,13 @@ export const AnimationCard = memo(function AnimationCard({
);

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

const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
const propEase =
(animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none";
const easeName = optimisticEase ?? propEase;
if (optimisticEase && propEase === optimisticEase) setOptimisticEase(null);
const easeLabel = easeName.startsWith("custom(")
? "Custom curve"
: (EASE_LABELS[easeName] ?? easeName);
Expand Down Expand Up @@ -440,23 +444,28 @@ export const AnimationCard = memo(function AnimationCard({
value={easeName.startsWith("custom(") ? "custom" : easeName}
options={[...SUPPORTED_EASES, "custom"]}
onChange={(next) => {
const easeKey = animation.keyframes ? "easeEach" : "ease";
if (next === "custom") {
const points = controlPointsForGsapEase(
easeName !== "none" ? easeName : "power2.out",
);
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
onUpdateMeta(animation.id, { ease: `custom(${path})` });
setOptimisticEase(`custom(${path})`);
onUpdateMeta(animation.id, { [easeKey]: `custom(${path})` });
} else {
onUpdateMeta(animation.id, { ease: next });
setOptimisticEase(next);
onUpdateMeta(animation.id, { [easeKey]: next });
}
}}
/>
<EaseCurveSection
ease={easeName}
duration={animation.duration}
onCustomEaseCommit={(customEase) =>
onUpdateMeta(animation.id, { ease: customEase })
}
onCustomEaseCommit={(customEase) => {
const easeKey = animation.keyframes ? "easeEach" : "ease";
setOptimisticEase(customEase);
onUpdateMeta(animation.id, { [easeKey]: customEase });
}}
/>
</>
)}
Expand Down
6 changes: 3 additions & 3 deletions packages/studio/src/components/editor/EaseCurveSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
import { roundToCenti } from "../../utils/rounding";

Expand Down Expand Up @@ -40,7 +40,7 @@ function MiniCurveSvg({
);
}

const EasePresetGrid = memo(function EasePresetGrid({
const EasePresetGrid = function EasePresetGrid({
currentEase,
onSelect,
}: {
Expand Down Expand Up @@ -74,7 +74,7 @@ const EasePresetGrid = memo(function EasePresetGrid({
})}
</div>
);
});
};

const round2 = roundToCenti;

Expand Down
8 changes: 7 additions & 1 deletion packages/studio/src/hooks/useGestureCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { editLog } from "../utils/editDebugLog";
import { useGestureRecording } from "./useGestureRecording";
import { simplifyGestureSamples } from "../utils/rdpSimplify";
import { smoothGestureKeyframes } from "../utils/gestureSmoother";
import { usePlayerStore } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
Expand Down Expand Up @@ -150,10 +151,13 @@ export function useGestureCommit({
}
if (liveSession.commitMutation) {
const recStart = recordingStartTimeRef.current;
const keyframes = sortedPcts.map((pct) => ({
const rawKeyframes = sortedPcts.map((pct) => ({
percentage: pct,
properties: simplified.get(pct) as Record<string, number | string>,
}));
// Smooth jittery pointer input — Gaussian-weighted moving average
// rounds off sharp corners while preserving overall path shape.
const keyframes = smoothGestureKeyframes(rawKeyframes, 3);
const hasPositionProps = keyframes.some((kf) =>
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
);
Expand Down Expand Up @@ -236,6 +240,7 @@ export function useGestureCommit({
position: roundTo3(recStart),
duration: roundTo3(duration),
keyframes: groupKfs,
easeEach: "power1.inOut",
},
{ label: "Gesture recording (new range)", softReload: true },
);
Expand All @@ -252,6 +257,7 @@ export function useGestureCommit({
position: roundTo3(recStart),
duration: roundTo3(duration),
keyframes: groupKfs,
easeEach: "power1.inOut",
},
{ label: "Gesture recording", softReload: true },
);
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/hooks/useGsapAnimationOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function useGsapAnimationOps({
async (
selection: DomEditSelection,
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
updates: { duration?: number; ease?: string; easeEach?: string; position?: number },
) => {
if (sdkSession && sdkDeps) {
const targetPath = selection.sourceFile || activeCompPath || "index.html";
Expand All @@ -57,7 +57,7 @@ export function useGsapAnimationOps({
commitMutationSafely(
selection,
{ type: "update-meta", animationId, updates },
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, softReload: true },
);
},
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
Expand Down
6 changes: 3 additions & 3 deletions packages/studio/src/hooks/useGsapTweenCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export async function fetchParsedAnimations(
): Promise<ParsedGsap | null> {
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}?_t=${Date.now()}`,
);
if (!res.ok) return null;
const parsed = (await res.json()) as ParsedGsap;
Expand Down Expand Up @@ -169,7 +169,7 @@ export function useGsapAnimationsForElement(

// Retry once if initial fetch returned 0 animations — handles
// cold-load race where the sourceFile isn't resolved yet.
if (parsed.animations.length === 0 && target) {
if (parsed.animations.length === 0 && targetKey) {
retryTimerRef.current = setTimeout(() => {
if (cancelled) return;
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
Expand All @@ -189,7 +189,7 @@ export function useGsapAnimationsForElement(
retryTimerRef.current = null;
}
};
}, [projectId, sourceFile, version, target]);
}, [projectId, sourceFile, version, target?.id, target?.selector]);

const targetId = target?.id ?? null;
const targetSelector = target?.selector ?? null;
Expand Down
Loading
Loading