feat(engine): auto-disable streaming-encode on Windows software-GPU compound

Field signal ts=1784131903 (win32/x64, CLI 0.7.58, 156s UI-heavy):
stable ONLY with four flags together — --workers 1 --no-browser-gpu
--low-memory-mode + PRODUCER_ENABLE_STREAMING_ENCODE=false. Since
--no-browser-gpu and --low-memory-mode already imply screenshot
capture, three of the four flags are structurally coupled. Auto-detect
the compound at resolveConfig time and disable streaming-encode on
the caller's behalf; user explicit-set (PRODUCER_ENABLE_STREAMING_ENCODE
or overrides.enableStreamingEncode) always wins.

Composition duration is not known at the config layer, so the wire-up
passes compositionDurationSec:undefined and the helper reduces to the
three-condition compound (platform + softwareGpuForced + workers=1).
The 4-arg helper stays exported for downstream callers that DO know
duration (e.g., renderOrchestrator) and want the >120s guard.

Trade-off documented in code + PR body: false positives possible for
short (~<120s) Windows software-GPU single-worker renders. Mitigation
is the explicit opt-in escape hatch.

Emits a single [hyperframes] log line naming the trigger + how to opt
back in, so operators can tell an auto-disable apart from an explicit
opt-out. Adds streamingEncodeAutoDisabledOnWin32Compound internal
provenance for downstream telemetry.

Stack: PR #2 of 9 (base via/protocol-timeout-discoverability).

Signed-off-by: Via
This commit is contained in:
Via
2026-07-15 22:21:54 +00:00
parent 6944a1c2d0
commit cbf2a2ec69
2 changed files with 322 additions and 0 deletions
+111
View File
@@ -150,6 +150,15 @@ export interface EngineConfig {
enableChunkedEncode: boolean;
chunkSizeFrames: number;
enableStreamingEncode: boolean;
/**
* INTERNAL. Set by `resolveConfig` when the Windows software-GPU compound
* heuristic (`shouldAutoDisableStreamingEncodeOnWin32Compound`) turned
* `enableStreamingEncode` off on the caller's behalf. Not intended to be
* set by callers; surfaces the auto-decision for downstream observability
* (log lines, telemetry) so operators can tell an auto-disable apart from
* an explicit user opt-out.
*/
streamingEncodeAutoDisabledOnWin32Compound?: boolean;
/**
* Max composition duration eligible for streaming encode (seconds).
* Mirrors GSAP rendering's 4-minute streaming guard: production has seen
@@ -355,6 +364,62 @@ export function scaleProtocolTimeoutForComposition(
return Math.min(ceiling, Math.max(baseTimeoutMs, scaled));
}
/**
* Auto-disable `enableStreamingEncode` on Windows software-GPU compound.
*
* Field signal (`ts=1784131903`, win32/x64, CLI 0.7.58, 156s UI-heavy
* composition): the render was stable ONLY with FOUR flags together —
* `--workers 1 --no-browser-gpu --low-memory-mode` + explicit
* `PRODUCER_ENABLE_STREAMING_ENCODE=false`. Every recent Windows-related
* fix (#2359, #2245, #2298, #2331) already shipped in 0.7.58; the residual
* failure is screenshot streaming-encode via CDP `Page.captureScreenshot`
* on Windows even after software fallback. Since `--low-memory-mode` and
* `--no-browser-gpu` already imply screenshot capture, three of the four
* flags are structurally coupled — auto-detect the compound and disable
* streaming-encode automatically so callers don't have to memorize the
* four-flag combination.
*
* Conservative gates (all must hold):
* 1. `platform === "win32"` — the failure is Windows-specific to CDP's
* screenshot streaming path.
* 2. `softwareGpuForced` — the render is already on the SwiftShader /
* forced-screenshot path (from `--no-browser-gpu`, `disableGpu`, or
* `--low-memory-mode` implying screenshot capture).
* 3. `workers === 1` — the field signal reproduces on single-worker
* captures; parallel workers have a different failure surface
* (missing media frames) already handled by the worker-count route.
* 4. Composition duration >120s WHEN KNOWN. When unknown at the config
* layer (composition duration is parsed downstream), the guard
* reduces to the three-condition compound. Trade-off documented in
* the PR body: false positives possible for short (~<120s) Windows
* software-GPU single-worker renders. Mitigation: the explicit
* opt-in escape hatch (`PRODUCER_ENABLE_STREAMING_ENCODE=true` or
* `overrides.enableStreamingEncode !== undefined`) always wins.
*
* Pure function; exported for tests.
*/
export function shouldAutoDisableStreamingEncodeOnWin32Compound(opts: {
platform: NodeJS.Platform;
softwareGpuForced: boolean;
workers: number;
compositionDurationSec: number | undefined;
userExplicitlySet: boolean;
}): boolean {
if (opts.userExplicitlySet) return false;
if (opts.platform !== "win32") return false;
if (!opts.softwareGpuForced) return false;
// Strict equality: NaN (concurrency: "auto") and fractional / zero worker
// counts do NOT match. The field-signal compound is `--workers 1`.
if (opts.workers !== 1) return false;
// Duration boundary: when known, only auto-disable if >120s (avoid
// over-triggering on short renders). When unknown, skip this check —
// the three conditions above are already conservative on their own.
if (opts.compositionDurationSec !== undefined && opts.compositionDurationSec <= 120) {
return false;
}
return true;
}
function memoryAdaptiveCacheLimit(): number {
const total = getSystemTotalMb();
if (total < 4096) return 32;
@@ -608,6 +673,52 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
merged.forceScreenshot = true;
}
// Windows software-GPU compound auto-disable for streaming-encode.
//
// Field signal ts=1784131903 (win32/x64, CLI 0.7.58, 156s UI-heavy):
// stable ONLY with FOUR flags — `--workers 1 --no-browser-gpu
// --low-memory-mode` + `PRODUCER_ENABLE_STREAMING_ENCODE=false`. Since
// `--no-browser-gpu` and `--low-memory-mode` already imply screenshot
// capture, three of the four flags are structurally coupled: auto-detect
// the compound and disable streaming-encode on the caller's behalf.
//
// Explicit user intent wins: if `PRODUCER_ENABLE_STREAMING_ENCODE` env
// is set to any value OR the caller passed `overrides.enableStreamingEncode`,
// this clamp is a no-op (the user's explicit choice — including
// `PRODUCER_ENABLE_STREAMING_ENCODE=true` — is preserved).
//
// Composition duration is not known at the config-resolution layer
// (the composition is parsed downstream of `resolveConfig`), so this
// wire-up passes `compositionDurationSec: undefined` and the helper
// reduces to the three-condition compound. Trade-off documented in the
// helper's JSDoc and the PR body: false positives possible for short
// Windows software-GPU single-worker renders; the explicit opt-in
// escape hatch is the mitigation.
const streamingEncodeUserExplicitlySet =
env("PRODUCER_ENABLE_STREAMING_ENCODE") !== undefined ||
overrides?.enableStreamingEncode !== undefined;
const softwareGpuForced =
merged.browserGpuMode === "software" || merged.disableGpu || merged.lowMemoryMode;
const resolvedWorkers = typeof merged.concurrency === "number" ? merged.concurrency : NaN;
if (
merged.enableStreamingEncode &&
shouldAutoDisableStreamingEncodeOnWin32Compound({
platform: process.platform,
softwareGpuForced,
workers: resolvedWorkers,
compositionDurationSec: undefined,
userExplicitlySet: streamingEncodeUserExplicitlySet,
})
) {
merged.enableStreamingEncode = false;
merged.streamingEncodeAutoDisabledOnWin32Compound = true;
console.error(
"[hyperframes] Windows compound-workaround auto-detected — disabling streaming-encode " +
"(platform=win32, software-GPU forced, workers=1). Field signal ts=1784131903. " +
"Override: PRODUCER_ENABLE_STREAMING_ENCODE=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