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.
engine.addClip(options: CreateClipOptions): ClipAdds a new clip to a track. The clip is placed at startFrame and returns the created Clip object.
trackIdstringTarget track IDtypeClipType'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 clipstextTextClipMetadata?Content + style; required when type is 'text'engine.moveClip(clipId: string, fromTrackId: string, toTrackId: string, startFrame: number): voidMoves 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.
engine.removeClip(clipId: string, trackId: string): voidRemoves a clip from a track.
engine.updateClip(clipId: string, trackId: string, updates: Partial<Clip>): voidUpdates 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.
engine.addTrack(kind: TrackKind, options?: Partial<CreateTrackOptions>): TrackAdds a new track. Video is capped at one lane — adding a video track when one exists returns the existing track (idempotent).
engine.addTransition(options): Transition | nullAdds 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 cliptoClipIdstringThe incoming cliptrackIdstringTrack both clips live on (required)kindTransitionKind'fade' | 'slide' | 'wipe'durationFramesnumberHow many frames the transition spanseasingTransitionEasing?'linear' | 'ease-in' | 'ease-out'engine.batch(fn: () => void, label?: string): voidGroups multiple mutations into a single undo entry. All mutations inside fn() are committed atomically.
engine.undo(): boolean | engine.redo(): booleanStep backwards/forwards through the commit history. Returns true if a step was applied (use canUndo() / canRedo() to gate UI).
engine.getProject(): ProjectReturns the current immutable Project snapshot.
engine.setStage(width: number, height: number): voidSets 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.
// 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.
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.
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 unmountHooks
useTimelineEngine()→ TimelineEngineAccess the TimelineEngine from any child of EditorProvider. Use this for mutations.
usePlaybackEngine()→ PlaybackEngineAccess the PlaybackEngine directly. Usually prefer usePlaybackStore for state.
useTracksStore(selector)→ TZustand store for tracks, clips, totalFrames. Reactive to all engine mutations.
usePlaybackStore(selector)→ TZustand store for currentFrame, isPlaying, togglePlayPause, setCurrentFrame.
useSelectionStore(selector)→ TZustand store for selected clip IDs.
useTransitionsStore(selector)→ TZustand store for all transitions.
useMediaLibrary()→ UseMediaLibraryApiAccess the media library. Returns { assets, getAsset, removeAsset, updateAsset, importFiles, importUrl, importBlob }.
Types
// 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
}