← All @molecule/* packages · App templates
@molecule/api-video-renderUtility · video-render · API (Node) · v1.0.1 · Apache-2.0
FFmpeg-driven server-side video rendering, queue-driven (MP4 export). Wraps ffmpeg with a Renderer interface and a worker entry-point compatible with a queue bond (e.g. @molecule/api-queue-redis).
npm install @molecule/api-video-render@molecule/api-video-render is a utility package for the API (Node) side (video-render).
import { renderVideo, getRenderStatus, cancelRender } from '@molecule/api-video-render'
const job = await renderVideo(
{
duration: 10,
resolution: { width: 1920, height: 1080 },
fps: 30,
tracks: [
{
id: 'v0',
kind: 'video',
clips: [
{ id: 'c0', source: '/uploads/intro.mp4', start: 0, duration: 5 },
{ id: 'c1', source: '/uploads/main.mp4', start: 5, duration: 5 },
],
effects: [{ id: 'fx0', kind: 'fade-in', start: 0, duration: 1 }],
},
],
},
{ format: 'mp4', outputPath: '/tmp/out.mp4' },
)
// Poll for completion.
let status = await getRenderStatus(job.jobId)
while (status.status === 'queued' || status.status === 'rendering') {
await new Promise((r) => setTimeout(r, 250))
status = await getRenderStatus(job.jobId)
}Works with: @molecule/api-queue
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Server-side video rendering for molecule.dev. Takes a VideoTimeline
(clips + tracks + effects) and queues an ffmpeg-driven render that
produces an MP4 (or WebM) file at a caller-supplied outputPath.
Renders run asynchronously: {@link renderVideo} enqueues a job onto the
bonded @molecule/api-queue provider and returns a { jobId, status: 'queued' } handle. Workers process jobs by calling
{@link processRenderJob}, which spawns ffmpeg with a sanitized,
argv-only command line — never a shell string. Status is observed via
{@link getRenderStatus}; jobs are cancelled via {@link cancelRender}.
import { renderVideo, getRenderStatus, cancelRender } from '@molecule/api-video-render'
const job = await renderVideo(
{
duration: 10,
resolution: { width: 1920, height: 1080 },
fps: 30,
tracks: [
{
id: 'v0',
kind: 'video',
clips: [
{ id: 'c0', source: '/uploads/intro.mp4', start: 0, duration: 5 },
{ id: 'c1', source: '/uploads/main.mp4', start: 5, duration: 5 },
],
effects: [{ id: 'fx0', kind: 'fade-in', start: 0, duration: 1 }],
},
],
},
{ format: 'mp4', outputPath: '/tmp/out.mp4' },
)
// Poll for completion.
let status = await getRenderStatus(job.jobId)
while (status.status === 'queued' || status.status === 'rendering') {
await new Promise((r) => setTimeout(r, 250))
status = await getRenderStatus(job.jobId)
}
// Express adapter (POST /render/video, GET/DELETE /render/jobs/:id)
import express from 'express'
import {
createEnqueueRenderHandler,
createGetRenderStatusHandler,
createCancelRenderHandler,
} from '@molecule/api-video-render'
const router = express.Router()
const enqueue = createEnqueueRenderHandler()
const status = createGetRenderStatusHandler()
const cancel = createCancelRenderHandler()
router.post('/render/video', (req, res, next) => {
enqueue(
{ body: req.body },
{
setStatus: (s) => {
res.status(s)
},
sendJson: (j) => {
res.json(j)
},
},
).catch(next)
})
router.get('/render/jobs/:id', (req, res, next) => {
status(
{ params: req.params },
{
setStatus: (s) => {
res.status(s)
},
sendJson: (j) => {
res.json(j)
},
},
).catch(next)
})
router.delete('/render/jobs/:id', (req, res, next) => {
cancel(
{ params: req.params },
{
setStatus: (s) => {
res.status(s)
},
sendJson: (j) => {
res.json(j)
},
},
).catch(next)
})
utility
npm install @molecule/api-video-render @molecule/api-queue
CreateEnqueueRenderHandlerOptionsOptions for {@link createEnqueueRenderHandler}. The optional validate
hook can reject requests pre-flight (e.g. tier limits, max duration).
interface CreateEnqueueRenderHandlerOptions {
/** Pre-flight validator — throw to reject with HTTP 400. */
validate?: (timeline: VideoTimeline, options: RenderVideoOptions) => void | Promise<void>
}
FfmpegProcessThe minimal child-process surface the worker observes. Real
ChildProcesses satisfy this; tests provide a EventEmitter-shaped fake.
interface FfmpegProcess {
stderr: FfmpegStderr | null
on(event: 'close', listener: (code: number | null) => void): void
on(event: 'error', listener: (error: Error) => void): void
/** Sends SIGTERM (or another signal) to the child. */
kill(signal?: NodeJS.Signals | number): boolean
}
FfmpegStderrSubset of child_process.ChildProcess's stderr we care about — enough
to listen for the textual progress lines ffmpeg writes to stderr.
interface FfmpegStderr {
on(event: 'data', listener: (chunk: Buffer | string) => void): void
}
JobStoreThe minimal contract job-status backends must satisfy. All operations are async to allow remote stores (Redis, database, etc.).
interface JobStore {
/** Read the latest status snapshot for a job. */
get(jobId: string): Promise<RenderJobStatus | undefined>
/** Write/replace the status snapshot for a job. */
set(jobId: string, status: RenderJobStatus): Promise<void>
/** Merge partial fields into the existing status (creating it if absent). */
patch(jobId: string, patch: Partial<RenderJobStatus>): Promise<RenderJobStatus>
/** Remove a job's status entry. Used by `cancelRender` cleanup. */
delete(jobId: string): Promise<void>
}
ProcessRenderJobDepsOptional dependency overrides for {@link processRenderJob}. Defaults to the bonded job store + ffmpeg runner.
interface ProcessRenderJobDeps {
/** Override the job store. Defaults to {@link getDefaultJobStore}. */
jobStore?: JobStore
/** Override the ffmpeg runner. Defaults to {@link getDefaultFfmpegRunner}. */
ffmpegRunner?: FfmpegRunner
}
RenderJobResult of {@link renderVideo}. The job is queued; callers must poll {@link getRenderStatus} to observe progress.
interface RenderJob {
/** Stable job identifier. Use this to poll status and to cancel. */
jobId: string
/** Initial job state — always `queued`. */
status: 'queued'
/** Queue name the job was enqueued onto. */
queueName: string
}
RenderJobMessageInternal envelope written to the queue for each render request. Workers deserialize this back into the renderer.
interface RenderJobMessage {
/** Job identifier. */
jobId: string
/** Timeline definition. */
timeline: VideoTimeline
/** Resolved render options (defaults already applied). */
options: RenderVideoOptions & { format: VideoRenderFormat; codec: VideoCodec }
}
RenderJobStatusStatus snapshot for a render job, returned by {@link getRenderStatus}.
interface RenderJobStatus {
/** Current state of the job. */
status: RenderJobState
/** Progress fraction 0..1 when in `rendering` state. */
progress?: number
/** Output URL/path when in `completed` state. */
outputUrl?: string
/** Error message when in `failed` state. */
error?: string
/** Wall-clock timestamps for state transitions. */
startedAt?: Date
/** Wall-clock timestamps for state transitions. */
finishedAt?: Date
}
RenderVideoOptionsRender-time options accepted by {@link renderVideo}.
interface RenderVideoOptions {
/** Output container format. Defaults to `mp4`. */
format?: VideoRenderFormat
/**
* Override the timeline's output resolution. Useful for proxy renders.
*/
resolution?: VideoResolution
/**
* Override the timeline's output FPS.
*/
fps?: VideoFps
/**
* Encoder codec. Defaults to `libx264` for mp4, `libvpx-vp9` for webm.
*/
codec?: VideoCodec
/**
* Constant Rate Factor (quality knob) — lower = higher quality. Codec-specific
* range. Defaults are sensible (`23` for x264).
*/
crf?: number
/**
* Output destination path. The worker writes the rendered file here.
* Must be an absolute path; the worker validates this before opening it.
*/
outputPath: string
/**
* Queue name used for enqueuing render jobs. Defaults to `video-render`.
*/
queueName?: string
/**
* Optional caller-supplied job ID. When omitted, an auto-generated random
* ID is used.
*/
jobId?: string
}
VideoClipA single source clip placed on a timeline track. The renderer trims the
source from [sourceStart, sourceStart + duration) and places it at
start on the track timeline.
source is a path or https?:/file: URL — never a user-controlled
argument-string. Filenames are escaped before being passed to ffmpeg.
interface VideoClip {
/** Stable identifier for the clip (used for status / progress correlation). */
id: string
/**
* Source file path or absolute URL. The worker validates that the value
* is a plain string with no shell metacharacters before passing to ffmpeg.
*/
source: string
/** Track-local start time (seconds). */
start: number
/** Clip duration on the timeline (seconds). */
duration: number
/** Source-local trim-in point (seconds). Defaults to 0. */
sourceStart?: number
/** Override clip volume (linear, `1` = unity). Defaults to 1. */
volume?: number
/**
* Whether to mute audio for this clip. When true, no audio stream is
* pulled from the source.
*/
muted?: boolean
}
VideoEffectA pre-baked effect placed on a clip or track. Effects are name-keyed; the
worker resolves them to safe ffmpeg filter graphs from a built-in
allow-list. Free-form -vf filter strings are NEVER accepted from
user input — all parameters are typed and validated.
interface VideoEffect {
/** Stable identifier. */
id: string
/** Allow-listed effect kind. */
kind: 'fade-in' | 'fade-out' | 'crossfade' | 'crop' | 'scale' | 'volume'
/** Track-local start time (seconds). Effects apply from `start` to `start + duration`. */
start: number
/** Effect duration (seconds). */
duration: number
/**
* Numeric parameters for the effect. Validated against the effect kind by
* the worker (e.g. `crop` requires `x`, `y`, `width`, `height`).
*/
params?: Record<string, number>
}
VideoRenderRequestMinimal request shape used by the render handlers.
interface VideoRenderRequest {
/** Parsed JSON body — only needed for the enqueue endpoint. */
body?: unknown
/** Path parameters — `{ id: string }` for status/cancel. */
params?: Record<string, string | undefined>
}
VideoRenderResponseMinimal response shape used by the render handlers.
interface VideoRenderResponse {
/** Set the HTTP status code. */
setStatus(status: number): void
/** Write a JSON body and end the response. */
sendJson(body: unknown): void
}
VideoResolutionRender output resolution as discrete pixel dimensions. The renderer scales each clip's source to fit. Dimensions must be even (codec requirement).
interface VideoResolution {
/** Output width in pixels. Must be even. */
width: number
/** Output height in pixels. Must be even. */
height: number
}
VideoTimelineTop-level timeline definition consumed by {@link renderVideo}.
interface VideoTimeline {
/** Total timeline duration (seconds). */
duration: number
/** Output resolution. */
resolution: VideoResolution
/** Output frames-per-second. */
fps: VideoFps
/** Tracks, rendered bottom-to-top. */
tracks: VideoTrack[]
/** Optional default background color (CSS `#rrggbb`). Defaults to black. */
background?: string
}
VideoTrackA timeline track. Tracks render bottom-to-top (last track on top). Clips
within a track render in start-order; overlaps composite via the
renderer's mixing rules.
interface VideoTrack {
/** Stable identifier. */
id: string
/** Track kind — `video` tracks contribute pixels, `audio` tracks contribute audio only. */
kind: 'video' | 'audio'
/** Clips on this track, in `start`-order. */
clips: VideoClip[]
/** Effects scoped to this track. */
effects?: VideoEffect[]
/** Track-level volume (linear). Defaults to 1. */
volume?: number
/** Whether the track is muted. */
muted?: boolean
}
FfmpegRunnerFunction that spawns ffmpeg with a fully-formed argv. The first element of
args is NOT the ffmpeg binary — implementations supply that themselves.
Implementations MUST NOT pass args through a shell.
type FfmpegRunner = (args: readonly string[]) => FfmpegProcess
RenderJobStateRender job lifecycle states.
type RenderJobState = 'queued' | 'rendering' | 'completed' | 'failed' | 'cancelled'
VideoCodecEncoder codec. Sensible defaults are picked per format when omitted —
libx264 for mp4, libvpx-vp9 for webm.
type VideoCodec = 'libx264' | 'libx265' | 'libvpx' | 'libvpx-vp9' | 'libaom-av1'
VideoFpsFrames per second for the rendered output. Defaults to 30.
type VideoFps = number
VideoRenderFormatOutput container format. Drives the codec defaults and file extension.
type VideoRenderFormat = 'mp4' | 'webm'
assertEvenDimension(value, label)Validate an even, positive integer dimension (codec requirement).
function assertEvenDimension(value: unknown, label: string): number
value — The candidate dimension.label — Field name for error messages.assertFiniteNonNegative(value, label, max)Validate a finite, non-negative number with an upper bound. Throws on bad input.
function assertFiniteNonNegative(value: unknown, label: string, max?: number): number
value — The candidate number.label — Field name for error messages.max — Optional maximum (defaults to 24h in seconds).assertSafePath(value, label)Validate a source path or URL. Throws TypeError with a descriptive
message if the value is unsafe.
function assertSafePath(value: unknown, label: string): void
value — The candidate path/URL.label — Human-readable name (e.g. 'clip.source') for error messages.assertValidClip(clip, trackId)Validate a {@link VideoClip}'s shape and return a sanitized copy.
function assertValidClip(clip: VideoClip, trackId: string): VideoClip
clip — The candidate clip.trackId — Owning track ID, for error messages.assertValidTimeline(timeline)Validate a complete {@link VideoTimeline}.
function assertValidTimeline(timeline: VideoTimeline): VideoTimeline
timeline — The candidate timeline.assertValidTrack(track)Validate a {@link VideoTrack} and its clips/effects.
function assertValidTrack(track: VideoTrack): VideoTrack
track — The candidate track.buildFfmpegArgs(message)Build a flat, read-only argv array for ffmpeg from a validated render
message. Inputs are passed via -i per clip; effects translate to
a filter_complex graph via the allow-listed {@link effectToFilter}.
The returned array is the EXACT argv that gets passed to spawn — no additional shell processing happens.
function buildFfmpegArgs(message: RenderJobMessage): readonly string[]
message — The validated render job message.Returns: The ffmpeg argv (excluding the ffmpeg binary itself).
cancelRender(jobId)Mark a render job as cancelled. If the worker has already started processing, it is responsible for observing the cancelled state at the next progress checkpoint and tearing down its ffmpeg child.
Returns the resulting status snapshot.
function cancelRender(jobId: string): Promise<RenderJobStatus>
jobId — The job identifier.Returns: The post-cancel status.
createCancelRenderHandler()Build the DELETE /render/jobs/:id handler.
function createCancelRenderHandler(): (
req: VideoRenderRequest,
res: VideoRenderResponse,
) => Promise<void>
Returns: An async handler.
createEnqueueRenderHandler(handlerOptions)Build the POST /render/video handler. The request body must be
{ timeline, options } (or { video, options }).
function createEnqueueRenderHandler(
handlerOptions?: CreateEnqueueRenderHandlerOptions,
): (req: VideoRenderRequest, res: VideoRenderResponse) => Promise<void>
handlerOptions — Optional validator hook.Returns: An async handler.
createGetRenderStatusHandler()Build the GET /render/jobs/:id handler.
function createGetRenderStatusHandler(): (
req: VideoRenderRequest,
res: VideoRenderResponse,
) => Promise<void>
Returns: An async handler.
createMemoryJobStore()In-memory job store backed by a Map. Process-local — restart loses state.
function createMemoryJobStore(): JobStore
defaultFfmpegRunner(args)Default runner — spawns the configured ffmpeg binary directly
({@link getFfmpegBinaryPath}), passing each argv element as a discrete
argument (no shell interpretation). A missing binary surfaces as an
actionable error via {@link toActionableSpawnError} — both when spawn
throws synchronously and when the child emits error asynchronously.
function defaultFfmpegRunner(args: readonly string[]): FfmpegProcess
args — The argv to pass after the binary name.Returns: The spawned {@link FfmpegProcess}.
effectToFilter(effect)Validate an effect kind against the allow-list and return a fixed, parameter-only filter string. Numeric parameters are validated and formatted; nothing user-typed is interpolated as a filter token.
function effectToFilter(effect: VideoEffect): string
effect — The effect to translate.Returns: The ffmpeg filter expression for the effect.
generateJobId()Generate a stable random job ID. Format: vrj_<16 hex chars>. Uses
crypto.randomUUID() when available (Node 19+), otherwise a Math.random
fallback (sufficient for non-cryptographic uniqueness).
function generateJobId(): string
Returns: A new job ID.
getFfmpegBinaryPath()Resolve the ffmpeg binary path the default runner will spawn. Precedence:
explicit override ({@link setFfmpegBinaryPath}) → FFMPEG_PATH env var →
'ffmpeg' (resolved on $PATH).
function getFfmpegBinaryPath(): string
Returns: The ffmpeg binary path/name to spawn.
getFfmpegRunner()Returns the active ffmpeg runner — the default child_process.spawn-based
runner unless overridden by {@link setFfmpegRunner}.
function getFfmpegRunner(): FfmpegRunner
Returns: The active {@link FfmpegRunner}.
getJobStore()Returns the currently active job store. Defaults to the in-memory store.
function getJobStore(): JobStore
Returns: The active {@link JobStore}.
getRenderStatus(jobId)Read the current status of a render job. When the job is unknown to the
job store, returns a failed-shaped status with a clear error message.
function getRenderStatus(jobId: string): Promise<RenderJobStatus>
jobId — The job identifier returned by {@link renderVideo}.Returns: The current {@link RenderJobStatus}.
parseFfmpegProgressSeconds(chunk)ffmpeg reports progress as time=HH:MM:SS.SS lines on stderr. Parse and
convert to seconds. Returns undefined when no progress line is present
in the chunk.
function parseFfmpegProgressSeconds(chunk: string): number | undefined
chunk — A chunk of stderr text.Returns: The current decoded position in seconds, if found.
processRenderJob(message, deps)Process a single render job. Returns the terminal {@link RenderJobStatus} after the worker observes ffmpeg's exit (or the job's cancellation).
Implementations of @molecule/api-queue consumers wire this up by
subscribing to the queue and calling processRenderJob(message) for each
received message.
function processRenderJob(
message: RenderJobMessage,
deps?: ProcessRenderJobDeps,
): Promise<RenderJobStatus>
message — The render job message to process.deps — Optional dependency overrides (primarily for tests).Returns: The final job status.
renderVideo(timeline, options)Enqueue a video render job.
The function validates the timeline + options, records an initial
queued status in the job store, and pushes a {@link RenderJobMessage}
onto the bonded queue. Workers consuming the queue invoke
processRenderJob to do the actual ffmpeg work.
function renderVideo(timeline: VideoTimeline, options: RenderVideoOptions): Promise<RenderJob>
timeline — The timeline definition.options — Render options (must include outputPath).Returns: A handle containing the new jobId and initial status.
setFfmpegBinaryPath(path)Set (or clear) an explicit path to the ffmpeg binary used by
{@link defaultFfmpegRunner}. Takes precedence over the FFMPEG_PATH
environment variable. Pass undefined to clear the override and fall back
to FFMPEG_PATH / 'ffmpeg' again.
function setFfmpegBinaryPath(path: string | undefined): void
path — Absolute path to the ffmpeg binary, or undefined to reset.setFfmpegRunner(runner)Replace the active ffmpeg runner. Tests pass a stub; production code
generally leaves this as the default. Pass undefined to reset.
function setFfmpegRunner(runner: FfmpegRunner | undefined): void
runner — The runner to use, or undefined to reset.setJobStore(store)Replace the active job store. Bond packages (e.g. a Redis-backed store)
call this during application startup. Pass undefined to reset to the
default in-memory store — primarily useful in tests.
function setJobStore(store: JobStore | undefined): void
store — The store implementation to use, or undefined to reset.toActionableSpawnError(error, binaryPath)Translate a spawn failure into an actionable {@link Error}. A raw
spawn ffmpeg ENOENT says nothing about the fix; this rewrites it to name
the resolved binary path and how to point at a real one. Non-ENOENT
errors are returned unchanged.
function toActionableSpawnError(error: unknown, binaryPath: string): Error
error — The error thrown/emitted by child_process.spawn.binaryPath — The binary path that failed to spawn.Returns: An {@link Error} with an actionable message (ENOENT) or the original.
Peer dependencies:
@molecule/api-queue ^1.0.1@molecule/api-queueSecurity model. The argv passed to ffmpeg is built by
{@link buildFfmpegArgs} — a pure function that validates every field on
the timeline against a strict allow-list before emitting tokens.
Filenames must match [A-Za-z0-9_./:\-+@%]+ and may not start with -
(which ffmpeg would otherwise read as an option). Effects compile to
fixed filter strings keyed by effect.kind; arbitrary -vf filter
strings from user input are NEVER accepted. The default ffmpeg runner
uses child_process.spawn(args, { shell: false }) so argv elements
cannot be reinterpreted by a shell.
Resource intensity. Rendering even a short timeline can take
minutes and saturate a CPU core; the package is queue-driven by design.
Bond a real queue provider (e.g. @molecule/api-queue-redis or
@molecule/api-queue-rabbitmq; @molecule/api-queue-memory keeps
everything in one process for dev/tests) before calling renderVideo —
without one, the call throws because no queue provider is bonded.
Runtime prerequisite: the ffmpeg binary. The worker spawns ffmpeg,
which must be installed in the process that runs {@link processRenderJob}.
The binary path is configurable — {@link setFfmpegBinaryPath} (or the
FFMPEG_PATH env var) points at a bundled/custom binary, defaulting to
'ffmpeg' on $PATH. ffmpeg is NOT bundled with this package and is NOT
present in minimal containers (including the molecule.dev sandbox base
image) — install it there. When it's missing, the job fails with a clear,
actionable error naming the path and the fix (install ffmpeg or set
FFMPEG_PATH), surfaced as job status failed — not a raw
spawn ffmpeg ENOENT.
Job status is process-local by default. The default {@link JobStore}
is an in-memory Map. If the queue worker runs in a separate process from
the API (the normal topology for redis/rabbitmq/sqs queues), the API's
getRenderStatus polls ITS OWN store and reports queued forever while
the worker renders happily elsewhere. Either run the worker in-process
(with @molecule/api-queue-memory), or wire a shared store via
{@link setJobStore} (Redis/database-backed) in BOTH the API and worker
processes before enqueueing.
Locale. This package is purely programmatic. There is no companion locale bond; all error messages are English-only by design.