@zumer/snapdom-plugins 2.2.2 → 3.0.0-beta.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/LICENSE +21 -0
- package/README.md +192 -50
- package/agent-map.d.ts +31 -0
- package/agent-map.js +248 -88
- package/ascii-export.d.ts +20 -0
- package/ascii-export.js +17 -12
- package/capture-frames.js +85 -0
- package/color-tint.d.ts +18 -0
- package/color-tint.js +7 -4
- package/context-export.d.ts +25 -0
- package/context-export.js +283 -0
- package/filter.d.ts +17 -0
- package/filter.js +10 -3
- package/gif-export.d.ts +29 -0
- package/gif-export.js +15 -21
- package/html-export.d.ts +31 -0
- package/html-export.js +62 -16
- package/index.d.ts +19 -0
- package/index.js +2 -1
- package/package.json +11 -4
- package/pdf-image.d.ts +18 -0
- package/pdf-image.js +14 -14
- package/privacy-policy.js +55 -0
- package/redact-clone.js +150 -0
- package/redact-inputs.d.ts +43 -0
- package/redact-inputs.js +175 -0
- package/replace-text.d.ts +20 -0
- package/replace-text.js +6 -0
- package/timestamp-overlay.d.ts +19 -0
- package/timestamp-overlay.js +4 -1
- package/video-export.d.ts +28 -0
- package/video-export.js +70 -52
- package/html-in-canvas.js +0 -139
package/redact-inputs.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redactInputs - Official SnapDOM Plugin
|
|
3
|
+
* Mask typed values and optionally exclude blocks or remove named attributes in captures.
|
|
4
|
+
*
|
|
5
|
+
* Core captures what the browser paints, which is the whole point of a fidelity-first
|
|
6
|
+
* renderer: an `email` or `tel` field shows its value in plain text on screen, so the
|
|
7
|
+
* capture shows it too. The ONE exception core makes is `type="password"`, and only because
|
|
8
|
+
* the control already paints bullets, so masking it costs zero fidelity.
|
|
9
|
+
*
|
|
10
|
+
* Redacting anything else is a deliberate loss of fidelity, so it is opt-in and lives here.
|
|
11
|
+
* Defaults cover the fields that are usually sensitive AND rendered in the clear.
|
|
12
|
+
*
|
|
13
|
+
* The default mask keeps the string's length. Glyph widths can differ, so wrapping and
|
|
14
|
+
* truncation may change. `mask: () => ''` blanks a field outright.
|
|
15
|
+
*
|
|
16
|
+
* The built-in mask without a selector permits unchanged-repeat memoization. Custom maskers
|
|
17
|
+
* and selectors (including block/attribute rules) run on every capture because they may
|
|
18
|
+
* read changing external state. Official semantic exporters share this capture's policy.
|
|
19
|
+
* A changed tree rebuilds fully: the differential path cannot skip this afterClone transformation.
|
|
20
|
+
* Pinned by __tests__/plugins.transforms.v3.test.js.
|
|
21
|
+
* @module plugins/redact-inputs
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} [options]
|
|
24
|
+
* @param {string[]} [options.types] - input `type` values to redact. Default:
|
|
25
|
+
* ['email', 'tel']. Note 'password' is already masked by core.
|
|
26
|
+
* @param {string[]} [options.autocomplete] - autocomplete tokens to redact. A trailing '*'
|
|
27
|
+
* matches by prefix. Default: ['cc-*', 'current-password', 'new-password', 'one-time-code'].
|
|
28
|
+
* @param {string} [options.selector] - extra CSS selector for inputs and textareas
|
|
29
|
+
* (use it for textareas and app-specific fields).
|
|
30
|
+
* @param {boolean} [options.all=false] - redact EVERY input and textarea, ignoring the lists.
|
|
31
|
+
* @param {(value: string, el: Element) => string} [options.mask] - custom masker.
|
|
32
|
+
* @param {string|string[]} [options.blocks] - CSS selectors for subtrees to exclude,
|
|
33
|
+
* honoring the capture's excludeMode (hide by default).
|
|
34
|
+
* @param {{selector: string, names: string[]}[]} [options.attributes] - exact attributes
|
|
35
|
+
* to remove from matching source elements. Does not remove copies in visible text/CSS.
|
|
36
|
+
* @returns {Object} SnapDOM plugin
|
|
37
|
+
*/
|
|
38
|
+
import { createPrivacyPolicy, getPrivacyPolicy, setPrivacyPolicy } from './privacy-policy.js';
|
|
39
|
+
import { sanitizeClone } from './redact-clone.js';
|
|
40
|
+
|
|
41
|
+
export function redactInputs(options = {}) {
|
|
42
|
+
const {
|
|
43
|
+
types = ['email', 'tel'],
|
|
44
|
+
autocomplete = ['cc-*', 'current-password', 'new-password', 'one-time-code'],
|
|
45
|
+
selector = '',
|
|
46
|
+
all = false,
|
|
47
|
+
mask = (value) => '•'.repeat(value.length),
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
const blockInput = options.blocks ?? [];
|
|
51
|
+
const blocks = typeof blockInput === 'string' ? [blockInput] : blockInput;
|
|
52
|
+
if (!Array.isArray(blocks) || blocks.some(value => typeof value !== 'string' || !value.trim())) {
|
|
53
|
+
throw new TypeError('[snapdom] redact-inputs: blocks must be a selector or an array of non-empty selectors');
|
|
54
|
+
}
|
|
55
|
+
const blockSelectors = [...new Set(blocks.map(value => value.trim()))];
|
|
56
|
+
const attributeInput = options.attributes ?? [];
|
|
57
|
+
if (!Array.isArray(attributeInput)) throw new TypeError('[snapdom] redact-inputs: attributes must be an array of rules');
|
|
58
|
+
const attributes = attributeInput.map(rule => {
|
|
59
|
+
if (!rule || typeof rule.selector !== 'string' || !rule.selector.trim() || !Array.isArray(rule.names) ||
|
|
60
|
+
rule.names.some(name => typeof name !== 'string' || !/^[a-zA-Z_][\w:.-]*$/.test(name))) {
|
|
61
|
+
throw new TypeError('[snapdom] redact-inputs: attribute rules need a selector and exact attribute names');
|
|
62
|
+
}
|
|
63
|
+
return { selector: rule.selector.trim(), names: [...new Set(rule.names)] };
|
|
64
|
+
}).filter(rule => rule.names.length);
|
|
65
|
+
const extended = blockSelectors.length > 0 || attributes.length > 0;
|
|
66
|
+
const validatedDocuments = new WeakSet();
|
|
67
|
+
const validateSelectors = (doc) => {
|
|
68
|
+
if (!extended || validatedDocuments.has(doc)) return;
|
|
69
|
+
const probe = doc.createElement('div');
|
|
70
|
+
for (const value of [...blockSelectors, ...attributes.map(rule => rule.selector)]) {
|
|
71
|
+
try { probe.matches(value); }
|
|
72
|
+
catch { throw new TypeError(`[snapdom] redact-inputs: invalid privacy selector ${JSON.stringify(value)}`); }
|
|
73
|
+
}
|
|
74
|
+
validatedDocuments.add(doc);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const typeSet = new Set(types.map((t) => String(t).toLowerCase()));
|
|
78
|
+
const acExact = new Set();
|
|
79
|
+
const acPrefixes = [];
|
|
80
|
+
for (const token of autocomplete) {
|
|
81
|
+
const t = String(token).toLowerCase();
|
|
82
|
+
if (t.endsWith('*')) acPrefixes.push(t.slice(0, -1));
|
|
83
|
+
else acExact.add(t);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function matchesAutocomplete(el) {
|
|
87
|
+
const ac = (el.getAttribute('autocomplete') || '').toLowerCase();
|
|
88
|
+
if (!ac) return false;
|
|
89
|
+
for (const token of ac.split(/\s+/)) {
|
|
90
|
+
if (acExact.has(token)) return true;
|
|
91
|
+
for (const prefix of acPrefixes) {
|
|
92
|
+
if (token.startsWith(prefix)) return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function shouldRedact(el) {
|
|
99
|
+
if (all) return true;
|
|
100
|
+
if (selector) {
|
|
101
|
+
try {
|
|
102
|
+
if (el.matches(selector)) return true;
|
|
103
|
+
} catch { /* invalid selector: the other rules still apply */ }
|
|
104
|
+
}
|
|
105
|
+
// Read the ATTRIBUTE, not el.type: an unknown type attribute reflects as 'text' through
|
|
106
|
+
// the property, so a `type="email"` the engine does not implement would slip through.
|
|
107
|
+
if (el.tagName === 'INPUT' && typeSet.has((el.getAttribute('type') || 'text').toLowerCase())) return true;
|
|
108
|
+
return matchesAutocomplete(el);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
name: 'redact-inputs',
|
|
113
|
+
pure: options.mask === undefined && !selector && !extended,
|
|
114
|
+
|
|
115
|
+
beforeSnap(ctx) {
|
|
116
|
+
validateSelectors(ctx.element.ownerDocument);
|
|
117
|
+
const policy = createPrivacyPolicy(blockSelectors, attributes, shouldRedact);
|
|
118
|
+
setPrivacyPolicy(ctx, policy);
|
|
119
|
+
if (blockSelectors.length) {
|
|
120
|
+
// createContext's compiled exclusion policy closes over this array. Extend it
|
|
121
|
+
// in place so core and semantic exporters see the same policy before cloning.
|
|
122
|
+
const exclude = ctx.exclude;
|
|
123
|
+
if (!Array.isArray(exclude)) throw new Error('[snapdom] redact-inputs requires normalized capture exclusions');
|
|
124
|
+
for (const value of blockSelectors) if (!exclude.includes(value)) exclude.push(value);
|
|
125
|
+
// Capturing a child of a private region must not escape that region's policy.
|
|
126
|
+
if (policy.isBlocked(ctx.element) && !exclude.includes('*')) exclude.push('*');
|
|
127
|
+
const previous = ctx.shouldExclude;
|
|
128
|
+
ctx.shouldExclude = (el) => policy.isBlocked(el) || previous(el);
|
|
129
|
+
if (ctx.excludeMode === 'remove' && policy.isBlocked(ctx.element)) {
|
|
130
|
+
throw new Error('[snapdom] redact-inputs: cannot remove the capture root; use excludeMode: "hide"');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// Asset/pseudo phases can create attributes and clone referenced SVG definitions.
|
|
136
|
+
// Only the extended rules need this pass; the default field plugin keeps its route.
|
|
137
|
+
...(extended ? { beforeRender(ctx) { sanitizeClone(ctx, getPrivacyPolicy(ctx), { final: true }); } } : {}),
|
|
138
|
+
|
|
139
|
+
afterClone(ctx) {
|
|
140
|
+
const root = ctx.clone;
|
|
141
|
+
if (!root || !root.querySelectorAll) return;
|
|
142
|
+
// querySelectorAll skips the root itself, and the capture root IS routinely a single
|
|
143
|
+
// field. Include it explicitly (this is the #461 shape of bug).
|
|
144
|
+
const nodes = [...root.querySelectorAll('input, textarea')];
|
|
145
|
+
if (root.matches?.('input, textarea')) nodes.unshift(root);
|
|
146
|
+
|
|
147
|
+
for (const el of nodes) {
|
|
148
|
+
// Core turns date/time controls into text on Firefox/WebKit. Their source still
|
|
149
|
+
// carries the type and selector state the caller asked to match.
|
|
150
|
+
if (!shouldRedact(ctx.nodeMap?.get(el) || el)) continue;
|
|
151
|
+
if (el.tagName === 'TEXTAREA') {
|
|
152
|
+
const masked = String(mask(el.textContent || '', el));
|
|
153
|
+
// Native canvas rendering and later plugins read value; SVG serialization reads text.
|
|
154
|
+
el.value = masked;
|
|
155
|
+
el.textContent = masked;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const masked = String(mask(el.value || '', el));
|
|
159
|
+
try { el.value = masked; } catch {
|
|
160
|
+
// File inputs reject non-empty values. A redacted clone must display the mask,
|
|
161
|
+
// not abort the capture before the remaining fields have been processed.
|
|
162
|
+
el.type = 'text';
|
|
163
|
+
el.value = masked;
|
|
164
|
+
}
|
|
165
|
+
if (el.value !== masked) {
|
|
166
|
+
// Number/date controls silently sanitize arbitrary mask text to an empty value.
|
|
167
|
+
el.type = 'text';
|
|
168
|
+
el.value = masked;
|
|
169
|
+
}
|
|
170
|
+
el.setAttribute('value', masked);
|
|
171
|
+
}
|
|
172
|
+
if (extended) sanitizeClone(ctx, getPrivacyPolicy(ctx) || createPrivacyPolicy(blockSelectors, attributes, shouldRedact));
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/replace-text`.
|
|
3
|
+
*
|
|
4
|
+
* The plugin instance type comes from the core package: one definition of what a plugin IS,
|
|
5
|
+
* so a plugin that stops matching the host's contract fails to typecheck instead of failing
|
|
6
|
+
* at capture time.
|
|
7
|
+
*/
|
|
8
|
+
import type { SnapdomPlugin } from '@zumer/snapdom';
|
|
9
|
+
|
|
10
|
+
export interface TextReplacement {
|
|
11
|
+
find: string | RegExp;
|
|
12
|
+
replace: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReplaceTextOptions {
|
|
16
|
+
replacements?: TextReplacement[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Replaces DOM text in the clone, leaving stylesheet/script text unchanged. */
|
|
20
|
+
export declare function replaceText(options?: ReplaceTextOptions): SnapdomPlugin;
|
package/replace-text.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* replaceText - Official SnapDOM Plugin
|
|
3
3
|
* Find-and-replace text in the captured clone.
|
|
4
|
+
* Preserves stylesheet/script text; replacement arrays remain live between captures.
|
|
5
|
+
* Pinned by __tests__/plugins.transforms.v3.test.js.
|
|
6
|
+
* @module plugins/replace-text
|
|
4
7
|
*
|
|
5
8
|
* @param {Object} [options]
|
|
6
9
|
* @param {Array<{find: string|RegExp, replace: string}>} [options.replacements=[]]
|
|
@@ -12,12 +15,15 @@ export function replaceText(options = {}) {
|
|
|
12
15
|
} = options;
|
|
13
16
|
|
|
14
17
|
function walkTextNodes(node, fn) {
|
|
18
|
+
// Retained CSS is text too, but changing it would replace styles, URLs and selectors.
|
|
19
|
+
if (node.nodeType === Node.ELEMENT_NODE && /^(style|script)$/i.test(node.localName)) return;
|
|
15
20
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
16
21
|
fn(node);
|
|
17
22
|
} else {
|
|
18
23
|
for (const child of node.childNodes) {
|
|
19
24
|
walkTextNodes(child, fn);
|
|
20
25
|
}
|
|
26
|
+
if (node.localName === 'textarea') node.value = node.textContent;
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/timestamp-overlay`.
|
|
3
|
+
*
|
|
4
|
+
* The plugin instance type comes from the core package: one definition of what a plugin IS,
|
|
5
|
+
* so a plugin that stops matching the host's contract fails to typecheck instead of failing
|
|
6
|
+
* at capture time.
|
|
7
|
+
*/
|
|
8
|
+
import type { SnapdomPlugin } from '@zumer/snapdom';
|
|
9
|
+
|
|
10
|
+
export interface TimestampOverlayOptions {
|
|
11
|
+
format?: 'datetime' | 'date' | 'time' | 'iso' | ((date: Date) => string);
|
|
12
|
+
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
13
|
+
background?: string;
|
|
14
|
+
color?: string;
|
|
15
|
+
fontSize?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Adds a timestamp label, evaluating the clock/formatter again for every capture. */
|
|
19
|
+
export declare function timestampOverlay(options?: TimestampOverlayOptions): SnapdomPlugin;
|
package/timestamp-overlay.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* timestampOverlay - Official SnapDOM Plugin
|
|
3
3
|
* Adds a translucent timestamp label to the captured clone.
|
|
4
|
+
* Reads the clock/formatter for every capture; it does not opt into unchanged-repeat memoization.
|
|
5
|
+
* Pinned by __tests__/plugins.transforms.v3.test.js.
|
|
6
|
+
* @module plugins/timestamp-overlay
|
|
4
7
|
*
|
|
5
8
|
* @param {Object} [options]
|
|
6
|
-
* @param {string} [options.format='datetime'] - 'datetime' | 'date' | 'time' | 'iso' | custom
|
|
9
|
+
* @param {string|((date: Date) => string)} [options.format='datetime'] - 'datetime' | 'date' | 'time' | 'iso' | custom formatter
|
|
7
10
|
* @param {string} [options.position='bottom-right'] - 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
|
8
11
|
* @param {string} [options.background='rgba(0,0,0,0.6)']
|
|
9
12
|
* @param {string} [options.color='#fff']
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/video-export`.
|
|
3
|
+
*
|
|
4
|
+
* The plugin instance type comes from the core package: one definition of what a plugin IS,
|
|
5
|
+
* so a plugin that stops matching the host's contract fails to typecheck instead of failing
|
|
6
|
+
* at capture time.
|
|
7
|
+
*/
|
|
8
|
+
import type { SnapdomPlugin } from '@zumer/snapdom';
|
|
9
|
+
|
|
10
|
+
export interface VideoExportOptions {
|
|
11
|
+
fps?: number;
|
|
12
|
+
/** Total duration in ms. Ignored when `frames` is set. */
|
|
13
|
+
duration?: number;
|
|
14
|
+
/** Explicit frame count; overrides `duration`. */
|
|
15
|
+
frames?: number;
|
|
16
|
+
/** Color composited under transparent pixels. */
|
|
17
|
+
background?: string;
|
|
18
|
+
/** Capture scale; inherits the original capture when omitted. */
|
|
19
|
+
scale?: number;
|
|
20
|
+
/** `videoBitsPerSecond` passed to MediaRecorder. */
|
|
21
|
+
bitrate?: number;
|
|
22
|
+
/** Download filename; the default follows the actual .mp4/.webm container. */
|
|
23
|
+
filename?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Adds `result.toMp4()`: records through MediaRecorder, with WebM fallback when needed. */
|
|
27
|
+
export declare function videoExport(options?: VideoExportOptions): SnapdomPlugin;
|
|
28
|
+
export default videoExport;
|
package/video-export.js
CHANGED
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
* @param {number} [options.duration=2000] - Total duration in ms (ignored if options.frames is set)
|
|
14
14
|
* @param {number} [options.frames] - Explicit frame count (overrides duration)
|
|
15
15
|
* @param {string} [options.background='#ffffff'] - Color composited under transparent pixels
|
|
16
|
-
* @param {number} [options.scale
|
|
16
|
+
* @param {number} [options.scale] - Capture scale (inherits the capture when omitted)
|
|
17
17
|
* @param {number} [options.bitrate] - videoBitsPerSecond passed to MediaRecorder
|
|
18
|
-
* @param {string} [options.filename] - Download filename (
|
|
18
|
+
* @param {string} [options.filename] - Download filename (default follows the recorded container)
|
|
19
19
|
* @returns {Object} SnapDOM plugin
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
21
|
+
import { rememberCanvas, exportOption, frameOptions } from './capture-frames.js';
|
|
22
22
|
|
|
23
23
|
const MIME_CANDIDATES = [
|
|
24
24
|
'video/mp4;codecs=avc1',
|
|
@@ -34,7 +34,7 @@ export function videoExport(options = {}) {
|
|
|
34
34
|
duration = 2000,
|
|
35
35
|
frames: frameOpt = null,
|
|
36
36
|
background = '#ffffff',
|
|
37
|
-
scale
|
|
37
|
+
scale,
|
|
38
38
|
bitrate = null,
|
|
39
39
|
filename = null,
|
|
40
40
|
} = options;
|
|
@@ -42,45 +42,28 @@ export function videoExport(options = {}) {
|
|
|
42
42
|
return {
|
|
43
43
|
name: 'video-export',
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
beforeSnap(ctx) {
|
|
48
|
-
if (ctx && ctx.options) ctx.options.__snapSource = ctx.element;
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
defineExports() {
|
|
45
|
+
defineExports(context) {
|
|
46
|
+
rememberCanvas(context);
|
|
52
47
|
return {
|
|
53
48
|
mp4: async (ctx, opts = {}) => {
|
|
54
49
|
if (typeof MediaRecorder === 'undefined') {
|
|
55
50
|
throw new Error('[snapdom] video-export: MediaRecorder is not available in this environment');
|
|
56
51
|
}
|
|
57
|
-
|
|
52
|
+
if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') {
|
|
53
|
+
throw new Error('[snapdom] video-export: canvas.captureStream is not available in this environment');
|
|
54
|
+
}
|
|
55
|
+
const el = ctx.element;
|
|
58
56
|
if (!el) throw new Error('[snapdom] video-export: no source element on context');
|
|
59
57
|
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const _bg = opts.background ?? background;
|
|
64
|
-
const _scale = opts.scale ?? scale ?? ctx.scale ?? 1;
|
|
65
|
-
const _bitrate = opts.bitrate ?? bitrate;
|
|
58
|
+
const recording = frameOptions(ctx, opts, { fps, duration, frames: frameOpt, scale, background }, 'video-export');
|
|
59
|
+
const { fps: _fps, count: _count, background: _bg } = recording;
|
|
60
|
+
const _bitrate = exportOption(ctx, opts, 'bitrate', bitrate);
|
|
66
61
|
const frameMs = 1000 / _fps;
|
|
67
62
|
|
|
68
|
-
// 1)
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
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
|
-
}
|
|
63
|
+
// 1) Recapture the live first frame, retaining the caller's capture policy.
|
|
64
|
+
// Later frames use the same options and the engine's normal freshness checks.
|
|
65
|
+
const firstSrc = await recording.next();
|
|
66
|
+
const W = firstSrc.width, H = firstSrc.height;
|
|
84
67
|
|
|
85
68
|
// 2) Pick the best supported container/codec.
|
|
86
69
|
const mimeType = MIME_CANDIDATES.find(t =>
|
|
@@ -90,33 +73,68 @@ export function videoExport(options = {}) {
|
|
|
90
73
|
console.warn(`[snapdom] video-export: MP4 not supported by this browser's MediaRecorder; falling back to ${mimeType}`);
|
|
91
74
|
}
|
|
92
75
|
|
|
93
|
-
// 3)
|
|
76
|
+
// 3) Request each painted frame where supported. MediaRecorder uses a real-time
|
|
77
|
+
// clock: slow captures can still stretch timing; requestFrame supplies no timestamp.
|
|
94
78
|
const stage = document.createElement('canvas');
|
|
95
79
|
stage.width = W; stage.height = H;
|
|
96
80
|
const sctx = stage.getContext('2d');
|
|
97
|
-
const
|
|
98
|
-
|
|
81
|
+
const paint = (src) => {
|
|
82
|
+
sctx.fillStyle = _bg;
|
|
83
|
+
sctx.fillRect(0, 0, W, H);
|
|
84
|
+
sctx.drawImage(src, 0, 0, W, H);
|
|
85
|
+
};
|
|
99
86
|
const recOpts = {};
|
|
100
87
|
if (mimeType) recOpts.mimeType = mimeType;
|
|
101
88
|
if (_bitrate) recOpts.videoBitsPerSecond = _bitrate;
|
|
102
|
-
const rec = new MediaRecorder(stream, recOpts);
|
|
103
|
-
|
|
104
89
|
const chunks = [];
|
|
105
|
-
|
|
106
|
-
|
|
90
|
+
let stream, rec;
|
|
91
|
+
try {
|
|
92
|
+
stream = stage.captureStream(0);
|
|
93
|
+
let track = stream.getVideoTracks()[0];
|
|
94
|
+
if (!track) throw new Error('[snapdom] video-export: captureStream produced no video track');
|
|
95
|
+
if (typeof track.requestFrame !== 'function') {
|
|
96
|
+
// A zero-rate track without requestFrame never receives later paintings.
|
|
97
|
+
for (const t of stream.getTracks()) t.stop();
|
|
98
|
+
stream = stage.captureStream(_fps);
|
|
99
|
+
track = stream.getVideoTracks()[0];
|
|
100
|
+
if (!track) throw new Error('[snapdom] video-export: captureStream produced no video track');
|
|
101
|
+
}
|
|
102
|
+
const pushFrame = () => track.requestFrame?.();
|
|
103
|
+
rec = new MediaRecorder(stream, recOpts);
|
|
104
|
+
rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
|
|
105
|
+
const stopped = new Promise(resolve => { rec.onstop = resolve; });
|
|
106
|
+
const failed = new Promise((_, reject) => {
|
|
107
|
+
rec.onerror = event => reject(event.error || new Error('[snapdom] video-export: MediaRecorder failed'));
|
|
108
|
+
});
|
|
109
|
+
// An error can arrive between awaited frame operations. Mark it handled now;
|
|
110
|
+
// every wait below still observes the original rejection and exits the recorder.
|
|
111
|
+
failed.catch(() => {});
|
|
112
|
+
const wait = promise => Promise.race([promise, failed]);
|
|
113
|
+
const sleep = ms => wait(new Promise(resolve => setTimeout(resolve, ms)));
|
|
107
114
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
await
|
|
115
|
+
rec.start();
|
|
116
|
+
paint(firstSrc);
|
|
117
|
+
pushFrame();
|
|
118
|
+
// Hold the first frame too; immediately painting the second used to erase it.
|
|
119
|
+
await sleep(frameMs);
|
|
120
|
+
for (let i = 1; i < _count; i++) {
|
|
121
|
+
const t0 = performance.now();
|
|
122
|
+
paint(await wait(recording.next()));
|
|
123
|
+
pushFrame();
|
|
124
|
+
const remaining = frameMs - (performance.now() - t0);
|
|
125
|
+
// Give the last painting a full frame interval before stopping the encoder.
|
|
126
|
+
if (i === _count - 1 || remaining > 0) await sleep(i === _count - 1 ? frameMs : remaining);
|
|
127
|
+
}
|
|
128
|
+
rec.stop();
|
|
129
|
+
await wait(stopped);
|
|
130
|
+
} finally {
|
|
131
|
+
if (rec && rec.state !== 'inactive') { try { rec.stop(); } catch { /* already failed */ } }
|
|
132
|
+
for (const track of stream?.getTracks() || []) track.stop();
|
|
113
133
|
}
|
|
114
|
-
await new Promise(r => setTimeout(r, frameMs)); // let the last frame land
|
|
115
|
-
rec.stop();
|
|
116
|
-
await stopped;
|
|
117
134
|
|
|
118
|
-
const
|
|
119
|
-
const
|
|
135
|
+
const actualType = (rec.mimeType || chunks.find(chunk => chunk.type)?.type || mimeType || 'video/webm').split(';')[0];
|
|
136
|
+
const isMp4 = actualType.startsWith('video/mp4');
|
|
137
|
+
const blob = new Blob(chunks, { type: actualType });
|
|
120
138
|
|
|
121
139
|
const dl = opts.download;
|
|
122
140
|
if (dl) {
|
|
@@ -124,7 +142,7 @@ export function videoExport(options = {}) {
|
|
|
124
142
|
const a = document.createElement('a');
|
|
125
143
|
a.href = objUrl;
|
|
126
144
|
const fallbackName = isMp4 ? 'capture.mp4' : 'capture.webm';
|
|
127
|
-
a.download = typeof dl === 'string' ? dl : (opts
|
|
145
|
+
a.download = typeof dl === 'string' ? dl : exportOption(ctx, opts, 'filename', filename || fallbackName);
|
|
128
146
|
a.click();
|
|
129
147
|
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
|
|
130
148
|
}
|
package/html-in-canvas.js
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Experimental plugin for WICG html-in-canvas (issue #172).
|
|
3
|
-
* Uses drawElementImage() to render the snapdom clone directly into canvas,
|
|
4
|
-
* bypassing SVG/foreignObject.
|
|
5
|
-
*
|
|
6
|
-
* Requires: Chrome with chrome://flags/#canvas-draw-element enabled.
|
|
7
|
-
* @see https://github.com/WICG/html-in-canvas
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const PLUGIN_NAME = 'html-in-canvas'
|
|
11
|
-
|
|
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() {
|
|
18
|
-
try {
|
|
19
|
-
const c = document.createElement('canvas')
|
|
20
|
-
const ctx = c.getContext('2d')
|
|
21
|
-
if (!ctx) return null
|
|
22
|
-
if (typeof ctx.drawElement === 'function') return 'drawElement'
|
|
23
|
-
if (typeof ctx.drawElementImage === 'function') return 'drawElementImage'
|
|
24
|
-
return null
|
|
25
|
-
} catch {
|
|
26
|
-
return null
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @returns {import('../../src/core/plugins.js').Plugin}
|
|
32
|
-
*/
|
|
33
|
-
export function htmlInCanvasPlugin() {
|
|
34
|
-
const drawApi = detectDrawApi()
|
|
35
|
-
const available = !!drawApi
|
|
36
|
-
if (!available) {
|
|
37
|
-
console.warn('[snapdom] html-in-canvas plugin: drawElement / drawElementImage not available. Enable chrome://flags/#canvas-draw-element')
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
name: PLUGIN_NAME,
|
|
42
|
-
|
|
43
|
-
beforeRender(state) {
|
|
44
|
-
if (!available) return
|
|
45
|
-
if (!state.clone || !state.element) return
|
|
46
|
-
state.options.__htmlInCanvas = {
|
|
47
|
-
clone: state.clone,
|
|
48
|
-
baseCSS: state.baseCSS || '',
|
|
49
|
-
fontsCSS: state.fontsCSS || '',
|
|
50
|
-
classCSS: state.classCSS || '',
|
|
51
|
-
element: state.element,
|
|
52
|
-
w0: null,
|
|
53
|
-
h0: null
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
afterRender(state) {
|
|
58
|
-
if (!available) return
|
|
59
|
-
const meta = state.options?.meta
|
|
60
|
-
const stored = state.options?.__htmlInCanvas
|
|
61
|
-
if (meta && stored) {
|
|
62
|
-
stored.w0 = meta.w0
|
|
63
|
-
stored.h0 = meta.h0
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
async defineExports(ctx) {
|
|
68
|
-
if (!available) return {}
|
|
69
|
-
const stored = ctx.__htmlInCanvas
|
|
70
|
-
if (!stored) return {}
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
htmlInCanvas: async (opts = {}) => {
|
|
74
|
-
const { clone, baseCSS, fontsCSS, classCSS, element } = stored
|
|
75
|
-
const w0 = stored.w0 ?? element?.offsetWidth
|
|
76
|
-
const h0 = stored.h0 ?? element?.offsetHeight
|
|
77
|
-
const scale = opts.scale ?? ctx.scale ?? 1
|
|
78
|
-
const dpr = opts.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1)
|
|
79
|
-
|
|
80
|
-
const rect = element?.getBoundingClientRect?.()
|
|
81
|
-
const width = w0 ?? rect?.width ?? 100
|
|
82
|
-
const height = h0 ?? rect?.height ?? 100
|
|
83
|
-
const outW = Math.round(width * scale * dpr)
|
|
84
|
-
const outH = Math.round(height * scale * dpr)
|
|
85
|
-
|
|
86
|
-
const canvas = document.createElement('canvas')
|
|
87
|
-
canvas.width = outW
|
|
88
|
-
canvas.height = outH
|
|
89
|
-
canvas.setAttribute('layoutsubtree', '')
|
|
90
|
-
|
|
91
|
-
const wrapper = document.createElement('div')
|
|
92
|
-
wrapper.style.cssText = `width:${width}px;height:${height}px;overflow:visible;box-sizing:border-box;`
|
|
93
|
-
wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
|
|
94
|
-
|
|
95
|
-
const styleTag = document.createElement('style')
|
|
96
|
-
styleTag.textContent = `${baseCSS}${fontsCSS}svg{overflow:visible;}${classCSS}`
|
|
97
|
-
wrapper.appendChild(styleTag)
|
|
98
|
-
|
|
99
|
-
const cloneCopy = clone.cloneNode(true)
|
|
100
|
-
wrapper.appendChild(cloneCopy)
|
|
101
|
-
|
|
102
|
-
canvas.appendChild(wrapper)
|
|
103
|
-
|
|
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)
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
canvas.getBoundingClientRect()
|
|
113
|
-
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
114
|
-
|
|
115
|
-
const ctx2d = canvas.getContext('2d')
|
|
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')
|
|
119
|
-
}
|
|
120
|
-
ctx2d.save()
|
|
121
|
-
ctx2d.scale(dpr * scale, dpr * scale)
|
|
122
|
-
fn.call(ctx2d, wrapper, 0, 0, width, height)
|
|
123
|
-
ctx2d.restore()
|
|
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
|
|
130
|
-
} finally {
|
|
131
|
-
try { canvas.remove() } catch {}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export default htmlInCanvasPlugin
|