Skip to content

Getting started

View .mdllms.txt

One command sets up everything this page describes: package.json (with the install-script approvals below, scripts, dependencies and packageManager), tsconfig.json, src/theme.ts, film.ts and render.ts, assets/ with Git LFS rules, .gitignore, and the Inter and JetBrains Mono fonts.

Terminal window
pnpm dlx framefields init my-film --install # or: npx framefields init my-film --install
cd my-film && pnpm frames -- 0 30 60 # first frames in output/
  • Nothing to answer. init asks no questions, so it runs the same in CI and on remote servers.
  • Existing projects are safe. Run it inside one to add framefields there. Files you have are left alone, and package.json, .gitignore and .gitattributes only gain what they lack. A "type": "commonjs" package is refused, because framefields needs ES modules.
  • Fonts are pinned. Inter and JetBrains Mono are downloaded from Google Fonts, checked by SHA-256 and saved in assets/fonts/ with their OFL licenses. The generated src/components/fonts.ts registers them, and theme.ts calls it. The npm package ships no fonts.
  • The AI gateway is optional. To generate images, video, music, voice and 3D from code, create a key at framefields.dev/dashboard, copy .env.example to .env and set FRAMEFIELDS_API_KEY. render.ts loads .env when it exists. See Generative AI.
Option Effect
--install Install dependencies afterwards
--pm npm|pnpm|yarn|bun Package manager (default: the one running the command)
--emoji Also install Noto Color Emoji, so emoji in text draw in color
--no-fonts Skip Inter and JetBrains Mono
--no-git Don’t run git init
--name <name> Package name (default: the folder’s name)

Then jump to 4. Verify. The steps below set up the same project by hand, for when you want to see each piece or merge into an unusual setup.

Terminal window
npx framefields list # ready-made components, one line each
npx framefields add chat-thread # copies a component's source into src/components/
npx framefields fonts add emoji # fonts on their own: emoji, sans (Inter), mono (JetBrains Mono)

Components that need a font bring it along: chat-thread and captions install the emoji font, terminal and code-block JetBrains Mono. Text with emoji but no emoji font draws nothing, so comp.lint() reports missing_emoji_font and the first render prints how to fix it.

Terminal window
node --version # must be v22 or later
  • A GPU, or a CPU renderer. Rendering uses WebGPU via Dawn: Metal on macOS (every Mac has a Metal GPU), Vulkan on Linux, Direct3D 12 or Vulkan on Windows. Without a GPU it renders on the CPU, about 12× slower: on Linux install Mesa lavapipe (apt-get install -y libvulkan1 mesa-vulkan-drivers); Windows has WARP built in. See Rendering without a GPU.
  • ES modules. package.json needs "type": "module".

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.

package.json
{
"name": "my-film",
"private": true,
"type": "module",
"allowScripts": {
"skia-canvas": true,
"sharp": false,
"webgpu": false,
"onnxruntime-node": false,
"node-av": false,
"esbuild": false,
"protobufjs": false
},
"pnpm": {
"onlyBuiltDependencies": ["skia-canvas"],
"ignoredBuiltDependencies": ["sharp", "webgpu", "onnxruntime-node", "node-av", "esbuild", "protobufjs"]
},
"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. Deny the rest: they fetch an ffmpeg CLI and CUDA libraries framefields doesn’t use, or do nothing it needs.

Add "packageManager" with the manager and version you install with (e.g. "pnpm@10.13.1"; pnpm --version prints it), and commit the lockfile it writes. A render worker installs with exactly that version and lockfile.

Terminal window
npm install framefields
npm install --save-dev tsx typescript @types/node
tsconfig.json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*"]
}

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 | video
assets/ 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 / .webp
output/ renders (gitignored, never committed)
.gitattributes LFS rules for assets/
src/theme.ts
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") });
}
src/film.ts
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;
}
src/render.ts
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);

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)

Keep every input file under assets/ and store it with Git LFS. Set it up before adding any media:

Terminal window
git lfs install
git lfs track "assets/fonts/**" "assets/footage/**" "assets/audio/**" "assets/images/**" \
"assets/luts/**" "assets/models/**" "assets/lottie/**"
echo "output/" >> .gitignore
git 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.