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. Any number of tracks of any kind is allowed. Video tracks composite in track order (the topmost lane draws on top) and a new video track is placed directly below the last existing one; other kinds append, above any bottom-pinned lane.
namestring?Display name; defaults to a kind-based nameheightnumber?Lane height in px (default 64)ordernumber?Explicit render order; normally left to the engineprotectedboolean?When true, the user cannot remove the trackpinned'bottom'?Keeps the lane below every non-pinned track, whatever is added laterengine.loadProject(project: Project, options?: { transport?: 'rewind' | 'keep'; history?: 'reset' | 'keep' }): voidReplaces the whole composition. Pass the result of readProjectDocument(json), which throws ProjectDocumentError for an unreadable or too-new document. By default undo history is cleared and the playhead rewinds; { transport: 'keep', history: 'keep' } is for the relinkProjectMedia repair pass over a composition that is already on screen. Emits 'change' then 'project:loaded'.
transport'rewind' | 'keep''rewind' (default) stops and returns to frame 0; 'keep' leaves the playhead alonehistory'reset' | 'keep''reset' (default) clears undo/redo; 'keep' preserves the stacks and any open batch or dragengine.setClipSpeed(clipId: string, trackId: string, speed: number): voidSets a video clip's playback multiplier, clamped to 0.25–4. The clip's on-timeline length follows the new speed; growth is clamped to the gap before the next clip.
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
All hooks below live in @elah/react and are re-exported by @elah/editor — install @elah/react directly if you're building custom UI on @elah/core without the full editor SDK. The @elah/react page covers the context, the store hooks, the audio hooks and using the same stores without React in detail.
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.
useTextStylePresetsStore(selector)→ TZustand store for reusable text looks: the built-in presets plus any the user saves.
useClipLoadStore(selector)→ TZustand store of which clips the preview cannot draw yet: byClipId[id] is 'loading' or 'error'. Renderer state only; never saved or undoable.
useMediaLibrary()→ UseMediaLibraryApiAccess the media library. Returns { assets, getAsset, removeAsset, updateAsset, importFiles, importUrl, importBlob }.
useAudioMixer(controller)→ AudioMixerApiClick-free master/track gain controls for an AudioPlaybackController. Pass null while the controller isn’t initialized yet — methods become no-ops.
useMasterVolume(controller, engine)→ MasterVolumeApiMaster volume with both immediate audio-graph control and persistence to the project model.
useTrackLevels(controller)→ Map<string, TrackLevel>Polls per-track RMS levels on every animation frame, for building level meters.
Types
The clip and track fields added in 0.6.0 (Clip.speed, Clip.crop, Clip.cornerRadius, Track.protected, Track.pinned) are explained on Clips & Tracks. Layered text motion is on Text Templates & Motion.
// 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
protected?: boolean // the user cannot remove this track
pinned?: 'bottom' // addTrack keeps this lane below every non-pinned track
}
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
speed?: number // video only; 0.25..4, default 1
cornerRadius?: number // video/image; 0..0.5 of the shorter side
crop?: { x: number; y: number; width: number; height: number } // 0..1 of the source
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
shapeAnimation?: 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
scaleX?: number // extra horizontal stretch on top of scale; omitted = 1
scaleY?: number // extra vertical stretch on top of scale; omitted = 1
rotation: number // radians, positive = clockwise
anchor: { x: number; y: number } // 0..1 within the clip box
}
// Entry/exit ramp for text (and shape) clips.
type TextAnimationKind = 'fade' | 'spin' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right'
interface TextAnimation {
in?: TextAnimationKind
out?: TextAnimationKind
durationFrames: number
// Layered motion. When present it replaces in / out; used by text templates.
inMotion?: MotionSpec
outMotion?: MotionSpec
}
// One end of a layered animation: the state at the FAR end of the ramp.
// The near end is always the clip's resting state. Omitted channels stay at rest.
interface MotionSpec {
opacity?: number // 0..1
offsetX?: number // fraction of stage width
offsetY?: number // fraction of stage height
scale?: number // multiplier on the authored scale
rotation?: number // radians
ease?: TextAnimationEasing // geometry channels
opacityEase?: TextAnimationEasing // default 'linear'
}
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 // target SHORT edge in px (1080 = 1080p in either
// orientation); other edge is rounded to even.
// Default = the stage's own short edge
onProgress?: (p: ExportProgress) => void
signal?: AbortSignal
}
interface ExportProgress {
frame: number
totalFrames: number
}