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.
- package/FEATURES.md +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- 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
|
+
}
|