# Getting started

> Install framefields, scaffold theme/film/render files, and verify a first render from cheapest check to final MP4.

## 1. Check the machine

```bash
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"`.

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

```json title="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.

```bash
npm install framefields
npm install --save-dev tsx typescript @types/node
```

:::caution
If you see `Cannot find module '.../skia.node'`, the `skia-canvas` script was skipped. Approve it as above, then run `npm rebuild skia-canvas` (or `pnpm rebuild skia-canvas`).
:::

```json title="tsconfig.json"
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src/**/*"]
}
```

## 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 | 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/
```

```ts title="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") });
}
```

```ts title="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;
}
```

```ts title="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);
```

## 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](/docs/guides/preview/)) |
| 5 | `npm run render` | the MP4, plus QA findings ([Rendering](/docs/guides/rendering/)) |

## 5. Store assets with Git LFS

Keep every input file under `assets/` and store it with [Git LFS](https://git-lfs.com). Set it up before adding any media:

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

:::caution
Until pulled, LFS files are ~130-byte pointers. framefields cannot decode them: fonts fail to register and media renders blank. Ignore `output/`, not `*.mp4`, or your footage disappears from Git too.
:::

Details, including fixing a file committed without LFS: [Assets and Git LFS](/docs/agents/workflow/#assets-and-git-lfs).

## Next

- [Core concepts](/docs/core-concepts/) — how frames, layers and timing fit together.
- [framefields for agents](/docs/agents/) — install the skills so your agent knows all of this.
