Skip to content
Back to skills

Framefields Render

ASecurity

Render, inspect, test, preview and export WebGPU video and frame grids with the framefields headless engine. Use when verifying compositions, diagnosing blank frames, running frame grids or pixel tests, showing the user a live preview, exporting MP4/WebM, reading render progress, running video QA, naming renders in framefields.json, rendering without a GPU, or splitting a render into chunks in framefields. Cloud GPU rendering is in framefields-cloud.

  • 511 stars
  • 0 votes
  • 0 copies
  • 5 views
  • Added October 18, 2026
developmenttypescriptgobashnoderailsgitapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned November 11, 2026

npx -y skills add gatewai-dev/framefields --skill framefields-render --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Framefields Render?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Framefields Render
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/gatewai-dev-framefields-render/badge)](https://www.skillsdirectory.com/skills/gatewai-dev-framefields-render)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: framefields-render
description: Render, inspect, test, preview and export WebGPU video and frame grids with the framefields headless engine. Use when verifying compositions, diagnosing blank frames, running frame grids or pixel tests, showing the user a live preview, exporting MP4/WebM, reading render progress, running video QA, naming renders in framefields.json, rendering without a GPU, or splitting a render into chunks in framefields. Cloud GPU rendering is in framefields-cloud.
---

# framefields-render

`framefields` renders headlessly in Node.js on native WebGPU (Dawn), encoding through FFmpeg with a hardware encoder when one works (NVENC, VideoToolbox), else software. No browser. Project setup is in `framefields`; rendering on framefields' GPUs is in `framefields-cloud`.

## The verification ladder

Go in order: a layout or timing bug costs seconds at step 1–3 and minutes after a full export.

### 1. Typecheck, schema, lint

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

const parsed = CompositorProgramSchema.safeParse(comp.toSpec());
if (!parsed.success) { console.error(parsed.error.format()); process.exit(1); }
for (const d of comp.lint()) console.log(`${d.severity} ${d.code}: ${d.message}`); // renders, but not as intended
```

- `npx tsc --noEmit -p .` typechecks.
- `npx framefields check [name] --json` imports the render's export in an isolated process and writes one versioned JSON result (no name: every render; a module: `check src/film.ts --export buildFilm`). Errors fail it; warnings with `--warnings-as-errors`; `--timeout 60000` bounds it. The export must only build: importing it runs project code.
- `--render` (or `pnpm check:render` in scaffolded projects) adds a GPU smoke check: a frame and a three-point grid under `output/check` (`--output <dir>`). Not a substitute for pixel assertions or video QA.
- `npx framefields api` prints timing, grid and preflight type declarations for the installed version.

### 2. Single frames (`renderFrame`)

```typescript
import { HeadlessMediaRenderer } from "framefields";

const renderer = new HeadlessMediaRenderer();
await fs.writeFile("output/f45.png", await comp.renderFrame({ frame: 45, renderer }));
```

`atMs` wins over `frame`. Frames are nonnegative safe integers; fractional timestamps and FPS are fine. The composition's FPS is authoritative: a different render/grid FPS throws.

### 3. Contact sheets (`renderFrameGrid`)

```typescript
const png = await comp.renderFrameGrid({
  frames: [0, 6, 12, 18, 24, 30, 36, 42, 48, 54, 60],
  columns: 4, cellWidth: 480, showLabels: true,
});
```

Shows hitches, easing, text wrap jumps, z-order and missing cuts at a glance. Automatic sampling stops before the exported frame count (`max(1, round(durationMs * fps / 1000))`); explicit frames may go past it. Order and duplicates are kept. At most 64 samples by default, 120 max.

### 4. Pixel tests (vitest)

```typescript
it("renders the title on frame 30", async () => {
  const comp = await buildFilm();
  const png = await comp.renderFrame({ frame: 30, renderer: new HeadlessMediaRenderer() });
  expect(png.length).toBeGreaterThan(1000); // decode and probe pixels at known coordinates
});
```

### 5. Show the user (`preview`)

When a person is in the loop, let them watch before the export. `npx framefields preview <name>` or `startPreview` serves a localhost player (sound, waveform timeline, frame stepping). The page runs the composition's own code with WebGPU in the browser in real time; a slow device skips frames to keep the sound in time. The soundtrack is the export's own mix.

```typescript
import { startPreview } from "framefields";

const session = await startPreview({ entry: new URL("./film.ts", import.meta.url), export: "buildFilm" }, { title: "Launch film" });
console.log(`Preview at ${session.url}`);
await session.closed; // "idle" | "replaced" | "closed"
```

- **The export runs in the browser.** It may be a `Composition` or a (async) function returning one. `node:path`, `fileURLToPath(import.meta.url)` and synchronous `fs` reads work through shims; writes are skipped. Build asset paths from `import.meta.url`, not `process.cwd()`. Files inside the project (nearest `.git` or `package.json`) are served; pass `root: [...]` for others (e.g. a shared fonts folder).
- **Run it in the background and read its output.** Stderr lines (`[framefields preview]`) say when the page opened, each distinct page error once (build error, frame threw, missing font, no WebGPU), and why it stopped. Check them instead of assuming the film drew.
- **One tab for the whole loop.** The port is fixed per project. Running it again replaces the running one and the open tab reloads at the same moment. It stops ~5 s after its last tab closes (`idleCloseMs`); just start it again.
- **Show it where the user is.** It opens nothing: use the app's built-in browser if there is one, else give the link, or pass `open: true`. Needs current Chrome, Edge or Safari.
- **Notes.** Paused, the user clicks (or drags an area) and writes a note. Each prints (`note 3 at 0:12.50 (frame 750), spot 42%,31%: …`) and is saved in `.framefields/preview-notes/notes.json` with `note-<id>.jpg` (the frame, spot marked; `notesDir` moves it, `false` disables). To act: read `notes.json`, open each open note's picture, find the layer from the moment and spot, change it, rerun the preview. You may set `"done": true` yourself.
- **Links and scripting.** `#t=12.5` or `#frame=750` opens at a moment, and changing the fragment of an open page seeks there in place (paused, no reload). Keys: ←/→ a frame, ⇧←/→ a second, Space play/pause, Home/End. `window.framefieldsPreview` has `seek(seconds)` / `seekFrame(n)` (resolve once drawn: screenshot then), `play()`, `pause()`, `notes()`, `state()` (`{ state, error, frame, time, playing, buffering, audio, audioMixed, audioMixer, connected, playbackFps, renderMs, meta }`). `<html data-preview="ready">` marks the first frame. Hidden tabs pause.
- **Embedding in an app** (`<iframe src="…/?embed=1#t=5">`). `?embed=1` drops the page's header (title, size, status) for the host's own. In a frame the page talks to `window.parent` over `postMessage` (protocol 1; types exported from `framefields/preview`, e.g. `PreviewMessage`, `PreviewCommandMessage`, `readPreviewCommand`):
  - Page → parent: `{ type: "framefields-preview:ready", protocol: 1, seekByHash: true, meta: { title, width, height, fps, frames, duration } }` once the first frame shows; `{ type: "framefields-preview:state", frame, time, playing }` on play, pause and each finished seek, and at most 4 a second while playing; `{ type: "framefields-preview:build", status: "building" | "ready" | "error", message? }` ("building" right before it reloads into a newer preview, whose `ready` ends it; "error" with the text when a build or a frame fails).
  - Parent → page (only from `window.parent`): `{ type: "framefields-preview:command", id, command: "seek", time }`, `"seekFrame", frame`, `"play"`, `"pause"` or `"state"`. Each is answered once done (a seek once drawn) with `{ type: "framefields-preview:reply", id, ok: true, state: { frame, time, playing } }` or `{ …, ok: false, error }`.
  - Setting the frame's `#t=` also seeks it; commands add a reply when it's drawn.
- Vision effects analyse each frame from the one drawn before: check cutouts paused or in the export.

### 6. Export (`renderVideo`)

```typescript
const result = await comp.renderVideo({
  outputPath: "output/film.mp4",
  quality: "very_high", // very_low | low | high | very_high (default)
  codec: "h264",        // h264 (default) | vp9 | vp8 | gif; aac | mp3 | opus = audio only
  qa: true,             // see Video QA
});
await result.cleanup?.();
```

No HEVC or AV1; an unknown codec falls back to H.264. Or from the CLI: `npx framefields render <name>` uses the project's own tsx and framefields, calls the export with the render's `args`/`select`, downloads missing pinned assets first, and writes the render's `output`.

Always end render scripts with `process.exit(0)`: native GPU and encoder threads keep Node alive.

## Named renders (`framefields.json`)

`framefields.json` names what the project renders. Add one per cut (teaser, vertical, language) rather than flags in `render.ts`; the CLI, `framefields check`, the cloud and the dashboard pick them by name.

```json
{
  "renders": {
    "film":   { "title": "Launch film", "entry": "src/film.ts", "export": "buildFilm", "workers": "auto", "output": "output/film.mp4" },
    "teaser": { "title": "15 s teaser", "description": "Vertical cut", "entry": "src/film.ts", "export": "buildTeaser", "codec": "vp9" }
  }
}
```

| Field | Meaning |
|---|---|
| `entry` | Module that builds the composition (required) |
| `export` | A `Composition` or a function returning one (default `buildFilm`) |
| `title`, `description` | Shown to the user in the dashboard and `framefields renders` |
| `args` / `select` | Arguments for a function export / which composition when it returns a `Record` |
| `workers`, `maxWorkers` | Cloud GPUs: `1` (default), a number, or `"auto"` |
| `codec`, `quality` | `h264` / `vp9`; `very_low` … `very_high` |
| `output` | Where the video is written |

Names: letters, digits, `-`, `_`, `.` (≤ 64). `npx framefields renders [--json]` lists them. After adding one, run `framefields check <name>`. Needs framefields 2.12+.

## Reading render progress

`renderVideo` logs about 15 short `[framefields]` lines to stderr however long the film:

```text
[framefields] Preparing 1800 frames (60.0s) at 1920x1080, 30 fps, h264
[framefields] Rendered 10% (180/1800 frames) · 41.2 fps · eta 39.3s
[framefields] Mixing audio
[framefields] Muxing mp4
[framefields] Done in 51.4s (14.2 MB): prepare 1.2s · render 43.1s · audio 3.2s · audio-encode 1.1s · mux 0.8s
```

- **Work continues after `Rendered 100%`:** mixing decodes every clip's audio, then encode and mux. Not a hang; wait for `Done in …`.
- Quiet phases print `Still …` lines after 10 s, 30 s, 70 s, then every 5 min. It's stuck only when no line arrives well past the last gap. Killing it leaves no video. Failures name their phase.
- Run long exports in the background with the longest timeout available and read the output when done.
- `LOG_LEVEL=warn|debug`; `NODE_ENV=production` or `FRAMEFIELDS_LOG_FORMAT=json` gives pino JSON on stdout.
- `onProgress` receives `{ phase, frame, totalFrames, elapsedMs, phaseProgress?, fps?, etaSec?, detail? }`; phases `prepare`, `render`, `audio`, `audio-encode`, `mux`, `convert` (GIF), `done`.

## Video QA (`renderVideo({ qa })`)

The engine measures every frame as it's read back and the audio as it's mixed: no second decode.

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

const result = await comp.renderVideo({ outputPath: "output/film.mp4", qa: { targetLufs: -14 } });
console.log(formatQaReport(result.qa!));
if (!result.qa!.passed) process.exitCode = 1;
```

| `issues[].code` | Severity | Meaning |
|---|---|---|
| `frames_missing` | error | Fewer frames encoded than the composition has |
| `black_frames` | warning | ≥ `minBlackSec` (0.5 s) of black; `startSec`/`endSec` |
| `audio_clipping` | warning | Samples at or beyond full scale |
| `loudness_off_target` | warning | More than `lufsTolerance` (1 LU) from `targetLufs` |
| `animation_truncated` | warning | Keyframes past a layer's window never play |
| `frozen_video` | info | Unchanged for ≥ `minFrozenSec` (2 s) |
| `audio_silence` / `audio_silent` | info | ≥ 2 s below `silenceDbfs` (-60) / whole track silent |

`result.qa` also includes every `comp.lint()` finding (`invalid_gradient`, `unknown_color`, `low_contrast_text`, `offscreen_layer`, `duplicate_id`, `unsupported_image_format`, `shadow_unsupported_light`, `pbr_without_environment`, `static_track`, …). `passed` is false on any error or warning. `qa.audio.integratedLufs` is BS.1770 (platforms normalize to about -14); `qa.audio.peakDbfs` is the sample peak.

For files rendered elsewhere:

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

## Splitting a long render

Each process renders a frame range without audio, the audio renders once, then the files join without re-encoding:

```typescript
import { stitchChunks } from "framefields/renderer";

await comp.renderVideo({ outputPath: "out/chunk-0.mp4", frames: { start: 0, end: 900 }, audio: false }); // per worker
await comp.renderVideo({ outputPath: "out/audio.mp4", codec: "aac" });                                   // once
await stitchChunks({ chunks: ["out/chunk-0.mp4", "out/chunk-1.mp4"], audio: "out/audio.mp4", outputPath: "out/final.mp4" }); // no GPU
```

- Trails, datamosh and particles replay earlier frames before `start`: same picture, extra time.
- Same framefields version, codec, quality and GPU type for every chunk; `stitchChunks` refuses mismatched codec, size or encoder parameters.
- Run SAM tracking once and share `FRAMEFIELDS_VISION_CACHE_DIR` so no chunk re-tracks earlier frames.
- `qa` checks each chunk alone; check the stitched file with ffmpeg.
- `planChunks({ totalFrames, samples, workers: "auto" })` picks count and cuts (adds workers while each spends ≥ 85% of its time rendering; splits at equal cost). `renderer.probeRender(comp)` measures `samples`.

The cloud does all of this for you: `framefields-cloud`.

## Rendering without a GPU

framefields falls back to the CPU with identical output for every feature (vision inference too).

- **Linux:** `apt-get install -y libvulkan1 mesa-vulkan-drivers` (lavapipe). Without any Vulkan driver nothing renders.
- **Windows:** WARP (built in) or lavapipe. **macOS:** always has Metal.
- `await describeGpu()` reports `software: true`.
- About 12× slower than an M1 Pro GPU, scaling with cores. Fine for frames, grids and short exports; render long films in the cloud (`framefields-cloud`) or on a GPU machine.

## Failure modes

| Symptom | Cause | Fix |
|---|---|---|
| Whole frame black | Unregistered font or `NaN` in a signal | `FontManager.register()` every font before building; keep signals finite. |
| Media layer blank | Wrong path, LFS pointer, zero opacity | Check the file is real (`git lfs pull`, `framefields assets pull`). |
| Audio cuts out early | No `durationFrames` on `Layer.audio()` | Set it to the film's length. |
| Vision cutout empty on a single frame | Vision reads the previous frame | Render ≥ 2 consecutive frames with one renderer; `renderVideo` handles it. |
| Ghosted double in a cutout | Frames rendered out of order (grids, jumps) | Check vision with sequential frames or the export. |
| Pause after `Rendered 100%` | Audio mix / encode / mux | Wait for `Still …` and `Done in …`. |
| Script hangs after `Done in …` | Worker threads hold Node | `process.exit(0)`. |
| Very slow on a Linux server | CPU adapter (lavapipe) | `describeGpu()`; fix the Vulkan driver (`vulkaninfo --summary`). |
| Encoder fails to open (NVENC, `h264_rkmpp`) | framefields < 2.2 | Upgrade, or `FRAMEFIELDS_HW_CODECS=off`. |
| Jump at a chunk seam | Mismatched chunk settings, or an effect reading wall-clock time | One commit and settings for all chunks; deterministic effects. |
| Effect shows no change | Value on the wrong scale | Look it up in the `framefields-effects` catalog. |
| Text truncated | Box narrower than the text | Set `width` or `width: "fill"`. |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in development →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.