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
2 changes: 2 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export {
BROWSER_GPU_NOT_SOFTWARE,
} from "./utils/assertSwiftShader.js";

export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";

export {
extractMediaMetadata,
extractVideoMetadata,
Expand Down
15 changes: 15 additions & 0 deletions packages/engine/src/services/parallelCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
type BeforeCaptureHook,
} from "./frameCapture.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { assertSwiftShader } from "../utils/assertSwiftShader.js";
import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";

export interface WorkerTask {
workerId: number;
Expand Down Expand Up @@ -205,6 +207,19 @@ async function executeWorkerTask(
createBeforeCaptureHook(),
config,
);
// Per-worker SwiftShader assertion: when the caller declares
// `browserGpuMode: "software"`, every worker session must verify Chrome's
// WebGL backend is actually SwiftShader before the first frame. Hosts
// that fall back to a hardware GL backend (or silently fail to load
// SwiftShader) would otherwise produce non-deterministic pixels and
// break the distributed byte-identical-retry contract — the parallel
// branch wouldn't catch it via the pre-warmup probe (renderChunk now
// skips that when chunkWorkerCount > 1). The canvas-based reader works
// on both regular Chrome and chrome-headless-shell (which serves
// `chrome://gpu` as an empty document).
if (config?.browserGpuMode === "software") {
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
}
await initializeSession(session);

const outputOffset = task.outputFrameOffset ?? 0;
Expand Down
52 changes: 52 additions & 0 deletions packages/engine/src/utils/readWebGlVendorInfoFromCanvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Read SwiftShader vendor/renderer via a 1×1 WebGL canvas + the
* `WEBGL_debug_renderer_info` extension. Used as the `readInfo` override
* for {@link assertSwiftShader} when the worker is running on
* `chrome-headless-shell` — that build serves `chrome://gpu` as an empty
* document so the default `chrome://gpu`-based info reader trips
* `net::ERR_FAILED` even when the GL backend is in fact SwiftShader.
*
* The canvas-based probe runs against whatever page the caller hands in
* (we use a fresh `about:blank` so it doesn't depend on the composition
* URL being navigated yet). The renderer string returned matches the
* format `assertSwiftShader` expects (substring match against
* `"swiftshader"`).
*/

import type { Page } from "puppeteer-core";

export async function readWebGlVendorInfoFromCanvas(
page: Page,
): Promise<{ vendor: string; renderer: string }> {
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 30_000 });
return page.evaluate((): { vendor: string; renderer: string } => {
try {
const canvas = document.createElement("canvas");
const gl =
(canvas.getContext("webgl") as WebGLRenderingContext | null) ??
(canvas.getContext("experimental-webgl") as WebGLRenderingContext | null);
if (!gl) {
return { vendor: "", renderer: "" };
}
const ext = gl.getExtension("WEBGL_debug_renderer_info") as {
UNMASKED_VENDOR_WEBGL: number;
UNMASKED_RENDERER_WEBGL: number;
} | null;
if (!ext) {
return {
vendor: String(gl.getParameter(gl.VENDOR) ?? ""),
renderer: String(gl.getParameter(gl.RENDERER) ?? ""),
};
}
// Older Chrome builds expose the unmasked strings under the literal
// numeric constants 0x9245 / 0x9246. The extension surface above is
// identical across builds — read through it.
return {
vendor: String(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) ?? ""),
renderer: String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) ?? ""),
};
} catch {
return { vendor: "", renderer: "" };
}
});
}
122 changes: 45 additions & 77 deletions packages/producer/src/services/distributed/renderChunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { extname, join } from "node:path";
import type { Page } from "puppeteer-core";
import {
assertSwiftShader,
type BeforeCaptureHook,
Expand All @@ -53,6 +52,7 @@ import {
type ExtractedFrames,
getEncoderPreset,
initializeSession,
readWebGlVendorInfoFromCanvas,
resolveConfig,
} from "@hyperframes/engine";
import { defaultLogger } from "../../logger.js";
Expand Down Expand Up @@ -215,55 +215,11 @@ interface PlanJson {
*/
export { applyRuntimeEnvSnapshot } from "../render/runtimeEnvSnapshot.js";

/**
* Read SwiftShader vendor/renderer via a 1×1 WebGL canvas + the
* `WEBGL_debug_renderer_info` extension. Used as the `readInfo` override
* for {@link assertSwiftShader} when the worker is running on
* `chrome-headless-shell` — that build serves `chrome://gpu` as an empty
* document so the default `chrome://gpu`-based info reader trips
* `net::ERR_FAILED` even when the GL backend is in fact SwiftShader.
*
* The canvas-based probe runs against whatever page the caller hands in
* (we use a fresh `about:blank` so it doesn't depend on the composition
* URL being navigated yet). The renderer string returned matches the
* format `assertSwiftShader` expects (substring match against
* `"swiftshader"`).
*/
export async function readWebGlVendorInfoFromCanvas(
page: Page,
): Promise<{ vendor: string; renderer: string }> {
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 30_000 });
return page.evaluate((): { vendor: string; renderer: string } => {
try {
const canvas = document.createElement("canvas");
const gl =
(canvas.getContext("webgl") as WebGLRenderingContext | null) ??
(canvas.getContext("experimental-webgl") as WebGLRenderingContext | null);
if (!gl) {
return { vendor: "", renderer: "" };
}
const ext = gl.getExtension("WEBGL_debug_renderer_info") as {
UNMASKED_VENDOR_WEBGL: number;
UNMASKED_RENDERER_WEBGL: number;
} | null;
if (!ext) {
return {
vendor: String(gl.getParameter(gl.VENDOR) ?? ""),
renderer: String(gl.getParameter(gl.RENDERER) ?? ""),
};
}
// Older Chrome builds expose the unmasked strings under the literal
// numeric constants 0x9245 / 0x9246. The extension surface above is
// identical across builds — read through it.
return {
vendor: String(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) ?? ""),
renderer: String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) ?? ""),
};
} catch {
return { vendor: "", renderer: "" };
}
});
}
// `readWebGlVendorInfoFromCanvas` lives in `@hyperframes/engine` (it's
// used both here and by `parallelCoordinator.executeWorkerTask`). Re-exported
// from this subpath so downstream consumers that already import it from
// `@hyperframes/producer/distributed` keep working.
export { readWebGlVendorInfoFromCanvas } from "@hyperframes/engine";

/**
* Compute a deterministic SHA-256 fingerprint for the chunk's output.
Expand Down Expand Up @@ -510,35 +466,50 @@ export async function renderChunk(
lockWarmupTicks: true,
};

// Resolve worker count up-front so we can decide whether to bother
// pre-warming a probe session at all. The parallel branch
// (chunkWorkerCount > 1) closes the probe immediately and creates fresh
// per-worker sessions; `executeWorkerTask` now runs its own
// `assertSwiftShader` against each worker session (gated on
// `cfg.browserGpuMode === "software"`), so the safety contract holds
// without the eager pre-probe.
//
// Capture-cost calibration based on shader transitions / renderModeHints
// is not threaded through to chunks yet; the in-process renderer's
// `resolveRenderWorkerCount` wraps this with that reduction, but
// `PlanJson` doesn't carry the compiled hints needed to call it
// directly. The existing adaptive-retry path reduces workers if
// compositor contention surfaces as CDP timeouts.
const chunkWorkerCount = calculateOptimalWorkers(framesInChunk, undefined, cfg);

// ── Browser + warmup ──
let session: CaptureSession | null = null;
let outputKind: "file" | "frame-dir";
let framesEncoded = 0;
try {
session = await createCaptureSession(fileServer.url, framesDir, captureOptions, null, cfg);
// SwiftShader assertion runs BEFORE initializeSession (which navigates to
// the composition); on failure we tear down without ever touching the
// composition URL. We pass `readWebGlVendorInfoFromCanvas` rather than
// letting `assertSwiftShader` use its default `chrome://gpu` reader —
// `chrome-headless-shell` serves chrome:// pages as empty documents,
// which would trip a false-negative even when the GL backend is in fact
// SwiftShader. The canvas + WEBGL_debug_renderer_info probe works on
// any page (we navigate to about:blank inside the helper).
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
await initializeSession(session);

// `discardWarmupCapture` is intentionally NOT called: every frame
// seeks fresh DOM, so `lastFrameCache` is never read; priming it
// would deadlock Chrome's compositor by issuing a second beginFrame
// at a `frameTimeTicks` it had just advanced to.

// Capture-cost calibration based on shader transitions /
// renderModeHints is not threaded through to chunks yet; the in-process
// renderer's `resolveRenderWorkerCount` wraps this with that reduction,
// but `PlanJson` doesn't carry the compiled hints needed to call it
// directly. The existing adaptive-retry path reduces workers if
// compositor contention surfaces as CDP timeouts.
const chunkWorkerCount = calculateOptimalWorkers(framesInChunk, undefined, cfg);
if (chunkWorkerCount === 1) {
// Sequential branch reuses the probe session for the actual capture.
// SwiftShader assertion runs BEFORE initializeSession (which
// navigates to the composition); on failure we tear down without
// ever touching the composition URL. We pass
// `readWebGlVendorInfoFromCanvas` rather than letting
// `assertSwiftShader` use its default `chrome://gpu` reader —
// `chrome-headless-shell` serves chrome:// pages as empty documents,
// which would trip a false-negative even when the GL backend is in
// fact SwiftShader. The canvas + WEBGL_debug_renderer_info probe
// works on any page (we navigate to about:blank inside the helper).
session = await createCaptureSession(fileServer.url, framesDir, captureOptions, null, cfg);
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
await initializeSession(session);
// `discardWarmupCapture` is intentionally NOT called: every frame
// seeks fresh DOM, so `lastFrameCache` is never read; priming it
// would deadlock Chrome's compositor by issuing a second beginFrame
// at a `frameTimeTicks` it had just advanced to.
}
// chunkWorkerCount > 1: skip the probe entirely. Each parallel worker
// creates its own session and runs `assertSwiftShader` before its
// first frame.

await runCaptureStage({
fileServer,
workDir,
Expand All @@ -549,9 +520,6 @@ export async function renderChunk(
forceScreenshot: encoder.forceScreenshot,
log,
workerCount: chunkWorkerCount,
// The parallel branch closes this session and spins up its own
// worker sessions, wasting the ~3-5s of pre-warmed setup. Worth a
// follow-up to skip pre-warmup when the resolved workerCount > 1.
probeSession: session,
needsAlpha: plan.dimensions.format !== "mp4",
captureAttempts: [],
Expand Down
Loading