@marver-design/marver 0.16.1 → 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 +25 -0
- package/README.md +2 -2
- package/dist/{build-B4yPgFNF.mjs → build-D_g53Bp2.mjs} +2 -2
- package/dist/cli.mjs +5 -5
- package/dist/{dev-DH2W7Ffw.mjs → dev-LnIISva5.mjs} +7 -2
- package/dist/{plugin-BeBGu3gH.mjs → plugin-D2msH1cj.mjs} +142 -24
- package/dist/{poster-CoyobbGW.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/package.json +1 -1
- package/templates/AGENTS-embedded.md +4 -1
- package/templates/AGENTS-studio.md +4 -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-z-d-zMzf.mjs +0 -528
package/dist/shot-z-d-zMzf.mjs
DELETED
|
@@ -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-B4yPgFNF.mjs");
|
|
99
|
-
const { ensurePoster } = await import("./poster-CoyobbGW.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 };
|