diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index e6dd5ea776..3262812e65 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -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"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 6b62d3cfb6..22c570d8ae 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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; diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index c43782c530..fdd920ec1b 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -105,6 +105,17 @@ declare global { * resolved values for the instance currently executing. */ __hfVariablesByComp?: Record>; + /** + * 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; } } diff --git a/packages/producer/package.json b/packages/producer/package.json index 1b3358ae34..ba3e6aeecd 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -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", diff --git a/packages/producer/scripts/build-hf-early-stub.ts b/packages/producer/scripts/build-hf-early-stub.ts new file mode 100644 index 0000000000..46f932cf9a --- /dev/null +++ b/packages/producer/scripts/build-hf-early-stub.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 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"), + }), +); diff --git a/packages/producer/src/generated/hf-early-stub-inline.ts b/packages/producer/src/generated/hf-early-stub-inline.ts new file mode 100644 index 0000000000..da859cd306 --- /dev/null +++ b/packages/producer/src/generated/hf-early-stub-inline.ts @@ -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 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; +} diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 426dfb31dc..6d5af7a998 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -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 & { + __hf?: Record; + __hfTimelinesBuilding?: boolean; + gsap?: { timeline: () => { totalTime: (time?: number) => number | unknown } }; + requestAnimationFrame: typeof requestAnimationFrame; + setTimeout: typeof setTimeout; + }; + document: Record; + 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 & { + __hf?: Record; + __hfTimelinesBuilding?: boolean; + gsap?: { + timeline: () => { to: (...args: unknown[]) => unknown; getChildren: () => unknown[] }; + }; + requestAnimationFrame: typeof requestAnimationFrame; + setTimeout: typeof setTimeout; + }; + document: Record; + 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 & { + __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); + }); }); diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 17c383a985..8b98346960 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -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 `