domotion-svg 0.1.1 → 0.3.1
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 +1 -0
- package/README.md +37 -0
- package/dist/{animator.d.ts → animation/animator.d.ts} +14 -2
- package/dist/{animator.js → animation/animator.js} +103 -23
- package/dist/{animator.test.js → animation/animator.test.js} +121 -0
- package/dist/{cursor-overlay.js → animation/cursor-overlay.js} +0 -2
- package/dist/animation/index.d.ts +2 -0
- package/dist/animation/index.js +7 -0
- package/dist/capture/embed.d.ts +97 -0
- package/dist/capture/embed.js +272 -0
- package/dist/capture/emoji.d.ts +34 -0
- package/dist/capture/emoji.js +220 -0
- package/dist/{capture.d.ts → capture/index.d.ts} +52 -0
- package/dist/{capture.js → capture/index.js} +465 -8
- package/dist/capture/script/color-norm.d.ts +4 -0
- package/dist/capture/script/color-norm.js +86 -0
- package/dist/capture/script/emoji-detect.d.ts +4 -0
- package/dist/capture/script/emoji-detect.js +100 -0
- package/dist/capture/script/font-metrics.d.ts +4 -0
- package/dist/capture/script/font-metrics.js +142 -0
- package/dist/capture/script/index.d.ts +4 -0
- package/dist/capture/script/index.js +1576 -0
- package/dist/capture/script/placeholder-shown.d.ts +3 -0
- package/dist/capture/script/placeholder-shown.js +63 -0
- package/dist/capture/script/pseudo-rules.d.ts +14 -0
- package/dist/capture/script/pseudo-rules.js +244 -0
- package/dist/capture/script/utils.d.ts +2 -0
- package/dist/capture/script/utils.js +15 -0
- package/dist/capture/script/walker/borders-backgrounds.d.ts +61 -0
- package/dist/capture/script/walker/borders-backgrounds.js +290 -0
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/form-controls.d.ts +51 -0
- package/dist/capture/script/walker/form-controls.js +237 -0
- package/dist/capture/script/walker/input-value.d.ts +23 -0
- package/dist/capture/script/walker/input-value.js +220 -0
- package/dist/capture/script/walker/lists-counters.d.ts +18 -0
- package/dist/capture/script/walker/lists-counters.js +113 -0
- package/dist/capture/script/walker/masks-clips.d.ts +10 -0
- package/dist/capture/script/walker/masks-clips.js +178 -0
- package/dist/capture/script/walker/pseudo-content.d.ts +105 -0
- package/dist/capture/script/walker/pseudo-content.js +820 -0
- package/dist/capture/script/walker/pseudo-inject.d.ts +17 -0
- package/dist/capture/script/walker/pseudo-inject.js +202 -0
- package/dist/capture/script/walker/replaced-elements.d.ts +5 -0
- package/dist/capture/script/walker/replaced-elements.js +97 -0
- package/dist/capture/script/walker/text-segments.d.ts +67 -0
- package/dist/capture/script/walker/text-segments.js +417 -0
- package/dist/capture/script/walker/transforms.d.ts +9 -0
- package/dist/capture/script/walker/transforms.js +160 -0
- package/dist/capture/script/warnings.d.ts +5 -0
- package/dist/capture/script/warnings.js +41 -0
- package/dist/capture/script.generated.d.ts +1 -0
- package/dist/capture/script.generated.js +6 -0
- package/dist/{dom-to-svg.d.ts → capture/types.d.ts} +258 -327
- package/dist/capture/types.js +18 -0
- package/dist/capture/warnings.d.ts +23 -0
- package/dist/capture/warnings.js +34 -0
- package/dist/cli/animate.d.ts +8 -0
- package/dist/cli/animate.js +436 -0
- package/dist/cli/capture.d.ts +8 -0
- package/dist/cli/capture.js +156 -0
- package/dist/cli/common.d.ts +41 -0
- package/dist/cli/common.js +126 -0
- package/dist/cli/index.js +165 -0
- package/dist/cross-origin-font-face.test.js +1 -1
- package/dist/dark-mode-capture.test.js +1 -1
- package/dist/dark-mode-form-controls.test.js +2 -2
- package/dist/embed-remote-images.test.js +1 -1
- package/dist/index.d.ts +11 -12
- package/dist/index.js +36 -7
- package/dist/mask.test.js +13 -8
- package/dist/post-processing/gzip.d.ts +10 -0
- package/dist/post-processing/gzip.js +13 -0
- package/dist/post-processing/index.d.ts +2 -0
- package/dist/post-processing/index.js +4 -0
- package/dist/post-processing/optimize.test.js +40 -0
- package/dist/preserve-aspect-ratio.test.js +1 -1
- package/dist/render/borders.d.ts +97 -0
- package/dist/render/borders.js +731 -0
- package/dist/{border-radius.test.js → render/borders.test.js} +1 -1
- package/dist/render/box-shadow.d.ts +16 -0
- package/dist/render/box-shadow.js +62 -0
- package/dist/render/colors.d.ts +25 -0
- package/dist/render/colors.js +167 -0
- package/dist/{conic-raster.d.ts → render/conic-raster.d.ts} +2 -1
- package/dist/{conic-raster.js → render/conic-raster.js} +2 -1
- package/dist/{conic-raster.test.js → render/conic-raster.test.js} +3 -3
- package/dist/{coretext-extractor.test.js → render/coretext.test.js} +1 -1
- package/dist/render/css-tokens.d.ts +21 -0
- package/dist/render/css-tokens.js +46 -0
- package/dist/render/element-tree-to-svg.d.ts +131 -0
- package/dist/render/element-tree-to-svg.js +5685 -0
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/{form-controls.d.ts → render/form-controls.d.ts} +1 -1
- package/dist/{form-controls.js → render/form-controls.js} +46 -25
- package/dist/render/format.d.ts +11 -0
- package/dist/render/format.js +21 -0
- package/dist/{gradients.d.ts → render/gradients.d.ts} +15 -0
- package/dist/{gradients.js → render/gradients.js} +103 -2
- package/dist/{gradients.test.js → render/gradients.test.js} +34 -0
- package/dist/render/index.d.ts +2 -0
- package/dist/render/index.js +4 -0
- package/dist/{text-to-path.d.ts → render/text-to-path.d.ts} +46 -3
- package/dist/{text-to-path.js → render/text-to-path.js} +700 -49
- package/dist/{text-to-path.test.js → render/text-to-path.test.js} +311 -20
- package/dist/{text-renderer.d.ts → render/text.d.ts} +17 -1
- package/dist/{text-renderer.js → render/text.js} +443 -43
- package/dist/{text-renderer.test.js → render/text.test.js} +83 -4
- package/dist/render/transforms.d.ts +14 -0
- package/dist/render/transforms.js +56 -0
- package/dist/scroll/composer.d.ts +73 -0
- package/dist/scroll/composer.js +296 -0
- package/dist/scroll/composer.test.d.ts +1 -0
- package/dist/scroll/composer.test.js +452 -0
- package/dist/scroll/executor.d.ts +132 -0
- package/dist/scroll/executor.js +446 -0
- package/dist/scroll/executor.test.d.ts +1 -0
- package/dist/scroll/executor.test.js +236 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/index.d.ts +13 -0
- package/dist/scroll/index.js +10 -0
- package/dist/scroll/pattern.d.ts +133 -0
- package/dist/scroll/pattern.js +507 -0
- package/dist/scroll/pattern.test.d.ts +1 -0
- package/dist/scroll/pattern.test.js +438 -0
- package/dist/stacking-context.test.js +3 -3
- package/dist/{frame-merge.d.ts → tree-ops/frame-merge.d.ts} +10 -0
- package/dist/{frame-merge.js → tree-ops/frame-merge.js} +23 -2
- package/dist/{frame-merge.test.js → tree-ops/frame-merge.test.js} +45 -0
- package/dist/tree-ops/index.d.ts +5 -0
- package/dist/tree-ops/index.js +6 -0
- package/dist/{resize-embedded-images.d.ts → tree-ops/resize-embedded-images.d.ts} +1 -1
- package/dist/{resize-embedded-images.js → tree-ops/resize-embedded-images.js} +2 -2
- package/dist/{resize-embedded-images.test.js → tree-ops/resize-embedded-images.test.js} +2 -2
- package/dist/tree-ops/tree-diff.d.ts +72 -0
- package/dist/tree-ops/tree-diff.js +196 -0
- package/dist/tree-ops/tree-diff.test.d.ts +1 -0
- package/dist/tree-ops/tree-diff.test.js +267 -0
- package/dist/tree-ops/viewbox-culling.d.ts +63 -0
- package/dist/tree-ops/viewbox-culling.js +286 -0
- package/dist/tree-ops/viewbox-culling.test.d.ts +1 -0
- package/dist/tree-ops/viewbox-culling.test.js +240 -0
- package/dist/utils/region-feedback.d.ts +56 -0
- package/dist/utils/region-feedback.js +134 -0
- package/dist/utils/region-feedback.test.d.ts +1 -0
- package/dist/utils/region-feedback.test.js +221 -0
- package/dist/webfont-unicode-range.test.js +2 -2
- package/package.json +31 -15
- package/src/{animator.test.ts → animation/animator.test.ts} +127 -0
- package/src/{animator.ts → animation/animator.ts} +118 -26
- package/src/{cursor-overlay.ts → animation/cursor-overlay.ts} +0 -2
- package/src/animation/index.ts +29 -0
- package/src/capture/embed.ts +305 -0
- package/src/capture/emoji.ts +226 -0
- package/src/{capture.ts → capture/index.ts} +513 -19
- package/src/capture/script/color-norm.ts +80 -0
- package/src/capture/script/emoji-detect.ts +97 -0
- package/src/capture/script/font-metrics.ts +130 -0
- package/src/capture/script/index.ts +1506 -0
- package/src/capture/script/placeholder-shown.ts +54 -0
- package/src/capture/script/pseudo-rules.ts +223 -0
- package/src/capture/script/utils.ts +18 -0
- package/src/capture/script/walker/borders-backgrounds.ts +277 -0
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/form-controls.ts +242 -0
- package/src/capture/script/walker/input-value.ts +221 -0
- package/src/capture/script/walker/lists-counters.ts +110 -0
- package/src/capture/script/walker/masks-clips.ts +181 -0
- package/src/capture/script/walker/pseudo-content.ts +784 -0
- package/src/capture/script/walker/pseudo-inject.ts +203 -0
- package/src/capture/script/walker/replaced-elements.ts +102 -0
- package/src/capture/script/walker/text-segments.ts +404 -0
- package/src/capture/script/walker/transforms.ts +160 -0
- package/src/capture/script/warnings.ts +39 -0
- package/src/capture/script.generated.ts +7 -0
- package/src/capture/types.ts +891 -0
- package/src/capture/warnings.ts +39 -0
- package/src/cli/animate.ts +511 -0
- package/src/cli/capture.ts +198 -0
- package/src/cli/common.ts +126 -0
- package/src/cli/index.ts +170 -0
- package/src/cross-origin-font-face.test.ts +1 -1
- package/src/dark-mode-capture.test.ts +2 -1
- package/src/dark-mode-form-controls.test.ts +3 -2
- package/src/embed-remote-images.test.ts +2 -3
- package/src/index.ts +82 -12
- package/src/kerf-jsx-augmentation.d.ts +1 -1
- package/src/mask.test.ts +14 -9
- package/src/post-processing/gzip.ts +14 -0
- package/src/post-processing/index.ts +5 -0
- package/src/post-processing/optimize.test.ts +48 -0
- package/src/preserve-aspect-ratio.test.ts +1 -1
- package/src/{border-radius.test.ts → render/borders.test.ts} +1 -1
- package/src/render/borders.ts +783 -0
- package/src/render/box-shadow.ts +65 -0
- package/src/render/colors.ts +163 -0
- package/src/{conic-raster.test.ts → render/conic-raster.test.ts} +3 -3
- package/src/{conic-raster.ts → render/conic-raster.ts} +3 -6
- package/src/{coretext-extractor.test.ts → render/coretext.test.ts} +1 -0
- package/src/render/css-tokens.ts +44 -0
- package/src/render/element-tree-to-svg.ts +5644 -0
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/{form-controls.ts → render/form-controls.ts} +47 -26
- package/src/render/format.ts +24 -0
- package/src/{gradients.test.ts → render/gradients.test.ts} +46 -0
- package/src/{gradients.ts → render/gradients.ts} +94 -2
- package/src/render/index.ts +14 -0
- package/src/render/opentype.js.d.ts +7 -0
- package/src/{text-to-path.test.ts → render/text-to-path.test.ts} +331 -20
- package/src/{text-to-path.ts → render/text-to-path.ts} +746 -50
- package/src/{text-renderer.test.ts → render/text.test.ts} +99 -5
- package/src/{text-renderer.ts → render/text.ts} +438 -44
- package/src/render/transforms.ts +41 -0
- package/src/scroll/composer.test.ts +505 -0
- package/src/scroll/composer.ts +373 -0
- package/src/scroll/executor.test.ts +270 -0
- package/src/scroll/executor.ts +592 -0
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/index.ts +36 -0
- package/src/scroll/pattern.test.ts +531 -0
- package/src/scroll/pattern.ts +610 -0
- package/src/stacking-context.test.ts +4 -3
- package/src/{frame-merge.test.ts → tree-ops/frame-merge.test.ts} +51 -0
- package/src/{frame-merge.ts → tree-ops/frame-merge.ts} +26 -3
- package/src/tree-ops/index.ts +11 -0
- package/src/{resize-embedded-images.test.ts → tree-ops/resize-embedded-images.test.ts} +3 -3
- package/src/{resize-embedded-images.ts → tree-ops/resize-embedded-images.ts} +3 -6
- package/src/tree-ops/tree-diff.test.ts +295 -0
- package/src/tree-ops/tree-diff.ts +236 -0
- package/src/tree-ops/viewbox-culling.test.ts +292 -0
- package/src/tree-ops/viewbox-culling.ts +327 -0
- package/src/utils/region-feedback.test.ts +261 -0
- package/src/utils/region-feedback.ts +216 -0
- package/src/webfont-unicode-range.test.ts +2 -2
- package/dist/chrome.d.ts +0 -45
- package/dist/chrome.js +0 -107
- package/dist/cli.js +0 -512
- package/dist/client/dom.d.ts +0 -10
- package/dist/client/dom.js +0 -17
- package/dist/dom-to-svg.js +0 -7717
- package/dist/jsx-runtime.d.ts +0 -27
- package/dist/jsx-runtime.js +0 -96
- package/dist/jsx-runtime.test.js +0 -41
- package/src/cli.ts +0 -582
- package/src/dom-to-svg.ts +0 -8376
- /package/dist/{animator.test.d.ts → animation/animator.test.d.ts} +0 -0
- /package/dist/{cursor-overlay.d.ts → animation/cursor-overlay.d.ts} +0 -0
- /package/dist/{cursor-overlay.test.d.ts → animation/cursor-overlay.test.d.ts} +0 -0
- /package/dist/{cursor-overlay.test.js → animation/cursor-overlay.test.js} +0 -0
- /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
- /package/dist/{optimize.d.ts → post-processing/optimize.d.ts} +0 -0
- /package/dist/{optimize.js → post-processing/optimize.js} +0 -0
- /package/dist/{border-radius.test.d.ts → post-processing/optimize.test.d.ts} +0 -0
- /package/dist/{conic-raster.test.d.ts → render/borders.test.d.ts} +0 -0
- /package/dist/{coretext-extractor.test.d.ts → render/conic-raster.test.d.ts} +0 -0
- /package/dist/{coretext-helper.d.ts → render/coretext.d.ts} +0 -0
- /package/dist/{coretext-helper.js → render/coretext.js} +0 -0
- /package/dist/{gradients.test.d.ts → render/coretext.test.d.ts} +0 -0
- /package/dist/{jsx-runtime.test.d.ts → render/gradients.test.d.ts} +0 -0
- /package/dist/{text-to-path.test.d.ts → render/text-to-path.test.d.ts} +0 -0
- /package/dist/{text-renderer.test.d.ts → render/text.test.d.ts} +0 -0
- /package/dist/{frame-merge.test.d.ts → tree-ops/frame-merge.test.d.ts} +0 -0
- /package/dist/{resize-embedded-images.test.d.ts → tree-ops/resize-embedded-images.test.d.ts} +0 -0
- /package/src/{cursor-overlay.test.ts → animation/cursor-overlay.test.ts} +0 -0
- /package/src/{optimize.ts → post-processing/optimize.ts} +0 -0
- /package/src/{coretext-helper.ts → render/coretext.ts} +0 -0
package/FEATURES.md
CHANGED
|
@@ -26,6 +26,7 @@ Each feature has a visual regression test that compares HTML-to-PNG with SVG-to-
|
|
|
26
26
|
- [x] **border-solid**: Solid borders with color
|
|
27
27
|
- [x] **border-radius**: Rounded corners
|
|
28
28
|
- [x] **border-radius-pill**: Fully rounded (pill shape)
|
|
29
|
+
- [x] **inline-box-decoration-break**: wrapped inline backgrounds / borders paint per line-fragment (`slice` + `clone`); first/last fragment own the start/end edges in slice mode, every fragment paints a full box in clone mode
|
|
29
30
|
|
|
30
31
|
### Layout
|
|
31
32
|
- [x] **layout-flex-row**: Horizontal flex layout with gap
|
package/README.md
CHANGED
|
@@ -16,6 +16,14 @@ Domotion captures real HTML/CSS as it renders in Chromium, then emits a single i
|
|
|
16
16
|
|
|
17
17
|
Early — extracted in 2026-04 from the slicekit project where it had been incubating as `tools/svg-demo-gen`. APIs may still shift while the project's external surface stabilizes.
|
|
18
18
|
|
|
19
|
+
## Platform support
|
|
20
|
+
|
|
21
|
+
Domotion ships as a normal npm package and is **designed** to work on macOS, Linux, and Windows — the captured SVG is meant to be pixel-faithful to Chromium on whichever platform the capture is running on (CoreText fallback on macOS, fontconfig on Linux, DirectWrite on Windows).
|
|
22
|
+
|
|
23
|
+
**Today it's only actively tested and calibrated on macOS.** Linux and Windows are roadmap items: cross-platform system-font path discovery, per-platform fallback-font chains calibrated against the host Chromium, optional bundled fallback fonts when no local match resolves, and CI coverage on both platforms. The package will install and run on Linux/Windows, but text rendering won't yet match the host Chromium as faithfully as it does on macOS.
|
|
24
|
+
|
|
25
|
+
If you'd like to help with cross-platform support — testing on Linux or Windows, reporting issues you hit, or sending fixes — please open an issue or PR on [GitHub](https://github.com/brianwestphal/domotion). Bug reports against macOS are also welcome.
|
|
26
|
+
|
|
19
27
|
## Install
|
|
20
28
|
|
|
21
29
|
```bash
|
|
@@ -28,6 +36,35 @@ yourself to keep the first job's runtime down.
|
|
|
28
36
|
|
|
29
37
|
## Usage
|
|
30
38
|
|
|
39
|
+
The fastest way in is the `domotion` CLI — no TypeScript, no Playwright bring-up. Point it at a URL or HTML file:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Capture a URL as SVG.
|
|
43
|
+
domotion capture https://example.com -o example.svg
|
|
44
|
+
|
|
45
|
+
# Capture a local HTML file at a specific viewport, only the .hero region, optimized.
|
|
46
|
+
domotion capture ./demo.html \
|
|
47
|
+
--width 1200 --height 600 \
|
|
48
|
+
--selector ".hero" \
|
|
49
|
+
--optimize \
|
|
50
|
+
-o hero.svg
|
|
51
|
+
|
|
52
|
+
# Capture HTML piped on stdin.
|
|
53
|
+
cat demo.html | domotion capture - -o demo.svg
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
domotion animate ./demo.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The config describes each frame (input URL or HTML file, duration, transition, optional pre-capture actions like `click` / `fill` / `scroll` / `hover`). See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
|
|
63
|
+
|
|
64
|
+
### Scripting API
|
|
65
|
+
|
|
66
|
+
When you outgrow the CLI — custom interaction loops, programmatic frame composition, custom overlays — the same primitives are available as a library:
|
|
67
|
+
|
|
31
68
|
```ts
|
|
32
69
|
import { captureElementTree, elementTreeToSvg, launchChromium, wrapSvg } from "domotion-svg";
|
|
33
70
|
|
|
@@ -8,6 +8,18 @@ import { type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
|
|
|
8
8
|
export interface AnimationFrame {
|
|
9
9
|
/** SVG content for this frame (from dom-to-svg) */
|
|
10
10
|
svgContent: string;
|
|
11
|
+
/**
|
|
12
|
+
* Per-element viewBox-cull keyframes CSS (DM-603). The caller runs
|
|
13
|
+
* `cullElementsOutsideViewBox()` on the captured tree before `elementTreeToSvg()` — that
|
|
14
|
+
* mutates `displayNone` / `cullClass` on each element (which the renderer
|
|
15
|
+
* surfaces) and returns the keyframes blocks that map each `cull-N` class
|
|
16
|
+
* to its visible window. The animator splices this CSS into the scene-wide
|
|
17
|
+
* `<style>` block.
|
|
18
|
+
*
|
|
19
|
+
* When omitted, no culling happens — callers passing pre-rendered
|
|
20
|
+
* `svgContent` strings without the cull CSS get unchanged behavior.
|
|
21
|
+
*/
|
|
22
|
+
cullCss?: string;
|
|
11
23
|
/** Duration this frame is shown (ms) */
|
|
12
24
|
duration: number;
|
|
13
25
|
/** Transition to next frame */
|
|
@@ -22,7 +34,7 @@ export interface AnimationFrame {
|
|
|
22
34
|
duration: number;
|
|
23
35
|
};
|
|
24
36
|
/** Overlays: typing, tap ripple */
|
|
25
|
-
overlays?:
|
|
37
|
+
overlays?: AnimationOverlay[];
|
|
26
38
|
/**
|
|
27
39
|
* Intra-frame property animations. Run during this frame's hold time.
|
|
28
40
|
* The CLI / `DemoRecorder` resolves selectors against the DOM at capture
|
|
@@ -99,7 +111,7 @@ export interface SvgOverlay {
|
|
|
99
111
|
delay?: number;
|
|
100
112
|
};
|
|
101
113
|
}
|
|
102
|
-
export type
|
|
114
|
+
export type AnimationOverlay = TypingOverlay | TapOverlay | SvgOverlay;
|
|
103
115
|
/**
|
|
104
116
|
* Animate a CSS property on captured elements that match a selector, while
|
|
105
117
|
* the frame is held on screen. The selector is resolved against the source
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Takes captured SVG frame content and composes them into a single
|
|
5
5
|
* animated SVG with CSS keyframe transitions.
|
|
6
6
|
*/
|
|
7
|
-
import { mergeFrames } from "
|
|
7
|
+
import { mergeFrames } from "../tree-ops/frame-merge.js";
|
|
8
8
|
import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
|
|
9
9
|
export function generateAnimatedSvg(config) {
|
|
10
10
|
const { width, height, frames } = config;
|
|
@@ -50,13 +50,27 @@ export function generateAnimatedSvg(config) {
|
|
|
50
50
|
const transEndPct = pct(timeOffset + frame.duration + transDur, totalDuration);
|
|
51
51
|
const prevFrame = i > 0 ? frames[i - 1] : null;
|
|
52
52
|
const entersViaPush = prevFrame?.transition?.type === "push-left";
|
|
53
|
+
const entersViaScroll = prevFrame?.transition?.type === "scroll";
|
|
54
|
+
// Both push-left and scroll overlap their transition with the next
|
|
55
|
+
// frame's entry — the next frame is already sliding in while the current
|
|
56
|
+
// one slides out, so its show window starts at `timeOffset - prevTransDur`
|
|
57
|
+
// rather than at `startPct`.
|
|
58
|
+
const entersViaOverlap = entersViaPush || entersViaScroll;
|
|
53
59
|
const prevTransDur = prevFrame != null ? transitionDuration(prevFrame) : 300;
|
|
54
|
-
const enterStartPct =
|
|
60
|
+
const enterStartPct = entersViaOverlap
|
|
55
61
|
? pct(timeOffset - prevTransDur, totalDuration)
|
|
56
62
|
: startPct;
|
|
57
63
|
if (transType === "push-left") {
|
|
58
64
|
// Push: slide in from right, slide out to left
|
|
59
65
|
frameGroups.push(` <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${frame.svgContent}\n </g></g>`);
|
|
66
|
+
// DM-599: parallel `fd-${i}` animation snaps `display` between none /
|
|
67
|
+
// inline at the visibility boundary so the browser can skip painting
|
|
68
|
+
// this frame's content while it's fully off-screen between cycles.
|
|
69
|
+
// Window is [enterStartPct .. transEndPct] (when the slide has fully
|
|
70
|
+
// exited the viewBox); 0.01% pad on each side keeps the snap inside the
|
|
71
|
+
// existing opacity:0 bookend.
|
|
72
|
+
const visStart = enterStartPct;
|
|
73
|
+
const visEnd = transEndPct;
|
|
60
74
|
keyframes.push(`
|
|
61
75
|
@keyframes fp-${i} {
|
|
62
76
|
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateX(${entersViaPush ? width : 0}px); }
|
|
@@ -70,21 +84,38 @@ export function generateAnimatedSvg(config) {
|
|
|
70
84
|
${enterStartPct}% { opacity: 1; }
|
|
71
85
|
${transEndPct}% { opacity: 1; }
|
|
72
86
|
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
|
|
73
|
-
}
|
|
74
|
-
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }
|
|
87
|
+
}${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
|
|
88
|
+
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
|
|
75
89
|
.fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
|
|
76
90
|
}
|
|
77
91
|
else if (transType === "scroll") {
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
// DM-609: `scroll` now means real geometric scroll between two frames
|
|
93
|
+
// (was opacity-only — see DM-604 §10a "replace with new geometric
|
|
94
|
+
// semantics"). Vertical equivalent of `push-left`: incoming frame
|
|
95
|
+
// slides up from the bottom of the viewport, outgoing slides up off
|
|
96
|
+
// the top. Uses height instead of width and translateY instead of
|
|
97
|
+
// translateX, otherwise identical machinery (incl. the cull-friendly
|
|
98
|
+
// `fd-${i}` display animation).
|
|
99
|
+
const entersViaScroll = prevFrame?.transition?.type === "scroll";
|
|
100
|
+
frameGroups.push(` <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${frame.svgContent}\n </g></g>`);
|
|
101
|
+
const visStart = enterStartPct;
|
|
102
|
+
const visEnd = transEndPct;
|
|
82
103
|
keyframes.push(`
|
|
83
|
-
@keyframes
|
|
84
|
-
0%, ${
|
|
85
|
-
${startPct}
|
|
104
|
+
@keyframes fp-${i} {
|
|
105
|
+
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateY(${entersViaScroll ? height : 0}px); }
|
|
106
|
+
${startPct}% { transform: translateY(0); }
|
|
107
|
+
${holdEndPct}% { transform: translateY(0); }
|
|
108
|
+
${transEndPct}% { transform: translateY(-${height}px); }
|
|
109
|
+
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateY(-${height}px); }
|
|
86
110
|
}
|
|
87
|
-
|
|
111
|
+
@keyframes fv-${i} {
|
|
112
|
+
0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
|
|
113
|
+
${enterStartPct}% { opacity: 1; }
|
|
114
|
+
${transEndPct}% { opacity: 1; }
|
|
115
|
+
${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
|
|
116
|
+
}${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
|
|
117
|
+
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
|
|
118
|
+
.fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
|
|
88
119
|
}
|
|
89
120
|
else {
|
|
90
121
|
// Crossfade or cut: opacity in/out.
|
|
@@ -105,14 +136,23 @@ export function generateAnimatedSvg(config) {
|
|
|
105
136
|
const endNum = parseFloat(transEndPct);
|
|
106
137
|
const beforeStart = Math.max(0, startNum - 0.001).toFixed(3);
|
|
107
138
|
const afterEnd = Math.min(100, endNum + 0.001).toFixed(3);
|
|
139
|
+
// DM-599: cut already uses step-end on the opacity animation, so we
|
|
140
|
+
// fold visibility into the same keyframes block — both snap together.
|
|
141
|
+
// DM-641: this used to toggle `display`. The base `.f { display: none }`
|
|
142
|
+
// rule kept the element out of the render tree at t=0, and Chromium
|
|
143
|
+
// doesn't tick infinite animations on out-of-tree elements — so the
|
|
144
|
+
// 0% keyframe never ran and the frame stayed permanently hidden.
|
|
145
|
+
// Switching to `visibility` leaves the element in the render tree
|
|
146
|
+
// (still skips painting, which was the DM-599 goal) so the animation
|
|
147
|
+
// ticks normally.
|
|
108
148
|
keyframes.push(`
|
|
109
149
|
@keyframes fv-${i} {
|
|
110
|
-
0% { opacity: 0; }
|
|
111
|
-
${beforeStart}% { opacity: 0; }
|
|
112
|
-
${startNum.toFixed(3)}% { opacity: 1; }
|
|
113
|
-
${endNum.toFixed(3)}% { opacity: 1; }
|
|
114
|
-
${afterEnd}% { opacity: 0; }
|
|
115
|
-
100% { opacity: 0; }
|
|
150
|
+
0% { opacity: 0; visibility: hidden; }
|
|
151
|
+
${beforeStart}% { opacity: 0; visibility: hidden; }
|
|
152
|
+
${startNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
153
|
+
${endNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
154
|
+
${afterEnd}% { opacity: 0; visibility: hidden; }
|
|
155
|
+
100% { opacity: 0; visibility: hidden; }
|
|
116
156
|
}
|
|
117
157
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
|
|
118
158
|
}
|
|
@@ -123,12 +163,14 @@ export function generateAnimatedSvg(config) {
|
|
|
123
163
|
const prevEnd = i > 0
|
|
124
164
|
? `${Math.max(0, parseFloat(fadeInStartPct) - 0.01).toFixed(2)}%,`
|
|
125
165
|
: "";
|
|
166
|
+
// DM-599: visible window spans the full fade — fadeInStart through
|
|
167
|
+
// transEnd (display stays `inline` while opacity interpolates).
|
|
126
168
|
keyframes.push(`
|
|
127
169
|
@keyframes fv-${i} {
|
|
128
170
|
0%, ${prevEnd} ${transEndPct}, 100% { opacity: 0; }
|
|
129
171
|
${startPct}, ${holdEndPct} { opacity: 1; }
|
|
130
|
-
}
|
|
131
|
-
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }`);
|
|
172
|
+
}${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, transEndPct)}
|
|
173
|
+
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }`);
|
|
132
174
|
}
|
|
133
175
|
}
|
|
134
176
|
// Overlays
|
|
@@ -156,6 +198,11 @@ export function generateAnimatedSvg(config) {
|
|
|
156
198
|
// Compose final SVG with XML declaration for proper UTF-8
|
|
157
199
|
const sharedDefsMarkup = config.sharedDefs ?? "";
|
|
158
200
|
const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
|
|
201
|
+
// DM-603: per-frame viewBox-cull keyframes — each frame's caller pre-ran
|
|
202
|
+
// `cullElementsOutsideViewBox()` and we splice the resulting blocks into the scene-wide
|
|
203
|
+
// <style>. The keyframes reference `var(--scene-dur)`; we expose that
|
|
204
|
+
// variable on the root selector below.
|
|
205
|
+
const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
|
|
159
206
|
// Cursor overlay (DM-277). The frame start times let the resolver pick
|
|
160
207
|
// which frame's selector matches apply at each event's timestamp.
|
|
161
208
|
let overlayMarkup = "";
|
|
@@ -175,8 +222,9 @@ export function generateAnimatedSvg(config) {
|
|
|
175
222
|
<clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
|
|
176
223
|
</defs>
|
|
177
224
|
<style>
|
|
178
|
-
|
|
179
|
-
|
|
225
|
+
:root { --scene-dur: ${totalSec.toFixed(2)}s; }
|
|
226
|
+
.f { opacity: 0; visibility: hidden; }
|
|
227
|
+
${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
|
|
180
228
|
</style>
|
|
181
229
|
<g clip-path="url(#viewport-clip)">
|
|
182
230
|
<rect width="${width}" height="${height}" fill="#0d1117" />
|
|
@@ -261,6 +309,36 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
|
|
|
261
309
|
function pct(ms, total) {
|
|
262
310
|
return `${((ms / total) * 100).toFixed(2)}%`;
|
|
263
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* DM-599: build a step-end keyframes block that toggles `display` between
|
|
314
|
+
* `none` and `inline` around a visible window. Used in parallel with the
|
|
315
|
+
* opacity-controlling `fv-*` animation so the browser can skip painting the
|
|
316
|
+
* frame entirely while it's outside its show window (the dominant cost on
|
|
317
|
+
* long multi-frame demos with complex captured content).
|
|
318
|
+
*
|
|
319
|
+
* `visibleStartPct` / `visibleEndPct` accept either a numeric-style string
|
|
320
|
+
* (`"12.34"`) or one with a trailing `%` (`"12.34%"`) — `pct()` returns the
|
|
321
|
+
* latter and the unmerged-path keyframes feed either form.
|
|
322
|
+
*/
|
|
323
|
+
function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
|
|
324
|
+
// DM-641: kept the function name for callers but the toggle is now on
|
|
325
|
+
// `visibility`, not `display`, for the same reason as `fv-${i}` above —
|
|
326
|
+
// animating `display` away from an element starting `display: none` never
|
|
327
|
+
// ticks in Chromium.
|
|
328
|
+
const start = parseFloat(String(visibleStartPct));
|
|
329
|
+
const end = parseFloat(String(visibleEndPct));
|
|
330
|
+
const startMinus = Math.max(0, start - 0.01).toFixed(3);
|
|
331
|
+
const endPlus = Math.min(100, end + 0.01).toFixed(3);
|
|
332
|
+
return `
|
|
333
|
+
@keyframes ${name} {
|
|
334
|
+
0% { visibility: hidden; }
|
|
335
|
+
${startMinus}% { visibility: hidden; }
|
|
336
|
+
${start.toFixed(3)}% { visibility: visible; }
|
|
337
|
+
${end.toFixed(3)}% { visibility: visible; }
|
|
338
|
+
${endPlus}% { visibility: hidden; }
|
|
339
|
+
100% { visibility: hidden; }
|
|
340
|
+
}`;
|
|
341
|
+
}
|
|
264
342
|
/**
|
|
265
343
|
* Render a frame-local SVG overlay. The embedded SVG markup is wrapped in a
|
|
266
344
|
* `<g transform="translate(x y)" clip-path="..."/>` and an inner
|
|
@@ -344,6 +422,8 @@ function composeMergedSvg(config, frameTiming, totalSec) {
|
|
|
344
422
|
const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
|
|
345
423
|
const sharedDefsMarkup = config.sharedDefs ?? "";
|
|
346
424
|
const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
|
|
425
|
+
// DM-603: viewBox-cull keyframes from each frame's pre-pass (see unmerged path).
|
|
426
|
+
const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
|
|
347
427
|
// Cursor overlay (DM-277). Same emission as the unmerged path — the
|
|
348
428
|
// overlay sits above the merged frame group, clipped to the viewport.
|
|
349
429
|
const totalDuration = totalSec * 1000;
|
|
@@ -365,7 +445,7 @@ function composeMergedSvg(config, frameTiming, totalSec) {
|
|
|
365
445
|
</defs>
|
|
366
446
|
<style>
|
|
367
447
|
:root { --scene-dur: ${totalSec.toFixed(2)}s; }
|
|
368
|
-
${css}${animationCss}
|
|
448
|
+
${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
|
|
369
449
|
</style>
|
|
370
450
|
<g clip-path="url(#viewport-clip)">
|
|
371
451
|
<rect width="${width}" height="${height}" fill="#0d1117" />
|
|
@@ -50,6 +50,26 @@ describe("animator", () => {
|
|
|
50
50
|
});
|
|
51
51
|
expect((svg.match(/<rect width="50" height="50" fill="green"\/>/g) ?? []).length).toBe(1);
|
|
52
52
|
});
|
|
53
|
+
it("DM-609: scroll transition is now a vertical push (translateY), not opacity-only", () => {
|
|
54
|
+
// The `scroll` transition used to be opacity-only (a misnomer). Per
|
|
55
|
+
// DM-604 §10a, it's been replaced with real geometric semantics: the
|
|
56
|
+
// outgoing frame slides up off the top while the next slides up from
|
|
57
|
+
// the bottom. We verify by checking for translateY in the emitted CSS.
|
|
58
|
+
const svg = generateAnimatedSvg({
|
|
59
|
+
width: 100, height: 100,
|
|
60
|
+
frames: [
|
|
61
|
+
{ svgContent: `<rect/>`, duration: 1000, transition: { type: "scroll", duration: 200 } },
|
|
62
|
+
{ svgContent: `<rect/>`, duration: 1000 },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
expect(svg).toContain("translateY");
|
|
66
|
+
// The clipPath wrapper is added too (frames are clipped to viewport
|
|
67
|
+
// during the slide so they don't show outside their region).
|
|
68
|
+
expect(svg).toMatch(/<clipPath id="fc-\d+">/);
|
|
69
|
+
// The fp-N keyframes (transform animation) get emitted alongside fv-N
|
|
70
|
+
// (opacity) and fd-N (display) — mirrors the push-left structure.
|
|
71
|
+
expect(svg).toMatch(/@keyframes fp-0/);
|
|
72
|
+
});
|
|
53
73
|
it("non-crossfade transitions are unaffected", () => {
|
|
54
74
|
const svg = generateAnimatedSvg({
|
|
55
75
|
width: 100,
|
|
@@ -152,6 +172,107 @@ describe("animator", () => {
|
|
|
152
172
|
expect(svg).toContain("transform: translateY(240px)");
|
|
153
173
|
expect(svg).toContain("transform: translateY(0px)");
|
|
154
174
|
});
|
|
175
|
+
it("DM-599/DM-641: push-left frame gets a paired fd-N visibility animation alongside fv-N", () => {
|
|
176
|
+
// push-left is unmergeable (the merge fast path only takes crossfade/cut),
|
|
177
|
+
// so it goes through the unmerged emit path that emits per-frame fv-/fp-
|
|
178
|
+
// blocks. The DM-599 optimization adds an fd-N keyframes block that
|
|
179
|
+
// toggles a paint-skip property so the frame is dropped from paint
|
|
180
|
+
// outside its show window. DM-641 changed that toggle from `display`
|
|
181
|
+
// (which parks the CSS-animations engine when an element starts out of
|
|
182
|
+
// the render tree) to `visibility` (which keeps the element rendered
|
|
183
|
+
// but skips painting it).
|
|
184
|
+
const svg = generateAnimatedSvg({
|
|
185
|
+
width: 100, height: 100,
|
|
186
|
+
frames: [
|
|
187
|
+
{ svgContent: `<rect/>`, duration: 1000, transition: { type: "push-left", duration: 200 } },
|
|
188
|
+
{ svgContent: `<rect/>`, duration: 1000 },
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
// Both frames get an fd-N keyframes block …
|
|
192
|
+
expect(svg).toMatch(/@keyframes fd-0\s*{/);
|
|
193
|
+
expect(svg).toMatch(/@keyframes fd-1\s*{/);
|
|
194
|
+
// … alongside the existing fv-N opacity block.
|
|
195
|
+
expect(svg).toMatch(/@keyframes fv-0\s*{/);
|
|
196
|
+
// The keyframes flip visibility (DM-641 — formerly display).
|
|
197
|
+
expect(svg).toMatch(/visibility:\s*hidden/);
|
|
198
|
+
expect(svg).toMatch(/visibility:\s*visible/);
|
|
199
|
+
// DM-641: must NOT emit display toggles anywhere — those broke the
|
|
200
|
+
// infinite animation in Chromium.
|
|
201
|
+
expect(svg).not.toMatch(/display:\s*none/);
|
|
202
|
+
expect(svg).not.toMatch(/display:\s*inline/);
|
|
203
|
+
// The frame's CSS rule lists BOTH animations, with the fd one tagged
|
|
204
|
+
// step-end so the visibility flip is instant.
|
|
205
|
+
expect(svg).toMatch(/\.f-0\s*{\s*animation:[^}]*fv-0[^}]*,[^}]*fd-0[^}]*step-end/);
|
|
206
|
+
// The base .f rule sets visibility:hidden (was display:none pre-DM-641).
|
|
207
|
+
expect(svg).toMatch(/\.f\s*{[^}]*visibility:\s*hidden/);
|
|
208
|
+
});
|
|
209
|
+
it("DM-599/DM-641: cut frames fold visibility into fv-N (same step-end timing)", () => {
|
|
210
|
+
// Three explicit `cut` frames — the all-mergeable check trips and these
|
|
211
|
+
// route through the MERGE pipeline. But a non-mergeable transition mixed
|
|
212
|
+
// in (e.g. push-left) would route this through the unmerged path. We
|
|
213
|
+
// verify the unmerged path's cut branch here by mixing.
|
|
214
|
+
const svg = generateAnimatedSvg({
|
|
215
|
+
width: 100, height: 100,
|
|
216
|
+
frames: [
|
|
217
|
+
{ svgContent: `<rect fill="red"/>`, duration: 1000, transition: { type: "push-left", duration: 100 } },
|
|
218
|
+
{ svgContent: `<rect fill="blue"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
219
|
+
{ svgContent: `<rect fill="green"/>`, duration: 1000 },
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
// The "cut" frame's fv-N keyframes carry the visibility toggle inline
|
|
223
|
+
// (no separate fd-N block) since cut already uses step-end on fv-N.
|
|
224
|
+
const fv1Match = svg.match(/@keyframes fv-1\s*{[\s\S]*?\n\s*}/);
|
|
225
|
+
expect(fv1Match).not.toBeNull();
|
|
226
|
+
expect(fv1Match[0]).toMatch(/visibility:\s*hidden/);
|
|
227
|
+
expect(fv1Match[0]).toMatch(/visibility:\s*visible/);
|
|
228
|
+
// The "cut" frame uses ONLY fv-1 (no fd-1 — it's folded in).
|
|
229
|
+
expect(svg).not.toMatch(/@keyframes fd-1\s*{/);
|
|
230
|
+
});
|
|
231
|
+
it("DM-599/DM-641: merged-path keyframes emit visibility alongside opacity", () => {
|
|
232
|
+
// Two crossfade frames with different content route through the merge
|
|
233
|
+
// pipeline. Per-element visibility classes (tN) now toggle BOTH opacity
|
|
234
|
+
// and visibility so the browser can skip painting hidden elements.
|
|
235
|
+
const svg = generateAnimatedSvg({
|
|
236
|
+
width: 100, height: 100,
|
|
237
|
+
frames: [
|
|
238
|
+
{ svgContent: `<rect fill="red" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
239
|
+
{ svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
// Each tN keyframe stop with opacity:1 also has visibility:visible; each
|
|
243
|
+
// opacity:0 stop has visibility:hidden.
|
|
244
|
+
const tN = svg.match(/@keyframes t\d+\s*{[\s\S]*?\n\s*}/);
|
|
245
|
+
expect(tN).not.toBeNull();
|
|
246
|
+
expect(tN[0]).toMatch(/opacity:\s*1;\s*visibility:\s*visible/);
|
|
247
|
+
expect(tN[0]).toMatch(/opacity:\s*0;\s*visibility:\s*hidden/);
|
|
248
|
+
});
|
|
249
|
+
it("DM-641: never emits `display: none` keyframes (would park Chromium's animation engine)", () => {
|
|
250
|
+
// Regression. The repro from the ticket: a multi-frame animation with
|
|
251
|
+
// `cut` transitions produced `@keyframes fv-0 { 0% { opacity:0; display:none } … }`
|
|
252
|
+
// plus `.f { display: none }`, which Chromium would never tick — so
|
|
253
|
+
// EVERY frame stayed permanently hidden when the SVG was loaded into a
|
|
254
|
+
// browser. The fix swapped both sites onto `visibility`. This test pins
|
|
255
|
+
// the fix on every code path that emits keyframes for the animator.
|
|
256
|
+
const cutSvg = generateAnimatedSvg({
|
|
257
|
+
width: 100, height: 100,
|
|
258
|
+
frames: [
|
|
259
|
+
{ svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
260
|
+
{ svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
261
|
+
{ svgContent: `<rect/>`, duration: 1000 },
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
expect(cutSvg).not.toMatch(/display:\s*none/);
|
|
265
|
+
expect(cutSvg).not.toMatch(/display:\s*inline/);
|
|
266
|
+
const pushSvg = generateAnimatedSvg({
|
|
267
|
+
width: 100, height: 100,
|
|
268
|
+
frames: [
|
|
269
|
+
{ svgContent: `<rect/>`, duration: 1000, transition: { type: "push-left", duration: 100 } },
|
|
270
|
+
{ svgContent: `<rect/>`, duration: 1000 },
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
expect(pushSvg).not.toMatch(/display:\s*none/);
|
|
274
|
+
expect(pushSvg).not.toMatch(/display:\s*inline/);
|
|
275
|
+
});
|
|
155
276
|
it("cut transition: timeline boundary is exactly at the frame edge", () => {
|
|
156
277
|
// For two frames each held 1000ms with cut transitions and no overlap,
|
|
157
278
|
// the visibility flip should land at exactly 50% of the scene.
|
|
@@ -114,13 +114,11 @@ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
|
|
|
114
114
|
return { x: curX + ev.by.dx, y: curY + ev.by.dy };
|
|
115
115
|
if (ev.selector != null) {
|
|
116
116
|
if (resolveSelector == null) {
|
|
117
|
-
// eslint-disable-next-line no-console
|
|
118
117
|
console.warn(`cursor-overlay: selector "${ev.selector}" used but no resolveSelector provided; skipping`);
|
|
119
118
|
return null;
|
|
120
119
|
}
|
|
121
120
|
const rect = resolveSelector(ev.selector, frameIndex);
|
|
122
121
|
if (rect == null) {
|
|
123
|
-
// eslint-disable-next-line no-console
|
|
124
122
|
console.warn(`cursor-overlay: selector "${ev.selector}" matched no element in frame ${frameIndex}; skipping`);
|
|
125
123
|
return null;
|
|
126
124
|
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { generateAnimatedSvg, type AnimationConfig, type AnimationFrame, type AnimationOverlay, type TypingOverlay, type TapOverlay, type SvgOverlay, type IntraFrameAnimation, } from "./animator.js";
|
|
2
|
+
export { cursorOverlayMarkup, resolveCursorScript, type CursorOverlay, type CursorEvent, type CursorMoveEvent, type CursorClickEvent, type CursorShowEvent, type CursorHideEvent, type CursorStyle, type SelectorResolver, } from "./cursor-overlay.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public surface of the animation pipeline. The composer
|
|
2
|
+
// (`generateAnimatedSvg`) consumes per-frame element trees + transition /
|
|
3
|
+
// overlay config and emits one self-contained SVG with `@keyframes` cross-
|
|
4
|
+
// fade / push-left / scroll / cut transitions and optional typing / tap /
|
|
5
|
+
// SVG / cursor overlays.
|
|
6
|
+
export { generateAnimatedSvg, } from "./animator.js";
|
|
7
|
+
export { cursorOverlayMarkup, resolveCursorScript, } from "./cursor-overlay.js";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image-embed pipeline: turn `<img src>`, `background-image: url(...)`,
|
|
3
|
+
* `mask-image: url(...)`, `border-image-source: url(...)`, and
|
|
4
|
+
* `list-style-image: url(...)` URLs into self-contained data URIs the renderer
|
|
5
|
+
* can inline into the SVG output. Three pieces:
|
|
6
|
+
*
|
|
7
|
+
* 1. `embedAsDataUri` / `_dataUriCache` — synchronous local-file / cached
|
|
8
|
+
* data-URI lookup used by the renderer at emit time.
|
|
9
|
+
* 2. `embedResizedDataUri` / `_resizedDataUriCache` / `_activeHiDPIFactor` —
|
|
10
|
+
* DM-540 size-tagged variant: returns a pre-resized data URI when the
|
|
11
|
+
* resize pre-pass (`resizeEmbeddedImages`) populated the cache for the
|
|
12
|
+
* consumer's CSS box, falling back to the source-resolution URI otherwise.
|
|
13
|
+
* 3. `embedRemoteImages` — async pre-pass that fetches every http(s) URL
|
|
14
|
+
* referenced by the captured tree and stashes the bytes in
|
|
15
|
+
* `_dataUriCache`. Yields a self-contained SVG that renders in image
|
|
16
|
+
* viewers (Preview, QuickLook, etc.) that block remote resources from
|
|
17
|
+
* local files.
|
|
18
|
+
*/
|
|
19
|
+
import type { CapturedElement, CaptureWarning } from "./types.js";
|
|
20
|
+
/**
|
|
21
|
+
* @internal — exposed for `resize-embedded-images.ts` (DM-539). The resize
|
|
22
|
+
* pre-pass reads source bytes from this cache (populated by `embedRemoteImages`)
|
|
23
|
+
* and writes per-(URL, size) resized variants into `_resizedDataUriCache`.
|
|
24
|
+
*/
|
|
25
|
+
export declare const _dataUriCache: Map<string, string>;
|
|
26
|
+
/**
|
|
27
|
+
* @internal — DM-539. Per-source-URL map of sizeKey → resized data URI. The
|
|
28
|
+
* sizeKey is `${ceil(targetW * hiDPI)}x${ceil(targetH * hiDPI)}`. Populated by
|
|
29
|
+
* `resizeEmbeddedImages` (only when `embedRemoteImagesResize: true`); read by
|
|
30
|
+
* `embedResizedDataUri` (DM-540) when the renderer emits an `<image href>`.
|
|
31
|
+
* Empty at module load — first capture with the resize flag fills it.
|
|
32
|
+
*/
|
|
33
|
+
export declare const _resizedDataUriCache: Map<string, Map<string, string>>;
|
|
34
|
+
export declare function setActiveHiDPIFactor(n: number): void;
|
|
35
|
+
/**
|
|
36
|
+
* DM-540 — renderer-side lookup for the image-resize-on-embed pipeline.
|
|
37
|
+
* Returns the resized PNG data URI for `(url, ceil(w * hiDPI), ceil(h * hiDPI))`
|
|
38
|
+
* when the resize pre-pass populated `_resizedDataUriCache` for that key;
|
|
39
|
+
* otherwise falls back to `embedAsDataUri(url)` so the renderer behaves
|
|
40
|
+
* identically to today when resize is disabled (no entries in the resized
|
|
41
|
+
* cache → source-resolution data URI returned).
|
|
42
|
+
*/
|
|
43
|
+
export declare function embedResizedDataUri(url: string, consumerW: number, consumerH: number, hiDPIFactor?: number): string;
|
|
44
|
+
export interface EmbedRemoteImagesOptions {
|
|
45
|
+
/**
|
|
46
|
+
* DM-527: append per-URL fetch failures here as `CaptureWarning` entries.
|
|
47
|
+
* If omitted, warnings push to the module-global `lastCaptureWarnings`
|
|
48
|
+
* (visible via `getLastCaptureWarnings` / `logCaptureWarnings`). Concurrent
|
|
49
|
+
* captures should pass an explicit array to avoid racing on the global.
|
|
50
|
+
*/
|
|
51
|
+
warnings?: CaptureWarning[];
|
|
52
|
+
/**
|
|
53
|
+
* DM-528: per-URL fetch timeout in ms. A stalled CDN host (slow DNS, slow
|
|
54
|
+
* first-byte, unresponsive origin) would otherwise hang the capture
|
|
55
|
+
* indefinitely — the parallel `Promise.all` won't resolve until every
|
|
56
|
+
* fetch settles. With this timeout the slowest fetch caps total pre-pass
|
|
57
|
+
* time at ~`timeoutMs * (retries + 1) + retryBackoffMs * retries` (since
|
|
58
|
+
* fetches run in parallel). Timed-out fetches produce a `remote-image`
|
|
59
|
+
* warning and the URL stays as-is in the SVG. Default 10000.
|
|
60
|
+
*/
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
/**
|
|
63
|
+
* DM-529: number of retry attempts for transient failures (5xx response,
|
|
64
|
+
* network error, or timeout). 4xx responses are not retried — those are
|
|
65
|
+
* deterministic and a retry would just consume time. The originating
|
|
66
|
+
* fetch + retries together can take up to
|
|
67
|
+
* `(retries + 1) * timeoutMs + retries * retryBackoffMs` per URL, so keep
|
|
68
|
+
* this small. Default 1.
|
|
69
|
+
*/
|
|
70
|
+
retries?: number;
|
|
71
|
+
/**
|
|
72
|
+
* DM-529: delay (ms) between attempts when retrying a transient failure.
|
|
73
|
+
* Default 500.
|
|
74
|
+
*/
|
|
75
|
+
retryBackoffMs?: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* DM-512: fetch every http(s) image URL referenced by the captured tree and
|
|
79
|
+
* stash the resolved bytes as a data: URI in the renderer's data-URI cache.
|
|
80
|
+
* Subsequent calls to `embedAsDataUri` for those URLs return the cached data
|
|
81
|
+
* URI instead of passing the URL through verbatim — yielding a self-contained
|
|
82
|
+
* SVG that loads correctly in image viewers (Preview, Finder QuickLook, etc.)
|
|
83
|
+
* that don't fetch remote resources from local files.
|
|
84
|
+
*
|
|
85
|
+
* Walks the captured tree once collecting URLs from: `imageSrc` (for `<img>`),
|
|
86
|
+
* `pseudoImages[].url` (for `::before`/`::after` content: url(...)), and CSS
|
|
87
|
+
* `url(...)` tokens inside `styles.backgroundImage` / `.maskImage` /
|
|
88
|
+
* `.borderImageSource` / `.listStyleImage`. Dedupes per call.
|
|
89
|
+
*
|
|
90
|
+
* Per-URL fetch failures (network error, non-2xx, missing Content-Type) leave
|
|
91
|
+
* the URL in the tree as-is, so the SVG still references it. DM-527: each
|
|
92
|
+
* failure is surfaced as a `CaptureWarning` (feature: `remote-image`) carrying
|
|
93
|
+
* the URL and the HTTP status / error class, so callers can trace which images
|
|
94
|
+
* didn't inline. The warning is appended to `options.warnings` if supplied,
|
|
95
|
+
* otherwise to `getLastCaptureWarnings()`.
|
|
96
|
+
*/
|
|
97
|
+
export declare function embedRemoteImages(tree: CapturedElement[], options?: EmbedRemoteImagesOptions): Promise<void>;
|