Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b7364e8
fix(studio): sync code editor after GSAP mutations + clear keyframe c…
miguel-heygen Jun 5, 2026
40fd909
feat(studio): per-corner border-radius display in design panel
miguel-heygen Jun 5, 2026
48f4d67
feat(studio): visual border-radius editor with corner picker
miguel-heygen Jun 5, 2026
9461383
feat(studio): wire GSAP runtime border-radius values to corner picker
miguel-heygen Jun 5, 2026
84d75b7
fix(studio): bump GSAP cache on code-tab edits
miguel-heygen Jun 5, 2026
7226bf9
fix: respect user timeouts on low-memory systems (#1221)
miguel-heygen Jun 5, 2026
7e874f5
fix: add progress logging during silent render pipeline stages (#1220)
miguel-heygen Jun 5, 2026
8726ea2
feat(core): lift MotionPathPlugin validation ban and add conditional …
miguel-heygen Jun 5, 2026
468647c
feat(core): add ArcPathConfig types and motionPath parser recognition
miguel-heygen Jun 5, 2026
f5268b2
feat(core): add AST mutation functions for arc path (motionPath)
miguel-heygen Jun 5, 2026
9d44506
feat(studio): add arc path mutation API routes and commit wrappers
miguel-heygen Jun 5, 2026
2026c8a
feat(studio): add arc path controls in design panel with full wiring
miguel-heygen Jun 5, 2026
70dd0e2
feat(studio): add MotionPathOverlay SVG component for arc path visual…
miguel-heygen Jun 5, 2026
566fa58
feat(studio): register MotionPathPlugin in runtime and soft reload
miguel-heygen Jun 5, 2026
2c82c95
feat(runtime): auto-stamp data-start/data-duration on GSAP-targeted e…
miguel-heygen Jun 5, 2026
0a70bbd
fix(runtime): auto-stamp elements at t=0 spanning full composition du…
miguel-heygen Jun 5, 2026
77584c8
feat(studio): synthesize keyframe diamonds from flat GSAP tweens
miguel-heygen Jun 5, 2026
de4ff88
refactor(studio): design panel visual redesign — instrument panel aes…
miguel-heygen Jun 5, 2026
005ed42
refactor(studio): title-case headers, drop uppercase tracking across …
miguel-heygen Jun 5, 2026
882ffd2
refactor(studio): switch accent from blue to teal #2DD4BF
miguel-heygen Jun 5, 2026
c6c1f07
refactor(studio): define panel color tokens in Tailwind config
miguel-heygen Jun 6, 2026
3b509fa
refactor(studio): remove card wrapper from text field sections
miguel-heygen Jun 6, 2026
6785a62
refactor(studio): collapse Text section by default
miguel-heygen Jun 6, 2026
c1d7616
refactor(studio): apply panel-* tokens to Layers and Renders panels
miguel-heygen Jun 6, 2026
d24b987
feat(studio): redesign render controls + add Export button to header
miguel-heygen Jun 6, 2026
39e17d8
refactor(studio): clean render controls — 2×2 grid with labels, no du…
miguel-heygen Jun 6, 2026
201907d
fix(studio): restore format help tooltip next to Format label
miguel-heygen Jun 6, 2026
3a495af
fix(studio): add node: builtins to Vite SSR externals for render route
miguel-heygen Jun 6, 2026
02b162b
feat(studio): export header button triggers render with default settings
miguel-heygen Jun 6, 2026
944ba30
fix(studio): externalize all non-workspace packages in SSR to fix req…
miguel-heygen Jun 6, 2026
714675d
fix(studio): fix toggle knob positioning and slider styling with tail…
miguel-heygen Jun 6, 2026
97c6a47
feat(runtime): auto-stamp all ID'd elements in timeline, not just GSA…
miguel-heygen Jun 6, 2026
960baed
fix(studio): detect styled elements as clickable (background, border,…
miguel-heygen Jun 6, 2026
f1f6a33
feat(studio): selection overlay matches element border-radius
miguel-heygen Jun 6, 2026
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
14 changes: 14 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@
"file": "packages/cli/src/commands/render.ts",
"exports": ["resolveBrowserGpuForCli", "renderLocal"],
},
// captureCost.ts: constants and helpers consumed by the runCaptureCalibration
// orchestration function and tests, but the entry-point graph doesn't
// reach them because the orchestrator's caller resolves them dynamically.
{
"file": "packages/producer/src/services/render/captureCost.ts",
"exports": [
"CAPTURE_CALIBRATION_TARGET_MS",
"MAX_MEASURED_CAPTURE_COST_MULTIPLIER",
"CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS",
"measureCaptureCostFromSession",
"logCaptureCalibrationResult",
"createFailedCaptureCalibrationEstimate",
],
},
],
"ignoreDependencies": [
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,20 @@ export default defineCommand({
"readiness poll has its own 45s budget). " +
"Env fallback: PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS (MILLISECONDS).",
},
"protocol-timeout": {
type: "string",
description:
"CDP protocol timeout in ms. Increase on slow/low-memory machines " +
"where Chrome operations time out. Default: 300000 (5 min). " +
"Env: PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS.",
},
"player-ready-timeout": {
type: "string",
description:
"Timeout in ms for the composition player to become ready. " +
"Increase for complex compositions on slow hardware. Default: 45000 (45 s). " +
"Env: PRODUCER_PLAYER_READY_TIMEOUT_MS.",
},
},
// `run` is the citty handler for `hyperframes render` — sequential flag
// validation + render dispatch. Inherited CRITICAL on main (CRAP 1290);
Expand Down Expand Up @@ -326,6 +340,32 @@ export default defineCommand({
workers = parsed;
}

// ── Validate timeout overrides ─────────────────────────────────────
let protocolTimeout: number | undefined;
if (args["protocol-timeout"] != null) {
const parsed = parseInt(args["protocol-timeout"], 10);
if (isNaN(parsed) || parsed < 1000) {
errorBox(
"Invalid protocol-timeout",
`Got "${args["protocol-timeout"]}". Must be a number >= 1000 (ms).`,
);
process.exit(1);
}
protocolTimeout = parsed;
}
let playerReadyTimeout: number | undefined;
if (args["player-ready-timeout"] != null) {
const parsed = parseInt(args["player-ready-timeout"], 10);
if (isNaN(parsed) || parsed < 1000) {
errorBox(
"Invalid player-ready-timeout",
`Got "${args["player-ready-timeout"]}". Must be a number >= 1000 (ms).`,
);
process.exit(1);
}
playerReadyTimeout = parsed;
}

// ── Wire opt-in: page-side compositing ───────────────────────────────
if (args["page-side-compositing"] === false) {
process.env.HF_PAGE_SIDE_COMPOSITING = "false";
Expand All @@ -347,6 +387,7 @@ export default defineCommand({
// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
const ext = FORMAT_EXT[format] ?? ".mp4";
// fallow-ignore-next-line code-duplication
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
Expand Down Expand Up @@ -528,6 +569,8 @@ export default defineCommand({
outputResolution,
pageSideCompositing: args["page-side-compositing"] !== false,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
exitAfterComplete: true,
});
} else {
Expand All @@ -547,6 +590,8 @@ export default defineCommand({
entryFile,
outputResolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
exitAfterComplete: true,
});
}
Expand Down Expand Up @@ -583,6 +628,10 @@ interface RenderOptions {
* producer's EngineConfig override.
*/
pageNavigationTimeoutMs?: number;
/** CDP protocol timeout override (ms). */
protocolTimeout?: number;
/** Player-ready timeout override (ms). */
playerReadyTimeout?: number;
}

/**
Expand Down Expand Up @@ -848,6 +897,7 @@ async function renderDocker(
if (options.exitAfterComplete) scheduleRenderProcessExit();
}

// fallow-ignore-next-line complexity
export async function renderLocal(
projectDir: string,
outputPath: string,
Expand Down Expand Up @@ -885,6 +935,8 @@ export async function renderLocal(
...(options.pageNavigationTimeoutMs != null
? { pageNavigationTimeout: options.pageNavigationTimeoutMs }
: {}),
...(options.protocolTimeout != null && { protocolTimeout: options.protocolTimeout }),
...(options.playerReadyTimeout != null && { playerReadyTimeout: options.playerReadyTimeout }),
}),
hdrMode: options.hdrMode,
crf: options.crf,
Expand Down
12 changes: 9 additions & 3 deletions packages/core/scripts/test-hyperframe-runtime-seek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,18 @@ function testGsapAdapterPreservesTotalTime(): void {
const { calls, timeline } = createTimeline(true);
const adapter = createGsapAdapter({ getTimeline: () => timeline });

adapter.seek({ time: 2.033333333333333 });
const seekTime = 2.033333333333333;
adapter.seek({ time: seekTime });

assert.deepEqual(
calls,
[{ method: "pause" }, { method: "totalTime", time: 2.033333333333333, suppressEvents: false }],
"GSAP adapter should not downgrade deterministic seeks back to seek()",
[
{ method: "pause" },
// Nudge to force GSAP 3.x dirty state before the real seek
{ method: "totalTime", time: seekTime + 0.001, suppressEvents: true },
{ method: "totalTime", time: seekTime, suppressEvents: false },
],
"GSAP adapter should nudge then seek via totalTime() (not downgrade to seek())",
);
}

Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/generators/hyperframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
} from "../core.types";
import type { GsapAnimation } from "../parsers/gsapParser";
import { serializeGsapAnimations, keyframesToGsapAnimations } from "../parsers/gsapParser";
import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants";
import {
GSAP_CDN,
MOTIONPATH_CDN,
BASE_STYLES,
ZOOM_CONTAINER_STYLES,
} from "../templates/constants";

const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2";
const FONT_WEIGHTS: Record<string, string> = {
Expand Down Expand Up @@ -337,6 +342,10 @@ export function generateHyperframesHtml(
: "";

const gsapCdnTag = includeScripts ? ` <script src="${GSAP_CDN}"></script>` : "";
const motionPathCdnTag =
includeScripts && gsapScript && /motionPath\s*[:{]/.test(gsapScript)
? `\n <script src="${MOTIONPATH_CDN}"></script>`
: "";

const gsapScriptTag = includeScripts
? ` <script>
Expand Down Expand Up @@ -373,7 +382,7 @@ ${gsapScript}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${includeStyles ? googleFontsLink : ""}
${gsapCdnTag}
${gsapCdnTag}${motionPathCdnTag}
${styleTags ? ` ${styleTags}` : ""}
</head>
<body>
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/parsers/gsapParser.stress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,16 +869,18 @@ describe("Additional edge cases", () => {
expect(result.animations[1].targetSelector).toBe("#el2");
});

it("skips a variable target that is not bound to a DOM lookup", () => {
it("marks a variable target that is not bound to a DOM lookup as __unresolved__", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(mysteryTarget, { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
// mysteryTarget has no resolvable selector binding — only the literal survives.
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#el2");
// mysteryTarget has no resolvable selector binding — kept with __unresolved__ marker.
expect(result.animations).toHaveLength(2);
expect(result.animations[0].targetSelector).toBe("__unresolved__");
expect(result.animations[0].hasUnresolvedSelector).toBe(true);
expect(result.animations[1].targetSelector).toBe("#el2");
});

it("boolean values in vars are not included in properties", () => {
Expand Down
141 changes: 139 additions & 2 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,14 +920,16 @@ describe("variable-target resolution (querySelector pattern)", () => {
expect(result.animations[2].extras?.stagger).toBe("__raw:0.1");
});

it("leaves unresolvable variable targets out of the animation list", () => {
it("marks unresolvable variable targets with __unresolved__ and hasUnresolvedSelector", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(someUnknownThing, { opacity: 1, duration: 0.5 }, 0);
tl.to(".real", { opacity: 1, duration: 0.5 }, 1);
`;
const result = parseGsapScript(script);
expect(result.animations.map((a) => a.targetSelector)).toEqual([".real"]);
expect(result.animations.map((a) => a.targetSelector)).toEqual(["__unresolved__", ".real"]);
expect(result.animations[0].hasUnresolvedSelector).toBe(true);
expect(result.animations[1].hasUnresolvedSelector).toBeUndefined();
});
});

Expand Down Expand Up @@ -1502,3 +1504,138 @@ describe("keyframe mutations", () => {
expect(anim.properties.opacity).toBe(1);
});
});

describe("motionPath parsing", () => {
it("parses motionPath with waypoint array and curviness", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
curviness: 1.5
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.enabled).toBe(true);
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].curviness).toBe(1.5);
expect(anim.arcPath!.segments[1].curviness).toBe(1.5);

expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.y).toBe(0);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(200);
expect(anim.keyframes!.keyframes[1].properties.y).toBe(-100);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(400);
expect(anim.keyframes!.keyframes[2].properties.y).toBe(50);
});

it("parses motionPath with type cubic and explicit control points", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [
{x: 0, y: 0},
{x: 50, y: -80}, {x: 150, y: -120},
{x: 200, y: -100},
{x: 250, y: -80}, {x: 350, y: 30},
{x: 400, y: 50}
],
type: "cubic"
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(2);

expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 });
expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 });

expect(anim.arcPath!.segments[1].cp1).toEqual({ x: 250, y: -80 });
expect(anim.arcPath!.segments[1].cp2).toEqual({ x: 350, y: 30 });

expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ x: 200, y: -100 });
expect(anim.keyframes!.keyframes[2].properties).toEqual({ x: 400, y: 50 });
});

it("parses motionPath with autoRotate", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
autoRotate: true
},
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath!.autoRotate).toBe(true);
});

it("merges motionPath waypoints into existing keyframes", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
curviness: 2
},
keyframes: {
"0%": { opacity: 1 },
"100%": { opacity: 0 }
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(1);
expect(anim.arcPath!.segments[0].curviness).toBe(2);

expect(anim.keyframes!.keyframes).toHaveLength(2);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ opacity: 1, x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ opacity: 0, x: 200, y: 100 });
});

it("skips motionPath with fewer than 2 waypoints", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: { path: [{x: 0, y: 0}] },
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].arcPath).toBeUndefined();
});

it("tween without motionPath parses identically to before", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, y: 200, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeUndefined();
expect(anim.properties.x).toBe(100);
expect(anim.properties.y).toBe(200);
});
});
Loading