1. Check the machine
Section titled “1. Check the machine”node --version # must be v22 or later- GPU required. Rendering uses WebGPU via Dawn: Metal on macOS, Vulkan on Linux/Windows. VMs and CI runners without a GPU cannot render.
- ES modules.
package.jsonneeds"type": "module".
2. Write package.json, then install
Section titled “2. Write package.json, then install”framefields needs exactly one dependency install script: skia-canvas downloads its native binary. npm 11, pnpm 10 and bun block install scripts unless approved, so approve that one and deny the rest before installing.
{ "name": "my-film", "private": true, "type": "module", "allowScripts": { "skia-canvas": true, "sharp": false, "webgpu": false, "onnxruntime-node": false, "node-av": false, "esbuild": false }, "pnpm": { "onlyBuiltDependencies": ["skia-canvas"], "ignoredBuiltDependencies": ["sharp", "webgpu", "onnxruntime-node", "node-av", "esbuild"] }, "trustedDependencies": ["skia-canvas"], "scripts": { "typecheck": "tsc --noEmit -p .", "frames": "tsx src/render.ts frames", "grid": "tsx src/render.ts grid", "preview": "tsx src/render.ts preview", "render": "tsx src/render.ts" }}allowScripts is read by npm, pnpm by pnpm, trustedDependencies by bun. Yarn needs none of them.
npm install framefieldsnpm install --save-dev tsx typescript @types/node{ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "strict": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src/**/*"]}3. Scaffold
Section titled “3. Scaffold”Keep the composition separate from the script that renders it, so tests, preview and export all build the same film.
src/ theme.ts sizes, fps, colors, fonts — the only place for magic numbers film.ts buildFilm(): returns the Composition render.ts frames | grid | preview | videoassets/ every input file, stored with Git LFS fonts/ .ttf / .otf (text needs a real font file) footage/ .mp4 / .mov / .webm audio/ .mp3 / .wav images/ .png / .jpg / .webpoutput/ renders (gitignored, never committed).gitattributes LFS rules for assets/import path from "node:path";import { fileURLToPath } from "node:url";import { FontManager } from "framefields";
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const W = 1920;export const H = 1080;export const FPS = 30;export const INK = "#0e0d0c";export const PAPER = "#eee9e0";export const SANS = "Inter";
export async function registerFonts(): Promise<void> { // Resolve from the module URL so the browser preview finds the file too. await FontManager.register({ family: SANS, source: path.resolve(HERE, "../assets/fonts/Inter.ttf") });}import { Composition, Layer, LayerAnimation } from "framefields";import { FPS, H, INK, PAPER, registerFonts, SANS, W } from "./theme.js";
const DURATION = 3 * FPS; // frames
export async function buildFilm(): Promise<Composition> { await registerFonts(); // before any text layer is built
const film = new Composition({ width: W, height: H, fps: FPS, durationMs: (DURATION / FPS) * 1000, backgroundColor: INK, });
film.add( Layer.text("Hello, framefields", { id: "title", position: "absolute", x: 0, y: H / 2 - 60, width: W, height: 120, fontFamily: SANS, fontSize: 96, fontWeight: 700, fill: PAPER, align: "center", }).animate( LayerAnimation.create() .fadeIn(0, 20, "power2.out") .fromTo("y", H / 2 - 20, H / 2 - 60, { start: 0, end: 30, ease: "expo.out" }), ), );
return film;}import fs from "node:fs/promises";import path from "node:path";import { HeadlessMediaRenderer, startPreview } from "framefields";import { buildFilm } from "./film.js";
const OUT = path.resolve(import.meta.dirname, "../output");const [mode = "video", ...args] = process.argv.slice(2);
if (mode === "preview") { const session = await startPreview({ entry: new URL("./film.ts", import.meta.url), export: "buildFilm" }); console.log(`Preview at ${session.url}`); await session.closed; process.exit(0);}
const film = await buildFilm();await fs.mkdir(OUT, { recursive: true });
if (mode === "frames") { const renderer = new HeadlessMediaRenderer(); for (const frame of args.map(Number)) { const png = await film.renderFrame({ frame, renderer }); await fs.writeFile(path.join(OUT, `f${String(frame).padStart(4, "0")}.png`), png); }} else if (mode === "grid") { const png = await film.renderFrameGrid({ frames: args.length ? args.map(Number) : [0, 10, 20, 30, 45, 60, 75, 89], columns: 4, cellWidth: 480, }); await fs.writeFile(path.join(OUT, "grid.png"), png);} else { const result = await film.renderVideo({ outputPath: path.join(OUT, "output.mp4"), quality: "high", qa: true }); console.log(`Exported ${result.filePath}`);}
// Native GPU and encoder threads keep Node alive: always exit explicitly.process.exit(0);4. Verify, cheapest step first
Section titled “4. Verify, cheapest step first”Each step catches in seconds what a full render takes minutes to show.
| Step | Command | Look for |
|---|---|---|
| 1 | npm run typecheck |
type errors |
| 2 | npm run frames -- 0 30 60 |
PNGs in output/; a black frame usually means a missing font, an off-canvas layer, or opacity stuck at 0 |
| 3 | npm run grid |
smooth motion across the contact sheet |
| 4 | npm run preview |
a person watches it with sound (Live preview) |
| 5 | npm run render |
the MP4, plus QA findings (Rendering) |
5. Store assets with Git LFS
Section titled “5. Store assets with Git LFS”Keep every input file under assets/ and store it with Git LFS. Set it up before adding any media:
git lfs installgit lfs track "assets/fonts/**" "assets/footage/**" "assets/audio/**" "assets/images/**" \ "assets/luts/**" "assets/models/**" "assets/lottie/**"echo "output/" >> .gitignoregit add .gitattributes .gitignore && git commit -m "Track assets with Git LFS"Push: git add assets/…, commit and git push as usual; the media upload with the push. Run git lfs ls-files before committing to confirm each new file is listed.
Pull: in a fresh clone, container or CI runner, run git lfs pull before rendering (in GitHub Actions, actions/checkout with lfs: true).
Details, including fixing a file committed without LFS: Assets and Git LFS.
- Core concepts — how frames, layers and timing fit together.
- framefields for agents — install the skills so your agent knows all of this.