The single most important rule in the engine is also the most boring to state: a renderer takes a Scene and produces pixels, and it knows nothing else. It does not ask the engine what time it is. It does not look up clips by id. It does not know what a Project is. Everything a renderer needs is in the Scene it receives.
That constraint sounds like an inconvenience. It is actually the thing that makes the whole system testable, exportable, and future-proof.
Four methods, nothing else
interface Renderer {
mount(container: HTMLElement): void
resize(cssWidth: number, cssHeight: number, dpr?: number): void
render(scene: Scene): void
dispose(): void
}render(scene) is synchronous and idempotent on equal references — if scene === lastScene, it is a no-op. The renderer reads only the Scene; it never imports Project, Clip, the engines, the stores, or React. Any async decode or upload work happens out-of-band on subsequent ticks, never inside the render call.
The pure function in the middle
The bridge between mutable editor state and a dumb renderer is one pure function:
function resolveTimeline(frame: number, project: Project): SceneGiven the same (frame, project), it always produces a structurally-equal Scene. No DOM access, no React, no Zustand, no side effects. It decides what is visible and audible at a given frame and returns plain data: videos, audios, texts, images, and transitions. Callers do the side effects; the resolver only computes.
Because resolveTimeline is pure, it is unit-testable without a DOM, safe to run inside a Web Worker, and memoizable by (frame, project) reference equality. It runs 60 times per second — bugs there are invisible without tests, so the test suite is the spec.
How this keeps preview and export in sync
Export is not a Renderer. The export worker draws to a 2D OffscreenCanvas rather than instantiating the WebGL renderer. So what stops preview and export from drifting apart? They consume the same resolveTimeline output and reuse the same pure placement helpers — resolveDrawRect, computeTextLayout. It is the shared resolution, not a shared draw call, that guarantees identical geometry.
This is the payoff of the boundary. The live preview runs WebGL2 on the main thread; export runs Canvas2D in a worker with no GPU context at all. Two completely different draw paths, one source of truth for what to draw and where. They cannot drift because the math that decides geometry lives in one place that both import.
What this buys for the future
A WebGPU backend — for shader effects and richer transitions — would implement the same four-method Renderer interface and consume the same Scene. No change to the engine, the resolver, or the React layer. The same is true for a hypothetical DOM or server-side renderer. The contract is the Scene, and the Scene is small.
The renderer that knows about the project is the renderer you cannot test, cannot move to a worker, and cannot replace. Draw the line at the Scene and never cross it.
Building something with browser-native video?
Try the SDK, read the docs, or join the conversation.
