Reference

API Reference

Complete reference for TimelineEngine, PlaybackEngine, resolveTimeline, GpuRenderer, React hooks, and TypeScript types.

TimelineEngine

The single mutation funnel. All edits go through TimelineEngine. Backed by Immer for structural sharing; every commit produces a new Project snapshot.

addClip
engine.addClip(options: CreateClipOptions): Clip

Adds a new clip to a track. The clip is placed at startFrame and returns the created Clip object.

trackIdstringTarget track ID
typeClipType'video' | 'audio' | 'text' | 'image' | 'shape' | 'freehand'
startFramenumberPosition on the timeline (integer frames)
durationFramesnumberLength of the clip (integer frames)
srcstring?URL or blob ref for video/audio/image clips
textTextClipMetadata?Content + style; required when type is 'text'
moveClip
engine.moveClip(clipId: string, fromTrackId: string, toTrackId: string, startFrame: number): void

Moves a clip to a new start frame, optionally onto a different track. Pass the same id for fromTrackId and toTrackId to move within a track. No-ops if the move would overlap a neighbour or either track is locked.

removeClip
engine.removeClip(clipId: string, trackId: string): void

Removes a clip from a track.

updateClip
engine.updateClip(clipId: string, trackId: string, updates: Partial<Clip>): void

Updates any fields on a clip. Common use: transform, text content, opacity. During drag/trim gestures prefer previewClip() + commitInteraction() so the interaction is a single undo entry.

addTrack
engine.addTrack(kind: TrackKind, options?: Partial<CreateTrackOptions>): Track

Adds a new track. Video is capped at one lane — adding a video track when one exists returns the existing track (idempotent).

addTransition
engine.addTransition(options): Transition | null

Adds a transition between two adjacent clips on the same track. Returns the created Transition, or null if the clips aren't found on that track.

fromClipIdstringThe outgoing clip
toClipIdstringThe incoming clip
trackIdstringTrack both clips live on (required)
kindTransitionKind'fade' | 'slide' | 'wipe'
durationFramesnumberHow many frames the transition spans
easingTransitionEasing?'linear' | 'ease-in' | 'ease-out'
batch
engine.batch(fn: () => void, label?: string): void

Groups multiple mutations into a single undo entry. All mutations inside fn() are committed atomically.

undo / redo
engine.undo(): boolean | engine.redo(): boolean

Step backwards/forwards through the commit history. Returns true if a step was applied (use canUndo() / canRedo() to gate UI).

getProject
engine.getProject(): Project

Returns the current immutable Project snapshot.

setStage
engine.setStage(width: number, height: number): void

Sets the canvas output dimensions. Clips re-fit to the new stage on the next resolve — placement is normalized, so no per-clip migration is needed.

PlaybackEngine

Owns the RAF clock. Emits (frame, isPlaying) snapshots. React consumes it via usePlaybackStore.

typescript
// Direct usage (advanced — usually use usePlaybackStore instead)
import { PlaybackEngine } from '@elah/core'

const engine = new PlaybackEngine({ fps: 30 })

engine.play()
engine.pause()
engine.seek(frame)

// Subscribe to playback ticks
const unsub = engine.subscribe((snapshot) => {
  console.log(snapshot.frame, snapshot.isPlaying)
})

resolveTimeline()

The pure, deterministic resolver. Consumes a Project and frame index; produces a Scene. No side effects, no imports, safe to call in tests, workers, and export pipelines.

typescript
import { resolveTimeline, type Scene } from '@elah/core'

const scene: Scene = resolveTimeline(currentFrame, project)

// Scene shape:
interface Scene {
  frame: number
  fps: number
  stage: { width: number; height: number }
  videos: ActiveVideoClip[]
  audios: ActiveAudioClip[]
  texts: ActiveTextClip[]
  images: ActiveImageClip[]
  shapes: ActiveShapeClip[]
  freehand: ActiveFreehandClip[]
  transitions: ActiveTransition[]
}

// Fields shared by every active clip:
interface ActiveClipBase {
  id: string
  trackId: string
  name: string
  sourceFrame: number      // source-asset frame at the current playhead
  opacity: number          // 0..1, modified by transitions
  zIndex: number           // higher = closer to viewer (front)
  transform?: Transform    // undefined → renderer default (contain-fit)
}

// Each ActiveVideoClip adds:
interface ActiveVideoClip extends ActiveClipBase {
  type: 'video'
  src: string
  volume: number           // 0..1, after track mute
}

GpuRenderer

The shipped WebGL2 renderer. Accepts a Scene and draws sorted textured quads. Can be replaced with any Renderer-conforming implementation.

typescript
import { GpuRenderer, type Renderer } from '@elah/core'

// The Renderer interface
interface Renderer {
  mount(container: HTMLElement): void
  resize(cssWidth: number, cssHeight: number, dpr?: number): void
  render(scene: Scene): void
  prewarm?(scene: Scene): void   // optional decode look-ahead
  dispose(): void
}

// Direct usage (usually consumed through <Preview>)
// The constructor takes RendererOptions — the stage size comes from the
// Scene each tick, not the constructor. Pass a demuxerFactory to enable
// the real WebCodecs decode pipeline.
const renderer = new GpuRenderer({
  demuxerFactory: () => createMediabunnyBackend(mediabunny),
})

renderer.mount(containerEl)      // attach to the DOM once
renderer.resize(1920, 1080, window.devicePixelRatio)
renderer.render(scene)           // called each RAF tick
renderer.dispose()               // cleanup on unmount

Hooks

useTimelineEngine()TimelineEngine

Access the TimelineEngine from any child of EditorProvider. Use this for mutations.

usePlaybackEngine()PlaybackEngine

Access the PlaybackEngine directly. Usually prefer usePlaybackStore for state.

useTracksStore(selector)T

Zustand store for tracks, clips, totalFrames. Reactive to all engine mutations.

usePlaybackStore(selector)T

Zustand store for currentFrame, isPlaying, togglePlayPause, setCurrentFrame.

useSelectionStore(selector)T

Zustand store for selected clip IDs.

useTransitionsStore(selector)T

Zustand store for all transitions.

useMediaLibrary()UseMediaLibraryApi

Access the media library. Returns { assets, getAsset, removeAsset, updateAsset, importFiles, importUrl, importBlob }.

Types

types.ts
// Time
type FrameCount = number // always integer

type ClipType = 'video' | 'audio' | 'text' | 'image' | 'shape' | 'freehand'
type TrackKind = 'video' | 'audio' | 'elements'

// Core data types
interface Project {
  id: string
  fps: number
  stage: { width: number; height: number }
  tracks: Track[]
  // Clips are stored keyed by trackId, NOT nested on Track.
  clips: Record<string, Clip[]>
  transitions: Transition[]
  version: number
  masterVolume?: number   // 0..2, linear
}

interface Track {
  id: string
  name: string
  kind: TrackKind
  order: number           // lower = closer to top of timeline
  height: number          // px
  locked: boolean
  disabled: boolean
  muted: boolean
  solo: boolean
  volume?: number         // 0..2, linear
}

interface Clip {
  id: string
  trackId: string
  type: ClipType
  name: string
  startFrame: FrameCount        // position on the timeline
  durationFrames: FrameCount    // length on the timeline
  sourceStartFrame: FrameCount  // trim in-point into the source
  sourceDurationFrames: FrameCount
  src?: string
  assetId?: string
  transform?: Transform
  opacity?: number              // 0..1
  volume?: number               // 0..1
  locked?: boolean
  disabled?: boolean
  // Text clips (flat fields, not a nested object):
  content?: string
  fontSize?: number
  color?: string
  fontFamily?: string
  fontWeight?: 'normal' | 'bold'
  textAlign?: 'left' | 'center' | 'right'
  textAnimation?: TextAnimation
  // Shape / freehand clips have their own shape*/stroke*/pathData fields.
}

interface Transform {
  x: number        // 0..1, normalized to stage width
  y: number        // 0..1, normalized to stage height
  scale: number    // 1 = native size
  rotation: number // radians, positive = clockwise
  anchor: { x: number; y: number } // 0..1 within the clip box
}

// Entry/exit ramp for text (and shape) clips.
interface TextAnimation {
  in?: 'fade'
  out?: 'fade'
  durationFrames: number
}

interface Transition {
  id: string
  kind: 'fade' | 'slide' | 'wipe'
  fromClipId: string
  toClipId: string
  trackId: string
  startFrame: FrameCount   // = toClip.startFrame - durationFrames / 2
  durationFrames: FrameCount
  direction?: 'left' | 'right' | 'up' | 'down'
  easing?: 'linear' | 'ease-in' | 'ease-out'
}

// Export — fps is read from project.fps; the export worker uses
// mediabunny directly, so there is no demuxerFactory here.
type ExportVideoCodec = 'avc' | 'vp9' | 'vp8'
type ExportAudioCodec = 'aac' | 'opus'

interface ExportOptions {
  videoCodec?: ExportVideoCodec   // default 'avc'
  audioCodec?: ExportAudioCodec
  videoBitrate?: number           // bits/s, default 8 Mbps
  audioBitrate?: number           // bits/s, default 128 kbps
  outputHeight?: number           // scale output; default = stage height
  onProgress?: (p: ExportProgress) => void
  signal?: AbortSignal
}

interface ExportProgress {
  frame: number
  totalFrames: number
}