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 layerCustom 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)