# 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` |

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 -
```

## Production rendering

A worker uses roughly 200–400 MB of RAM (vs 2–4 GB for headless Chromium), which suits GPU instances and job queues. The framefields repo ships a `Dockerfile.renderer` for a renderer service.
