Examples
Integration examples for common use cases. Copy, adapt, ship.
Complete production example
Runnable, end-to-end editor apps consuming the published @elah/editor package. Clone, install, and run — or read the source on GitHub.
Minimal starter
The smallest complete editor: import media, drag it onto the timeline, scrub, play. Start here when you are building a custom UI — it is all integration contract and nothing else, with the four silent-failure gotchas called out inline.
View on GitHubReact
A complete production editor — preview, timeline, asset/element panels, text inspector, and MP4 export — wired as a standalone Vite app consuming @elah/editor from npm.
View on GitHubNext.js
The same full editor composition in a Next.js App Router app, with the client-only dynamic import and transpilePackages config needed to ship it in production.
View on GitHubTimeline Only
The timeline as a drop-in component — no renderer, no decode pipeline. Ideal for building a custom rendering layer or exploring the edit model.
1import { useRef } from 'react'2import {3 EditorProvider,4 Timeline,5 useTimelineEngine,6 usePlaybackStore,7 framesToTimecode,8 type TimelineRef,9} from '@elah/editor'1011const TRACKS = [12 { kind: 'video' as const, name: 'Video / Image' },13 { kind: 'audio' as const, name: 'Audio' },14 { kind: 'elements' as const, name: 'Elements' },15]1617function Transport({ fps }: { fps: number }) {18 const { isPlaying, togglePlayPause, currentFrame } =19 usePlaybackStore((s) => s)2021 return (22 <div style={{ display: 'flex', gap: 8, padding: 8 }}>23 <button onClick={togglePlayPause}>24 {isPlaying ? '⏸' : '▶'}25 </button>26 <span style={{ fontFamily: 'monospace', fontSize: 12 }}>27 {framesToTimecode(currentFrame, fps)}28 </span>29 </div>30 )31}3233export default function TimelineOnlyExample() {34 const ref = useRef<TimelineRef>(null)35 const fps = 303637 return (38 <EditorProvider fps={fps} initialTracks={TRACKS}>39 <Transport fps={fps} />40 <Timeline ref={ref} fps={fps} style={{ height: 260 }} />41 </EditorProvider>42 )43}Custom Demuxer
Replace mediabunny with your own demuxer by implementing the DemuxerFactory interface.
1import {2 EditorProvider,3 Preview,4 Timeline,5 type DemuxerFactory,6 type DemuxerBackend,7} from '@elah/editor'89// DemuxerBackend is a pull-based interface — the decode pipeline asks you for10// packets over a time range; you do not push chunks to a callback.11//12// open(src) → Promise<void>13// getConfig() → VideoDecoderConfig14// packets([start, end]) → AsyncIterable<EncodedVideoChunk> (seconds)15// seekToKeyframe(time) → Promise<void>16// dispose() → void1718class MyBackend implements DemuxerBackend {19 private config!: VideoDecoderConfig2021 async open(src: string) {22 const res = await fetch(`/api/probe?src=${encodeURIComponent(src)}`)23 // Whatever your container parser needs to produce a decoder config.24 this.config = await res.json() // { codec, codedWidth, codedHeight, description }25 }2627 getConfig(): VideoDecoderConfig {28 return this.config29 }3031 async *packets(timeRange: [number, number]): AsyncIterable<EncodedVideoChunk> {32 const [start, end] = timeRange33 for await (const pkt of myContainerReader(start, end)) {34 yield new EncodedVideoChunk({35 type: pkt.isKeyframe ? 'key' : 'delta',36 timestamp: pkt.timestampMicroseconds,37 duration: pkt.durationMicroseconds,38 data: pkt.bytes,39 })40 }41 }4243 async seekToKeyframe(time: number) {44 await myContainerReader.seek(time)45 }4647 dispose() {48 myContainerReader.close()49 }50}5152// A DemuxerFactory is just `() => DemuxerBackend` — one backend per source.53const myDemuxerFactory: DemuxerFactory = () => new MyBackend()5455export default function CustomDemuxerExample() {56 return (57 <EditorProvider fps={30}>58 <Preview demuxerFactory={myDemuxerFactory} style={{ flex: 1 }} />59 <Timeline fps={30} style={{ height: 240 }} />60 </EditorProvider>61 )62}Export with Progress UI
Full export flow with progress bar, cancel support, and MP4 download trigger.
1import { useState, useCallback, useRef } from 'react'2import {3 lazyExportVideo,4 usePlaybackStore,5 useTimelineEngine,6 type ExportProgress,7} from '@elah/editor'89// Note: export takes NO fps and NO demuxerFactory. fps comes from the project,10// and the export worker builds its own decode pipeline.1112export function ExportPanel() {13 const engine = useTimelineEngine()14 const [progress, setProgress] = useState<ExportProgress | null>(null)15 const [error, setError] = useState<string | null>(null)16 const abortRef = useRef<AbortController | null>(null)1718 const handleExport = useCallback(async () => {19 setError(null)20 usePlaybackStore.getState().pause()2122 const controller = new AbortController()23 abortRef.current = controller2425 try {26 const blob = await lazyExportVideo(engine.getProject(), {27 videoCodec: 'avc',28 audioCodec: 'aac',29 videoBitrate: 8_000_000,30 outputHeight: 1080,31 signal: controller.signal,32 // ExportProgress is { frame, totalFrames } — derive the percentage.33 onProgress: setProgress,34 })3536 // Trigger download37 const a = Object.assign(document.createElement('a'), {38 href: URL.createObjectURL(blob),39 download: 'export.mp4',40 })41 a.click()42 URL.revokeObjectURL(a.href)43 } catch (e) {44 if ((e as Error).name !== 'AbortError') {45 setError(e instanceof Error ? e.message : 'Export failed')46 }47 } finally {48 setProgress(null)49 abortRef.current = null50 }51 }, [engine])5253 const percent = progress54 ? Math.round((progress.frame / progress.totalFrames) * 100)55 : 05657 return (58 <div style={{ padding: 16 }}>59 <button60 onClick={handleExport}61 disabled={!!progress}62 style={{ padding: '8px 16px', background: '#b7102a', color: '#fff' }}63 >64 {progress ? `Exporting ${percent}%` : 'Export MP4'}65 </button>6667 {progress && (68 <div style={{ marginTop: 8 }}>69 <progress value={percent} max={100} style={{ width: '100%' }} />70 <div style={{ fontSize: 11, color: '#666', marginTop: 4 }}>71 frame {progress.frame} / {progress.totalFrames}72 </div>73 <button onClick={() => abortRef.current?.abort()}>Cancel</button>74 </div>75 )}7677 {error && (78 <div style={{ marginTop: 8, color: '#b7102a', fontSize: 12 }}>79 Error: {error}80 </div>81 )}82 </div>83 )84}Custom Renderer with resolveTimeline
Use the pure resolver to build a completely custom rendering layer — DOM, Canvas 2D, or any other target.
1import { useEffect, useRef } from 'react'2import {3 EditorProvider,4 Timeline,5 useTimelineEngine,6 usePlaybackStore,7 useTracksStore,8 resolveTimeline,9} from '@elah/editor'1011// DOM renderer: updates div positions instead of WebGL12function DomPreview() {13 const engine = useTimelineEngine()14 const currentFrame = usePlaybackStore((s) => s.currentFrame)15 const containerRef = useRef<HTMLDivElement>(null)1617 useEffect(() => {18 const project = engine.getProject()19 const scene = resolveTimeline(currentFrame, project)2021 if (!containerRef.current) return22 containerRef.current.innerHTML = ''2324 // Render text clips as DOM elements.25 // ActiveTextClip fields are FLAT — content/fontSize/color sit directly on26 // the clip. (Nesting under `text: {...}` applies only when CREATING a clip.)27 for (const text of scene.texts) {28 const el = document.createElement('div')29 el.textContent = text.content30 el.style.cssText = `31 position: absolute;32 opacity: ${text.opacity};33 font-size: ${text.fontSize ?? 32}px;34 color: ${text.color ?? '#fff'};35 left: 50%;36 top: 50%;37 transform: translate(-50%, -50%);38 `39 containerRef.current.appendChild(el)40 }41 }, [currentFrame, engine])4243 return (44 <div45 ref={containerRef}46 style={{47 position: 'relative',48 flex: 1,49 background: '#000',50 overflow: 'hidden',51 }}52 />53 )54}5556export default function CustomRendererExample() {57 return (58 <EditorProvider fps={30}>59 <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>60 <DomPreview />61 <Timeline fps={30} style={{ height: 240 }} />62 </div>63 </EditorProvider>64 )65}Check the API reference or open an issue on GitHub.
