CLI & Server
The same engine that powers the in-browser editor runs headlessly on your server. @elah/cli is a thin consumer of @elah/core's public APIs — build projects from JSON specs, export MP4s, or run a long-lived HTTP render server.
Install
Install globally, or run without installing via npx:
npm install -g @elah/cli
elah serve
# or, zero-install:
npx @elah/cli serveRequires Node.js ≥ 18. export and serve also require a branded Chrome or Edge on the host — Playwright's bundled Chromium lacks H.264/AAC codec support. Point at a specific browser with --browser <path> or the ELAH_BROWSER env var.
Commands
Exit codes: 0 success, 1 validation/runtime failure, 2 usage error. Diagnostics go to stderr.
The Build Spec
elah buildconsumes a seconds-based spec, probes each media asset's real duration, and constructs the project through TimelineEngine, so overlaps, track caps, and source bounds are all validated with precise, path-addressed errors (clips[2].duration must be …) that a generating model can self-correct from.
{
"fps": 30,
"stage": { "width": 1920, "height": 1080 },
"assets": {
"footage": "./media/clip.mp4",
"music": "./media/song.mp3",
"logo": "./media/logo.png"
},
"clips": [
{ "track": "video", "asset": "footage", "start": 0, "duration": 8, "sourceStart": 2, "volume": 0.8 },
{ "track": "text", "text": "Hello", "start": 0.5, "duration": 4,
"fontSize": 96, "color": "#ffffff", "fontFamily": "Arial", "fontWeight": "bold",
"align": "center", "x": 0.5, "y": 0.15 },
{ "track": "image", "asset": "logo", "start": 1, "duration": 6, "x": 0.9, "y": 0.1, "scale": 0.3, "opacity": 0.8 },
{ "track": "audio", "asset": "music", "start": 0, "duration": 8, "volume": 0.5 }
]
}Rules worth knowing:
- Times are seconds (floats fine), converted to integer frames at
fps(default 30).stagedefaults to 1920×1080. - assets maps names to paths relative to the spec file, or
http(s)URLs. - x/y are the normalized (0..1) stage position of the clip's center;
scaleis relative to native size. - Overlaps: video clips must not overlap (single video track, engine-enforced); overlapping text/image/audio clips are automatically placed on additional tracks.
Then build and export in one step:
elah build --spec spec.json --export final.mp4Serve Mode
elah serve runs a long-lived HTTP render server with a warm browser, so each request only pays for a new browser tab instead of a fresh Chrome process:
elah serve --port 8080 --concurrency 2 --media-root ./assets200 { status, browser: "connected" | "disconnected" }422 on validation errors, 503 + Retry-After when at capacity (--concurrency), 500 on render failure.There is no job queue — this is a synchronous, retry-on-503 contract:
curl -X POST --data-binary @spec.json http://127.0.0.1:8080/render -o out.mp4How Rendering Works
export and servelaunch a branded Chrome headlessly (via Playwright) and run core's real exportVideo pipeline inside it — the same worker + OffscreenCanvas code path the browser editor uses. Output is bit-identical to browser export by construction, not by approximation: there is no separate server-side renderer to drift out of sync.
Docker & Self-Hosting
The packages/cli repo ships a Dockerfile that installs branded Chrome and fonts, then runs elah serve:
docker build -f packages/cli/Dockerfile -t elah-render .
docker run -p 8080:8080 elah-renderThe render server ships with no built-in authentication — bind it to 127.0.0.1 or put it behind a proxy that handles auth. See docs/deploy-render-server.md for systemd setup, security hardening, and GPU/performance notes.
Library API
@elah/cli is also importable — no shelling out, no stderr parsing:
import { build, exportProject } from '@elah/cli'
const { project } = await build({ spec: mySpecObject, baseDir: '/path/to/assets' })
const { bytes } = await exportProject(
{ project, outPath: 'out.mp4' },
{ onProgress: ({ frame, totalFrames }) => console.log(`${frame}/${totalFrames}`) }
)For repeated renders, createRenderSession() keeps a browser warm across calls instead of launching Chrome per export — this is what elah serve uses internally:
import { createRenderSession } from '@elah/cli'
const session = createRenderSession()
await session.warmup()
const mp4 = await session.render(project, '/path/to/assets')
// ... more session.render() calls reuse the same browser ...
await session.close()Also exported: startServe, validateSpec, probeMedia. Errors throw CliError with a .message that is already the human-readable, path-addressed text.
See the full public API across @elah/core, @elah/timeline, and @elah/editor.
Continue to API Reference →