How Hyperframes Achieves Parallel Rendering with Worker Coordination
Hyperframes accelerates video rendering by distributing frame capture across Node.js worker threads and Puppeteer browser sessions, coordinating tasks through a centralized Parallel Coordinator service that dynamically sizes worker pools based on CPU, memory, and frame count.
Hyperframes is an open-source video rendering engine that tackles the computational intensity of browser-based frame capture through sophisticated parallelization. By leveraging multiple concurrent workers rather than sequential processing, the framework can render complex HTML-to-video pipelines significantly faster. This article examines the specific implementation details of how Hyperframes coordinates these workers, from calculating optimal thread counts to merging distributed outputs.
Dynamic Worker Pool Sizing
Before spawning processes, Hyperframes calculates the optimal number of workers by analyzing system resources and job parameters. The calculateOptimalWorkers function in packages/engine/src/services/parallelCoordinator.ts evaluates three constraints and returns the most restrictive value.
// packages/engine/src/services/parallelCoordinator.ts
export function calculateOptimalWorkers(
totalFrames: number,
requested?: number,
config?: WorkerSizingConfig,
): number {
…
const effectiveMaxWorkers = … // respects user‑specified `--workers` or “auto”
…
const cpuBasedWorkers = Math.max(1, cpuCount - 2);
const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMB * 0.5) / MEMORY_PER_WORKER_MB));
const frameBasedWorkers = Math.floor(totalFrames / MIN_FRAMES_PER_WORKER);
const optimal = Math.min(cpuBasedWorkers, memoryBasedWorkers, frameBasedWorkers);
…
return finalWorkers;
}
The function applies three heuristics:
- CPU-based: Reserves two cores for the main process, using
Math.max(1, cpuCount - 2) - Memory-based: Allocates 50% of available RAM divided by a per-worker memory constant
- Frame-based: Ensures each worker processes at least
MIN_FRAMES_PER_WORKER(30) frames
The final count is clamped to ABSOLUTE_MAX_WORKERS (24) and respects any user-specified --workers flag.
Distributing Frame Ranges
Once the worker count is determined, the distributeFrames function splits the rendering job into discrete tasks. Each WorkerTask object specifies the exact frame range, output directory, and naming offset for a single worker.
export function distributeFrames(
totalFrames: number,
workerCount: number,
workDir: string,
rangeStart: number = 0,
): WorkerTask[] {
const framesPerWorker = Math.ceil(totalFrames / workerCount);
…
tasks.push({
workerId: i,
startFrame,
endFrame,
outputDir: join(workDir, `worker-${i}`),
outputFrameOffset: rangeStart,
});
}
This ensures deterministic frame numbering across distributed workers, with each task writing to an isolated subdirectory like worker-0/, worker-1/, etc., preventing I/O collisions during parallel capture.
Executing Parallel Capture
The executeParallelCapture function orchestrates the actual concurrency by mapping each task to a worker execution promise. Located in packages/engine/src/services/parallelCoordinator.ts, it coordinates both Node.js worker threads (for shader transitions) and Puppeteer browser sessions (for DOM capture).
export async function executeParallelCapture(
serverUrl: string,
workDir: string,
tasks: WorkerTask[],
captureOptions: CaptureOptions,
createBeforeCaptureHook: () => BeforeCaptureHook | null,
signal?: AbortSignal,
onProgress?: (progress: ParallelProgress) => void,
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
config?: Partial<EngineConfig>,
): Promise<WorkerResult[]> {
const results = await Promise.all(
tasks.map(task =>
executeWorkerTask(
task, serverUrl, captureOptions, createBeforeCaptureHook,
signal, onFrameCaptured, onFrameBuffer, config,
),
),
);
…
}
Each executeWorkerTask call runs in its own thread via the worker pool defined in shaderTransitionWorkerPool.ts, creating an isolated Puppeteer session for its assigned frame range. Progress aggregates through onProgress callbacks, while AbortSignal support enables graceful cancellation across all workers.
Per-Worker Rendering Loop
Inside each worker, a tight loop drives Chrome DevTools Protocol (CDP) to seek to specific timestamps and capture frames. The implementation in packages/engine/src/services/parallelCoordinator.ts handles both disk writes and memory buffering.
for (let i = task.startFrame; i < task.endFrame; i++) {
if (signal?.aborted) throw new Error("Parallel worker cancelled");
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
const fileFrameIdx = i - outputOffset;
if (onFrameBuffer) {
const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
await onFrameBuffer(i, buffer);
} else {
await captureFrame(session, fileFrameIdx, time);
}
framesCaptured++;
if (onFrameCaptured) onFrameCaptured(task.workerId, i);
}
captureFrame writes PNG/JPG files to disk via the helper functions in packages/engine/src/services/frameCapture.js, while captureFrameToBuffer returns raw buffers for immediate processing—useful when piping frames directly to an encoder without intermediate disk I/O.
Error Handling and Retry Mechanisms
When workers complete, executeParallelCapture aggregates results and throws a consolidated error if any worker failed:
const errors = results.filter(r => r.error);
if (errors.length > 0) {
const errorMessages = errors.map(e => `Worker ${e.workerId}: ${e.error}`).join("; ");
throw new Error(`[Parallel] Capture failed: ${errorMessages}`);
}
The producer's executeDiskCaptureWithAdaptiveRetry in packages/producer/src/services/renderOrchestrator.ts catches specific recoverable errors—such as timeouts or protocol errors—and automatically retries with fewer workers. This fallback mechanism prevents transient resource exhaustion from failing an entire render job.
Merging Distributed Results
After successful capture, mergeWorkerFrames collates outputs from isolated worker directories into a single contiguous sequence. This function in packages/engine/src/services/parallelCoordinator.ts preserves numeric ordering to ensure encoder compatibility.
export async function mergeWorkerFrames(
workDir: string,
tasks: WorkerTask[],
outputDir: string,
): Promise<number> {
…
for (const task of sortedTasks) {
const files = readdirSync(task.outputDir)
.filter(f => f.startsWith("frame_") && (f.endsWith(".jpg") || f.endsWith(".png")))
.sort();
await Promise.all(files.map(async file => {
const src = join(task.outputDir, file);
const dst = join(outputDir, file);
try { await rename(src, dst); } catch { await copyFile(src, dst); }
}));
totalFrames += files.length;
}
return totalFrames;
}
The function attempts atomic moves first, falling back to copies if cross-device links prevent renaming. This ensures the final outputDir contains a correctly indexed frame sequence regardless of how work was distributed.
GPU Shader Transition Coordination
Beyond DOM capture, Hyperframes maintains a separate worker pool for HDR shader blending defined in packages/producer/src/services/shaderTransitionWorkerPool.ts. This pool:
- Spawns
Nworkers usingnew Worker(entry, { execArgv }) - Transfers raw frame buffers via
postMessagewithArrayBuffertransfer - Receives blended results and writes them to disk
The pool size is explicitly bounded by the same workers value used for capture, preventing CPU-GPU resource contention. This separation allows shader-heavy transitions to run in parallel with browser capture without blocking the main event loop.
Command-Line and Programmatic Usage
You can control parallelism through the CLI or Node.js API.
Automatic worker detection:
npx hyperframes render my-video.html --out output.mp4
Explicit worker limit:
npx hyperframes render my-video.html --out output.mp4 --workers 4
Programmatic configuration:
import { RenderOrchestrator } from "@hyperframes/producer";
async function render() {
const job = RenderOrchestrator.createRenderJob({
fps: { num: 30, den: 1 },
format: "mp4",
workers: 6, // explicit parallelism
entryFile: "src/index.html",
});
await RenderOrchestrator.executeRenderJob(
job,
"/path/to/project",
"/tmp/output.mp4",
);
}
render();
Summary
- Dynamic sizing: The
calculateOptimalWorkersfunction inpackages/engine/src/services/parallelCoordinator.tsselects concurrency levels based on CPU count minus 2, available memory (50% allocation), and minimum 30 frames per worker, capped at 24 workers. - Task distribution:
distributeFramescreates isolatedWorkerTaskobjects with specific frame ranges and output directories to prevent I/O conflicts. - Dual pool architecture: Separate worker pools handle Puppeteer browser sessions for DOM capture and Node.js worker threads for GPU shader blending, both coordinated through
executeParallelCapture. - Resilient execution: The system supports
AbortSignalcancellation, aggregates worker errors with specific worker IDs, and implements adaptive retry logic inrenderOrchestrator.tsfor recoverable failures. - Deterministic merging:
mergeWorkerFramescollates distributed outputs using atomic moves or copies, ensuring contiguous frame indexing for video encoding.
Frequently Asked Questions
How does Hyperframes determine the optimal number of parallel workers?
Hyperframes analyzes three system constraints in calculateOptimalWorkers: CPU cores (reserving 2 for the main process), available memory (allocating 50% divided by per-worker requirements), and total frame count (ensuring at least 30 frames per worker). The final count is the minimum of these values, clamped to an absolute maximum of 24 workers unless overridden by the --workers flag.
What happens if a worker fails during frame capture?
When a worker fails, executeParallelCapture aggregates all worker results and throws a descriptive error identifying the specific worker ID and failure reason. The executeDiskCaptureWithAdaptiveRetry function in renderOrchestrator.ts catches recoverable errors—such as Puppeteer timeouts or CDP protocol errors—and automatically retries the render with a reduced worker count to mitigate resource contention issues.
How does the shader transition worker pool differ from the capture workers?
While both use Node.js worker_threads, the shader pool in shaderTransitionWorkerPool.ts specifically handles GPU-intensive HDR blending operations rather than browser automation. It transfers raw frame data via ArrayBuffer through postMessage for zero-copy performance, whereas capture workers instantiate full Puppeteer browser sessions. Both pools respect the same concurrency limits to prevent system overload.
Can I disable parallel rendering or limit it for debugging?
Yes. Setting --workers 1 in the CLI forces sequential rendering, while --workers 4 (or any specific number) caps parallelism explicitly. Programmatically, the workers field in RenderOrchestrator.createRenderJob accepts any integer, where 1 disables parallelization and values above 1 enable the worker pool coordination described in the Parallel Coordinator service.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →