mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 21:29:34 +00:00
## 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.
191 lines
6.1 KiB
TypeScript
191 lines
6.1 KiB
TypeScript
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>();
|
|
|
|
function setEnv(key: string, value: string) {
|
|
savedEnv.set(key, process.env[key]);
|
|
process.env[key] = value;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
savedEnv.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const [key, value] of savedEnv) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("returns defaults when no overrides or env vars are set", () => {
|
|
const config = resolveConfig();
|
|
expect(config.fps).toBe(30);
|
|
expect(config.quality).toBe("standard");
|
|
expect(config.format).toBe("jpeg");
|
|
expect(config.jpegQuality).toBe(80);
|
|
expect(config.browserGpuMode).toBe("software");
|
|
expect(config.enableStreamingEncode).toBe(true);
|
|
expect(config.streamingEncodeMaxDurationSeconds).toBe(240);
|
|
expect(config.audioGain).toBe(1);
|
|
expect(config.debug).toBe(false);
|
|
});
|
|
|
|
it("applies explicit overrides over defaults", () => {
|
|
const config = resolveConfig({ fps: 60, debug: true });
|
|
expect(config.fps).toBe(60);
|
|
expect(config.debug).toBe(true);
|
|
// Non-overridden fields remain at defaults
|
|
expect(config.quality).toBe("standard");
|
|
});
|
|
|
|
it("reads numeric env vars with PRODUCER_ prefix", () => {
|
|
setEnv("PRODUCER_MAX_WORKERS", "4");
|
|
setEnv("PRODUCER_CORES_PER_WORKER", "3");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.concurrency).toBe(4);
|
|
expect(config.coresPerWorker).toBe(3);
|
|
});
|
|
|
|
it("reads boolean env vars (true/false strings)", () => {
|
|
setEnv("PRODUCER_DISABLE_GPU", "true");
|
|
setEnv("PRODUCER_ENABLE_BROWSER_POOL", "true");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.disableGpu).toBe(true);
|
|
expect(config.enableBrowserPool).toBe(true);
|
|
});
|
|
|
|
it("lets env vars opt out of default streaming encode", () => {
|
|
setEnv("PRODUCER_ENABLE_STREAMING_ENCODE", "false");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.enableStreamingEncode).toBe(false);
|
|
});
|
|
|
|
it("reads the streaming encode duration cutoff from env", () => {
|
|
setEnv("PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS", "120");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.streamingEncodeMaxDurationSeconds).toBe(120);
|
|
});
|
|
|
|
it("clamps negative streaming encode duration cutoff env values to zero", () => {
|
|
setEnv("PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS", "-1");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.streamingEncodeMaxDurationSeconds).toBe(0);
|
|
});
|
|
|
|
it("treats non-'true' boolean env vars as false", () => {
|
|
setEnv("PRODUCER_DISABLE_GPU", "yes");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.disableGpu).toBe(false);
|
|
});
|
|
|
|
it("reads browser GPU mode from env", () => {
|
|
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.browserGpuMode).toBe("hardware");
|
|
});
|
|
|
|
it("accepts 'auto' as a valid browser GPU mode env value", () => {
|
|
setEnv("PRODUCER_BROWSER_GPU_MODE", "auto");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.browserGpuMode).toBe("auto");
|
|
});
|
|
|
|
it("falls back to software browser GPU mode for invalid env values", () => {
|
|
setEnv("PRODUCER_BROWSER_GPU_MODE", "native");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.browserGpuMode).toBe("software");
|
|
});
|
|
|
|
it("explicit overrides take precedence over env vars", () => {
|
|
setEnv("PRODUCER_CORES_PER_WORKER", "5");
|
|
|
|
const config = resolveConfig({ coresPerWorker: 8 });
|
|
expect(config.coresPerWorker).toBe(8);
|
|
});
|
|
|
|
it("falls back to defaults for invalid numeric env vars", () => {
|
|
setEnv("PRODUCER_CORES_PER_WORKER", "not-a-number");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.coresPerWorker).toBe(DEFAULT_CONFIG.coresPerWorker);
|
|
});
|
|
|
|
it("clamps chunkSizeFrames to minimum of 120", () => {
|
|
setEnv("PRODUCER_CHUNK_SIZE_FRAMES", "50");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.chunkSizeFrames).toBe(120);
|
|
});
|
|
|
|
it("clamps frameDataUriCacheLimit to minimum of 32", () => {
|
|
setEnv("PRODUCER_FRAME_DATA_URI_CACHE_LIMIT", "10");
|
|
|
|
const config = resolveConfig();
|
|
expect(config.frameDataUriCacheLimit).toBe(32);
|
|
});
|
|
|
|
describe("enablePageSideCompositing (HF_PAGE_SIDE_COMPOSITING)", () => {
|
|
it("defaults to true", () => {
|
|
const config = resolveConfig();
|
|
expect(config.enablePageSideCompositing).toBe(true);
|
|
});
|
|
|
|
it("disabled when HF_PAGE_SIDE_COMPOSITING=false", () => {
|
|
setEnv("HF_PAGE_SIDE_COMPOSITING", "false");
|
|
const config = resolveConfig();
|
|
expect(config.enablePageSideCompositing).toBe(false);
|
|
});
|
|
|
|
it("explicit override wins over the env var", () => {
|
|
setEnv("HF_PAGE_SIDE_COMPOSITING", "true");
|
|
const config = resolveConfig({ enablePageSideCompositing: false });
|
|
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);
|
|
});
|
|
});
|
|
});
|