fix: align local WebGPU capture behavior (#2907)

* fix: align local WebGPU capture behavior

* fix: address WebGPU capture review feedback

* fix: retain overlapping GPU seek work

* fix: satisfy runtime seek completion types

* fix: drain concurrent GPU seek work

* fix: prevent WebGPU capture barrier starvation

* fix: keep WebGPU presentation active during render seeks
This commit is contained in:
James Russo
2026-07-30 21:52:30 -07:00
committed by GitHub
parent 1d636f603c
commit 3a6b7f0612
34 changed files with 1208 additions and 137 deletions
+8
View File
@@ -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<string, unknown>): CheckOptions {
frameCheck: parseFrameCheck(args["frame-check"]),
layout: parseLayout(args.layout),
autoProxy: args.proxy as boolean | undefined,
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
};
}
+12 -1
View File
@@ -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();
@@ -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<void>;
};
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 =
'<div data-composition-id="gpu" data-requires-webgpu data-duration="2"></div>';
let finishGpuWork!: () => void;
const gpuWork = new Promise<void>((resolve) => {
finishGpuWork = resolve;
});
const handler = (event: Event) => {
(
event as CustomEvent<{
waitUntil(promise: PromiseLike<unknown>): 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<void>((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<void>((resolve) => {
finishGpuWork = resolve;
});
const handler = vi.fn((event: Event) => {
(
event as CustomEvent<{
waitUntil(promise: PromiseLike<unknown>): 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<void>((resolve) => {
finishRuntimeGpuWork = resolve;
});
const fallbackGpuWork = new Promise<void>((resolve) => {
finishFallbackGpuWork = resolve;
});
let runtimeCompletion: Promise<void> | undefined;
let dispatchCount = 0;
const handler = (event: Event) => {
dispatchCount += 1;
(
event as CustomEvent<{
waitUntil(promise: PromiseLike<unknown>): void;
}>
).detail.waitUntil(dispatchCount === 1 ? runtimeGpuWork : fallbackGpuWork);
};
window.addEventListener("hf-seek", handler);
motionWindow.__player = {
renderSeek(time) {
const pending: PromiseLike<unknown>[] = [];
window.dispatchEvent(
new CustomEvent("hf-seek", {
detail: {
time,
waitUntil(promise: PromiseLike<unknown>) {
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);
});
});
+66 -40
View File
@@ -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<void> {
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<void>;
__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);
let runtimeSeeked = false;
const pendingGpuWork: PromiseLike<unknown>[] = [];
tryCall(() => {
if (typeof w.__player?.renderSeek === "function") w.__player.renderSeek(tt);
else if (typeof w.__player?.seek === "function") w.__player.seek(tt);
});
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<unknown>) {
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<v
// Launch headless Chrome, load the composition sized to its canvas, wait for the
// timelines + fonts to be ready. Returns the browser (caller closes it), page, size.
async function openCompositionPage(
html: string,
url: string,
executablePath: string,
): Promise<{
@@ -251,30 +286,21 @@ async function openCompositionPage(
size: FrameSize;
}> {
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<string> {
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<void> }
).__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<void> })
.__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;
+17
View File
@@ -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"));
@@ -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]));
+18 -4
View File
@@ -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 = {
@@ -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", () => {
+10
View File
@@ -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<string[]> {
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) {
+9 -1
View File
@@ -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 },
),
});