Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/cli/src/browser/gpuPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -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(
'<div data-requires-webgpu data-composition-id="gpu" data-duration="2"></div>',
),
).toBe(true);
expect(compositionRequiresWebGpu('<div data-composition-id="dom"></div>')).toBe(false);
});

it("rejects an auto software fallback for required WebGPU compositions", () => {
const html = '<div data-composition-id="gpu" data-requires-webgpu></div>';
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();
});
});
43 changes: 43 additions & 0 deletions packages/cli/src/browser/gpuPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export type BrowserGpuMode = "auto" | "hardware" | "software";
export type ResolvedBrowserGpuMode = Exclude<BrowserGpuMode, "auto">;

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<ResolvedBrowserGpuMode> {
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.",
);
}
71 changes: 51 additions & 20 deletions packages/cli/src/capture/captureCompositionFrame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,21 @@ 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 () => {
const { page, evaluate } = fakeSeekPage();

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 () => {
Expand Down Expand Up @@ -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);
});

Expand All @@ -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<void>((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 }");
});
});

Expand Down
29 changes: 23 additions & 6 deletions packages/cli/src/capture/captureCompositionFrame.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<void>;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -175,7 +185,7 @@ export async function openSettledCompositionPage(
executablePath: browser.executablePath,
args: buildChromeArgs(
{ ...viewport, captureMode: "screenshot" },
{ browserGpuMode: options.browserGpuMode },
{ browserGpuMode: resolvedGpuMode },
),
});

Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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/",
Expand Down Expand Up @@ -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),
};
}

Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/commands/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand Down
Loading
Loading