mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(producer): auto low-memory safe render profile (#1225)
## What Adds an auto-detected **low-memory safe render profile**. On hosts at or below 8 GB total RAM, the render pipeline collapses to its cheapest shape instead of running multiple concurrent Chrome instances. When `lowMemoryMode` is active and the user hasn't passed `--workers`, the orchestrator: - **skips auto-worker calibration** — no throwaway second Chrome just to time 5 frames; - **pins to a single worker** — so the probe Chrome is reused for capture, never N concurrent; - **prefers screenshot capture over BeginFrame** — avoids the BeginFrame protocol-timeout → relaunch churn on slow hardware; - logs a one-line explanation of what it did and how to override. Builds on #1221 (merged), which fixed the calibration timeout cap, the `<= 8192` boundary, and added the CLI timeout flags. ## Why Reported in #1218 / #1219: renders on 8 GB laptops sit at low progress for minutes or stall. Root cause (per the triage thread) is architectural — the default pipeline launches up to 4 Chrome instances sequentially/overlapping (probe, calibration, capture, screenshot-fallback), each ~256 MB+, on machines with ~3 GB free. The concurrent browsers drive memory pressure that makes every CDP call slow and spikes V8 GC pauses. #1221 made the timeouts and memory flags *apply correctly*; this PR removes the expensive shape entirely on the machines that can't afford it, rather than tuning it. "Smarter by default." ## How - **`packages/engine/src/services/systemMemory.ts`** (new): one shared `isLowMemorySystem()` / `getSystemTotalMb()`, de-duplicating the `totalmem()` reads previously copied in `config.ts` and `browserManager.ts`. Threshold is inclusive (`<= 8192 MB`) — real "8 GB" hardware reports ~7600–8192 MB after firmware/iGPU reservations, so a strict `<` would skip the optimisation on the very hardware that needs it. - **`config.ts`**: new `lowMemoryMode` field on `EngineConfig`, resolved tri-state — explicit override → `PRODUCER_LOW_MEMORY_MODE` (on/off) → auto-detect from total RAM. - **`renderOrchestrator.ts`**: gate calibration off, pin workers to 1, force screenshot capture, and emit a safe-mode log line when `lowMemoryMode` is set and `--workers` is absent. - **`render.ts`**: `--low-memory-mode` / `--no-low-memory-mode` override (sets the env var the producer's `resolveConfig` reads) + docs table entry. Fully overridable: an explicit `--workers N` restores calibration-free parallelism; `--no-low-memory-mode` / `PRODUCER_LOW_MEMORY_MODE=false` restores the full default shape. ### Deliberately deferred (separate PRs) - **Reuse the probe session for calibration**: only executes on the tier *above* 8 GB (safe-mode skips calibration on the target boxes). A correct BeginFrame-mode reuse would lose calibration's fast-fail-to-screenshot timeout — real risk on a path the reported scenario never hits. Better scoped on its own. - **Retuning `calculateOptimalWorkers`'s `totalmem*0.5/256` memory model**: hot path for *all* renders incl. servers/Lambda, outside this PR's local-laptop scope. ## Test plan - [x] Unit tests added/updated — `systemMemory.test.ts` (8192 boundary cases), `config.test.ts` (tri-state env resolution + explicit-override precedence). Engine suite passes (25 relevant tests). - [x] `tsc` clean across engine/producer/cli; `oxlint` + `oxfmt` clean; removed an unused export so the `fallow --fail-on-issues` dead-code gate stays green. - [x] Documentation updated — `docs/packages/cli.mdx` render-flags table. - [ ] Manual testing on a real ≤ 8 GB host — not yet run; behaviour is unit-covered and the safe path (1 worker + screenshot) is already a supported render shape. Note: one pre-existing producer test (`rejects a maliciously crafted key…`) fails identically on `main` — environment-specific path test, unrelated to this change.
This commit is contained in:
@@ -616,6 +616,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|
||||
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) |
|
||||
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
|
||||
| `--workers` | 1-8 | 4 | Parallel render workers |
|
||||
| `--low-memory-mode` / `--no-low-memory-mode` | — | auto (≤ 8 GB RAM) | Force the low-memory safe render profile on or off. Safe mode pins to 1 worker, uses screenshot capture, and skips auto-worker calibration so the pipeline doesn't launch multiple concurrent Chrome instances on constrained machines. Auto-detection reads **host** RAM (`os.totalmem()`), not cgroup/container limits — containerised or serverless callers (incl. `--docker`) should set `PRODUCER_LOW_MEMORY_MODE` explicitly. Env fallback `PRODUCER_LOW_MEMORY_MODE`. |
|
||||
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV) |
|
||||
| `--browser-gpu` / `--no-browser-gpu` | — | on locally, off in Docker | Use or opt out of host GPU acceleration for local Chrome/WebGL capture |
|
||||
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
|
||||
|
||||
@@ -263,6 +263,15 @@ export default defineCommand({
|
||||
"Increase for complex compositions on slow hardware. Default: 45000 (45 s). " +
|
||||
"Env: PRODUCER_PLAYER_READY_TIMEOUT_MS.",
|
||||
},
|
||||
"low-memory-mode": {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Force the low-memory safe render profile on (--low-memory-mode) or " +
|
||||
"off (--no-low-memory-mode). Safe mode pins to 1 worker, uses " +
|
||||
"screenshot capture, and skips auto-worker calibration to avoid " +
|
||||
"memory thrash on constrained machines. Default: auto-detected from " +
|
||||
"total RAM (<= 8 GB). Env: PRODUCER_LOW_MEMORY_MODE.",
|
||||
},
|
||||
},
|
||||
// `run` is the citty handler for `hyperframes render` — sequential flag
|
||||
// validation + render dispatch. Inherited CRITICAL on main (CRAP 1290);
|
||||
@@ -371,6 +380,14 @@ export default defineCommand({
|
||||
process.env.HF_PAGE_SIDE_COMPOSITING = "false";
|
||||
}
|
||||
|
||||
// ── Override: low-memory safe profile (tri-state) ────────────────────
|
||||
// Absent → auto-detect from total RAM inside resolveConfig. Explicit
|
||||
// --low-memory-mode / --no-low-memory-mode forces it on/off via the env
|
||||
// var the producer's resolveConfig reads.
|
||||
if (args["low-memory-mode"] != null) {
|
||||
process.env.PRODUCER_LOW_MEMORY_MODE = args["low-memory-mode"] ? "true" : "false";
|
||||
}
|
||||
|
||||
// ── Validate max-concurrent-renders ─────────────────────────────────
|
||||
if (args["max-concurrent-renders"] != null) {
|
||||
const parsed = parseInt(args["max-concurrent-renders"], 10);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { resolveConfig, DEFAULT_CONFIG } from "./config.js";
|
||||
import { isLowMemorySystem } from "./services/systemMemory.js";
|
||||
|
||||
describe("resolveConfig", () => {
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
@@ -157,4 +158,33 @@ describe("resolveConfig", () => {
|
||||
expect(config.enablePageSideCompositing).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lowMemoryMode", () => {
|
||||
it("forces on for truthy PRODUCER_LOW_MEMORY_MODE values", () => {
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
for (const v of ["true", "on", "1", "TRUE"]) {
|
||||
process.env.PRODUCER_LOW_MEMORY_MODE = v;
|
||||
expect(resolveConfig().lowMemoryMode).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("forces off for falsy PRODUCER_LOW_MEMORY_MODE values", () => {
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "false");
|
||||
for (const v of ["false", "off", "0", "OFF"]) {
|
||||
process.env.PRODUCER_LOW_MEMORY_MODE = v;
|
||||
expect(resolveConfig().lowMemoryMode).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-detects from total RAM when the env var is unset", () => {
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "");
|
||||
delete process.env.PRODUCER_LOW_MEMORY_MODE;
|
||||
expect(resolveConfig().lowMemoryMode).toBe(isLowMemorySystem());
|
||||
});
|
||||
|
||||
it("explicit override beats both env and auto-detection", () => {
|
||||
setEnv("PRODUCER_LOW_MEMORY_MODE", "true");
|
||||
expect(resolveConfig({ lowMemoryMode: false }).lowMemoryMode).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
* fallbacks for backward compatibility during migration.
|
||||
*/
|
||||
|
||||
import { totalmem } from "os";
|
||||
import {
|
||||
getSystemTotalMb,
|
||||
isLowMemorySystem,
|
||||
LOW_MEMORY_TOTAL_MB_THRESHOLD,
|
||||
} from "./services/systemMemory.js";
|
||||
|
||||
/**
|
||||
* Full engine configuration. All fields are wired through the config
|
||||
@@ -50,6 +54,16 @@ export interface EngineConfig {
|
||||
expectedChromiumMajor?: number;
|
||||
/** Force screenshot capture mode (skip BeginFrame even on Linux). */
|
||||
forceScreenshot: 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.
|
||||
*
|
||||
@@ -194,6 +208,9 @@ export const DEFAULT_CONFIG: EngineConfig = {
|
||||
browserTimeout: 120_000,
|
||||
protocolTimeout: 300_000,
|
||||
forceScreenshot: false,
|
||||
// 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,
|
||||
|
||||
enableChunkedEncode: false,
|
||||
@@ -221,21 +238,17 @@ export const DEFAULT_CONFIG: EngineConfig = {
|
||||
debug: false,
|
||||
};
|
||||
|
||||
function getSystemTotalMb(): number {
|
||||
return Math.floor(totalmem() / (1024 * 1024));
|
||||
}
|
||||
|
||||
function memoryAdaptiveCacheLimit(): number {
|
||||
const total = getSystemTotalMb();
|
||||
if (total < 4096) return 32;
|
||||
if (total <= 8192) return 64;
|
||||
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 <= 8192) return 256;
|
||||
if (total <= LOW_MEMORY_TOTAL_MB_THRESHOLD) return 256;
|
||||
return DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb;
|
||||
}
|
||||
|
||||
@@ -262,6 +275,13 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
|
||||
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();
|
||||
};
|
||||
|
||||
// Env-var layer (backward compat)
|
||||
const fromEnv: Partial<EngineConfig> = {
|
||||
@@ -287,6 +307,7 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
|
||||
: undefined,
|
||||
|
||||
forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
|
||||
lowMemoryMode: resolveLowMemoryMode(),
|
||||
enablePageSideCompositing: envBool(
|
||||
"HF_PAGE_SIDE_COMPOSITING",
|
||||
DEFAULT_CONFIG.enablePageSideCompositing,
|
||||
|
||||
@@ -44,6 +44,11 @@ export type {
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────────
|
||||
export { resolveConfig, DEFAULT_CONFIG, type EngineConfig } from "./config.js";
|
||||
export {
|
||||
getSystemTotalMb,
|
||||
isLowMemorySystem,
|
||||
LOW_MEMORY_TOTAL_MB_THRESHOLD,
|
||||
} from "./services/systemMemory.js";
|
||||
|
||||
// ── Browser management ─────────────────────────────────────────────────────────
|
||||
export {
|
||||
|
||||
@@ -9,8 +9,9 @@ import type { Browser, PuppeteerNode } from "puppeteer-core";
|
||||
import { execSync } from "child_process";
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir, totalmem } from "os";
|
||||
import { homedir } from "os";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
|
||||
|
||||
let _puppeteer: PuppeteerNode | undefined;
|
||||
|
||||
@@ -478,10 +479,6 @@ export function _setPuppeteerForTests(mock: PuppeteerNode | undefined): void {
|
||||
_puppeteer = mock;
|
||||
}
|
||||
|
||||
function getTotalMemMb(): number {
|
||||
return Math.floor(totalmem() / (1024 * 1024));
|
||||
}
|
||||
|
||||
let _cachedVramMb: number | null = null;
|
||||
|
||||
function probeNvidiaVramMb(): number | null {
|
||||
@@ -508,15 +505,15 @@ function getGpuMemBudgetMb(): number {
|
||||
const vram = probeNvidiaVramMb();
|
||||
if (vram) return Math.min(vram, 16384);
|
||||
|
||||
const total = getTotalMemMb();
|
||||
const total = getSystemTotalMb();
|
||||
if (total < 4096) return 512;
|
||||
if (total <= 8192) return 1024;
|
||||
if (total <= LOW_MEMORY_TOTAL_MB_THRESHOLD) return 1024;
|
||||
return Math.min(Math.floor(total / 2), 16384);
|
||||
}
|
||||
|
||||
function getLowMemoryFlags(): string[] {
|
||||
const total = getTotalMemMb();
|
||||
if (total > 8192) return [];
|
||||
const total = getSystemTotalMb();
|
||||
if (total > LOW_MEMORY_TOTAL_MB_THRESHOLD) return [];
|
||||
const heapMb = total < 4096 ? 256 : 512;
|
||||
return [`--js-flags=--max-old-space-size=${heapMb}`];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isLowMemorySystem, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
|
||||
|
||||
describe("isLowMemorySystem", () => {
|
||||
it("treats sub-threshold RAM as low-memory", () => {
|
||||
expect(isLowMemorySystem(4096)).toBe(true);
|
||||
expect(isLowMemorySystem(6000)).toBe(true);
|
||||
expect(isLowMemorySystem(7600)).toBe(true);
|
||||
});
|
||||
|
||||
it("includes machines reporting exactly the threshold (8 GB boundary)", () => {
|
||||
// Real "8 GB" hosts report at/just under 8192 MiB after firmware/iGPU
|
||||
// reservations — the inclusive bound is the whole point (issue #1219).
|
||||
expect(isLowMemorySystem(LOW_MEMORY_TOTAL_MB_THRESHOLD)).toBe(true);
|
||||
expect(isLowMemorySystem(8192)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats above-threshold RAM as normal", () => {
|
||||
expect(isLowMemorySystem(8193)).toBe(false);
|
||||
expect(isLowMemorySystem(16384)).toBe(false);
|
||||
expect(isLowMemorySystem(65536)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* System-memory probing for memory-adaptive render behaviour.
|
||||
*
|
||||
* The render pipeline tunes itself to the host's RAM in several places —
|
||||
* frame-cache sizes (`config.ts`), Chrome heap + GPU budget flags
|
||||
* (`browserManager.ts`), and worker count (`parallelCoordinator.ts`).
|
||||
* They all need the same "how much memory does this box have" reading, so
|
||||
* it lives here once instead of being re-derived inline.
|
||||
*/
|
||||
|
||||
import { totalmem } from "os";
|
||||
|
||||
/** Total physical RAM in MiB. */
|
||||
export function getSystemTotalMb(): number {
|
||||
return Math.floor(totalmem() / (1024 * 1024));
|
||||
}
|
||||
|
||||
/**
|
||||
* Total-RAM ceiling (MiB) at or below which the host is treated as
|
||||
* memory-constrained. Tuned to the 8 GB laptops in
|
||||
* heygen-com/hyperframes#1218 / #1219: on those boxes the default render
|
||||
* shape (probe Chrome + a throwaway calibration Chrome + N capture
|
||||
* workers) thrashes, so the pipeline collapses to its cheapest form.
|
||||
*
|
||||
* `<=` deliberately includes machines that report exactly 8192 MiB —
|
||||
* real "8 GB" hardware reports anywhere from ~7600 to 8192 MiB once
|
||||
* firmware/integrated-GPU reservations are subtracted, and a strict `<`
|
||||
* would skip the optimisation on the very hardware that needs it.
|
||||
*/
|
||||
export const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
|
||||
|
||||
/**
|
||||
* True when the host should run the low-memory render profile.
|
||||
*
|
||||
* Keyed on total physical RAM, not free memory: free memory swings
|
||||
* moment to moment and is underreported on macOS, whereas total RAM is a
|
||||
* stable proxy for "how many concurrent Chrome instances can this box
|
||||
* survive". Accepts an explicit `totalMb` so callers (and tests) can pass
|
||||
* a known value instead of re-probing.
|
||||
*
|
||||
* Caveat: `os.totalmem()` reports the *host's* physical RAM, not a
|
||||
* cgroup/container memory limit. A 4 GB container on a 32 GB host will not
|
||||
* auto-flag as low-memory, and an 8 GB container on a 64 GB host won't
|
||||
* either. Containerised and serverless callers (Docker `--docker` renders,
|
||||
* Lambda) that want a specific profile should set `PRODUCER_LOW_MEMORY_MODE`
|
||||
* explicitly rather than relying on auto-detection. Hosts whose *total* RAM
|
||||
* is genuinely <= the threshold (laptops, small VMs, small Lambda tiers) are
|
||||
* detected correctly regardless of container nesting.
|
||||
*/
|
||||
export function isLowMemorySystem(totalMb: number = getSystemTotalMb()): boolean {
|
||||
return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;
|
||||
}
|
||||
@@ -129,6 +129,18 @@ export function resolveRenderWorkerCount(
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Low-memory safe profile pins capture to a single worker (unless the user
|
||||
// asked for a specific count) so the pipeline never runs N concurrent
|
||||
// Chrome instances on a constrained host. Kept here, alongside the other
|
||||
// worker-count decisions, so the "why workers=N" log stays coherent across
|
||||
// every path into capture.
|
||||
if (cfg.lowMemoryMode && requestedWorkers === undefined) {
|
||||
log.info(
|
||||
"[Render] Low-memory profile — pinning to 1 capture worker (auto-worker calibration skipped).",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const captureCost = combineCaptureCostEstimates(
|
||||
estimateCaptureCostMultiplier(compiled),
|
||||
measuredCaptureCost,
|
||||
|
||||
@@ -406,6 +406,7 @@ function createConfig(): EngineConfig {
|
||||
browserTimeout: 120000,
|
||||
protocolTimeout: 300000,
|
||||
forceScreenshot: false,
|
||||
lowMemoryMode: false,
|
||||
enableChunkedEncode: false,
|
||||
chunkSizeFrames: 360,
|
||||
enableStreamingEncode: false,
|
||||
@@ -622,6 +623,53 @@ describe("resolveRenderWorkerCount", () => {
|
||||
expect(log.warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("pins to 1 worker in low-memory mode when no explicit --workers is set", () => {
|
||||
const log = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
const workers = resolveRenderWorkerCount(
|
||||
900,
|
||||
undefined,
|
||||
{ ...cfg, lowMemoryMode: true },
|
||||
{
|
||||
hasShaderTransitions: false,
|
||||
renderModeHints: { recommendScreenshot: false, reasons: [] },
|
||||
},
|
||||
log,
|
||||
);
|
||||
|
||||
expect(workers).toBe(1);
|
||||
expect(log.info).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("respects explicit --workers in low-memory mode (only the pin is bypassed)", () => {
|
||||
const log = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
const workers = resolveRenderWorkerCount(
|
||||
900,
|
||||
4,
|
||||
{ ...cfg, lowMemoryMode: true, coresPerWorker: 2.5 },
|
||||
{
|
||||
hasShaderTransitions: false,
|
||||
renderModeHints: { recommendScreenshot: false, reasons: [] },
|
||||
},
|
||||
log,
|
||||
);
|
||||
|
||||
expect(workers).toBe(4);
|
||||
});
|
||||
|
||||
it("keeps baseline auto workers after screenshot fallback when measured capture is cheap", () => {
|
||||
const log = {
|
||||
error: vi.fn(),
|
||||
|
||||
@@ -74,6 +74,8 @@ import {
|
||||
convertTransfer,
|
||||
type ElementStackingInfo,
|
||||
type HfTransitionMeta,
|
||||
getSystemTotalMb,
|
||||
LOW_MEMORY_TOTAL_MB_THRESHOLD,
|
||||
} from "@hyperframes/engine";
|
||||
import { join, dirname, resolve } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -1565,6 +1567,29 @@ export async function executeRenderJob(
|
||||
// `cfg.forceScreenshot` directly.
|
||||
let captureForceScreenshot = compileResult.forceScreenshot;
|
||||
|
||||
// Low-memory safe profile: on memory-constrained hosts the default render
|
||||
// shape (probe Chrome + a throwaway calibration Chrome + N capture
|
||||
// workers) thrashes — concurrent Chrome instances drive memory pressure
|
||||
// that slows every CDP call and spikes V8 GC, surfacing as the slow/stuck
|
||||
// renders in heygen-com/hyperframes#1218 / #1219. Collapse to the cheapest
|
||||
// shape: skip auto-worker calibration (the gate below), pin to a single
|
||||
// worker (resolved below), and prefer screenshot capture over BeginFrame
|
||||
// (which avoids the BeginFrame protocol-timeout → relaunch churn on slow
|
||||
// hardware). Auto-detected from total RAM; opt out with
|
||||
// `--no-low-memory-mode` / PRODUCER_LOW_MEMORY_MODE=false. An explicit
|
||||
// `--workers N` still gets screenshot capture + skipped calibration; only
|
||||
// the single-worker pin is bypassed.
|
||||
if (cfg.lowMemoryMode) {
|
||||
captureForceScreenshot = true;
|
||||
log.info(
|
||||
"[Render] Low-memory render profile active — " +
|
||||
"screenshot capture, auto-worker calibration skipped" +
|
||||
(job.config.workers === undefined ? ", pinned to 1 worker" : "") +
|
||||
". Override with --no-low-memory-mode or PRODUCER_LOW_MEMORY_MODE=false.",
|
||||
{ totalMemMb: getSystemTotalMb(), thresholdMb: LOW_MEMORY_TOTAL_MB_THRESHOLD },
|
||||
);
|
||||
}
|
||||
|
||||
const probeResult = await runProbeStage({
|
||||
projectDir,
|
||||
workDir,
|
||||
@@ -1704,7 +1729,12 @@ export async function executeRenderJob(
|
||||
const htmlInCanvasDetected = compiled.renderModeHints.reasons.some(
|
||||
(r) => r.code === "htmlInCanvas",
|
||||
);
|
||||
if (job.config.workers === undefined && totalFrames >= 60 && !htmlInCanvasDetected) {
|
||||
if (
|
||||
job.config.workers === undefined &&
|
||||
totalFrames >= 60 &&
|
||||
!htmlInCanvasDetected &&
|
||||
!cfg.lowMemoryMode
|
||||
) {
|
||||
const outcome = await runCaptureCalibration({
|
||||
cfg,
|
||||
fileServer,
|
||||
@@ -1726,6 +1756,8 @@ export async function executeRenderJob(
|
||||
}
|
||||
}
|
||||
|
||||
// Low-memory safe-mode's single-worker pin lives inside
|
||||
// resolveRenderWorkerCount so its "why workers=N" logging stays coherent.
|
||||
let workerCount = resolveRenderWorkerCount(
|
||||
totalFrames,
|
||||
job.config.workers,
|
||||
|
||||
Reference in New Issue
Block a user