diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx
index aba45fe16..3110bfa3f 100644
--- a/docs/guides/rendering.mdx
+++ b/docs/guides/rendering.mdx
@@ -154,16 +154,13 @@ Each render worker launches a **separate Chrome browser process** to capture fra
### Default behavior
-By default, Hyperframes uses **half of your CPU cores, capped at 4**:
+By default, HyperFrames uses a frame-aware auto heuristic instead of a fixed per-machine table:
-| Machine | CPU cores | Default workers |
-|---------|-----------|----------------|
-| MacBook Air (M1) | 8 | 4 |
-| MacBook Pro (M3) | 12 | 4 (capped) |
-| 4-core laptop | 4 | 2 |
-| 2-core VM | 2 | 1 |
+- very short renders stay single-worker, because parallel startup overhead outweighs the gain
+- longer renders scale up based on CPU, available memory, and total frame count
+- very large renders are capped again to avoid Chrome/FFmpeg contention
-This is intentionally conservative. Each worker spawns its own Chrome process, so the per-worker overhead is significant. Fewer workers avoids resource contention with FFmpeg encoding and your other applications.
+This is intentionally conservative. Each worker spawns its own Chrome process, so the per-worker overhead is significant. Fewer workers avoids resource contention with FFmpeg encoding, GPU-heavy capture, and your other applications.
### Choosing a worker count
@@ -182,11 +179,16 @@ npx hyperframes render --workers 8 --output output.mp4
Start with the default. If renders feel slow and your system has headroom (check Activity Monitor / `htop`), try increasing `--workers`. If you see high memory pressure or fan noise, reduce it.
+
+ If a WebGL-heavy, Three.js, or bloom-heavy composition times out under `--workers auto`, try `--workers 2` before dropping all the way to sequential capture. Scenes with multiple actively seeking video elements may still need `--workers 1`.
+
+
### When to use 1 worker
- Short compositions (under 2 seconds / 60 frames) — parallelism overhead exceeds the benefit
- Low-memory machines (4 GB or less)
- Running renders alongside other heavy processes (video editing, large builds)
+- Multi-video compositions that keep timing out under parallel capture
### When to increase workers
diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts
index 7c3f9d5ec..078a23cf0 100644
--- a/packages/cli/src/commands/render.ts
+++ b/packages/cli/src/commands/render.ts
@@ -11,7 +11,7 @@ export const examples: Example[] = [
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
["HDR output (H.265 10-bit)", "hyperframes render --hdr --output hdr-output.mp4"],
];
-import { cpus, freemem, tmpdir } from "node:os";
+import { freemem, tmpdir } from "node:os";
import { resolve, dirname, join, basename } from "node:path";
import { execFileSync, spawn } from "node:child_process";
import { resolveProject } from "../utils/project.js";
@@ -33,13 +33,6 @@ const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const VALID_FORMAT = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
-const CPU_CORE_COUNT = cpus().length;
-
-/** 3/4 of CPU cores, capped at 8. Each worker spawns a Chrome process (~256 MB). */
-function defaultWorkerCount(): number {
- return Math.max(1, Math.min(Math.floor((CPU_CORE_COUNT * 3) / 4), 8));
-}
-
export default defineCommand({
meta: {
name: "render",
@@ -216,12 +209,9 @@ export default defineCommand({
}
// ── Print render plan ─────────────────────────────────────────────────
- const workerCount = workers ?? defaultWorkerCount();
if (!quiet) {
const workerLabel =
- args.workers != null
- ? `${workerCount} workers`
- : `${workerCount} workers (auto — ${CPU_CORE_COUNT} cores detected)`;
+ workers != null ? `${workers} workers` : "workers auto (frame-aware heuristic)";
console.log("");
console.log(
c.accent("\u25C6") +
@@ -307,7 +297,7 @@ export default defineCommand({
fps,
quality,
format,
- workers: workerCount,
+ workers,
gpu: useGpu,
hdr: args.hdr ?? false,
crf,
@@ -319,7 +309,7 @@ export default defineCommand({
fps,
quality,
format,
- workers: workerCount,
+ workers,
gpu: useGpu,
hdr: args.hdr ?? false,
crf,
@@ -335,7 +325,7 @@ interface RenderOptions {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: "mp4" | "webm" | "mov";
- workers: number;
+ workers?: number;
gpu: boolean;
hdr: boolean;
crf?: number;
@@ -601,7 +591,7 @@ function trackRenderMetrics(
durationMs: elapsedMs,
fps: options.fps,
quality: options.quality,
- workers: options.workers,
+ workers: job.perfSummary?.workers ?? options.workers,
docker,
gpu: options.gpu,
compositionDurationMs,
diff --git a/packages/cli/src/docs/rendering.md b/packages/cli/src/docs/rendering.md
index 83aa596db..4dd49b040 100644
--- a/packages/cli/src/docs/rendering.md
+++ b/packages/cli/src/docs/rendering.md
@@ -26,4 +26,6 @@ Requires: Docker installed and running.
- Use `draft` quality for fast previews during development
- Use `npx hyperframes benchmark` to find optimal settings
-- 4 workers is usually the sweet spot for most compositions
+- Leave `--workers` on `auto` unless you have a reason to override it
+- If a WebGL-heavy or bloom-heavy scene times out under `auto`, try `--workers 2`
+- If a composition has multiple actively seeking video elements, try `--workers 1`
diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts
index f7db477e9..1dd6007bd 100644
--- a/packages/cli/src/telemetry/events.ts
+++ b/packages/cli/src/telemetry/events.ts
@@ -8,7 +8,7 @@ export function trackRenderComplete(props: {
durationMs: number;
fps: number;
quality: string;
- workers: number;
+ workers?: number;
docker: boolean;
gpu: boolean;
// Composition metadata
diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts
index 287afba65..e4566eb24 100644
--- a/packages/cli/src/utils/dockerRunArgs.test.ts
+++ b/packages/cli/src/utils/dockerRunArgs.test.ts
@@ -159,4 +159,12 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("10M");
expect(args).not.toContain("--crf");
});
+
+ it("omits --workers when auto-selection should happen inside the container", () => {
+ const args = buildDockerRunArgs({
+ ...FIXED_INPUT,
+ options: { ...BASE, workers: undefined },
+ });
+ expect(args).not.toContain("--workers");
+ });
});
diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts
index b56b5aef5..6ec926103 100644
--- a/packages/cli/src/utils/dockerRunArgs.ts
+++ b/packages/cli/src/utils/dockerRunArgs.ts
@@ -22,7 +22,7 @@ export interface DockerRenderOptions {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: "mp4" | "webm" | "mov";
- workers: number;
+ workers?: number;
gpu: boolean;
hdr: boolean;
crf?: number;
@@ -54,8 +54,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
options.quality,
"--format",
options.format,
- "--workers",
- String(options.workers),
+ ...(options.workers != null ? ["--workers", String(options.workers)] : []),
...(options.crf != null ? ["--crf", String(options.crf)] : []),
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
...(options.quiet ? ["--quiet"] : []),
diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts
index 6653c84ce..7f8284bc9 100644
--- a/packages/producer/src/services/htmlCompiler.test.ts
+++ b/packages/producer/src/services/htmlCompiler.test.ts
@@ -327,6 +327,36 @@ describe("detectRenderModeHints", () => {
expect(result.reasons.map((reason) => reason.code)).toEqual(["requestAnimationFrame"]);
});
+ it("detects inline WebGL and Three.js scenes without forcing screenshot mode", () => {
+ const html = `
+
+
+
+`;
+
+ const result = detectRenderModeHints(html);
+
+ expect(result.recommendScreenshot).toBe(false);
+ expect(result.reasons.map((reason) => reason.code)).toEqual(["webgl"]);
+ });
+
+ it("detects external Three.js scripts as WebGL-heavy", () => {
+ const html = `
+
+
+
+`;
+
+ const result = detectRenderModeHints(html);
+
+ expect(result.recommendScreenshot).toBe(false);
+ expect(result.reasons.map((reason) => reason.code)).toEqual(["webgl"]);
+ });
+
it("ignores requestAnimationFrame inside comments and external scripts", () => {
const html = `
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index 892c292de..48ef7efab 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -53,7 +53,7 @@ export interface CompiledComposition {
renderModeHints: RenderModeHints;
}
-export type RenderModeHintCode = "iframe" | "requestAnimationFrame";
+export type RenderModeHintCode = "iframe" | "requestAnimationFrame" | "webgl";
export interface RenderModeHint {
code: RenderModeHintCode;
@@ -76,6 +76,11 @@ function dedupeElementsById(elements: T[]): T[] {
const INLINE_SCRIPT_PATTERN = /