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 } from '@elah/core'

export class CanvasRenderer implements Renderer {
  private ctx: CanvasRenderingContext2D

  constructor(canvas: HTMLCanvasElement) {
    this.ctx = canvas.getContext('2d')!
  }

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

    // Render in zIndex order
    const allClips = [
      ...scene.videos,
      ...scene.images,
      ...scene.texts,
    ].sort((a, b) => a.zIndex - b.zIndex)

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

      if ('src' in clip) {
        // draw image/video frame
        const frame = getFrame(clip.src, scene.frame)
        if (frame) ctx.drawImage(frame, clip.drawRect.x, clip.drawRect.y,
                                        clip.drawRect.width, clip.drawRect.height)
      }
    }

    ctx.globalAlpha = 1
  }

  destroy(): void {
    // cleanup
  }
}

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'

const myDemuxerFactory: DemuxerFactory = () => {
  // Return a DemuxerBackend implementation
  return {
    async probe(src: string): Promise<MediaInfo> {
      // Return video dimensions, duration, track info
    },
    async demux(
      src: string,
      options: DemuxOptions,
      onChunk: (chunk: EncodedVideoChunk) => void
    ): Promise<void> {
      // Feed EncodedVideoChunks to the WebCodecs decoder
    },
    destroy(): void {},
  }
}

// Pass your factory to Preview and exportVideo
<Preview demuxerFactory={myDemuxerFactory} />

await exportVideo(project, {
  fps: 30,
  demuxerFactory: myDemuxerFactory,
})