Skip to content

Rendering

View .mdllms.txt
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
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.

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.

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.

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
});
});
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).

Terminal window
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 -
  • 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.

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.

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.

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).

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.

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.