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,
  createDefaultDemuxerFactory,
  type ExportOptions,
  type ExportProgress,
} from '@elah/editor'
import { useState } from 'react'

const demuxerFactory = createDefaultDemuxerFactory()

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

  const handleExport = async () => {
    const project = engine.getProject()

    const options: ExportOptions = {
      fps: 30,
      demuxerFactory,
      videoCodec: 'avc',     // 'avc' | 'vp9' — default: 'avc'
      audioCodec: 'aac',     // 'aac' | 'opus' — default: 'aac'
      videoBitrate: 8_000_000, // 8 Mbps
      audioBitrate: 192_000,
      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)
    }
  }

  return (
    <div>
      <button onClick={handleExport} disabled={!!progress}>
        {progress
          ? `Exporting ${Math.round(progress.percent)}%`
          : 'Export MP4'}
      </button>
      {progress && (
        <progress value={progress.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

If you need lower-level access, use lazyExportVideo() — same pipeline but returns an AsyncIterator of frame chunks for streaming mux:

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

const iterator = lazyExportVideo(project, options)
for await (const chunk of iterator) {
  // chunk.type: 'video-chunk' | 'audio-chunk' | 'done'
  // chunk.data: EncodedVideoChunk | EncodedAudioChunk | undefined
  if (chunk.type === 'done') break
  muxer.addChunk(chunk)
}

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.
// To control audio from outside:
import { usePlaybackStore } from '@elah/editor'

// Enable/disable audio playback
const enableAudio = usePlaybackStore((s) => s.enableAudio)
const setEnableAudio = usePlaybackStore((s) => s.setEnableAudio)

// Or pass enableAudio prop to Preview (default: true)
<Preview demuxerFactory={demuxerFactory} enableAudio={false} />

Progress Tracking

typescript
interface ExportProgress {
  frame: number        // current frame being rendered
  totalFrames: number  // total frames in the project
  percent: number      // 0..100
  phase: 'encoding' | 'muxing' | 'done'
}

// Usage
const blob = await exportVideo(project, {
  fps: 30,
  demuxerFactory,
  onProgress: (p) => {
    console.log(`${p.phase}: ${Math.round(p.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.