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: 'text', name: 'Text' },
]

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.canvas — the underlying HTMLCanvasElement
  // ref.current.renderer — the GpuRenderer instance

  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:
import { useMediaLibrary, importFiles } from '@elah/editor'

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

  const handleFileDrop = async (files: FileList) => {
    const result = await importFiles({ files, addAsset })
    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 provides interactive drag-move and corner-drag uniform scale for video and image clips:

typescript
interface Transform {
  x: number      // offset from clip center (normalized 0..1 of stage width)
  y: number      // offset from clip center (normalized 0..1 of stage height)
  scale: number  // uniform scale factor (1.0 = contain-fit size)
  rotation: number // degrees
}

// Set transform programmatically
engine.updateClip(clipId, {
  transform: { x: 0, y: -0.1, scale: 1.2, rotation: 0 },
})

// The transform flows to both renderers:
// GpuRenderer: applies to WebGL2 textured quad
// ExportWorker: applies via resolveDrawRect() placement math
Status

Move and uniform scale are fully interactive. Rotation handle is partial — transform.rotation flows through both renderers but the interactive drag handle is not yet built.

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), and inline-edit.

tsx
// Add a text clip
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'
    fontStyle: 'normal',    // 'normal' | 'italic'
    textAlign: 'center',    // 'left' | 'center' | 'right'
    fontFamily: 'Inter',
    // animation (optional)
    animation: {
      kind: 'fade-in',
      durationFrames: 10,
    },
  },
  transform: {
    x: 0,
    y: 0.3, // lower third position
    scale: 1,
    rotation: 0,
  },
})

Transitions

Transitions use a snapshot-overlay architecture. The resolver sets fromClip.opacity and toClip.opacity; TransitionOverlay fades a frozen canvas snapshot via CSS; export mirrors with globalAlpha.

tsx
// Add a fade transition between two clips on the same track
engine.addTransition({
  fromClipId: clip1.id,
  toClipId: clip2.id,
  kind: 'fade',           // 'fade' | 'slide' (partial) | 'wipe' (partial)
  durationFrames: 15,
  easing: 'ease-in-out',  // 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'
})

// 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()

// Switch to portrait (9:16 — Reels/Shorts/TikTok)
engine.setStage({ width: 1080, height: 1920 })

// Switch to landscape (16:9 — YouTube)
engine.setStage({ width: 1920, height: 1080 })

// Square (1:1)
engine.setStage({ width: 1080, height: 1080 })

// Custom
engine.setStage({ width: 2560, height: 1440 })