Skill Tracer
Skills › Anatomy › anatomy › capture.mjs

capture.mjs

269 lines · scripts/capture.mjs at 1482e84

1import { chromePort } from "./chrome-port.mjs";
2import { spawn } from "node:child_process";
3import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4import { homedir, tmpdir } from "node:os";
5import { dirname, join, resolve } from "node:path";
6import { pathToFileURL } from "node:url";
7
8if (typeof WebSocket === "undefined" && !process.execArgv.includes("--experimental-websocket")) {
9 const child = spawn(process.execPath, ["--experimental-websocket", ...process.argv.slice(1)], { stdio: "inherit" });
10 child.on("exit", (code) => process.exit(code ?? 1));
11} else {
12 await main();
15function usage() {
16 console.log(`Capture a figure with headless Chrome.
18 node capture.mjs <file.html | file.svg | url> [options]
20 --out <png> output path (default: capture.png in the current directory)
21 --width <px> viewport width (default 1440; use 390 for a phone)
22 --height <px> viewport height (default 1000)
23 --scale <n> device pixel ratio (default 2)
24 --wait <ms> time to let the page settle before the first shot (default 2500)
25 --select <css> clip to this element (default: .iso-plate, else the whole page)
26 --zoom <x,y,w,h> clip a region inside the selected element, in CSS px, for close-up checks
27 --zoom-scale <n> pixel ratio for --zoom shots (default 4)
28 --hover <fx,fy> move the mouse to this fraction of the selected element first (e.g. 0.6,0.5)
29 --key <keys> press keys on the focused figure first, comma separated (e.g. Tab,ArrowRight,7,Escape);
30 Tab is skipped (the figure is focused for you), a comma is written Comma
31 --times <ms,...> extra shots at these delays, each measured from the first shot (not from the previous one),
32 saved as name-<ms>.png; a capture takes 60-200 ms, so delays closer than that run late
33 --reduced emulate prefers-reduced-motion: reduce
34 --theme <dark|light> emulate prefers-color-scheme
36Prints the clip box, console errors, uncaught exceptions and whether the page scrolls sideways.`);
39function parse(argv) {
40 const options = { width: 1440, height: 1000, scale: 2, wait: 2500, zoomScale: 4, times: [] };
41 const rest = [];
42 for (let index = 0; index < argv.length; index++) {
43 const arg = argv[index];
44 const next = () => argv[++index];
45 if (arg === "--out") options.out = next();
46 else if (arg === "--width") options.width = Number(next());
47 else if (arg === "--height") options.height = Number(next());
48 else if (arg === "--scale") options.scale = Number(next());
49 else if (arg === "--wait") options.wait = Number(next());
50 else if (arg === "--select") options.select = next();
51 else if (arg === "--zoom") options.zoom = next().split(",").map(Number);
52 else if (arg === "--zoom-scale") options.zoomScale = Number(next());
53 else if (arg === "--hover") options.hover = next().split(",").map(Number);
54 else if (arg === "--key") options.keys = next().split(",");
55 else if (arg === "--times") options.times = next().split(",").map(Number);
56 else if (arg === "--reduced") options.reduced = true;
57 else if (arg === "--theme") options.theme = next();
58 else if (arg === "--help" || arg === "-h") options.help = true;
59 else rest.push(arg);
60 }
61 options.target = rest[0];
62 return options;
65function problemWith(options) {
66 const positive = (value) => Number.isFinite(value) && value > 0;
67 const numbers = (list, count) => list.length === count && list.every(Number.isFinite);
68 for (const name of ["width", "height", "scale", "zoomScale"]) if (!positive(options[name])) return `--${name === "zoomScale" ? "zoom-scale" : name} needs a number above 0`;
69 if (!Number.isFinite(options.wait) || options.wait < 0) return "--wait needs a number of milliseconds, 0 or more";
70 if (options.zoom && !(numbers(options.zoom, 4) && options.zoom[2] > 0 && options.zoom[3] > 0)) return "--zoom needs x,y,w,h in CSS px, with a width and height above 0";
71 if (options.hover && !numbers(options.hover, 2)) return "--hover needs fx,fy, two fractions like 0.6,0.5";
72 if (!options.times.every((delay) => Number.isFinite(delay) && delay >= 0)) return "--times needs milliseconds, 0 or more, comma separated";
73 if (options.theme !== undefined && options.theme !== "dark" && options.theme !== "light") return "--theme must be dark or light";
76function chromePath() {
77 const candidates = [
78 process.env.CHROME_PATH,
79 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
80 "/Applications/Chromium.app/Contents/MacOS/Chromium",
81 "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
82 "/usr/bin/google-chrome",
83 "/usr/bin/google-chrome-stable",
84 "/usr/bin/chromium",
85 "/usr/bin/chromium-browser",
86 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
87 "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
88 ...(process.env.LOCALAPPDATA ? [join(process.env.LOCALAPPDATA, "Google\\Chrome\\Application\\chrome.exe")] : []),
89 "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
90 "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
91 ].filter(Boolean);
92 const playwright = join(homedir(), "Library/Caches/ms-playwright");
93 if (existsSync(playwright)) {
94 for (const dir of readdirSync(playwright).sort().reverse()) {
95 for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium"]) {
96 candidates.push(join(playwright, dir, sub));
97 }
98 }
99 }
100 const linux = join(homedir(), ".cache/ms-playwright");
101 if (existsSync(linux)) for (const dir of readdirSync(linux).sort().reverse()) for (const sub of ["chrome-linux64/chrome", "chrome-linux/chrome"]) candidates.push(join(linux, dir, sub));
102 const win = join(homedir(), "AppData/Local/ms-playwright");
103 if (existsSync(win)) for (const dir of readdirSync(win).sort().reverse()) for (const sub of ["chrome-win/chrome.exe", "chromium-win64/chrome.exe"]) candidates.push(join(win, dir, sub));
104 return candidates.find((path) => existsSync(path));
107async function main() {
108 const options = parse(process.argv.slice(2));
109 if (options.help || !options.target) {
110 usage();
111 process.exit(options.help ? 0 : 1);
112 }
113 const problem = problemWith(options);
114 if (problem) {
115 console.error(`capture: ${problem}`);
116 process.exit(1);
117 }
118 const url = /^https?:|^file:/.test(options.target) ? options.target : pathToFileURL(resolve(options.target)).href;
119 const out = resolve(options.out ?? "capture.png");
120 const binary = chromePath();
121 if (!binary) {
122 console.error("No Chrome found. Set CHROME_PATH to a Chrome or Chromium binary.");
123 process.exit(1);
124 }
125 const profile = mkdtempSync(join(tmpdir(), "iso-capture-"));
126 const chrome = spawn(binary, [
127 "--headless=new", ...(process.platform === "linux" ? ["--no-sandbox"] : []), ...(process.platform === "win32" ? ["--do-not-de-elevate"] : []),
128 "--remote-debugging-port=0",
129 `--user-data-dir=${profile}`,
130 "--no-first-run",
131 "--no-default-browser-check",
132 "--hide-scrollbars",
133 "--allow-file-access-from-files",
134 "--enable-unsafe-swiftshader",
135 "--ignore-gpu-blocklist",
136 "about:blank",
137 ]);
138 const port = await chromePort(chrome, profile);
139 const finish = (code) => {
140 chrome.kill("SIGKILL");
141 try {
142 rmSync(profile, { recursive: true, force: true });
143 } catch {}
144 process.exit(code);
145 };
146 try {
147 const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
148 const socket = new WebSocket(target.webSocketDebuggerUrl);
149 let id = 0;
150 const pending = new Map();
151 const problems = [];
152 let loaded = () => {};
153 socket.onmessage = (message) => {
154 const data = JSON.parse(message.data);
155 if (data.method === "Page.loadEventFired") loaded();
156 if (data.id && pending.has(data.id)) {
157 pending.get(data.id)(data);
158 pending.delete(data.id);
159 } else if (data.method === "Runtime.consoleAPICalled" && (data.params.type === "error" || data.params.type === "warning")) {
160 problems.push(`console.${data.params.type}: ${data.params.args.map((arg) => arg.value ?? arg.description ?? "").join(" ").slice(0, 400)}`);
161 } else if (data.method === "Runtime.exceptionThrown") {
162 const detail = data.params.exceptionDetails;
163 problems.push(`exception: ${detail.exception?.description ?? detail.text}`.slice(0, 600));
164 }
165 };
166 await new Promise((done) => (socket.onopen = done));
167 const send = (method, params = {}) =>
168 new Promise((done, fail) => {
169 const call = ++id;
170 const timer = setTimeout(() => {
171 pending.delete(call);
172 fail(new Error(`Chrome did not answer ${method} within 60 s`));
173 }, 60000);
174 pending.set(call, (data) => {
175 clearTimeout(timer);
176 done(data);
177 });
178 socket.send(JSON.stringify({ id: call, method, params }));
179 });
180 const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })).result?.result?.value;
181 const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
182 await send("Runtime.enable");
183 await send("Page.enable");
184 const features = [];
185 if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
186 if (options.theme) features.push({ name: "prefers-color-scheme", value: options.theme });
187 if (features.length) await send("Emulation.setEmulatedMedia", { features });
188 await send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: options.scale, mobile: options.width < 600 });
189 const load = new Promise((done) => {
190 loaded = done;
191 setTimeout(done, 10000);
192 });
193 const navigation = await send("Page.navigate", { url });
194 if (navigation.error) throw new Error(`Navigation failed: ${navigation.error.message}`);
195 if (navigation.result?.errorText) throw new Error(`Navigation failed: ${navigation.result.errorText}`);
196 await load;
197 await sleep(Math.min(options.wait, 1500));
198 await evaluate(`(async()=>{const h=document.documentElement.scrollHeight;for(let y=0;y<h;y+=400){scrollTo(0,y);await new Promise(r=>setTimeout(r,60))}scrollTo(0,0)})()`);
199 await sleep(Math.max(0, options.wait - 1500));
200 const select = options.select ?? ".iso-plate";
201 const boxOf = async () => {
202 const box = await evaluate(`(()=>{let el;try{el=document.querySelector(${JSON.stringify(select)})}catch{};if(!el&&${options.select === undefined})el=document.querySelector("svg")||document.body;if(!el)return null;el.scrollIntoView({block:"center"});const r=el.getBoundingClientRect();return {x:r.left+scrollX,y:r.top+scrollY,w:r.width,h:r.height,vx:r.left,vy:r.top}})()`);
203 if (!box) throw new Error(`Capture target not found: ${select}`);
204 return box;
205 };
206 let box = await boxOf();
207 if (options.hover) {
208 await sleep(300);
209 box = await boxOf();
210 const x = box.vx + box.w * options.hover[0];
211 const y = box.vy + box.h * options.hover[1];
212 await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.vx + 2, y: box.vy + 2 });
213 await sleep(60);
214 for (let step = 1; step <= 12; step++) {
215 await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.vx + 2 + ((x - box.vx - 2) * step) / 12, y: box.vy + 2 + ((y - box.vy - 2) * step) / 12 });
216 await sleep(30);
217 }
218 await sleep(1200);
219 }
220 if (options.keys) {
221 const focus = options.select === undefined ? `document.querySelector('[tabindex="0"]')` : `(()=>{const el=document.querySelector(${JSON.stringify(select)});return el.matches('[tabindex="0"]')?el:el.querySelector('[tabindex="0"]')||el.closest('[tabindex="0"]')||el})()`;
222 await evaluate(`(()=>{const f=${focus};if(f)f.focus()})()`);
223 const named = { " ": "Space", "-": "Minus", "=": "Equal", ",": "Comma", ".": "Period", "/": "Slash", ";": "Semicolon", "'": "Quote", "[": "BracketLeft", "]": "BracketRight", "\\": "Backslash", "`": "Backquote" };
224 for (const entry of options.keys) {
225 if (entry === "Tab") continue;
226 const key = entry === "Comma" ? "," : entry === "Space" ? " " : entry;
227 const code = named[key] ?? (/^[0-9]$/.test(key) ? `Digit${key}` : key.length === 1 ? `Key${key.toUpperCase()}` : key);
228 await send("Input.dispatchKeyEvent", { type: "keyDown", key, code, text: key === "Enter" ? "\r" : key.length === 1 ? key : undefined, windowsVirtualKeyCode: key === " " ? 32 : key === "Enter" ? 13 : 0 });
229 await send("Input.dispatchKeyEvent", { type: "keyUp", key, code, windowsVirtualKeyCode: key === "Enter" ? 13 : undefined });
230 await sleep(250);
231 }
232 await sleep(1200);
233 }
234 const shoot = async (path, clip, scale) => {
235 const shot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, clip: { ...clip, scale } });
236 mkdirSync(dirname(path), { recursive: true });
237 writeFileSync(path, Buffer.from(shot.result.data, "base64"));
238 };
239 const clipOf = () => {
240 if (!options.zoom) return { clip: { x: box.x - 6, y: box.y - 6, width: box.w + 12, height: box.h + 12 }, scale: 1 };
241 const [zx, zy, zw, zh] = options.zoom;
242 return { clip: { x: box.x + zx, y: box.y + zy, width: zw, height: zh }, scale: options.zoomScale / options.scale };
243 };
244 box = await boxOf();
245 const first = clipOf();
246 await shoot(out, first.clip, first.scale);
247 const shotAt = Date.now();
248 const readoutOf = () => evaluate(options.select === undefined ? `(document.querySelector("[data-readout]")||{}).textContent||""` : `(()=>{const el=document.querySelector(${JSON.stringify(select)});const plate=el.closest(".iso-plate")||el.querySelector(".iso-plate")||el;return(plate.querySelector("[data-readout]")||{}).textContent||""})()`);
249 const readouts = [];
250 for (const delay of [...options.times].sort((a, b) => a - b)) {
251 await sleep(Math.max(0, shotAt + delay - Date.now()));
252 box = await boxOf();
253 const next = clipOf();
254 await shoot(out.replace(/\.png$/, `-${delay}.png`), next.clip, next.scale);
255 readouts.push(`+${delay}ms "${await readoutOf()}"`);
256 }
257 const sideways = await evaluate(`document.documentElement.scrollWidth > innerWidth + 1`);
258 const readout = await readoutOf();
259 console.log(`saved ${out}`);
260 console.log(`clip ${Math.round(box.x)},${Math.round(box.y)} ${Math.round(box.w)}x${Math.round(box.h)} · readout "${readout}" · scrolls sideways: ${sideways ? "YES" : "no"}`);
261 if (readouts.length) console.log(`readout over time: ${readouts.join(" · ")}`);
262 console.log(problems.length ? problems.join("\n") : "console: clean");
263 socket.close();
264 finish(0);
265 } catch (error) {
266 console.error(error);
267 finish(1);
268 }