@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.
@@ -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 };