@writepanda/mcp 1.259.0 → 1.265.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.
@@ -0,0 +1,389 @@
1
+ // Edit preview view (ui://pandastudio/edit-preview.html): the rendered frame
2
+ // or a strip of frames across the edit, the timeline length and what the edit
3
+ // contains. Shown for project_render_frame, project_render_sheet,
4
+ // preview_show and project_edit_summary.
5
+
6
+ import { viewPage } from "./viewKit.mjs";
7
+
8
+ export const EDIT_PREVIEW_URI = "ui://pandastudio/edit-preview.html";
9
+
10
+ const CSS = `
11
+ .head { margin-bottom: 10px; }
12
+ .meta { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 4px; font-size: 12px; color: var(--fg2); }
13
+ .meta b { color: var(--fg); font-weight: 600; }
14
+ .stage { position: relative; border-radius: var(--radius-lg); overflow: hidden; background: var(--bg2);
15
+ display: flex; align-items: center; justify-content: center; }
16
+ .stage img { display: block; max-width: 100%; max-height: 460px; object-fit: contain; }
17
+ .stage .missing { color: var(--fg2); }
18
+ .stage .ph { width: 100%; aspect-ratio: var(--ar, 16 / 9); max-height: 460px; }
19
+ .stage .tc { position: absolute; left: 8px; bottom: 8px; background: rgba(0, 0, 0, 0.62); color: #fff;
20
+ font-size: 12px; padding: 2px 7px; border-radius: 5px; font-variant-numeric: tabular-nums; }
21
+ .strip { display: grid; grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); gap: 6px; margin-top: 8px; }
22
+ .strip button { all: unset; cursor: pointer; position: relative; border-radius: 6px; overflow: hidden;
23
+ background: #000; outline: 2px solid transparent; outline-offset: -2px; }
24
+ .strip button[aria-current="true"] { outline-color: var(--accent); }
25
+ .strip img, .strip .ph { display: block; width: 100%; aspect-ratio: var(--ar, 16 / 9); object-fit: cover; }
26
+ .strip .tc { position: absolute; left: 4px; bottom: 4px; background: rgba(0, 0, 0, 0.62); color: #fff;
27
+ font-size: 10.5px; padding: 1px 5px; border-radius: 4px; font-variant-numeric: tabular-nums; }
28
+ .changes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
29
+ .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
30
+ .missing { font-size: 13px; text-align: center; padding: 30px 16px; }
31
+ .warn { margin-top: 10px; font-size: 12px; color: var(--danger); }
32
+ `;
33
+
34
+ /** Runs in the view. Self-contained: serialized into the page. */
35
+ function main(kit, ui) {
36
+ const { h, duration, actionButton, toast } = ui;
37
+ const app = document.getElementById("app");
38
+ const view = {
39
+ args: {},
40
+ project: null, // { id?, path? } for follow-up calls
41
+ frames: [], // [{ atMs, path, src? }]
42
+ sheetPath: null,
43
+ selected: 0,
44
+ summary: null,
45
+ summaryState: "idle", // idle | loading | ready | unavailable
46
+ framesState: "idle", // idle | loading | ready | error
47
+ framesError: null,
48
+ playheadMs: null,
49
+ warnings: [],
50
+ kind: null, // frame | sheet | show | summary
51
+ ratio: "16 / 9",
52
+ };
53
+
54
+ const ratioOf = (ar) => {
55
+ const m = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(ar ?? "");
56
+ return m ? `${m[1]} / ${m[2]}` : "16 / 9";
57
+ };
58
+ const projectArgs = () => {
59
+ const p = view.project ?? {};
60
+ if (p.id) return { id: p.id };
61
+ if (p.path) return { path: p.path };
62
+ return {};
63
+ };
64
+
65
+ function kindOf(data) {
66
+ const name = kit.toolName();
67
+ if (name === "project_render_frame") return "frame";
68
+ if (name === "project_render_sheet") return "sheet";
69
+ if (name === "preview_show") return "show";
70
+ if (name === "project_edit_summary") return "summary";
71
+ if (Array.isArray(data?.frames) || "sheetPath" in (data ?? {})) return "sheet";
72
+ if (typeof data?.path === "string" && typeof data?.timeMs === "number") return "frame";
73
+ if (Array.isArray(data?.highlights)) return "summary";
74
+ return "show";
75
+ }
76
+
77
+ async function loadImages() {
78
+ const paths = view.frames.map((f) => f.path).filter(Boolean);
79
+ if (view.sheetPath && !paths.length) paths.push(view.sheetPath);
80
+ if (!paths.length) return;
81
+ const images = await kit.loadImages(paths);
82
+ for (const f of view.frames) f.src = images.get(f.path) ?? null;
83
+ if (view.sheetPath && !view.frames.length) {
84
+ view.frames = [{ atMs: null, path: view.sheetPath, src: images.get(view.sheetPath) ?? null }];
85
+ }
86
+ }
87
+
88
+ async function renderStrip() {
89
+ view.framesState = "loading";
90
+ render();
91
+ try {
92
+ const { data } = await kit.callTool("project_render_sheet", {
93
+ ...projectArgs(),
94
+ count: 8,
95
+ cols: 4,
96
+ });
97
+ view.frames = (data?.frames ?? []).map((f) => ({ atMs: f.atMs, path: f.path }));
98
+ view.sheetPath = view.frames.length ? null : (data?.sheetPath ?? null);
99
+ if (typeof view.playheadMs === "number" && view.frames.length) {
100
+ let best = 0;
101
+ view.frames.forEach((f, i) => {
102
+ if (
103
+ Math.abs(f.atMs - view.playheadMs) < Math.abs(view.frames[best].atMs - view.playheadMs)
104
+ )
105
+ best = i;
106
+ });
107
+ view.selected = best;
108
+ }
109
+ await loadImages();
110
+ view.framesState = "ready";
111
+ } catch (err) {
112
+ view.framesState = "error";
113
+ view.framesError = err?.message ?? String(err);
114
+ }
115
+ render();
116
+ }
117
+
118
+ async function rerenderFrame() {
119
+ const f = view.frames[view.selected];
120
+ view.framesState = "loading";
121
+ render();
122
+ try {
123
+ const { data } = await kit.callTool("project_render_frame", {
124
+ ...projectArgs(),
125
+ atMs: f?.atMs ?? 0,
126
+ width: 1280,
127
+ });
128
+ view.frames = [{ atMs: data.timeMs, path: data.path }];
129
+ view.selected = 0;
130
+ view.warnings = data.warnings ?? [];
131
+ await loadImages();
132
+ view.framesState = "ready";
133
+ } catch (err) {
134
+ view.framesState = "error";
135
+ view.framesError = err?.message ?? String(err);
136
+ }
137
+ render();
138
+ }
139
+
140
+ async function loadSummary() {
141
+ view.summaryState = "loading";
142
+ render();
143
+ try {
144
+ const { data } = await kit.callTool("project_edit_summary", projectArgs());
145
+ view.summary = data;
146
+ if (data?.id && !view.project?.id) view.project = { ...view.project, id: data.id };
147
+ view.ratio = ratioOf(data?.aspectRatio);
148
+ view.summaryState = "ready";
149
+ } catch {
150
+ // An app older than project.edit-summary: show the frames alone.
151
+ view.summaryState = "unavailable";
152
+ }
153
+ render();
154
+ }
155
+
156
+ async function start(data) {
157
+ view.kind = kindOf(data);
158
+ view.warnings = Array.isArray(data?.warnings)
159
+ ? data.warnings
160
+ : data?.warning
161
+ ? [data.warning]
162
+ : [];
163
+ const a = view.args ?? {};
164
+ view.project = a.id ? { id: a.id } : a.path ? { path: a.path } : null;
165
+ if (view.kind === "frame") {
166
+ view.frames = [{ atMs: data.timeMs, path: data.path }];
167
+ } else if (view.kind === "sheet") {
168
+ view.frames = (data.frames ?? []).map((f) => ({ atMs: f.atMs, path: f.path }));
169
+ view.sheetPath = view.frames.length ? null : (data.sheetPath ?? null);
170
+ } else if (view.kind === "show") {
171
+ if (!view.project && data?.projectPath) view.project = { path: data.projectPath };
172
+ view.playheadMs = typeof data?.playheadMs === "number" ? data.playheadMs : null;
173
+ } else if (view.kind === "summary") {
174
+ view.summary = data;
175
+ view.summaryState = "ready";
176
+ view.ratio = ratioOf(data?.aspectRatio);
177
+ view.project = data?.id ? { id: data.id } : data?.path ? { path: data.path } : view.project;
178
+ }
179
+ render();
180
+ const work = [];
181
+ if (view.kind !== "summary") work.push(loadSummary());
182
+ if (view.kind === "frame" || view.kind === "sheet") {
183
+ view.framesState = "loading";
184
+ work.push(
185
+ loadImages().then(
186
+ () => {
187
+ view.framesState = "ready";
188
+ render();
189
+ },
190
+ (err) => {
191
+ view.framesState = "error";
192
+ view.framesError = err?.message ?? String(err);
193
+ render();
194
+ },
195
+ ),
196
+ );
197
+ } else {
198
+ work.push(renderStrip());
199
+ }
200
+ await Promise.allSettled(work);
201
+ }
202
+
203
+ function stage() {
204
+ const f = view.frames[view.selected];
205
+ if (view.framesState === "loading" && !f?.src)
206
+ return h("div", { class: "stage" }, h("div", { class: "ph skeleton" }));
207
+ if (view.framesState === "error")
208
+ return h(
209
+ "div",
210
+ { class: "stage" },
211
+ h("div", { class: "missing" }, `Couldn't render the preview: ${view.framesError}`),
212
+ );
213
+ if (!f) return null;
214
+ if (!f.src)
215
+ return h(
216
+ "div",
217
+ { class: "stage" },
218
+ h(
219
+ "div",
220
+ { class: "missing" },
221
+ "This frame is no longer available. Render it again to see it.",
222
+ ),
223
+ );
224
+ return h(
225
+ "div",
226
+ { class: "stage" },
227
+ h("img", { src: f.src, alt: `Frame at ${duration(f.atMs)}` }),
228
+ typeof f.atMs === "number" ? h("span", { class: "tc" }, duration(f.atMs)) : null,
229
+ );
230
+ }
231
+
232
+ function strip() {
233
+ if (view.frames.length < 2) return null;
234
+ const [w, hgt] = view.ratio.split(" / ").map(Number);
235
+ const cols = w < hgt ? 8 : w === hgt ? 6 : 4;
236
+ return h(
237
+ "div",
238
+ { class: "strip", style: { "--cols": String(cols) } },
239
+ view.frames.map((f, i) =>
240
+ h(
241
+ "button",
242
+ {
243
+ type: "button",
244
+ title: `Frame at ${duration(f.atMs)}`,
245
+ "aria-current": i === view.selected ? "true" : "false",
246
+ onclick: () => {
247
+ view.selected = i;
248
+ render();
249
+ },
250
+ },
251
+ f.src
252
+ ? h("img", { src: f.src, alt: "" })
253
+ : h("div", { class: view.framesState === "loading" ? "ph skeleton" : "ph" }),
254
+ typeof f.atMs === "number" ? h("span", { class: "tc" }, duration(f.atMs)) : null,
255
+ ),
256
+ ),
257
+ );
258
+ }
259
+
260
+ function header() {
261
+ const s = view.summary;
262
+ const fileName = view.project?.path
263
+ ?.split(/[\\/]/)
264
+ .pop()
265
+ ?.replace(/\.pandastudio$/i, "");
266
+ const title =
267
+ s?.name ?? fileName ?? (view.kind === "frame" ? "Rendered frame" : "Edit preview");
268
+ const meta = [];
269
+ if (s) {
270
+ meta.push(h("span", null, h("b", null, duration(s.editedDurationMs)), " long"));
271
+ if (s.removedMs >= 500)
272
+ meta.push(
273
+ h("span", null, `${duration(s.removedMs)} cut from ${duration(s.sourceDurationMs)}`),
274
+ );
275
+ meta.push(h("span", null, s.aspectRatio));
276
+ if (s.clipCount > 1) meta.push(h("span", null, `${s.clipCount} clips`));
277
+ } else if (view.summaryState === "loading") {
278
+ meta.push(h("span", { class: "faint" }, "Reading the edit…"));
279
+ }
280
+ return h(
281
+ "div",
282
+ { class: "head" },
283
+ h("div", { class: "brand" }, h("i"), "PandaStudio"),
284
+ h("h1", null, title),
285
+ meta.length ? h("div", { class: "meta" }, meta) : null,
286
+ );
287
+ }
288
+
289
+ function changes() {
290
+ const lines = view.summary?.highlights ?? [];
291
+ if (!lines.length) {
292
+ return view.summaryState === "ready"
293
+ ? h("div", { class: "changes" }, h("span", { class: "chip" }, "No edits yet"))
294
+ : null;
295
+ }
296
+ return h(
297
+ "div",
298
+ { class: "changes" },
299
+ lines.map((t, i) => h("span", { class: i === 0 ? "chip strong" : "chip" }, t)),
300
+ );
301
+ }
302
+
303
+ function actions() {
304
+ const f = view.frames[view.selected];
305
+ const atMs = typeof f?.atMs === "number" ? f.atMs : (view.playheadMs ?? 0);
306
+ const btns = [
307
+ actionButton(
308
+ view.kind === "show" ? `Show ${duration(atMs)} in the editor` : "Open in PandaStudio",
309
+ async () => {
310
+ await kit.callTool("preview_show", { ...projectArgs(), atMs });
311
+ toast("Opened in PandaStudio");
312
+ },
313
+ { primary: true, busy: "Opening…" },
314
+ ),
315
+ ];
316
+ if (view.kind === "frame") {
317
+ btns.push(actionButton("Render again", rerenderFrame, { busy: "Rendering…" }));
318
+ btns.push(actionButton("Frames across the edit", renderStrip, { busy: "Rendering…" }));
319
+ } else {
320
+ btns.push(
321
+ actionButton(
322
+ "Refresh",
323
+ async () => {
324
+ await Promise.all([renderStrip(), loadSummary()]);
325
+ },
326
+ { busy: "Refreshing…" },
327
+ ),
328
+ );
329
+ }
330
+ if (kit.canMessage() && view.summary?.name) {
331
+ btns.push(
332
+ actionButton("Ask to export", async () => {
333
+ await kit.sendMessage(`The preview of "${view.summary.name}" looks good. Export it.`);
334
+ }),
335
+ );
336
+ }
337
+ if (kit.canFullscreen()) {
338
+ const full = kit.state.hostContext.displayMode === "fullscreen";
339
+ btns.push(
340
+ actionButton(full ? "Exit full screen" : "Full screen", () =>
341
+ kit.requestDisplayMode(full ? "inline" : "fullscreen"),
342
+ ),
343
+ );
344
+ }
345
+ return h("div", { class: "actions" }, btns);
346
+ }
347
+
348
+ function render() {
349
+ document.documentElement.style.setProperty("--ar", view.ratio);
350
+ app.replaceChildren(
351
+ ...[
352
+ header(),
353
+ stage(),
354
+ strip(),
355
+ changes(),
356
+ view.warnings.length ? h("div", { class: "warn" }, view.warnings.join(" ")) : null,
357
+ actions(),
358
+ ].filter(Boolean),
359
+ );
360
+ }
361
+
362
+ app.append(h("div", { class: "stage" }, h("div", { class: "ph skeleton" })));
363
+ kit.on("input", (args) => {
364
+ view.args = args ?? {};
365
+ });
366
+ kit.on("result", (result) => {
367
+ const error = kit.resultError(result);
368
+ if (error) {
369
+ app.replaceChildren(
370
+ h("div", { class: "brand" }, h("i"), "PandaStudio"),
371
+ h("p", { class: "error" }, error),
372
+ );
373
+ return;
374
+ }
375
+ start(kit.resultData(result) ?? {});
376
+ });
377
+ kit.on("context", () => {
378
+ if (view.kind) render();
379
+ });
380
+ kit.connect().catch((err) => {
381
+ app.replaceChildren(
382
+ h("p", { class: "error" }, `Couldn't connect to the chat: ${err?.message ?? err}`),
383
+ );
384
+ });
385
+ }
386
+
387
+ export function editPreviewHtml(version) {
388
+ return viewPage({ title: "PandaStudio edit preview", version, css: CSS, main });
389
+ }
@@ -0,0 +1,256 @@
1
+ // MCP Apps (interactive views in the chat) for the PandaStudio MCP server.
2
+ //
3
+ // Spec: SEP-1865 / io.modelcontextprotocol/ui, protocol 2026-01-26. A view
4
+ // is a `ui://` resource (text/html;profile=mcp-app) that a host renders in a
5
+ // sandboxed iframe next to a tool call whose `_meta.ui.resourceUri` names it.
6
+ // The host sends the view the call's input and result; the view calls this
7
+ // server's tools back through the host (tools/call over postMessage).
8
+ // ChatGPT implements the same spec and still reads the older
9
+ // `openai/outputTemplate` key, so tools carry both.
10
+ //
11
+ // Clients that don't render views (Cursor, Claude Code, the in-app agent)
12
+ // see exactly what they saw before: no resources, no extra tools, no _meta.
13
+ // Tool results never change: `content` stays the JSON text the model reads.
14
+ //
15
+ // Images reach a view through the app-only tool ui_read_media, which serves
16
+ // only image files the app itself reported in a result this session (frames,
17
+ // contact sheets, clip stills), so a view can't read arbitrary files.
18
+
19
+ import { readFile, stat } from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { CLIPS_URI, clipsHtml } from "./clips.mjs";
22
+ import { EDIT_PREVIEW_URI, editPreviewHtml } from "./editPreview.mjs";
23
+ import { RECORDER_URI, recorderHtml } from "./recorder.mjs";
24
+
25
+ export { CLIPS_URI, EDIT_PREVIEW_URI, RECORDER_URI };
26
+ export const UI_EXTENSION = "io.modelcontextprotocol/ui";
27
+ export const UI_MIME = "text/html;profile=mcp-app";
28
+ /** Deprecated flat key some hosts still read; the official helper sets both. */
29
+ const LEGACY_URI_KEY = "ui/resourceUri";
30
+
31
+ const RESOURCES = [
32
+ {
33
+ uri: EDIT_PREVIEW_URI,
34
+ name: "edit-preview",
35
+ title: "Edit preview",
36
+ description:
37
+ "The rendered frame or frames across the edit, its length and what it contains, with Open in PandaStudio.",
38
+ html: editPreviewHtml,
39
+ },
40
+ {
41
+ uri: CLIPS_URI,
42
+ name: "clips",
43
+ title: "Clips",
44
+ description:
45
+ "A clip set as cards (still, title, hook, score, length) with Open, Export and Remove.",
46
+ html: clipsHtml,
47
+ },
48
+ {
49
+ uri: RECORDER_URI,
50
+ name: "recorder",
51
+ title: "Recorder",
52
+ description:
53
+ "The script loaded into PandaStudio's teleprompter (editable, with reading time), Open the recorder again, and I've recorded it.",
54
+ html: recorderHtml,
55
+ },
56
+ ];
57
+
58
+ /** Resource-level UI metadata: no network or extra origins needed (images
59
+ * arrive as data: URLs, which the default CSP allows). */
60
+ const RESOURCE_UI_META = {
61
+ ui: { prefersBorder: true, csp: {} },
62
+ "openai/widgetPrefersBorder": true,
63
+ };
64
+
65
+ /** Model-visible tools that render a view, with ChatGPT's status lines. */
66
+ export const TOOL_VIEWS = {
67
+ project_render_frame: {
68
+ uri: EDIT_PREVIEW_URI,
69
+ invoking: "Rendering the frame…",
70
+ invoked: "Frame rendered",
71
+ },
72
+ project_render_sheet: {
73
+ uri: EDIT_PREVIEW_URI,
74
+ invoking: "Rendering frames…",
75
+ invoked: "Frames rendered",
76
+ },
77
+ preview_show: {
78
+ uri: EDIT_PREVIEW_URI,
79
+ invoking: "Opening the preview…",
80
+ invoked: "Preview ready",
81
+ },
82
+ project_edit_summary: {
83
+ uri: EDIT_PREVIEW_URI,
84
+ invoking: "Reading the edit…",
85
+ invoked: "Edit summary ready",
86
+ },
87
+ clips_make: { uri: CLIPS_URI, invoking: "Starting clips…", invoked: "Making clips" },
88
+ clips_get: { uri: CLIPS_URI, invoking: "Loading clips…", invoked: "Clips loaded" },
89
+ recording_open_recorder: {
90
+ uri: RECORDER_URI,
91
+ invoking: "Opening the recorder…",
92
+ invoked: "Recorder open",
93
+ },
94
+ };
95
+
96
+ /** Tools only views call (hidden from the model by UI hosts). */
97
+ export const APP_TOOLS = [
98
+ {
99
+ name: "ui_read_media",
100
+ description:
101
+ "For PandaStudio's chat views: read images the app returned this session (frames, contact sheets, clip stills) as image content.",
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {
105
+ paths: {
106
+ type: "array",
107
+ items: { type: "string" },
108
+ description: "Absolute image paths, up to 24.",
109
+ },
110
+ },
111
+ required: ["paths"],
112
+ },
113
+ _meta: {
114
+ ui: { visibility: ["app"] },
115
+ "openai/visibility": "private",
116
+ "openai/widgetAccessible": true,
117
+ },
118
+ },
119
+ ];
120
+
121
+ /**
122
+ * Does this client render MCP Apps views? It says so in its initialize
123
+ * capabilities (extensions["io.modelcontextprotocol/ui"].mimeTypes). The
124
+ * in-app agent never does. PANDASTUDIO_MCP_UI=1 / 0 forces it either way
125
+ * (a host that renders views without advertising them, or to switch off).
126
+ */
127
+ export function clientSupportsUi(capabilities, { inApp = false, env = process.env } = {}) {
128
+ const force = String(env.PANDASTUDIO_MCP_UI ?? "").toLowerCase();
129
+ if (["0", "false", "off", "no"].includes(force)) return false;
130
+ if (["1", "true", "on", "yes"].includes(force)) return true;
131
+ if (inApp) return false;
132
+ const mimeTypes = capabilities?.extensions?.[UI_EXTENSION]?.mimeTypes;
133
+ return Array.isArray(mimeTypes) && mimeTypes.includes(UI_MIME);
134
+ }
135
+
136
+ /** _meta for a tool's tools/list entry, or undefined when it has no view. */
137
+ export function toolUiMeta(toolName) {
138
+ const view = TOOL_VIEWS[toolName];
139
+ if (!view) return undefined;
140
+ return {
141
+ ui: { resourceUri: view.uri },
142
+ [LEGACY_URI_KEY]: view.uri,
143
+ "openai/outputTemplate": view.uri,
144
+ "openai/toolInvocation/invoking": view.invoking,
145
+ "openai/toolInvocation/invoked": view.invoked,
146
+ "openai/widgetAccessible": true,
147
+ };
148
+ }
149
+
150
+ export function listUiResources() {
151
+ return RESOURCES.map(({ uri, name, title, description }) => ({
152
+ uri,
153
+ name,
154
+ title,
155
+ description,
156
+ mimeType: UI_MIME,
157
+ _meta: RESOURCE_UI_META,
158
+ }));
159
+ }
160
+
161
+ /** resources/read for a ui:// view, or null when the uri isn't one. */
162
+ export function readUiResource(uri, version) {
163
+ const res = RESOURCES.find((r) => r.uri === uri);
164
+ if (!res) return null;
165
+ return {
166
+ contents: [
167
+ { uri: res.uri, mimeType: UI_MIME, text: res.html(version), _meta: RESOURCE_UI_META },
168
+ ],
169
+ };
170
+ }
171
+
172
+ // ── Images for views ────────────────────────────────────────────────────
173
+
174
+ const IMAGE_TYPES = {
175
+ ".png": "image/png",
176
+ ".jpg": "image/jpeg",
177
+ ".jpeg": "image/jpeg",
178
+ ".webp": "image/webp",
179
+ ".gif": "image/gif",
180
+ };
181
+ const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
182
+ const MAX_PATHS = 24;
183
+ const imageType = (p) => IMAGE_TYPES[path.extname(p).toLowerCase()];
184
+
185
+ /**
186
+ * Image paths the app reported in tool results this session. Any string
187
+ * field whose key ends in "path" / "Path" and names an absolute image file
188
+ * counts (render-frame path, render-sheet sheetPath + frames[].path,
189
+ * clips[].thumbPath, ...).
190
+ */
191
+ export class MediaRegistry {
192
+ constructor(limit = 5000) {
193
+ this.limit = limit;
194
+ this.paths = new Set();
195
+ }
196
+
197
+ note(data) {
198
+ const walk = (value, depth) => {
199
+ if (!value || typeof value !== "object" || depth > 6) return;
200
+ if (Array.isArray(value)) {
201
+ for (const v of value) walk(v, depth + 1);
202
+ return;
203
+ }
204
+ for (const [k, v] of Object.entries(value)) {
205
+ if (typeof v === "string") {
206
+ if (/path$/i.test(k) && path.isAbsolute(v) && imageType(v)) this.add(v);
207
+ } else walk(v, depth + 1);
208
+ }
209
+ };
210
+ walk(data, 0);
211
+ }
212
+
213
+ add(p) {
214
+ const key = path.resolve(p);
215
+ this.paths.delete(key);
216
+ this.paths.add(key);
217
+ if (this.paths.size > this.limit) this.paths.delete(this.paths.values().next().value);
218
+ }
219
+
220
+ has(p) {
221
+ return typeof p === "string" && this.paths.has(path.resolve(p));
222
+ }
223
+
224
+ /** The ui_read_media result: one image block per readable path, and
225
+ * structuredContent.items mapping each path to its block (or an error). */
226
+ async read(paths) {
227
+ if (!Array.isArray(paths) || paths.length === 0)
228
+ return {
229
+ content: [{ type: "text", text: "paths must be a non-empty array" }],
230
+ isError: true,
231
+ };
232
+ const content = [];
233
+ const items = [];
234
+ for (const p of paths.slice(0, MAX_PATHS)) {
235
+ if (typeof p !== "string" || !this.has(p)) {
236
+ items.push({ path: p, error: "not_available" });
237
+ continue;
238
+ }
239
+ try {
240
+ const info = await stat(p);
241
+ if (!info.isFile() || info.size > MAX_IMAGE_BYTES) {
242
+ items.push({ path: p, error: "too_large" });
243
+ continue;
244
+ }
245
+ const buf = await readFile(p);
246
+ items.push({ path: p, image: content.length });
247
+ content.push({ type: "image", data: buf.toString("base64"), mimeType: imageType(p) });
248
+ } catch {
249
+ items.push({ path: p, error: "missing" });
250
+ }
251
+ }
252
+ const loaded = items.filter((i) => typeof i.image === "number").length;
253
+ content.push({ type: "text", text: `${loaded} of ${items.length} images` });
254
+ return { content, structuredContent: { items } };
255
+ }
256
+ }