Skip to content
Open
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
71 changes: 71 additions & 0 deletions packages/cli/src/browser/gpuPolicy.colorGradingStall.test.ts
Original file line number Diff line number Diff line change
@@ -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 =
'<div data-composition-id="main"><img data-color-grading=\'{"adjust":{"saturation":-1}}\' src="a.jpg" /></div>';
const UNGRADED_HTML = '<div data-composition-id="main"><img src="a.jpg" /></div>';

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();
});
});
12 changes: 12 additions & 0 deletions packages/cli/src/browser/gpuPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
assertWebGpuRequirement,
compositionRequiresWebGpu,
compositionUsesColorGrading,
resolveLocalBrowserGpuMode,
} from "./gpuPolicy.js";

Expand Down Expand Up @@ -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(
'<div data-composition-id="main"><img data-color-grading=\'{"adjust":{"saturation":-1}}\' src="a.jpg" /></div>',
),
).toBe(true);
expect(
compositionUsesColorGrading('<div data-composition-id="main"><img src="a.jpg" /></div>'),
).toBe(false);
});
});
49 changes: 49 additions & 0 deletions packages/cli/src/browser/gpuPolicy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core";

export type BrowserGpuMode = "auto" | "hardware" | "software";
export type ResolvedBrowserGpuMode = Exclude<BrowserGpuMode, "auto">;

Expand Down Expand Up @@ -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<string | null> {
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;
}
}
19 changes: 18 additions & 1 deletion packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -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;
Expand Down
Loading