Files
hyperframes/packages/engine/src/config.ts
T
Vance Ingalls f44bc3a525 fix(engine,producer): drive forceScreenshot from one authoritative local
Miguel R4 blocker on #2359: my R3 fix at renderOrchestrator only updated
the observability copy, leaving the authoritative captureForceScreenshot
local at compileResult.forceScreenshot (false for auto→software). The
frameCapture side clamped its own local and correctly routed screenshot,
but downstream orchestrator code overwrote observability back to
beginframe from the still-false local at two sites:

  - Parallel-stream label at renderOrchestrator.ts:2293 mis-labelled the
    stream as 'beginframe' when actual capture was 'screenshot'.
  - capture_strategy telemetry at renderOrchestrator.ts:2440-2450
    overwrote the earlier observability correction, so the final
    captureMode observation flipped back to 'beginframe' while the
    engine actually captured screenshot.

Fix: extract the clamp into a caller-facing helper
applyConcreteGpuScreenshotClamp(current, resolvedGpuMode, cfg) that
returns the (possibly-promoted) new boolean. Callers assign it back to
their authoritative local, so routing + telemetry + strategy code read
one value.

Changes:

  - packages/engine/src/config.ts: new exported
    applyConcreteGpuScreenshotClamp; delegates to
    shouldClampToScreenshotForConcreteGpu but computes the caller's
    final value, not just the clamp decision. Reads the programmatic
    opt-out from cfg.forceScreenshotExplicitlyOptedOut. Idempotent on
    already-true input.
  - packages/engine/src/index.ts: export the new helper.
  - packages/engine/src/services/frameCapture.ts: replace the inline
    OR expression with applyConcreteGpuScreenshotClamp.
  - packages/producer/src/services/renderOrchestrator.ts: assign result
    into the AUTHORITATIVE captureForceScreenshot local (was updating
    only observability). Downstream parallel-stream label at :2293 and
    capture_strategy telemetry at :2440-2450 now read the corrected
    value.

Tests: 6 new caller-level cases for applyConcreteGpuScreenshotClamp
covering the exact matrix Miguel called out:

  - resolved software + default false → promotes to true (screenshot)
  - resolved software + programmatic opt-out → stays false (BeginFrame)
  - resolved hardware + default false → stays false
  - resolved software + already-true → stays true (idempotent)
  - resolved software + env PRODUCER_FORCE_SCREENSHOT=false → stays false
  - resolved software + undefined cfg → promotes to true (frameCapture path)

Local: 67/67 engine config tests pass (was 61). oxfmt clean.
2026-07-14 03:17:29 +00:00

692 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Engine Configuration
*
* Typed configuration for the rendering pipeline. Replaces the PRODUCER_*
* env var sprawl with a structured interface. Env vars still work as
* fallbacks for backward compatibility during migration.
*/
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
getSystemTotalMb,
isLowMemorySystem,
LOW_MEMORY_TOTAL_MB_THRESHOLD,
} from "./services/systemMemory.js";
import { DEFAULT_VP9_CPU_USED, normalizeVp9CpuUsed } from "./services/vp9Options.js";
/**
* Full engine configuration. All fields are wired through the config
* object; env vars serve as backward-compatible fallbacks resolved
* in `resolveConfig()`.
*/
export interface EngineConfig {
// ── Rendering ────────────────────────────────────────────────────────
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: "jpeg" | "png";
jpegQuality: number;
// ── Parallelism ──────────────────────────────────────────────────────
/** Max worker count. "auto" uses CPU-based heuristic. */
concurrency: number | "auto";
/** CPU cores allocated per worker. */
coresPerWorker: number;
/** Minimum frames before parallel workers are used. */
minParallelFrames: number;
/** Frame count threshold for "large render" heuristics. */
largeRenderThreshold: number;
// ── Browser ──────────────────────────────────────────────────────────
chromePath?: string;
disableGpu: boolean;
/**
* Chrome/WebGL rendering backend.
* - "software": SwiftShader (CPU-only). Always works; ~5-50× slower than GPU.
* - "hardware": host GPU via platform-native ANGLE backend (Metal/D3D11/EGL).
* Errors if no usable GPU is reachable from Chrome.
* - "auto": probe Chrome for WebGL availability on first launch in this
* process; fall back to software if hardware-mode WebGL is unavailable.
* Cost: one extra Chrome launch (~1-2 s) per process; result cached.
*/
browserGpuMode: "software" | "hardware" | "auto";
enableBrowserPool: boolean;
browserTimeout: number;
protocolTimeout: number;
/** Expected Chromium major version (optional validation). */
expectedChromiumMajor?: number;
/** Force screenshot capture mode (skip BeginFrame even on Linux). */
forceScreenshot: boolean;
/**
* Static-frame dedup: reuse byte-identical frames instead of re-seeking +
* re-screenshotting (anchor-verified at init). Default ON; disable via
* `HF_STATIC_DEDUP` in {false,0,off}. Only arms in screenshot capture mode.
*/
staticFrameDedup: boolean;
/**
* Use drawElementImage for frame capture (requires the CanvasDrawElement
* Chrome flag, added globally in buildChromeArgs). Default ON, clamped in
* `resolveConfig` to hosts where it can actually engage (macOS + hardware-GPU
* browser); compile/init gates and the runtime self-verification net route
* incompatible or damaged renders back to screenshot capture.
* Kill switch: `PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false` (or the CLI
* `--experimental-fast-capture=false`).
*/
useDrawElement: boolean;
/**
* Pipeline JPEG encode into an in-page OffscreenCanvas Worker for the
* drawElement fast-capture path (macOS hardware GPU only). The worker
* encodes frame N while the main thread seeks+paints frame N+1
* (~1.651.96× wall-time speedup). No-op unless `useDrawElement` is also
* true. Kill switch: `HF_DE_WORKER_ENCODE=false`.
*/
enableDrawElementWorkerEncode: boolean;
/**
* INTERNAL. Set by resolveConfig when it disabled enablePageSideCompositing
* solely because drawElement was on. Lets the producer's compile-time gates
* restore page-side compositing without overriding an explicit caller/env
* 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
* throwaway auto-worker calibration browser, pins capture to a single
* worker (unless the user passed an explicit `--workers`), and prefers
* screenshot capture over BeginFrame. Resolved automatically from total
* RAM (`isLowMemorySystem()`); force on/off via `PRODUCER_LOW_MEMORY_MODE`
* or the `--low-memory-mode` CLI flag.
*/
lowMemoryMode: boolean;
/**
* Opt-in: page-side shader-transition compositing.
*
* When `true`, shader transitions for SDR compositions run their blend
* inside Chrome via WebGL on a page-side compositor canvas instead of
* Node-side per-pixel blending (the hf#677 layered pipeline). The engine
* then captures ONE opaque RGB frame per output frame via the streaming
* capture path, skipping per-scene transparent screenshots and the
* Node-side shader-blend worker pool entirely.
*
* The feature stacks on top of the hf#677 chain — it does not undo it.
* When this flag is OFF (the default), behaviour is byte-identical to the
* current path. When ON and the composition has no shader transitions or
* has HDR content (which forces the layered path regardless), this flag
* is a no-op.
*
* Mac viability: Chrome on Mac accelerates page-side WebGL canvases via
* Metal/CoreAnimation natively. This is the lever for Mac users who
* cannot use `--enable-begin-frame-control` (Chromium structural limit,
* crbug.com/40656275).
*
* Determinism: page-side WebGL is f32, not f64. Byte-equality fixture
* pins are NOT compatible with this path; the new path's correctness
* pin is PSNR-based. Default OFF preserves the existing pins for the
* hf#677 chain.
*
* Env fallback: `HF_PAGE_SIDE_COMPOSITING=true`.
*/
enablePageSideCompositing: boolean;
// ── Encoding ─────────────────────────────────────────────────────────
/**
* libvpx-vp9 speed/quality tradeoff. Higher values encode faster with a
* larger quality/size tradeoff. FFmpeg accepts integer values from -8 to 8.
*/
vp9CpuUsed: number;
enableChunkedEncode: boolean;
chunkSizeFrames: number;
enableStreamingEncode: boolean;
/**
* Max composition duration eligible for streaming encode (seconds).
* Mirrors GSAP rendering's 4-minute streaming guard: production has seen
* ffmpeg's streaming pipe hit FFMPEG_STREAMING_TIMEOUT_MS on longer videos.
*/
streamingEncodeMaxDurationSeconds: number;
// ── FFmpeg timeouts ──────────────────────────────────────────────────
/** Timeout for FFmpeg frame encoding (ms). Default: 600_000 */
ffmpegEncodeTimeout: number;
/** Timeout for FFmpeg mux/faststart processes (ms). Default: 300_000 */
ffmpegProcessTimeout: number;
/**
* Inactivity timeout for FFmpeg streaming encode (ms). The timer resets on
* every successful `writeFrame` call, so this caps the duration of a
* single "no frame arrived" gap (capture hang, dead Chrome), not the total
* render time. Default: 600_000 (10 minutes without any frame = dead).
*/
ffmpegStreamingTimeout: number;
// ── HDR ──────────────────────────────────────────────────────────────
/** HDR output transfer function. false = SDR output (default). */
hdr: { transfer: "hlg" | "pq" } | false;
/** Auto-detect HDR from video sources when hdr is not explicitly set. */
hdrAutoDetect: boolean;
// ── Media ────────────────────────────────────────────────────────────
audioGain: number;
/**
* Hard upper bound on entries kept in the video frame data URI cache.
* Acts as a sanity cap; the byte budget below normally fires first on
* high-resolution renders. At 1080p with ~6 MB per JPEG frame the default
* 256 entries fit inside ~1.5 GB. At 4K the byte budget evicts long
* before this cap is reached.
*/
frameDataUriCacheLimit: number;
/**
* Memory budget for the cache, in megabytes. Eviction kicks in once the
* sum of cached data-URI string lengths exceeds this. Sized so a worker
* stays comfortably under a few GB even at 4K (where each PNG frame is
* ~25 MB and the base64 data URI is ~33 MB).
*/
frameDataUriCacheBytesLimitMb: number;
// ── Timeouts ─────────────────────────────────────────────────────────
playerReadyTimeout: number;
renderReadyTimeout: number;
/**
* Puppeteer `page.goto()` navigation timeout for the entry HTML, in ms.
* The browser must reach `domcontentloaded` within this budget — heavy
* compositions (many videos, large fonts, hundreds of asset requests)
* can blow past the default 60s on cold cache. Default: 60_000.
*
* Env fallback: `PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS`.
* CLI flag: `--browser-timeout <seconds>`.
*/
pageNavigationTimeout: number;
// ── Runtime ──────────────────────────────────────────────────────────
/** Verify Hyperframe runtime SHA256 checksums. */
verifyRuntime: boolean;
/** Custom manifest path for Hyperframe runtime. */
runtimeManifestPath?: string;
// ── Cache ────────────────────────────────────────────────────────────
/**
* Directory where the content-addressed extraction cache persists frame
* bundles keyed on (path, mtime, size, mediaStart, duration, fps, format).
* Defaults on under the OS temp directory:
* `<tmpdir>/hyperframes-extract-cache-<uid>`.
*
* New entries publish atomically: frames are extracted into a unique
* partial directory, the `.hf-complete` sentinel is written there, and the
* partial directory is renamed into the final key directory. Concurrent
* renders against the same cache are safe; at worst, two renders duplicate
* ffmpeg work and one rehydrates from the winner.
*
* Set `HYPERFRAMES_EXTRACT_CACHE_DIR` to a path to override the default, or
* to `off`, `none`, `false`, or `0` to disable caching for the process.
* When disabled, extraction runs into the render's workDir and cleanup
* removes it when the render ends, preserving the pre-cache behaviour.
*
* **Network filesystems.** `mtime` resolution on NFS/SMB mounts can be
* coarser than expected (seconds rather than nanoseconds), which may
* produce spurious cache hits if a source file is overwritten within the
* same mtime tick. Local filesystems are the intended deployment target.
*
* Env fallback: `HYPERFRAMES_EXTRACT_CACHE_DIR`.
*/
extractCacheDir?: string;
/**
* Soft disk budget for `extractCacheDir`, in bytes. The renderer runs a
* best-effort LRU sweep after extraction and evicts oldest sentineled
* entries until the cache is under this cap, while protecting young entries
* that may belong to live renders.
*
* Env fallback: `HYPERFRAMES_EXTRACT_CACHE_MAX_MB` (megabytes).
*/
extractCacheMaxBytes: number;
// ── Debug ────────────────────────────────────────────────────────────
debug: boolean;
}
/** Default configuration — sensible for Hyperframes compositions. */
export const DEFAULT_CONFIG: EngineConfig = {
fps: 30,
quality: "standard",
format: "jpeg",
jpegQuality: 80,
concurrency: "auto",
coresPerWorker: 2.5,
minParallelFrames: 120,
largeRenderThreshold: 1000,
disableGpu: false,
browserGpuMode: "software",
enableBrowserPool: true,
browserTimeout: 120_000,
protocolTimeout: 300_000,
forceScreenshot: false,
staticFrameDedup: true,
useDrawElement: true,
enableDrawElementWorkerEncode: true,
// Auto-detected per host in `resolveConfig`; defaults off for the raw
// DEFAULT_CONFIG (used directly by tests and worker-sizing fallbacks).
lowMemoryMode: false,
enablePageSideCompositing: true,
vp9CpuUsed: DEFAULT_VP9_CPU_USED,
enableChunkedEncode: false,
chunkSizeFrames: 360,
enableStreamingEncode: true,
streamingEncodeMaxDurationSeconds: 240,
ffmpegEncodeTimeout: 600_000,
ffmpegProcessTimeout: 300_000,
ffmpegStreamingTimeout: 600_000,
hdr: false,
hdrAutoDetect: true,
audioGain: 1,
frameDataUriCacheLimit: 256,
frameDataUriCacheBytesLimitMb: 1500,
playerReadyTimeout: 45_000,
renderReadyTimeout: 15_000,
pageNavigationTimeout: 60_000,
verifyRuntime: true,
extractCacheMaxBytes: 2 * 1024 ** 3,
debug: false,
};
/**
* Reference canvas area for the baseline `protocolTimeout`: 1080p. A single CDP
* call (`Runtime.callFunctionOn` seek+paint, or `Page.captureScreenshot`)
* scales with the *output pixel area* it has to render/serialize — NOT with the
* frame count (that governs total wall-clock, capped separately by the ffmpeg
* streaming inactivity timeout). A fixed 300s ceiling intermittently kills
* legitimate slow-but-valid renders on large canvases with
* `Runtime.callFunctionOn timed out`, so we scale the per-call ceiling with
* area.
*/
const PROTOCOL_TIMEOUT_REFERENCE_PIXELS = 1920 * 1080;
/**
* Absolute ceiling on the scaled protocol timeout (30 minutes). Bounds the
* blast radius: a genuinely wedged CDP call must still eventually fail rather
* than hang for an unbounded time on a pathologically large composition.
*/
const MAX_SCALED_PROTOCOL_TIMEOUT_MS = 1_800_000;
/**
* Scale a base `protocolTimeout` up for oversized compositions.
*
* Scales by output pixel area (`width*height / reference`) — where width/height
* are the *device-scaled output* dimensions (the pixels a single CDP call
* actually renders/serializes), not the CSS composition size. Clamped to
* `[baseTimeout, max(baseTimeout, MAX_SCALED_PROTOCOL_TIMEOUT_MS)]`: never
* scales DOWN (a small composition — or a base already above the ceiling —
* keeps the configured base), and only ever raises. Pure function; exported
* for tests.
*/
export function scaleProtocolTimeoutForComposition(
baseTimeoutMs: number,
dims: { width: number; height: number },
): number {
const { width, height } = dims;
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return baseTimeoutMs;
}
const factor = (width * height) / PROTOCOL_TIMEOUT_REFERENCE_PIXELS;
if (factor <= 1) return baseTimeoutMs;
const scaled = Math.ceil(baseTimeoutMs * factor);
// Ceiling is `max(base, MAX)` so an explicit base above the ceiling is never
// lowered (preserves the "only ever raise" contract for all callers).
const ceiling = Math.max(baseTimeoutMs, MAX_SCALED_PROTOCOL_TIMEOUT_MS);
return Math.min(ceiling, Math.max(baseTimeoutMs, scaled));
}
function memoryAdaptiveCacheLimit(): number {
const total = getSystemTotalMb();
if (total < 4096) return 32;
if (total <= LOW_MEMORY_TOTAL_MB_THRESHOLD) return 64;
return DEFAULT_CONFIG.frameDataUriCacheLimit;
}
function memoryAdaptiveCacheBytesMb(): number {
const total = getSystemTotalMb();
if (total < 4096) return 128;
if (total <= LOW_MEMORY_TOTAL_MB_THRESHOLD) return 256;
return DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb;
}
/**
* Resolve configuration by merging: defaults ← env vars ← explicit overrides.
* Env vars provide backward compatibility during migration; explicit config
* takes precedence over everything.
*/
export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
const env = (key: string): string | undefined => process.env[key];
const envNum = (key: string, fallback: number): number => {
const raw = env(key);
if (raw === undefined || raw === "") return fallback;
const n = Number(raw);
return Number.isFinite(n) ? n : fallback;
};
const envBool = (key: string, fallback: boolean): boolean => {
const raw = env(key);
if (raw === undefined) return fallback;
return raw === "true";
};
const envVp9CpuUsed = (): number => {
const raw = env("PRODUCER_VP9_CPU_USED");
if (raw === undefined || raw === "") return DEFAULT_CONFIG.vp9CpuUsed;
return normalizeVp9CpuUsed(Number(raw));
};
const envBrowserGpuMode = (): EngineConfig["browserGpuMode"] => {
const raw = env("PRODUCER_BROWSER_GPU_MODE");
if (raw === "hardware" || raw === "software" || raw === "auto") return raw;
return DEFAULT_CONFIG.browserGpuMode;
};
// Tri-state: explicit on/off via env, otherwise auto-detect from total RAM.
const resolveLowMemoryMode = (): boolean => {
const raw = env("PRODUCER_LOW_MEMORY_MODE")?.toLowerCase();
if (raw === "true" || raw === "on" || raw === "1") return true;
if (raw === "false" || raw === "off" || raw === "0") return false;
return isLowMemorySystem();
};
// Opt-OUT: default ON, disabled only by an explicit falsey value.
const resolveStaticFrameDedup = (): boolean => {
const raw = env("HF_STATIC_DEDUP")?.trim().toLowerCase();
return !(raw === "false" || raw === "off" || raw === "0");
};
const resolveExtractCacheDir = (): string | undefined => {
const raw = env("HYPERFRAMES_EXTRACT_CACHE_DIR");
if (raw === undefined) {
return join(tmpdir(), `hyperframes-extract-cache-${process.getuid?.() ?? "u"}`);
}
const trimmed = raw.trim();
const normalized = trimmed.toLowerCase();
if (
normalized === "off" ||
normalized === "none" ||
normalized === "false" ||
normalized === "0"
) {
return undefined;
}
return raw;
};
// Env-var layer (backward compat)
const fromEnv: Partial<EngineConfig> = {
concurrency: env("PRODUCER_MAX_WORKERS") ? Number(env("PRODUCER_MAX_WORKERS")) : undefined,
coresPerWorker: envNum("PRODUCER_CORES_PER_WORKER", DEFAULT_CONFIG.coresPerWorker),
minParallelFrames: envNum("PRODUCER_MIN_PARALLEL_FRAMES", DEFAULT_CONFIG.minParallelFrames),
largeRenderThreshold: envNum(
"PRODUCER_LARGE_RENDER_THRESHOLD",
DEFAULT_CONFIG.largeRenderThreshold,
),
chromePath: env("PRODUCER_HEADLESS_SHELL_PATH"),
disableGpu: envBool("PRODUCER_DISABLE_GPU", DEFAULT_CONFIG.disableGpu),
browserGpuMode: envBrowserGpuMode(),
enableBrowserPool: envBool("PRODUCER_ENABLE_BROWSER_POOL", DEFAULT_CONFIG.enableBrowserPool),
browserTimeout: envNum("PRODUCER_PUPPETEER_LAUNCH_TIMEOUT_MS", DEFAULT_CONFIG.browserTimeout),
protocolTimeout: envNum(
"PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS",
DEFAULT_CONFIG.protocolTimeout,
),
expectedChromiumMajor: env("PRODUCER_EXPECTED_CHROMIUM_MAJOR")
? Number(env("PRODUCER_EXPECTED_CHROMIUM_MAJOR"))
: undefined,
forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
staticFrameDedup: resolveStaticFrameDedup(),
useDrawElement: envBool("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", DEFAULT_CONFIG.useDrawElement),
enableDrawElementWorkerEncode: envBool(
"HF_DE_WORKER_ENCODE",
DEFAULT_CONFIG.enableDrawElementWorkerEncode,
),
lowMemoryMode: resolveLowMemoryMode(),
enablePageSideCompositing: envBool(
"HF_PAGE_SIDE_COMPOSITING",
DEFAULT_CONFIG.enablePageSideCompositing,
),
vp9CpuUsed: envVp9CpuUsed(),
enableChunkedEncode: envBool(
"PRODUCER_ENABLE_CHUNKED_ENCODE",
DEFAULT_CONFIG.enableChunkedEncode,
),
chunkSizeFrames: Math.max(
120,
envNum("PRODUCER_CHUNK_SIZE_FRAMES", DEFAULT_CONFIG.chunkSizeFrames),
),
enableStreamingEncode: envBool(
"PRODUCER_ENABLE_STREAMING_ENCODE",
DEFAULT_CONFIG.enableStreamingEncode,
),
streamingEncodeMaxDurationSeconds: Math.max(
0,
envNum(
"PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS",
DEFAULT_CONFIG.streamingEncodeMaxDurationSeconds,
),
),
ffmpegEncodeTimeout: envNum("FFMPEG_ENCODE_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegEncodeTimeout),
ffmpegProcessTimeout: envNum("FFMPEG_PROCESS_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegProcessTimeout),
ffmpegStreamingTimeout: envNum(
"FFMPEG_STREAMING_TIMEOUT_MS",
DEFAULT_CONFIG.ffmpegStreamingTimeout,
),
hdr: (() => {
const raw = env("PRODUCER_HDR_TRANSFER");
if (raw === "hlg" || raw === "pq") return { transfer: raw };
return false;
})(),
hdrAutoDetect: envBool("PRODUCER_HDR_AUTO_DETECT", DEFAULT_CONFIG.hdrAutoDetect),
audioGain: envNum("PRODUCER_AUDIO_GAIN", DEFAULT_CONFIG.audioGain),
frameDataUriCacheLimit: Math.max(
32,
envNum("PRODUCER_FRAME_DATA_URI_CACHE_LIMIT", memoryAdaptiveCacheLimit()),
),
frameDataUriCacheBytesLimitMb: Math.max(
64,
envNum("PRODUCER_FRAME_DATA_URI_CACHE_BYTES_MB", memoryAdaptiveCacheBytesMb()),
),
playerReadyTimeout: envNum(
"PRODUCER_PLAYER_READY_TIMEOUT_MS",
DEFAULT_CONFIG.playerReadyTimeout,
),
renderReadyTimeout: envNum(
"PRODUCER_RENDER_READY_TIMEOUT_MS",
DEFAULT_CONFIG.renderReadyTimeout,
),
pageNavigationTimeout: envNum(
"PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS",
DEFAULT_CONFIG.pageNavigationTimeout,
),
verifyRuntime: env("PRODUCER_VERIFY_HYPERFRAME_RUNTIME") !== "false",
runtimeManifestPath: env("PRODUCER_HYPERFRAME_MANIFEST_PATH"),
extractCacheDir: resolveExtractCacheDir(),
extractCacheMaxBytes:
envNum("HYPERFRAMES_EXTRACT_CACHE_MAX_MB", DEFAULT_CONFIG.extractCacheMaxBytes / 1024 ** 2) *
1024 ** 2,
};
// Remove undefined values so they don't override defaults
const cleanEnv = Object.fromEntries(Object.entries(fromEnv).filter(([, v]) => v !== undefined));
const merged = {
...DEFAULT_CONFIG,
...cleanEnv,
...overrides,
};
// Default-on drawElement is clamped to hosts where it can actually engage
// (macOS with a non-software-GPU browser; SwiftShader drops transparent
// sub-layers — crbug 521434899). "auto" passes the clamp: the stock CLI
// resolves GPU mode to auto, which probes to hardware on real Macs — and if
// it resolves to software after all, the SwiftShader init-time gate still
// routes the session to the screenshot baseline. Without the clamp, the
// default would needlessly disable page-side shader compositing (below) on
// Linux/Docker hosts where DE never runs. An EXPLICIT opt-in (env or caller override)
// skips the clamp and keeps the old semantics — attempt DE, let the
// init-time gates route away — which debugging relies on.
const explicitDrawElementOptIn =
env("PRODUCER_EXPERIMENTAL_FAST_CAPTURE") === "true" || overrides?.useDrawElement === true;
if (
merged.useDrawElement &&
!explicitDrawElementOptIn &&
!(process.platform === "darwin" && merged.browserGpuMode !== "software")
) {
merged.useDrawElement = false;
}
// The runtime self-verification net lives in the worker-encode drain — the
// serial drawElement path has only the blank guard. Default-on drawElement
// therefore requires worker-encode; disabling HF_DE_WORKER_ENCODE without an
// explicit drawElement opt-in falls back to the screenshot baseline rather
// than shipping unverified drawElement frames.
if (merged.useDrawElement && !explicitDrawElementOptIn && !merged.enableDrawElementWorkerEncode) {
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
// fast capture is on, force page-side compositing off so shader
// transitions fall back to the Node-side layered blend rather than silently
// dropping. This keeps the flag self-consistent and avoids a per-session
// incompatibility warning on every fast-capture render.
if (merged.useDrawElement && merged.enablePageSideCompositing) {
merged.enablePageSideCompositing = false;
// Record that THIS resolution (not the caller) turned page-side
// compositing off, so a later compile-time drawElement gate can restore
// it without clobbering an explicit enablePageSideCompositing:false from
// the programmatic API or HF_PAGE_SIDE_COMPOSITING=false.
merged.pageSideCompositingAutoDisabled = true;
}
return {
...merged,
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,
})
);
}