mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -7,6 +7,7 @@ import {
|
||||
scaleProtocolTimeoutForComposition,
|
||||
shouldClampToScreenshotForConcreteGpu,
|
||||
applyConcreteGpuScreenshotClamp,
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound,
|
||||
} from "./config.js";
|
||||
import type { EngineConfig } from "./config.js";
|
||||
import { isLowMemorySystem } from "./services/systemMemory.js";
|
||||
@@ -518,6 +519,216 @@ describe("resolveConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAutoDisableStreamingEncodeOnWin32Compound (helper)", () => {
|
||||
// Baseline: field-signal compound — win32 + software-GPU forced + workers=1,
|
||||
// duration unknown, user hasn't touched the env / overrides.
|
||||
const compound = {
|
||||
platform: "win32" as NodeJS.Platform,
|
||||
softwareGpuForced: true,
|
||||
workers: 1,
|
||||
compositionDurationSec: undefined as number | undefined,
|
||||
userExplicitlySet: false,
|
||||
};
|
||||
|
||||
it("triggers on the field-signal compound (win32 + software-GPU + workers=1)", () => {
|
||||
expect(shouldAutoDisableStreamingEncodeOnWin32Compound(compound)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT trigger on linux or darwin (platform gate)", () => {
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, platform: "linux" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, platform: "darwin" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT trigger without software-GPU forced (bypass on hardware paths)", () => {
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, softwareGpuForced: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT trigger with parallel workers (workers > 1 has a different failure surface)", () => {
|
||||
expect(shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, workers: 2 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, workers: 4 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT trigger when the user explicitly set enableStreamingEncode (escape hatch)", () => {
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({ ...compound, userExplicitlySet: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT trigger for short (<=120s) compositions when duration is known", () => {
|
||||
// 120s boundary is off (edge)
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({
|
||||
...compound,
|
||||
compositionDurationSec: 120,
|
||||
}),
|
||||
).toBe(false);
|
||||
// 60s — clearly short, off
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({
|
||||
...compound,
|
||||
compositionDurationSec: 60,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("triggers when duration is known and exceeds 120s (heavy Windows composition)", () => {
|
||||
// 121s — just past the boundary, on
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({
|
||||
...compound,
|
||||
compositionDurationSec: 121,
|
||||
}),
|
||||
).toBe(true);
|
||||
// 156s — matches the field signal, on
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({
|
||||
...compound,
|
||||
compositionDurationSec: 156,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("triggers when duration is undefined (config-layer wire-up reduces to 3-cond)", () => {
|
||||
// resolveConfig can't see composition duration at config time; the
|
||||
// three-condition compound is conservative on its own.
|
||||
expect(
|
||||
shouldAutoDisableStreamingEncodeOnWin32Compound({
|
||||
...compound,
|
||||
compositionDurationSec: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enableStreamingEncode (Windows compound auto-disable wire-up)", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform) {
|
||||
Object.defineProperty(process, "platform", { value: platform, configurable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
||||
});
|
||||
|
||||
it("auto-disables on win32 + software-GPU + workers=1 + no user opt-in", () => {
|
||||
setPlatform("win32");
|
||||
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
|
||||
setEnv("PRODUCER_MAX_WORKERS", "1");
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE");
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.enableStreamingEncode).toBe(false);
|
||||
expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves streaming-encode on when platform is linux", () => {
|
||||
setPlatform("linux");
|
||||
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
|
||||
setEnv("PRODUCER_MAX_WORKERS", "1");
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE");
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.enableStreamingEncode).toBe(true);
|
||||
expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves streaming-encode on when workers > 1", () => {
|
||||
setPlatform("win32");
|
||||
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
|
||||
setEnv("PRODUCER_MAX_WORKERS", "4");
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE");
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.enableStreamingEncode).toBe(true);
|
||||
expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects explicit env opt-in (PRODUCER_ENABLE_STREAMING_ENCODE=true) on the compound", () => {
|
||||
setPlatform("win32");
|
||||
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
|
||||
setEnv("PRODUCER_MAX_WORKERS", "1");
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
setEnv("PRODUCER_ENABLE_STREAMING_ENCODE", "true");
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.enableStreamingEncode).toBe(true);
|
||||
expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects programmatic override enableStreamingEncode=true on the compound", () => {
|
||||
setPlatform("win32");
|
||||
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
|
||||
setEnv("PRODUCER_MAX_WORKERS", "1");
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE");
|
||||
|
||||
const config = resolveConfig({ enableStreamingEncode: true });
|
||||
expect(config.enableStreamingEncode).toBe(true);
|
||||
expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined();
|
||||
});
|
||||
|
||||
it("triggers via disableGpu on win32 + workers=1 (browserGpuMode may still be 'auto')", () => {
|
||||
// --disable-gpu path: browserGpuMode may not be literal "software" but
|
||||
// Chrome is still routed to CPU raster. Field-signal compound applies.
|
||||
setPlatform("win32");
|
||||
unsetEnv("PRODUCER_BROWSER_GPU_MODE");
|
||||
setEnv("PRODUCER_DISABLE_GPU", "true");
|
||||
setEnv("PRODUCER_MAX_WORKERS", "1");
|
||||
unsetEnv("PRODUCER_LOW_MEMORY_MODE");
|
||||
unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE");
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.enableStreamingEncode).toBe(false);
|
||||
expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBe(true);
|
||||
});
|
||||
|
||||
it("triggers via lowMemoryMode alone on win32 + workers=1 (screenshot capture implied)", () => {
|
||||
// --low-memory-mode implies screenshot capture. Compound applies even
|
||||
// if browserGpuMode is not literal "software" (defense-in-depth: matches
|
||||
// the OR semantics in `softwareGpuForced`).
|
||||
setPlatform("win32");
|
||||
unsetEnv("PRODUCER_BROWSER_GPU_MODE");
|
||||
unsetEnv("PRODUCER_DISABLE_GPU");
|
||||
setEnv("PRODUCER_MAX_WORKERS", "1");
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE");
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.enableStreamingEncode).toBe(false);
|
||||
expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBe(true);
|
||||
});
|
||||
|
||||
it("does not trigger when concurrency is 'auto' (workers not explicitly pinned)", () => {
|
||||
// Config-layer sees `concurrency === "auto"`, not a number — the
|
||||
// helper's numeric workers check treats NaN as "unknown, don't trigger".
|
||||
// Downstream workers may still resolve to 1 via lowMemoryMode, but the
|
||||
// config-time clamp is deliberately conservative.
|
||||
setPlatform("win32");
|
||||
setEnv("PRODUCER_BROWSER_GPU_MODE", "software");
|
||||
unsetEnv("PRODUCER_MAX_WORKERS");
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
unsetEnv("PRODUCER_ENABLE_STREAMING_ENCODE");
|
||||
|
||||
const config = resolveConfig();
|
||||
expect(config.enableStreamingEncode).toBe(true);
|
||||
expect(config.streamingEncodeAutoDisabledOnWin32Compound).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lowMemoryMode", () => {
|
||||
it("forces on for truthy PRODUCER_LOW_MEMORY_MODE values", () => {
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user