Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@zumer/snapdom-plugins

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@zumer/snapdom-plugins - npm Package Compare versions

Comparing version
2.1.0
to
2.2.0
+279
gif-export.js
/**
* 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;
/**
* 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"
}
}

@@ -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 @@