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 downscale; default = stage height
      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>
  )
}

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.