@zumer/snapdom-plugins
Advanced tools
+279
| /** | ||
| * gifExport - Official SnapDOM Plugin | ||
| * Adds a toGif() export that records an animated GIF by re-capturing the live | ||
| * element over time and encoding the frames (median-cut quantization + LZW, | ||
| * GIF89a, no dependencies). | ||
| * | ||
| * Returns a Blob (image/gif). Use opts.download to also trigger a file download. | ||
| * | ||
| * @param {Object} [options] | ||
| * @param {number} [options.fps=10] - Frames per second | ||
| * @param {number} [options.duration=2000] - Total duration in ms (ignored if options.frames is set) | ||
| * @param {number} [options.frames] - Explicit frame count (overrides duration) | ||
| * @param {number} [options.maxColors=256] - Palette size per frame (2-256) | ||
| * @param {string} [options.background='#ffffff'] - Color composited under transparent pixels | ||
| * @param {number} [options.scale=1] - Capture scale | ||
| * @param {number} [options.repeat=0] - Loop count (0 = forever, -1 = play once) | ||
| * @param {string} [options.filename='capture.gif'] - Download filename | ||
| * @returns {Object} SnapDOM plugin | ||
| */ | ||
| import { snapdom } from '@zumer/snapdom'; | ||
| export function gifExport(options = {}) { | ||
| const { | ||
| fps = 10, | ||
| duration = 2000, | ||
| frames: frameOpt = null, | ||
| maxColors = 256, | ||
| background = '#ffffff', | ||
| scale = 1, | ||
| repeat = 0, | ||
| filename = 'capture.gif', | ||
| } = options; | ||
| return { | ||
| name: 'gif-export', | ||
| // The export ctx comes from createContext (no `element`). Stash the live | ||
| // element during a capture hook so toGif() can re-capture frames from it. | ||
| beforeSnap(ctx) { | ||
| if (ctx && ctx.options) ctx.options.__snapSource = ctx.element; | ||
| }, | ||
| defineExports() { | ||
| return { | ||
| gif: async (ctx, opts = {}) => { | ||
| const el = ctx.__snapSource || ctx.element; | ||
| if (!el) throw new Error('[snapdom] gif-export: no source element on context'); | ||
| const _fps = opts.fps ?? fps; | ||
| const _dur = opts.duration ?? duration; | ||
| const _count = Math.max(1, opts.frames ?? frameOpt ?? Math.round((_dur / 1000) * _fps)); | ||
| const _max = Math.min(256, Math.max(2, opts.maxColors ?? maxColors)); | ||
| const _bg = opts.background ?? background; | ||
| const _scale = opts.scale ?? scale ?? ctx.scale ?? 1; | ||
| const _repeat = opts.repeat ?? repeat; | ||
| const delayCs = Math.max(2, Math.round(100 / _fps)); // GIF delay unit is 1/100 s | ||
| let W = 0, H = 0; | ||
| const frames = []; | ||
| for (let i = 0; i < _count; i++) { | ||
| const cap = await snapdom(el, { scale: _scale, backgroundColor: _bg, fast: true }); | ||
| const src = await cap.toCanvas(); | ||
| if (i === 0) { W = src.width; H = src.height; } | ||
| const fc = document.createElement('canvas'); | ||
| fc.width = W; fc.height = H; | ||
| const fx = fc.getContext('2d'); | ||
| fx.fillStyle = _bg; | ||
| fx.fillRect(0, 0, W, H); | ||
| fx.drawImage(src, 0, 0, W, H); | ||
| frames.push(fx.getImageData(0, 0, W, H)); | ||
| if (i < _count - 1) await new Promise(r => setTimeout(r, 1000 / _fps)); | ||
| } | ||
| const bytes = encodeGif(frames, W, H, _max, delayCs, _repeat); | ||
| const blob = new Blob([bytes], { type: 'image/gif' }); | ||
| const dl = opts.download; | ||
| if (dl) { | ||
| const objUrl = URL.createObjectURL(blob); | ||
| const a = document.createElement('a'); | ||
| a.href = objUrl; | ||
| a.download = typeof dl === 'string' ? dl : filename; | ||
| a.click(); | ||
| setTimeout(() => URL.revokeObjectURL(objUrl), 5000); | ||
| } | ||
| return blob; | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
| } | ||
| /* ── GIF89a encoder (no dependencies) ─────────────────────────────────────── */ | ||
| function encodeGif(frames, w, h, maxColors, delayCs, repeat) { | ||
| const out = []; | ||
| const byte = b => out.push(b & 0xff); | ||
| const short = s => { out.push(s & 0xff); out.push((s >> 8) & 0xff); }; | ||
| const str = s => { for (let i = 0; i < s.length; i++) out.push(s.charCodeAt(i)); }; | ||
| str('GIF89a'); | ||
| short(w); short(h); | ||
| byte(0x70); // no global color table; 8-bit color resolution | ||
| byte(0); // background color index | ||
| byte(0); // pixel aspect ratio | ||
| if (frames.length > 1 && repeat >= 0) { | ||
| byte(0x21); byte(0xff); byte(0x0b); | ||
| str('NETSCAPE2.0'); | ||
| byte(0x03); byte(0x01); | ||
| short(repeat); // 0 = loop forever | ||
| byte(0x00); | ||
| } | ||
| for (const frame of frames) { | ||
| const { palette, indices, bits } = quantizeFrame(frame.data, maxColors); | ||
| const tableSize = 1 << bits; | ||
| // Graphic Control Extension | ||
| byte(0x21); byte(0xf9); byte(0x04); | ||
| byte(0x00); // disposal none, no transparency | ||
| short(delayCs); | ||
| byte(0x00); // transparent color index (unused) | ||
| byte(0x00); | ||
| // Image Descriptor | ||
| byte(0x2c); | ||
| short(0); short(0); | ||
| short(w); short(h); | ||
| byte(0x80 | (bits - 1)); // local color table, size = bits-1 | ||
| for (let i = 0; i < tableSize; i++) { | ||
| const c = palette[i] || [0, 0, 0]; | ||
| byte(c[0]); byte(c[1]); byte(c[2]); | ||
| } | ||
| const minCodeSize = Math.max(2, bits); | ||
| byte(minCodeSize); | ||
| const lzw = lzwEncode(indices, minCodeSize); | ||
| let p = 0; | ||
| while (p < lzw.length) { | ||
| const n = Math.min(255, lzw.length - p); | ||
| byte(n); | ||
| for (let i = 0; i < n; i++) byte(lzw[p + i]); | ||
| p += n; | ||
| } | ||
| byte(0x00); | ||
| } | ||
| byte(0x3b); | ||
| return new Uint8Array(out); | ||
| } | ||
| function quantizeFrame(data, maxColors) { | ||
| const hist = new Map(); | ||
| for (let i = 0; i < data.length; i += 4) { | ||
| const key = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]; | ||
| const e = hist.get(key); | ||
| if (e) e.n++; | ||
| else hist.set(key, { r: data[i], g: data[i + 1], b: data[i + 2], n: 1 }); | ||
| } | ||
| const colors = [...hist.values()]; | ||
| let palette = colors.length <= maxColors | ||
| ? colors.map(c => [c.r, c.g, c.b]) | ||
| : medianCut(colors, maxColors); | ||
| if (palette.length === 0) palette = [[0, 0, 0]]; | ||
| let bits = 1; | ||
| while ((1 << bits) < palette.length) bits++; | ||
| const keyToIndex = new Map(); | ||
| for (const c of colors) { | ||
| keyToIndex.set((c.r << 16) | (c.g << 8) | c.b, nearest(palette, c.r, c.g, c.b)); | ||
| } | ||
| const indices = new Uint8Array(data.length / 4); | ||
| for (let i = 0, j = 0; i < data.length; i += 4, j++) { | ||
| indices[j] = keyToIndex.get((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]); | ||
| } | ||
| return { palette, indices, bits }; | ||
| } | ||
| function nearest(palette, r, g, b) { | ||
| let best = 0, bestD = Infinity; | ||
| for (let i = 0; i < palette.length; i++) { | ||
| const p = palette[i]; | ||
| const dr = p[0] - r, dg = p[1] - g, db = p[2] - b; | ||
| const d = dr * dr + dg * dg + db * db; | ||
| if (d < bestD) { bestD = d; best = i; if (d === 0) break; } | ||
| } | ||
| return best; | ||
| } | ||
| function medianCut(colors, maxColors) { | ||
| let boxes = [colors]; | ||
| while (boxes.length < maxColors) { | ||
| let bi = -1, bestRange = -1, ch = 'r'; | ||
| for (let i = 0; i < boxes.length; i++) { | ||
| const box = boxes[i]; | ||
| if (box.length < 2) continue; | ||
| let rmin = 255, rmax = 0, gmin = 255, gmax = 0, bmin = 255, bmax = 0; | ||
| for (const c of box) { | ||
| if (c.r < rmin) rmin = c.r; if (c.r > rmax) rmax = c.r; | ||
| if (c.g < gmin) gmin = c.g; if (c.g > gmax) gmax = c.g; | ||
| if (c.b < bmin) bmin = c.b; if (c.b > bmax) bmax = c.b; | ||
| } | ||
| const rr = rmax - rmin, gg = gmax - gmin, bb = bmax - bmin; | ||
| const range = Math.max(rr, gg, bb); | ||
| if (range > bestRange) { | ||
| bestRange = range; | ||
| bi = i; | ||
| ch = rr >= gg && rr >= bb ? 'r' : (gg >= bb ? 'g' : 'b'); | ||
| } | ||
| } | ||
| if (bi < 0) break; | ||
| const box = boxes[bi]; | ||
| box.sort((a, b) => a[ch] - b[ch]); | ||
| const mid = box.length >> 1; | ||
| boxes.splice(bi, 1, box.slice(0, mid), box.slice(mid)); | ||
| } | ||
| return boxes.map(box => { | ||
| let r = 0, g = 0, b = 0, tot = 0; | ||
| for (const c of box) { r += c.r * c.n; g += c.g * c.n; b += c.b * c.n; tot += c.n; } | ||
| tot = tot || 1; | ||
| return [Math.round(r / tot), Math.round(g / tot), Math.round(b / tot)]; | ||
| }); | ||
| } | ||
| function lzwEncode(indices, minCodeSize) { | ||
| const clearCode = 1 << minCodeSize; | ||
| const eoiCode = clearCode + 1; | ||
| let codeSize = minCodeSize + 1; | ||
| let dict = new Map(); | ||
| let nextCode = clearCode + 2; | ||
| const out = []; | ||
| let cur = 0, curBits = 0; | ||
| const emit = code => { | ||
| cur |= code << curBits; | ||
| curBits += codeSize; | ||
| while (curBits >= 8) { out.push(cur & 0xff); cur >>>= 8; curBits -= 8; } | ||
| }; | ||
| const reset = () => { dict = new Map(); nextCode = clearCode + 2; codeSize = minCodeSize + 1; }; | ||
| emit(clearCode); | ||
| if (indices.length === 0) { | ||
| emit(eoiCode); | ||
| if (curBits > 0) out.push(cur & 0xff); | ||
| return out; | ||
| } | ||
| let prefix = indices[0]; | ||
| for (let i = 1; i < indices.length; i++) { | ||
| const k = indices[i]; | ||
| const key = (prefix << 8) | k; | ||
| if (dict.has(key)) { | ||
| prefix = dict.get(key); | ||
| } else { | ||
| emit(prefix); | ||
| // Grow the code size BEFORE assigning the next code (omggif/GIF semantics): | ||
| // emit the prefix at the current width, then widen. Growing after the | ||
| // increment switches one code too early and desyncs every decoder. | ||
| if (nextCode === 4096) { | ||
| emit(clearCode); | ||
| reset(); | ||
| } else { | ||
| if (nextCode >= (1 << codeSize) && codeSize < 12) codeSize++; | ||
| dict.set(key, nextCode++); | ||
| } | ||
| prefix = k; | ||
| } | ||
| } | ||
| emit(prefix); | ||
| emit(eoiCode); | ||
| if (curBits > 0) out.push(cur & 0xff); | ||
| return out; | ||
| } | ||
| export default gifExport; |
| /** | ||
| * htmlExport - Official SnapDOM Plugin | ||
| * Adds a toHtml() export that returns the capture as a self-contained, | ||
| * re-renderable HTML document (clone + inlined styles/fonts) instead of pixels. | ||
| * | ||
| * It unwraps the SVG <foreignObject> snapdom already produced, so the markup | ||
| * and CSS match the capture byte-for-byte. Nothing is rasterized. | ||
| * | ||
| * @param {Object} [options] | ||
| * @param {boolean} [options.fullDocument=true] - Wrap in <!DOCTYPE html>…; if false, return just <style> + fragment | ||
| * @param {string} [options.filename='capture.html'] - Download filename when opts.download is used | ||
| * @returns {Object} SnapDOM plugin | ||
| */ | ||
| export function htmlExport(options = {}) { | ||
| const { | ||
| fullDocument = true, | ||
| filename = 'capture.html', | ||
| } = options; | ||
| return { | ||
| name: 'html-export', | ||
| defineExports() { | ||
| return { | ||
| html: async (ctx, opts = {}) => { | ||
| const url = ctx.export.url; | ||
| if (typeof url !== 'string' || !url.startsWith('data:image/svg+xml')) { | ||
| throw new Error('[snapdom] html-export: capture is not an SVG data URL'); | ||
| } | ||
| const svgString = decodeURIComponent(url.replace(/^data:image\/svg\+xml[^,]*,/, '')); | ||
| const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml'); | ||
| const fo = doc.querySelector('foreignObject'); | ||
| if (!fo) throw new Error('[snapdom] html-export: capture has no foreignObject'); | ||
| const styleEl = fo.querySelector('style'); | ||
| const container = fo.querySelector('div'); | ||
| const css = styleEl ? styleEl.textContent : ''; | ||
| const body = container ? new XMLSerializer().serializeToString(container) : ''; | ||
| const asDoc = opts.fullDocument ?? fullDocument; | ||
| const html = asDoc | ||
| ? `<!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` | ||
| : `<style>${css}</style>\n${body}\n`; | ||
| const dl = opts.download; | ||
| if (dl) { | ||
| const blob = new Blob([html], { type: 'text/html' }); | ||
| const objUrl = URL.createObjectURL(blob); | ||
| const a = document.createElement('a'); | ||
| a.href = objUrl; | ||
| a.download = typeof dl === 'string' ? dl : (opts.filename || filename); | ||
| a.click(); | ||
| setTimeout(() => URL.revokeObjectURL(objUrl), 5000); | ||
| } | ||
| return html; | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
| } | ||
| export default htmlExport; |
+138
| /** | ||
| * videoExport - Official SnapDOM Plugin | ||
| * Adds a toMp4() export that records a video by re-capturing the live element | ||
| * over time and encoding the frames with the native MediaRecorder. | ||
| * | ||
| * Codec reality: MediaRecorder output depends on the browser. Safari produces | ||
| * MP4 (H.264); Chromium typically produces WebM (VP8/VP9). When MP4 is not | ||
| * supported the plugin falls back to WebM and warns. Returns a Blob whose type | ||
| * reflects what was actually produced. | ||
| * | ||
| * @param {Object} [options] | ||
| * @param {number} [options.fps=10] - Frames per second | ||
| * @param {number} [options.duration=2000] - Total duration in ms (ignored if options.frames is set) | ||
| * @param {number} [options.frames] - Explicit frame count (overrides duration) | ||
| * @param {string} [options.background='#ffffff'] - Color composited under transparent pixels | ||
| * @param {number} [options.scale=1] - Capture scale | ||
| * @param {number} [options.bitrate] - videoBitsPerSecond passed to MediaRecorder | ||
| * @param {string} [options.filename] - Download filename (extension auto-set to .mp4/.webm) | ||
| * @returns {Object} SnapDOM plugin | ||
| */ | ||
| import { snapdom } from '@zumer/snapdom'; | ||
| const MIME_CANDIDATES = [ | ||
| 'video/mp4;codecs=avc1', | ||
| 'video/mp4', | ||
| 'video/webm;codecs=vp9', | ||
| 'video/webm;codecs=vp8', | ||
| 'video/webm', | ||
| ]; | ||
| export function videoExport(options = {}) { | ||
| const { | ||
| fps = 10, | ||
| duration = 2000, | ||
| frames: frameOpt = null, | ||
| background = '#ffffff', | ||
| scale = 1, | ||
| bitrate = null, | ||
| filename = null, | ||
| } = options; | ||
| return { | ||
| name: 'video-export', | ||
| // The export ctx comes from createContext (no `element`). Stash the live | ||
| // element during a capture hook so toMp4() can re-capture frames from it. | ||
| beforeSnap(ctx) { | ||
| if (ctx && ctx.options) ctx.options.__snapSource = ctx.element; | ||
| }, | ||
| defineExports() { | ||
| return { | ||
| mp4: async (ctx, opts = {}) => { | ||
| if (typeof MediaRecorder === 'undefined') { | ||
| throw new Error('[snapdom] video-export: MediaRecorder is not available in this environment'); | ||
| } | ||
| const el = ctx.__snapSource || ctx.element; | ||
| if (!el) throw new Error('[snapdom] video-export: no source element on context'); | ||
| const _fps = opts.fps ?? fps; | ||
| const _dur = opts.duration ?? duration; | ||
| const _count = Math.max(1, opts.frames ?? frameOpt ?? Math.round((_dur / 1000) * _fps)); | ||
| const _bg = opts.background ?? background; | ||
| const _scale = opts.scale ?? scale ?? ctx.scale ?? 1; | ||
| const _bitrate = opts.bitrate ?? bitrate; | ||
| const frameMs = 1000 / _fps; | ||
| // 1) Pre-render every frame onto a fixed-size canvas. | ||
| let W = 0, H = 0; | ||
| const frames = []; | ||
| for (let i = 0; i < _count; i++) { | ||
| const cap = await snapdom(el, { scale: _scale, backgroundColor: _bg, fast: true }); | ||
| const src = await cap.toCanvas(); | ||
| if (i === 0) { W = src.width; H = src.height; } | ||
| const fc = document.createElement('canvas'); | ||
| fc.width = W; fc.height = H; | ||
| const fx = fc.getContext('2d'); | ||
| fx.fillStyle = _bg; | ||
| fx.fillRect(0, 0, W, H); | ||
| fx.drawImage(src, 0, 0, W, H); | ||
| frames.push(fc); | ||
| if (i < _count - 1) await new Promise(r => setTimeout(r, frameMs)); | ||
| } | ||
| // 2) Pick the best supported container/codec. | ||
| const mimeType = MIME_CANDIDATES.find(t => | ||
| typeof MediaRecorder.isTypeSupported === 'function' && MediaRecorder.isTypeSupported(t) | ||
| ) || ''; | ||
| if (mimeType && !mimeType.startsWith('video/mp4')) { | ||
| console.warn(`[snapdom] video-export: MP4 not supported by this browser's MediaRecorder; falling back to ${mimeType}`); | ||
| } | ||
| // 3) Play the frames onto a stage canvas while recording its stream. | ||
| const stage = document.createElement('canvas'); | ||
| stage.width = W; stage.height = H; | ||
| const sctx = stage.getContext('2d'); | ||
| const stream = stage.captureStream(_fps); | ||
| const recOpts = {}; | ||
| if (mimeType) recOpts.mimeType = mimeType; | ||
| if (_bitrate) recOpts.videoBitsPerSecond = _bitrate; | ||
| const rec = new MediaRecorder(stream, recOpts); | ||
| const chunks = []; | ||
| rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); }; | ||
| const stopped = new Promise(res => { rec.onstop = res; }); | ||
| rec.start(); | ||
| for (let i = 0; i < frames.length; i++) { | ||
| sctx.clearRect(0, 0, W, H); | ||
| sctx.drawImage(frames[i], 0, 0); | ||
| await new Promise(r => setTimeout(r, frameMs)); | ||
| } | ||
| await new Promise(r => setTimeout(r, frameMs)); // let the last frame land | ||
| rec.stop(); | ||
| await stopped; | ||
| const isMp4 = mimeType.startsWith('video/mp4'); | ||
| const blob = new Blob(chunks, { type: (mimeType || 'video/webm').split(';')[0] }); | ||
| const dl = opts.download; | ||
| if (dl) { | ||
| const objUrl = URL.createObjectURL(blob); | ||
| const a = document.createElement('a'); | ||
| a.href = objUrl; | ||
| const fallbackName = isMp4 ? 'capture.mp4' : 'capture.webm'; | ||
| a.download = typeof dl === 'string' ? dl : (opts.filename || filename || fallbackName); | ||
| a.click(); | ||
| setTimeout(() => URL.revokeObjectURL(objUrl), 5000); | ||
| } | ||
| return blob; | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
| } | ||
| export default videoExport; |
+4
-1
@@ -16,2 +16,5 @@ /** | ||
| export { agentMap } from './agent-map.js'; | ||
| // export { htmlInCanvas } from './html-in-canvas.js'; | ||
| export { htmlExport } from './html-export.js'; | ||
| export { gifExport } from './gif-export.js'; | ||
| export { videoExport } from './video-export.js'; | ||
| export { htmlInCanvas } from './html-in-canvas.js'; |
+6
-3
| { | ||
| "name": "@zumer/snapdom-plugins", | ||
| "version": "2.1.0", | ||
| "version": "2.2.0", | ||
| "description": "Official plugins for SnapDOM", | ||
@@ -15,3 +15,6 @@ "type": "module", | ||
| "./pdf-image": "./pdf-image.js", | ||
| "./agent-map": "./agent-map.js" | ||
| "./agent-map": "./agent-map.js", | ||
| "./html-export": "./html-export.js", | ||
| "./gif-export": "./gif-export.js", | ||
| "./video-export": "./video-export.js" | ||
| }, | ||
@@ -40,4 +43,4 @@ "files": [ | ||
| "peerDependencies": { | ||
| "@zumer/snapdom": ">=2.7.0" | ||
| "@zumer/snapdom": ">=2.12.1" | ||
| } | ||
| } |
+82
-0
@@ -241,2 +241,84 @@ # @zumer/snapdom-plugins | ||
| ### `html-export` | ||
| Adds a `toHtml()` export that returns the capture as a self-contained, **re-renderable HTML document** (clone + inlined styles/fonts) instead of pixels. It unwraps the SVG `<foreignObject>` SnapDOM already produced, so the markup and CSS match the capture byte-for-byte — nothing is rasterized. | ||
| ```js | ||
| import { htmlExport } from '@zumer/snapdom-plugins/html-export'; | ||
| const result = await snapdom(el, { plugins: [htmlExport()] }); | ||
| const html = await result.toHtml(); // full <!DOCTYPE html> string | ||
| // Or download a .html file: | ||
| await result.toHtml({ download: 'snapshot.html' }); | ||
| ``` | ||
| | Option | Type | Default | Description | | ||
| |--------|------|---------|-------------| | ||
| | `fullDocument` | `boolean` | `true` | Wrap output in `<!DOCTYPE html>…`; if `false`, return just `<style>` + the fragment | | ||
| | `filename` | `string` | `'capture.html'` | Download filename when `opts.download` is `true` | | ||
| Per-call `opts`: `download` (`boolean \| string` — `true` triggers download, a string sets the filename), plus `fullDocument` / `filename` overrides. Returns the HTML `string`. | ||
| --- | ||
| ### `gif-export` | ||
| Adds a `toGif()` export that records an **animated GIF** by re-capturing the live element over time and encoding the frames. The GIF89a encoder (median-cut quantization + LZW) is built in — no dependencies. Returns a `Blob` (`image/gif`). | ||
| ```js | ||
| import { gifExport } from '@zumer/snapdom-plugins/gif-export'; | ||
| const result = await snapdom(el, { plugins: [gifExport({ fps: 12, duration: 3000 })] }); | ||
| const blob = await result.toGif(); | ||
| // Or download directly: | ||
| await result.toGif({ download: 'animation.gif' }); | ||
| ``` | ||
| | Option | Type | Default | Description | | ||
| |--------|------|---------|-------------| | ||
| | `fps` | `number` | `10` | Frames per second | | ||
| | `duration` | `number` | `2000` | Total duration in ms (ignored if `frames` is set) | | ||
| | `frames` | `number` | — | Explicit frame count (overrides `duration`) | | ||
| | `maxColors` | `number` | `256` | Palette size per frame (2–256) | | ||
| | `background` | `string` | `'#ffffff'` | Color composited under transparent pixels | | ||
| | `scale` | `number` | `1` | Capture scale | | ||
| | `repeat` | `number` | `0` | Loop count (`0` = forever, `-1` = play once) | | ||
| | `filename` | `string` | `'capture.gif'` | Download filename | | ||
| Per-call `opts` override any constructor option, plus `download` (`boolean \| string`). Each frame is captured live, so CSS animations / dynamic content are recorded as they play. | ||
| --- | ||
| ### `video-export` | ||
| Adds a `toMp4()` export that records a **video** by re-capturing the live element over time and encoding the frames with the native `MediaRecorder`. Returns a `Blob` whose type reflects what was actually produced. | ||
| ```js | ||
| import { videoExport } from '@zumer/snapdom-plugins/video-export'; | ||
| const result = await snapdom(el, { plugins: [videoExport({ fps: 30, duration: 4000 })] }); | ||
| const blob = await result.toMp4(); | ||
| // Or download directly: | ||
| await result.toMp4({ download: true }); | ||
| ``` | ||
| > **Codec reality:** `MediaRecorder` output depends on the browser. Safari produces MP4 (H.264); Chromium typically produces WebM (VP8/VP9). When MP4 isn't supported the plugin falls back to WebM, warns in the console, and the downloaded file extension is set to `.mp4` / `.webm` accordingly. | ||
| | Option | Type | Default | Description | | ||
| |--------|------|---------|-------------| | ||
| | `fps` | `number` | `10` | Frames per second | | ||
| | `duration` | `number` | `2000` | Total duration in ms (ignored if `frames` is set) | | ||
| | `frames` | `number` | — | Explicit frame count (overrides `duration`) | | ||
| | `background` | `string` | `'#ffffff'` | Color composited under transparent pixels | | ||
| | `scale` | `number` | `1` | Capture scale | | ||
| | `bitrate` | `number` | — | `videoBitsPerSecond` passed to `MediaRecorder` | | ||
| | `filename` | `string` | — | Download filename (extension auto-set to `.mp4` / `.webm`) | | ||
| Requires `MediaRecorder` (unavailable in some headless environments). Per-call `opts` override any constructor option, plus `download` (`boolean \| string`). | ||
| --- | ||
| ## Plugin registration | ||
@@ -243,0 +325,0 @@ |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
63860
51.58%14
27.27%1215
55.17%361
29.39%0
-100%