domotion-svg 0.17.0 → 0.19.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/FEATURES.md +5 -1
- package/README.md +3 -1
- package/dist/animation/animator.d.ts +5 -0
- package/dist/animation/animator.js +19 -32
- package/dist/animation/composite.js +4 -4
- package/dist/animation/cursor-overlay.d.ts +8 -10
- package/dist/animation/cursor-overlay.js +61 -40
- package/dist/capture/index.d.ts +54 -0
- package/dist/capture/index.js +92 -24
- package/dist/capture/script/cross-origin.d.ts +17 -0
- package/dist/capture/script/cross-origin.js +83 -0
- package/dist/capture/script/index.js +259 -4
- package/dist/capture/script/walker/counter-prewalk.d.ts +1 -1
- package/dist/capture/script/walker/counter-prewalk.js +6 -3
- package/dist/capture/script/walker/masks-clips.js +16 -4
- package/dist/capture/script/walker/replaced-elements.js +6 -1
- package/dist/capture/script.generated.js +1 -1
- package/dist/cli/capture.js +96 -42
- package/dist/cli/common.d.ts +5 -6
- package/dist/cli/common.js +5 -24
- package/dist/cli/index.js +12 -0
- package/dist/cli/scrubber.js +18 -0
- package/dist/post-processing/optimize.js +7 -0
- package/dist/render/element-tree-to-svg.d.ts +7 -0
- package/dist/render/element-tree-to-svg.js +855 -763
- package/dist/render/format.d.ts +14 -0
- package/dist/render/format.js +17 -0
- package/dist/render/mask.js +271 -252
- package/dist/render/text.js +224 -198
- package/dist/render/vertical-text.js +2 -3
- package/dist/scroll/composer.d.ts +5 -0
- package/dist/scroll/composer.js +52 -33
- package/dist/scrubber/client.bundle.generated.js +1 -1
- package/dist/scrubber/client.d.ts +2 -0
- package/dist/scrubber/client.js +181 -5
- package/dist/scrubber/server.d.ts +90 -0
- package/dist/scrubber/server.js +139 -2
- package/dist/tree-ops/viewbox-culling.js +1 -1
- package/dist/utils/transparent-background.d.ts +22 -0
- package/dist/utils/transparent-background.js +40 -0
- package/llms.txt +3 -3
- package/package.json +8 -4
package/FEATURES.md
CHANGED
|
@@ -57,7 +57,11 @@ Each feature has a visual regression test that compares HTML-to-PNG with SVG-to-
|
|
|
57
57
|
- [x] **replaced-video-poster**: `<video poster=…>` paused — poster image captured
|
|
58
58
|
- [x] **replaced-canvas-overlay**: `<canvas>` under a positioned `<div z-index:10>` overlay — overlay does NOT bleed into the canvas snapshot
|
|
59
59
|
- [x] **replaced-canvas-fixed-overlay**: `<canvas>` under a sibling-positioned `<div>` painting on top — sibling does NOT bleed into the canvas snapshot
|
|
60
|
-
- [x] **replaced-iframe-same-origin
|
|
60
|
+
- [x] **replaced-iframe-same-origin** (DM-1441): same-origin `<iframe>` (srcdoc) — content **recurses into native SVG** (crisp/scalable/selectable), not rastered; pixel-identical to the prior snapshot. See [docs/81](docs/81-iframe-recursion.md).
|
|
61
|
+
- [x] **iframe-recursion-bordered** (DM-1441): same-origin iframe recursion through a non-zero border + padding on the iframe — inner document's origin lands at the iframe **content box** and the inner subtree clips to it.
|
|
62
|
+
- [x] **iframe-canvas-bg-fill** (DM-1448): recursed iframe **taller than its content** — the inner document's propagated canvas background (html-bg-or-else-body-bg) fills the whole inner viewport, so the strip below the content paints the canvas color instead of showing through to the outer page.
|
|
63
|
+
- [x] **iframe-inner-clip-mask** (DM-1446): recursed iframe whose inner content uses `clip-path: url(#id)` + `mask-image: url(#id)` defined in the **iframe's own** `<defs>` — fragment refs resolve against `el.ownerDocument`, so the `<clipPath>`/`<mask>` defs hoist and paint (clipped circle + masked square-with-hole, 0.00%). (Inner `element(#id)` paint refs are frame-aware — DM-1447 — though CSS `element()` itself is unsupported in Chromium / dormant, DM-1450; tall-iframe canvas-bg fill shipped in DM-1448.)
|
|
64
|
+
- [x] **cross-origin iframe recursion** (DM-1442): `--cross-origin-frames "*"|host[:port],…` (config: `captureCrossOriginFrames`) recurses **allowlisted** cross-origin frames into native SVG by launching Chromium with web security disabled; non-allowlisted frames stay raster. Default off + a stderr security warning (disabling web security disables CORS). Unit + e2e tested (`tests/cross-origin-iframe-recursion.e2e.test.ts`). See [docs/81](docs/81-iframe-recursion.md).
|
|
61
65
|
- [x] **snapshot-isolation-pseudo-overlay** (DM-458, `tests/snapshot-isolation.tsx`): canvas covered by a sibling's `::after` pseudo overlay. Inspection-style — decodes the captured snapshot's PNG data URI and asserts no overlay-color pixels leaked through. Catches regressions in the hide-everything-else stylesheet that a comparison-style fixture wouldn't.
|
|
62
66
|
|
|
63
67
|
### Showcase Integration Tests
|
package/README.md
CHANGED
|
@@ -79,6 +79,8 @@ cat demo.html | domotion capture - -o demo.svg
|
|
|
79
79
|
domotion capture https://example.com --scroll "down:bottom/8s" -o scroll.svg
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
Same-origin `<iframe>` content is recursed into the capture as native, selectable SVG rather than flattened to a screenshot; opt into cross-origin frames you trust with `--cross-origin-frames "<hosts>"`.
|
|
83
|
+
|
|
82
84
|
For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
|
|
83
85
|
|
|
84
86
|
```bash
|
|
@@ -150,7 +152,7 @@ svg-review --expected example.debug/expected.png --actual example.debug/actual.s
|
|
|
150
152
|
|
|
151
153
|
The browser opens a single review card showing the expected / actual / diff PNGs. Arrow keys cycle through the three at full size; drag on any image to mark a problem region and caption it. The side panel builds a GitHub-issue-ready Markdown block as you go — copy it, then file the issue at <https://github.com/brianwestphal/domotion/issues/new> and attach `expected.png` + `actual.svg` so a maintainer can reproduce.
|
|
152
154
|
|
|
153
|
-
For an *animated* SVG, the package also ships `svg-scrubber` — a local video-style bench to play / pause / scrub / mark an in-out range, export the current frame as PNG, export the range as MP4, or trim it to a new self-contained animated SVG.
|
|
155
|
+
For an *animated* SVG, the package also ships `svg-scrubber` — a local video-style bench to play / pause / scrub / mark an in-out range, export the current frame as PNG, export the range as MP4, or trim it to a new self-contained animated SVG. Add `--review` to file a focused issue against a moment in the timeline: it writes an importable `.ticket` (frame time, range, and drawn regions) the same way `svg-review` builds a report for a still.
|
|
154
156
|
|
|
155
157
|
### Scripting API
|
|
156
158
|
|
|
@@ -74,6 +74,11 @@ export interface AnimationConfig {
|
|
|
74
74
|
width: number;
|
|
75
75
|
height: number;
|
|
76
76
|
frames: AnimationFrame[];
|
|
77
|
+
/** DM-1488: accessible name → `role="img"` + `<title>` on the root `<svg>`
|
|
78
|
+
* (for inline-`<svg>` embedding). Omit to leave the output unchanged. */
|
|
79
|
+
title?: string;
|
|
80
|
+
/** DM-1488: accessible long description → `<desc>` on the root `<svg>`. */
|
|
81
|
+
desc?: string;
|
|
77
82
|
/**
|
|
78
83
|
* Markup (e.g. `<path id="g0" d="..."/>...`) hoisted into the top-level
|
|
79
84
|
* `<defs>`. Frames can reference these IDs via `<use href="#...">`. Use for
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
|
|
8
8
|
import { escapeHtml } from "../utils/escapeHtml.js";
|
|
9
|
+
import { isTransparentBackground } from "../utils/transparent-background.js";
|
|
10
|
+
import { rootSvgA11y } from "../render/format.js";
|
|
9
11
|
import { DEFAULT_TRANSITION_MS, frameAdvanceMs, transitionDurationMs } from "./frame-timeline.js";
|
|
10
12
|
import { offsetEmbeddedAnimatedSvgTimeline } from "./embed-timeline.js";
|
|
11
13
|
import { KEYFRAME_EPSILON, padAfter, padBefore } from "../utils/keyframe-pad.js";
|
|
@@ -38,7 +40,7 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
|
|
|
38
40
|
${afterH}% { opacity: 0; visibility: hidden; }
|
|
39
41
|
100% { opacity: 0; visibility: hidden; }
|
|
40
42
|
}
|
|
41
|
-
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s
|
|
43
|
+
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
|
|
42
44
|
// Bridge composite: visible during the transition window only.
|
|
43
45
|
groups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
|
|
44
46
|
keyframes.push(`
|
|
@@ -50,7 +52,7 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
|
|
|
50
52
|
${afterT}% { opacity: 0; visibility: hidden; }
|
|
51
53
|
100% { opacity: 0; visibility: hidden; }
|
|
52
54
|
}
|
|
53
|
-
.mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s
|
|
55
|
+
.mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
|
|
54
56
|
// Per-element slide / fade keyframes within the window (linear interp).
|
|
55
57
|
// The composite is only visible [holdEnd..transEnd], so the held values
|
|
56
58
|
// outside that window are never painted — they just pin the endpoints.
|
|
@@ -111,18 +113,11 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
|
|
|
111
113
|
}
|
|
112
114
|
return { groups, keyframes };
|
|
113
115
|
}
|
|
114
|
-
|
|
115
|
-
* Emit one crossfade or cut frame (the default transition path): the frame
|
|
116
|
-
* blob plus its opacity keyframes. `cut` (or zero-duration) uses disjoint
|
|
117
|
-
* step-end keyframes so opacity flips instantly with no interpolation smear;
|
|
118
|
-
* crossfade overlaps the fade-in with the previous frame's fade-out, with the
|
|
119
|
-
* visible window driven by `fadeInStartPct` (precomputed by the caller, which
|
|
120
|
-
* knows the overlap state). Extracted from `generateAnimatedSvg` (DM-1089).
|
|
121
|
-
*/
|
|
122
|
-
function emitCrossfadeOrCutFrame(i, frame, transType, transDur, startPct, holdEndPct, transEndPct, fadeInStartPct, totalSec,
|
|
116
|
+
function emitCrossfadeOrCutFrame(i, frame, transType, transDur, win, totalSec,
|
|
123
117
|
/** DM-1148: the last frame, when the loop must NOT cross-dissolve, holds
|
|
124
118
|
* opacity 1 to 100% instead of fading out over its transition window. */
|
|
125
119
|
holdToEnd) {
|
|
120
|
+
const { startPct, holdEndPct, transEndPct, fadeInStartPct } = win;
|
|
126
121
|
const groups = [];
|
|
127
122
|
const keyframes = [];
|
|
128
123
|
groups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
|
|
@@ -141,7 +136,7 @@ holdToEnd) {
|
|
|
141
136
|
${afterEnd}% { opacity: 0; visibility: hidden; }
|
|
142
137
|
100% { opacity: 0; visibility: hidden; }
|
|
143
138
|
}
|
|
144
|
-
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s
|
|
139
|
+
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
|
|
145
140
|
}
|
|
146
141
|
else {
|
|
147
142
|
const prevEnd = i > 0
|
|
@@ -212,18 +207,9 @@ export function dedupeFrameIds(frames) {
|
|
|
212
207
|
return { ...frame, svgContent: out };
|
|
213
208
|
});
|
|
214
209
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
* EXIT slide axis (`X`/`Y`) and the slid extent (viewport width / height). The
|
|
219
|
-
* group rect always spans `width × height`. The frame's ENTRANCE is composed
|
|
220
|
-
* separately from `enter` (DM-1414): a slide frame after a crossfade fades in,
|
|
221
|
-
* after a different-axis slide slides in on the predecessor's axis, etc.
|
|
222
|
-
* Extracted from generateAnimatedSvg (DM-1375), mirroring emit*Frame.
|
|
223
|
-
*/
|
|
224
|
-
function emitSlideFrame(i, svgContent, exitAxis, exitSize, enter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame) {
|
|
225
|
-
const group = ` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${width}" height="${height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${svgContent}\n </g></g>`;
|
|
226
|
-
const keyframe = slideKeyframes(i, exitAxis, exitSize, enter, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec, holdLastFrame);
|
|
210
|
+
function emitSlideFrame(i, svgContent, exitAxis, exitSize, enter, dims, win, totalSec, holdLastFrame) {
|
|
211
|
+
const group = ` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${dims.width}" height="${dims.height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${svgContent}\n </g></g>`;
|
|
212
|
+
const keyframe = slideKeyframes(i, exitAxis, exitSize, enter, win.enterStartPct, win.startPct, win.holdEndPct, win.transEndPct, win.enterStartPct, win.transEndPct, totalSec, holdLastFrame);
|
|
227
213
|
return { group, keyframe };
|
|
228
214
|
}
|
|
229
215
|
/**
|
|
@@ -361,7 +347,7 @@ export function generateAnimatedSvg(config) {
|
|
|
361
347
|
// The parallel `fd-${i}` display snap (inside slideKeyframes) lets the
|
|
362
348
|
// browser skip painting this frame's content while it's fully off-screen
|
|
363
349
|
// between cycles (DM-599).
|
|
364
|
-
const r = emitSlideFrame(i, frame.svgContent, "X", width, slideEnter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame);
|
|
350
|
+
const r = emitSlideFrame(i, frame.svgContent, "X", width, slideEnter, { width, height }, { enterStartPct, startPct, holdEndPct, transEndPct }, totalSec, holdLastFrame);
|
|
365
351
|
frameGroups.push(r.group);
|
|
366
352
|
keyframes.push(r.keyframe);
|
|
367
353
|
}
|
|
@@ -369,7 +355,7 @@ export function generateAnimatedSvg(config) {
|
|
|
369
355
|
// DM-609: `scroll` is a real geometric scroll — the vertical equivalent of
|
|
370
356
|
// push-left (translateY over height instead of translateX over width),
|
|
371
357
|
// otherwise identical machinery. Exit slides up; enter per `slideEnter`.
|
|
372
|
-
const r = emitSlideFrame(i, frame.svgContent, "Y", height, slideEnter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame);
|
|
358
|
+
const r = emitSlideFrame(i, frame.svgContent, "Y", height, slideEnter, { width, height }, { enterStartPct, startPct, holdEndPct, transEndPct }, totalSec, holdLastFrame);
|
|
373
359
|
frameGroups.push(r.group);
|
|
374
360
|
keyframes.push(r.keyframe);
|
|
375
361
|
}
|
|
@@ -399,7 +385,7 @@ export function generateAnimatedSvg(config) {
|
|
|
399
385
|
// holds-then-cuts — so this is a no-op for cut frames.
|
|
400
386
|
const isCutFrame = transType === "cut" || transDur === 0;
|
|
401
387
|
const holdToEnd = i === frames.length - 1 && config.loopFade !== true && !isCutFrame;
|
|
402
|
-
const r = emitCrossfadeOrCutFrame(i, frame, transType, transDur, startPct, holdEndPct, transEndPct, fadeInStartPct, totalSec, holdToEnd);
|
|
388
|
+
const r = emitCrossfadeOrCutFrame(i, frame, transType, transDur, { startPct, holdEndPct, transEndPct, fadeInStartPct }, totalSec, holdToEnd);
|
|
403
389
|
frameGroups.push(...r.groups);
|
|
404
390
|
keyframes.push(...r.keyframes);
|
|
405
391
|
}
|
|
@@ -434,11 +420,12 @@ export function generateAnimatedSvg(config) {
|
|
|
434
420
|
// Default (none / transparent) emits nothing so the SVG composites over the
|
|
435
421
|
// host page, matching the single-frame `transparentRootBgRect` path (DM-554).
|
|
436
422
|
const bg = config.background;
|
|
437
|
-
const canvasBgRect = (bg != null && bg
|
|
423
|
+
const canvasBgRect = (bg != null && !isTransparentBackground(bg))
|
|
438
424
|
? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
|
|
439
425
|
: "";
|
|
426
|
+
const a11y = rootSvgA11y(config.title, config.desc);
|
|
440
427
|
const out = `<?xml version="1.0" encoding="UTF-8"?>
|
|
441
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"
|
|
428
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"${a11y.roleAttr}>${a11y.markup}
|
|
442
429
|
<defs>
|
|
443
430
|
<clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
|
|
444
431
|
</defs>
|
|
@@ -879,7 +866,7 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
|
|
|
879
866
|
const enterId = `${id}-enter`;
|
|
880
867
|
cssRules.push(`
|
|
881
868
|
@keyframes ${enterId} { 0% { transform: ${fromStr}; } ${pct(enterStart, totalDuration)} { transform: ${fromStr}; } ${pct(enterEnd, totalDuration)} { transform: translate(0, 0); } 100% { transform: translate(0, 0); } }
|
|
882
|
-
.${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s
|
|
869
|
+
.${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s ${easing} infinite; }`);
|
|
883
870
|
}
|
|
884
871
|
// Slide-out exit. Mirror of enter — translate from (0,0) to off-screen.
|
|
885
872
|
if (overlay.exit != null) {
|
|
@@ -891,7 +878,7 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
|
|
|
891
878
|
const exitId = `${id}-exit`;
|
|
892
879
|
cssRules.push(`
|
|
893
880
|
@keyframes ${exitId} { 0%, ${pct(exitStart, totalDuration)} { transform: translate(0, 0); } ${pct(exitStart + e.duration, totalDuration)} { transform: ${toStr}; } 100% { transform: ${toStr}; } }
|
|
894
|
-
.${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s
|
|
881
|
+
.${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s ${easing} infinite; }`);
|
|
895
882
|
}
|
|
896
883
|
// Markup: outer wrapper translates to (x, y) and clips, inner wrapper
|
|
897
884
|
// carries the visibility class, then the enter/exit transform wrapper, then
|
|
@@ -999,7 +986,7 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
|
|
|
999
986
|
${endPct.toFixed(3)}% { ${propValue(a.to)} }
|
|
1000
987
|
100% { ${propValue(a.to)} }
|
|
1001
988
|
}
|
|
1002
|
-
.anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s
|
|
989
|
+
.anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s ${easing} infinite;${originDecl} }`);
|
|
1003
990
|
}
|
|
1004
991
|
}
|
|
1005
992
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* one-transform-animation-per-element rule, layer transforms live on the layer's
|
|
30
30
|
* own wrapper group, separate from the nested content's animations.
|
|
31
31
|
*/
|
|
32
|
+
import { isTransparentBackground } from "../utils/transparent-background.js";
|
|
32
33
|
import { namespaceEmbeddedAnimatedSvg } from "./embed-namespace.js";
|
|
33
34
|
import { offsetEmbeddedAnimatedSvgTimeline } from "./embed-timeline.js";
|
|
34
35
|
import { parseSvgIntrinsicSize, fmt, clampPct } from "./svg-meta.js";
|
|
@@ -163,10 +164,9 @@ export function composeAnimatedLayers(layers, opts) {
|
|
|
163
164
|
}
|
|
164
165
|
groups.push(`<g class="${token}layer"${clipAttr}${groupStyle}>${nested}</g>`);
|
|
165
166
|
}
|
|
166
|
-
// Transparent
|
|
167
|
-
//
|
|
168
|
-
const
|
|
169
|
-
const bg = bgNorm === "" || bgNorm === "transparent" || bgNorm === "rgba(0,0,0,0)"
|
|
167
|
+
// Transparent (no backdrop rect) for every transparent CSS form — keywords,
|
|
168
|
+
// zero-alpha hex, zero-alpha rgba()/hsla() — via the canonical predicate.
|
|
169
|
+
const bg = isTransparentBackground(opts.background ?? "")
|
|
170
170
|
? ""
|
|
171
171
|
: `<rect width="${width}" height="${height}" fill="${opts.background}"/>`;
|
|
172
172
|
const defs = defsParts.join("");
|
|
@@ -153,16 +153,14 @@ resolveCursorAt?: CursorAtResolver | null): {
|
|
|
153
153
|
cursorTimeline: CursorTimelineEntry[] | null;
|
|
154
154
|
};
|
|
155
155
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* in as the all-glyphs-off `null` state). Without a timeline, the single white
|
|
165
|
-
* arrow paints (back-compat).
|
|
156
|
+
* DM-1507: the cursor overlay is emitted as CSS `@keyframes`, NOT SMIL. SMIL
|
|
157
|
+
* (`<animateTransform>`/`<animate>`) runs on the SVG's *own* timeline while the
|
|
158
|
+
* frame animations run on the CSS/document timeline — two clocks that Safari
|
|
159
|
+
* pauses/throttles independently when the SVG is offscreen (tab-switch, scrolled
|
|
160
|
+
* away), so the pointer drifted out of sync with the animation on return. Driving
|
|
161
|
+
* the cursor with CSS puts everything on ONE timeline; they pause and resume
|
|
162
|
+
* together, so they can't desync. The `@keyframes` are collected into a `<style>`
|
|
163
|
+
* inside the overlay group and applied via inline `animation:` on each element.
|
|
166
164
|
*/
|
|
167
165
|
export declare function cursorOverlayMarkup(positions: KeyframePoint[], clicks: ResolvedClick[], style: CursorStyle, totalDurationMs: number, cursorTimeline?: CursorTimelineEntry[] | null): string;
|
|
168
166
|
export {};
|
|
@@ -231,83 +231,104 @@ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
|
|
|
231
231
|
* in as the all-glyphs-off `null` state). Without a timeline, the single white
|
|
232
232
|
* arrow paints (back-compat).
|
|
233
233
|
*/
|
|
234
|
+
/** Small deterministic hash → 6-char base36, used to namespace this overlay's
|
|
235
|
+
* `@keyframes` so a composited SVG with several overlays can't collide. */
|
|
236
|
+
function overlayUid(seed) {
|
|
237
|
+
let h = 0x811c9dc5;
|
|
238
|
+
for (let i = 0; i < seed.length; i++) {
|
|
239
|
+
h ^= seed.charCodeAt(i);
|
|
240
|
+
h = Math.imul(h, 0x01000193);
|
|
241
|
+
}
|
|
242
|
+
return (h >>> 0).toString(36).padStart(6, "0").slice(0, 6);
|
|
243
|
+
}
|
|
244
|
+
/** Format a 0..1 keyframe fraction as a CSS keyframe percentage. */
|
|
245
|
+
function pct(frac) {
|
|
246
|
+
return `${Number((frac * 100).toFixed(4))}%`;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* DM-1507: the cursor overlay is emitted as CSS `@keyframes`, NOT SMIL. SMIL
|
|
250
|
+
* (`<animateTransform>`/`<animate>`) runs on the SVG's *own* timeline while the
|
|
251
|
+
* frame animations run on the CSS/document timeline — two clocks that Safari
|
|
252
|
+
* pauses/throttles independently when the SVG is offscreen (tab-switch, scrolled
|
|
253
|
+
* away), so the pointer drifted out of sync with the animation on return. Driving
|
|
254
|
+
* the cursor with CSS puts everything on ONE timeline; they pause and resume
|
|
255
|
+
* together, so they can't desync. The `@keyframes` are collected into a `<style>`
|
|
256
|
+
* inside the overlay group and applied via inline `animation:` on each element.
|
|
257
|
+
*/
|
|
234
258
|
export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs, cursorTimeline = null) {
|
|
235
259
|
if (positions.length === 0 || totalDurationMs <= 0)
|
|
236
260
|
return "";
|
|
237
261
|
const totalSec = totalDurationMs / 1000;
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
const posAnim = `<animateTransform attributeName="transform" type="translate" values="${valueStrs.join("; ")}" keyTimes="${keyTimes.join("; ")}" dur="${totalSec}s" repeatCount="indefinite" fill="freeze" />`;
|
|
248
|
-
// Pulse SVG fragments — one per click, with timing keyed off `t`.
|
|
249
|
-
const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, totalDurationMs)).join("\n");
|
|
262
|
+
const uid = overlayUid(`${totalDurationMs}|${positions.map((p) => `${p.x},${p.y},${p.t}`).join(";")}`);
|
|
263
|
+
const kf = []; // @keyframes collected here, injected into <style>
|
|
264
|
+
// Position track — linear translate along the keyframes (holds are duplicate
|
|
265
|
+
// consecutive values, exactly as under SMIL). keyTimes start at 0 and end at 1.
|
|
266
|
+
const posName = `co-pos-${uid}`;
|
|
267
|
+
kf.push(`@keyframes ${posName}{${positions.map((p) => `${pct(p.t / totalDurationMs)}{transform:translate(${num(p.x)}px,${num(p.y)}px)}`).join("")}}`);
|
|
268
|
+
const posAnim = `${posName} ${totalSec}s linear infinite`;
|
|
269
|
+
// Pulse fragments — one per click; each pushes its own keyframes into `kf`.
|
|
270
|
+
const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, uid, kf)).join("\n");
|
|
250
271
|
let pointerGroup;
|
|
251
272
|
if (cursorTimeline != null && cursorTimeline.length > 0) {
|
|
252
|
-
// DM-1106: one glyph per distinct keyword, each toggled by a
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// correctly regardless of its own hotspot.
|
|
273
|
+
// DM-1106: one glyph per distinct keyword, each toggled by a DISCRETE opacity
|
|
274
|
+
// track (SMIL `calcMode="discrete"` → CSS `step-end`, which holds each value
|
|
275
|
+
// until the next keyframe). The parent carries the position animation.
|
|
256
276
|
const size = 22 * (style.cursorScale || 1);
|
|
257
|
-
const tKeyTimes = cursorTimeline.map((e) => (e.t / totalDurationMs).toFixed(4));
|
|
258
277
|
const kinds = Array.from(new Set(cursorTimeline.map((e) => e.cursor).filter((c) => c != null)));
|
|
259
|
-
const glyphLayers = kinds.map((kind) => {
|
|
278
|
+
const glyphLayers = kinds.map((kind, gi) => {
|
|
260
279
|
const glyph = cursorGlyphSvg(kind, 0, 0, size, style.cursorStroke);
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
const gName = `co-glyph-${uid}-${gi}`;
|
|
281
|
+
kf.push(`@keyframes ${gName}{${cursorTimeline.map((e) => `${pct(e.t / totalDurationMs)}{opacity:${e.cursor === kind ? "1" : "0"}}`).join("")}}`);
|
|
282
|
+
return ` <g opacity="0" style="animation:${gName} ${totalSec}s step-end infinite">
|
|
264
283
|
${glyph}
|
|
265
284
|
</g>`;
|
|
266
285
|
}).join("\n");
|
|
267
|
-
pointerGroup = ` <g class="cursor-pointer">
|
|
268
|
-
${posAnim}
|
|
286
|
+
pointerGroup = ` <g class="cursor-pointer" style="animation:${posAnim}">
|
|
269
287
|
${glyphLayers}
|
|
270
288
|
</g>`;
|
|
271
289
|
}
|
|
272
290
|
else {
|
|
273
291
|
// Legacy single-arrow path (no auto cursor-type resolver supplied).
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
<animate attributeName="opacity" values="${visValues.join(";")}" keyTimes="${keyTimes.join(";")}" dur="${totalSec}s" repeatCount="indefinite" calcMode="discrete" fill="freeze" />
|
|
292
|
+
const visName = `co-vis-${uid}`;
|
|
293
|
+
kf.push(`@keyframes ${visName}{${positions.map((p) => `${pct(p.t / totalDurationMs)}{opacity:${p.visible ? "1" : "0"}}`).join("")}}`);
|
|
294
|
+
pointerGroup = ` <g class="cursor-arrow" opacity="0" style="animation:${posAnim},${visName} ${totalSec}s step-end infinite">
|
|
278
295
|
${macosCursorPath(style.cursorScale)}
|
|
279
296
|
</g>`;
|
|
280
297
|
}
|
|
281
298
|
return ` <g class="cursor-overlay" pointer-events="none">
|
|
299
|
+
<style>${kf.join("")}</style>
|
|
282
300
|
${pointerGroup}
|
|
283
301
|
${pulseMarkup}
|
|
284
302
|
</g>`;
|
|
285
303
|
}
|
|
286
|
-
/** Build the SVG fragment for a single click pulse
|
|
287
|
-
|
|
304
|
+
/** Build the SVG fragment for a single click pulse, pushing its keyframes into
|
|
305
|
+
* `kf`. The expanding ring is `transform:scale` with `non-scaling-stroke` (so
|
|
306
|
+
* it matches the old SMIL `r` growth but on the CSS timeline), played once at
|
|
307
|
+
* its click time and frozen afterward (`forwards`), like the prior single-fire
|
|
308
|
+
* SMIL pulse. */
|
|
309
|
+
function buildPulseFragment(c, idx, uid, kf) {
|
|
288
310
|
const beginSec = (c.t / 1000).toFixed(3);
|
|
289
311
|
const durSec = (c.style.pulseDurationMs / 1000).toFixed(3);
|
|
290
312
|
const r0 = 4;
|
|
291
313
|
const r1 = c.style.pulseRadius;
|
|
292
314
|
const innerR = r1 * 0.55;
|
|
315
|
+
const outerName = `co-pulse-${uid}-${idx}o`;
|
|
316
|
+
const innerName = `co-pulse-${uid}-${idx}i`;
|
|
317
|
+
kf.push(`@keyframes ${outerName}{0%{transform:scale(1);opacity:0}15%{opacity:0.9}100%{transform:scale(${num(r1 / r0)});opacity:0}}`);
|
|
318
|
+
kf.push(`@keyframes ${innerName}{0%{transform:scale(1);opacity:0}15%{opacity:0.95}100%{transform:scale(${num((r1 - 1) / r0)});opacity:0}}`);
|
|
319
|
+
const ringStyle = (name) => `transform-box:fill-box;transform-origin:center;vector-effect:non-scaling-stroke;animation:${name} ${durSec}s linear ${beginSec}s 1 forwards`;
|
|
293
320
|
// Right-half-disc fill for secondary clicks.
|
|
294
321
|
let secondaryHalf = "";
|
|
295
322
|
if (c.button === "secondary") {
|
|
296
323
|
const halfPath = `M ${num(c.x)} ${num(c.y - innerR)} A ${num(innerR)} ${num(innerR)} 0 0 1 ${num(c.x)} ${num(c.y + innerR)} Z`;
|
|
324
|
+
const halfName = `co-pulse-${uid}-${idx}h`;
|
|
325
|
+
kf.push(`@keyframes ${halfName}{0%{opacity:0}20%{opacity:1}100%{opacity:0}}`);
|
|
297
326
|
secondaryHalf = `
|
|
298
|
-
<path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0"
|
|
299
|
-
<animate attributeName="opacity" values="0; 1; 0" keyTimes="0; 0.2; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
300
|
-
</path>`;
|
|
327
|
+
<path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0" style="animation:${halfName} ${durSec}s linear ${beginSec}s 1 forwards" />`;
|
|
301
328
|
}
|
|
302
329
|
return ` <g class="cursor-click cursor-click-${idx}">
|
|
303
|
-
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0"
|
|
304
|
-
|
|
305
|
-
<animate attributeName="opacity" values="0; 0.9; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
306
|
-
</circle>
|
|
307
|
-
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStroke}" stroke-width="1" opacity="0">
|
|
308
|
-
<animate attributeName="r" values="${r0}; ${r1 - 1}" keyTimes="0; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
309
|
-
<animate attributeName="opacity" values="0; 0.95; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
|
|
310
|
-
</circle>${secondaryHalf}
|
|
330
|
+
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0" style="${ringStyle(outerName)}" />
|
|
331
|
+
<circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStroke}" stroke-width="1" opacity="0" style="${ringStyle(innerName)}" />${secondaryHalf}
|
|
311
332
|
</g>`;
|
|
312
333
|
}
|
|
313
334
|
/** macOS-style cursor arrow path. The hot point (0, 0) sits at the tip. */
|
package/dist/capture/index.d.ts
CHANGED
|
@@ -66,6 +66,16 @@ export interface CaptureOptions {
|
|
|
66
66
|
* Values < 1 are clamped to 1.
|
|
67
67
|
*/
|
|
68
68
|
embedRemoteImagesHiDPIFactor?: number;
|
|
69
|
+
/**
|
|
70
|
+
* DM-1442: opt-in cross-origin `<iframe>` recursion. `"*"` recurses every
|
|
71
|
+
* cross-origin frame; a comma-separated `host[:port]` list recurses only
|
|
72
|
+
* frames whose origin matches an entry (exact host; `:port` requires an exact
|
|
73
|
+
* port, otherwise any port). Undefined / omitted leaves cross-origin frames
|
|
74
|
+
* as raster snapshots (same-origin frames recurse regardless). Enabling this
|
|
75
|
+
* launches Chromium with web security disabled — which also disables CORS, so
|
|
76
|
+
* only use it on trusted pages. See docs/81-iframe-recursion.md.
|
|
77
|
+
*/
|
|
78
|
+
captureCrossOriginFrames?: string;
|
|
69
79
|
}
|
|
70
80
|
/**
|
|
71
81
|
* Launch Chromium via Playwright, auto-installing the browser binary on first
|
|
@@ -78,6 +88,20 @@ export interface CaptureOptions {
|
|
|
78
88
|
* are a normal `chromium.launch()` with no overhead.
|
|
79
89
|
*/
|
|
80
90
|
export declare function launchChromium(opts?: LaunchOptions): Promise<Browser>;
|
|
91
|
+
/**
|
|
92
|
+
* DM-1442: Chromium launch args needed to make cross-origin `<iframe>`
|
|
93
|
+
* documents readable from the in-page capture script. `--disable-web-security`
|
|
94
|
+
* lifts the Same-Origin Policy on cross-document access; the
|
|
95
|
+
* `--disable-features` flag co-locates frames in one renderer process so the
|
|
96
|
+
* cross-origin `contentDocument` is reachable (some builds need it, harmless on
|
|
97
|
+
* those that don't). Returns `[]` when `value` requests no cross-origin
|
|
98
|
+
* recursion, so callers can spread it unconditionally:
|
|
99
|
+
* `launchChromium({ args: crossOriginFramesLaunchArgs(value) })`.
|
|
100
|
+
*
|
|
101
|
+
* NOTE: disabling web security also disables CORS — only launch this way when
|
|
102
|
+
* capturing your own / trusted pages. Callers should print a visible warning.
|
|
103
|
+
*/
|
|
104
|
+
export declare function crossOriginFramesLaunchArgs(value: string | undefined | null): string[];
|
|
81
105
|
export declare class DemoRecorder {
|
|
82
106
|
private browser;
|
|
83
107
|
private context;
|
|
@@ -91,6 +115,7 @@ export declare class DemoRecorder {
|
|
|
91
115
|
private embedRemoteImagesRetryBackoffMs;
|
|
92
116
|
private embedRemoteImagesResize;
|
|
93
117
|
private embedRemoteImagesHiDPIFactor;
|
|
118
|
+
private captureCrossOriginFrames;
|
|
94
119
|
constructor(baseUrl: string, opts: CaptureOptions);
|
|
95
120
|
init(opts: CaptureOptions): Promise<void>;
|
|
96
121
|
/** Navigate to a URL and capture the visible DOM as SVG. */
|
|
@@ -213,6 +238,8 @@ export declare function captureElementTree(page: Page, selector: string | undefi
|
|
|
213
238
|
y: number;
|
|
214
239
|
width: number;
|
|
215
240
|
height: number;
|
|
241
|
+
}, opts?: {
|
|
242
|
+
crossOriginFrames?: string;
|
|
216
243
|
}): Promise<CapturedElement[]>;
|
|
217
244
|
/**
|
|
218
245
|
* Same capture as `captureElementTree` but returns the warnings inline so
|
|
@@ -232,10 +259,37 @@ export declare function captureElementTreeWithWarnings(page: Page, selector: str
|
|
|
232
259
|
* rotating cross-origin-iframe content. Caller is responsible for
|
|
233
260
|
* ensuring the source covers the same coordinate space as `viewport`. */
|
|
234
261
|
rasterizeFromImagePath?: string;
|
|
262
|
+
/** DM-1442: the raw `--cross-origin-frames` allowlist value (`"*"` or a
|
|
263
|
+
* comma-separated `host[:port]` list). Passed into the capture script so
|
|
264
|
+
* cross-origin iframes whose origin is on the list recurse into native SVG
|
|
265
|
+
* instead of staying a raster snapshot. Requires the browser to have been
|
|
266
|
+
* launched with web security disabled (see `crossOriginFramesLaunchArgs`).
|
|
267
|
+
* Same-origin recursion (Phase 1) happens regardless. */
|
|
268
|
+
crossOriginFrames?: string;
|
|
235
269
|
}): Promise<{
|
|
236
270
|
tree: CapturedElement[];
|
|
237
271
|
warnings: CaptureWarning[];
|
|
238
272
|
}>;
|
|
273
|
+
/**
|
|
274
|
+
* DM-494: Rasterise each element referenced by `mask-image: element(#id)`.
|
|
275
|
+
* Mirrors `rasterizeReplacedElements` (doc 17): hide-everything-else
|
|
276
|
+
* stylesheet → screenshot the target's painted box → encode as a data URI →
|
|
277
|
+
* stash on `tree[0].maskRasters[i].dataUri`. The renderer's `buildMaskDef`
|
|
278
|
+
* picks up the data URI from the root tree's lookup table when it sees an
|
|
279
|
+
* `element()` mask layer.
|
|
280
|
+
*
|
|
281
|
+
* Same-document only — CSS spec doesn't define cross-document `element()`.
|
|
282
|
+
* Always-on (no opt-in flag) — dedupe by referenced id (one screenshot per
|
|
283
|
+
* unique target regardless of how many consumers reference it). Targets that
|
|
284
|
+
* are display:none / 0-area are filtered at capture time and never reach
|
|
285
|
+
* here. See `docs/22-mask-element-paint-references.md`.
|
|
286
|
+
*/
|
|
287
|
+
export declare function rasterizeMaskSources(page: Page, tree: CapturedElement[], viewport: {
|
|
288
|
+
x: number;
|
|
289
|
+
y: number;
|
|
290
|
+
width: number;
|
|
291
|
+
height: number;
|
|
292
|
+
}): Promise<void>;
|
|
239
293
|
/**
|
|
240
294
|
* Calibrate `fontAscent` on text-bearing elements by scanning a reference
|
|
241
295
|
* PNG (Chrome's actual paint) for each element's painted ink top, then
|