@zumer/snapdom-plugins 4.0.2 → 4.1.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Plugin changes
2
2
 
3
+ ## 4.1.0
4
+
5
+ - Add the stream plugin at @zumer/snapdom-plugins/stream: a live canvas that mirrors an element and repaints only the boxes that changed, for WebGL textures, video overlays and WebRTC tracks.
6
+ - Add streamTexture(gl), which uploads only the changed rectangles to a WebGL2 texture, and recordCanvas(canvas) to record a stream.
7
+ - Add streamExport(), which gives a capture result a toStream() method next to toMp4() and toGif().
8
+ - Add broadcast overlay templates (overlayStage, scoreboard, lowerThird, ticker and liveBadge), themed through CSS variables and built so that a score, a clock tick or a slide repaints as a patch.
9
+
10
+ Requires SnapDOM 3.4.0 or newer within 3.x.
11
+
3
12
  ## 4.0.1
4
13
 
5
14
  - Add GitHub Sponsors funding metadata.
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # @zumer/snapdom-plugins
2
2
 
3
- Official plugins for [SnapDOM](https://github.com/zumerlab/snapdom), a capture engine for
4
- web apps. Core exports images and canvases; this package adds HTML, structured context,
5
- element maps, PDF, editable vectors, ASCII, visual transforms and live GIF/video recording.
3
+ Official plugins for [SnapDOM](https://github.com/zumerlab/snapdom), a high-performance engine
4
+ for capturing, modifying, and converting DOM elements into any format. Core exports images and
5
+ canvases; this package adds HTML, structured context, element maps, PDF, editable vectors, ASCII,
6
+ visual transforms, live GIF/video recording and live DOM streaming.
6
7
 
7
8
  Image, HTML and structured exports use captured state; GIF and video start a sequence of
8
9
  live captures when their export method is called.
@@ -15,21 +16,7 @@ Install the plugins next to a core supported by their peer dependency:
15
16
  npm install @zumer/snapdom@latest @zumer/snapdom-plugins@latest
16
17
  ```
17
18
 
18
- Plugins 4.x in this checkout require SnapDOM 3.3.0 or newer within 3.x. The 4.x release replaces pdf-image with the full PDF exporter and adds Vector. Both are MIT-licensed and available on npm in `@zumer/snapdom-plugins` 4.x. The `@latest` command above installs the published release. See the [migration guide](https://github.com/zumerlab/snapdom#migrating-from-v2) when upgrading from v2.
19
-
20
- ## Releasing from the repository root
21
-
22
- ```sh
23
- npm run bump:plugins:dry
24
- npm run bump:plugins
25
- npm run release:plugins
26
- npm run publish:plugins -- --dry-run
27
- npm run publish:plugins
28
- ```
29
-
30
- `bump:plugins` runs `@zumerbox/bump` followed by `@zumerbox/changelog` inside the plugin workspace, matching the core's tools. The bump command requires a clean Git working tree and prompts for the next version. It updates the plugin version and root lockfile; the changelog command writes `packages/plugins/CHANGELOG.md`. `bump:plugins:dry` previews the bump without updating the version or changelog. Skip the bump if the version has already been prepared.
31
-
32
- `release:plugins` runs the full cross-browser suite with required visual baselines, the PDF and Vector checks, and consumer packaging checks, then creates the plugin tarball. It does not publish. `publish:plugins` publishes only `@zumer/snapdom-plugins` under the `latest` tag.
19
+ Plugins 4.x in this checkout require SnapDOM 3.3.8 or newer within 3.x. The 4.x release replaces pdf-image with the full PDF exporter and adds Vector. Both are MIT-licensed and available on npm in `@zumer/snapdom-plugins` 4.x. The `@latest` command above installs the published release. See the [migration guide](https://github.com/zumerlab/snapdom#migrating-from-v2) when upgrading from v2.
33
20
 
34
21
  ## Usage
35
22
 
@@ -243,7 +230,7 @@ console.log(art);
243
230
  ### `pdf`
244
231
 
245
232
  Adds `toPdf()`, returning a PDF Blob with the capture's appearance, selectable text,
246
- links and pagination. This is the MIT-licensed full exporter formerly distributed as PDF Pro.
233
+ links and pagination. MIT licensed.
247
234
  It replaces `pdf-image`; there is no second image-only PDF implementation.
248
235
 
249
236
  ```js
@@ -503,6 +490,23 @@ environments. Per-call `opts` override constructor options and accept `download`
503
490
  (`boolean \| string`). Recording starts at the call and preserves capture options as GIF
504
491
  recording does. MediaRecorder uses a real-time clock, so slow captures can stretch duration.
505
492
 
493
+ ### `stream`
494
+
495
+ Streams a live element into a canvas that repaints only the boxes that changed, for WebGL
496
+ textures and video overlays. `stream(el, options)` is the entry; `streamExport()` adds the same
497
+ as `toStream()`. Includes `streamTexture(gl)`, which uploads only the changed rects, and
498
+ `recordCanvas(canvas)`.
499
+
500
+ ```js
501
+ import { stream, streamTexture } from '@zumer/snapdom-plugins/stream';
502
+
503
+ const tex = streamTexture(gl);
504
+ const live = stream(panel, { scale: 2, onFrame: (frame) => tex.update(frame) });
505
+ ```
506
+
507
+ Each patched frame matches a full capture of the same DOM state. Full documentation, options
508
+ and limits: [stream/README.md](stream/README.md).
509
+
506
510
  ---
507
511
 
508
512
  ## Plugin registration
@@ -541,10 +545,22 @@ See [PLUGIN_SPEC.md](../../PLUGIN_SPEC.md) for the full hook specification and [
541
545
 
542
546
  ---
543
547
 
544
- ## License
548
+ ## Live demos
545
549
 
546
- MIT
550
+ Run `npm run compile` and `npm run site` from the monorepo root, then open `/showcase/`, `/plugins/pdf/` or `/plugins/vector/`. The demos generate actual files from editable DOM samples.
547
551
 
548
- ## Live demos
552
+ ## Releasing from the repository root
549
553
 
550
- Run `npm run compile` and `npm run site` from the monorepo root, then open `/playground/`, `/pro/pdf/` or `/pro/vector/`. The demos generate actual files from editable DOM samples; the `/pro/` paths are retained for existing links and do not denote paid plugins.
554
+ ```sh
555
+ npm run bump:plugins:dry
556
+ npm run bump:plugins
557
+ # review packages/plugins/CHANGELOG.md
558
+ npm run release:plugins
559
+ npm run publish:plugins
560
+ ```
561
+
562
+ `release:plugins` commits the plugin version, the lockfile and the changelog, then pushes. `publish:plugins` runs the PDF and vector checks, then publishes only `@zumer/snapdom-plugins` under `latest`. `bump:plugins` runs `@zumerbox/bump` followed by `@zumerbox/changelog` inside the plugin workspace, matching the core's tools. The bump command requires a clean Git working tree and prompts for the next version. It updates the plugin version and root lockfile; the changelog command writes `packages/plugins/CHANGELOG.md`. `bump:plugins:dry` previews the bump without updating the version or changelog. Skip the bump if the version has already been prepared.
563
+
564
+ ## License
565
+
566
+ MIT
package/capture-frames.js CHANGED
@@ -35,6 +35,31 @@ export function exportOption(ctx, opts, key, fallback) {
35
35
  ? (opts[key] ?? fallback) : fallback;
36
36
  }
37
37
 
38
+ /**
39
+ * Rebuild the options a live recapture needs from an export context: capture inputs, not
40
+ * released stage artifacts or export-session internals. createContext splits exclusion
41
+ * predicates out of `exclude`; they are put back. Pinned by
42
+ * __tests__/plugin.recording.contract.test.js and __tests__/plugin.stream.test.js.
43
+ * @param {object} ctx - Export context
44
+ * @param {object} opts - Normalized per-export options
45
+ * @returns {object} options for snapdom(ctx.element, ...)
46
+ */
47
+ export function captureInputs(ctx, opts) {
48
+ const capture = {};
49
+ for (const key of ['debug', 'width', 'height', 'dpr', 'backgroundColor', 'quality', 'format',
50
+ 'exclude', 'excludeMode', 'filter', 'filterMode', 'placeholders', 'captureSelection', 'canvas', 'embedFonts',
51
+ 'iconFonts', 'localFonts', 'excludeFonts', 'fontStylesheetDomains', 'fallbackURL', 'cache',
52
+ 'useProxy', 'outerTransforms', 'outerShadows', 'reconcile', 'burst', 'engine', 'invalidate',
53
+ 'clip', 'compress', 'excludeStyleProps', 'resolvePicturePlaceholders', 'plugins']) {
54
+ if (opts[key] !== undefined) capture[key] = opts[key];
55
+ else if (ctx[key] !== undefined) capture[key] = ctx[key];
56
+ }
57
+ if (capture.exclude === ctx.exclude && ctx.excludePredicates?.length) {
58
+ capture.exclude = [...(ctx.exclude || []), ...ctx.excludePredicates];
59
+ }
60
+ return capture;
61
+ }
62
+
38
63
  /**
39
64
  * Build a live frame reader that preserves capture options and validates finite pacing.
40
65
  * Recombine v3's separated exclusion predicates before recapturing. Pinned by
@@ -57,20 +82,7 @@ export function frameOptions(ctx, opts, defaults, name) {
57
82
  const count = frames ?? Math.max(1, Math.round(duration / 1000 * fps));
58
83
  if (!Number.isFinite(count)) throw new RangeError(`[SnapDOM] ${name}: frame count must be finite`);
59
84
 
60
- // Copy capture inputs, not released stage artifacts or export-session internals. In
61
- // particular createContext splits exclusion predicates out of exclude: put them back.
62
- const capture = {};
63
- for (const key of ['debug', 'width', 'height', 'dpr', 'backgroundColor', 'quality', 'format',
64
- 'exclude', 'excludeMode', 'filter', 'filterMode', 'placeholders', 'captureSelection', 'canvas', 'embedFonts',
65
- 'iconFonts', 'localFonts', 'excludeFonts', 'fontStylesheetDomains', 'fallbackURL', 'cache',
66
- 'useProxy', 'outerTransforms', 'outerShadows', 'reconcile', 'burst', 'engine', 'invalidate',
67
- 'clip', 'compress', 'excludeStyleProps', 'resolvePicturePlaceholders', 'plugins']) {
68
- if (opts[key] !== undefined) capture[key] = opts[key];
69
- else if (ctx[key] !== undefined) capture[key] = ctx[key];
70
- }
71
- if (capture.exclude === ctx.exclude && ctx.excludePredicates?.length) {
72
- capture.exclude = [...(ctx.exclude || []), ...ctx.excludePredicates];
73
- }
85
+ const capture = captureInputs(ctx, opts);
74
86
  capture.scale = scale;
75
87
  return {
76
88
  fps, count,
package/index.d.ts CHANGED
@@ -18,3 +18,4 @@ export * from './video-export.js';
18
18
  export * from './agent-map.js';
19
19
  export * from './context-export.js';
20
20
  export * from './vector.js';
21
+ export * from './stream.js';
package/index.js CHANGED
@@ -19,3 +19,4 @@ export { htmlExport } from './html-export.js';
19
19
  export { gifExport } from './gif-export.js';
20
20
  export { videoExport } from './video-export.js';
21
21
  export { vector } from './vector.js';
22
+ export { stream, streamExport, streamTexture, recordCanvas, overlayStage, scoreboard, lowerThird, ticker, liveBadge } from './stream.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zumer/snapdom-plugins",
3
- "version": "4.0.2",
3
+ "version": "4.1.0",
4
4
  "description": "Official plugins for SnapDOM",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -21,7 +21,8 @@
21
21
  "./gif-export": "./gif-export.js",
22
22
  "./video-export": "./video-export.js",
23
23
  "./pdf": "./pdf.js",
24
- "./vector": "./vector.js"
24
+ "./vector": "./vector.js",
25
+ "./stream": "./stream.js"
25
26
  },
26
27
  "files": [
27
28
  "*.js",
@@ -37,7 +38,10 @@
37
38
  "vector/svd/",
38
39
  "vector/CONTRACT.md",
39
40
  "vector/LICENSE",
40
- "vector/README.md"
41
+ "vector/README.md",
42
+ "stream/src/",
43
+ "stream/README.md",
44
+ "stream/LICENSE"
41
45
  ],
42
46
  "types": "./index.d.ts",
43
47
  "keywords": [
@@ -62,7 +66,7 @@
62
66
  "url": "https://github.com/sponsors/tinchox5"
63
67
  },
64
68
  "peerDependencies": {
65
- "@zumer/snapdom": "^3.3.0"
69
+ "@zumer/snapdom": "^3.3.8"
66
70
  },
67
71
  "devDependencies": {
68
72
  "@zumer/snapdom": "file:../.."
package/pdf/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # PDF plugin
2
2
 
3
- MIT-licensed PDF export for SnapDOM, replacing the former PDF Pro and `pdf-image` implementations. This source is part of `@zumer/snapdom-plugins` 4.x and requires core 3.x. The 4.x release is available on npm.
3
+ MIT-licensed PDF export for SnapDOM, replacing the former `pdf-image` implementation. This source is part of `@zumer/snapdom-plugins` 4.x and requires core 3.x. The 4.x release is available on npm.
4
4
 
5
5
  ```js
6
6
  import { snapdom } from '@zumer/snapdom';
@@ -17,6 +17,6 @@ Put capture-time measurement options on `pdf()` and paper/output options on `toP
17
17
 
18
18
  ## Live demo and verification
19
19
 
20
- From the monorepo root, run `npm run compile` and `npm run site`, then open `/pro/pdf/` or `/playground/`. These demos generate real PDFs from editable DOM samples. The retained `/pro/` URL does not denote a paid product.
20
+ From the monorepo root, run `npm run compile` and `npm run site`, then open `/plugins/pdf/` or `/showcase/`. These demos generate real PDFs from editable DOM samples. The retained `/pro/` URL does not denote a paid product.
21
21
 
22
22
  Run `npm run test:pdf` for the PDF engine suites. See [verification notes](test/README.md).
@@ -1,4 +1,4 @@
1
- # pdfmega — the "pro pro" layer
1
+ # pdfmega: the document-generator layer
2
2
 
3
3
  The modules that took `@zumer/snapdom-pdf` from *exact capture in a PDF* to
4
4
  *document generator*: identity, diagnostics, flow control, folios, fillable
package/stream/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ZumerLab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,138 @@
1
+ # stream
2
+
3
+ A live element as a canvas that repaints only what changed. For WebGL textures, video overlays
4
+ and anything else that needs the DOM as pixels, many times a second.
5
+
6
+ ```js
7
+ import { stream } from '@zumer/snapdom-plugins/stream';
8
+
9
+ const live = stream(panel, {
10
+ scale: 2,
11
+ onFrame(frame) {
12
+ // frame.canvas is updated in place; frame.rects lists what changed, in canvas pixels.
13
+ },
14
+ });
15
+ await live.ready;
16
+ // ...
17
+ live.stop();
18
+ ```
19
+
20
+ ## How it works
21
+
22
+ The first frame is a full capture. After that, every change (mutations, typing, focus, hover,
23
+ running animations) is traced to the nearest box that can be repainted on its own, and only
24
+ that subtree is captured and drawn into its rect. A frame that cannot be patched exactly is a
25
+ full frame, and `frame.reason` says why (`'root resized'`, `'no host for <div.x>'`, ...).
26
+ A keyframe in idle time heals anything the change tracking could not see, at most every 2 s
27
+ and never more than 2% of the main thread.
28
+
29
+ Every patched frame is meant to match a full capture of the same DOM state pixel for pixel.
30
+ The test suite checks exactly that on Chromium, Firefox and WebKit.
31
+
32
+ Measured on a 1000px grid with one card changing per frame, median per frame:
33
+
34
+ | cards | Chromium full / patch | Firefox | WebKit |
35
+ |---|---|---|---|
36
+ | 400 | 11.7 / 4.2 ms | 28 / 4 ms | 20 / 4 ms |
37
+ | 2000 | 52.9 / 4.9 ms | 119 / 5 ms | 90 / 4 ms |
38
+
39
+ ## Options
40
+
41
+ All [capture options](https://snapdom.dev/) apply (`scale`, `dpr`, `embedFonts`, `exclude`,
42
+ ...), plus:
43
+
44
+ | option | default | |
45
+ |---|---|---|
46
+ | `onFrame(frame)` | | called after each repaint: `{ full, reason, rects, ms, canvas }` |
47
+ | `fps` | every animation frame | upper bound on repaints per second |
48
+ | `patches` | `true` | `false` repaints full frames only, for comparison |
49
+
50
+ `stream()` returns `{ canvas, ready, stats, stop() }`.
51
+
52
+ ## WebGL
53
+
54
+ `streamTexture(gl)` keeps a texture in step with the stream. On WebGL2 it uploads only the
55
+ changed rects (4% of the texture per frame in the WebGL demo, against 100%).
56
+
57
+ ```js
58
+ import { stream, streamTexture } from '@zumer/snapdom-plugins/stream';
59
+
60
+ const tex = streamTexture(gl);
61
+ stream(panel, { onFrame: (frame) => tex.update(frame) });
62
+ // draw with tex.texture
63
+ ```
64
+
65
+ ## Video
66
+
67
+ Composite `live.canvas` over anything with `drawImage`, and record the result:
68
+
69
+ ```js
70
+ import { recordCanvas } from '@zumer/snapdom-plugins/stream';
71
+
72
+ const rec = recordCanvas(output, { fps: 30, duration: 10000 });
73
+ const blob = await rec.done; // MP4 or WebM, whichever the browser records
74
+ ```
75
+
76
+ ## WebRTC
77
+
78
+ The composited canvas is a video track like a camera's, so an HTML overlay travels inside the
79
+ video to every viewer, with no desktop software and no server-side render:
80
+
81
+ ```js
82
+ const track = output.captureStream(30).getVideoTracks()[0];
83
+ track.contentHint = 'detail'; // graphics are text: keep the resolution
84
+ peerConnection.addTrack(track, new MediaStream([track]));
85
+ ```
86
+
87
+ `demos/stream-webrtc.html` sends one tab's program to viewer tabs this way.
88
+
89
+ ## Overlay templates
90
+
91
+ A stage and four broadcast graphics, as plain DOM with a small controller each. Every box that
92
+ changes is opaque and the lower third slides with `left`, so a score, a clock tick or a slide
93
+ costs a patch, not a full frame.
94
+
95
+ ```js
96
+ import { stream, overlayStage, scoreboard, lowerThird, ticker, liveBadge } from '@zumer/snapdom-plugins/stream';
97
+
98
+ const stage = overlayStage({ width: 1920, height: 1080, theme: { accent: '#facc15' } });
99
+ const score = scoreboard({ home: 'HOME', away: 'AWAY' });
100
+ const lower = lowerThird({ name: 'Alex Morgan', role: 'Commentator' });
101
+ stage.append(score.el, liveBadge().el, lower.el, ticker({ items: ['Kick-off at 18:00'] }).el);
102
+ document.body.append(stage);
103
+
104
+ score.clock.start();
105
+ score.add('home');
106
+ lower.set({ name: 'Jamie Rivera' });
107
+ lower.hide();
108
+
109
+ const live = stream(stage, { onFrame });
110
+ ```
111
+
112
+ Theme through CSS variables on the stage: `accent`, `panel`, `panel-2`, `text`, `ink`, `live`,
113
+ `font`.
114
+
115
+ ## As an export
116
+
117
+ `streamExport()` adds `toStream()` to a capture result, next to `toMp4()` and `toGif()`:
118
+
119
+ ```js
120
+ import { streamExport } from '@zumer/snapdom-plugins/stream';
121
+
122
+ const result = await snapdom(panel, { plugins: [streamExport()] });
123
+ const live = await result.toStream({ onFrame });
124
+ ```
125
+
126
+ ## Limits
127
+
128
+ - Patches need a box that can be repainted standalone. A change inside a transparent box with
129
+ no solid backdrop, under a positioned element, or that moves its siblings is a full frame.
130
+ - Programmatic CSSOM edits (`insertRule`, `rule.style`) produce no signal; the keyframe heals
131
+ them.
132
+ - Text ink that spills past a box edge onto a pixel shared with a neighbour waits for the
133
+ keyframe.
134
+ - Needs SnapDOM 3.3.8 or newer, which has the `__subpixel` layout option. On an older core the
135
+ stream still runs, but patches at fractional positions are not pixel-exact.
136
+
137
+ Demos: `demos/stream-templates.html`, `demos/stream-overlay.html`, `demos/stream-webrtc.html`, `demos/stream-webgl.html`,
138
+ `demos/stream-lab.html`, `demos/stream-glass.html`, `demos/stream-fx.html`, `demos/stream-virtual.html`.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Live DOM streaming for SnapDOM: an element as a canvas that repaints only what changed,
3
+ * plus the pieces that put it to work (a WebGL texture that uploads only dirty rects, a
4
+ * recorder, broadcast overlay templates). `stream(el)` is the entry; `streamExport()` adds the same thing to a capture
5
+ * result as `toStream()`, next to toMp4 and toGif.
6
+ * @module plugins/stream
7
+ */
8
+ import { stream } from './stream.js'
9
+ import { captureInputs, exportOption } from '../../capture-frames.js'
10
+
11
+ export { stream } from './stream.js'
12
+ export { streamTexture } from './webgl.js'
13
+ export { recordCanvas } from './recorder.js'
14
+ export { overlayStage, scoreboard, lowerThird, ticker, liveBadge } from './overlays.js'
15
+
16
+ /**
17
+ * Plugin form: `result.toStream(options)` streams the captured element with the capture's
18
+ * own options. Returns the stream at once, so it never holds the result's export queue.
19
+ * Pinned by __tests__/plugin.stream.test.js.
20
+ * @param {object} [defaults] - stream options applied to every toStream()
21
+ * @returns {object} SnapDOM plugin
22
+ */
23
+ export function streamExport(defaults = {}) {
24
+ return {
25
+ name: 'stream',
26
+ defineExports() {
27
+ return {
28
+ stream: (ctx, opts = {}) => {
29
+ if (!ctx.element) throw new Error('[SnapDOM] stream: there is no element to stream.')
30
+ const requested = ctx.export?.requestedOptions || opts
31
+ return stream(ctx.element, {
32
+ ...captureInputs(ctx, opts),
33
+ scale: exportOption(ctx, opts, 'scale', ctx.scale ?? 1),
34
+ ...defaults,
35
+ ...requested,
36
+ })
37
+ },
38
+ }
39
+ },
40
+ }
41
+ }
@@ -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
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A WebGL texture fed by a stream: uploads only the rects a frame changed.
3
+ *
4
+ * WebGL2 reads a sub-rectangle of a canvas source through the unpack parameters, so a patch
5
+ * frame costs its own pixels on the bus, not the texture's. demos/stream-webgl.html: 4% of
6
+ * a 1000x1976 texture per frame against 100% re-uploading it. WebGL1 has no row length, so
7
+ * there every frame uploads the whole canvas.
8
+ * @module plugins/stream/webgl
9
+ */
10
+
11
+ /**
12
+ * Create a texture and the `update(frame)` that keeps it in step with a stream.
13
+ * @param {WebGLRenderingContext|WebGL2RenderingContext} gl
14
+ * @param {{filter?: number}} [options] - TEXTURE_MIN/MAG_FILTER, default gl.LINEAR.
15
+ * @returns {{texture: WebGLTexture, width: number, height: number, update: (frame: {full: boolean, rects: {x:number,y:number,width:number,height:number}[], canvas: HTMLCanvasElement}) => number}}
16
+ * `update` returns the pixels it uploaded.
17
+ */
18
+ export function streamTexture(gl, options = {}) {
19
+ const texture = gl.createTexture()
20
+ const filter = options.filter ?? gl.LINEAR
21
+ const webgl2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext
22
+ gl.bindTexture(gl.TEXTURE_2D, texture)
23
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)
24
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter)
25
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
26
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
27
+ const state = { texture, width: 0, height: 0, update }
28
+
29
+ function update(frame) {
30
+ const c = frame.canvas
31
+ gl.bindTexture(gl.TEXTURE_2D, texture)
32
+ if (frame.full || !webgl2 || c.width !== state.width || c.height !== state.height) {
33
+ state.width = c.width
34
+ state.height = c.height
35
+ if (webgl2) {
36
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0)
37
+ gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, 0)
38
+ gl.pixelStorei(gl.UNPACK_SKIP_ROWS, 0)
39
+ }
40
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c)
41
+ return c.width * c.height
42
+ }
43
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, c.width)
44
+ let uploaded = 0
45
+ for (const r of frame.rects) {
46
+ gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, r.x)
47
+ gl.pixelStorei(gl.UNPACK_SKIP_ROWS, r.y)
48
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, r.x, r.y, r.width, r.height, gl.RGBA, gl.UNSIGNED_BYTE, c)
49
+ uploaded += r.width * r.height
50
+ }
51
+ gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0)
52
+ gl.pixelStorei(gl.UNPACK_SKIP_PIXELS, 0)
53
+ gl.pixelStorei(gl.UNPACK_SKIP_ROWS, 0)
54
+ return uploaded
55
+ }
56
+
57
+ return state
58
+ }
package/stream.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { SnapdomOptions, SnapdomPlugin } from '@zumer/snapdom'
2
+
3
+ /** One repaint of a stream: the rects of `canvas` that changed, in canvas pixels. */
4
+ export interface StreamFrame {
5
+ /** True when the whole canvas was repainted; `reason` says why. */
6
+ full: boolean
7
+ reason: string | null
8
+ rects: { x: number; y: number; width: number; height: number }[]
9
+ ms: number
10
+ canvas: HTMLCanvasElement
11
+ }
12
+
13
+ export interface StreamOptions extends SnapdomOptions {
14
+ onFrame?: (frame: StreamFrame) => void
15
+ /** Cap on frames per second; default every animation frame. */
16
+ fps?: number
17
+ /** false repaints full frames only. Default true. */
18
+ patches?: boolean
19
+ }
20
+
21
+ export interface Stream {
22
+ canvas: HTMLCanvasElement
23
+ ready: Promise<void>
24
+ stats: { full: number; patch: number; skipped: number; lastMs: number }
25
+ stop(): void
26
+ }
27
+
28
+ /**
29
+ * Stream a live element into a canvas that repaints only the boxes that changed: the
30
+ * first frame is a full capture, later frames capture and redraw just the changed
31
+ * subtrees, and anything that cannot be patched exactly is a full frame.
32
+ */
33
+ export function stream(element: Element, options?: StreamOptions): Stream
34
+
35
+ /** Plugin form: adds `result.toStream(options)` to a capture result. */
36
+ export function streamExport(defaults?: StreamOptions): SnapdomPlugin
37
+
38
+ /** A WebGL texture kept in step with a stream; `update` uploads only the changed rects on WebGL2. */
39
+ export function streamTexture(
40
+ gl: WebGLRenderingContext | WebGL2RenderingContext,
41
+ options?: { filter?: number }
42
+ ): { texture: WebGLTexture; width: number; height: number; update(frame: StreamFrame): number }
43
+
44
+ /** Record a canvas with MediaRecorder; `duration` (ms) stops it by itself. */
45
+ export function recordCanvas(
46
+ canvas: HTMLCanvasElement,
47
+ options?: { fps?: number; duration?: number; bitrate?: number }
48
+ ): { done: Promise<Blob>; stop(): Promise<Blob>; type: string }
49
+
50
+ /** The transparent stage every overlay template sits on; stream this element. */
51
+ export function overlayStage(options?: { width?: number; height?: number; theme?: Record<string, string> }): HTMLDivElement
52
+
53
+ export interface ScoreboardClock { start(): void; stop(): void; reset(seconds?: number): void; set(text: string): void }
54
+
55
+ /** Two teams, their points and a running clock. */
56
+ export function scoreboard(options?: { home?: string; away?: string; homeColor?: string; awayColor?: string }): {
57
+ el: HTMLElement
58
+ score(home: number, away: number): void
59
+ add(side: 'home' | 'away', n?: number): void
60
+ clock: ScoreboardClock
61
+ }
62
+
63
+ /** A name and a role that slide in and out. */
64
+ export function lowerThird(options?: { name?: string; role?: string; visible?: boolean }): {
65
+ el: HTMLElement
66
+ set(fields: { name?: string; role?: string }): void
67
+ show(): void
68
+ hide(): void
69
+ }
70
+
71
+ /** A scrolling news line; speed is seconds per loop. */
72
+ export function ticker(options?: { label?: string; items?: string[]; speed?: number }): { el: HTMLElement; set(items: string[]): void }
73
+
74
+ /** A pulsing LIVE bug. */
75
+ export function liveBadge(options?: { text?: string }): { el: HTMLElement; set(text: string): void }
package/stream.js ADDED
@@ -0,0 +1 @@
1
+ export { stream, streamExport, streamTexture, recordCanvas, overlayStage, scoreboard, lowerThird, ticker, liveBadge } from './stream/src/index.js';
@@ -862,12 +862,13 @@ lossless one.** For text metrics the Plugin API payload is strictly richer —
862
862
  `letter-spacing`, per-range small caps and a text stroke all survive it and none
863
863
  of them survives a paste. See the TEXT block under `emit/figma-json.js`.
864
864
 
865
- ## The two paid plugins are separate products, and now separate repositories
865
+ ## PDF and Vector stay independent
866
866
 
867
- `@zumer/snapdom-pdf` and `@zumer/snapdom-vector` are bought separately, and **neither
868
- may import the other.** This is a licence boundary before it is an architectural one:
869
- a vector customer who receives a slice of the PDF plugin has been shipped something
870
- nobody sold them.
867
+ Both exporters are MIT licensed and ship together in `@zumer/snapdom-plugins` 4.x
868
+ (`packages/plugins/pdf` and `packages/plugins/vector`), and **neither may import the
869
+ other.** The rule started as a licence boundary, when `@zumer/snapdom-pdf` and
870
+ `@zumer/snapdom-vector` were sold separately, and it stays because each exporter can
871
+ then be shipped and fixed on its own.
871
872
 
872
873
  The history is worth keeping because it is the reason for the shape. Until 2026-08-08
873
874
  `emit/pdf.js` imported the byte-level writer through `../../../pdf/src/pdf-writer.js`
@@ -883,16 +884,12 @@ more, and nothing can be fixed in both at once either: a correction here does no
883
884
  reach the PDF product, and the two will drift. That is the cost of the boundary being
884
885
  a repository rather than a rule.
885
886
 
886
- Three rules follow, and the first two are unchanged:
887
+ Two rules follow:
887
888
 
888
889
  - **Nothing in `src/writer/` may name a product or know what it is drawing.** The
889
890
  warning prefix is injected by whoever loaded it (`setWarnPrefix`).
890
891
  - **A module only this product needs belongs to this product.** There is no shared
891
892
  package left to put it in, and reaching into the other repository is not available.
892
- - **The build enforces this, not review.** `packages/vector/build.mjs` fails if the
893
- bundle names the other product, imports it, or leaves behind an import the buyer
894
- cannot resolve. A rule with no test is a rule that gets re-broken by the next
895
- person who needs one function.
896
893
 
897
894
  ## House rules
898
895
 
package/vector/README.md CHANGED
@@ -32,4 +32,4 @@ clipboard export does not require installing that plugin.
32
32
 
33
33
  ## Live demo
34
34
 
35
- Run `npm run compile` and `npm run site` from the monorepo root, then open `/pro/vector/` or `/playground/`. The demo exports real SVG and can copy artwork to Figma. The retained `/pro/` URL does not denote a paid product. See [the public contract](CONTRACT.md) for supported behavior and limits.
35
+ Run `npm run compile` and `npm run site` from the monorepo root, then open `/plugins/vector/` or `/showcase/`. The demo exports real SVG and can copy artwork to Figma. The retained `/pro/` URL does not denote a paid product. See [the public contract](CONTRACT.md) for supported behavior and limits.