From 4eedc0ac4191eda9528a4e9085187ea38250cb57 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Fri, 11 Sep 2026 18:55:29 +0000 Subject: [PATCH] fix(cli): warn preflight when color grading meets no hardware GPU check --browser-gpu on a composition using data-color-grading can blow past the navigation timeout when the browser silently falls back to SwiftShader: the grading canvas's per-frame GPU readback has no fast path under software WebGL (~40x slower, measured), a known SwiftShader limitation rather than a hyperframes bug (see vault finding color-grading-canvas-readpixels-gpu-stall-under-swiftshader.md). Detect the composition/GPU combination before opening the browser and print an actionable warning (larger --timeout, a real-GPU host, or the monochrome-preprocessing workaround) so a stalled or timed-out check isn't a silent mystery. Also recorded as a "warning" runtime finding when the run succeeds anyway, so check --strict and --json surface it structurally too. PRINFRA-685 Co-Authored-By: Claude Sonnet 5 --- .../gpuPolicy.colorGradingStall.test.ts | 71 +++++++++++++++++++ packages/cli/src/browser/gpuPolicy.test.ts | 12 ++++ packages/cli/src/browser/gpuPolicy.ts | 49 +++++++++++++ packages/cli/src/utils/checkBrowser.ts | 19 ++++- 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/browser/gpuPolicy.colorGradingStall.test.ts diff --git a/packages/cli/src/browser/gpuPolicy.colorGradingStall.test.ts b/packages/cli/src/browser/gpuPolicy.colorGradingStall.test.ts new file mode 100644 index 0000000000..a95fa75c38 --- /dev/null +++ b/packages/cli/src/browser/gpuPolicy.colorGradingStall.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ensureBrowser: vi.fn(), + resolveBrowserGpuMode: vi.fn(async (): Promise<"hardware" | "software"> => "hardware"), +})); + +vi.mock("./manager.js", () => ({ + ensureBrowser: mocks.ensureBrowser, +})); + +vi.mock("@hyperframes/engine", () => ({ + resolveBrowserGpuMode: mocks.resolveBrowserGpuMode, +})); + +import { detectColorGradingGpuStallRisk } from "./gpuPolicy.js"; + +const GRADED_HTML = + '
'; +const UNGRADED_HTML = '
'; + +describe("detectColorGradingGpuStallRisk", () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.ensureBrowser.mockResolvedValue({ executablePath: "/chrome", source: "cache" }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("warns when the composition uses color grading and no hardware GPU is found", async () => { + mocks.resolveBrowserGpuMode.mockResolvedValue("software"); + const warning = await detectColorGradingGpuStallRisk(GRADED_HTML, "auto"); + expect(warning).toContain("data-color-grading"); + expect(warning).toContain("SwiftShader"); + expect(warning).toContain("--timeout"); + }); + + it("stays silent when a real hardware GPU is found", async () => { + mocks.resolveBrowserGpuMode.mockResolvedValue("hardware"); + const warning = await detectColorGradingGpuStallRisk(GRADED_HTML, "auto"); + expect(warning).toBeNull(); + }); + + it("stays silent (and never probes) when the composition has no color grading", async () => { + const warning = await detectColorGradingGpuStallRisk(UNGRADED_HTML, "auto"); + expect(warning).toBeNull(); + expect(mocks.ensureBrowser).not.toHaveBeenCalled(); + expect(mocks.resolveBrowserGpuMode).not.toHaveBeenCalled(); + }); + + it("stays silent (and never probes) when software mode was explicitly requested", async () => { + const warning = await detectColorGradingGpuStallRisk(GRADED_HTML, "software"); + expect(warning).toBeNull(); + expect(mocks.ensureBrowser).not.toHaveBeenCalled(); + expect(mocks.resolveBrowserGpuMode).not.toHaveBeenCalled(); + }); + + it("forces an 'auto' probe even for an explicit --browser-gpu request, to get the ground truth", async () => { + mocks.resolveBrowserGpuMode.mockResolvedValue("software"); + await detectColorGradingGpuStallRisk(GRADED_HTML, "hardware"); + expect(mocks.resolveBrowserGpuMode).toHaveBeenCalledWith("auto", { chromePath: "/chrome" }); + }); + + it("treats a probe failure as nothing to warn about", async () => { + mocks.resolveBrowserGpuMode.mockRejectedValue(new Error("probe boom")); + const warning = await detectColorGradingGpuStallRisk(GRADED_HTML, "auto"); + expect(warning).toBeNull(); + }); +}); diff --git a/packages/cli/src/browser/gpuPolicy.test.ts b/packages/cli/src/browser/gpuPolicy.test.ts index 7ea3766721..e8ef9244bf 100644 --- a/packages/cli/src/browser/gpuPolicy.test.ts +++ b/packages/cli/src/browser/gpuPolicy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { assertWebGpuRequirement, compositionRequiresWebGpu, + compositionUsesColorGrading, resolveLocalBrowserGpuMode, } from "./gpuPolicy.js"; @@ -31,4 +32,15 @@ describe("local browser GPU policy", () => { expect(() => assertWebGpuRequirement(html, "hardware", "hardware")).not.toThrow(); expect(() => assertWebGpuRequirement(html, "software", "software")).not.toThrow(); }); + + it("detects data-color-grading on any element, not just the composition root", () => { + expect( + compositionUsesColorGrading( + '
', + ), + ).toBe(true); + expect( + compositionUsesColorGrading('
'), + ).toBe(false); + }); }); diff --git a/packages/cli/src/browser/gpuPolicy.ts b/packages/cli/src/browser/gpuPolicy.ts index 0c809e3715..0a42b16127 100644 --- a/packages/cli/src/browser/gpuPolicy.ts +++ b/packages/cli/src/browser/gpuPolicy.ts @@ -1,3 +1,5 @@ +import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core"; + export type BrowserGpuMode = "auto" | "hardware" | "software"; export type ResolvedBrowserGpuMode = Exclude; @@ -41,3 +43,50 @@ export function assertWebGpuRequirement( "use --no-browser-gpu only when intentionally testing the composition's software fallback.", ); } + +export function compositionUsesColorGrading(html: string): boolean { + const escapedAttr = HF_COLOR_GRADING_ATTR.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); + return new RegExp(`\\s${escapedAttr}(?:\\s|=|>)`, "i").test(html); +} + +const COLOR_GRADING_GPU_STALL_WARNING = + `This composition uses ${HF_COLOR_GRADING_ATTR}, but no hardware GPU was detected — ` + + "the browser will render on the SwiftShader/software WebGL fallback. Color grading's " + + "per-element canvas readback has no fast path under software WebGL: it has been measured " + + "at roughly 40x slower than an ungraded composition, which is easily enough to exceed the " + + "navigation/render-ready timeout, or to make check/render painfully slow even once past it. " + + "If this run is unexpectedly slow or times out, try a much larger --timeout, run on a host " + + "with a real GPU, or preprocess to monochrome derivatives with grading intensity 0 before " + + "capturing with --browser-gpu to skip the expensive per-frame grading pass entirely."; + +/** + * Preflight for a known SwiftShader limitation (not a hyperframes bug): a + * per-element color-grading canvas pays a synchronous GPU-stall readback cost + * that software WebGL has no fast path for, ~40x slower than an ungraded + * composition in measured practice. `requestedMode: "software"` is a + * deliberate, already-informed choice and is not warned about; `"auto"` / + * `"hardware"` both expect speed, so a silent fallback to software there is + * exactly the surprise this call is meant to catch before capture starts. + * Reuses `resolveCaptureBrowserGpuMode`'s cached probe (forcing `"auto"` to + * get the ground-truth answer even when the caller requested `"hardware"`, + * which always reports back `"hardware"` verbatim) — resolved against the + * same `ensureBrowser()` executable path a subsequent real launch will use, + * so this doesn't seed the shared cache with a different browser's probe. + * Best-effort: any probe failure here is treated as "nothing to warn about" + * rather than failing the caller — the real launch will surface a genuine + * browser problem on its own. + */ +export async function detectColorGradingGpuStallRisk( + html: string, + requestedMode: BrowserGpuMode, +): Promise { + if (requestedMode === "software" || !compositionUsesColorGrading(html)) return null; + try { + const { ensureBrowser } = await import("./manager.js"); + const browser = await ensureBrowser(); + const actualMode = await resolveCaptureBrowserGpuMode("auto", browser.executablePath); + return actualMode === "software" ? COLOR_GRADING_GPU_STALL_WARNING : null; + } catch { + return null; + } +} diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index f5c00ec52a..e61d07e8a0 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -18,6 +18,7 @@ import { shouldIgnoreHttpError, shouldIgnoreRequestFailure, } from "../commands/validate.js"; +import { detectColorGradingGpuStallRisk } from "../browser/gpuPolicy.js"; import { loadBrowserScript } from "../commands/layout.js"; import { normalizeErrorMessage } from "./errorMessage.js"; import { ambiguousIssue, type MotionFrame } from "./motionAudit.js"; @@ -156,6 +157,14 @@ export async function runBrowserCheck( const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js"); const html = await bundleWithLocalizedFonts(project.dir); await preResolveHostileMediaProxies(project.dir, html, options.autoProxy); + const requestedGpuMode = options.browserGpuMode ?? resolveCliChromeGpuMode(); + // Printed eagerly (not just recorded as a finding) because the risk this + // flags is a navigation timeout — if it fires, `runBrowserCheck` throws + // before ever returning a report, so a finding pushed to `drafts` would be + // discarded along with the whole in-flight result (see runCheckPipeline's + // catch, which replaces browser with emptyBrowserResult() on that path). + const colorGradingGpuWarning = await detectColorGradingGpuStallRisk(html, requestedGpuMode); + if (colorGradingGpuWarning) console.warn(`\n[hyperframes] ${colorGradingGpuWarning}`); const server = await serveStaticProjectHtml( project.dir, html, @@ -164,6 +173,14 @@ export async function runBrowserCheck( options.autoProxy, ); const drafts: RuntimeDraft[] = []; + if (colorGradingGpuWarning) { + drafts.push({ + code: "color_grading_gpu_stall_risk", + severity: "warning", + message: colorGradingGpuWarning, + time: 0, + }); + } let currentTime = 0; let chromeBrowser: import("puppeteer-core").Browser | undefined; @@ -173,7 +190,7 @@ export async function runBrowserCheck( navigationTimeoutMs: options.timeout, renderReadyTimeoutMs: options.timeout, renderReadyWarningSuffix: "checking the current page state", - browserGpuMode: options.browserGpuMode ?? resolveCliChromeGpuMode(), + browserGpuMode: requestedGpuMode, beforeNavigate: (page) => wireRuntimeListeners(page, drafts, () => currentTime), }); chromeBrowser = session.browser;