Skip to content

Commit f85614e

Browse files
fix(cli): WebGPU compositions work in snapshot, check and layout like render (#5354)
* fix(cli): snapshot, check, layout and thumbnails honour software WebGPU like render * test(cli): stub the local WebGPU helper in the studio server tests * fix(engine): webgpu compositions are recognised after the bundled runtime script * fix(engine): a hung GPU probe falls back to software in 15 s instead of 120 s * fix(engine): the GPU probe keeps the configured launch timeout and bounds only its page * build(parsers): declare the html-attribute-spans subpath in its descriptor * build(gcp-cloud-run): the BeginFrame contract stage copies parsers, which the engine now imports * test(cli): the screenshot launch pin follows the shared local WebGPU helper * fix(engine): the GPU probe's wait for Chrome's first page falls inside its 15 s limit
1 parent 5562c28 commit f85614e

18 files changed

Lines changed: 228 additions & 65 deletions

‎packages/cli/src/browser/gpuPolicy.test.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, expect, it } from "vitest";
2-
import { resolveLocalBrowserGpuMode } from "./gpuPolicy.js";
1+
import { buildChromeArgs } from "@hyperframes/engine";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { resolveLocalBrowserGpuMode, resolveLocalWebGpu } from "./gpuPolicy.js";
34

45
// compositionRequiresWebGpu and assertWebGpuAdapterAvailable are implemented
56
// in @hyperframes/engine (browserManager.ts) and only re-exported here — see
@@ -13,3 +14,30 @@ describe("local browser GPU policy", () => {
1314
expect(resolveLocalBrowserGpuMode(false, "hardware")).toBe("software");
1415
});
1516
});
17+
18+
describe("resolveLocalWebGpu", () => {
19+
afterEach(() => vi.unstubAllEnvs());
20+
const launchArgs = (gpuConfig: ReturnType<typeof resolveLocalWebGpu>["gpuConfig"]) =>
21+
buildChromeArgs(
22+
{ width: 640, height: 360, captureMode: "screenshot", requiresWebGpu: true },
23+
gpuConfig,
24+
);
25+
26+
it("runs a WebGPU composition on SwiftShader in software mode when render's opt-in is set", () => {
27+
vi.stubEnv("PRODUCER_ALLOW_SOFTWARE_WEBGPU", "true");
28+
const { gpuConfig, softwareWebGpu } = resolveLocalWebGpu("software", true);
29+
expect(softwareWebGpu).toBe(true);
30+
expect(launchArgs(gpuConfig)).toContain("--use-vulkan=swiftshader");
31+
});
32+
33+
it("stays off without the opt-in, on a GPU, or for a composition that does not need WebGPU", () => {
34+
vi.stubEnv("PRODUCER_ALLOW_SOFTWARE_WEBGPU", "");
35+
expect(resolveLocalWebGpu("software", true).softwareWebGpu).toBe(false);
36+
expect(launchArgs(resolveLocalWebGpu("software", true).gpuConfig)).not.toContain(
37+
"--use-vulkan=swiftshader",
38+
);
39+
vi.stubEnv("PRODUCER_ALLOW_SOFTWARE_WEBGPU", "true");
40+
expect(resolveLocalWebGpu("hardware", true).softwareWebGpu).toBe(false);
41+
expect(resolveLocalWebGpu("software", false).softwareWebGpu).toBe(false);
42+
});
43+
});

‎packages/cli/src/browser/gpuPolicy.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { resolveConfig, usesSoftwareWebGpu } from "@hyperframes/engine";
2+
13
export type BrowserGpuMode = "auto" | "hardware" | "software";
24
export type ResolvedBrowserGpuMode = Exclude<BrowserGpuMode, "auto">;
35

@@ -24,3 +26,12 @@ export async function resolveCaptureBrowserGpuMode(
2426
// buildChromeArgs caller goes through, CLI included. Re-exported here so
2527
// existing CLI imports don't need to change their module path.
2628
export { compositionRequiresWebGpu, assertWebGpuAdapterAvailable } from "@hyperframes/engine";
29+
30+
/** The launch config and adapter check for a local capture, honouring PRODUCER_ALLOW_SOFTWARE_WEBGPU as render does. */
31+
export function resolveLocalWebGpu(
32+
browserGpuMode: ResolvedBrowserGpuMode,
33+
requiresWebGpu: boolean,
34+
) {
35+
const gpuConfig = { browserGpuMode, allowSoftwareWebGpu: resolveConfig().allowSoftwareWebGpu };
36+
return { gpuConfig, softwareWebGpu: usesSoftwareWebGpu(requiresWebGpu, gpuConfig) };
37+
}

‎packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
99
resolveBrowserGpuMode: vi.fn(async () => "software" as const),
1010
compositionRequiresWebGpu: vi.fn(() => false),
1111
assertWebGpuAdapterAvailable: vi.fn(async () => undefined),
12+
resolveConfig: vi.fn(),
13+
usesSoftwareWebGpu: vi.fn(),
1214
}));
1315

1416
vi.mock("../browser/manager.js", () => ({
@@ -26,6 +28,8 @@ vi.mock("@hyperframes/engine", () => ({
2628
resolveBrowserGpuMode: mocks.resolveBrowserGpuMode,
2729
compositionRequiresWebGpu: mocks.compositionRequiresWebGpu,
2830
assertWebGpuAdapterAvailable: mocks.assertWebGpuAdapterAvailable,
31+
resolveConfig: mocks.resolveConfig,
32+
usesSoftwareWebGpu: mocks.usesSoftwareWebGpu,
2933
}));
3034

3135
import { openSettledCompositionPage } from "./captureCompositionFrame.js";
@@ -71,13 +75,33 @@ describe("openSettledCompositionPage Windows bundled-browser recovery", () => {
7175
mocks.findSystemBrowser.mockReturnValue({ executablePath: SYSTEM, source: "system" });
7276
mocks.buildChromeArgs.mockReturnValue([]);
7377
mocks.resolveBrowserGpuMode.mockResolvedValue("software");
78+
mocks.resolveConfig.mockReturnValue({ allowSoftwareWebGpu: false });
79+
mocks.usesSoftwareWebGpu.mockReturnValue(false);
7480
});
7581

7682
afterEach(() => {
7783
vi.useRealTimers();
7884
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
7985
});
8086

87+
it("launches software WebGPU and accepts its adapter when render's opt-in is set", async () => {
88+
const opened = fakeBrowser();
89+
mocks.launch.mockResolvedValueOnce(opened.browser);
90+
mocks.compositionRequiresWebGpu.mockReturnValue(true);
91+
mocks.resolveConfig.mockReturnValue({ allowSoftwareWebGpu: true });
92+
mocks.usesSoftwareWebGpu.mockReturnValue(true);
93+
94+
await openSettledCompositionPage(HTML, "http://127.0.0.1:3000", OPTIONS);
95+
96+
const gpuConfig = { browserGpuMode: "software", allowSoftwareWebGpu: true };
97+
expect(mocks.buildChromeArgs).toHaveBeenCalledWith(
98+
expect.objectContaining({ requiresWebGpu: true }),
99+
gpuConfig,
100+
);
101+
expect(mocks.usesSoftwareWebGpu).toHaveBeenCalledWith(true, gpuConfig);
102+
expect(mocks.assertWebGpuAdapterAvailable).toHaveBeenCalledWith(opened.page, true, true);
103+
});
104+
81105
it("retries the known managed-shell crash once with system Chrome", async () => {
82106
const fallback = fakeBrowser();
83107
mocks.launch.mockRejectedValueOnce(launchCrash()).mockResolvedValueOnce(fallback.browser);

‎packages/cli/src/capture/captureCompositionFrame.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,9 @@ describe("screenshot Chrome arguments", () => {
272272
const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8");
273273

274274
expect(captureSource).toContain("resolveManagedGpuMode(");
275-
expect(captureSource).toContain("{ browserGpuMode: resolvedGpuMode }");
275+
expect(captureSource).toContain("resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu)");
276276
expect(layoutSource).toContain("resolveManagedGpuMode(");
277-
expect(layoutSource).toContain("{ browserGpuMode: resolvedGpuMode }");
277+
expect(layoutSource).toContain("resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu)");
278278
});
279279
});
280280

‎packages/cli/src/capture/captureCompositionFrame.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
assertWebGpuAdapterAvailable,
77
compositionRequiresWebGpu,
88
resolveLocalBrowserGpuMode,
9+
resolveLocalWebGpu,
910
type BrowserGpuMode,
1011
} from "../browser/gpuPolicy.js";
1112
import { windowsChromeCrashRemediation } from "../browser/windowsCrash.js";
@@ -185,14 +186,17 @@ export async function openSettledCompositionPage(
185186
const { buildChromeArgs } = await import("@hyperframes/engine");
186187
const requestedGpuMode = options.browserGpuMode ?? resolveCliChromeGpuMode();
187188
const requiresWebGpu = compositionRequiresWebGpu(html);
189+
let softwareWebGpu = false;
188190
const launch = async (executablePath: string): Promise<Browser> => {
189191
const resolvedGpuMode = await resolveManagedGpuMode(requestedGpuMode, executablePath);
192+
const webGpu = resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu);
193+
softwareWebGpu = webGpu.softwareWebGpu;
190194
return launchManagedBrowser(puppeteer.default, {
191195
headless: true,
192196
executablePath,
193197
args: buildChromeArgs(
194198
{ ...viewport, captureMode: "screenshot", requiresWebGpu },
195-
{ browserGpuMode: resolvedGpuMode },
199+
webGpu.gpuConfig,
196200
),
197201
});
198202
};
@@ -235,7 +239,7 @@ export async function openSettledCompositionPage(
235239
waitUntil: "domcontentloaded",
236240
timeout: resolveDiagnosticNavigationTimeoutMs(process.env, options.navigationTimeoutMs),
237241
});
238-
await assertWebGpuAdapterAvailable(page, requiresWebGpu);
242+
await assertWebGpuAdapterAvailable(page, requiresWebGpu, softwareWebGpu);
239243
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
240244
return { browser: chromeBrowser, page, renderReadyTimedOut };
241245
} catch (err) {

‎packages/cli/src/commands/layout.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,12 @@ async function runLayoutAudit(
205205
const { ensureBrowser } = await import("../browser/manager.js");
206206
const puppeteer = await import("puppeteer-core");
207207
const { buildChromeArgs } = await import("@hyperframes/engine");
208-
const { assertWebGpuAdapterAvailable, compositionRequiresWebGpu, resolveLocalBrowserGpuMode } =
209-
await import("../browser/gpuPolicy.js");
208+
const {
209+
assertWebGpuAdapterAvailable,
210+
compositionRequiresWebGpu,
211+
resolveLocalBrowserGpuMode,
212+
resolveLocalWebGpu,
213+
} = await import("../browser/gpuPolicy.js");
210214
const html = await bundleProjectHtml(projectDir);
211215
const server = await serveStaticProjectHtml(
212216
projectDir,
@@ -220,12 +224,13 @@ async function runLayoutAudit(
220224
const requestedGpuMode = resolveLocalBrowserGpuMode();
221225
const resolvedGpuMode = await resolveManagedGpuMode(requestedGpuMode, browser.executablePath);
222226
const requiresWebGpu = compositionRequiresWebGpu(html);
227+
const { gpuConfig, softwareWebGpu } = resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu);
223228
chromeBrowser = await launchManagedBrowser(puppeteer.default, {
224229
headless: true,
225230
executablePath: browser.executablePath,
226231
args: buildChromeArgs(
227232
{ width: 1920, height: 1080, captureMode: "screenshot", requiresWebGpu },
228-
{ browserGpuMode: resolvedGpuMode },
233+
gpuConfig,
229234
),
230235
});
231236

@@ -236,7 +241,7 @@ async function runLayoutAudit(
236241
waitUntil: "domcontentloaded",
237242
timeout: resolveDiagnosticNavigationTimeoutMs(),
238243
});
239-
await assertWebGpuAdapterAvailable(page, requiresWebGpu);
244+
await assertWebGpuAdapterAvailable(page, requiresWebGpu, softwareWebGpu);
240245
await alignViewportToComposition(page, server.url);
241246
await waitForRuntimeReady(page, opts.timeout);
242247
await waitForCompositionFonts(page, 750);

‎packages/cli/src/commands/motionShot.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ describe("motion-shot adapter seeking", () => {
8484

8585
expect(source).toContain("resolveManagedGpuMode");
8686
expect(source).toContain("compositionRequiresWebGpu(html)");
87-
expect(source).toContain("assertWebGpuAdapterAvailable(page, requiresWebGpu)");
88-
expect(source).toContain("{ browserGpuMode: resolvedGpuMode }");
87+
expect(source).toContain("resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu)");
88+
expect(source).toContain("assertWebGpuAdapterAvailable(page, requiresWebGpu, softwareWebGpu)");
8989
expect(source).not.toContain('"--disable-gpu"');
9090
});
9191

‎packages/cli/src/commands/motionShot.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
assertWebGpuAdapterAvailable,
2121
compositionRequiresWebGpu,
2222
resolveLocalBrowserGpuMode,
23+
resolveLocalWebGpu,
2324
} from "../browser/gpuPolicy.js";
2425
import {
2526
buildOnionSvg,
@@ -403,19 +404,17 @@ async function openCompositionPage(
403404
const requestedGpuMode = resolveLocalBrowserGpuMode();
404405
const resolvedGpuMode = await resolveManagedGpuMode(requestedGpuMode, executablePath);
405406
const requiresWebGpu = compositionRequiresWebGpu(html);
407+
const { gpuConfig, softwareWebGpu } = resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu);
406408
const browser = await launchManagedBrowser(puppeteer.default, {
407409
headless: true,
408410
executablePath,
409-
args: buildChromeArgs(
410-
{ ...size, captureMode: "screenshot", requiresWebGpu },
411-
{ browserGpuMode: resolvedGpuMode },
412-
),
411+
args: buildChromeArgs({ ...size, captureMode: "screenshot", requiresWebGpu }, gpuConfig),
413412
});
414413
const page = await browser.newPage();
415414
const navigationTimeout = resolveDiagnosticNavigationTimeoutMs();
416415
await page.setViewport(size);
417416
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout });
418-
await assertWebGpuAdapterAvailable(page, requiresWebGpu);
417+
await assertWebGpuAdapterAvailable(page, requiresWebGpu, softwareWebGpu);
419418
await waitForRuntimeReady(page, 10000);
420419
await page
421420
.evaluate(async () => {

‎packages/cli/src/commands/validate.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -439,17 +439,15 @@ async function validateInBrowser(
439439
const puppeteer = await import("puppeteer-core");
440440
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
441441
const requestedGpuMode = resolveCliChromeGpuMode();
442-
const { assertWebGpuAdapterAvailable, compositionRequiresWebGpu } =
442+
const { assertWebGpuAdapterAvailable, compositionRequiresWebGpu, resolveLocalWebGpu } =
443443
await import("../browser/gpuPolicy.js");
444444
const resolvedGpuMode = await resolveManagedGpuMode(requestedGpuMode, browser.executablePath);
445445
const requiresWebGpu = compositionRequiresWebGpu(html);
446+
const { gpuConfig, softwareWebGpu } = resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu);
446447
const chromeBrowser = await launchManagedBrowser(puppeteer.default, {
447448
headless: true,
448449
executablePath: browser.executablePath,
449-
args: buildChromeArgs(
450-
{ ...viewport, captureMode: "screenshot", requiresWebGpu },
451-
{ browserGpuMode: resolvedGpuMode },
452-
),
450+
args: buildChromeArgs({ ...viewport, captureMode: "screenshot", requiresWebGpu }, gpuConfig),
453451
});
454452

455453
const page = await chromeBrowser.newPage();
@@ -508,7 +506,7 @@ async function validateInBrowser(
508506
if (hinted) throw hinted;
509507
throw err;
510508
}
511-
await assertWebGpuAdapterAvailable(page, requiresWebGpu);
509+
await assertWebGpuAdapterAvailable(page, requiresWebGpu, softwareWebGpu);
512510
await waitForRuntimeReady(page, opts.timeout ?? 3000);
513511
await new Promise((r) => setTimeout(r, opts.timeout ?? 3000));
514512

‎packages/cli/src/server/studioServer.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ vi.mock("../browser/gpuPolicy.js", () => ({
6262
resolveLocalBrowserGpuMode: () => "software",
6363
compositionRequiresWebGpu: () => false,
6464
assertWebGpuAdapterAvailable: async () => {},
65+
resolveLocalWebGpu: (browserGpuMode: string) => ({
66+
gpuConfig: { browserGpuMode, allowSoftwareWebGpu: false },
67+
softwareWebGpu: false,
68+
}),
6569
}));
6670
vi.mock("../browser/preflight.js", async (importOriginal) => ({
6771
...(await importOriginal<typeof import("../browser/preflight.js")>()),
@@ -135,8 +139,8 @@ describe("Studio thumbnail GPU capture plumbing", () => {
135139
it("uses the shared auto probe, resolved launch mode, requirement guard, and completion-aware seek", () => {
136140
const source = readFileSync(new URL("./studioServer.ts", import.meta.url), "utf8");
137141
expect(source).toContain("resolveCaptureBrowserGpuMode");
138-
expect(source).toContain("{ browserGpuMode: resolvedGpuMode }");
139-
expect(source).toContain("assertWebGpuAdapterAvailable(page, requiresWebGpu)");
142+
expect(source).toContain("resolveLocalWebGpu(resolvedGpuMode, true)");
143+
expect(source).toContain("requiresWebGpu && session.softwareWebGpu");
140144
expect(source).toContain("await seekCompositionTimeline(page, opts.seekTime");
141145
});
142146
});

0 commit comments

Comments
 (0)