The ladder
Section titled “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
Section titled “1. Check without rendering”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.
2. One frame
Section titled “2. One frame”import fs from "node:fs/promises";import { HeadlessMediaRenderer } from "framefields";
const renderer = new HeadlessMediaRenderer(); // reuse across framesconst png = await comp.renderFrame({ frame: 45, renderer }); // PNG Bufferawait fs.writeFile("output/f0045.png", png);Render the key moments — entrance, peak, exit — and look at them.
3. Contact sheet
Section titled “3. Contact sheet”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.
4. Pixel tests
Section titled “4. Pixel tests”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
Section titled “5. Export”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).
Inspect a file from elsewhere
Section titled “Inspect a file from elsewhere”ffprobe -v error -show_entries stream=codec_name,width,height,duration,r_frame_rate output/film.mp4ffmpeg -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
Section titled “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.