@elah/cli
v0.1.2
Published
Headless command-line runtime for the Elah video engine — split, trim, and export projects using @elah/core
Maintainers
Readme
@elah/cli
Headless command-line runtime for the Elah video engine. A thin consumer of
@elah/core's public APIs — no rendering or timeline logic lives here.
Install
npm install -g @elah/cliOr without installing, via npx @elah/cli <command>. Requires Node >= 18.17. Version 0.1.2 depends on @elah/core@^0.6.0.
export/build --export/serve additionally require a system Google Chrome
(or --browser <path> / ELAH_BROWSER) — see Serve mode and
Docker for headless environments.
Commands
elah split --project <in.json> --clip <clipId> --at <frame|timecode> [--out <out.json>]
elah trim --project <in.json> --clip <clipId> [--start <frame|timecode>] [--duration <frames|timecode>] [--out <out.json>]
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>]
elah build --spec <spec.json> [--out <project.json>] [--export <file.mp4>] [export options]
elah serve [--port <n>] [--host <addr>] [--concurrency <n>] [--media-root <dir>]
[--codec avc|vp9|vp8] [--height <N>] [--video-bitrate <bps>] [--audio-bitrate <bps>]
[--browser <path>] [--timeout <s>] [--verbose]--height is the target for the stage's short edge (1080 = 1920×1080 landscape or
1080×1920 portrait); the other edge follows the stage's aspect ratio, rounded to an even number.
split, trim and build run in plain Node against the engine. export
launches the system Google Chrome headlessly (or --browser <path> /
ELAH_BROWSER) and runs core's real exportVideo pipeline in it, so CLI
output is identical to Editor output by construction. Exit codes: 0 success,
1 validation/runtime failure, 2 usage error. Diagnostics go to stderr;
split/trim print the resulting project JSON to stdout unless --out is given.
The build spec — the AI-generation contract
elah build consumes a seconds-based spec, probes each media asset's real
duration, and constructs the project through TimelineEngine, so overlaps
and source bounds are 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:
- Times are seconds (floats fine), converted to integer frames at
fps(default 30; rounding to nearest frame).stagedefaults to 1920×1080. assetsmaps names to paths relative to the spec file, orhttp(s)URLs. Duplicate keys follow JSON semantics (last one wins) — avoid them.- video/audio clips:
durationdefaults to the media's remainder aftersourceStart; exceeding it is an error (with a one-frame rounding tolerance, which clamps). The real media length is recorded as the clip's source bounds so latertrim/splitbehave correctly. - text clips need
text+duration; image clips needduration. x/yare the normalized (0..1) stage position of the clip's center;scaleis relative to native size.- Overlaps: every spec video clip is placed on one video track, so overlapping video clips are a spec error — the build fails, naming the clips. (Since 0.1.2; the engine itself allows several video tracks, but the spec has no way to say which one a clip belongs to.) Overlapping text, image and audio clips are automatically placed on additional tracks. Note: a clip bumped to a later track renders beneath the one it overlaps — list the clip you want on top first.
- Unknown or misspelled fields are rejected by name.
Then: elah build --spec spec.json --export final.mp4 (add --out project.json
to keep the editable project; export options like --height pass through).
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()Errors throw CliError (aliased ElahError) with a .message that is
already the human-readable, path-addressed text (clips[2].duration must be …).
Also exported: validateSpec / specToProject (the spec layer on its own), probeMedia,
startServe (the elah serve server, with Semaphore), CODECS, and the types BuildSpec,
SpecClip, RenderSession, RenderJobOptions, ProgressPayload, ServeOptions and Project.
exportProject and session.render take outputHeight (short edge), videoCodec,
videoBitrate, audioBitrate and timeoutMs.
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:
elah serve --port 8080 --concurrency 2 --media-root ./assets| Route | Behavior |
|---|---|
| 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 spec/asset validation errors, 503 + Retry-After when at capacity (--concurrency), 500 on render failure. |
curl -X POST --data-binary @spec.json http://127.0.0.1:8080/render -o out.mp4There is no job queue — this is a synchronous, retry-on-503 contract. See docs/deploy-render-server.md for the full contract, security notes, and deployment options.
Docker
A Dockerfile that installs branded Chrome + fonts and runs elah serve is at
Dockerfile (build from the repo root — see
docs/deploy-render-server.md).
Validation tooling
ELAH_E2E=1 npm test --workspace=packages/cliadditionally runs a browser end-to-end test (exports the no-media fixture through headless Chrome and probes the MP4).node packages/cli/scripts/parity-compare.mjs <editor.mp4> <cli.mp4>compares an editor UI export against a CLI export of the same project: stream params, decoded-video hashes (expected bit-identical), and the audio null-test residual (−91 dB = digital silence; a small residual is a systematic editor-page vs harness-page environment delta in the AAC encode stage — both pipelines are individually deterministic).
See elah --help for the full flag list.
Package layers
@elah/core — engine, playback, resolver, vanilla stores, media, export (framework-agnostic)
@elah/react — React bindings: EditorContext, store hooks, audio hooks
@elah/timeline — React timeline UI components and hooks
@elah/editor — EditorProvider, Preview, AssetPanel + re-exports everything above
@elah/cli — headless split/trim/build/export/serve on top of @elah/core (this package)@elah/cli depends only on @elah/core — it never touches @elah/react,
@elah/timeline, or React at all, so it has no peer dependencies on React or
lucide-react.
Use @elah/cli for automation, AI-generation pipelines, and server-side rendering.
Use @elah/editor when you need an interactive, in-browser editing UI.
