Plugins

Plugins & Custom Renderers

Swap the renderer, add custom GPU layers, or bring your own media demuxer.

Custom Renderers

Any object that implements the Renderer interface can replace the built-in GpuRenderer. The renderer receives a Scene each tick and is responsible for writing pixels:

MyRenderer.ts
import { type Renderer, type Scene, resolveDrawRect } from '@elah/core'

export class CanvasRenderer implements Renderer {
  private canvas!: HTMLCanvasElement
  private ctx!: CanvasRenderingContext2D

  // Attach to the host container once. The renderer owns its canvas.
  mount(container: HTMLElement): void {
    this.canvas = document.createElement('canvas')
    this.ctx = this.canvas.getContext('2d')!
    container.appendChild(this.canvas)
  }

  // Update the backing-store size when the container resizes.
  resize(cssWidth: number, cssHeight: number, dpr = 1): void {
    this.canvas.width = Math.round(cssWidth * dpr)
    this.canvas.height = Math.round(cssHeight * dpr)
  }

  render(scene: Scene): void {
    const { ctx } = this
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)

    // Arrays are already sorted back-to-front; last element wins.
    const allClips = [...scene.videos, ...scene.images, ...scene.texts]

    for (const clip of allClips) {
      ctx.globalAlpha = clip.opacity

      if ('src' in clip) {
        // Compute placement yourself — drawRect is NOT on the Scene clip.
        // resolveDrawRect(transform, stageW, stageH, contentW?, contentH?)
        const { width: sw, height: sh } = scene.stage
        const rect = resolveDrawRect(clip.transform, sw, sh)
        const frame = getFrame(clip.src, scene.frame)
        if (frame) ctx.drawImage(frame, rect.x, rect.y, rect.width, rect.height)
      }
    }

    ctx.globalAlpha = 1
  }

  dispose(): void {
    this.canvas.remove()
  }
}

Custom GPU Layers

The GpuRenderer uses a layer registry. Each layer type (VideoLayer, ImageLayer, TextLayer) is a class that handles setup, texture upload, and draw calls for its clip type.

typescript
// Layers are registered on the renderer.
// To add a custom layer, extend GpuLayer (internal class):

// 1. Create a new layer class
class GradientLayer {
  setup(gl: WebGL2RenderingContext): void { /* shader setup */ }
  draw(gl: WebGL2RenderingContext, clip: ActiveVideoClip): void { /* draw call */ }
  destroy(): void { /* cleanup */ }
}

// 2. Register on the renderer (internal API — subject to change)
renderer.registerLayer('gradient', GradientLayer)

// 3. Scene clips with matching type are routed to your layer

Custom Demuxers

The decode pipeline is fully pluggable via the DemuxerFactory interface. The built-in implementation uses mediabunny, but you can swap in any demuxer that implements the interface:

typescript
import { type DemuxerFactory, type DemuxerBackend } from '@elah/core'

// A DemuxerFactory is just () => DemuxerBackend.
const myDemuxerFactory: DemuxerFactory = () => {
  return {
    // Open the source and prepare to read packets.
    async open(src: string): Promise<void> {},
    // Return the WebCodecs config used to configure the VideoDecoder.
    getConfig(): VideoDecoderConfig {
      return { codec: 'avc1.640028' /* ... */ }
    },
    // Yield EncodedVideoChunks covering [startSec, endSec].
    async *packets(timeRange: [number, number]): AsyncIterable<EncodedVideoChunk> {
      // yield chunk
    },
    // Seek the reader to the keyframe at/just before the given time (seconds).
    async seekToKeyframe(time: number): Promise<void> {},
    dispose(): void {},
  }
}

// Pass your factory to Preview to drive live playback decode:
<Preview demuxerFactory={myDemuxerFactory} />

// Note: export runs in a dedicated worker that uses mediabunny directly,
// so exportVideo() does not accept a demuxerFactory.
await exportVideo(project)