diff --git a/packages/cli/src/browser/gpuPolicy.test.ts b/packages/cli/src/browser/gpuPolicy.test.ts new file mode 100644 index 0000000000..7ea3766721 --- /dev/null +++ b/packages/cli/src/browser/gpuPolicy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + assertWebGpuRequirement, + compositionRequiresWebGpu, + resolveLocalBrowserGpuMode, +} from "./gpuPolicy.js"; + +describe("local browser GPU policy", () => { + it("defaults to auto and preserves explicit CLI/env overrides", () => { + expect(resolveLocalBrowserGpuMode(undefined, undefined)).toBe("auto"); + expect(resolveLocalBrowserGpuMode(undefined, "hardware")).toBe("hardware"); + expect(resolveLocalBrowserGpuMode(undefined, "software")).toBe("software"); + expect(resolveLocalBrowserGpuMode(true, "software")).toBe("hardware"); + expect(resolveLocalBrowserGpuMode(false, "hardware")).toBe("software"); + }); + + it("detects the explicit WebGPU capability marker on the composition root", () => { + expect( + compositionRequiresWebGpu( + '
', + ), + ).toBe(true); + expect(compositionRequiresWebGpu('
')).toBe(false); + }); + + it("rejects an auto software fallback for required WebGPU compositions", () => { + const html = '
'; + expect(() => assertWebGpuRequirement(html, "auto", "software")).toThrow( + "PRODUCER_BROWSER_GPU_MODE=hardware", + ); + expect(() => assertWebGpuRequirement(html, "hardware", "hardware")).not.toThrow(); + expect(() => assertWebGpuRequirement(html, "software", "software")).not.toThrow(); + }); +}); diff --git a/packages/cli/src/browser/gpuPolicy.ts b/packages/cli/src/browser/gpuPolicy.ts new file mode 100644 index 0000000000..0c809e3715 --- /dev/null +++ b/packages/cli/src/browser/gpuPolicy.ts @@ -0,0 +1,43 @@ +export type BrowserGpuMode = "auto" | "hardware" | "software"; +export type ResolvedBrowserGpuMode = Exclude; + +export function resolveLocalBrowserGpuMode( + browserGpuArg?: boolean, + envMode = process.env.PRODUCER_BROWSER_GPU_MODE, +): BrowserGpuMode { + if (browserGpuArg === true) return "hardware"; + if (browserGpuArg === false) return "software"; + if (envMode === "hardware" || envMode === "software" || envMode === "auto") return envMode; + return "auto"; +} + +export async function resolveCaptureBrowserGpuMode( + requestedMode: BrowserGpuMode, + chromePath?: string, +): Promise { + const { resolveBrowserGpuMode } = await import("@hyperframes/engine"); + return resolveBrowserGpuMode(requestedMode, { chromePath }); +} + +export function compositionRequiresWebGpu(html: string): boolean { + const compositionRoot = html.match( + /<[^>]*\bdata-composition-id(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?[^>]*>/i, + ); + return compositionRoot ? /\bdata-requires-webgpu(?:\s|=|>)/i.test(compositionRoot[0]) : false; +} + +export function assertWebGpuRequirement( + html: string, + requestedMode: BrowserGpuMode, + resolvedMode: ResolvedBrowserGpuMode, +): void { + if (requestedMode !== "auto" || resolvedMode !== "software" || !compositionRequiresWebGpu(html)) { + return; + } + throw new Error( + "This composition declares data-requires-webgpu, but browser GPU auto-detection found no hardware GPU. " + + "Run on a WebGPU-capable host or set PRODUCER_BROWSER_GPU_MODE=hardware " + + "(or pass --browser-gpu on commands that support it) to require hardware explicitly; " + + "use --no-browser-gpu only when intentionally testing the composition's software fallback.", + ); +} diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index a1cfff9823..1fd125fbcc 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -55,12 +55,13 @@ describe("seekCompositionTimeline", () => { await seekCompositionTimeline(page, 1.25); expect(waitForFunction).not.toHaveBeenCalled(); - expect(evaluate).toHaveBeenCalledTimes(3); + expect(evaluate).toHaveBeenCalledTimes(4); expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false); - expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)"); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(evaluate.mock.calls[2]?.[0]).toContain("window.setTimeout(finish, 100)"); // Post-seek font settle: a seek can reveal glyphs whose unicode-range // subsets only start loading after the next layout (CJK snapshot reports). - expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500); + expect(evaluate).toHaveBeenNthCalledWith(4, expect.any(Function), 500); }); it("waitForFontsMs: 0 disables the post-seek font wait", async () => { @@ -68,7 +69,7 @@ describe("seekCompositionTimeline", () => { await seekCompositionTimeline(page, 1.25, { waitForFontsMs: 0 }); - expect(evaluate).toHaveBeenCalledTimes(2); + expect(evaluate).toHaveBeenCalledTimes(3); }); it("prefers renderSeek so the runtime synchronizes clip visibility", async () => { @@ -148,7 +149,7 @@ describe("seekCompositionTimeline", () => { await pending; expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 }); - expect(evaluate).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledTimes(2); expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true); }); @@ -165,39 +166,69 @@ describe("seekCompositionTimeline", () => { await vi.advanceTimersByTimeAsync(120); await pending; - expect(evaluate).toHaveBeenCalledTimes(3); + expect(evaluate).toHaveBeenCalledTimes(4); expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true); expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function)); - expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500); + expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(evaluate).toHaveBeenNthCalledWith(4, expect.any(Function), 500); + }); + + it("awaits GPU completion registered by the seek event before settling", async () => { + let completeGpu: (() => void) | undefined; + const gpuWork = new Promise((resolve) => { + completeGpu = resolve; + }); + let evaluateCall = 0; + vi.stubGlobal("window", { + __player: { renderSeek: vi.fn() }, + __hfWaitForSeekCompletion: () => gpuWork, + }); + const page: CompositionSeekPage = { + evaluate: vi.fn(async (pageFunction, value, fallback) => { + evaluateCall += 1; + if (typeof pageFunction === "function") { + return Reflect.apply(pageFunction, undefined, [value, fallback]); + } + }), + }; + + let settled = false; + const pending = seekCompositionTimeline(page, 1, { + animationFrameSettle: "none", + waitForFontsMs: 0, + }).then(() => { + settled = true; + }); + await Promise.resolve(); + expect(evaluateCall).toBe(2); + expect(settled).toBe(false); + completeGpu?.(); + await pending; + expect(settled).toBe(true); }); }); describe("resolveCliChromeGpuMode", () => { - it("preserves validate's software-only opt-in mapping", () => { + it("preserves auto/hardware/software env mapping", () => { expect(resolveCliChromeGpuMode("software")).toBe("software"); expect(resolveCliChromeGpuMode("hardware")).toBe("hardware"); - expect(resolveCliChromeGpuMode("auto")).toBe("hardware"); - expect(resolveCliChromeGpuMode("")).toBe("hardware"); + expect(resolveCliChromeGpuMode("auto")).toBe("auto"); + expect(resolveCliChromeGpuMode("")).toBe("auto"); }); }); describe("screenshot Chrome arguments", () => { - it("leaves shared capture and layout on the engine's software default", () => { - const defaultScreenshotArgs = - /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\}\s*\),/; + it("resolves auto once and launches shared captures with the concrete mode", () => { const captureSource = readFileSync( new URL("./captureCompositionFrame.ts", import.meta.url), "utf8", ); const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8"); - // openSettledCompositionPage threads the caller's optional browserGpuMode; - // callers that omit it (snapshot, compare) fall through to the engine's - // software default for screenshot capture. - expect(captureSource).toMatch( - /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\},\s*\{\s*browserGpuMode:\s*options\.browserGpuMode\s*\},?\s*\),/, - ); - expect(layoutSource).toMatch(defaultScreenshotArgs); + expect(captureSource).toContain("resolveCaptureBrowserGpuMode("); + expect(captureSource).toContain("{ browserGpuMode: resolvedGpuMode }"); + expect(layoutSource).toContain("resolveCaptureBrowserGpuMode("); + expect(layoutSource).toContain("{ browserGpuMode: resolvedGpuMode }"); }); }); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index fcc6dd2d2b..af20c32ad2 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -1,6 +1,12 @@ import { spawn } from "node:child_process"; import type { Browser, Page } from "puppeteer-core"; import { c } from "../ui/colors.js"; +import { + assertWebGpuRequirement, + resolveCaptureBrowserGpuMode, + resolveLocalBrowserGpuMode, + type BrowserGpuMode, +} from "../browser/gpuPolicy.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js"; @@ -66,9 +72,7 @@ export interface OpenSettledCompositionPageOptions { navigationTimeoutMs?: number; renderReadyTimeoutMs: number; renderReadyWarningSuffix: string; - // Screenshot paths take the engine's software-GPU default; validate/check - // thread the PRODUCER_BROWSER_GPU_MODE opt-in through here. - browserGpuMode?: "software" | "hardware"; + browserGpuMode?: BrowserGpuMode; // Runs after the page exists but before page.goto, so console/pageerror/ // request listeners can attach without missing load-time events. beforeNavigate?: (page: Page) => void | Promise; @@ -82,8 +86,8 @@ export interface FfmpegRunResult { export function resolveCliChromeGpuMode( envMode = process.env.PRODUCER_BROWSER_GPU_MODE, -): "software" | "hardware" { - return envMode === "software" ? "software" : "hardware"; +): BrowserGpuMode { + return resolveLocalBrowserGpuMode(undefined, envMode); } function compositionRuntimeReadyInBrowser(): boolean { @@ -167,6 +171,12 @@ export async function openSettledCompositionPage( const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); const { buildChromeArgs } = await import("@hyperframes/engine"); + const requestedGpuMode = options.browserGpuMode ?? resolveCliChromeGpuMode(); + const resolvedGpuMode = await resolveCaptureBrowserGpuMode( + requestedGpuMode, + browser.executablePath, + ); + assertWebGpuRequirement(html, requestedGpuMode, resolvedGpuMode); let chromeBrowser: Browser | undefined; try { @@ -175,7 +185,7 @@ export async function openSettledCompositionPage( executablePath: browser.executablePath, args: buildChromeArgs( { ...viewport, captureMode: "screenshot" }, - { browserGpuMode: options.browserGpuMode }, + { browserGpuMode: resolvedGpuMode }, ), }); @@ -256,6 +266,13 @@ export async function seekCompositionTimeline( options.fallbackToBridgeAndTimelines === true, ); + await page.evaluate(async () => { + const waitForCompletion = Reflect.get(window, "__hfWaitForSeekCompletion"); + if (typeof waitForCompletion === "function") { + await Reflect.apply(waitForCompletion, window, []); + } + }); + const animationFrameSettle = options.animationFrameSettle ?? "race"; if (animationFrameSettle === "race") { await page.evaluate(`new Promise(function(r) { diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index d23350e209..84b0c90487 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -17,6 +17,7 @@ import { type CheckSection, } from "../utils/checkPipeline.js"; import type { CaptionZoneOptions, FrameCheckOptions, LayoutOptions } from "../utils/checkTypes.js"; +import { resolveLocalBrowserGpuMode } from "../browser/gpuPolicy.js"; export const examples: Example[] = [ ["Run the full verification gate", "hyperframes check"], @@ -106,6 +107,12 @@ export function createCheckCommand( "Auto-transcode browser-hostile video codecs (default: hyperframes.json media.autoProxy, which defaults on)", default: undefined, }, + "browser-gpu": { + type: "boolean", + description: + "Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)", + default: undefined, + }, snapshots: { type: "boolean", description: "Save the five contrast-pass PNGs under snapshots/", @@ -175,6 +182,7 @@ function parseCheckOptions(args: Record): CheckOptions { frameCheck: parseFrameCheck(args["frame-check"]), layout: parseLayout(args.layout), autoProxy: args.proxy as boolean | undefined, + browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined), }; } diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 3db73e1d07..42bd3bb305 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -203,6 +203,8 @@ async function runLayoutAudit( const { ensureBrowser } = await import("../browser/manager.js"); const puppeteer = await import("puppeteer-core"); const { buildChromeArgs } = await import("@hyperframes/engine"); + const { assertWebGpuRequirement, resolveCaptureBrowserGpuMode, resolveLocalBrowserGpuMode } = + await import("../browser/gpuPolicy.js"); const html = await bundleProjectHtml(projectDir); const server = await serveStaticProjectHtml( projectDir, @@ -213,10 +215,19 @@ async function runLayoutAudit( try { const browser = await ensureBrowser(); + const requestedGpuMode = resolveLocalBrowserGpuMode(); + const resolvedGpuMode = await resolveCaptureBrowserGpuMode( + requestedGpuMode, + browser.executablePath, + ); + assertWebGpuRequirement(html, requestedGpuMode, resolvedGpuMode); chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }), + args: buildChromeArgs( + { width: 1920, height: 1080, captureMode: "screenshot" }, + { browserGpuMode: resolvedGpuMode }, + ), }); const page = await chromeBrowser.newPage(); diff --git a/packages/cli/src/commands/motionShot.test.ts b/packages/cli/src/commands/motionShot.test.ts new file mode 100644 index 0000000000..521f30e287 --- /dev/null +++ b/packages/cli/src/commands/motionShot.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { seekAllAdaptersInBrowser } from "./motionShot.js"; + +const motionShotSourcePath = join(dirname(fileURLToPath(import.meta.url)), "motionShot.ts"); +const motionWindow = window as Window & { + __player?: { renderSeek?: (time: number) => void }; + __hfWaitForSeekCompletion?: () => Promise; +}; + +afterEach(() => { + delete motionWindow.__player; + delete motionWindow.__hfWaitForSeekCompletion; + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +describe("motion-shot adapter seeking", () => { + it("awaits GPU work registered by a standalone hf-seek listener", async () => { + document.body.innerHTML = + '
'; + let finishGpuWork!: () => void; + const gpuWork = new Promise((resolve) => { + finishGpuWork = resolve; + }); + const handler = (event: Event) => { + ( + event as CustomEvent<{ + waitUntil(promise: PromiseLike): void; + }> + ).detail.waitUntil(gpuWork); + }; + window.addEventListener("hf-seek", handler); + + let settled = false; + const seeking = seekAllAdaptersInBrowser(1.5).then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + finishGpuWork(); + await seeking; + window.removeEventListener("hf-seek", handler); + expect(settled).toBe(true); + }); + + it("launches through the shared GPU policy and WebGPU requirement guard", () => { + const source = readFileSync(motionShotSourcePath, "utf8"); + + expect(source).toContain("resolveCaptureBrowserGpuMode"); + expect(source).toContain("assertWebGpuRequirement(html"); + expect(source).toContain("{ browserGpuMode: resolvedGpuMode }"); + expect(source).not.toContain('"--disable-gpu"'); + }); + + it("awaits the runtime completion hook without dispatching a second legacy seek event", async () => { + let finishGpuWork!: () => void; + const gpuWork = new Promise((resolve) => { + finishGpuWork = resolve; + }); + const handler = vi.fn(); + window.addEventListener("hf-seek", handler); + motionWindow.__player = { + renderSeek(time) { + window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } })); + }, + }; + motionWindow.__hfWaitForSeekCompletion = () => gpuWork; + + let settled = false; + const seeking = seekAllAdaptersInBrowser(2).then(() => { + settled = true; + }); + await Promise.resolve(); + expect(handler).toHaveBeenCalledOnce(); + expect(settled).toBe(false); + + finishGpuWork(); + await seeking; + window.removeEventListener("hf-seek", handler); + expect(settled).toBe(true); + }); + + it("falls back to a completion-aware hf-seek when the runtime seek hook throws", async () => { + let finishGpuWork!: () => void; + const gpuWork = new Promise((resolve) => { + finishGpuWork = resolve; + }); + const handler = vi.fn((event: Event) => { + ( + event as CustomEvent<{ + waitUntil(promise: PromiseLike): void; + }> + ).detail.waitUntil(gpuWork); + }); + window.addEventListener("hf-seek", handler); + motionWindow.__player = { + renderSeek() { + throw new Error("runtime seek failed"); + }, + }; + + let settled = false; + const seeking = seekAllAdaptersInBrowser(2.5).then(() => { + settled = true; + }); + await Promise.resolve(); + expect(handler).toHaveBeenCalledOnce(); + expect(settled).toBe(false); + + finishGpuWork(); + await seeking; + window.removeEventListener("hf-seek", handler); + expect(settled).toBe(true); + }); + + it("drains runtime work when a runtime seek hook dispatches and then throws", async () => { + let finishRuntimeGpuWork!: () => void; + let finishFallbackGpuWork!: () => void; + const runtimeGpuWork = new Promise((resolve) => { + finishRuntimeGpuWork = resolve; + }); + const fallbackGpuWork = new Promise((resolve) => { + finishFallbackGpuWork = resolve; + }); + let runtimeCompletion: Promise | undefined; + let dispatchCount = 0; + const handler = (event: Event) => { + dispatchCount += 1; + ( + event as CustomEvent<{ + waitUntil(promise: PromiseLike): void; + }> + ).detail.waitUntil(dispatchCount === 1 ? runtimeGpuWork : fallbackGpuWork); + }; + window.addEventListener("hf-seek", handler); + motionWindow.__player = { + renderSeek(time) { + const pending: PromiseLike[] = []; + window.dispatchEvent( + new CustomEvent("hf-seek", { + detail: { + time, + waitUntil(promise: PromiseLike) { + pending.push(promise); + }, + }, + }), + ); + runtimeCompletion = Promise.all(pending).then(() => undefined); + throw new Error("runtime seek failed after dispatch"); + }, + }; + motionWindow.__hfWaitForSeekCompletion = () => runtimeCompletion ?? Promise.resolve(); + + let settled = false; + const seeking = seekAllAdaptersInBrowser(3).then(() => { + settled = true; + }); + await Promise.resolve(); + expect(dispatchCount).toBe(2); + + finishFallbackGpuWork(); + await Promise.resolve(); + expect(settled).toBe(false); + + finishRuntimeGpuWork(); + await seeking; + window.removeEventListener("hf-seek", handler); + expect(settled).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts index 43ddf59929..31c8d08380 100644 --- a/packages/cli/src/commands/motionShot.ts +++ b/packages/cli/src/commands/motionShot.ts @@ -13,6 +13,12 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js"; +import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; +import { + assertWebGpuRequirement, + resolveCaptureBrowserGpuMode, + resolveLocalBrowserGpuMode, +} from "../browser/gpuPolicy.js"; import { buildOnionSvg, ghostAlphas, @@ -156,23 +162,27 @@ function compositeGhostFrames( // Runs IN THE BROWSER. Self-contained (only `tt` + window/document — never a // Node-side closure variable), pauses/seeks every adapter to time `tt`: GSAP // `__timelines`, the Web Animations API, `__hfAnime` instances, then dispatches -// `hf-seek` and nudges the three/GSAP render hooks. This one routine backs BOTH +// `hf-seek` and nudges the three/GSAP render hooks. GPU work registered through +// `waitUntil()` is awaited before returning. This one routine backs BOTH // the ghost-frame capture and the marker sampler below — see installSeekHelper // for why it's installed as a page global instead of being duplicated inline // (Puppeteer's page.evaluate only serializes the single function passed to it, // so a Node-side function can't be *called* from inside another evaluate // callback; it can only be reused by installing its source as a real page // global once, up front). -function seekAllAdaptersInBrowser(tt: number): void { - const tryCall = (fn: () => void): void => { +export async function seekAllAdaptersInBrowser(tt: number): Promise { + const tryCall = (fn: () => void): boolean => { try { fn(); + return true; } catch { - /* best-effort */ + return false; } }; const w = window as unknown as { __player?: { renderSeek?: (t: number) => void; seek?: (t: number) => void }; + __hfReseekGpu?: (t: number) => void; + __hfWaitForSeekCompletion?: () => Promise; __hfThreeTime?: number; __hfThreeRender?: () => void; __hfAnime?: Array<{ pause?: () => void; seek?: (timeMs: number) => void }>; @@ -187,11 +197,16 @@ function seekAllAdaptersInBrowser(tt: number): void { >; }; const timeMs = Math.max(0, tt * 1000); - - tryCall(() => { - if (typeof w.__player?.renderSeek === "function") w.__player.renderSeek(tt); - else if (typeof w.__player?.seek === "function") w.__player.seek(tt); - }); + let runtimeSeeked = false; + const pendingGpuWork: PromiseLike[] = []; + + if (typeof w.__player?.renderSeek === "function") { + runtimeSeeked = tryCall(() => w.__player?.renderSeek?.(tt)); + } else if (typeof w.__player?.seek === "function") { + runtimeSeeked = tryCall(() => w.__player?.seek?.(tt)); + } else if (typeof w.__hfReseekGpu === "function") { + runtimeSeeked = tryCall(() => w.__hfReseekGpu?.(tt)); + } Object.values(w.__timelines ?? {}).forEach((tl) => { tryCall(() => { @@ -223,12 +238,31 @@ function seekAllAdaptersInBrowser(tt: number): void { }); } - tryCall(() => { - w.__hfThreeTime = tt; - window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: tt } })); - w.__hfThreeRender?.(); - w.gsap?.ticker?.tick?.(); - }); + w.__hfThreeTime = tt; + if (!runtimeSeeked) { + let acceptingGpuWork = true; + try { + window.dispatchEvent( + new CustomEvent("hf-seek", { + detail: { + time: tt, + waitUntil(promise: PromiseLike) { + if (!acceptingGpuWork) { + throw new Error("hf-seek waitUntil() must be called synchronously"); + } + pendingGpuWork.push(promise); + }, + }, + }), + ); + } finally { + acceptingGpuWork = false; + } + } + tryCall(() => w.__hfThreeRender?.()); + tryCall(() => w.gsap?.ticker?.tick?.()); + + await Promise.all([Promise.all(pendingGpuWork), w.__hfWaitForSeekCompletion?.()]); } // Installs seekAllAdaptersInBrowser as a real `window` global, once per page @@ -243,6 +277,7 @@ async function installSeekHelper(page: import("puppeteer-core").Page): Promise { const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); + const size = resolveCompositionViewportFromHtml(html); + const requestedGpuMode = resolveLocalBrowserGpuMode(); + const resolvedGpuMode = await resolveCaptureBrowserGpuMode(requestedGpuMode, executablePath); + assertWebGpuRequirement(html, requestedGpuMode, resolvedGpuMode); const browser = await puppeteer.default.launch({ headless: true, executablePath, - args: [ - "--no-sandbox", - "--disable-gpu", - "--disable-dev-shm-usage", - "--enable-webgl", - "--use-gl=angle", - "--use-angle=swiftshader", - ], + args: buildChromeArgs( + { ...size, captureMode: "screenshot" }, + { browserGpuMode: resolvedGpuMode }, + ), }); const page = await browser.newPage(); const navigationTimeout = resolveDiagnosticNavigationTimeoutMs(); - await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout }); - const size = await page.evaluate(() => { - const root = document.querySelector("[data-composition-id][data-width][data-height]"); - const w = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0; - const h = root ? parseInt(root.getAttribute("data-height") ?? "", 10) : 0; - return { - width: Number.isFinite(w) && w > 0 ? Math.min(w, 4096) : 1920, - height: Number.isFinite(h) && h > 0 ? Math.min(h, 4096) : 1080, - }; - }); await page.setViewport(size); await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout }); await page @@ -436,10 +462,10 @@ async function resolveScopedRequests( // SAME tick — before the browser clears the GL drawing buffer (works without // preserveDrawingBuffer; page.screenshot can't see the GL buffer here). function captureGhostFrame(page: import("puppeteer-core").Page, t: number): Promise { - return page.evaluate((tt: number) => { - (window as unknown as { __hfSeekAllAdapters?: (time: number) => void }).__hfSeekAllAdapters?.( - tt, - ); + return page.evaluate(async (tt: number) => { + await ( + window as unknown as { __hfSeekAllAdapters?: (time: number) => Promise } + ).__hfSeekAllAdapters?.(tt); const root = (document.querySelector("[data-composition-id]") ?? document.body) as HTMLElement; const rb = root.getBoundingClientRect(); const off = document.createElement("canvas"); @@ -537,8 +563,8 @@ async function captureMarkerOnionSkin( await applyOrbitCameraIfAngled(page, requests, camera); const elements = (await page.evaluate( - (selectors: string[], ts: number[]) => { - const seek = (window as unknown as { __hfSeekAllAdapters?: (t: number) => void }) + async (selectors: string[], ts: number[]) => { + const seek = (window as unknown as { __hfSeekAllAdapters?: (t: number) => Promise }) .__hfSeekAllAdapters; const rigs = selectors.map((sel) => { @@ -563,7 +589,7 @@ async function captureMarkerOnionSkin( }); const out = selectors.map((selector) => ({ selector, samples: [] as PageSample[] })); for (const t of ts) { - seek?.(t); + await seek?.(t); rigs.forEach((rig, i) => { if (!rig) return; const pts = rig.markers.map((m) => { @@ -638,7 +664,7 @@ export async function captureMotionPathShot( let browserInstance: import("puppeteer-core").Browser | undefined; try { const browser = await ensureBrowser(); - const opened = await openCompositionPage(server.url, browser.executablePath); + const opened = await openCompositionPage(html, server.url, browser.executablePath); browserInstance = opened.browser; const { page, size } = opened; diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index c336c5f239..1c297427f4 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -1,4 +1,5 @@ import { setCommandExitCode, requestCliExit } from "../utils/commandResult.js"; +// fallow-ignore-file code-duplication import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { spawn, type ChildProcessByStdio } from "node:child_process"; @@ -68,6 +69,7 @@ import { startBackgroundPreview, stopBackgroundPreview, } from "./previewLifecycle.js"; +import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; interface BrowserLaunchOptions { noOpen?: boolean; @@ -80,6 +82,7 @@ interface BrowserLaunchOptions { interface StudioLaunchOptions extends BrowserLaunchOptions { projectName?: string; autoProxy?: boolean; + browserGpuMode?: BrowserGpuMode; } interface EmbeddedStudioOptions extends StudioLaunchOptions { @@ -190,6 +193,12 @@ export default defineCommand({ description: "Launch the opened browser with --disable-gpu (requires --browser-path). For hosts where hardware acceleration crashes the graphics driver (e.g. NVIDIA Xid resets); with the system default browser use --no-open instead.", }, + "browser-gpu": { + type: "boolean", + description: + "Use hardware GPU for Studio thumbnails and frame capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect)", + default: undefined, + }, proxy: { type: "boolean", description: @@ -198,6 +207,9 @@ export default defineCommand({ }, }, async run({ args }) { + const browserGpuMode = resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined); + if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware"; + if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software"; const startPort = parseInt(args.port ?? "3002", 10); const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined; @@ -380,6 +392,7 @@ export default defineCommand({ try { background = await startBackgroundPreview(dir, startPort, { forceNew: Boolean(args["force-new"]), + browserGpuMode, }); } catch (error) { clack.log.error(errorMessage(error)); @@ -417,6 +430,7 @@ export default defineCommand({ userDataDir, remoteDebuggingPort, browserNoGpu, + browserGpuMode, }); }, }); @@ -1046,6 +1060,7 @@ async function runEmbeddedMode( projectDir: dir, projectName: pName, autoProxy: options?.autoProxy, + browserGpuMode: options?.browserGpuMode, }); const serverBuildSignature = await loadPreviewServerBuildSignature(); @@ -1057,6 +1072,8 @@ async function runEmbeddedMode( dir, !!options?.forceNew, serverBuildSignature, + undefined, + options?.browserGpuMode, ); } catch (err: unknown) { s.stop(c.error("Failed to start studio")); diff --git a/packages/cli/src/commands/previewLifecycle.test.ts b/packages/cli/src/commands/previewLifecycle.test.ts index 86ba10e89c..1f0fbdb0aa 100644 --- a/packages/cli/src/commands/previewLifecycle.test.ts +++ b/packages/cli/src/commands/previewLifecycle.test.ts @@ -94,6 +94,32 @@ describe("background preview lifecycle", () => { expect(spawn).toHaveBeenCalledOnce(); }); + it("starts a replacement when the existing server uses a different GPU policy", async () => { + const hardwareServer = { ...server, browserGpuMode: "hardware" as const }; + const softwareServer = { + ...server, + port: 3211, + pid: "5432", + browserGpuMode: "software" as const, + }; + let scans = 0; + const scan = vi.fn(async () => + ++scans < 2 ? [hardwareServer] : [hardwareServer, softwareServer], + ); + const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() })); + + const result = await startBackgroundPreview(projectDir, 3002, { + browserGpuMode: "software", + scan, + spawn, + sleep: async () => {}, + stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")), + }); + + expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 }); + expect(spawn).toHaveBeenCalledOnce(); + }); + it("returns after a detached child becomes reachable and records its session", async () => { let scans = 0; const scan = vi.fn(async () => (++scans < 2 ? [] : [server])); diff --git a/packages/cli/src/commands/previewLifecycle.ts b/packages/cli/src/commands/previewLifecycle.ts index 4082370d28..eaec7f91ab 100644 --- a/packages/cli/src/commands/previewLifecycle.ts +++ b/packages/cli/src/commands/previewLifecycle.ts @@ -12,6 +12,7 @@ import { import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { scanActiveServers, type ActiveServer } from "../server/portUtils.js"; +import type { BrowserGpuMode } from "../browser/gpuPolicy.js"; import { killProcessTree } from "../utils/orphanCleanup.js"; export interface PreviewSession { @@ -41,6 +42,7 @@ interface LifecycleDependencies { kill?: (pid: number) => void; stateHome?: string; forceNew?: boolean; + browserGpuMode?: BrowserGpuMode; } function defaultStateHome(): string { @@ -97,8 +99,18 @@ function removePreviewSession(projectDir: string, stateHome = defaultStateHome() rmSync(previewSessionPath(projectDir, stateHome), { force: true }); } -function matchingServer(servers: ActiveServer[], projectDir: string): ActiveServer | null { - return servers.find((server) => normalized(server.projectDir) === normalized(projectDir)) ?? null; +function matchingServer( + servers: ActiveServer[], + projectDir: string, + browserGpuMode?: BrowserGpuMode, +): ActiveServer | null { + return ( + servers.find( + (server) => + normalized(server.projectDir) === normalized(projectDir) && + (browserGpuMode === undefined || server.browserGpuMode === browserGpuMode), + ) ?? null + ); } function stopProcess(pid: number): void { @@ -147,10 +159,11 @@ function startedServer( projectDir: string, existing: ActiveServer | null, forceNew: boolean, + browserGpuMode?: BrowserGpuMode, ): ActiveServer | null { const candidates = forceNew && existing ? servers.filter((server) => server.port !== existing.port) : servers; - return matchingServer(candidates, projectDir); + return matchingServer(candidates, projectDir, browserGpuMode); } export function buildBackgroundPreviewArgs(argv: string[]): string[] { @@ -198,7 +211,7 @@ export async function startBackgroundPreview( | { type: "started"; port: number; pid: number; logPath: string } > { const scan = dependencies.scan ?? scanActiveServers; - const existing = matchingServer(await scan(startPort), projectDir); + const existing = matchingServer(await scan(startPort), projectDir, dependencies.browserGpuMode); if (existing && !dependencies.forceNew) { return { type: "reused", @@ -218,6 +231,7 @@ export async function startBackgroundPreview( projectDir, existing, dependencies.forceNew === true, + dependencies.browserGpuMode, ); if (server) { const session = { diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index fc6155154c..d2db7c3c03 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -53,6 +53,13 @@ describe("transparent snapshot capture", () => { expect(source).toContain("autoProxy: args.proxy as boolean | undefined"); expect(source).toContain("opts.autoProxy"); }); + + it("resolves and forwards the shared local browser GPU policy", () => { + const source = readFileSync(new URL("./snapshot.ts", import.meta.url), "utf8"); + expect(source).toContain("resolveLocalBrowserGpuMode"); + expect(source).toContain("browserGpuMode: opts.browserGpuMode"); + expect(source).toContain('"browser-gpu": {'); + }); }); describe("resolveSnapshotVideoFrameTime", () => { diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index b05b86e066..b10096d265 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -21,6 +21,7 @@ import { c } from "../ui/colors.js"; import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import { parseAngle, type Camera } from "./motionShotLayout.js"; import type { Example } from "./_examples.js"; +import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; // Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so // the REAL painted pixels are viewed from an orthogonal angle (FINDING [10]: @@ -244,6 +245,7 @@ async function captureSnapshots( zoom?: ZoomTarget; zoomScale?: number; autoProxy?: boolean; + browserGpuMode?: BrowserGpuMode; }, ): Promise { const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js"); @@ -261,6 +263,7 @@ async function captureSnapshots( const { browser: chromeBrowser, page } = await openSettledCompositionPage(html, server.url, { renderReadyTimeoutMs: opts.timeout ?? 5000, renderReadyWarningSuffix: "snapshots may be inaccurate", + browserGpuMode: opts.browserGpuMode, }); try { @@ -638,6 +641,12 @@ export default defineCommand({ "Auto-transcode browser-hostile video codecs for snapshots (default: on; overrides hyperframes.json media.autoProxy)", default: undefined, }, + "browser-gpu": { + type: "boolean", + description: + "Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)", + default: undefined, + }, }, async run({ args }) { const project = resolveProject(args.dir); @@ -687,6 +696,7 @@ export default defineCommand({ zoom: zoomTarget, zoomScale, autoProxy: args.proxy as boolean | undefined, + browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined), }); if (paths.length === 0) { diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 1450597b5f..e25989d7c0 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -418,12 +418,20 @@ async function validateInBrowser( const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine"); + const requestedGpuMode = resolveCliChromeGpuMode(); + const { assertWebGpuRequirement, resolveCaptureBrowserGpuMode } = + await import("../browser/gpuPolicy.js"); + const resolvedGpuMode = await resolveCaptureBrowserGpuMode( + requestedGpuMode, + browser.executablePath, + ); + assertWebGpuRequirement(html, requestedGpuMode, resolvedGpuMode); const chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, args: buildChromeArgs( { ...viewport, captureMode: "screenshot" }, - { browserGpuMode: resolveCliChromeGpuMode() }, + { browserGpuMode: resolvedGpuMode }, ), }); diff --git a/packages/cli/src/server/portUtils.test.ts b/packages/cli/src/server/portUtils.test.ts index a52dc289ec..4c6ee962fd 100644 --- a/packages/cli/src/server/portUtils.test.ts +++ b/packages/cli/src/server/portUtils.test.ts @@ -193,4 +193,48 @@ describe("detectHyperframesServer", () => { expect(result).toEqual({ type: "match" }); }); + + it("treats same-project servers with a different browser GPU policy as mismatch", async () => { + const projectDir = "/tmp/demo-project"; + const port = await startConfigProbeServer({ + isHyperframes: true, + projectName: "demo-project", + projectDir, + serverBuildSignature: "same-build", + browserGpuMode: "hardware", + version: "0.6.42", + }); + + const normalizedProjectDir = resolve(projectDir).replace(/\\/g, "/").toLowerCase(); + const result = await detectHyperframesServer( + port, + normalizedProjectDir, + "same-build", + "software", + ); + + expect(result).toEqual({ type: "mismatch", projectName: "demo-project" }); + }); + + it("matches same-project servers with the requested browser GPU policy", async () => { + const projectDir = "/tmp/demo-project"; + const port = await startConfigProbeServer({ + isHyperframes: true, + projectName: "demo-project", + projectDir, + serverBuildSignature: "same-build", + browserGpuMode: "software", + version: "0.6.42", + }); + + const normalizedProjectDir = resolve(projectDir).replace(/\\/g, "/").toLowerCase(); + const result = await detectHyperframesServer( + port, + normalizedProjectDir, + "same-build", + "software", + ); + + expect(result).toEqual({ type: "match" }); + }); }); diff --git a/packages/cli/src/server/portUtils.ts b/packages/cli/src/server/portUtils.ts index a8db6f75ea..ee9cb6f4a9 100644 --- a/packages/cli/src/server/portUtils.ts +++ b/packages/cli/src/server/portUtils.ts @@ -16,6 +16,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { resolve } from "node:path"; import { c } from "../ui/colors.js"; +import type { BrowserGpuMode } from "../browser/gpuPolicy.js"; const execFileAsync = promisify(execFile); @@ -101,6 +102,7 @@ interface HyperframesConfigResponse { projectName: string; projectDir: string; serverBuildSignature?: string | null; + browserGpuMode?: BrowserGpuMode; version: string; } @@ -117,6 +119,7 @@ export function detectHyperframesServer( port: number, normalizedProjectDir: string, expectedServerBuildSignature: string | null = null, + expectedBrowserGpuMode?: BrowserGpuMode, ): Promise { return new Promise((resolveResult) => { const req = http.get( @@ -161,6 +164,12 @@ export function detectHyperframesServer( ) { return resolveResult({ type: "mismatch", projectName: json.projectName }); } + if ( + expectedBrowserGpuMode !== undefined && + json.browserGpuMode !== expectedBrowserGpuMode + ) { + return resolveResult({ type: "mismatch", projectName: json.projectName }); + } return resolveResult({ type: "match" }); } @@ -217,6 +226,7 @@ export interface ActiveServer { projectDir: string; version: string; pid: string | null; + browserGpuMode?: BrowserGpuMode; } /** @@ -289,6 +299,7 @@ export async function scanActiveServers(startPort = 3002): Promise { const { createAdaptorServer } = await import("@hono/node-server"); // SECURITY (F-001): bind to loopback by default. The studio API exposes @@ -398,6 +410,7 @@ export async function findPortAndServe( port, normalizedDir, expectedServerBuildSignature, + expectedBrowserGpuMode, ); if (detection.type === "match") { return { type: "already-running", port }; diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index 466317aff3..041a58de65 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadHyperframeRuntimeSource } from "@hyperframes/core"; @@ -12,6 +12,16 @@ describe("loadRuntimeSource", () => { }); }); +describe("Studio thumbnail GPU capture plumbing", () => { + it("uses the shared auto probe, resolved launch mode, requirement guard, and completion-aware seek", () => { + const source = readFileSync(new URL("./studioServer.ts", import.meta.url), "utf8"); + expect(source).toContain("resolveCaptureBrowserGpuMode"); + expect(source).toContain("{ browserGpuMode: resolvedGpuMode }"); + expect(source).toContain("assertWebGpuRequirement"); + expect(source).toContain("await seekCompositionTimeline(page, opts.seekTime"); + }); +}); + describe("createStudioServer autoProxy plumbing", () => { const dirs: string[] = []; let server: StudioServer | undefined; @@ -56,4 +66,14 @@ describe("createStudioServer autoProxy plumbing", () => { expect(server.adapter.autoProxy).toBe(true); }); + + it("advertises the GPU policy used for thumbnail capture", async () => { + const projectDir = tmpProject(); + server = createStudioServer({ projectDir, browserGpuMode: "software" }); + + const response = await server.app.request("/__hyperframes_config"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ browserGpuMode: "software" }); + }); }); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 358189ad63..e9d50f3cf3 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -35,6 +35,14 @@ import { resolveAutoProxy } from "../utils/projectConfig.js"; import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; import type { RenderJob } from "@hyperframes/producer"; +import { seekCompositionTimeline } from "../capture/captureCompositionFrame.js"; +import { + assertWebGpuRequirement, + resolveCaptureBrowserGpuMode, + resolveLocalBrowserGpuMode, + type BrowserGpuMode, + type ResolvedBrowserGpuMode, +} from "../browser/gpuPolicy.js"; const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; const REMOTE_GIF_IMG_SRC_RE = @@ -169,23 +177,46 @@ async function downloadRemoteGifImageSources( // share a single Chrome process instead of running two independent ones. let _thumbnailBrowserLease: import("@hyperframes/engine").BrowserLease | null = null; -let _thumbnailBrowserInitializing: Promise< - import("@hyperframes/engine").BrowserLease | null -> | null = null; +let _thumbnailBrowserInitializing: Promise | null = null; +let _thumbnailBrowserModes: { + requested: BrowserGpuMode; + resolved: ResolvedBrowserGpuMode; +} | null = null; + +interface ThumbnailBrowserSession { + browser: import("puppeteer-core").Browser; + requestedGpuMode: BrowserGpuMode; + resolvedGpuMode: ResolvedBrowserGpuMode; +} -async function getThumbnailBrowser(): Promise { - if (_thumbnailBrowserLease?.browser.connected) return _thumbnailBrowserLease.browser; +async function getThumbnailBrowser( + requestedGpuMode: BrowserGpuMode, +): Promise { + if ( + _thumbnailBrowserLease?.browser.connected && + _thumbnailBrowserModes?.requested === requestedGpuMode + ) { + return { + browser: _thumbnailBrowserLease.browser, + requestedGpuMode: _thumbnailBrowserModes.requested, + resolvedGpuMode: _thumbnailBrowserModes.resolved, + }; + } if (_thumbnailBrowserInitializing) { - return (await _thumbnailBrowserInitializing)?.browser ?? null; + const session = await _thumbnailBrowserInitializing; + if (session?.requestedGpuMode === requestedGpuMode) return session; } + if (_thumbnailBrowserLease) await closeThumbnailBrowser(); _thumbnailBrowserInitializing = (async () => { try { const { ensureBrowser } = await import("../browser/manager.js"); const { acquireBrowser, buildChromeArgs } = await import("@hyperframes/engine"); + let executablePath: string | undefined; try { const b = await ensureBrowser({ preferManagedChrome: true }); + executablePath = b.executablePath; if (b.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) { process.env.PRODUCER_HEADLESS_SHELL_PATH = b.executablePath; } @@ -193,16 +224,23 @@ async function getThumbnailBrowser(): Promise { - if (_thumbnailBrowserLease === acquired) _thumbnailBrowserLease = null; + if (_thumbnailBrowserLease !== acquired) return; + _thumbnailBrowserLease = null; + _thumbnailBrowserModes = null; _thumbnailBrowserInitializing = null; }); - return acquired; + return { browser: acquired.browser, requestedGpuMode, resolvedGpuMode }; } catch (err) { console.warn( "[Studio] Failed to launch thumbnail browser:", @@ -213,13 +251,14 @@ async function getThumbnailBrowser(): Promise { if (!_thumbnailBrowserLease) return; const lease = _thumbnailBrowserLease; _thumbnailBrowserLease = null; + _thumbnailBrowserModes = null; _thumbnailBrowserInitializing = null; await lease.release().catch(() => {}); } @@ -237,6 +276,8 @@ export interface StudioServerOptions { * config (default true) applies. */ autoProxy?: boolean | undefined; + /** GPU policy used by Studio thumbnails and frame capture. */ + browserGpuMode?: BrowserGpuMode; } export interface StudioServer { @@ -302,6 +343,7 @@ function rewriteWrittenToHostViewport(projectDir: string, written: string[]): vo export function createStudioServer(options: StudioServerOptions): StudioServer { const { projectDir, projectName } = options; const projectId = projectName || basename(projectDir); + const browserGpuMode = options.browserGpuMode ?? resolveLocalBrowserGpuMode(); const studioDir = resolveDistDir(); const runtimePath = resolveRuntimePath(); const watcher = createProjectWatcher(projectDir); @@ -494,14 +536,22 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }, async generateThumbnail(opts): Promise { - const browser = await getThumbnailBrowser(); - if (!browser) { + const session = await getThumbnailBrowser(browserGpuMode); + if (!session) { console.warn("[Studio] Thumbnail: no browser available — Chrome may not be installed"); return null; } + const sourcePath = join(opts.project.dir, opts.compPath); + if (existsSync(sourcePath)) { + assertWebGpuRequirement( + readFileSync(sourcePath, "utf-8"), + session.requestedGpuMode, + session.resolvedGpuMode, + ); + } let page: import("puppeteer-core").Page | null = null; try { - page = await browser.newPage(); + page = await session.browser.newPage(); await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 }); await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); await page @@ -515,22 +565,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { { timeout: 5000 }, ) .catch(() => {}); - // fallow-ignore-next-line code-duplication - await page.evaluate((t: number) => { - const w = window as Window & { - __player?: { seek?: (time: number) => void }; - __timelines?: Record void }>; - gsap?: { ticker?: { tick?: () => void } }; - }; - if (typeof w.__player?.seek === "function") { - w.__player.seek(t); - } else if (w.__timelines) { - for (const tl of Object.values(w.__timelines)) { - tl?.pause?.(t); - } - w.gsap?.ticker?.tick?.(); - } - }, opts.seekTime); + await seekCompositionTimeline(page, opts.seekTime, { + fallbackToBridgeAndTimelines: true, + waitForPreferredSeekTargetMs: 500, + animationFrameSettle: "double", + waitForFontsMs: 500, + }); const manifestContent = readStudioManualEditManifestContent(opts.project.dir); await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath); await page.evaluate(() => { @@ -625,6 +665,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { projectName: projectId, projectDir: projectDir, serverBuildSignature, + browserGpuMode, version, }); }; diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index ad94e169f2..8a6a4920ab 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -1,4 +1,5 @@ // @vitest-environment happy-dom +// fallow-ignore-file code-duplication import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -435,6 +436,49 @@ it("surfaces the runtime's media-proxy-unavailable console.info line as its own ); }); +it("elevates and deduplicates WebGPU validation warnings while preserving ordinary warnings", async () => { + vi.spyOn(Date, "now").mockReturnValue(100); + mountCanvasFixture(); + const page = fakePage(); + const validationFailure = fakeConsoleMessage( + "warn", + "WebGPU uncaptured error: GPUValidationError: Destroyed texture used in a submit", + ); + const ordinaryWarning = fakeConsoleMessage("warn", "Optional caption font was not loaded"); + page.on = vi.fn( + (event: string, handler: (message: ReturnType) => void) => { + if (event === "console") { + handler(validationFailure); + handler(validationFailure); + handler(ordinaryWarning); + } + }, + ); + installSessionMock(page); + + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false }, + { kind: "none" }, + runAuditGrid, + ); + + expect(result.runtimeFindings).toContainEqual( + expect.objectContaining({ + code: "webgpu_runtime_error", + severity: "error", + message: `${validationFailure.text()} (repeated 2 times)`, + }), + ); + expect(result.runtimeFindings).toContainEqual( + expect.objectContaining({ + code: "console_warning", + severity: "warning", + message: ordinaryWarning.text(), + }), + ); +}); + describe("preResolveHostileMediaProxies", () => { const dirs: string[] = []; const mkProjectDir = (): string => { diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 3af73878b1..012708b217 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -58,6 +58,7 @@ interface RuntimeDraft { time: number; url?: string; line?: number; + count?: number; } interface AnchorRequest { @@ -168,7 +169,7 @@ export async function runBrowserCheck( navigationTimeoutMs: options.timeout, renderReadyTimeoutMs: options.timeout, renderReadyWarningSuffix: "checking the current page state", - browserGpuMode: resolveCliChromeGpuMode(), + browserGpuMode: options.browserGpuMode ?? resolveCliChromeGpuMode(), beforeNavigate: (page) => wireRuntimeListeners(page, drafts, () => currentTime), }); chromeBrowser = session.browser; @@ -229,7 +230,7 @@ export async function captureFindingCrops( navigationTimeoutMs: options.timeout, renderReadyTimeoutMs: options.timeout, renderReadyWarningSuffix: "capturing finding crops", - browserGpuMode: resolveCliChromeGpuMode(), + browserGpuMode: options.browserGpuMode ?? resolveCliChromeGpuMode(), }); chromeBrowser = session.browser; const page = session.page; @@ -263,6 +264,31 @@ export async function captureFindingCrops( // `console.info` from a composition author's own script must not. const MEDIA_PROXY_MARKER_PREFIX = "[hyperframes] runtime_media_proxy_"; const MEDIA_PROXY_UNAVAILABLE_MARKER = "[hyperframes] runtime_media_proxy_unavailable"; +const WEBGPU_RUNTIME_FAILURE = + /\b(?:GPUValidationError|GPUOutOfMemoryError|GPUInternalError)\b|WebGPU uncaptured error|(?:destroyed\b.*\b(?:GPU )?(?:resource|buffer|texture)\b.*\bsubmit)|(?:(?:GPU )?(?:resource|buffer|texture)\b.*\bdestroyed\b.*\bsubmit)/i; + +function isWebGpuRuntimeFailure(text: string): boolean { + return WEBGPU_RUNTIME_FAILURE.test(text); +} + +function pushRuntimeDraft(drafts: RuntimeDraft[], draft: RuntimeDraft): void { + if (draft.code !== "webgpu_runtime_error") { + drafts.push(draft); + return; + } + const duplicate = drafts.find( + (entry) => + entry.code === draft.code && + entry.message === draft.message && + entry.url === draft.url && + entry.line === draft.line, + ); + if (duplicate) { + duplicate.count = (duplicate.count ?? 1) + 1; + return; + } + drafts.push({ ...draft, count: 1 }); +} function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void { page.on("console", (message) => { @@ -270,7 +296,7 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: ( const text = message.text(); if (type === "error" && !text.startsWith("Failed to load resource")) { const location = message.location(); - drafts.push({ + pushRuntimeDraft(drafts, { code: "console_error", severity: "error", message: text, @@ -280,9 +306,10 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: ( }); } else if (type === "warn") { const location = message.location(); - drafts.push({ - code: "console_warning", - severity: "warning", + const webGpuFailure = isWebGpuRuntimeFailure(text); + pushRuntimeDraft(drafts, { + code: webGpuFailure ? "webgpu_runtime_error" : "console_warning", + severity: webGpuFailure ? "error" : "warning", message: text, time: currentTime(), url: location.url, @@ -290,7 +317,7 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: ( }); } else if (type === "info" && text.startsWith(MEDIA_PROXY_MARKER_PREFIX)) { const location = message.location(); - drafts.push({ + pushRuntimeDraft(drafts, { code: text.includes(MEDIA_PROXY_UNAVAILABLE_MARKER) ? "media_proxy_unavailable" : "media_proxy_fallback", @@ -307,7 +334,12 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: ( if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '<'")) { return; } - drafts.push({ code: "page_error", severity: "error", message, time: currentTime() }); + pushRuntimeDraft(drafts, { + code: "page_error", + severity: "error", + message, + time: currentTime(), + }); }); wireNetworkListeners(page, drafts, currentTime); } @@ -1088,7 +1120,8 @@ function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding { return { code: draft.code, severity: draft.severity, - message: draft.message, + message: + (draft.count ?? 1) > 1 ? `${draft.message} (repeated ${draft.count} times)` : draft.message, selector: root.selector, dataAttributes: root.dataAttributes, sourceFile: root.sourceFile, diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index cf98188fef..c984f7e52d 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -85,6 +85,7 @@ export const DEFAULT_CHECK_OPTIONS: CheckOptions = { contrast: true, strict: false, snapshots: false, + browserGpuMode: "auto", }; /** Pick at most five evenly-strided points from the already-merged layout grid. */ diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 54e5aee53a..5baa31916f 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -3,6 +3,7 @@ import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js"; import type { Canvas, MotionFrame } from "./motionAudit.js"; import type { MotionSpec } from "./motionSpec.js"; import type { ProjectDir } from "./project.js"; +import type { BrowserGpuMode } from "../browser/gpuPolicy.js"; export interface CheckOptions { samples: number; @@ -22,6 +23,7 @@ export interface CheckOptions { layout?: LayoutOptions; /** Explicit --proxy/--no-proxy override; undefined preserves project config. */ autoProxy?: boolean; + browserGpuMode?: BrowserGpuMode; } export interface CaptionZoneOptions { diff --git a/packages/core/src/runtime/adapters/seek-dispatch.test.ts b/packages/core/src/runtime/adapters/seek-dispatch.test.ts index 34a70ebf91..6c503e013b 100644 --- a/packages/core/src/runtime/adapters/seek-dispatch.test.ts +++ b/packages/core/src/runtime/adapters/seek-dispatch.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { dispatchSeekEvent, forceDispatchSeekEvent, resetSeekDispatchState } from "./seek-dispatch"; +import { + dispatchSeekEvent, + forceDispatchSeekEvent, + resetSeekDispatchState, + waitForSeekCompletion, + type HfSeekEventDetail, +} from "./seek-dispatch"; describe("seek-dispatch", () => { beforeEach(() => { @@ -42,4 +48,167 @@ describe("seek-dispatch", () => { window.removeEventListener("hf-seek", handler); expect(handler).toHaveBeenCalledTimes(1); }); + + it("waits for all GPU work registered synchronously by listeners", async () => { + let finish: (() => void) | undefined; + const gpuWork = new Promise((resolve) => { + finish = resolve; + }); + const handler = (event: Event) => { + (event as CustomEvent).detail.waitUntil(gpuWork); + }; + window.addEventListener("hf-seek", handler); + dispatchSeekEvent(9); + window.removeEventListener("hf-seek", handler); + + let settled = false; + const pending = waitForSeekCompletion().then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + finish?.(); + await pending; + expect(settled).toBe(true); + }); + + it("retains overlapping seek generations until a capture observes them", async () => { + let finishFirst: (() => void) | undefined; + let finishSecond: (() => void) | undefined; + const firstGpuWork = new Promise((resolve) => { + finishFirst = resolve; + }); + const secondGpuWork = new Promise((resolve) => { + finishSecond = resolve; + }); + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + detail.waitUntil(detail.time === 10 ? firstGpuWork : secondGpuWork); + }; + window.addEventListener("hf-seek", handler); + dispatchSeekEvent(10); + dispatchSeekEvent(11); + window.removeEventListener("hf-seek", handler); + + let settled = false; + const pending = waitForSeekCompletion().then(() => { + settled = true; + }); + finishSecond?.(); + await Promise.resolve(); + expect(settled).toBe(false); + + finishFirst?.(); + await pending; + expect(settled).toBe(true); + }); + + it("drains success and failure generations registered after the capture wait starts", async () => { + let finishFirst: (() => void) | undefined; + let finishSecond: (() => void) | undefined; + let failThird: ((reason: unknown) => void) | undefined; + const firstGpuWork = new Promise((resolve) => { + finishFirst = resolve; + }); + const secondGpuWork = new Promise((resolve) => { + finishSecond = resolve; + }); + const thirdGpuWork = new Promise((_resolve, reject) => { + failThird = reject; + }); + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + const gpuWork = + detail.time === 13 ? firstGpuWork : detail.time === 14 ? secondGpuWork : thirdGpuWork; + detail.waitUntil(gpuWork); + }; + window.addEventListener("hf-seek", handler); + dispatchSeekEvent(13); + + let settled = false; + const failure = new Error("later GPU queue failed"); + const pending = waitForSeekCompletion() + .then(() => { + settled = true; + }) + .catch((reason: unknown) => { + settled = true; + throw reason; + }); + await Promise.resolve(); + + forceDispatchSeekEvent(14); + forceDispatchSeekEvent(15); + finishFirst?.(); + await Promise.resolve(); + expect(settled).toBe(false); + + finishSecond?.(); + await Promise.resolve(); + expect(settled).toBe(false); + + failThird?.(failure); + await expect(pending).rejects.toBe(failure); + window.removeEventListener("hf-seek", handler); + await expect(waitForSeekCompletion()).resolves.toBeUndefined(); + }); + + it("does not retain empty or fulfilled heartbeat generations between captures", async () => { + for (let i = 0; i < 20; i += 1) { + forceDispatchSeekEvent(20); + } + + const handler = (event: Event) => { + (event as CustomEvent).detail.waitUntil(Promise.resolve()); + }; + window.addEventListener("hf-seek", handler); + for (let i = 0; i < 20; i += 1) { + forceDispatchSeekEvent(21); + } + window.removeEventListener("hf-seek", handler); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + const promiseAll = vi.spyOn(Promise, "all"); + await waitForSeekCompletion(); + expect(promiseAll).not.toHaveBeenCalled(); + }); + + it("reports a rejected generation once, then consumes it", async () => { + const failure = new Error("GPU queue failed"); + const handler = (event: Event) => { + (event as CustomEvent).detail.waitUntil(Promise.reject(failure)); + }; + window.addEventListener("hf-seek", handler); + dispatchSeekEvent(12); + window.removeEventListener("hf-seek", handler); + + await expect(waitForSeekCompletion()).rejects.toBe(failure); + await expect(waitForSeekCompletion()).resolves.toBeUndefined(); + }); + + it("reports one rejected generation to concurrent capture barriers", async () => { + let failGpuWork: ((reason: unknown) => void) | undefined; + const gpuWork = new Promise((_resolve, reject) => { + failGpuWork = reject; + }); + const failure = new Error("GPU queue failed"); + const handler = (event: Event) => { + (event as CustomEvent).detail.waitUntil(gpuWork); + }; + window.addEventListener("hf-seek", handler); + dispatchSeekEvent(22); + window.removeEventListener("hf-seek", handler); + + const firstCapture = waitForSeekCompletion(); + const secondCapture = waitForSeekCompletion(); + failGpuWork?.(failure); + + await expect(Promise.allSettled([firstCapture, secondCapture])).resolves.toEqual([ + { status: "rejected", reason: failure }, + { status: "rejected", reason: failure }, + ]); + await expect(waitForSeekCompletion()).resolves.toBeUndefined(); + }); }); diff --git a/packages/core/src/runtime/adapters/seek-dispatch.ts b/packages/core/src/runtime/adapters/seek-dispatch.ts index ad9f0dad8a..391e99f8d9 100644 --- a/packages/core/src/runtime/adapters/seek-dispatch.ts +++ b/packages/core/src/runtime/adapters/seek-dispatch.ts @@ -19,15 +19,52 @@ import { swallow } from "../diagnostics"; */ let _lastDispatchedTime = -1; +type SeekCompletionResult = { status: "fulfilled" } | { status: "rejected"; reason: unknown }; +let _pendingCompletions = new Set>(); +let _pendingFailure: { reason: unknown } | undefined; +let _activeCompletionBarriers = 0; -export function dispatchSeekEvent(time: number): void { - if (time === _lastDispatchedTime) return; - _lastDispatchedTime = time; +export interface HfSeekEventDetail { + time: number; + waitUntil: (promise: PromiseLike) => void; +} + +function dispatch(time: number): void { + const pending: PromiseLike[] = []; + let accepting = true; + const detail: HfSeekEventDetail = { + time, + waitUntil: (promise) => { + if (!accepting) { + throw new Error("hf-seek waitUntil() must be called synchronously from the event listener"); + } + pending.push(promise); + }, + }; try { - window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } })); + window.dispatchEvent(new CustomEvent("hf-seek", { detail })); } catch (err) { swallow("runtime.adapters.seek-dispatch.site1", err); + } finally { + accepting = false; } + if (pending.length === 0) return; + const completion = Promise.all(pending) + .then(() => ({ status: "fulfilled" })) + .catch((reason: unknown) => ({ status: "rejected", reason })); + _pendingCompletions.add(completion); + void completion.then((result) => { + if (!_pendingCompletions.delete(completion)) return; + if (result.status === "rejected" && _pendingFailure === undefined) { + _pendingFailure = { reason: result.reason }; + } + }); +} + +export function dispatchSeekEvent(time: number): void { + if (time === _lastDispatchedTime) return; + _lastDispatchedTime = time; + dispatch(time); } /** @@ -42,14 +79,44 @@ export function dispatchSeekEvent(time: number): void { */ export function forceDispatchSeekEvent(time: number): void { _lastDispatchedTime = time; + dispatch(time); +} + +export function isSeekCompletionBarrierActive(): boolean { + return _activeCompletionBarriers > 0; +} + +export async function waitForSeekCompletion(): Promise { + _activeCompletionBarriers += 1; + let failed = _pendingFailure; try { - window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } })); - } catch (err) { - swallow("runtime.adapters.seek-dispatch.force", err); + // Let concurrently started barriers snapshot the same retained failure + // before either one consumes it. + await Promise.resolve(); + while (_pendingCompletions.size > 0) { + const results = await Promise.all([..._pendingCompletions]); + const rejected = results.find((result) => result.status === "rejected"); + if (failed === undefined && rejected?.status === "rejected") { + failed = { reason: rejected.reason }; + } + } + const retainedFailure = _pendingFailure; + if (failed === undefined) { + failed = retainedFailure; + } + if (_pendingFailure === retainedFailure) { + _pendingFailure = undefined; + } + if (failed) throw failed.reason; + } finally { + _activeCompletionBarriers -= 1; } } /** Reset internal state — used in tests to prevent cross-test contamination. */ export function resetSeekDispatchState(): void { _lastDispatchedTime = -1; + _pendingCompletions = new Set(); + _pendingFailure = undefined; + _activeCompletionBarriers = 0; } diff --git a/packages/core/src/runtime/adapters/typegpu.test.ts b/packages/core/src/runtime/adapters/typegpu.test.ts index f38e1e5d4a..96d559c0ac 100644 --- a/packages/core/src/runtime/adapters/typegpu.test.ts +++ b/packages/core/src/runtime/adapters/typegpu.test.ts @@ -1,11 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createTypegpuAdapter } from "./typegpu"; -import { resetSeekDispatchState } from "./seek-dispatch"; +import { createTypegpuAdapter, TYPEGPU_PRESENT_HEARTBEAT_MS } from "./typegpu"; +import { + resetSeekDispatchState, + waitForSeekCompletion, + type HfSeekEventDetail, +} from "./seek-dispatch"; const gpuWindow = window as Window & { __hfTypegpuTime?: number }; describe("typegpu adapter", () => { beforeEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; delete gpuWindow.__hfTypegpuTime; // Reset shared dedup state so each test starts with a clean dispatch history resetSeekDispatchState(); @@ -86,4 +92,77 @@ describe("typegpu adapter", () => { const adapter = createTypegpuAdapter(); expect(() => adapter.discover()).not.toThrow(); }); + + it("re-presents the paused WebGPU frame without advancing seek time", async () => { + vi.useFakeTimers(); + document.body.innerHTML = + '
'; + const adapter = createTypegpuAdapter(); + const times: number[] = []; + const handler = (event: Event) => { + times.push((event as CustomEvent<{ time: number }>).detail.time); + }; + window.addEventListener("hf-seek", handler); + + adapter.seek({ time: 1.25 }); + adapter.pause(); + await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS); + adapter.play?.(); + await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2); + window.removeEventListener("hf-seek", handler); + + expect(times).toEqual([1.25, 1.25]); + expect(gpuWindow.__hfTypegpuTime).toBe(1.25); + }); + + it("does not start a present heartbeat without the WebGPU capability marker", async () => { + vi.useFakeTimers(); + document.body.innerHTML = '
'; + const adapter = createTypegpuAdapter(); + const handler = vi.fn(); + window.addEventListener("hf-seek", handler); + + adapter.seek({ time: 1.25 }); + adapter.pause(); + await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2); + window.removeEventListener("hf-seek", handler); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it("pauses presentation heartbeats while a capture barrier drains slow GPU work", async () => { + vi.useFakeTimers(); + document.body.innerHTML = + '
'; + const adapter = createTypegpuAdapter(); + const times: number[] = []; + const completionLatency = TYPEGPU_PRESENT_HEARTBEAT_MS * 2 + 1; + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + times.push(detail.time); + detail.waitUntil( + new Promise((resolve) => { + setTimeout(resolve, completionLatency); + }), + ); + }; + window.addEventListener("hf-seek", handler); + + adapter.seek({ time: 1.25 }); + adapter.pause(); + let settled = false; + const capture = waitForSeekCompletion().then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS * 2); + expect(settled).toBe(false); + expect(times).toEqual([1.25]); + + await vi.advanceTimersByTimeAsync(1); + await capture; + adapter.play?.(); + window.removeEventListener("hf-seek", handler); + expect(settled).toBe(true); + expect(times).toEqual([1.25]); + }); }); diff --git a/packages/core/src/runtime/adapters/typegpu.ts b/packages/core/src/runtime/adapters/typegpu.ts index 60a5a3ae0d..c89eabd15f 100644 --- a/packages/core/src/runtime/adapters/typegpu.ts +++ b/packages/core/src/runtime/adapters/typegpu.ts @@ -1,5 +1,11 @@ import type { RuntimeDeterministicAdapter } from "../types"; -import { dispatchSeekEvent } from "./seek-dispatch"; +import { + dispatchSeekEvent, + forceDispatchSeekEvent, + isSeekCompletionBarrierActive, +} from "./seek-dispatch"; + +export const TYPEGPU_PRESENT_HEARTBEAT_MS = 250; /** * TypeGPU / WebGPU adapter for HyperFrames @@ -31,7 +37,10 @@ import { dispatchSeekEvent } from "./seek-dispatch"; * } * * // Seek: fired by HyperFrames whenever the player scrubs or plays - * window.addEventListener("hf-seek", (e) => render(e.detail.time)); + * window.addEventListener("hf-seek", (e) => { + * render(e.detail.time); + * e.detail.waitUntil(device.queue.onSubmittedWorkDone()); + * }); * * // Initial frame at t=0 * render(window.__hfTypegpuTime ?? 0); @@ -44,9 +53,9 @@ import { dispatchSeekEvent } from "./seek-dispatch"; * * ## Render-mode determinism * - * For frame-perfect video renders, call `await device.queue.onSubmittedWorkDone()` - * after each `render(time)` invocation before the frame is captured. This ensures - * the GPU has finished writing to the canvas before the engine screenshots it. + * For frame-perfect video renders, register GPU completion synchronously with + * `e.detail.waitUntil(device.queue.onSubmittedWorkDone())` after `render(time)`. + * HyperFrames awaits the registered work before screenshots and frame capture. * * ## Browser feature detection * @@ -64,6 +73,24 @@ import { dispatchSeekEvent } from "./seek-dispatch"; export function createTypegpuAdapter(): RuntimeDeterministicAdapter { let forcedTime: number | null = null; let lastForcedTime = 0; + let presentHeartbeat: number | null = null; + + const stopPresentHeartbeat = () => { + if (presentHeartbeat === null) return; + window.clearInterval(presentHeartbeat); + presentHeartbeat = null; + }; + + const startPresentHeartbeat = () => { + if (presentHeartbeat !== null) return; + if (!document.querySelector("[data-composition-id][data-requires-webgpu]")) return; + presentHeartbeat = window.setInterval(() => { + if (forcedTime === null) return; + if (isSeekCompletionBarrierActive()) return; + window.__hfTypegpuTime = forcedTime; + forceDispatchSeekEvent(forcedTime); + }, TYPEGPU_PRESENT_HEARTBEAT_MS); + }; return { name: "typegpu", @@ -83,13 +110,16 @@ export function createTypegpuAdapter(): RuntimeDeterministicAdapter { if (forcedTime == null) { forcedTime = Math.max(0, lastForcedTime); } + startPresentHeartbeat(); }, play: () => { + stopPresentHeartbeat(); forcedTime = null; }, revert: () => { + stopPresentHeartbeat(); forcedTime = null; lastForcedTime = 0; }, diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index ff91e738ed..cc59b76045 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1,6 +1,7 @@ // fallow-ignore-file code-duplication import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; +import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu"; import type { RuntimeTimelineLike } from "./types"; function createMockTimeline(duration: number): RuntimeTimelineLike { @@ -122,6 +123,7 @@ describe("initSandboxRuntimeModular", () => { delete (window as { __hfAutoNoopRegistered?: boolean }).__hfAutoNoopRegistered; delete window.gsap; vi.restoreAllMocks(); + vi.useRealTimers(); window.requestAnimationFrame = originalRequestAnimationFrame; window.cancelAnimationFrame = originalCancelAnimationFrame; }); @@ -370,6 +372,31 @@ describe("initSandboxRuntimeModular", () => { expect(child.style.visibility).toBe("visible"); }); + it("keeps WebGPU presentation active after renderSeek pauses the frame", async () => { + vi.useFakeTimers(); + + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-requires-webgpu", ""); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + window.__timelines = { main: createMockTimeline(10) }; + + const times: number[] = []; + const onSeek = (event: Event) => { + times.push((event as CustomEvent<{ time: number }>).detail.time); + }; + window.addEventListener("hf-seek", onSeek); + + initSandboxRuntimeModular(); + window.__player?.renderSeek(4); + await vi.advanceTimersByTimeAsync(TYPEGPU_PRESENT_HEARTBEAT_MS); + + window.removeEventListener("hf-seek", onSeek); + expect(times).toEqual([0, 4, 4]); + }); + it("uses export render fps when quantizing renderSeek", () => { const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); const root = document.createElement("div"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cf65ecd262..cd8bd8b2ea 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -17,7 +17,7 @@ import { patchVideoTextureCompat, patchWebGLVideoTextureCompat, } from "./adapters/video-texture-compat"; -import { forceDispatchSeekEvent } from "./adapters/seek-dispatch"; +import { forceDispatchSeekEvent, waitForSeekCompletion } from "./adapters/seek-dispatch"; import { createWaapiAdapter } from "./adapters/waapi"; import { readElementPlaybackRate, @@ -2412,6 +2412,7 @@ export function initSandboxRuntimeModular(): void { activateChildren: true, suppressEvents: options?.suppressEvents, }); + runAdapters("pause"); syncMediaForCurrentState(); colorGrading.redraw(); postState(true); @@ -2514,6 +2515,12 @@ export function initSandboxRuntimeModular(): void { window.__hfTypegpuTime = t; forceDispatchSeekEvent(t); }; + window.__hfWaitForSeekCompletion = waitForSeekCompletion; + runtimeCleanupCallbacks.push(() => { + if (window.__hfWaitForSeekCompletion === waitForSeekCompletion) { + delete window.__hfWaitForSeekCompletion; + } + }); installRuntimeErrorDiagnostics(); bindMediaMetadataListeners(); runAdapters("discover"); diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index aaa79ccb47..68a93ebb46 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -72,6 +72,11 @@ declare global { * freshly-injected `__render_frame__` images. See `forceDispatchSeekEvent`. */ __hfReseekGpu?: (time: number) => void; + /** + * Await GPU work registered synchronously by `hf-seek` listeners through + * `event.detail.waitUntil(...)`. + */ + __hfWaitForSeekCompletion?: () => Promise; /** * Canonical root-timeline start for a media element. Snapshot capture uses * this runtime-owned resolver so reference expressions, authored timing diff --git a/packages/engine/src/services/frameCapture-gpuCompletion.test.ts b/packages/engine/src/services/frameCapture-gpuCompletion.test.ts new file mode 100644 index 0000000000..0145762781 --- /dev/null +++ b/packages/engine/src/services/frameCapture-gpuCompletion.test.ts @@ -0,0 +1,43 @@ +// fallow-ignore-file code-duplication +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { waitForPendingSeekCompletion } from "./frameCapture.js"; + +describe("WebGPU frame completion", () => { + it("blocks capture preparation until the runtime completion promise settles", async () => { + let finish: (() => void) | undefined; + const gpuWork = new Promise((resolve) => { + finish = resolve; + }); + const previousWindow = Reflect.get(globalThis, "window"); + Reflect.set(globalThis, "window", { __hfWaitForSeekCompletion: () => gpuWork }); + const page = { + evaluate: vi.fn(async (pageFunction: () => unknown) => pageFunction()), + }; + + try { + let settled = false; + const pending = waitForPendingSeekCompletion(page).then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + finish?.(); + await pending; + expect(settled).toBe(true); + } finally { + if (previousWindow === undefined) Reflect.deleteProperty(globalThis, "window"); + else Reflect.set(globalThis, "window", previousWindow); + } + }); + + it("runs the completion wait after video injection and before screenshot capture", () => { + const source = readFileSync(new URL("./frameCapture.ts", import.meta.url), "utf8"); + const injection = source.indexOf("await session.onBeforeCapture(page, quantizedTime)"); + const completion = source.indexOf("await waitForPendingSeekCompletion(page)"); + const screenshot = source.indexOf("async function captureFrameCore"); + expect(injection).toBeGreaterThan(-1); + expect(completion).toBeGreaterThan(injection); + expect(screenshot).toBeGreaterThan(completion); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 28d6df0d6a..7fdb61f281 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -2366,6 +2366,15 @@ async function captureFrameErrorDiagnostics( * Shared by captureFrame (disk) and captureFrameToBuffer (buffer). * Returns timing breakdown for perf tracking. */ +export async function waitForPendingSeekCompletion(page: Pick): Promise { + await page.evaluate(async () => { + const waitForCompletion = ( + window as Window & { __hfWaitForSeekCompletion?: () => Promise } + ).__hfWaitForSeekCompletion; + await waitForCompletion?.(); + }); +} + async function prepareFrameForCapture( session: CaptureSession, frameIndex: number, @@ -2406,6 +2415,7 @@ async function prepareFrameForCapture( if (session.onBeforeCapture) { await session.onBeforeCapture(page, quantizedTime); } + await waitForPendingSeekCompletion(page); await page.evaluate(async () => { const runtime = ( window as Window & { diff --git a/packages/producer/tests/typegpu-adapter/src/index.html b/packages/producer/tests/typegpu-adapter/src/index.html index a6c3206bbd..09456e4b97 100644 --- a/packages/producer/tests/typegpu-adapter/src/index.html +++ b/packages/producer/tests/typegpu-adapter/src/index.html @@ -9,7 +9,7 @@ -
+
TypeGPU adapter test
@@ -114,7 +114,10 @@ } render(0); - window.addEventListener('hf-seek', function(e) { render(e.detail.time); }); + window.addEventListener('hf-seek', function(e) { + render(e.detail.time); + e.detail.waitUntil(device.queue.onSubmittedWorkDone()); + }); })();
diff --git a/skills-manifest.json b/skills-manifest.json index caf80df57b..e529b76071 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,7 +22,7 @@ "files": 17 }, "hyperframes-animation": { - "hash": "6c13cdce4f3a23c2", + "hash": "5bc2ce098387a547", "files": 121 }, "hyperframes-cli": { diff --git a/skills/hyperframes-animation/adapters/typegpu.md b/skills/hyperframes-animation/adapters/typegpu.md index 8af87c8ff7..675d1bd753 100644 --- a/skills/hyperframes-animation/adapters/typegpu.md +++ b/skills/hyperframes-animation/adapters/typegpu.md @@ -17,9 +17,10 @@ The render engine auto-passes `--enable-unsafe-webgpu` and `--enable-features=Ca - Render from HyperFrames time, not `performance.now()`. - Listen for the `hf-seek` event and re-render at exactly that time. - Guard against environments where WebGPU is unavailable — the adapter does not check for you. -- For video renders, call `await device.queue.onSubmittedWorkDone()` after submitting GPU work to ensure the canvas is flushed before the frame is captured. +- If the composition cannot render without WebGPU, add `data-requires-webgpu` to its composition root. Local capture commands then report an actionable error instead of capturing a no-GPU fallback screen when auto-detection selects software rendering. +- After submitting GPU work, register queue completion synchronously with `e.detail.waitUntil(device.queue.onSubmittedWorkDone())`. HyperFrames awaits registered work before screenshots and frame capture. -The adapter sets `window.__hfTypegpuTime` and dispatches `new CustomEvent("hf-seek", { detail: { time } })` on each seek. +The adapter sets `window.__hfTypegpuTime` and dispatches an `hf-seek` event with `{ time, waitUntil }` on each seek. While Studio is paused, HyperFrames may dispatch the same time again to keep the WebGPU swapchain presented. Re-render that exact time; do not advance simulation state. ## Basic Pattern @@ -67,7 +68,10 @@ The adapter sets `window.__hfTypegpuTime` and dispatches `new CustomEvent("hf-se } render(0); - window.addEventListener("hf-seek", (e) => render(e.detail.time)); + window.addEventListener("hf-seek", (e) => { + render(e.detail.time); + e.detail.waitUntil(device.queue.onSubmittedWorkDone()); + }); })(); ``` @@ -173,6 +177,6 @@ Use this to define inside/ring/outside zones for glass effects. Negative values ## Deterministic Rendering - No `Math.random()` — use a seeded PRNG. -- No `requestAnimationFrame` for the render loop — render only in response to `hf-seek`. +- Do not use an autonomous `requestAnimationFrame` simulation loop. Render in response to `hf-seek`; HyperFrames owns the paused-presentation heartbeat and may re-present the same time. - No `performance.now()` for animation time — read `window.__hfTypegpuTime` or `e.detail.time`. -- After GPU submit, call `await device.queue.onSubmittedWorkDone()` for render-mode frame capture. +- Register GPU completion with `e.detail.waitUntil(device.queue.onSubmittedWorkDone())` before the event listener returns.