mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user