mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(engine): disable browser pool for parallel capture workers (#1087)
* fix(engine): disable browser pool for parallel capture in BeginFrame mode BeginFrame's compositor is process-global — when multiple pages in the same Chrome instance drive HeadlessExperimental.beginFrame concurrently, they race the compositor and crash with "Protocol error: Target closed". Only disable the pool when BeginFrame mode would actually be active (Linux + headless-shell + not forceScreenshot). Screenshot mode (macOS/Windows) is unaffected and keeps the pool for memory efficiency. Also extracts the frame capture loop into captureFrameRange to reduce function complexity in executeWorkerTask. * fix(engine): include supersampling in BeginFrame-mode predicate Match the full capture-mode predicate from createCaptureSession: DPR > 1 (supersampling) forces screenshot mode, which is pool-safe. Without this check, supersampled parallel renders on Linux would unnecessarily launch separate browsers.
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { assertSwiftShader } from "../utils/assertSwiftShader.js";
|
||||
import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
|
||||
import { resolveHeadlessShellPath } from "./browserManager.js";
|
||||
|
||||
export interface WorkerTask {
|
||||
workerId: number;
|
||||
@@ -191,6 +192,33 @@ export function shouldVerifyWorkerGpu(workerId: number, config?: Partial<EngineC
|
||||
return config?.browserGpuMode === "software" && workerId === 0;
|
||||
}
|
||||
|
||||
async function captureFrameRange(
|
||||
session: CaptureSession,
|
||||
task: WorkerTask,
|
||||
captureOptions: CaptureOptions,
|
||||
signal: AbortSignal | undefined,
|
||||
onFrameCaptured: ((workerId: number, frameIndex: number) => void) | undefined,
|
||||
onFrameBuffer: ((frameIndex: number, buffer: Buffer) => Promise<void>) | undefined,
|
||||
): Promise<number> {
|
||||
let framesCaptured = 0;
|
||||
const outputOffset = task.outputFrameOffset ?? 0;
|
||||
for (let i = task.startFrame; i < task.endFrame; i++) {
|
||||
if (signal?.aborted) throw new Error("Parallel worker cancelled");
|
||||
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
|
||||
const fileFrameIdx = i - outputOffset;
|
||||
|
||||
if (onFrameBuffer) {
|
||||
const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
|
||||
await onFrameBuffer(i, buffer);
|
||||
} else {
|
||||
await captureFrame(session, fileFrameIdx, time);
|
||||
}
|
||||
framesCaptured++;
|
||||
if (onFrameCaptured) onFrameCaptured(task.workerId, i);
|
||||
}
|
||||
return framesCaptured;
|
||||
}
|
||||
|
||||
async function executeWorkerTask(
|
||||
task: WorkerTask,
|
||||
serverUrl: string,
|
||||
@@ -200,6 +228,7 @@ async function executeWorkerTask(
|
||||
onFrameCaptured?: (workerId: number, frameIndex: number) => void,
|
||||
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
|
||||
config?: Partial<EngineConfig>,
|
||||
parallel?: boolean,
|
||||
): Promise<WorkerResult> {
|
||||
const startTime = Date.now();
|
||||
let framesCaptured = 0;
|
||||
@@ -209,58 +238,43 @@ async function executeWorkerTask(
|
||||
let session: CaptureSession | null = null;
|
||||
let perf: CapturePerfSummary | undefined;
|
||||
|
||||
// BeginFrame's compositor is process-global — multiple pages driving
|
||||
// beginFrame in the same browser race it and crash with "Target closed".
|
||||
// Only disable the pool when BeginFrame mode would actually be active.
|
||||
// Must match the predicate in createCaptureSession (frameCapture.ts):
|
||||
// Linux + headless-shell + !forceScreenshot + !supersampling.
|
||||
const supersampling = (captureOptions.deviceScaleFactor ?? 1) > 1;
|
||||
const needsSeparateBrowsers =
|
||||
parallel &&
|
||||
process.platform === "linux" &&
|
||||
!config?.forceScreenshot &&
|
||||
!supersampling &&
|
||||
resolveHeadlessShellPath(config) !== undefined;
|
||||
const workerConfig: Partial<EngineConfig> | undefined = needsSeparateBrowsers
|
||||
? { ...config, enableBrowserPool: false }
|
||||
: config;
|
||||
|
||||
try {
|
||||
session = await createCaptureSession(
|
||||
serverUrl,
|
||||
task.outputDir,
|
||||
captureOptions,
|
||||
createBeforeCaptureHook(),
|
||||
config,
|
||||
workerConfig,
|
||||
);
|
||||
// Per-worker SwiftShader assertion, gated to worker 0 only.
|
||||
// When `browserGpuMode: "software"` is declared, the chunk's GL backend
|
||||
// must be verified as SwiftShader before the first frame — a host that
|
||||
// falls back to a hardware GL backend (or silently fails to load
|
||||
// SwiftShader) would otherwise produce non-deterministic pixels and
|
||||
// break the distributed byte-identical-retry contract. Running this
|
||||
// probe on every worker means N concurrent navigations to a WebGL
|
||||
// probe page per chunk; with `chunkWorkerCount=6` × 3 chunks, that's
|
||||
// 18 simultaneous CDP page-loads, which inflated c=3 worst-case wall
|
||||
// by ~24s vs c=6/c=8 on the texture-launch bench. Workers in the same
|
||||
// chunk share the same Chrome binary, flags, and OS/driver state, so
|
||||
// worker 0's success is representative — gate it there and skip the
|
||||
// rest. See `heygen-com/hyperframes#955` for the bench data and the
|
||||
// pre-warmup probe interaction (which `renderChunk` already skips
|
||||
// when `chunkWorkerCount > 1`).
|
||||
if (shouldVerifyWorkerGpu(task.workerId, config)) {
|
||||
// Worker-0-only SwiftShader assertion — see `shouldVerifyWorkerGpu` and #955.
|
||||
if (shouldVerifyWorkerGpu(task.workerId, workerConfig)) {
|
||||
await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
|
||||
}
|
||||
await initializeSession(session);
|
||||
|
||||
const outputOffset = task.outputFrameOffset ?? 0;
|
||||
for (let i = task.startFrame; i < task.endFrame; i++) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Parallel worker cancelled");
|
||||
}
|
||||
// captureOptions.fps is an Fps rational; collapse to decimal for the
|
||||
// frame-index → time math. The 1-in-1001 ULP loss for NTSC is invisible
|
||||
// at our scales (frame count tops out at single-digit thousands).
|
||||
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
|
||||
const fileFrameIdx = i - outputOffset;
|
||||
|
||||
if (onFrameBuffer) {
|
||||
// The streaming-encode callback receives the absolute index `i`
|
||||
// (not `fileFrameIdx`) so the encoder sequences frames against the
|
||||
// composition's timeline.
|
||||
const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
|
||||
await onFrameBuffer(i, buffer);
|
||||
} else {
|
||||
await captureFrame(session, fileFrameIdx, time);
|
||||
}
|
||||
framesCaptured++;
|
||||
|
||||
if (onFrameCaptured) onFrameCaptured(task.workerId, i);
|
||||
}
|
||||
framesCaptured = await captureFrameRange(
|
||||
session,
|
||||
task,
|
||||
captureOptions,
|
||||
signal,
|
||||
onFrameCaptured,
|
||||
onFrameBuffer,
|
||||
);
|
||||
|
||||
perf = getCapturePerfSummary(session);
|
||||
return {
|
||||
@@ -318,6 +332,7 @@ export async function executeParallelCapture(
|
||||
}
|
||||
};
|
||||
|
||||
const parallel = tasks.length > 1;
|
||||
const results = await Promise.all(
|
||||
tasks.map((task) =>
|
||||
executeWorkerTask(
|
||||
@@ -329,6 +344,7 @@ export async function executeParallelCapture(
|
||||
onFrameCaptured,
|
||||
onFrameBuffer,
|
||||
config,
|
||||
parallel,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user