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.
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.
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.rectslists 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
clipcapture of that box, with everything that paints there. - Hover
- The pointer repaints only what a
:hoverrule 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.reasonsays why:'root resized','no host for <div.x>'and so on.patches: falserepaints 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:
| Cards | Chromium full / patch | Firefox full / patch | WebKit full / patch |
|---|---|---|---|
| 400 | 11.7 / 4.2 ms | 28 / 4 ms | 20 / 4 ms |
| 2000 | 52.9 / 4.9 ms | 119 / 5 ms | 90 / 4 ms |
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 gainstoStream(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.canvasover anything withdrawImage, and record the result.rec.doneresolves 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,tickerandliveBadge: 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 withleft, 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 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 }.msis wall time;busyMsis 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.falserepaints full frames only, for comparison.- animations
true.falsestops 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, abackdrop-filterbox 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.