@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -4
  3. package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
  4. package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
  5. package/dist/{build-Ct0iMoSi.mjs → build-D_g53Bp2.mjs} +12 -8
  6. package/dist/cli.mjs +27 -16
  7. package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-CdcIuhZJ.mjs → dev-LnIISva5.mjs} +9 -4
  9. package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
  10. package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
  11. package/dist/{plugin-BXyezwfN.mjs → plugin-D2msH1cj.mjs} +223 -43
  12. package/dist/{poster-DOY7pax8.mjs → poster-BEjUcQP3.mjs} +13 -5
  13. package/dist/shot-BzQ0PXKH.mjs +86 -0
  14. package/dist/shot-DlmTO8AF.mjs +926 -0
  15. package/docs/live-jam.md +20 -8
  16. package/docs/publish.md +2 -2
  17. package/package.json +1 -1
  18. package/src/client/shell/App.tsx +42 -14
  19. package/src/client/shell/BoardList.tsx +88 -58
  20. package/src/client/shell/LockedApp.tsx +2 -1
  21. package/src/client/shell/Play.tsx +1 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +76 -29
  24. package/src/client/shell/styles.css +5 -1
  25. package/src/shared/board-tree.ts +104 -46
  26. package/templates/AGENTS-embedded.md +13 -3
  27. package/templates/AGENTS-studio.md +13 -3
  28. package/templates/instructions/boards.md +27 -9
  29. package/templates/instructions/discover.md +13 -1
  30. package/templates/instructions/jam.md +15 -3
  31. package/templates/instructions/shape.md +2 -1
  32. package/dist/shot-By1AItpD.mjs +0 -30
  33. package/dist/shot-CwmHO5T4.mjs +0 -528
@@ -45,7 +45,19 @@ e.g. `# Checkout - the buyer's path from cart to receipt (v2, after the pricing
45
45
  That line lands in `design/manifest.json` as the scene's `description`, so a later
46
46
  session reads it without opening the brief; keep it true as the scene moves on (a
47
47
  scene that only needs a gist - a version snapshot, an archive - gets a one-line brief).
48
- Show it. Get the nod.
48
+ A scene's directory is its identity (every frame id starts with it); what humans SEE
49
+ for it is `title` in the brief's YAML front matter - optional, free text - the sidebar
50
+ Title-Cases the directory otherwise (`checkout` → "Checkout", but `mvp` → "Mvp"):
51
+
52
+ ```md
53
+ ---
54
+ title: "Checkout (v2)"
55
+ ---
56
+ # Checkout - the buyer's path from cart to receipt (v2, after the pricing pivot)
57
+ ```
58
+
59
+ The human's Rename on a scene writes exactly that line; leave the block alone and
60
+ never rename the directory for a title. Show the brief. Get the nod.
49
61
 
50
62
  **Unattended?** When the human is away or has said "don't ask", the interview and
51
63
  the nod convert to obligations, not blockers: answer the five questions yourself
@@ -98,6 +98,15 @@ file - the dev server renders it and writes the PNG back:
98
98
  3. **Read the PNG** at that `path` and check it with your own eyes: content present, both
99
99
  themes if you touched theming, nothing clipped. Fix and re-shoot; files overwrite in place.
100
100
 
101
+ **Several frames? Ask for them in ONE request** - a whole scene renders in one browser,
102
+ several frames at a time, for about the cost of one shot. Write any `<name>.request.json`
103
+ with `{"scene":"checkout"}`, or `{"frames":["checkout/cart","checkout/summary"]}`, or
104
+ `{"all":true}` (plus `"theme"`, `"scale"` as you like). The result is
105
+ `{"ok":true,"results":[{"frame":"checkout/cart","ok":true,"path":"..."},...]}`, one entry
106
+ per frame in the order asked; a frame that failed carries its own `"ok":false,"error"` and
107
+ the others still ship. A frame that ran out of time to settle (a slow image, a heavy chart)
108
+ comes back with `"unsettled":true` and a `note` - shoot that one alone before you judge it.
109
+
101
110
  The `result.json` is the universal signal - it works even when you cannot see images.
102
111
  `"ok":false` means the frame did not render: the `error` carries the reason (a runtime
103
112
  throw shows the frame's own exception, "the frame rendered an error - ..."; an unreachable
@@ -108,9 +117,12 @@ shot at its natural width and its FULL height, so a wide layout or a long spec r
108
117
  full, not cropped. A frame tall enough to hit the capture cap comes back with
109
118
  `"truncated":true` and a `note` - split it or shorten it and re-shoot.
110
119
 
111
- (If you DO have a shell - `npx marver shot <scene/frame> [--theme dark] [--scale 4]` is the
112
- same thing in one line, printing the PNG path. `--scale 4` is for a print-quality still - the
113
- human's "copy as image" on the canvas uses this same renderer, so you both see one picture.)
120
+ (If you DO have a shell - `npx marver shot <scene/frame ...>`, `npx marver shot --scene
121
+ <name>` or `--all` (`[--theme dark] [--scale 4] [--json]`) is the same thing in one line,
122
+ printing one PNG path per line; a failed frame goes to stderr and the exit code is 1. After
123
+ writing a scene, shoot the scene, not the frames. `--scale 4` is for a print-quality still -
124
+ the human's "copy as image" on the canvas uses this same renderer, so you both see one
125
+ picture.)
114
126
 
115
127
  Verification is best-effort, not a gate. If your model cannot read images, or the result
116
128
  reports no Chrome on the machine, still act on `ok`/`error` - and say plainly in your reply
@@ -183,7 +183,8 @@ of described imagery every time (the full asset rules: instructions/craft.md,
183
183
  never set a frame height - the frame auto-heights to fit everything the canvas
184
184
  measures. Marver renders images crisp and zooms fast, so fine detail is one zoom away.
185
185
  - Judge on the RENDER, not the props: after composing, look at the actual frame
186
- (screenshot it if you can) and adjust the per-row count until it reads well. "The code
186
+ (`npx marver shot --scene <scene>` shoots the whole scene in one go; instructions/jam.md
187
+ has the shell-less way) and adjust the per-row count until it reads well. "The code
187
188
  says they're the same width" proves nothing.
188
189
 
189
190
  ## When Shape ends
@@ -1,30 +0,0 @@
1
- import { n as NAME } from "./cli.mjs";
2
- import { readDevInfo } from "./work-CLrmY-vQ.mjs";
3
- //#region src/cli/shot.ts
4
- /**
5
- * `marver shot <scene/frame>` - render one frame headless and print the PNG path.
6
- *
7
- * The shell-ful agents' door into the same verify loop jam teaches: build, shoot, LOOK.
8
- * Thin wrapper over the dev server's /api/shot (shot.ts has the capture story).
9
- */
10
- async function shotCommand(root, frame, opts) {
11
- if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>] [--scale 1-4]`);
12
- const info = readDevInfo(root);
13
- if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
14
- const qs = new URLSearchParams({
15
- frame,
16
- ...opts.theme ? { theme: opts.theme } : {},
17
- ...opts.scale != null ? { scale: String(opts.scale) } : {}
18
- });
19
- let res;
20
- try {
21
- res = await fetch(`http://localhost:${info.port}/__mv/api/shot?${qs}`, { headers: { "x-mv-work": info.token } });
22
- } catch {
23
- throw new Error(`could not reach \`${NAME} dev\` on port ${info.port} - is it still running?`);
24
- }
25
- const data = await res.json().catch(() => ({}));
26
- if (!res.ok || !data.path) throw new Error(data.error ?? `shot failed (${res.status})`);
27
- console.log(data.path);
28
- }
29
- //#endregion
30
- export { shotCommand };
@@ -1,528 +0,0 @@
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 { tmpdir } from "node:os";
6
- import { spawn } from "node:child_process";
7
- //#region src/server/shot.ts
8
- /**
9
- * Frame screenshots without a dependency - the system's own Chrome, driven over CDP
10
- * (Node 22 ships a WebSocket client, so this is ~zero cost).
11
- *
12
- * This exists for Live Jam's verify loop: a jam agent has no shell (deliberately - the job
13
- * packet carries untrusted text), so "look at what you built" must be a capability the dev
14
- * server provides, not a command the agent runs. The /api/shot endpoint calls this; the
15
- * `marver shot` CLI and a plain WebFetch both reach that endpoint.
16
- *
17
- * Readiness is DETERMINISTIC, not a sleep: poll until #root (or body, for html frames) has
18
- * children, then wait for fonts. A frame that never mounts fails with the page's own
19
- * exception text - which is exactly what the agent needs to fix it.
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
- const slug = (frameId) => frameId.replace(/\//g, "--");
38
- const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
39
- const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
40
- function planShot(frame, viewports, override = {}) {
41
- const cw = num(frame.contentWidth);
42
- const vpObj = frame.viewport ? viewports[frame.viewport] : void 0;
43
- const ow = num(override.w), oh = num(override.h);
44
- const sl = slideSize(frame);
45
- if (sl) return {
46
- width: sl.width,
47
- initialHeight: sl.height,
48
- fullHeight: false
49
- };
50
- const fallback = viewports.mobile ?? {
51
- width: 390,
52
- height: 844
53
- };
54
- if (cw) {
55
- const width = clamp(ow ?? vpObj?.width ?? cw, 320, 1600);
56
- return {
57
- width,
58
- initialHeight: Math.round(width * .75),
59
- fullHeight: true
60
- };
61
- }
62
- const vp = vpObj ?? fallback;
63
- if (ow || oh) return {
64
- width: clamp(ow ?? vp.width, 120, 3840),
65
- initialHeight: clamp(oh ?? vp.height, 80, 2160),
66
- fullHeight: false
67
- };
68
- return {
69
- width: vp.width,
70
- initialHeight: vp.height,
71
- fullHeight: false
72
- };
73
- }
74
- /** Resolve a frame from the manifest and screenshot it - the shared core behind BOTH
75
- * transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
76
- * name output identically. `origin` is the dev server's own base URL. */
77
- async function shootFrame(opts) {
78
- const { root, viewports, frameId, theme, origin, scale = 2, size } = opts;
79
- if (!/^[a-z0-9-]+$/i.test(theme)) return {
80
- ok: false,
81
- error: "invalid theme"
82
- };
83
- if (!Number.isInteger(scale) || scale < 1 || scale > 4) return {
84
- ok: false,
85
- error: "invalid scale"
86
- };
87
- let manifest = {};
88
- try {
89
- manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
90
- } catch {}
91
- const frame = (manifest.frames ?? []).find((f) => f.id === frameId);
92
- if (!frame) return {
93
- ok: false,
94
- error: `unknown frame "${frameId}" - ids are in design/manifest.json`
95
- };
96
- const plan = planShot(frame, viewports, size);
97
- if (frame.kind !== "html") try {
98
- const { scanAssetRefs } = await import("./build-Ct0iMoSi.mjs");
99
- const { ensurePoster } = await import("./poster-DOY7pax8.mjs");
100
- const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
101
- for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
102
- } catch {}
103
- const shotsDir = join(root, "design", ".local", "shots");
104
- mkdirSync(shotsDir, { recursive: true });
105
- sweepTemps(shotsDir);
106
- const explicit = opts.scale !== void 0;
107
- const relFor = (used) => {
108
- const tag = !explicit || scale === 2 && used === 2 ? "" : used === scale ? `@${scale}x` : `@${scale}x-as-${used}x`;
109
- return `design/.local/shots/${slug(frameId)}--${theme}${tag}.png`;
110
- };
111
- const tmp = join(shotsDir, `.${slug(frameId)}--${theme}.${randomBytes(6).toString("hex")}.tmp.png`);
112
- const result = await capture({
113
- url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
114
- width: plan.width,
115
- height: plan.initialHeight,
116
- out: tmp,
117
- fullHeight: plan.fullHeight,
118
- scale
119
- });
120
- if (!result.ok) {
121
- rmSync(tmp, { force: true });
122
- return result;
123
- }
124
- const rel = relFor(result.scale);
125
- try {
126
- try {
127
- renameSync(tmp, join(root, rel));
128
- } catch (err) {
129
- const code = err.code;
130
- if (code !== "EEXIST" && code !== "EPERM") throw err;
131
- copyFileSync(tmp, join(root, rel));
132
- }
133
- } catch (err) {
134
- return {
135
- ok: false,
136
- error: `could not write the shot - ${err.message}`
137
- };
138
- } finally {
139
- rmSync(tmp, { force: true });
140
- }
141
- return {
142
- ok: true,
143
- path: rel,
144
- width: result.width,
145
- height: result.height,
146
- scale: result.scale,
147
- ...result.truncated ? { truncated: true } : {},
148
- ...result.note ? { note: result.note } : {}
149
- };
150
- }
151
- /** A crashed process can leave a `.tmp.png` behind; sweep them (older than a minute, so an in-
152
- * flight capture in this process is never touched) at the next shot. */
153
- const sweptAt = /* @__PURE__ */ new Map();
154
- function sweepTemps(dir) {
155
- if (Date.now() - (sweptAt.get(dir) ?? 0) < 6e4) return;
156
- sweptAt.set(dir, Date.now());
157
- try {
158
- for (const f of readdirSync(dir)) {
159
- if (!f.endsWith(".tmp.png")) continue;
160
- const p = join(dir, f);
161
- try {
162
- if (Date.now() - statSync(p).mtimeMs > 6e4) rmSync(p, { force: true });
163
- } catch {}
164
- }
165
- } catch {}
166
- }
167
- /** One capture at a time PER LANE: shots are seconds apart at most, and a Chrome per
168
- * concurrent request would stampede the machine mid-jam. Posters have their own lane - a
169
- * frame being shot may ask for its poster mid-render, and on the shot's lane that request
170
- * would wait for the very shot that is waiting for it. */
171
- const chains = /* @__PURE__ */ new Map();
172
- function capture(req, lane = "shot") {
173
- const run = (chains.get(lane) ?? Promise.resolve()).then(() => captureNow(req), () => captureNow(req));
174
- chains.set(lane, run);
175
- return run;
176
- }
177
- /** The capture budget. Chrome's surface tops out near 16384 device px per SIDE, and a bitmap is
178
- * also bounded by its AREA: 64M device px (~256 MiB RGBA) is comfortably rendered, encoded and
179
- * pasted, where a 3840×2160@4 (133M px) can stall the renderer or the paste target. Both limits
180
- * are exported so the tests hold the same numbers. */
181
- const SURFACE = 16384;
182
- const AREA = 64e6;
183
- const fits = (w, h, dsf) => w * dsf <= 16384 && h * dsf <= 16384 && w * h * dsf * dsf <= 64e6;
184
- /** The tallest CSS height a full-height capture of `width` can hold at `dsf`. */
185
- const capFor = (width, dsf) => Math.max(1, Math.min(Math.floor(SURFACE / dsf), Math.floor(AREA / (width * dsf * dsf))));
186
- async function captureNow({ url, width, height, out, fullHeight = false, timeoutMs = 3e4, scale: wantScale = 2, clip }) {
187
- let scale = Math.min(4, Math.max(1, Math.round(wantScale)));
188
- while (scale > 1 && !fits(width, height, scale)) scale--;
189
- const bin = findChrome();
190
- if (!bin) return {
191
- ok: false,
192
- error: "no Chrome/Chromium found - install one or set MARVER_CHROME to a browser binary"
193
- };
194
- const profile = mkdtempSync(join(tmpdir(), "mv-shot-"));
195
- const chrome = spawn(bin, [
196
- "--headless=new",
197
- "--disable-gpu",
198
- "--hide-scrollbars",
199
- "--no-first-run",
200
- "--no-default-browser-check",
201
- "--disable-extensions",
202
- `--user-data-dir=${profile}`,
203
- "--remote-debugging-port=0",
204
- "about:blank"
205
- ], { stdio: [
206
- "ignore",
207
- "ignore",
208
- "pipe"
209
- ] });
210
- let ws = null;
211
- let watchdog;
212
- try {
213
- const wsUrl = await new Promise((resolve, reject) => {
214
- let buf = "";
215
- const to = setTimeout(() => reject(/* @__PURE__ */ new Error("the browser did not expose devtools in time")), 15e3);
216
- chrome.stderr?.on("data", (d) => {
217
- buf += d;
218
- const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
219
- if (m) {
220
- clearTimeout(to);
221
- resolve(m[1]);
222
- }
223
- });
224
- chrome.on("error", (e) => {
225
- clearTimeout(to);
226
- reject(e);
227
- });
228
- chrome.on("exit", () => {
229
- clearTimeout(to);
230
- reject(/* @__PURE__ */ new Error("the browser exited before exposing devtools"));
231
- });
232
- });
233
- ws = new WebSocket(wsUrl);
234
- await new Promise((res, rej) => {
235
- ws.onopen = () => res();
236
- ws.onerror = () => rej(/* @__PURE__ */ new Error("devtools socket failed"));
237
- });
238
- let seq = 0;
239
- const pending = /* @__PURE__ */ new Map();
240
- let lastException = "";
241
- const failPending = (why) => {
242
- for (const [id, cb] of pending) {
243
- pending.delete(id);
244
- cb({ error: { message: why } });
245
- }
246
- };
247
- ws.onclose = () => failPending("devtools socket closed");
248
- ws.onerror = () => failPending("devtools socket error");
249
- ws.onmessage = (e) => {
250
- let m;
251
- try {
252
- m = JSON.parse(String(e.data));
253
- } catch {
254
- return;
255
- }
256
- if (m.id && pending.has(m.id)) {
257
- pending.get(m.id)(m);
258
- pending.delete(m.id);
259
- }
260
- if (m.method === "Runtime.exceptionThrown") {
261
- const d = m.params?.exceptionDetails;
262
- lastException = String(d?.exception?.description ?? d?.text ?? "").split("\n")[0];
263
- }
264
- };
265
- const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
266
- const id = ++seq;
267
- pending.set(id, (m) => m.error ? rej(/* @__PURE__ */ new Error(`${method}: ${m.error.message}`)) : res(m.result));
268
- try {
269
- ws.send(JSON.stringify({
270
- id,
271
- method,
272
- params,
273
- sessionId
274
- }));
275
- } catch (e) {
276
- pending.delete(id);
277
- rej(e);
278
- }
279
- });
280
- const sendD = (method, params, ms = 1500) => Promise.race([send(method, params, sessionId), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error(`${method} timed out`)), ms))]);
281
- const hardBy = Date.now() + timeoutMs + 15e3;
282
- watchdog = setTimeout(() => {
283
- try {
284
- chrome.kill("SIGKILL");
285
- } catch {}
286
- }, timeoutMs + 15e3);
287
- const RESERVE = 21e3;
288
- const discretionary = () => hardBy - RESERVE - Date.now();
289
- const { targetId } = await send("Target.createTarget", { url: "about:blank" });
290
- const { sessionId } = await send("Target.attachToTarget", {
291
- targetId,
292
- flatten: true
293
- });
294
- await send("Emulation.setDeviceMetricsOverride", {
295
- width,
296
- height,
297
- deviceScaleFactor: scale,
298
- mobile: false
299
- }, sessionId);
300
- await send("Page.enable", {}, sessionId);
301
- await send("Runtime.enable", {}, sessionId);
302
- const nav = await send("Page.navigate", { url }, sessionId);
303
- if (nav?.errorText && nav.errorText !== "net::ERR_ABORTED") return {
304
- ok: false,
305
- error: `could not load the frame (${nav.errorText}) - is the dev server reachable at ${new URL(url).origin}?`
306
- };
307
- const t0 = Date.now();
308
- let ready = false;
309
- while (Date.now() - t0 < timeoutMs) {
310
- if ((await send("Runtime.evaluate", {
311
- expression: `(() => { const el = document.getElementById('root') ?? document.body; return !!el && el.childElementCount > 0 && document.readyState !== 'loading' })()`,
312
- returnByValue: true
313
- }, sessionId).catch(() => null))?.result?.value) {
314
- ready = true;
315
- break;
316
- }
317
- await new Promise((r2) => setTimeout(r2, 150));
318
- }
319
- if (!ready) return {
320
- ok: false,
321
- error: `the frame never rendered${lastException ? ` - the page threw: ${lastException}` : " (no exception surfaced - is the dev server reachable from this machine?)"}`
322
- };
323
- const SETTLED = `(() => {
324
- if (typeof window.__mvLodBusy === 'function' && window.__mvLodBusy() > 0) return false
325
- if (typeof window.__mvPosterBusy === 'function' && window.__mvPosterBusy() > 0) return false
326
- const H = window.innerHeight, W = window.innerWidth
327
- for (const im of document.images) {
328
- if (im.complete) continue
329
- const r = im.getBoundingClientRect()
330
- if (r.bottom < 0 || r.top > H || r.right < 0 || r.left > W) continue
331
- return false
332
- }
333
- for (const c of document.querySelectorAll('.mv-chart')) if (!c.querySelector('svg, canvas')) return false
334
- for (const d of document.querySelectorAll('.mv-diagram')) if (!d.querySelector('.mv-diagram-svg svg, .mv-diagram-err')) return false
335
- return true
336
- })()`;
337
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
338
- const settle = async (budgetMs) => {
339
- const total = Math.min(budgetMs, discretionary());
340
- if (total <= 0) return false;
341
- const by = Date.now() + total;
342
- const left = () => Math.max(1, by - Date.now());
343
- await sendD("Runtime.evaluate", {
344
- expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
345
- returnByValue: true
346
- }, left()).catch(() => null);
347
- await sendD("Runtime.evaluate", {
348
- expression: "document.fonts.ready.then(() => true)",
349
- awaitPromise: true,
350
- returnByValue: true
351
- }, left()).catch(() => null);
352
- let ok = false;
353
- while (Date.now() < by) {
354
- if ((await sendD("Runtime.evaluate", {
355
- expression: SETTLED,
356
- returnByValue: true
357
- }, left()).catch(() => null))?.result?.value === true) {
358
- ok = true;
359
- break;
360
- }
361
- await wait(Math.min(100, left()));
362
- }
363
- if (Date.now() < by) await sendD("Runtime.evaluate", {
364
- expression: "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))",
365
- awaitPromise: true,
366
- returnByValue: true
367
- }, Math.min(1e3, left())).catch(() => null);
368
- if (Date.now() < by) await wait(Math.min(ok ? 150 : 250, left()));
369
- return ok;
370
- };
371
- await settle(3e3);
372
- const errEval = await send("Runtime.evaluate", {
373
- expression: "window.__mvFrameError || \"\"",
374
- returnByValue: true
375
- }, sessionId).catch(() => null);
376
- const frameError = typeof errEval?.result?.value === "string" ? errEval.result.value : "";
377
- if (frameError) return {
378
- ok: false,
379
- error: `the frame rendered an error - ${frameError}`
380
- };
381
- let capW = width, capH = height, truncated = false, note = "";
382
- if (fullHeight) {
383
- const measureH = async () => {
384
- const v = (await sendD("Runtime.evaluate", {
385
- expression: `Math.ceil((document.querySelector('.mv-doc')||document.getElementById('root')||document.body).getBoundingClientRect().height)`,
386
- returnByValue: true
387
- }).catch(() => null))?.result?.value;
388
- return typeof v === "number" && Number.isFinite(v) ? v : 0;
389
- };
390
- const lodIdle = async () => {
391
- const v = (await sendD("Runtime.evaluate", {
392
- expression: `(typeof window.__mvLodBusy==='function')?window.__mvLodBusy():0`,
393
- returnByValue: true
394
- }).catch(() => null))?.result?.value;
395
- return typeof v === "number" ? v === 0 : true;
396
- };
397
- let measured = 0;
398
- const growFit = async (dsf, capCss, deadline) => {
399
- let h = Math.max(height, 2e3), prev = -1;
400
- for (let i = 0; i < 8 && Date.now() < deadline; i++) {
401
- await sendD("Emulation.setDeviceMetricsOverride", {
402
- width,
403
- height: Math.min(h, capCss),
404
- deviceScaleFactor: dsf,
405
- mobile: false
406
- }).catch(() => {});
407
- if (Date.now() >= deadline) break;
408
- await sendD("Runtime.evaluate", {
409
- expression: "document.fonts.ready.then(()=>true)",
410
- awaitPromise: true,
411
- returnByValue: true
412
- }).catch(() => null);
413
- if (Date.now() >= deadline) break;
414
- for (let j = 0; j < 10 && Date.now() < deadline && !await lodIdle(); j++) await wait(100);
415
- if (Date.now() >= deadline) break;
416
- await wait(150);
417
- if (Date.now() >= deadline) break;
418
- const m = await measureH();
419
- if (m > 0) measured = m;
420
- const capped = Math.min(h, capCss);
421
- if (capped >= capCss) break;
422
- if (m > 0 && m <= capped && m === prev) break;
423
- prev = m;
424
- h = Math.max(m, capped);
425
- }
426
- };
427
- const settleDeadline = Date.now() + Math.max(0, Math.min(6e3, discretionary()));
428
- const asked = scale;
429
- await growFit(scale, capFor(width, scale), settleDeadline);
430
- if (measured > capFor(width, scale) && scale > 2) {
431
- scale = 2;
432
- await growFit(2, capFor(width, 2), settleDeadline);
433
- }
434
- if (measured > capFor(width, scale) && scale > 1) {
435
- scale = 1;
436
- await growFit(1, capFor(width, 1), settleDeadline);
437
- }
438
- const cap = capFor(width, scale);
439
- capW = width;
440
- if (measured > cap) {
441
- capH = cap;
442
- truncated = true;
443
- note = `frame is ${measured}px tall; captured the top ${cap}px - split it or reduce its height`;
444
- } else capH = clamp(measured || height, 80, cap);
445
- if (scale < asked && !truncated) note = `frame is ${measured}px tall - too tall for ${asked}x, captured at ${scale}x`;
446
- await sendD("Emulation.setDeviceMetricsOverride", {
447
- width: capW,
448
- height: capH,
449
- deviceScaleFactor: scale,
450
- mobile: false
451
- }, 5e3);
452
- await wait(Math.min(300, Math.max(0, discretionary())));
453
- await settle(Math.max(1200, settleDeadline - Date.now()));
454
- const m2 = await measureH();
455
- if (m2 > capH) {
456
- if (m2 <= cap) capH = m2;
457
- else {
458
- capH = cap;
459
- truncated = true;
460
- note = `frame is ${m2}px tall; captured the top ${cap}px - split it or reduce its height`;
461
- }
462
- await sendD("Emulation.setDeviceMetricsOverride", {
463
- width: capW,
464
- height: capH,
465
- deviceScaleFactor: scale,
466
- mobile: false
467
- }, 5e3);
468
- await settle(600);
469
- }
470
- }
471
- let capX = 0, capY = 0;
472
- if (clip) {
473
- const box = (await sendD("Runtime.evaluate", {
474
- 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 } })()`,
475
- returnByValue: true
476
- }, 2e3).catch(() => null))?.result?.value;
477
- if (!box || box.w < 1 || box.h < 1) return {
478
- ok: false,
479
- error: `nothing to capture at "${clip}"`
480
- };
481
- capX = Math.max(0, Math.floor(box.x));
482
- capY = Math.max(0, Math.floor(box.y));
483
- capW = Math.max(1, Math.round(box.w));
484
- capH = Math.max(1, Math.round(box.h));
485
- }
486
- const shot = await sendD("Page.captureScreenshot", {
487
- format: "png",
488
- captureBeyondViewport: true,
489
- clip: {
490
- x: capX,
491
- y: capY,
492
- width: capW,
493
- height: capH,
494
- scale: 1
495
- }
496
- }, 15e3);
497
- writeFileSync(out, Buffer.from(String(shot.data), "base64"));
498
- return {
499
- ok: true,
500
- width: capW,
501
- height: capH,
502
- scale,
503
- ...truncated ? { truncated: true } : {},
504
- ...note ? { note } : {}
505
- };
506
- } catch (err) {
507
- return {
508
- ok: false,
509
- error: err.message
510
- };
511
- } finally {
512
- if (watchdog) clearTimeout(watchdog);
513
- try {
514
- ws?.close();
515
- } catch {}
516
- chrome.kill("SIGKILL");
517
- chrome.once("exit", () => {
518
- try {
519
- rmSync(profile, {
520
- recursive: true,
521
- force: true
522
- });
523
- } catch {}
524
- });
525
- }
526
- }
527
- //#endregion
528
- export { capture, findChrome, shootFrame };