domotion-svg 0.1.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.
Files changed (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Device chrome wrappers — render terminal / browser / phone bezels
3
+ * around captured SVG content.
4
+ *
5
+ * The chrome adds margin and decorative elements at known fixed offsets,
6
+ * then translates the captured content into the inner area. The total
7
+ * rendered SVG grows by the chrome's outer dimensions.
8
+ *
9
+ * Each renderer returns:
10
+ * - `outerWidth` / `outerHeight` — the resulting SVG's full size.
11
+ * - `contentX` / `contentY` — where the captured content should be drawn.
12
+ * - `defs` — extra `<defs>` content to embed in the wrapper SVG.
13
+ * - `before` / `after` — markup to wrap the translated captured content.
14
+ *
15
+ * The composer (`generateAnimatedSvg` / `wrapWithChrome`) is responsible for
16
+ * stitching these into the final SVG.
17
+ */
18
+ export type DeviceChromeKind = "terminal" | "browser" | "phone";
19
+ export interface DeviceChromeConfig {
20
+ type: DeviceChromeKind;
21
+ /** For browser chrome: the URL to display in the address bar. */
22
+ url?: string;
23
+ /** For terminal/browser chrome: the title text in the title bar / tab. */
24
+ title?: string;
25
+ }
26
+ export interface ChromeFrame {
27
+ outerWidth: number;
28
+ outerHeight: number;
29
+ contentX: number;
30
+ contentY: number;
31
+ defs: string;
32
+ before: string;
33
+ after: string;
34
+ }
35
+ export declare function buildChrome(config: DeviceChromeConfig, contentWidth: number, contentHeight: number): ChromeFrame;
36
+ /**
37
+ * Wrap a complete `<svg>...</svg>` document in device chrome. The chrome
38
+ * is rendered as outer SVG markup and the captured content is embedded
39
+ * via a translated `<g>`.
40
+ *
41
+ * `inner` should be the SVG fragment (`defs + groups`) returned by
42
+ * `elementTreeToSvg` (NOT a wrapped `<svg>` document) so it can be embedded
43
+ * directly into the chrome's translation group.
44
+ */
45
+ export declare function wrapWithChrome(inner: string, contentWidth: number, contentHeight: number, chrome: DeviceChromeConfig): string;
package/dist/chrome.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Device chrome wrappers — render terminal / browser / phone bezels
3
+ * around captured SVG content.
4
+ *
5
+ * The chrome adds margin and decorative elements at known fixed offsets,
6
+ * then translates the captured content into the inner area. The total
7
+ * rendered SVG grows by the chrome's outer dimensions.
8
+ *
9
+ * Each renderer returns:
10
+ * - `outerWidth` / `outerHeight` — the resulting SVG's full size.
11
+ * - `contentX` / `contentY` — where the captured content should be drawn.
12
+ * - `defs` — extra `<defs>` content to embed in the wrapper SVG.
13
+ * - `before` / `after` — markup to wrap the translated captured content.
14
+ *
15
+ * The composer (`generateAnimatedSvg` / `wrapWithChrome`) is responsible for
16
+ * stitching these into the final SVG.
17
+ */
18
+ export function buildChrome(config, contentWidth, contentHeight) {
19
+ if (config.type === "terminal")
20
+ return terminalChrome(contentWidth, contentHeight, config.title ?? "Terminal");
21
+ if (config.type === "browser")
22
+ return browserChrome(contentWidth, contentHeight, config.url ?? "https://example.com", config.title ?? "");
23
+ if (config.type === "phone")
24
+ return phoneChrome(contentWidth, contentHeight);
25
+ throw new Error(`buildChrome: unknown chrome type "${config.type}"`);
26
+ }
27
+ /**
28
+ * Wrap a complete `<svg>...</svg>` document in device chrome. The chrome
29
+ * is rendered as outer SVG markup and the captured content is embedded
30
+ * via a translated `<g>`.
31
+ *
32
+ * `inner` should be the SVG fragment (`defs + groups`) returned by
33
+ * `elementTreeToSvg` (NOT a wrapped `<svg>` document) so it can be embedded
34
+ * directly into the chrome's translation group.
35
+ */
36
+ export function wrapWithChrome(inner, contentWidth, contentHeight, chrome) {
37
+ const f = buildChrome(chrome, contentWidth, contentHeight);
38
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${f.outerWidth} ${f.outerHeight}" width="${f.outerWidth}" height="${f.outerHeight}">`
39
+ + (f.defs !== "" ? `<defs>${f.defs}</defs>` : "")
40
+ + f.before
41
+ + `<g transform="translate(${f.contentX}, ${f.contentY})">${inner}</g>`
42
+ + f.after
43
+ + `</svg>`;
44
+ }
45
+ function terminalChrome(width, height, title) {
46
+ const padding = 16;
47
+ const titleBarHeight = 36;
48
+ const outerWidth = width + padding * 2;
49
+ const outerHeight = height + titleBarHeight + padding;
50
+ const contentX = padding;
51
+ const contentY = titleBarHeight;
52
+ const before = `<rect width="${outerWidth}" height="${outerHeight}" rx="10" fill="#1e1e2e" />`
53
+ + `<rect width="${outerWidth}" height="${titleBarHeight}" rx="10" fill="#2b2b3d" />`
54
+ + `<rect y="${titleBarHeight - 4}" width="${outerWidth}" height="4" fill="#2b2b3d" />`
55
+ + `<circle cx="20" cy="${titleBarHeight / 2}" r="6" fill="#ff5f57" />`
56
+ + `<circle cx="40" cy="${titleBarHeight / 2}" r="6" fill="#febc2e" />`
57
+ + `<circle cx="60" cy="${titleBarHeight / 2}" r="6" fill="#28c840" />`
58
+ + `<text x="${outerWidth / 2}" y="${titleBarHeight / 2 + 4}" text-anchor="middle" font-family="-apple-system, sans-serif" font-size="12" fill="#8b8fa3">${escapeXml(title)}</text>`;
59
+ return { outerWidth, outerHeight, contentX, contentY, defs: "", before, after: "" };
60
+ }
61
+ function browserChrome(width, height, url, title) {
62
+ const titleBarHeight = 40;
63
+ const addressBarHeight = 32;
64
+ const chromeHeight = titleBarHeight + addressBarHeight;
65
+ const outerWidth = width;
66
+ const outerHeight = height + chromeHeight;
67
+ const contentX = 0;
68
+ const contentY = chromeHeight;
69
+ const tabLabel = title === "" ? hostFrom(url) : title;
70
+ const before = `<rect width="${outerWidth}" height="${outerHeight}" rx="10" fill="#161b22" />`
71
+ + `<rect width="${outerWidth}" height="${chromeHeight}" rx="10" fill="#21262d" />`
72
+ + `<rect y="${chromeHeight - 4}" width="${outerWidth}" height="4" fill="#21262d" />`
73
+ + `<circle cx="20" cy="20" r="6" fill="#ff5f57" />`
74
+ + `<circle cx="40" cy="20" r="6" fill="#febc2e" />`
75
+ + `<circle cx="60" cy="20" r="6" fill="#28c840" />`
76
+ + `<rect x="80" y="8" width="180" height="24" rx="6" fill="#161b22" />`
77
+ + `<text x="92" y="24" font-family="-apple-system, sans-serif" font-size="11" fill="#e6edf3">${escapeXml(tabLabel)}</text>`
78
+ + `<rect x="12" y="${titleBarHeight + 4}" width="${outerWidth - 24}" height="24" rx="6" fill="#0d1117" />`
79
+ + `<text x="24" y="${titleBarHeight + 20}" font-family="-apple-system, sans-serif" font-size="11" fill="#8b949e">${escapeXml(url)}</text>`;
80
+ return { outerWidth, outerHeight, contentX, contentY, defs: "", before, after: "" };
81
+ }
82
+ function phoneChrome(width, height) {
83
+ const framePadding = 12;
84
+ const statusBarHeight = 44;
85
+ const homeIndicatorHeight = 34;
86
+ const outerWidth = width + framePadding * 2;
87
+ const outerHeight = height + statusBarHeight + homeIndicatorHeight + framePadding * 2;
88
+ const contentX = framePadding;
89
+ const contentY = framePadding + statusBarHeight;
90
+ const before = `<rect width="${outerWidth}" height="${outerHeight}" rx="40" fill="#1a1a1a" />`
91
+ + `<rect x="${framePadding}" y="${framePadding}" width="${width}" height="${outerHeight - framePadding * 2}" rx="4" fill="#0d1117" />`
92
+ + `<rect x="${outerWidth / 2 - 60}" y="${framePadding}" width="120" height="28" rx="12" fill="#1a1a1a" />`
93
+ + `<text x="${framePadding + 16}" y="${framePadding + 28}" font-family="-apple-system, sans-serif" font-size="12" font-weight="600" fill="#e6edf3">9:41</text>`
94
+ + `<rect x="${outerWidth / 2 - 67}" y="${outerHeight - framePadding - 20}" width="134" height="5" rx="2.5" fill="#e6edf3" opacity="0.3" />`;
95
+ return { outerWidth, outerHeight, contentX, contentY, defs: "", before, after: "" };
96
+ }
97
+ function hostFrom(url) {
98
+ try {
99
+ return new URL(url).hostname;
100
+ }
101
+ catch {
102
+ return url;
103
+ }
104
+ }
105
+ function escapeXml(s) {
106
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
107
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Domotion CLI — DOM-to-animated-SVG renderer.
4
+ *
5
+ * Two commands:
6
+ * domotion capture <input> [options] single-frame capture
7
+ * domotion animate <config.json> multi-frame animated capture
8
+ *
9
+ * `<input>` for `capture` may be:
10
+ * - a URL (`https://...`, `http://...`)
11
+ * - a local HTML file path
12
+ * - `-` to read HTML from stdin
13
+ *
14
+ * Run `domotion --help` for the full option list.
15
+ */
16
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Domotion CLI — DOM-to-animated-SVG renderer.
4
+ *
5
+ * Two commands:
6
+ * domotion capture <input> [options] single-frame capture
7
+ * domotion animate <config.json> multi-frame animated capture
8
+ *
9
+ * `<input>` for `capture` may be:
10
+ * - a URL (`https://...`, `http://...`)
11
+ * - a local HTML file path
12
+ * - `-` to read HTML from stdin
13
+ *
14
+ * Run `domotion --help` for the full option list.
15
+ */
16
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { resolve, dirname, basename } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import { parseArgs } from "node:util";
20
+ import { captureElementTree, elementTreeToSvg, wrapSvg, generateAnimatedSvg, optimizeSvg, launchChromium, logCaptureWarnings, discoverAndRegisterWebfonts, attachWebfontTracker, clearWebfonts, } from "./index.js";
21
+ const VERSION = "0.1.0";
22
+ const HELP = `domotion ${VERSION} — DOM-to-animated-SVG renderer
23
+
24
+ Usage:
25
+ domotion capture <input> [options]
26
+ domotion animate <config.json>
27
+ domotion --help | --version
28
+
29
+ Commands:
30
+ capture Capture a single frame from a URL or HTML file as SVG.
31
+ animate Capture multiple frames described by a JSON config and stitch
32
+ them into one animated SVG with CSS keyframe transitions.
33
+
34
+ capture options:
35
+ -o, --output <path> Output SVG path (default: stdout, or <input>.svg
36
+ when input is a file).
37
+ --width <n> Viewport width in CSS pixels (default 800).
38
+ --height <n> Viewport height in CSS pixels (default 600).
39
+ --selector <css> Element selector to capture (default "body").
40
+ --clip <x,y,w,h> Capture only this region (default: full viewport).
41
+ --scroll <x,y> Scroll the page to this offset before capturing.
42
+ --wait <ms> Sleep this long after the page settles (default 200).
43
+ --wait-for <css> Wait for this selector to appear before capturing.
44
+ --no-fonts-ready Skip the document.fonts.ready wait (default: wait).
45
+ --optimize Run output through SVGO.
46
+ --warnings Log capture warnings to stderr after capture.
47
+ --mobile Emulate a mobile device (iOS UA, isMobile=true).
48
+ --color-scheme <s> Set prefers-color-scheme: "light" | "dark" | "no-preference".
49
+
50
+ animate config (JSON):
51
+ {
52
+ "width": 800,
53
+ "height": 400,
54
+ "output": "demo.svg",
55
+ "optimize": true,
56
+ "frames": [
57
+ {
58
+ "input": "./frames/start.html", // or a URL
59
+ "duration": 1500, // ms held on screen
60
+ "transition": { "type": "crossfade", "duration": 300 },
61
+ "selector": "body", // optional
62
+ "wait": 200, // optional ms
63
+ "waitFor": ".ready", // optional CSS selector
64
+ "scroll": [0, 0], // optional [x, y]
65
+ "actions": [ // optional, run before capture
66
+ { "type": "click", "selector": ".btn" },
67
+ { "type": "fill", "selector": "input", "value": "hi" },
68
+ { "type": "press", "key": "Enter" },
69
+ { "type": "scroll", "y": 200 },
70
+ { "type": "hover", "selector": ".tooltip" },
71
+ { "type": "wait", "ms": 300 }
72
+ ],
73
+ "overlays": [ // see Overlay types
74
+ { "kind": "tap", "x": 100, "y": 50 },
75
+ { "kind": "typing", "text": "Hello", "x": 20, "y": 40 }
76
+ ],
77
+ "animations": [ // intra-frame motion
78
+ {
79
+ "selector": ".bar", // CSS selector in source HTML
80
+ "property": "transform", // or width/height/opacity/translateX/translateY
81
+ "from": "scaleX(0)",
82
+ "to": "scaleX(1)",
83
+ "duration": 2000,
84
+ "easing": "ease-out", // optional, default "linear"
85
+ "delay": 150 // optional ms after frame start
86
+ }
87
+ ]
88
+ }
89
+ ]
90
+ }
91
+
92
+ Transition types: "crossfade" | "push-left" | "scroll" | "cut".
93
+ ("cut" = instant; duration is ignored.)
94
+ Paths in "input" are resolved relative to the config file's directory.
95
+
96
+ Examples:
97
+ # Capture the front page of example.com at 1280×720.
98
+ domotion capture https://example.com --width 1280 --height 720 -o demo.svg
99
+
100
+ # Capture a local HTML file, optimised, only the .hero region.
101
+ domotion capture ./hero.html --selector ".hero" --optimize -o hero.svg
102
+
103
+ # Capture HTML piped on stdin.
104
+ cat my.html | domotion capture - -o out.svg
105
+
106
+ # Build a 3-frame animated demo from a config.
107
+ domotion animate ./demo.json
108
+ `;
109
+ void main();
110
+ async function main() {
111
+ const argv = process.argv.slice(2);
112
+ if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
113
+ process.stdout.write(HELP);
114
+ process.exit(0);
115
+ }
116
+ if (argv[0] === "--version" || argv[0] === "-v") {
117
+ process.stdout.write(`${VERSION}\n`);
118
+ process.exit(0);
119
+ }
120
+ const cmd = argv[0];
121
+ const rest = argv.slice(1);
122
+ try {
123
+ if (cmd === "capture") {
124
+ await runCapture(rest);
125
+ }
126
+ else if (cmd === "animate") {
127
+ await runAnimate(rest);
128
+ }
129
+ else {
130
+ process.stderr.write(`domotion: unknown command "${cmd}"\n\n`);
131
+ process.stderr.write(HELP);
132
+ process.exit(2);
133
+ }
134
+ }
135
+ catch (err) {
136
+ process.stderr.write(`domotion: ${err instanceof Error ? err.message : String(err)}\n`);
137
+ process.exit(1);
138
+ }
139
+ }
140
+ async function runCapture(args) {
141
+ const { values, positionals } = parseArgs({
142
+ args,
143
+ allowPositionals: true,
144
+ strict: true,
145
+ options: {
146
+ output: { type: "string", short: "o" },
147
+ width: { type: "string" },
148
+ height: { type: "string" },
149
+ selector: { type: "string" },
150
+ clip: { type: "string" },
151
+ scroll: { type: "string" },
152
+ wait: { type: "string" },
153
+ "wait-for": { type: "string" },
154
+ "no-fonts-ready": { type: "boolean" },
155
+ optimize: { type: "boolean" },
156
+ warnings: { type: "boolean" },
157
+ mobile: { type: "boolean" },
158
+ "color-scheme": { type: "string" },
159
+ help: { type: "boolean", short: "h" },
160
+ },
161
+ });
162
+ if (values.help === true) {
163
+ process.stdout.write(HELP);
164
+ process.exit(0);
165
+ }
166
+ if (positionals.length === 0)
167
+ throw new Error("capture: missing <input> (URL, path, or '-')");
168
+ if (positionals.length > 1)
169
+ throw new Error(`capture: unexpected extra argument "${positionals[1]}"`);
170
+ const input = positionals[0];
171
+ const flags = {
172
+ output: values.output,
173
+ width: parseIntFlag(values.width, "width", 800),
174
+ height: parseIntFlag(values.height, "height", 600),
175
+ selector: values.selector ?? "body",
176
+ clip: values.clip != null ? parseTuple(values.clip, 4, "clip") : undefined,
177
+ scroll: values.scroll != null ? parseTuple(values.scroll, 2, "scroll") : undefined,
178
+ wait: parseIntFlag(values.wait, "wait", 200),
179
+ waitFor: values["wait-for"],
180
+ fontsReady: values["no-fonts-ready"] !== true,
181
+ optimize: values.optimize === true,
182
+ warnings: values.warnings === true,
183
+ mobile: values.mobile === true,
184
+ colorScheme: parseColorScheme(values["color-scheme"]),
185
+ };
186
+ const browser = await launchChromium();
187
+ try {
188
+ const ctx = await browser.newContext({
189
+ viewport: { width: flags.width, height: flags.height },
190
+ isMobile: flags.mobile,
191
+ ...(flags.mobile ? { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" } : {}),
192
+ ...(flags.colorScheme != null ? { colorScheme: flags.colorScheme } : {}),
193
+ });
194
+ const page = await ctx.newPage();
195
+ // DM-479: bump Playwright's 30 s defaults to 90 s. Long captures on
196
+ // heavy pages routinely push past 30 s.
197
+ page.setDefaultTimeout(90_000);
198
+ page.setDefaultNavigationTimeout(90_000);
199
+ // Track every font URL the browser fetches during the page load. Most
200
+ // webfonts are cross-origin (Google Fonts, Adobe Fonts CDNs) and don't
201
+ // expose their resource-timing entries to JS, so this listener-based
202
+ // tracker is how `discoverAndRegisterWebfonts` learns about them.
203
+ const tracker = attachWebfontTracker(page);
204
+ await loadInputIntoPage(page, input);
205
+ await applyReadyWaits(page, flags);
206
+ // Webfont discovery: now that document.fonts.ready resolved, walk the
207
+ // page's @font-face rules, fetch the actual bytes via the browser's
208
+ // request stack, and register them with text-to-path so the renderer
209
+ // draws with the real webfont glyphs instead of a system substitute.
210
+ clearWebfonts();
211
+ await discoverAndRegisterWebfonts(page, tracker.urls);
212
+ tracker.detach();
213
+ const clip = flags.clip ?? [0, 0, flags.width, flags.height];
214
+ const tree = await captureElementTree(page, flags.selector, {
215
+ x: clip[0], y: clip[1], width: clip[2], height: clip[3],
216
+ });
217
+ const inner = elementTreeToSvg(tree, clip[2], clip[3]);
218
+ let svg = wrapSvg(inner, clip[2], clip[3]);
219
+ if (flags.optimize)
220
+ svg = optimizeSvg(svg);
221
+ if (flags.warnings)
222
+ logCaptureWarnings("capture");
223
+ const outPath = resolveOutputPath(flags.output, input, ".svg");
224
+ if (outPath === null) {
225
+ process.stdout.write(svg);
226
+ }
227
+ else {
228
+ writeFileSync(outPath, svg);
229
+ process.stderr.write(`Wrote ${outPath} (${(svg.length / 1024).toFixed(1)} KB)\n`);
230
+ }
231
+ }
232
+ finally {
233
+ await browser.close();
234
+ }
235
+ }
236
+ async function runAnimate(args) {
237
+ const { values, positionals } = parseArgs({
238
+ args,
239
+ allowPositionals: true,
240
+ strict: true,
241
+ options: {
242
+ output: { type: "string", short: "o" },
243
+ optimize: { type: "boolean" },
244
+ help: { type: "boolean", short: "h" },
245
+ },
246
+ });
247
+ if (values.help === true) {
248
+ process.stdout.write(HELP);
249
+ process.exit(0);
250
+ }
251
+ if (positionals.length === 0)
252
+ throw new Error("animate: missing <config.json>");
253
+ if (positionals.length > 1)
254
+ throw new Error(`animate: unexpected extra argument "${positionals[1]}"`);
255
+ const configPath = resolve(positionals[0]);
256
+ if (!existsSync(configPath))
257
+ throw new Error(`animate: config not found: ${configPath}`);
258
+ const cfg = JSON.parse(readFileSync(configPath, "utf8"));
259
+ validateAnimateConfig(cfg);
260
+ const configDir = dirname(configPath);
261
+ const browser = await launchChromium();
262
+ try {
263
+ const ctx = await browser.newContext({
264
+ viewport: { width: cfg.width, height: cfg.height },
265
+ isMobile: cfg.mobile === true,
266
+ ...(cfg.mobile === true ? { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" } : {}),
267
+ ...(cfg.colorScheme != null ? { colorScheme: cfg.colorScheme } : {}),
268
+ });
269
+ const page = await ctx.newPage();
270
+ // DM-479: 90 s instead of Playwright's 30 s default.
271
+ page.setDefaultTimeout(90_000);
272
+ page.setDefaultNavigationTimeout(90_000);
273
+ const frames = [];
274
+ // Frames may pull from different documents with different webfonts.
275
+ // Clear once at the start; each frame's discovery accumulates into the
276
+ // same registry. Multiple frames declaring the same family register
277
+ // multiple variants and the resolver picks the closest weight/style.
278
+ clearWebfonts();
279
+ // One tracker for the whole animate run — fonts fetched by any frame
280
+ // get accumulated, and we deduplicate URLs inside discoverAndRegister.
281
+ const tracker = attachWebfontTracker(page);
282
+ for (let i = 0; i < cfg.frames.length; i++) {
283
+ const fc = cfg.frames[i];
284
+ const input = resolveFrameInput(fc.input, configDir);
285
+ await loadInputIntoPage(page, input);
286
+ await applyReadyWaits(page, {
287
+ wait: fc.wait ?? 200,
288
+ waitFor: fc.waitFor,
289
+ fontsReady: true,
290
+ });
291
+ await discoverAndRegisterWebfonts(page, tracker.urls);
292
+ if (fc.scroll != null) {
293
+ const sx = fc.scroll[0], sy = fc.scroll[1];
294
+ await page.evaluate((coords) => window.scrollTo(coords[0], coords[1]), [sx, sy]);
295
+ }
296
+ if (fc.actions != null)
297
+ await runActions(page, fc.actions);
298
+ // Intra-frame animations (DM-209): tag the live DOM with
299
+ // `data-domotion-anim="<id>"` for each animation's selector. The capture
300
+ // pass picks up the data attribute and the renderer surfaces it as
301
+ // class="anim-<id>" on the rendered group, which the animator targets
302
+ // with a CSS keyframe block.
303
+ const resolvedAnimations = [];
304
+ if (fc.animations != null && fc.animations.length > 0) {
305
+ for (let ai = 0; ai < fc.animations.length; ai++) {
306
+ const a = fc.animations[ai];
307
+ const animId = `f${i}a${ai}`;
308
+ await page.evaluate((args) => {
309
+ const els = document.querySelectorAll(args.selector);
310
+ els.forEach((el) => {
311
+ if (el instanceof HTMLElement)
312
+ el.dataset.domotionAnim = args.animId;
313
+ });
314
+ }, { selector: a.selector, animId });
315
+ resolvedAnimations.push({
316
+ animId,
317
+ property: a.property,
318
+ from: a.from,
319
+ to: a.to,
320
+ duration: a.duration,
321
+ easing: a.easing,
322
+ delay: a.delay,
323
+ });
324
+ }
325
+ }
326
+ const tree = await captureElementTree(page, fc.selector ?? "body", {
327
+ x: 0, y: 0, width: cfg.width, height: cfg.height,
328
+ });
329
+ const svgContent = elementTreeToSvg(tree, cfg.width, cfg.height, `f${i}-`);
330
+ // Resolve SVG-kind overlays: read each `src` from disk, namespace its
331
+ // ids, and replace with `innerSvg`. Other overlay kinds pass through
332
+ // verbatim. (DM-210.)
333
+ const overlays = resolveSvgOverlays(fc.overlays, configDir, i);
334
+ frames.push({
335
+ svgContent,
336
+ duration: fc.duration,
337
+ transition: fc.transition,
338
+ overlays,
339
+ animations: resolvedAnimations.length > 0 ? resolvedAnimations : undefined,
340
+ });
341
+ }
342
+ tracker.detach();
343
+ let svg = generateAnimatedSvg({ width: cfg.width, height: cfg.height, frames });
344
+ const optimize = values.optimize === true || cfg.optimize === true;
345
+ if (optimize)
346
+ svg = optimizeSvg(svg);
347
+ const outPath = resolveOutputPath(values.output ?? cfg.output, configPath, ".svg");
348
+ if (outPath === null) {
349
+ process.stdout.write(svg);
350
+ }
351
+ else {
352
+ writeFileSync(outPath, svg);
353
+ process.stderr.write(`Wrote ${outPath} (${(svg.length / 1024).toFixed(1)} KB, ${cfg.frames.length} frames)\n`);
354
+ }
355
+ }
356
+ finally {
357
+ await browser.close();
358
+ }
359
+ }
360
+ async function runActions(page, actions) {
361
+ for (const a of actions) {
362
+ if (a.type === "click")
363
+ await page.click(a.selector);
364
+ else if (a.type === "fill")
365
+ await page.fill(a.selector, a.value);
366
+ else if (a.type === "press")
367
+ await page.keyboard.press(a.key);
368
+ else if (a.type === "scroll")
369
+ await page.evaluate((coords) => window.scrollTo(coords[0], coords[1]), [a.x ?? 0, a.y ?? 0]);
370
+ else if (a.type === "hover")
371
+ await page.hover(a.selector);
372
+ else if (a.type === "wait")
373
+ await page.waitForTimeout(a.ms);
374
+ else
375
+ throw new Error(`animate: unknown action type "${a.type}"`);
376
+ }
377
+ }
378
+ async function loadInputIntoPage(page, input) {
379
+ if (input === "-") {
380
+ const html = readFileSync(0, "utf8"); // stdin
381
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
382
+ return;
383
+ }
384
+ if (/^https?:\/\//i.test(input)) {
385
+ await page.goto(input, { waitUntil: "networkidle" });
386
+ return;
387
+ }
388
+ const path = resolve(input);
389
+ if (!existsSync(path))
390
+ throw new Error(`input file not found: ${path}`);
391
+ await page.goto(pathToFileURL(path).href, { waitUntil: "networkidle" });
392
+ }
393
+ async function applyReadyWaits(page, flags) {
394
+ if (flags.fontsReady) {
395
+ await page.evaluate(() => document.fonts.ready);
396
+ }
397
+ if (flags.waitFor != null) {
398
+ await page.waitForSelector(flags.waitFor, { state: "visible" });
399
+ }
400
+ if (flags.wait > 0) {
401
+ await page.waitForTimeout(flags.wait);
402
+ }
403
+ }
404
+ function validateAnimateConfig(cfg) {
405
+ if (typeof cfg.width !== "number" || typeof cfg.height !== "number") {
406
+ throw new Error("animate: config requires numeric width and height");
407
+ }
408
+ if (!Array.isArray(cfg.frames) || cfg.frames.length === 0) {
409
+ throw new Error("animate: config.frames must be a non-empty array");
410
+ }
411
+ for (let i = 0; i < cfg.frames.length; i++) {
412
+ const f = cfg.frames[i];
413
+ if (typeof f.input !== "string")
414
+ throw new Error(`animate: frames[${i}].input must be a string`);
415
+ if (typeof f.duration !== "number")
416
+ throw new Error(`animate: frames[${i}].duration must be a number`);
417
+ }
418
+ }
419
+ /**
420
+ * Walk a frame's overlay list, expand `kind: "svg"` entries by reading the
421
+ * referenced SVG file, namespacing its ids, and replacing `src` with the
422
+ * inlined `innerSvg`. Other overlay kinds pass through verbatim.
423
+ */
424
+ function resolveSvgOverlays(rawOverlays, configDir, frameIdx) {
425
+ if (rawOverlays == null)
426
+ return undefined;
427
+ const out = [];
428
+ let svgIdx = 0;
429
+ for (const ov of rawOverlays) {
430
+ if (ov != null && typeof ov === "object" && ov.kind === "svg") {
431
+ const raw = ov;
432
+ const srcPath = resolve(configDir, raw.src);
433
+ if (!existsSync(srcPath))
434
+ throw new Error(`animate: svg overlay file not found: ${srcPath}`);
435
+ const fileText = readFileSync(srcPath, "utf8");
436
+ const animId = `s${svgIdx++}`;
437
+ const namespaced = namespaceSvgIds(fileText, `f${frameIdx}o${animId}-`);
438
+ out.push({
439
+ kind: "svg",
440
+ innerSvg: namespaced,
441
+ x: raw.x, y: raw.y, width: raw.width, height: raw.height,
442
+ animId,
443
+ enter: raw.enter, exit: raw.exit,
444
+ });
445
+ }
446
+ else {
447
+ out.push(ov);
448
+ }
449
+ }
450
+ return out;
451
+ }
452
+ /**
453
+ * Strip the outer `<svg>` wrapper (if present) from an SVG file's contents,
454
+ * then prefix every `id="..."`, `href="#..."`, and `xlink:href="#..."` with
455
+ * the given prefix so multiple inlined SVGs can coexist in one document
456
+ * without id collisions.
457
+ */
458
+ function namespaceSvgIds(svg, prefix) {
459
+ // Strip XML decl + outer <svg ...> wrapper.
460
+ let inner = svg;
461
+ inner = inner.replace(/<\?xml[^>]*\?>/, "");
462
+ inner = inner.replace(/<svg\b[^>]*>/, "");
463
+ inner = inner.replace(/<\/svg>\s*$/, "");
464
+ // Prefix ids and hash references.
465
+ inner = inner.replace(/\bid="([^"]+)"/g, (_m, id) => `id="${prefix}${id}"`);
466
+ inner = inner.replace(/\b(href|xlink:href)="#([^"]+)"/g, (_m, attr, id) => `${attr}="#${prefix}${id}"`);
467
+ inner = inner.replace(/url\(#([^)]+)\)/g, (_m, id) => `url(#${prefix}${id})`);
468
+ return inner;
469
+ }
470
+ function resolveFrameInput(input, configDir) {
471
+ if (input === "-")
472
+ return input;
473
+ if (/^https?:\/\//i.test(input))
474
+ return input;
475
+ return resolve(configDir, input);
476
+ }
477
+ function parseIntFlag(value, name, def) {
478
+ if (value == null)
479
+ return def;
480
+ const n = Number(value);
481
+ if (!Number.isFinite(n) || Math.floor(n) !== n || n <= 0) {
482
+ throw new Error(`--${name} expects a positive integer, got "${value}"`);
483
+ }
484
+ return n;
485
+ }
486
+ function parseColorScheme(value) {
487
+ if (value == null)
488
+ return undefined;
489
+ if (value === "light" || value === "dark" || value === "no-preference")
490
+ return value;
491
+ throw new Error(`--color-scheme expects one of "light", "dark", "no-preference"; got "${value}"`);
492
+ }
493
+ function parseTuple(value, len, name) {
494
+ const parts = value.split(",").map((s) => s.trim());
495
+ if (parts.length !== len)
496
+ throw new Error(`--${name} expects ${len} comma-separated numbers, got "${value}"`);
497
+ const nums = parts.map((p) => Number(p));
498
+ if (nums.some((n) => !Number.isFinite(n)))
499
+ throw new Error(`--${name} contains a non-numeric component: "${value}"`);
500
+ return nums;
501
+ }
502
+ function resolveOutputPath(output, input, ext) {
503
+ if (output === "-")
504
+ return null;
505
+ if (output != null)
506
+ return resolve(output);
507
+ if (input === "-" || /^https?:\/\//i.test(input))
508
+ return null; // stream to stdout
509
+ // Local file → write next to it with the same basename.
510
+ const stem = basename(input).replace(/\.[^.]+$/, "");
511
+ return resolve(dirname(input), `${stem}${ext}`);
512
+ }