# Render on a server

> Render a framefields project straight from git on a GPU worker (Docker, RunPod pod or serverless). Project checklist, job JSON, outputs and error codes.

The **renderer worker** renders a project from its git repo: it fetches a commit, pulls Git LFS assets, installs dependencies, renders an exported composition on a warm GPU, checks the file with ffprobe and uploads it. It runs as a Docker container on any NVIDIA host, a RunPod pod or a RunPod serverless endpoint. Deploying it: [renderer-service README](https://github.com/gatewai-dev/framefields/tree/main/apps/renderer-service).

The worker has no engine of its own. It renders with the `framefields` version in the project's lockfile.

## Project checklist

A project that follows [Getting started](/docs/getting-started/) already meets most of this. Check every line before submitting a job.

| Rule | Why | Fails with |
| --- | --- | --- |
| `framefields` is a dependency, version 2.2 or newer | 2.2 adds progress, cancel, warm workers and safe video encoders | `ENGINE_NOT_FOUND`; 2.1 renders but without those |
| A lockfile is committed and matches `package.json`: `pnpm-lock.yaml`, `package-lock.json` or `yarn.lock` | the worker installs with `--frozen-lockfile` / `npm ci`. Bun lockfiles aren't supported | `LOCKFILE_MISSING`, `INSTALL_FAILED` |
| `packageManager` is pinned, e.g. `"pnpm@10.13.1"` | the worker installs with that exact version | install differences |
| Install-script approvals are in `package.json` (only `skia-canvas`) | the worker runs the same install as you | `Cannot find module '.../skia.node'` |
| The entry module only **builds** the film: `film.ts` exports `buildFilm`, and `render.ts` does the rendering | the worker imports the entry. Rendering or `process.exit()` at import time is rejected | `EXPORT_SIDE_EFFECT`, `EXPORT_NOT_FOUND` |
| Every input file is under `assets/`, stored with Git LFS, and pushed (`git lfs ls-files`) | the worker pulls LFS objects for the commit it renders | `LFS_POINTER_UNRESOLVED`, `LFS_OBJECT_MISSING`, `ASSET_NOT_FOUND` |
| Asset paths are built from `import.meta.url`, never `process.cwd()` | the worker's working directory isn't your project | `ASSET_NOT_FOUND` |
| Nothing writes into `node_modules` while rendering | the worker shares `node_modules` files read-only between projects | a permission error (`EACCES`/`EPERM`) with a hint |
| Everything is committed and pushed | the worker renders the pushed commit, not your working tree | stale output |

## What the export can return

```ts title="src/film.ts"
// One video
export async function buildFilm(args?: { locale?: string }): Promise<Composition> { … }

// Several named outputs
export const cuts = () => ({ main: buildFilm(), teaser: buildTeaser() });

// A plan, with settings per output
export const deliverables = () => ({
  outputs: [
    { name: "film", composition: buildFilm() },
    { name: "sheet", composition: buildFilm(), render: { kind: "frameGrid", grid: { step: 30 } } },
  ],
});
```

This is the same `{ entry, export }` convention as [`startPreview`](/docs/guides/preview/). `target.args` is passed to a function export. `target.select` picks names from a record or plan.

## Submit a job

```bash
curl -s "$WORKER/v2/jobs" -H "authorization: Bearer $RENDERER_API_KEY" -H "content-type: application/json" -d '{
  "version": 2,
  "source": { "repo": "https://github.com/acme/films.git", "ref": "main", "workdir": "videos/launch" },
  "target": { "entry": "src/film.ts", "export": "buildFilm" },
  "render": { "kind": "video", "codec": "h264", "quality": "high" },
  "output": { "type": "s3", "key": "launch/{shortSha}/{name}.{ext}" }
}'
# → {"id":"…","status":"IN_QUEUE"}
```

| Call | Returns |
| --- | --- |
| `GET /v2/jobs/<id>` | status and result: output URLs or keys, the rendered commit SHA, timings, GPU |
| `GET /v2/jobs/<id>/events` | live progress (Server-Sent Events) |
| `POST /v2/jobs/<id>/cancel` | stops the render between frames |

On **RunPod serverless**, send the same JSON as `{"input": …}` to `https://api.runpod.ai/v2/<endpoint-id>/run`. Then poll `/status/<id>`, which includes progress.

Useful job fields:

| Field | Use |
| --- | --- |
| `source.ref` | branch, tag or commit SHA. The result reports the SHA it rendered |
| `source.commit` | fail if the ref has moved past this commit |
| `source.workdir` | the package folder, for a monorepo |
| `source.lfs.include` | pull only the assets this render needs |
| `render.kind` | `video` (`h264`, `vp9`, `vp8`, `gif`), `audio` (`aac`, `mp3`, `opus`), `frames`, `frameGrid`, `lut` |
| `render.qa.failOn` | QA codes that fail the job (`QA_FAILED`). The outputs still upload |
| `output.type` | `s3` (key template), `presigned` (one URL per output), `local`, `inline` (base64) |
| `idempotencyKey` | a retry of the same commit returns the earlier upload |

Key templates can use `{jobId}`, `{sha}`, `{shortSha}`, `{ref}`, `{date}`, `{name}`, `{index}` and `{ext}`.

## Errors

A failed job returns `{ code, message, phase, retryable, hint }`. When `retryable` is true, resubmit the same job. Otherwise, fix the cause first:

| Code | Fix |
| --- | --- |
| `LOCKFILE_MISSING`, `INSTALL_FAILED` | commit a lockfile that matches `package.json` |
| `EXPORT_NOT_FOUND`, `EXPORT_INVALID`, `EXPORT_SIDE_EFFECT` | export a builder from the entry; render only in `render.ts` |
| `LFS_POINTER_UNRESOLVED`, `ASSET_NOT_FOUND` | push the LFS object, or widen `source.lfs.include` |
| `REPO_NOT_ALLOWED`, `GIT_AUTH_FAILED`, `REPO_NOT_FOUND` | the worker's allowlist or git token doesn't cover the repo |
| `REF_NOT_FOUND`, `REF_AMBIGUOUS` | push the branch, or pass a SHA |
| `ENGINE_SWITCH` (retryable) | the worker loaded another framefields version and restarts. Keep projects on one version per worker to avoid it |
| `GPU_UNAVAILABLE` (retryable) | the host's GPU isn't usable for WebGPU. Another worker takes the retry |
| `RENDER_TIMEOUT` | raise `timeoutSec`, or the endpoint's execution timeout on RunPod |

## Hardware

- **Drawing frames.** Frames are drawn with WebGPU on Vulkan. The `FF_GPU` setting: `auto` uses the GPU, or the CPU (lavapipe, much slower) when there is no GPU; `require` refuses to run without a GPU (used by the RunPod templates); `cpu` always draws on the CPU.
- **Vision.** Vision models run on the same GPU through WebGPU. The log line `[vision] Inference on the GPU (WebGPU)` confirms it.
- **Video encoding.** framefields encodes with NVENC when it works and falls back to libx264/libx265/libvpx/SVT-AV1 otherwise. NVENC needs NVIDIA driver 610 or newer. A100 and H100 have no NVENC.
- **Picking a GPU.** RTX/L4/L40S-class cards suit rendering. AI-training GPUs cost more and encode on the CPU.
