# How Hyperframes Achieves Parallel Rendering with Worker Coordination

> Discover how Hyperframes achieves parallel rendering with worker coordination. This Node.js library distributes tasks across workers and browser sessions for accelerated video rendering. Learn more!

- Repository: [HeyGen/hyperframes](https://github.com/heygen-com/hyperframes)
- Tags: internals
- Published: 2026-05-17

---

**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`](https://github.com/heygen-com/hyperframes/blob/main/packages/engine/src/services/parallelCoordinator.ts) evaluates three constraints and returns the most restrictive value.

```typescript
// 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.

```typescript
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`](https://github.com/heygen-com/hyperframes/blob/main/packages/engine/src/services/parallelCoordinator.ts), it coordinates both **Node.js worker threads** (for shader transitions) and **Puppeteer browser sessions** (for DOM capture).

```typescript
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`](https://github.com/heygen-com/hyperframes/blob/main/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`](https://github.com/heygen-com/hyperframes/blob/main/packages/engine/src/services/parallelCoordinator.ts) handles both disk writes and memory buffering.

```typescript
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`](https://github.com/heygen-com/hyperframes/blob/main/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:

```typescript
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`](https://github.com/heygen-com/hyperframes/blob/main/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`](https://github.com/heygen-com/hyperframes/blob/main/packages/engine/src/services/parallelCoordinator.ts) preserves numeric ordering to ensure encoder compatibility.

```typescript
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`](https://github.com/heygen-com/hyperframes/blob/main/packages/producer/src/services/shaderTransitionWorkerPool.ts). This pool:

- Spawns `N` workers using `new Worker(entry, { execArgv })`
- Transfers raw frame buffers via `postMessage` with `ArrayBuffer` transfer
- 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:**

```bash
npx hyperframes render my-video.html --out output.mp4

```

**Explicit worker limit:**

```bash
npx hyperframes render my-video.html --out output.mp4 --workers 4

```

**Programmatic configuration:**

```typescript
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 `calculateOptimalWorkers` function in [`packages/engine/src/services/parallelCoordinator.ts`](https://github.com/heygen-com/hyperframes/blob/main/packages/engine/src/services/parallelCoordinator.ts) selects concurrency levels based on CPU count minus 2, available memory (50% allocation), and minimum 30 frames per worker, capped at 24 workers.
- **Task distribution**: `distributeFrames` creates isolated `WorkerTask` objects 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 `AbortSignal` cancellation, aggregates worker errors with specific worker IDs, and implements adaptive retry logic in [`renderOrchestrator.ts`](https://github.com/heygen-com/hyperframes/blob/main/renderOrchestrator.ts) for recoverable failures.
- **Deterministic merging**: `mergeWorkerFrames` collates 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`](https://github.com/heygen-com/hyperframes/blob/main/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`](https://github.com/heygen-com/hyperframes/blob/main/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.