@zumer/snapdom-plugins 4.0.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Broadcast overlay templates: a stage, a scoreboard with a clock, a lower third, a ticker and
3
+ * a live badge, as plain DOM with a small controller each. Framework-free: append `.el`
4
+ * wherever the overlay lives and stream the stage.
5
+ *
6
+ * Built to patch. Every box that changes is opaque, so a score or a clock tick repaints that
7
+ * box alone; the lower third slides with `left`, which moves an out-of-flow box the stream
8
+ * clears and redraws, where a `transform` slide would be a full frame on every step of it.
9
+ * Theme through CSS variables on the stage (--sdo-accent, --sdo-panel, --sdo-text, --sdo-font).
10
+ * Pinned by __tests__/plugin.stream.overlays.test.js.
11
+ * @module plugins/stream/overlays
12
+ */
13
+
14
+ const CSS = `
15
+ .sdo-stage{position:relative;overflow:hidden;--sdo-accent:#facc15;--sdo-panel:#0b0f17;--sdo-panel-2:#161d2b;--sdo-text:#fff;--sdo-ink:#111;--sdo-live:#ef4444;--sdo-font:Arial,Helvetica,sans-serif;font:16px/20px var(--sdo-font);color:var(--sdo-text)}
16
+ .sdo-score{position:absolute;left:24px;top:24px;display:flex;background:var(--sdo-panel);border-radius:6px;overflow:hidden;box-shadow:0 4px 14px #0009}
17
+ .sdo-team{display:flex;align-items:center;gap:10px;padding:8px 14px;background:var(--sdo-panel);font-weight:700;letter-spacing:.04em}
18
+ .sdo-team i{width:10px;height:28px;border-radius:2px;display:block}
19
+ .sdo-pts{min-width:44px;text-align:center;font:700 26px/32px var(--sdo-font);padding:4px 8px;background:var(--sdo-panel-2)}
20
+ .sdo-clock{padding:0 14px;display:flex;align-items:center;background:var(--sdo-accent);color:var(--sdo-ink);font:700 20px/40px ui-monospace,Menlo,monospace}
21
+ .sdo-live{position:absolute;right:24px;top:24px;background:var(--sdo-panel);border-radius:6px;padding:6px 12px 6px 28px;font-weight:700;letter-spacing:.1em}
22
+ .sdo-live::before{content:'';position:absolute;left:11px;top:11px;width:10px;height:10px;border-radius:50%;background:var(--sdo-live);animation:sdo-pulse 1s ease-in-out infinite}
23
+ @keyframes sdo-pulse{50%{opacity:.25}}
24
+ .sdo-lower{position:absolute;left:24px;bottom:76px;display:flex;flex-direction:column;transition:left .45s cubic-bezier(.2,.8,.2,1)}
25
+ .sdo-lower.sdo-hidden{left:-720px}
26
+ .sdo-name{background:#f8fafc;color:#0b0f17;font:700 24px/30px var(--sdo-font);padding:6px 16px;border-radius:4px 4px 0 0}
27
+ .sdo-role{background:#1d4ed8;color:#fff;padding:6px 16px;border-radius:0 0 4px 4px}
28
+ .sdo-ticker{position:absolute;left:0;right:0;bottom:0;height:40px;background:var(--sdo-panel);overflow:hidden;display:flex;align-items:center}
29
+ .sdo-tag{background:var(--sdo-live);height:40px;padding:0 14px;display:flex;align-items:center;font-weight:700;position:relative;z-index:1}
30
+ .sdo-run{white-space:nowrap;padding-left:20px;animation:sdo-run var(--sdo-speed,18s) linear infinite}
31
+ @keyframes sdo-run{from{transform:translateX(0)}to{transform:translateX(-50%)}}
32
+ `
33
+
34
+ function el(tag, className, text) {
35
+ const node = document.createElement(tag)
36
+ if (className) node.className = className
37
+ if (text != null) node.textContent = text
38
+ return node
39
+ }
40
+
41
+ function injectCss(doc = document) {
42
+ if (doc.getElementById('sdo-css')) return
43
+ const style = el('style')
44
+ style.id = 'sdo-css'
45
+ style.textContent = CSS
46
+ doc.head.append(style)
47
+ }
48
+
49
+ /**
50
+ * The transparent stage every overlay sits on; stream this element.
51
+ * @param {{width?: number, height?: number, theme?: Record<string, string>}} [options] -
52
+ * size in CSS px (default 960x540); theme maps --sdo-* names without the prefix.
53
+ * @returns {HTMLDivElement}
54
+ */
55
+ export function overlayStage({ width = 960, height = 540, theme = {} } = {}) {
56
+ injectCss()
57
+ const stage = el('div', 'sdo-stage')
58
+ stage.style.width = `${width}px`
59
+ stage.style.height = `${height}px`
60
+ for (const [k, v] of Object.entries(theme)) stage.style.setProperty(`--sdo-${k}`, v)
61
+ return stage
62
+ }
63
+
64
+ /**
65
+ * Two teams, their points and a running clock.
66
+ * @param {{home?: string, away?: string, homeColor?: string, awayColor?: string}} [options]
67
+ * @returns {{el: HTMLElement, score(home: number, away: number): void, add(side: 'home'|'away', n?: number): void,
68
+ * clock: {start(): void, stop(): void, reset(seconds?: number): void, set(text: string): void}}}
69
+ */
70
+ export function scoreboard({ home = 'HOME', away = 'AWAY', homeColor = '#22c55e', awayColor = '#3b82f6' } = {}) {
71
+ injectCss()
72
+ const root = el('div', 'sdo-score')
73
+ const team = (name, color, first) => {
74
+ const t = el('div', 'sdo-team')
75
+ const swatch = el('i')
76
+ swatch.style.background = color
77
+ t.append(...(first ? [swatch, el('span', '', name)] : [el('span', '', name), swatch]))
78
+ return t
79
+ }
80
+ const pts = [el('div', 'sdo-pts', '0'), el('div', 'sdo-pts', '0')]
81
+ const clockEl = el('div', 'sdo-clock', '00:00.0')
82
+ root.append(team(home, homeColor, true), pts[0], pts[1], team(away, awayColor, false), clockEl)
83
+ const points = [0, 0]
84
+ let started = 0, offset = 0, timer = 0
85
+ const show = (s) => { clockEl.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:${(s % 60).toFixed(1).padStart(4, '0')}` }
86
+ return {
87
+ el: root,
88
+ score(h, a) { points[0] = h; points[1] = a; pts[0].textContent = h; pts[1].textContent = a },
89
+ add(side, n = 1) { const i = side === 'away' ? 1 : 0; points[i] += n; pts[i].textContent = points[i] },
90
+ clock: {
91
+ start() {
92
+ if (timer) return
93
+ started = performance.now()
94
+ timer = setInterval(() => show(offset + (performance.now() - started) / 1000), 100)
95
+ },
96
+ stop() {
97
+ if (!timer) return
98
+ clearInterval(timer)
99
+ timer = 0
100
+ offset += (performance.now() - started) / 1000
101
+ },
102
+ reset(seconds = 0) { offset = seconds; started = performance.now(); show(seconds) },
103
+ set(text) { clockEl.textContent = text },
104
+ },
105
+ }
106
+ }
107
+
108
+ /**
109
+ * A name and a role that slide in and out.
110
+ * @param {{name?: string, role?: string, visible?: boolean}} [options]
111
+ * @returns {{el: HTMLElement, set(fields: {name?: string, role?: string}): void, show(): void, hide(): void}}
112
+ */
113
+ export function lowerThird({ name = '', role = '', visible = true } = {}) {
114
+ injectCss()
115
+ const root = el('div', 'sdo-lower' + (visible ? '' : ' sdo-hidden'))
116
+ const nameEl = el('div', 'sdo-name', name)
117
+ const roleEl = el('div', 'sdo-role', role)
118
+ root.append(nameEl, roleEl)
119
+ return {
120
+ el: root,
121
+ set({ name: n, role: r } = {}) { if (n != null) nameEl.textContent = n; if (r != null) roleEl.textContent = r },
122
+ show() { root.classList.remove('sdo-hidden') },
123
+ hide() { root.classList.add('sdo-hidden') },
124
+ }
125
+ }
126
+
127
+ /**
128
+ * A scrolling news line.
129
+ * @param {{label?: string, items?: string[], speed?: number}} [options] - speed: seconds per loop.
130
+ * @returns {{el: HTMLElement, set(items: string[]): void}}
131
+ */
132
+ export function ticker({ label = 'NEWS', items = [], speed = 18 } = {}) {
133
+ injectCss()
134
+ const root = el('div', 'sdo-ticker')
135
+ const run = el('div', 'sdo-run')
136
+ root.style.setProperty('--sdo-speed', `${speed}s`)
137
+ root.append(el('div', 'sdo-tag', label), run)
138
+ // Twice over, so the loop's -50% lands exactly where it started.
139
+ const set = (list) => { run.textContent = (list.join(' · ') + ' · ').repeat(2) }
140
+ set(items)
141
+ return { el: root, set }
142
+ }
143
+
144
+ /**
145
+ * A pulsing "LIVE" bug.
146
+ * @param {{text?: string}} [options]
147
+ * @returns {{el: HTMLElement, set(text: string): void}}
148
+ */
149
+ export function liveBadge({ text = 'LIVE' } = {}) {
150
+ injectCss()
151
+ const root = el('div', 'sdo-live', text)
152
+ return { el: root, set(t) { root.textContent = t } }
153
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Record any canvas a stream feeds (the stream's own, or one it is composited into over
3
+ * video) with the native MediaRecorder. The container follows the browser: Safari records
4
+ * MP4, Chromium and Firefox WebM unless they support MP4.
5
+ * @module plugins/stream/recorder
6
+ */
7
+
8
+ const TYPES = ['video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
9
+
10
+ /**
11
+ * Start recording `canvas`.
12
+ * @param {HTMLCanvasElement} canvas
13
+ * @param {{fps?: number, duration?: number, bitrate?: number}} [options] - fps default 30;
14
+ * with `duration` (ms) the recording stops by itself.
15
+ * @returns {{done: Promise<Blob>, stop: () => Promise<Blob>, type: string}}
16
+ */
17
+ export function recordCanvas(canvas, options = {}) {
18
+ if (typeof MediaRecorder === 'undefined' || typeof canvas.captureStream !== 'function') {
19
+ throw new Error('[SnapDOM] stream: recording needs MediaRecorder and canvas.captureStream')
20
+ }
21
+ const type = TYPES.find((t) => MediaRecorder.isTypeSupported(t)) || ''
22
+ const recorder = new MediaRecorder(canvas.captureStream(options.fps ?? 30), {
23
+ mimeType: type, videoBitsPerSecond: options.bitrate,
24
+ })
25
+ const chunks = []
26
+ recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data) }
27
+ const done = new Promise((resolve, reject) => {
28
+ recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || type }))
29
+ recorder.onerror = (e) => reject(e.error || e)
30
+ })
31
+ recorder.start(1000)
32
+ if (options.duration) setTimeout(() => recorder.state !== 'inactive' && recorder.stop(), options.duration)
33
+ return {
34
+ done,
35
+ type: recorder.mimeType || type,
36
+ stop() {
37
+ if (recorder.state !== 'inactive') recorder.stop()
38
+ return done
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,484 @@
1
+ /**
2
+ * `snapdom.stream(el)`: a live element as a canvas that repaints only what changed.
3
+ *
4
+ * A browser compositor does not repaint the page when one button changes; it repaints that
5
+ * layer. This does the same over captures. The first frame is a full capture. After that,
6
+ * every change (mutation records, typing, focus, hover, running animations) is attributed to
7
+ * the nearest ancestor that can be painted standalone over the previous pixels: an opaque
8
+ * box, with no ancestor effect (opacity, filter, transform, blend, mask) between it and the
9
+ * root, inside every clipping ancestor and under no positioned element. A box that moved or
10
+ * was restyled hands the change to the enclosing box that did not; an absolutely positioned
11
+ * box over a solid or clear backdrop may move and resize itself, since it pushes nothing.
12
+ * Only that subtree is captured, laid out at its subpixel phase, and drawn over its backdrop
13
+ * or where it is fully opaque. Anything this cannot prove is a full frame, with the reason
14
+ * on the frame.
15
+ *
16
+ * Measured 2026-10-23, 1000px grid, one card's text changed per frame, median ms per frame
17
+ * (full / patch): 400 cards Chromium 11.7 / 4.2, Firefox 28 / 4, WebKit 20 / 4; 2000 cards
18
+ * 52.9 / 4.9, 119 / 5, 90 / 4. The patch cost tracks the changed box, not the page.
19
+ *
20
+ * Output: `stream.canvas` is updated in place; `onFrame` receives the dirty rects in canvas
21
+ * pixels, so a WebGL consumer can `texSubImage2D` just those and a video consumer can use
22
+ * `canvas.captureStream()`. Pinned by __tests__/plugin.stream.test.js, which compares every
23
+ * patched frame against a full capture of the same DOM state.
24
+ *
25
+ * Built on the public capture API plus one engine contract, the internal `__subpixel` option
26
+ * (src/engines/svg.js), which lays a patch out at the page's subpixel offset.
27
+ * @module plugins/stream
28
+ */
29
+ import { snapdom as capture } from '@zumer/snapdom'
30
+
31
+ // WebKit keeps toCanvas, whose draw workarounds a raw decode would skip. Every iOS browser is
32
+ // WebKit; elsewhere WebKit is AppleWebKit without a Chromium token.
33
+ const WEBKIT = typeof navigator !== 'undefined' && (/ipad|iphone|ipod/i.test(navigator.userAgent) ||
34
+ (/applewebkit/i.test(navigator.userAgent) && !/chrome|chromium|crios|edg|opr|android/i.test(navigator.userAgent)))
35
+
36
+ const EVENTS = ['input', 'change', 'focusin', 'focusout', 'pointerover', 'pointerout', 'scroll', 'toggle']
37
+ /** A patch larger than this share of the frame costs about what a full frame does. */
38
+ const MAX_PATCH_AREA = 0.5
39
+ /** Self-healing keyframe: anything the heuristics missed lasts at most this long, or 50
40
+ * full frames' worth of time, so the safety net costs under 2% of the main thread (a
41
+ * 3000-card lab frame took 235 ms: every 2 s it froze the page 7 times in 8 s). */
42
+ const KEYFRAME_MS = 2000
43
+ const KEYFRAME_COST = 50
44
+
45
+ function opaqueColor(value) {
46
+ if (!value || value === 'transparent') return false
47
+ const m = value.match(/rgba?\(([^)]+)\)/)
48
+ if (!m) return false
49
+ const parts = m[1].split(/[\s,/]+/).filter(Boolean)
50
+ return parts.length < 4 || parseFloat(parts[3]) >= 1
51
+ }
52
+
53
+ /** An ancestor effect the standalone subtree capture would not reproduce. */
54
+ function hasEffect(cs) {
55
+ return parseFloat(cs.opacity) < 1 || cs.filter !== 'none' || cs.transform !== 'none' ||
56
+ cs.mixBlendMode !== 'normal' || (cs.maskImage && cs.maskImage !== 'none') ||
57
+ cs.clipPath !== 'none' || (cs.backdropFilter && cs.backdropFilter !== 'none')
58
+ }
59
+
60
+ function clips(cs) {
61
+ return cs.overflowX !== 'visible' || cs.overflowY !== 'visible'
62
+ }
63
+
64
+ function inside(a, b) {
65
+ return a.left >= b.left - 0.5 && a.top >= b.top - 0.5 && a.right <= b.right + 0.5 && a.bottom <= b.bottom + 0.5
66
+ }
67
+
68
+ /** Whether a clipping ancestor cuts into `r`: past its edges, or into a rounded corner. A
69
+ * clock flush with the end of a rounded scoreboard drew square corners where the page
70
+ * clipped them round. */
71
+ function escapesClip(r, clip, cs) {
72
+ if (!inside(r, clip)) return true
73
+ const radius = Math.max(...['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius']
74
+ .map((p) => parseFloat(cs[p]) || 0))
75
+ if (!radius) return false
76
+ return [[clip.left, clip.top], [clip.right - radius, clip.top], [clip.left, clip.bottom - radius], [clip.right - radius, clip.bottom - radius]]
77
+ .some(([x, y]) => intersects(r, { left: x, top: y, right: x + radius, bottom: y + radius }))
78
+ }
79
+
80
+ function intersects(a, b) {
81
+ return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
82
+ }
83
+
84
+ function sameRect(a, b) {
85
+ return Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5 &&
86
+ Math.abs(a.width - b.width) < 0.5 && Math.abs(a.height - b.height) < 0.5
87
+ }
88
+
89
+ /** Targets whose records have a net effect. The capture pipeline writes to live nodes and
90
+ * undoes the write (text-overflow, line-clamp, content-visibility); those records cancel. */
91
+ function netTargets(records, out, restyled) {
92
+ const attrs = new Map(), chars = new Map()
93
+ for (const rec of records) {
94
+ if (rec.type === 'attributes') {
95
+ const key = rec.target
96
+ let byName = attrs.get(key)
97
+ if (!byName) attrs.set(key, byName = new Map())
98
+ if (!byName.has(rec.attributeName)) byName.set(rec.attributeName, rec.oldValue)
99
+ } else if (rec.type === 'characterData') {
100
+ if (!chars.has(rec.target)) chars.set(rec.target, rec.oldValue)
101
+ } else {
102
+ let added = 0
103
+ for (const n of rec.addedNodes) added += n.nodeType === 3 ? n.data.length : 1e6
104
+ for (const n of rec.removedNodes) added -= n.nodeType === 3 ? n.data.length : 1e6
105
+ if (added || rec.addedNodes.length !== rec.removedNodes.length) out.add(rec.target)
106
+ else {
107
+ const before = [...rec.removedNodes].map(n => n.data).join('')
108
+ const after = [...rec.addedNodes].map(n => n.data).join('')
109
+ if (before !== after) out.add(rec.target)
110
+ }
111
+ }
112
+ }
113
+ for (const [el, byName] of attrs) {
114
+ for (const [name, old] of byName) if (el.getAttribute(name) !== old) { out.add(el); restyled?.add(el); break }
115
+ }
116
+ for (const [node, old] of chars) if (node.data !== old) out.add(node.parentElement)
117
+ }
118
+
119
+ function describe(node) {
120
+ const el = node?.nodeType === 1 ? node : node?.parentElement
121
+ if (!el) return 'a detached node'
122
+ return `<${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.classList?.length ? '.' + [...el.classList].join('.') : ''}>`
123
+ }
124
+
125
+ async function decode(url) {
126
+ const img = new Image()
127
+ img.decoding = 'sync'
128
+ img.src = url
129
+ await img.decode()
130
+ return img
131
+ }
132
+
133
+ /**
134
+ * Stream an element into a canvas, repainting only the boxes that changed.
135
+ * @param {Element} element
136
+ * @param {object} [options] - capture options (scale, dpr, embedFonts, ...), plus:
137
+ * @param {(frame: {full: boolean, reason: string|null, rects: {x:number,y:number,width:number,height:number}[], ms: number, canvas: HTMLCanvasElement}) => void} [options.onFrame]
138
+ * @param {number} [options.fps] - cap on frames per second; default every animation frame.
139
+ * @param {boolean} [options.patches=true] - false repaints full frames only (the baseline).
140
+ * @returns {{canvas: HTMLCanvasElement, stop: () => void, ready: Promise<void>, stats: object}}
141
+ */
142
+ export function stream(element, options = {}) {
143
+ const { onFrame, fps, patches = true, ...rest } = options
144
+ const opts = { ...rest, outerShadows: false, dpr: rest.dpr ?? (globalThis.devicePixelRatio || 1) }
145
+ const k = (opts.scale ?? 1) * opts.dpr
146
+ const canvas = document.createElement('canvas')
147
+ const ctx = canvas.getContext('2d')
148
+ const dirty = new Set()
149
+ const restyled = new Set() // elements whose own attributes changed: their box may have too
150
+ const rects = new WeakMap() // element -> rect at the last paint that covered it
151
+ const backdrops = new Map() // this frame's hosts that draw over a solid colour
152
+ const movedFrom = new Map() // this frame's out-of-flow hosts that moved -> their old rect
153
+ const stats = { full: 0, patch: 0, skipped: 0, lastMs: 0 }
154
+ let floaters = []
155
+ let forceFull = true
156
+ let lastKeyframe = 0
157
+ let keyframeDue = false // set from idle time once the keyframe interval has passed
158
+ let fullMs = 0
159
+ let idleAsked = false
160
+ let stopped = false
161
+ let busy = false
162
+ let lastTick = 0
163
+ let rootRect = null
164
+ let animating = new Set() // targets of the animations running at the last tick
165
+ let ox = 0, oy = 0 // canvas pixels from the canvas origin to the root's border box
166
+
167
+ const observer = new MutationObserver(records => netTargets(records, dirty, restyled))
168
+ observer.observe(element, { subtree: true, childList: true, attributes: true, characterData: true, attributeOldValue: true, characterDataOldValue: true })
169
+ const onEvent = (e) => {
170
+ dirty.add(e.target)
171
+ if (e.relatedTarget && element.contains(e.relatedTarget)) dirty.add(e.relatedTarget)
172
+ }
173
+ for (const type of EVENTS) element.addEventListener(type, onEvent, true)
174
+ const onResize = () => { forceFull = true }
175
+ addEventListener('resize', onResize)
176
+
177
+ function rectOf(el) {
178
+ const r = el.getBoundingClientRect()
179
+ return { left: r.left - rootRect.left, top: r.top - rootRect.top, right: r.right - rootRect.left, bottom: r.bottom - rootRect.top, width: r.width, height: r.height }
180
+ }
181
+
182
+ /** `r` grown to whole canvas pixels, in the same root-relative CSS px. */
183
+ function pixelRect(r) {
184
+ return { left: (Math.floor(ox + r.left * k) - ox) / k, top: (Math.floor(oy + r.top * k) - oy) / k, right: (Math.ceil(ox + r.right * k) - ox) / k, bottom: (Math.ceil(oy + r.bottom * k) - oy) / k }
185
+ }
186
+
187
+ /** The nearest absolutely or fixed positioned ancestor: it leaves the flow, so its own
188
+ * changes of size or place move nothing else. Drawn only over a backdrop. */
189
+ function positionedHost(node) {
190
+ for (let el = node?.nodeType === 1 ? node : node?.parentElement; el && el !== element; el = el.parentElement) {
191
+ const cs = getComputedStyle(el)
192
+ if (cs.position !== 'absolute' && cs.position !== 'fixed') continue
193
+ for (let a = el; a !== element; a = a.parentElement) {
194
+ const as = getComputedStyle(a)
195
+ if (hasEffect(as) || (a !== el && clips(as))) return null
196
+ }
197
+ return el
198
+ }
199
+ return null
200
+ }
201
+
202
+ /** Remember every box's rect and the positioned elements that can paint over others. */
203
+ function snapshotLayout() {
204
+ rootRect = element.getBoundingClientRect()
205
+ floaters = []
206
+ for (const el of element.querySelectorAll('*')) {
207
+ rects.set(el, rectOf(el))
208
+ const cs = getComputedStyle(el)
209
+ if (cs.position === 'absolute' || cs.position === 'fixed' || cs.position === 'sticky' ||
210
+ (cs.position === 'relative' && cs.zIndex !== 'auto') || cs.transform !== 'none') floaters.push(el)
211
+ }
212
+ }
213
+
214
+ /** The box a change to `node` can be repainted through, or null for a full frame. */
215
+ function hostFor(node) {
216
+ let el = node?.nodeType === 1 ? node : node?.parentElement
217
+ if (!el || el === element || !element.contains(el)) return null
218
+ const chain = []
219
+ for (let a = el; a && a !== element; a = a.parentElement) chain.push(a)
220
+ // Search above the highest ancestor with an effect or a clip the box would escape.
221
+ let start = 0
222
+ for (let i = 0; i < chain.length; i++) {
223
+ const cs = getComputedStyle(chain[i])
224
+ if (hasEffect(cs)) start = i + 1
225
+ else if (i > 0 && clips(cs) && escapesClip(rectOf(chain[0]), rectOf(chain[i]), cs)) start = i
226
+ }
227
+ for (let i = start; i < chain.length; i++) {
228
+ const cs = getComputedStyle(chain[i])
229
+ if (!opaqueColor(cs.backgroundColor)) continue
230
+ // Visible overflow paints outside the box, over pixels the patch does not repaint.
231
+ if (!clips(cs) && (chain[i].scrollWidth > chain[i].clientWidth + 1 || chain[i].scrollHeight > chain[i].clientHeight + 1)) continue
232
+ return chain[i]
233
+ }
234
+ return null
235
+ }
236
+
237
+ async function full() {
238
+ // A full frame is the one long task of a stream; 'auto' lets a long clone yield (#503).
239
+ const t0 = performance.now()
240
+ // A full frame is the one long task of a stream; 'auto' lets a long clone yield (#503).
241
+ // Lab, 1000 cards under constant edits: worst frame gap 67 -> 33 ms. Under edits on
242
+ // every frame a big tree tears and redoes in one task, which only the interval helps.
243
+ const res = await capture(element, { fast: 'auto', ...opts })
244
+ const { vbW, vbH, contentX = 0, contentY = 0 } = res.meta
245
+ const src = WEBKIT ? await res.toCanvas() : await decode(res.url)
246
+ const w = Math.round(vbW * k), h = Math.round(vbH * k)
247
+ if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h }
248
+ ctx.clearRect(0, 0, w, h)
249
+ ctx.drawImage(src, 0, 0, w, h)
250
+ ox = contentX * k
251
+ oy = contentY * k
252
+ snapshotLayout()
253
+ lastKeyframe = performance.now()
254
+ keyframeDue = false
255
+ fullMs = lastKeyframe - t0
256
+ stats.full++
257
+ return [{ x: 0, y: 0, width: w, height: h }]
258
+ }
259
+
260
+ /** The solid colour behind `host` ('transparent' over a clear root) when the patch can be
261
+ * drawn over it and come out exact, edges and corners included; null otherwise. Needs no
262
+ * shadow or outline of its own (they paint outside the box, where the fill would erase
263
+ * them), only opaque or clear ancestor backgrounds up to the first opaque one, and no
264
+ * other box on the whole-pixel rect around the host, or around `old`, where a moved host
265
+ * was. */
266
+ function backdropOf(host, old) {
267
+ const cs = getComputedStyle(host)
268
+ if (cs.boxShadow !== 'none' || (cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0)) return null
269
+ let color = null
270
+ for (let a = host.parentElement; a && !color; a = a === element ? null : a.parentElement) {
271
+ const s = getComputedStyle(a)
272
+ if (s.backgroundImage !== 'none') return null
273
+ if (opaqueColor(s.backgroundColor)) color = s.backgroundColor
274
+ else if (s.backgroundColor !== 'rgba(0, 0, 0, 0)' && s.backgroundColor !== 'transparent') return null
275
+ }
276
+ const areas = [pixelRect(rectOf(host)), ...(old ? [pixelRect(old)] : [])]
277
+ const others = [...host.parentElement.children, ...floaters]
278
+ for (const other of others) {
279
+ if (other === host || host.contains(other) || other.contains(host)) continue
280
+ const or = rects.get(other)
281
+ if (or && areas.some((a) => intersects(or, a))) return null
282
+ }
283
+ return color || 'transparent'
284
+ }
285
+
286
+ function paintBackdrop(backdrop, x, y, w, h) {
287
+ ctx.clearRect(x, y, w, h)
288
+ if (backdrop !== 'transparent') {
289
+ ctx.fillStyle = backdrop
290
+ ctx.fillRect(x, y, w, h)
291
+ }
292
+ }
293
+
294
+ /** Capture one host at its subpixel phase and return its pixels, placed. */
295
+ async function renderPatch(host) {
296
+ const r = rectOf(host)
297
+ // Lay the patch out at the page's subpixel phase (svg engine, options.__subpixel), so
298
+ // its glyphs and snapped box edges land on the same pixels as in the full frame:
299
+ // white cards on black at 1fr offsets differed on 596 pixels at phase zero.
300
+ const left = ox + r.left * k, top = oy + r.top * k
301
+ const x0 = Math.floor(left + 0.001), y0 = Math.floor(top + 0.001)
302
+ const phase = { x: (left - x0) / k, y: (top - y0) / k }
303
+ // Always passed, even at phase zero: it also lays the root out at its exact width.
304
+ const res = await capture(host, { ...opts, __subpixel: phase })
305
+ const { vbW, vbH, contentX = 0, contentY = 0 } = res.meta
306
+ const x = Math.round(x0 - contentX * k), y = Math.round(y0 - contentY * k)
307
+ const w = Math.round(vbW * k), h = Math.round(vbH * k)
308
+ const src = WEBKIT ? await res.toCanvas() : await decode(res.url)
309
+ const layer = document.createElement('canvas')
310
+ layer.width = w
311
+ layer.height = h
312
+ const backdrop = backdrops.get(host)
313
+ if (backdrop) {
314
+ // Over its solid backdrop the patch is the full frame's pixels, edges included.
315
+ const lctx = layer.getContext('2d')
316
+ if (backdrop !== 'transparent') {
317
+ lctx.fillStyle = backdrop
318
+ lctx.fillRect(0, 0, w, h)
319
+ }
320
+ lctx.drawImage(src, 0, 0, w, h)
321
+ return { layer, x, y, w, h, backdrop, host, drawnAt: r }
322
+ }
323
+ // Otherwise keep only the pixels the box covers fully. Inside an opaque box that is
324
+ // everything; what is left are its antialiased edges and rounded corners, which did not
325
+ // change (restyled hosts without a backdrop climb in plan) and which drawing again would
326
+ // blend over themselves. Same scene: 36 pixels off without this, 0 with it.
327
+ const lctx = layer.getContext('2d', { willReadFrequently: true })
328
+ lctx.drawImage(src, 0, 0, w, h)
329
+ const img = lctx.getImageData(0, 0, w, h)
330
+ const px = img.data
331
+ for (let i = 3; i < px.length; i += 4) if (px[i] < 255) px[i] = 0
332
+ lctx.putImageData(img, 0, 0)
333
+ return { layer, x, y, w, h, host, drawnAt: r }
334
+ }
335
+
336
+ async function patch(hosts) {
337
+ // Captures and decodes overlap; the draws then land in one synchronous pass, so the
338
+ // canvas never shows half a frame. Three hosts a frame (clock, KPI, list row) in
339
+ // demos/stream-webgl.html: 11.9 ms one after another.
340
+ const layers = await Promise.all(hosts.map(renderPatch))
341
+ const out = []
342
+ for (const [host, old] of movedFrom) {
343
+ const r = pixelRect(old)
344
+ const x = Math.round(ox + r.left * k), y = Math.round(oy + r.top * k)
345
+ const w = Math.round(ox + r.right * k) - x, h = Math.round(oy + r.bottom * k) - y
346
+ paintBackdrop(backdrops.get(host), x, y, w, h)
347
+ out.push({ x, y, width: w, height: h })
348
+ }
349
+ for (const { layer, x, y, w, h, backdrop } of layers) {
350
+ // A backdrop layer is the full frame's pixels for its whole rect: replace, not blend.
351
+ if (backdrop) ctx.clearRect(x, y, w, h)
352
+ ctx.drawImage(layer, x, y)
353
+ out.push({ x, y, width: w, height: h })
354
+ }
355
+ movedFrom.clear()
356
+ // Remember where each host was drawn, not where it is now: a sliding box kept moving
357
+ // while it was captured, and clearing its current rect next frame left a trail behind.
358
+ for (const { host, drawnAt } of layers) {
359
+ rects.set(host, drawnAt)
360
+ for (const el of host.querySelectorAll('*')) rects.set(el, rectOf(el))
361
+ }
362
+ stats.patch++
363
+ return out
364
+ }
365
+
366
+ /** Hosts for this frame, or the reason only a full frame is safe. */
367
+ function plan() {
368
+ if (!patches) return 'patches off'
369
+ if (forceFull) return 'resize'
370
+ if (keyframeDue) return 'keyframe'
371
+ const now = element.getBoundingClientRect()
372
+ if (!sameRect({ left: 0, top: 0, width: now.width, height: now.height }, { left: 0, top: 0, width: rootRect.width, height: rootRect.height })) return 'root resized'
373
+ rootRect = now
374
+ const hosts = new Set()
375
+ for (const node of dirty) {
376
+ if (!node?.isConnected) return 'node removed'
377
+ let host = hostFor(node)
378
+ // A box that moved or resized may have moved its siblings, and a restyled box may have
379
+ // changed its own edges, which only a solid backdrop lets the patch redraw. If an
380
+ // enclosing host kept its rect, the change stayed inside it: repaint that box instead.
381
+ while (host && (!sameRect(rects.get(host) || {}, rectOf(host)) || (restyled.has(host) && !backdropOf(host)))) host = hostFor(host.parentElement)
382
+ if (!host) {
383
+ // Out of the flow, a box can move or resize freely: clear where it was, draw it
384
+ // where it is. A lower third whose name got longer.
385
+ const p = positionedHost(node)
386
+ const old = p && rects.get(p)
387
+ if (old && backdropOf(p, old)) {
388
+ host = p
389
+ // Always clear where it was last drawn: a slide's last step can be under the
390
+ // half-pixel sameRect allows, and the column it left kept the old edge.
391
+ movedFrom.set(p, old)
392
+ }
393
+ }
394
+ if (!host) return `no host for ${describe(node)}`
395
+ hosts.add(host)
396
+ }
397
+ // Outermost hosts only: a box inside another dirty box is repainted by it.
398
+ for (const h of [...hosts]) {
399
+ for (let a = h.parentElement; a && a !== element; a = a.parentElement) if (hosts.has(a)) { hosts.delete(h); break }
400
+ }
401
+ let area = 0
402
+ backdrops.clear()
403
+ for (const h of movedFrom.keys()) if (!hosts.has(h)) movedFrom.delete(h)
404
+ for (const h of hosts) {
405
+ const backdrop = backdropOf(h)
406
+ if (backdrop) backdrops.set(h, backdrop)
407
+ const after = rectOf(h)
408
+ area += after.width * after.height
409
+ for (const f of floaters) {
410
+ if (f === h || h.contains(f) || f.contains(h)) continue
411
+ if (intersects(rectOf(f), after)) return `${describe(f)} overlaps ${describe(h)}`
412
+ }
413
+ }
414
+ if (area > MAX_PATCH_AREA * rootRect.width * rootRect.height) return 'patch area too large'
415
+ return [...hosts]
416
+ }
417
+
418
+ async function tick() {
419
+ if (stopped) return
420
+ requestAnimationFrame(tick)
421
+ const now = performance.now()
422
+ if (busy || (fps && now - lastTick < 1000 / fps)) return
423
+ // A running animation repaints its target every frame, and once more when it stops: the
424
+ // frame before a pause or the end of a transition is not where it came to rest.
425
+ const running = new Set()
426
+ for (const a of element.getAnimations?.({ subtree: true }) || []) {
427
+ if (a.playState === 'running') running.add(a.effect?.target || element)
428
+ }
429
+ for (const target of running) dirty.add(target)
430
+ for (const target of animating) if (!running.has(target)) dirty.add(target)
431
+ animating = running
432
+ netTargets(observer.takeRecords(), dirty, restyled)
433
+ // The keyframe waits for idle time, so it never lands on top of a burst of edits.
434
+ if (!keyframeDue && !idleAsked && now - lastKeyframe > Math.max(KEYFRAME_MS, fullMs * KEYFRAME_COST)) {
435
+ idleAsked = true
436
+ const due = () => { keyframeDue = true; idleAsked = false }
437
+ if (typeof requestIdleCallback === 'function') requestIdleCallback(due, { timeout: KEYFRAME_MS })
438
+ else due()
439
+ }
440
+ if (!dirty.size && !forceFull && !keyframeDue) { stats.skipped++; return }
441
+ busy = true
442
+ lastTick = now
443
+ try {
444
+ const hosts = plan()
445
+ dirty.clear()
446
+ restyled.clear()
447
+ const reason = typeof hosts === 'string' ? hosts : null
448
+ forceFull = false
449
+ const out = reason ? await full() : hosts.length ? await patch(hosts) : []
450
+ // Records produced by our own capture are net-zero; anything else lands next frame.
451
+ netTargets(observer.takeRecords(), dirty, restyled)
452
+ stats.lastMs = performance.now() - now
453
+ if (out.length) onFrame?.({ full: !!reason, reason, rects: out, ms: stats.lastMs, canvas })
454
+ } finally {
455
+ busy = false
456
+ }
457
+ }
458
+
459
+ const ready = (async () => {
460
+ busy = true
461
+ try {
462
+ forceFull = false
463
+ // Edits from before the capture are in it. Edits landing while it runs may come after
464
+ // it read the DOM: they stay dirty for the first tick. Dropping them here lost them
465
+ // until a keyframe (a 1000-card lab under edits kept stale cards for seconds).
466
+ await full()
467
+ netTargets(observer.takeRecords(), dirty, restyled)
468
+ onFrame?.({ full: true, reason: 'start', rects: [{ x: 0, y: 0, width: canvas.width, height: canvas.height }], ms: 0, canvas })
469
+ } finally { busy = false }
470
+ requestAnimationFrame(tick)
471
+ })()
472
+
473
+ return {
474
+ canvas,
475
+ ready,
476
+ stats,
477
+ stop() {
478
+ stopped = true
479
+ observer.disconnect()
480
+ for (const type of EVENTS) element.removeEventListener(type, onEvent, true)
481
+ removeEventListener('resize', onResize)
482
+ },
483
+ }
484
+ }