@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.
- package/CHANGELOG.md +16 -0
- package/README.md +39 -23
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +70 -27
- package/capture-frames.js +26 -14
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +8 -4
- package/pdf/README.md +2 -2
- package/pdf/src/mega/README.md +1 -1
- package/stream/LICENSE +21 -0
- package/stream/README.md +138 -0
- package/stream/src/index.js +41 -0
- package/stream/src/overlays.js +153 -0
- package/stream/src/recorder.js +41 -0
- package/stream/src/stream.js +484 -0
- package/stream/src/webgl.js +58 -0
- package/stream.d.ts +75 -0
- package/stream.js +1 -0
- package/vector/CONTRACT.md +7 -10
- package/vector/README.md +1 -1
|
@@ -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
|
+
}
|