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
package/src/cli.ts ADDED
@@ -0,0 +1,582 @@
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
+
17
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
18
+ import { resolve, dirname, basename } from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { parseArgs } from "node:util";
21
+ import type { Page } from "@playwright/test";
22
+ import {
23
+ captureElementTree,
24
+ elementTreeToSvg,
25
+ wrapSvg,
26
+ generateAnimatedSvg,
27
+ optimizeSvg,
28
+ launchChromium,
29
+ logCaptureWarnings,
30
+ discoverAndRegisterWebfonts,
31
+ attachWebfontTracker,
32
+ clearWebfonts,
33
+ type AnimationFrame,
34
+ type IntraFrameAnimation,
35
+ type Overlay,
36
+ type SvgOverlay,
37
+ } from "./index.js";
38
+
39
+ const VERSION = "0.1.0";
40
+
41
+ const HELP = `domotion ${VERSION} — DOM-to-animated-SVG renderer
42
+
43
+ Usage:
44
+ domotion capture <input> [options]
45
+ domotion animate <config.json>
46
+ domotion --help | --version
47
+
48
+ Commands:
49
+ capture Capture a single frame from a URL or HTML file as SVG.
50
+ animate Capture multiple frames described by a JSON config and stitch
51
+ them into one animated SVG with CSS keyframe transitions.
52
+
53
+ capture options:
54
+ -o, --output <path> Output SVG path (default: stdout, or <input>.svg
55
+ when input is a file).
56
+ --width <n> Viewport width in CSS pixels (default 800).
57
+ --height <n> Viewport height in CSS pixels (default 600).
58
+ --selector <css> Element selector to capture (default "body").
59
+ --clip <x,y,w,h> Capture only this region (default: full viewport).
60
+ --scroll <x,y> Scroll the page to this offset before capturing.
61
+ --wait <ms> Sleep this long after the page settles (default 200).
62
+ --wait-for <css> Wait for this selector to appear before capturing.
63
+ --no-fonts-ready Skip the document.fonts.ready wait (default: wait).
64
+ --optimize Run output through SVGO.
65
+ --warnings Log capture warnings to stderr after capture.
66
+ --mobile Emulate a mobile device (iOS UA, isMobile=true).
67
+ --color-scheme <s> Set prefers-color-scheme: "light" | "dark" | "no-preference".
68
+
69
+ animate config (JSON):
70
+ {
71
+ "width": 800,
72
+ "height": 400,
73
+ "output": "demo.svg",
74
+ "optimize": true,
75
+ "frames": [
76
+ {
77
+ "input": "./frames/start.html", // or a URL
78
+ "duration": 1500, // ms held on screen
79
+ "transition": { "type": "crossfade", "duration": 300 },
80
+ "selector": "body", // optional
81
+ "wait": 200, // optional ms
82
+ "waitFor": ".ready", // optional CSS selector
83
+ "scroll": [0, 0], // optional [x, y]
84
+ "actions": [ // optional, run before capture
85
+ { "type": "click", "selector": ".btn" },
86
+ { "type": "fill", "selector": "input", "value": "hi" },
87
+ { "type": "press", "key": "Enter" },
88
+ { "type": "scroll", "y": 200 },
89
+ { "type": "hover", "selector": ".tooltip" },
90
+ { "type": "wait", "ms": 300 }
91
+ ],
92
+ "overlays": [ // see Overlay types
93
+ { "kind": "tap", "x": 100, "y": 50 },
94
+ { "kind": "typing", "text": "Hello", "x": 20, "y": 40 }
95
+ ],
96
+ "animations": [ // intra-frame motion
97
+ {
98
+ "selector": ".bar", // CSS selector in source HTML
99
+ "property": "transform", // or width/height/opacity/translateX/translateY
100
+ "from": "scaleX(0)",
101
+ "to": "scaleX(1)",
102
+ "duration": 2000,
103
+ "easing": "ease-out", // optional, default "linear"
104
+ "delay": 150 // optional ms after frame start
105
+ }
106
+ ]
107
+ }
108
+ ]
109
+ }
110
+
111
+ Transition types: "crossfade" | "push-left" | "scroll" | "cut".
112
+ ("cut" = instant; duration is ignored.)
113
+ Paths in "input" are resolved relative to the config file's directory.
114
+
115
+ Examples:
116
+ # Capture the front page of example.com at 1280×720.
117
+ domotion capture https://example.com --width 1280 --height 720 -o demo.svg
118
+
119
+ # Capture a local HTML file, optimised, only the .hero region.
120
+ domotion capture ./hero.html --selector ".hero" --optimize -o hero.svg
121
+
122
+ # Capture HTML piped on stdin.
123
+ cat my.html | domotion capture - -o out.svg
124
+
125
+ # Build a 3-frame animated demo from a config.
126
+ domotion animate ./demo.json
127
+ `;
128
+
129
+ void main();
130
+
131
+ async function main(): Promise<void> {
132
+ const argv = process.argv.slice(2);
133
+
134
+ if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
135
+ process.stdout.write(HELP);
136
+ process.exit(0);
137
+ }
138
+ if (argv[0] === "--version" || argv[0] === "-v") {
139
+ process.stdout.write(`${VERSION}\n`);
140
+ process.exit(0);
141
+ }
142
+
143
+ const cmd = argv[0];
144
+ const rest = argv.slice(1);
145
+
146
+ try {
147
+ if (cmd === "capture") {
148
+ await runCapture(rest);
149
+ } else if (cmd === "animate") {
150
+ await runAnimate(rest);
151
+ } else {
152
+ process.stderr.write(`domotion: unknown command "${cmd}"\n\n`);
153
+ process.stderr.write(HELP);
154
+ process.exit(2);
155
+ }
156
+ } catch (err) {
157
+ process.stderr.write(`domotion: ${err instanceof Error ? err.message : String(err)}\n`);
158
+ process.exit(1);
159
+ }
160
+ }
161
+
162
+ interface CaptureFlags {
163
+ output?: string;
164
+ width: number;
165
+ height: number;
166
+ selector: string;
167
+ clip?: [number, number, number, number];
168
+ scroll?: [number, number];
169
+ wait: number;
170
+ waitFor?: string;
171
+ fontsReady: boolean;
172
+ optimize: boolean;
173
+ warnings: boolean;
174
+ mobile: boolean;
175
+ colorScheme?: "light" | "dark" | "no-preference";
176
+ }
177
+
178
+ async function runCapture(args: string[]): Promise<void> {
179
+ const { values, positionals } = parseArgs({
180
+ args,
181
+ allowPositionals: true,
182
+ strict: true,
183
+ options: {
184
+ output: { type: "string", short: "o" },
185
+ width: { type: "string" },
186
+ height: { type: "string" },
187
+ selector: { type: "string" },
188
+ clip: { type: "string" },
189
+ scroll: { type: "string" },
190
+ wait: { type: "string" },
191
+ "wait-for": { type: "string" },
192
+ "no-fonts-ready": { type: "boolean" },
193
+ optimize: { type: "boolean" },
194
+ warnings: { type: "boolean" },
195
+ mobile: { type: "boolean" },
196
+ "color-scheme": { type: "string" },
197
+ help: { type: "boolean", short: "h" },
198
+ },
199
+ });
200
+ if (values.help === true) { process.stdout.write(HELP); process.exit(0); }
201
+ if (positionals.length === 0) throw new Error("capture: missing <input> (URL, path, or '-')");
202
+ if (positionals.length > 1) throw new Error(`capture: unexpected extra argument "${positionals[1]}"`);
203
+
204
+ const input = positionals[0];
205
+ const flags: CaptureFlags = {
206
+ output: values.output,
207
+ width: parseIntFlag(values.width, "width", 800),
208
+ height: parseIntFlag(values.height, "height", 600),
209
+ selector: values.selector ?? "body",
210
+ clip: values.clip != null ? parseTuple(values.clip, 4, "clip") as [number, number, number, number] : undefined,
211
+ scroll: values.scroll != null ? parseTuple(values.scroll, 2, "scroll") as [number, number] : undefined,
212
+ wait: parseIntFlag(values.wait, "wait", 200),
213
+ waitFor: values["wait-for"],
214
+ fontsReady: values["no-fonts-ready"] !== true,
215
+ optimize: values.optimize === true,
216
+ warnings: values.warnings === true,
217
+ mobile: values.mobile === true,
218
+ colorScheme: parseColorScheme(values["color-scheme"]),
219
+ };
220
+
221
+ const browser = await launchChromium();
222
+ try {
223
+ const ctx = await browser.newContext({
224
+ viewport: { width: flags.width, height: flags.height },
225
+ isMobile: flags.mobile,
226
+ ...(flags.mobile ? { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" } : {}),
227
+ ...(flags.colorScheme != null ? { colorScheme: flags.colorScheme } : {}),
228
+ });
229
+ const page = await ctx.newPage();
230
+ // DM-479: bump Playwright's 30 s defaults to 90 s. Long captures on
231
+ // heavy pages routinely push past 30 s.
232
+ page.setDefaultTimeout(90_000);
233
+ page.setDefaultNavigationTimeout(90_000);
234
+ // Track every font URL the browser fetches during the page load. Most
235
+ // webfonts are cross-origin (Google Fonts, Adobe Fonts CDNs) and don't
236
+ // expose their resource-timing entries to JS, so this listener-based
237
+ // tracker is how `discoverAndRegisterWebfonts` learns about them.
238
+ const tracker = attachWebfontTracker(page);
239
+
240
+ await loadInputIntoPage(page, input);
241
+ await applyReadyWaits(page, flags);
242
+
243
+ // Webfont discovery: now that document.fonts.ready resolved, walk the
244
+ // page's @font-face rules, fetch the actual bytes via the browser's
245
+ // request stack, and register them with text-to-path so the renderer
246
+ // draws with the real webfont glyphs instead of a system substitute.
247
+ clearWebfonts();
248
+ await discoverAndRegisterWebfonts(page, tracker.urls);
249
+ tracker.detach();
250
+
251
+ const clip = flags.clip ?? [0, 0, flags.width, flags.height];
252
+ const tree = await captureElementTree(page, flags.selector, {
253
+ x: clip[0], y: clip[1], width: clip[2], height: clip[3],
254
+ });
255
+ const inner = elementTreeToSvg(tree, clip[2], clip[3]);
256
+ let svg = wrapSvg(inner, clip[2], clip[3]);
257
+ if (flags.optimize) svg = optimizeSvg(svg);
258
+
259
+ if (flags.warnings) logCaptureWarnings("capture");
260
+
261
+ const outPath = resolveOutputPath(flags.output, input, ".svg");
262
+ if (outPath === null) {
263
+ process.stdout.write(svg);
264
+ } else {
265
+ writeFileSync(outPath, svg);
266
+ process.stderr.write(`Wrote ${outPath} (${(svg.length / 1024).toFixed(1)} KB)\n`);
267
+ }
268
+ } finally {
269
+ await browser.close();
270
+ }
271
+ }
272
+
273
+ interface AnimateConfig {
274
+ width: number;
275
+ height: number;
276
+ output?: string;
277
+ optimize?: boolean;
278
+ mobile?: boolean;
279
+ colorScheme?: "light" | "dark" | "no-preference";
280
+ frames: AnimateFrameConfig[];
281
+ }
282
+
283
+ interface AnimateFrameConfig {
284
+ input: string;
285
+ duration: number;
286
+ transition?: { type: "crossfade" | "push-left" | "scroll" | "cut"; duration: number };
287
+ selector?: string;
288
+ wait?: number;
289
+ waitFor?: string;
290
+ scroll?: [number, number];
291
+ actions?: AnimateAction[];
292
+ // Overlays passed through verbatim — typed as unknown[] here, validated by AnimationFrame at runtime.
293
+ overlays?: unknown[];
294
+ /** Intra-frame animations (DM-209). Selector resolved against the captured DOM. */
295
+ animations?: AnimateFrameAnimationConfig[];
296
+ }
297
+
298
+ interface AnimateFrameAnimationConfig {
299
+ selector: string;
300
+ property: IntraFrameAnimation["property"];
301
+ from: string;
302
+ to: string;
303
+ duration: number;
304
+ easing?: string;
305
+ delay?: number;
306
+ }
307
+
308
+ type AnimateAction =
309
+ | { type: "click"; selector: string }
310
+ | { type: "fill"; selector: string; value: string }
311
+ | { type: "press"; key: string }
312
+ | { type: "scroll"; x?: number; y?: number }
313
+ | { type: "hover"; selector: string }
314
+ | { type: "wait"; ms: number };
315
+
316
+ async function runAnimate(args: string[]): Promise<void> {
317
+ const { values, positionals } = parseArgs({
318
+ args,
319
+ allowPositionals: true,
320
+ strict: true,
321
+ options: {
322
+ output: { type: "string", short: "o" },
323
+ optimize: { type: "boolean" },
324
+ help: { type: "boolean", short: "h" },
325
+ },
326
+ });
327
+ if (values.help === true) { process.stdout.write(HELP); process.exit(0); }
328
+ if (positionals.length === 0) throw new Error("animate: missing <config.json>");
329
+ if (positionals.length > 1) throw new Error(`animate: unexpected extra argument "${positionals[1]}"`);
330
+
331
+ const configPath = resolve(positionals[0]);
332
+ if (!existsSync(configPath)) throw new Error(`animate: config not found: ${configPath}`);
333
+
334
+ const cfg = JSON.parse(readFileSync(configPath, "utf8")) as AnimateConfig;
335
+ validateAnimateConfig(cfg);
336
+ const configDir = dirname(configPath);
337
+
338
+ const browser = await launchChromium();
339
+ try {
340
+ const ctx = await browser.newContext({
341
+ viewport: { width: cfg.width, height: cfg.height },
342
+ isMobile: cfg.mobile === true,
343
+ ...(cfg.mobile === true ? { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" } : {}),
344
+ ...(cfg.colorScheme != null ? { colorScheme: cfg.colorScheme } : {}),
345
+ });
346
+ const page = await ctx.newPage();
347
+ // DM-479: 90 s instead of Playwright's 30 s default.
348
+ page.setDefaultTimeout(90_000);
349
+ page.setDefaultNavigationTimeout(90_000);
350
+ const frames: AnimationFrame[] = [];
351
+ // Frames may pull from different documents with different webfonts.
352
+ // Clear once at the start; each frame's discovery accumulates into the
353
+ // same registry. Multiple frames declaring the same family register
354
+ // multiple variants and the resolver picks the closest weight/style.
355
+ clearWebfonts();
356
+ // One tracker for the whole animate run — fonts fetched by any frame
357
+ // get accumulated, and we deduplicate URLs inside discoverAndRegister.
358
+ const tracker = attachWebfontTracker(page);
359
+
360
+ for (let i = 0; i < cfg.frames.length; i++) {
361
+ const fc = cfg.frames[i];
362
+ const input = resolveFrameInput(fc.input, configDir);
363
+ await loadInputIntoPage(page, input);
364
+ await applyReadyWaits(page, {
365
+ wait: fc.wait ?? 200,
366
+ waitFor: fc.waitFor,
367
+ fontsReady: true,
368
+ });
369
+ await discoverAndRegisterWebfonts(page, tracker.urls);
370
+ if (fc.scroll != null) {
371
+ const sx = fc.scroll[0], sy = fc.scroll[1];
372
+ await page.evaluate((coords: number[]) => window.scrollTo(coords[0], coords[1]), [sx, sy]);
373
+ }
374
+ if (fc.actions != null) await runActions(page, fc.actions);
375
+
376
+ // Intra-frame animations (DM-209): tag the live DOM with
377
+ // `data-domotion-anim="<id>"` for each animation's selector. The capture
378
+ // pass picks up the data attribute and the renderer surfaces it as
379
+ // class="anim-<id>" on the rendered group, which the animator targets
380
+ // with a CSS keyframe block.
381
+ const resolvedAnimations: IntraFrameAnimation[] = [];
382
+ if (fc.animations != null && fc.animations.length > 0) {
383
+ for (let ai = 0; ai < fc.animations.length; ai++) {
384
+ const a = fc.animations[ai];
385
+ const animId = `f${i}a${ai}`;
386
+ await page.evaluate(
387
+ (args: { selector: string; animId: string }) => {
388
+ const els = document.querySelectorAll(args.selector);
389
+ els.forEach((el) => {
390
+ if (el instanceof HTMLElement) el.dataset.domotionAnim = args.animId;
391
+ });
392
+ },
393
+ { selector: a.selector, animId },
394
+ );
395
+ resolvedAnimations.push({
396
+ animId,
397
+ property: a.property,
398
+ from: a.from,
399
+ to: a.to,
400
+ duration: a.duration,
401
+ easing: a.easing,
402
+ delay: a.delay,
403
+ });
404
+ }
405
+ }
406
+
407
+ const tree = await captureElementTree(page, fc.selector ?? "body", {
408
+ x: 0, y: 0, width: cfg.width, height: cfg.height,
409
+ });
410
+ const svgContent = elementTreeToSvg(tree, cfg.width, cfg.height, `f${i}-`);
411
+
412
+ // Resolve SVG-kind overlays: read each `src` from disk, namespace its
413
+ // ids, and replace with `innerSvg`. Other overlay kinds pass through
414
+ // verbatim. (DM-210.)
415
+ const overlays = resolveSvgOverlays(fc.overlays, configDir, i);
416
+
417
+ frames.push({
418
+ svgContent,
419
+ duration: fc.duration,
420
+ transition: fc.transition,
421
+ overlays,
422
+ animations: resolvedAnimations.length > 0 ? resolvedAnimations : undefined,
423
+ });
424
+ }
425
+ tracker.detach();
426
+
427
+ let svg = generateAnimatedSvg({ width: cfg.width, height: cfg.height, frames });
428
+ const optimize = values.optimize === true || cfg.optimize === true;
429
+ if (optimize) svg = optimizeSvg(svg);
430
+
431
+ const outPath = resolveOutputPath(values.output ?? cfg.output, configPath, ".svg");
432
+ if (outPath === null) {
433
+ process.stdout.write(svg);
434
+ } else {
435
+ writeFileSync(outPath, svg);
436
+ process.stderr.write(`Wrote ${outPath} (${(svg.length / 1024).toFixed(1)} KB, ${cfg.frames.length} frames)\n`);
437
+ }
438
+ } finally {
439
+ await browser.close();
440
+ }
441
+ }
442
+
443
+ async function runActions(page: Page, actions: AnimateAction[]): Promise<void> {
444
+ for (const a of actions) {
445
+ if (a.type === "click") await page.click(a.selector);
446
+ else if (a.type === "fill") await page.fill(a.selector, a.value);
447
+ else if (a.type === "press") await page.keyboard.press(a.key);
448
+ else if (a.type === "scroll") await page.evaluate((coords: number[]) => window.scrollTo(coords[0], coords[1]), [a.x ?? 0, a.y ?? 0]);
449
+ else if (a.type === "hover") await page.hover(a.selector);
450
+ else if (a.type === "wait") await page.waitForTimeout(a.ms);
451
+ else throw new Error(`animate: unknown action type "${(a as { type: string }).type}"`);
452
+ }
453
+ }
454
+
455
+ async function loadInputIntoPage(page: Page, input: string): Promise<void> {
456
+ if (input === "-") {
457
+ const html = readFileSync(0, "utf8"); // stdin
458
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
459
+ return;
460
+ }
461
+ if (/^https?:\/\//i.test(input)) {
462
+ await page.goto(input, { waitUntil: "networkidle" });
463
+ return;
464
+ }
465
+ const path = resolve(input);
466
+ if (!existsSync(path)) throw new Error(`input file not found: ${path}`);
467
+ await page.goto(pathToFileURL(path).href, { waitUntil: "networkidle" });
468
+ }
469
+
470
+ async function applyReadyWaits(page: Page, flags: { wait: number; waitFor?: string; fontsReady: boolean }): Promise<void> {
471
+ if (flags.fontsReady) {
472
+ await page.evaluate(() => document.fonts.ready);
473
+ }
474
+ if (flags.waitFor != null) {
475
+ await page.waitForSelector(flags.waitFor, { state: "visible" });
476
+ }
477
+ if (flags.wait > 0) {
478
+ await page.waitForTimeout(flags.wait);
479
+ }
480
+ }
481
+
482
+ function validateAnimateConfig(cfg: AnimateConfig): void {
483
+ if (typeof cfg.width !== "number" || typeof cfg.height !== "number") {
484
+ throw new Error("animate: config requires numeric width and height");
485
+ }
486
+ if (!Array.isArray(cfg.frames) || cfg.frames.length === 0) {
487
+ throw new Error("animate: config.frames must be a non-empty array");
488
+ }
489
+ for (let i = 0; i < cfg.frames.length; i++) {
490
+ const f = cfg.frames[i];
491
+ if (typeof f.input !== "string") throw new Error(`animate: frames[${i}].input must be a string`);
492
+ if (typeof f.duration !== "number") throw new Error(`animate: frames[${i}].duration must be a number`);
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Walk a frame's overlay list, expand `kind: "svg"` entries by reading the
498
+ * referenced SVG file, namespacing its ids, and replacing `src` with the
499
+ * inlined `innerSvg`. Other overlay kinds pass through verbatim.
500
+ */
501
+ function resolveSvgOverlays(rawOverlays: unknown[] | undefined, configDir: string, frameIdx: number): Overlay[] | undefined {
502
+ if (rawOverlays == null) return undefined;
503
+ const out: Overlay[] = [];
504
+ let svgIdx = 0;
505
+ for (const ov of rawOverlays) {
506
+ if (ov != null && typeof ov === "object" && (ov as { kind?: string }).kind === "svg") {
507
+ const raw = ov as { kind: "svg"; src: string; x: number; y: number; width: number; height: number; enter?: SvgOverlay["enter"]; exit?: SvgOverlay["exit"] };
508
+ const srcPath = resolve(configDir, raw.src);
509
+ if (!existsSync(srcPath)) throw new Error(`animate: svg overlay file not found: ${srcPath}`);
510
+ const fileText = readFileSync(srcPath, "utf8");
511
+ const animId = `s${svgIdx++}`;
512
+ const namespaced = namespaceSvgIds(fileText, `f${frameIdx}o${animId}-`);
513
+ out.push({
514
+ kind: "svg",
515
+ innerSvg: namespaced,
516
+ x: raw.x, y: raw.y, width: raw.width, height: raw.height,
517
+ animId,
518
+ enter: raw.enter, exit: raw.exit,
519
+ });
520
+ } else {
521
+ out.push(ov as Overlay);
522
+ }
523
+ }
524
+ return out;
525
+ }
526
+
527
+ /**
528
+ * Strip the outer `<svg>` wrapper (if present) from an SVG file's contents,
529
+ * then prefix every `id="..."`, `href="#..."`, and `xlink:href="#..."` with
530
+ * the given prefix so multiple inlined SVGs can coexist in one document
531
+ * without id collisions.
532
+ */
533
+ function namespaceSvgIds(svg: string, prefix: string): string {
534
+ // Strip XML decl + outer <svg ...> wrapper.
535
+ let inner = svg;
536
+ inner = inner.replace(/<\?xml[^>]*\?>/, "");
537
+ inner = inner.replace(/<svg\b[^>]*>/, "");
538
+ inner = inner.replace(/<\/svg>\s*$/, "");
539
+ // Prefix ids and hash references.
540
+ inner = inner.replace(/\bid="([^"]+)"/g, (_m, id: string) => `id="${prefix}${id}"`);
541
+ inner = inner.replace(/\b(href|xlink:href)="#([^"]+)"/g, (_m, attr: string, id: string) => `${attr}="#${prefix}${id}"`);
542
+ inner = inner.replace(/url\(#([^)]+)\)/g, (_m, id: string) => `url(#${prefix}${id})`);
543
+ return inner;
544
+ }
545
+
546
+ function resolveFrameInput(input: string, configDir: string): string {
547
+ if (input === "-") return input;
548
+ if (/^https?:\/\//i.test(input)) return input;
549
+ return resolve(configDir, input);
550
+ }
551
+
552
+ function parseIntFlag(value: string | undefined, name: string, def: number): number {
553
+ if (value == null) return def;
554
+ const n = Number(value);
555
+ if (!Number.isFinite(n) || Math.floor(n) !== n || n <= 0) {
556
+ throw new Error(`--${name} expects a positive integer, got "${value}"`);
557
+ }
558
+ return n;
559
+ }
560
+
561
+ function parseColorScheme(value: string | undefined): "light" | "dark" | "no-preference" | undefined {
562
+ if (value == null) return undefined;
563
+ if (value === "light" || value === "dark" || value === "no-preference") return value;
564
+ throw new Error(`--color-scheme expects one of "light", "dark", "no-preference"; got "${value}"`);
565
+ }
566
+
567
+ function parseTuple(value: string, len: number, name: string): number[] {
568
+ const parts = value.split(",").map((s) => s.trim());
569
+ if (parts.length !== len) throw new Error(`--${name} expects ${len} comma-separated numbers, got "${value}"`);
570
+ const nums = parts.map((p) => Number(p));
571
+ if (nums.some((n) => !Number.isFinite(n))) throw new Error(`--${name} contains a non-numeric component: "${value}"`);
572
+ return nums;
573
+ }
574
+
575
+ function resolveOutputPath(output: string | undefined, input: string, ext: string): string | null {
576
+ if (output === "-") return null;
577
+ if (output != null) return resolve(output);
578
+ if (input === "-" || /^https?:\/\//i.test(input)) return null; // stream to stdout
579
+ // Local file → write next to it with the same basename.
580
+ const stem = basename(input).replace(/\.[^.]+$/, "");
581
+ return resolve(dirname(input), `${stem}${ext}`);
582
+ }