@elah/core
v0.4.1
Published
Framework-agnostic video timeline engine — playback, frame resolution, WebGL2 renderer, and media management
Maintainers
Readme
@elah/core
Framework-agnostic video timeline engine. No React. No renderer. Just the pure logic layer — project state, playback, frame resolution, and media management.
Used internally by @elah/react, @elah/timeline, and @elah/editor, but can be consumed directly for custom rendering pipelines or headless environments.
React hooks are not in this package — they live in @elah/react, which wraps the stores below for component use.
Install
npm install @elah/coreBundle size: ~41 KiB gzipped (218 KiB raw, tsc ESM output). Core runtime deps: immer (~9 KiB gz) + zustand (<1 KiB gz). The media toolchain (mediabunny) is lazy-imported by the export pipeline and demuxer, so it stays out of the main bundle until you actually decode or export — see lazyExport.
What's inside
| Module | Description |
|---|---|
| TimelineEngine | Manages project state — tracks, clips, undo/redo |
| PlaybackEngine | Frame-accurate playback clock |
| resolveTimeline | Pure function — project → active scene at a given frame |
| GpuRenderer | WebGL2 renderer for video, image, text, shape, and freehand layers |
| AudioPlaybackController | Multi-track audio mixer on the playback clock |
| tracksStore | Vanilla Zustand store mirroring project state — bind it with useTracksStore from @elah/react |
| playbackStore | Vanilla Zustand store mirroring playback state — bind it with usePlaybackStore from @elah/react |
| selectionStore | Vanilla Zustand store for clip selection — bind it with useSelectionStore from @elah/react |
| transitionsStore | Vanilla Zustand store mirroring transitions — bind it with useTransitionsStore from @elah/react |
| mediaLibraryStore | Media asset registry (vanilla store) — bind it with useMediaLibrary from @elah/react |
| importFiles / importUrl / importBlob | Import local files, remote URLs, or blobs into the media library |
| exportVideo | Export the timeline to MP4 via a web worker |
These stores are vanilla (zustand/vanilla) — no React, subscribable from anywhere (store.getState(), store.subscribe()). They are also module-level singletons: one tracksStore, one playbackStore, etc. per JS realm. @elah/editor's <EditorProvider> wires each TimelineEngine/PlaybackEngine instance it creates into these same shared stores, so only one active project per page is supported today — mounting two <EditorProvider> (or two manually-wired engines) at once will have them overwrite each other's state in the stores. Multiple independent editors on one page need separate tabs/iframes/windows until scoped stores land.
Quick start
import { TimelineEngine, PlaybackEngine, resolveTimeline } from '@elah/core'
const engine = new TimelineEngine({ fps: 30, stage: { width: 1920, height: 1080 } })
const playback = new PlaybackEngine({ fps: 30, getTotalFrames: () => engine.getTotalFrames() })
// Add a track, then a clip onto it. addClip takes a single typed
// options object (a discriminated union keyed on `type`) and returns the Clip.
const track = engine.addTrack('video')
engine.addClip({ trackId: track.id, type: 'video', src: 'video.mp4', startFrame: 0, durationFrames: 90 })
// Resolve the scene at frame 15 — pure (frame, project) → Scene.
const scene = resolveTimeline(15, engine.getProject())Clip factories
Standalone builders that return a fully-normalized Clip object (rounded frames, default volume/opacity, generated id) without an engine — useful for headless pipelines that feed resolveTimeline directly. When you have an engine, prefer engine.addClip(options) instead, which builds the clip and records an undo entry.
import {
createVideoClip,
createAudioClip,
createTextClip,
createImageClip,
createShapeClip,
createFreehandClip,
} from '@elah/core'
const clip = createVideoClip({ trackId: 'v1', src: 'video.mp4', startFrame: 0, durationFrames: 90 })
const rect = createShapeClip({
trackId: 'el1',
startFrame: 0,
durationFrames: 90,
shape: { shapeKind: 'rect', shapeFill: '#22d3ee' }, // 'rect' | 'circle' | 'triangle'
})Export
import { exportVideo } from '@elah/core'
const blob = await exportVideo(engine.getProject(), {
videoBitrate: 8_000_000,
onProgress: ({ frame, totalFrames }) => console.log(frame, '/', totalFrames),
})