fix(producer): thread forceScreenshot to probe stage for low-memory mode (#1237)

* fix(producer): thread forceScreenshot to probe stage for low-memory mode

The render orchestrator sets captureForceScreenshot=true when low-memory
mode is active and passes it to all three capture stages, but never
passed it to the probe stage. The probe stage received the original cfg
with forceScreenshot=false, so on Linux it launched the browser in
beginframe mode — which hangs on memory-constrained hardware.

Add a forceScreenshot parameter to ProbeStageInput (matching the pattern
used by captureStage, captureStreamingStage, and captureHdrStage) and
wire it through from both the render orchestrator and the distributed
planner.

Fixes heygen-com/hyperframes#1236

* fix(producer): add lowMemoryMode→forceScreenshot bump in distributed plan + regression test

- plan.ts: mirror the renderOrchestrator's lowMemoryMode bump so that
  distributed runs on low-RAM hosts also force screenshot capture mode.
  Previously the bump was only applied in the in-process path (renderOrchestrator.ts:1598);
  plan.ts derived forceScreenshot from compileResult without the bump, leaving the
  distributed probe stage in beginframe mode on low-memory hosts (same shape of bug as #1236).
  Added TODO to unify the bump into compileStage so all paths share one source of truth.

- probeStage.test.ts: add regression test pinning that createCaptureSession
  receives forceScreenshot:true when the stage input carries it but cfg.forceScreenshot
  is false (low-memory mode override). Mirrors worker-count test shape from
  captureStreamingStage.test.ts. Two cases: override active, override inactive.

Addresses review feedback from #1237.
This commit is contained in:
Miguel Ángel
2026-06-06 14:07:06 -04:00
committed by GitHub
parent 164167341f
commit ca3bdd149b
4 changed files with 215 additions and 3 deletions
@@ -746,7 +746,16 @@ export async function plan(
});
let compiled = compileResult.compiled;
const composition = compileResult.composition;
const { deviceScaleFactor, forceScreenshot } = compileResult;
const { deviceScaleFactor } = compileResult;
// Apply the same low-memory mode bump that renderOrchestrator does at
// renderOrchestrator.ts:1598-1606 — compileStage does not consult
// cfg.lowMemoryMode, so the probe would otherwise see forceScreenshot:false
// on a constrained host and launch in beginframe mode (the exact bug #1236
// fixed for the in-process path).
// TODO: move this bump into compileStage so both call sites simplify and
// the rule lives in one place (follow-up; out of scope for #1236 fix).
let forceScreenshot = compileResult.forceScreenshot;
if (cfg.lowMemoryMode) forceScreenshot = true;
// composition.{width,height} are the authored page dimensions. The
// post-supersample output dims are `compileResult.outputWidth/outputHeight`
// — chunks render at output dims, but planHash + composition.json record
@@ -769,6 +778,7 @@ export async function plan(
workDir,
job,
cfg,
forceScreenshot,
log,
assertNotAborted,
compiled,
@@ -1,6 +1,161 @@
import { describe, expect, it } from "bun:test";
import { describe, expect, it, mock } from "bun:test";
import { hasScriptedAudioVolumeAutomation } from "./probeStage.js";
// ── Mocks for runProbeStage tests ────────────────────────────────────────────
// Capture the cfg passed to createCaptureSession so we can assert it carries
// the correct forceScreenshot value (regression for #1236 — probe was launched
// in beginframe mode even when lowMemoryMode demanded screenshot capture).
const capturedCfgs: unknown[] = [];
const mockPage = {
evaluate: async () => ({
timelineKeys: [],
hfDuration: 5,
gsapLoaded: false,
totalDurationMs: 5000,
__hf: {},
}),
};
mock.module("@hyperframes/engine", () => ({
createCaptureSession: async (
_url: string,
_dir: string,
_opts: unknown,
_nullArg: unknown,
cfg: unknown,
) => {
capturedCfgs.push(cfg);
return {
isInitialized: false,
browserConsoleBuffer: [],
page: mockPage,
};
},
initializeSession: async (session: { isInitialized: boolean }) => {
session.isInitialized = true;
},
getCompositionDuration: async () => 5,
closeCaptureSession: async () => {},
}));
mock.module("../../fileServer.js", () => ({
createFileServer: async () => ({
url: "http://127.0.0.1:0",
port: 0,
close: () => {},
addPreHeadScript: () => {},
}),
VIRTUAL_TIME_SHIM: "",
}));
mock.module("../../htmlCompiler.js", () => ({
discoverMediaFromBrowser: async () => [],
discoverAudioVolumeAutomationFromTimeline: async () => [],
discoverVideoVisibilityFromTimeline: async () => [],
recompileWithResolutions: async (c: unknown) => c,
resolveCompositionDurations: async () => [],
}));
mock.module("../shared.js", () => ({
BROWSER_MEDIA_EPSILON: 0.0001,
projectBrowserEndToCompositionTimeline: () => 0,
writeCompiledArtifacts: () => {},
}));
function makeProbeInput(overrides: {
cfgForceScreenshot?: boolean;
stageForceScreenshot?: boolean;
}) {
const cfg = {
forceScreenshot: overrides.cfgForceScreenshot ?? false,
lowMemoryMode: false,
// Minimal EngineConfig fields consumed by probeStage
fps: 30,
quality: "standard",
format: "jpeg",
jpegQuality: 80,
concurrency: "auto",
coresPerWorker: 2.5,
minParallelFrames: 120,
largeRenderThreshold: 1000,
disableGpu: false,
browserGpuMode: "software",
enableBrowserPool: false,
browserTimeout: 120_000,
protocolTimeout: 300_000,
enableChunkedEncode: false,
chunkSizeFrames: 360,
enableStreamingEncode: false,
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,
verifyRuntime: true,
debug: false,
};
return {
projectDir: "/tmp/hf-probe-test-project",
workDir: "/tmp/hf-probe-test-work",
job: {
id: "probe-test",
config: { fps: { num: 30, den: 1 }, quality: "standard" },
status: "queued",
progress: 0,
currentStage: "Probe",
createdAt: new Date(0),
duration: 0,
},
// composition.duration = 0 forces needsBrowser = true, triggering
// the createCaptureSession call we want to inspect.
composition: {
duration: 0,
videos: [],
audios: [],
images: [],
width: 1920,
height: 1080,
},
compiled: {
html: "<html><body><div class='clip' data-duration='5'></div></body></html>",
subCompositions: new Map(),
videos: [],
audios: [],
images: [],
unresolvedCompositions: [],
externalAssets: new Map(),
width: 1920,
height: 1080,
staticDuration: 5,
renderModeHints: { recommendScreenshot: false, reasons: [] },
hasShaderTransitions: false,
},
cfg,
// This is the value the orchestrator/planner threads in after the
// low-memory bump (or any other forceScreenshot override).
forceScreenshot: overrides.stageForceScreenshot ?? false,
width: 1920,
height: 1080,
needsAlpha: false,
deviceScaleFactor: 1,
log: {
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
},
assertNotAborted: () => {},
};
}
describe("hasScriptedAudioVolumeAutomation", () => {
it("ignores non-script volume text", () => {
expect(
@@ -31,3 +186,38 @@ describe("hasScriptedAudioVolumeAutomation", () => {
).toBe(false);
});
});
describe("runProbeStage — forceScreenshot threading", () => {
it("passes forceScreenshot:true to createCaptureSession when stage input carries it but cfg does not (low-memory mode fix #1236)", async () => {
capturedCfgs.length = 0;
const { runProbeStage } = await import("./probeStage.js");
// Simulate renderOrchestrator / plan.ts after the low-memory bump:
// cfg.forceScreenshot = false (compileStage resolved it without the bump)
// stage forceScreenshot = true (orchestrator detected lowMemoryMode and bumped)
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: true });
await runProbeStage(input);
expect(capturedCfgs.length).toBeGreaterThan(0);
const capturedCfg = capturedCfgs[0] as { forceScreenshot: boolean };
expect(capturedCfg.forceScreenshot).toBe(true);
// Caller-owned cfg must not be mutated
expect(input.cfg.forceScreenshot).toBe(false);
});
it("passes forceScreenshot:false through unchanged when neither cfg nor stage input forces it", async () => {
capturedCfgs.length = 0;
const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
await runProbeStage(input);
expect(capturedCfgs.length).toBeGreaterThan(0);
const capturedCfg = capturedCfgs[0] as { forceScreenshot: boolean };
expect(capturedCfg.forceScreenshot).toBe(false);
});
});
@@ -61,6 +61,12 @@ export interface ProbeStageInput {
workDir: string;
job: RenderJob;
cfg: EngineConfig;
/**
* Capture-mode flag threaded from the orchestrator. The stage derives a
* local copy of `cfg` with this value applied to `forceScreenshot`
* before any engine call, so the caller-owned `cfg` is never mutated.
*/
forceScreenshot: boolean;
log: ProducerLogger;
assertNotAborted: () => void;
/** From compileStage. May be replaced via `recompileWithResolutions`. */
@@ -112,6 +118,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
workDir,
job,
cfg,
forceScreenshot,
log,
assertNotAborted,
composition,
@@ -121,6 +128,10 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
deviceScaleFactor,
} = input;
let { compiled } = input;
const probeCfg: EngineConfig =
cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
let fileServer: FileServerHandle | null = null;
let probeSession: CaptureSession | null = null;
let lastBrowserConsole: string[] = [];
@@ -170,7 +181,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
join(workDir, "probe"),
captureOpts,
null,
cfg,
probeCfg,
);
log.info("Waiting for composition to initialize...");
const initStart = Date.now();
@@ -1611,6 +1611,7 @@ export async function executeRenderJob(
workDir,
job,
cfg,
forceScreenshot: captureForceScreenshot,
log,
assertNotAborted,
compiled,