@zumer/snapdom-plugins 1.2.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -53
- package/agent-map.js +376 -0
- package/gif-export.js +279 -0
- package/html-export.js +63 -0
- package/html-in-canvas.js +33 -17
- package/index.js +5 -2
- package/package.json +6 -3
- package/video-export.js +138 -0
- package/prompt-export.js +0 -550
package/gif-export.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gifExport - Official SnapDOM Plugin
|
|
3
|
+
* Adds a toGif() export that records an animated GIF by re-capturing the live
|
|
4
|
+
* element over time and encoding the frames (median-cut quantization + LZW,
|
|
5
|
+
* GIF89a, no dependencies).
|
|
6
|
+
*
|
|
7
|
+
* Returns a Blob (image/gif). Use opts.download to also trigger a file download.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} [options]
|
|
10
|
+
* @param {number} [options.fps=10] - Frames per second
|
|
11
|
+
* @param {number} [options.duration=2000] - Total duration in ms (ignored if options.frames is set)
|
|
12
|
+
* @param {number} [options.frames] - Explicit frame count (overrides duration)
|
|
13
|
+
* @param {number} [options.maxColors=256] - Palette size per frame (2-256)
|
|
14
|
+
* @param {string} [options.background='#ffffff'] - Color composited under transparent pixels
|
|
15
|
+
* @param {number} [options.scale=1] - Capture scale
|
|
16
|
+
* @param {number} [options.repeat=0] - Loop count (0 = forever, -1 = play once)
|
|
17
|
+
* @param {string} [options.filename='capture.gif'] - Download filename
|
|
18
|
+
* @returns {Object} SnapDOM plugin
|
|
19
|
+
*/
|
|
20
|
+
import { snapdom } from '@zumer/snapdom';
|
|
21
|
+
|
|
22
|
+
export function gifExport(options = {}) {
|
|
23
|
+
const {
|
|
24
|
+
fps = 10,
|
|
25
|
+
duration = 2000,
|
|
26
|
+
frames: frameOpt = null,
|
|
27
|
+
maxColors = 256,
|
|
28
|
+
background = '#ffffff',
|
|
29
|
+
scale = 1,
|
|
30
|
+
repeat = 0,
|
|
31
|
+
filename = 'capture.gif',
|
|
32
|
+
} = options;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
name: 'gif-export',
|
|
36
|
+
|
|
37
|
+
// The export ctx comes from createContext (no `element`). Stash the live
|
|
38
|
+
// element during a capture hook so toGif() can re-capture frames from it.
|
|
39
|
+
beforeSnap(ctx) {
|
|
40
|
+
if (ctx && ctx.options) ctx.options.__snapSource = ctx.element;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
defineExports() {
|
|
44
|
+
return {
|
|
45
|
+
gif: async (ctx, opts = {}) => {
|
|
46
|
+
const el = ctx.__snapSource || ctx.element;
|
|
47
|
+
if (!el) throw new Error('[snapdom] gif-export: no source element on context');
|
|
48
|
+
|
|
49
|
+
const _fps = opts.fps ?? fps;
|
|
50
|
+
const _dur = opts.duration ?? duration;
|
|
51
|
+
const _count = Math.max(1, opts.frames ?? frameOpt ?? Math.round((_dur / 1000) * _fps));
|
|
52
|
+
const _max = Math.min(256, Math.max(2, opts.maxColors ?? maxColors));
|
|
53
|
+
const _bg = opts.background ?? background;
|
|
54
|
+
const _scale = opts.scale ?? scale ?? ctx.scale ?? 1;
|
|
55
|
+
const _repeat = opts.repeat ?? repeat;
|
|
56
|
+
const delayCs = Math.max(2, Math.round(100 / _fps)); // GIF delay unit is 1/100 s
|
|
57
|
+
|
|
58
|
+
let W = 0, H = 0;
|
|
59
|
+
const frames = [];
|
|
60
|
+
for (let i = 0; i < _count; i++) {
|
|
61
|
+
const cap = await snapdom(el, { scale: _scale, backgroundColor: _bg, fast: true });
|
|
62
|
+
const src = await cap.toCanvas();
|
|
63
|
+
if (i === 0) { W = src.width; H = src.height; }
|
|
64
|
+
const fc = document.createElement('canvas');
|
|
65
|
+
fc.width = W; fc.height = H;
|
|
66
|
+
const fx = fc.getContext('2d');
|
|
67
|
+
fx.fillStyle = _bg;
|
|
68
|
+
fx.fillRect(0, 0, W, H);
|
|
69
|
+
fx.drawImage(src, 0, 0, W, H);
|
|
70
|
+
frames.push(fx.getImageData(0, 0, W, H));
|
|
71
|
+
if (i < _count - 1) await new Promise(r => setTimeout(r, 1000 / _fps));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const bytes = encodeGif(frames, W, H, _max, delayCs, _repeat);
|
|
75
|
+
const blob = new Blob([bytes], { type: 'image/gif' });
|
|
76
|
+
|
|
77
|
+
const dl = opts.download;
|
|
78
|
+
if (dl) {
|
|
79
|
+
const objUrl = URL.createObjectURL(blob);
|
|
80
|
+
const a = document.createElement('a');
|
|
81
|
+
a.href = objUrl;
|
|
82
|
+
a.download = typeof dl === 'string' ? dl : filename;
|
|
83
|
+
a.click();
|
|
84
|
+
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
|
|
85
|
+
}
|
|
86
|
+
return blob;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── GIF89a encoder (no dependencies) ─────────────────────────────────────── */
|
|
94
|
+
|
|
95
|
+
function encodeGif(frames, w, h, maxColors, delayCs, repeat) {
|
|
96
|
+
const out = [];
|
|
97
|
+
const byte = b => out.push(b & 0xff);
|
|
98
|
+
const short = s => { out.push(s & 0xff); out.push((s >> 8) & 0xff); };
|
|
99
|
+
const str = s => { for (let i = 0; i < s.length; i++) out.push(s.charCodeAt(i)); };
|
|
100
|
+
|
|
101
|
+
str('GIF89a');
|
|
102
|
+
short(w); short(h);
|
|
103
|
+
byte(0x70); // no global color table; 8-bit color resolution
|
|
104
|
+
byte(0); // background color index
|
|
105
|
+
byte(0); // pixel aspect ratio
|
|
106
|
+
|
|
107
|
+
if (frames.length > 1 && repeat >= 0) {
|
|
108
|
+
byte(0x21); byte(0xff); byte(0x0b);
|
|
109
|
+
str('NETSCAPE2.0');
|
|
110
|
+
byte(0x03); byte(0x01);
|
|
111
|
+
short(repeat); // 0 = loop forever
|
|
112
|
+
byte(0x00);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const frame of frames) {
|
|
116
|
+
const { palette, indices, bits } = quantizeFrame(frame.data, maxColors);
|
|
117
|
+
const tableSize = 1 << bits;
|
|
118
|
+
|
|
119
|
+
// Graphic Control Extension
|
|
120
|
+
byte(0x21); byte(0xf9); byte(0x04);
|
|
121
|
+
byte(0x00); // disposal none, no transparency
|
|
122
|
+
short(delayCs);
|
|
123
|
+
byte(0x00); // transparent color index (unused)
|
|
124
|
+
byte(0x00);
|
|
125
|
+
|
|
126
|
+
// Image Descriptor
|
|
127
|
+
byte(0x2c);
|
|
128
|
+
short(0); short(0);
|
|
129
|
+
short(w); short(h);
|
|
130
|
+
byte(0x80 | (bits - 1)); // local color table, size = bits-1
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < tableSize; i++) {
|
|
133
|
+
const c = palette[i] || [0, 0, 0];
|
|
134
|
+
byte(c[0]); byte(c[1]); byte(c[2]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const minCodeSize = Math.max(2, bits);
|
|
138
|
+
byte(minCodeSize);
|
|
139
|
+
const lzw = lzwEncode(indices, minCodeSize);
|
|
140
|
+
let p = 0;
|
|
141
|
+
while (p < lzw.length) {
|
|
142
|
+
const n = Math.min(255, lzw.length - p);
|
|
143
|
+
byte(n);
|
|
144
|
+
for (let i = 0; i < n; i++) byte(lzw[p + i]);
|
|
145
|
+
p += n;
|
|
146
|
+
}
|
|
147
|
+
byte(0x00);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
byte(0x3b);
|
|
151
|
+
return new Uint8Array(out);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function quantizeFrame(data, maxColors) {
|
|
155
|
+
const hist = new Map();
|
|
156
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
157
|
+
const key = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
|
|
158
|
+
const e = hist.get(key);
|
|
159
|
+
if (e) e.n++;
|
|
160
|
+
else hist.set(key, { r: data[i], g: data[i + 1], b: data[i + 2], n: 1 });
|
|
161
|
+
}
|
|
162
|
+
const colors = [...hist.values()];
|
|
163
|
+
let palette = colors.length <= maxColors
|
|
164
|
+
? colors.map(c => [c.r, c.g, c.b])
|
|
165
|
+
: medianCut(colors, maxColors);
|
|
166
|
+
if (palette.length === 0) palette = [[0, 0, 0]];
|
|
167
|
+
|
|
168
|
+
let bits = 1;
|
|
169
|
+
while ((1 << bits) < palette.length) bits++;
|
|
170
|
+
|
|
171
|
+
const keyToIndex = new Map();
|
|
172
|
+
for (const c of colors) {
|
|
173
|
+
keyToIndex.set((c.r << 16) | (c.g << 8) | c.b, nearest(palette, c.r, c.g, c.b));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const indices = new Uint8Array(data.length / 4);
|
|
177
|
+
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
|
|
178
|
+
indices[j] = keyToIndex.get((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
|
|
179
|
+
}
|
|
180
|
+
return { palette, indices, bits };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function nearest(palette, r, g, b) {
|
|
184
|
+
let best = 0, bestD = Infinity;
|
|
185
|
+
for (let i = 0; i < palette.length; i++) {
|
|
186
|
+
const p = palette[i];
|
|
187
|
+
const dr = p[0] - r, dg = p[1] - g, db = p[2] - b;
|
|
188
|
+
const d = dr * dr + dg * dg + db * db;
|
|
189
|
+
if (d < bestD) { bestD = d; best = i; if (d === 0) break; }
|
|
190
|
+
}
|
|
191
|
+
return best;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function medianCut(colors, maxColors) {
|
|
195
|
+
let boxes = [colors];
|
|
196
|
+
while (boxes.length < maxColors) {
|
|
197
|
+
let bi = -1, bestRange = -1, ch = 'r';
|
|
198
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
199
|
+
const box = boxes[i];
|
|
200
|
+
if (box.length < 2) continue;
|
|
201
|
+
let rmin = 255, rmax = 0, gmin = 255, gmax = 0, bmin = 255, bmax = 0;
|
|
202
|
+
for (const c of box) {
|
|
203
|
+
if (c.r < rmin) rmin = c.r; if (c.r > rmax) rmax = c.r;
|
|
204
|
+
if (c.g < gmin) gmin = c.g; if (c.g > gmax) gmax = c.g;
|
|
205
|
+
if (c.b < bmin) bmin = c.b; if (c.b > bmax) bmax = c.b;
|
|
206
|
+
}
|
|
207
|
+
const rr = rmax - rmin, gg = gmax - gmin, bb = bmax - bmin;
|
|
208
|
+
const range = Math.max(rr, gg, bb);
|
|
209
|
+
if (range > bestRange) {
|
|
210
|
+
bestRange = range;
|
|
211
|
+
bi = i;
|
|
212
|
+
ch = rr >= gg && rr >= bb ? 'r' : (gg >= bb ? 'g' : 'b');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (bi < 0) break;
|
|
216
|
+
const box = boxes[bi];
|
|
217
|
+
box.sort((a, b) => a[ch] - b[ch]);
|
|
218
|
+
const mid = box.length >> 1;
|
|
219
|
+
boxes.splice(bi, 1, box.slice(0, mid), box.slice(mid));
|
|
220
|
+
}
|
|
221
|
+
return boxes.map(box => {
|
|
222
|
+
let r = 0, g = 0, b = 0, tot = 0;
|
|
223
|
+
for (const c of box) { r += c.r * c.n; g += c.g * c.n; b += c.b * c.n; tot += c.n; }
|
|
224
|
+
tot = tot || 1;
|
|
225
|
+
return [Math.round(r / tot), Math.round(g / tot), Math.round(b / tot)];
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function lzwEncode(indices, minCodeSize) {
|
|
230
|
+
const clearCode = 1 << minCodeSize;
|
|
231
|
+
const eoiCode = clearCode + 1;
|
|
232
|
+
let codeSize = minCodeSize + 1;
|
|
233
|
+
let dict = new Map();
|
|
234
|
+
let nextCode = clearCode + 2;
|
|
235
|
+
|
|
236
|
+
const out = [];
|
|
237
|
+
let cur = 0, curBits = 0;
|
|
238
|
+
const emit = code => {
|
|
239
|
+
cur |= code << curBits;
|
|
240
|
+
curBits += codeSize;
|
|
241
|
+
while (curBits >= 8) { out.push(cur & 0xff); cur >>>= 8; curBits -= 8; }
|
|
242
|
+
};
|
|
243
|
+
const reset = () => { dict = new Map(); nextCode = clearCode + 2; codeSize = minCodeSize + 1; };
|
|
244
|
+
|
|
245
|
+
emit(clearCode);
|
|
246
|
+
if (indices.length === 0) {
|
|
247
|
+
emit(eoiCode);
|
|
248
|
+
if (curBits > 0) out.push(cur & 0xff);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let prefix = indices[0];
|
|
253
|
+
for (let i = 1; i < indices.length; i++) {
|
|
254
|
+
const k = indices[i];
|
|
255
|
+
const key = (prefix << 8) | k;
|
|
256
|
+
if (dict.has(key)) {
|
|
257
|
+
prefix = dict.get(key);
|
|
258
|
+
} else {
|
|
259
|
+
emit(prefix);
|
|
260
|
+
// Grow the code size BEFORE assigning the next code (omggif/GIF semantics):
|
|
261
|
+
// emit the prefix at the current width, then widen. Growing after the
|
|
262
|
+
// increment switches one code too early and desyncs every decoder.
|
|
263
|
+
if (nextCode === 4096) {
|
|
264
|
+
emit(clearCode);
|
|
265
|
+
reset();
|
|
266
|
+
} else {
|
|
267
|
+
if (nextCode >= (1 << codeSize) && codeSize < 12) codeSize++;
|
|
268
|
+
dict.set(key, nextCode++);
|
|
269
|
+
}
|
|
270
|
+
prefix = k;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
emit(prefix);
|
|
274
|
+
emit(eoiCode);
|
|
275
|
+
if (curBits > 0) out.push(cur & 0xff);
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default gifExport;
|
package/html-export.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* htmlExport - Official SnapDOM Plugin
|
|
3
|
+
* Adds a toHtml() export that returns the capture as a self-contained,
|
|
4
|
+
* re-renderable HTML document (clone + inlined styles/fonts) instead of pixels.
|
|
5
|
+
*
|
|
6
|
+
* It unwraps the SVG <foreignObject> snapdom already produced, so the markup
|
|
7
|
+
* and CSS match the capture byte-for-byte. Nothing is rasterized.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} [options]
|
|
10
|
+
* @param {boolean} [options.fullDocument=true] - Wrap in <!DOCTYPE html>…; if false, return just <style> + fragment
|
|
11
|
+
* @param {string} [options.filename='capture.html'] - Download filename when opts.download is used
|
|
12
|
+
* @returns {Object} SnapDOM plugin
|
|
13
|
+
*/
|
|
14
|
+
export function htmlExport(options = {}) {
|
|
15
|
+
const {
|
|
16
|
+
fullDocument = true,
|
|
17
|
+
filename = 'capture.html',
|
|
18
|
+
} = options;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
name: 'html-export',
|
|
22
|
+
|
|
23
|
+
defineExports() {
|
|
24
|
+
return {
|
|
25
|
+
html: async (ctx, opts = {}) => {
|
|
26
|
+
const url = ctx.export.url;
|
|
27
|
+
if (typeof url !== 'string' || !url.startsWith('data:image/svg+xml')) {
|
|
28
|
+
throw new Error('[snapdom] html-export: capture is not an SVG data URL');
|
|
29
|
+
}
|
|
30
|
+
const svgString = decodeURIComponent(url.replace(/^data:image\/svg\+xml[^,]*,/, ''));
|
|
31
|
+
|
|
32
|
+
const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml');
|
|
33
|
+
const fo = doc.querySelector('foreignObject');
|
|
34
|
+
if (!fo) throw new Error('[snapdom] html-export: capture has no foreignObject');
|
|
35
|
+
|
|
36
|
+
const styleEl = fo.querySelector('style');
|
|
37
|
+
const container = fo.querySelector('div');
|
|
38
|
+
const css = styleEl ? styleEl.textContent : '';
|
|
39
|
+
const body = container ? new XMLSerializer().serializeToString(container) : '';
|
|
40
|
+
|
|
41
|
+
const asDoc = opts.fullDocument ?? fullDocument;
|
|
42
|
+
const html = asDoc
|
|
43
|
+
? `<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n<style>${css}</style>\n</head>\n<body>\n${body}\n</body>\n</html>\n`
|
|
44
|
+
: `<style>${css}</style>\n${body}\n`;
|
|
45
|
+
|
|
46
|
+
const dl = opts.download;
|
|
47
|
+
if (dl) {
|
|
48
|
+
const blob = new Blob([html], { type: 'text/html' });
|
|
49
|
+
const objUrl = URL.createObjectURL(blob);
|
|
50
|
+
const a = document.createElement('a');
|
|
51
|
+
a.href = objUrl;
|
|
52
|
+
a.download = typeof dl === 'string' ? dl : (opts.filename || filename);
|
|
53
|
+
a.click();
|
|
54
|
+
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
|
|
55
|
+
}
|
|
56
|
+
return html;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default htmlExport;
|
package/html-in-canvas.js
CHANGED
|
@@ -9,13 +9,21 @@
|
|
|
9
9
|
|
|
10
10
|
const PLUGIN_NAME = 'html-in-canvas'
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The WICG canvas-place-element spec evolved: Chrome ~130+ exposes drawElement(),
|
|
14
|
+
* earlier flagged builds shipped drawElementImage(). Detect either.
|
|
15
|
+
* @returns {'drawElement'|'drawElementImage'|null}
|
|
16
|
+
*/
|
|
17
|
+
function detectDrawApi() {
|
|
13
18
|
try {
|
|
14
19
|
const c = document.createElement('canvas')
|
|
15
20
|
const ctx = c.getContext('2d')
|
|
16
|
-
|
|
21
|
+
if (!ctx) return null
|
|
22
|
+
if (typeof ctx.drawElement === 'function') return 'drawElement'
|
|
23
|
+
if (typeof ctx.drawElementImage === 'function') return 'drawElementImage'
|
|
24
|
+
return null
|
|
17
25
|
} catch {
|
|
18
|
-
return
|
|
26
|
+
return null
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
|
|
@@ -23,9 +31,10 @@ function isDrawElementImageAvailable() {
|
|
|
23
31
|
* @returns {import('../../src/core/plugins.js').Plugin}
|
|
24
32
|
*/
|
|
25
33
|
export function htmlInCanvasPlugin() {
|
|
26
|
-
const
|
|
34
|
+
const drawApi = detectDrawApi()
|
|
35
|
+
const available = !!drawApi
|
|
27
36
|
if (!available) {
|
|
28
|
-
console.warn('[snapdom] html-in-canvas plugin: drawElementImage not available. Enable chrome://flags/#canvas-draw-element')
|
|
37
|
+
console.warn('[snapdom] html-in-canvas plugin: drawElement / drawElementImage not available. Enable chrome://flags/#canvas-draw-element')
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
return {
|
|
@@ -92,27 +101,34 @@ export function htmlInCanvasPlugin() {
|
|
|
92
101
|
|
|
93
102
|
canvas.appendChild(wrapper)
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
// Append directly to body, taken out of flow with position:fixed + z-index:-1
|
|
105
|
+
// so it sits behind the page's content (covered by body/main backgrounds)
|
|
106
|
+
// while still being painted. visibility:hidden / opacity:0 / left:-9999px
|
|
107
|
+
// skip the paint pass and trigger "No cached paint record".
|
|
108
|
+
canvas.style.cssText = 'position:fixed;top:0;left:0;z-index:-1;'
|
|
109
|
+
document.body.appendChild(canvas)
|
|
100
110
|
|
|
101
111
|
try {
|
|
102
|
-
|
|
112
|
+
canvas.getBoundingClientRect()
|
|
113
|
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
114
|
+
|
|
103
115
|
const ctx2d = canvas.getContext('2d')
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
const fn = ctx2d && (ctx2d[drawApi] || ctx2d.drawElement || ctx2d.drawElementImage)
|
|
117
|
+
if (typeof fn !== 'function') {
|
|
118
|
+
throw new Error('drawElement / drawElementImage not available on this canvas context')
|
|
106
119
|
}
|
|
107
120
|
ctx2d.save()
|
|
108
121
|
ctx2d.scale(dpr * scale, dpr * scale)
|
|
109
|
-
|
|
122
|
+
fn.call(ctx2d, wrapper, 0, 0, width, height)
|
|
110
123
|
ctx2d.restore()
|
|
111
124
|
return canvas
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (e && /paint record/i.test(e.message || '')) {
|
|
127
|
+
throw new Error('Browser had no paint record for the element. Make sure the document is fully loaded and visible before calling html-in-canvas (drawElement requires a real paint pass).')
|
|
128
|
+
}
|
|
129
|
+
throw e
|
|
112
130
|
} finally {
|
|
113
|
-
try {
|
|
114
|
-
document.body.removeChild(container)
|
|
115
|
-
} catch {}
|
|
131
|
+
try { canvas.remove() } catch {}
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
134
|
}
|
package/index.js
CHANGED
|
@@ -12,5 +12,8 @@ export { replaceText } from './replace-text.js';
|
|
|
12
12
|
export { filter } from './filter.js';
|
|
13
13
|
export { colorTint } from './color-tint.js';
|
|
14
14
|
export { pdfImage } from './pdf-image.js';
|
|
15
|
-
export {
|
|
16
|
-
|
|
15
|
+
export { agentMap } from './agent-map.js';
|
|
16
|
+
export { htmlExport } from './html-export.js';
|
|
17
|
+
export { gifExport } from './gif-export.js';
|
|
18
|
+
export { videoExport } from './video-export.js';
|
|
19
|
+
export { htmlInCanvas } from './html-in-canvas.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zumer/snapdom-plugins",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Official plugins for SnapDOM",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
"./color-tint": "./color-tint.js",
|
|
13
13
|
"./html-in-canvas": "./html-in-canvas.js",
|
|
14
14
|
"./pdf-image": "./pdf-image.js",
|
|
15
|
-
"./
|
|
15
|
+
"./agent-map": "./agent-map.js",
|
|
16
|
+
"./html-export": "./html-export.js",
|
|
17
|
+
"./gif-export": "./gif-export.js",
|
|
18
|
+
"./video-export": "./video-export.js"
|
|
16
19
|
},
|
|
17
20
|
"files": [
|
|
18
21
|
"*.js",
|
|
@@ -36,6 +39,6 @@
|
|
|
36
39
|
},
|
|
37
40
|
"homepage": "https://zumerlab.github.io/snapdom/",
|
|
38
41
|
"peerDependencies": {
|
|
39
|
-
"@zumer/snapdom": ">=2.
|
|
42
|
+
"@zumer/snapdom": ">=2.12.1"
|
|
40
43
|
}
|
|
41
44
|
}
|
package/video-export.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* videoExport - Official SnapDOM Plugin
|
|
3
|
+
* Adds a toMp4() export that records a video by re-capturing the live element
|
|
4
|
+
* over time and encoding the frames with the native MediaRecorder.
|
|
5
|
+
*
|
|
6
|
+
* Codec reality: MediaRecorder output depends on the browser. Safari produces
|
|
7
|
+
* MP4 (H.264); Chromium typically produces WebM (VP8/VP9). When MP4 is not
|
|
8
|
+
* supported the plugin falls back to WebM and warns. Returns a Blob whose type
|
|
9
|
+
* reflects what was actually produced.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} [options]
|
|
12
|
+
* @param {number} [options.fps=10] - Frames per second
|
|
13
|
+
* @param {number} [options.duration=2000] - Total duration in ms (ignored if options.frames is set)
|
|
14
|
+
* @param {number} [options.frames] - Explicit frame count (overrides duration)
|
|
15
|
+
* @param {string} [options.background='#ffffff'] - Color composited under transparent pixels
|
|
16
|
+
* @param {number} [options.scale=1] - Capture scale
|
|
17
|
+
* @param {number} [options.bitrate] - videoBitsPerSecond passed to MediaRecorder
|
|
18
|
+
* @param {string} [options.filename] - Download filename (extension auto-set to .mp4/.webm)
|
|
19
|
+
* @returns {Object} SnapDOM plugin
|
|
20
|
+
*/
|
|
21
|
+
import { snapdom } from '@zumer/snapdom';
|
|
22
|
+
|
|
23
|
+
const MIME_CANDIDATES = [
|
|
24
|
+
'video/mp4;codecs=avc1',
|
|
25
|
+
'video/mp4',
|
|
26
|
+
'video/webm;codecs=vp9',
|
|
27
|
+
'video/webm;codecs=vp8',
|
|
28
|
+
'video/webm',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function videoExport(options = {}) {
|
|
32
|
+
const {
|
|
33
|
+
fps = 10,
|
|
34
|
+
duration = 2000,
|
|
35
|
+
frames: frameOpt = null,
|
|
36
|
+
background = '#ffffff',
|
|
37
|
+
scale = 1,
|
|
38
|
+
bitrate = null,
|
|
39
|
+
filename = null,
|
|
40
|
+
} = options;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
name: 'video-export',
|
|
44
|
+
|
|
45
|
+
// The export ctx comes from createContext (no `element`). Stash the live
|
|
46
|
+
// element during a capture hook so toMp4() can re-capture frames from it.
|
|
47
|
+
beforeSnap(ctx) {
|
|
48
|
+
if (ctx && ctx.options) ctx.options.__snapSource = ctx.element;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
defineExports() {
|
|
52
|
+
return {
|
|
53
|
+
mp4: async (ctx, opts = {}) => {
|
|
54
|
+
if (typeof MediaRecorder === 'undefined') {
|
|
55
|
+
throw new Error('[snapdom] video-export: MediaRecorder is not available in this environment');
|
|
56
|
+
}
|
|
57
|
+
const el = ctx.__snapSource || ctx.element;
|
|
58
|
+
if (!el) throw new Error('[snapdom] video-export: no source element on context');
|
|
59
|
+
|
|
60
|
+
const _fps = opts.fps ?? fps;
|
|
61
|
+
const _dur = opts.duration ?? duration;
|
|
62
|
+
const _count = Math.max(1, opts.frames ?? frameOpt ?? Math.round((_dur / 1000) * _fps));
|
|
63
|
+
const _bg = opts.background ?? background;
|
|
64
|
+
const _scale = opts.scale ?? scale ?? ctx.scale ?? 1;
|
|
65
|
+
const _bitrate = opts.bitrate ?? bitrate;
|
|
66
|
+
const frameMs = 1000 / _fps;
|
|
67
|
+
|
|
68
|
+
// 1) Pre-render every frame onto a fixed-size canvas.
|
|
69
|
+
let W = 0, H = 0;
|
|
70
|
+
const frames = [];
|
|
71
|
+
for (let i = 0; i < _count; i++) {
|
|
72
|
+
const cap = await snapdom(el, { scale: _scale, backgroundColor: _bg, fast: true });
|
|
73
|
+
const src = await cap.toCanvas();
|
|
74
|
+
if (i === 0) { W = src.width; H = src.height; }
|
|
75
|
+
const fc = document.createElement('canvas');
|
|
76
|
+
fc.width = W; fc.height = H;
|
|
77
|
+
const fx = fc.getContext('2d');
|
|
78
|
+
fx.fillStyle = _bg;
|
|
79
|
+
fx.fillRect(0, 0, W, H);
|
|
80
|
+
fx.drawImage(src, 0, 0, W, H);
|
|
81
|
+
frames.push(fc);
|
|
82
|
+
if (i < _count - 1) await new Promise(r => setTimeout(r, frameMs));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2) Pick the best supported container/codec.
|
|
86
|
+
const mimeType = MIME_CANDIDATES.find(t =>
|
|
87
|
+
typeof MediaRecorder.isTypeSupported === 'function' && MediaRecorder.isTypeSupported(t)
|
|
88
|
+
) || '';
|
|
89
|
+
if (mimeType && !mimeType.startsWith('video/mp4')) {
|
|
90
|
+
console.warn(`[snapdom] video-export: MP4 not supported by this browser's MediaRecorder; falling back to ${mimeType}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3) Play the frames onto a stage canvas while recording its stream.
|
|
94
|
+
const stage = document.createElement('canvas');
|
|
95
|
+
stage.width = W; stage.height = H;
|
|
96
|
+
const sctx = stage.getContext('2d');
|
|
97
|
+
const stream = stage.captureStream(_fps);
|
|
98
|
+
|
|
99
|
+
const recOpts = {};
|
|
100
|
+
if (mimeType) recOpts.mimeType = mimeType;
|
|
101
|
+
if (_bitrate) recOpts.videoBitsPerSecond = _bitrate;
|
|
102
|
+
const rec = new MediaRecorder(stream, recOpts);
|
|
103
|
+
|
|
104
|
+
const chunks = [];
|
|
105
|
+
rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
|
|
106
|
+
const stopped = new Promise(res => { rec.onstop = res; });
|
|
107
|
+
|
|
108
|
+
rec.start();
|
|
109
|
+
for (let i = 0; i < frames.length; i++) {
|
|
110
|
+
sctx.clearRect(0, 0, W, H);
|
|
111
|
+
sctx.drawImage(frames[i], 0, 0);
|
|
112
|
+
await new Promise(r => setTimeout(r, frameMs));
|
|
113
|
+
}
|
|
114
|
+
await new Promise(r => setTimeout(r, frameMs)); // let the last frame land
|
|
115
|
+
rec.stop();
|
|
116
|
+
await stopped;
|
|
117
|
+
|
|
118
|
+
const isMp4 = mimeType.startsWith('video/mp4');
|
|
119
|
+
const blob = new Blob(chunks, { type: (mimeType || 'video/webm').split(';')[0] });
|
|
120
|
+
|
|
121
|
+
const dl = opts.download;
|
|
122
|
+
if (dl) {
|
|
123
|
+
const objUrl = URL.createObjectURL(blob);
|
|
124
|
+
const a = document.createElement('a');
|
|
125
|
+
a.href = objUrl;
|
|
126
|
+
const fallbackName = isMp4 ? 'capture.mp4' : 'capture.webm';
|
|
127
|
+
a.download = typeof dl === 'string' ? dl : (opts.filename || filename || fallbackName);
|
|
128
|
+
a.click();
|
|
129
|
+
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
|
|
130
|
+
}
|
|
131
|
+
return blob;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default videoExport;
|