Timeline

Timeline

The Timeline component is a fully interactive NLE timeline. Tracks, clips, drag-to-trim, drag-to-move, snapping, zoom, and the full keyboard shortcut set.

Overview

The Timeline component reads state from useTracksStore and dispatches mutations through TimelineEngine. It is fully controlled by the Zustand stores — you can read or write those stores directly to drive the timeline from outside.

Example: Timeline only
import { useRef } from 'react'
import {
  EditorProvider,
  Timeline,
  type TimelineRef,
  type InitialTrackConfig,
} from '@elah/editor'

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

export default function TimelineOnlyDemo() {
  const ref = useRef<TimelineRef>(null)

  return (
    <EditorProvider fps={30} initialTracks={TRACKS}>
      <Timeline
        ref={ref}
        fps={30}
        style={{ height: 260 }}
      />
    </EditorProvider>
  )
}

Tracks & Clips

Each Track has a kind: video, audio, or text. V1 uses fixed 3-lane layout. Each track holds an array of Clip objects.

types.ts (data model)
interface Track {
  id: string
  kind: 'video' | 'audio' | 'text'
  name: string
  muted: boolean
  solo: boolean
  zIndex: number
}

interface Clip {
  id: string
  trackId: string
  type: 'video' | 'audio' | 'text' | 'image'
  name: string
  src?: string           // URL or blob ref for video/audio/image
  startFrame: number     // integer — position on the timeline
  durationFrames: number // integer — how long the clip is
  trimInFrames: number   // frames trimmed from the start of the source
  trimOutFrames: number  // frames trimmed from the end of the source
  transform?: Transform  // position, scale, rotation
  text?: TextClipData    // only for type: 'text'
  opacity: number        // 0..1, managed by transition system
  zIndex: number
}

Add and remove clips via the engine:

tsx
const engine = useTimelineEngine()

// Add a video clip
engine.addClip({
  trackId: videoTrack.id,
  type: 'video',
  src: 'https://example.com/video.mp4',
  startFrame: 0,
  durationFrames: 150, // 5 seconds at 30fps
  name: 'Intro',
})

// Move a clip to a new position
engine.moveClip(clipId, { startFrame: 30, trackId: videoTrack.id })

// Remove a clip
engine.removeClip(clipId, trackId)

// Split clip at playhead position
import { splitClipAtPlayhead } from '@elah/editor'
splitClipAtPlayhead(engine, selectedClipId, currentFrame)

Playback

The PlaybackEngine owns the RAF clock and publishes (frame, isPlaying) snapshots. React reads playback state via usePlaybackStore:

TransportControls.tsx
import {
  usePlaybackStore,
  useTracksStore,
  framesToTimecode,
} from '@elah/editor'

export function TransportControls({ fps = 30 }) {
  const isPlaying = usePlaybackStore((s) => s.isPlaying)
  const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause)
  const currentFrame = usePlaybackStore((s) => s.currentFrame)
  const setCurrentFrame = usePlaybackStore((s) => s.setCurrentFrame)
  const totalFrames = useTracksStore((s) => s.totalFrames)

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
      <button onClick={() => setCurrentFrame(0)}>⏮</button>
      <button onClick={togglePlayPause}>
        {isPlaying ? '⏸ Pause' : '▶ Play'}
      </button>
      <span style={{ fontFamily: 'monospace', fontSize: 12 }}>
        {framesToTimecode(currentFrame, fps)}
        {' / '}
        {framesToTimecode(totalFrames, fps)}
      </span>
    </div>
  )
}

Zooming & Snapping

The timeline supports Ctrl/Cmd + scroll to zoom. Clips snap to other clip edges, the playhead, and track boundaries. The snap tolerance is configurable:

tsx
// Timeline accepts a snapTolerance prop (in pixels, default 6)
<Timeline
  ref={ref}
  fps={30}
  snapTolerance={8}
  style={{ height: 240 }}
/>

// Snap utilities are also exported for custom implementations
import {
  snapFrame,
  buildSnapPoints,
  DEFAULT_OVERLAP_TOLERANCE,
} from '@elah/editor'

const snapPoints = buildSnapPoints(project, excludeClipId)
const snappedFrame = snapFrame(frame, snapPoints, tolerance)

Transitions

Transitions are defined on the Project level and stored in useTransitionsStore. The fade transition is fully implemented; slide/wipe transitions have architecture in place.

tsx
const engine = useTimelineEngine()

// Add a fade transition between two adjacent clips
engine.addTransition({
  fromClipId: clip1.id,
  toClipId: clip2.id,
  kind: 'fade',
  durationFrames: 15, // 0.5 seconds at 30fps
  easing: 'ease-in-out',
})

// The resolver handles opacity automatically:
// resolveTimeline(frame, project) → Scene
// During transition: fromClip.opacity interpolated 1→0
//                   toClip.opacity interpolated 0→1
//
// Preview: TransitionOverlay fades a CSS snapshot
// Export: globalAlpha mirrors the opacity values

Keyboard Shortcuts

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