Skip to content

Render on a server

View .mdllms.txt

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.

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

A project that follows 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
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. target.args is passed to a function export. target.select picks names from a record or plan.

Terminal window
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}.

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