Skip to content
Code

Examples

Integration examples for common use cases. Copy, adapt, ship.

Integration

Timeline 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.

timeline-only.tsx
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}
Advanced

Custom Demuxer

Replace mediabunny with your own demuxer by implementing the DemuxerFactory interface.

custom-demuxer.tsx
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

Export with Progress UI

Full export flow with progress bar, cancel support, and MP4 download trigger.

export-with-progress.tsx
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}
Advanced

Custom Renderer with resolveTimeline

Use the pure resolver to build a completely custom rendering layer — DOM, Canvas 2D, or any other target.

resolve-timeline-custom.tsx
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}
Need something more specific?

Check the API reference or open an issue on GitHub.