shotops-mcp 0.5.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/dist/local.js ADDED
@@ -0,0 +1,3261 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/local.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // ../mockup-engine/appstore/devices.ts
8
+ var IPHONE_MODEL_ID = "iphone-17-pro";
9
+ var PIXEL_MODEL_ID = "pixel-10-pro";
10
+ var DEVICES = [
11
+ { id: "iphone-6-9", label: "iPhone 6.9\u2033", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67", modelId: IPHONE_MODEL_ID, screenFamily: "iphone" },
12
+ { id: "iphone-6-5", label: "iPhone 6.5\u2033", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65", modelId: IPHONE_MODEL_ID, screenFamily: "iphone" },
13
+ { id: "pixel-10-pro", label: "Pixel 10 Pro", panelPresetId: "pixel69", ascDeviceSet: "ANDROID_PHONE", modelId: PIXEL_MODEL_ID, screenFamily: "android" },
14
+ { id: "ipad-13", label: "iPad 13\u2033", panelPresetId: "ipad13", ascDeviceSet: "APP_IPAD_PRO_129", modelId: "ipad-pro", screenFamily: "ipad", comingSoon: true },
15
+ { id: "ipad-12-9", label: "iPad 12.9\u2033", panelPresetId: "ipad129", ascDeviceSet: "APP_IPAD_PRO_3GEN_129", modelId: "ipad-pro", screenFamily: "ipad", comingSoon: true }
16
+ ];
17
+ var DEFAULT_OUTPUT_DEVICE_ID = "iphone-6-9";
18
+ function deviceById(id) {
19
+ return DEVICES.find((d) => d.id === id);
20
+ }
21
+ function selectableDevices() {
22
+ return DEVICES.filter((d) => !d.comingSoon);
23
+ }
24
+ function deriveOutputs(explicit, panelPresetId2) {
25
+ if (Array.isArray(explicit)) {
26
+ const seen = /* @__PURE__ */ new Set();
27
+ const cleaned = [];
28
+ for (const id of explicit) {
29
+ const d = deviceById(id);
30
+ if (d && !d.comingSoon && !seen.has(id)) {
31
+ seen.add(id);
32
+ cleaned.push(id);
33
+ }
34
+ }
35
+ if (cleaned.length > 0) return cleaned;
36
+ }
37
+ if (panelPresetId2) {
38
+ const byPreset = selectableDevices().find((d) => d.panelPresetId === panelPresetId2);
39
+ if (byPreset) return [byPreset.id];
40
+ }
41
+ return [DEFAULT_OUTPUT_DEVICE_ID];
42
+ }
43
+ var BASE_SCREEN_FAMILY = deviceById(DEFAULT_OUTPUT_DEVICE_ID)?.screenFamily ?? "iphone";
44
+
45
+ // ../mockup-engine/appstore/shotLook.ts
46
+ var DEFAULT_LOOK = {
47
+ angle: "front",
48
+ cameraPos: null,
49
+ roll: "0",
50
+ phoneHeight: "72",
51
+ hOffset: "0",
52
+ vOffset: "0",
53
+ material: "real",
54
+ colorway: "silver",
55
+ customColor: "F5F5F5",
56
+ finish: "0",
57
+ clearcoat: "0",
58
+ clayTone: "grey",
59
+ clayCustom: "B0B0B0",
60
+ flatScreen: false,
61
+ glare: false,
62
+ lighting: true,
63
+ reflections: false
64
+ };
65
+
66
+ // ../mockup-engine/appstore/panel.ts
67
+ function normalizePanelBackgrounds(raw) {
68
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
69
+ const out = {};
70
+ for (const [panelId, entry] of Object.entries(raw)) {
71
+ if (!entry || typeof entry !== "object") continue;
72
+ const e = entry;
73
+ if (e.kind === "solid" && typeof e.color === "string") {
74
+ out[panelId] = { kind: "solid", color: e.color };
75
+ } else if (e.kind === "gradient" && typeof e.from === "string" && typeof e.to === "string" && (e.dir === "horizontal" || e.dir === "vertical")) {
76
+ out[panelId] = { kind: "gradient", from: e.from, to: e.to, dir: e.dir };
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // ../mockup-engine/appstore/captionModel.ts
83
+ var DEFAULT_CAPTION_ANCHOR = { x: 0.5, y: 0.06 };
84
+ function isAutoAnchor(anchor) {
85
+ return !anchor || anchor.x === DEFAULT_CAPTION_ANCHOR.x && anchor.y === DEFAULT_CAPTION_ANCHOR.y;
86
+ }
87
+ function captionLayerStyle(layer) {
88
+ const { text: _text, anchor, ...rest } = layer;
89
+ return isAutoAnchor(anchor) ? { ...rest } : { ...rest, anchor };
90
+ }
91
+ var DEFAULT_CAPTION_STYLE = {
92
+ fontId: "inter",
93
+ sizePt: 44,
94
+ color: "FFFFFF",
95
+ align: "center",
96
+ anchor: DEFAULT_CAPTION_ANCHOR,
97
+ maxWidth: 0.86
98
+ };
99
+ var LEGACY_SUBTITLE_SCALE = 0.55;
100
+ var DEFAULT_LOCALE = "en-US";
101
+ function normalizeCaptionStyles(raw) {
102
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
103
+ const out = {};
104
+ for (const [panelId, entry] of Object.entries(raw)) {
105
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
106
+ out[panelId] = normalizeCaptionLayerStyle(entry);
107
+ }
108
+ return out;
109
+ }
110
+ function normalizeAnchor(raw) {
111
+ if (!raw || typeof raw !== "object") return void 0;
112
+ const a = raw;
113
+ const x = typeof a.x === "number" ? a.x : DEFAULT_CAPTION_ANCHOR.x;
114
+ const y = typeof a.y === "number" ? a.y : DEFAULT_CAPTION_ANCHOR.y;
115
+ return isAutoAnchor({ x, y }) ? void 0 : { x, y };
116
+ }
117
+ function normalizeCaptionLayerStyle(raw) {
118
+ const e = raw && typeof raw === "object" ? raw : {};
119
+ const anchor = normalizeAnchor(e.anchor);
120
+ const merged = { ...DEFAULT_CAPTION_STYLE, ...e };
121
+ if (anchor) merged.anchor = anchor;
122
+ else delete merged.anchor;
123
+ return merged;
124
+ }
125
+ function normalizeCaptionLayer(raw) {
126
+ const e = raw && typeof raw === "object" ? raw : {};
127
+ return { ...normalizeCaptionLayerStyle(e), text: typeof e.text === "string" ? e.text : "" };
128
+ }
129
+ function normalizeCaptionLayerStyles(raw) {
130
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
131
+ const out = {};
132
+ for (const [panelId, entry] of Object.entries(raw)) {
133
+ if (Array.isArray(entry)) {
134
+ if (entry.length > 0) out[panelId] = entry.map(normalizeCaptionLayerStyle);
135
+ } else if (entry && typeof entry === "object") {
136
+ out[panelId] = [normalizeCaptionLayerStyle(entry)];
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ function layersFromCaptionText(text, style2) {
142
+ const layers = [{ ...normalizeCaptionLayerStyle(style2), text: text.headline }];
143
+ if (typeof text.subtitle === "string" && text.subtitle.trim()) {
144
+ layers.push({ ...normalizeCaptionLayerStyle({ ...style2, sizePt: style2.sizePt * LEGACY_SUBTITLE_SCALE }), text: text.subtitle });
145
+ }
146
+ return layers;
147
+ }
148
+ function normalizeCaptionText(raw, captionStyles, baseLocale = DEFAULT_LOCALE) {
149
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
150
+ const styles = normalizeCaptionStyles(captionStyles);
151
+ const styleFor = (panelId) => styles[panelId] ?? DEFAULT_CAPTION_STYLE;
152
+ const innerToLayers = (inner) => {
153
+ if (!inner || typeof inner !== "object" || Array.isArray(inner)) return {};
154
+ const out2 = {};
155
+ for (const [panelId, value] of Object.entries(inner)) {
156
+ if (Array.isArray(value)) {
157
+ if (value.length > 0) out2[panelId] = value.map(normalizeCaptionLayer);
158
+ } else if (value && typeof value === "object" && typeof value.headline === "string") {
159
+ out2[panelId] = layersFromCaptionText(value, styleFor(panelId));
160
+ }
161
+ }
162
+ return out2;
163
+ };
164
+ const entries = Object.entries(raw);
165
+ const looksFlat = entries.some(
166
+ ([, value]) => !!value && typeof value === "object" && typeof value.headline === "string"
167
+ );
168
+ if (looksFlat) return { [baseLocale]: innerToLayers(raw) };
169
+ const out = {};
170
+ for (const [locale2, inner] of entries) out[locale2] = innerToLayers(inner);
171
+ return out;
172
+ }
173
+ function resolveCaptionText(captionText, locale2, baseLocale = DEFAULT_LOCALE) {
174
+ return captionText[locale2 ?? baseLocale] ?? captionText[baseLocale] ?? {};
175
+ }
176
+ function captionStylesFromLayers(captionText, baseLocale = DEFAULT_LOCALE) {
177
+ const out = {};
178
+ const locales = Object.keys(captionText);
179
+ const ordered = locales.includes(baseLocale) ? [baseLocale, ...locales.filter((l) => l !== baseLocale)] : locales;
180
+ for (const locale2 of ordered) {
181
+ const inner = captionText[locale2];
182
+ if (!inner) continue;
183
+ for (const [panelId, layers] of Object.entries(inner)) {
184
+ if (out[panelId] === void 0 && Array.isArray(layers) && layers.length > 0) out[panelId] = layers.map(captionLayerStyle);
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+
190
+ // ../mockup-engine/appstore/localeAxis.ts
191
+ function deriveLocales(captionText, explicit, baseLocale = DEFAULT_LOCALE) {
192
+ const dedupe = (list) => {
193
+ const seen = /* @__PURE__ */ new Set();
194
+ const out = [];
195
+ for (const l of list) if (typeof l === "string" && l && !seen.has(l)) {
196
+ seen.add(l);
197
+ out.push(l);
198
+ }
199
+ return out;
200
+ };
201
+ if (Array.isArray(explicit)) {
202
+ const cleaned = dedupe(explicit);
203
+ if (cleaned.length > 0) return cleaned.includes(baseLocale) ? cleaned : [...cleaned, baseLocale];
204
+ }
205
+ return dedupe([baseLocale, ...Object.keys(captionText)]);
206
+ }
207
+ function normalizeBaseLocale(raw, fallback = DEFAULT_LOCALE) {
208
+ return typeof raw === "string" && raw.length > 0 ? raw : fallback;
209
+ }
210
+
211
+ // ../mockup-engine/appstore/outputCaptions.ts
212
+ function normalizeOutputCaptionText(raw) {
213
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
214
+ const out = {};
215
+ for (const [family, perLocaleRaw] of Object.entries(raw)) {
216
+ if (family === BASE_SCREEN_FAMILY) continue;
217
+ if (!perLocaleRaw || typeof perLocaleRaw !== "object" || Array.isArray(perLocaleRaw)) continue;
218
+ const perLocale = {};
219
+ for (const [locale2, byPanelRaw] of Object.entries(perLocaleRaw)) {
220
+ if (!byPanelRaw || typeof byPanelRaw !== "object" || Array.isArray(byPanelRaw)) continue;
221
+ const byPanel = {};
222
+ for (const [panelId, words] of Object.entries(byPanelRaw)) {
223
+ if (Array.isArray(words) && words.every((w) => typeof w === "string")) {
224
+ byPanel[panelId] = words;
225
+ }
226
+ }
227
+ if (Object.keys(byPanel).length > 0) perLocale[locale2] = byPanel;
228
+ }
229
+ if (Object.keys(perLocale).length > 0) out[family] = perLocale;
230
+ }
231
+ return out;
232
+ }
233
+
234
+ // ../mockup-engine/appstore/localeVariants.ts
235
+ function isLocaleVariants(entry) {
236
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
237
+ const locales = entry.locales;
238
+ return !!locales && typeof locales === "object" && !Array.isArray(locales);
239
+ }
240
+ function resolveLocaleVariant(entry, locale2, baseLocale = DEFAULT_LOCALE) {
241
+ if (!isLocaleVariants(entry)) return entry;
242
+ const variants = entry.locales;
243
+ const declared = Object.keys(variants);
244
+ if (declared.length === 0) {
245
+ throw new Error(
246
+ 'a per-locale screenshot entry has an empty "locales" map \u2014 provide at least one locale, e.g. { "locales": { "en-US": { "ref": "\u2026" } } }'
247
+ );
248
+ }
249
+ return variants[locale2 ?? baseLocale] ?? variants[baseLocale] ?? variants[declared[0]];
250
+ }
251
+ function resolveLocaleVariantSlots(slots, locale2, baseLocale = DEFAULT_LOCALE) {
252
+ return slots.map((slot2) => slot2.map((entry) => resolveLocaleVariant(entry, locale2, baseLocale)));
253
+ }
254
+ function collectVariantLocales(entries) {
255
+ const seen = /* @__PURE__ */ new Set();
256
+ const out = [];
257
+ for (const entry of entries) {
258
+ if (!isLocaleVariants(entry)) continue;
259
+ for (const locale2 of Object.keys(entry.locales)) {
260
+ if (!seen.has(locale2)) {
261
+ seen.add(locale2);
262
+ out.push(locale2);
263
+ }
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ // ../mockup-engine/appstore/shot.ts
270
+ function shotsInPanelOrder(shots, panels) {
271
+ return panels.flatMap((panel) => shots.filter((s) => s.panelId === panel.id));
272
+ }
273
+
274
+ // ../mockup-engine/appstore/serialize.ts
275
+ function extractStripStyle(state, captionText) {
276
+ return {
277
+ panelPresetId: state.panelPresetId,
278
+ bgMode: state.bgMode,
279
+ gradientFrom: state.gradientFrom,
280
+ gradientTo: state.gradientTo,
281
+ gradientDir: state.gradientDir,
282
+ panelColors: state.panelColors,
283
+ shadow: state.shadow,
284
+ floorReflection: state.floorReflection ?? false,
285
+ panelBackgrounds: state.panelBackgrounds ?? {},
286
+ captionStyles: { ...normalizeCaptionLayerStyles(state.captionStyles), ...captionStylesFromLayers(captionText) }
287
+ };
288
+ }
289
+
290
+ // ../mockup-engine/appstore/project.ts
291
+ var PROJECT_SCHEMA_VERSION = 2;
292
+ function serializeProject(state) {
293
+ const ordered = shotsInPanelOrder(state.shots, state.panels);
294
+ const shots = ordered.map((s) => ({
295
+ id: s.id,
296
+ panelId: s.panelId,
297
+ frameNodeId: s.frameNodeId,
298
+ frameName: s.frameName,
299
+ look: s.look,
300
+ inputAnchor: s.inputAnchor,
301
+ // v2 provenance — persisted so drift survives reload
302
+ outputAnchor: s.outputAnchor
303
+ }));
304
+ const baseLocale = normalizeBaseLocale(state.baseLocale);
305
+ const captionText = normalizeCaptionText(state.captionText, state.captionStyles, baseLocale);
306
+ return {
307
+ schema: PROJECT_SCHEMA_VERSION,
308
+ shots,
309
+ panels: state.panels,
310
+ selectedShotId: state.selectedShotId,
311
+ // Caption TEXT is content: emitted here on the project record, deliberately NOT via
312
+ // extractStripStyle — so words can never leak into serializeLook's styling blob (invariant 2).
313
+ captionText,
314
+ // The language list is content too — placed here (not the extractStripStyle spread) so languages
315
+ // never leak into a Look (invariant 3). Per-locale screenshot BYTES (state.localeScreens) are
316
+ // DROPPED, exactly as Shot.bytes are: bytes never enter the record (rule 1). They persist only in
317
+ // the byte-cache and re-join by frameName on load.
318
+ locales: deriveLocales(captionText, state.locales, baseLocale),
319
+ // #94 — the caption inheritance base. Content side (structure), never via extractStripStyle.
320
+ baseLocale,
321
+ // The OUTPUT device list is structure too — placed here (not the extractStripStyle spread) so a
322
+ // device selection never leaks into a Look (invariant 3). Always non-empty; migrates from
323
+ // panelPresetId when the editor state predates the axis.
324
+ outputs: deriveOutputs(state.outputs, state.panelPresetId),
325
+ // #88 — per-Output caption WORDS. Content, like captionText: emitted here on the record, NEVER via
326
+ // extractStripStyle (invariant 2/3). Normalized so a malformed/base-family key can't ride through.
327
+ // {} drops to an empty object (a pre-#88 project stays effectively unchanged).
328
+ outputCaptions: normalizeOutputCaptionText(state.outputCaptions),
329
+ ...extractStripStyle(state, captionText)
330
+ };
331
+ }
332
+
333
+ // ../mockup-engine/appstore/lookDivergence.ts
334
+ function deepEqual(a, b) {
335
+ if (a === b) return true;
336
+ if (typeof a !== typeof b) return false;
337
+ if (a === null || b === null || typeof a !== "object") return false;
338
+ if (Array.isArray(a) || Array.isArray(b)) {
339
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
340
+ return a.every((v, i) => deepEqual(v, b[i]));
341
+ }
342
+ const ao = a;
343
+ const bo = b;
344
+ const ak = Object.keys(ao);
345
+ const bk = Object.keys(bo);
346
+ if (ak.length !== bk.length) return false;
347
+ return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k]));
348
+ }
349
+ function byPanelOrdinal(look2, record) {
350
+ const ordinalOf = /* @__PURE__ */ new Map();
351
+ for (const s of look2.shots) {
352
+ if (!ordinalOf.has(s.panelId)) ordinalOf.set(s.panelId, ordinalOf.size);
353
+ }
354
+ const out = {};
355
+ for (const [panelId, value] of Object.entries(record ?? {})) {
356
+ const ord = ordinalOf.get(panelId);
357
+ if (ord !== void 0) out[ord] = value;
358
+ }
359
+ return out;
360
+ }
361
+ function panelColorsByOrdinal(look2) {
362
+ return byPanelOrdinal(look2, look2.panelColors);
363
+ }
364
+ function captionStylesByOrdinal(look2) {
365
+ return byPanelOrdinal(look2, look2.captionStyles);
366
+ }
367
+ function panelBackgroundsByOrdinal(look2) {
368
+ return byPanelOrdinal(look2, look2.panelBackgrounds);
369
+ }
370
+ function looksMatch(a, b) {
371
+ if (a.bgMode !== b.bgMode) return false;
372
+ if (a.panelPresetId !== b.panelPresetId) return false;
373
+ if (a.gradientFrom !== b.gradientFrom) return false;
374
+ if (a.gradientTo !== b.gradientTo) return false;
375
+ if (a.gradientDir !== b.gradientDir) return false;
376
+ if (a.shadow !== b.shadow) return false;
377
+ if (!!a.floorReflection !== !!b.floorReflection) return false;
378
+ if (a.shots.length !== b.shots.length) return false;
379
+ if (!a.shots.every((s, i) => deepEqual({ ...DEFAULT_LOOK, ...s.look }, { ...DEFAULT_LOOK, ...b.shots[i].look })))
380
+ return false;
381
+ if (!deepEqual(captionStylesByOrdinal(a), captionStylesByOrdinal(b))) return false;
382
+ if (!deepEqual(panelBackgroundsByOrdinal(a), panelBackgroundsByOrdinal(b))) return false;
383
+ return deepEqual(panelColorsByOrdinal(a), panelColorsByOrdinal(b));
384
+ }
385
+
386
+ // src/screenshotInput.ts
387
+ import { readFile } from "node:fs/promises";
388
+ import { basename } from "node:path";
389
+
390
+ // src/uploads.ts
391
+ import { randomBytes } from "node:crypto";
392
+
393
+ // ../api/_lib/shareStorage.ts
394
+ var BUCKET = "share-bundles";
395
+ function storageBase() {
396
+ const base = (process.env.SUPABASE_URL ?? "").replace(/\/+$/, "");
397
+ if (!base) throw new Error("SUPABASE_URL not set");
398
+ return `${base}/storage/v1`;
399
+ }
400
+ function serviceHeaders(extra = {}) {
401
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
402
+ if (!key) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
403
+ return { apikey: key, Authorization: `Bearer ${key}`, ...extra };
404
+ }
405
+ async function createSignedUploadUrl(path) {
406
+ const res = await fetch(`${storageBase()}/object/upload/sign/${BUCKET}/${path}`, {
407
+ method: "POST",
408
+ headers: serviceHeaders({ "Content-Type": "application/json" }),
409
+ body: JSON.stringify({})
410
+ });
411
+ if (!res.ok) {
412
+ throw new Error(`storage sign-upload ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
413
+ }
414
+ const data = await res.json();
415
+ if (!data.url) throw new Error("storage sign-upload: no url in response");
416
+ return { uploadUrl: `${storageBase()}${data.url}` };
417
+ }
418
+ async function createSignedDownloadUrl(path, expiresInSeconds) {
419
+ const res = await fetch(`${storageBase()}/object/sign/${BUCKET}/${path}`, {
420
+ method: "POST",
421
+ headers: serviceHeaders({ "Content-Type": "application/json" }),
422
+ body: JSON.stringify({ expiresIn: expiresInSeconds })
423
+ });
424
+ if (!res.ok) {
425
+ throw new Error(`storage sign-download ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
426
+ }
427
+ const data = await res.json();
428
+ if (!data.signedURL) throw new Error("storage sign-download: no signedURL in response");
429
+ return `${storageBase()}${data.signedURL}`;
430
+ }
431
+ async function putBytes(uploadUrl, bytes, contentType) {
432
+ const res = await fetch(uploadUrl, {
433
+ method: "PUT",
434
+ headers: { "content-type": contentType },
435
+ // A fresh ArrayBuffer copy — undici rejects a Uint8Array view whose buffer it can't own.
436
+ body: bytes.slice()
437
+ });
438
+ if (!res.ok) {
439
+ throw new Error(`storage PUT ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
440
+ }
441
+ }
442
+ async function downloadObject(path) {
443
+ const res = await fetch(`${storageBase()}/object/${BUCKET}/${path}`, { headers: serviceHeaders() });
444
+ if (!res.ok) {
445
+ throw new Error(`storage download ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
446
+ }
447
+ return new Uint8Array(await res.arrayBuffer());
448
+ }
449
+
450
+ // src/uploads.ts
451
+ var DOWNLOAD_URL_TTL_SECONDS = 60 * 60;
452
+ function uploadDirFor(userId) {
453
+ return `uploads/${userId}/${randomBytes(12).toString("base64url")}`;
454
+ }
455
+ var NAME_SEGMENT_RE = /^\d+__([A-Za-z0-9_-]+)\.png$/;
456
+ function decodeNameFromRef(ref) {
457
+ const last = ref.slice(ref.lastIndexOf("/") + 1);
458
+ const match = NAME_SEGMENT_RE.exec(last);
459
+ if (!match) return null;
460
+ try {
461
+ return Buffer.from(match[1], "base64url").toString("utf-8");
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+ function refBelongsToUser(ref, userId) {
467
+ return ref.startsWith(`uploads/${userId}/`) && !ref.includes("..");
468
+ }
469
+ async function fetchUploadedRef(ref, userId) {
470
+ if (!refBelongsToUser(ref, userId)) {
471
+ throw new Error(`ref does not belong to this account: ${ref}`);
472
+ }
473
+ return downloadObject(ref);
474
+ }
475
+ async function storeAsset(dir, filename, bytes, contentType) {
476
+ const ref = `${dir}/${filename}`;
477
+ const { uploadUrl } = await createSignedUploadUrl(ref);
478
+ await putBytes(uploadUrl, bytes, contentType);
479
+ const url = await createSignedDownloadUrl(ref, DOWNLOAD_URL_TTL_SECONDS);
480
+ return { ref, url };
481
+ }
482
+ var MAX_CHATGPT_FILE_BYTES = 20 * 1024 * 1024;
483
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
484
+
485
+ // src/screenshotInput.ts
486
+ function entryName(entry) {
487
+ if (typeof entry === "string") return null;
488
+ if (typeof entry.name === "string" && entry.name.trim()) return entry.name.trim();
489
+ if ("ref" in entry) return decodeNameFromRef(entry.ref);
490
+ if ("path" in entry) return basename(entry.path) || null;
491
+ return null;
492
+ }
493
+ function resolveScreenshotNames(slots) {
494
+ return slots.map((slot2) => slot2.map(entryName));
495
+ }
496
+ var MAX_URL_FETCH_BYTES = 20 * 1024 * 1024;
497
+ var MAX_INLINE_TOTAL_BYTES = 8 * 1024 * 1024;
498
+ function estimateDecodedBytes(base64) {
499
+ return Math.floor(base64.length * 3 / 4);
500
+ }
501
+ function assertInlinePayloadSize(entries) {
502
+ const totalInline = entries.filter((e) => typeof e === "string").reduce((sum, s) => sum + estimateDecodedBytes(s), 0);
503
+ if (totalInline > MAX_INLINE_TOTAL_BYTES) {
504
+ const mb = (totalInline / 1024 / 1024).toFixed(1);
505
+ const capMb = MAX_INLINE_TOTAL_BYTES / 1024 / 1024;
506
+ throw new Error(
507
+ `payload too large (${mb}MB inline, cap ${capMb}MB) \u2014 call request_screenshot_upload and pass { "ref": "..." } entries instead of inline base64.`
508
+ );
509
+ }
510
+ }
511
+ function assertInlinePayloadSizeNested(slots) {
512
+ assertInlinePayloadSize(slots.flat());
513
+ }
514
+ async function fetchUrlAsBase64(url) {
515
+ if (!/^https:\/\//i.test(url)) throw new Error(`screenshot url must be https: ${url}`);
516
+ const res = await fetch(url, { redirect: "manual" });
517
+ if (res.status >= 300 && res.status < 400) {
518
+ throw new Error(`screenshot url redirected \u2014 redirects are not followed (https-only, one hop): ${url}`);
519
+ }
520
+ if (!res.ok) throw new Error(`screenshot url fetch failed (${res.status}): ${url}`);
521
+ const contentType = res.headers.get("content-type") ?? "";
522
+ if (!/^image\/png/i.test(contentType)) {
523
+ throw new Error(`screenshot url did not return image/png (got "${contentType || "no content-type"}"): ${url}`);
524
+ }
525
+ const buf = new Uint8Array(await res.arrayBuffer());
526
+ if (buf.byteLength > MAX_URL_FETCH_BYTES) {
527
+ throw new Error(`screenshot url exceeds the ${MAX_URL_FETCH_BYTES / 1024 / 1024}MB cap: ${url}`);
528
+ }
529
+ return Buffer.from(buf).toString("base64");
530
+ }
531
+ async function readLocalPathAsBase64(path) {
532
+ try {
533
+ return (await readFile(path)).toString("base64");
534
+ } catch (err) {
535
+ throw new Error(`could not read local screenshot at "${path}": ${err.message}`);
536
+ }
537
+ }
538
+ async function resolvePanelBytes(entry, userId, allowLocalPath) {
539
+ if ("path" in entry) {
540
+ if (!allowLocalPath) {
541
+ throw new Error(
542
+ `{ "path": "${entry.path}" } panel entries are only available over the local stdio server (npx shotops-mcp) \u2014 the hosted server rejects them. Pass { "ref": "..." } from a prior render_strip output:"urls" result instead.`
543
+ );
544
+ }
545
+ try {
546
+ return await readFile(entry.path);
547
+ } catch (err) {
548
+ throw new Error(`could not read local panel at "${entry.path}": ${err.message}`);
549
+ }
550
+ }
551
+ return fetchUploadedRef(entry.ref, userId);
552
+ }
553
+ async function resolveEntry(entry, userId, allowLocalPath) {
554
+ if (typeof entry === "string") return entry;
555
+ if ("ref" in entry) return Buffer.from(await fetchUploadedRef(entry.ref, userId)).toString("base64");
556
+ if ("path" in entry) {
557
+ if (!allowLocalPath) {
558
+ throw new Error(
559
+ `{ "path": "${entry.path}" } screenshot entries are only available over the local stdio server (npx shotops-mcp) \u2014 the hosted server rejects them. Use { "ref": "..." } (request_screenshot_upload) or { "url": "..." } instead.`
560
+ );
561
+ }
562
+ return readLocalPathAsBase64(entry.path);
563
+ }
564
+ return fetchUrlAsBase64(entry.url);
565
+ }
566
+ async function resolveScreenshots(slots, userId, allowLocalPath = false) {
567
+ assertInlinePayloadSizeNested(slots);
568
+ return Promise.all(slots.map((slot2) => Promise.all(slot2.map((entry) => resolveEntry(entry, userId, allowLocalPath)))));
569
+ }
570
+
571
+ // src/schemas.ts
572
+ import { z } from "zod";
573
+ var PANEL_PRESET_IDS = ["r69", "r65", "r55", "ipad13", "ipad129"];
574
+ var OUTPUT_DEVICE_IDS = ["iphone-6-9", "iphone-6-5"];
575
+ var OUTPUT_DEVICES = [
576
+ { id: "iphone-6-9", label: "iPhone 6.9\u2033", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67" },
577
+ { id: "iphone-6-5", label: "iPhone 6.5\u2033", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65" }
578
+ ];
579
+ function outputDeviceFor(id) {
580
+ return OUTPUT_DEVICES.find((device) => device.id === id);
581
+ }
582
+ var PANEL_LABELS = {
583
+ r69: "6.9\u2033 iPhone",
584
+ r65: "6.5\u2033 iPhone",
585
+ r55: "5.5\u2033 iPhone",
586
+ ipad13: "13\u2033 iPad",
587
+ ipad129: "12.9\u2033 iPad"
588
+ };
589
+ function panelLabelFor(presetId, width, height) {
590
+ const name = PANEL_LABELS[presetId];
591
+ return name ? `${name} \xB7 ${width}\xD7${height}` : `${width}\xD7${height}`;
592
+ }
593
+ var PANEL_DIMENSIONS = {
594
+ r69: { width: 1290, height: 2796 },
595
+ r65: { width: 1242, height: 2688 },
596
+ r55: { width: 1242, height: 2208 },
597
+ ipad13: { width: 2064, height: 2752 },
598
+ ipad129: { width: 2048, height: 2732 }
599
+ };
600
+ function panelDimensionsFor(presetId) {
601
+ return PANEL_DIMENSIONS[presetId] ?? PANEL_DIMENSIONS.r69;
602
+ }
603
+ var nameField = z.string().max(200).optional().describe(
604
+ `The screenshot's original filename (e.g. "03_statistics.png"). Pass it so that, if this render is saved as a project, the user's own screenshots folder re-loads exactly this file by name. Optional if the ref already carries a name (from request_screenshot_upload({ names })); omit entirely and the record falls back to a positional name (screen-N.png).`
605
+ );
606
+ var inlineBase64Entry = z.string().min(1).describe('Inline base64-encoded PNG (no "data:" prefix). Small payloads only.');
607
+ var refEntry = z.object({ ref: z.string().min(1), name: nameField }).strict().describe("A ref returned by request_screenshot_upload \u2014 resolved server-side, never re-sent inline.");
608
+ var urlEntry = z.object({ url: z.string().min(1), name: nameField }).strict().describe("An https URL to a PNG \u2014 fetched server-side (image/png, no redirects, ~20MB cap).");
609
+ var pathEntry = z.object({ path: z.string().min(1), name: nameField }).strict().describe(
610
+ "A local filesystem path to a PNG, read straight off disk \u2014 ONLY available over the local stdio server (npx shotops-mcp); the hosted server rejects this entry shape."
611
+ );
612
+ var singleScreenshotEntry = z.union([inlineBase64Entry, refEntry, urlEntry, pathEntry]);
613
+ var localeScreenshotEntry = z.object({ locales: z.record(z.string(), singleScreenshotEntry) }).strict().describe(
614
+ 'Per-locale variants of ONE screenshot: { "locales": { "en-US": <entry>, "de-DE": <entry> } } (each variant is an inline base64 / ref / url / path entry). render_strip picks the top-level `locale`\'s variant; emit_bundle with `locales` renders/packages every listed locale. A locale with no variant of its own falls back to the en-US variant (else the first declared), so you can localize only some screenshots.'
615
+ );
616
+ var screenshotEntry = z.union([inlineBase64Entry, refEntry, urlEntry, pathEntry, localeScreenshotEntry]);
617
+ var screenshotsDescription = 'An array of App Store slots in display order (1\u201310; the store allows up to 10 per size). Each slot is itself an array of screenshots: one entry = one phone in that slot, several entries = multiple phones composited into that one slot. Each screenshot entry is EITHER an inline base64 PNG string (small payloads only \u2014 large inline payloads are rejected with a clear error), OR { "ref": "..." } from request_screenshot_upload, OR { "url": "https://..." }, OR (local stdio mode only) { "path": "/abs/or/relative/path.png" } to read a file straight off disk, OR { "locales": { "<locale>": <any of those> } } to vary that screenshot per App Store locale (missing locales fall back to the en-US variant). Prefer ref/url for real screenshots so the bytes never transit this conversation.';
618
+ var slot = z.array(screenshotEntry).min(1).max(6).describe(
619
+ "One App Store slot: 1\u20136 screenshots. Several entries = several phones composited into that one slot."
620
+ );
621
+ var screenshots = z.array(slot).min(1).max(10).describe(screenshotsDescription);
622
+ var screenshotsForSave = z.array(slot).min(1).max(10).describe(
623
+ `${screenshotsDescription} NOTE: save_project does not render \u2014 it uses only each slot's SHAPE (phones per panel) and each entry's \`name\` (source filename, for re-load matching). The image bytes are never fetched or stored, so pass the same entries you rendered with (esp. their \`name\`s).`
624
+ );
625
+ var screenshotsOptional = z.array(slot).min(1).max(10).optional().describe(`${screenshotsDescription} Required unless \`panels\` (pre-rendered refs) is given instead.`);
626
+ var panelRef = z.object({ ref: z.string().min(1) }).strict();
627
+ var pathPanel = z.object({ path: z.string().min(1) }).strict().describe(
628
+ '(local stdio mode only) a pre-rendered panel PNG read straight off disk: { "path": "/abs/or/relative/panel-01.png" }. Mirrors a screenshot { path } \u2014 rejected on the hosted server. Lets the free local tier PACKAGE a render with no account / no upload refs.'
629
+ );
630
+ var panelEntry = z.union([panelRef, pathPanel]);
631
+ var localePanelRef = z.object({ locales: z.record(z.string(), panelEntry) }).strict().describe(
632
+ 'Per-locale variants of ONE pre-rendered panel: { "locales": { "en-US": { "ref": "\u2026" }, "de-DE": { "ref": "\u2026" } } }. Each variant is a { "ref" } or (local) { "path" }. Used with `locales` to compose a multi-locale bundle from per-locale render_strip results without re-rendering; a missing locale falls back to the en-US variant (else the first declared).'
633
+ );
634
+ var panelRefs = z.array(z.union([panelRef, pathPanel, localePanelRef])).min(1).max(10).optional().describe(
635
+ 'Pre-rendered panels, packaged into the bundle WITHOUT rendering again \u2014 either { "ref": "\u2026" } from a prior render_strip `output: "urls"` call (hosted), or (local stdio mode) { "path": "/abs/panel.png" } straight off disk so the free local tier can package a two-pass render. Mutually exclusive with `screenshots`; one of the two is required. An entry may also be { "locales": { "<locale>": { "ref" | "path" } } } to vary that panel per locale (see `locales`).'
636
+ );
637
+ var output = z.enum(["inline", "urls"]).optional().describe(
638
+ 'How to return rendered bytes: "inline" (base64), "urls" (uploaded, short-lived signed download URLs \u2014 use for real/full-resolution renders so bytes never transit this conversation), or omit for auto (inline under ~200KB total, urls above).'
639
+ );
640
+ var outputsField = z.array(z.enum(OUTPUT_DEVICE_IDS)).min(1).max(OUTPUT_DEVICE_IDS.length).optional().describe(
641
+ "Target device outputs to render into one fastlane bundle. Supported now: iphone-6-9 (1290\xD72796 / APP_IPHONE_67) and iphone-6-5 (1242\xD72688 / APP_IPHONE_65). Each is rendered from the same Look and inputs; omit this field to keep the legacy single panelPresetId form unchanged. Not supported with pre-rendered `panels` refs."
642
+ );
643
+ var clip = z.enum(["strip", "panel"]).optional().describe(
644
+ "How the strip handles a device that overflows its panel. 'strip' (default) = one continuous canvas \u2014 a phone can straddle the seam into the next panel (swipe-through scenes; also what hOffset \xB11 opts into). 'panel' = clip each phone to its OWN panel so it can't bleed a foreign edge into the neighbouring App Store screenshot (independent images). A render option, not part of the saved look."
645
+ );
646
+ var preview = z.boolean().optional().describe(
647
+ "Render at ~25% resolution for a fast, cheap styling preview \u2014 small enough to always come back inline. Not for final delivery: re-render without `preview` (or use emit_bundle directly) once the look is right."
648
+ );
649
+ var panelPresetId = z.enum(PANEL_PRESET_IDS).optional().describe("App Store screenshot size. Default r69 (6.9\u2033 iPhone, 1290\xD72796).");
650
+ var useSavedLook = z.boolean().optional().describe("If true, style the strip with the project's saved look (ignored when `look`/`style` is given).");
651
+ var locale = z.string().optional().describe(
652
+ 'App Store locale, e.g. "de-DE" (default en-US). Labels the render, selects which { "locales": \u2026 } screenshot variants render, and, for emit_bundle, selects the fastlane screenshots folder. Does not select caption text \u2014 pass the copy for this locale yourself via `style.captions[].text` (see the README\'s `captions.<locale>.json` convention).'
653
+ );
654
+ var localesField = z.array(z.string().min(1)).min(1).max(40).optional().describe(
655
+ 'Emit ONE multi-locale bundle: render (or, with `panels`, just package) each listed App Store locale and lay them out as fastlane/screenshots/<locale>/ folders in a single zip. Screenshot/panel entries may vary per locale via { "locales": \u2026 }; a plain entry \u2014 or a missing locale variant \u2014 falls back to the en-US variant, so a locale with no screens of its own ships the fallback pixels in its own folder (correct for fastlane). With `screenshots` this renders once PER locale (slow \u2014 prefer per-locale render_strip calls with output:"urls", then compose here with per-locale `panels`). A single-entry list behaves exactly like `locale`. Caption text is per-RENDER input: in this one-call form the same style.captions apply to every locale, so for localized captions use the render-per-locale-then-compose flow.'
656
+ );
657
+ var CAPTION_FONT_IDS = ["inter", "manrope", "poppins", "fraunces", "space-grotesk"];
658
+ var DEFAULT_SHOT_LOOK = {
659
+ angle: "front",
660
+ cameraPos: null,
661
+ roll: "0",
662
+ phoneHeight: "72",
663
+ hOffset: "0",
664
+ vOffset: "0",
665
+ material: "real",
666
+ colorway: "silver",
667
+ customColor: "F5F5F5",
668
+ finish: "0",
669
+ clearcoat: "0",
670
+ clayTone: "grey",
671
+ clayCustom: "B0B0B0",
672
+ flatScreen: false,
673
+ glare: false,
674
+ lighting: true,
675
+ reflections: false
676
+ };
677
+ var DEFAULT_CAPTION_STYLE2 = {
678
+ fontId: "inter",
679
+ sizePt: 44,
680
+ color: "FFFFFF",
681
+ align: "center",
682
+ anchor: { x: 0.5, y: 0.06 },
683
+ maxWidth: 0.86
684
+ };
685
+ var numeric = z.union([z.string(), z.number()]);
686
+ var shotLook = z.object({
687
+ angle: z.enum(["front", "left", "right"]).optional().describe("Camera preset. Default front."),
688
+ cameraPos: z.object({ x: z.number(), y: z.number(), z: z.number() }).nullable().optional().describe("Manual camera position override; null/omit = use the angle preset."),
689
+ roll: numeric.optional().describe("\u221245\u202645\xB0 clock-hand tilt. Default 0."),
690
+ phoneHeight: numeric.optional().describe("Phone height, 20\u2013100 (% of panel height). Default 72."),
691
+ hOffset: numeric.optional().describe("Horizontal position \u2212100\u2026100; 0 = centre. Default 0 (multi-phone slots auto-stagger)."),
692
+ vOffset: numeric.optional().describe("Vertical position \u2212200\u2026200; 0 = centre. Default 0."),
693
+ material: z.enum(["real", "clay"]).optional().describe("Device body. Default real."),
694
+ colorway: z.enum(["orange", "blue", "silver", "custom"]).optional().describe('Body colour (material "real"). Default silver.'),
695
+ customColor: z.string().optional().describe('Hex body colour when colorway is "custom".'),
696
+ finish: numeric.optional().describe("0 = matte \u2026 1 = glossy. Default 0."),
697
+ clearcoat: numeric.optional().describe("0 = none \u2026 1 = glossy clear-coat lacquer over the body (a wet/ceramic sheen, on top of finish). Default 0."),
698
+ clayTone: z.enum(["grey", "white", "charcoal", "custom"]).optional().describe('Clay tone (material "clay"). Default grey.'),
699
+ clayCustom: z.string().optional().describe('Hex clay colour when clayTone is "custom".'),
700
+ flatScreen: z.boolean().optional().describe("Render the screen flat (no curvature). Default false."),
701
+ glare: z.boolean().optional().describe("Screen glare. Default false."),
702
+ lighting: z.boolean().optional().describe("Scene lighting. Default true."),
703
+ reflections: z.boolean().optional().describe("Body reflections. Default false.")
704
+ }).strict();
705
+ var background = z.object({
706
+ mode: z.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel."),
707
+ gradientFrom: z.string().optional().describe("Gradient start (hex). Default #1b1b2e."),
708
+ gradientTo: z.string().optional().describe("Gradient end (hex). Default #0a0a14."),
709
+ gradientDir: z.enum(["vertical", "horizontal"]).optional().describe("Default vertical."),
710
+ panelColors: z.array(z.string().nullable()).max(10).optional().describe('Per-panel flat colours (mode "perPanel"), one entry per slot in order; null = default.'),
711
+ shadow: z.boolean().optional().describe("Drop shadow under the phones. Default true."),
712
+ floorReflection: z.boolean().optional().describe("Flipped, faded floor reflection under each phone. Default false.")
713
+ }).strict();
714
+ var captionEntry = z.object({
715
+ fontId: z.enum(CAPTION_FONT_IDS).optional().describe("Bundled font. Default inter."),
716
+ sizePt: z.number().positive().max(200).optional().describe("Font size in iOS points (preset-independent). Default 44."),
717
+ color: z.string().optional().describe("Text colour (hex). Default FFFFFF."),
718
+ align: z.enum(["left", "center", "right"]).optional().describe("Default center."),
719
+ anchor: z.object({ x: z.number().min(0).max(1).optional(), y: z.number().min(0).max(1).optional() }).optional().describe("Normalized 0\u20131 position of the caption on the panel. Default {x:0.5, y:0.06}."),
720
+ maxWidth: z.number().min(0.05).max(1).optional().describe("Wrap width as a 0\u20131 fraction of the panel. Default 0.86."),
721
+ text: z.string().max(200).optional().describe("The headline \u2014 per-RENDER input, never stored in a look."),
722
+ subtitle: z.string().max(300).optional().describe("Optional subtitle under the headline \u2014 also per-render input.")
723
+ }).strict();
724
+ var style = z.object({
725
+ shotLook: shotLook.optional().describe("Device/camera styling, applied to every phone in the strip."),
726
+ background: background.optional(),
727
+ // plan-20 Phase 8 (D9): one entry PER PANEL, and each entry is EITHER a single caption object OR
728
+ // an ORDERED ARRAY of caption layers (a multi-line strip: headline + subline + …). Both are
729
+ // accepted forever — this is a public wire contract (published npm + hosted Fly server), so the
730
+ // union is additive, never a breaking swap. null = no caption on that panel.
731
+ captions: z.array(z.union([captionEntry, z.array(captionEntry).max(8)]).nullable()).max(10).optional().describe(
732
+ "One entry PER PANEL in slot order; null = no caption on that panel. An entry is EITHER a single caption object OR an array of caption layers (stacked, in order) on that panel."
733
+ )
734
+ }).strict().optional().describe(
735
+ "Structured styling \u2014 the discoverable path (call describe_look for the full field catalog + defaults). COMPOSES with `look`: pass BOTH to get per-panel devices from the `look` AND captions from `style.captions` in ONE render (every real App Store strip). When a `look` is also given it supplies the devices + background, so `style.shotLook`/`style.background` are ignored (a note says so) \u2014 use `style` for `captions` then. Alone, `style` styles every phone identically + captions. Also composes with `useSavedLook`/`version`."
736
+ );
737
+ var lookShot = z.object({
738
+ panelId: z.string().optional().describe("Panel this shot styles. Panels are panel-1\u2026panel-N in slot order; a saved look\u2019s own ids are remapped by ordinal."),
739
+ look: shotLook.optional().describe("This panel\u2019s device/camera styling \u2014 the SAME fields as style.shotLook.")
740
+ }).strict();
741
+ var lookObject = z.object({
742
+ schema: z.number().optional().describe("Look schema version (read_look stamps it; ignored on input)."),
743
+ panelPresetId: z.string().optional().describe("Canvas size preset, e.g. r69 = 1290\xD72796. Default r69."),
744
+ bgMode: z.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel. Default gradient."),
745
+ gradientFrom: z.string().optional().describe("Gradient start (hex). Default #1b1b2e."),
746
+ gradientTo: z.string().optional().describe("Gradient end (hex). Default #0a0a14."),
747
+ gradientDir: z.enum(["horizontal", "vertical"]).optional().describe("Default vertical."),
748
+ panelColors: z.record(z.string(), z.string()).optional().describe("Per-panel flat colours (mode perPanel), keyed by panel id."),
749
+ shadow: z.boolean().optional().describe("Drop shadow under the phones. Default true."),
750
+ floorReflection: z.boolean().optional().describe("Flipped, faded floor reflection under each phone. Default false."),
751
+ panelBackgrounds: z.record(z.string(), z.unknown()).optional().describe("Per-frame background overrides keyed by panel id (web-authored; agents rarely set these)."),
752
+ captionStyles: z.record(z.string(), z.unknown()).optional().describe("Per-panel caption STYLE arrays keyed by panel id (styling only, no words \u2014 for caption text pass style.captions)."),
753
+ shots: z.array(lookShot).max(60).optional().describe("One entry per panel in slot order, each with its OWN device `look` \u2014 this is how a look styles each panel DIFFERENTLY (mixed-device / continuous-scene strips), which style.shotLook cannot.")
754
+ }).strict();
755
+ var look = lookObject.optional().describe(
756
+ 'A ShotOps "look" \u2014 per-panel DEVICE styling + background, exactly as read_look returns it. Its shots[] give each panel its own device (tint/tilt/size); style.shotLook is one device for all. COMPOSES with `style`: pass a look for the devices + `style.captions` for the words in ONE render. Validated \u2014 unknown keys are rejected (call describe_look for the full field catalog).'
757
+ );
758
+ var project = z.string().min(1).optional().describe("The ShotOps project id to target (as returned by read_look). Omit = your most recently edited project.");
759
+ var version = z.number().int().min(1).optional().describe(
760
+ "Render a specific saved look version of the project (implies the saved look). Omit = the project's HELD version if one is held (hold_look), else the latest saved look."
761
+ );
762
+ var createProjectFlag = z.boolean().optional().describe(
763
+ "Also save this render as an editable ShotOps project the signed-in user can open + refine (returns projectId + an openUrl). Pass an existing `project` id to update it instead of creating a new one. Full-res only \u2014 not allowed with preview:true. No image bytes are stored (structure + look only)."
764
+ );
765
+ var projectName = z.string().optional().describe('Name for the created project (when createProject makes a new one). Default "ShotOps render".');
766
+ var sourceDir = z.string().optional().describe(
767
+ 'Advanced: the on-disk folder these screenshots were read from (only meaningful when they came from local { path } entries on the SAME machine). When set, the saved project remembers this folder as its screen source (kind: "local-path") instead of just filenames. Used by the local stdio bridge \u2014 most callers should omit it.'
768
+ );
769
+ var renderStripShape = {
770
+ screenshots,
771
+ panelPresetId,
772
+ clip,
773
+ look,
774
+ style,
775
+ useSavedLook,
776
+ project,
777
+ version,
778
+ locale,
779
+ preview,
780
+ output,
781
+ createProject: createProjectFlag,
782
+ projectName,
783
+ sourceDir
784
+ };
785
+ var emitBundleShape = {
786
+ screenshots: screenshotsOptional,
787
+ panels: panelRefs,
788
+ panelPresetId,
789
+ clip,
790
+ outputs: outputsField,
791
+ look,
792
+ style,
793
+ useSavedLook,
794
+ project,
795
+ version,
796
+ bundleId: z.string().describe(
797
+ "Your app\u2019s reverse-DNS bundle identifier, e.g. com.acme.app. Pre-fills the fastlane Deliverfile ONLY \u2014 it is never sent to any store from here."
798
+ ),
799
+ locale,
800
+ locales: localesField,
801
+ projectName: z.string().optional().describe('A name for the bundle (used for the zip filename + any share link). Default "shotops".'),
802
+ share: z.boolean().optional().describe(
803
+ "Also create an unguessable, 14-day share link to a landing page (preview + bundle download + fastlane how-to), attributed to your account. Default false."
804
+ ),
805
+ // create-project: save the rendered strip as an editable Studio project too (returns
806
+ // projectId + openUrl). Only on the RENDER path (screenshots) — the compose-only `panels`
807
+ // path never re-styles, so there's nothing to persist; it's rejected with a pointer to
808
+ // render_strip/save_project.
809
+ createProject: createProjectFlag,
810
+ output,
811
+ sourceDir
812
+ };
813
+ var saveProjectShape = {
814
+ screenshots: screenshotsForSave,
815
+ panelPresetId,
816
+ look,
817
+ style,
818
+ useSavedLook,
819
+ project,
820
+ version,
821
+ locale,
822
+ projectName,
823
+ sourceDir
824
+ };
825
+ var readLookShape = { project };
826
+ var readProjectShape = { project };
827
+ var screenshotFileList = z.array(singleScreenshotEntry).min(1).max(60).describe(
828
+ `A FLAT list of the developer's raw app screenshots \u2014 each an inline base64 PNG, { "ref" }, { "url" }, or (local stdio only) { "path" }, and each with a \`name\` (its original filename). They are matched to the saved project's shots BY FILENAME (name === the shot's frameName), so the order you pass them does NOT matter and you never pre-sort. A shot with no matching file is reported as missing (it does not error the render). Prefer ref/url for real screenshots so the bytes never transit this conversation.`
829
+ );
830
+ var renderProjectShape = {
831
+ project,
832
+ screenshots: screenshotFileList,
833
+ locale,
834
+ preview,
835
+ output
836
+ };
837
+ var saveLookShape = {
838
+ project,
839
+ // C (#50): the SAME validated shape as the render `look` (required here). A typo'd device key
840
+ // is rejected at save time now, not silently persisted — the handler's structural `shots` gate
841
+ // and the looksMatch dedupe still run on top.
842
+ look: lookObject.describe(
843
+ "The look JSON to save (styling only \u2014 the shape read_look returns / describe_look documents). Caption text is NOT part of a look; pass it per render instead."
844
+ ),
845
+ sourceName: z.string().max(120).optional().describe("Where this styling came from (shown in the studio UI).")
846
+ };
847
+ var heldVersion = z.number().int().min(1).describe("The saved look version to HOLD as the one agents render by default (see read_look `versions`).");
848
+ var holdLookShape = { project, version: heldVersion };
849
+ var releaseLookShape = { project };
850
+ var describeLookShape = {};
851
+ var DESCRIBE_LOOK_CATALOG = {
852
+ style: {
853
+ shotLook: {
854
+ description: "Device/camera styling, applied to every phone in the strip.",
855
+ defaults: DEFAULT_SHOT_LOOK,
856
+ fields: {
857
+ angle: "front | left | right (preset only \u2014 there is no free yaw)",
858
+ cameraPos: "{ x, y, z } rotates the camera around the device to refine the angle beyond the presets, or null to use the preset. It does NOT zoom or resize \u2014 device SIZE is set ONLY by phoneHeight (the export alpha-crops the device tight, so camera distance is discarded).",
859
+ roll: "-45\u202645 (degrees, clock-hand tilt; continuous, unlike angle)",
860
+ phoneHeight: "20\u2026100 (% of panel height) \u2014 the ONLY device-size control. Larger = more near-bleed. Default 72.",
861
+ hOffset: "-100\u2026100 (0 = centre; multi-phone slots auto-stagger when unset)",
862
+ vOffset: "-200\u2026200 (0 = centre)",
863
+ material: "real | clay",
864
+ colorway: "orange | blue | silver | custom (with customColor hex)",
865
+ finish: "0 (matte) \u2026 1 (glossy)",
866
+ clearcoat: "0 (none) \u2026 1 (glossy clear-coat lacquer over the body, on top of finish)",
867
+ clayTone: "grey | white | charcoal | custom (with clayCustom hex)",
868
+ flatScreen: "boolean",
869
+ glare: "boolean",
870
+ lighting: "boolean",
871
+ reflections: "boolean"
872
+ }
873
+ },
874
+ background: {
875
+ description: "The strip background.",
876
+ defaults: { mode: "gradient", gradientFrom: "#1b1b2e", gradientTo: "#0a0a14", gradientDir: "vertical", shadow: true, floorReflection: false },
877
+ fields: {
878
+ mode: "gradient | perPanel",
879
+ panelColors: 'per-panel hex colours (mode "perPanel"), one entry per slot in order',
880
+ floorReflection: "boolean \u2014 a flipped, faded floor reflection under each phone"
881
+ }
882
+ },
883
+ captions: {
884
+ description: "One entry PER PANEL in slot order (null = no caption). An entry is EITHER a single caption object OR an array of caption LAYERS stacked in order on that panel (e.g. a headline layer + a subline layer). Styling is part of the look; `text` (and a single caption\u2019s `subtitle`) are per-RENDER input and are never stored (zero-custody of content). Captions AUTO-LAYOUT above the device by default so they never collide with the phone.",
885
+ defaults: DEFAULT_CAPTION_STYLE2,
886
+ fonts: CAPTION_FONT_IDS,
887
+ fields: {
888
+ sizePt: "font size in iOS points \u2014 preset-independent",
889
+ anchor: "{ x, y } normalized 0\u20131 position on the panel \u2014 OMIT to auto-place above the device",
890
+ maxWidth: "wrap width as a 0\u20131 fraction of the panel width",
891
+ text: "the headline / this layer\u2019s words (per-render input)",
892
+ subtitle: "single-caption shape only: an optional second line (per-render input). In the ARRAY shape, add another layer instead."
893
+ }
894
+ }
895
+ },
896
+ // B (#49): the opaque `look` shape, dumped in full with an example — no more reverse-engineering
897
+ // it from the source. A look is per-panel DEVICE styling + background; style.shotLook is one device
898
+ // for all. All fields optional; validated (unknown keys rejected).
899
+ look: {
900
+ description: "The per-panel styling path: give each panel its OWN device (tint/tilt/size) via shots[], which style.shotLook cannot. The exact shape read_look returns \u2014 hand-author it or round-trip it. COMPOSES with style (see notes): a look for the devices + style.captions for the words, one render.",
901
+ fields: {
902
+ shots: "The per-panel devices: an array, one entry { panelId, look } per panel in slot order, each `look` taking the SAME fields as style.shotLook above. This is the only way to vary the device per panel.",
903
+ panelPresetId: "canvas size preset id (see panelPresets); the top-level panelPresetId param overrides it",
904
+ bgMode: "gradient | perPanel",
905
+ gradientFrom: "gradient start (hex). Default #1b1b2e",
906
+ gradientTo: "gradient end (hex). Default #0a0a14",
907
+ gradientDir: "vertical | horizontal. Default vertical",
908
+ panelColors: 'per-panel flat colours keyed by panel id (mode perPanel), e.g. { "panel-1": "#123456" }',
909
+ shadow: "boolean \u2014 drop shadow under the phones. Default true",
910
+ floorReflection: "boolean \u2014 flipped, faded floor reflection. Default false",
911
+ panelBackgrounds: "per-frame background overrides keyed by panel id \u2014 web-authored; usually only present on a round-tripped look",
912
+ captionStyles: "per-panel caption STYLE arrays keyed by panel id (styling only, no words) \u2014 web-authored; for caption text pass style.captions"
913
+ },
914
+ example: {
915
+ bgMode: "gradient",
916
+ gradientFrom: "#3E3D42",
917
+ gradientTo: "#1E1D22",
918
+ shots: [
919
+ { panelId: "panel-1", look: { material: "clay", clayTone: "custom", clayCustom: "175030", angle: "left", roll: "-19", phoneHeight: "74" } },
920
+ { panelId: "panel-2", look: { material: "clay", clayTone: "custom", clayCustom: "194860", angle: "right", roll: "8", phoneHeight: "86" } }
921
+ ]
922
+ }
923
+ },
924
+ panelPresets: PANEL_PRESET_IDS.map((id) => ({ id, ...PANEL_DIMENSIONS[id], label: PANEL_LABELS[id] })),
925
+ // E (#54): a top-level render option (not styling) — named here so it's discoverable.
926
+ clip: {
927
+ description: "How the strip handles a device that overflows its panel (top-level render_strip/emit_bundle param, not part of the look). 'strip' (default) = one continuous canvas; a phone can straddle the seam into the next panel (swipe-through scenes). 'panel' = clip each phone to its own panel so it can't bleed a foreign-coloured edge into the neighbouring App Store screenshot.",
928
+ values: ["strip", "panel"],
929
+ default: "strip"
930
+ },
931
+ notes: [
932
+ "Pass `style` to render_strip/emit_bundle for structured styling, or save a composed look with save_look and render with useSavedLook.",
933
+ "style.shotLook is ONE device for the whole strip. For a DIFFERENT device per panel (mixed-device or continuous-scene strips), pass a `look` instead \u2014 a look has shots[], one entry per panel in slot order, each with its own `look` (same fields as shotLook). See the `look` section for the full shape + an example.",
934
+ "COMPOSE (every real App Store strip): to style each panel with its OWN device AND caption it, pass BOTH `look` (per-panel devices via shots[] + background) and `style` (captions via style.captions) in ONE render_strip/emit_bundle call \u2014 they combine. The look supplies the styling; if you also pass style.shotLook/style.background alongside a look they are ignored (the look wins) and a note tells you.",
935
+ "Fidelity ceilings: `angle` is preset-only (front|left|right) and `roll` is continuous (\xB145\xB0), but there is NO free yaw \u2014 an arbitrary 3-axis source rotation can only be approximated. Device SIZE is set only by `phoneHeight` (there is no separate scale, and cameraPos does not resize). Fonts are the 5 in `style.captions.fonts` \u2014 pick the closest; an exact non-bundled typeface match is not possible.",
936
+ "save_look appends a look version; hold_look sets which saved version agents render by default (or Hold one in ShotOps\u2019s Version history), and render_strip `version` renders a specific one."
937
+ ]
938
+ };
939
+ var requestScreenshotUploadShape = {
940
+ count: z.number().int().min(1).max(10).describe("How many upload slots to mint (1\u201310, one per screenshot)."),
941
+ // Phase 2 (item 2, PINNED): each name is encoded into its slot's ref, so it reaches the
942
+ // saved project's frameName even if a later render_strip/emit_bundle call passes that slot's
943
+ // `{ ref }` bare (no `name`). Optional; a shorter/absent array just leaves those slots nameless.
944
+ names: z.array(z.string().max(200)).optional().describe(
945
+ 'Original filenames, one per slot in the same order (e.g. ["03_statistics.png", ...]). Each is encoded into that slot\'s ref, so the saved project re-loads by real filename even without re-passing `name` at render time. Optional.'
946
+ ),
947
+ // plan-21 Phase 5 (D6): an ORGANIZATIONAL tag only — which locale this batch of uploads is
948
+ // for. Echoed back in the response so an agent uploading per-locale batches can keep its
949
+ // refs sorted; it changes nothing about the refs themselves (any ref works in any locale
950
+ // slot of a { locales: … } entry).
951
+ locale: z.string().optional().describe(
952
+ 'Optional tag: which App Store locale these screenshots are for (e.g. "de-DE") when uploading one batch per locale. Echoed back for your bookkeeping only \u2014 the refs are locale-agnostic; place each under the matching key of a { "locales": \u2026 } screenshot entry.'
953
+ )
954
+ };
955
+ var deleteAssetsShape = {
956
+ refs: z.array(z.string().min(1)).min(1).max(50).describe(
957
+ "One or more account-scoped refs previously returned by import_screenshot, request_screenshot_upload, render_strip, render_project, or emit_bundle. Deletion is irreversible."
958
+ )
959
+ };
960
+ var chatGptFile = z.object({
961
+ download_url: z.string().url().describe("Short-lived HTTPS download URL supplied by ChatGPT."),
962
+ file_id: z.string().min(1).describe("ChatGPT file identifier."),
963
+ mime_type: z.string().optional().describe("File MIME type supplied by ChatGPT."),
964
+ file_name: z.string().max(200).optional().describe("Original attachment filename, when available.")
965
+ }).strict();
966
+ var importScreenshotShape = {
967
+ file: chatGptFile.describe("A PNG attached by the user in ChatGPT."),
968
+ name: z.string().max(200).optional().describe("Override the filename stored in the returned ref. Defaults to file.file_name."),
969
+ locale: z.string().optional().describe("Optional bookkeeping tag for the App Store locale this screenshot belongs to.")
970
+ };
971
+ var projectSummary = z.object({ id: z.string(), name: z.string() }).passthrough();
972
+ var storedAssetOutput = z.object({ ref: z.string(), url: z.string().url() }).passthrough();
973
+ var panelOutput = z.union([z.string(), storedAssetOutput]);
974
+ var renderStripOutputSchema = z.object({
975
+ ok: z.boolean(),
976
+ count: z.number(),
977
+ panelPresetId: z.string(),
978
+ panelWidth: z.number(),
979
+ panelHeight: z.number(),
980
+ renderMs: z.number(),
981
+ output: z.enum(["inline", "urls"]),
982
+ locale: z.string(),
983
+ panels: z.array(panelOutput)
984
+ }).passthrough();
985
+ var emitBundleOutputSchema = z.object({
986
+ ok: z.boolean(),
987
+ filename: z.string(),
988
+ bundleId: z.string(),
989
+ panelCount: z.number(),
990
+ output: z.enum(["inline", "urls"])
991
+ }).passthrough();
992
+ var saveProjectOutputSchema = z.object({
993
+ ok: z.boolean(),
994
+ projectId: z.string(),
995
+ openUrl: z.string().url(),
996
+ count: z.number(),
997
+ panelPresetId: z.string(),
998
+ locale: z.string(),
999
+ message: z.string()
1000
+ }).passthrough();
1001
+ var readLookOutputSchema = z.object({
1002
+ saved: z.boolean(),
1003
+ project: projectSummary.optional(),
1004
+ message: z.string().optional()
1005
+ }).passthrough();
1006
+ var readProjectOutputSchema = z.object({
1007
+ saved: z.boolean(),
1008
+ project: projectSummary.optional(),
1009
+ message: z.string().optional()
1010
+ }).passthrough();
1011
+ var renderProjectOutputSchema = renderStripOutputSchema.extend({
1012
+ project: projectSummary,
1013
+ shots: z.array(z.object({ frameName: z.string(), matched: z.boolean() }).passthrough())
1014
+ });
1015
+ var saveLookOutputSchema = z.object({ ok: z.boolean(), project: projectSummary, version: z.number(), message: z.string() }).passthrough();
1016
+ var holdLookOutputSchema = z.object({ ok: z.boolean(), project: projectSummary, heldVersion: z.number(), message: z.string() }).passthrough();
1017
+ var releaseLookOutputSchema = z.object({ ok: z.boolean(), project: projectSummary, heldVersion: z.null(), message: z.string() }).passthrough();
1018
+ var describeLookOutputSchema = z.object({
1019
+ style: z.unknown(),
1020
+ look: z.unknown(),
1021
+ panelPresets: z.array(z.unknown()),
1022
+ clip: z.unknown(),
1023
+ notes: z.array(z.string())
1024
+ }).passthrough();
1025
+ var requestScreenshotUploadOutputSchema = z.object({
1026
+ ok: z.boolean(),
1027
+ slots: z.array(
1028
+ z.object({ ref: z.string(), uploadUrl: z.string().url(), name: z.string().optional() }).passthrough()
1029
+ ),
1030
+ instructions: z.string()
1031
+ }).passthrough();
1032
+ var importScreenshotOutputSchema = z.object({
1033
+ ok: z.boolean(),
1034
+ ref: z.string(),
1035
+ url: z.string().url(),
1036
+ name: z.string(),
1037
+ size: z.number().int().nonnegative(),
1038
+ instructions: z.string()
1039
+ }).passthrough();
1040
+ var deleteAssetsOutputSchema = z.object({
1041
+ ok: z.boolean(),
1042
+ deletedCount: z.number().int().nonnegative(),
1043
+ deletedRefs: z.array(z.string()),
1044
+ message: z.string()
1045
+ }).passthrough();
1046
+
1047
+ // src/toolContract.ts
1048
+ var SERVER_INSTRUCTIONS = [
1049
+ "ShotOps turns raw app screenshots into styled 3D device mockups laid out as an App Store",
1050
+ "screenshot strip, and packages them into a fastlane deliver bundle. It renders images only \u2014",
1051
+ "no App Store / Apple credential is ever involved, and nothing is submitted for review.",
1052
+ "",
1053
+ "## Workflow",
1054
+ "1. In ChatGPT, call import_screenshot once per attached PNG; it returns a ShotOps { ref }",
1055
+ " without putting image bytes in model context. In other hosted MCP clients, use",
1056
+ " request_screenshot_upload({ count, names }) \u2014 get signed slots, then PUT each raw PNG to its",
1057
+ ' uploadUrl (curl -T screenshot.png "<uploadUrl>"). NEVER inline full-resolution screenshots;',
1058
+ " pass the returned { ref } instead. Only small images may be inlined as base64. Pass `names`",
1059
+ " (original filenames, same order as count) and each ref already carries its name \u2014 see below.",
1060
+ "2. render_strip({ screenshots, panelPresetId, style }) \u2014 composite onto a device, return",
1061
+ " per-panel PNGs. Iterate with preview:true (fast ~25% render); drop it for final delivery.",
1062
+ ' Large results come back as output:"urls" (download them; bytes never transit the chat).',
1063
+ "3. emit_bundle({ screenshots|panels, bundleId }) \u2014 a fastlane deliver-ready zip. Pass a prior",
1064
+ ' render_strip output:"urls" result as `panels` to package without re-rendering. On the LOCAL',
1065
+ " stdio tier (no account, so no upload refs) pass panels straight off disk instead:",
1066
+ ' panels: [{ "path": "/abs/panel-01.png" }, \u2026] \u2014 the same { path } local-only door as screenshots.',
1067
+ "4. Hosted refs are private but retained until deleted. After downloading the final results, call",
1068
+ " delete_assets({ refs: [...] }) for screenshots, panels, and bundles the user no longer needs.",
1069
+ "",
1070
+ "## Input hygiene \u2014 feed RAW app screenshots, never already-composed panels",
1071
+ "render_strip expects RAW in-app screen captures (no device frame, no caption). Do NOT feed it a",
1072
+ "previously-rendered ShotOps panel, an exported fastlane image, or any screenshot that already",
1073
+ "has a phone frame or caption baked in \u2014 it double-frames (a phone inside a phone). If a folder",
1074
+ "holds both raw captures and finished panels, use the raw ones. Rule of thumb: if it already",
1075
+ "looks like an App Store screenshot, it is the WRONG input.",
1076
+ "",
1077
+ "## Big jobs: render, THEN bundle (do NOT render+zip in one call for 4+ panels)",
1078
+ "A full-resolution render is seconds PER PANEL and runs one panel at a time, so a 5-panel",
1079
+ "full-res render can take a couple of minutes \u2014 long enough that a single render+zip call",
1080
+ "(emit_bundle with `screenshots`) can exceed the MCP client idle window and the client gives up",
1081
+ 'even though the server finished. For 4+ panels, SPLIT it: first render_strip({ output:"urls" })',
1082
+ "(you get panel URLs back as soon as it completes \u2014 watchable, and re-running is cheap since the",
1083
+ "panels are already uploaded), THEN emit_bundle({ panels: [...those refs] }) which only ZIPS",
1084
+ "(near-instant, no re-render). Preview (preview:true) is always fast and safe for iteration \u2014",
1085
+ "only the FINAL full-res pass is the slow one, so split that one.",
1086
+ "",
1087
+ "## Per-locale screenshots \u2192 ONE multi-locale bundle",
1088
+ 'A screenshot entry can carry per-locale variants: { "locales": { "en-US": { "ref": "\u2026" },',
1089
+ '"de-DE": { "ref": "\u2026" } } }. render_strip picks the top-level `locale`\'s variant; a locale',
1090
+ "with no variant of its own falls back to the en-US one (ship German captions over English",
1091
+ 'screens now, upgrade the pixels later). emit_bundle({ locales: ["en-US","de-DE"], \u2026 }) emits',
1092
+ "ONE zip with a fastlane/screenshots/<locale>/ folder per locale \u2014 fastlane deliver uploads",
1093
+ "every locale in a single run. request_screenshot_upload({ count, names, locale }) tags a",
1094
+ "batch of upload slots with the locale it is for (echoed back; bookkeeping only).",
1095
+ "RECOMMENDED multi-locale flow \u2014 render per locale, then compose ONCE (this is also the only",
1096
+ "way to get per-locale CAPTION text into one bundle, since caption text is per-render input):",
1097
+ `1. per locale L: render_strip({ locale: L, output: "urls", style: { captions: [<L's words>] },`,
1098
+ ` screenshots: [<L's screens or { "locales": \u2026 } entries>] }) \u2192 per-panel refs for L.`,
1099
+ '2. emit_bundle({ locales: [...], bundleId, panels: [ { "locales": { "en-US": { "ref": p1en },',
1100
+ ' "de-DE": { "ref": p1de } } }, \u2026 ] }) \u2014 zips every locale with NO re-render, near-instant.',
1101
+ "The one-call form (emit_bundle with `screenshots` + `locales`) works but renders once PER",
1102
+ "locale with the SAME captions for every locale \u2014 and multiplies the slow full-res render by",
1103
+ "the locale count (see Big jobs above), so prefer the compose flow for real jobs.",
1104
+ "",
1105
+ "## Styling \u2014 you can fully art-direct the strip via the `style` object",
1106
+ "Call describe_look for the exact field catalog + defaults; this is the map of what to reach for.",
1107
+ "Three groups (all optional \u2014 omit for a sensible dark default):",
1108
+ "- style.shotLook (the device, same for every phone): angle (front|left|right), material",
1109
+ " (real|clay), colorway (orange|blue|silver|custom+customColor), finish (0 matte\u20261 glossy),",
1110
+ " clearcoat (0\u20261 glossy clear-coat lacquer, on top of finish), roll (-45\u202645\xB0 tilt),",
1111
+ " phoneHeight (20\u2026100 % of panel), hOffset/vOffset to nudge position, glare, reflections,",
1112
+ " lighting, flatScreen.",
1113
+ "- style.background (the strip): mode gradient|perPanel; gradientFrom/gradientTo (hex),",
1114
+ " gradientDir vertical|horizontal, shadow, floorReflection; or panelColors[] (one hex per slot) for perPanel.",
1115
+ "- style.captions[] (ONE entry per panel, in slot order; null = no caption). An entry is EITHER a",
1116
+ " single caption object OR an array of layers stacked on that panel: [{text,\u2026},{text,\u2026}]. Fields:",
1117
+ " text (this layer\u2019s words), subtitle (single-caption shape only \u2014 in the array shape add another",
1118
+ " layer instead), fontId (inter|manrope|poppins|fraunces|space-grotesk), sizePt, color (hex),",
1119
+ " align, anchor {x,y} (0\u20131 position; OMIT to auto-place above the device), maxWidth (0\u20131 fraction).",
1120
+ "panelPresetId picks the App Store size: r69 (6.9\u2033, default, 1290\xD72796) | r65 | r55 | ipad13 |",
1121
+ "ipad129. Match it to the pixel size of your source screenshots (1320\xD72868 or 1290\xD72796 \u2192 r69).",
1122
+ "Per-panel variety (a DIFFERENT device per panel \u2014 mixed-device or continuous-scene strips):",
1123
+ "style.shotLook is ONE device for the WHOLE strip. To style each panel differently, pass a `look`",
1124
+ "\u2014 a look carries `shots[]`, ONE entry per panel in slot order, each with its own `look` (same",
1125
+ 'fields as shotLook). e.g. look:{ shots:[ {panelId:"panel-1",look:{angle:"left"}},',
1126
+ '{panelId:"panel-2",look:{angle:"front",colorway:"orange"}} ] } tilts panel 1 and recolours panel 2.',
1127
+ "COMPOSE \u2014 every real App Store strip needs BOTH varied devices AND headlines, and you get them in",
1128
+ "ONE call: pass the `look` AND a `style` together \u2014 the look gives the per-panel devices +",
1129
+ "background, style.captions gives the words. (A style.shotLook/style.background passed alongside a",
1130
+ "look is redundant \u2014 the look wins and the result carries a note saying so.) This is the exact shape",
1131
+ "read_look/save_look use; describe_look details it and dumps the full look schema + an example.",
1132
+ "Continuous scene: panels have no hard divider \u2014 a phone pushed past its panel edge with a large",
1133
+ "hOffset overflows into the NEIGHBOURING panel, so one device (or a multi-phone fan) can span",
1134
+ "several panels and the strip reads as one swipe-through image. Deliberate \u2014 preview to place it.",
1135
+ 'To opt OUT of that bleed (App Store panels as independent images), pass top-level clip:"panel" \u2014',
1136
+ "it confines each phone to its own panel so an oversized/offset device never leaks a foreign edge",
1137
+ 'into the neighbour. Default clip:"strip" keeps the continuous scene. It is a render option, not styling.',
1138
+ "",
1139
+ "## Caption guardrails (do this or captions crowd)",
1140
+ 'Defaults are fontId "inter", sizePt 44, maxWidth 0.86, and NO anchor (auto). By default a caption',
1141
+ "AUTO-LAYOUTS in a band above the device and the phone is pushed down so the two never collide \u2014",
1142
+ "even a headline that wraps to two lines, or several stacked layers, clears the phone automatically.",
1143
+ "So you rarely need to set anchor at all. To keep it reading well:",
1144
+ '- Punchy, benefit-led headlines read best ("Track every throw", "See your stats"), one idea per',
1145
+ " panel. A consistent fontId + color across all panels makes the strip feel designed.",
1146
+ "- Want a headline + a smaller subline? Pass an ARRAY of layers for that panel \u2014 the first larger,",
1147
+ " the second smaller (lower sizePt) \u2014 instead of one caption; they stack in order in the band.",
1148
+ "- Keep headlines fairly short; or drop sizePt to ~36\u201340, or raise maxWidth toward 1.0, to reduce",
1149
+ " wrapping if a line looks too tight. Setting anchor {x,y} OVERRIDES auto-layout \u2014 the caption is",
1150
+ " then pinned exactly there (it will not dodge the phone), so only set it when you want that.",
1151
+ "",
1152
+ "## Two kinds of state, kept separate on purpose",
1153
+ "- STYLING (the look \u2014 device, background, caption styling) is reusable: save_look persists it",
1154
+ " per project (bumps a version), read_look returns it, and render_strip can re-apply it via",
1155
+ " useSavedLook. describe_look is the authoritative field list.",
1156
+ "- CAPTION TEXT (the words) is per-render input and comes from the CALLER (your repo), never the",
1157
+ " server. No tool returns Studio-typed caption text \u2014 pass the copy yourself in",
1158
+ " style.captions[].text. Use the optional top-level `locale` to label a render and route an",
1159
+ " emit_bundle into fastlane/screenshots/<locale>/ (see the captions.<locale>.json convention).",
1160
+ "",
1161
+ "## Look version history + Hold",
1162
+ "Every save_look (or a web Save look) appends a retained VERSION. read_look returns the full",
1163
+ "`versions` history (newest-first) plus the current `heldVersion`. Render any specific one with",
1164
+ "render_strip/emit_bundle `version: N`. To make one version the DEFAULT agents render (without",
1165
+ "the designer having to re-save it as the newest), hold_look({ version: N }); release_look returns",
1166
+ "to Follow latest (render the newest saved). With no version held, useSavedLook / omitting `version`",
1167
+ "renders the latest.",
1168
+ "",
1169
+ "## Save a render as an editable project (offer this)",
1170
+ "When the user might want to KEEP or later edit the strip (not just receive images), save it as",
1171
+ "a real ShotOps project they can open + refine: call save_project, or pass",
1172
+ "`createProject: true` on render_strip / emit_bundle. Either returns a projectId + an openUrl \u2014",
1173
+ "hand the openUrl back so they can open it (signed in as the SAME account). To iterate, thread",
1174
+ "that projectId back as `project` on the next call and it UPDATES the same project in place",
1175
+ "(instead of creating a new one each time).",
1176
+ "save_project is FAST and never renders \u2014 it saves structure + look only, so prefer it (over the",
1177
+ "createProject flag) when the user just wants the editable project and you do not also need the",
1178
+ "rendered images in the same call; it is the reliable way to save many/large strips. The",
1179
+ "`createProject: true` flag renders too, so it cannot be combined with preview:true \u2014 for the",
1180
+ "flag path, save the full-res pass, not a preview.",
1181
+ "",
1182
+ "## IMPORTANT \u2014 the raw screenshots are the ONE thing the account cannot give back",
1183
+ "The saved record is structure + look only: NO screenshot bytes are stored (zero-custody), so",
1184
+ "when the user opens the project the web app prompts them to RE-LOAD the raw app screenshots.",
1185
+ "ShotOps cannot supply those \u2014 only the user's original files can. So whenever you save a",
1186
+ "project (createProject / save_project), you MUST make the strip re-loadable:",
1187
+ "- PASS EACH SCREENSHOT'S ORIGINAL FILENAME \u2014 easiest via request_screenshot_upload({ names }),",
1188
+ ` which encodes it into that slot's ref so a later bare { "ref": "\u2026" } already carries it; or`,
1189
+ ' set it explicitly per entry, e.g. { "ref": "\u2026", "name": "03_statistics.png" }. That filename',
1190
+ " becomes the saved shot's identity, so the user just re-opens their OWN screenshots folder and",
1191
+ " ShotOps matches exactly the files you used BY NAME \u2014 even a folder of many screenshots",
1192
+ ' where only some were used. This is the clean path: no copying, no "which ones did you use?".',
1193
+ " The web banner names the files it expects, drawn from these names.",
1194
+ "- Use the RAW app screenshots (the images you were given), NOT the rendered mockup panels or the",
1195
+ " exported fastlane zip \u2014 re-loading the export instead double-mockups the strip (a painful trap).",
1196
+ "- If you generated/fetched the screenshots yourself, save them to disk and tell the user the",
1197
+ " folder path (with the same filenames you passed as `name`/`names`).",
1198
+ "Skip the names and the record falls back to positional filenames (screen-1.png, screen-2.png\u2026)",
1199
+ "the user's folder can't match \u2014 the single worst create-project failure mode. Pass the real filenames.",
1200
+ "",
1201
+ '## "Any updates?" \u2014 read back what a designer changed (read_project)',
1202
+ "read_look returns only STYLING. To see what a designer edited in the web app \u2014 frame ORDER, the",
1203
+ "per-locale caption WORDS, the locale list, structural changes \u2014 call read_project. It returns the",
1204
+ "full project as `project_file` (an opaque ProjectFile: `panels` in the designer's order,",
1205
+ "`captionText` keyed by locale then panel, `locales`, plus the styling) together with `updatedAt`.",
1206
+ 'To answer a recurring "any updates?": remember the `updatedAt` you last saw and compare on the next',
1207
+ "read_project \u2014 a newer value means the designer changed something; re-read project_file to see what.",
1208
+ "read_project is read-only and never returns screenshots (zero-custody). To reproduce the designer's",
1209
+ "exact strip, feed the project to render_project with YOUR OWN raw screenshots (matched by filename).",
1210
+ "",
1211
+ "## Reproduce a saved project (render_project)",
1212
+ "render_project({ project?, screenshots }) re-renders a SAVED project with YOUR OWN raw screenshots \u2014",
1213
+ "the headless twin of opening the project in the web app and re-loading the raws. The PROJECT owns the",
1214
+ "structure (frame order, per-locale captions, styling); you just supply the pixels. Pass `screenshots`",
1215
+ "as a FLAT list of entries (base64 / { ref } / { url }), EACH with its original filename as `name` \u2014",
1216
+ "they are matched to the project's shots BY filename, so the ORDER you pass them in does not matter and",
1217
+ "you must NOT pre-sort. Pass `locale` to render that locale's caption words (the project already holds",
1218
+ "them). A shot with no matching file renders without its device and is listed in the result's `shots`",
1219
+ "/`missing` report \u2014 supply that filename to complete the strip. Typical flow: read_project to see what",
1220
+ "the designer changed, then render_project passing your raws by their real filenames.",
1221
+ "",
1222
+ "## Refine visually, then re-render headlessly",
1223
+ "A human can refine the look in ShotOps (https://storeframe-studio.vercel.app \u2014 sign",
1224
+ "in as the SAME account) and Save look; read_look then returns exactly that, so an agent",
1225
+ "reproduces the human-tuned design with zero manual step. Zero-custody: source screenshots are",
1226
+ "ephemeral (one render, then discarded); only the styling look is persisted, never the pixels."
1227
+ ].join("\n");
1228
+
1229
+ // ../mockup-engine/fastlaneBundle.ts
1230
+ import { strToU8, zipSync } from "fflate";
1231
+ var DELIVERFILE_TEMPLATE = `# Generated by ShotOps \u2014 screenshots-only upload, nothing else.
1232
+ # Uploads to your app's editable (unreleased) version. Never submits for review.
1233
+ app_identifier "{{BUNDLE_ID}}"
1234
+ skip_binary_upload true
1235
+ skip_metadata true
1236
+ skip_app_version_update true
1237
+ overwrite_screenshots true
1238
+ `;
1239
+ var README_TEMPLATE = `# Upload these screenshots to App Store Connect
1240
+
1241
+ This bundle was exported from ShotOps for **{{BUNDLE_ID}}** \u2014
1242
+ {{COUNT}} screenshots \xB7 {{PANEL_LABEL}} \xB7 locale \`{{LOCALE}}\`.
1243
+
1244
+ You upload it yourself with [Fastlane](https://fastlane.tools), signed in with **your own
1245
+ Apple account or API key**. Your credentials never touch ShotOps.
1246
+
1247
+ ## Upload
1248
+
1249
+ \`\`\`
1250
+ cd <this folder>
1251
+ fastlane deliver
1252
+ \`\`\`
1253
+
1254
+ Fastlane will:
1255
+
1256
+ 1. Ask you to sign in (Apple ID with 2FA, or an App Store Connect API key if you have
1257
+ fastlane configured with one).
1258
+ 2. Detect the device size automatically from the image dimensions.
1259
+ 3. Show you an **HTML preview** of exactly what will be uploaded and ask
1260
+ \`Does the Preview look okay for you? (y/n)\` \u2014 nothing is sent until you confirm.
1261
+ 4. Upload the screenshots to your app's **editable (unreleased) version**, replacing the
1262
+ existing set for this device size and locale.
1263
+
1264
+ **Nothing is ever submitted for review.** This bundle only updates screenshots on a draft
1265
+ version; you review and submit in App Store Connect yourself, whenever you're ready.
1266
+
1267
+ ## First time?
1268
+
1269
+ - Install fastlane: \`brew install fastlane\` (or \`gem install fastlane\`).
1270
+ - You need an **editable version** of your app in App Store Connect (any version that
1271
+ isn't live yet). If every version is released, create a new version in App Store
1272
+ Connect first \u2014 deliver can't attach screenshots to a released version.
1273
+
1274
+ ## What's in this bundle
1275
+
1276
+ \`\`\`
1277
+ README.md this file
1278
+ fastlane/
1279
+ Deliverfile pre-filled config \u2014 screenshots only, nothing else
1280
+ fastlane/screenshots/{{LOCALE}}/ your screenshots; the NN_ filename prefix is the
1281
+ display order in the App Store
1282
+ \`\`\`
1283
+
1284
+ The \`Deliverfile\` is deliberately minimal and read-safe: it skips binary upload, skips
1285
+ metadata, and never submits. Feel free to inspect it \u2014 it's five lines.
1286
+
1287
+ ## Troubleshooting
1288
+
1289
+ - **"Could not find app with bundle identifier"** \u2014 the Bundle ID above doesn't match an
1290
+ app on the team you signed in with. Re-export from ShotOps with the right Bundle ID.
1291
+ - **Screenshots land under the wrong language** \u2014 re-export with the right locale; the
1292
+ folder name under \`fastlane/screenshots/\` is the App Store locale code.
1293
+ - **Wrong order in the store** \u2014 the \`NN_\` prefix controls order; re-arrange the strip in
1294
+ ShotOps and re-export rather than renaming files by hand.
1295
+ `;
1296
+ var MULTI_LOCALE_README_TEMPLATE = `# Upload these screenshots to App Store Connect
1297
+
1298
+ This bundle was exported from ShotOps for **{{BUNDLE_ID}}** \u2014
1299
+ {{LOCALE_COUNT}} locales ({{LOCALES}}) \xB7 {{PER_LOCALE_COUNT}} screenshots each \xB7 {{PANEL_LABEL}}.
1300
+
1301
+ You upload it yourself with [Fastlane](https://fastlane.tools), signed in with **your own
1302
+ Apple account or API key**. Your credentials never touch ShotOps.
1303
+
1304
+ ## Upload
1305
+
1306
+ \`\`\`
1307
+ cd <this folder>
1308
+ fastlane deliver
1309
+ \`\`\`
1310
+
1311
+ Fastlane will:
1312
+
1313
+ 1. Ask you to sign in (Apple ID with 2FA, or an App Store Connect API key if you have
1314
+ fastlane configured with one).
1315
+ 2. Detect the device size automatically from the image dimensions.
1316
+ 3. Upload screenshots for **every locale** below in one run \u2014 it reads the locale from each
1317
+ \`fastlane/screenshots/<locale>/\` folder name.
1318
+ 4. Show you an **HTML preview** of exactly what will be uploaded and ask
1319
+ \`Does the Preview look okay for you? (y/n)\` \u2014 nothing is sent until you confirm.
1320
+ 5. Upload the screenshots to your app's **editable (unreleased) version**, replacing the
1321
+ existing set for each device size and locale.
1322
+
1323
+ **Nothing is ever submitted for review.** This bundle only updates screenshots on a draft
1324
+ version; you review and submit in App Store Connect yourself, whenever you're ready.
1325
+
1326
+ ## First time?
1327
+
1328
+ - Install fastlane: \`brew install fastlane\` (or \`gem install fastlane\`).
1329
+ - You need an **editable version** of your app in App Store Connect (any version that
1330
+ isn't live yet). If every version is released, create a new version in App Store
1331
+ Connect first \u2014 deliver can't attach screenshots to a released version.
1332
+
1333
+ ## What's in this bundle
1334
+
1335
+ \`\`\`
1336
+ README.md this file
1337
+ fastlane/
1338
+ Deliverfile pre-filled config \u2014 screenshots only, nothing else
1339
+ {{LOCALE_FOLDERS}}
1340
+ \`\`\`
1341
+
1342
+ Each locale folder holds that language's screenshots; the \`NN_\` filename prefix is the
1343
+ display order in the App Store.
1344
+
1345
+ The \`Deliverfile\` is deliberately minimal and read-safe: it skips binary upload, skips
1346
+ metadata, and never submits. Feel free to inspect it \u2014 it's a handful of lines.
1347
+
1348
+ ## Troubleshooting
1349
+
1350
+ - **"Could not find app with bundle identifier"** \u2014 the Bundle ID above doesn't match an
1351
+ app on the team you signed in with. Re-export from ShotOps with the right Bundle ID.
1352
+ - **Screenshots land under the wrong language** \u2014 re-export with the right locale; the
1353
+ folder name under \`fastlane/screenshots/\` is the App Store locale code.
1354
+ - **Wrong order in the store** \u2014 the \`NN_\` prefix controls order; re-arrange the strip in
1355
+ ShotOps and re-export rather than renaming files by hand.
1356
+ `;
1357
+ function render(template, vars) {
1358
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key) => vars[key] ?? match);
1359
+ }
1360
+ function generateDeliverfile(opts) {
1361
+ const base = render(DELIVERFILE_TEMPLATE, { BUNDLE_ID: opts.bundleId });
1362
+ const locales = opts.locales?.filter((l) => l.length > 0) ?? [];
1363
+ const outputs = opts.outputs?.filter((o) => o.length > 0) ?? [];
1364
+ if (locales.length === 0 && outputs.length === 0) return base;
1365
+ const comments = [
1366
+ ...locales.length > 0 ? [`# Locales in this bundle: ${locales.join(", ")} (one fastlane/screenshots/<locale>/ folder each).`] : [],
1367
+ ...outputs.length > 0 ? [`# Device outputs in this bundle: ${outputs.join(", ")} (fastlane detects each from PNG dimensions).`] : []
1368
+ ];
1369
+ return `${comments.join("\n")}
1370
+ ${base}`;
1371
+ }
1372
+ function generateBundleReadme(opts) {
1373
+ return render(README_TEMPLATE, {
1374
+ BUNDLE_ID: opts.bundleId,
1375
+ LOCALE: opts.locale,
1376
+ PANEL_LABEL: opts.panelLabel,
1377
+ COUNT: String(opts.count)
1378
+ });
1379
+ }
1380
+ function generateMultiLocaleBundleReadme(opts) {
1381
+ const folderLines = opts.locales.map((l) => ` fastlane/screenshots/${l}/`.padEnd(33) + `your ${l} screenshots`).join("\n");
1382
+ return render(MULTI_LOCALE_README_TEMPLATE, {
1383
+ BUNDLE_ID: opts.bundleId,
1384
+ LOCALES: opts.locales.join(", "),
1385
+ LOCALE_COUNT: String(opts.locales.length),
1386
+ PER_LOCALE_COUNT: String(opts.perLocaleCount),
1387
+ PANEL_LABEL: opts.panelLabel,
1388
+ LOCALE_FOLDERS: folderLines
1389
+ });
1390
+ }
1391
+ function screenshotFilename(index) {
1392
+ return `${String(index).padStart(2, "0")}_shotops.png`;
1393
+ }
1394
+ function outputScreenshotFilename(ascDeviceSet, index) {
1395
+ return `${ascDeviceSet}_${String(index).padStart(2, "0")}_shotops.png`;
1396
+ }
1397
+ function bundleZipFilename(projectName2) {
1398
+ const slug = projectName2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1399
+ return `${slug || "shotops"}-appstore-upload.zip`;
1400
+ }
1401
+ var BUNDLE_ID_RE = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$/;
1402
+ function isValidBundleId(s) {
1403
+ return BUNDLE_ID_RE.test(s);
1404
+ }
1405
+ function buildFastlaneBundleZip(slots, opts) {
1406
+ const files = {
1407
+ "README.md": strToU8(
1408
+ generateBundleReadme({
1409
+ bundleId: opts.bundleId,
1410
+ locale: opts.locale,
1411
+ panelLabel: opts.panelLabel,
1412
+ count: slots.length
1413
+ })
1414
+ ),
1415
+ "fastlane/Deliverfile": strToU8(generateDeliverfile({ bundleId: opts.bundleId }))
1416
+ };
1417
+ for (const slot2 of slots) {
1418
+ files[`fastlane/screenshots/${opts.locale}/${screenshotFilename(slot2.displayPosition)}`] = slot2.bytes;
1419
+ }
1420
+ return zipSync(files, { level: 0 });
1421
+ }
1422
+ function buildMultiLocaleFastlaneBundleZip(localeSlots, opts) {
1423
+ const locales = localeSlots.map((ls) => ls.locale);
1424
+ const files = {
1425
+ "README.md": strToU8(
1426
+ generateMultiLocaleBundleReadme({
1427
+ bundleId: opts.bundleId,
1428
+ locales,
1429
+ panelLabel: opts.panelLabel,
1430
+ // Structure is shared across locales (invariant 2), so every locale has the same slot
1431
+ // count — take the first (0 for the degenerate empty-input case).
1432
+ perLocaleCount: localeSlots[0]?.slots.length ?? 0
1433
+ })
1434
+ ),
1435
+ "fastlane/Deliverfile": strToU8(generateDeliverfile({ bundleId: opts.bundleId, locales }))
1436
+ };
1437
+ for (const { locale: locale2, slots } of localeSlots) {
1438
+ for (const slot2 of slots) {
1439
+ files[`fastlane/screenshots/${locale2}/${screenshotFilename(slot2.displayPosition)}`] = slot2.bytes;
1440
+ }
1441
+ }
1442
+ return zipSync(files, { level: 0 });
1443
+ }
1444
+ var MULTI_OUTPUT_README_TEMPLATE = `# Upload these screenshots to App Store Connect
1445
+
1446
+ This bundle was exported from ShotOps for **{{BUNDLE_ID}}** \u2014
1447
+ {{OUTPUT_COUNT}} device outputs ({{OUTPUTS}}) \xD7 {{LOCALE_COUNT}} locales ({{LOCALES}}).
1448
+
1449
+ You upload it yourself with [Fastlane](https://fastlane.tools), signed in with **your own
1450
+ Apple account or API key**. Your credentials never touch ShotOps.
1451
+
1452
+ ## Upload
1453
+
1454
+ \`\`\`
1455
+ cd <this folder>
1456
+ fastlane deliver
1457
+ \`\`\`
1458
+
1459
+ Fastlane reads each \`fastlane/screenshots/<locale>/\` folder and detects the target device
1460
+ from each PNG's dimensions. The \`APP_IPHONE_*\` filename prefix makes the intended device
1461
+ set explicit; device-set subfolders are deliberately not used because deliver does not scan
1462
+ them. It shows an HTML preview and asks for confirmation before uploading.
1463
+
1464
+ **Nothing is ever submitted for review.** The generated Deliverfile skips binaries and
1465
+ metadata and only replaces screenshots on an editable version.
1466
+
1467
+ ## Included outputs
1468
+
1469
+ {{OUTPUT_LINES}}
1470
+
1471
+ ## Included locale folders
1472
+
1473
+ {{LOCALE_LINES}}
1474
+ `;
1475
+ function generateMultiOutputBundleReadme(targets, opts) {
1476
+ const locales = Array.from(new Set(targets.flatMap((target) => target.localeSlots.map((entry) => entry.locale))));
1477
+ return render(MULTI_OUTPUT_README_TEMPLATE, {
1478
+ BUNDLE_ID: opts.bundleId,
1479
+ OUTPUT_COUNT: String(targets.length),
1480
+ OUTPUTS: targets.map((target) => target.output.label).join(", "),
1481
+ LOCALE_COUNT: String(locales.length),
1482
+ LOCALES: locales.join(", "),
1483
+ OUTPUT_LINES: targets.map(
1484
+ ({ output: output2 }) => `- ${output2.label}: ${output2.ascDeviceSet} \xB7 preset ${output2.panelPresetId}`
1485
+ ).join("\n"),
1486
+ LOCALE_LINES: locales.map((locale2) => `- fastlane/screenshots/${locale2}/`).join("\n")
1487
+ });
1488
+ }
1489
+ function buildMultiOutputFastlaneBundleZip(targets, opts) {
1490
+ const locales = Array.from(new Set(targets.flatMap((target) => target.localeSlots.map((entry) => entry.locale))));
1491
+ const files = {
1492
+ "README.md": strToU8(generateMultiOutputBundleReadme(targets, opts)),
1493
+ "fastlane/Deliverfile": strToU8(
1494
+ generateDeliverfile({
1495
+ bundleId: opts.bundleId,
1496
+ locales,
1497
+ outputs: targets.map(({ output: output2 }) => `${output2.label} (${output2.ascDeviceSet})`)
1498
+ })
1499
+ )
1500
+ };
1501
+ for (const { output: output2, localeSlots } of targets) {
1502
+ for (const { locale: locale2, slots } of localeSlots) {
1503
+ for (const slot2 of slots) {
1504
+ const path = `fastlane/screenshots/${locale2}/${outputScreenshotFilename(
1505
+ output2.ascDeviceSet,
1506
+ slot2.displayPosition
1507
+ )}`;
1508
+ if (files[path]) throw new Error(`duplicate fastlane screenshot path: ${path}`);
1509
+ files[path] = slot2.bytes;
1510
+ }
1511
+ }
1512
+ }
1513
+ return zipSync(files, { level: 0 });
1514
+ }
1515
+
1516
+ // src/toolsBundle.ts
1517
+ function dedupeLocales(list) {
1518
+ if (!Array.isArray(list)) return [];
1519
+ const seen = /* @__PURE__ */ new Set();
1520
+ const out = [];
1521
+ for (const raw of list) {
1522
+ const locale2 = typeof raw === "string" ? raw.trim() : "";
1523
+ if (locale2 && !seen.has(locale2)) {
1524
+ seen.add(locale2);
1525
+ out.push(locale2);
1526
+ }
1527
+ }
1528
+ return out;
1529
+ }
1530
+ function dedupeOutputDevices(list) {
1531
+ if (!Array.isArray(list)) return [];
1532
+ const seen = /* @__PURE__ */ new Set();
1533
+ const devices = [];
1534
+ for (const id of list) {
1535
+ if (seen.has(id)) continue;
1536
+ const device = outputDeviceFor(id);
1537
+ if (!device) continue;
1538
+ seen.add(id);
1539
+ devices.push(device);
1540
+ }
1541
+ return devices;
1542
+ }
1543
+ async function handleEmitBundle(deps2, args) {
1544
+ if (!isValidBundleId(args.bundleId)) {
1545
+ return errorResult(`invalid bundleId "${args.bundleId}" \u2014 expected reverse-DNS, e.g. com.acme.app`);
1546
+ }
1547
+ const projectName2 = args.projectName?.trim() || "shotops";
1548
+ const requestedLocales = dedupeLocales(args.locales);
1549
+ const requestedOutputs = dedupeOutputDevices(args.outputs);
1550
+ if (requestedOutputs.length > 0) {
1551
+ if (args.panels && args.panels.length > 0) {
1552
+ return errorResult(
1553
+ "outputs cannot be combined with pre-rendered `panels` refs \u2014 those PNGs already have one fixed size. Pass `screenshots` so emit_bundle can re-render each target device."
1554
+ );
1555
+ }
1556
+ if (args.createProject) {
1557
+ return errorResult(
1558
+ "createProject is not supported with the multi-output `outputs` form \u2014 save the editable project separately with save_project, then emit the device bundle."
1559
+ );
1560
+ }
1561
+ const locales = requestedLocales.length > 0 ? requestedLocales : [args.locale?.trim() || "en-US"];
1562
+ return emitMultiOutputBundle(deps2, args, requestedOutputs, locales, projectName2);
1563
+ }
1564
+ if (requestedLocales.length >= 2) {
1565
+ if (args.createProject) {
1566
+ return errorResult(
1567
+ "createProject is not supported with the multi-locale `locales` form \u2014 save the editable project separately with save_project (a project record is structure + look, locale-independent), or emit a single-locale bundle with `locale`."
1568
+ );
1569
+ }
1570
+ return emitMultiLocaleBundle(deps2, args, requestedLocales, projectName2);
1571
+ }
1572
+ const locale2 = requestedLocales[0] ?? (args.locale?.trim() || "en-US");
1573
+ if (args.createProject && args.panels && args.panels.length > 0) {
1574
+ return errorResult("createProject is not supported on the compose-only `panels` path (nothing is re-styled here) \u2014 use render_strip({ createProject: true }) or save_project on the render path instead.");
1575
+ }
1576
+ let slots;
1577
+ let panelLabel;
1578
+ let previewPng;
1579
+ let note;
1580
+ let projectRecord;
1581
+ if (args.panels && args.panels.length > 0) {
1582
+ const dims = panelDimensionsFor(args.panelPresetId ?? "r69");
1583
+ panelLabel = panelLabelFor(args.panelPresetId ?? "r69", dims.width, dims.height);
1584
+ let panelEntries;
1585
+ try {
1586
+ panelEntries = args.panels.map((p) => resolveLocaleVariant(p, locale2));
1587
+ } catch (err) {
1588
+ return errorResult(err.message);
1589
+ }
1590
+ let bytesList;
1591
+ try {
1592
+ bytesList = await Promise.all(panelEntries.map((p) => resolvePanelBytes(p, deps2.userId, deps2.allowLocalScreenshots ?? false)));
1593
+ } catch (err) {
1594
+ return errorResult(`failed to read panel: ${err.message}`);
1595
+ }
1596
+ slots = bytesList.map((bytes, i) => ({ displayPosition: i + 1, bytes: Buffer.from(bytes) }));
1597
+ previewPng = bytesList[0] ? Buffer.from(bytesList[0]) : null;
1598
+ } else {
1599
+ if (!args.screenshots || args.screenshots.length === 0) {
1600
+ return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
1601
+ }
1602
+ let entries;
1603
+ try {
1604
+ entries = resolveLocaleVariantSlots(args.screenshots, locale2);
1605
+ } catch (err) {
1606
+ return errorResult(err.message);
1607
+ }
1608
+ let screenshots2;
1609
+ try {
1610
+ screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
1611
+ } catch (err) {
1612
+ return errorResult(err.message);
1613
+ }
1614
+ const resolved = await resolveLook(deps2, args);
1615
+ note = [resolved.note, composeConflictNote(resolved.look, resolved.style)].filter(Boolean).join(" ") || void 0;
1616
+ const result = await deps2.renderer.render({
1617
+ screenshots: screenshots2,
1618
+ panelPresetId: args.panelPresetId,
1619
+ look: resolved.look,
1620
+ style: resolved.style,
1621
+ clip: args.clip,
1622
+ locale: locale2,
1623
+ buildRecord: args.createProject === true,
1624
+ ...args.createProject ? { screenshotNames: resolveScreenshotNames(entries) } : {}
1625
+ });
1626
+ if (!result.ok || !result.panels || !result.panelPresetId) {
1627
+ return errorResult(`render failed: ${result.error ?? "unknown error"}`);
1628
+ }
1629
+ slots = result.panels.map((b64, i) => ({ displayPosition: i + 1, bytes: Buffer.from(b64, "base64") }));
1630
+ panelLabel = panelLabelFor(result.panelPresetId, result.panelWidth ?? 0, result.panelHeight ?? 0);
1631
+ previewPng = result.stripBase64 ? Buffer.from(result.stripBase64, "base64") : null;
1632
+ projectRecord = result.projectRecord;
1633
+ }
1634
+ const declaredLocales = collectVariantLocales([...(args.screenshots ?? []).flat(), ...args.panels ?? []]);
1635
+ if (declaredLocales.length > 0) {
1636
+ const hint = `per-locale screenshots detected (${declaredLocales.join(", ")}) \u2014 this call bundled only ${locale2}; pass locales: [...] to emit ONE multi-locale bundle.`;
1637
+ note = note ? `${note} ${hint}` : hint;
1638
+ }
1639
+ const zip = buildFastlaneBundleZip(slots, { bundleId: args.bundleId, locale: locale2, panelLabel });
1640
+ const filename = bundleZipFilename(projectName2);
1641
+ let shareLink;
1642
+ if (args.share) {
1643
+ if (!previewPng) return errorResult("cannot create share link: no preview image available");
1644
+ try {
1645
+ shareLink = await deps2.createShareLink({
1646
+ userId: deps2.userId,
1647
+ zip,
1648
+ previewPng,
1649
+ projectName: projectName2,
1650
+ panelCount: slots.length,
1651
+ bundleId: args.bundleId,
1652
+ locale: locale2,
1653
+ origin: deps2.studioOrigin
1654
+ });
1655
+ } catch (err) {
1656
+ return errorResult(`could not create share link: ${err.message}`);
1657
+ }
1658
+ }
1659
+ const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, zip.byteLength);
1660
+ const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename, zip, "application/zip").then((a) => ({
1661
+ zipUrl: a.url,
1662
+ zipRef: a.ref
1663
+ })) : { zipBase64: Buffer.from(zip).toString("base64") };
1664
+ let persisted;
1665
+ let persistNote;
1666
+ if (args.createProject) {
1667
+ if (projectRecord == null) {
1668
+ persistNote = "bundle succeeded but no project record was produced \u2014 not saved.";
1669
+ } else {
1670
+ const p = await persistProject(deps2, args, projectRecord);
1671
+ if ("error" in p) persistNote = `not saved: ${p.error}`;
1672
+ else persisted = p;
1673
+ }
1674
+ }
1675
+ return textResult({
1676
+ ok: true,
1677
+ filename,
1678
+ bundleId: args.bundleId,
1679
+ locale: locale2,
1680
+ panelCount: slots.length,
1681
+ output: outputMode,
1682
+ ...note ? { note } : {},
1683
+ ...shareLink ? { shareLink } : {},
1684
+ ...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
1685
+ ...persistNote ? { projectNote: persistNote } : {},
1686
+ ...zipOut
1687
+ });
1688
+ }
1689
+ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2) {
1690
+ if (!args.screenshots || args.screenshots.length === 0) {
1691
+ return errorResult("emit_bundle with outputs needs a non-empty `screenshots` array.");
1692
+ }
1693
+ const resolved = await resolveLook(deps2, args);
1694
+ const targets = [];
1695
+ let previewPng = null;
1696
+ for (const output2 of outputs) {
1697
+ const localeSlots = [];
1698
+ for (const locale2 of locales) {
1699
+ let entries;
1700
+ try {
1701
+ entries = resolveLocaleVariantSlots(args.screenshots, locale2);
1702
+ } catch (err) {
1703
+ return errorResult(err.message);
1704
+ }
1705
+ let screenshots2;
1706
+ try {
1707
+ screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
1708
+ } catch (err) {
1709
+ return errorResult(err.message);
1710
+ }
1711
+ const result = await deps2.renderer.render({
1712
+ screenshots: screenshots2,
1713
+ panelPresetId: output2.panelPresetId,
1714
+ look: resolved.look,
1715
+ style: resolved.style,
1716
+ clip: args.clip,
1717
+ locale: locale2
1718
+ });
1719
+ if (!result.ok || !result.panels || !result.panelPresetId) {
1720
+ return errorResult(
1721
+ `render failed for ${output2.label} / ${locale2}: ${result.error ?? "unknown error"}`
1722
+ );
1723
+ }
1724
+ if (result.panelPresetId !== output2.panelPresetId) {
1725
+ return errorResult(
1726
+ `renderer returned ${result.panelPresetId} for ${output2.label}; expected ${output2.panelPresetId}`
1727
+ );
1728
+ }
1729
+ localeSlots.push({
1730
+ locale: locale2,
1731
+ slots: result.panels.map((b64, index) => ({
1732
+ displayPosition: index + 1,
1733
+ bytes: Buffer.from(b64, "base64")
1734
+ }))
1735
+ });
1736
+ if (!previewPng && result.stripBase64) previewPng = Buffer.from(result.stripBase64, "base64");
1737
+ }
1738
+ targets.push({ output: output2, localeSlots });
1739
+ }
1740
+ const zip = buildMultiOutputFastlaneBundleZip(targets, { bundleId: args.bundleId });
1741
+ const filename = bundleZipFilename(projectName2);
1742
+ const panelCount = targets[0]?.localeSlots[0]?.slots.length ?? 0;
1743
+ let shareLink;
1744
+ if (args.share) {
1745
+ if (!previewPng) return errorResult("cannot create share link: no preview image available");
1746
+ try {
1747
+ shareLink = await deps2.createShareLink({
1748
+ userId: deps2.userId,
1749
+ zip,
1750
+ previewPng,
1751
+ projectName: projectName2,
1752
+ panelCount: panelCount * outputs.length,
1753
+ bundleId: args.bundleId,
1754
+ locale: locales.join(", "),
1755
+ origin: deps2.studioOrigin
1756
+ });
1757
+ } catch (err) {
1758
+ return errorResult(`could not create share link: ${err.message}`);
1759
+ }
1760
+ }
1761
+ const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, zip.byteLength);
1762
+ const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename, zip, "application/zip").then((asset) => ({
1763
+ zipUrl: asset.url,
1764
+ zipRef: asset.ref
1765
+ })) : { zipBase64: Buffer.from(zip).toString("base64") };
1766
+ return textResult({
1767
+ ok: true,
1768
+ filename,
1769
+ bundleId: args.bundleId,
1770
+ outputs: outputs.map((output2) => output2.id),
1771
+ locales,
1772
+ panelCount,
1773
+ totalPanelCount: panelCount * outputs.length * locales.length,
1774
+ output: outputMode,
1775
+ ...resolved.note ? { note: resolved.note } : {},
1776
+ ...shareLink ? { shareLink } : {},
1777
+ ...zipOut
1778
+ });
1779
+ }
1780
+ async function emitMultiLocaleBundle(deps2, args, locales, projectName2) {
1781
+ let localeSlots;
1782
+ let panelLabel;
1783
+ let previewPng = null;
1784
+ let note;
1785
+ if (args.panels && args.panels.length > 0) {
1786
+ const dims = panelDimensionsFor(args.panelPresetId ?? "r69");
1787
+ panelLabel = panelLabelFor(args.panelPresetId ?? "r69", dims.width, dims.height);
1788
+ const bytesCache = /* @__PURE__ */ new Map();
1789
+ const readPanel = (p) => {
1790
+ const key = "path" in p ? `path:${p.path}` : `ref:${p.ref}`;
1791
+ let pending = bytesCache.get(key);
1792
+ if (!pending) {
1793
+ pending = resolvePanelBytes(p, deps2.userId, deps2.allowLocalScreenshots ?? false);
1794
+ bytesCache.set(key, pending);
1795
+ }
1796
+ return pending;
1797
+ };
1798
+ localeSlots = [];
1799
+ try {
1800
+ for (const loc of locales) {
1801
+ const panelEntries = args.panels.map((p) => resolveLocaleVariant(p, loc));
1802
+ const bytesList = await Promise.all(panelEntries.map((p) => readPanel(p)));
1803
+ localeSlots.push({
1804
+ locale: loc,
1805
+ slots: bytesList.map((bytes, i) => ({ displayPosition: i + 1, bytes: Buffer.from(bytes) }))
1806
+ });
1807
+ }
1808
+ } catch (err) {
1809
+ return errorResult(`failed to read panel: ${err.message}`);
1810
+ }
1811
+ const firstPanel = localeSlots[0]?.slots[0];
1812
+ previewPng = firstPanel ? Buffer.from(firstPanel.bytes) : null;
1813
+ } else {
1814
+ if (!args.screenshots || args.screenshots.length === 0) {
1815
+ return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
1816
+ }
1817
+ const resolved = await resolveLook(deps2, args);
1818
+ note = resolved.note;
1819
+ localeSlots = [];
1820
+ panelLabel = "";
1821
+ for (const loc of locales) {
1822
+ let entries;
1823
+ try {
1824
+ entries = resolveLocaleVariantSlots(args.screenshots, loc);
1825
+ } catch (err) {
1826
+ return errorResult(err.message);
1827
+ }
1828
+ let screenshots2;
1829
+ try {
1830
+ screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
1831
+ } catch (err) {
1832
+ return errorResult(err.message);
1833
+ }
1834
+ const result = await deps2.renderer.render({
1835
+ screenshots: screenshots2,
1836
+ panelPresetId: args.panelPresetId,
1837
+ look: resolved.look,
1838
+ style: resolved.style,
1839
+ clip: args.clip,
1840
+ locale: loc
1841
+ });
1842
+ if (!result.ok || !result.panels || !result.panelPresetId) {
1843
+ return errorResult(`render failed for locale ${loc}: ${result.error ?? "unknown error"}`);
1844
+ }
1845
+ localeSlots.push({
1846
+ locale: loc,
1847
+ slots: result.panels.map((b64, i) => ({ displayPosition: i + 1, bytes: Buffer.from(b64, "base64") }))
1848
+ });
1849
+ if (!panelLabel) panelLabel = panelLabelFor(result.panelPresetId, result.panelWidth ?? 0, result.panelHeight ?? 0);
1850
+ if (!previewPng && result.stripBase64) previewPng = Buffer.from(result.stripBase64, "base64");
1851
+ }
1852
+ }
1853
+ const zip = buildMultiLocaleFastlaneBundleZip(localeSlots, { bundleId: args.bundleId, panelLabel });
1854
+ const filename = bundleZipFilename(projectName2);
1855
+ let shareLink;
1856
+ if (args.share) {
1857
+ if (!previewPng) return errorResult("cannot create share link: no preview image available");
1858
+ try {
1859
+ shareLink = await deps2.createShareLink({
1860
+ userId: deps2.userId,
1861
+ zip,
1862
+ previewPng,
1863
+ projectName: projectName2,
1864
+ panelCount: localeSlots[0]?.slots.length ?? 0,
1865
+ bundleId: args.bundleId,
1866
+ // The share row's locale is display metadata — name every locale in the bundle.
1867
+ locale: locales.join(", "),
1868
+ origin: deps2.studioOrigin
1869
+ });
1870
+ } catch (err) {
1871
+ return errorResult(`could not create share link: ${err.message}`);
1872
+ }
1873
+ }
1874
+ const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, zip.byteLength);
1875
+ const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename, zip, "application/zip").then((a) => ({
1876
+ zipUrl: a.url,
1877
+ zipRef: a.ref
1878
+ })) : { zipBase64: Buffer.from(zip).toString("base64") };
1879
+ return textResult({
1880
+ ok: true,
1881
+ filename,
1882
+ bundleId: args.bundleId,
1883
+ locales,
1884
+ panelCount: localeSlots[0]?.slots.length ?? 0,
1885
+ // per locale — total files = panelCount × locales.length
1886
+ output: outputMode,
1887
+ ...note ? { note } : {},
1888
+ ...shareLink ? { shareLink } : {},
1889
+ ...zipOut
1890
+ });
1891
+ }
1892
+
1893
+ // src/toolsRender.ts
1894
+ async function handleRenderStrip(deps2, args) {
1895
+ const { look: look2, style: style2, note: resolveNote } = await resolveLook(deps2, args);
1896
+ const note = [resolveNote, composeConflictNote(look2, style2)].filter(Boolean).join(" ") || void 0;
1897
+ const locale2 = args.locale?.trim() || "en-US";
1898
+ if (args.createProject && args.preview) {
1899
+ return errorResult("createProject cannot be combined with preview:true \u2014 drop preview to persist a full render (or iterate with preview, then render once without it).");
1900
+ }
1901
+ const screenshotLocales = collectVariantLocales(args.screenshots.flat());
1902
+ let entries;
1903
+ try {
1904
+ entries = resolveLocaleVariantSlots(args.screenshots, locale2);
1905
+ } catch (err) {
1906
+ return errorResult(err.message);
1907
+ }
1908
+ let screenshots2;
1909
+ try {
1910
+ screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
1911
+ } catch (err) {
1912
+ return errorResult(err.message);
1913
+ }
1914
+ const result = await deps2.renderer.render({
1915
+ screenshots: screenshots2,
1916
+ panelPresetId: args.panelPresetId,
1917
+ look: look2,
1918
+ style: style2,
1919
+ clip: args.clip,
1920
+ preview: args.preview,
1921
+ locale: locale2,
1922
+ buildRecord: args.createProject === true,
1923
+ // Phase 5A: only compute names when we're building a record (saving a project).
1924
+ // Per-locale entries: names come from the locale-resolved entries — the record describes
1925
+ // exactly the files this render used.
1926
+ ...args.createProject ? { screenshotNames: resolveScreenshotNames(entries) } : {}
1927
+ });
1928
+ if (!result.ok || !result.panels) {
1929
+ return errorResult(`render failed: ${result.error ?? "unknown error"}`);
1930
+ }
1931
+ let persisted;
1932
+ let persistNote;
1933
+ if (args.createProject) {
1934
+ if (result.projectRecord == null) {
1935
+ persistNote = "render succeeded but no project record was produced \u2014 not saved.";
1936
+ } else {
1937
+ const p = await persistProject(deps2, args, result.projectRecord);
1938
+ if ("error" in p) persistNote = `not saved: ${p.error}`;
1939
+ else persisted = p;
1940
+ }
1941
+ }
1942
+ const totalBytes = result.panels.reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
1943
+ const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, totalBytes);
1944
+ const panels = outputMode === "urls" ? await uploadPanels(deps2.userId, result.panels) : result.panels;
1945
+ return textResult({
1946
+ ok: true,
1947
+ count: result.count,
1948
+ panelPresetId: result.panelPresetId,
1949
+ panelWidth: result.panelWidth,
1950
+ panelHeight: result.panelHeight,
1951
+ renderMs: result.ms,
1952
+ output: outputMode,
1953
+ locale: locale2,
1954
+ // Only when the payload actually carried per-locale entries — a legacy payload's
1955
+ // response stays byte-identical (D6).
1956
+ ...screenshotLocales.length > 0 ? { screenshotLocales } : {},
1957
+ ...note ? { note } : {},
1958
+ ...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
1959
+ ...persistNote ? { projectNote: persistNote } : {},
1960
+ panels
1961
+ });
1962
+ }
1963
+ async function uploadPanels(userId, panelsBase64) {
1964
+ const dir = uploadDirFor(userId);
1965
+ return Promise.all(
1966
+ panelsBase64.map(
1967
+ (b64, i) => storeAsset(dir, `panel-${String(i + 1).padStart(2, "0")}.png`, Buffer.from(b64, "base64"), "image/png")
1968
+ )
1969
+ );
1970
+ }
1971
+ function frameNamesFromProjectFile(projectFile) {
1972
+ const shots = projectFile?.shots;
1973
+ if (!Array.isArray(shots)) return [];
1974
+ return shots.map((s) => s?.frameName).filter((n) => typeof n === "string" && n.length > 0);
1975
+ }
1976
+ function handoffRefsFromRecord(record) {
1977
+ const raw = record?.handoffRefs;
1978
+ if (!raw || typeof raw !== "object") return {};
1979
+ const out = {};
1980
+ for (const [name, ref] of Object.entries(raw)) {
1981
+ if (typeof ref === "string" && ref.length > 0) out[name] = ref;
1982
+ }
1983
+ return out;
1984
+ }
1985
+ async function handleRenderProject(deps2, args) {
1986
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
1987
+ if (!project2) {
1988
+ return errorResult(
1989
+ args.project ? "No such project on this account." : "No ShotOps project on this account yet \u2014 open ShotOps and load a strip first."
1990
+ );
1991
+ }
1992
+ const rec = await deps2.readProjectRecord(project2.id);
1993
+ if (!rec) {
1994
+ return errorResult(`Project "${project2.name}" has no saved record yet \u2014 open it in ShotOps and save a strip first.`);
1995
+ }
1996
+ const projectFile = projectFileFromRecord(rec.record);
1997
+ const nested = args.screenshots.map((e) => [e]);
1998
+ let resolvedBytes;
1999
+ try {
2000
+ resolvedBytes = await resolveScreenshots(nested, deps2.userId, deps2.allowLocalScreenshots ?? false);
2001
+ } catch (err) {
2002
+ return errorResult(err.message);
2003
+ }
2004
+ const names = resolveScreenshotNames(nested);
2005
+ const screensByName = {};
2006
+ let unnamed = 0;
2007
+ names.forEach((slot2, i) => {
2008
+ const name = slot2[0];
2009
+ if (name) screensByName[name] = resolvedBytes[i][0];
2010
+ else unnamed++;
2011
+ });
2012
+ const expected = frameNamesFromProjectFile(projectFile);
2013
+ const handoffRefs = handoffRefsFromRecord(rec.record);
2014
+ const handoffUsed = [];
2015
+ if (deps2.fetchHandoffBytes) {
2016
+ for (const frameName of expected) {
2017
+ if (screensByName[frameName]) continue;
2018
+ const ref = handoffRefs[frameName];
2019
+ if (!ref) continue;
2020
+ try {
2021
+ const bytes = await deps2.fetchHandoffBytes(ref);
2022
+ screensByName[frameName] = Buffer.from(bytes).toString("base64");
2023
+ handoffUsed.push(frameName);
2024
+ } catch {
2025
+ }
2026
+ }
2027
+ }
2028
+ if (expected.length > 0 && !expected.some((n) => screensByName[n])) {
2029
+ return errorResult(
2030
+ `None of the supplied screenshots matched this project's frames by filename. This project expects: ${expected.join(", ")}. Pass each raw screenshot with its original \`name\` (its filename), in any order \u2014 render_project matches by name.`
2031
+ );
2032
+ }
2033
+ const locale2 = args.locale?.trim() || void 0;
2034
+ const result = await deps2.renderer.renderProject({ projectFile, screensByName, locale: locale2, preview: args.preview });
2035
+ if (!result.ok || !result.panels) {
2036
+ return errorResult(`render failed: ${result.error ?? "unknown error"}`);
2037
+ }
2038
+ const shots = result.shots ?? [];
2039
+ const missing = shots.filter((s) => !s.matched).map((s) => s.frameName);
2040
+ const totalBytes = result.panels.reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
2041
+ const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, totalBytes);
2042
+ const panels = outputMode === "urls" ? await uploadPanels(deps2.userId, result.panels) : result.panels;
2043
+ const notes = [];
2044
+ if (handoffUsed.length > 0) {
2045
+ notes.push(
2046
+ `${handoffUsed.length} shot(s) were resolved from the designer's uploaded screenshots (auto-handoff): ${handoffUsed.join(", ")}.`
2047
+ );
2048
+ }
2049
+ if (missing.length > 0) {
2050
+ notes.push(
2051
+ `${missing.length} shot(s) had no matching screenshot and rendered without a device: ${missing.join(", ")}. Supply those files (by filename) to complete the strip.`
2052
+ );
2053
+ }
2054
+ if (unnamed > 0) {
2055
+ notes.push(
2056
+ `${unnamed} supplied screenshot(s) had no filename (name) and could not be matched \u2014 pass each screenshot's original filename via \`name\`.`
2057
+ );
2058
+ }
2059
+ return textResult({
2060
+ ok: true,
2061
+ count: result.count,
2062
+ panelPresetId: result.panelPresetId,
2063
+ panelWidth: result.panelWidth,
2064
+ panelHeight: result.panelHeight,
2065
+ renderMs: result.ms,
2066
+ output: outputMode,
2067
+ locale: result.locale ?? locale2 ?? "en-US",
2068
+ project: { id: project2.id, name: project2.name },
2069
+ heldVersion: project2.heldVersion,
2070
+ // echoed for transparency; the render uses the record's saved look
2071
+ shots,
2072
+ // [{ frameName, matched }] — which of the project's shots got developer bytes
2073
+ ...handoffUsed.length > 0 ? { handoffUsed } : {},
2074
+ // shots resolved from the designer's uploads
2075
+ ...missing.length > 0 ? { missing } : {},
2076
+ ...notes.length > 0 ? { note: notes.join(" ") } : {},
2077
+ panels
2078
+ });
2079
+ }
2080
+
2081
+ // src/tools.ts
2082
+ var SAVED_RECORD_VERSION = 2;
2083
+ function textResult(payload, extraContent = []) {
2084
+ return {
2085
+ structuredContent: payload,
2086
+ // Keep the existing JSON text for clients that do not consume structuredContent yet.
2087
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }, ...extraContent]
2088
+ };
2089
+ }
2090
+ function errorResult(message) {
2091
+ return { content: [{ type: "text", text: message }], isError: true };
2092
+ }
2093
+ var AUTO_INLINE_THRESHOLD_BYTES = 200 * 1024;
2094
+ function resolveOutputMode(requested, totalBytes) {
2095
+ if (requested === "inline" || requested === "urls") return requested;
2096
+ return totalBytes > AUTO_INLINE_THRESHOLD_BYTES ? "urls" : "inline";
2097
+ }
2098
+ async function resolveLook(deps2, args) {
2099
+ const style2 = args.style ?? null;
2100
+ if (args.look != null) return { look: args.look, style: style2 };
2101
+ if (args.useSavedLook || args.version != null) {
2102
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
2103
+ if (!project2) {
2104
+ return { look: null, style: style2, note: "no matching ShotOps project found for this account \u2014 used default styling." };
2105
+ }
2106
+ const saved = await deps2.readLook(project2.id);
2107
+ if (!saved) {
2108
+ return { look: null, style: style2, note: `no saved look on project "${project2.name}" \u2014 used default styling.` };
2109
+ }
2110
+ if (args.version != null) {
2111
+ if (args.version === saved.version) return { look: saved.look, style: style2 };
2112
+ const v = await deps2.readVersion(project2.id, args.version);
2113
+ if (v) return { look: v.look, style: style2 };
2114
+ return {
2115
+ look: saved.look,
2116
+ style: style2,
2117
+ note: `version ${args.version} not found on project "${project2.name}" (latest is v${saved.version}) \u2014 rendered the latest.`
2118
+ };
2119
+ }
2120
+ if (project2.heldVersion != null && project2.heldVersion !== saved.version) {
2121
+ const held = await deps2.readVersion(project2.id, project2.heldVersion);
2122
+ if (held) return { look: held.look, style: style2, note: `rendered the held look v${project2.heldVersion} (Follow latest to change).` };
2123
+ return {
2124
+ look: saved.look,
2125
+ style: style2,
2126
+ note: `held version v${project2.heldVersion} not found on project "${project2.name}" \u2014 rendered the latest (v${saved.version}).`
2127
+ };
2128
+ }
2129
+ return { look: saved.look, style: style2 };
2130
+ }
2131
+ return { look: null, style: style2 };
2132
+ }
2133
+ function composeConflictNote(look2, style2) {
2134
+ if (look2 == null || style2 == null || typeof style2 !== "object") return void 0;
2135
+ const s = style2;
2136
+ const ignored = [];
2137
+ if (s.shotLook != null) ignored.push("style.shotLook");
2138
+ if (s.background != null) ignored.push("style.background");
2139
+ if (ignored.length === 0) return void 0;
2140
+ return `a look is supplying the styling, so ${ignored.join(" + ")} ${ignored.length > 1 ? "were" : "was"} ignored \u2014 pass per-panel devices/background via the look, and captions via style.captions.`;
2141
+ }
2142
+ function screenNamesFromRecord(record) {
2143
+ const shots = record?.shots;
2144
+ if (!Array.isArray(shots)) return [];
2145
+ return shots.map((s) => s?.frameName).filter((n) => typeof n === "string" && n.length > 0);
2146
+ }
2147
+ async function persistProject(deps2, args, record) {
2148
+ const names = screenNamesFromRecord(record);
2149
+ const source = names.length === 0 ? void 0 : args.sourceDir ? { kind: "local-path", ref: { dir: args.sourceDir, names } } : { kind: "agent", ref: { names } };
2150
+ const wrapped = {
2151
+ record: SAVED_RECORD_VERSION,
2152
+ project: record,
2153
+ ...source ? { source } : {}
2154
+ };
2155
+ const ctx = {
2156
+ screenshots: args.screenshots,
2157
+ look: args.look,
2158
+ style: args.style,
2159
+ panelPresetId: args.panelPresetId,
2160
+ locale: args.locale,
2161
+ sourceDir: args.sourceDir
2162
+ };
2163
+ try {
2164
+ let projectId;
2165
+ if (args.project) {
2166
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
2167
+ if (!project2) return { error: "no matching ShotOps project for this account." };
2168
+ await deps2.updateProjectRecord(project2.id, wrapped, ctx);
2169
+ projectId = project2.id;
2170
+ } else {
2171
+ const name = args.projectName?.trim() || "ShotOps render";
2172
+ const created = await deps2.createProject(deps2.userId, name, wrapped, ctx);
2173
+ projectId = created.id;
2174
+ }
2175
+ return { projectId, openUrl: `${deps2.studioOrigin}/?project=${projectId}` };
2176
+ } catch (err) {
2177
+ return { error: err.message };
2178
+ }
2179
+ }
2180
+ async function handleSaveProject(deps2, args) {
2181
+ const { look: look2, style: style2 } = await resolveLook(deps2, args);
2182
+ const locale2 = args.locale?.trim() || "en-US";
2183
+ if (!Array.isArray(args.screenshots) || args.screenshots.length === 0) {
2184
+ return errorResult("save_project needs a non-empty `screenshots` array (its slot shape + names define the project structure).");
2185
+ }
2186
+ const slotSizes = args.screenshots.map((s) => Array.isArray(s) ? s.length : 1);
2187
+ if (slotSizes.some((n) => n < 1)) {
2188
+ return errorResult("every `screenshots` slot must contain at least one screenshot entry.");
2189
+ }
2190
+ let entries;
2191
+ try {
2192
+ entries = resolveLocaleVariantSlots(args.screenshots, locale2);
2193
+ } catch (err) {
2194
+ return errorResult(err.message);
2195
+ }
2196
+ const screenshotNames = resolveScreenshotNames(entries).flat();
2197
+ let record;
2198
+ try {
2199
+ record = await deps2.renderer.buildRecord({
2200
+ slotSizes,
2201
+ panelPresetId: args.panelPresetId,
2202
+ look: look2,
2203
+ style: style2,
2204
+ locale: locale2,
2205
+ screenshotNames
2206
+ });
2207
+ } catch (err) {
2208
+ return errorResult(`could not build the project record: ${err.message}`);
2209
+ }
2210
+ if (record == null) {
2211
+ return errorResult("no project record was produced \u2014 not saved.");
2212
+ }
2213
+ const p = await persistProject(deps2, args, record);
2214
+ if ("error" in p) return errorResult(p.error);
2215
+ return textResult({
2216
+ ok: true,
2217
+ projectId: p.projectId,
2218
+ openUrl: p.openUrl,
2219
+ count: slotSizes.length,
2220
+ panelPresetId: args.panelPresetId ?? "r69",
2221
+ locale: locale2,
2222
+ message: `Saved as an editable ShotOps project \u2014 open it at ${p.openUrl} (sign in as the same account). Thread this projectId back on later renders to update it in place.`
2223
+ });
2224
+ }
2225
+ async function handleReadLook(deps2, args) {
2226
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
2227
+ if (!project2) {
2228
+ return textResult({
2229
+ saved: false,
2230
+ message: args.project ? "No such project on this account." : "No ShotOps project on this account yet \u2014 open ShotOps and load a strip first."
2231
+ });
2232
+ }
2233
+ const saved = await deps2.readLook(project2.id);
2234
+ if (!saved) {
2235
+ return textResult({
2236
+ saved: false,
2237
+ project: { id: project2.id, name: project2.name },
2238
+ message: `No saved look on project "${project2.name}". Style a strip in ShotOps and use the Look bar \u2192 Save look, or compose one here with describe_look + save_look.`
2239
+ });
2240
+ }
2241
+ return textResult({
2242
+ saved: true,
2243
+ project: { id: project2.id, name: project2.name },
2244
+ sourceName: saved.sourceName,
2245
+ updatedAt: saved.updatedAt,
2246
+ version: saved.version,
2247
+ heldVersion: project2.heldVersion,
2248
+ // null = renders Follow latest
2249
+ // The full saved-look history (newest-first). Render a specific one with render_strip `version`,
2250
+ // or hold it as the default with hold_look. Metadata only — fetch a version's styling by rendering.
2251
+ versions: await deps2.listVersions(project2.id),
2252
+ look: saved.look
2253
+ });
2254
+ }
2255
+ function projectFileFromRecord(record) {
2256
+ const inner = record?.project;
2257
+ return inner != null && typeof inner === "object" ? inner : record;
2258
+ }
2259
+ async function handleReadProject(deps2, args) {
2260
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
2261
+ if (!project2) {
2262
+ return textResult({
2263
+ saved: false,
2264
+ message: args.project ? "No such project on this account." : "No ShotOps project on this account yet \u2014 open ShotOps and load a strip first."
2265
+ });
2266
+ }
2267
+ const rec = await deps2.readProjectRecord(project2.id);
2268
+ if (!rec) {
2269
+ return textResult({
2270
+ saved: false,
2271
+ project: { id: project2.id, name: project2.name },
2272
+ message: `Project "${project2.name}" has no saved record yet \u2014 open it in ShotOps and save a strip first.`
2273
+ });
2274
+ }
2275
+ return textResult({
2276
+ saved: true,
2277
+ project: { id: project2.id, name: project2.name },
2278
+ updatedAt: rec.updatedAt,
2279
+ heldVersion: project2.heldVersion,
2280
+ // null = renders Follow latest
2281
+ versions: await deps2.listVersions(project2.id),
2282
+ // the saved-look history (newest-first metadata)
2283
+ project_file: projectFileFromRecord(rec.record),
2284
+ message: `Full project for "${project2.name}". project_file is the designer's current strip (frame order in \`panels\`, per-locale words in \`captionText\`, the language list in \`locales\`, the caption base the untranslated locales inherit in \`baseLocale\`, styling). To detect changes, remember updatedAt and compare on your next read_project \u2014 a newer value means the designer edited it.`
2285
+ });
2286
+ }
2287
+ async function handleSaveLook(deps2, args) {
2288
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
2289
+ if (!project2) {
2290
+ return errorResult(
2291
+ args.project ? "No such project on this account." : "No ShotOps project on this account yet \u2014 open ShotOps and load a strip first."
2292
+ );
2293
+ }
2294
+ const look2 = args.look;
2295
+ if (!look2 || typeof look2 !== "object" || !Array.isArray(look2.shots)) {
2296
+ return errorResult("`look` must be a look JSON with a `shots` array \u2014 see describe_look / read_look for the shape.");
2297
+ }
2298
+ if (JSON.stringify(args.look).length > 256 * 1024) {
2299
+ return errorResult("`look` is too large \u2014 a look is styling only (no screenshots, no image bytes).");
2300
+ }
2301
+ const latest = await deps2.readLook(project2.id);
2302
+ if (latest && looksMatch(args.look, latest.look)) {
2303
+ return textResult({
2304
+ ok: true,
2305
+ project: { id: project2.id, name: project2.name },
2306
+ version: latest.version,
2307
+ message: `No styling change \u2014 still version ${latest.version} on "${project2.name}".`
2308
+ });
2309
+ }
2310
+ const { version: version2 } = await deps2.saveLook(project2.id, args.look, args.sourceName?.trim() || void 0);
2311
+ return textResult({
2312
+ ok: true,
2313
+ project: { id: project2.id, name: project2.name },
2314
+ version: version2,
2315
+ message: `Saved as version ${version2} on "${project2.name}" \u2014 render it with useSavedLook, or hold it as the default in ShotOps.`
2316
+ });
2317
+ }
2318
+ async function handleHoldLook(deps2, args) {
2319
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
2320
+ if (!project2) {
2321
+ return errorResult(
2322
+ args.project ? "No such project on this account." : "No ShotOps project on this account yet."
2323
+ );
2324
+ }
2325
+ const versions = await deps2.listVersions(project2.id);
2326
+ if (!versions.some((v) => v.version === args.version)) {
2327
+ const available = versions.length ? versions.map((v) => `v${v.version}`).join(", ") : "none saved yet";
2328
+ return errorResult(`Version ${args.version} not found on project "${project2.name}" (saved: ${available}).`);
2329
+ }
2330
+ try {
2331
+ await deps2.setHeldVersion(project2.id, args.version);
2332
+ } catch (err) {
2333
+ return errorResult(err.message);
2334
+ }
2335
+ return textResult({
2336
+ ok: true,
2337
+ project: { id: project2.id, name: project2.name },
2338
+ heldVersion: args.version,
2339
+ message: `Holding v${args.version} on "${project2.name}" \u2014 agents render it by default until release_look (Follow latest).`
2340
+ });
2341
+ }
2342
+ async function handleReleaseLook(deps2, args) {
2343
+ const project2 = await deps2.resolveProject(deps2.userId, args.project);
2344
+ if (!project2) {
2345
+ return errorResult(
2346
+ args.project ? "No such project on this account." : "No ShotOps project on this account yet."
2347
+ );
2348
+ }
2349
+ try {
2350
+ await deps2.setHeldVersion(project2.id, null);
2351
+ } catch (err) {
2352
+ return errorResult(err.message);
2353
+ }
2354
+ return textResult({
2355
+ ok: true,
2356
+ project: { id: project2.id, name: project2.name },
2357
+ heldVersion: null,
2358
+ message: `Released the hold on "${project2.name}" \u2014 agents now Follow latest (render the newest saved version).`
2359
+ });
2360
+ }
2361
+ function handleDescribeLook() {
2362
+ return textResult(DESCRIBE_LOOK_CATALOG);
2363
+ }
2364
+ async function handleRequestScreenshotUpload(deps2, args) {
2365
+ let slots;
2366
+ try {
2367
+ slots = await deps2.requestScreenshotUpload(deps2.userId, args.count, args.names);
2368
+ } catch (err) {
2369
+ return errorResult(err.message);
2370
+ }
2371
+ const locale2 = args.locale?.trim() || void 0;
2372
+ return textResult({
2373
+ ok: true,
2374
+ slots,
2375
+ ...locale2 ? { locale: locale2 } : {},
2376
+ instructions: 'PUT each screenshot\'s raw PNG bytes to its uploadUrl, e.g.: curl -T screenshot.png "<uploadUrl>" \u2014 then pass { "ref": "<the matching ref>" } as that screenshot\'s entry in render_strip/emit_bundle\'s `screenshots` array. If you passed `names`, that ref already carries its filename \u2014 no need to also set `name` on the screenshot entry. Uploads are not auto-purged yet \u2014 treat refs as short-lived and single-use.' + (locale2 ? ` These slots are tagged for ${locale2}: place each ref under that key of a per-locale screenshot entry, e.g. { "locales": { "${locale2}": { "ref": "<ref>" } } }.` : "")
2377
+ });
2378
+ }
2379
+ async function handleImportScreenshot(deps2, args) {
2380
+ if (!deps2.importScreenshot) {
2381
+ return errorResult(
2382
+ 'import_screenshot is only available on the hosted ChatGPT/HTTP connection. In local stdio mode, pass a { "path": "..." } screenshot entry directly.'
2383
+ );
2384
+ }
2385
+ let imported;
2386
+ try {
2387
+ imported = await deps2.importScreenshot(deps2.userId, args.file, args.name);
2388
+ } catch (err) {
2389
+ return errorResult(err.message);
2390
+ }
2391
+ const locale2 = args.locale?.trim() || void 0;
2392
+ const payload = {
2393
+ ok: true,
2394
+ ...imported,
2395
+ ...locale2 ? { locale: locale2 } : {},
2396
+ instructions: 'Pass { "ref": "<ref>" } as this screenshot in render_strip or emit_bundle. For render_project, also pass { "name": "<name>" } so it matches the saved frame by filename.'
2397
+ };
2398
+ return textResult(payload, [
2399
+ {
2400
+ type: "resource_link",
2401
+ name: imported.name,
2402
+ uri: imported.url,
2403
+ description: "Imported ShotOps screenshot (short-lived signed download URL).",
2404
+ mimeType: "image/png",
2405
+ size: imported.size
2406
+ }
2407
+ ]);
2408
+ }
2409
+ async function handleDeleteAssets(deps2, args) {
2410
+ if (!deps2.deleteAssets) {
2411
+ return errorResult(
2412
+ "delete_assets is only available on the hosted ShotOps connection. Local mode does not upload or retain screenshot files."
2413
+ );
2414
+ }
2415
+ try {
2416
+ const deletedRefs = await deps2.deleteAssets(deps2.userId, args.refs);
2417
+ return textResult({
2418
+ ok: true,
2419
+ deletedCount: deletedRefs.length,
2420
+ deletedRefs,
2421
+ message: "Deleted the selected ShotOps uploads. Saved projects that referenced these files may now report missing screenshots."
2422
+ });
2423
+ } catch (err) {
2424
+ return errorResult(err.message);
2425
+ }
2426
+ }
2427
+ var READ_ONLY = {
2428
+ readOnlyHint: true,
2429
+ destructiveHint: false,
2430
+ idempotentHint: true,
2431
+ openWorldHint: false
2432
+ };
2433
+ var ADDITIVE_WRITE = {
2434
+ readOnlyHint: false,
2435
+ destructiveHint: false,
2436
+ idempotentHint: false,
2437
+ openWorldHint: false
2438
+ };
2439
+ var IDEMPOTENT_WRITE = {
2440
+ readOnlyHint: false,
2441
+ destructiveHint: false,
2442
+ idempotentHint: true,
2443
+ openWorldHint: false
2444
+ };
2445
+ var DESTRUCTIVE_WRITE = {
2446
+ readOnlyHint: false,
2447
+ destructiveHint: true,
2448
+ idempotentHint: false,
2449
+ openWorldHint: false
2450
+ };
2451
+ var OPEN_WORLD_DESTRUCTIVE_WRITE = {
2452
+ readOnlyHint: false,
2453
+ destructiveHint: true,
2454
+ idempotentHint: false,
2455
+ openWorldHint: true
2456
+ };
2457
+ var IDEMPOTENT_DESTRUCTIVE_WRITE = {
2458
+ readOnlyHint: false,
2459
+ destructiveHint: true,
2460
+ idempotentHint: true,
2461
+ openWorldHint: false
2462
+ };
2463
+ function toolMeta(deps2, invoking, invoked, extra = {}) {
2464
+ return {
2465
+ // Current MCP SDK exposes Apps authentication policy through `_meta`; `noauth` keeps the
2466
+ // same registrar accurate for the local stdio server.
2467
+ securitySchemes: deps2.allowLocalScreenshots ? [{ type: "noauth" }] : [{ type: "oauth2", scopes: [] }],
2468
+ "openai/toolInvocation/invoking": invoking,
2469
+ "openai/toolInvocation/invoked": invoked,
2470
+ ...extra
2471
+ };
2472
+ }
2473
+ function registerTools(server2, deps2) {
2474
+ server2.registerTool(
2475
+ "render_strip",
2476
+ {
2477
+ title: "Render App Store strip",
2478
+ description: 'Turn app screenshots into styled 3D device mockups laid out as an App Store screenshot strip, and return the finished per-panel PNGs. Optionally style with a saved look, render a cheap low-res `preview`, and get results `inline` or as `urls` for large payloads. Screenshots can vary per App Store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots, call request_screenshot_upload first \u2014 inline base64 is for small payloads only. No store credential is involved \u2014 this only renders images.',
2479
+ inputSchema: renderStripShape,
2480
+ outputSchema: renderStripOutputSchema,
2481
+ annotations: DESTRUCTIVE_WRITE,
2482
+ _meta: toolMeta(deps2, "Rendering ShotOps strip\u2026", "ShotOps strip rendered")
2483
+ },
2484
+ (args) => handleRenderStrip(deps2, args)
2485
+ );
2486
+ server2.registerTool(
2487
+ "emit_bundle",
2488
+ {
2489
+ title: "Emit fastlane bundle",
2490
+ description: 'Render the strip (or package pre-rendered `panels` refs from a prior render_strip `output: "urls"` call, without re-rendering), then package it as a `fastlane deliver`-ready zip (screenshots + a pre-filled, never-submitting Deliverfile + a README). Pass `locales: [...]` for ONE multi-locale bundle \u2014 a fastlane/screenshots/<locale>/ folder per locale, best combined with per-locale { "locales": \u2026 } screenshot/panel entries. Returns the zip `inline` or as a `url` for large payloads, and can also mint a 14-day share link to a landing page. YOU upload it with your own fastlane \u2014 this server never touches a store credential and never submits for review.',
2491
+ inputSchema: emitBundleShape,
2492
+ outputSchema: emitBundleOutputSchema,
2493
+ annotations: OPEN_WORLD_DESTRUCTIVE_WRITE,
2494
+ _meta: toolMeta(deps2, "Building fastlane bundle\u2026", "Fastlane bundle built")
2495
+ },
2496
+ (args) => handleEmitBundle(deps2, args)
2497
+ );
2498
+ server2.registerTool(
2499
+ "save_project",
2500
+ {
2501
+ title: "Save as editable project",
2502
+ description: "SAVE the strip as an editable ShotOps project the signed-in user can open + refine (returns projectId + an openUrl \u2014 no panel PNGs). Fast: it persists structure + look only, so it does NOT render \u2014 no image bytes are stored or returned, and it never hits the render timeout. Pass an existing `project` id to update that project in place; omit it to create a new one. Prefer this (or render_strip/emit_bundle with `createProject: true` when you also need the images) when the user wants to keep + edit the strip later, not just receive the images.",
2503
+ inputSchema: saveProjectShape,
2504
+ outputSchema: saveProjectOutputSchema,
2505
+ annotations: DESTRUCTIVE_WRITE,
2506
+ _meta: toolMeta(deps2, "Saving ShotOps project\u2026", "ShotOps project saved")
2507
+ },
2508
+ (args) => handleSaveProject(deps2, args)
2509
+ );
2510
+ server2.registerTool(
2511
+ "read_look",
2512
+ {
2513
+ title: "Read saved look",
2514
+ description: "Return a project's saved ShotOps look (styling only \u2014 no screenshots, no credentials), with its latest version, the held version (if any), and the `versions` history. Feed the returned look back into render_strip/emit_bundle via the `look` field, or just pass `useSavedLook: true`; render a specific `versions` entry with `version`, or hold_look it.",
2515
+ inputSchema: readLookShape,
2516
+ outputSchema: readLookOutputSchema,
2517
+ annotations: READ_ONLY,
2518
+ _meta: toolMeta(deps2, "Reading saved look\u2026", "Saved look read")
2519
+ },
2520
+ (args) => handleReadLook(deps2, args)
2521
+ );
2522
+ server2.registerTool(
2523
+ "read_project",
2524
+ {
2525
+ title: "Read full project",
2526
+ description: "Return a project's FULL current state \u2014 the designer's frame order (panels), per-locale caption words (captionText), locale list, and styling \u2014 as an opaque ProjectFile, NOT just the look. Use it to answer \"what did the designer change?\": remember the returned `updatedAt` and re-read later; a newer value means the strip was edited. Read-only, no store credential. To re-render this project with your own screenshots, pass it to render_project.",
2527
+ inputSchema: readProjectShape,
2528
+ outputSchema: readProjectOutputSchema,
2529
+ annotations: READ_ONLY,
2530
+ _meta: toolMeta(deps2, "Reading ShotOps project\u2026", "ShotOps project read")
2531
+ },
2532
+ (args) => handleReadProject(deps2, args)
2533
+ );
2534
+ server2.registerTool(
2535
+ "render_project",
2536
+ {
2537
+ title: "Render a saved project",
2538
+ description: "Re-render a SAVED ShotOps project with your OWN raw screenshots \u2014 the headless twin of opening the project in the web app and re-loading the raws. Pass the developer's screenshots as a FLAT list (each with its original filename as `name`); they are matched to the project's shots BY filename, so ORDER does not matter and you never pre-sort. The project supplies the structure \u2014 frame order, per-locale caption words (pick one with `locale`), styling \u2014 so the result reproduces the designer's exact strip. Shots with no matching file are reported as missing (not an error). Returns per-panel PNGs (`inline` or `urls`). No store credential.",
2539
+ inputSchema: renderProjectShape,
2540
+ outputSchema: renderProjectOutputSchema,
2541
+ annotations: ADDITIVE_WRITE,
2542
+ _meta: toolMeta(deps2, "Rendering saved project\u2026", "Saved project rendered")
2543
+ },
2544
+ (args) => handleRenderProject(deps2, args)
2545
+ );
2546
+ server2.registerTool(
2547
+ "save_look",
2548
+ {
2549
+ title: "Save look",
2550
+ description: "Persist a composed ShotOps look (styling only) on a project, bumping its version. Use describe_look for the field catalog; caption text is never stored \u2014 pass it per render. No store credential is involved.",
2551
+ inputSchema: saveLookShape,
2552
+ outputSchema: saveLookOutputSchema,
2553
+ annotations: ADDITIVE_WRITE,
2554
+ _meta: toolMeta(deps2, "Saving ShotOps look\u2026", "ShotOps look saved")
2555
+ },
2556
+ (args) => handleSaveLook(deps2, args)
2557
+ );
2558
+ server2.registerTool(
2559
+ "hold_look",
2560
+ {
2561
+ title: "Hold a look version",
2562
+ description: "HOLD which saved look version the hosted MCP / agents render by DEFAULT \u2014 the coherent way to fix a render target. The version must exist (see read_look `versions`). A held version stays put while the designer keeps saving newer ones; render_strip/emit_bundle with `useSavedLook: true` then render it. Use release_look to Follow latest again. No credential.",
2563
+ inputSchema: holdLookShape,
2564
+ outputSchema: holdLookOutputSchema,
2565
+ annotations: IDEMPOTENT_WRITE,
2566
+ _meta: toolMeta(deps2, "Holding look version\u2026", "Look version held")
2567
+ },
2568
+ (args) => handleHoldLook(deps2, args)
2569
+ );
2570
+ server2.registerTool(
2571
+ "release_look",
2572
+ {
2573
+ title: "Release the held look",
2574
+ description: "Clear a project\u2019s held version (undo hold_look): agents go back to rendering the LATEST saved look (Follow latest). No-op if nothing was held. No store credential.",
2575
+ inputSchema: releaseLookShape,
2576
+ outputSchema: releaseLookOutputSchema,
2577
+ annotations: IDEMPOTENT_WRITE,
2578
+ _meta: toolMeta(deps2, "Releasing look version\u2026", "Look version released")
2579
+ },
2580
+ (args) => handleReleaseLook(deps2, args)
2581
+ );
2582
+ server2.registerTool(
2583
+ "describe_look",
2584
+ {
2585
+ title: "Describe look fields",
2586
+ description: "The styling field catalog + defaults (device/camera, background, captions, panel presets) for authoring a `style` or a full look from scratch.",
2587
+ inputSchema: describeLookShape,
2588
+ outputSchema: describeLookOutputSchema,
2589
+ annotations: READ_ONLY,
2590
+ _meta: toolMeta(deps2, "Reading look field catalog\u2026", "Look field catalog read")
2591
+ },
2592
+ () => handleDescribeLook()
2593
+ );
2594
+ server2.registerTool(
2595
+ "request_screenshot_upload",
2596
+ {
2597
+ title: "Request screenshot upload slots",
2598
+ description: "Mint signed upload URLs so real screenshots' bytes never transit this conversation's context \u2014 PUT bytes directly to the returned URLs, then pass the returned `ref`s into render_strip/emit_bundle. Use this instead of inline base64 for real (full-resolution) screenshots. Uploading one batch per App Store locale? Tag each batch with `locale` (echoed back for your bookkeeping).",
2599
+ inputSchema: requestScreenshotUploadShape,
2600
+ outputSchema: requestScreenshotUploadOutputSchema,
2601
+ annotations: ADDITIVE_WRITE,
2602
+ _meta: toolMeta(deps2, "Preparing screenshot uploads\u2026", "Screenshot uploads ready")
2603
+ },
2604
+ (args) => handleRequestScreenshotUpload(deps2, args)
2605
+ );
2606
+ server2.registerTool(
2607
+ "import_screenshot",
2608
+ {
2609
+ title: "Import attached screenshot",
2610
+ description: "Import one PNG the user attached in ChatGPT and return an account-scoped ShotOps `ref`. Use the ref in render_strip/emit_bundle, or use it with its filename in render_project. The attachment bytes move server-to-server and never enter model context.",
2611
+ inputSchema: importScreenshotShape,
2612
+ outputSchema: importScreenshotOutputSchema,
2613
+ annotations: ADDITIVE_WRITE,
2614
+ _meta: toolMeta(deps2, "Importing attached screenshot\u2026", "Attached screenshot imported", {
2615
+ "openai/fileParams": ["file"]
2616
+ })
2617
+ },
2618
+ (args) => handleImportScreenshot(deps2, args)
2619
+ );
2620
+ server2.registerTool(
2621
+ "delete_assets",
2622
+ {
2623
+ title: "Delete uploaded assets",
2624
+ description: "Permanently delete private, account-scoped screenshot, rendered-panel, or generated-bundle refs previously returned by ShotOps. This cannot delete another account\u2019s files or public share-link objects. Saved projects that reference a deleted screenshot may render it as missing. This action is irreversible.",
2625
+ inputSchema: deleteAssetsShape,
2626
+ outputSchema: deleteAssetsOutputSchema,
2627
+ annotations: IDEMPOTENT_DESTRUCTIVE_WRITE,
2628
+ _meta: toolMeta(deps2, "Deleting ShotOps assets\u2026", "ShotOps assets deleted")
2629
+ },
2630
+ (args) => handleDeleteAssets(deps2, args)
2631
+ );
2632
+ }
2633
+
2634
+ // src/env.ts
2635
+ import { existsSync } from "node:fs";
2636
+ import { URL as NodeUrl, fileURLToPath } from "node:url";
2637
+ var rootEnvLocal = fileURLToPath(new NodeUrl("../../.env.local", import.meta.url));
2638
+ if (existsSync(rootEnvLocal)) {
2639
+ try {
2640
+ process.loadEnvFile(rootEnvLocal);
2641
+ } catch {
2642
+ }
2643
+ }
2644
+ function studioOrigin() {
2645
+ return (process.env.STUDIO_ORIGIN || "https://storeframe-studio.vercel.app").replace(/\/+$/, "");
2646
+ }
2647
+
2648
+ // src/renderer.ts
2649
+ import { chromium } from "playwright";
2650
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2651
+
2652
+ // ../mockup-engine/stripFromSlots.ts
2653
+ var FALLBACK_GRADIENT_FROM = "#1b1b2e";
2654
+ var FALLBACK_GRADIENT_TO = "#0a0a14";
2655
+ function staggeredHOffset(k) {
2656
+ return String(Math.max(-100, Math.min(100, k * 35)));
2657
+ }
2658
+ var NUMERIC_STRING_FIELDS = ["roll", "phoneHeight", "hOffset", "vOffset", "finish", "clearcoat"];
2659
+ function coerceShotLook(raw) {
2660
+ if (!raw || typeof raw !== "object") return {};
2661
+ const out = { ...raw };
2662
+ for (const f of NUMERIC_STRING_FIELDS) {
2663
+ if (typeof out[f] === "number") out[f] = String(out[f]);
2664
+ }
2665
+ return out;
2666
+ }
2667
+ function coerceCaptionLayer(el) {
2668
+ const { subtitle: _subtitle, sizePt, maxWidth, ...rest } = el;
2669
+ return {
2670
+ ...rest,
2671
+ ...sizePt !== void 0 ? { sizePt: Number(sizePt) } : {},
2672
+ ...maxWidth !== void 0 ? { maxWidth: Number(maxWidth) } : {}
2673
+ };
2674
+ }
2675
+ function remapByOrdinal(record, sourceOrder, targetIds) {
2676
+ const out = {};
2677
+ if (!record) return out;
2678
+ sourceOrder.forEach((sourceId, i) => {
2679
+ const target = targetIds[i];
2680
+ if (target !== void 0 && record[sourceId] !== void 0) out[target] = record[sourceId];
2681
+ });
2682
+ return out;
2683
+ }
2684
+ function stripFromSlots(input) {
2685
+ const slotSizes = input.slotSizes.filter((n) => Number.isInteger(n) && n > 0);
2686
+ const panelIds = slotSizes.map((_, i) => `panel-${i + 1}`);
2687
+ let captionLayers = {};
2688
+ const opaque = input.look && typeof input.look === "object" ? input.look : null;
2689
+ const style2 = input.style ?? null;
2690
+ const opaqueShots = opaque && Array.isArray(opaque.shots) ? opaque.shots : [];
2691
+ const hasOpaqueShots = opaqueShots.length > 0;
2692
+ const styleShotLook = style2 ? coerceShotLook(style2.shotLook) : null;
2693
+ const shots = [];
2694
+ let globalIdx = 0;
2695
+ slotSizes.forEach((phoneCount, slotIdx) => {
2696
+ for (let k = 0; k < phoneCount; k++) {
2697
+ const base = hasOpaqueShots ? coerceShotLook(opaqueShots[globalIdx]?.look ?? opaqueShots[0]?.look ?? {}) : styleShotLook ?? {};
2698
+ const hOffset = base.hOffset !== void 0 && base.hOffset !== null ? String(base.hOffset) : staggeredHOffset(k);
2699
+ shots.push({ panelId: panelIds[slotIdx], look: { ...DEFAULT_LOOK, ...base, hOffset } });
2700
+ globalIdx++;
2701
+ }
2702
+ });
2703
+ let panelColors = {};
2704
+ let captionStyles = {};
2705
+ let panelBackgrounds = {};
2706
+ let bgMode = "gradient";
2707
+ let gradientFrom = FALLBACK_GRADIENT_FROM;
2708
+ let gradientTo = FALLBACK_GRADIENT_TO;
2709
+ let gradientDir = "vertical";
2710
+ let shadow = true;
2711
+ let floorReflection = false;
2712
+ if (opaque) {
2713
+ bgMode = opaque.bgMode === "perPanel" ? "perPanel" : "gradient";
2714
+ if (typeof opaque.gradientFrom === "string") gradientFrom = opaque.gradientFrom;
2715
+ if (typeof opaque.gradientTo === "string") gradientTo = opaque.gradientTo;
2716
+ if (opaque.gradientDir === "horizontal") gradientDir = "horizontal";
2717
+ if (opaque.shadow === false) shadow = false;
2718
+ if (opaque.floorReflection === true) floorReflection = true;
2719
+ const sourceOrder = [];
2720
+ for (const s of opaqueShots) {
2721
+ const id = typeof s?.panelId === "string" ? s.panelId : null;
2722
+ if (id && !sourceOrder.includes(id)) sourceOrder.push(id);
2723
+ }
2724
+ panelColors = remapByOrdinal(opaque.panelColors, sourceOrder, panelIds);
2725
+ captionStyles = remapByOrdinal(normalizeCaptionLayerStyles(opaque.captionStyles), sourceOrder, panelIds);
2726
+ panelBackgrounds = remapByOrdinal(normalizePanelBackgrounds(opaque.panelBackgrounds), sourceOrder, panelIds);
2727
+ } else if (style2) {
2728
+ const bg = style2.background ?? {};
2729
+ bgMode = bg.mode === "perPanel" ? "perPanel" : "gradient";
2730
+ if (typeof bg.gradientFrom === "string") gradientFrom = bg.gradientFrom;
2731
+ if (typeof bg.gradientTo === "string") gradientTo = bg.gradientTo;
2732
+ if (bg.gradientDir === "horizontal") gradientDir = "horizontal";
2733
+ if (bg.shadow === false) shadow = false;
2734
+ if (bg.floorReflection === true) floorReflection = true;
2735
+ if (Array.isArray(bg.panelColors)) {
2736
+ bg.panelColors.forEach((color, i) => {
2737
+ if (typeof color === "string" && color && panelIds[i] !== void 0) panelColors[panelIds[i]] = color;
2738
+ });
2739
+ }
2740
+ }
2741
+ if (style2 && Array.isArray(style2.captions)) {
2742
+ const rawStyles = {};
2743
+ const inner = {};
2744
+ style2.captions.forEach((entry, i) => {
2745
+ const panelId = panelIds[i];
2746
+ if (panelId === void 0 || entry === null || entry === void 0) return;
2747
+ if (Array.isArray(entry)) {
2748
+ const layers = entry.filter((el) => !!el && typeof el === "object" && typeof el.text === "string" && el.text.trim() !== "").map(coerceCaptionLayer);
2749
+ if (layers.length > 0) inner[panelId] = layers;
2750
+ } else if (typeof entry === "object") {
2751
+ const { text, subtitle, sizePt, maxWidth, ...rest } = entry;
2752
+ rawStyles[panelId] = {
2753
+ ...rest,
2754
+ ...sizePt !== void 0 ? { sizePt: Number(sizePt) } : {},
2755
+ ...maxWidth !== void 0 ? { maxWidth: Number(maxWidth) } : {}
2756
+ };
2757
+ if (typeof text === "string" && text.trim()) {
2758
+ inner[panelId] = { headline: text, ...typeof subtitle === "string" && subtitle ? { subtitle } : {} };
2759
+ }
2760
+ }
2761
+ });
2762
+ const localeLayers = normalizeCaptionText({ [DEFAULT_LOCALE]: inner }, normalizeCaptionStyles(rawStyles));
2763
+ captionStyles = captionStylesFromLayers(localeLayers);
2764
+ captionLayers = resolveCaptionText(localeLayers);
2765
+ }
2766
+ const panelPresetId2 = input.panelPresetId ?? (typeof opaque?.panelPresetId === "string" ? opaque.panelPresetId : void 0) ?? "r69";
2767
+ return {
2768
+ look: {
2769
+ panelPresetId: panelPresetId2,
2770
+ bgMode,
2771
+ gradientFrom,
2772
+ gradientTo,
2773
+ gradientDir,
2774
+ panelColors,
2775
+ shadow,
2776
+ floorReflection,
2777
+ panelBackgrounds,
2778
+ captionStyles,
2779
+ shots
2780
+ },
2781
+ panelIds,
2782
+ captionText: captionLayers
2783
+ };
2784
+ }
2785
+
2786
+ // ../mockup-engine/recordFromStrip.ts
2787
+ function normalizeSourceName(raw) {
2788
+ const name = raw?.trim();
2789
+ if (!name) return null;
2790
+ return /\.(png|jpe?g|webp)$/i.test(name) ? name : `${name}.png`;
2791
+ }
2792
+ function frameNamesFor(shots, shotNames) {
2793
+ const seen = /* @__PURE__ */ new Map();
2794
+ return shots.map((_, i) => {
2795
+ const real = normalizeSourceName(shotNames?.[i]);
2796
+ if (!real) return `screen-${i + 1}.png`;
2797
+ const count = seen.get(real) ?? 0;
2798
+ seen.set(real, count + 1);
2799
+ if (count === 0) return real;
2800
+ const dot = real.lastIndexOf(".");
2801
+ return `${real.slice(0, dot)}-${count + 1}${real.slice(dot)}`;
2802
+ });
2803
+ }
2804
+ function recordFromStrip(input) {
2805
+ const { look: look2, panelIds, captionText, locale: locale2, shotNames } = input;
2806
+ const panels = panelIds.map((id) => ({ id }));
2807
+ const frameNames = frameNamesFor(look2.shots, shotNames);
2808
+ const shots = look2.shots.map((s, i) => ({
2809
+ id: `shot-${i + 1}`,
2810
+ panelId: s.panelId,
2811
+ frameNodeId: frameNames[i],
2812
+ frameName: frameNames[i],
2813
+ bytes: null,
2814
+ look: s.look,
2815
+ thumb: null,
2816
+ thumbStale: true
2817
+ }));
2818
+ return serializeProject({
2819
+ shots,
2820
+ panels,
2821
+ selectedShotId: shots[0]?.id ?? null,
2822
+ panelPresetId: look2.panelPresetId,
2823
+ bgMode: look2.bgMode,
2824
+ gradientFrom: look2.gradientFrom,
2825
+ gradientTo: look2.gradientTo,
2826
+ gradientDir: look2.gradientDir,
2827
+ panelColors: look2.panelColors,
2828
+ shadow: look2.shadow,
2829
+ panelBackgrounds: look2.panelBackgrounds,
2830
+ captionStyles: look2.captionStyles,
2831
+ // Nest this render's flat caption text under its locale (default en-US) so the stored blob
2832
+ // is locale-correct; serializeProject's normalizeCaptionText passes a nested map through.
2833
+ captionText: captionText && Object.keys(captionText).length > 0 ? { [locale2 || DEFAULT_LOCALE]: captionText } : {}
2834
+ });
2835
+ }
2836
+
2837
+ // src/staticServer.ts
2838
+ import { createServer } from "node:http";
2839
+ import { readFile as readFile2 } from "node:fs/promises";
2840
+ import { extname, join, relative, isAbsolute } from "node:path";
2841
+ var MIME = {
2842
+ ".html": "text/html; charset=utf-8",
2843
+ ".js": "text/javascript; charset=utf-8",
2844
+ ".mjs": "text/javascript; charset=utf-8",
2845
+ ".css": "text/css; charset=utf-8",
2846
+ ".json": "application/json; charset=utf-8",
2847
+ ".glb": "model/gltf-binary",
2848
+ ".woff2": "font/woff2",
2849
+ ".woff": "font/woff",
2850
+ ".png": "image/png",
2851
+ ".svg": "image/svg+xml",
2852
+ ".wasm": "application/wasm"
2853
+ };
2854
+ async function startStaticServer(rootDir) {
2855
+ const server2 = createServer((req, res) => {
2856
+ const reqPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
2857
+ const rel = reqPath === "/" ? "index.html" : reqPath.replace(/^\/+/, "");
2858
+ const filePath = join(rootDir, rel);
2859
+ const within = relative(rootDir, filePath);
2860
+ if (within.startsWith("..") || isAbsolute(within)) {
2861
+ res.statusCode = 403;
2862
+ res.end("forbidden");
2863
+ return;
2864
+ }
2865
+ readFile2(filePath).then(
2866
+ (body) => {
2867
+ res.statusCode = 200;
2868
+ res.setHeader("Content-Type", MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream");
2869
+ res.end(body);
2870
+ },
2871
+ () => {
2872
+ res.statusCode = 404;
2873
+ res.end("not found");
2874
+ }
2875
+ );
2876
+ });
2877
+ await new Promise((resolve, reject) => {
2878
+ server2.once("error", reject);
2879
+ server2.listen(0, "127.0.0.1", resolve);
2880
+ });
2881
+ const addr = server2.address();
2882
+ return {
2883
+ url: `http://127.0.0.1:${addr.port}/`,
2884
+ close: () => new Promise((resolve) => {
2885
+ server2.close(() => resolve());
2886
+ })
2887
+ };
2888
+ }
2889
+
2890
+ // src/renderer.ts
2891
+ var StripRenderer = class {
2892
+ serveMode;
2893
+ vite = null;
2894
+ staticServer = null;
2895
+ browser = null;
2896
+ page = null;
2897
+ booting = null;
2898
+ sawContextLost = false;
2899
+ // Default 'vite' so `new StripRenderer()` (server.ts) keeps the hosted path byte-for-byte.
2900
+ constructor(options = {}) {
2901
+ this.serveMode = options.serve ?? "vite";
2902
+ }
2903
+ boot() {
2904
+ if (!this.booting) {
2905
+ this.booting = this.doBoot().catch((err) => {
2906
+ this.booting = null;
2907
+ throw err;
2908
+ });
2909
+ }
2910
+ return this.booting;
2911
+ }
2912
+ async doBoot() {
2913
+ const url = await this.startHarnessServer();
2914
+ this.browser = await chromium.launch({ headless: true });
2915
+ const page = await this.browser.newPage();
2916
+ page.on("console", (m) => {
2917
+ if (/CONTEXT_LOST|Context Lost/i.test(m.text())) this.sawContextLost = true;
2918
+ });
2919
+ await page.goto(url, { waitUntil: "load" });
2920
+ await page.waitForFunction(() => window.__RENDER_READY === true, { timeout: 9e4 });
2921
+ this.page = page;
2922
+ }
2923
+ // The one line that forks between hosted and local. Everything downstream (browser launch,
2924
+ // the __RENDER_READY handshake, window.renderStrip, boot-retry, CONTEXT_LOST) is identical
2925
+ // — the page is the same harness, only its bytes come from a different server.
2926
+ async startHarnessServer() {
2927
+ if (this.serveMode === "static") {
2928
+ const rootDir = fileURLToPath2(new URL("../harness-dist", import.meta.url));
2929
+ this.staticServer = await startStaticServer(rootDir);
2930
+ return this.staticServer.url;
2931
+ }
2932
+ const configFile = fileURLToPath2(new URL("../vite.config.mjs", import.meta.url));
2933
+ const { createServer: createServer2 } = await import("vite");
2934
+ this.vite = await createServer2({ configFile });
2935
+ await this.vite.listen();
2936
+ const addr = this.vite.httpServer?.address();
2937
+ const port = this.vite.config.server.port || (addr && typeof addr === "object" ? addr.port : 0);
2938
+ return `http://localhost:${port}/`;
2939
+ }
2940
+ // buildRecord: the SAME styling seam the render uses (stripFromSlots → recordFromStrip), run
2941
+ // in Node with NO browser. Both are pure, browser-free @engine functions and a record carries
2942
+ // no bytes, so save_project persists an editable project without a multi-second Chromium render
2943
+ // — the fix for the Fly proxy timeout on that path. Kept byte-for-byte aligned with the
2944
+ // harness's buildRecord branch (render.js): same stripFromSlots call, same recordFromStrip
2945
+ // args — so a save_project record matches a render({buildRecord:true}) record exactly.
2946
+ async buildRecord(input) {
2947
+ const { look: look2, panelIds, captionText } = stripFromSlots({
2948
+ slotSizes: input.slotSizes,
2949
+ panelPresetId: input.panelPresetId,
2950
+ look: input.look ?? void 0,
2951
+ style: input.style ?? void 0
2952
+ });
2953
+ return recordFromStrip({
2954
+ look: look2,
2955
+ panelIds,
2956
+ captionText,
2957
+ locale: input.locale,
2958
+ shotNames: input.screenshotNames
2959
+ });
2960
+ }
2961
+ async render(input) {
2962
+ await this.boot();
2963
+ if (!this.page) return { ok: false, error: "renderer failed to boot" };
2964
+ const result = await this.page.evaluate((inp) => window.renderStrip(inp), input);
2965
+ if (this.sawContextLost) {
2966
+ console.warn("[renderer] CONTEXT_LOST observed since boot");
2967
+ }
2968
+ return result;
2969
+ }
2970
+ // render_project: same page + boot handshake as render(); only the harness entry differs
2971
+ // (window.renderProject restores the saved ProjectFile and joins bytes by frameName).
2972
+ async renderProject(input) {
2973
+ await this.boot();
2974
+ if (!this.page) return { ok: false, error: "renderer failed to boot" };
2975
+ const result = await this.page.evaluate((inp) => window.renderProject(inp), input);
2976
+ if (this.sawContextLost) {
2977
+ console.warn("[renderer] CONTEXT_LOST observed since boot");
2978
+ }
2979
+ return result;
2980
+ }
2981
+ async close() {
2982
+ await this.page?.close().catch(() => {
2983
+ });
2984
+ await this.browser?.close().catch(() => {
2985
+ });
2986
+ await this.vite?.close().catch(() => {
2987
+ });
2988
+ await this.staticServer?.close().catch(() => {
2989
+ });
2990
+ this.page = null;
2991
+ this.browser = null;
2992
+ this.vite = null;
2993
+ this.staticServer = null;
2994
+ this.booting = null;
2995
+ }
2996
+ };
2997
+
2998
+ // src/hostedBridge.ts
2999
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3000
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3001
+ import { dirname } from "node:path";
3002
+
3003
+ // package.json
3004
+ var package_default = {
3005
+ name: "shotops-mcp",
3006
+ version: "0.5.0",
3007
+ private: false,
3008
+ type: "module",
3009
+ description: "The bundle-emitting MCP server over the @engine/@sync spine. Exposes ShotOps tools to ChatGPT, Claude Code, Cursor, and CI over Streamable HTTP with OAuth or personal API tokens. Zero-custody: no code path accepts, stores, or forwards a store-signing credential \u2014 upload stays the user's own fastlane. Renders via headless Playwright Chromium, the engine's native non-browser habitat (same path as mockup-mcp). Also ships as a free LOCAL stdio server (`npx shotops-mcp`) \u2014 same render, on your own machine, no account needed.",
3010
+ license: "UNLICENSED",
3011
+ bin: {
3012
+ "shotops-mcp": "./dist/local.js"
3013
+ },
3014
+ files: [
3015
+ "dist",
3016
+ "harness-dist",
3017
+ "README.md"
3018
+ ],
3019
+ engines: {
3020
+ node: ">=20.12"
3021
+ },
3022
+ scripts: {
3023
+ dev: "tsx src/server.ts",
3024
+ start: "tsx src/server.ts",
3025
+ "build:harness": "vite build --config vite.harness.config.mjs",
3026
+ "build:node": "node build.mjs",
3027
+ build: "npm run build:harness && npm run build:node",
3028
+ prepublishOnly: "npm run build",
3029
+ typecheck: "tsc --noEmit"
3030
+ },
3031
+ dependencies: {
3032
+ "@modelcontextprotocol/sdk": "^1.29.0",
3033
+ express: "^5.2.1",
3034
+ fflate: "^0.8.3",
3035
+ playwright: "1.61.1",
3036
+ tsx: "^4.23.0",
3037
+ zod: "^3.25.76"
3038
+ },
3039
+ devDependencies: {
3040
+ "@types/express": "^5.0.6",
3041
+ esbuild: "^0.28.1",
3042
+ pixelmatch: "^7.2.0",
3043
+ pngjs: "^7.0.0",
3044
+ typescript: "^5.6.0",
3045
+ vite: "^5.4.0"
3046
+ }
3047
+ };
3048
+
3049
+ // src/version.ts
3050
+ var VERSION = package_default.version;
3051
+
3052
+ // src/hostedBridge.ts
3053
+ var DEFAULT_HOSTED_MCP_URL = "https://shotops-mcp.fly.dev/mcp";
3054
+ function hostedMcpUrl() {
3055
+ return (process.env.SHOTOPS_MCP_URL || DEFAULT_HOSTED_MCP_URL).replace(/\/+$/, "");
3056
+ }
3057
+ async function callRemoteTool(client, name, args) {
3058
+ const result = await client.callTool({ name, arguments: args });
3059
+ const text = result.content?.find((c) => c.type === "text")?.text;
3060
+ if (result.isError) throw new Error(text || `${name} failed`);
3061
+ if (!text) throw new Error(`${name} returned no content`);
3062
+ return JSON.parse(text);
3063
+ }
3064
+ function localSourceDir(screenshots2) {
3065
+ if (!Array.isArray(screenshots2)) return void 0;
3066
+ const dirs = /* @__PURE__ */ new Set();
3067
+ for (const slot2 of screenshots2) {
3068
+ if (!Array.isArray(slot2)) continue;
3069
+ for (const entry of slot2) {
3070
+ if (entry && typeof entry === "object" && "path" in entry && typeof entry.path === "string") {
3071
+ dirs.add(dirname(entry.path));
3072
+ }
3073
+ }
3074
+ }
3075
+ return dirs.size === 1 ? [...dirs][0] : void 0;
3076
+ }
3077
+ async function callSaveProject(client, ctx, extra) {
3078
+ const res = await callRemoteTool(client, "save_project", {
3079
+ screenshots: ctx?.screenshots,
3080
+ panelPresetId: ctx?.panelPresetId,
3081
+ look: ctx?.look,
3082
+ style: ctx?.style,
3083
+ locale: ctx?.locale,
3084
+ sourceDir: ctx?.sourceDir ?? localSourceDir(ctx?.screenshots),
3085
+ ...extra
3086
+ });
3087
+ return { id: res.projectId };
3088
+ }
3089
+ async function connectHostedBridge(token2) {
3090
+ const client = new Client({ name: "shotops-mcp-local-bridge", version: VERSION }, { capabilities: {} });
3091
+ const transport = new StreamableHTTPClientTransport(new URL(hostedMcpUrl()), {
3092
+ requestInit: { headers: { Authorization: `Bearer ${token2}` } }
3093
+ });
3094
+ await client.connect(transport);
3095
+ return {
3096
+ async resolveProject(_userId, projectId) {
3097
+ const res = await callRemoteTool(client, "read_look", projectId ? { project: projectId } : {});
3098
+ const project2 = res.project;
3099
+ if (!project2?.id) return null;
3100
+ return {
3101
+ id: project2.id,
3102
+ name: typeof project2.name === "string" ? project2.name : "My project",
3103
+ heldVersion: typeof res.heldVersion === "number" ? res.heldVersion : null
3104
+ };
3105
+ },
3106
+ async readLook(projectId) {
3107
+ const res = await callRemoteTool(client, "read_look", { project: projectId });
3108
+ if (!res.saved) return null;
3109
+ return {
3110
+ look: res.look,
3111
+ sourceName: typeof res.sourceName === "string" ? res.sourceName : null,
3112
+ updatedAt: typeof res.updatedAt === "string" ? res.updatedAt : null,
3113
+ version: typeof res.version === "number" ? res.version : 0
3114
+ };
3115
+ },
3116
+ async readVersion() {
3117
+ return null;
3118
+ },
3119
+ async listVersions(projectId) {
3120
+ const res = await callRemoteTool(client, "read_look", { project: projectId });
3121
+ return Array.isArray(res.versions) ? res.versions : [];
3122
+ },
3123
+ async setHeldVersion(projectId, version2) {
3124
+ if (version2 === null) await callRemoteTool(client, "release_look", { project: projectId });
3125
+ else await callRemoteTool(client, "hold_look", { project: projectId, version: version2 });
3126
+ },
3127
+ async readProjectRecord(projectId) {
3128
+ const res = await callRemoteTool(client, "read_project", { project: projectId });
3129
+ if (!res.saved) return null;
3130
+ return {
3131
+ record: { project: res.project_file },
3132
+ updatedAt: typeof res.updatedAt === "string" ? res.updatedAt : null
3133
+ };
3134
+ },
3135
+ async saveLook(projectId, look2, sourceName) {
3136
+ const res = await callRemoteTool(client, "save_look", {
3137
+ project: projectId,
3138
+ look: look2,
3139
+ ...sourceName ? { sourceName } : {}
3140
+ });
3141
+ return { version: res.version };
3142
+ },
3143
+ async createProject(_userId, projectName2, _record, ctx) {
3144
+ return callSaveProject(client, ctx, { projectName: projectName2 });
3145
+ },
3146
+ async updateProjectRecord(projectId, _record, ctx) {
3147
+ await callSaveProject(client, ctx, { project: projectId });
3148
+ },
3149
+ async close() {
3150
+ await client.close();
3151
+ }
3152
+ };
3153
+ }
3154
+
3155
+ // src/localDeps.ts
3156
+ var SIGN_IN = "not available in local (free) mode \u2014 connect the hosted ShotOps MCP and sign in for saved looks, editable projects, and share links.";
3157
+ var SHARE_LINK_NOT_BRIDGED = `Creating a share link is ${SIGN_IN} (bridging this needs a hosted rendering step, which local mode deliberately avoids \u2014 connect directly to the hosted MCP for share links.)`;
3158
+ function localToolDeps(token2) {
3159
+ const base = {
3160
+ renderer: new StripRenderer({ serve: "static" }),
3161
+ requestScreenshotUpload: async () => {
3162
+ throw new Error(
3163
+ 'request_screenshot_upload is not available in local mode \u2014 local mode reads screenshots straight off disk: pass { "path": "/abs/or/relative/path.png" } screenshot entries instead.'
3164
+ );
3165
+ },
3166
+ allowLocalScreenshots: true,
3167
+ forceOutput: "inline",
3168
+ // no Supabase storage locally to upload an `output: "urls"` result to
3169
+ studioOrigin: studioOrigin()
3170
+ };
3171
+ if (!token2) {
3172
+ return {
3173
+ ...base,
3174
+ resolveProject: async () => null,
3175
+ readLook: async () => null,
3176
+ readVersion: async () => null,
3177
+ listVersions: async () => [],
3178
+ setHeldVersion: async () => {
3179
+ throw new Error(`Holding a look version is ${SIGN_IN}`);
3180
+ },
3181
+ readProjectRecord: async () => null,
3182
+ saveLook: async () => {
3183
+ throw new Error(`Saving a look is ${SIGN_IN}`);
3184
+ },
3185
+ createProject: async () => {
3186
+ throw new Error(`Saving an editable project is ${SIGN_IN}`);
3187
+ },
3188
+ updateProjectRecord: async () => {
3189
+ throw new Error(`Updating a saved project is ${SIGN_IN}`);
3190
+ },
3191
+ createShareLink: async () => {
3192
+ throw new Error(`Creating a share link is ${SIGN_IN}`);
3193
+ }
3194
+ };
3195
+ }
3196
+ const hostedToken = token2;
3197
+ let bridgePromise = null;
3198
+ function bridge() {
3199
+ if (!bridgePromise) {
3200
+ bridgePromise = connectHostedBridge(hostedToken).catch((err) => {
3201
+ bridgePromise = null;
3202
+ throw new Error(
3203
+ `could not reach the hosted ShotOps MCP (${err.message}) \u2014 check SHOTOPS_TOKEN / network, or drop the token to use local-only mode.`
3204
+ );
3205
+ });
3206
+ }
3207
+ return bridgePromise;
3208
+ }
3209
+ return {
3210
+ ...base,
3211
+ resolveProject: async (userId, projectId) => (await bridge()).resolveProject(userId, projectId),
3212
+ readLook: async (projectId) => (await bridge()).readLook(projectId),
3213
+ readVersion: async (projectId, version2) => (await bridge()).readVersion(projectId, version2),
3214
+ listVersions: async (projectId) => (await bridge()).listVersions(projectId),
3215
+ setHeldVersion: async (projectId, version2) => (await bridge()).setHeldVersion(projectId, version2),
3216
+ readProjectRecord: async (projectId) => (await bridge()).readProjectRecord(projectId),
3217
+ saveLook: async (projectId, look2, sourceName) => (await bridge()).saveLook(projectId, look2, sourceName),
3218
+ createProject: async (userId, name, record, ctx) => (await bridge()).createProject(userId, name, record, ctx),
3219
+ updateProjectRecord: async (projectId, record, ctx) => (await bridge()).updateProjectRecord(projectId, record, ctx),
3220
+ createShareLink: async () => {
3221
+ throw new Error(SHARE_LINK_NOT_BRIDGED);
3222
+ },
3223
+ closeBridge: async () => {
3224
+ if (bridgePromise) await (await bridgePromise).close().catch(() => {
3225
+ });
3226
+ }
3227
+ };
3228
+ }
3229
+
3230
+ // src/local.ts
3231
+ function tokenFromArgv(argv) {
3232
+ const eq = argv.find((a) => a.startsWith("--token="));
3233
+ if (eq) return eq.slice("--token=".length);
3234
+ const idx = argv.indexOf("--token");
3235
+ return idx !== -1 ? argv[idx + 1] : void 0;
3236
+ }
3237
+ var token = tokenFromArgv(process.argv.slice(2)) || process.env.SHOTOPS_TOKEN || void 0;
3238
+ var deps = localToolDeps(token);
3239
+ var localInstructions = token ? `${SERVER_INSTRUCTIONS}
3240
+
3241
+ ## Local mode (this connection)
3242
+ Rendering runs on THIS machine \u2014 free, no timeout ceiling. A hosted token is configured, so save_project / read_look / save_look also work (they read/write your hosted ShotOps account); request_screenshot_upload and import_screenshot still no-op \u2014 pass local files as { "path": "..." } screenshot entries instead.` : `${SERVER_INSTRUCTIONS}
3243
+
3244
+ ## Local mode (this connection)
3245
+ Rendering runs on THIS machine \u2014 free, no timeout ceiling. No hosted token is configured, so save_project / read_look / save_look / share links cleanly no-op ("sign in"); request_screenshot_upload and import_screenshot are hosted-only \u2014 pass local files as { "path": "..." } screenshot entries and render_strip / emit_bundle work fully.`;
3246
+ var server = new McpServer(
3247
+ { name: "shotops-mcp-local", version: VERSION },
3248
+ { instructions: localInstructions }
3249
+ );
3250
+ registerTools(server, { userId: "local", ...deps });
3251
+ async function shutdown() {
3252
+ await deps.renderer.close();
3253
+ await deps.closeBridge?.();
3254
+ process.exit(0);
3255
+ }
3256
+ process.on("SIGINT", () => void shutdown());
3257
+ process.on("SIGTERM", () => void shutdown());
3258
+ await server.connect(new StdioServerTransport());
3259
+ console.error(
3260
+ `[shotops-mcp-local] connected over stdio \u2014 renders run on this machine.${token ? " (hosted bridge configured)" : ""}`
3261
+ );