Skip to content

Getting started

View .mdllms.txt
Terminal window
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.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
},
"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.

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.