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;