Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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-detected from total RAM; 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) |
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions packages/engine/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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>();
Expand Down Expand Up @@ -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);
});
});
});
27 changes: 22 additions & 5 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* fallbacks for backward compatibility during migration.
*/

import { totalmem } from "os";
import { getSystemTotalMb, isLowMemorySystem } from "./services/systemMemory.js";

/**
* Full engine configuration. All fields are wired through the config
Expand Down Expand Up @@ -50,6 +50,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.
*
Expand Down Expand Up @@ -194,6 +204,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,
Expand Down Expand Up @@ -221,10 +234,6 @@ 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;
Expand Down Expand Up @@ -262,6 +271,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> = {
Expand All @@ -287,6 +303,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,
Expand Down
5 changes: 5 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 4 additions & 7 deletions packages/engine/src/services/browserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from "./systemMemory.js";

let _puppeteer: PuppeteerNode | undefined;

Expand Down Expand Up @@ -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 {
Expand All @@ -508,14 +505,14 @@ 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;
return Math.min(Math.floor(total / 2), 16384);
}

function getLowMemoryFlags(): string[] {
const total = getTotalMemMb();
const total = getSystemTotalMb();
if (total > 8192) return [];
const heapMb = total < 4096 ? 256 : 512;
return [`--js-flags=--max-old-space-size=${heapMb}`];
Expand Down
23 changes: 23 additions & 0 deletions packages/engine/src/services/systemMemory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
43 changes: 43 additions & 0 deletions packages/engine/src/services/systemMemory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* 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.
*/
export function isLowMemorySystem(totalMb: number = getSystemTotalMb()): boolean {
return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;
}
51 changes: 42 additions & 9 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 system detected — using safe render profile: " +
"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,
Expand Down Expand Up @@ -1703,7 +1728,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,
Expand All @@ -1725,14 +1755,17 @@ export async function executeRenderJob(
}
}

let workerCount = resolveRenderWorkerCount(
totalFrames,
job.config.workers,
cfg,
compiled,
log,
captureCalibration?.estimate,
);
let workerCount =
cfg.lowMemoryMode && job.config.workers === undefined
? 1
: resolveRenderWorkerCount(
totalFrames,
job.config.workers,
cfg,
compiled,
log,
captureCalibration?.estimate,
);

if (workerCount > 1 && probeSession) {
lastBrowserConsole = probeSession.browserConsoleBuffer;
Expand Down
Loading