@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 +9 -0
- package/README.md +39 -23
- 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
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
|
|
4
|
-
|
|
5
|
-
element maps, PDF, editable vectors, ASCII,
|
|
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.
|
|
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.
|
|
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
|
-
##
|
|
548
|
+
## Live demos
|
|
545
549
|
|
|
546
|
-
|
|
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
|
-
##
|
|
552
|
+
## Releasing from the repository root
|
|
549
553
|
|
|
550
|
-
|
|
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
|
-
|
|
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
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
|
|
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.
|
|
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
|
|
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 `/
|
|
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).
|
package/pdf/src/mega/README.md
CHANGED
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.
|
package/stream/README.md
ADDED
|
@@ -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';
|
package/vector/CONTRACT.md
CHANGED
|
@@ -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
|
-
##
|
|
865
|
+
## PDF and Vector stay independent
|
|
866
866
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
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 `/
|
|
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.
|