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,
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.
If you need lower-level access, use lazyExportVideo() — same pipeline but returns an AsyncIterator of frame chunks for streaming mux:
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
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.
// 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
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})`)
},
})