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:
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.
| Stage | outputHeight | Encoded size |
|---|---|---|
| 1920×1080 (landscape) | 1080 | 1920×1080 |
| 1080×1920 (portrait) | 1080 | 1080×1920 |
| 1920×1080 (landscape) | 720 | 1280×720 |
| 1080×1920 (portrait) | 720 | 720×1280 |
| 1080×1080 (square) | 720 | 720×720 |
| any | omitted | the stage size, unscaled |
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.
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:
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
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:
// 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
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})`)
},
})