Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
18 changes: 10 additions & 8 deletions docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
</Tip>

<Tip>
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`.
Comment thread
miguel-heygen marked this conversation as resolved.
</Tip>

### 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

Expand Down
22 changes: 6 additions & 16 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -33,13 +33,6 @@ const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const VALID_FORMAT = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { 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",
Expand Down Expand Up @@ -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") +
Expand Down Expand Up @@ -307,7 +297,7 @@ export default defineCommand({
fps,
quality,
format,
workers: workerCount,
workers,
gpu: useGpu,
hdr: args.hdr ?? false,
crf,
Expand All @@ -319,7 +309,7 @@ export default defineCommand({
fps,
quality,
format,
workers: workerCount,
workers,
gpu: useGpu,
hdr: args.hdr ?? false,
crf,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
2 changes: 1 addition & 1 deletion packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function trackRenderComplete(props: {
durationMs: number;
fps: number;
quality: string;
workers: number;
workers?: number;
docker: boolean;
gpu: boolean;
// Composition metadata
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
5 changes: 2 additions & 3 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"] : []),
Expand Down
30 changes: 30 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!DOCTYPE html>
<html><body>
<canvas id="scene"></canvas>
<script>
import * as THREE from "three";
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById("scene") });
const composer = new EffectComposer(renderer);
</script>
</body></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 = `<!DOCTYPE html>
<html><body>
<canvas id="scene"></canvas>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.min.js"></script>
</body></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 = `<!DOCTYPE html>
<html><body>
Expand Down
43 changes: 38 additions & 5 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -76,6 +76,11 @@ function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
const INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
const COMPILER_MOUNT_BLOCK_START = "/* __HF_COMPILER_MOUNT_START__ */";
const COMPILER_MOUNT_BLOCK_END = "/* __HF_COMPILER_MOUNT_END__ */";
const WEBGL_CONTEXT_PATTERN = /\bgetContext\s*\(\s*["'`](?:webgl2?|experimental-webgl)["'`]\s*\)/i;
const WEBGL_INLINE_PATTERN =
/\b(?:THREE\.(?:WebGLRenderer|PMREMGenerator)|WebGL(?:2)?RenderingContext|PMREMGenerator|EffectComposer|UnrealBloomPass)\b|(?:import\s*\(?\s*["'`]three(?:\/|["'`]))/i;
const WEBGL_SCRIPT_SRC_PATTERN =
/(?:@react-three\/(?:fiber|drei))|(?:^|[/@-])three(?:[./@-]|(?:\.module|\.min)?(?:\.js)?(?:$|[?#/]))/i;

function stripJsComments(source: string): string {
return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
Expand Down Expand Up @@ -105,21 +110,49 @@ export function detectRenderModeHints(html: string): RenderModeHints {

let scriptMatch: RegExpExecArray | null;
const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
let detectedInlineRequestAnimationFrame = false;
let detectedWebgl = false;
while ((scriptMatch = scriptPattern.exec(html)) !== null) {
const attrs = scriptMatch[1] || "";
if (/\bsrc\s*=/i.test(attrs)) continue;
if (/\bsrc\s*=/i.test(attrs)) {
const srcMatch = attrs.match(/\bsrc\s*=\s*["']([^"']+)["']/i);
if (srcMatch?.[1] && WEBGL_SCRIPT_SRC_PATTERN.test(srcMatch[1])) {
detectedWebgl = true;
}
continue;
}
const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
if (!/requestAnimationFrame\s*\(/.test(content)) continue;
if (!detectedInlineRequestAnimationFrame && /requestAnimationFrame\s*\(/.test(content)) {
detectedInlineRequestAnimationFrame = true;
}
if (
!detectedWebgl &&
(WEBGL_CONTEXT_PATTERN.test(content) || WEBGL_INLINE_PATTERN.test(content))
) {
detectedWebgl = true;
}
}

if (detectedInlineRequestAnimationFrame) {
reasons.push({
code: "requestAnimationFrame",
message:
"Detected raw requestAnimationFrame() in an inline script. This render is routed through screenshot capture mode with virtual time enabled.",
});
break;
}

if (detectedWebgl) {
reasons.push({
code: "webgl",
message:
"Detected WebGL/Three.js scene setup. GPU-heavy compositions often need fewer auto workers to avoid Chrome compositor starvation.",
});
}

return {
recommendScreenshot: reasons.length > 0,
recommendScreenshot: reasons.some(
(reason) => reason.code === "iframe" || reason.code === "requestAnimationFrame",
),
reasons,
};
}
Expand Down
Loading
Loading