CLI & Server

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
npm install -g @elah/cli
elah serve

# or, zero-install:
npx @elah/cli serve

Requires 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

elah split--project <in.json> --clip <clipId> --at <frame|timecode> [--out <out.json>]
Pure Node, no browser. Prints project JSON to stdout unless --out is given.
elah trim--project <in.json> --clip <clipId> [--start <frame|timecode>] [--duration <frames|timecode>] [--out <out.json>]
Pure Node, no browser.
elah export--project <in.json> --out <file.mp4> [--codec avc|vp9|vp8] [--height <N>] [--video-bitrate <bps>] [--audio-bitrate <bps>] [--browser <path>] [--headed] [--timeout <s>]
Launches headless Chrome and runs the real exportVideo pipeline.
elah build--spec <spec.json> [--out <project.json>] [--export <file.mp4>] [export options]
Builds a project from a seconds-based spec; optionally exports it.
elah serve[--port 8080] [--host 127.0.0.1] [--concurrency 1] [--media-root <dir>] [--timeout 600] [--verbose] [export options]
Long-lived HTTP render server with a warm browser.

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.

spec.json
{
  "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). stage defaults 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; scale is 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:

bash
elah build --spec spec.json --export final.mp4

Serve 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:

bash
elah serve --port 8080 --concurrency 2 --media-root ./assets
GET /healthz
200 { status, browser: "connected" | "disconnected" }
POST /render
Body = a build spec JSON document; blocks until the render finishes and returns the MP4 bytes. 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:

bash
curl -X POST --data-binary @spec.json http://127.0.0.1:8080/render -o out.mp4

How 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:

bash
docker build -f packages/cli/Dockerfile -t elah-render .
docker run -p 8080:8080 elah-render

The 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:

typescript
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:

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

Up Next
API Reference

See the full public API across @elah/core, @elah/timeline, and @elah/editor.

Continue to API Reference →