Skip to content
Docs / Overview
Export

Export Pipeline

The export pipeline runs frame-by-frame in a dedicated Web Worker, muxes MP4 with mediabunny, and never drifts from the live preview.

exportVideo()

exportVideo() is the primary export entry point. It spins up the export worker, renders every frame to OffscreenCanvas, muxes the result, and resolves with an MP4 Blob:

ExportButton.tsx
import {
  exportVideo,
  useTimelineEngine,
  type ExportOptions,
  type ExportProgress,
} from '@elah/editor'
import { useState } from 'react'

export function ExportButton() {
  const engine = useTimelineEngine()
  const [progress, setProgress] = useState<ExportProgress | null>(null)

  const handleExport = async () => {
    // fps comes from project.fps — you don't pass it to exportVideo.
    const project = engine.getProject()

    const options: ExportOptions = {
      videoCodec: 'avc',       // 'avc' | 'vp9' | 'vp8' — default: 'avc'
      audioCodec: 'aac',       // 'aac' | 'opus'
      videoBitrate: 8_000_000, // 8 Mbps (default)
      audioBitrate: 192_000,
      outputHeight: 1080,      // optional: the stage's SHORT edge in px; default = native short edge
      onProgress: (p) => setProgress(p),
    }

    try {
      const blob = await exportVideo(project, options)

      // Download the file
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = 'export.mp4'
      a.click()
      URL.revokeObjectURL(url)
    } finally {
      setProgress(null)
    }
  }

  const percent = progress
    ? Math.round((progress.frame / Math.max(1, progress.totalFrames)) * 100)
    : 0

  return (
    <div>
      <button onClick={handleExport} disabled={!!progress}>
        {progress ? `Exporting ${percent}%` : 'Export MP4'}
      </button>
      {progress && <progress value={percent} max={100} />}
    </div>
  )
}

outputHeight is the short edge

outputHeight names the stage's short edge, the way “1080p” does: 1080means 1920×1080 for a landscape stage and 1080×1920 for a portrait one. The other edge is derived from the stage's aspect ratio and rounded to an even number, which most video codecs require. Omit it and the export uses the stage's native size.

StageoutputHeightEncoded size
1920×1080 (landscape)10801920×1080
1080×1920 (portrait)10801080×1920
1920×1080 (landscape)7201280×720
1080×1920 (portrait)720720×1280
1080×1080 (square)720720×720
anyomittedthe stage size, unscaled
Behaviour change in 0.6.0

The value used to be scaled against the stage height. That only holds for landscape stages, because on a portrait stage the height is the long edge. A 9:16 project asked for 1080 came out 608×1080 instead of 1080×1920. If you pass outputHeight for a portrait project, the encoded size changes when you upgrade. The headless CLI's --height flag is the same option.

Export Worker

The export worker runs in a Web Worker with an OffscreenCanvas. It uses the exact same resolveTimeline() and placement math (resolveDrawRect, computeTextLayout) as the live renderer — so preview and export never drift.

1
Serialize project
Main thread transfers the Project snapshot and source blobs to the worker
2
Frame loop
Worker calls resolveTimeline(frame, project) for each frame and draws to OffscreenCanvas
3
VideoEncoder
Each canvas frame is encoded via WebCodecs VideoEncoder with the configured codec/bitrate
4
Audio mix
Audio is decoded and mixed on the main thread (Web Audio not available in workers)
5
MP4 mux
mediabunny muxes video and audio tracks into a final MP4 Blob

To keep mediabunny out of your main bundle, use lazyExportVideo() — the same call and the same Promise<Blob> result, but the export module (and mediabunny) is dynamically imported only when you first export, so bundlers code-split it out:

typescript
import { lazyExportVideo } from '@elah/editor'

// Identical signature to exportVideo — resolves with an MP4 Blob.
// mediabunny is only loaded on first call.
const blob = await lazyExportVideo(project, options)

Audio Pipeline

Important constraint

Web Audio API is not available in Web Workers. Audio is decoded and mixed on the main thread during export. For long projects this is acceptable; for very large projects consider chunking.

During live playback, AudioPlaybackController reads scene.audios and schedules Web Audio nodes beside the renderer on the same PlaybackEngine clock:

typescript
// AudioPlaybackController is wired by EditorProvider automatically.
// Control master output volume / mute via the playback store:
import { usePlaybackStore } from '@elah/editor'

const volume = usePlaybackStore((s) => s.volume)      // 0..1
const setVolume = usePlaybackStore((s) => s.setVolume)
const muted = usePlaybackStore((s) => s.muted)
const toggleMute = usePlaybackStore((s) => s.toggleMute)

// To disable the audio pipeline entirely, pass enableAudio to Preview
// (default: true):
<Preview demuxerFactory={demuxerFactory} enableAudio={false} />

Progress Tracking

typescript
interface ExportProgress {
  frame: number        // current frame being encoded
  totalFrames: number  // total frames in the project
}

// Derive a percentage yourself from frame / totalFrames.
const blob = await exportVideo(project, {
  onProgress: (p) => {
    const percent = Math.round((p.frame / Math.max(1, p.totalFrames)) * 100)
    console.log(`${percent}% (${p.frame}/${p.totalFrames})`)
  },
})

Browser Limits

WebCodecs availability
Chrome/Edge 108+. Firefox has partial support behind a flag. Safari: limited.
VideoEncoder hardware limits
Simultaneous encoder count is hardware-dependent. Export creates one encoder per run.
Memory — frame cache
Each decoded frame is an ImageBitmap. Long projects with many tracks will pressure heap. The copy-and-close pattern limits live pool size.
Audio in workers
Web Audio API is unavailable in Web Workers. Audio decode + mix happens on the main thread.
COOP/COEP headers
mediabunny requires SharedArrayBuffer. The server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.
Export file size
Browsers have per-Blob memory limits. Very long high-bitrate exports may OOM. Use chunked streaming (lazyExportVideo) if you hit limits.
On this page