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 = /]*)>([\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, ""); @@ -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, }; } diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index c805fd026..b0c7a6de6 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -6,12 +6,38 @@ import type { EngineConfig } from "@hyperframes/engine"; import type { CompiledComposition } from "./htmlCompiler.js"; import { + applyAutoWorkerCompatibilityHints, applyRenderModeHints, extractStandaloneEntryFromIndex, writeCompiledArtifacts, } from "./renderOrchestrator.js"; import { toExternalAssetKey } from "../utils/paths.js"; +function createCompiledComposition( + reasonCodes: Array<"iframe" | "requestAnimationFrame" | "webgl">, +): CompiledComposition { + return { + html: "", + subCompositions: new Map(), + videos: [], + audios: [], + unresolvedCompositions: [], + externalAssets: new Map(), + width: 1920, + height: 1080, + staticDuration: 5, + renderModeHints: { + recommendScreenshot: reasonCodes.some( + (code) => code === "iframe" || code === "requestAnimationFrame", + ), + reasons: reasonCodes.map((code) => ({ + code, + message: `reason: ${code}`, + })), + }, + }; +} + describe("extractStandaloneEntryFromIndex", () => { it("reuses the index wrapper and keeps only the requested composition host", () => { const indexHtml = ` @@ -159,29 +185,6 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat }); describe("applyRenderModeHints", () => { - function createCompiledComposition( - reasonCodes: Array<"iframe" | "requestAnimationFrame">, - ): CompiledComposition { - return { - html: "", - subCompositions: new Map(), - videos: [], - audios: [], - unresolvedCompositions: [], - externalAssets: new Map(), - width: 1920, - height: 1080, - staticDuration: 5, - renderModeHints: { - recommendScreenshot: reasonCodes.length > 0, - reasons: reasonCodes.map((code) => ({ - code, - message: `reason: ${code}`, - })), - }, - }; - } - function createConfig(): EngineConfig { return { fps: 30, @@ -244,3 +247,81 @@ describe("applyRenderModeHints", () => { expect(log.warn).not.toHaveBeenCalled(); }); }); + +describe("applyAutoWorkerCompatibilityHints", () => { + it("caps auto workers for requestAnimationFrame screenshot-mode compositions", () => { + const log = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }; + + const workers = applyAutoWorkerCompatibilityHints( + 6, + { fps: 30, quality: "standard" }, + createCompiledComposition(["requestAnimationFrame"]), + log, + ); + + expect(workers).toBe(2); + expect(log.info).toHaveBeenCalledOnce(); + }); + + it("does not cap explicitly requested workers", () => { + const log = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }; + + const workers = applyAutoWorkerCompatibilityHints( + 6, + { fps: 30, quality: "standard", workers: 6 }, + createCompiledComposition(["requestAnimationFrame"]), + log, + ); + + expect(workers).toBe(6); + expect(log.info).not.toHaveBeenCalled(); + }); + + it("caps auto workers for detected WebGL-heavy compositions", () => { + const log = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }; + + const workers = applyAutoWorkerCompatibilityHints( + 6, + { fps: 30, quality: "standard" }, + createCompiledComposition(["webgl"]), + log, + ); + + expect(workers).toBe(2); + expect(log.info).toHaveBeenCalledOnce(); + }); + + it("does not cap when compatibility hints are unrelated to requestAnimationFrame", () => { + const log = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }; + + const workers = applyAutoWorkerCompatibilityHints( + 6, + { fps: 30, quality: "standard" }, + createCompiledComposition(["iframe"]), + log, + ); + + expect(workers).toBe(6); + expect(log.info).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 423e1e33b..514d37bc9 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -396,6 +396,34 @@ export function applyRenderModeHints( }); } +// Issue #410 reported that 2 workers stayed stable for GPU-heavy scenes where +// the default auto heuristic chose 6 and hit Chrome compositor starvation. +const WEBGL_AUTO_WORKER_CAP = 2; + +export function applyAutoWorkerCompatibilityHints( + workerCount: number, + config: RenderConfig, + compiled: CompiledComposition, + log: ProducerLogger = defaultLogger, +): number { + if (config.workers !== undefined) return workerCount; + + const hasCompatibilityHint = compiled.renderModeHints.reasons.some( + (reason) => reason.code === "requestAnimationFrame" || reason.code === "webgl", + ); + if (!hasCompatibilityHint || workerCount <= WEBGL_AUTO_WORKER_CAP) { + return workerCount; + } + + const reducedWorkerCount = WEBGL_AUTO_WORKER_CAP; + log.info("Reduced auto worker count for WebGL-heavy composition", { + from: workerCount, + to: reducedWorkerCount, + reasonCodes: compiled.renderModeHints.reasons.map((reason) => reason.code), + }); + return reducedWorkerCount; +} + /** * Blit a single HDR video layer onto an rgb48le canvas. * @@ -1172,7 +1200,12 @@ export async function executeRenderJob( quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95, }; - const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg); + const workerCount = applyAutoWorkerCompatibilityHints( + calculateOptimalWorkers(totalFrames, job.config.workers, cfg), + job.config, + compiled, + log, + ); const FORMAT_EXT: Record = { mp4: ".mp4", webm: ".webm", mov: ".mov" }; const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4"; @@ -2359,7 +2392,8 @@ export async function executeRenderJob( if (isTimeoutError && wasParallel) { log.warn( `Parallel capture timed out with ${job.config.workers ?? "auto"} workers. ` + - `Video-heavy compositions often need sequential capture. Retry with --workers 1`, + `WebGL-heavy scenes often need lower parallelism. Retry with --workers 2 first; ` + + `video-heavy compositions with multiple active elements may still need --workers 1`, ); }