Skip to content
Docs / Overview
Editor

Editor

EditorProvider, Preview, AssetPanel, transform overlays, text editing, and the transition system.

EditorProvider

EditorProvider creates and wires the TimelineEngine, PlaybackEngine, and all Zustand stores. It must wrap all components that use engine hooks.

tsx
import { EditorProvider, type InitialTrackConfig } from '@elah/editor'

const tracks: InitialTrackConfig[] = [
  { kind: 'video', name: 'Video / Image' },
  { kind: 'audio', name: 'Audio' },
  { kind: 'elements', name: 'Elements' },
]

function App() {
  return (
    <EditorProvider
      fps={30}          // frames per second (required)
      initialTracks={tracks}
    >
      {/* All children can access engine + stores */}
    </EditorProvider>
  )
}
PropTypeDescription
fpsnumberFrames per second (e.g. 30, 60, 24)
initialTracksInitialTrackConfig[]Track layout to initialize the engine with
childrenReactNodeAll components that need engine access

Preview

<Preview> mounts the WebGL2 renderer and drives the RAF playback loop. It reads resolved scenes from resolveTimeline and composites video, image, and text layers. Transform overlays are painted on top automatically.

Preview usage
import {
  Preview,
  createDefaultDemuxerFactory,
  type PreviewHandle,
} from '@elah/editor'
import { useRef } from 'react'

// Build the factory once outside the component
const demuxerFactory = createDefaultDemuxerFactory()

function MyPreview() {
  const ref = useRef<PreviewHandle>(null)

  // PreviewHandle exposes:
  // ref.current.getCanvas()   → HTMLCanvasElement | null
  // ref.current.getRenderer() → GpuRenderer | null

  return (
    <Preview
      ref={ref}
      demuxerFactory={demuxerFactory}
      enableAudio={true}    // default: true
      style={{ flex: 1 }}
    />
  )
}

The canvas is letterboxed to the project stage aspect ratio. Off-aspect clips are contained (never stretched) within the frame using object-fit: contain semantics.

AssetPanel

The AssetPanel provides the media library UI. Features: file import via button or drag-drop, filmstrip thumbnail generation, audio waveform visualization, and drag-to-timeline for clip creation.

tsx
import { AssetPanel } from '@elah/editor'

// Minimal usage
<AssetPanel style={{ width: 240, minHeight: 0, overflowY: 'auto' }} />

// The panel uses useMediaLibrary() internally. The hook returns the assets
// plus import/remove helpers — importFiles takes an Iterable<File>:
import { useMediaLibrary } from '@elah/editor'

function CustomLibrary() {
  const { assets, importFiles, removeAsset } = useMediaLibrary()

  const handleFileDrop = async (files: FileList) => {
    const result = await importFiles(files)
    console.log('imported:', result.imported)
    console.log('skipped:', result.skipped)
  }

  return (
    <div onDrop={(e) => handleFileDrop(e.dataTransfer.files)}>
      {assets.map((asset) => (
        <div key={asset.id}>{asset.name}</div>
      ))}
    </div>
  )
}

Transforms

Every clip has an optional transform property. The MediaTransformOverlay is the interactive surface for video and image clips: click to select, drag to move, eight resize handles, and a Crop mode. It writes its result back to the engine as a transform (or a crop), so there is one renderer and one source of truth.

typescript
interface Transform {
  x: number        // position, normalized 0..1 of stage width
  y: number        // position, normalized 0..1 of stage height
  scale: number    // uniform scale factor (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's own box
}

// Set transform programmatically (updateClip needs the clip's trackId)
engine.updateClip(clipId, trackId, {
  transform: { x: 0.5, y: 0.4, scale: 1.2, rotation: 0, anchor: { x: 0.5, y: 0.5 } },
})

// The transform flows to both renderers:
// GpuRenderer: applies to WebGL2 textured quad
// ExportWorker: applies via resolveDrawRect() placement math
  • Eight resize handles: four corners and four edges. Dragging a handle scales the clip on that axis (scaleX and scaleY), so a non-uniform stretch is the default. Hold Shift to keep the aspect ratio. The opposite corner or edge stays fixed on screen while you drag.
  • Crop mode. A small Resize / Crop toggle floats above the selection box. In Crop mode the same eight handles resize the clip's crop window instead of its scale, and dragging inside the box pans the source underneath a fixed crop window. A fresh selection always starts in Resize. See Clips & Tracks.
  • One undo step per gesture. Move, resize and crop all write through the same preview-then-commit protocol, so each drag is a single undo entry, and preview and export honour the result identically.
  • A clip with no transform is drawn contained. On the first gesture the overlay bakes the contain-equivalent transform (transformFromContainRect) as the baseline, so grabbing a clip never moves it.
Rotation

transform.rotation flows through both renderers, and the media selection box tilts with the clip. Text clips have a rotate knob above the box (drag it; Shift snaps to 15 degree steps). Media clips do not have a rotate handle yet: set rotation on the transform through the engine, as above.

Text Overlays

Text clips are rendered via a 2D-canvas-to-texture pipeline (GPU TextLayer). An interactive overlay (TextOverlay) handles drag, resize (re-rasterized to stay crisp), rotation by a knob above the box, and double-click inline-edit.

tsx
// Add a text clip. The 'text' option carries content + style
// (TextClipMetadata); it is required when type is 'text'.
engine.addClip({
  trackId: textTrack.id,
  type: 'text',
  startFrame: 0,
  durationFrames: 90,
  name: 'Title',
  text: {
    content: 'Hello World',
    fontSize: 48,
    color: '#ffffff',
    fontWeight: 'bold',     // 'normal' | 'bold'
    textAlign: 'center',    // 'left' | 'center' | 'right'
    fontFamily: 'Inter',
  },
  transform: {
    x: 0.5,
    y: 0.8, // lower third
    scale: 1,
    rotation: 0,            // radians
    anchor: { x: 0.5, y: 0.5 },
  },
})

// Entry/exit animation lives on the clip as 'textAnimation', not on 'text'.
// Set it after creation via updateClip (in/out ramp, kind is 'fade'):
engine.updateClip(clipId, textTrack.id, {
  textAnimation: { in: 'fade', out: 'fade', durationFrames: 10 },
})

One end of an animation can also drive several channels at once with a layered MotionSpec, and 14 built-in text templates bundle a look with its motion. Applying a template is one undoable engine.updateClip call. See Text Templates & Motion.

Transitions

Transitions use a snapshot-overlay architecture. TransitionOverlay captures a frozen snapshot of the outgoing clip and animates it away over the incoming clip with CSS, while the GPU renders only the incoming clip. Export mirrors the same three modes in the 2D canvas API. There are three kinds, and all three are implemented in preview and export:

fade
The outgoing snapshot fades out: opacity 1 to 0 (export: globalAlpha).
slide
The outgoing snapshot slides horizontally off the stage. direction: 'left' slides it left; any other direction value slides it right. Up and down are not implemented.
wipe
The outgoing snapshot is clipped from the right edge, revealing the incoming clip. It is a horizontal wipe and ignores direction.
tsx
// Add a transition between two adjacent clips on the same track.
// trackId is required; returns the created Transition (or null if the
// clips aren't found on that track).
engine.addTransition({
  fromClipId: clip1.id,
  toClipId: clip2.id,
  trackId: track.id,
  kind: 'fade',           // 'fade' | 'slide' | 'wipe'
  durationFrames: 15,
  easing: 'ease-out',     // 'linear' | 'ease-in' | 'ease-out'
  direction: 'left',      // optional: 'left' | 'right' | 'up' | 'down' (slide uses left vs. not-left)
})

// Read transitions
const transitions = useTransitionsStore((s) => s.transitions)

// Remove a transition
engine.removeTransition(transitionId)

Stage / Aspect Ratio

The stage aspect ratio is set on the engine and changes the canvas viewport. All clips are letterboxed to the stage. The StageBorder component shows a frame outline:

tsx
const engine = useTimelineEngine()

// setStage takes (width, height) as positional args.
// Portrait (9:16 — Reels/Shorts/TikTok)
engine.setStage(1080, 1920)

// Landscape (16:9 — YouTube)
engine.setStage(1920, 1080)

// Square (1:1)
engine.setStage(1080, 1080)

// Custom
engine.setStage(2560, 1440)
On this page