Skip to content
SnapDOMGitHub★ 8K
Official plugin · MIT

Stream a live element as a canvas.

Use a canvas that follows DOM changes as a WebGL texture, video overlay or live preview. Unchanged areas keep their pixels.

Quick start

Add Stream to a page

Requires SnapDOM 3.4.0 or newer within 3.x. Source and MIT license.

npm install @zumer/snapdom @zumer/snapdom-stream

stream(element, options) returns the canvas, ready (a promise for the first full frame), stats and stop(). The canvas is updated in place and onFrame gets each repaint. Every capture option applies (scale, dpr, embedFonts, exclude and the rest).

import { stream } from '@zumer/snapdom-stream'

const live = stream(panel, {
  scale: 2,
  onFrame(frame) {
    // frame.canvas is updated in place; frame.rects lists what changed, in canvas pixels.
  },
})
await live.ready
document.body.append(live.canvas)
// Call live.stop() when you remove the preview.
How it works

One full frame, then patches

The first frame is a full capture. After that, every change (mutations, typing, focus, hover, running animations) is traced to the nearest box that can be repainted on its own, and only that subtree is captured and drawn into its rect.

Patches
A changed card costs the capture of that card, not of the page. frame.rects lists the boxes a frame redrew, in canvas pixels, so a consumer can upload or copy just those.
Clip windows
A change in a box that cannot be repainted on its own (a value on a translucent card, a row under a badge) repaints a window of the element instead: a clip capture of that box, with everything that paints there.
Hover
The pointer repaints only what a :hover rule restyles.
Idle keyframes
A keyframe in idle time heals anything the change tracking could not see, at most every 2 s and never more than 2% of the main thread.
Full frames and frame.reason
A frame that cannot be patched exactly is a full frame, and frame.reason says why: 'root resized', 'no host for <div.x>' and so on. patches: false repaints full frames only, for comparison.
Pixel for pixel
Every patched frame is meant to match a full capture of the same DOM state pixel for pixel. The test suite checks exactly that on Chromium, Firefox and WebKit.

Measured on a 1000px grid with one card changing per frame, median per frame, as full capture / patch:

CardsChromium full / patchFirefox full / patchWebKit full / patch
40011.7 / 4.2 ms28 / 4 ms20 / 4 ms
200052.9 / 4.9 ms119 / 5 ms90 / 4 ms
The whole API

One function, and the pieces around it

Everything below is exported from @zumer/snapdom-stream.

stream(element, options)
The entry. Returns { canvas, ready, stats, stop() }; the options are the capture options plus the four listed further down.
streamExport() and toStream()
The plugin form. Register streamExport() on a capture and the result gains toStream(options). It streams the element using that capture’s options. GIF and video recording methods require their own plugins.
import { snapdom } from '@zumer/snapdom'
import { streamExport } from '@zumer/snapdom-stream'

const result = await snapdom(panel, { plugins: [streamExport()] })
const live = await result.toStream()
await live.ready
document.body.append(live.canvas)
streamTexture(gl)
Keeps a WebGL texture in step with the stream. On WebGL2 it uploads only the changed rects: 4% of the texture per frame in the WebGL demo, against 100%.
import { stream, streamTexture } from '@zumer/snapdom-stream'

const tex = streamTexture(gl)
stream(panel, { onFrame: (frame) => tex.update(frame) })
// draw with tex.texture
recordCanvas(canvas, options)
Composite live.canvas over anything with drawImage, and record the result. rec.done resolves to an MP4 or WebM Blob, whichever the browser records.
import { recordCanvas } from '@zumer/snapdom-stream'

const rec = recordCanvas(output, { fps: 30, duration: 10000 })
const blob = await rec.done // MP4 or WebM, whichever the browser records
WebRTC
The composited canvas is a video track like a camera's, so an HTML overlay travels inside the video to every viewer, with no desktop software and no server-side render.
const track = output.captureStream(30).getVideoTracks()[0]
track.contentHint = 'detail' // graphics are text: keep the resolution
peerConnection.addTrack(track, new MediaStream([track]))
Overlay templates
overlayStage, scoreboard, lowerThird, ticker and liveBadge: a stage and four broadcast graphics, as plain DOM with a small controller each. Every box that changes is opaque and the lower third slides with left, so a score, a clock tick or a slide costs a patch, not a full frame. Theme through CSS variables on the stage: accent, panel, panel-2, text, ink, live, font.
import { stream, overlayStage, scoreboard, lowerThird, ticker, liveBadge } from '@zumer/snapdom-stream'

const stage = overlayStage({ width: 1920, height: 1080, theme: { accent: '#facc15' } })
const score = scoreboard({ home: 'HOME', away: 'AWAY' })
const lower = lowerThird({ name: 'Alex Morgan', role: 'Commentator' })
stage.append(score.el, liveBadge().el, lower.el, ticker({ items: ['Kick-off at 18:00'] }).el)
document.body.append(stage)

score.clock.start()
score.add('home')
lower.set({ name: 'Jamie Rivera' })
lower.hide()

const live = stream(stage)
await live.ready
document.body.append(live.canvas)
Options

Options and limits

All capture options apply (scale, dpr, embedFonts, exclude and the rest), plus these four.

onFrame(frame)
Called after each repaint with { full, reason, rects, ms, busyMs, canvas }. ms is wall time; busyMs is the part the stream held the main thread, the one to budget on.
fps
Upper bound on repaints per second. Default: every animation frame.
patches
true. false repaints full frames only, for comparison.
animations
true. false stops following running animations and transitions: an animated box keeps the pixels it was last drawn with until a patch over it, a full frame or the keyframe. For a still of the page, where a spinner's angle does not matter but its full frames would.

Limits

Full frames
A change with no box that can be repainted standalone repaints a window of the enclosing box that kept its rect, with everything that paints there. It is a full frame only when nothing kept its rect (the root resized), a node was removed, a blur, drop-shadow or url() filter above the change spreads it, a backdrop-filter box overlaps it, a positioned box overlaps a host that cannot be a window, or the patches would cover more than half of the canvas.
CSSOM edits
Programmatic CSSOM edits (insertRule, rule.style) produce no signal; the keyframe heals them.
Spilled ink
Text ink that spills past a box edge onto a pixel shared with a neighbour waits for the keyframe.
Core version
Requires SnapDOM 3.4.0 or newer for pixel-aligned patches at fractional positions.

Questions

Is the canvas a video of the element?

No. It is a capture, redrawn where the element changed. The first frame is a full capture; after that only the changed boxes are captured and drawn into their rects, and a keyframe in idle time heals anything the tracking could not see.

How do I know whether a frame was patched?

frame.full is false and frame.rects lists the boxes that were redrawn, in canvas pixels. A full frame carries frame.reason: 'root resized', 'no host for <div.x>' and so on.

Which engines are covered?

The test suite compares every patched frame against a full capture of the same DOM state on Chromium, Firefox and WebKit, and the per-frame numbers above were measured on the three.

Can I record it, or send it over WebRTC?

Yes. recordCanvas() records any canvas a stream feeds, as MP4 or WebM, whichever the browser records. canvas.captureStream() turns the composited canvas into a video track for a peer connection, overlay included.

Where can I see it running?

Three labs are built on it: the video overlay, the WebRTC overlay and the ASCII mirror, which is the demo below. The showcase has a stream demo too.

Try it hereASCII mirror lab
Edit the live card: its changes appear in the ASCII preview. The cube uses the streamed canvas as a texture.