@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
|
@@ -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
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
(
|
|
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
|
package/dist/shot-By1AItpD.mjs
DELETED
|
@@ -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 };
|
package/dist/shot-CwmHO5T4.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-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 };
|