Headless rendering — premation render
The file it writes is the file the editor would have written: the CLI drives the same deterministic frame loop, the same GPU render graph and the same ffmpeg encode that the Export dialog and the Render Queue use. There is no second renderer to keep in step, and no "close enough" path.
- Why it opens a window
- Running it
- Commands
- Options
- Data-driven batches
- Exit codes
- Machine-readable output
- Windows and stdout
- Assets
- What it does not do
- Source map
Why it opens a window#
The render pipeline is a DOM pipeline. Twenty-odd render-path modules touch
document, the texture provider builds real HTMLImageElement /
HTMLVideoElement objects, and page-loaded web fonts are invisible to a
worker's OffscreenCanvas — so a "pure Node" renderer would produce different
pixels from the preview for any composition containing text. That is the same
reason parallel frame rendering was investigated and declined; see
docs/AE_COMPARISON.md §3, Tier 1b.
So a CLI render is the editor: a hidden BrowserWindow, the real engine, the
real scene graph, and nothing shown. backgroundThrottling is off, because
Chromium otherwise throttles a hidden window's timers to about one tick per
second and the frame loop yields between frames.
What the headless process does not boot is everything a render has no business holding open: no application menu, no auto-updater, no managed backend, no account session, no plugin network bridge, no AI provider channel. The IPC it registers is the disk (the project, its assets, its blobs) and ffmpeg.
Running it#
From a packaged build, the app executable is the CLI:
Premation render Promo.motion --comp Main --out promo.mp4From this repository, build the renderer once, then drive the compiled main process:
npm run build:localnpm run cli -- render Promo.motion --comp Main --out promo.mp4npm run cli compiles electron/ and launches Electron; everything after --
is the command line. It reads the built renderer from dist/, so re-run
npm run build:local after changing renderer code.
Each render is its own process. The single-instance lock that makes a
premation:// deep link return to an already-open editor is deliberately
bypassed for CLI runs — without that, a render started while the editor was open
would exit 0 having rendered nothing.
Commands#
render <project> [options]#
Renders one composition to one file.
premation render Promo.motion --out promo.mp4With no --comp, it renders the project's first real composition — "real"
meaning not the auto-minted empty one a fresh document carries, which would
otherwise deliver a blank frame and report success.
With no --out, it writes beside the project, named after it:
Promo.motion → Promo.mp4.
reframe <project> --aspect <ratio>#
Retargets the composition to another shape — following the subject, jumping at
cuts — and renders the result. Every render option applies.
premation reframe Promo.motion --aspect 9:16 --out vertical.mp4Aspects: 9:16, 1:1, 4:5, 16:9, 4:3. The reframed composition is created
in the project exactly as the editor's command creates it; the source is
untouched. See AUTO_REFRAME.md for how the crop decides
where to look.
captions <project> [--out subs.srt]#
Transcribes the composition's own audio and writes a caption file.
premation captions Promo.motion --out promo.srt --language en.vtt writes WebVTT, anything else SubRip. Needs an OpenAI key in the desktop
app's keystore — see CAPTIONS.md for why that provider and
where the key lives. A silent composition is reported as such rather than
producing an empty file.
To burn captions INTO a render instead, pass --captions to render:
premation render Promo.motion --captions promo.srt --out subtitled.mp4That replaces any captions already in the composition, so re-running a pipeline does not stack a second set of layers over the first.
comps <project>#
Lists what is in a project — name, size, frame rate, duration and id — so a script can discover what to render.
premation comps Promo.motionMain (1920×1080 @ 30fps, 12.00s, id comp_1)
Lower Third (1920×1080 @ 30fps, 4.00s, id comp_7)
Options#
| Option | Meaning |
|---|---|
--comp <name|id> |
Composition to render. Matched by id, then by exact name, then case-insensitively. |
--out <file> |
Output path. Created directories are fine; the file is overwritten. |
--format <fmt> |
mp4, mov, webm, gif, hdr10, hlg, png-sequence, jpg-sequence, exr-sequence, png. Inferred from --out's extension when it is unambiguous, else mp4. |
--range <a-b> |
Inclusive frame range, e.g. --range 0-119 renders 120 frames. |
--start <frame> / --end <frame> |
The same range, given separately. --end is inclusive. |
--fps <n> |
Override the composition's frame rate. |
--scale <n> |
Uniform output scale. --scale 0.5 halves both dimensions. |
--width <px> / --height <px> |
Output size. Giving one keeps the composition's aspect ratio. |
--quality <tier> |
high (default), medium, draft. |
--prores <profile> |
mov only: proxy, lt, 422, hq, 4444 (default). Refused on other formats rather than silently ignored. |
--transparent |
Force an alpha channel on regardless of the composition's own setting. |
--data <file> |
A CSV or JSON table. Renders one file per row — see below. |
--from-row <n> |
Start a --data batch at row n (1-based). Resumes without re-rendering. |
--captions <file> |
Import an .srt/.vtt before rendering (burn-in). |
--aspect <ratio> |
reframe only: the target shape. |
--language <code> |
captions only: a speech language hint. |
--json |
One JSON object per line. |
--quiet |
No progress lines; the result still prints. |
--log <file> |
Mirror every line into a file as well. See Windows and stdout. |
--out overwrites, where the Render Queue never does. A queued render
suffixing (2) protects a file the user forgot about; a pipeline whose artifact
lands at promo (7).mp4 on the seventh run has no artifact path at all.
A bad command line exits 2 without booting anything — a typo should not cost a GPU boot to discover.
Data-driven batches#
Author one composition, expose the layers you want driven as template fields, and render one file per row of a spreadsheet:
premation render LowerThird.motion --data people.csv --out "out/{index}-{name}.mp4"name,role
Ada Lovelace,Mathematician
Grace Hopper,Rear Admiral
A column fills the template field with the same id. Columns with no field
(a notes column, an output-name column) are ignored; fields with no column
keep their authored value on every row. Text, colour and number fields are
filled; media fields are reported as skipped rather than silently ignored.
--out must contain a {token}, and the CLI refuses the command if it
does not. {index} is the 1-based row number, zero-padded to the width of the
table so the folder sorts in table order; anything else names a column. Values
are made filename-safe, and a cell that sanitises to nothing falls back to its
row number so two rows can never collide on one path.
--out "out/{index}-{name}.mp4" → out/01-Ada Lovelace.mp4
--out "out/{name}.mp4" → out/Ada Lovelace.mp4
--out "out/promo.mp4" → refused: every row would overwrite the last
Rows render strictly sequentially — apply the row, await the whole render,
then the next. That is not an implementation detail: a render reads the live
scene graph when it runs, so queueing forty jobs and filling forty rows would
produce forty copies of the last row, each correctly named after a different
one, with nothing reported. See src/core/template/batchRender.ts.
A failed row is recorded and the batch continues; the run then exits 1 with every row named in the log, because thirty-nine good files and one named failure is a better morning than one error and no files — and a pipeline must not read "39 of 40" as green.
A batch that was interrupted resumes with --from-row, which is 1-based
because the log, the CSV and every spreadsheet are:
premation render Promo.motion --data rows.csv --out "out/{index}.mp4" --from-row 31The editor's Render every row does the same: stopping it leaves a "Resume from row N" button rather than throwing away files that are already on disk.
The same loop is in the editor: Templates ▸ Data ▸ Render every row, which writes into the folder the Render Queue already asked for. Both put the template's own values back when they finish, so a batch never leaves the last row's copy on your layers.
Exit codes#
| Code | Meaning |
|---|---|
0 |
The file was written. |
1 |
The render failed. The reason is the last line printed. |
2 |
The command line was wrong. Nothing was rendered. |
A render that stops making progress for 15 minutes is stopped and reported, rather than holding a CI runner until the runner's own timeout kills it with no explanation. The clock resets on every frame, so it bounds the gap between frames, never the length of the render.
Machine-readable output#
--json prints one object per line:
{"event":"progress","fraction":0.5,"percent":50}
{"event":"warning","message":"warning: Layer \"Clip\" points at media this machine cannot resolve (local-path): C:\\footage\\a.mp4"}
{"event":"done","message":"Wrote …","outPath":"…","frames":120,"width":1920,"height":1080,"fps":30,"elapsedMs":48210,"warnings":[]}Failures print {"event":"error","message":"…"} and exit 1.
Windows and stdout#
A packaged Electron app on Windows is a GUI-subsystem binary with no console
attached, so a run started from cmd or PowerShell gets the exit code and
nothing else — console.log has nowhere to go. This is a Windows packaging
constraint, not something the CLI can work around from inside the process.
Use --log there:
Premation render Promo.motion --out promo.mp4 --json --log render.logThe exit code is reliable on every platform, with or without --log.
Assets#
A .motion bundle carries its own footage, and the CLI restores it.
Saving a bundle collects every asset that only lived in that session's
memory: the bytes are content-addressed into blobs/, recorded in
assets/registry.json, and the document is repointed at them. Opening one —
including headlessly — reads that registry back, so a project moved to another
machine renders the footage it was authored with, with no relink step. Verified
by rendering a hand-built bundle whose document carried nothing but a dead
blob: URL and an asset id.
Two things still travel badly, and the CLI names both rather than hiding them:
- Media referenced by an absolute path belongs to the machine that authored it. Printed as a warning line naming the layer and the path.
- A dead object URL with no matching registry row — footage imported into a
project that was never saved as a bundle. Also warned, and if the frame would
be visibly wrong the export refuses outright rather than shipping a hole; see
offlineRenderer.ts.
Projects with no external footage — shapes, text, solids, effects, precomps — render anywhere regardless.
What it does not do#
- No parallelism. One render at a time, one row at a time. Run several processes if your machine has the GPU headroom.
- No audio-only or interchange formats.
wav,lottie,json,edl,otio,fcpxml,aleandmogrthand a blob to the browser's download machinery rather than writing a path, so they remain editor features. - No queue. Pausing, resuming and reordering belong to the Render Queue panel, which has a person in front of it.
Source map#
| File | What it owns |
|---|---|
electron/cliArgs.ts |
argv → a job. Pure, and fully unit-tested. |
electron/cliRender.ts |
The hidden window, the watchdog, stdout, the exit code. |
electron/main.ts |
Which launch this is, and what a headless one is allowed to register. |
src/pages/RenderPage.tsx |
The #/render route: boots Providers, asks for its job, reports once. |
src/core/cli/headlessRender.ts |
Open the project, resolve the composition, render, write the file. |
src/core/export/renderJob.ts |
The render itself — shared verbatim with the Render Queue. |
src/core/template/batchRender.ts |
The one-render-per-row loop, the naming rules, and the restore. |