@triscope/mcp 0.4.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/LICENSE +21 -0
- package/README.md +31 -0
- package/bin/triscope-mcp-supervised.mjs +114 -0
- package/bin/triscope-mcp.mjs +11 -0
- package/dist/browser.mjs +348 -0
- package/dist/browser.mjs.map +1 -0
- package/dist/logger.mjs +51 -0
- package/dist/logger.mjs.map +1 -0
- package/dist/refs.mjs +396 -0
- package/dist/refs.mjs.map +1 -0
- package/dist/server.mjs +3125 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +49 -0
- package/src/browser.ts +461 -0
- package/src/logger.ts +94 -0
- package/src/optimize.ts +142 -0
- package/src/refs.ts +468 -0
- package/src/server.ts +2678 -0
- package/src/targets.ts +163 -0
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,3125 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { spawn as spawn2 } from "child_process";
|
|
3
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
5
|
+
import { join as join4 } from "path";
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { PNG as PNG2 } from "pngjs";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
// src/browser.ts
|
|
13
|
+
import { spawn } from "child_process";
|
|
14
|
+
import { existsSync, readdirSync } from "fs";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
var wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
var DEFAULT_CHROME_ARGS = [
|
|
19
|
+
"--enable-unsafe-webgpu",
|
|
20
|
+
"--ignore-gpu-blocklist",
|
|
21
|
+
// Suppress the first-run wizard and the default-browser banner: both
|
|
22
|
+
// can block the new window from rendering or trigger a different code
|
|
23
|
+
// path that ignores --remote-debugging-port until dismissed. Both have
|
|
24
|
+
// been seen to leave Chromium "running" but with no CDP endpoint open,
|
|
25
|
+
// which manifests as a "DevTools endpoint did not become ready" error.
|
|
26
|
+
"--no-first-run",
|
|
27
|
+
"--no-default-browser-check"
|
|
28
|
+
];
|
|
29
|
+
function parseExtraChromeArgs() {
|
|
30
|
+
const raw = process.env.TRISCOPE_CHROME_ARGS ?? "";
|
|
31
|
+
if (!raw.trim()) return [];
|
|
32
|
+
return raw.trim().split(/\s+/).filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
function tailLines(text, maxLines = 24) {
|
|
35
|
+
const lines = text.trim().split(/\r?\n/).filter(Boolean);
|
|
36
|
+
return lines.slice(-maxLines).join("\n");
|
|
37
|
+
}
|
|
38
|
+
async function readDevtoolsPages(port) {
|
|
39
|
+
try {
|
|
40
|
+
const pages = await fetch(`http://127.0.0.1:${port}/json`, {
|
|
41
|
+
signal: AbortSignal.timeout(1e3)
|
|
42
|
+
}).then((r) => r.json());
|
|
43
|
+
return Array.isArray(pages) && pages.length > 0 ? pages : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function cdpClient(ws) {
|
|
49
|
+
const pending = /* @__PURE__ */ new Map();
|
|
50
|
+
let nextId = 0;
|
|
51
|
+
ws.onmessage = (event) => {
|
|
52
|
+
const msg = JSON.parse(event.data);
|
|
53
|
+
if (msg.id && pending.has(msg.id)) {
|
|
54
|
+
pending.get(msg.id)(msg);
|
|
55
|
+
pending.delete(msg.id);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const drain = (reason) => {
|
|
59
|
+
for (const cb of pending.values()) cb({ error: { message: reason } });
|
|
60
|
+
pending.clear();
|
|
61
|
+
};
|
|
62
|
+
ws.onclose = (event) => drain(`CDP WebSocket closed (code ${event?.code ?? "?"}) before the response arrived`);
|
|
63
|
+
ws.onerror = (event) => drain(`CDP WebSocket error (${event?.message ?? "unknown"}) before the response arrived`);
|
|
64
|
+
const call = (method, params = {}) => new Promise((resolve2, reject) => {
|
|
65
|
+
const id = ++nextId;
|
|
66
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
67
|
+
const t = setTimeout(() => {
|
|
68
|
+
pending.delete(id);
|
|
69
|
+
reject(new Error(`CDP timeout for ${method}`));
|
|
70
|
+
}, 2e4);
|
|
71
|
+
pending.set(id, (msg) => {
|
|
72
|
+
clearTimeout(t);
|
|
73
|
+
if (msg.error) reject(new Error(`${method}: ${msg.error.message}`));
|
|
74
|
+
else resolve2(msg);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
return call;
|
|
78
|
+
}
|
|
79
|
+
function inferGraphicalEnv() {
|
|
80
|
+
if (process.platform !== "linux") return process.env;
|
|
81
|
+
const env = { ...process.env };
|
|
82
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
|
|
83
|
+
const runtimeDir = env.XDG_RUNTIME_DIR || (uid !== void 0 ? `/run/user/${uid}` : void 0);
|
|
84
|
+
if (runtimeDir && existsSync(runtimeDir)) {
|
|
85
|
+
env.XDG_RUNTIME_DIR = runtimeDir;
|
|
86
|
+
if (!env.WAYLAND_DISPLAY) {
|
|
87
|
+
try {
|
|
88
|
+
const waylandSocket = readdirSync(runtimeDir).find((name) => /^wayland-\d+$/.test(name));
|
|
89
|
+
if (waylandSocket) env.WAYLAND_DISPLAY = waylandSocket;
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!env.DISPLAY && existsSync("/tmp/.X11-unix/X0")) env.DISPLAY = ":0";
|
|
95
|
+
return env;
|
|
96
|
+
}
|
|
97
|
+
function defaultChromeBinary() {
|
|
98
|
+
if (process.platform === "win32") {
|
|
99
|
+
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
100
|
+
}
|
|
101
|
+
if (process.platform === "darwin") {
|
|
102
|
+
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
103
|
+
}
|
|
104
|
+
return "chromium";
|
|
105
|
+
}
|
|
106
|
+
function createBrowserPool({
|
|
107
|
+
chromeBin = process.env.CHROME_BIN ?? process.env.PUPPETEER_EXECUTABLE_PATH ?? defaultChromeBinary(),
|
|
108
|
+
port = Number(process.env.TRISCOPE_DEBUG_PORT ?? 9230),
|
|
109
|
+
logger: logger2 = void 0
|
|
110
|
+
} = {}) {
|
|
111
|
+
let chrome = null;
|
|
112
|
+
let chromeExit = null;
|
|
113
|
+
let chromeStderr = "";
|
|
114
|
+
let ws = null;
|
|
115
|
+
let call = null;
|
|
116
|
+
let currentUrl = null;
|
|
117
|
+
let lastWarmUrl = null;
|
|
118
|
+
async function connectToPage(initialUrl, pages) {
|
|
119
|
+
const page = pages.find((p) => p.url === initialUrl || p.url?.startsWith(initialUrl)) ?? pages[0];
|
|
120
|
+
ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
121
|
+
await new Promise((res, rej) => {
|
|
122
|
+
ws.onopen = res;
|
|
123
|
+
ws.onerror = (e) => rej(new Error(`ws error: ${e?.message ?? "unknown"}`));
|
|
124
|
+
});
|
|
125
|
+
call = cdpClient(ws);
|
|
126
|
+
await call("Runtime.enable");
|
|
127
|
+
await call("Page.enable");
|
|
128
|
+
currentUrl = page.url ?? initialUrl;
|
|
129
|
+
if (currentUrl !== initialUrl) await navigateIfNeeded(initialUrl);
|
|
130
|
+
}
|
|
131
|
+
function chromeLaunchArgs(profile, initialUrl) {
|
|
132
|
+
return [
|
|
133
|
+
...DEFAULT_CHROME_ARGS,
|
|
134
|
+
...parseExtraChromeArgs(),
|
|
135
|
+
`--user-data-dir=${profile}`,
|
|
136
|
+
`--remote-debugging-port=${port}`,
|
|
137
|
+
"--window-size=1600,900",
|
|
138
|
+
initialUrl
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
async function ensureBrowser(initialUrl) {
|
|
142
|
+
if (chrome && !chrome.killed && ws && ws.readyState === 1) return;
|
|
143
|
+
const existingPages = await readDevtoolsPages(port);
|
|
144
|
+
if (existingPages) {
|
|
145
|
+
logger2?.info("browser", "attaching to existing DevTools endpoint", {
|
|
146
|
+
port,
|
|
147
|
+
pages: existingPages.length
|
|
148
|
+
});
|
|
149
|
+
await connectToPage(initialUrl, existingPages);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const profile = join(
|
|
153
|
+
tmpdir(),
|
|
154
|
+
`triscope-mcp-profile-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
155
|
+
);
|
|
156
|
+
const args = chromeLaunchArgs(profile, initialUrl);
|
|
157
|
+
chromeExit = null;
|
|
158
|
+
chromeStderr = "";
|
|
159
|
+
const browserEnv = inferGraphicalEnv();
|
|
160
|
+
logger2?.info("browser", "spawning chromium", {
|
|
161
|
+
chromeBin,
|
|
162
|
+
port,
|
|
163
|
+
profile,
|
|
164
|
+
args,
|
|
165
|
+
env: {
|
|
166
|
+
DISPLAY: browserEnv.DISPLAY,
|
|
167
|
+
WAYLAND_DISPLAY: browserEnv.WAYLAND_DISPLAY,
|
|
168
|
+
XDG_RUNTIME_DIR: browserEnv.XDG_RUNTIME_DIR
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
chrome = spawn(chromeBin, args, { stdio: ["ignore", "ignore", "pipe"], env: browserEnv });
|
|
172
|
+
chrome.stderr.on("data", (d) => {
|
|
173
|
+
chromeStderr += String(d);
|
|
174
|
+
if (chromeStderr.length > 12e3) chromeStderr = chromeStderr.slice(-12e3);
|
|
175
|
+
});
|
|
176
|
+
chrome.on("exit", (code, signal) => {
|
|
177
|
+
chromeExit = { code, signal };
|
|
178
|
+
logger2?.warn("browser", "chromium exited", { code, signal, stderr: tailLines(chromeStderr) });
|
|
179
|
+
});
|
|
180
|
+
chrome.on("error", (err) => {
|
|
181
|
+
chromeExit = { code: 1, signal: null };
|
|
182
|
+
chromeStderr += `
|
|
183
|
+
spawn error: ${err?.message ?? String(err)}`;
|
|
184
|
+
logger2?.error("browser", "chromium spawn error", {
|
|
185
|
+
message: err?.message ?? String(err)
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
let pages = null;
|
|
189
|
+
const start = Date.now();
|
|
190
|
+
while (Date.now() - start < 1e4) {
|
|
191
|
+
pages = await readDevtoolsPages(port);
|
|
192
|
+
if (pages) break;
|
|
193
|
+
if (chromeExit) break;
|
|
194
|
+
await wait(250);
|
|
195
|
+
}
|
|
196
|
+
if (!pages) {
|
|
197
|
+
const stderr = tailLines(chromeStderr);
|
|
198
|
+
const exit = chromeExit ? ` chromiumExit=${JSON.stringify(chromeExit)}` : "";
|
|
199
|
+
const silentExit = !chromeExit && !pages;
|
|
200
|
+
const hint = silentExit ? "Chromium process is alive but DevTools never opened \u2014 usually the host sandbox is blocking the bind on TRISCOPE_DEBUG_PORT, or another Chromium instance is reusing the same profile dir. Workarounds: (1) pre-launch Chrome yourself with --remote-debugging-port=" + port + " --enable-unsafe-webgpu \u2014 the MCP server auto-attaches to an existing endpoint when present; (2) set TRISCOPE_DEBUG_PORT to a port the sandbox permits." : "If this runs under Codex/Claude and headed launch still fails, pre-launch Chrome with --remote-debugging-port=" + port + " or set TRISCOPE_CHROME_ARGS=--headless=new for non-interactive capture.";
|
|
201
|
+
throw new Error(
|
|
202
|
+
`DevTools endpoint did not become ready on 127.0.0.1:${port}.${exit}${stderr ? `
|
|
203
|
+
stderr:
|
|
204
|
+
${stderr}` : ""}
|
|
205
|
+
${hint}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
await connectToPage(initialUrl, pages);
|
|
209
|
+
}
|
|
210
|
+
function harnessNotMountedError(url) {
|
|
211
|
+
return new Error(
|
|
212
|
+
`window.__TRISCOPE__ did not mount within 10s on ${url}. Common causes: (1) the page never loaded \u2014 confirm the dev server is up and the URL is right (open it in a real browser tab); (2) WebGPU init failed \u2014 Linux Chrome needs --enable-unsafe-webgpu and either xvfb or a real display; (3) runLab() threw before mounting \u2014 check the #boot overlay text or the page console; (4) the lab page doesn't call runLab() at all \u2014 verify its entry script imports @triscope/core.`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
async function mountErrorOrTimeout(url) {
|
|
216
|
+
try {
|
|
217
|
+
const r = await call("Runtime.evaluate", {
|
|
218
|
+
expression: "JSON.stringify(window.__TRISCOPE_MOUNT_ERROR__ ?? null)",
|
|
219
|
+
returnByValue: true
|
|
220
|
+
});
|
|
221
|
+
const me = JSON.parse(r.result.result.value);
|
|
222
|
+
if (me?.message) {
|
|
223
|
+
const probs = me.problems?.length ? `
|
|
224
|
+
Contract problems:
|
|
225
|
+
- ${me.problems.join("\n - ")}` : "";
|
|
226
|
+
return new Error(`element "${me.element ?? "?"}" failed to mount: ${me.message}${probs}`);
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
return harnessNotMountedError(url);
|
|
231
|
+
}
|
|
232
|
+
async function navigateIfNeeded(url) {
|
|
233
|
+
if (url === currentUrl) return;
|
|
234
|
+
await call("Page.navigate", { url });
|
|
235
|
+
lastWarmUrl = null;
|
|
236
|
+
const start = Date.now();
|
|
237
|
+
while (Date.now() - start < 1e4) {
|
|
238
|
+
try {
|
|
239
|
+
const probe = await call("Runtime.evaluate", {
|
|
240
|
+
expression: '!!window.__TRISCOPE__ && (Object.keys(window.__TRISCOPE__.cameras || {}).length > 0 || typeof window.__TRISCOPE__.availableElements === "function")',
|
|
241
|
+
returnByValue: true
|
|
242
|
+
});
|
|
243
|
+
if (probe.result.result.value) {
|
|
244
|
+
currentUrl = url;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
await wait(200);
|
|
250
|
+
}
|
|
251
|
+
throw await mountErrorOrTimeout(url);
|
|
252
|
+
}
|
|
253
|
+
async function waitForHarness() {
|
|
254
|
+
for (let i = 0; i < 40; i++) {
|
|
255
|
+
try {
|
|
256
|
+
const probe = await call("Runtime.evaluate", {
|
|
257
|
+
expression: '!!window.__TRISCOPE__ && (Object.keys(window.__TRISCOPE__.cameras || {}).length > 0 || typeof window.__TRISCOPE__.availableElements === "function")',
|
|
258
|
+
returnByValue: true
|
|
259
|
+
});
|
|
260
|
+
if (probe.result.result.value) return;
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
await wait(250);
|
|
264
|
+
}
|
|
265
|
+
throw await mountErrorOrTimeout(currentUrl ?? "(initial page)");
|
|
266
|
+
}
|
|
267
|
+
async function waitForWarmFrame() {
|
|
268
|
+
if (currentUrl && currentUrl === lastWarmUrl) return;
|
|
269
|
+
for (let i = 0; i < 20; i++) {
|
|
270
|
+
try {
|
|
271
|
+
const probe = await call("Runtime.evaluate", {
|
|
272
|
+
expression: '(function(){var t=window.__TRISCOPE__;if(!t)return 0;if(typeof t.framesRendered==="function")return t.framesRendered();try{return (t.sampleTelemetry&&t.sampleTelemetry().perf&&t.sampleTelemetry().perf.fps>0)?2:0;}catch(e){return 0;}})()',
|
|
273
|
+
returnByValue: true
|
|
274
|
+
});
|
|
275
|
+
if ((probe.result.result.value ?? 0) >= 2) {
|
|
276
|
+
lastWarmUrl = currentUrl;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
}
|
|
281
|
+
await wait(100);
|
|
282
|
+
}
|
|
283
|
+
lastWarmUrl = currentUrl;
|
|
284
|
+
}
|
|
285
|
+
async function isAlive() {
|
|
286
|
+
if (!chrome || chrome.killed || !ws || ws.readyState !== 1) return false;
|
|
287
|
+
try {
|
|
288
|
+
await Promise.race([
|
|
289
|
+
call("Runtime.evaluate", { expression: "1", returnByValue: true }),
|
|
290
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("alive probe timeout")), 1500))
|
|
291
|
+
]);
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function disposeQuiet() {
|
|
298
|
+
try {
|
|
299
|
+
ws?.close();
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
if (chrome && !chrome.killed) chrome.kill();
|
|
304
|
+
} catch {
|
|
305
|
+
}
|
|
306
|
+
ws = null;
|
|
307
|
+
chrome = null;
|
|
308
|
+
call = null;
|
|
309
|
+
currentUrl = null;
|
|
310
|
+
lastWarmUrl = null;
|
|
311
|
+
chromeExit = null;
|
|
312
|
+
chromeStderr = "";
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
/** Lazy-spawn Chromium (first call) or reuse it (subsequent). Always
|
|
316
|
+
* guarantees the page is sitting on `url` with the harness mounted.
|
|
317
|
+
* Self-heals: if the previous Chromium died externally (manual kill,
|
|
318
|
+
* crash) the next call disposes the stale state and respawns. */
|
|
319
|
+
async getPage(url, opts = {}) {
|
|
320
|
+
const reload = opts?.reload === true;
|
|
321
|
+
if (chrome && !await isAlive()) {
|
|
322
|
+
disposeQuiet();
|
|
323
|
+
}
|
|
324
|
+
if (!chrome) {
|
|
325
|
+
await ensureBrowser(url);
|
|
326
|
+
await waitForHarness();
|
|
327
|
+
lastWarmUrl = null;
|
|
328
|
+
} else if (reload && url === currentUrl) {
|
|
329
|
+
try {
|
|
330
|
+
await call("Runtime.evaluate", {
|
|
331
|
+
expression: "try { window.__TRISCOPE__ = undefined; } catch (e) {}",
|
|
332
|
+
returnByValue: true
|
|
333
|
+
});
|
|
334
|
+
} catch {
|
|
335
|
+
}
|
|
336
|
+
lastWarmUrl = null;
|
|
337
|
+
await call("Page.reload", { ignoreCache: true });
|
|
338
|
+
await waitForHarness();
|
|
339
|
+
} else {
|
|
340
|
+
await navigateIfNeeded(url);
|
|
341
|
+
}
|
|
342
|
+
await waitForWarmFrame();
|
|
343
|
+
return { call };
|
|
344
|
+
},
|
|
345
|
+
/** Synchronous teardown. Safe to call multiple times. */
|
|
346
|
+
dispose() {
|
|
347
|
+
disposeQuiet();
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/logger.ts
|
|
353
|
+
import { appendFileSync, existsSync as existsSync2, renameSync, statSync, unlinkSync } from "fs";
|
|
354
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
355
|
+
import { join as join2 } from "path";
|
|
356
|
+
var MAX_LOG_BYTES = 1024 * 1024;
|
|
357
|
+
function createLogger(project) {
|
|
358
|
+
const logPath = join2(tmpdir2(), `${project}-mcp.log`);
|
|
359
|
+
function rotateIfNeeded() {
|
|
360
|
+
try {
|
|
361
|
+
if (!existsSync2(logPath)) return;
|
|
362
|
+
const size = statSync(logPath).size;
|
|
363
|
+
if (size < MAX_LOG_BYTES) return;
|
|
364
|
+
const rolled = `${logPath}.1`;
|
|
365
|
+
if (existsSync2(rolled)) {
|
|
366
|
+
try {
|
|
367
|
+
unlinkSync(rolled);
|
|
368
|
+
} catch {
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
renameSync(logPath, rolled);
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function write(entry) {
|
|
376
|
+
const tag = `[triscope-mcp:${entry.level}:${entry.scope}]`;
|
|
377
|
+
if (entry.level === "error" || entry.level === "warn") {
|
|
378
|
+
console.error(tag, entry.msg, entry.meta ?? "");
|
|
379
|
+
} else if (process.env.TRISCOPE_MCP_DEBUG) {
|
|
380
|
+
console.error(tag, entry.msg, entry.meta ?? "");
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
rotateIfNeeded();
|
|
384
|
+
appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
385
|
+
} catch {
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function make(level) {
|
|
389
|
+
return (scope, msg, meta) => write({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, scope, msg, meta });
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
info: make("info"),
|
|
393
|
+
warn: make("warn"),
|
|
394
|
+
error: make("error"),
|
|
395
|
+
debug: make("debug"),
|
|
396
|
+
logPath
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/optimize.ts
|
|
401
|
+
var PHI = (1 + Math.sqrt(5)) / 2;
|
|
402
|
+
var INV_PHI = 1 / PHI;
|
|
403
|
+
async function goldenSectionMax(f, a, b, maxIter = 12, tol = 1e-4) {
|
|
404
|
+
const history = [];
|
|
405
|
+
const cache = /* @__PURE__ */ new Map();
|
|
406
|
+
let budget = maxIter;
|
|
407
|
+
const evalAt = async (x) => {
|
|
408
|
+
const k = x.toFixed(6);
|
|
409
|
+
const hit = cache.get(k);
|
|
410
|
+
if (hit !== void 0) return hit;
|
|
411
|
+
if (budget <= 0) return Number.NEGATIVE_INFINITY;
|
|
412
|
+
budget--;
|
|
413
|
+
const fx = await f(x);
|
|
414
|
+
cache.set(k, fx);
|
|
415
|
+
history.push({ x, fx });
|
|
416
|
+
return fx;
|
|
417
|
+
};
|
|
418
|
+
let lo = a;
|
|
419
|
+
let hi = b;
|
|
420
|
+
let c = hi - (hi - lo) * INV_PHI;
|
|
421
|
+
let d = lo + (hi - lo) * INV_PHI;
|
|
422
|
+
let fc = await evalAt(c);
|
|
423
|
+
let fd = await evalAt(d);
|
|
424
|
+
while (budget > 0 && Math.abs(hi - lo) > tol * (b - a)) {
|
|
425
|
+
if (fc > fd) {
|
|
426
|
+
hi = d;
|
|
427
|
+
d = c;
|
|
428
|
+
fd = fc;
|
|
429
|
+
c = hi - (hi - lo) * INV_PHI;
|
|
430
|
+
fc = await evalAt(c);
|
|
431
|
+
} else {
|
|
432
|
+
lo = c;
|
|
433
|
+
c = d;
|
|
434
|
+
fc = fd;
|
|
435
|
+
d = lo + (hi - lo) * INV_PHI;
|
|
436
|
+
fd = await evalAt(d);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
let best = history[0] ?? { x: (a + b) / 2, fx: Number.NEGATIVE_INFINITY };
|
|
440
|
+
for (const h of history) if (h.fx > best.fx) best = h;
|
|
441
|
+
return { x: best.x, fx: best.fx, history };
|
|
442
|
+
}
|
|
443
|
+
async function coordinateDescent(opts) {
|
|
444
|
+
const { knobs, evalAt } = opts;
|
|
445
|
+
const maxCycles = opts.maxCycles ?? 3;
|
|
446
|
+
const perKnobIters = opts.perKnobIters ?? 12;
|
|
447
|
+
const maxEvaluations = opts.maxEvaluations ?? Number.POSITIVE_INFINITY;
|
|
448
|
+
const tol = opts.tol ?? 1e-3;
|
|
449
|
+
const current = {};
|
|
450
|
+
for (const k of knobs) current[k.key] = k.start;
|
|
451
|
+
const history = [];
|
|
452
|
+
let evaluations = 0;
|
|
453
|
+
let bestScore = await evalAt({ ...current });
|
|
454
|
+
evaluations++;
|
|
455
|
+
let cycles = 0;
|
|
456
|
+
for (let cycle = 0; cycle < maxCycles; cycle++) {
|
|
457
|
+
cycles = cycle + 1;
|
|
458
|
+
const cycleStart = bestScore;
|
|
459
|
+
for (const k of knobs) {
|
|
460
|
+
if (evaluations >= maxEvaluations)
|
|
461
|
+
return { best: current, bestScore, cycles, evaluations, history };
|
|
462
|
+
const f = async (x) => {
|
|
463
|
+
if (evaluations >= maxEvaluations) return Number.NEGATIVE_INFINITY;
|
|
464
|
+
evaluations++;
|
|
465
|
+
const score = await evalAt({ ...current, [k.key]: x });
|
|
466
|
+
history.push({ cycle, knob: k.key, value: x, score });
|
|
467
|
+
return score;
|
|
468
|
+
};
|
|
469
|
+
const r = await goldenSectionMax(f, k.min, k.max, perKnobIters);
|
|
470
|
+
if (r.fx >= bestScore) {
|
|
471
|
+
current[k.key] = r.x;
|
|
472
|
+
bestScore = r.fx;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (bestScore - cycleStart < tol) break;
|
|
476
|
+
}
|
|
477
|
+
return { best: current, bestScore, cycles, evaluations, history };
|
|
478
|
+
}
|
|
479
|
+
function knobMatches(requested, applied, tol = 1e-4) {
|
|
480
|
+
if (typeof requested === "number" && typeof applied === "number") {
|
|
481
|
+
return Math.abs(requested - applied) <= tol;
|
|
482
|
+
}
|
|
483
|
+
if (typeof requested === "string" && typeof applied === "string") {
|
|
484
|
+
return requested.toLowerCase() === applied.toLowerCase();
|
|
485
|
+
}
|
|
486
|
+
return requested === applied;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/refs.ts
|
|
490
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
491
|
+
import { dirname, join as join3, resolve } from "path";
|
|
492
|
+
import { PNG } from "pngjs";
|
|
493
|
+
var stripPrefix = (s) => s.replace(/^data:image\/png;base64,/, "");
|
|
494
|
+
function refsRoot(cwd) {
|
|
495
|
+
return resolve(cwd, "refs");
|
|
496
|
+
}
|
|
497
|
+
function refsPath(cwd, element, camera) {
|
|
498
|
+
const safeCam = String(camera).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
499
|
+
return join3(refsRoot(cwd), element, `${safeCam}.png`);
|
|
500
|
+
}
|
|
501
|
+
function refsMotionPaths(cwd, element, camera) {
|
|
502
|
+
const safeCam = String(camera).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
503
|
+
const base = join3(refsRoot(cwd), element);
|
|
504
|
+
return {
|
|
505
|
+
filmstrip: join3(base, `${safeCam}.motion.png`),
|
|
506
|
+
meta: join3(base, `${safeCam}.motion.json`)
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function setReference({
|
|
510
|
+
cwd,
|
|
511
|
+
element,
|
|
512
|
+
camera,
|
|
513
|
+
path,
|
|
514
|
+
base64
|
|
515
|
+
}) {
|
|
516
|
+
if (!element || !camera) throw new Error("element and camera are required");
|
|
517
|
+
let bytes;
|
|
518
|
+
if (path) {
|
|
519
|
+
if (!existsSync3(path)) throw new Error(`reference file not found: ${path}`);
|
|
520
|
+
bytes = readFileSync(path);
|
|
521
|
+
} else if (base64) {
|
|
522
|
+
bytes = Buffer.from(stripPrefix(base64), "base64");
|
|
523
|
+
} else {
|
|
524
|
+
throw new Error("provide either path or base64");
|
|
525
|
+
}
|
|
526
|
+
const dest = refsPath(cwd, element, camera);
|
|
527
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
528
|
+
writeFileSync(dest, bytes);
|
|
529
|
+
return { path: dest, bytes: bytes.length };
|
|
530
|
+
}
|
|
531
|
+
function decodePng(buffer) {
|
|
532
|
+
return PNG.sync.read(buffer);
|
|
533
|
+
}
|
|
534
|
+
function nearestNeighborResize(src, targetW, targetH) {
|
|
535
|
+
if (src.width === targetW && src.height === targetH) return src;
|
|
536
|
+
const dst = new PNG({ width: targetW, height: targetH });
|
|
537
|
+
for (let y = 0; y < targetH; y++) {
|
|
538
|
+
const sy = Math.min(src.height - 1, Math.floor(y * src.height / targetH));
|
|
539
|
+
for (let x = 0; x < targetW; x++) {
|
|
540
|
+
const sx = Math.min(src.width - 1, Math.floor(x * src.width / targetW));
|
|
541
|
+
const si = (sy * src.width + sx) * 4;
|
|
542
|
+
const di = (y * targetW + x) * 4;
|
|
543
|
+
dst.data[di] = src.data[si];
|
|
544
|
+
dst.data[di + 1] = src.data[si + 1];
|
|
545
|
+
dst.data[di + 2] = src.data[si + 2];
|
|
546
|
+
dst.data[di + 3] = 255;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return dst;
|
|
550
|
+
}
|
|
551
|
+
function composeSideBySide(left, right) {
|
|
552
|
+
const h = Math.min(left.height, right.height);
|
|
553
|
+
const sep = 4;
|
|
554
|
+
const lw = Math.round(left.width * h / left.height);
|
|
555
|
+
const rw = Math.round(right.width * h / right.height);
|
|
556
|
+
const w = lw + sep + rw;
|
|
557
|
+
const L = nearestNeighborResize(left, lw, h);
|
|
558
|
+
const R = nearestNeighborResize(right, rw, h);
|
|
559
|
+
const out = new PNG({ width: w, height: h });
|
|
560
|
+
for (let i = 0; i < out.data.length; i += 4) out.data[i + 3] = 255;
|
|
561
|
+
for (let y = 0; y < h; y++) {
|
|
562
|
+
const orow = y * w * 4;
|
|
563
|
+
const lrow = y * lw * 4;
|
|
564
|
+
const rrow = y * rw * 4;
|
|
565
|
+
L.data.copy(out.data, orow, lrow, lrow + lw * 4);
|
|
566
|
+
R.data.copy(out.data, orow + (lw + sep) * 4, rrow, rrow + rw * 4);
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
function meanAbsDiff(a, b) {
|
|
571
|
+
const W = 256;
|
|
572
|
+
const H = 256;
|
|
573
|
+
const A = nearestNeighborResize(a, W, H);
|
|
574
|
+
const B = nearestNeighborResize(b, W, H);
|
|
575
|
+
let sum = 0;
|
|
576
|
+
const pixels = W * H * 3;
|
|
577
|
+
for (let i = 0; i < W * H; i++) {
|
|
578
|
+
const j = i * 4;
|
|
579
|
+
sum += Math.abs(A.data[j] - B.data[j]);
|
|
580
|
+
sum += Math.abs(A.data[j + 1] - B.data[j + 1]);
|
|
581
|
+
sum += Math.abs(A.data[j + 2] - B.data[j + 2]);
|
|
582
|
+
}
|
|
583
|
+
return +(sum / pixels).toFixed(2);
|
|
584
|
+
}
|
|
585
|
+
function ssim(a, b) {
|
|
586
|
+
const W = 256;
|
|
587
|
+
const H = 256;
|
|
588
|
+
const A = nearestNeighborResize(a, W, H);
|
|
589
|
+
const B = nearestNeighborResize(b, W, H);
|
|
590
|
+
const lumA = new Float32Array(W * H);
|
|
591
|
+
const lumB = new Float32Array(W * H);
|
|
592
|
+
for (let i = 0; i < W * H; i++) {
|
|
593
|
+
const j = i * 4;
|
|
594
|
+
lumA[i] = 0.2126 * A.data[j] + 0.7152 * A.data[j + 1] + 0.0722 * A.data[j + 2];
|
|
595
|
+
lumB[i] = 0.2126 * B.data[j] + 0.7152 * B.data[j + 1] + 0.0722 * B.data[j + 2];
|
|
596
|
+
}
|
|
597
|
+
const L = 255;
|
|
598
|
+
const C1 = (0.01 * L) ** 2;
|
|
599
|
+
const C2 = (0.03 * L) ** 2;
|
|
600
|
+
const WIN = 8;
|
|
601
|
+
let total = 0;
|
|
602
|
+
let count = 0;
|
|
603
|
+
for (let wy = 0; wy < H; wy += WIN) {
|
|
604
|
+
for (let wx = 0; wx < W; wx += WIN) {
|
|
605
|
+
let muA = 0, muB = 0;
|
|
606
|
+
for (let dy = 0; dy < WIN; dy++)
|
|
607
|
+
for (let dx = 0; dx < WIN; dx++) {
|
|
608
|
+
const i = (wy + dy) * W + (wx + dx);
|
|
609
|
+
muA += lumA[i];
|
|
610
|
+
muB += lumB[i];
|
|
611
|
+
}
|
|
612
|
+
muA /= WIN * WIN;
|
|
613
|
+
muB /= WIN * WIN;
|
|
614
|
+
let varA = 0, varB = 0, covAB = 0;
|
|
615
|
+
for (let dy = 0; dy < WIN; dy++)
|
|
616
|
+
for (let dx = 0; dx < WIN; dx++) {
|
|
617
|
+
const i = (wy + dy) * W + (wx + dx);
|
|
618
|
+
const da = lumA[i] - muA;
|
|
619
|
+
const db = lumB[i] - muB;
|
|
620
|
+
varA += da * da;
|
|
621
|
+
varB += db * db;
|
|
622
|
+
covAB += da * db;
|
|
623
|
+
}
|
|
624
|
+
varA /= WIN * WIN - 1;
|
|
625
|
+
varB /= WIN * WIN - 1;
|
|
626
|
+
covAB /= WIN * WIN - 1;
|
|
627
|
+
const num = (2 * muA * muB + C1) * (2 * covAB + C2);
|
|
628
|
+
const den = (muA * muA + muB * muB + C1) * (varA + varB + C2);
|
|
629
|
+
total += num / den;
|
|
630
|
+
count += 1;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return +(total / count).toFixed(4);
|
|
634
|
+
}
|
|
635
|
+
function ssimTileGrid(a, b, gridSize = 8) {
|
|
636
|
+
const W = 256;
|
|
637
|
+
const H = 256;
|
|
638
|
+
const A = nearestNeighborResize(a, W, H);
|
|
639
|
+
const B = nearestNeighborResize(b, W, H);
|
|
640
|
+
const lumA = new Float32Array(W * H);
|
|
641
|
+
const lumB = new Float32Array(W * H);
|
|
642
|
+
for (let i = 0; i < W * H; i++) {
|
|
643
|
+
const j = i * 4;
|
|
644
|
+
lumA[i] = 0.2126 * A.data[j] + 0.7152 * A.data[j + 1] + 0.0722 * A.data[j + 2];
|
|
645
|
+
lumB[i] = 0.2126 * B.data[j] + 0.7152 * B.data[j + 1] + 0.0722 * B.data[j + 2];
|
|
646
|
+
}
|
|
647
|
+
const L = 255;
|
|
648
|
+
const C1 = (0.01 * L) ** 2;
|
|
649
|
+
const C2 = (0.03 * L) ** 2;
|
|
650
|
+
const tile = Math.floor(W / gridSize);
|
|
651
|
+
const grid = [];
|
|
652
|
+
let worst = { row: 0, col: 0, ssim: Number.POSITIVE_INFINITY };
|
|
653
|
+
let min = Number.POSITIVE_INFINITY;
|
|
654
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
655
|
+
let sum = 0;
|
|
656
|
+
let count = 0;
|
|
657
|
+
for (let r = 0; r < gridSize; r++) {
|
|
658
|
+
const rowArr = [];
|
|
659
|
+
for (let c = 0; c < gridSize; c++) {
|
|
660
|
+
const x0 = c * tile;
|
|
661
|
+
const y0 = r * tile;
|
|
662
|
+
const n = tile * tile;
|
|
663
|
+
let muA = 0;
|
|
664
|
+
let muB = 0;
|
|
665
|
+
for (let dy = 0; dy < tile; dy++)
|
|
666
|
+
for (let dx = 0; dx < tile; dx++) {
|
|
667
|
+
const i = (y0 + dy) * W + (x0 + dx);
|
|
668
|
+
muA += lumA[i];
|
|
669
|
+
muB += lumB[i];
|
|
670
|
+
}
|
|
671
|
+
muA /= n;
|
|
672
|
+
muB /= n;
|
|
673
|
+
let vA = 0;
|
|
674
|
+
let vB = 0;
|
|
675
|
+
let cov = 0;
|
|
676
|
+
for (let dy = 0; dy < tile; dy++)
|
|
677
|
+
for (let dx = 0; dx < tile; dx++) {
|
|
678
|
+
const i = (y0 + dy) * W + (x0 + dx);
|
|
679
|
+
const da = lumA[i] - muA;
|
|
680
|
+
const db = lumB[i] - muB;
|
|
681
|
+
vA += da * da;
|
|
682
|
+
vB += db * db;
|
|
683
|
+
cov += da * db;
|
|
684
|
+
}
|
|
685
|
+
vA /= n - 1;
|
|
686
|
+
vB /= n - 1;
|
|
687
|
+
cov /= n - 1;
|
|
688
|
+
const s = (2 * muA * muB + C1) * (2 * cov + C2) / ((muA * muA + muB * muB + C1) * (vA + vB + C2));
|
|
689
|
+
const sr = +s.toFixed(4);
|
|
690
|
+
rowArr.push(sr);
|
|
691
|
+
sum += sr;
|
|
692
|
+
count += 1;
|
|
693
|
+
if (sr < min) min = sr;
|
|
694
|
+
if (sr > max) max = sr;
|
|
695
|
+
if (sr < worst.ssim) worst = { row: r, col: c, ssim: sr };
|
|
696
|
+
}
|
|
697
|
+
grid.push(rowArr);
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
grid,
|
|
701
|
+
worst,
|
|
702
|
+
min: +min.toFixed(4),
|
|
703
|
+
max: +max.toFixed(4),
|
|
704
|
+
mean: +(sum / count).toFixed(4),
|
|
705
|
+
gridSize
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function heatColor(t) {
|
|
709
|
+
const x = Math.max(0, Math.min(1, t));
|
|
710
|
+
const stops = [
|
|
711
|
+
[0, [0, 0, 0]],
|
|
712
|
+
[0.33, [0, 0, 255]],
|
|
713
|
+
[0.66, [255, 255, 0]],
|
|
714
|
+
[1, [255, 0, 0]]
|
|
715
|
+
];
|
|
716
|
+
for (let i = 1; i < stops.length; i++) {
|
|
717
|
+
if (x <= stops[i][0]) {
|
|
718
|
+
const [t0, c0] = stops[i - 1];
|
|
719
|
+
const [t1, c1] = stops[i];
|
|
720
|
+
const f = (x - t0) / (t1 - t0 || 1);
|
|
721
|
+
return [
|
|
722
|
+
Math.round(c0[0] + f * (c1[0] - c0[0])),
|
|
723
|
+
Math.round(c0[1] + f * (c1[1] - c0[1])),
|
|
724
|
+
Math.round(c0[2] + f * (c1[2] - c0[2]))
|
|
725
|
+
];
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return [255, 0, 0];
|
|
729
|
+
}
|
|
730
|
+
function composeDiffHeatmap(a, b, size = 256) {
|
|
731
|
+
const A = nearestNeighborResize(a, size, size);
|
|
732
|
+
const B = nearestNeighborResize(b, size, size);
|
|
733
|
+
const out = new PNG({ width: size, height: size });
|
|
734
|
+
for (let p = 0; p < size * size; p++) {
|
|
735
|
+
const j = p * 4;
|
|
736
|
+
const d = (Math.abs(A.data[j] - B.data[j]) + Math.abs(A.data[j + 1] - B.data[j + 1]) + Math.abs(A.data[j + 2] - B.data[j + 2])) / (3 * 255);
|
|
737
|
+
const [r, g, bl] = heatColor(d);
|
|
738
|
+
out.data[j] = r;
|
|
739
|
+
out.data[j + 1] = g;
|
|
740
|
+
out.data[j + 2] = bl;
|
|
741
|
+
out.data[j + 3] = 255;
|
|
742
|
+
}
|
|
743
|
+
return PNG.sync.write(out);
|
|
744
|
+
}
|
|
745
|
+
function composeFilmstrip(frameBase64s, opts = {}) {
|
|
746
|
+
if (!Array.isArray(frameBase64s) || frameBase64s.length === 0) {
|
|
747
|
+
throw new Error("composeFilmstrip: no frames");
|
|
748
|
+
}
|
|
749
|
+
const sep = opts.sep ?? 2;
|
|
750
|
+
const frames = frameBase64s.map((b) => decodePng(Buffer.from(stripPrefix(b), "base64")));
|
|
751
|
+
const h = Math.min(...frames.map((f) => f.height));
|
|
752
|
+
const resized = frames.map(
|
|
753
|
+
(f) => nearestNeighborResize(f, Math.round(f.width * h / f.height), h)
|
|
754
|
+
);
|
|
755
|
+
const totalW = resized.reduce((acc, f, i) => acc + f.width + (i > 0 ? sep : 0), 0);
|
|
756
|
+
const out = new PNG({ width: totalW, height: h });
|
|
757
|
+
for (let i = 0; i < out.data.length; i += 4) out.data[i + 3] = 255;
|
|
758
|
+
let x = 0;
|
|
759
|
+
for (let f = 0; f < resized.length; f++) {
|
|
760
|
+
const img = resized[f];
|
|
761
|
+
for (let y = 0; y < h; y++) {
|
|
762
|
+
const srcRow = y * img.width * 4;
|
|
763
|
+
const dstRow = (y * totalW + x) * 4;
|
|
764
|
+
img.data.copy(out.data, dstRow, srcRow, srcRow + img.width * 4);
|
|
765
|
+
}
|
|
766
|
+
x += img.width + sep;
|
|
767
|
+
}
|
|
768
|
+
return PNG.sync.write(out);
|
|
769
|
+
}
|
|
770
|
+
function motionMagnitudeFromFrames(frameBase64s) {
|
|
771
|
+
if (!Array.isArray(frameBase64s) || frameBase64s.length < 2) return 0;
|
|
772
|
+
const decoded = frameBase64s.map((b) => decodePng(Buffer.from(stripPrefix(b), "base64")));
|
|
773
|
+
let total = 0;
|
|
774
|
+
for (let i = 1; i < decoded.length; i++) {
|
|
775
|
+
total += meanAbsDiff(decoded[i - 1], decoded[i]);
|
|
776
|
+
}
|
|
777
|
+
return +(total / (decoded.length - 1)).toFixed(2);
|
|
778
|
+
}
|
|
779
|
+
function setReferenceMotion({ cwd, element, camera, frameBase64s, meta }) {
|
|
780
|
+
if (!Array.isArray(frameBase64s) || frameBase64s.length < 2) {
|
|
781
|
+
throw new Error("setReferenceMotion: need at least 2 frames");
|
|
782
|
+
}
|
|
783
|
+
const filmstrip = composeFilmstrip(frameBase64s);
|
|
784
|
+
const { filmstrip: fpath, meta: mpath } = refsMotionPaths(cwd, element, camera);
|
|
785
|
+
mkdirSync(dirname(fpath), { recursive: true });
|
|
786
|
+
writeFileSync(fpath, filmstrip);
|
|
787
|
+
writeFileSync(
|
|
788
|
+
mpath,
|
|
789
|
+
JSON.stringify(
|
|
790
|
+
{
|
|
791
|
+
frames: frameBase64s.length,
|
|
792
|
+
...meta,
|
|
793
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
794
|
+
},
|
|
795
|
+
null,
|
|
796
|
+
2
|
|
797
|
+
)
|
|
798
|
+
);
|
|
799
|
+
return { filmstripPath: fpath, metaPath: mpath, frames: frameBase64s.length };
|
|
800
|
+
}
|
|
801
|
+
function diffReferenceMotion({ cwd, element, camera, currentFrames }) {
|
|
802
|
+
const { filmstrip: fpath, meta: mpath } = refsMotionPaths(cwd, element, camera);
|
|
803
|
+
if (!existsSync3(fpath)) {
|
|
804
|
+
throw new Error(`no motion reference at ${fpath} \u2014 call set_reference_motion first`);
|
|
805
|
+
}
|
|
806
|
+
if (!Array.isArray(currentFrames) || currentFrames.length === 0) {
|
|
807
|
+
throw new Error("currentFrames must be a non-empty array of base64 PNGs");
|
|
808
|
+
}
|
|
809
|
+
const refFilmstrip = decodePng(readFileSync(fpath));
|
|
810
|
+
const curFilmstrip = decodePng(composeFilmstrip(currentFrames));
|
|
811
|
+
const h = Math.min(refFilmstrip.height, curFilmstrip.height);
|
|
812
|
+
const lw = Math.round(refFilmstrip.width * h / refFilmstrip.height);
|
|
813
|
+
const rw = Math.round(curFilmstrip.width * h / curFilmstrip.height);
|
|
814
|
+
const w = Math.max(lw, rw);
|
|
815
|
+
const sep = 4;
|
|
816
|
+
const composite = new PNG({ width: w, height: h * 2 + sep });
|
|
817
|
+
for (let i = 0; i < composite.data.length; i += 4) composite.data[i + 3] = 255;
|
|
818
|
+
const refResized = nearestNeighborResize(refFilmstrip, w, h);
|
|
819
|
+
const curResized = nearestNeighborResize(curFilmstrip, w, h);
|
|
820
|
+
for (let y = 0; y < h; y++) {
|
|
821
|
+
refResized.data.copy(composite.data, y * w * 4, y * w * 4, (y + 1) * w * 4);
|
|
822
|
+
curResized.data.copy(composite.data, (y + h + sep) * w * 4, y * w * 4, (y + 1) * w * 4);
|
|
823
|
+
}
|
|
824
|
+
let meta = null;
|
|
825
|
+
try {
|
|
826
|
+
meta = existsSync3(mpath) ? JSON.parse(readFileSync(mpath, "utf8")) : null;
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
const motionDiff = meanAbsDiff(refFilmstrip, curFilmstrip);
|
|
830
|
+
return {
|
|
831
|
+
refFilmstripPath: fpath,
|
|
832
|
+
refMeta: meta,
|
|
833
|
+
motionDiff,
|
|
834
|
+
compositeBase64: PNG.sync.write(composite).toString("base64")
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function diffReference({ cwd, element, camera, currentBase64 }) {
|
|
838
|
+
const refPath = refsPath(cwd, element, camera);
|
|
839
|
+
if (!existsSync3(refPath)) {
|
|
840
|
+
throw new Error(`no reference at ${refPath} \u2014 call set_reference first`);
|
|
841
|
+
}
|
|
842
|
+
if (!currentBase64) throw new Error("currentBase64 is required");
|
|
843
|
+
const refPng = decodePng(readFileSync(refPath));
|
|
844
|
+
const curPng = decodePng(Buffer.from(stripPrefix(currentBase64), "base64"));
|
|
845
|
+
const composite = composeSideBySide(refPng, curPng);
|
|
846
|
+
const compositeBuf = PNG.sync.write(composite);
|
|
847
|
+
const meanAbs = meanAbsDiff(refPng, curPng);
|
|
848
|
+
const ssimScore = ssim(refPng, curPng);
|
|
849
|
+
const tiles = ssimTileGrid(refPng, curPng);
|
|
850
|
+
const heatmap = composeDiffHeatmap(refPng, curPng);
|
|
851
|
+
return {
|
|
852
|
+
camera,
|
|
853
|
+
refPath,
|
|
854
|
+
meanAbsDiff: meanAbs,
|
|
855
|
+
// 0 = identical, 255 = max difference
|
|
856
|
+
ssim: ssimScore,
|
|
857
|
+
// 1 = identical, lower = more different
|
|
858
|
+
// Spatial localization: 8×8 SSIM map + the single worst tile, so the agent
|
|
859
|
+
// knows WHERE the divergence is without re-reading the composite image.
|
|
860
|
+
tileGrid: tiles.grid,
|
|
861
|
+
worstTile: tiles.worst,
|
|
862
|
+
tileStats: { min: tiles.min, max: tiles.max, mean: tiles.mean },
|
|
863
|
+
compositeBase64: compositeBuf.toString("base64"),
|
|
864
|
+
heatmapBase64: heatmap.toString("base64")
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/targets.ts
|
|
869
|
+
function evaluateTargets(targetsByCamera, capturedByCamera) {
|
|
870
|
+
const results = [];
|
|
871
|
+
for (const [camera, c] of Object.entries(targetsByCamera ?? {})) {
|
|
872
|
+
const cap = capturedByCamera?.[camera];
|
|
873
|
+
if (!cap) {
|
|
874
|
+
results.push({
|
|
875
|
+
camera,
|
|
876
|
+
constraint: "capture",
|
|
877
|
+
op: "present",
|
|
878
|
+
expected: "captured view",
|
|
879
|
+
actual: void 0,
|
|
880
|
+
pass: false,
|
|
881
|
+
skipped: true,
|
|
882
|
+
reason: "camera not captured"
|
|
883
|
+
});
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (typeof c.ssim === "number") {
|
|
887
|
+
if (typeof cap.ssim === "number") {
|
|
888
|
+
results.push({
|
|
889
|
+
camera,
|
|
890
|
+
constraint: "ssim",
|
|
891
|
+
op: ">=",
|
|
892
|
+
expected: c.ssim,
|
|
893
|
+
actual: cap.ssim,
|
|
894
|
+
pass: cap.ssim >= c.ssim
|
|
895
|
+
});
|
|
896
|
+
} else {
|
|
897
|
+
results.push({
|
|
898
|
+
camera,
|
|
899
|
+
constraint: "ssim",
|
|
900
|
+
op: ">=",
|
|
901
|
+
expected: c.ssim,
|
|
902
|
+
actual: void 0,
|
|
903
|
+
pass: true,
|
|
904
|
+
skipped: true,
|
|
905
|
+
reason: "no reference image \u2014 set_reference to enable"
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (typeof c.meanAbsDiff === "number") {
|
|
910
|
+
if (typeof cap.meanAbsDiff === "number") {
|
|
911
|
+
results.push({
|
|
912
|
+
camera,
|
|
913
|
+
constraint: "meanAbsDiff",
|
|
914
|
+
op: "<=",
|
|
915
|
+
expected: c.meanAbsDiff,
|
|
916
|
+
actual: cap.meanAbsDiff,
|
|
917
|
+
pass: cap.meanAbsDiff <= c.meanAbsDiff
|
|
918
|
+
});
|
|
919
|
+
} else {
|
|
920
|
+
results.push({
|
|
921
|
+
camera,
|
|
922
|
+
constraint: "meanAbsDiff",
|
|
923
|
+
op: "<=",
|
|
924
|
+
expected: c.meanAbsDiff,
|
|
925
|
+
actual: void 0,
|
|
926
|
+
pass: true,
|
|
927
|
+
skipped: true,
|
|
928
|
+
reason: "no reference image \u2014 set_reference to enable"
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
pushBand(results, camera, "luminance", c.luminance, cap.luminance);
|
|
933
|
+
pushBand(results, camera, "dynamicRange", c.dynamicRange, cap.dynamicRange);
|
|
934
|
+
}
|
|
935
|
+
const evaluated = results.filter((r) => !r.skipped);
|
|
936
|
+
const passed = evaluated.filter((r) => r.pass).length;
|
|
937
|
+
return {
|
|
938
|
+
checked: evaluated.length,
|
|
939
|
+
passed,
|
|
940
|
+
allPassed: evaluated.every((r) => r.pass),
|
|
941
|
+
results
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
function pushBand(out, camera, constraint, band, actual) {
|
|
945
|
+
if (!band) return;
|
|
946
|
+
if (typeof actual !== "number") {
|
|
947
|
+
out.push({
|
|
948
|
+
camera,
|
|
949
|
+
constraint,
|
|
950
|
+
op: "range",
|
|
951
|
+
expected: band,
|
|
952
|
+
actual: void 0,
|
|
953
|
+
pass: true,
|
|
954
|
+
skipped: true,
|
|
955
|
+
reason: "probe unavailable"
|
|
956
|
+
});
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (typeof band.min === "number") {
|
|
960
|
+
out.push({
|
|
961
|
+
camera,
|
|
962
|
+
constraint: `${constraint}.min`,
|
|
963
|
+
op: ">=",
|
|
964
|
+
expected: band.min,
|
|
965
|
+
actual,
|
|
966
|
+
pass: actual >= band.min
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
if (typeof band.max === "number") {
|
|
970
|
+
out.push({
|
|
971
|
+
camera,
|
|
972
|
+
constraint: `${constraint}.max`,
|
|
973
|
+
op: "<=",
|
|
974
|
+
expected: band.max,
|
|
975
|
+
actual,
|
|
976
|
+
pass: actual <= band.max
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// src/server.ts
|
|
982
|
+
var wait2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
983
|
+
function readProjectName(cwd) {
|
|
984
|
+
if (process.env.TRISCOPE_PROJECT) return process.env.TRISCOPE_PROJECT;
|
|
985
|
+
try {
|
|
986
|
+
const p = join4(cwd, "package.json");
|
|
987
|
+
if (!existsSync4(p)) return "triscope-project";
|
|
988
|
+
const pkg = JSON.parse(readFileSync2(p, "utf8"));
|
|
989
|
+
return String(pkg.name ?? "triscope-project").replace(/[^A-Za-z0-9._-]/g, "-");
|
|
990
|
+
} catch {
|
|
991
|
+
return "triscope-project";
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
var DEV_URL = (process.env.TRISCOPE_URL ?? "http://localhost:5173").replace(/\/$/, "");
|
|
995
|
+
var PROJECT = readProjectName(process.cwd());
|
|
996
|
+
var STATE_PATH = join4(tmpdir3(), `${PROJECT}-state.json`);
|
|
997
|
+
var INLINE_PAYLOAD_BUDGET = Number(process.env.TRISCOPE_INLINE_PAYLOAD_BUDGET ?? 1024 * 1024);
|
|
998
|
+
var SERVER_START_TIME = Date.now();
|
|
999
|
+
var RECENT_ERRORS_CAP = 16;
|
|
1000
|
+
var recentErrors = [];
|
|
1001
|
+
var logger = createLogger(PROJECT);
|
|
1002
|
+
var browserPool = createBrowserPool({ logger });
|
|
1003
|
+
var shutdown = () => browserPool.dispose();
|
|
1004
|
+
process.on("exit", shutdown);
|
|
1005
|
+
process.on("SIGINT", () => {
|
|
1006
|
+
shutdown();
|
|
1007
|
+
process.exit(130);
|
|
1008
|
+
});
|
|
1009
|
+
process.on("SIGTERM", () => {
|
|
1010
|
+
shutdown();
|
|
1011
|
+
process.exit(143);
|
|
1012
|
+
});
|
|
1013
|
+
function recordError(source, err) {
|
|
1014
|
+
const detail = err?.stack ?? err?.message ?? String(err);
|
|
1015
|
+
const msg = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${source}: ${detail}`;
|
|
1016
|
+
logger.error(source, String(err?.message ?? err), { stack: err?.stack });
|
|
1017
|
+
recentErrors.push(msg);
|
|
1018
|
+
if (recentErrors.length > RECENT_ERRORS_CAP) recentErrors.shift();
|
|
1019
|
+
}
|
|
1020
|
+
process.on("uncaughtException", (err) => recordError("uncaughtException", err));
|
|
1021
|
+
process.on("unhandledRejection", (err) => recordError("unhandledRejection", err));
|
|
1022
|
+
function applyPath(data, path) {
|
|
1023
|
+
if (!path) return data;
|
|
1024
|
+
const segs = path.replace(/^\./, "").split(".").filter(Boolean);
|
|
1025
|
+
let cur = data;
|
|
1026
|
+
for (const s of segs) {
|
|
1027
|
+
if (cur == null) return void 0;
|
|
1028
|
+
cur = cur[s];
|
|
1029
|
+
}
|
|
1030
|
+
return cur;
|
|
1031
|
+
}
|
|
1032
|
+
async function fetchManifest(devUrl = DEV_URL) {
|
|
1033
|
+
try {
|
|
1034
|
+
const r = await fetch(`${devUrl}/__manifest`);
|
|
1035
|
+
if (!r.ok) return null;
|
|
1036
|
+
const m = await r.json();
|
|
1037
|
+
return m && typeof m === "object" ? m : null;
|
|
1038
|
+
} catch {
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
var _manifestCache = /* @__PURE__ */ new Map();
|
|
1043
|
+
async function fetchManifestCached(devUrl = DEV_URL, ttlMs = 1e3) {
|
|
1044
|
+
const now = Date.now();
|
|
1045
|
+
const hit = _manifestCache.get(devUrl);
|
|
1046
|
+
if (hit && now - hit.at < ttlMs) return hit.value;
|
|
1047
|
+
const value = await fetchManifest(devUrl);
|
|
1048
|
+
_manifestCache.set(devUrl, { value, at: now });
|
|
1049
|
+
return value;
|
|
1050
|
+
}
|
|
1051
|
+
function resolveDevUrl({ devUrl, labUrl }) {
|
|
1052
|
+
if (devUrl) return devUrl.replace(/\/$/, "");
|
|
1053
|
+
if (labUrl) {
|
|
1054
|
+
try {
|
|
1055
|
+
return new URL(labUrl).origin;
|
|
1056
|
+
} catch {
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return DEV_URL;
|
|
1060
|
+
}
|
|
1061
|
+
async function fetchState(devUrl) {
|
|
1062
|
+
try {
|
|
1063
|
+
const r = await fetch(`${devUrl}/__state`, { signal: AbortSignal.timeout(2e3) });
|
|
1064
|
+
if (!r.ok) return null;
|
|
1065
|
+
const data = await r.json();
|
|
1066
|
+
if (!data || typeof data !== "object" || Object.keys(data).length === 0) return null;
|
|
1067
|
+
const staleMs = typeof data.postedAt === "number" ? Date.now() - data.postedAt : null;
|
|
1068
|
+
return { data, staleMs };
|
|
1069
|
+
} catch {
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
var STALE_MS = Number(process.env.TRISCOPE_STALE_MS ?? 1500);
|
|
1074
|
+
function clampKnobValue(spec, value) {
|
|
1075
|
+
if (!spec || typeof spec !== "object") return { clamped: false, final: value };
|
|
1076
|
+
switch (spec.type) {
|
|
1077
|
+
case "number": {
|
|
1078
|
+
const n = Number(value);
|
|
1079
|
+
if (!Number.isFinite(n)) return { clamped: true, final: spec.default };
|
|
1080
|
+
let next = n;
|
|
1081
|
+
if (typeof spec.step === "number" && spec.step > 0) {
|
|
1082
|
+
next = spec.min + Math.round((next - spec.min) / spec.step) * spec.step;
|
|
1083
|
+
}
|
|
1084
|
+
next = Math.min(spec.max, Math.max(spec.min, next));
|
|
1085
|
+
return { clamped: Math.abs(next - n) >= 1e-9, final: next };
|
|
1086
|
+
}
|
|
1087
|
+
case "int": {
|
|
1088
|
+
const n = Number(value);
|
|
1089
|
+
if (!Number.isFinite(n)) return { clamped: true, final: spec.default };
|
|
1090
|
+
const next = Math.min(spec.max, Math.max(spec.min, Math.round(n)));
|
|
1091
|
+
return { clamped: next !== n, final: next };
|
|
1092
|
+
}
|
|
1093
|
+
case "color":
|
|
1094
|
+
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(String(value)) ? { clamped: false, final: value } : { clamped: true, final: spec.default };
|
|
1095
|
+
case "boolean":
|
|
1096
|
+
return { clamped: typeof value !== "boolean", final: Boolean(value) };
|
|
1097
|
+
default:
|
|
1098
|
+
return { clamped: false, final: value };
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function knobSpecMapFromManifest(manifest) {
|
|
1102
|
+
const out = {};
|
|
1103
|
+
for (const [el, entry] of Object.entries(manifest?.elements ?? {})) {
|
|
1104
|
+
const byKey = {};
|
|
1105
|
+
for (const k of entry?.knobs ?? []) {
|
|
1106
|
+
if (!k?.name) continue;
|
|
1107
|
+
byKey[k.name] = k;
|
|
1108
|
+
const local = el && k.name.startsWith(`${el}.`) ? k.name.slice(el.length + 1) : k.name;
|
|
1109
|
+
if (!(local in byKey)) byKey[local] = k;
|
|
1110
|
+
}
|
|
1111
|
+
out[el] = byKey;
|
|
1112
|
+
}
|
|
1113
|
+
return out;
|
|
1114
|
+
}
|
|
1115
|
+
function readProjectLabMap(cwd) {
|
|
1116
|
+
try {
|
|
1117
|
+
const p = join4(cwd, "package.json");
|
|
1118
|
+
if (!existsSync4(p)) return {};
|
|
1119
|
+
const pkg = JSON.parse(readFileSync2(p, "utf8"));
|
|
1120
|
+
const m = pkg?.triscope?.labs;
|
|
1121
|
+
return m && typeof m === "object" ? m : {};
|
|
1122
|
+
} catch {
|
|
1123
|
+
return {};
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
var PROJECT_LABS = readProjectLabMap(process.cwd());
|
|
1127
|
+
function absolutize(maybePath, base = DEV_URL) {
|
|
1128
|
+
if (!maybePath) return null;
|
|
1129
|
+
if (/^https?:\/\//.test(maybePath)) return maybePath;
|
|
1130
|
+
return `${base}${maybePath.startsWith("/") ? "" : "/"}${maybePath}`;
|
|
1131
|
+
}
|
|
1132
|
+
async function resolveLabUrl({
|
|
1133
|
+
element,
|
|
1134
|
+
labUrl,
|
|
1135
|
+
devUrl
|
|
1136
|
+
}) {
|
|
1137
|
+
if (labUrl) return absolutize(labUrl);
|
|
1138
|
+
const base = resolveDevUrl({ devUrl });
|
|
1139
|
+
if (!element) return base;
|
|
1140
|
+
const manifest = await fetchManifest(base);
|
|
1141
|
+
const entry = manifest?.elements?.[element];
|
|
1142
|
+
if (entry?.labUrl) return absolutize(entry.labUrl, base);
|
|
1143
|
+
if (PROJECT_LABS[element]) return absolutize(PROJECT_LABS[element], base);
|
|
1144
|
+
return `${base}/labs/${element}.html`;
|
|
1145
|
+
}
|
|
1146
|
+
async function listElements({ devUrl, labUrl } = {}) {
|
|
1147
|
+
const m = await fetchManifest(resolveDevUrl({ devUrl, labUrl }));
|
|
1148
|
+
if (!m || !m.elements || Object.keys(m.elements).length === 0) {
|
|
1149
|
+
return {
|
|
1150
|
+
manifest: null,
|
|
1151
|
+
note: "Dev server is up but no manifest has been posted yet. Load a lab page first."
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
return { manifest: m };
|
|
1155
|
+
}
|
|
1156
|
+
async function readTelemetry(path, { devUrl, labUrl } = {}) {
|
|
1157
|
+
const url = resolveDevUrl({ devUrl, labUrl });
|
|
1158
|
+
let data = null;
|
|
1159
|
+
let source = null;
|
|
1160
|
+
let staleMs = null;
|
|
1161
|
+
const live = await fetchState(url);
|
|
1162
|
+
if (live) {
|
|
1163
|
+
data = live.data;
|
|
1164
|
+
source = "live";
|
|
1165
|
+
staleMs = live.staleMs;
|
|
1166
|
+
} else if (url === DEV_URL && existsSync4(STATE_PATH)) {
|
|
1167
|
+
data = safeReadState();
|
|
1168
|
+
source = "disk";
|
|
1169
|
+
staleMs = typeof data?.postedAt === "number" ? Date.now() - data.postedAt : null;
|
|
1170
|
+
}
|
|
1171
|
+
if (data == null) {
|
|
1172
|
+
throw new Error(
|
|
1173
|
+
`No telemetry from ${url}/__state (and no local file). Is the dev server running with a lab page open?`
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
const sliced = applyPath(data, path);
|
|
1177
|
+
if (path == null && sliced && typeof sliced === "object" && !Array.isArray(sliced)) {
|
|
1178
|
+
return {
|
|
1179
|
+
...sliced,
|
|
1180
|
+
__source: source,
|
|
1181
|
+
...staleMs != null ? { __staleMs: staleMs } : {},
|
|
1182
|
+
...staleMs != null && staleMs > STALE_MS ? { __warning: `telemetry is ${staleMs}ms old \u2014 the lab tab may be backgrounded or closed` } : {}
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
return sliced;
|
|
1186
|
+
}
|
|
1187
|
+
async function setKnob(payload) {
|
|
1188
|
+
const batched = Array.isArray(payload?.updates);
|
|
1189
|
+
const updates = batched ? payload.updates : [payload];
|
|
1190
|
+
const url = resolveDevUrl({ devUrl: payload?.devUrl, labUrl: payload?.labUrl });
|
|
1191
|
+
const specs = knobSpecMapFromManifest(await fetchManifestCached(url));
|
|
1192
|
+
const clampedReports = [];
|
|
1193
|
+
const outgoing = updates.map((u) => {
|
|
1194
|
+
const spec = specs[u.element]?.[u.key];
|
|
1195
|
+
if (!spec) return u;
|
|
1196
|
+
const c = clampKnobValue(spec, u.value);
|
|
1197
|
+
if (c.clamped) {
|
|
1198
|
+
clampedReports.push({ element: u.element, key: u.key, requested: u.value, final: c.final });
|
|
1199
|
+
}
|
|
1200
|
+
return { ...u, value: c.final };
|
|
1201
|
+
});
|
|
1202
|
+
const body = JSON.stringify(batched ? outgoing : outgoing[0]);
|
|
1203
|
+
const r = await fetch(`${url}/__knob`, {
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
headers: { "content-type": "application/json" },
|
|
1206
|
+
body
|
|
1207
|
+
});
|
|
1208
|
+
if (!r.ok) throw new Error(`__knob returned ${r.status}`);
|
|
1209
|
+
return {
|
|
1210
|
+
ok: true,
|
|
1211
|
+
count: outgoing.length,
|
|
1212
|
+
...clampedReports.length ? { clamped: clampedReports } : {}
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
function trimCaptureTelemetry(sample) {
|
|
1216
|
+
if (!sample || typeof sample !== "object") return sample;
|
|
1217
|
+
try {
|
|
1218
|
+
if (sample.perf && typeof sample.perf === "object") {
|
|
1219
|
+
sample.perf = {
|
|
1220
|
+
...sample.perf,
|
|
1221
|
+
note: "capture-time fps \u2014 reads low right after a navigate while WebGPU warms; use read_telemetry for steady-state"
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
for (const el of Object.values(sample.elements ?? {})) {
|
|
1225
|
+
for (const probe of Object.values(el?.motion ?? {})) {
|
|
1226
|
+
if (probe && Array.isArray(probe.samples)) {
|
|
1227
|
+
probe.sampleCount = probe.samples.length;
|
|
1228
|
+
delete probe.samples;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
} catch {
|
|
1233
|
+
}
|
|
1234
|
+
return sample;
|
|
1235
|
+
}
|
|
1236
|
+
async function captureViews({
|
|
1237
|
+
element,
|
|
1238
|
+
labUrl,
|
|
1239
|
+
inline = true,
|
|
1240
|
+
fresh = false
|
|
1241
|
+
}) {
|
|
1242
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1243
|
+
const outDir = join4(tmpdir3(), `${PROJECT}-capture-${element ?? "scene"}`);
|
|
1244
|
+
mkdirSync2(outDir, { recursive: true });
|
|
1245
|
+
const t0 = Date.now();
|
|
1246
|
+
const timings = {};
|
|
1247
|
+
const tNavStart = Date.now();
|
|
1248
|
+
const { call } = await browserPool.getPage(target, { reload: fresh });
|
|
1249
|
+
timings.navigate = Date.now() - tNavStart;
|
|
1250
|
+
const tRenderStart = Date.now();
|
|
1251
|
+
const result = await call("Runtime.evaluate", {
|
|
1252
|
+
expression: "window.__TRISCOPE__.captureViews()",
|
|
1253
|
+
awaitPromise: true,
|
|
1254
|
+
returnByValue: true
|
|
1255
|
+
});
|
|
1256
|
+
timings.render = Date.now() - tRenderStart;
|
|
1257
|
+
const views = result.result.result.value;
|
|
1258
|
+
if (!views || typeof views !== "object") {
|
|
1259
|
+
throw new Error(
|
|
1260
|
+
`captureViews returned no images for element="${element ?? "(scene)"}" at ${target}. Most likely the Element declares no cameras \u2014 check the manifest: mcp__triscope__list_elements.`
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
const written = {};
|
|
1264
|
+
const base64ByCam = {};
|
|
1265
|
+
const tWriteStart = Date.now();
|
|
1266
|
+
for (const [cam, dataUrl] of Object.entries(views)) {
|
|
1267
|
+
if (typeof dataUrl !== "string") continue;
|
|
1268
|
+
const b64 = dataUrl.replace(/^data:image\/png;base64,/, "");
|
|
1269
|
+
const path = join4(outDir, `${cam}.png`);
|
|
1270
|
+
writeFileSync2(path, Buffer.from(b64, "base64"));
|
|
1271
|
+
written[cam] = path;
|
|
1272
|
+
base64ByCam[cam] = b64;
|
|
1273
|
+
}
|
|
1274
|
+
timings.writePngs = Date.now() - tWriteStart;
|
|
1275
|
+
const tTelStart = Date.now();
|
|
1276
|
+
const telemetry = await call("Runtime.evaluate", {
|
|
1277
|
+
expression: "JSON.stringify(window.__TRISCOPE__.sampleTelemetry())",
|
|
1278
|
+
returnByValue: true
|
|
1279
|
+
});
|
|
1280
|
+
const sample = trimCaptureTelemetry(JSON.parse(telemetry.result.result.value));
|
|
1281
|
+
timings.telemetry = Date.now() - tTelStart;
|
|
1282
|
+
const tProbeStart = Date.now();
|
|
1283
|
+
let gpuProbes = null;
|
|
1284
|
+
let gpuProbesSource = "unavailable";
|
|
1285
|
+
try {
|
|
1286
|
+
const probesResp = await call("Runtime.evaluate", {
|
|
1287
|
+
expression: "JSON.stringify(window.__TRISCOPE__.lastGpuProbes ?? null)",
|
|
1288
|
+
returnByValue: true
|
|
1289
|
+
});
|
|
1290
|
+
const fromHarness = JSON.parse(probesResp.result.result.value);
|
|
1291
|
+
if (fromHarness && Object.keys(fromHarness).length > 0) {
|
|
1292
|
+
gpuProbes = fromHarness;
|
|
1293
|
+
gpuProbesSource = "harness";
|
|
1294
|
+
}
|
|
1295
|
+
} catch {
|
|
1296
|
+
}
|
|
1297
|
+
if (!gpuProbes) {
|
|
1298
|
+
gpuProbes = {};
|
|
1299
|
+
for (const [cam, b64] of Object.entries(base64ByCam)) {
|
|
1300
|
+
try {
|
|
1301
|
+
gpuProbes[cam] = probeStatsFromPng(Buffer.from(b64, "base64"));
|
|
1302
|
+
} catch {
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (Object.keys(gpuProbes).length === 0) gpuProbes = null;
|
|
1306
|
+
else gpuProbesSource = "server-fallback";
|
|
1307
|
+
}
|
|
1308
|
+
timings.probes = Date.now() - tProbeStart;
|
|
1309
|
+
const blackFrames = gpuProbes ? Object.entries(gpuProbes).filter(([, s]) => s?.blackFrame).map(([cam]) => cam) : [];
|
|
1310
|
+
const flatFrames = gpuProbes ? Object.entries(gpuProbes).filter(([, s]) => {
|
|
1311
|
+
const p = s;
|
|
1312
|
+
return p && !p.blackFrame && typeof p.dynamicRange === "number" && p.dynamicRange <= 1.05;
|
|
1313
|
+
}).map(([cam]) => cam) : [];
|
|
1314
|
+
return {
|
|
1315
|
+
element: element ?? null,
|
|
1316
|
+
dir: outDir,
|
|
1317
|
+
files: written,
|
|
1318
|
+
cameraOrder: Object.keys(written),
|
|
1319
|
+
telemetry: sample,
|
|
1320
|
+
gpuProbes,
|
|
1321
|
+
gpuProbesSource,
|
|
1322
|
+
blackFrames,
|
|
1323
|
+
flatFrames,
|
|
1324
|
+
inline,
|
|
1325
|
+
captureMs: Date.now() - t0,
|
|
1326
|
+
timings,
|
|
1327
|
+
_base64ByCam: base64ByCam
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
var BLACK_FRAME_LUMINANCE = 4e-3;
|
|
1331
|
+
function probeStatsFromPng(pngBuf) {
|
|
1332
|
+
const img = PNG2.sync.read(pngBuf);
|
|
1333
|
+
const stride = Math.max(1, Math.floor(Math.sqrt(img.width * img.height / 2304)));
|
|
1334
|
+
const lums = [];
|
|
1335
|
+
let sum = 0;
|
|
1336
|
+
for (let y = 0; y < img.height; y += stride) {
|
|
1337
|
+
for (let x = 0; x < img.width; x += stride) {
|
|
1338
|
+
const i = (y * img.width + x) * 4;
|
|
1339
|
+
const r = img.data[i] / 255;
|
|
1340
|
+
const g = img.data[i + 1] / 255;
|
|
1341
|
+
const b = img.data[i + 2] / 255;
|
|
1342
|
+
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
1343
|
+
lums.push(lum);
|
|
1344
|
+
sum += lum;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
lums.sort((a, b) => a - b);
|
|
1348
|
+
const n = lums.length;
|
|
1349
|
+
const p5 = lums[Math.floor(n * 0.05)];
|
|
1350
|
+
const p95 = lums[Math.floor(n * 0.95)];
|
|
1351
|
+
const luminance = +(sum / n).toFixed(4);
|
|
1352
|
+
return {
|
|
1353
|
+
luminance,
|
|
1354
|
+
p5: +p5.toFixed(4),
|
|
1355
|
+
p95: +p95.toFixed(4),
|
|
1356
|
+
dynamicRange: +(p95 / Math.max(p5, 1 / 255)).toFixed(2),
|
|
1357
|
+
samples: n,
|
|
1358
|
+
...luminance < BLACK_FRAME_LUMINANCE ? { blackFrame: true } : {}
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
async function captureMotionFramesRaw({
|
|
1362
|
+
element,
|
|
1363
|
+
camera,
|
|
1364
|
+
frames,
|
|
1365
|
+
dt,
|
|
1366
|
+
mode,
|
|
1367
|
+
labUrl
|
|
1368
|
+
}) {
|
|
1369
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1370
|
+
const { call } = await browserPool.getPage(target);
|
|
1371
|
+
const result = await call("Runtime.evaluate", {
|
|
1372
|
+
expression: `window.__TRISCOPE__.captureMotionFrames(${JSON.stringify(camera)}, ${JSON.stringify({ frames, dt, mode })})`,
|
|
1373
|
+
awaitPromise: true,
|
|
1374
|
+
returnByValue: true
|
|
1375
|
+
});
|
|
1376
|
+
const frames_ = result.result.result.value;
|
|
1377
|
+
if (!Array.isArray(frames_) || frames_.length === 0) {
|
|
1378
|
+
throw new Error(`captureMotionFrames returned empty for camera "${camera}"`);
|
|
1379
|
+
}
|
|
1380
|
+
return frames_.map((du) => du.replace(/^data:image\/png;base64,/, ""));
|
|
1381
|
+
}
|
|
1382
|
+
async function captureMotion({
|
|
1383
|
+
element,
|
|
1384
|
+
camera,
|
|
1385
|
+
frames = 6,
|
|
1386
|
+
dt = 0.25,
|
|
1387
|
+
mode = "time",
|
|
1388
|
+
labUrl,
|
|
1389
|
+
fresh = false
|
|
1390
|
+
}) {
|
|
1391
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1392
|
+
const outDir = join4(tmpdir3(), `${PROJECT}-motion-${element ?? "scene"}`);
|
|
1393
|
+
mkdirSync2(outDir, { recursive: true });
|
|
1394
|
+
const t0 = Date.now();
|
|
1395
|
+
const { call } = await browserPool.getPage(target, { reload: fresh });
|
|
1396
|
+
let cameraOrder;
|
|
1397
|
+
if (camera) {
|
|
1398
|
+
cameraOrder = [camera];
|
|
1399
|
+
} else {
|
|
1400
|
+
const camsProbe = await call("Runtime.evaluate", {
|
|
1401
|
+
expression: "Object.keys(window.__TRISCOPE__.cameras)",
|
|
1402
|
+
returnByValue: true
|
|
1403
|
+
});
|
|
1404
|
+
cameraOrder = camsProbe.result.result.value ?? [];
|
|
1405
|
+
}
|
|
1406
|
+
if (!Array.isArray(cameraOrder) || cameraOrder.length === 0) {
|
|
1407
|
+
throw new Error("no cameras available \u2014 is the harness mounted?");
|
|
1408
|
+
}
|
|
1409
|
+
const filmstripPaths = {};
|
|
1410
|
+
const filmstripBase64 = {};
|
|
1411
|
+
const magnitudeByCam = {};
|
|
1412
|
+
for (const camName of cameraOrder) {
|
|
1413
|
+
const result = await call("Runtime.evaluate", {
|
|
1414
|
+
expression: `window.__TRISCOPE__.captureMotionFrames(${JSON.stringify(camName)}, ${JSON.stringify({ frames, dt, mode })})`,
|
|
1415
|
+
awaitPromise: true,
|
|
1416
|
+
returnByValue: true
|
|
1417
|
+
});
|
|
1418
|
+
const dataUrls = result.result.result.value;
|
|
1419
|
+
if (!Array.isArray(dataUrls) || dataUrls.length === 0) {
|
|
1420
|
+
throw new Error(`captureMotionFrames returned empty for camera "${camName}"`);
|
|
1421
|
+
}
|
|
1422
|
+
const strip = composeFilmstrip(dataUrls);
|
|
1423
|
+
const stripPath = join4(outDir, `${camName}.filmstrip.png`);
|
|
1424
|
+
writeFileSync2(stripPath, strip);
|
|
1425
|
+
filmstripPaths[camName] = stripPath;
|
|
1426
|
+
filmstripBase64[camName] = strip.toString("base64");
|
|
1427
|
+
magnitudeByCam[camName] = motionMagnitudeFromFrames(dataUrls);
|
|
1428
|
+
}
|
|
1429
|
+
const telemetry = await call("Runtime.evaluate", {
|
|
1430
|
+
expression: "JSON.stringify(window.__TRISCOPE__.sampleTelemetry())",
|
|
1431
|
+
returnByValue: true
|
|
1432
|
+
});
|
|
1433
|
+
const sample = trimCaptureTelemetry(JSON.parse(telemetry.result.result.value));
|
|
1434
|
+
return {
|
|
1435
|
+
element: element ?? null,
|
|
1436
|
+
frames,
|
|
1437
|
+
dt,
|
|
1438
|
+
mode,
|
|
1439
|
+
dir: outDir,
|
|
1440
|
+
filmstrips: filmstripPaths,
|
|
1441
|
+
cameraOrder,
|
|
1442
|
+
motionMagnitude: magnitudeByCam,
|
|
1443
|
+
captureMs: Date.now() - t0,
|
|
1444
|
+
telemetry: sample,
|
|
1445
|
+
_filmstripBase64: filmstripBase64
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
var SNAPSHOT_TAG_PREFIX = "triscope/snapshot/";
|
|
1449
|
+
var NEED_SHELL = process.platform === "win32";
|
|
1450
|
+
var TRISCOPE_BIN = process.platform === "win32" ? "triscope.cmd" : "triscope";
|
|
1451
|
+
function rejectFlagArg(value, label) {
|
|
1452
|
+
if (typeof value !== "string" || value.length === 0 || value.startsWith("-")) {
|
|
1453
|
+
throw new Error(
|
|
1454
|
+
`unsafe ${label}: ${JSON.stringify(value)} (must be a non-empty value not starting with '-')`
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
function git(args, cwd = process.cwd()) {
|
|
1459
|
+
return new Promise((resolve2, reject) => {
|
|
1460
|
+
const child = spawn2("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: NEED_SHELL });
|
|
1461
|
+
let stdout = "";
|
|
1462
|
+
let stderr = "";
|
|
1463
|
+
child.stdout.on("data", (d) => stdout += d);
|
|
1464
|
+
child.stderr.on("data", (d) => stderr += d);
|
|
1465
|
+
child.on("error", reject);
|
|
1466
|
+
child.on(
|
|
1467
|
+
"exit",
|
|
1468
|
+
(code) => resolve2({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() })
|
|
1469
|
+
);
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
async function assertCleanWt(cwd, action) {
|
|
1473
|
+
const status = await git(["status", "--porcelain"], cwd);
|
|
1474
|
+
if (status.stdout.length > 0) {
|
|
1475
|
+
throw new Error(
|
|
1476
|
+
`${action} refuses to run with a dirty working tree. Commit, stash, or revert your in-progress edits first.
|
|
1477
|
+
Dirty paths:
|
|
1478
|
+
${status.stdout.split("\n").slice(0, 10).join("\n")}`
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
async function waitForKnobApplied(key, value, timeoutMs = 2500, element, devUrl) {
|
|
1483
|
+
const ns = element ? `${element}.${key}` : null;
|
|
1484
|
+
const url = resolveDevUrl({ devUrl });
|
|
1485
|
+
const useHttp = url !== DEV_URL;
|
|
1486
|
+
const start = Date.now();
|
|
1487
|
+
while (Date.now() - start < timeoutMs) {
|
|
1488
|
+
try {
|
|
1489
|
+
let st = null;
|
|
1490
|
+
if (useHttp) {
|
|
1491
|
+
st = (await fetchState(url))?.data ?? null;
|
|
1492
|
+
} else if (existsSync4(STATE_PATH)) {
|
|
1493
|
+
st = JSON.parse(readFileSync2(STATE_PATH, "utf8"));
|
|
1494
|
+
}
|
|
1495
|
+
const applied = st?.knobs?.[key] ?? (ns ? st?.knobs?.[ns] : void 0);
|
|
1496
|
+
if (applied !== void 0 && knobMatches(value, applied)) return true;
|
|
1497
|
+
} catch {
|
|
1498
|
+
}
|
|
1499
|
+
await wait2(50);
|
|
1500
|
+
}
|
|
1501
|
+
return false;
|
|
1502
|
+
}
|
|
1503
|
+
async function fetchPersistedKnobs() {
|
|
1504
|
+
try {
|
|
1505
|
+
const r = await fetch(`${DEV_URL}/__knob/current`);
|
|
1506
|
+
if (!r.ok) return {};
|
|
1507
|
+
return await r.json();
|
|
1508
|
+
} catch {
|
|
1509
|
+
return {};
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
async function snapshot({ name, message }) {
|
|
1513
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
1514
|
+
throw new Error(`snapshot name must be [A-Za-z0-9._-]+ (got "${name}")`);
|
|
1515
|
+
}
|
|
1516
|
+
const cwd = process.cwd();
|
|
1517
|
+
await assertCleanWt(cwd, "snapshot");
|
|
1518
|
+
const head = await git(["rev-parse", "HEAD"], cwd);
|
|
1519
|
+
if (head.code !== 0) throw new Error(`git rev-parse failed: ${head.stderr}`);
|
|
1520
|
+
const knobs = await fetchPersistedKnobs();
|
|
1521
|
+
const payload = {
|
|
1522
|
+
name,
|
|
1523
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1524
|
+
commit: head.stdout,
|
|
1525
|
+
message: message ?? "",
|
|
1526
|
+
knobs
|
|
1527
|
+
// { [element]: { [knobKey]: value, ... } }
|
|
1528
|
+
};
|
|
1529
|
+
const tagName = `${SNAPSHOT_TAG_PREFIX}${name}`;
|
|
1530
|
+
const tagBody = `triscope snapshot v1
|
|
1531
|
+
|
|
1532
|
+
${JSON.stringify(payload, null, 2)}`;
|
|
1533
|
+
const tag = await git(["tag", "-a", tagName, "-m", tagBody, payload.commit], cwd);
|
|
1534
|
+
if (tag.code !== 0) {
|
|
1535
|
+
if (tag.stderr.includes("already exists")) {
|
|
1536
|
+
throw new Error(
|
|
1537
|
+
`snapshot "${name}" already exists. Pick a different name or delete the existing tag: git tag -d ${tagName}`
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
throw new Error(`git tag failed: ${tag.stderr}`);
|
|
1541
|
+
}
|
|
1542
|
+
return {
|
|
1543
|
+
ok: true,
|
|
1544
|
+
tag: tagName,
|
|
1545
|
+
...payload,
|
|
1546
|
+
hint: "Restore later with mcp__triscope__restore name=" + name
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
async function listSnapshots() {
|
|
1550
|
+
const cwd = process.cwd();
|
|
1551
|
+
const list = await git(
|
|
1552
|
+
[
|
|
1553
|
+
"tag",
|
|
1554
|
+
"--list",
|
|
1555
|
+
`${SNAPSHOT_TAG_PREFIX}*`,
|
|
1556
|
+
"--format=%(refname:short)|%(creatordate:iso)|%(subject)"
|
|
1557
|
+
],
|
|
1558
|
+
cwd
|
|
1559
|
+
);
|
|
1560
|
+
if (list.code !== 0) throw new Error(`git tag --list failed: ${list.stderr}`);
|
|
1561
|
+
const snapshots = [];
|
|
1562
|
+
for (const line of list.stdout.split("\n").filter(Boolean)) {
|
|
1563
|
+
const [tag, created, ...subj] = line.split("|");
|
|
1564
|
+
snapshots.push({
|
|
1565
|
+
name: tag.replace(SNAPSHOT_TAG_PREFIX, ""),
|
|
1566
|
+
tag,
|
|
1567
|
+
created,
|
|
1568
|
+
subject: subj.join("|")
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
return { count: snapshots.length, snapshots };
|
|
1572
|
+
}
|
|
1573
|
+
async function restore({ name }) {
|
|
1574
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error(`invalid snapshot name`);
|
|
1575
|
+
const cwd = process.cwd();
|
|
1576
|
+
await assertCleanWt(cwd, "restore");
|
|
1577
|
+
const tagName = `${SNAPSHOT_TAG_PREFIX}${name}`;
|
|
1578
|
+
const show = await git(["cat-file", "-p", tagName], cwd);
|
|
1579
|
+
if (show.code !== 0) throw new Error(`snapshot "${name}" not found (tag ${tagName})`);
|
|
1580
|
+
const bodyMatch = show.stdout.match(/\n\n([\s\S]*)/);
|
|
1581
|
+
const body = bodyMatch?.[1] ?? "";
|
|
1582
|
+
const jsonStart = body.indexOf("{");
|
|
1583
|
+
if (jsonStart < 0) throw new Error(`snapshot ${name} has no JSON payload`);
|
|
1584
|
+
let payload;
|
|
1585
|
+
try {
|
|
1586
|
+
payload = JSON.parse(body.slice(jsonStart));
|
|
1587
|
+
} catch {
|
|
1588
|
+
throw new Error(`snapshot ${name} payload is not valid JSON`);
|
|
1589
|
+
}
|
|
1590
|
+
const checkout = await git(["checkout", payload.commit], cwd);
|
|
1591
|
+
if (checkout.code !== 0)
|
|
1592
|
+
throw new Error(`git checkout ${payload.commit} failed: ${checkout.stderr}`);
|
|
1593
|
+
const updates = [];
|
|
1594
|
+
for (const [elName, kv] of Object.entries(payload.knobs ?? {})) {
|
|
1595
|
+
for (const [k, v] of Object.entries(kv)) {
|
|
1596
|
+
updates.push({ element: elName, key: k, value: v });
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if (updates.length > 0) {
|
|
1600
|
+
try {
|
|
1601
|
+
await setKnob({ updates });
|
|
1602
|
+
} catch {
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
ok: true,
|
|
1607
|
+
tag: tagName,
|
|
1608
|
+
restoredCommit: payload.commit,
|
|
1609
|
+
knobUpdates: updates.length,
|
|
1610
|
+
payload
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
async function autoTune({
|
|
1614
|
+
element,
|
|
1615
|
+
knob,
|
|
1616
|
+
range,
|
|
1617
|
+
target_camera,
|
|
1618
|
+
max_iterations = 12,
|
|
1619
|
+
labUrl
|
|
1620
|
+
}) {
|
|
1621
|
+
const refPath = refsPath(process.cwd(), element, target_camera);
|
|
1622
|
+
if (!existsSync4(refPath)) {
|
|
1623
|
+
throw new Error(
|
|
1624
|
+
`auto_tune needs a reference image at ${refPath}. Call set_reference with element=${element}, camera=${target_camera} first (or paste a PNG path/base64).`
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1628
|
+
const devUrl = resolveDevUrl({ labUrl: target });
|
|
1629
|
+
const { call } = await browserPool.getPage(target);
|
|
1630
|
+
const phi = (1 + Math.sqrt(5)) / 2;
|
|
1631
|
+
const invPhi = 1 / phi;
|
|
1632
|
+
let [a, b] = range;
|
|
1633
|
+
if (!(b > a))
|
|
1634
|
+
throw new Error(`auto_tune range must be [min, max] with max > min, got [${a}, ${b}]`);
|
|
1635
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1636
|
+
const history = [];
|
|
1637
|
+
const t0 = Date.now();
|
|
1638
|
+
async function evalAt(x) {
|
|
1639
|
+
const key = x.toFixed(6);
|
|
1640
|
+
if (cache.has(key)) return cache.get(key);
|
|
1641
|
+
const iterStart = Date.now();
|
|
1642
|
+
await setKnob({ element, key: knob, value: x, devUrl });
|
|
1643
|
+
const applied = await waitForKnobApplied(knob, x, 2500, element, devUrl);
|
|
1644
|
+
if (!applied) await wait2(300);
|
|
1645
|
+
const cap = await call("Runtime.evaluate", {
|
|
1646
|
+
expression: "window.__TRISCOPE__.captureViews()",
|
|
1647
|
+
awaitPromise: true,
|
|
1648
|
+
returnByValue: true
|
|
1649
|
+
});
|
|
1650
|
+
const views = cap.result.result.value ?? {};
|
|
1651
|
+
const b64 = String(views[target_camera] ?? "").replace(/^data:image\/png;base64,/, "");
|
|
1652
|
+
if (!b64)
|
|
1653
|
+
throw new Error(`auto_tune: captureViews returned no PNG for camera "${target_camera}"`);
|
|
1654
|
+
const diff = diffReference({
|
|
1655
|
+
cwd: process.cwd(),
|
|
1656
|
+
element,
|
|
1657
|
+
camera: target_camera,
|
|
1658
|
+
currentBase64: b64
|
|
1659
|
+
});
|
|
1660
|
+
const score = diff.ssim;
|
|
1661
|
+
cache.set(key, score);
|
|
1662
|
+
history.push({ iter: history.length, knob: x, ssim: score, ms: Date.now() - iterStart });
|
|
1663
|
+
return score;
|
|
1664
|
+
}
|
|
1665
|
+
let c = b - (b - a) * invPhi;
|
|
1666
|
+
let d = a + (b - a) * invPhi;
|
|
1667
|
+
let fc = await evalAt(c);
|
|
1668
|
+
let fd = await evalAt(d);
|
|
1669
|
+
for (let i = 0; i < max_iterations - 2; i++) {
|
|
1670
|
+
if (fc > fd) {
|
|
1671
|
+
b = d;
|
|
1672
|
+
d = c;
|
|
1673
|
+
fd = fc;
|
|
1674
|
+
c = b - (b - a) * invPhi;
|
|
1675
|
+
fc = await evalAt(c);
|
|
1676
|
+
} else {
|
|
1677
|
+
a = c;
|
|
1678
|
+
c = d;
|
|
1679
|
+
fc = fd;
|
|
1680
|
+
d = a + (b - a) * invPhi;
|
|
1681
|
+
fd = await evalAt(d);
|
|
1682
|
+
}
|
|
1683
|
+
if (Math.abs(b - a) < (range[1] - range[0]) * 0.01) break;
|
|
1684
|
+
}
|
|
1685
|
+
const best = [...cache.entries()].map(([k, v]) => ({ knob: Number(k), ssim: v })).sort((x, y) => y.ssim - x.ssim)[0];
|
|
1686
|
+
await setKnob({ element, key: knob, value: best.knob, devUrl });
|
|
1687
|
+
return {
|
|
1688
|
+
element,
|
|
1689
|
+
knob,
|
|
1690
|
+
target_camera,
|
|
1691
|
+
bestKnobValue: best.knob,
|
|
1692
|
+
bestSsim: best.ssim,
|
|
1693
|
+
iterations: history.length,
|
|
1694
|
+
history,
|
|
1695
|
+
totalMs: Date.now() - t0,
|
|
1696
|
+
hint: "SSIM 1.0 = identical to reference, 0.9+ = visually close, <0.7 = clearly different. The knob has been left at bestKnobValue in the live lab."
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
async function multiTune({
|
|
1700
|
+
element,
|
|
1701
|
+
knobs,
|
|
1702
|
+
target_camera,
|
|
1703
|
+
max_cycles = 2,
|
|
1704
|
+
per_knob_iters = 8,
|
|
1705
|
+
compute_interactions = false,
|
|
1706
|
+
max_evaluations,
|
|
1707
|
+
labUrl
|
|
1708
|
+
}) {
|
|
1709
|
+
const cwd = process.cwd();
|
|
1710
|
+
const refPath = refsPath(cwd, element, target_camera);
|
|
1711
|
+
if (!existsSync4(refPath)) {
|
|
1712
|
+
throw new Error(
|
|
1713
|
+
`multi_tune needs a reference image at ${refPath}. Call set_reference (element=${element}, camera=${target_camera}) first.`
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1717
|
+
const devUrl = resolveDevUrl({ labUrl: target });
|
|
1718
|
+
const { call } = await browserPool.getPage(target);
|
|
1719
|
+
const t0 = Date.now();
|
|
1720
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1721
|
+
const evalAt = async (values) => {
|
|
1722
|
+
const ck = JSON.stringify(values);
|
|
1723
|
+
const hit = cache.get(ck);
|
|
1724
|
+
if (hit !== void 0) return hit;
|
|
1725
|
+
const updates = Object.entries(values).map(([key, value]) => ({ element, key, value }));
|
|
1726
|
+
await setKnob({ updates, devUrl });
|
|
1727
|
+
const last = updates[updates.length - 1];
|
|
1728
|
+
const ok = await waitForKnobApplied(last.key, last.value, 2500, element, devUrl);
|
|
1729
|
+
if (!ok) await wait2(300);
|
|
1730
|
+
const cap = await call("Runtime.evaluate", {
|
|
1731
|
+
expression: "window.__TRISCOPE__.captureViews()",
|
|
1732
|
+
awaitPromise: true,
|
|
1733
|
+
returnByValue: true
|
|
1734
|
+
});
|
|
1735
|
+
const views = cap.result.result.value ?? {};
|
|
1736
|
+
const b64 = String(views[target_camera] ?? "").replace(/^data:image\/png;base64,/, "");
|
|
1737
|
+
if (!b64) throw new Error(`multi_tune: captureViews returned no PNG for "${target_camera}"`);
|
|
1738
|
+
const diff = diffReference({ cwd, element, camera: target_camera, currentBase64: b64 });
|
|
1739
|
+
cache.set(ck, diff.ssim);
|
|
1740
|
+
return diff.ssim;
|
|
1741
|
+
};
|
|
1742
|
+
const specs = knobs.map((kn) => ({
|
|
1743
|
+
key: kn.key,
|
|
1744
|
+
min: kn.range[0],
|
|
1745
|
+
max: kn.range[1],
|
|
1746
|
+
start: kn.start ?? (kn.range[0] + kn.range[1]) / 2
|
|
1747
|
+
}));
|
|
1748
|
+
const hardCap = max_evaluations ?? Math.min(specs.length * per_knob_iters * max_cycles + 4, 64);
|
|
1749
|
+
const result = await coordinateDescent({
|
|
1750
|
+
knobs: specs,
|
|
1751
|
+
evalAt,
|
|
1752
|
+
maxCycles: max_cycles,
|
|
1753
|
+
perKnobIters: per_knob_iters,
|
|
1754
|
+
maxEvaluations: hardCap
|
|
1755
|
+
});
|
|
1756
|
+
let interactions;
|
|
1757
|
+
if (compute_interactions && specs.length >= 2) {
|
|
1758
|
+
interactions = [];
|
|
1759
|
+
const base = await evalAt({ ...result.best });
|
|
1760
|
+
for (let i = 0; i < specs.length; i++) {
|
|
1761
|
+
for (let j = i + 1; j < specs.length; j++) {
|
|
1762
|
+
const A = specs[i];
|
|
1763
|
+
const B = specs[j];
|
|
1764
|
+
const dA = (A.max - A.min) * 0.05;
|
|
1765
|
+
const dB = (B.max - B.min) * 0.05;
|
|
1766
|
+
const clamp = (v, s) => Math.min(s.max, Math.max(s.min, v));
|
|
1767
|
+
const fa = await evalAt({ ...result.best, [A.key]: clamp(result.best[A.key] + dA, A) });
|
|
1768
|
+
const fb = await evalAt({ ...result.best, [B.key]: clamp(result.best[B.key] + dB, B) });
|
|
1769
|
+
const fab = await evalAt({
|
|
1770
|
+
...result.best,
|
|
1771
|
+
[A.key]: clamp(result.best[A.key] + dA, A),
|
|
1772
|
+
[B.key]: clamp(result.best[B.key] + dB, B)
|
|
1773
|
+
});
|
|
1774
|
+
interactions.push({ a: A.key, b: B.key, interaction: +(fab - fa - fb + base).toFixed(4) });
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
await setKnob({
|
|
1779
|
+
updates: Object.entries(result.best).map(([key, value]) => ({ element, key, value })),
|
|
1780
|
+
devUrl
|
|
1781
|
+
});
|
|
1782
|
+
return {
|
|
1783
|
+
element,
|
|
1784
|
+
target_camera,
|
|
1785
|
+
best: result.best,
|
|
1786
|
+
bestSsim: +result.bestScore.toFixed(4),
|
|
1787
|
+
cycles: result.cycles,
|
|
1788
|
+
evaluations: result.evaluations,
|
|
1789
|
+
totalMs: Date.now() - t0,
|
|
1790
|
+
interactions,
|
|
1791
|
+
hint: "SSIM 1.0 = identical, 0.9+ = close. Knobs left at best values in the live lab. interaction>0 = the two knobs reinforce, <0 = they fight (only present when compute_interactions=true)."
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
async function checkTargets({ element, labUrl }) {
|
|
1795
|
+
const cwd = process.cwd();
|
|
1796
|
+
const file = join4(cwd, ".claude", "element-targets.json");
|
|
1797
|
+
if (!existsSync4(file)) {
|
|
1798
|
+
return { checked: 0, allPassed: true, note: `no targets file at ${file}` };
|
|
1799
|
+
}
|
|
1800
|
+
let all;
|
|
1801
|
+
try {
|
|
1802
|
+
all = JSON.parse(readFileSync2(file, "utf8"));
|
|
1803
|
+
} catch (e) {
|
|
1804
|
+
throw new Error(`invalid ${file}: ${e?.message ?? e}`);
|
|
1805
|
+
}
|
|
1806
|
+
const targetsByCamera = all?.[element];
|
|
1807
|
+
if (!targetsByCamera || typeof targetsByCamera !== "object") {
|
|
1808
|
+
return { checked: 0, allPassed: true, note: `no targets for element "${element}" in ${file}` };
|
|
1809
|
+
}
|
|
1810
|
+
const cap = await captureViews({ element, labUrl, inline: true });
|
|
1811
|
+
const capturedByCamera = {};
|
|
1812
|
+
for (const [camera, probe] of Object.entries(cap.gpuProbes ?? {})) {
|
|
1813
|
+
capturedByCamera[camera] = { luminance: probe.luminance, dynamicRange: probe.dynamicRange };
|
|
1814
|
+
}
|
|
1815
|
+
for (const camera of Object.keys(targetsByCamera)) {
|
|
1816
|
+
const c = targetsByCamera[camera] ?? {};
|
|
1817
|
+
const wantsRefMetric = c.ssim !== void 0 || c.meanAbsDiff !== void 0;
|
|
1818
|
+
if (wantsRefMetric && existsSync4(refsPath(cwd, element, camera))) {
|
|
1819
|
+
const b64 = cap._base64ByCam?.[camera];
|
|
1820
|
+
if (b64) {
|
|
1821
|
+
const d = diffReference({ cwd, element, camera, currentBase64: b64 });
|
|
1822
|
+
capturedByCamera[camera] = {
|
|
1823
|
+
...capturedByCamera[camera] ?? {},
|
|
1824
|
+
ssim: d.ssim,
|
|
1825
|
+
meanAbsDiff: d.meanAbsDiff
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
const report = evaluateTargets(targetsByCamera, capturedByCamera);
|
|
1831
|
+
return {
|
|
1832
|
+
element,
|
|
1833
|
+
file,
|
|
1834
|
+
...report,
|
|
1835
|
+
hint: report.allPassed ? "All evaluated constraints pass \u2014 this view meets its target." : "Some constraints fail (see results[].pass=false). Skipped constraints need a set_reference to evaluate."
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
async function inspect({ element, camera }) {
|
|
1839
|
+
const baseUrl = await resolveLabUrl({ element });
|
|
1840
|
+
const sep = baseUrl.includes("?") ? "&" : "?";
|
|
1841
|
+
const inspectUrl = `${baseUrl}${sep}inspect=${encodeURIComponent(element)}${camera ? `&camera=${encodeURIComponent(camera)}` : ""}`;
|
|
1842
|
+
const t0 = Date.now();
|
|
1843
|
+
await browserPool.getPage(inspectUrl);
|
|
1844
|
+
return {
|
|
1845
|
+
element,
|
|
1846
|
+
camera: camera ?? null,
|
|
1847
|
+
url: inspectUrl,
|
|
1848
|
+
navMs: Date.now() - t0,
|
|
1849
|
+
hint: "Right-drag to orbit, scroll to zoom, left-click to pick a mesh. Read .selection from telemetry after the user clicks."
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
async function addElement({
|
|
1853
|
+
name,
|
|
1854
|
+
element,
|
|
1855
|
+
labUrl
|
|
1856
|
+
}) {
|
|
1857
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1858
|
+
throw new Error("add_element requires a non-empty `name`");
|
|
1859
|
+
}
|
|
1860
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1861
|
+
const { call } = await browserPool.getPage(target);
|
|
1862
|
+
const res = await call("Runtime.evaluate", {
|
|
1863
|
+
expression: `JSON.stringify((function(){var t=window.__TRISCOPE__;if(!t||!t.addElement)return{ok:false,error:'addElement unavailable \u2014 rebuild the lab against newer @triscope/core'};var ok=t.addElement(${JSON.stringify(name)});return{ok:ok,name:${JSON.stringify(name)},mounted:t.mountedElements?t.mountedElements():[],available:t.availableElements?t.availableElements():[]};})())`,
|
|
1864
|
+
returnByValue: true
|
|
1865
|
+
});
|
|
1866
|
+
return JSON.parse(res.result.result.value);
|
|
1867
|
+
}
|
|
1868
|
+
async function removeElement({
|
|
1869
|
+
name,
|
|
1870
|
+
element,
|
|
1871
|
+
labUrl
|
|
1872
|
+
}) {
|
|
1873
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
1874
|
+
throw new Error("remove_element requires a non-empty `name`");
|
|
1875
|
+
}
|
|
1876
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1877
|
+
const { call } = await browserPool.getPage(target);
|
|
1878
|
+
const res = await call("Runtime.evaluate", {
|
|
1879
|
+
expression: `JSON.stringify((function(){var t=window.__TRISCOPE__;if(!t||!t.removeElement)return{ok:false,error:'removeElement unavailable \u2014 rebuild the lab against newer @triscope/core'};var ok=t.removeElement(${JSON.stringify(name)});return{ok:ok,name:${JSON.stringify(name)},mounted:t.mountedElements?t.mountedElements():[],available:t.availableElements?t.availableElements():[]};})())`,
|
|
1880
|
+
returnByValue: true
|
|
1881
|
+
});
|
|
1882
|
+
return JSON.parse(res.result.result.value);
|
|
1883
|
+
}
|
|
1884
|
+
async function inspectScene({
|
|
1885
|
+
element,
|
|
1886
|
+
maxNodes,
|
|
1887
|
+
labUrl,
|
|
1888
|
+
fresh = false
|
|
1889
|
+
}) {
|
|
1890
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1891
|
+
const { call } = await browserPool.getPage(target, { reload: fresh });
|
|
1892
|
+
const n = Number.isFinite(maxNodes) ? maxNodes : 500;
|
|
1893
|
+
const res = await call("Runtime.evaluate", {
|
|
1894
|
+
expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.queryScene ? window.__TRISCOPE__.queryScene(${n}) : { error: 'queryScene unavailable \u2014 the lab is on an older @triscope/core; rebuild it' })`,
|
|
1895
|
+
returnByValue: true
|
|
1896
|
+
});
|
|
1897
|
+
return JSON.parse(res.result.result.value);
|
|
1898
|
+
}
|
|
1899
|
+
async function readUniform({
|
|
1900
|
+
element,
|
|
1901
|
+
path,
|
|
1902
|
+
labUrl
|
|
1903
|
+
}) {
|
|
1904
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1905
|
+
const { call } = await browserPool.getPage(target);
|
|
1906
|
+
const res = await call("Runtime.evaluate", {
|
|
1907
|
+
expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.readUniform ? window.__TRISCOPE__.readUniform(${JSON.stringify(path)}) : { kind: 'not-found', error: 'readUniform unavailable \u2014 rebuild the lab against newer @triscope/core' })`,
|
|
1908
|
+
returnByValue: true
|
|
1909
|
+
});
|
|
1910
|
+
return JSON.parse(res.result.result.value);
|
|
1911
|
+
}
|
|
1912
|
+
async function setUniform({
|
|
1913
|
+
element,
|
|
1914
|
+
path,
|
|
1915
|
+
value,
|
|
1916
|
+
labUrl
|
|
1917
|
+
}) {
|
|
1918
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1919
|
+
const { call } = await browserPool.getPage(target);
|
|
1920
|
+
const res = await call("Runtime.evaluate", {
|
|
1921
|
+
expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.setUniform ? window.__TRISCOPE__.setUniform(${JSON.stringify(path)}, ${JSON.stringify(value)}) : { ok: false, kind: 'not-found', error: 'setUniform unavailable \u2014 rebuild the lab against newer @triscope/core' })`,
|
|
1922
|
+
returnByValue: true
|
|
1923
|
+
});
|
|
1924
|
+
return JSON.parse(res.result.result.value);
|
|
1925
|
+
}
|
|
1926
|
+
async function setSceneParam({
|
|
1927
|
+
cameras,
|
|
1928
|
+
knobs,
|
|
1929
|
+
elements
|
|
1930
|
+
}) {
|
|
1931
|
+
const delta = {};
|
|
1932
|
+
if (cameras && Object.keys(cameras).length) delta.cameras = cameras;
|
|
1933
|
+
if (knobs && Object.keys(knobs).length) delta.knobs = knobs;
|
|
1934
|
+
if (elements && Object.keys(elements).length) delta.elements = elements;
|
|
1935
|
+
if (!delta.cameras && !delta.knobs && !delta.elements) {
|
|
1936
|
+
throw new Error("set_scene_param needs `cameras`, `knobs`, and/or `elements`");
|
|
1937
|
+
}
|
|
1938
|
+
const r = await fetch(`${DEV_URL}/__scene`, {
|
|
1939
|
+
method: "POST",
|
|
1940
|
+
headers: { "content-type": "application/json" },
|
|
1941
|
+
body: JSON.stringify(delta)
|
|
1942
|
+
});
|
|
1943
|
+
if (!r.ok) throw new Error(`/__scene returned ${r.status}`);
|
|
1944
|
+
return {
|
|
1945
|
+
ok: true,
|
|
1946
|
+
applied: delta,
|
|
1947
|
+
note: "Applied by the harness within ~100 ms (no reload) and persisted via /__scene so it survives a reload. Read back with get_scene or read_telemetry .cameras."
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
async function getScene({ devUrl, labUrl } = {}) {
|
|
1951
|
+
const url = resolveDevUrl({ devUrl, labUrl });
|
|
1952
|
+
let sceneSpec = {};
|
|
1953
|
+
let live = {};
|
|
1954
|
+
try {
|
|
1955
|
+
const r = await fetch(`${url}/__scene/current`);
|
|
1956
|
+
if (r.ok) sceneSpec = await r.json();
|
|
1957
|
+
} catch {
|
|
1958
|
+
}
|
|
1959
|
+
const state = await fetchState(url);
|
|
1960
|
+
const st = state?.data ?? (url === DEV_URL && existsSync4(STATE_PATH) ? safeReadState() : null);
|
|
1961
|
+
if (st) live = { cameras: st.cameras, knobs: st.knobs, elements: st.sceneElements };
|
|
1962
|
+
return { sceneSpec, live };
|
|
1963
|
+
}
|
|
1964
|
+
function safeReadState() {
|
|
1965
|
+
try {
|
|
1966
|
+
return JSON.parse(readFileSync2(STATE_PATH, "utf8"));
|
|
1967
|
+
} catch {
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
async function openSelection({ editor }) {
|
|
1972
|
+
if (!existsSync4(STATE_PATH)) {
|
|
1973
|
+
throw new Error(`No telemetry at ${STATE_PATH} \u2014 is the dev server running with a lab open?`);
|
|
1974
|
+
}
|
|
1975
|
+
const state = JSON.parse(readFileSync2(STATE_PATH, "utf8"));
|
|
1976
|
+
const sel = state?.selection;
|
|
1977
|
+
if (!sel?.source?.file) {
|
|
1978
|
+
throw new Error(
|
|
1979
|
+
"No mesh selected yet. Open the lab in inspect mode and left-click something first."
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
const rawFile = String(sel.source.file);
|
|
1983
|
+
const fsPath = rawFile.replace(/^https?:\/\/[^/]+\//, "").replace(/^file:\/\//, "").replace(/[?#].*$/, "");
|
|
1984
|
+
const absPath = fsPath.startsWith("/") ? fsPath : join4(process.cwd(), fsPath);
|
|
1985
|
+
const line = Number(sel.source.line ?? 1);
|
|
1986
|
+
const col = Number(sel.source.col ?? 1);
|
|
1987
|
+
const cmd = editor ?? process.env.EDITOR ?? "code";
|
|
1988
|
+
const usesGoto = /code\b/.test(cmd);
|
|
1989
|
+
const args = usesGoto ? ["--goto", `${absPath}:${line}:${col}`] : [`${absPath}:${line}:${col}`];
|
|
1990
|
+
return await new Promise((resolve2, reject) => {
|
|
1991
|
+
const child = spawn2(cmd, args, {
|
|
1992
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1993
|
+
detached: true,
|
|
1994
|
+
shell: NEED_SHELL
|
|
1995
|
+
});
|
|
1996
|
+
let stderr = "";
|
|
1997
|
+
child.stderr.on("data", (d) => stderr += d);
|
|
1998
|
+
child.on("error", (err) => reject(new Error(`failed to spawn ${cmd}: ${err.message}`)));
|
|
1999
|
+
setTimeout(() => {
|
|
2000
|
+
try {
|
|
2001
|
+
child.unref();
|
|
2002
|
+
} catch {
|
|
2003
|
+
}
|
|
2004
|
+
resolve2({ ok: true, cmd, args, file: absPath, line, col, stderr: stderr || void 0 });
|
|
2005
|
+
}, 200);
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
async function runSmoke({ element }) {
|
|
2009
|
+
return new Promise((resolve2, reject) => {
|
|
2010
|
+
const args = ["smoke"];
|
|
2011
|
+
if (element) args.push(element);
|
|
2012
|
+
const child = spawn2("triscope", args, { stdio: ["ignore", "pipe", "pipe"], shell: NEED_SHELL });
|
|
2013
|
+
let out = "";
|
|
2014
|
+
let err = "";
|
|
2015
|
+
child.stdout.on("data", (d) => out += d);
|
|
2016
|
+
child.stderr.on("data", (d) => err += d);
|
|
2017
|
+
child.on("error", reject);
|
|
2018
|
+
child.on("exit", (code) => resolve2({ exitCode: code ?? 0, stdout: out, stderr: err }));
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
async function importElement({ source, name }) {
|
|
2022
|
+
rejectFlagArg(source, "source");
|
|
2023
|
+
if (name !== void 0) rejectFlagArg(name, "name");
|
|
2024
|
+
return new Promise((resolve2, reject) => {
|
|
2025
|
+
const args = ["import", source];
|
|
2026
|
+
if (name) args.push("--name", name);
|
|
2027
|
+
const child = spawn2(TRISCOPE_BIN, args, { stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
2028
|
+
let out = "";
|
|
2029
|
+
let err = "";
|
|
2030
|
+
child.stdout.on("data", (d) => out += d);
|
|
2031
|
+
child.stderr.on("data", (d) => err += d);
|
|
2032
|
+
child.on("error", reject);
|
|
2033
|
+
child.on("exit", (code) => resolve2({ exitCode: code ?? 0, stdout: out, stderr: err }));
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
async function scaffoldFromGltf({ file, name }) {
|
|
2037
|
+
rejectFlagArg(file, "file");
|
|
2038
|
+
if (name !== void 0) rejectFlagArg(name, "name");
|
|
2039
|
+
return new Promise((resolve2, reject) => {
|
|
2040
|
+
const args = ["new-gltf", file];
|
|
2041
|
+
if (name) args.push("--name", name);
|
|
2042
|
+
const child = spawn2(TRISCOPE_BIN, args, { stdio: ["ignore", "pipe", "pipe"], shell: false });
|
|
2043
|
+
let out = "";
|
|
2044
|
+
let err = "";
|
|
2045
|
+
child.stdout.on("data", (d) => out += d);
|
|
2046
|
+
child.stderr.on("data", (d) => err += d);
|
|
2047
|
+
child.on("error", reject);
|
|
2048
|
+
child.on("exit", (code) => resolve2({ exitCode: code ?? 0, stdout: out, stderr: err }));
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
var tools = [
|
|
2052
|
+
{
|
|
2053
|
+
name: "list_elements",
|
|
2054
|
+
description: "List elements registered with the running triscope dev server. Cameras + current knob values appear per element ONLY after a browser has mounted that lab (the harness POSTs the full manifest on mount); a cold call returns just the seeded `{element, labUrl}` from package.json#triscope.labs. Pass devUrl/labUrl to target a different dev server than the one the MCP booted against.",
|
|
2055
|
+
inputSchema: {
|
|
2056
|
+
type: "object",
|
|
2057
|
+
properties: {
|
|
2058
|
+
devUrl: {
|
|
2059
|
+
type: "string",
|
|
2060
|
+
description: "Target dev-server origin (default: the boot one)."
|
|
2061
|
+
},
|
|
2062
|
+
labUrl: { type: "string", description: "A lab URL; its origin is used as devUrl." }
|
|
2063
|
+
},
|
|
2064
|
+
additionalProperties: false
|
|
2065
|
+
}
|
|
2066
|
+
},
|
|
2067
|
+
{
|
|
2068
|
+
name: "read_telemetry",
|
|
2069
|
+
description: 'Read the latest telemetry snapshot (live over GET /__state, falling back to the on-disk file \u2014 so it now works cross-project). Optional jq-style "path" (e.g. ".elements.ship.triangles", ".perf.fps") returns that slice VERBATIM. The FULL snapshot (no path) is annotated with `__source` ("live"/"disk") + `__staleMs` + a `__warning` when old (the lab tab may be backgrounded/closed). Use for hidden numeric state where screenshots lie. Pass devUrl/labUrl to read a different dev server/project.',
|
|
2070
|
+
inputSchema: {
|
|
2071
|
+
type: "object",
|
|
2072
|
+
properties: {
|
|
2073
|
+
path: { type: "string", description: "Dot-separated jq-style path into the snapshot." },
|
|
2074
|
+
devUrl: {
|
|
2075
|
+
type: "string",
|
|
2076
|
+
description: "Target dev-server origin (default: the boot one)."
|
|
2077
|
+
},
|
|
2078
|
+
labUrl: { type: "string", description: "A lab URL; its origin is used as devUrl." }
|
|
2079
|
+
},
|
|
2080
|
+
additionalProperties: false
|
|
2081
|
+
}
|
|
2082
|
+
},
|
|
2083
|
+
{
|
|
2084
|
+
name: "set_knob",
|
|
2085
|
+
description: "Live-update one or many knobs in a single round trip. Either pass {element,key,value} for a single update OR {updates:[{element,key,value},...]} to batch. Use absolute values, never deltas. Changes take effect in the running browser within ~100 ms. Out-of-range / wrong-type values are clamped to the knob spec before they apply; when that happens the response includes clamped:[{element,key,requested,final}] so you know what actually took effect. Pass devUrl/labUrl to target a different dev server.",
|
|
2086
|
+
inputSchema: {
|
|
2087
|
+
type: "object",
|
|
2088
|
+
properties: {
|
|
2089
|
+
element: { type: "string", description: "Element name (single-update form)." },
|
|
2090
|
+
key: { type: "string", description: "Knob key (single-update form)." },
|
|
2091
|
+
value: { description: 'Absolute value (number, "#aabbcc" color, boolean).' },
|
|
2092
|
+
updates: {
|
|
2093
|
+
type: "array",
|
|
2094
|
+
description: "Batch form: array of {element,key,value} entries applied atomically.",
|
|
2095
|
+
items: {
|
|
2096
|
+
type: "object",
|
|
2097
|
+
properties: {
|
|
2098
|
+
element: { type: "string" },
|
|
2099
|
+
key: { type: "string" },
|
|
2100
|
+
value: {}
|
|
2101
|
+
},
|
|
2102
|
+
required: ["element", "key", "value"]
|
|
2103
|
+
}
|
|
2104
|
+
},
|
|
2105
|
+
devUrl: {
|
|
2106
|
+
type: "string",
|
|
2107
|
+
description: "Target dev-server origin (default: the boot one)."
|
|
2108
|
+
},
|
|
2109
|
+
labUrl: { type: "string", description: "A lab URL; its origin is used as devUrl." }
|
|
2110
|
+
},
|
|
2111
|
+
additionalProperties: false
|
|
2112
|
+
}
|
|
2113
|
+
},
|
|
2114
|
+
{
|
|
2115
|
+
name: "capture_views",
|
|
2116
|
+
description: "Spawn Chromium against a lab page (resolved via Element.labUrl in the manifest, package.json#triscope.labs, or /labs/<element>.html as fallback) and render every named camera. Writes PNGs to /tmp/<project>-capture-<element>/<camera>.png AND returns each image inline as MCP image content blocks (so the model sees them directly without a Read call). Set inline=false to return paths only (smaller payload). The response includes blackFrames:[camera,...] (panes the GPU drew black \u2014 luminance below threshold, a render failure) AND flatFrames:[camera,...] (panes with dynamicRange\u22481, i.e. only the flat clear-color is in view \u2014 the element drifted out of frame or was not drawn; blackFrames misses this when the clear-color is above the black threshold).",
|
|
2117
|
+
inputSchema: {
|
|
2118
|
+
type: "object",
|
|
2119
|
+
properties: {
|
|
2120
|
+
element: {
|
|
2121
|
+
type: "string",
|
|
2122
|
+
description: "Element name. URL is resolved via manifest/config."
|
|
2123
|
+
},
|
|
2124
|
+
labUrl: {
|
|
2125
|
+
type: "string",
|
|
2126
|
+
description: "Override the lab URL entirely (highest precedence)."
|
|
2127
|
+
},
|
|
2128
|
+
inline: {
|
|
2129
|
+
type: "boolean",
|
|
2130
|
+
description: "Return images as inline content blocks. Default false \u2014 safer for many-camera elements where the inline base64 payload can blow the MCP stdio message budget. Set true only when you specifically want inline.",
|
|
2131
|
+
default: false
|
|
2132
|
+
},
|
|
2133
|
+
fresh: {
|
|
2134
|
+
type: "boolean",
|
|
2135
|
+
description: "Force a full page reload before capturing, so a source edit the pooled page would otherwise miss (e.g. a *.lab.ts change) is picked up. Costs a reload (~1-3s) and drops transient set_uniform writes. Default false.",
|
|
2136
|
+
default: false
|
|
2137
|
+
}
|
|
2138
|
+
},
|
|
2139
|
+
additionalProperties: false
|
|
2140
|
+
}
|
|
2141
|
+
},
|
|
2142
|
+
{
|
|
2143
|
+
name: "set_reference",
|
|
2144
|
+
description: "Save a reference image for an (element, camera) pair under <project>/refs/<element>/<camera>.png. Accepts EITHER a `path` to a file on disk (e.g. a chat-attachment path) OR `base64` inline PNG data. Use this when the user pastes a reference image they want the AI to converge toward.",
|
|
2145
|
+
inputSchema: {
|
|
2146
|
+
type: "object",
|
|
2147
|
+
properties: {
|
|
2148
|
+
element: { type: "string", description: "Element name." },
|
|
2149
|
+
camera: { type: "string", description: "Camera name (must match Element.cameras key)." },
|
|
2150
|
+
path: {
|
|
2151
|
+
type: "string",
|
|
2152
|
+
description: "Filesystem path to a PNG/JPEG (one of path or base64 required)."
|
|
2153
|
+
},
|
|
2154
|
+
base64: {
|
|
2155
|
+
type: "string",
|
|
2156
|
+
description: "Base64-encoded PNG (with or without data: prefix)."
|
|
2157
|
+
}
|
|
2158
|
+
},
|
|
2159
|
+
required: ["element", "camera"],
|
|
2160
|
+
additionalProperties: false
|
|
2161
|
+
}
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
name: "diff_reference",
|
|
2165
|
+
description: "Capture the current view at (element, camera), diff it against the stored reference, and return: numeric meanAbsDiff (0-255) + ssim (1=identical); an 8\xD78 per-tile SSIM grid + the worstTile {row,col,ssim} so you know WHERE they differ; the side-by-side composite (left=ref, right=current); and a black\u2192blue\u2192yellow\u2192red difference heatmap (hot = divergent region) as a second image. Requires a prior set_reference for the same (element, camera).",
|
|
2166
|
+
inputSchema: {
|
|
2167
|
+
type: "object",
|
|
2168
|
+
properties: {
|
|
2169
|
+
element: { type: "string", description: "Element name." },
|
|
2170
|
+
camera: { type: "string", description: "Camera name." },
|
|
2171
|
+
labUrl: {
|
|
2172
|
+
type: "string",
|
|
2173
|
+
description: "Override the lab URL (otherwise resolved like capture_views)."
|
|
2174
|
+
},
|
|
2175
|
+
fresh: {
|
|
2176
|
+
type: "boolean",
|
|
2177
|
+
description: "Force a full reload before capturing (pick up source edits). Default false."
|
|
2178
|
+
}
|
|
2179
|
+
},
|
|
2180
|
+
required: ["element", "camera"],
|
|
2181
|
+
additionalProperties: false
|
|
2182
|
+
}
|
|
2183
|
+
},
|
|
2184
|
+
{
|
|
2185
|
+
name: "set_reference_motion",
|
|
2186
|
+
description: "Capture the CURRENT motion sequence at (element, camera) and save it as the animated reference. Writes <project>/refs/<element>/<camera>.motion.png (filmstrip) + <camera>.motion.json (frames/dt/mode metadata). Use to lock in a known-good animation before risky shader/uniform edits, then diff_reference_motion confirms regressions visually + numerically.",
|
|
2187
|
+
inputSchema: {
|
|
2188
|
+
type: "object",
|
|
2189
|
+
properties: {
|
|
2190
|
+
element: { type: "string" },
|
|
2191
|
+
camera: { type: "string" },
|
|
2192
|
+
frames: { type: "number", description: "Default 6." },
|
|
2193
|
+
dt: { type: "number", description: "Seconds between frames. Default 0.25." },
|
|
2194
|
+
mode: { type: "string", enum: ["time", "real"], description: 'Default "time".' },
|
|
2195
|
+
labUrl: { type: "string" }
|
|
2196
|
+
},
|
|
2197
|
+
required: ["element", "camera"],
|
|
2198
|
+
additionalProperties: false
|
|
2199
|
+
}
|
|
2200
|
+
},
|
|
2201
|
+
{
|
|
2202
|
+
name: "diff_reference_motion",
|
|
2203
|
+
description: "Capture current motion at (element, camera), diff against the saved animated reference. Returns a vertically-stacked composite (reference filmstrip on top, current on bottom) inline AND a scalar motionDiff (0=identical animation, >5=visible drift, >30=clearly different). Requires a prior set_reference_motion for the same (element, camera).",
|
|
2204
|
+
inputSchema: {
|
|
2205
|
+
type: "object",
|
|
2206
|
+
properties: {
|
|
2207
|
+
element: { type: "string" },
|
|
2208
|
+
camera: { type: "string" },
|
|
2209
|
+
frames: { type: "number" },
|
|
2210
|
+
dt: { type: "number" },
|
|
2211
|
+
mode: { type: "string", enum: ["time", "real"] },
|
|
2212
|
+
labUrl: { type: "string" }
|
|
2213
|
+
},
|
|
2214
|
+
required: ["element", "camera"],
|
|
2215
|
+
additionalProperties: false
|
|
2216
|
+
}
|
|
2217
|
+
},
|
|
2218
|
+
{
|
|
2219
|
+
name: "capture_motion",
|
|
2220
|
+
description: "Capture N frames per camera spaced by dt seconds, compose each into an inline filmstrip image (frames tiled left-to-right), and return a numeric motionMagnitude per camera (0-255 scale; <1 = static, >5 = visible motion, >20 = vigorous). Use this WHEN THE ELEMENT HAS ANIMATION (shader-driven motion, sail billow, particle systems, oscillation) \u2014 a single capture_views frame cannot reveal whether motion is happening. For complementary numeric verification of hidden animated state, read_telemetry .elements.<name>.motion (if the Element declared motionProbes).",
|
|
2221
|
+
inputSchema: {
|
|
2222
|
+
type: "object",
|
|
2223
|
+
properties: {
|
|
2224
|
+
element: { type: "string" },
|
|
2225
|
+
camera: {
|
|
2226
|
+
type: "string",
|
|
2227
|
+
description: "Single camera. Omit to capture all cameras (one filmstrip each)."
|
|
2228
|
+
},
|
|
2229
|
+
frames: { type: "number", description: "Frames per filmstrip. Default 6." },
|
|
2230
|
+
dt: { type: "number", description: "Seconds between captured frames. Default 0.25." },
|
|
2231
|
+
mode: {
|
|
2232
|
+
type: "string",
|
|
2233
|
+
enum: ["time", "real"],
|
|
2234
|
+
description: '"time" (default) is deterministic (steps time.value, fast). "real" runs wall-clock (slower; needed for CPU-integrated state).'
|
|
2235
|
+
},
|
|
2236
|
+
labUrl: {
|
|
2237
|
+
type: "string",
|
|
2238
|
+
description: "Override the lab URL (otherwise resolved like capture_views)."
|
|
2239
|
+
},
|
|
2240
|
+
inline: {
|
|
2241
|
+
type: "boolean",
|
|
2242
|
+
description: "Include filmstrips as inline images. Default true."
|
|
2243
|
+
},
|
|
2244
|
+
fresh: {
|
|
2245
|
+
type: "boolean",
|
|
2246
|
+
description: "Force a full reload before capturing (pick up source edits). Default false."
|
|
2247
|
+
}
|
|
2248
|
+
},
|
|
2249
|
+
required: ["element"],
|
|
2250
|
+
additionalProperties: false
|
|
2251
|
+
}
|
|
2252
|
+
},
|
|
2253
|
+
{
|
|
2254
|
+
name: "health",
|
|
2255
|
+
description: 'Server health snapshot. Returns uptime, dev-server reachability, browser-pool state, pid, recent errors (last 16). Call this when other tools misbehave: a "Connection closed" error from a capture tool followed by a healthy health() call means the MCP server is alive but the browser pool needs to recover; a failed health() means the server itself is sick.',
|
|
2256
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
2257
|
+
},
|
|
2258
|
+
{
|
|
2259
|
+
name: "run_smoke",
|
|
2260
|
+
description: "Run the headed-Chromium smoke harness against a lab page. Returns exit code, stdout, stderr. Use as a CI gate after a batch of knob changes.",
|
|
2261
|
+
inputSchema: {
|
|
2262
|
+
type: "object",
|
|
2263
|
+
properties: {
|
|
2264
|
+
element: {
|
|
2265
|
+
type: "string",
|
|
2266
|
+
description: "Element lab to test (defaults to the scene lab)."
|
|
2267
|
+
}
|
|
2268
|
+
},
|
|
2269
|
+
additionalProperties: false
|
|
2270
|
+
}
|
|
2271
|
+
},
|
|
2272
|
+
{
|
|
2273
|
+
name: "import_element",
|
|
2274
|
+
description: "Install a published element package (naming convention `triscope-element-<name>`, or `github:user/repo`) into the current project and wire its lab page + vite input + package.json#triscope.labs. Runs `triscope import`; returns exit code + output. After it succeeds, capture_views/inspect_scene the new element by name.",
|
|
2275
|
+
inputSchema: {
|
|
2276
|
+
type: "object",
|
|
2277
|
+
properties: {
|
|
2278
|
+
source: { type: "string", description: "npm package or github:user/repo[#ref]." },
|
|
2279
|
+
name: { type: "string", description: "Override the local element/lab name." }
|
|
2280
|
+
},
|
|
2281
|
+
required: ["source"],
|
|
2282
|
+
additionalProperties: false
|
|
2283
|
+
}
|
|
2284
|
+
},
|
|
2285
|
+
{
|
|
2286
|
+
name: "scaffold_from_gltf",
|
|
2287
|
+
description: "Generate a wired triscope Element from a glTF/GLB asset path: auto-detects bounds + animation clips, copies the asset to /public, and writes src/elements/<name>.ts (GLTFLoader + AnimationMixer + fitted cameras + scale knob + dispose). Runs `triscope new-gltf`; returns exit code + output. Saves ~40-100 lines of loader/dispose boilerplate when bringing in a model.",
|
|
2288
|
+
inputSchema: {
|
|
2289
|
+
type: "object",
|
|
2290
|
+
properties: {
|
|
2291
|
+
file: { type: "string", description: "Path to a .glb or .gltf file." },
|
|
2292
|
+
name: {
|
|
2293
|
+
type: "string",
|
|
2294
|
+
description: "Override the element name (default: file basename)."
|
|
2295
|
+
}
|
|
2296
|
+
},
|
|
2297
|
+
required: ["file"],
|
|
2298
|
+
additionalProperties: false
|
|
2299
|
+
}
|
|
2300
|
+
},
|
|
2301
|
+
{
|
|
2302
|
+
name: "inspect_scene",
|
|
2303
|
+
description: 'Snapshot the live scene graph: every mesh/light/group with triangleCount, worldPosition, materialKind + materialColor, uniformNames, and the file:line `source` where it was added. Answers "what is in this scene / which object is X / where is its code" in ONE call instead of many capture_views probes. Returns `nodes` (sorted by triangle count, capped at maxNodes \u2014 default 120; `truncated`+`total` flag the rest, raise maxNodes to see it) PLUS a separate `lights` array \u2014 every light with type/intensity/color/position, ALWAYS included regardless of the cap (a named light is then read/writable via read_uniform/set_uniform "name.intensity").',
|
|
2304
|
+
inputSchema: {
|
|
2305
|
+
type: "object",
|
|
2306
|
+
properties: {
|
|
2307
|
+
element: {
|
|
2308
|
+
type: "string",
|
|
2309
|
+
description: "Element/lab to introspect (resolved like capture_views). Omit for the scene lab."
|
|
2310
|
+
},
|
|
2311
|
+
maxNodes: { type: "number", description: "Cap on returned nodes. Default 500." },
|
|
2312
|
+
labUrl: { type: "string", description: "Override the lab URL." },
|
|
2313
|
+
fresh: {
|
|
2314
|
+
type: "boolean",
|
|
2315
|
+
description: "Force a full reload first (pick up source edits). Default false."
|
|
2316
|
+
}
|
|
2317
|
+
},
|
|
2318
|
+
additionalProperties: false
|
|
2319
|
+
}
|
|
2320
|
+
},
|
|
2321
|
+
{
|
|
2322
|
+
name: "read_uniform",
|
|
2323
|
+
description: `Read ANY live material uniform / material property / object property by "objectName|uuid.key" path \u2014 even values never declared as knobs (e.g. "ship.metalness", "sun.intensity", a shader uniform "ocean.uChoppiness"). Use inspect_scene first to discover object names + uniformNames. Returns {kind, value} (colors as #hex, vectors as arrays); kind="not-found" if the path doesn't resolve.`,
|
|
2324
|
+
inputSchema: {
|
|
2325
|
+
type: "object",
|
|
2326
|
+
properties: {
|
|
2327
|
+
element: { type: "string", description: "Element/lab (resolved like capture_views)." },
|
|
2328
|
+
path: { type: "string", description: '"<objectName|uuid>.<key>" \u2014 split on the last dot.' },
|
|
2329
|
+
labUrl: { type: "string" }
|
|
2330
|
+
},
|
|
2331
|
+
required: ["path"],
|
|
2332
|
+
additionalProperties: false
|
|
2333
|
+
}
|
|
2334
|
+
},
|
|
2335
|
+
{
|
|
2336
|
+
name: "set_uniform",
|
|
2337
|
+
description: `Write ANY live material uniform / material property / object property by "objectName|uuid.key" path WITHOUT a source edit or reload \u2014 for probing a shader parameter that isn't a declared knob. Colors accept "#rrggbb" or a number; vectors accept [x,y,z]. TRANSIENT: not persisted across reloads (use set_knob for values you want to keep). Returns {ok, previous, current}.`,
|
|
2338
|
+
inputSchema: {
|
|
2339
|
+
type: "object",
|
|
2340
|
+
properties: {
|
|
2341
|
+
element: { type: "string" },
|
|
2342
|
+
path: { type: "string", description: '"<objectName|uuid>.<key>".' },
|
|
2343
|
+
value: { description: 'Number, "#hex" / numeric color, boolean, or [x,y,z] vector.' },
|
|
2344
|
+
labUrl: { type: "string" }
|
|
2345
|
+
},
|
|
2346
|
+
required: ["path", "value"],
|
|
2347
|
+
additionalProperties: false
|
|
2348
|
+
}
|
|
2349
|
+
},
|
|
2350
|
+
{
|
|
2351
|
+
name: "inspect",
|
|
2352
|
+
description: 'Open the lab for an element in interactive inspect mode (solo full-canvas camera + OrbitControls + click-to-pick). Navigates the running browser via CDP to ?inspect=<element>&camera=<name>. Use when the user asks to "inspect" or "open" an element so they can rotate and click parts of it; subsequent clicks populate .selection in telemetry with source file:line.',
|
|
2353
|
+
inputSchema: {
|
|
2354
|
+
type: "object",
|
|
2355
|
+
properties: {
|
|
2356
|
+
element: { type: "string", description: "Element to inspect (must match the manifest)." },
|
|
2357
|
+
camera: {
|
|
2358
|
+
type: "string",
|
|
2359
|
+
description: "Starting camera (defaults to the element's first declared camera)."
|
|
2360
|
+
}
|
|
2361
|
+
},
|
|
2362
|
+
required: ["element"],
|
|
2363
|
+
additionalProperties: false
|
|
2364
|
+
}
|
|
2365
|
+
},
|
|
2366
|
+
{
|
|
2367
|
+
name: "open_selection",
|
|
2368
|
+
description: 'Open the file:line of the currently selected mesh (from inspect mode) in the user\'s editor. Reads .selection.source from the telemetry snapshot and spawns $EDITOR (or `code --goto` by default). Use after the user clicks a mesh and says "open this" / "show me the code".',
|
|
2369
|
+
inputSchema: {
|
|
2370
|
+
type: "object",
|
|
2371
|
+
properties: {
|
|
2372
|
+
editor: {
|
|
2373
|
+
type: "string",
|
|
2374
|
+
description: "Override the editor command. Default: $EDITOR or `code`."
|
|
2375
|
+
}
|
|
2376
|
+
},
|
|
2377
|
+
additionalProperties: false
|
|
2378
|
+
}
|
|
2379
|
+
},
|
|
2380
|
+
{
|
|
2381
|
+
name: "snapshot",
|
|
2382
|
+
description: "Freeze the current tuning state as a git tag (triscope/snapshot/<name>). Stores the HEAD commit + every persisted knob value across all elements, as JSON inside the tag's annotated message \u2014 no working-tree files written, no rebase noise. Refuses on a dirty working tree (would silently lose the in-progress edits on restore).",
|
|
2383
|
+
inputSchema: {
|
|
2384
|
+
type: "object",
|
|
2385
|
+
properties: {
|
|
2386
|
+
name: { type: "string", description: "Snapshot name. Must match [A-Za-z0-9._-]+." },
|
|
2387
|
+
message: {
|
|
2388
|
+
type: "string",
|
|
2389
|
+
description: "Optional human note for `git show triscope/snapshot/<name>`."
|
|
2390
|
+
}
|
|
2391
|
+
},
|
|
2392
|
+
required: ["name"],
|
|
2393
|
+
additionalProperties: false
|
|
2394
|
+
}
|
|
2395
|
+
},
|
|
2396
|
+
{
|
|
2397
|
+
name: "restore",
|
|
2398
|
+
description: "Restore a snapshot: git checkout the recorded commit and re-post every knob value via /__knob. Refuses on a dirty working tree. Leaves HEAD detached \u2014 branch from there if you want to keep iterating.",
|
|
2399
|
+
inputSchema: {
|
|
2400
|
+
type: "object",
|
|
2401
|
+
properties: {
|
|
2402
|
+
name: {
|
|
2403
|
+
type: "string",
|
|
2404
|
+
description: "Snapshot name (matches `mcp__triscope__list_snapshots`)."
|
|
2405
|
+
}
|
|
2406
|
+
},
|
|
2407
|
+
required: ["name"],
|
|
2408
|
+
additionalProperties: false
|
|
2409
|
+
}
|
|
2410
|
+
},
|
|
2411
|
+
{
|
|
2412
|
+
name: "list_snapshots",
|
|
2413
|
+
description: "List every triscope snapshot tag in this repo (name, creation date, message subject). Use to find which one to restore.",
|
|
2414
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
name: "auto_tune",
|
|
2418
|
+
description: "Find the knob value that maximises SSIM (perceptual similarity) between the captured view and a stored reference image, using derivative-free golden-section search. Requires a prior set_reference for (element, target_camera). Iterations: post_knob \u2192 wait \u2192 captureViews \u2192 diff_reference. Use to converge a single shader parameter on a reference photo without manual bisection.",
|
|
2419
|
+
inputSchema: {
|
|
2420
|
+
type: "object",
|
|
2421
|
+
properties: {
|
|
2422
|
+
element: { type: "string", description: "Element whose knob to tune." },
|
|
2423
|
+
knob: {
|
|
2424
|
+
type: "string",
|
|
2425
|
+
description: "Knob key (must exist on Element.knobs and be type=number)."
|
|
2426
|
+
},
|
|
2427
|
+
range: {
|
|
2428
|
+
type: "array",
|
|
2429
|
+
description: "Inclusive [min, max] search bracket. Should cover the knob's declared min/max.",
|
|
2430
|
+
items: { type: "number" },
|
|
2431
|
+
minItems: 2,
|
|
2432
|
+
maxItems: 2
|
|
2433
|
+
},
|
|
2434
|
+
target_camera: {
|
|
2435
|
+
type: "string",
|
|
2436
|
+
description: "Camera the SSIM is computed on. Must have a stored reference (set_reference first)."
|
|
2437
|
+
},
|
|
2438
|
+
max_iterations: {
|
|
2439
|
+
type: "number",
|
|
2440
|
+
description: "Cap on knob evaluations. Default 12 (golden section converges to ~0.7% of range)."
|
|
2441
|
+
},
|
|
2442
|
+
labUrl: {
|
|
2443
|
+
type: "string",
|
|
2444
|
+
description: "Override the lab URL (otherwise resolved like capture_views)."
|
|
2445
|
+
}
|
|
2446
|
+
},
|
|
2447
|
+
required: ["element", "knob", "range", "target_camera"],
|
|
2448
|
+
additionalProperties: false
|
|
2449
|
+
}
|
|
2450
|
+
},
|
|
2451
|
+
{
|
|
2452
|
+
name: "multi_tune",
|
|
2453
|
+
description: "Converge 2-N coupled knobs on a reference image at once via coordinate descent (golden-section per knob, repeated cycles) maximising SSIM. Use when several knobs jointly shape the look (e.g. choppiness + foamThreshold + exposure) \u2014 beats chaining single-knob auto_tune. Requires a prior set_reference for (element, target_camera). Hard-capped on total captures so wall time stays bounded; best with <=4 knobs. Leaves the lab at the converged values.",
|
|
2454
|
+
inputSchema: {
|
|
2455
|
+
type: "object",
|
|
2456
|
+
properties: {
|
|
2457
|
+
element: { type: "string" },
|
|
2458
|
+
knobs: {
|
|
2459
|
+
type: "array",
|
|
2460
|
+
minItems: 1,
|
|
2461
|
+
description: "Knobs to co-tune: [{key, range:[min,max], start?}]. Keep to <=4 for reasonable wall time.",
|
|
2462
|
+
items: {
|
|
2463
|
+
type: "object",
|
|
2464
|
+
properties: {
|
|
2465
|
+
key: { type: "string" },
|
|
2466
|
+
range: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 },
|
|
2467
|
+
start: { type: "number" }
|
|
2468
|
+
},
|
|
2469
|
+
required: ["key", "range"],
|
|
2470
|
+
additionalProperties: false
|
|
2471
|
+
}
|
|
2472
|
+
},
|
|
2473
|
+
target_camera: {
|
|
2474
|
+
type: "string",
|
|
2475
|
+
description: "Camera the SSIM is computed on (needs a stored reference)."
|
|
2476
|
+
},
|
|
2477
|
+
max_cycles: { type: "number", description: "Passes over all knobs. Default 2." },
|
|
2478
|
+
per_knob_iters: {
|
|
2479
|
+
type: "number",
|
|
2480
|
+
description: "Golden-section evals per knob per cycle. Default 8."
|
|
2481
|
+
},
|
|
2482
|
+
compute_interactions: {
|
|
2483
|
+
type: "boolean",
|
|
2484
|
+
description: "Also return a pairwise knob-interaction matrix (extra evals). Default false."
|
|
2485
|
+
},
|
|
2486
|
+
max_evaluations: {
|
|
2487
|
+
type: "number",
|
|
2488
|
+
description: "Hard cap on total captures. Default min(knobs*iters*cycles+4, 64)."
|
|
2489
|
+
},
|
|
2490
|
+
labUrl: { type: "string" }
|
|
2491
|
+
},
|
|
2492
|
+
required: ["element", "knobs", "target_camera"],
|
|
2493
|
+
additionalProperties: false
|
|
2494
|
+
}
|
|
2495
|
+
},
|
|
2496
|
+
{
|
|
2497
|
+
name: "check_targets",
|
|
2498
|
+
description: 'Evaluate per-camera convergence constraints from .claude/element-targets.json against the current capture \u2014 a machine-readable "is this view done?". Constraints per camera: ssim (min), meanAbsDiff (max), luminance {min,max}, dynamicRange {min,max}. ssim/meanAbsDiff need a stored reference (skipped, not failed, when absent); luminance/dynamicRange come from the GPU probe. Returns per-constraint pass/fail + allPassed. No targets file \u2192 no-op pass.',
|
|
2499
|
+
inputSchema: {
|
|
2500
|
+
type: "object",
|
|
2501
|
+
properties: {
|
|
2502
|
+
element: { type: "string" },
|
|
2503
|
+
labUrl: { type: "string" }
|
|
2504
|
+
},
|
|
2505
|
+
required: ["element"],
|
|
2506
|
+
additionalProperties: false
|
|
2507
|
+
}
|
|
2508
|
+
},
|
|
2509
|
+
{
|
|
2510
|
+
name: "set_scene_param",
|
|
2511
|
+
description: 'Live-mutate the scene WITHOUT a code edit or reload (Scene Description Layer): repoint cameras (position/target/fov), override knobs, and show/hide composed elements (enabled true/false \u2014 the "solo a track" model). Applied by the harness within ~100 ms and persisted so it survives a reload. NOTE: `elements` toggles visibility of elements already composed into the scene; instantiating a brand-new element type still needs a code edit.',
|
|
2512
|
+
inputSchema: {
|
|
2513
|
+
type: "object",
|
|
2514
|
+
properties: {
|
|
2515
|
+
cameras: {
|
|
2516
|
+
type: "object",
|
|
2517
|
+
description: "Map of cameraName \u2192 {position:[x,y,z], target:[x,y,z], fov} (any subset).",
|
|
2518
|
+
additionalProperties: {
|
|
2519
|
+
type: "object",
|
|
2520
|
+
properties: {
|
|
2521
|
+
position: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 },
|
|
2522
|
+
target: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 },
|
|
2523
|
+
fov: { type: "number" }
|
|
2524
|
+
},
|
|
2525
|
+
additionalProperties: false
|
|
2526
|
+
}
|
|
2527
|
+
},
|
|
2528
|
+
knobs: {
|
|
2529
|
+
type: "object",
|
|
2530
|
+
description: "Map of knobKey \u2192 value (same effect as set_knob; here for one-call scene edits)."
|
|
2531
|
+
},
|
|
2532
|
+
elements: {
|
|
2533
|
+
type: "object",
|
|
2534
|
+
description: "Map of elementName \u2192 {enabled:boolean} to show/hide a composed element live.",
|
|
2535
|
+
additionalProperties: {
|
|
2536
|
+
type: "object",
|
|
2537
|
+
properties: { enabled: { type: "boolean" } },
|
|
2538
|
+
additionalProperties: false
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
},
|
|
2542
|
+
additionalProperties: false
|
|
2543
|
+
}
|
|
2544
|
+
},
|
|
2545
|
+
{
|
|
2546
|
+
name: "get_scene",
|
|
2547
|
+
description: "Return the current scene: `sceneSpec` (the persisted SDL overrides from set_scene_param) and `live` (the camera positions/targets/fov + knob values from telemetry). Use to see what a viewpoint currently is before repointing it. Pass devUrl/labUrl to target a different dev server.",
|
|
2548
|
+
inputSchema: {
|
|
2549
|
+
type: "object",
|
|
2550
|
+
properties: {
|
|
2551
|
+
devUrl: {
|
|
2552
|
+
type: "string",
|
|
2553
|
+
description: "Target dev-server origin (default: the boot one)."
|
|
2554
|
+
},
|
|
2555
|
+
labUrl: { type: "string", description: "A lab URL; its origin is used as devUrl." }
|
|
2556
|
+
},
|
|
2557
|
+
additionalProperties: false
|
|
2558
|
+
}
|
|
2559
|
+
},
|
|
2560
|
+
{
|
|
2561
|
+
name: "add_element",
|
|
2562
|
+
description: "Instantiate a registered element into a runSceneLab scene LIVE (no reload): mounts it, adds its namespaced `<element>.<camera>` cameras + `<element>.<knob>` knobs, and rebuilds the grid. Returns {ok, mounted, available}. Only works on a multi-element scene booted with runSceneLab \u2014 on a single-element runLab page it returns {ok:false}. Use `available` from a prior add/remove (or list_elements) to see mountable names.",
|
|
2563
|
+
inputSchema: {
|
|
2564
|
+
type: "object",
|
|
2565
|
+
properties: {
|
|
2566
|
+
name: { type: "string", description: "Registered element name to mount." },
|
|
2567
|
+
element: {
|
|
2568
|
+
type: "string",
|
|
2569
|
+
description: "Any element in the scene, used only to locate the lab page."
|
|
2570
|
+
},
|
|
2571
|
+
labUrl: { type: "string", description: "Explicit lab URL (alternative to element)." }
|
|
2572
|
+
},
|
|
2573
|
+
required: ["name"],
|
|
2574
|
+
additionalProperties: false
|
|
2575
|
+
}
|
|
2576
|
+
},
|
|
2577
|
+
{
|
|
2578
|
+
name: "remove_element",
|
|
2579
|
+
description: "Dispose a mounted element from a runSceneLab scene LIVE (no reload): drops its cameras/knobs/telemetry and rebuilds the grid. The element stays in the registry and can be re-added with add_element. Returns {ok, mounted, available}.",
|
|
2580
|
+
inputSchema: {
|
|
2581
|
+
type: "object",
|
|
2582
|
+
properties: {
|
|
2583
|
+
name: { type: "string", description: "Mounted element name to dispose." },
|
|
2584
|
+
element: {
|
|
2585
|
+
type: "string",
|
|
2586
|
+
description: "Any element in the scene, used only to locate the lab page."
|
|
2587
|
+
},
|
|
2588
|
+
labUrl: { type: "string", description: "Explicit lab URL (alternative to element)." }
|
|
2589
|
+
},
|
|
2590
|
+
required: ["name"],
|
|
2591
|
+
additionalProperties: false
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
];
|
|
2595
|
+
function jsonResult(value) {
|
|
2596
|
+
let text;
|
|
2597
|
+
if (value === void 0) text = "undefined";
|
|
2598
|
+
else if (typeof value === "string") text = value;
|
|
2599
|
+
else text = JSON.stringify(value, null, 2);
|
|
2600
|
+
return {
|
|
2601
|
+
content: [{ type: "text", text }]
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
async function startServer() {
|
|
2605
|
+
const server = new Server(
|
|
2606
|
+
// Reported to every MCP client in the `initialize` handshake — keep in sync
|
|
2607
|
+
// with packages/mcp/package.json version on each release.
|
|
2608
|
+
{ name: "triscope-mcp", version: "0.4.0" },
|
|
2609
|
+
{ capabilities: { tools: {} } }
|
|
2610
|
+
);
|
|
2611
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
2612
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
2613
|
+
const { name, arguments: args = {} } = req.params;
|
|
2614
|
+
const toolStart = Date.now();
|
|
2615
|
+
logger.info(`tool:${name}`, "invoked", { args });
|
|
2616
|
+
const finish = (outcome, extra) => logger.info(`tool:${name}`, outcome, { ms: Date.now() - toolStart, ...extra ?? {} });
|
|
2617
|
+
try {
|
|
2618
|
+
const result = await (async () => {
|
|
2619
|
+
switch (name) {
|
|
2620
|
+
case "list_elements":
|
|
2621
|
+
return jsonResult(
|
|
2622
|
+
await listElements({
|
|
2623
|
+
devUrl: args.devUrl,
|
|
2624
|
+
labUrl: args.labUrl
|
|
2625
|
+
})
|
|
2626
|
+
);
|
|
2627
|
+
case "read_telemetry":
|
|
2628
|
+
return jsonResult(
|
|
2629
|
+
await readTelemetry(args.path, {
|
|
2630
|
+
devUrl: args.devUrl,
|
|
2631
|
+
labUrl: args.labUrl
|
|
2632
|
+
})
|
|
2633
|
+
);
|
|
2634
|
+
case "set_knob": {
|
|
2635
|
+
const value = z.union([z.number(), z.string(), z.boolean()]);
|
|
2636
|
+
const update = z.object({ element: z.string(), key: z.string(), value });
|
|
2637
|
+
const schema = z.union([z.object({ updates: z.array(update).min(1) }), update]);
|
|
2638
|
+
const parsed = schema.parse(args);
|
|
2639
|
+
const loc = z.object({ devUrl: z.string().optional(), labUrl: z.string().optional() }).parse(args);
|
|
2640
|
+
return jsonResult(await setKnob({ ...parsed, ...loc }));
|
|
2641
|
+
}
|
|
2642
|
+
case "capture_views": {
|
|
2643
|
+
const res = await captureViews({
|
|
2644
|
+
element: args.element,
|
|
2645
|
+
labUrl: args.labUrl,
|
|
2646
|
+
inline: args.inline ?? false,
|
|
2647
|
+
fresh: args.fresh ?? false
|
|
2648
|
+
});
|
|
2649
|
+
const { _base64ByCam, ...summary } = res;
|
|
2650
|
+
let inlineBytes = 0;
|
|
2651
|
+
for (const b64 of Object.values(_base64ByCam)) inlineBytes += b64.length;
|
|
2652
|
+
const inlineCapped = res.inline && inlineBytes > INLINE_PAYLOAD_BUDGET;
|
|
2653
|
+
const finalInline = res.inline && !inlineCapped;
|
|
2654
|
+
const summaryWithWarn = inlineCapped ? {
|
|
2655
|
+
...summary,
|
|
2656
|
+
inline: false,
|
|
2657
|
+
inlineCapped: true,
|
|
2658
|
+
inlineWarning: `inline payload would have been ${(inlineBytes / 1048576).toFixed(1)} MB (limit ${(INLINE_PAYLOAD_BUDGET / 1048576).toFixed(0)} MB) \u2014 files are on disk, Read them by path.`
|
|
2659
|
+
} : summary;
|
|
2660
|
+
const text = JSON.stringify(summaryWithWarn, null, 2);
|
|
2661
|
+
if (!finalInline) return { content: [{ type: "text", text }] };
|
|
2662
|
+
const content = [{ type: "text", text }];
|
|
2663
|
+
for (const cam of res.cameraOrder) {
|
|
2664
|
+
const data = _base64ByCam[cam];
|
|
2665
|
+
if (!data) continue;
|
|
2666
|
+
content.push({ type: "image", data, mimeType: "image/png" });
|
|
2667
|
+
}
|
|
2668
|
+
return { content };
|
|
2669
|
+
}
|
|
2670
|
+
case "set_reference": {
|
|
2671
|
+
const parsed = z.object({
|
|
2672
|
+
element: z.string(),
|
|
2673
|
+
camera: z.string(),
|
|
2674
|
+
path: z.string().optional(),
|
|
2675
|
+
base64: z.string().optional()
|
|
2676
|
+
}).parse(args);
|
|
2677
|
+
const result2 = setReference({ cwd: process.cwd(), ...parsed });
|
|
2678
|
+
return jsonResult(result2);
|
|
2679
|
+
}
|
|
2680
|
+
case "diff_reference": {
|
|
2681
|
+
const parsed = z.object({
|
|
2682
|
+
element: z.string(),
|
|
2683
|
+
camera: z.string(),
|
|
2684
|
+
labUrl: z.string().optional(),
|
|
2685
|
+
fresh: z.boolean().optional()
|
|
2686
|
+
}).parse(args);
|
|
2687
|
+
const refExists = existsSync4(refsPath(process.cwd(), parsed.element, parsed.camera));
|
|
2688
|
+
if (!refExists) {
|
|
2689
|
+
return {
|
|
2690
|
+
isError: true,
|
|
2691
|
+
content: [
|
|
2692
|
+
{
|
|
2693
|
+
type: "text",
|
|
2694
|
+
text: `no reference at ${refsPath(process.cwd(), parsed.element, parsed.camera)}. Call set_reference first.`
|
|
2695
|
+
}
|
|
2696
|
+
]
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
const cap = await captureViews({
|
|
2700
|
+
element: parsed.element,
|
|
2701
|
+
labUrl: parsed.labUrl,
|
|
2702
|
+
inline: true,
|
|
2703
|
+
fresh: parsed.fresh ?? false
|
|
2704
|
+
});
|
|
2705
|
+
const currentBase64 = cap._base64ByCam?.[parsed.camera];
|
|
2706
|
+
if (!currentBase64) {
|
|
2707
|
+
return {
|
|
2708
|
+
isError: true,
|
|
2709
|
+
content: [
|
|
2710
|
+
{
|
|
2711
|
+
type: "text",
|
|
2712
|
+
text: `camera "${parsed.camera}" not found on element "${parsed.element}". Available: ${cap.cameraOrder.join(", ")}`
|
|
2713
|
+
}
|
|
2714
|
+
]
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
const diff = diffReference({
|
|
2718
|
+
cwd: process.cwd(),
|
|
2719
|
+
element: parsed.element,
|
|
2720
|
+
camera: parsed.camera,
|
|
2721
|
+
currentBase64
|
|
2722
|
+
});
|
|
2723
|
+
const compositeBytes = diff.compositeBase64.length;
|
|
2724
|
+
const heatmapBytes = diff.heatmapBase64?.length ?? 0;
|
|
2725
|
+
const heatmapFits = compositeBytes + heatmapBytes <= INLINE_PAYLOAD_BUDGET;
|
|
2726
|
+
const content = [
|
|
2727
|
+
{
|
|
2728
|
+
type: "text",
|
|
2729
|
+
text: JSON.stringify(
|
|
2730
|
+
{
|
|
2731
|
+
camera: diff.camera,
|
|
2732
|
+
refPath: diff.refPath,
|
|
2733
|
+
meanAbsDiff: diff.meanAbsDiff,
|
|
2734
|
+
ssim: diff.ssim,
|
|
2735
|
+
worstTile: diff.worstTile,
|
|
2736
|
+
tileStats: diff.tileStats,
|
|
2737
|
+
tileGrid: diff.tileGrid,
|
|
2738
|
+
heatmapIncluded: heatmapFits,
|
|
2739
|
+
hint: "meanAbsDiff: 0 = identical, ~30 = visibly close, >80 = clearly different. ssim: 1.0 = identical, 0.9+ = visually close, <0.7 = clearly different (prefer SSIM, robust to AA noise). tileGrid is an 8\xD78 SSIM map (row 0 = top); worstTile points at the most divergent region. The second image is a black\u2192blue\u2192yellow\u2192red difference heatmap \u2014 hot = where the frames differ."
|
|
2740
|
+
},
|
|
2741
|
+
null,
|
|
2742
|
+
2
|
|
2743
|
+
)
|
|
2744
|
+
},
|
|
2745
|
+
{ type: "image", data: diff.compositeBase64, mimeType: "image/png" }
|
|
2746
|
+
];
|
|
2747
|
+
if (heatmapFits && diff.heatmapBase64) {
|
|
2748
|
+
content.push({ type: "image", data: diff.heatmapBase64, mimeType: "image/png" });
|
|
2749
|
+
}
|
|
2750
|
+
return { content };
|
|
2751
|
+
}
|
|
2752
|
+
case "set_reference_motion": {
|
|
2753
|
+
const parsed = z.object({
|
|
2754
|
+
element: z.string(),
|
|
2755
|
+
camera: z.string(),
|
|
2756
|
+
frames: z.number().int().min(2).max(32).optional(),
|
|
2757
|
+
dt: z.number().positive().max(5).optional(),
|
|
2758
|
+
mode: z.enum(["time", "real"]).optional(),
|
|
2759
|
+
labUrl: z.string().optional()
|
|
2760
|
+
}).parse(args);
|
|
2761
|
+
const opts = {
|
|
2762
|
+
frames: parsed.frames ?? 6,
|
|
2763
|
+
dt: parsed.dt ?? 0.25,
|
|
2764
|
+
mode: parsed.mode ?? "time"
|
|
2765
|
+
};
|
|
2766
|
+
const frameB64s = await captureMotionFramesRaw({ ...parsed, ...opts });
|
|
2767
|
+
const r = setReferenceMotion({
|
|
2768
|
+
cwd: process.cwd(),
|
|
2769
|
+
element: parsed.element,
|
|
2770
|
+
camera: parsed.camera,
|
|
2771
|
+
frameBase64s: frameB64s,
|
|
2772
|
+
meta: opts
|
|
2773
|
+
});
|
|
2774
|
+
return jsonResult(r);
|
|
2775
|
+
}
|
|
2776
|
+
case "diff_reference_motion": {
|
|
2777
|
+
const parsed = z.object({
|
|
2778
|
+
element: z.string(),
|
|
2779
|
+
camera: z.string(),
|
|
2780
|
+
frames: z.number().int().min(2).max(32).optional(),
|
|
2781
|
+
dt: z.number().positive().max(5).optional(),
|
|
2782
|
+
mode: z.enum(["time", "real"]).optional(),
|
|
2783
|
+
labUrl: z.string().optional()
|
|
2784
|
+
}).parse(args);
|
|
2785
|
+
const { filmstrip, meta } = refsMotionPaths(
|
|
2786
|
+
process.cwd(),
|
|
2787
|
+
parsed.element,
|
|
2788
|
+
parsed.camera
|
|
2789
|
+
);
|
|
2790
|
+
if (!existsSync4(filmstrip)) {
|
|
2791
|
+
return {
|
|
2792
|
+
isError: true,
|
|
2793
|
+
content: [
|
|
2794
|
+
{
|
|
2795
|
+
type: "text",
|
|
2796
|
+
text: `no motion reference at ${filmstrip}. Call set_reference_motion first.`
|
|
2797
|
+
}
|
|
2798
|
+
]
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
let savedMeta = {};
|
|
2802
|
+
try {
|
|
2803
|
+
savedMeta = existsSync4(meta) ? JSON.parse(readFileSync2(meta, "utf8")) : {};
|
|
2804
|
+
} catch {
|
|
2805
|
+
}
|
|
2806
|
+
const opts = {
|
|
2807
|
+
frames: parsed.frames ?? savedMeta.frames ?? 6,
|
|
2808
|
+
dt: parsed.dt ?? savedMeta.dt ?? 0.25,
|
|
2809
|
+
mode: parsed.mode ?? savedMeta.mode ?? "time"
|
|
2810
|
+
};
|
|
2811
|
+
const frameB64s = await captureMotionFramesRaw({ ...parsed, ...opts });
|
|
2812
|
+
const diff = diffReferenceMotion({
|
|
2813
|
+
cwd: process.cwd(),
|
|
2814
|
+
element: parsed.element,
|
|
2815
|
+
camera: parsed.camera,
|
|
2816
|
+
currentFrames: frameB64s
|
|
2817
|
+
});
|
|
2818
|
+
return {
|
|
2819
|
+
content: [
|
|
2820
|
+
{
|
|
2821
|
+
type: "text",
|
|
2822
|
+
text: JSON.stringify(
|
|
2823
|
+
{
|
|
2824
|
+
camera: parsed.camera,
|
|
2825
|
+
refFilmstripPath: diff.refFilmstripPath,
|
|
2826
|
+
refMeta: diff.refMeta,
|
|
2827
|
+
motionDiff: diff.motionDiff,
|
|
2828
|
+
hint: "0 = identical animation, >5 = visible drift, >30 = clearly different"
|
|
2829
|
+
},
|
|
2830
|
+
null,
|
|
2831
|
+
2
|
|
2832
|
+
)
|
|
2833
|
+
},
|
|
2834
|
+
{ type: "image", data: diff.compositeBase64, mimeType: "image/png" }
|
|
2835
|
+
]
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
case "capture_motion": {
|
|
2839
|
+
const parsed = z.object({
|
|
2840
|
+
element: z.string(),
|
|
2841
|
+
camera: z.string().optional(),
|
|
2842
|
+
frames: z.number().int().min(2).max(32).optional(),
|
|
2843
|
+
dt: z.number().positive().max(5).optional(),
|
|
2844
|
+
mode: z.enum(["time", "real"]).optional(),
|
|
2845
|
+
labUrl: z.string().optional(),
|
|
2846
|
+
inline: z.boolean().optional(),
|
|
2847
|
+
fresh: z.boolean().optional()
|
|
2848
|
+
}).parse(args);
|
|
2849
|
+
const res = await captureMotion(parsed);
|
|
2850
|
+
const { _filmstripBase64, ...summary } = res;
|
|
2851
|
+
let filmstripBytes = 0;
|
|
2852
|
+
for (const b64 of Object.values(_filmstripBase64))
|
|
2853
|
+
filmstripBytes += b64.length;
|
|
2854
|
+
const userWantsInline = parsed.inline !== false;
|
|
2855
|
+
const filmstripCapped = userWantsInline && filmstripBytes > INLINE_PAYLOAD_BUDGET;
|
|
2856
|
+
const finalInline = userWantsInline && !filmstripCapped;
|
|
2857
|
+
const text = JSON.stringify(
|
|
2858
|
+
{
|
|
2859
|
+
...summary,
|
|
2860
|
+
...filmstripCapped ? {
|
|
2861
|
+
inlineCapped: true,
|
|
2862
|
+
inlineWarning: `filmstrip payload would have been ${(filmstripBytes / 1048576).toFixed(1)} MB (limit ${(INLINE_PAYLOAD_BUDGET / 1048576).toFixed(0)} MB) \u2014 files are on disk, Read them by path.`
|
|
2863
|
+
} : {},
|
|
2864
|
+
hint: "<1 = static, >5 = visible motion, >20 = vigorous (in motionMagnitude)"
|
|
2865
|
+
},
|
|
2866
|
+
null,
|
|
2867
|
+
2
|
|
2868
|
+
);
|
|
2869
|
+
if (!finalInline) return { content: [{ type: "text", text }] };
|
|
2870
|
+
const content = [{ type: "text", text }];
|
|
2871
|
+
for (const cam of res.cameraOrder) {
|
|
2872
|
+
const data = _filmstripBase64[cam];
|
|
2873
|
+
if (data) content.push({ type: "image", data, mimeType: "image/png" });
|
|
2874
|
+
}
|
|
2875
|
+
return { content };
|
|
2876
|
+
}
|
|
2877
|
+
case "health": {
|
|
2878
|
+
let devServerOk = false;
|
|
2879
|
+
let manifestElements = [];
|
|
2880
|
+
try {
|
|
2881
|
+
const r = await fetch(`${DEV_URL}/__manifest`, { signal: AbortSignal.timeout(2e3) });
|
|
2882
|
+
if (r.ok) {
|
|
2883
|
+
const m = await r.json();
|
|
2884
|
+
devServerOk = true;
|
|
2885
|
+
manifestElements = Object.keys(m?.elements ?? {});
|
|
2886
|
+
}
|
|
2887
|
+
} catch {
|
|
2888
|
+
}
|
|
2889
|
+
const mem = process.memoryUsage();
|
|
2890
|
+
return jsonResult({
|
|
2891
|
+
uptimeSec: Math.round((Date.now() - SERVER_START_TIME) / 1e3),
|
|
2892
|
+
pid: process.pid,
|
|
2893
|
+
nodeVersion: process.version,
|
|
2894
|
+
project: PROJECT,
|
|
2895
|
+
devServer: { url: DEV_URL, reachable: devServerOk, manifestElements },
|
|
2896
|
+
memoryMB: {
|
|
2897
|
+
rss: +(mem.rss / 1048576).toFixed(1),
|
|
2898
|
+
heapUsed: +(mem.heapUsed / 1048576).toFixed(1),
|
|
2899
|
+
external: +(mem.external / 1048576).toFixed(1)
|
|
2900
|
+
},
|
|
2901
|
+
logPath: logger.logPath,
|
|
2902
|
+
recentErrors
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
case "run_smoke":
|
|
2906
|
+
return jsonResult(await runSmoke({ element: args.element }));
|
|
2907
|
+
case "import_element": {
|
|
2908
|
+
const parsed = z.object({ source: z.string(), name: z.string().optional() }).parse(args);
|
|
2909
|
+
return jsonResult(
|
|
2910
|
+
await importElement({ source: parsed.source, name: parsed.name })
|
|
2911
|
+
);
|
|
2912
|
+
}
|
|
2913
|
+
case "scaffold_from_gltf": {
|
|
2914
|
+
const parsed = z.object({ file: z.string(), name: z.string().optional() }).parse(args);
|
|
2915
|
+
return jsonResult(
|
|
2916
|
+
await scaffoldFromGltf({ file: parsed.file, name: parsed.name })
|
|
2917
|
+
);
|
|
2918
|
+
}
|
|
2919
|
+
case "read_uniform": {
|
|
2920
|
+
const parsed = z.object({
|
|
2921
|
+
element: z.string().optional(),
|
|
2922
|
+
path: z.string(),
|
|
2923
|
+
labUrl: z.string().optional()
|
|
2924
|
+
}).parse(args);
|
|
2925
|
+
return jsonResult(
|
|
2926
|
+
await readUniform({
|
|
2927
|
+
element: parsed.element,
|
|
2928
|
+
path: parsed.path,
|
|
2929
|
+
labUrl: parsed.labUrl
|
|
2930
|
+
})
|
|
2931
|
+
);
|
|
2932
|
+
}
|
|
2933
|
+
case "set_uniform": {
|
|
2934
|
+
const parsed = z.object({
|
|
2935
|
+
element: z.string().optional(),
|
|
2936
|
+
path: z.string(),
|
|
2937
|
+
value: z.union([z.number(), z.string(), z.boolean(), z.array(z.number())]),
|
|
2938
|
+
labUrl: z.string().optional()
|
|
2939
|
+
}).parse(args);
|
|
2940
|
+
return jsonResult(
|
|
2941
|
+
await setUniform({
|
|
2942
|
+
element: parsed.element,
|
|
2943
|
+
path: parsed.path,
|
|
2944
|
+
value: parsed.value,
|
|
2945
|
+
labUrl: parsed.labUrl
|
|
2946
|
+
})
|
|
2947
|
+
);
|
|
2948
|
+
}
|
|
2949
|
+
case "inspect_scene": {
|
|
2950
|
+
const parsed = z.object({
|
|
2951
|
+
element: z.string().optional(),
|
|
2952
|
+
maxNodes: z.number().int().min(1).max(5e3).optional(),
|
|
2953
|
+
labUrl: z.string().optional(),
|
|
2954
|
+
fresh: z.boolean().optional()
|
|
2955
|
+
}).parse(args);
|
|
2956
|
+
return jsonResult(await inspectScene(parsed));
|
|
2957
|
+
}
|
|
2958
|
+
case "inspect": {
|
|
2959
|
+
const parsed = z.object({
|
|
2960
|
+
element: z.string(),
|
|
2961
|
+
camera: z.string().optional()
|
|
2962
|
+
}).parse(args);
|
|
2963
|
+
return jsonResult(await inspect({ element: parsed.element, camera: parsed.camera }));
|
|
2964
|
+
}
|
|
2965
|
+
case "open_selection": {
|
|
2966
|
+
const parsed = z.object({
|
|
2967
|
+
editor: z.string().optional()
|
|
2968
|
+
}).parse(args);
|
|
2969
|
+
return jsonResult(await openSelection(parsed));
|
|
2970
|
+
}
|
|
2971
|
+
case "snapshot": {
|
|
2972
|
+
const parsed = z.object({
|
|
2973
|
+
name: z.string(),
|
|
2974
|
+
message: z.string().optional()
|
|
2975
|
+
}).parse(args);
|
|
2976
|
+
return jsonResult(await snapshot({ name: parsed.name, message: parsed.message }));
|
|
2977
|
+
}
|
|
2978
|
+
case "restore": {
|
|
2979
|
+
const parsed = z.object({ name: z.string() }).parse(args);
|
|
2980
|
+
return jsonResult(await restore({ name: parsed.name }));
|
|
2981
|
+
}
|
|
2982
|
+
case "list_snapshots":
|
|
2983
|
+
return jsonResult(await listSnapshots());
|
|
2984
|
+
case "auto_tune": {
|
|
2985
|
+
const parsed = z.object({
|
|
2986
|
+
element: z.string(),
|
|
2987
|
+
knob: z.string(),
|
|
2988
|
+
range: z.tuple([z.number(), z.number()]),
|
|
2989
|
+
target_camera: z.string(),
|
|
2990
|
+
max_iterations: z.number().int().min(2).max(50).optional(),
|
|
2991
|
+
labUrl: z.string().optional()
|
|
2992
|
+
}).parse(args);
|
|
2993
|
+
return jsonResult(
|
|
2994
|
+
await autoTune({
|
|
2995
|
+
element: parsed.element,
|
|
2996
|
+
knob: parsed.knob,
|
|
2997
|
+
range: [parsed.range[0], parsed.range[1]],
|
|
2998
|
+
target_camera: parsed.target_camera,
|
|
2999
|
+
max_iterations: parsed.max_iterations,
|
|
3000
|
+
labUrl: parsed.labUrl
|
|
3001
|
+
})
|
|
3002
|
+
);
|
|
3003
|
+
}
|
|
3004
|
+
case "multi_tune": {
|
|
3005
|
+
const parsed = z.object({
|
|
3006
|
+
element: z.string(),
|
|
3007
|
+
knobs: z.array(
|
|
3008
|
+
z.object({
|
|
3009
|
+
key: z.string(),
|
|
3010
|
+
range: z.tuple([z.number(), z.number()]),
|
|
3011
|
+
start: z.number().optional()
|
|
3012
|
+
})
|
|
3013
|
+
).min(1),
|
|
3014
|
+
target_camera: z.string(),
|
|
3015
|
+
max_cycles: z.number().int().min(1).max(6).optional(),
|
|
3016
|
+
per_knob_iters: z.number().int().min(2).max(20).optional(),
|
|
3017
|
+
compute_interactions: z.boolean().optional(),
|
|
3018
|
+
max_evaluations: z.number().int().min(2).max(300).optional(),
|
|
3019
|
+
labUrl: z.string().optional()
|
|
3020
|
+
}).parse(args);
|
|
3021
|
+
return jsonResult(
|
|
3022
|
+
await multiTune({
|
|
3023
|
+
element: parsed.element,
|
|
3024
|
+
knobs: parsed.knobs.map((k) => ({
|
|
3025
|
+
key: k.key,
|
|
3026
|
+
range: [k.range[0], k.range[1]],
|
|
3027
|
+
start: k.start
|
|
3028
|
+
})),
|
|
3029
|
+
target_camera: parsed.target_camera,
|
|
3030
|
+
max_cycles: parsed.max_cycles,
|
|
3031
|
+
per_knob_iters: parsed.per_knob_iters,
|
|
3032
|
+
compute_interactions: parsed.compute_interactions,
|
|
3033
|
+
max_evaluations: parsed.max_evaluations,
|
|
3034
|
+
labUrl: parsed.labUrl
|
|
3035
|
+
})
|
|
3036
|
+
);
|
|
3037
|
+
}
|
|
3038
|
+
case "check_targets": {
|
|
3039
|
+
const parsed = z.object({ element: z.string(), labUrl: z.string().optional() }).parse(args);
|
|
3040
|
+
return jsonResult(
|
|
3041
|
+
await checkTargets({ element: parsed.element, labUrl: parsed.labUrl })
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
case "set_scene_param": {
|
|
3045
|
+
const parsed = z.object({
|
|
3046
|
+
cameras: z.record(
|
|
3047
|
+
z.object({
|
|
3048
|
+
position: z.array(z.number()).optional(),
|
|
3049
|
+
target: z.array(z.number()).optional(),
|
|
3050
|
+
fov: z.number().optional()
|
|
3051
|
+
})
|
|
3052
|
+
).optional(),
|
|
3053
|
+
knobs: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
|
|
3054
|
+
elements: z.record(z.object({ enabled: z.boolean().optional() })).optional()
|
|
3055
|
+
}).parse(args);
|
|
3056
|
+
return jsonResult(await setSceneParam(parsed));
|
|
3057
|
+
}
|
|
3058
|
+
case "get_scene":
|
|
3059
|
+
return jsonResult(
|
|
3060
|
+
await getScene({
|
|
3061
|
+
devUrl: args.devUrl,
|
|
3062
|
+
labUrl: args.labUrl
|
|
3063
|
+
})
|
|
3064
|
+
);
|
|
3065
|
+
case "add_element": {
|
|
3066
|
+
const parsed = z.object({
|
|
3067
|
+
name: z.string(),
|
|
3068
|
+
element: z.string().optional(),
|
|
3069
|
+
labUrl: z.string().optional()
|
|
3070
|
+
}).parse(args);
|
|
3071
|
+
return jsonResult(
|
|
3072
|
+
await addElement({
|
|
3073
|
+
name: parsed.name,
|
|
3074
|
+
element: parsed.element,
|
|
3075
|
+
labUrl: parsed.labUrl
|
|
3076
|
+
})
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
case "remove_element": {
|
|
3080
|
+
const parsed = z.object({
|
|
3081
|
+
name: z.string(),
|
|
3082
|
+
element: z.string().optional(),
|
|
3083
|
+
labUrl: z.string().optional()
|
|
3084
|
+
}).parse(args);
|
|
3085
|
+
return jsonResult(
|
|
3086
|
+
await removeElement({
|
|
3087
|
+
name: parsed.name,
|
|
3088
|
+
element: parsed.element,
|
|
3089
|
+
labUrl: parsed.labUrl
|
|
3090
|
+
})
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
3093
|
+
default:
|
|
3094
|
+
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
|
3095
|
+
}
|
|
3096
|
+
})();
|
|
3097
|
+
finish("succeeded");
|
|
3098
|
+
return result;
|
|
3099
|
+
} catch (err) {
|
|
3100
|
+
finish("failed");
|
|
3101
|
+
recordError(`tool:${name}`, err);
|
|
3102
|
+
return {
|
|
3103
|
+
isError: true,
|
|
3104
|
+
content: [{ type: "text", text: `${name} failed: ${err?.message ?? String(err)}` }]
|
|
3105
|
+
};
|
|
3106
|
+
}
|
|
3107
|
+
});
|
|
3108
|
+
const transport = new StdioServerTransport();
|
|
3109
|
+
await server.connect(transport);
|
|
3110
|
+
console.error(`[triscope-mcp] connected. dev server: ${DEV_URL}, project: ${PROJECT}`);
|
|
3111
|
+
}
|
|
3112
|
+
export {
|
|
3113
|
+
absolutize,
|
|
3114
|
+
applyPath,
|
|
3115
|
+
clampKnobValue,
|
|
3116
|
+
jsonResult,
|
|
3117
|
+
knobSpecMapFromManifest,
|
|
3118
|
+
probeStatsFromPng,
|
|
3119
|
+
readProjectLabMap,
|
|
3120
|
+
readProjectName,
|
|
3121
|
+
recordError,
|
|
3122
|
+
resolveDevUrl,
|
|
3123
|
+
startServer
|
|
3124
|
+
};
|
|
3125
|
+
//# sourceMappingURL=server.mjs.map
|