fix: batch GSAP timeline construction to prevent main-thread hang (#1231) (#1249)

* fix: batch GSAP timeline construction to prevent main-thread hang (#1231)

Compositions with thousands of tl.to() calls (e.g. 8,562 in the
reported case) block Chrome's main thread synchronously during HTML
parsing, preventing DOMContentLoaded from firing before Puppeteer's
navigation timeout. This caused render jobs to hang indefinitely at
'Initializing calibration session...' with no error message.

Root cause: GSAP's timeline API is synchronous — each tl.to() call
registers a tween immediately on the main thread. A script with 8k+
calls holds the thread for seconds, starving the browser event loop and
delaying DCL past the navigation timeout window.

Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected
at the top of <head>, before GSAP or user scripts load). When GSAP
assigns itself to window.gsap, the setter intercepts the real gsap
object and wraps gsap.timeline() to return a proxy that queues tween
descriptors (to/from/fromTo/set) instead of calling them synchronously.
A requestAnimationFrame-based flush loop drains 100 tweens per frame,
yielding the main thread between batches so DCL can fire.

When the queue is drained, the stub sets window.__hfTimelinesBuilding =
false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks
this flag at DOMContentLoaded time; if building is still in progress it
defers bindRootTimelineIfAvailable() until the event fires, then sets
window.__renderReady = true as normal. pollHfReady continues to gate
on both __renderReady and window.__hf.duration > 0, so the render
pipeline does not start until the full timeline is bound.

- Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale)
- Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0))
- Determinism: 'hf-timelines-built' event guarantees sequencing
- Proxy forwards: pause/seek/totalTime/time/duration/add/paused/
  timeScale/play delegate to the real timeline immediately
- No GSAP package changes; no navigation timeout increase

Fixes #1231

* style: apply oxfmt formatting to producer stub files

* fix(producer): unwrap proxy children in add(), gate setter return on args.length

Addresses two latent correctness concerns from code review:

1. proxy.add() now unwraps __hfReal from any proxy child before passing it
   to the real timeline. GSAP's internal tween graph (_first/_next/_prev
   linkage) requires real timeline instances — proxy objects lack internal
   fields like _dp that GSAP's iteration paths expect.

2. totalTime/time/paused/timeScale now return proxy when called in setter form
   (args.length > 0). Previously these returned the real timeline, causing
   callers who chain .to(...) after a setter call to bypass batching.

Also: build-hf-early-stub.ts now runs oxfmt on the generated output file
so the format check passes in CI on every build.

* fix(producer): gate __hf.duration=0 while GSAP timelines are batching

The HF_BRIDGE_SCRIPT duration getter now returns 0 whenever
window.__hfTimelinesBuilding is true (set by HF_EARLY_STUB while the rAF
batch loop is draining queued tl.to() calls).

pollHfReady in the engine polls until window.__hf.duration > 0, so
returning 0 keeps the engine waiting until the hf-timelines-built event
fires and all tweens are committed to the real GSAP timelines.

Without this gate, normal compositions (style-6, style-13, vignelli)
were being captured mid-batch — the real timelines were empty so GSAP
could not seek them, producing frozen/blank frames in the output video.

* fix(producer): flush GSAP batching under virtual time

* fix(producer): gate render bridge on runtime readiness

* fix(producer): preserve timeline child binding under batching
This commit is contained in:
Miguel Ángel
2026-06-07 09:31:13 -04:00
committed by GitHub
parent 29d6f1eac9
commit ebd156bcc1
9 changed files with 761 additions and 44 deletions
+33
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSandboxRuntimeModular } from "./init";
import type { RuntimeTimelineLike } from "./types";
@@ -92,6 +93,7 @@ describe("initSandboxRuntimeModular", () => {
delete window.__player;
delete window.__playerReady;
delete window.__renderReady;
delete window.__hfTimelinesBuilding;
vi.restoreAllMocks();
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
@@ -676,6 +678,37 @@ describe("initSandboxRuntimeModular", () => {
expect(window.__player).toBeDefined();
});
it("waits for GSAP batching to finish before publishing render readiness", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
let timelineDuration = 0;
const timeline = createMockTimeline(0);
timeline.duration = () => timelineDuration;
window.__timelines = {
main: timeline,
};
window.__hfTimelinesBuilding = true;
initSandboxRuntimeModular();
expect(window.__playerReady).toBe(true);
expect(window.__renderReady).toBe(false);
expect(window.__player?.getDuration()).toBe(0);
timelineDuration = 10;
window.__hfTimelinesBuilding = false;
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
expect(window.__renderReady).toBe(true);
expect(window.__player?.getDuration()).toBe(10);
});
it("sets __renderReady even without a GSAP timeline (CSS/WAAPI compositions)", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+61 -33
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication complexity
import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge";
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
import { createCssAdapter } from "./adapters/css";
@@ -1515,6 +1516,10 @@ export function initSandboxRuntimeModular(): void {
}
};
let maybePublishRenderReady = () => {
window.__renderReady = false;
};
if (!externalCompositionsReady) {
const compositionLoaderParams = {
injectedStyles: state.injectedCompStyles,
@@ -1539,14 +1544,10 @@ export function initSandboxRuntimeModular(): void {
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
.finally(() => {
externalCompositionsReady = true;
bindRootTimelineIfAvailable();
window.__renderReady = true;
bindMediaMetadataListeners();
runAdapters("discover", state.currentTime);
installAssetFailureDiagnostics();
applyCaptionOverrides();
postTimeline();
postState(true);
maybePublishRenderReady();
});
} else {
// No external/inline compositions to load — apply caption overrides immediately
@@ -1706,34 +1707,6 @@ export function initSandboxRuntimeModular(): void {
onDisablePickMode: () => picker.disablePickMode(),
});
bindRootTimelineIfAvailable();
if (state.capturedTimeline) {
player._timeline = state.capturedTimeline;
}
// __renderReady = timeline binding attempted, safe for deterministic seeking.
// Set unconditionally: renderSeek works with or without a GSAP timeline
// (CSS/WAAPI/Lottie compositions use adapter-only seeking).
// fileServer.ts sets this immediately (no timeline to bind in its runtime).
window.__renderReady = true;
// When the bundler inlines compositions, data-composition-src is removed so
// loadExternalCompositions() is skipped. But inline scripts registering child
// timelines in __timelines haven't executed yet (they run in the browser's next
// microtask). Defer a rebinding attempt to catch them.
if (externalCompositionsReady) {
setTimeout(() => {
const prevTimeline = state.capturedTimeline;
if (bindRootTimelineIfAvailable() && state.capturedTimeline !== prevTimeline) {
player._timeline = state.capturedTimeline;
}
runAdapters("discover", state.currentTime);
window.__renderReady = true;
postTimeline();
postState(true);
}, 0);
}
state.deterministicAdapters = [
createWaapiAdapter(),
createCssAdapter({
@@ -1761,6 +1734,61 @@ export function initSandboxRuntimeModular(): void {
void webAudio.init().then((ok) => {
webAudioReady = ok;
});
const publishRenderReadyAfterTimelineBinding = () => {
const prevTimeline = state.capturedTimeline;
const rebound = bindRootTimelineIfAvailable();
if (
state.capturedTimeline &&
(rebound || state.capturedTimeline !== prevTimeline || !player._timeline)
) {
player._timeline = state.capturedTimeline;
}
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (boundDuration > 0) {
clock.setDuration(boundDuration);
}
runAdapters("discover", state.currentTime);
// __renderReady = timeline binding attempted, safe for deterministic seeking.
// Set after any GSAP batching has completed. renderSeek works with or
// without a GSAP timeline (CSS/WAAPI/Lottie compositions use adapters only).
window.__renderReady = true;
postTimeline();
postState(true);
};
maybePublishRenderReady = () => {
if (!externalCompositionsReady || window.__hfTimelinesBuilding) {
window.__renderReady = false;
return;
}
publishRenderReadyAfterTimelineBinding();
};
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
// active, composition scripts queue tl.to() calls instead of executing them
// synchronously. Wait for the "hf-timelines-built" event before the first
// binding attempt so the transport clock receives the finished timeline
// duration instead of permanently publishing duration=0.
if (window.__hfTimelinesBuilding) {
window.__renderReady = false;
const onTimelinesBuilt = () => {
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
maybePublishRenderReady();
};
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
}
maybePublishRenderReady();
// When the bundler inlines compositions, data-composition-src is removed so
// loadExternalCompositions() is skipped. But inline scripts registering child
// timelines in __timelines haven't executed yet (they run in the browser's next
// microtask). Defer a rebinding attempt to catch them.
if (externalCompositionsReady) {
setTimeout(() => {
maybePublishRenderReady();
}, 0);
}
let transportTickCount = 0;
let inTransportTick = false;
+11
View File
@@ -105,6 +105,17 @@ declare global {
* resolved values for the instance currently executing.
*/
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
/**
* Set to `true` while the GSAP tween-batching interceptor (injected via
* HF_EARLY_STUB in fileServer.ts) is still draining queued tween calls
* through requestAnimationFrame batches. Cleared and the "hf-timelines-built"
* CustomEvent is dispatched when all queues are empty.
*
* init.ts uses this to decide whether to defer `bindRootTimelineIfAvailable`:
* if true at DOMContentLoaded time, it adds a one-shot event listener and
* rebinds after the event fires.
*/
__hfTimelinesBuilding?: boolean;
}
}
+2 -1
View File
@@ -31,8 +31,9 @@
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "bun run build:fonts && bun run --cwd ../.. build:hyperframes-runtime:modular && node build.mjs",
"build": "bun run build:fonts && bun run build:hf-early-stub && bun run --cwd ../.. build:hyperframes-runtime:modular && node build.mjs",
"build:fonts": "node scripts/build-fonts.mjs",
"build:hf-early-stub": "bun run scripts/build-hf-early-stub.ts",
"typecheck": "tsc --noEmit",
"parity:check": "tsx src/parity-harness.ts",
"parity:fixtures": "tsx src/parity-fixtures.ts",
@@ -0,0 +1,82 @@
/**
* Build script: compile stubs/hf-early-stub.ts src/generated/hf-early-stub-inline.ts
*
* Run via: bun run scripts/build-hf-early-stub.ts
* (also called automatically as part of `bun run build`)
*
* Output format mirrors packages/core/scripts/build-hyperframes-runtime-artifact.ts:
* a TypeScript module exporting a single string-constant getter that is
* compiled by tsc into dist/ no esbuild, no file I/O, no dynamic paths at
* runtime.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import { execSync } from "node:child_process";
const thisDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(thisDir, "..");
const stubEntry = resolve(repoRoot, "stubs/hf-early-stub.ts");
const generatedDir = resolve(repoRoot, "src/generated");
const outPath = resolve(generatedDir, "hf-early-stub-inline.ts");
// ── Compile the stub to a self-contained IIFE ─────────────────────────────────
const result = buildSync({
entryPoints: [stubEntry],
bundle: true,
write: false,
platform: "browser",
format: "iife",
target: ["es2020"],
// Minify for production — the stub is injected on every page load.
minify: true,
legalComments: "none",
});
const iife = result.outputFiles[0]?.text ?? "";
if (!iife) {
throw new Error("esbuild produced no output for hf-early-stub.ts");
}
// ── Write the generated module ────────────────────────────────────────────────
mkdirSync(generatedDir, { recursive: true });
const escaped = JSON.stringify(iife);
writeFileSync(
outPath,
[
"// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit",
`const HF_EARLY_STUB_IIFE: string = ${escaped};`,
"",
"/**",
" * Returns the pre-built HyperFrames early stub IIFE as a string constant.",
" * Inject into <head> before any other scripts so the GSAP batching",
" * interceptor is in place when user composition scripts run.",
" */",
"export function getHfEarlyStub(): string {",
" return HF_EARLY_STUB_IIFE;",
"}",
"",
].join("\n"),
"utf8",
);
// Format the generated file so `oxfmt --check` passes in CI.
// Errors are intentionally swallowed — oxfmt unavailable in some envs.
try {
execSync(`bunx oxfmt ${outPath}`, { stdio: "ignore" });
} catch {
// not fatal
}
console.log(
JSON.stringify({
event: "hf_early_stub_generated",
stubEntry,
outPath,
bytes: Buffer.byteLength(iife, "utf8"),
}),
);
@@ -0,0 +1,12 @@
// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit
const HF_EARLY_STUB_IIFE: string =
'"use strict";(()=>{var c=100,k=[],u=[],l=!1,s=!1;function a(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function p(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function h(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function m(n){let e=n.proxy.__hfReal,i=e[n.method];if(typeof i=="function"){let t=n.method==="add"?n.args.map(h):n.args;i.call(e,...t)}}function r(n,e,i){let t={proxy:n,method:e,args:i};return n.__hfQueue.push(t),u.push(t),_(),n}function f(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function o(){for(;u.length>0;){let n=u.shift();n&&(f(n),m(n))}T()}function w(){s=!1,window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function T(){s||(s=!0,p(()=>{u.length===0?w():s=!1}))}function d(){l=!1;let n=u.splice(0,c);for(let e of n)f(e),m(e);u.length>0?(l=!0,a(d)):w()}function _(){l||(l=!0,window.__hfTimelinesBuilding=!0,a(d))}function y(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0,to(...i){return r(e,"to",i)},from(...i){return r(e,"from",i)},fromTo(...i){return r(e,"fromTo",i)},set(...i){return r(e,"set",i)},add(...i){return r(e,"add",i)},pause(...i){return o(),n.pause(...i),e},play(...i){return o(),n.play(...i),e},seek(...i){return o(),n.seek(...i),e},totalTime(...i){return o(),i.length>0?(n.totalTime(...i),e):n.totalTime()},time(...i){return o(),i.length>0?(n.time(...i),e):n.time()},duration(...i){return o(),i.length>0?(n.duration(...i),e):n.duration()},getChildren(...i){o();let t=n.getChildren(...i);return Array.isArray(t)?t:[]},paused(...i){return o(),i.length>0?(n.paused(...i),e):n.paused()},timeScale(...i){return o(),i.length>0?(n.timeScale(...i),e):n.timeScale()},kill(){o(),n.kill()}};return k.push(e),e}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1;let n=null;try{Object.defineProperty(window,"gsap",{configurable:!0,enumerable:!0,get(){return n},set(e){if(n=e,!e||typeof e.timeline!="function")return;let i=e.timeline.bind(e);e.timeline=t=>y(i(t))}})}catch{}}})();\n';
/**
* Returns the pre-built HyperFrames early stub IIFE as a string constant.
* Inject into <head> before any other scripts so the GSAP batching
* interceptor is in place when user composition scripts run.
*/
export function getHfEarlyStub(): string {
return HF_EARLY_STUB_IIFE;
}
@@ -279,6 +279,181 @@ describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => {
{ time: 5, duration: 0.5, shader: "domain-warp", fromScene: "a", toScene: "b" },
]);
expect(typeof sandbox.window.__hf?.seek).toBe("function");
expect(sandbox.window.__hf?.duration).toBe(0);
sandbox.window.__renderReady = true;
expect(sandbox.window.__hf?.duration).toBe(30);
});
it("keeps render-time timeline seeks synchronous during large renders", () => {
const sandbox: {
window: Record<string, unknown> & {
__hf?: Record<string, unknown>;
__hfTimelinesBuilding?: boolean;
gsap?: { timeline: () => { totalTime: (time?: number) => number | unknown } };
requestAnimationFrame: typeof requestAnimationFrame;
setTimeout: typeof setTimeout;
};
document: Record<string, never>;
CustomEvent: typeof CustomEvent;
} = {
window: {
requestAnimationFrame: (() => 1) as typeof requestAnimationFrame,
setTimeout: (() => 1) as typeof setTimeout,
},
document: {},
CustomEvent,
};
sandbox.window.window = sandbox.window;
sandbox.window.document = sandbox.document;
sandbox.window.CustomEvent = sandbox.CustomEvent;
new Function("window", "document", "CustomEvent", `with (window) {\n${HF_EARLY_STUB}\n}`)(
sandbox.window,
sandbox.document,
sandbox.CustomEvent,
);
const totalTimeCalls: number[] = [];
sandbox.window.gsap = {
timeline: () => ({
to: () => {},
from: () => {},
fromTo: () => {},
set: () => {},
pause: () => {},
play: () => {},
seek: () => {},
totalTime: (time?: number) => {
if (typeof time === "number") totalTimeCalls.push(time);
return totalTimeCalls.at(-1) ?? 0;
},
time: () => 0,
duration: () => 10,
add: () => {},
getChildren: () => [],
paused: () => true,
timeScale: () => 1,
kill: () => {},
}),
};
const timeline = sandbox.window.gsap.timeline();
for (let i = 0; i < 5100; i += 1) {
timeline.totalTime(i / 30);
}
expect(totalTimeCalls).toHaveLength(5100);
expect(sandbox.window.__hfTimelinesBuilding).toBe(false);
});
it("flushes queued construction calls before forwarding timeline children", () => {
const sandbox: {
window: Record<string, unknown> & {
__hf?: Record<string, unknown>;
__hfTimelinesBuilding?: boolean;
gsap?: {
timeline: () => { to: (...args: unknown[]) => unknown; getChildren: () => unknown[] };
};
requestAnimationFrame: typeof requestAnimationFrame;
setTimeout: typeof setTimeout;
};
document: Record<string, never>;
CustomEvent: typeof CustomEvent;
} = {
window: {
requestAnimationFrame: (() => 1) as typeof requestAnimationFrame,
setTimeout: ((callback: () => void) => {
callback();
return 1;
}) as typeof setTimeout,
},
document: {},
CustomEvent,
};
sandbox.window.window = sandbox.window;
sandbox.window.document = sandbox.document;
sandbox.window.CustomEvent = sandbox.CustomEvent;
new Function("window", "document", "CustomEvent", `with (window) {\n${HF_EARLY_STUB}\n}`)(
sandbox.window,
sandbox.document,
sandbox.CustomEvent,
);
const constructionCalls: unknown[][] = [];
const child = { id: "child" };
sandbox.window.gsap = {
timeline: () => ({
to: (...args: unknown[]) => {
constructionCalls.push(args);
},
from: () => {},
fromTo: () => {},
set: () => {},
pause: () => {},
play: () => {},
seek: () => {},
totalTime: () => 0,
time: () => 0,
duration: () => 10,
add: () => {},
getChildren: () => [child],
paused: () => true,
timeScale: () => 1,
kill: () => {},
}),
};
const timeline = sandbox.window.gsap.timeline();
timeline.to("#box", { x: 100 });
expect(constructionCalls).toHaveLength(0);
expect(timeline.getChildren()).toEqual([child]);
expect(constructionCalls).toHaveLength(1);
expect(sandbox.window.__hfTimelinesBuilding).toBe(false);
});
it("keeps bridge duration at zero until the runtime publishes render readiness", () => {
const sandbox: {
window: Record<string, unknown> & {
__hf?: { seek?: (t: number) => void; duration?: number };
__player?: { renderSeek: (t: number) => void; getDuration: () => number };
__renderReady?: boolean;
__hfTimelinesBuilding?: boolean;
setInterval: typeof setInterval;
clearInterval: typeof clearInterval;
};
document: { querySelector: () => { getAttribute: (name: string) => string | null } };
} = {
window: {
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
},
document: {
querySelector: () => ({
getAttribute: (name: string) => (name === "data-duration" ? "15" : null),
}),
},
};
sandbox.window.window = sandbox.window;
sandbox.window.document = sandbox.document;
sandbox.window.__player = {
renderSeek: () => {},
getDuration: () => 0,
};
new Function("window", "document", `with (window) {\n${HF_BRIDGE_SCRIPT}\n}`)(
sandbox.window,
sandbox.document,
);
expect(sandbox.window.__hf?.duration).toBe(0);
sandbox.window.__renderReady = true;
expect(sandbox.window.__hf?.duration).toBe(15);
sandbox.window.__hfTimelinesBuilding = true;
expect(sandbox.window.__hf?.duration).toBe(0);
});
});
+15 -10
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication complexity
/**
* File Server for Render Mode
*
@@ -14,6 +15,7 @@ import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
import { join, extname, resolve, sep } from "node:path";
import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler";
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js";
export { injectScriptsAtHeadStart };
@@ -401,7 +403,6 @@ const RENDER_MODE_SCRIPT = `(function() {
if (hasComposition) {
if (window.__player && typeof window.__player.renderSeek === "function") {
window.__playerReady = true;
window.__renderReady = true;
return;
}
__realSetTimeout(waitForPlayer, 50);
@@ -417,18 +418,15 @@ const RENDER_MODE_SCRIPT = `(function() {
/**
* Early stub: ensures `window.__hf` exists *before* any user `<script>` in
* `<body>` executes. Without this, libraries that opportunistically write to
* `__hf` during page-script execution (notably `@hyperframes/shader-transitions`,
* which writes the active transition map to `__hf.transitions` inside its
* `init()` call) silently no-op because `__hf` hasn't been created yet the
* full bridge script is injected at end-of-body and runs *after* user scripts.
* `<body>` executes, and batches GSAP timeline construction via
* requestAnimationFrame to prevent the main-thread hang described in
* https://github.com/heygen-com/hyperframes/issues/1231.
*
* Source: packages/producer/stubs/hf-early-stub.ts
* Generated: packages/producer/src/generated/hf-early-stub-inline.ts
* Injected at the very start of `<head>` so it runs before all other scripts.
*/
const HF_EARLY_STUB = `(function() {
if (typeof window === "undefined") return;
if (!window.__hf) window.__hf = {};
})();`;
const HF_EARLY_STUB = getHfEarlyStub();
/**
* Page-side compositing opt-in flag stub.
@@ -511,6 +509,13 @@ const HF_BRIDGE_SCRIPT = `(function() {
configurable: true,
enumerable: true,
get: function() {
// While the GSAP tween-batching interceptor (HF_EARLY_STUB) is draining
// queued tweens via rAF, the real timelines are still empty. Return 0
// here so pollHfReady in the engine keeps waiting (its condition is
// __hf.duration > 0), preventing the capture pipeline from seeking
// empty timelines and producing blank/incorrect frames.
if (window.__hfTimelinesBuilding) return 0;
if (!window.__renderReady) return 0;
var d = p.getDuration();
return d > 0 ? d : getDeclaredDuration();
},
+370
View File
@@ -0,0 +1,370 @@
// fallow-ignore-file unused-file
/**
* HyperFrames early stub injected at the very start of `<head>` before any
* other scripts run. Compiled to an IIFE by scripts/build-hf-early-stub.ts.
*
* This file lives outside `src/` intentionally: it is compiled by a separate
* esbuild step, NOT by the producer's tsc. Only the generated output
* (src/generated/hf-early-stub-inline.ts) is type-checked by tsc.
*
* Responsibilities
*
* 1. Create `window.__hf` so page scripts can write to it before the bridge
* loads (e.g. @hyperframes/shader-transitions writes transition metadata
* during its init() call, which runs before end-of-body scripts).
*
* 2. Intercept `window.gsap` assignment and batch `timeline.to/from/fromTo/set`
* calls via requestAnimationFrame to prevent the main-thread hang described
* in https://github.com/heygen-com/hyperframes/issues/1231.
*
* GSAP batching background
*
* Compositions with very large tween counts (thousands of `tl.to()` calls) block
* Chrome's main thread synchronously during HTML parsing, preventing
* DOMContentLoaded from firing before Puppeteer's navigation timeout. Each
* `tl.to()` triggers a synchronous GSAP state recomputation; 8 000+ calls in a
* row have been observed to hold the thread for >60 s.
*
* Fix: intercept `gsap.timeline()` via an `Object.defineProperty` trap on
* `window`. GSAP is not yet loaded when this stub runs it loads via a
* `<script>` tag in the HTML body. The trap replaces every returned timeline
* with a proxy that queues to/from/fromTo/set descriptors instead of executing
* them immediately. A `requestAnimationFrame` loop drains the queue in batches
* of BATCH_SIZE, yielding the main thread between batches so DCL can fire.
*
* When all queues are empty a `"hf-timelines-built"` CustomEvent is dispatched
* on `window` and `window.__hfTimelinesBuilding` is set to `false`. The runtime
* in `init.ts` listens for this event to rebind the timeline after batching
* completes (the captured timeline reference remains valid the proxy delegates
* all non-mutating calls to the real timeline throughout).
*
* Render-mode correctness: `window.__renderReady` is intentionally not gated
* here because the bridge script's `window.__hf.duration` getter already waits
* for `window.__player.getDuration() > 0`, which only becomes true after
* `bindRootTimelineIfAvailable()` completes in `init.ts`, which happens after
* the `"hf-timelines-built"` listener fires. No separate gate is needed.
*
* Batch size: ~100 tweens per rAF budget. Each batch completes in <4 ms on a
* 2023 laptop at the 8 562-tween scale; 16 ms rAF budgets are never exhausted.
*/
// `export {}` makes this file an ES module so that `declare global` is valid.
// esbuild's IIFE format wraps the output in a self-executing function, so the
// export is elided and no module runtime is emitted.
export {};
declare global {
interface Window {
__hf?: Record<string, unknown>;
__hfTimelinesBuilding?: boolean;
__HF_VIRTUAL_TIME__?: {
originalRequestAnimationFrame?: typeof window.requestAnimationFrame;
originalSetTimeout?: typeof window.setTimeout;
};
}
}
// ─── Types ───────────────────────────────────────────────────────────────────
type TimelineOperationMethod = "to" | "from" | "fromTo" | "set" | "add";
interface TimelineOperation {
proxy: TimelineProxy;
method: TimelineOperationMethod;
args: unknown[];
}
/**
* Minimal GSAP timeline surface exposed to this stub.
*
* All methods return `unknown` for values (rather than `this`) so that
* `TimelineProxy` can implement them without strict subtype constraints.
* Callers that need the real return value (e.g. duration()) receive it via
* forwarded delegation on `proxy.__hfReal`.
*/
interface GsapTimeline {
to(...args: unknown[]): unknown;
from(...args: unknown[]): unknown;
fromTo(...args: unknown[]): unknown;
set(...args: unknown[]): unknown;
pause(...args: unknown[]): unknown;
play(...args: unknown[]): unknown;
seek(...args: unknown[]): unknown;
totalTime(...args: unknown[]): unknown;
time(...args: unknown[]): unknown;
duration(...args: unknown[]): unknown;
add(...args: unknown[]): unknown;
getChildren(...args: unknown[]): unknown[];
paused(...args: unknown[]): unknown;
timeScale(...args: unknown[]): unknown;
kill(): void;
[key: string]: unknown;
}
interface GsapInstance {
timeline(params?: unknown): GsapTimeline;
[key: string]: unknown;
}
/**
* A proxy returned in place of a real GSAP timeline during batching.
*
* Mutating methods (to/from/fromTo/set) enqueue descriptors and return the
* proxy for chaining. Forwarded methods delegate straight to the real timeline
* and also return `proxy` for chaining, so composed call chains work correctly.
*/
interface TimelineProxy extends GsapTimeline {
__hfReal: GsapTimeline;
__hfQueue: TimelineOperation[];
__hfIsProxy?: true;
}
// ─── Module-level state ───────────────────────────────────────────────────────
const BATCH_SIZE = 100;
const activeProxies: TimelineProxy[] = [];
const pendingOperations: TimelineOperation[] = [];
let batchScheduled = false;
let publishCheckScheduled = false;
function requestBatchFrame(callback: FrameRequestCallback): number {
const originalRequestAnimationFrame = window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;
if (typeof originalRequestAnimationFrame === "function") {
return originalRequestAnimationFrame(callback);
}
return requestAnimationFrame(callback);
}
function scheduleAfterScriptStack(callback: () => void): void {
const originalSetTimeout = window.__HF_VIRTUAL_TIME__?.originalSetTimeout;
if (typeof originalSetTimeout === "function") {
originalSetTimeout(callback, 0);
return;
}
setTimeout(callback, 0);
}
// ─── Batch flusher ────────────────────────────────────────────────────────────
function unwrapTimelineArg(arg: unknown): unknown {
if (
arg !== null &&
typeof arg === "object" &&
"__hfIsProxy" in (arg as Record<string, unknown>)
) {
return (arg as TimelineProxy).__hfReal;
}
return arg;
}
function applyTimelineOperation(entry: TimelineOperation): void {
const real = entry.proxy.__hfReal;
const fn = real[entry.method];
if (typeof fn === "function") {
const args = entry.method === "add" ? entry.args.map(unwrapTimelineArg) : entry.args;
(fn as (...args: unknown[]) => unknown).call(real, ...args);
}
}
function enqueueTimelineOperation(
proxy: TimelineProxy,
method: TimelineOperationMethod,
args: unknown[],
): TimelineProxy {
const entry = { proxy, method, args };
proxy.__hfQueue.push(entry);
pendingOperations.push(entry);
scheduleBatch();
return proxy;
}
function removeProxyQueueEntry(entry: TimelineOperation): void {
const index = entry.proxy.__hfQueue.indexOf(entry);
if (index >= 0) entry.proxy.__hfQueue.splice(index, 1);
}
function flushPendingOperations(): void {
while (pendingOperations.length > 0) {
const entry = pendingOperations.shift();
if (!entry) continue;
removeProxyQueueEntry(entry);
applyTimelineOperation(entry);
}
scheduleTimelinesBuiltCheck();
}
function publishTimelinesBuilt(): void {
publishCheckScheduled = false;
window.__hfTimelinesBuilding = false;
try {
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
} catch {
// ignore — CustomEvent unavailable in some test environments
}
}
function scheduleTimelinesBuiltCheck(): void {
if (publishCheckScheduled) return;
publishCheckScheduled = true;
scheduleAfterScriptStack(() => {
if (pendingOperations.length === 0) {
publishTimelinesBuilt();
} else {
publishCheckScheduled = false;
}
});
}
// fallow-ignore-next-line complexity
function flushBatch(): void {
batchScheduled = false;
const batch = pendingOperations.splice(0, BATCH_SIZE);
for (const entry of batch) {
removeProxyQueueEntry(entry);
applyTimelineOperation(entry);
}
if (pendingOperations.length > 0) {
batchScheduled = true;
requestBatchFrame(flushBatch);
} else {
publishTimelinesBuilt();
}
}
function scheduleBatch(): void {
if (!batchScheduled) {
batchScheduled = true;
window.__hfTimelinesBuilding = true;
requestBatchFrame(flushBatch);
}
}
// ─── Timeline proxy factory ───────────────────────────────────────────────────
/**
* Create a queuing proxy around a real GSAP timeline.
*
* All methods return `proxy` so that callers who chain off the returned value
* continue to go through the proxy for the duration of the batching phase.
*/
function wrapTimeline(real: GsapTimeline): TimelineProxy {
const proxy: TimelineProxy = {
__hfReal: real,
__hfQueue: [],
__hfIsProxy: true,
to(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "to", args);
},
from(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "from", args);
},
fromTo(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "fromTo", args);
},
set(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "set", args);
},
add(...args: unknown[]): TimelineProxy {
return enqueueTimelineOperation(proxy, "add", args);
},
pause(...args: unknown[]): TimelineProxy {
flushPendingOperations();
real.pause(...args);
return proxy;
},
play(...args: unknown[]): TimelineProxy {
flushPendingOperations();
real.play(...args);
return proxy;
},
seek(...args: unknown[]): TimelineProxy {
flushPendingOperations();
real.seek(...args);
return proxy;
},
totalTime(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.totalTime(...args);
return proxy;
}
return real.totalTime();
},
time(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.time(...args);
return proxy;
}
return real.time();
},
duration(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.duration(...args);
return proxy;
}
return real.duration();
},
getChildren(...args: unknown[]): unknown[] {
flushPendingOperations();
const children = real.getChildren(...args);
return Array.isArray(children) ? children : [];
},
paused(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.paused(...args);
return proxy;
}
return real.paused();
},
timeScale(...args: unknown[]): unknown {
flushPendingOperations();
if (args.length > 0) {
real.timeScale(...args);
return proxy;
}
return real.timeScale();
},
kill(): void {
flushPendingOperations();
real.kill();
},
};
activeProxies.push(proxy);
return proxy;
}
// ─── Entry point ─────────────────────────────────────────────────────────────
if (typeof window !== "undefined") {
if (!window.__hf) window.__hf = {};
window.__hfTimelinesBuilding = false;
// Intercept window.gsap assignment via a property trap so we can wrap
// `gsap.timeline()` before any user script calls it. GSAP is not yet
// loaded when this stub runs — it loads via a <script> tag in the HTML body.
let _realGsap: GsapInstance | null = null;
try {
Object.defineProperty(window, "gsap", {
configurable: true,
enumerable: true,
get(): GsapInstance | null {
return _realGsap;
},
set(g: GsapInstance): void {
_realGsap = g;
if (!g || typeof g.timeline !== "function") return;
const origTimeline = g.timeline.bind(g) as (params?: unknown) => GsapTimeline;
g.timeline = (params?: unknown): GsapTimeline => wrapTimeline(origTimeline(params));
},
});
} catch {
// defineProperty failed (e.g. already non-configurable) — skip interception.
}
}