← All @molecule/* packages · App templates

@molecule/api-video-render

Utility · 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

npm · Source on GitHub

How it works

@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

Reference

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.ts JSDoc, 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}.

Quick Start

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)
})

Type

utility

Installation

npm install @molecule/api-video-render @molecule/api-queue

API

Interfaces

CreateEnqueueRenderHandlerOptions

Options 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>
}

FfmpegProcess

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

FfmpegStderr

Subset 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
}

JobStore

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

ProcessRenderJobDeps

Optional 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
}

RenderJob

Result 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
}

RenderJobMessage

Internal 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 }
}

RenderJobStatus

Status 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
}

RenderVideoOptions

Render-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
}

VideoClip

A 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
}

VideoEffect

A 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>
}

VideoRenderRequest

Minimal 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>
}

VideoRenderResponse

Minimal 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
}

VideoResolution

Render 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
}

VideoTimeline

Top-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
}

VideoTrack

A 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
}

Types

FfmpegRunner

Function 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

RenderJobState

Render job lifecycle states.

type RenderJobState = 'queued' | 'rendering' | 'completed' | 'failed' | 'cancelled'

VideoCodec

Encoder 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'

VideoFps

Frames per second for the rendered output. Defaults to 30.

type VideoFps = number

VideoRenderFormat

Output container format. Drives the codec defaults and file extension.

type VideoRenderFormat = 'mp4' | 'webm'

Functions

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.

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-queue ^1.0.1

Runtime Dependencies

  • @molecule/api-queue

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