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 |
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).
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 -Encoders and GPU
Section titled “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]). SetFRAMEFIELDS_HW_CODECS=offto always encode in software. - Adapter.
await describeGpu()returns{ vendor, architecture, device, description, software }.software: truemeans frames are drawn on the CPU (lavapipe, SwiftShader), which is much slower.
Rendering without a GPU
Section titled “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?
Section titled “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
Section titled “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
Section titled “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.
Production rendering
Section titled “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. In your own long-lived process, call resetRenderCaches() between jobs: the next job may have different files at the same paths.