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:
James Russo
2026-06-05 16:24:03 -07:00
committed by GitHub
parent a7cc9161a7
commit bacfb17538
11 changed files with 255 additions and 17 deletions
+1
View File
@@ -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) |
+17
View File
@@ -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);
+30
View File
@@ -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);
});
});
});
+28 -7
View File
@@ -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,
+5
View File
@@ -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,