# Rendering

> Lint, single frames, contact-sheet grids, pixel tests and MP4 export with built-in QA — in the order that wastes the least time.

## The ladder

| Step | Call | Cost |
| --- | --- | --- |
| 1 | `tsc --noEmit`, `comp.lint()`, `CompositorProgramSchema.safeParse(comp.toSpec())` | no GPU, instant |
| 2 | `comp.renderFrame({ frame })` | one frame |
| 3 | `comp.renderFrameGrid({ frames })` | a handful of frames |
| 4 | Vitest pixel probes | per test |
| 5 | `comp.renderVideo({ outputPath, qa: true })` | the whole film |

## 1. Check without rendering

```ts
import { CompositorProgramSchema } from "framefields";

const parsed = CompositorProgramSchema.safeParse(comp.toSpec());
if (!parsed.success) throw new Error(JSON.stringify(parsed.error.format()));

for (const d of comp.lint()) console.log(`${d.severity} ${d.code}: ${d.message}${d.layerId ? ` (${d.layerId})` : ""}`);
```

`lint()` finds what renders without error but not as intended: unparseable colors and gradients, low-contrast text, off-canvas layers, truncated animations, PBR props without an environment. Codes are in [QA codes](/docs/reference/qa-codes/).

## 2. One frame

```ts
import fs from "node:fs/promises";
import { HeadlessMediaRenderer } from "framefields";

const renderer = new HeadlessMediaRenderer();               // reuse across frames
const png = await comp.renderFrame({ frame: 45, renderer }); // PNG Buffer
await fs.writeFile("output/f0045.png", png);
```

Render the key moments — entrance, peak, exit — and look at them.

## 3. Contact sheet

```ts
const grid = await comp.renderFrameGrid({
  frames: [0, 6, 12, 18, 24, 30, 36, 42], // or startFrame / endFrame / stepFrames, or timesMs
  columns: 4,
  cellWidth: 480,                         // 120–960, default 320
  showLabels: true,                       // frame + time badge per cell (default)
  outputPath: "output/intro-grid.png",    // optional: also write it
});
```

A grid shows easing hitches, wrap jumps, wrong z-order and missing cuts in one image. Default cap is 64 frames (`maxFrames`, hard max 120). Not for vision layers — see [Vision](/docs/guides/vision/#timing-the-one-frame-delay).

## 4. Pixel tests

```ts title="src/film.test.ts"
import { describe, expect, it } from "vitest";
import { HeadlessMediaRenderer } from "framefields";
import { buildFilm } from "./film.js";

describe("hero", () => {
  it("draws the title by frame 30", async () => {
    const comp = await buildFilm();
    const png = await comp.renderFrame({ frame: 30, renderer: new HeadlessMediaRenderer() });
    expect(png.length).toBeGreaterThan(1000);
    // decode with skia-canvas and sample pixels at known coordinates
  });
});
```

## 5. Export

```ts
import { formatQaReport } from "framefields";

const result = await comp.renderVideo({
  outputPath: "output/film.mp4",
  quality: "high",       // "very_low" | "low" | "high" | "very_high" (default)
  codec: "h264",         // default, MP4; "vp9" | "vp8" → WebM
  concurrency: 4,
  qa: { targetLufs: -14 }, // or true
});

console.log(formatQaReport(result.qa!));
if (!result.qa!.passed) process.exitCode = 1;
process.exit();
```

| Option | Notes |
| --- | --- |
| `outputPath` | written there; `result.filePath` is the final path |
| `quality` | bitrate preset |
| `codec`, `audioCodec` | `h264` → MP4 + AAC; `vp9`/`vp8` → WebM + Opus; `aac`/`mp3`/`opus` alone → audio-only |
| `concurrency` | frames rendered in parallel |
| `ringCapacity` | GPU→encoder staging depth, 2–8 (or `FRAMEFIELDS_RING_CAPACITY`) |
| `qa` | measure frames and audio as they encode; returns `result.qa` |
| `renderer` | share a `HeadlessMediaRenderer` |
| `onProgress` | `({ phase, frame, totalFrames }) => void`; `phase` is `"render"`, then `"audio"` (2.2+) |
| `signal` | an `AbortSignal`; stops between frames, rejects with its reason, leaves no file (2.2+) |

QA thresholds: `minBlackSec` (0.5), `minFrozenSec` (2), `minSilenceSec` (2), `silenceDbfs` (−60), `targetLufs`, `lufsTolerance` (1). `qa.passed` is false on any error or warning. `qa.audio.integratedLufs` is BS.1770 loudness (streaming normalizes to about −14).

:::caution[Always exit]
Native GPU and encoder threads keep Node alive. End every render script with `process.exit()`.
:::

## Inspect a file from elsewhere

```bash
ffprobe -v error -show_entries stream=codec_name,width,height,duration,r_frame_rate output/film.mp4
ffmpeg -i output/film.mp4 -vf "blackdetect=d=0.5:pix_th=0.10" -f null -
ffmpeg -i output/film.mp4 -af volumedetect -f null -
```

## Encoders and GPU

- **Video encoder.** framefields 2.2 tests the hardware encoder (NVENC, VideoToolbox) once per codec and uses it only if it works. Otherwise it encodes with libx264, libx265, libvpx or SVT-AV1. The log says which one it picked (`[video-encoders]`). Set `FRAMEFIELDS_HW_CODECS=off` to always encode in software.
- **Adapter.** `await describeGpu()` returns `{ vendor, architecture, device, description, software }`. `software: true` means frames are drawn on the CPU (lavapipe, SwiftShader), which is much slower.

## Rendering without a GPU

Without a GPU (such as in headless CI runners, Docker containers, or cloud sandboxes), framefields renders on the CPU via software rasterization. The engine detects this automatically, and `await describeGpu()` reports `software: true`:

| Platform | CPU renderer | How to install / enable |
| --- | --- | --- |
| Linux | Mesa lavapipe (Vulkan) | `apt-get install -y libvulkan1 mesa-vulkan-drivers` |
| Windows | WARP (Direct3D 12) or lavapipe | Built into Windows (WARP); or via Vulkan Mesa drivers |
| macOS | Not needed | Every Mac and macOS VM provides a native Metal GPU |

On Linux with lavapipe, the output is pixel-accurate and matches a GPU render across every feature: layout, typography, SVG paths, Lottie, media, 2D VFX shaders, 3D scenes with shadows/bloom, and on-device vision.

### How slow is it?

Rendering on CPU is functional but significantly slower than a hardware GPU:

- **Measured benchmark:** In test suite benchmarks (three 720p test films with 3D elements and three vision layers), rendering on CPU via lavapipe took **9.7 minutes** compared to **49 seconds** on an Apple M1 Pro GPU (~**12× slower**).
- **Desktop GPU comparison:** Compared to modern discrete GPUs (e.g. NVIDIA RTX 3070/4090 or L4), CPU software rendering is typically **10× to 100× slower**, depending on shader complexity, screen resolution, and scene composition.
- **CPU scaling:** Performance scales directly with CPU core count. Mesa lavapipe multithreads fragment rasterization across available CPU cores, and software video encoding (`libx264`) uses thread pools.

### Subsystem fallbacks on CPU

When running without a hardware GPU, framefields optimizes each subsystem to avoid unnecessary bottlenecks:

- **WebGPU pipelines (Dawn):** Dawn binds to Mesa's Vulkan software ICD (`lvp_icd.*.json`). All WGSL shaders, blend passes, and 3D pipelines execute on CPU worker threads.
- **Vision inference:** When `describeGpu()` detects a software adapter (`software: true`), the vision subsystem automatically routes ONNX inference to the native CPU provider (`onnxruntime-node`). This avoids the extreme performance penalty of executing deep neural networks through a software GPU rasterizer.
- **Video encoding:** The renderer automatically falls back from hardware encoders (NVENC, VideoToolbox) to multi-threaded CPU software encoders (`libx264`, `libx265`, `libvpx`, `svtav1`).

### Recommended workflow on CPU

Because full video rendering on CPU takes minutes instead of seconds:

- **Use the ladder:** Always run `comp.lint()`, then verify with single frames (`renderFrame`) and contact sheet grids (`renderFrameGrid`). Catch layout and visual defects in seconds before committing to a full export.
- **For production films:** Keep CPU rendering for headless testing, CI sanity checks, and single-frame probes. Offload full-length exports and batch renders to a GPU machine or the [render worker](/docs/guides/render-service/).

## Production rendering

Measured worker memory, vision included: about 0.5 GB on an Apple Silicon Mac, 2–3 GB on Linux with an NVIDIA GPU, where driver memory counts too. To render projects from git on GPU machines, with progress, cancel and uploads to S3, use the [render worker](/docs/guides/render-service/). In your own long-lived process, call `resetRenderCaches()` between jobs: the next job may have different files at the same paths.
