@marver-design/marver 0.16.0 → 0.17.0
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/CHANGELOG.md +51 -0
- package/README.md +4 -4
- package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
- package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
- package/dist/{build-Ct0iMoSi.mjs → build-D_g53Bp2.mjs} +12 -8
- package/dist/cli.mjs +27 -16
- package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-CdcIuhZJ.mjs → dev-LnIISva5.mjs} +9 -4
- package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
- package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
- package/dist/{plugin-BXyezwfN.mjs → plugin-D2msH1cj.mjs} +223 -43
- package/dist/{poster-DOY7pax8.mjs → poster-BEjUcQP3.mjs} +13 -5
- package/dist/shot-BzQ0PXKH.mjs +86 -0
- package/dist/shot-DlmTO8AF.mjs +926 -0
- package/docs/live-jam.md +20 -8
- package/docs/publish.md +2 -2
- package/package.json +1 -1
- package/src/client/shell/App.tsx +42 -14
- package/src/client/shell/BoardList.tsx +88 -58
- package/src/client/shell/LockedApp.tsx +2 -1
- package/src/client/shell/Play.tsx +1 -0
- package/src/client/shell/labels.ts +4 -4
- package/src/client/shell/store.ts +76 -29
- package/src/client/shell/styles.css +5 -1
- package/src/shared/board-tree.ts +104 -46
- package/templates/AGENTS-embedded.md +13 -3
- package/templates/AGENTS-studio.md +13 -3
- package/templates/instructions/boards.md +27 -9
- package/templates/instructions/discover.md +13 -1
- package/templates/instructions/jam.md +15 -3
- package/templates/instructions/shape.md +2 -1
- package/dist/shot-By1AItpD.mjs +0 -30
- package/dist/shot-CwmHO5T4.mjs +0 -528
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
import { a as slideSize, i as ROUTE } from "./cli.mjs";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { availableParallelism, tmpdir } from "node:os";
|
|
6
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
7
|
+
//#region src/server/cdp.ts
|
|
8
|
+
/**
|
|
9
|
+
* A headless Chrome driven over CDP through Chrome's own debugging PIPE - fds 3 (we write,
|
|
10
|
+
* Chrome reads) and 4 (Chrome writes, we read), NUL-terminated JSON. No port, no stderr
|
|
11
|
+
* scrape, no WebSocket - and the one property everything else here rests on: when the read
|
|
12
|
+
* side of that pipe hits EOF Chrome shuts itself down, and the kernel closes the pipe on ANY
|
|
13
|
+
* death of this process (Ctrl-C, a closed terminal, `kill -9`, OOM). A shot browser cannot
|
|
14
|
+
* outlive the server that started it, whatever happens to the server.
|
|
15
|
+
*
|
|
16
|
+
* One browser belongs to one operation (a shot, a batch, a poster): `launch()` → sends →
|
|
17
|
+
* `close()` in a finally. Nothing is shared or kept warm - a headless instance of the user's
|
|
18
|
+
* own Chrome registers with macOS Launch Services as that Chrome, and a stray one can swallow
|
|
19
|
+
* the machine's link opens; the shortest possible life is the fix.
|
|
20
|
+
*/
|
|
21
|
+
const CHROMES = [
|
|
22
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
23
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
24
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
25
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
26
|
+
"/usr/bin/google-chrome-stable",
|
|
27
|
+
"/usr/bin/google-chrome",
|
|
28
|
+
"/usr/bin/chromium",
|
|
29
|
+
"/usr/bin/chromium-browser"
|
|
30
|
+
];
|
|
31
|
+
/** The browser binary to drive, or null. MARVER_CHROME overrides; otherwise first known install. */
|
|
32
|
+
function findChrome() {
|
|
33
|
+
const env = process.env.MARVER_CHROME;
|
|
34
|
+
if (env) return existsSync(env) ? env : null;
|
|
35
|
+
return CHROMES.find((p) => existsSync(p)) ?? null;
|
|
36
|
+
}
|
|
37
|
+
/** Profile directories marver has ever created (the sweep in shot.ts looks for both). */
|
|
38
|
+
const PROFILE_PREFIXES = ["mv-shot-", "mv-browser-"];
|
|
39
|
+
const FLAGS = [
|
|
40
|
+
"--headless=new",
|
|
41
|
+
"--hide-scrollbars",
|
|
42
|
+
"--no-first-run",
|
|
43
|
+
"--no-default-browser-check",
|
|
44
|
+
"--disable-extensions"
|
|
45
|
+
];
|
|
46
|
+
const HANDSHAKE_MS = 15e3;
|
|
47
|
+
const MAX_MESSAGE = 1 << 30;
|
|
48
|
+
/** The pipe's framing: NUL-terminated messages, arriving in chunks split anywhere - inside a
|
|
49
|
+
* message, inside a multibyte character, or several messages in one chunk. Segments are held
|
|
50
|
+
* and concatenated ONCE per delimiter (a screenshot answer is many chunks; re-concatenating
|
|
51
|
+
* the remainder per chunk would be quadratic). The size guard runs BEFORE a segment is held. */
|
|
52
|
+
var Frames = class {
|
|
53
|
+
max;
|
|
54
|
+
chunks = [];
|
|
55
|
+
held = 0;
|
|
56
|
+
constructor(max = MAX_MESSAGE) {
|
|
57
|
+
this.max = max;
|
|
58
|
+
}
|
|
59
|
+
/** Feed one chunk; returns the complete messages it finished, or throws on a runaway. */
|
|
60
|
+
push(d) {
|
|
61
|
+
const out = [];
|
|
62
|
+
let from = 0;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const nul = d.indexOf(0, from);
|
|
65
|
+
if (nul < 0) break;
|
|
66
|
+
this.hold(d.subarray(from, nul));
|
|
67
|
+
out.push(Buffer.concat(this.chunks).toString("utf8"));
|
|
68
|
+
this.chunks = [];
|
|
69
|
+
this.held = 0;
|
|
70
|
+
from = nul + 1;
|
|
71
|
+
}
|
|
72
|
+
if (from < d.length) this.hold(d.subarray(from));
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
hold(seg) {
|
|
76
|
+
if (this.held + seg.length > this.max) {
|
|
77
|
+
this.chunks = [];
|
|
78
|
+
this.held = 0;
|
|
79
|
+
throw new Error("the browser sent a runaway message");
|
|
80
|
+
}
|
|
81
|
+
this.chunks.push(seg);
|
|
82
|
+
this.held += seg.length;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var Browser = class Browser {
|
|
86
|
+
proc;
|
|
87
|
+
out;
|
|
88
|
+
seq = 0;
|
|
89
|
+
pending = /* @__PURE__ */ new Map();
|
|
90
|
+
listeners = /* @__PURE__ */ new Set();
|
|
91
|
+
profile;
|
|
92
|
+
/** Set once the browser is gone or unusable; every send after that rejects at once. */
|
|
93
|
+
dead = null;
|
|
94
|
+
exited = Promise.resolve();
|
|
95
|
+
get pid() {
|
|
96
|
+
return this.proc.pid;
|
|
97
|
+
}
|
|
98
|
+
/** Spawn and handshake (`Browser.getVersion` within 15s), or throw with the reason. A Chrome
|
|
99
|
+
* that starts but never brings up CDP is killed here - it can never hold a lane. */
|
|
100
|
+
static async launch(prefix = "mv-shot-") {
|
|
101
|
+
const bin = findChrome();
|
|
102
|
+
if (!bin) throw new Error("no Chrome/Chromium found - install one or set MARVER_CHROME to a browser binary");
|
|
103
|
+
const b = new Browser();
|
|
104
|
+
b.profile = mkdtempSync(join(tmpdir(), prefix));
|
|
105
|
+
b.proc = spawn(bin, [
|
|
106
|
+
...FLAGS,
|
|
107
|
+
`--user-data-dir=${b.profile}`,
|
|
108
|
+
"--remote-debugging-pipe",
|
|
109
|
+
"about:blank"
|
|
110
|
+
], { stdio: [
|
|
111
|
+
"ignore",
|
|
112
|
+
"ignore",
|
|
113
|
+
"ignore",
|
|
114
|
+
"pipe",
|
|
115
|
+
"pipe"
|
|
116
|
+
] });
|
|
117
|
+
b.out = b.proc.stdio[3];
|
|
118
|
+
const inp = b.proc.stdio[4];
|
|
119
|
+
b.exited = new Promise((res) => b.proc.once("exit", () => res()));
|
|
120
|
+
inp.on("data", (d) => b.feed(d));
|
|
121
|
+
inp.on("end", () => b.fail("the browser closed its devtools pipe"));
|
|
122
|
+
inp.on("error", () => b.fail("devtools pipe error"));
|
|
123
|
+
inp.on("close", () => b.fail("the browser closed its devtools pipe"));
|
|
124
|
+
b.out.on("error", () => b.fail("devtools pipe error"));
|
|
125
|
+
b.proc.on("error", (e) => b.fail(`could not start the browser - ${e.message}`));
|
|
126
|
+
b.proc.on("exit", (code, sig) => b.fail(`the browser exited (${sig ?? code})`));
|
|
127
|
+
b.proc.once("exit", () => {
|
|
128
|
+
b.rmProfile();
|
|
129
|
+
});
|
|
130
|
+
const handshake = setTimeout(() => b.fail("the browser did not answer in time"), HANDSHAKE_MS);
|
|
131
|
+
try {
|
|
132
|
+
await b.send("Browser.getVersion");
|
|
133
|
+
} catch (e) {
|
|
134
|
+
await b.close();
|
|
135
|
+
throw e;
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(handshake);
|
|
138
|
+
}
|
|
139
|
+
return b;
|
|
140
|
+
}
|
|
141
|
+
frames = new Frames();
|
|
142
|
+
feed(d) {
|
|
143
|
+
let msgs;
|
|
144
|
+
try {
|
|
145
|
+
msgs = this.frames.push(d);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return this.fail(e.message);
|
|
148
|
+
}
|
|
149
|
+
for (const text of msgs) {
|
|
150
|
+
let m;
|
|
151
|
+
try {
|
|
152
|
+
m = JSON.parse(text);
|
|
153
|
+
} catch {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (m.id != null && this.pending.has(m.id)) {
|
|
157
|
+
const p = this.pending.get(m.id);
|
|
158
|
+
this.pending.delete(m.id);
|
|
159
|
+
m.error ? p.reject(/* @__PURE__ */ new Error(`${p.method}: ${m.error.message}`)) : p.resolve(m.result);
|
|
160
|
+
} else for (const l of this.listeners) l(m);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
fail(why) {
|
|
164
|
+
if (this.dead) return;
|
|
165
|
+
this.dead = why;
|
|
166
|
+
for (const [id, p] of this.pending) {
|
|
167
|
+
this.pending.delete(id);
|
|
168
|
+
p.reject(/* @__PURE__ */ new Error(`${p.method}: ${why}`));
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
this.proc.kill("SIGKILL");
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
/** Send one CDP command. `owner` tags the call so `abort(owner)` can settle a shot's calls
|
|
175
|
+
* without touching another shot's in the same browser. */
|
|
176
|
+
send(method, params = {}, sessionId, owner) {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
if (this.dead) return reject(/* @__PURE__ */ new Error(`${method}: ${this.dead}`));
|
|
179
|
+
const gone = owner && this.aborted.get(owner);
|
|
180
|
+
if (gone) return reject(/* @__PURE__ */ new Error(`${method}: ${gone}`));
|
|
181
|
+
const id = ++this.seq;
|
|
182
|
+
this.pending.set(id, {
|
|
183
|
+
owner,
|
|
184
|
+
resolve,
|
|
185
|
+
reject,
|
|
186
|
+
method
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
this.out.write(JSON.stringify({
|
|
190
|
+
id,
|
|
191
|
+
method,
|
|
192
|
+
params,
|
|
193
|
+
sessionId
|
|
194
|
+
}) + "\0");
|
|
195
|
+
} catch (e) {
|
|
196
|
+
this.pending.delete(id);
|
|
197
|
+
reject(e);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
aborted = /* @__PURE__ */ new WeakMap();
|
|
202
|
+
/** Reject every pending call tagged `owner` AND every later one (a shot's watchdog fired:
|
|
203
|
+
* nothing of that shot may run past its deadline); the browser lives on. */
|
|
204
|
+
abort(owner, why) {
|
|
205
|
+
this.aborted.set(owner, why);
|
|
206
|
+
for (const [id, p] of this.pending) if (p.owner === owner) {
|
|
207
|
+
this.pending.delete(id);
|
|
208
|
+
p.reject(/* @__PURE__ */ new Error(`${p.method}: ${why}`));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Subscribe to CDP events (anything without an `id`); returns the unsubscribe. */
|
|
212
|
+
on(l) {
|
|
213
|
+
this.listeners.add(l);
|
|
214
|
+
return () => this.listeners.delete(l);
|
|
215
|
+
}
|
|
216
|
+
/** End the pipe (Chrome exits on EOF within ~60ms), SIGKILL after 1s if it is still there,
|
|
217
|
+
* then the profile goes once the process has actually exited. Safe to call twice. */
|
|
218
|
+
async close() {
|
|
219
|
+
try {
|
|
220
|
+
this.out.end();
|
|
221
|
+
} catch {}
|
|
222
|
+
const kill = setTimeout(() => {
|
|
223
|
+
try {
|
|
224
|
+
this.proc.kill("SIGKILL");
|
|
225
|
+
} catch {}
|
|
226
|
+
}, 1e3);
|
|
227
|
+
if (this.proc.exitCode == null && this.proc.signalCode == null) await this.exited;
|
|
228
|
+
clearTimeout(kill);
|
|
229
|
+
this.fail("the browser was closed");
|
|
230
|
+
await this.rmProfile();
|
|
231
|
+
}
|
|
232
|
+
rm;
|
|
233
|
+
rmProfile() {
|
|
234
|
+
return this.rm ??= (async () => {
|
|
235
|
+
for (let i = 0; i < 3; i++) try {
|
|
236
|
+
rmSync(this.profile, {
|
|
237
|
+
recursive: true,
|
|
238
|
+
force: true
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
} catch {
|
|
242
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
rmSync(this.profile, {
|
|
246
|
+
recursive: true,
|
|
247
|
+
force: true
|
|
248
|
+
});
|
|
249
|
+
} catch {}
|
|
250
|
+
})();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/server/shot.ts
|
|
255
|
+
/**
|
|
256
|
+
* Frame screenshots without a dependency - the system's own Chrome, driven over CDP through
|
|
257
|
+
* its debugging pipe (cdp.ts has the transport and the lifetime story).
|
|
258
|
+
*
|
|
259
|
+
* This exists for Live Jam's verify loop: a jam agent has no shell (deliberately - the job
|
|
260
|
+
* packet carries untrusted text), so "look at what you built" must be a capability the dev
|
|
261
|
+
* server provides, not a command the agent runs. The /api/shot and /api/shots endpoints call
|
|
262
|
+
* this; the `marver shot` CLI and the file-drop inbox both reach those.
|
|
263
|
+
*
|
|
264
|
+
* Readiness is DETERMINISTIC, not a sleep: poll until #root (or body, for html frames) has
|
|
265
|
+
* children, then wait for fonts. A frame that never mounts fails with the page's own
|
|
266
|
+
* exception text - which is exactly what the agent needs to fix it.
|
|
267
|
+
*
|
|
268
|
+
* One browser per OPERATION (a shot, a batch, a poster), N frames at a time inside a batch,
|
|
269
|
+
* and the browser closed in the operation's finally - never idle, never shared, never
|
|
270
|
+
* outliving the server.
|
|
271
|
+
*/
|
|
272
|
+
const slug = (frameId) => frameId.replace(/\//g, "--");
|
|
273
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
274
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
275
|
+
function planShot(frame, viewports, override = {}) {
|
|
276
|
+
const cw = num(frame.contentWidth);
|
|
277
|
+
const vpObj = frame.viewport ? viewports[frame.viewport] : void 0;
|
|
278
|
+
const ow = num(override.w), oh = num(override.h);
|
|
279
|
+
const sl = slideSize(frame);
|
|
280
|
+
if (sl) return {
|
|
281
|
+
width: sl.width,
|
|
282
|
+
initialHeight: sl.height,
|
|
283
|
+
fullHeight: false
|
|
284
|
+
};
|
|
285
|
+
const fallback = viewports.mobile ?? {
|
|
286
|
+
width: 390,
|
|
287
|
+
height: 844
|
|
288
|
+
};
|
|
289
|
+
if (cw) {
|
|
290
|
+
const width = clamp(ow ?? vpObj?.width ?? cw, 320, 1600);
|
|
291
|
+
return {
|
|
292
|
+
width,
|
|
293
|
+
initialHeight: Math.round(width * .75),
|
|
294
|
+
fullHeight: true
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const vp = vpObj ?? fallback;
|
|
298
|
+
if (ow || oh) return {
|
|
299
|
+
width: clamp(ow ?? vp.width, 120, 3840),
|
|
300
|
+
initialHeight: clamp(oh ?? vp.height, 80, 2160),
|
|
301
|
+
fullHeight: false
|
|
302
|
+
};
|
|
303
|
+
return {
|
|
304
|
+
width: vp.width,
|
|
305
|
+
initialHeight: vp.height,
|
|
306
|
+
fullHeight: false
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/** Resolve a frame from the manifest and screenshot it - the shared core behind BOTH
|
|
310
|
+
* transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
|
|
311
|
+
* name output identically. `origin` is the dev server's own base URL. */
|
|
312
|
+
async function shootFrame(opts) {
|
|
313
|
+
const { root, viewports, frameId, theme, origin, scale = 2, size, browser } = opts;
|
|
314
|
+
if (!/^[a-z0-9-]+$/i.test(theme)) return {
|
|
315
|
+
ok: false,
|
|
316
|
+
error: "invalid theme"
|
|
317
|
+
};
|
|
318
|
+
if (!Number.isInteger(scale) || scale < 1 || scale > 4) return {
|
|
319
|
+
ok: false,
|
|
320
|
+
error: "invalid scale"
|
|
321
|
+
};
|
|
322
|
+
let manifest = {};
|
|
323
|
+
try {
|
|
324
|
+
manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
|
|
325
|
+
} catch {}
|
|
326
|
+
const frame = (manifest.frames ?? []).find((f) => f.id === frameId);
|
|
327
|
+
if (!frame) return {
|
|
328
|
+
ok: false,
|
|
329
|
+
error: `unknown frame "${frameId}" - ids are in design/manifest.json`
|
|
330
|
+
};
|
|
331
|
+
const plan = planShot(frame, viewports, size);
|
|
332
|
+
if (frame.kind !== "html") try {
|
|
333
|
+
const { scanAssetRefs } = await import("./build-D_g53Bp2.mjs");
|
|
334
|
+
const { ensurePoster } = await import("./poster-BEjUcQP3.mjs");
|
|
335
|
+
const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
|
|
336
|
+
for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
|
|
337
|
+
} catch {}
|
|
338
|
+
const shotsDir = join(root, "design", ".local", "shots");
|
|
339
|
+
mkdirSync(shotsDir, { recursive: true });
|
|
340
|
+
sweepTemps(shotsDir);
|
|
341
|
+
const explicit = opts.scale !== void 0;
|
|
342
|
+
const relFor = (used) => {
|
|
343
|
+
const tag = !explicit || scale === 2 && used === 2 ? "" : used === scale ? `@${scale}x` : `@${scale}x-as-${used}x`;
|
|
344
|
+
return `design/.local/shots/${slug(frameId)}--${theme}${tag}.png`;
|
|
345
|
+
};
|
|
346
|
+
const tmp = join(shotsDir, `.${slug(frameId)}--${theme}.${randomBytes(6).toString("hex")}.tmp.png`);
|
|
347
|
+
const req = {
|
|
348
|
+
url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
|
|
349
|
+
width: plan.width,
|
|
350
|
+
height: plan.initialHeight,
|
|
351
|
+
out: tmp,
|
|
352
|
+
fullHeight: plan.fullHeight,
|
|
353
|
+
scale
|
|
354
|
+
};
|
|
355
|
+
const result = browser ? await captureIn(req, browser) : await capture(req);
|
|
356
|
+
if (!result.ok) {
|
|
357
|
+
rmSync(tmp, { force: true });
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
const rel = relFor(result.scale);
|
|
361
|
+
try {
|
|
362
|
+
try {
|
|
363
|
+
renameSync(tmp, join(root, rel));
|
|
364
|
+
} catch (err) {
|
|
365
|
+
const code = err.code;
|
|
366
|
+
if (code !== "EEXIST" && code !== "EPERM") throw err;
|
|
367
|
+
copyFileSync(tmp, join(root, rel));
|
|
368
|
+
}
|
|
369
|
+
} catch (err) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
error: `could not write the shot - ${err.message}`
|
|
373
|
+
};
|
|
374
|
+
} finally {
|
|
375
|
+
rmSync(tmp, { force: true });
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
ok: true,
|
|
379
|
+
path: rel,
|
|
380
|
+
width: result.width,
|
|
381
|
+
height: result.height,
|
|
382
|
+
scale: result.scale,
|
|
383
|
+
...result.truncated ? { truncated: true } : {},
|
|
384
|
+
...result.unsettled ? { unsettled: true } : {},
|
|
385
|
+
...result.note ? { note: result.note } : {}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/** Turn a selector into the ordered list of frame ids to shoot, or an error with the HTTP
|
|
389
|
+
* status it deserves. Ids the manifest does not know are kept (each fails its own entry);
|
|
390
|
+
* duplicates, an empty or oversize list, and a scene nobody belongs to are refused whole. */
|
|
391
|
+
function resolveFrames(root, sel) {
|
|
392
|
+
const keys = [
|
|
393
|
+
"frames",
|
|
394
|
+
"scene",
|
|
395
|
+
"all"
|
|
396
|
+
].filter((k) => sel[k] !== void 0);
|
|
397
|
+
if (keys.length !== 1) return {
|
|
398
|
+
ok: false,
|
|
399
|
+
status: 400,
|
|
400
|
+
error: "name exactly one of frames (an array of ids), scene (a name) or all (true)"
|
|
401
|
+
};
|
|
402
|
+
let manifest = {};
|
|
403
|
+
try {
|
|
404
|
+
manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
|
|
405
|
+
} catch {}
|
|
406
|
+
const entries = manifest.frames ?? [];
|
|
407
|
+
const listed = entries.map((f) => f.id);
|
|
408
|
+
let frames;
|
|
409
|
+
if (keys[0] === "frames") {
|
|
410
|
+
if (!Array.isArray(sel.frames) || !sel.frames.every((f) => typeof f === "string" && f)) return {
|
|
411
|
+
ok: false,
|
|
412
|
+
status: 400,
|
|
413
|
+
error: "frames must be an array of frame ids"
|
|
414
|
+
};
|
|
415
|
+
frames = sel.frames;
|
|
416
|
+
if (new Set(frames).size !== frames.length) return {
|
|
417
|
+
ok: false,
|
|
418
|
+
status: 400,
|
|
419
|
+
error: "frames lists the same id twice"
|
|
420
|
+
};
|
|
421
|
+
} else if (keys[0] === "scene") {
|
|
422
|
+
if (typeof sel.scene !== "string" || !sel.scene) return {
|
|
423
|
+
ok: false,
|
|
424
|
+
status: 400,
|
|
425
|
+
error: "scene must be a scene name"
|
|
426
|
+
};
|
|
427
|
+
frames = entries.filter((f) => f.scene === sel.scene).map((f) => f.id);
|
|
428
|
+
if (!frames.length) return {
|
|
429
|
+
ok: false,
|
|
430
|
+
status: 404,
|
|
431
|
+
error: `no frames in scene "${sel.scene}" - scenes are in design/manifest.json`
|
|
432
|
+
};
|
|
433
|
+
} else {
|
|
434
|
+
if (sel.all !== true) return {
|
|
435
|
+
ok: false,
|
|
436
|
+
status: 400,
|
|
437
|
+
error: "all must be true"
|
|
438
|
+
};
|
|
439
|
+
frames = listed;
|
|
440
|
+
}
|
|
441
|
+
if (!frames.length) return {
|
|
442
|
+
ok: false,
|
|
443
|
+
status: 400,
|
|
444
|
+
error: "nothing to shoot"
|
|
445
|
+
};
|
|
446
|
+
if (frames.length > 200) return {
|
|
447
|
+
ok: false,
|
|
448
|
+
status: 400,
|
|
449
|
+
error: `${frames.length} frames - a batch takes 200 at most`
|
|
450
|
+
};
|
|
451
|
+
return {
|
|
452
|
+
ok: true,
|
|
453
|
+
frames
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/** Shoot many frames as ONE operation: one browser, `shotConcurrency()` frames at a time
|
|
457
|
+
* inside it, results in the order asked. Every entry answers for itself - an unknown id or a
|
|
458
|
+
* frame that throws fails alone; only a browser that could not start fails the whole batch
|
|
459
|
+
* (the thrown error, for the caller to turn into a 503). Once work has begun a browser that
|
|
460
|
+
* dies fails the remaining entries individually. */
|
|
461
|
+
async function shootBatch(opts) {
|
|
462
|
+
const { root, viewports, frames, theme, origin, scale } = opts;
|
|
463
|
+
return withBrowser("shot", (b) => pool(shotConcurrency(), frames, async (frameId) => {
|
|
464
|
+
return {
|
|
465
|
+
frame: frameId,
|
|
466
|
+
...await shootFrame({
|
|
467
|
+
root,
|
|
468
|
+
viewports,
|
|
469
|
+
frameId,
|
|
470
|
+
theme,
|
|
471
|
+
origin,
|
|
472
|
+
scale,
|
|
473
|
+
browser: b
|
|
474
|
+
}).catch((e) => ({
|
|
475
|
+
ok: false,
|
|
476
|
+
error: e.message
|
|
477
|
+
}))
|
|
478
|
+
};
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
/** Kill the headless browsers earlier versions left behind (they outlived their server, and
|
|
482
|
+
* on macOS such a ghost of the user's own Chrome can swallow every link the machine opens),
|
|
483
|
+
* then remove the profile directories nothing references. Runs once at `dev()` start,
|
|
484
|
+
* best-effort, never throws. Orphaned is the rule: a live browser of ANY running marver
|
|
485
|
+
* server has that server as its parent, and ours die with it - so a headless Chrome on one
|
|
486
|
+
* of our profiles whose parent is 1 can only be a leak. darwin and linux (`ps -o`); anywhere
|
|
487
|
+
* else this is a no-op. */
|
|
488
|
+
const BROWSER_NAMES = /* @__PURE__ */ new Set([
|
|
489
|
+
"Google Chrome",
|
|
490
|
+
"Chromium",
|
|
491
|
+
"Microsoft Edge",
|
|
492
|
+
"Brave Browser",
|
|
493
|
+
"chrome",
|
|
494
|
+
"chromium",
|
|
495
|
+
"chromium-browser",
|
|
496
|
+
"google-chrome",
|
|
497
|
+
"google-chrome-stable",
|
|
498
|
+
"msedge",
|
|
499
|
+
"brave",
|
|
500
|
+
"brave-browser"
|
|
501
|
+
]);
|
|
502
|
+
async function sweepGhosts(log = () => {}) {
|
|
503
|
+
if (process.platform !== "darwin" && process.platform !== "linux") return;
|
|
504
|
+
const tmp = tmpdir();
|
|
505
|
+
const bins = /* @__PURE__ */ new Set([...CHROMES, ...process.env.MARVER_CHROME ? [process.env.MARVER_CHROME] : []]);
|
|
506
|
+
const profileOf = (cmd) => {
|
|
507
|
+
const m = /(?:^|\s)--user-data-dir=(\S+)/.exec(cmd);
|
|
508
|
+
if (!m) return null;
|
|
509
|
+
const dir = m[1];
|
|
510
|
+
return PROFILE_PREFIXES.some((pre) => dir.startsWith(join(tmp, pre))) ? dir : null;
|
|
511
|
+
};
|
|
512
|
+
const isBrowser = (cmd) => {
|
|
513
|
+
const exe = cmd.split(" --")[0];
|
|
514
|
+
if ([...bins].some((b) => exe === b || exe.startsWith(`${b} `))) return true;
|
|
515
|
+
return BROWSER_NAMES.has(exe.slice(exe.lastIndexOf("/") + 1));
|
|
516
|
+
};
|
|
517
|
+
const ps = (args) => {
|
|
518
|
+
try {
|
|
519
|
+
return execFileSync("ps", args, { encoding: "utf8" }).split("\n").map((l) => {
|
|
520
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(l);
|
|
521
|
+
return m ? {
|
|
522
|
+
pid: Number(m[1]),
|
|
523
|
+
ppid: Number(m[2]),
|
|
524
|
+
cmd: m[3]
|
|
525
|
+
} : null;
|
|
526
|
+
}).filter((r) => !!r);
|
|
527
|
+
} catch {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const rows = ps(["-axo", "pid=,ppid=,command="]);
|
|
532
|
+
if (!rows) return;
|
|
533
|
+
const isGhost = (r) => r.ppid === 1 && r.cmd.includes("--headless") && !!profileOf(r.cmd) && isBrowser(r.cmd);
|
|
534
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
535
|
+
for (const r of rows) {
|
|
536
|
+
const dir = profileOf(r.cmd);
|
|
537
|
+
if (dir) referenced.add(dir);
|
|
538
|
+
}
|
|
539
|
+
for (const g of rows.filter(isGhost)) {
|
|
540
|
+
const now = ps([
|
|
541
|
+
"-o",
|
|
542
|
+
"pid=,ppid=,command=",
|
|
543
|
+
"-p",
|
|
544
|
+
String(g.pid)
|
|
545
|
+
])?.[0];
|
|
546
|
+
if (!now || now.cmd !== g.cmd || now.ppid !== g.ppid) continue;
|
|
547
|
+
try {
|
|
548
|
+
process.kill(g.pid, "SIGKILL");
|
|
549
|
+
} catch {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const by = Date.now() + 2e3;
|
|
553
|
+
while (Date.now() < by) try {
|
|
554
|
+
process.kill(g.pid, 0);
|
|
555
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
556
|
+
} catch {
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
log(`shot: removed a headless browser left by an earlier version (pid ${g.pid})`);
|
|
560
|
+
referenced.delete(profileOf(g.cmd));
|
|
561
|
+
}
|
|
562
|
+
let names = [];
|
|
563
|
+
try {
|
|
564
|
+
names = readdirSync(tmp).filter((n) => PROFILE_PREFIXES.some((pre) => n.startsWith(pre)));
|
|
565
|
+
} catch {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
for (const n of names) {
|
|
569
|
+
const dir = join(tmp, n);
|
|
570
|
+
if (referenced.has(dir)) continue;
|
|
571
|
+
try {
|
|
572
|
+
if (Date.now() - statSync(dir).mtimeMs < 6e5) continue;
|
|
573
|
+
} catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
rmSync(dir, {
|
|
578
|
+
recursive: true,
|
|
579
|
+
force: true
|
|
580
|
+
});
|
|
581
|
+
} catch {}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/** A crashed process can leave a `.tmp.png` behind; sweep them (older than a minute, so an in-
|
|
585
|
+
* flight capture in this process is never touched) at the next shot. */
|
|
586
|
+
const sweptAt = /* @__PURE__ */ new Map();
|
|
587
|
+
function sweepTemps(dir) {
|
|
588
|
+
if (Date.now() - (sweptAt.get(dir) ?? 0) < 6e4) return;
|
|
589
|
+
sweptAt.set(dir, Date.now());
|
|
590
|
+
try {
|
|
591
|
+
for (const f of readdirSync(dir)) {
|
|
592
|
+
if (!f.endsWith(".tmp.png")) continue;
|
|
593
|
+
const p = join(dir, f);
|
|
594
|
+
try {
|
|
595
|
+
if (Date.now() - statSync(p).mtimeMs > 6e4) rmSync(p, { force: true });
|
|
596
|
+
} catch {}
|
|
597
|
+
}
|
|
598
|
+
} catch {}
|
|
599
|
+
}
|
|
600
|
+
/** One OPERATION at a time PER LANE, and one browser per operation. An operation is a shot,
|
|
601
|
+
* a batch or a poster: it starts its browser, runs its frames through it (a batch runs N at
|
|
602
|
+
* once inside), and closes it in the finally - no browser is ever shared between operations
|
|
603
|
+
* or left idle. Operations on a lane queue as shots always did, so two operations never
|
|
604
|
+
* write the same output at the same time. Posters have their own lane - a frame being shot
|
|
605
|
+
* may ask for its poster mid-render, and on the shot's lane that request would wait for the
|
|
606
|
+
* very shot that is waiting for it. */
|
|
607
|
+
const chains = /* @__PURE__ */ new Map();
|
|
608
|
+
function withBrowser(lane, fn) {
|
|
609
|
+
const run = async () => {
|
|
610
|
+
const b = await Browser.launch();
|
|
611
|
+
try {
|
|
612
|
+
return await fn(b);
|
|
613
|
+
} finally {
|
|
614
|
+
await b.close();
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
const next = (chains.get(lane) ?? Promise.resolve()).then(run, run);
|
|
618
|
+
chains.set(lane, next);
|
|
619
|
+
return next;
|
|
620
|
+
}
|
|
621
|
+
/** One frame as its own operation (a poster, the shell's copy-as-image, a single `shot`). */
|
|
622
|
+
function capture(req, lane = "shot") {
|
|
623
|
+
return withBrowser(lane, (b) => captureIn(req, b)).catch((e) => ({
|
|
624
|
+
ok: false,
|
|
625
|
+
error: e.message
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
/** How many frames a batch renders at once inside its browser. Six at most by default, fewer
|
|
629
|
+
* on a small machine: the settle and grow passes are wall-clock budgets, and past a few
|
|
630
|
+
* concurrent renderers a frame starts spending its budget waiting for CPU instead of for its
|
|
631
|
+
* own content (measured 2026-09-04: 31 frames, no unsettled capture up to 8 on 18 cores). */
|
|
632
|
+
function shotConcurrency() {
|
|
633
|
+
const n = Number(process.env.MARVER_SHOT_CONCURRENCY);
|
|
634
|
+
if (Number.isInteger(n) && n >= 1 && n <= 16) return n;
|
|
635
|
+
return Math.max(2, Math.min(6, availableParallelism() - 2));
|
|
636
|
+
}
|
|
637
|
+
/** Run `fn` over `items` with at most `n` in flight; results in input order. */
|
|
638
|
+
async function pool(n, items, fn) {
|
|
639
|
+
const results = new Array(items.length);
|
|
640
|
+
let next = 0;
|
|
641
|
+
const worker = async () => {
|
|
642
|
+
for (;;) {
|
|
643
|
+
const i = next++;
|
|
644
|
+
if (i >= items.length) return;
|
|
645
|
+
results[i] = await fn(items[i], i);
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
|
|
649
|
+
return results;
|
|
650
|
+
}
|
|
651
|
+
/** The capture budget. Chrome's surface tops out near 16384 device px per SIDE, and a bitmap is
|
|
652
|
+
* also bounded by its AREA: 64M device px (~256 MiB RGBA) is comfortably rendered, encoded and
|
|
653
|
+
* pasted, where a 3840×2160@4 (133M px) can stall the renderer or the paste target. Both limits
|
|
654
|
+
* are exported so the tests hold the same numbers. */
|
|
655
|
+
const SURFACE = 16384;
|
|
656
|
+
const AREA = 64e6;
|
|
657
|
+
const fits = (w, h, dsf) => w * dsf <= 16384 && h * dsf <= 16384 && w * h * dsf * dsf <= 64e6;
|
|
658
|
+
/** The tallest CSS height a full-height capture of `width` can hold at `dsf`. */
|
|
659
|
+
const capFor = (width, dsf) => Math.max(1, Math.min(Math.floor(SURFACE / dsf), Math.floor(AREA / (width * dsf * dsf))));
|
|
660
|
+
async function captureIn({ url, width, height, out, fullHeight = false, timeoutMs = 3e4, scale: wantScale = 2, clip }, b) {
|
|
661
|
+
let scale = Math.min(4, Math.max(1, Math.round(wantScale)));
|
|
662
|
+
while (scale > 1 && !fits(width, height, scale)) scale--;
|
|
663
|
+
const me = {};
|
|
664
|
+
let watchdog;
|
|
665
|
+
let targetId;
|
|
666
|
+
let sessionId;
|
|
667
|
+
let off = () => {};
|
|
668
|
+
try {
|
|
669
|
+
let lastException = "";
|
|
670
|
+
const send = (method, params = {}, sid) => b.send(method, params, sid, me);
|
|
671
|
+
const sendD = (method, params, ms = 1500) => {
|
|
672
|
+
let timer;
|
|
673
|
+
return Promise.race([send(method, params, sessionId), new Promise((_, rej) => {
|
|
674
|
+
timer = setTimeout(() => rej(/* @__PURE__ */ new Error(`${method} timed out`)), ms);
|
|
675
|
+
})]).finally(() => clearTimeout(timer));
|
|
676
|
+
};
|
|
677
|
+
const hardBy = Date.now() + timeoutMs + 15e3;
|
|
678
|
+
watchdog = setTimeout(() => b.abort(me, "the shot timed out"), timeoutMs + 15e3);
|
|
679
|
+
const RESERVE = 21e3;
|
|
680
|
+
const discretionary = () => hardBy - RESERVE - Date.now();
|
|
681
|
+
targetId = (await send("Target.createTarget", { url: "about:blank" })).targetId;
|
|
682
|
+
sessionId = (await send("Target.attachToTarget", {
|
|
683
|
+
targetId,
|
|
684
|
+
flatten: true
|
|
685
|
+
})).sessionId;
|
|
686
|
+
off = b.on((m) => {
|
|
687
|
+
if (m.sessionId === sessionId && m.method === "Runtime.exceptionThrown") {
|
|
688
|
+
const d = m.params?.exceptionDetails;
|
|
689
|
+
lastException = String(d?.exception?.description ?? d?.text ?? "").split("\n")[0];
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
await send("Emulation.setDeviceMetricsOverride", {
|
|
693
|
+
width,
|
|
694
|
+
height,
|
|
695
|
+
deviceScaleFactor: scale,
|
|
696
|
+
mobile: false
|
|
697
|
+
}, sessionId);
|
|
698
|
+
await send("Page.enable", {}, sessionId);
|
|
699
|
+
await send("Runtime.enable", {}, sessionId);
|
|
700
|
+
const nav = await send("Page.navigate", { url }, sessionId);
|
|
701
|
+
if (nav?.errorText && nav.errorText !== "net::ERR_ABORTED") return {
|
|
702
|
+
ok: false,
|
|
703
|
+
error: `could not load the frame (${nav.errorText}) - is the dev server reachable at ${new URL(url).origin}?`
|
|
704
|
+
};
|
|
705
|
+
const t0 = Date.now();
|
|
706
|
+
let ready = false;
|
|
707
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
708
|
+
if ((await send("Runtime.evaluate", {
|
|
709
|
+
expression: `(() => { const el = document.getElementById('root') ?? document.body; return !!el && el.childElementCount > 0 && document.readyState !== 'loading' })()`,
|
|
710
|
+
returnByValue: true
|
|
711
|
+
}, sessionId).catch(() => null))?.result?.value) {
|
|
712
|
+
ready = true;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
await new Promise((r2) => setTimeout(r2, 150));
|
|
716
|
+
}
|
|
717
|
+
if (!ready) return {
|
|
718
|
+
ok: false,
|
|
719
|
+
error: `the frame never rendered${lastException ? ` - the page threw: ${lastException}` : " (no exception surfaced - is the dev server reachable from this machine?)"}`
|
|
720
|
+
};
|
|
721
|
+
const SETTLED = `(() => {
|
|
722
|
+
if (typeof window.__mvLodBusy === 'function' && window.__mvLodBusy() > 0) return false
|
|
723
|
+
if (typeof window.__mvPosterBusy === 'function' && window.__mvPosterBusy() > 0) return false
|
|
724
|
+
const H = window.innerHeight, W = window.innerWidth
|
|
725
|
+
for (const im of document.images) {
|
|
726
|
+
if (im.complete) continue
|
|
727
|
+
const r = im.getBoundingClientRect()
|
|
728
|
+
if (r.bottom < 0 || r.top > H || r.right < 0 || r.left > W) continue
|
|
729
|
+
return false
|
|
730
|
+
}
|
|
731
|
+
for (const c of document.querySelectorAll('.mv-chart')) if (!c.querySelector('svg, canvas')) return false
|
|
732
|
+
for (const d of document.querySelectorAll('.mv-diagram')) if (!d.querySelector('.mv-diagram-svg svg, .mv-diagram-err')) return false
|
|
733
|
+
return true
|
|
734
|
+
})()`;
|
|
735
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
736
|
+
const settle = async (budgetMs) => {
|
|
737
|
+
const total = Math.min(budgetMs, discretionary());
|
|
738
|
+
if (total <= 0) return false;
|
|
739
|
+
const by = Date.now() + total;
|
|
740
|
+
const left = () => Math.max(1, by - Date.now());
|
|
741
|
+
await sendD("Runtime.evaluate", {
|
|
742
|
+
expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
|
|
743
|
+
returnByValue: true
|
|
744
|
+
}, left()).catch(() => null);
|
|
745
|
+
await sendD("Runtime.evaluate", {
|
|
746
|
+
expression: "document.fonts.ready.then(() => true)",
|
|
747
|
+
awaitPromise: true,
|
|
748
|
+
returnByValue: true
|
|
749
|
+
}, left()).catch(() => null);
|
|
750
|
+
let ok = false;
|
|
751
|
+
while (Date.now() < by) {
|
|
752
|
+
if ((await sendD("Runtime.evaluate", {
|
|
753
|
+
expression: SETTLED,
|
|
754
|
+
returnByValue: true
|
|
755
|
+
}, left()).catch(() => null))?.result?.value === true) {
|
|
756
|
+
ok = true;
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
await wait(Math.min(100, left()));
|
|
760
|
+
}
|
|
761
|
+
if (Date.now() < by) await sendD("Runtime.evaluate", {
|
|
762
|
+
expression: "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))",
|
|
763
|
+
awaitPromise: true,
|
|
764
|
+
returnByValue: true
|
|
765
|
+
}, Math.min(1e3, left())).catch(() => null);
|
|
766
|
+
if (Date.now() < by) await wait(Math.min(ok ? 150 : 250, left()));
|
|
767
|
+
return ok;
|
|
768
|
+
};
|
|
769
|
+
let settled = await settle(3e3);
|
|
770
|
+
const errEval = await send("Runtime.evaluate", {
|
|
771
|
+
expression: "window.__mvFrameError || \"\"",
|
|
772
|
+
returnByValue: true
|
|
773
|
+
}, sessionId).catch(() => null);
|
|
774
|
+
const frameError = typeof errEval?.result?.value === "string" ? errEval.result.value : "";
|
|
775
|
+
if (frameError) return {
|
|
776
|
+
ok: false,
|
|
777
|
+
error: `the frame rendered an error - ${frameError}`
|
|
778
|
+
};
|
|
779
|
+
let capW = width, capH = height, truncated = false, note = "";
|
|
780
|
+
if (fullHeight) {
|
|
781
|
+
const measureH = async () => {
|
|
782
|
+
const v = (await sendD("Runtime.evaluate", {
|
|
783
|
+
expression: `Math.ceil((document.querySelector('.mv-doc')||document.getElementById('root')||document.body).getBoundingClientRect().height)`,
|
|
784
|
+
returnByValue: true
|
|
785
|
+
}).catch(() => null))?.result?.value;
|
|
786
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
787
|
+
};
|
|
788
|
+
const lodIdle = async () => {
|
|
789
|
+
const v = (await sendD("Runtime.evaluate", {
|
|
790
|
+
expression: `(typeof window.__mvLodBusy==='function')?window.__mvLodBusy():0`,
|
|
791
|
+
returnByValue: true
|
|
792
|
+
}).catch(() => null))?.result?.value;
|
|
793
|
+
return typeof v === "number" ? v === 0 : true;
|
|
794
|
+
};
|
|
795
|
+
let measured = 0;
|
|
796
|
+
const growFit = async (dsf, capCss, deadline) => {
|
|
797
|
+
let h = Math.max(height, 2e3), prev = -1;
|
|
798
|
+
for (let i = 0; i < 8 && Date.now() < deadline; i++) {
|
|
799
|
+
await sendD("Emulation.setDeviceMetricsOverride", {
|
|
800
|
+
width,
|
|
801
|
+
height: Math.min(h, capCss),
|
|
802
|
+
deviceScaleFactor: dsf,
|
|
803
|
+
mobile: false
|
|
804
|
+
}).catch(() => {});
|
|
805
|
+
if (Date.now() >= deadline) break;
|
|
806
|
+
await sendD("Runtime.evaluate", {
|
|
807
|
+
expression: "document.fonts.ready.then(()=>true)",
|
|
808
|
+
awaitPromise: true,
|
|
809
|
+
returnByValue: true
|
|
810
|
+
}).catch(() => null);
|
|
811
|
+
if (Date.now() >= deadline) break;
|
|
812
|
+
for (let j = 0; j < 10 && Date.now() < deadline && !await lodIdle(); j++) await wait(100);
|
|
813
|
+
if (Date.now() >= deadline) break;
|
|
814
|
+
await wait(150);
|
|
815
|
+
if (Date.now() >= deadline) break;
|
|
816
|
+
const m = await measureH();
|
|
817
|
+
if (m > 0) measured = m;
|
|
818
|
+
const capped = Math.min(h, capCss);
|
|
819
|
+
if (capped >= capCss) break;
|
|
820
|
+
if (m > 0 && m <= capped && m === prev) break;
|
|
821
|
+
prev = m;
|
|
822
|
+
h = Math.max(m, capped);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
const settleDeadline = Date.now() + Math.max(0, Math.min(6e3, discretionary()));
|
|
826
|
+
const asked = scale;
|
|
827
|
+
await growFit(scale, capFor(width, scale), settleDeadline);
|
|
828
|
+
if (measured > capFor(width, scale) && scale > 2) {
|
|
829
|
+
scale = 2;
|
|
830
|
+
await growFit(2, capFor(width, 2), settleDeadline);
|
|
831
|
+
}
|
|
832
|
+
if (measured > capFor(width, scale) && scale > 1) {
|
|
833
|
+
scale = 1;
|
|
834
|
+
await growFit(1, capFor(width, 1), settleDeadline);
|
|
835
|
+
}
|
|
836
|
+
const cap = capFor(width, scale);
|
|
837
|
+
capW = width;
|
|
838
|
+
if (measured > cap) {
|
|
839
|
+
capH = cap;
|
|
840
|
+
truncated = true;
|
|
841
|
+
note = `frame is ${measured}px tall; captured the top ${cap}px - split it or reduce its height`;
|
|
842
|
+
} else capH = clamp(measured || height, 80, cap);
|
|
843
|
+
if (scale < asked && !truncated) note = `frame is ${measured}px tall - too tall for ${asked}x, captured at ${scale}x`;
|
|
844
|
+
await sendD("Emulation.setDeviceMetricsOverride", {
|
|
845
|
+
width: capW,
|
|
846
|
+
height: capH,
|
|
847
|
+
deviceScaleFactor: scale,
|
|
848
|
+
mobile: false
|
|
849
|
+
}, 5e3);
|
|
850
|
+
await wait(Math.min(300, Math.max(0, discretionary())));
|
|
851
|
+
settled = await settle(Math.max(1200, settleDeadline - Date.now()));
|
|
852
|
+
const m2 = await measureH();
|
|
853
|
+
if (m2 > capH) {
|
|
854
|
+
if (m2 <= cap) capH = m2;
|
|
855
|
+
else {
|
|
856
|
+
capH = cap;
|
|
857
|
+
truncated = true;
|
|
858
|
+
note = `frame is ${m2}px tall; captured the top ${cap}px - split it or reduce its height`;
|
|
859
|
+
}
|
|
860
|
+
await sendD("Emulation.setDeviceMetricsOverride", {
|
|
861
|
+
width: capW,
|
|
862
|
+
height: capH,
|
|
863
|
+
deviceScaleFactor: scale,
|
|
864
|
+
mobile: false
|
|
865
|
+
}, 5e3);
|
|
866
|
+
settled = await settle(600);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
let capX = 0, capY = 0;
|
|
870
|
+
if (clip) {
|
|
871
|
+
const box = (await sendD("Runtime.evaluate", {
|
|
872
|
+
expression: `(() => { const el = document.querySelector(${JSON.stringify(clip)}); if (!el) return null; const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height } })()`,
|
|
873
|
+
returnByValue: true
|
|
874
|
+
}, 2e3).catch(() => null))?.result?.value;
|
|
875
|
+
if (!box || box.w < 1 || box.h < 1) return {
|
|
876
|
+
ok: false,
|
|
877
|
+
error: `nothing to capture at "${clip}"`
|
|
878
|
+
};
|
|
879
|
+
capX = Math.max(0, Math.floor(box.x));
|
|
880
|
+
capY = Math.max(0, Math.floor(box.y));
|
|
881
|
+
capW = Math.max(1, Math.round(box.w));
|
|
882
|
+
capH = Math.max(1, Math.round(box.h));
|
|
883
|
+
}
|
|
884
|
+
const shot = await sendD("Page.captureScreenshot", {
|
|
885
|
+
format: "png",
|
|
886
|
+
captureBeyondViewport: true,
|
|
887
|
+
clip: {
|
|
888
|
+
x: capX,
|
|
889
|
+
y: capY,
|
|
890
|
+
width: capW,
|
|
891
|
+
height: capH,
|
|
892
|
+
scale: 1
|
|
893
|
+
}
|
|
894
|
+
}, 15e3);
|
|
895
|
+
writeFileSync(out, Buffer.from(String(shot.data), "base64"));
|
|
896
|
+
const unsettled = !settled;
|
|
897
|
+
if (unsettled) note = `${note ? note + "; " : ""}captured before the frame settled - images, charts or diagrams may be missing; shoot it alone or lower MARVER_SHOT_CONCURRENCY`;
|
|
898
|
+
return {
|
|
899
|
+
ok: true,
|
|
900
|
+
width: capW,
|
|
901
|
+
height: capH,
|
|
902
|
+
scale,
|
|
903
|
+
...truncated ? { truncated: true } : {},
|
|
904
|
+
...unsettled ? { unsettled: true } : {},
|
|
905
|
+
...note ? { note } : {}
|
|
906
|
+
};
|
|
907
|
+
} catch (err) {
|
|
908
|
+
return {
|
|
909
|
+
ok: false,
|
|
910
|
+
error: err.message
|
|
911
|
+
};
|
|
912
|
+
} finally {
|
|
913
|
+
if (watchdog) clearTimeout(watchdog);
|
|
914
|
+
off();
|
|
915
|
+
if (targetId && !b.dead) {
|
|
916
|
+
let t;
|
|
917
|
+
await Promise.race([b.send("Target.closeTarget", { targetId }, void 0, me).catch(() => {}), new Promise((r) => {
|
|
918
|
+
t = setTimeout(r, 2e3);
|
|
919
|
+
})]);
|
|
920
|
+
clearTimeout(t);
|
|
921
|
+
}
|
|
922
|
+
b.abort(me, "the shot finished");
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
//#endregion
|
|
926
|
+
export { capture, resolveFrames, shootBatch, shootFrame, sweepGhosts, findChrome as t };
|