Getting Started

Quick Start

Three levels of integration — timeline only, timeline + preview, and the full editor. Start from whichever level matches your use case.

Level 1: Timeline Only

The Timeline component is the most isolated piece. Wrap it in EditorProvider and you get a fully functional NLE timeline — drag, trim, split, snap, undo/redo.

TimelineOnly.tsx
import { useRef } from 'react'
import {
  EditorProvider,
  Timeline,
  useTimelineEngine,
  type TimelineRef,
} from '@elah/editor'

function Controls() {
  const engine = useTimelineEngine()

  const addVideoClip = () => {
    const tracks = engine.getProject().tracks
    const videoTrack = tracks.find((t) => t.kind === 'video')
    if (!videoTrack) return

    engine.addClip({
      trackId: videoTrack.id,
      type: 'video',
      src: 'my-video.mp4',
      startFrame: 0,
      durationFrames: 90, // 3 seconds at 30fps
      name: 'My Clip',
    })
  }

  return (
    <div style={{ padding: 8, display: 'flex', gap: 8 }}>
      <button onClick={addVideoClip}>Add Video Clip</button>
      <button onClick={() => engine.undo()}>Undo</button>
      <button onClick={() => engine.redo()}>Redo</button>
    </div>
  )
}

export default function TimelineOnly() {
  const timelineRef = useRef<TimelineRef>(null)

  return (
    <EditorProvider
      fps={30}
      initialTracks={[
        { kind: 'video', name: 'Video' },
        { kind: 'audio', name: 'Audio' },
        { kind: 'text', name: 'Text' },
      ]}
    >
      <Controls />
      <Timeline
        ref={timelineRef}
        fps={30}
        style={{ height: 240 }}
      />
    </EditorProvider>
  )
}

This is the same integration used in the live Timeline playground.

Open Timeline →

Level 2: Timeline + Preview

Add <Preview> to mount the WebGL2 renderer and drive the RAF loop. You inject a demuxer factory so the SDK never hard-depends on a specific decode backend:

TimelineWithPreview.tsx
import { useRef } from 'react'
import {
  EditorProvider,
  Preview,
  Timeline,
  createDefaultDemuxerFactory,
  type PreviewHandle,
  type TimelineRef,
} from '@elah/editor'

// Zero-config demuxer — mediabunny ships bundled with @elah/editor
const demuxerFactory = createDefaultDemuxerFactory()

export default function App() {
  const previewRef = useRef<PreviewHandle>(null)
  const timelineRef = useRef<TimelineRef>(null)

  return (
    <EditorProvider fps={30}>
      <div style={{
        display: 'flex',
        flexDirection: 'column',
        height: '100vh',
      }}>
        {/* Preview takes up most of the vertical space */}
        <Preview
          ref={previewRef}
          demuxerFactory={demuxerFactory}
          enableAudio // default: true
          style={{ flex: 1, minHeight: 0 }}
        />

        {/* Timeline at the bottom */}
        <Timeline
          ref={timelineRef}
          fps={30}
          style={{ height: 240 }}
        />
      </div>
    </EditorProvider>
  )
}
Note

<Preview> paints interactive transform overlays automatically — drag/resize for video and image clips, inline-edit for text clips. No additional wiring needed.

Level 3: Full Editor

Add <AssetPanel> for the media library (drag-drop import, filmstrip thumbnails, waveform previews), a transport toolbar, and export:

FullEditor.tsx
import { useRef } from 'react'
import {
  EditorProvider,
  AssetPanel,
  Preview,
  Timeline,
  createDefaultDemuxerFactory,
  usePlaybackStore,
  useTimelineEngine,
  framesToTimecode,
  exportVideo,
  type TimelineRef,
  type PreviewHandle,
  type InitialTrackConfig,
} from '@elah/editor'

const FPS = 30

const INITIAL_TRACKS: InitialTrackConfig[] = [
  { kind: 'video', name: 'Video / Image' },
  { kind: 'audio', name: 'Audio' },
  { kind: 'text', name: 'Text' },
]

const demuxerFactory = createDefaultDemuxerFactory()

function Toolbar() {
  const engine = useTimelineEngine()
  const { isPlaying, togglePlayPause, currentFrame } =
    usePlaybackStore((s) => s)

  const handleExport = async () => {
    const project = engine.getProject()
    const blob = await exportVideo(project, {
      fps: FPS,
      demuxerFactory,
    })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = 'export.mp4'
    a.click()
  }

  return (
    <div style={{ display: 'flex', gap: 8, padding: 8,
                  background: '#1a1a1a', color: '#fff' }}>
      <button onClick={togglePlayPause}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <span style={{ fontFamily: 'monospace', fontSize: 12 }}>
        {framesToTimecode(currentFrame, FPS)}
      </span>
      <button onClick={() => engine.undo()}>Undo</button>
      <button onClick={() => engine.redo()}>Redo</button>
      <button onClick={handleExport}>Export MP4</button>
    </div>
  )
}

export default function FullEditor() {
  const timelineRef = useRef<TimelineRef>(null)
  const previewRef = useRef<PreviewHandle>(null)

  return (
    <EditorProvider fps={FPS} initialTracks={INITIAL_TRACKS}>
      <div style={{
        display: 'flex',
        flexDirection: 'column',
        height: '100vh',
      }}>
        <Toolbar />

        <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
          <AssetPanel style={{ width: 240, flexShrink: 0 }} />
          <Preview
            ref={previewRef}
            demuxerFactory={demuxerFactory}
            style={{ flex: 1, minWidth: 0 }}
          />
        </div>

        <Timeline
          ref={timelineRef}
          fps={FPS}
          style={{ height: 240, flexShrink: 0 }}
        />
      </div>
    </EditorProvider>
  )
}

This is the same integration used in the live Production playground — asset panel, preview, timeline, transport, and export.

Open Production →

Keyboard Shortcuts

The Timeline component handles these shortcuts automatically when it has focus:

SpacePlay / Pause
SSplit selected clip at playhead
Delete / BackspaceDelete selected clip(s)
Ctrl/Cmd + CCopy selected clip(s)
Ctrl/Cmd + VPaste copied clip(s) at playhead
Ctrl/Cmd + ZUndo
Ctrl/Cmd + Shift + ZRedo
Ctrl/Cmd + ScrollZoom timeline
← / →Step one frame back / forward

Adding Clips Programmatically

Use useTimelineEngine() to access the engine from any child of EditorProvider. All mutations go through engine.addClip():

AddClipButton.tsx
import { useTimelineEngine, useTracksStore, usePlaybackStore } from '@elah/editor'

export function AddClipButton() {
  const engine = useTimelineEngine()
  const tracks = useTracksStore((s) => s.tracks)
  const currentFrame = usePlaybackStore((s) => s.currentFrame)

  const addVideoClip = () => {
    const videoTrack = tracks.find((t) => t.kind === 'video')
    if (!videoTrack) return

    engine.addClip({
      trackId: videoTrack.id,
      type: 'video',
      src: 'my-video.mp4',
      startFrame: currentFrame,
      durationFrames: 90,
      name: 'Clip',
    })
  }

  const addTextClip = () => {
    const textTrack = tracks.find((t) => t.kind === 'text')
    if (!textTrack) return

    engine.addClip({
      trackId: textTrack.id,
      type: 'text',
      startFrame: currentFrame,
      durationFrames: 60,
      name: 'My Title',
      text: {
        content: 'Hello World',
        fontSize: 48,
        color: '#ffffff',
        fontWeight: 'bold',
      },
    })
  }

  const addImageClip = () => {
    const videoTrack = tracks.find((t) => t.kind === 'video')
    if (!videoTrack) return

    engine.addClip({
      trackId: videoTrack.id,
      type: 'image',
      src: 'my-image.png',
      startFrame: currentFrame,
      durationFrames: 60,
      name: 'Image',
    })
  }

  return (
    <div style={{ display: 'flex', gap: 8 }}>
      <button onClick={addVideoClip}>+ Video</button>
      <button onClick={addTextClip}>+ Text</button>
      <button onClick={addImageClip}>+ Image</button>
    </div>
  )
}
Batch edits

Wrap multiple mutations in engine.batch() to commit them as a single undo entry:

tsx
engine.batch(() => {
  engine.addClip({ trackId, type: 'video', ...videoOpts })
  engine.addClip({ trackId: audioTrackId, type: 'audio', ...audioOpts })
}, 'Add video + audio')