Merge pull request #2359 from heygen-com/via/issue-3-software-gpu-screenshot

fix(engine): software-GPU browsers imply screenshot capture
This commit is contained in:
Vance Ingalls
2026-07-13 20:37:12 -07:00
committed by GitHub
5 changed files with 385 additions and 7 deletions
+223 -1
View File
@@ -1,7 +1,14 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolveConfig, DEFAULT_CONFIG, scaleProtocolTimeoutForComposition } from "./config.js";
import {
resolveConfig,
DEFAULT_CONFIG,
scaleProtocolTimeoutForComposition,
shouldClampToScreenshotForConcreteGpu,
applyConcreteGpuScreenshotClamp,
} from "./config.js";
import type { EngineConfig } from "./config.js";
import { isLowMemorySystem } from "./services/systemMemory.js";
describe("resolveConfig", () => {
@@ -296,6 +303,221 @@ describe("resolveConfig", () => {
});
});
describe("forceScreenshot (software-GPU clamp)", () => {
it("forces screenshot capture when browserGpuMode resolves to software", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
unsetEnv("PRODUCER_FORCE_SCREENSHOT");
const config = resolveConfig();
expect(config.forceScreenshot).toBe(true);
});
it("leaves forceScreenshot alone on hardware GPU (default off)", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
unsetEnv("PRODUCER_FORCE_SCREENSHOT");
const config = resolveConfig();
expect(config.forceScreenshot).toBe(false);
});
it("does not force screenshot on auto (auto probes to hardware on real GPUs)", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "auto");
unsetEnv("PRODUCER_FORCE_SCREENSHOT");
const config = resolveConfig();
expect(config.forceScreenshot).toBe(false);
});
it("explicit env opt-out (PRODUCER_FORCE_SCREENSHOT=false) is honored on software", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
setEnv("PRODUCER_FORCE_SCREENSHOT", "false");
const config = resolveConfig();
expect(config.forceScreenshot).toBe(false);
});
it("explicit programmatic opt-out is honored on software", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
unsetEnv("PRODUCER_FORCE_SCREENSHOT");
const config = resolveConfig({ forceScreenshot: false });
expect(config.forceScreenshot).toBe(false);
});
it("caller override forceScreenshot=true stays true regardless of GPU mode", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
const config = resolveConfig({ forceScreenshot: true });
expect(config.forceScreenshot).toBe(true);
});
it("documents the auto-branch gap: resolveConfig leaves auto→software as forceScreenshot=false", () => {
// resolveConfig's clamp keys on the string `browserGpuMode`; `"auto"`
// that runtime-probes to software is invisible to this layer. The
// runtime companion `shouldClampToScreenshotForConcreteGpu` (below)
// closes the gap at the frameCapture + renderOrchestrator sites.
setEnv("PRODUCER_BROWSER_GPU_MODE", "auto");
unsetEnv("PRODUCER_FORCE_SCREENSHOT");
const config = resolveConfig();
expect(config.browserGpuMode).toBe("auto");
expect(config.forceScreenshot).toBe(false);
});
});
describe("shouldClampToScreenshotForConcreteGpu (runtime companion for auto→software)", () => {
it("returns true when resolved GPU is software AND forceScreenshot is currently false", () => {
// Env explicitly cleared so PRODUCER_FORCE_SCREENSHOT="false" opt-out
// doesn't fire.
expect(
shouldClampToScreenshotForConcreteGpu("software", false, {} as NodeJS.ProcessEnv),
).toBe(true);
});
it("returns false when resolved GPU is hardware (no clamp needed)", () => {
expect(
shouldClampToScreenshotForConcreteGpu("hardware", false, {} as NodeJS.ProcessEnv),
).toBe(false);
});
it("returns false when forceScreenshot is already true (invariant already satisfied)", () => {
expect(shouldClampToScreenshotForConcreteGpu("software", true, {} as NodeJS.ProcessEnv)).toBe(
false,
);
});
it("honors PRODUCER_FORCE_SCREENSHOT=false env opt-out on software", () => {
// BeginFrame-on-software debugging escape hatch.
expect(
shouldClampToScreenshotForConcreteGpu("software", false, {
PRODUCER_FORCE_SCREENSHOT: "false",
} as NodeJS.ProcessEnv),
).toBe(false);
});
it("does NOT treat other PRODUCER_FORCE_SCREENSHOT values as opt-out", () => {
// Only literal "false" opts out; "true", "0", missing, anything else clamps.
for (const value of [undefined, "true", "1", "0", "no", ""]) {
const env = (
value === undefined ? {} : { PRODUCER_FORCE_SCREENSHOT: value }
) as NodeJS.ProcessEnv;
expect(shouldClampToScreenshotForConcreteGpu("software", false, env)).toBe(true);
}
});
it("honors the programmatic opt-out via opts.programmaticOptOut on software", () => {
// The auto→software probe path is what this really guards: `resolveConfig`
// sets `forceScreenshotExplicitlyOptedOut = true` when the caller passed
// `overrides.forceScreenshot === false`, and the helper reads it here so
// the concrete-resolution route matches the config-time behavior.
expect(
shouldClampToScreenshotForConcreteGpu("software", false, {} as NodeJS.ProcessEnv, {
programmaticOptOut: true,
}),
).toBe(false);
});
it("programmatic opt-out beats a missing env opt-out (both escape hatches independent)", () => {
// Even with no env opt-out set, a programmatic opt-out preserves BeginFrame-
// on-software debugging on the auto→software probe path.
expect(
shouldClampToScreenshotForConcreteGpu(
"software",
false,
{ PRODUCER_FORCE_SCREENSHOT: "true" } as NodeJS.ProcessEnv,
{ programmaticOptOut: true },
),
).toBe(false);
});
});
describe("applyConcreteGpuScreenshotClamp (caller-level contract)", () => {
// This is the helper both frameCapture.ts and renderOrchestrator.ts call
// to compute the value the AUTHORITATIVE `forceScreenshot` local should
// hold after the concrete GPU is resolved. Routing AND telemetry read
// from that one value, so this contract must hold across default and
// opt-out combinations.
type OptOutCfg = Pick<EngineConfig, "forceScreenshotExplicitlyOptedOut">;
const cleanEnv = {} as NodeJS.ProcessEnv;
it("resolved software + default false → promotes to true (screenshot route)", () => {
// The core auto→software fix: routing AND downstream telemetry read
// the promoted value, so `updateCaptureObservability({ forceScreenshot:
// captureForceScreenshot })` at the capture_strategy site reports
// screenshot instead of overwriting back to beginframe.
expect(applyConcreteGpuScreenshotClamp(false, "software", {} as OptOutCfg, cleanEnv)).toBe(
true,
);
});
it("resolved software + programmatic opt-out → stays false (BeginFrame preserved)", () => {
// The programmatic escape hatch caller-level contract: setting
// overrides.forceScreenshot=false must keep BeginFrame across BOTH
// routing (frameCapture) and telemetry (renderOrchestrator) — since
// resolveConfig lifts the flag onto the config, both callers converge.
expect(
applyConcreteGpuScreenshotClamp(
false,
"software",
{ forceScreenshotExplicitlyOptedOut: true } as OptOutCfg,
cleanEnv,
),
).toBe(false);
});
it("resolved hardware + default false → stays false (no clamp needed)", () => {
expect(applyConcreteGpuScreenshotClamp(false, "hardware", {} as OptOutCfg, cleanEnv)).toBe(
false,
);
});
it("resolved software + already-true forceScreenshot → stays true (idempotent)", () => {
// Config-time clamp already fired (literal browserGpuMode:"software"),
// so re-applying at the concrete-resolved site is a no-op.
expect(applyConcreteGpuScreenshotClamp(true, "software", {} as OptOutCfg, cleanEnv)).toBe(
true,
);
});
it("resolved software + env PRODUCER_FORCE_SCREENSHOT=false → stays false", () => {
// Env opt-out preserved even when programmatic flag is not set (some
// callers, like debugging BeginFrame-on-software from CI, opt-out via
// env only).
expect(
applyConcreteGpuScreenshotClamp(
false,
"software",
{} as OptOutCfg,
{
PRODUCER_FORCE_SCREENSHOT: "false",
} as NodeJS.ProcessEnv,
),
).toBe(false);
});
it("resolved software + undefined cfg → default (no programmatic opt-out) → clamps to true", () => {
// Sanity: frameCapture.ts calls with `config` possibly undefined.
// Default case must still promote.
expect(applyConcreteGpuScreenshotClamp(false, "software", undefined, cleanEnv)).toBe(true);
});
});
describe("forceScreenshotExplicitlyOptedOut provenance", () => {
it("is set to true when programmatic override forceScreenshot=false is passed", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
unsetEnv("PRODUCER_FORCE_SCREENSHOT");
const config = resolveConfig({ forceScreenshot: false });
expect(config.forceScreenshotExplicitlyOptedOut).toBe(true);
});
it("is set to true when env PRODUCER_FORCE_SCREENSHOT=false is set", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
setEnv("PRODUCER_FORCE_SCREENSHOT", "false");
const config = resolveConfig();
expect(config.forceScreenshotExplicitlyOptedOut).toBe(true);
});
it("stays unset when neither opt-out is present (default)", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
unsetEnv("PRODUCER_FORCE_SCREENSHOT");
const config = resolveConfig();
expect(config.forceScreenshotExplicitlyOptedOut).toBeUndefined();
});
});
describe("lowMemoryMode", () => {
it("forces on for truthy PRODUCER_LOW_MEMORY_MODE values", () => {
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
+114
View File
@@ -88,6 +88,19 @@ export interface EngineConfig {
* opt-out. Not intended to be set by callers.
*/
pageSideCompositingAutoDisabled?: boolean;
/**
* INTERNAL. Set to `true` by `resolveConfig` when the caller explicitly
* opted out of the software-GPU→screenshot clamp — either via env
* `PRODUCER_FORCE_SCREENSHOT=false` or programmatic
* `overrides.forceScreenshot === false`. The concrete-resolved-GPU helper
* (`shouldClampToScreenshotForConcreteGpu`) reads this so the
* `browserGpuMode:"auto"` → software probe path preserves the same
* escape hatch as literal `browserGpuMode:"software"` (the boolean
* `forceScreenshot === false` at that point is otherwise ambiguous —
* default vs explicit opt-out — because the config resolves before
* the runtime probe fires). Not intended to be set by callers.
*/
forceScreenshotExplicitlyOptedOut?: boolean;
/**
* Low-memory render profile. When `true`, the orchestrator collapses the
* pipeline to its cheapest shape on memory-constrained hosts: it skips the
@@ -554,6 +567,47 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
merged.useDrawElement = false;
}
// Software GPU implies screenshot capture.
//
// Two existing platform gates already do most of the work: `browserManager`
// only launches BeginFrame on Linux + chrome-headless-shell + !forceScreenshot,
// and the DE clamp above turns off `useDrawElement` on non-(darwin +
// non-software) hosts. Setting `forceScreenshot` here layers defense-in-depth
// on top:
//
// 1. Linux + software (SwiftShader host): kicks the browser off BeginFrame,
// which stalls the compositor on shader-heavy frames under CPU raster
// (same motivation as the closed PR #822).
// 2. Observability truth: `renderOrchestrator`'s reported `captureMode`
// field is derived from `cfg.forceScreenshot ? "screenshot" : "beginframe"`
// — without this clamp it misreports `"beginframe"` for the actual
// screenshot capture on darwin + software.
// 3. Future-proofing: any new BeginFrame or drawElement entry point that
// forgets to gate on GPU mode still routes to screenshot here.
//
// Note this does NOT eliminate SwiftShader-on-darwin text-rasterization
// artifacts (an ANGLE-SwiftShader issue on macOS text — the fix there is to
// use `--browser-gpu`, which routes to `--use-angle=metal`). It only makes
// routing consistent + observability accurate.
//
// Explicit opt-out (env or programmatic override) is honored so BeginFrame-
// on-software debugging remains possible.
const explicitForceScreenshotOptOut =
env("PRODUCER_FORCE_SCREENSHOT") === "false" || overrides?.forceScreenshot === false;
// Persist provenance so the concrete-resolved-GPU helper can honor the
// programmatic opt-out too — at that point `forceScreenshot === false` is
// otherwise ambiguous between default and explicit opt-out.
if (explicitForceScreenshotOptOut) {
merged.forceScreenshotExplicitlyOptedOut = true;
}
if (
merged.browserGpuMode === "software" &&
!merged.forceScreenshot &&
!explicitForceScreenshotOptOut
) {
merged.forceScreenshot = true;
}
// drawElement capture and page-side shader compositing are mutually
// incompatible capture strategies (drawElement reads paint records directly
// and bypasses the page-side prepare→composite→resolve protocol). When
@@ -575,3 +629,63 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
vp9CpuUsed: normalizeVp9CpuUsed(merged.vp9CpuUsed),
};
}
/**
* Runtime-resolved companion to the software-GPU screenshot clamp in
* `resolveConfig`. Returns `true` iff callers should treat this render as
* `forceScreenshot=true` even though the config's stored `forceScreenshot`
* is `false`. Fires when the concrete resolved GPU is software AND neither
* the env opt-out (`PRODUCER_FORCE_SCREENSHOT=false`) nor the programmatic
* opt-out (`overrides.forceScreenshot === false`, carried via
* `cfg.forceScreenshotExplicitlyOptedOut`) is set.
*
* `resolveConfig`'s clamp only sees `browserGpuMode` as a string, so
* `"auto"` that runtime-probes to software slips through. This helper
* closes that gap at the concrete-resolution points (`frameCapture` and
* `renderOrchestrator`). Same invariant, same escape hatches, one predicate.
*
* Callers should skip when the invariant is already satisfied
* (`currentForceScreenshot === true`) to avoid redundant work. Pass
* `cfg.forceScreenshotExplicitlyOptedOut` via `opts.programmaticOptOut` so
* the `browserGpuMode:"auto"` → software probe path honors the same
* programmatic escape hatch as literal `browserGpuMode:"software"`.
*/
export function shouldClampToScreenshotForConcreteGpu(
resolvedGpuMode: "software" | "hardware",
currentForceScreenshot: boolean,
env: NodeJS.ProcessEnv = process.env,
opts: { programmaticOptOut?: boolean } = {},
): boolean {
if (currentForceScreenshot) return false;
if (resolvedGpuMode !== "software") return false;
if (opts.programmaticOptOut) return false;
return env["PRODUCER_FORCE_SCREENSHOT"] !== "false";
}
/**
* Caller-facing pair to `shouldClampToScreenshotForConcreteGpu`: computes the
* value the *authoritative* `forceScreenshot` local should hold after the
* concrete-resolved-GPU decision fires. Returns the (possibly-promoted) new
* boolean, so the caller can assign it back to its local — driving both
* routing AND telemetry from one source of truth.
*
* Reads the programmatic opt-out from `cfg.forceScreenshotExplicitlyOptedOut`
* (set by `resolveConfig` when EITHER env `PRODUCER_FORCE_SCREENSHOT=false`
* OR programmatic `overrides.forceScreenshot === false` was present).
*
* Idempotent: `applyConcreteGpuScreenshotClamp(true, ...)` returns `true`
* without consulting anything else.
*/
export function applyConcreteGpuScreenshotClamp(
currentForceScreenshot: boolean,
resolvedGpuMode: "software" | "hardware",
cfg: Pick<EngineConfig, "forceScreenshotExplicitlyOptedOut"> | undefined,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return (
currentForceScreenshot ||
shouldClampToScreenshotForConcreteGpu(resolvedGpuMode, currentForceScreenshot, env, {
programmaticOptOut: cfg?.forceScreenshotExplicitlyOptedOut ?? false,
})
);
}
+2
View File
@@ -50,6 +50,8 @@ export {
resolveConfig,
DEFAULT_CONFIG,
scaleProtocolTimeoutForComposition,
shouldClampToScreenshotForConcreteGpu,
applyConcreteGpuScreenshotClamp,
type EngineConfig,
} from "./config.js";
export {
+23 -5
View File
@@ -43,7 +43,7 @@ import {
produceDrawElementFrameBatch,
} from "./drawElementService.js";
import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { DEFAULT_CONFIG, applyConcreteGpuScreenshotClamp, type EngineConfig } from "../config.js";
import type {
CaptureOptions,
CaptureVideoMetadataHint,
@@ -815,15 +815,33 @@ export async function createCaptureSession(
// need explicit clip+scale on `Page.captureScreenshot`, so fall back to
// the screenshot path for any DPR > 1.
const supersampling = (options.deviceScaleFactor ?? 1) > 1;
const preMode: CaptureMode =
headlessShell && isLinux && !forceScreenshot && !supersampling && !drawElementTransparent
? "beginframe"
: "screenshot";
const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
chromePath: headlessShell ?? undefined,
browserTimeout: config?.browserTimeout,
});
// Apply the software-GPU→screenshot invariant at the concrete-resolved
// point too — `resolveConfig` can only see the pre-resolve `browserGpuMode`
// string, so `"auto"` that probes to software would otherwise slip through
// and launch BeginFrame + SwiftShader (the exact combination the invariant
// is meant to prevent). Both env and programmatic opt-outs preserved via
// `applyConcreteGpuScreenshotClamp` (the programmatic one carried on the
// config as `forceScreenshotExplicitlyOptedOut`, since at this point the
// boolean `forceScreenshot === false` is otherwise ambiguous between
// default and explicit opt-out).
const effectiveForceScreenshot = applyConcreteGpuScreenshotClamp(
forceScreenshot,
resolvedGpuMode,
config,
);
const preMode: CaptureMode =
headlessShell &&
isLinux &&
!effectiveForceScreenshot &&
!supersampling &&
!drawElementTransparent
? "beginframe"
: "screenshot";
const chromeArgs = buildChromeArgs(
{ width: options.width, height: options.height, captureMode: preMode },
{ ...config, browserGpuMode: resolvedGpuMode },
@@ -71,6 +71,7 @@ import {
type SubTimelineWaitOutcome,
resolveBrowserGpuMode,
resolveHeadlessShellPath,
applyConcreteGpuScreenshotClamp,
scaleProtocolTimeoutForComposition,
isMemoryExhaustionError,
isTransientBrowserError,
@@ -1982,7 +1983,28 @@ export async function executeRenderJob(
chromePath: resolveHeadlessShellPath(cfg),
browserTimeout: cfg.browserTimeout,
});
updateCaptureObservability({ browserGpuMode: resolvedBrowserGpuMode });
// Apply the software-GPU→screenshot clamp to the AUTHORITATIVE local
// `captureForceScreenshot` (not just the observability copy) so all
// downstream strategy + telemetry code reads the corrected value.
// Otherwise: `frameCapture.ts` clamps its own local and routes
// screenshot, but the still-`false` orchestrator local (a) mislabels the
// parallel-stream logging as "beginframe" below and (b) overwrites the
// earlier observability correction back to BeginFrame at the
// capture_strategy telemetry site. `resolveConfig` couldn't see
// `browserGpuMode:"auto"` resolving to software at config time, so
// `captureForceScreenshot` was still `compileResult.forceScreenshot === false`
// on that path. Both env and programmatic opt-outs preserved via
// `applyConcreteGpuScreenshotClamp` (the programmatic one carried on the
// config as `forceScreenshotExplicitlyOptedOut`).
captureForceScreenshot = applyConcreteGpuScreenshotClamp(
captureForceScreenshot,
resolvedBrowserGpuMode,
cfg,
);
updateCaptureObservability({
browserGpuMode: resolvedBrowserGpuMode,
forceScreenshot: captureForceScreenshot,
});
const videoCaptureBeyondViewport = resolveVideoCaptureBeyondViewport(composition.videos.length);
const captureOptions: CaptureOptions = {