All posts
Implementation7 min read

Snapshot-Overlay Transitions: Preview and Export in Sync

GPU crossfade is the obvious approach. It is also the wrong one for a renderer-agnostic system. Here is the snapshot-overlay architecture that keeps CSS preview and OffscreenCanvas export using the same resolver output.

When you set out to build a crossfade, the obvious move is to do it on the GPU: render both clips to textures, blend them in a shader with a time-varying alpha, done. It works beautifully — in the preview. Then you go to export, where there is no GPU context in the worker, and you have to reimplement the entire blend a second way. Now you own two crossfade implementations that must produce pixel-identical results, and they will drift.

For a renderer-agnostic system, the GPU-crossfade is the wrong primitive. The right one keeps the transition logic out of the renderer entirely.

The resolver decides opacity, not the renderer

In a fade, the resolver does the work that matters. During the overlap window it sets the outgoing clip to opacity = 0 and the incoming clip to opacity = 1, and emits the transition descriptor on Scene.transitions. The renderer never learns the word "fade" — it just draws clips at the opacities it is handed.

ts
interface Scene {
  frame: number
  videos: ActiveVideoClip[]
  audios: ActiveAudioClip[]
  texts: ActiveTextClip[]
  images: ActiveImageClip[]
  transitions: SceneTransition[]   // describes the fade; renderer ignores semantics
}

The snapshot-overlay trick in preview

Here is the part that keeps preview simple: instead of blending two live clips, the preview freezes a canvas snapshot of the outgoing frame and fades that snapshot out over the top of the incoming clip using plain CSS opacity. One frozen image, one CSS transition. No second live decode, no shader blend, no per-frame compositing math on the hot path.

A TransitionOverlay component owns the snapshot and its CSS fade. The underlying renderer just keeps drawing the incoming clip at full opacity. The crossfade you see is the snapshot dissolving away on top.

Export mirrors it with one line of canvas math

Export cannot use CSS, but it does not need the snapshot trick either — it has every frame available deterministically. It mirrors the same fade by drawing the outgoing content with a time-varying global alpha:

ts
// Export worker, per transition frame at progress t in [0, 1]
ctx.globalAlpha = 1 - t
drawOutgoing(ctx, scene)
ctx.globalAlpha = 1
drawIncoming(ctx, scene)

Both paths are driven by the same resolver output — the same opacities, the same transition descriptor, the same timing. Preview fades a frozen snapshot with CSS; export composites with globalAlpha. Two mechanisms, one source of truth, no drift.

Status

The snapshot-overlay architecture is in place and fade is fully implemented in both preview and export. Slide and wipe transitions reuse the same Scene.transitions plumbing; only fade is shipped so far.

The principle underneath

The reason this works is the same reason the whole engine works: the renderer stays dumb. The moment a transition becomes "a thing the renderer knows how to do," you have coupled visual effects to a specific draw backend, and every new backend owes you a reimplementation. Keep the semantics in the resolver, hand the renderer plain opacities, and let each output path realize the fade with whatever primitive it has — CSS here, globalAlpha there.

A transition is not something a renderer does. It is something the resolver describes and every renderer happens to obey.

Building something with browser-native video?

Try the SDK, read the docs, or join the conversation.