From 957acbd538372fa74d3395f315ec2278a6637930 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 27 Mar 2026 20:39:04 +0000 Subject: [PATCH] feat(cli): smart default worker count based on CPU cores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded default of 4 workers with a CPU-aware heuristic: half of available CPU cores, capped at 4. Each worker spawns a separate Chrome browser process (~256MB RAM each), so the previous default of 4 caused resource contention on smaller machines. The new defaults: 2-core laptop → 1 worker 4-core laptop → 2 workers 8-core desktop → 4 workers 16-core server → 4 workers (capped) Also adds --workers auto flag support, improves help text to explain what workers do, and adds a Workers section to the rendering docs with guidance on when to increase or decrease parallelism. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/rendering.mdx | 49 +++++++++++++++- packages/cli/src/commands/render.ts | 91 +++++++++++++++++++++++------ 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index 388407e09..6c7a480ad 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -116,11 +116,57 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4 with the [ | `--output` | path | `renders/.mp4` | Output file path | | `--fps` | 24, 30, 60 | 30 | Frames per second | | `--quality` | draft, standard, high | standard | Encoding quality preset | -| `--workers` | 1-8 | 4 | Parallel render workers | +| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) | | `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) | | `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) | | `--quiet` | — | off | Suppress verbose output | +## Workers + +Each render worker launches a **separate Chrome browser process** to capture frames in parallel. More workers can speed up rendering, but each one consumes ~256 MB of RAM and significant CPU. + +### Default behavior + +By default, Hyperframes uses **half of your CPU cores, capped at 4**: + +| 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 | + +This is intentionally conservative. Unlike tools that open multiple browser tabs in a single process, Hyperframes spawns separate Chrome processes per worker — the per-worker overhead is higher, so fewer workers avoids resource contention with FFmpeg encoding and your other applications. + +### Choosing a worker count + +```bash Terminal +# Explicit worker count +npx hyperframes render --workers 1 --output output.mp4 + +# Let Hyperframes pick based on your CPU +npx hyperframes render --workers auto --output output.mp4 + +# Maximum parallelism (use with caution on laptops) +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. + + +### 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) + +### When to increase workers + +- Long compositions (30+ seconds) on a machine with 8+ cores and 16+ GB RAM +- Dedicated render machines or CI runners +- Docker mode on a well-provisioned host + ## Tips @@ -128,7 +174,6 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4 with the [ - Use `npx hyperframes benchmark` to find optimal settings for your system -- 4 workers is usually the sweet spot for most compositions - Docker mode is slower but guarantees [identical output](/concepts/determinism) across platforms - For compositions with many frames, `--gpu` can significantly speed up local encoding diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 805e01d64..d81ff7ab0 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,5 +1,6 @@ import { defineCommand } from "citty"; import { existsSync, mkdirSync, statSync } from "node:fs"; +import { cpus } from "node:os"; import { resolve, dirname, join } from "node:path"; import { resolveProject } from "../utils/project.js"; import { loadProducer } from "../utils/producer.js"; @@ -12,6 +13,24 @@ const VALID_FPS = new Set([24, 30, 60]); const VALID_QUALITY = new Set(["draft", "standard", "high"]); const VALID_FORMAT = new Set(["mp4", "webm"]); +/** + * Calculate a conservative default worker count for CLI use. + * + * Uses half of available CPU cores, capped at 4. Each worker spawns a + * separate Chrome browser process (~256 MB RAM each), so we default lower + * than a production server would. Remotion uses a similar heuristic + * (50% of CPU threads) but can use tabs within a single browser — we need + * separate processes, so the per-worker cost is higher. + * + * 2-core laptop → 1 worker + * 4-core laptop → 2 workers + * 8-core desktop → 4 workers + * 16-core server → 4 workers (capped) + */ +function defaultWorkerCount(): number { + return Math.max(1, Math.min(Math.floor(cpus().length / 2), 4)); +} + export default defineCommand({ meta: { name: "render", @@ -24,19 +43,47 @@ Examples: hyperframes render --docker --output deterministic.mp4`, }, args: { - dir: { type: "positional", description: "Project directory", required: false }, - output: { type: "string", description: "Output path (default: renders/.mp4)" }, - fps: { type: "string", description: "Frame rate: 24, 30, 60", default: "30" }, - quality: { type: "string", description: "Quality: draft, standard, high", default: "standard" }, + dir: { + type: "positional", + description: "Project directory", + required: false, + }, + output: { + type: "string", + description: "Output path (default: renders/.mp4)", + }, + fps: { + type: "string", + description: "Frame rate: 24, 30, 60", + default: "30", + }, + quality: { + type: "string", + description: "Quality: draft, standard, high", + default: "standard", + }, format: { type: "string", description: "Output format: mp4, webm (WebM renders with transparency)", default: "mp4", }, - workers: { type: "string", description: "Parallel workers 1-8" }, - docker: { type: "boolean", description: "Use Docker for deterministic render", default: false }, + workers: { + type: "string", + description: + "Parallel render workers (1-8 or 'auto'). Default: half your CPU cores, max 4. " + + "Each worker launches a separate Chrome process.", + }, + docker: { + type: "boolean", + description: "Use Docker for deterministic render", + default: false, + }, gpu: { type: "boolean", description: "Use GPU encoding", default: false }, - quiet: { type: "boolean", description: "Suppress verbose output", default: false }, + quiet: { + type: "boolean", + description: "Suppress verbose output", + default: false, + }, }, async run({ args }) { // ── Resolve project ──────────────────────────────────────────────────── @@ -68,10 +115,10 @@ Examples: // ── Validate workers ────────────────────────────────────────────────── let workers: number | undefined; - if (args.workers != null) { + if (args.workers != null && args.workers !== "auto") { const parsed = parseInt(args.workers, 10); if (isNaN(parsed) || parsed < 1 || parsed > 8) { - errorBox("Invalid workers", `Got "${args.workers}". Must be between 1 and 8.`); + errorBox("Invalid workers", `Got "${args.workers}". Must be 1-8 or "auto".`); process.exit(1); } workers = parsed; @@ -95,8 +142,12 @@ Examples: const quiet = args.quiet ?? false; // ── Print render plan ───────────────────────────────────────────────── - const workerCount = workers ?? 4; + const workerCount = workers ?? defaultWorkerCount(); if (!quiet) { + const workerLabel = + args.workers != null + ? `${workerCount} workers` + : `${workerCount} workers (auto \u2014 half of ${cpus().length} cores)`; console.log(""); console.log( c.accent("\u25C6") + @@ -104,9 +155,7 @@ Examples: c.accent(project.name) + c.dim(" \u2192 " + outputPath), ); - console.log( - c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerCount + " workers"), - ); + console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel)); console.log(""); } @@ -205,7 +254,11 @@ async function renderDocker( }); await producer.executeRenderJob(job, projectDir, outputPath); } catch (error: unknown) { - trackRenderError({ fps: options.fps, quality: options.quality, docker: true }); + trackRenderError({ + fps: options.fps, + quality: options.quality, + docker: true, + }); const message = error instanceof Error ? error.message : String(error); errorBox("Render failed", message, "Check Docker is running: docker info"); process.exit(1); @@ -216,7 +269,7 @@ async function renderDocker( durationMs: elapsed, fps: options.fps, quality: options.quality, - workers: options.workers ?? 4, + workers: options.workers ?? defaultWorkerCount(), docker: true, gpu: options.gpu, }); @@ -256,7 +309,11 @@ async function renderLocal( try { await producer.executeRenderJob(job, projectDir, outputPath, onProgress); } catch (error: unknown) { - trackRenderError({ fps: options.fps, quality: options.quality, docker: false }); + trackRenderError({ + fps: options.fps, + quality: options.quality, + docker: false, + }); const message = error instanceof Error ? error.message : String(error); errorBox("Render failed", message, "Try --docker for containerized rendering"); process.exit(1); @@ -267,7 +324,7 @@ async function renderLocal( durationMs: elapsed, fps: options.fps, quality: options.quality, - workers: options.workers ?? 4, + workers: options.workers ?? defaultWorkerCount(), docker: false, gpu: options.gpu, });