Files
hyperframes/packages/engine/src/config.ts
T
Vance IngallsandClaude Fable 5 ec06f4bf89 feat(engine,producer): drawElement fast-capture default-on with runtime self-verification safety net (#1998)
* feat(engine,producer): drawElement fast-capture default-on with runtime self-verification safety net

Flip useDrawElement + worker-encode defaults on (HF_DE_BATCH default 4),
clamped in resolveConfig to hosts where drawElement can engage (macOS +
hardware-GPU browser) so page-side shader compositing is untouched
everywhere else; explicit env opt-in keeps attempt-and-gate semantics.

Safety net makes default-on safe: the compile/init gates catch predictable
incompatibility; this catches the intermittent residue no static analysis
can see (stale paints, dropped background images, transient blank frames).

- engine: captureDeVerificationFrames — K=4 (HF_DE_VERIFY) ground-truth
  screenshots at init, after gates + armStaticDedup, BEFORE canvas
  injection (post-injection screenshots show the canvas bitmap, not the
  DOM). Runs the video-injection hook per sample; double-captures so
  rAF-driven text counters settle (a single immediate screenshot captures
  stale text and false-positives). Skips png, <10 frames, implausible
  __hf.duration (infinite-repeat GSAP sentinel).
- producer: guardFrame on both worker-encode drains — rolling-median blank
  guard with retry-once at drain (byte-identical retry ⇒ deterministic dark
  frame, accepted; retry save/restores the static-dedup anchor) + ffmpeg
  PSNR self-verify vs ground truth (HF_DE_VERIFY_MIN_DB, default 32dB;
  natural agreement ≥45dB, damage ≤25dB). Breach dumps the frame pair to
  tmpdir and throws DrawElementVerificationError.
- orchestrator: one-shot retry — on verification error the whole render
  re-runs with forceScreenshot (slower, never wrong); telemetry flag
  deSelfVerifyFallback.
- tooling: de-canary-suite.sh (7-comp release gate with expected verdicts),
  de-gatecheck.sh (init-only corpus routing classifier), we-render.mjs.

Validated: canary suite 7/7; 611-comp routing sample 54% drawelement /
37.5% gated / 8.3% comp-defect; 12/12 risk-band renders clean on bare
defaults (48/48 verify samples); engine suite 888 passed; caught two real
intermittent damage classes in the wild (background-image drop, root-props
offset) that previously shipped silently.

Kill switches: PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false,
HF_DE_WORKER_ENCODE=false, HF_DE_BATCH=0, HF_DE_VERIFY=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engine,producer): harden the drawElement self-verification net (max code-review findings)

15 confirmed findings from the adversarial review of the default-on flip;
the load-bearing five:

- Ground-truth capture no longer scrubs GSAP state: seek(0) + forced frame
  FIRST (lazy .from()/overlap tweens record start values on first seek —
  mid-timeline scrubs corrupted them for the whole render, and since DE
  frames and truth shared the corruption, PSNR passed on damaged output),
  then ascending even-spread fractions, page left at frame 0.
- Default-on drawElement is confined to the verified path: resolveConfig
  requires worker-encode (the drain that runs the net), the orchestrator
  disengages the default when the render takes the disk path or parallel
  capture (no drain verification there), and closes a drawElement-initialized
  probe session rather than letting the unverified path reuse it. Explicit
  PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true keeps old attempt-and-gate behavior.
- Blank-frame retry can no longer splice wrong-frame pixels: recapture goes
  through recaptureDrawElementFrameForVerify — no static-dedup shortcut
  (lastEncodeResult runs ahead of the drain) and no "No cached paint record"
  screenshot fallback (post-injection that captures the canvas = the LAST
  drawn frame); any recapture failure falls back the whole render.
- Verify indices derive from the producer-resolved duration
  (CaptureOptions.compositionDurationSeconds) instead of raw __hf.duration,
  so samples always land inside the drained range.
- The platform clamp accepts "auto" GPU mode — the stock CLI resolves auto,
  and the literal-"hardware" clamp made default-on a no-op for the primary
  audience (masked in validation by explicitly-set env).

Also: NaN-safe env parses (HF_DE_VERIFY / HF_DE_VERIFY_MIN_DB / HF_DE_BATCH);
video comps skip verification when the session has no frame injector (probe
sessions — black-video truth false-positived); psnr infrastructure failures
skip the sample instead of failing the render; boundary-saturated sample
indices are skipped; shader-transition comps prefer page-side compositing
over default drawElement and compile-gated comps get page-side compositing
restored; observability.clearFailure un-brands the recovered first streaming
attempt; canary suite exempts known-marginal "any" comps from the cross-path
PSNR gate; dead we-render options removed; clamp tests pin their env.

Validated: canary suite 7/7; auto-GPU bare render engages the full stack;
disk-path and worker-encode-off renders disengage default drawElement;
malformed HF_DE_VERIFY_MIN_DB still verifies at the default threshold;
engine suite 890 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engine,producer): review fixes — PSC intent, verify-threshold clamp, fail-closed canaries

Addresses miguel-heygen's review on #1998:

- Page-side compositing restore preserves explicit caller intent (blocker):
  resolveConfig now records pageSideCompositingAutoDisabled only when IT
  turned page-side compositing off because drawElement was on; the
  compile-time drawElement gates restore page-side compositing only when
  that flag is set. An explicit enablePageSideCompositing:false from the
  programmatic API or HF_PAGE_SIDE_COMPOSITING=false stays off. Pinned by
  two config tests.
- HF_DE_VERIFY_MIN_DB clamped to [10, 60] with a warning on out-of-range
  values: below ~10dB the check passes severe damage; above ~60dB natural
  encoder differences force a screenshot fallback on every verified render.
- de-canary-suite.sh + de-gatecheck.sh run under set -euo pipefail with
  explicit `|| true` on expected-nonzero commands (render exits handled by
  the suite's own checks, grep no-match, kill/pkill/wait races) and a hard
  FAIL when the PSNR compare produces no value — release canaries fail
  closed. Full suite re-run green (7/7) under the new flags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:29:30 -07:00

578 lines
25 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;
/**
* 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;
}
// 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),
};
}