storeframe-mcp 0.1.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/README.md +318 -0
- package/dist/local.js +1674 -0
- package/harness-dist/assets/Fraunces-SemiBold-BtraKtER.woff2 +0 -0
- package/harness-dist/assets/Inter-SemiBold-B9yU462S.woff2 +0 -0
- package/harness-dist/assets/Manrope-SemiBold-44nuYf5T.woff2 +0 -0
- package/harness-dist/assets/Poppins-SemiBold-DJVo7c07.woff2 +0 -0
- package/harness-dist/assets/SpaceGrotesk-SemiBold-CyO2J5Y0.woff2 +0 -0
- package/harness-dist/assets/index-BppCs90_.js +3802 -0
- package/harness-dist/assets/iphone-optimized-BdcJzlHi.glb +0 -0
- package/harness-dist/index.html +10 -0
- package/package.json +44 -0
package/dist/local.js
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
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/fastlaneBundle.ts
|
|
8
|
+
import { strToU8, zipSync } from "fflate";
|
|
9
|
+
var DELIVERFILE_TEMPLATE = `# Generated by Storeframe Studio \u2014 screenshots-only upload, nothing else.
|
|
10
|
+
# Uploads to your app's editable (unreleased) version. Never submits for review.
|
|
11
|
+
app_identifier "{{BUNDLE_ID}}"
|
|
12
|
+
skip_binary_upload true
|
|
13
|
+
skip_metadata true
|
|
14
|
+
skip_app_version_update true
|
|
15
|
+
overwrite_screenshots true
|
|
16
|
+
`;
|
|
17
|
+
var README_TEMPLATE = `# Upload these screenshots to App Store Connect
|
|
18
|
+
|
|
19
|
+
This bundle was exported from Storeframe Studio for **{{BUNDLE_ID}}** \u2014
|
|
20
|
+
{{COUNT}} screenshots \xB7 {{PANEL_LABEL}} \xB7 locale \`{{LOCALE}}\`.
|
|
21
|
+
|
|
22
|
+
You upload it yourself with [Fastlane](https://fastlane.tools), signed in with **your own
|
|
23
|
+
Apple account or API key**. Your credentials never touch Storeframe.
|
|
24
|
+
|
|
25
|
+
## Upload
|
|
26
|
+
|
|
27
|
+
\`\`\`
|
|
28
|
+
cd <this folder>
|
|
29
|
+
fastlane deliver
|
|
30
|
+
\`\`\`
|
|
31
|
+
|
|
32
|
+
Fastlane will:
|
|
33
|
+
|
|
34
|
+
1. Ask you to sign in (Apple ID with 2FA, or an App Store Connect API key if you have
|
|
35
|
+
fastlane configured with one).
|
|
36
|
+
2. Detect the device size automatically from the image dimensions.
|
|
37
|
+
3. Show you an **HTML preview** of exactly what will be uploaded and ask
|
|
38
|
+
\`Does the Preview look okay for you? (y/n)\` \u2014 nothing is sent until you confirm.
|
|
39
|
+
4. Upload the screenshots to your app's **editable (unreleased) version**, replacing the
|
|
40
|
+
existing set for this device size and locale.
|
|
41
|
+
|
|
42
|
+
**Nothing is ever submitted for review.** This bundle only updates screenshots on a draft
|
|
43
|
+
version; you review and submit in App Store Connect yourself, whenever you're ready.
|
|
44
|
+
|
|
45
|
+
## First time?
|
|
46
|
+
|
|
47
|
+
- Install fastlane: \`brew install fastlane\` (or \`gem install fastlane\`).
|
|
48
|
+
- You need an **editable version** of your app in App Store Connect (any version that
|
|
49
|
+
isn't live yet). If every version is released, create a new version in App Store
|
|
50
|
+
Connect first \u2014 deliver can't attach screenshots to a released version.
|
|
51
|
+
|
|
52
|
+
## What's in this bundle
|
|
53
|
+
|
|
54
|
+
\`\`\`
|
|
55
|
+
README.md this file
|
|
56
|
+
fastlane/
|
|
57
|
+
Deliverfile pre-filled config \u2014 screenshots only, nothing else
|
|
58
|
+
fastlane/screenshots/{{LOCALE}}/ your screenshots; the NN_ filename prefix is the
|
|
59
|
+
display order in the App Store
|
|
60
|
+
\`\`\`
|
|
61
|
+
|
|
62
|
+
The \`Deliverfile\` is deliberately minimal and read-safe: it skips binary upload, skips
|
|
63
|
+
metadata, and never submits. Feel free to inspect it \u2014 it's five lines.
|
|
64
|
+
|
|
65
|
+
## Troubleshooting
|
|
66
|
+
|
|
67
|
+
- **"Could not find app with bundle identifier"** \u2014 the Bundle ID above doesn't match an
|
|
68
|
+
app on the team you signed in with. Re-export from Storeframe with the right Bundle ID.
|
|
69
|
+
- **Screenshots land under the wrong language** \u2014 re-export with the right locale; the
|
|
70
|
+
folder name under \`fastlane/screenshots/\` is the App Store locale code.
|
|
71
|
+
- **Wrong order in the store** \u2014 the \`NN_\` prefix controls order; re-arrange the strip in
|
|
72
|
+
Storeframe and re-export rather than renaming files by hand.
|
|
73
|
+
`;
|
|
74
|
+
function render(template, vars) {
|
|
75
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => vars[key] ?? match);
|
|
76
|
+
}
|
|
77
|
+
function generateDeliverfile(opts) {
|
|
78
|
+
return render(DELIVERFILE_TEMPLATE, { BUNDLE_ID: opts.bundleId });
|
|
79
|
+
}
|
|
80
|
+
function generateBundleReadme(opts) {
|
|
81
|
+
return render(README_TEMPLATE, {
|
|
82
|
+
BUNDLE_ID: opts.bundleId,
|
|
83
|
+
LOCALE: opts.locale,
|
|
84
|
+
PANEL_LABEL: opts.panelLabel,
|
|
85
|
+
COUNT: String(opts.count)
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function screenshotFilename(index) {
|
|
89
|
+
return `${String(index).padStart(2, "0")}_storeframe.png`;
|
|
90
|
+
}
|
|
91
|
+
function bundleZipFilename(projectName2) {
|
|
92
|
+
const slug = projectName2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
93
|
+
return `${slug || "storeframe"}-appstore-upload.zip`;
|
|
94
|
+
}
|
|
95
|
+
var BUNDLE_ID_RE = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$/;
|
|
96
|
+
function isValidBundleId(s) {
|
|
97
|
+
return BUNDLE_ID_RE.test(s);
|
|
98
|
+
}
|
|
99
|
+
function buildFastlaneBundleZip(slots, opts) {
|
|
100
|
+
const files = {
|
|
101
|
+
"README.md": strToU8(
|
|
102
|
+
generateBundleReadme({
|
|
103
|
+
bundleId: opts.bundleId,
|
|
104
|
+
locale: opts.locale,
|
|
105
|
+
panelLabel: opts.panelLabel,
|
|
106
|
+
count: slots.length
|
|
107
|
+
})
|
|
108
|
+
),
|
|
109
|
+
"fastlane/Deliverfile": strToU8(generateDeliverfile({ bundleId: opts.bundleId }))
|
|
110
|
+
};
|
|
111
|
+
for (const slot2 of slots) {
|
|
112
|
+
files[`fastlane/screenshots/${opts.locale}/${screenshotFilename(slot2.displayPosition)}`] = slot2.bytes;
|
|
113
|
+
}
|
|
114
|
+
return zipSync(files, { level: 0 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/screenshotInput.ts
|
|
118
|
+
import { readFile } from "node:fs/promises";
|
|
119
|
+
import { basename } from "node:path";
|
|
120
|
+
|
|
121
|
+
// src/uploads.ts
|
|
122
|
+
import { randomBytes } from "node:crypto";
|
|
123
|
+
|
|
124
|
+
// ../api/_lib/shareStorage.ts
|
|
125
|
+
var BUCKET = "share-bundles";
|
|
126
|
+
function storageBase() {
|
|
127
|
+
const base = (process.env.SUPABASE_URL ?? "").replace(/\/+$/, "");
|
|
128
|
+
if (!base) throw new Error("SUPABASE_URL not set");
|
|
129
|
+
return `${base}/storage/v1`;
|
|
130
|
+
}
|
|
131
|
+
function serviceHeaders(extra = {}) {
|
|
132
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
133
|
+
if (!key) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
|
|
134
|
+
return { apikey: key, Authorization: `Bearer ${key}`, ...extra };
|
|
135
|
+
}
|
|
136
|
+
async function createSignedUploadUrl(path) {
|
|
137
|
+
const res = await fetch(`${storageBase()}/object/upload/sign/${BUCKET}/${path}`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: serviceHeaders({ "Content-Type": "application/json" }),
|
|
140
|
+
body: JSON.stringify({})
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
throw new Error(`storage sign-upload ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
|
144
|
+
}
|
|
145
|
+
const data = await res.json();
|
|
146
|
+
if (!data.url) throw new Error("storage sign-upload: no url in response");
|
|
147
|
+
return { uploadUrl: `${storageBase()}${data.url}` };
|
|
148
|
+
}
|
|
149
|
+
async function createSignedDownloadUrl(path, expiresInSeconds) {
|
|
150
|
+
const res = await fetch(`${storageBase()}/object/sign/${BUCKET}/${path}`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: serviceHeaders({ "Content-Type": "application/json" }),
|
|
153
|
+
body: JSON.stringify({ expiresIn: expiresInSeconds })
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
throw new Error(`storage sign-download ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
|
157
|
+
}
|
|
158
|
+
const data = await res.json();
|
|
159
|
+
if (!data.signedURL) throw new Error("storage sign-download: no signedURL in response");
|
|
160
|
+
return `${storageBase()}${data.signedURL}`;
|
|
161
|
+
}
|
|
162
|
+
async function putBytes(uploadUrl, bytes, contentType) {
|
|
163
|
+
const res = await fetch(uploadUrl, {
|
|
164
|
+
method: "PUT",
|
|
165
|
+
headers: { "content-type": contentType },
|
|
166
|
+
// A fresh ArrayBuffer copy — undici rejects a Uint8Array view whose buffer it can't own.
|
|
167
|
+
body: bytes.slice()
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
throw new Error(`storage PUT ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function downloadObject(path) {
|
|
174
|
+
const res = await fetch(`${storageBase()}/object/${BUCKET}/${path}`, { headers: serviceHeaders() });
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
throw new Error(`storage download ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
|
177
|
+
}
|
|
178
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/uploads.ts
|
|
182
|
+
var DOWNLOAD_URL_TTL_SECONDS = 60 * 60;
|
|
183
|
+
function uploadDirFor(userId) {
|
|
184
|
+
return `uploads/${userId}/${randomBytes(12).toString("base64url")}`;
|
|
185
|
+
}
|
|
186
|
+
var NAME_SEGMENT_RE = /^\d+__([A-Za-z0-9_-]+)\.png$/;
|
|
187
|
+
function decodeNameFromRef(ref) {
|
|
188
|
+
const last = ref.slice(ref.lastIndexOf("/") + 1);
|
|
189
|
+
const match = NAME_SEGMENT_RE.exec(last);
|
|
190
|
+
if (!match) return null;
|
|
191
|
+
try {
|
|
192
|
+
return Buffer.from(match[1], "base64url").toString("utf-8");
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function refBelongsToUser(ref, userId) {
|
|
198
|
+
return ref.startsWith(`uploads/${userId}/`) && !ref.includes("..");
|
|
199
|
+
}
|
|
200
|
+
async function fetchUploadedRef(ref, userId) {
|
|
201
|
+
if (!refBelongsToUser(ref, userId)) {
|
|
202
|
+
throw new Error(`ref does not belong to this account: ${ref}`);
|
|
203
|
+
}
|
|
204
|
+
return downloadObject(ref);
|
|
205
|
+
}
|
|
206
|
+
async function storeAsset(dir, filename, bytes, contentType) {
|
|
207
|
+
const ref = `${dir}/${filename}`;
|
|
208
|
+
const { uploadUrl } = await createSignedUploadUrl(ref);
|
|
209
|
+
await putBytes(uploadUrl, bytes, contentType);
|
|
210
|
+
const url = await createSignedDownloadUrl(ref, DOWNLOAD_URL_TTL_SECONDS);
|
|
211
|
+
return { ref, url };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/screenshotInput.ts
|
|
215
|
+
function entryName(entry) {
|
|
216
|
+
if (typeof entry === "string") return null;
|
|
217
|
+
if (typeof entry.name === "string" && entry.name.trim()) return entry.name.trim();
|
|
218
|
+
if ("ref" in entry) return decodeNameFromRef(entry.ref);
|
|
219
|
+
if ("path" in entry) return basename(entry.path) || null;
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
function resolveScreenshotNames(slots) {
|
|
223
|
+
return slots.map((slot2) => slot2.map(entryName));
|
|
224
|
+
}
|
|
225
|
+
var MAX_URL_FETCH_BYTES = 20 * 1024 * 1024;
|
|
226
|
+
var MAX_INLINE_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
227
|
+
function estimateDecodedBytes(base64) {
|
|
228
|
+
return Math.floor(base64.length * 3 / 4);
|
|
229
|
+
}
|
|
230
|
+
function assertInlinePayloadSize(entries) {
|
|
231
|
+
const totalInline = entries.filter((e) => typeof e === "string").reduce((sum, s) => sum + estimateDecodedBytes(s), 0);
|
|
232
|
+
if (totalInline > MAX_INLINE_TOTAL_BYTES) {
|
|
233
|
+
const mb = (totalInline / 1024 / 1024).toFixed(1);
|
|
234
|
+
const capMb = MAX_INLINE_TOTAL_BYTES / 1024 / 1024;
|
|
235
|
+
throw new Error(
|
|
236
|
+
`payload too large (${mb}MB inline, cap ${capMb}MB) \u2014 call request_screenshot_upload and pass { "ref": "..." } entries instead of inline base64.`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function assertInlinePayloadSizeNested(slots) {
|
|
241
|
+
assertInlinePayloadSize(slots.flat());
|
|
242
|
+
}
|
|
243
|
+
async function fetchUrlAsBase64(url) {
|
|
244
|
+
if (!/^https:\/\//i.test(url)) throw new Error(`screenshot url must be https: ${url}`);
|
|
245
|
+
const res = await fetch(url, { redirect: "manual" });
|
|
246
|
+
if (res.status >= 300 && res.status < 400) {
|
|
247
|
+
throw new Error(`screenshot url redirected \u2014 redirects are not followed (https-only, one hop): ${url}`);
|
|
248
|
+
}
|
|
249
|
+
if (!res.ok) throw new Error(`screenshot url fetch failed (${res.status}): ${url}`);
|
|
250
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
251
|
+
if (!/^image\/png/i.test(contentType)) {
|
|
252
|
+
throw new Error(`screenshot url did not return image/png (got "${contentType || "no content-type"}"): ${url}`);
|
|
253
|
+
}
|
|
254
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
255
|
+
if (buf.byteLength > MAX_URL_FETCH_BYTES) {
|
|
256
|
+
throw new Error(`screenshot url exceeds the ${MAX_URL_FETCH_BYTES / 1024 / 1024}MB cap: ${url}`);
|
|
257
|
+
}
|
|
258
|
+
return Buffer.from(buf).toString("base64");
|
|
259
|
+
}
|
|
260
|
+
async function readLocalPathAsBase64(path) {
|
|
261
|
+
try {
|
|
262
|
+
return (await readFile(path)).toString("base64");
|
|
263
|
+
} catch (err) {
|
|
264
|
+
throw new Error(`could not read local screenshot at "${path}": ${err.message}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function resolveEntry(entry, userId, allowLocalPath) {
|
|
268
|
+
if (typeof entry === "string") return entry;
|
|
269
|
+
if ("ref" in entry) return Buffer.from(await fetchUploadedRef(entry.ref, userId)).toString("base64");
|
|
270
|
+
if ("path" in entry) {
|
|
271
|
+
if (!allowLocalPath) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`{ "path": "${entry.path}" } screenshot entries are only available over the local stdio server (npx storeframe-mcp) \u2014 the hosted server rejects them. Use { "ref": "..." } (request_screenshot_upload) or { "url": "..." } instead.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return readLocalPathAsBase64(entry.path);
|
|
277
|
+
}
|
|
278
|
+
return fetchUrlAsBase64(entry.url);
|
|
279
|
+
}
|
|
280
|
+
async function resolveScreenshots(slots, userId, allowLocalPath = false) {
|
|
281
|
+
assertInlinePayloadSizeNested(slots);
|
|
282
|
+
return Promise.all(slots.map((slot2) => Promise.all(slot2.map((entry) => resolveEntry(entry, userId, allowLocalPath)))));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// src/schemas.ts
|
|
286
|
+
import { z } from "zod";
|
|
287
|
+
var PANEL_PRESET_IDS = ["r69", "r65", "r55", "ipad13", "ipad129"];
|
|
288
|
+
var PANEL_LABELS = {
|
|
289
|
+
r69: "6.9\u2033 iPhone",
|
|
290
|
+
r65: "6.5\u2033 iPhone",
|
|
291
|
+
r55: "5.5\u2033 iPhone",
|
|
292
|
+
ipad13: "13\u2033 iPad",
|
|
293
|
+
ipad129: "12.9\u2033 iPad"
|
|
294
|
+
};
|
|
295
|
+
function panelLabelFor(presetId, width, height) {
|
|
296
|
+
const name = PANEL_LABELS[presetId];
|
|
297
|
+
return name ? `${name} \xB7 ${width}\xD7${height}` : `${width}\xD7${height}`;
|
|
298
|
+
}
|
|
299
|
+
var PANEL_DIMENSIONS = {
|
|
300
|
+
r69: { width: 1290, height: 2796 },
|
|
301
|
+
r65: { width: 1242, height: 2688 },
|
|
302
|
+
r55: { width: 1242, height: 2208 },
|
|
303
|
+
ipad13: { width: 2064, height: 2752 },
|
|
304
|
+
ipad129: { width: 2048, height: 2732 }
|
|
305
|
+
};
|
|
306
|
+
function panelDimensionsFor(presetId) {
|
|
307
|
+
return PANEL_DIMENSIONS[presetId] ?? PANEL_DIMENSIONS.r69;
|
|
308
|
+
}
|
|
309
|
+
var nameField = z.string().max(200).optional().describe(
|
|
310
|
+
`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).`
|
|
311
|
+
);
|
|
312
|
+
var screenshotEntry = z.union([
|
|
313
|
+
z.string().min(1).describe('Inline base64-encoded PNG (no "data:" prefix). Small payloads only.'),
|
|
314
|
+
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."),
|
|
315
|
+
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)."),
|
|
316
|
+
z.object({ path: z.string().min(1), name: nameField }).strict().describe(
|
|
317
|
+
"A local filesystem path to a PNG, read straight off disk \u2014 ONLY available over the local stdio server (npx storeframe-mcp); the hosted server rejects this entry shape."
|
|
318
|
+
)
|
|
319
|
+
]);
|
|
320
|
+
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. Prefer ref/url for real screenshots so the bytes never transit this conversation.';
|
|
321
|
+
var slot = z.array(screenshotEntry).min(1).max(6).describe(
|
|
322
|
+
"One App Store slot: 1\u20136 screenshots. Several entries = several phones composited into that one slot."
|
|
323
|
+
);
|
|
324
|
+
var screenshots = z.array(slot).min(1).max(10).describe(screenshotsDescription);
|
|
325
|
+
var screenshotsForSave = z.array(slot).min(1).max(10).describe(
|
|
326
|
+
`${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).`
|
|
327
|
+
);
|
|
328
|
+
var screenshotsOptional = z.array(slot).min(1).max(10).optional().describe(`${screenshotsDescription} Required unless \`panels\` (pre-rendered refs) is given instead.`);
|
|
329
|
+
var panelRefs = z.array(z.object({ ref: z.string().min(1) }).strict()).min(1).max(10).optional().describe(
|
|
330
|
+
'Pre-rendered panel refs from a prior render_strip call made with `output: "urls"` \u2014 packages them directly into the bundle WITHOUT rendering again. Mutually exclusive with `screenshots`; one of the two is required.'
|
|
331
|
+
);
|
|
332
|
+
var output = z.enum(["inline", "urls"]).optional().describe(
|
|
333
|
+
'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).'
|
|
334
|
+
);
|
|
335
|
+
var preview = z.boolean().optional().describe(
|
|
336
|
+
"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."
|
|
337
|
+
);
|
|
338
|
+
var panelPresetId = z.enum(PANEL_PRESET_IDS).optional().describe("App Store screenshot size. Default r69 (6.9\u2033 iPhone, 1290\xD72796).");
|
|
339
|
+
var look = z.record(z.string(), z.unknown()).optional().describe(
|
|
340
|
+
'A Storeframe "look" (styling only) JSON, exactly as returned by read_look, applied to the strip. Omit for default styling.'
|
|
341
|
+
);
|
|
342
|
+
var useSavedLook = z.boolean().optional().describe("If true, style the strip with the project's saved look (ignored when `look`/`style` is given).");
|
|
343
|
+
var locale = z.string().optional().describe(
|
|
344
|
+
'App Store locale, e.g. "de-DE" (default en-US). Labels the 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).'
|
|
345
|
+
);
|
|
346
|
+
var CAPTION_FONT_IDS = ["inter", "manrope", "poppins", "fraunces", "space-grotesk"];
|
|
347
|
+
var DEFAULT_SHOT_LOOK = {
|
|
348
|
+
angle: "front",
|
|
349
|
+
cameraPos: null,
|
|
350
|
+
roll: "0",
|
|
351
|
+
phoneHeight: "72",
|
|
352
|
+
hOffset: "0",
|
|
353
|
+
vOffset: "0",
|
|
354
|
+
material: "real",
|
|
355
|
+
colorway: "silver",
|
|
356
|
+
customColor: "F5F5F5",
|
|
357
|
+
finish: "0",
|
|
358
|
+
clayTone: "grey",
|
|
359
|
+
clayCustom: "B0B0B0",
|
|
360
|
+
flatScreen: false,
|
|
361
|
+
glare: false,
|
|
362
|
+
lighting: true,
|
|
363
|
+
reflections: false
|
|
364
|
+
};
|
|
365
|
+
var DEFAULT_CAPTION_STYLE = {
|
|
366
|
+
fontId: "inter",
|
|
367
|
+
sizePt: 44,
|
|
368
|
+
color: "FFFFFF",
|
|
369
|
+
align: "center",
|
|
370
|
+
anchor: { x: 0.5, y: 0.06 },
|
|
371
|
+
maxWidth: 0.86
|
|
372
|
+
};
|
|
373
|
+
var numeric = z.union([z.string(), z.number()]);
|
|
374
|
+
var shotLook = z.object({
|
|
375
|
+
angle: z.enum(["front", "left", "right"]).optional().describe("Camera preset. Default front."),
|
|
376
|
+
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."),
|
|
377
|
+
roll: numeric.optional().describe("\u221245\u202645\xB0 clock-hand tilt. Default 0."),
|
|
378
|
+
phoneHeight: numeric.optional().describe("Phone height, 20\u2013100 (% of panel height). Default 72."),
|
|
379
|
+
hOffset: numeric.optional().describe("Horizontal position \u2212100\u2026100; 0 = centre. Default 0 (multi-phone slots auto-stagger)."),
|
|
380
|
+
vOffset: numeric.optional().describe("Vertical position \u2212200\u2026200; 0 = centre. Default 0."),
|
|
381
|
+
material: z.enum(["real", "clay"]).optional().describe("Device body. Default real."),
|
|
382
|
+
colorway: z.enum(["orange", "blue", "silver", "custom"]).optional().describe('Body colour (material "real"). Default silver.'),
|
|
383
|
+
customColor: z.string().optional().describe('Hex body colour when colorway is "custom".'),
|
|
384
|
+
finish: numeric.optional().describe("0 = matte \u2026 1 = glossy. Default 0."),
|
|
385
|
+
clayTone: z.enum(["grey", "white", "charcoal", "custom"]).optional().describe('Clay tone (material "clay"). Default grey.'),
|
|
386
|
+
clayCustom: z.string().optional().describe('Hex clay colour when clayTone is "custom".'),
|
|
387
|
+
flatScreen: z.boolean().optional().describe("Render the screen flat (no curvature). Default false."),
|
|
388
|
+
glare: z.boolean().optional().describe("Screen glare. Default false."),
|
|
389
|
+
lighting: z.boolean().optional().describe("Scene lighting. Default true."),
|
|
390
|
+
reflections: z.boolean().optional().describe("Body reflections. Default false.")
|
|
391
|
+
}).strict();
|
|
392
|
+
var background = z.object({
|
|
393
|
+
mode: z.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel."),
|
|
394
|
+
gradientFrom: z.string().optional().describe("Gradient start (hex). Default #1b1b2e."),
|
|
395
|
+
gradientTo: z.string().optional().describe("Gradient end (hex). Default #0a0a14."),
|
|
396
|
+
gradientDir: z.enum(["vertical", "horizontal"]).optional().describe("Default vertical."),
|
|
397
|
+
panelColors: z.array(z.string().nullable()).max(10).optional().describe('Per-panel flat colours (mode "perPanel"), one entry per slot in order; null = default.'),
|
|
398
|
+
shadow: z.boolean().optional().describe("Drop shadow under the phones. Default true.")
|
|
399
|
+
}).strict();
|
|
400
|
+
var captionEntry = z.object({
|
|
401
|
+
fontId: z.enum(CAPTION_FONT_IDS).optional().describe("Bundled font. Default inter."),
|
|
402
|
+
sizePt: z.number().positive().max(200).optional().describe("Font size in iOS points (preset-independent). Default 44."),
|
|
403
|
+
color: z.string().optional().describe("Text colour (hex). Default FFFFFF."),
|
|
404
|
+
align: z.enum(["left", "center", "right"]).optional().describe("Default center."),
|
|
405
|
+
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}."),
|
|
406
|
+
maxWidth: z.number().min(0.05).max(1).optional().describe("Wrap width as a 0\u20131 fraction of the panel. Default 0.86."),
|
|
407
|
+
text: z.string().max(200).optional().describe("The headline \u2014 per-RENDER input, never stored in a look."),
|
|
408
|
+
subtitle: z.string().max(300).optional().describe("Optional subtitle under the headline \u2014 also per-render input.")
|
|
409
|
+
}).strict();
|
|
410
|
+
var style = z.object({
|
|
411
|
+
shotLook: shotLook.optional().describe("Device/camera styling, applied to every phone in the strip."),
|
|
412
|
+
background: background.optional(),
|
|
413
|
+
captions: z.array(captionEntry.nullable()).max(10).optional().describe("One entry PER PANEL in slot order; null = no caption on that panel.")
|
|
414
|
+
}).strict().optional().describe(
|
|
415
|
+
"Structured styling \u2014 the discoverable alternative to the opaque `look` param (call describe_look for the full field catalog + defaults). Wins over `look`/`useSavedLook`."
|
|
416
|
+
);
|
|
417
|
+
var project = z.string().min(1).optional().describe("The Storeframe project id to target (as returned by read_look). Omit = your most recently edited project.");
|
|
418
|
+
var version = z.number().int().min(1).optional().describe(
|
|
419
|
+
"Render a specific saved look version of the project (implies the saved look). Omit = the project's pinned version if one is pinned, else the latest saved look."
|
|
420
|
+
);
|
|
421
|
+
var createProjectFlag = z.boolean().optional().describe(
|
|
422
|
+
"Also save this render as an editable Storeframe Studio 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)."
|
|
423
|
+
);
|
|
424
|
+
var projectName = z.string().optional().describe('Name for the created project (when createProject makes a new one). Default "Storeframe render".');
|
|
425
|
+
var sourceDir = z.string().optional().describe(
|
|
426
|
+
'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.'
|
|
427
|
+
);
|
|
428
|
+
var renderStripShape = {
|
|
429
|
+
screenshots,
|
|
430
|
+
panelPresetId,
|
|
431
|
+
look,
|
|
432
|
+
style,
|
|
433
|
+
useSavedLook,
|
|
434
|
+
project,
|
|
435
|
+
version,
|
|
436
|
+
locale,
|
|
437
|
+
preview,
|
|
438
|
+
output,
|
|
439
|
+
createProject: createProjectFlag,
|
|
440
|
+
projectName,
|
|
441
|
+
sourceDir
|
|
442
|
+
};
|
|
443
|
+
var emitBundleShape = {
|
|
444
|
+
screenshots: screenshotsOptional,
|
|
445
|
+
panels: panelRefs,
|
|
446
|
+
panelPresetId,
|
|
447
|
+
look,
|
|
448
|
+
style,
|
|
449
|
+
useSavedLook,
|
|
450
|
+
project,
|
|
451
|
+
version,
|
|
452
|
+
bundleId: z.string().describe(
|
|
453
|
+
"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."
|
|
454
|
+
),
|
|
455
|
+
locale,
|
|
456
|
+
projectName: z.string().optional().describe('A name for the bundle (used for the zip filename + any share link). Default "storeframe".'),
|
|
457
|
+
share: z.boolean().optional().describe(
|
|
458
|
+
"Also create an unguessable, 14-day share link to a landing page (preview + bundle download + fastlane how-to), attributed to your account. Default false."
|
|
459
|
+
),
|
|
460
|
+
// create-project: save the rendered strip as an editable Studio project too (returns
|
|
461
|
+
// projectId + openUrl). Only on the RENDER path (screenshots) — the compose-only `panels`
|
|
462
|
+
// path never re-styles, so there's nothing to persist; it's rejected with a pointer to
|
|
463
|
+
// render_strip/save_project.
|
|
464
|
+
createProject: createProjectFlag,
|
|
465
|
+
output,
|
|
466
|
+
sourceDir
|
|
467
|
+
};
|
|
468
|
+
var saveProjectShape = {
|
|
469
|
+
screenshots: screenshotsForSave,
|
|
470
|
+
panelPresetId,
|
|
471
|
+
look,
|
|
472
|
+
style,
|
|
473
|
+
useSavedLook,
|
|
474
|
+
project,
|
|
475
|
+
version,
|
|
476
|
+
locale,
|
|
477
|
+
projectName,
|
|
478
|
+
sourceDir
|
|
479
|
+
};
|
|
480
|
+
var readLookShape = { project };
|
|
481
|
+
var saveLookShape = {
|
|
482
|
+
project,
|
|
483
|
+
look: z.record(z.string(), z.unknown()).describe(
|
|
484
|
+
"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."
|
|
485
|
+
),
|
|
486
|
+
sourceName: z.string().max(120).optional().describe("Where this styling came from (shown in the studio UI).")
|
|
487
|
+
};
|
|
488
|
+
var describeLookShape = {};
|
|
489
|
+
var DESCRIBE_LOOK_CATALOG = {
|
|
490
|
+
style: {
|
|
491
|
+
shotLook: {
|
|
492
|
+
description: "Device/camera styling, applied to every phone in the strip.",
|
|
493
|
+
defaults: DEFAULT_SHOT_LOOK,
|
|
494
|
+
fields: {
|
|
495
|
+
angle: "front | left | right",
|
|
496
|
+
cameraPos: "{ x, y, z } manual override, or null for the angle preset",
|
|
497
|
+
roll: "-45\u202645 (degrees, clock-hand tilt)",
|
|
498
|
+
phoneHeight: "20\u2026100 (% of panel height)",
|
|
499
|
+
hOffset: "-100\u2026100 (0 = centre; multi-phone slots auto-stagger when unset)",
|
|
500
|
+
vOffset: "-200\u2026200 (0 = centre)",
|
|
501
|
+
material: "real | clay",
|
|
502
|
+
colorway: "orange | blue | silver | custom (with customColor hex)",
|
|
503
|
+
finish: "0 (matte) \u2026 1 (glossy)",
|
|
504
|
+
clayTone: "grey | white | charcoal | custom (with clayCustom hex)",
|
|
505
|
+
flatScreen: "boolean",
|
|
506
|
+
glare: "boolean",
|
|
507
|
+
lighting: "boolean",
|
|
508
|
+
reflections: "boolean"
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
background: {
|
|
512
|
+
description: "The strip background.",
|
|
513
|
+
defaults: { mode: "gradient", gradientFrom: "#1b1b2e", gradientTo: "#0a0a14", gradientDir: "vertical", shadow: true },
|
|
514
|
+
fields: {
|
|
515
|
+
mode: "gradient | perPanel",
|
|
516
|
+
panelColors: 'per-panel hex colours (mode "perPanel"), one entry per slot in order'
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
captions: {
|
|
520
|
+
description: "One entry PER PANEL in slot order (null = no caption). Styling is part of the look; `text`/`subtitle` are per-RENDER input and are never stored (zero-custody of content).",
|
|
521
|
+
defaults: DEFAULT_CAPTION_STYLE,
|
|
522
|
+
fonts: CAPTION_FONT_IDS,
|
|
523
|
+
fields: {
|
|
524
|
+
sizePt: "font size in iOS points \u2014 preset-independent",
|
|
525
|
+
anchor: "{ x, y } normalized 0\u20131 position on the panel",
|
|
526
|
+
maxWidth: "wrap width as a 0\u20131 fraction of the panel width",
|
|
527
|
+
text: "the headline (per-render input)",
|
|
528
|
+
subtitle: "optional subtitle (per-render input)"
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
panelPresets: PANEL_PRESET_IDS.map((id) => ({ id, ...PANEL_DIMENSIONS[id], label: PANEL_LABELS[id] })),
|
|
533
|
+
notes: [
|
|
534
|
+
"Pass `style` to render_strip/emit_bundle for structured styling, or save a composed look with save_look and render with useSavedLook.",
|
|
535
|
+
"save_look bumps the look version; pinning a version is done in Storeframe Studio (the Look bar), and pinned projects render their pinned version by default."
|
|
536
|
+
]
|
|
537
|
+
};
|
|
538
|
+
var requestScreenshotUploadShape = {
|
|
539
|
+
count: z.number().int().min(1).max(10).describe("How many upload slots to mint (1\u201310, one per screenshot)."),
|
|
540
|
+
// Phase 2 (item 2, PINNED): each name is encoded into its slot's ref, so it reaches the
|
|
541
|
+
// saved project's frameName even if a later render_strip/emit_bundle call passes that slot's
|
|
542
|
+
// `{ ref }` bare (no `name`). Optional; a shorter/absent array just leaves those slots nameless.
|
|
543
|
+
names: z.array(z.string().max(200)).optional().describe(
|
|
544
|
+
'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.'
|
|
545
|
+
)
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// src/tools.ts
|
|
549
|
+
var SAVED_RECORD_VERSION = 2;
|
|
550
|
+
function textResult(payload) {
|
|
551
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
552
|
+
}
|
|
553
|
+
function errorResult(message) {
|
|
554
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
555
|
+
}
|
|
556
|
+
var AUTO_INLINE_THRESHOLD_BYTES = 200 * 1024;
|
|
557
|
+
function resolveOutputMode(requested, totalBytes) {
|
|
558
|
+
if (requested === "inline" || requested === "urls") return requested;
|
|
559
|
+
return totalBytes > AUTO_INLINE_THRESHOLD_BYTES ? "urls" : "inline";
|
|
560
|
+
}
|
|
561
|
+
async function resolveLook(deps2, args) {
|
|
562
|
+
if (args.style != null) return { look: null, style: args.style };
|
|
563
|
+
if (args.look != null) return { look: args.look };
|
|
564
|
+
if (args.useSavedLook || args.version != null) {
|
|
565
|
+
const project2 = await deps2.resolveProject(deps2.userId, args.project);
|
|
566
|
+
if (!project2) {
|
|
567
|
+
return { look: null, note: "no matching Storeframe project found for this account \u2014 used default styling." };
|
|
568
|
+
}
|
|
569
|
+
const saved = await deps2.readLook(project2.id);
|
|
570
|
+
if (!saved) {
|
|
571
|
+
return { look: null, note: `no saved look on project "${project2.name}" \u2014 used default styling.` };
|
|
572
|
+
}
|
|
573
|
+
if (args.version != null) {
|
|
574
|
+
if (saved.pinned?.version === args.version) return { look: saved.pinned.look };
|
|
575
|
+
if (saved.version === args.version) return { look: saved.look };
|
|
576
|
+
return {
|
|
577
|
+
look: saved.look,
|
|
578
|
+
note: `version ${args.version} not found on project "${project2.name}" (latest is v${saved.version}${saved.pinned ? `, pinned is v${saved.pinned.version}` : ""}) \u2014 rendered the latest.`
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (project2.pinnedVersion != null && saved.pinned) {
|
|
582
|
+
return { look: saved.pinned.look, note: `rendered the pinned look v${saved.pinned.version} (M4).` };
|
|
583
|
+
}
|
|
584
|
+
return { look: saved.look };
|
|
585
|
+
}
|
|
586
|
+
return { look: null };
|
|
587
|
+
}
|
|
588
|
+
function screenNamesFromRecord(record) {
|
|
589
|
+
const shots = record?.shots;
|
|
590
|
+
if (!Array.isArray(shots)) return [];
|
|
591
|
+
return shots.map((s) => s?.frameName).filter((n) => typeof n === "string" && n.length > 0);
|
|
592
|
+
}
|
|
593
|
+
async function persistProject(deps2, args, record) {
|
|
594
|
+
const names = screenNamesFromRecord(record);
|
|
595
|
+
const source = names.length === 0 ? void 0 : args.sourceDir ? { kind: "local-path", ref: { dir: args.sourceDir, names } } : { kind: "agent", ref: { names } };
|
|
596
|
+
const wrapped = {
|
|
597
|
+
record: SAVED_RECORD_VERSION,
|
|
598
|
+
project: record,
|
|
599
|
+
...source ? { source } : {}
|
|
600
|
+
};
|
|
601
|
+
const ctx = {
|
|
602
|
+
screenshots: args.screenshots,
|
|
603
|
+
look: args.look,
|
|
604
|
+
style: args.style,
|
|
605
|
+
panelPresetId: args.panelPresetId,
|
|
606
|
+
locale: args.locale,
|
|
607
|
+
sourceDir: args.sourceDir
|
|
608
|
+
};
|
|
609
|
+
try {
|
|
610
|
+
let projectId;
|
|
611
|
+
if (args.project) {
|
|
612
|
+
const project2 = await deps2.resolveProject(deps2.userId, args.project);
|
|
613
|
+
if (!project2) return { error: "no matching Storeframe project for this account." };
|
|
614
|
+
await deps2.updateProjectRecord(project2.id, wrapped, ctx);
|
|
615
|
+
projectId = project2.id;
|
|
616
|
+
} else {
|
|
617
|
+
const name = args.projectName?.trim() || "Storeframe render";
|
|
618
|
+
const created = await deps2.createProject(deps2.userId, name, wrapped, ctx);
|
|
619
|
+
projectId = created.id;
|
|
620
|
+
}
|
|
621
|
+
return { projectId, openUrl: `${deps2.studioOrigin}/?project=${projectId}` };
|
|
622
|
+
} catch (err) {
|
|
623
|
+
return { error: err.message };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async function handleRenderStrip(deps2, args) {
|
|
627
|
+
const { look: look2, style: style2, note } = await resolveLook(deps2, args);
|
|
628
|
+
const locale2 = args.locale?.trim() || "en-US";
|
|
629
|
+
if (args.createProject && args.preview) {
|
|
630
|
+
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).");
|
|
631
|
+
}
|
|
632
|
+
let screenshots2;
|
|
633
|
+
try {
|
|
634
|
+
screenshots2 = await resolveScreenshots(args.screenshots, deps2.userId, deps2.allowLocalScreenshots ?? false);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
return errorResult(err.message);
|
|
637
|
+
}
|
|
638
|
+
const result = await deps2.renderer.render({
|
|
639
|
+
screenshots: screenshots2,
|
|
640
|
+
panelPresetId: args.panelPresetId,
|
|
641
|
+
look: look2,
|
|
642
|
+
style: style2,
|
|
643
|
+
preview: args.preview,
|
|
644
|
+
locale: locale2,
|
|
645
|
+
buildRecord: args.createProject === true,
|
|
646
|
+
// Phase 5A: only compute names when we're building a record (saving a project).
|
|
647
|
+
...args.createProject ? { screenshotNames: resolveScreenshotNames(args.screenshots) } : {}
|
|
648
|
+
});
|
|
649
|
+
if (!result.ok || !result.panels) {
|
|
650
|
+
return errorResult(`render failed: ${result.error ?? "unknown error"}`);
|
|
651
|
+
}
|
|
652
|
+
let persisted;
|
|
653
|
+
let persistNote;
|
|
654
|
+
if (args.createProject) {
|
|
655
|
+
if (result.projectRecord == null) {
|
|
656
|
+
persistNote = "render succeeded but no project record was produced \u2014 not saved.";
|
|
657
|
+
} else {
|
|
658
|
+
const p = await persistProject(deps2, args, result.projectRecord);
|
|
659
|
+
if ("error" in p) persistNote = `not saved: ${p.error}`;
|
|
660
|
+
else persisted = p;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const totalBytes = result.panels.reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
|
|
664
|
+
const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, totalBytes);
|
|
665
|
+
const panels = outputMode === "urls" ? await uploadPanels(deps2.userId, result.panels) : result.panels;
|
|
666
|
+
return textResult({
|
|
667
|
+
ok: true,
|
|
668
|
+
count: result.count,
|
|
669
|
+
panelPresetId: result.panelPresetId,
|
|
670
|
+
panelWidth: result.panelWidth,
|
|
671
|
+
panelHeight: result.panelHeight,
|
|
672
|
+
renderMs: result.ms,
|
|
673
|
+
output: outputMode,
|
|
674
|
+
locale: locale2,
|
|
675
|
+
...note ? { note } : {},
|
|
676
|
+
...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
|
|
677
|
+
...persistNote ? { projectNote: persistNote } : {},
|
|
678
|
+
panels
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
async function uploadPanels(userId, panelsBase64) {
|
|
682
|
+
const dir = uploadDirFor(userId);
|
|
683
|
+
return Promise.all(
|
|
684
|
+
panelsBase64.map(
|
|
685
|
+
(b64, i) => storeAsset(dir, `panel-${String(i + 1).padStart(2, "0")}.png`, Buffer.from(b64, "base64"), "image/png")
|
|
686
|
+
)
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
async function handleEmitBundle(deps2, args) {
|
|
690
|
+
if (!isValidBundleId(args.bundleId)) {
|
|
691
|
+
return errorResult(`invalid bundleId "${args.bundleId}" \u2014 expected reverse-DNS, e.g. com.acme.app`);
|
|
692
|
+
}
|
|
693
|
+
const locale2 = args.locale?.trim() || "en-US";
|
|
694
|
+
const projectName2 = args.projectName?.trim() || "storeframe";
|
|
695
|
+
if (args.createProject && args.panels && args.panels.length > 0) {
|
|
696
|
+
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.");
|
|
697
|
+
}
|
|
698
|
+
let slots;
|
|
699
|
+
let panelLabel;
|
|
700
|
+
let previewPng;
|
|
701
|
+
let note;
|
|
702
|
+
let projectRecord;
|
|
703
|
+
if (args.panels && args.panels.length > 0) {
|
|
704
|
+
const dims = panelDimensionsFor(args.panelPresetId ?? "r69");
|
|
705
|
+
panelLabel = panelLabelFor(args.panelPresetId ?? "r69", dims.width, dims.height);
|
|
706
|
+
let bytesList;
|
|
707
|
+
try {
|
|
708
|
+
bytesList = await Promise.all(args.panels.map((p) => fetchUploadedRef(p.ref, deps2.userId)));
|
|
709
|
+
} catch (err) {
|
|
710
|
+
return errorResult(`failed to fetch panel ref: ${err.message}`);
|
|
711
|
+
}
|
|
712
|
+
slots = bytesList.map((bytes, i) => ({ displayPosition: i + 1, bytes: Buffer.from(bytes) }));
|
|
713
|
+
previewPng = bytesList[0] ? Buffer.from(bytesList[0]) : null;
|
|
714
|
+
} else {
|
|
715
|
+
if (!args.screenshots || args.screenshots.length === 0) {
|
|
716
|
+
return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
|
|
717
|
+
}
|
|
718
|
+
let screenshots2;
|
|
719
|
+
try {
|
|
720
|
+
screenshots2 = await resolveScreenshots(args.screenshots, deps2.userId, deps2.allowLocalScreenshots ?? false);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
return errorResult(err.message);
|
|
723
|
+
}
|
|
724
|
+
const resolved = await resolveLook(deps2, args);
|
|
725
|
+
note = resolved.note;
|
|
726
|
+
const result = await deps2.renderer.render({
|
|
727
|
+
screenshots: screenshots2,
|
|
728
|
+
panelPresetId: args.panelPresetId,
|
|
729
|
+
look: resolved.look,
|
|
730
|
+
style: resolved.style,
|
|
731
|
+
locale: locale2,
|
|
732
|
+
buildRecord: args.createProject === true,
|
|
733
|
+
...args.createProject && args.screenshots ? { screenshotNames: resolveScreenshotNames(args.screenshots) } : {}
|
|
734
|
+
});
|
|
735
|
+
if (!result.ok || !result.panels || !result.panelPresetId) {
|
|
736
|
+
return errorResult(`render failed: ${result.error ?? "unknown error"}`);
|
|
737
|
+
}
|
|
738
|
+
slots = result.panels.map((b64, i) => ({ displayPosition: i + 1, bytes: Buffer.from(b64, "base64") }));
|
|
739
|
+
panelLabel = panelLabelFor(result.panelPresetId, result.panelWidth ?? 0, result.panelHeight ?? 0);
|
|
740
|
+
previewPng = result.stripBase64 ? Buffer.from(result.stripBase64, "base64") : null;
|
|
741
|
+
projectRecord = result.projectRecord;
|
|
742
|
+
}
|
|
743
|
+
const zip = buildFastlaneBundleZip(slots, { bundleId: args.bundleId, locale: locale2, panelLabel });
|
|
744
|
+
const filename = bundleZipFilename(projectName2);
|
|
745
|
+
let shareLink;
|
|
746
|
+
if (args.share) {
|
|
747
|
+
if (!previewPng) return errorResult("cannot create share link: no preview image available");
|
|
748
|
+
try {
|
|
749
|
+
shareLink = await deps2.createShareLink({
|
|
750
|
+
userId: deps2.userId,
|
|
751
|
+
zip,
|
|
752
|
+
previewPng,
|
|
753
|
+
projectName: projectName2,
|
|
754
|
+
panelCount: slots.length,
|
|
755
|
+
bundleId: args.bundleId,
|
|
756
|
+
locale: locale2,
|
|
757
|
+
origin: deps2.studioOrigin
|
|
758
|
+
});
|
|
759
|
+
} catch (err) {
|
|
760
|
+
return errorResult(`could not create share link: ${err.message}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, zip.byteLength);
|
|
764
|
+
const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename, zip, "application/zip").then((a) => ({
|
|
765
|
+
zipUrl: a.url,
|
|
766
|
+
zipRef: a.ref
|
|
767
|
+
})) : { zipBase64: Buffer.from(zip).toString("base64") };
|
|
768
|
+
let persisted;
|
|
769
|
+
let persistNote;
|
|
770
|
+
if (args.createProject) {
|
|
771
|
+
if (projectRecord == null) {
|
|
772
|
+
persistNote = "bundle succeeded but no project record was produced \u2014 not saved.";
|
|
773
|
+
} else {
|
|
774
|
+
const p = await persistProject(deps2, args, projectRecord);
|
|
775
|
+
if ("error" in p) persistNote = `not saved: ${p.error}`;
|
|
776
|
+
else persisted = p;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return textResult({
|
|
780
|
+
ok: true,
|
|
781
|
+
filename,
|
|
782
|
+
bundleId: args.bundleId,
|
|
783
|
+
locale: locale2,
|
|
784
|
+
panelCount: slots.length,
|
|
785
|
+
output: outputMode,
|
|
786
|
+
...note ? { note } : {},
|
|
787
|
+
...shareLink ? { shareLink } : {},
|
|
788
|
+
...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
|
|
789
|
+
...persistNote ? { projectNote: persistNote } : {},
|
|
790
|
+
...zipOut
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
async function handleSaveProject(deps2, args) {
|
|
794
|
+
const { look: look2, style: style2 } = await resolveLook(deps2, args);
|
|
795
|
+
const locale2 = args.locale?.trim() || "en-US";
|
|
796
|
+
if (!Array.isArray(args.screenshots) || args.screenshots.length === 0) {
|
|
797
|
+
return errorResult("save_project needs a non-empty `screenshots` array (its slot shape + names define the project structure).");
|
|
798
|
+
}
|
|
799
|
+
const slotSizes = args.screenshots.map((s) => Array.isArray(s) ? s.length : 1);
|
|
800
|
+
if (slotSizes.some((n) => n < 1)) {
|
|
801
|
+
return errorResult("every `screenshots` slot must contain at least one screenshot entry.");
|
|
802
|
+
}
|
|
803
|
+
const screenshotNames = resolveScreenshotNames(args.screenshots).flat();
|
|
804
|
+
let record;
|
|
805
|
+
try {
|
|
806
|
+
record = await deps2.renderer.buildRecord({
|
|
807
|
+
slotSizes,
|
|
808
|
+
panelPresetId: args.panelPresetId,
|
|
809
|
+
look: look2,
|
|
810
|
+
style: style2,
|
|
811
|
+
locale: locale2,
|
|
812
|
+
screenshotNames
|
|
813
|
+
});
|
|
814
|
+
} catch (err) {
|
|
815
|
+
return errorResult(`could not build the project record: ${err.message}`);
|
|
816
|
+
}
|
|
817
|
+
if (record == null) {
|
|
818
|
+
return errorResult("no project record was produced \u2014 not saved.");
|
|
819
|
+
}
|
|
820
|
+
const p = await persistProject(deps2, args, record);
|
|
821
|
+
if ("error" in p) return errorResult(p.error);
|
|
822
|
+
return textResult({
|
|
823
|
+
ok: true,
|
|
824
|
+
projectId: p.projectId,
|
|
825
|
+
openUrl: p.openUrl,
|
|
826
|
+
count: slotSizes.length,
|
|
827
|
+
panelPresetId: args.panelPresetId ?? "r69",
|
|
828
|
+
locale: locale2,
|
|
829
|
+
message: `Saved as an editable Storeframe 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.`
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
async function handleReadLook(deps2, args) {
|
|
833
|
+
const project2 = await deps2.resolveProject(deps2.userId, args.project);
|
|
834
|
+
if (!project2) {
|
|
835
|
+
return textResult({
|
|
836
|
+
saved: false,
|
|
837
|
+
message: args.project ? "No such project on this account." : "No Storeframe project on this account yet \u2014 open Storeframe Studio and load a strip first."
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const saved = await deps2.readLook(project2.id);
|
|
841
|
+
if (!saved) {
|
|
842
|
+
return textResult({
|
|
843
|
+
saved: false,
|
|
844
|
+
project: { id: project2.id, name: project2.name },
|
|
845
|
+
message: `No saved look on project "${project2.name}". Style a strip in Storeframe Studio and use the Look bar \u2192 Save look, or compose one here with describe_look + save_look.`
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return textResult({
|
|
849
|
+
saved: true,
|
|
850
|
+
project: { id: project2.id, name: project2.name },
|
|
851
|
+
sourceName: saved.sourceName,
|
|
852
|
+
updatedAt: saved.updatedAt,
|
|
853
|
+
version: saved.version,
|
|
854
|
+
pinnedVersion: project2.pinnedVersion,
|
|
855
|
+
// null = renders follow the latest version
|
|
856
|
+
look: saved.look
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
async function handleSaveLook(deps2, args) {
|
|
860
|
+
const project2 = await deps2.resolveProject(deps2.userId, args.project);
|
|
861
|
+
if (!project2) {
|
|
862
|
+
return errorResult(
|
|
863
|
+
args.project ? "No such project on this account." : "No Storeframe project on this account yet \u2014 open Storeframe Studio and load a strip first."
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
const look2 = args.look;
|
|
867
|
+
if (!look2 || typeof look2 !== "object" || !Array.isArray(look2.shots)) {
|
|
868
|
+
return errorResult("`look` must be a look JSON with a `shots` array \u2014 see describe_look / read_look for the shape.");
|
|
869
|
+
}
|
|
870
|
+
if (JSON.stringify(args.look).length > 256 * 1024) {
|
|
871
|
+
return errorResult("`look` is too large \u2014 a look is styling only (no screenshots, no image bytes).");
|
|
872
|
+
}
|
|
873
|
+
const { version: version2 } = await deps2.saveLook(project2.id, args.look, args.sourceName?.trim() || void 0);
|
|
874
|
+
return textResult({
|
|
875
|
+
ok: true,
|
|
876
|
+
project: { id: project2.id, name: project2.name },
|
|
877
|
+
version: version2,
|
|
878
|
+
message: `Saved as version ${version2} on "${project2.name}" \u2014 render it with useSavedLook, or pin it in Storeframe Studio.`
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
function handleDescribeLook() {
|
|
882
|
+
return textResult(DESCRIBE_LOOK_CATALOG);
|
|
883
|
+
}
|
|
884
|
+
async function handleRequestScreenshotUpload(deps2, args) {
|
|
885
|
+
let slots;
|
|
886
|
+
try {
|
|
887
|
+
slots = await deps2.requestScreenshotUpload(deps2.userId, args.count, args.names);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
return errorResult(err.message);
|
|
890
|
+
}
|
|
891
|
+
return textResult({
|
|
892
|
+
ok: true,
|
|
893
|
+
slots,
|
|
894
|
+
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.'
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
var SERVER_INSTRUCTIONS = [
|
|
898
|
+
"Storeframe turns raw app screenshots into styled 3D device mockups laid out as an App Store",
|
|
899
|
+
"screenshot strip, and packages them into a fastlane deliver bundle. It renders images only \u2014",
|
|
900
|
+
"no App Store / Apple credential is ever involved, and nothing is submitted for review.",
|
|
901
|
+
"",
|
|
902
|
+
"## Workflow",
|
|
903
|
+
"1. request_screenshot_upload({ count, names }) \u2014 get signed slots, then PUT each raw PNG to its",
|
|
904
|
+
' uploadUrl (curl -T screenshot.png "<uploadUrl>"). NEVER inline full-resolution screenshots;',
|
|
905
|
+
" pass the returned { ref } instead. Only small images may be inlined as base64. Pass `names`",
|
|
906
|
+
" (original filenames, same order as count) and each ref already carries its name \u2014 see below.",
|
|
907
|
+
"2. render_strip({ screenshots, panelPresetId, style }) \u2014 composite onto a device, return",
|
|
908
|
+
" per-panel PNGs. Iterate with preview:true (fast ~25% render); drop it for final delivery.",
|
|
909
|
+
' Large results come back as output:"urls" (download them; bytes never transit the chat).',
|
|
910
|
+
"3. emit_bundle({ screenshots|panels, bundleId }) \u2014 a fastlane deliver-ready zip. Pass a prior",
|
|
911
|
+
' render_strip output:"urls" result as `panels` to package without re-rendering.',
|
|
912
|
+
"",
|
|
913
|
+
"## Big jobs: render, THEN bundle (do NOT render+zip in one call for 4+ panels)",
|
|
914
|
+
"A full-resolution render is seconds PER PANEL and runs one panel at a time, so a 5-panel",
|
|
915
|
+
"full-res render can take a couple of minutes \u2014 long enough that a single render+zip call",
|
|
916
|
+
"(emit_bundle with `screenshots`) can exceed the MCP client idle window and the client gives up",
|
|
917
|
+
'even though the server finished. For 4+ panels, SPLIT it: first render_strip({ output:"urls" })',
|
|
918
|
+
"(you get panel URLs back as soon as it completes \u2014 watchable, and re-running is cheap since the",
|
|
919
|
+
"panels are already uploaded), THEN emit_bundle({ panels: [...those refs] }) which only ZIPS",
|
|
920
|
+
"(near-instant, no re-render). Preview (preview:true) is always fast and safe for iteration \u2014",
|
|
921
|
+
"only the FINAL full-res pass is the slow one, so split that one.",
|
|
922
|
+
"",
|
|
923
|
+
"## Styling \u2014 you can fully art-direct the strip via the `style` object",
|
|
924
|
+
"Call describe_look for the exact field catalog + defaults; this is the map of what to reach for.",
|
|
925
|
+
"Three groups (all optional \u2014 omit for a sensible dark default):",
|
|
926
|
+
"- style.shotLook (the device, same for every phone): angle (front|left|right), material",
|
|
927
|
+
" (real|clay), colorway (orange|blue|silver|custom+customColor), finish (0 matte\u20261 glossy),",
|
|
928
|
+
" roll (-45\u202645\xB0 tilt), phoneHeight (20\u2026100 % of panel), hOffset/vOffset to nudge position,",
|
|
929
|
+
" glare, reflections, lighting, flatScreen.",
|
|
930
|
+
"- style.background (the strip): mode gradient|perPanel; gradientFrom/gradientTo (hex),",
|
|
931
|
+
" gradientDir vertical|horizontal, shadow; or panelColors[] (one hex per slot) for perPanel.",
|
|
932
|
+
"- style.captions[] (ONE entry per panel, in slot order; null = no caption): text (headline),",
|
|
933
|
+
" subtitle, fontId (inter|manrope|poppins|fraunces|space-grotesk), sizePt, color (hex),",
|
|
934
|
+
" align, anchor {x,y} (0\u20131 position), maxWidth (0\u20131 fraction of panel width).",
|
|
935
|
+
"panelPresetId picks the App Store size: r69 (6.9\u2033, default, 1290\xD72796) | r65 | r55 | ipad13 |",
|
|
936
|
+
"ipad129. Match it to the pixel size of your source screenshots (1320\xD72868 or 1290\xD72796 \u2192 r69).",
|
|
937
|
+
"",
|
|
938
|
+
"## Caption guardrails (do this or captions crowd \u2014 the #1 first-render mistake)",
|
|
939
|
+
'Defaults are fontId "inter", sizePt 44, anchor {x:0.5, y:0.06} (near the top), maxWidth 0.86.',
|
|
940
|
+
"At 44pt a headline longer than ~15\u201318 characters WRAPS to two lines, and with a subtitle the",
|
|
941
|
+
"two then collide at the top of the panel. To keep it clean:",
|
|
942
|
+
"- Keep headlines SHORT (aim \u2264 ~18 chars) so they stay one line; or drop sizePt to ~36\u201340 if",
|
|
943
|
+
" they must be longer; or raise maxWidth toward 1.0 to reduce wrapping.",
|
|
944
|
+
"- If you use a subtitle, give the block room: lower the phone a little (shotLook.phoneHeight",
|
|
945
|
+
" ~64\u201368) and/or set the caption anchor slightly higher (anchor.y ~0.04). Preview and LOOK at",
|
|
946
|
+
" the result before finalizing \u2014 a crowded headline+subtitle is the most common bad first pass.",
|
|
947
|
+
'- Punchy, benefit-led headlines read best ("Track every throw", "See your stats"), one idea',
|
|
948
|
+
" per panel. A consistent fontId + color across all panels makes the strip feel designed.",
|
|
949
|
+
"",
|
|
950
|
+
"## Two kinds of state, kept separate on purpose",
|
|
951
|
+
"- STYLING (the look \u2014 device, background, caption styling) is reusable: save_look persists it",
|
|
952
|
+
" per project (bumps a version), read_look returns it, and render_strip can re-apply it via",
|
|
953
|
+
" useSavedLook. describe_look is the authoritative field list.",
|
|
954
|
+
"- CAPTION TEXT (the words) is per-render input and comes from the CALLER (your repo), never the",
|
|
955
|
+
" server. No tool returns Studio-typed caption text \u2014 pass the copy yourself in",
|
|
956
|
+
" style.captions[].text. Use the optional top-level `locale` to label a render and route an",
|
|
957
|
+
" emit_bundle into fastlane/screenshots/<locale>/ (see the captions.<locale>.json convention).",
|
|
958
|
+
"",
|
|
959
|
+
"## Save a render as an editable project (offer this)",
|
|
960
|
+
"When the user might want to KEEP or later edit the strip (not just receive images), save it as",
|
|
961
|
+
"a real Storeframe Studio project they can open + refine: call save_project, or pass",
|
|
962
|
+
"`createProject: true` on render_strip / emit_bundle. Either returns a projectId + an openUrl \u2014",
|
|
963
|
+
"hand the openUrl back so they can open it (signed in as the SAME account). To iterate, thread",
|
|
964
|
+
"that projectId back as `project` on the next call and it UPDATES the same project in place",
|
|
965
|
+
"(instead of creating a new one each time).",
|
|
966
|
+
"save_project is FAST and never renders \u2014 it saves structure + look only, so prefer it (over the",
|
|
967
|
+
"createProject flag) when the user just wants the editable project and you do not also need the",
|
|
968
|
+
"rendered images in the same call; it is the reliable way to save many/large strips. The",
|
|
969
|
+
"`createProject: true` flag renders too, so it cannot be combined with preview:true \u2014 for the",
|
|
970
|
+
"flag path, save the full-res pass, not a preview.",
|
|
971
|
+
"",
|
|
972
|
+
"## IMPORTANT \u2014 the raw screenshots are the ONE thing the account cannot give back",
|
|
973
|
+
"The saved record is structure + look only: NO screenshot bytes are stored (zero-custody), so",
|
|
974
|
+
"when the user opens the project the web app prompts them to RE-LOAD the raw app screenshots.",
|
|
975
|
+
"Storeframe cannot supply those \u2014 only the user's original files can. So whenever you save a",
|
|
976
|
+
"project (createProject / save_project), you MUST make the strip re-loadable:",
|
|
977
|
+
"- PASS EACH SCREENSHOT'S ORIGINAL FILENAME \u2014 easiest via request_screenshot_upload({ names }),",
|
|
978
|
+
` which encodes it into that slot's ref so a later bare { "ref": "\u2026" } already carries it; or`,
|
|
979
|
+
' set it explicitly per entry, e.g. { "ref": "\u2026", "name": "03_statistics.png" }. That filename',
|
|
980
|
+
" becomes the saved shot's identity, so the user just re-opens their OWN screenshots folder and",
|
|
981
|
+
" Storeframe matches exactly the files you used BY NAME \u2014 even a folder of many screenshots",
|
|
982
|
+
' where only some were used. This is the clean path: no copying, no "which ones did you use?".',
|
|
983
|
+
" The web banner names the files it expects, drawn from these names.",
|
|
984
|
+
"- Use the RAW app screenshots (the images you were given), NOT the rendered mockup panels or the",
|
|
985
|
+
" exported fastlane zip \u2014 re-loading the export instead double-mockups the strip (a painful trap).",
|
|
986
|
+
"- If you generated/fetched the screenshots yourself, save them to disk and tell the user the",
|
|
987
|
+
" folder path (with the same filenames you passed as `name`/`names`).",
|
|
988
|
+
"Skip the names and the record falls back to positional filenames (screen-1.png, screen-2.png\u2026)",
|
|
989
|
+
"the user's folder can't match \u2014 the single worst create-project failure mode. Pass the real filenames.",
|
|
990
|
+
"",
|
|
991
|
+
"## Refine visually, then re-render headlessly",
|
|
992
|
+
"A human can refine the look in Storeframe Studio (https://storeframe-studio.vercel.app \u2014 sign",
|
|
993
|
+
"in as the SAME account) and Save look; read_look then returns exactly that, so an agent",
|
|
994
|
+
"reproduces the human-tuned design with zero manual step. Zero-custody: source screenshots are",
|
|
995
|
+
"ephemeral (one render, then discarded); only the styling look is persisted, never the pixels."
|
|
996
|
+
].join("\n");
|
|
997
|
+
function registerTools(server2, deps2) {
|
|
998
|
+
server2.registerTool(
|
|
999
|
+
"render_strip",
|
|
1000
|
+
{
|
|
1001
|
+
title: "Render App Store strip",
|
|
1002
|
+
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. 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.",
|
|
1003
|
+
inputSchema: renderStripShape
|
|
1004
|
+
},
|
|
1005
|
+
(args) => handleRenderStrip(deps2, args)
|
|
1006
|
+
);
|
|
1007
|
+
server2.registerTool(
|
|
1008
|
+
"emit_bundle",
|
|
1009
|
+
{
|
|
1010
|
+
title: "Emit fastlane bundle",
|
|
1011
|
+
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). 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.',
|
|
1012
|
+
inputSchema: emitBundleShape
|
|
1013
|
+
},
|
|
1014
|
+
(args) => handleEmitBundle(deps2, args)
|
|
1015
|
+
);
|
|
1016
|
+
server2.registerTool(
|
|
1017
|
+
"save_project",
|
|
1018
|
+
{
|
|
1019
|
+
title: "Save as editable project",
|
|
1020
|
+
description: "SAVE the strip as an editable Storeframe Studio 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.",
|
|
1021
|
+
inputSchema: saveProjectShape
|
|
1022
|
+
},
|
|
1023
|
+
(args) => handleSaveProject(deps2, args)
|
|
1024
|
+
);
|
|
1025
|
+
server2.registerTool(
|
|
1026
|
+
"read_look",
|
|
1027
|
+
{
|
|
1028
|
+
title: "Read saved look",
|
|
1029
|
+
description: "Return a project's saved Storeframe look (styling only \u2014 no screenshots, no credentials), with its version and pin state. Feed the returned look back into render_strip/emit_bundle via the `look` field, or just pass `useSavedLook: true`.",
|
|
1030
|
+
inputSchema: readLookShape
|
|
1031
|
+
},
|
|
1032
|
+
(args) => handleReadLook(deps2, args)
|
|
1033
|
+
);
|
|
1034
|
+
server2.registerTool(
|
|
1035
|
+
"save_look",
|
|
1036
|
+
{
|
|
1037
|
+
title: "Save look",
|
|
1038
|
+
description: "Persist a composed Storeframe 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.",
|
|
1039
|
+
inputSchema: saveLookShape
|
|
1040
|
+
},
|
|
1041
|
+
(args) => handleSaveLook(deps2, args)
|
|
1042
|
+
);
|
|
1043
|
+
server2.registerTool(
|
|
1044
|
+
"describe_look",
|
|
1045
|
+
{
|
|
1046
|
+
title: "Describe look fields",
|
|
1047
|
+
description: "The styling field catalog + defaults (device/camera, background, captions, panel presets) for authoring a `style` or a full look from scratch.",
|
|
1048
|
+
inputSchema: describeLookShape
|
|
1049
|
+
},
|
|
1050
|
+
() => handleDescribeLook()
|
|
1051
|
+
);
|
|
1052
|
+
server2.registerTool(
|
|
1053
|
+
"request_screenshot_upload",
|
|
1054
|
+
{
|
|
1055
|
+
title: "Request screenshot upload slots",
|
|
1056
|
+
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.",
|
|
1057
|
+
inputSchema: requestScreenshotUploadShape
|
|
1058
|
+
},
|
|
1059
|
+
(args) => handleRequestScreenshotUpload(deps2, args)
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// src/env.ts
|
|
1064
|
+
import { existsSync } from "node:fs";
|
|
1065
|
+
import { URL as NodeUrl, fileURLToPath } from "node:url";
|
|
1066
|
+
var rootEnvLocal = fileURLToPath(new NodeUrl("../../.env.local", import.meta.url));
|
|
1067
|
+
if (existsSync(rootEnvLocal)) {
|
|
1068
|
+
try {
|
|
1069
|
+
process.loadEnvFile(rootEnvLocal);
|
|
1070
|
+
} catch {
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
function studioOrigin() {
|
|
1074
|
+
return (process.env.STUDIO_ORIGIN || "https://storeframe-studio.vercel.app").replace(/\/+$/, "");
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/renderer.ts
|
|
1078
|
+
import { chromium } from "playwright";
|
|
1079
|
+
import { createServer as createServer2 } from "vite";
|
|
1080
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1081
|
+
|
|
1082
|
+
// ../mockup-engine/appstore.ts
|
|
1083
|
+
var DEFAULT_LOOK = {
|
|
1084
|
+
angle: "front",
|
|
1085
|
+
cameraPos: null,
|
|
1086
|
+
roll: "0",
|
|
1087
|
+
phoneHeight: "72",
|
|
1088
|
+
hOffset: "0",
|
|
1089
|
+
vOffset: "0",
|
|
1090
|
+
material: "real",
|
|
1091
|
+
colorway: "silver",
|
|
1092
|
+
customColor: "F5F5F5",
|
|
1093
|
+
finish: "0",
|
|
1094
|
+
clayTone: "grey",
|
|
1095
|
+
clayCustom: "B0B0B0",
|
|
1096
|
+
flatScreen: false,
|
|
1097
|
+
glare: false,
|
|
1098
|
+
lighting: true,
|
|
1099
|
+
reflections: false
|
|
1100
|
+
};
|
|
1101
|
+
var DEFAULT_CAPTION_STYLE2 = {
|
|
1102
|
+
fontId: "inter",
|
|
1103
|
+
sizePt: 44,
|
|
1104
|
+
color: "FFFFFF",
|
|
1105
|
+
align: "center",
|
|
1106
|
+
anchor: { x: 0.5, y: 0.06 },
|
|
1107
|
+
maxWidth: 0.86
|
|
1108
|
+
};
|
|
1109
|
+
var DEFAULT_LOCALE = "en-US";
|
|
1110
|
+
function normalizeCaptionStyles(raw) {
|
|
1111
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
1112
|
+
const out = {};
|
|
1113
|
+
for (const [panelId, entry] of Object.entries(raw)) {
|
|
1114
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1115
|
+
const e = entry;
|
|
1116
|
+
out[panelId] = {
|
|
1117
|
+
...DEFAULT_CAPTION_STYLE2,
|
|
1118
|
+
...e,
|
|
1119
|
+
anchor: { ...DEFAULT_CAPTION_STYLE2.anchor, ...e.anchor && typeof e.anchor === "object" ? e.anchor : {} }
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
return out;
|
|
1123
|
+
}
|
|
1124
|
+
function normalizeFlatCaptionMap(raw) {
|
|
1125
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
1126
|
+
const out = {};
|
|
1127
|
+
for (const [panelId, entry] of Object.entries(raw)) {
|
|
1128
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1129
|
+
const e = entry;
|
|
1130
|
+
if (typeof e.headline !== "string") continue;
|
|
1131
|
+
out[panelId] = { headline: e.headline, ...typeof e.subtitle === "string" ? { subtitle: e.subtitle } : {} };
|
|
1132
|
+
}
|
|
1133
|
+
return out;
|
|
1134
|
+
}
|
|
1135
|
+
function normalizeCaptionText(raw) {
|
|
1136
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
1137
|
+
const entries = Object.entries(raw);
|
|
1138
|
+
const looksFlat = entries.some(
|
|
1139
|
+
([, value]) => !!value && typeof value === "object" && typeof value.headline === "string"
|
|
1140
|
+
);
|
|
1141
|
+
if (looksFlat) return { [DEFAULT_LOCALE]: normalizeFlatCaptionMap(raw) };
|
|
1142
|
+
const out = {};
|
|
1143
|
+
for (const [locale2, inner] of entries) out[locale2] = normalizeFlatCaptionMap(inner);
|
|
1144
|
+
return out;
|
|
1145
|
+
}
|
|
1146
|
+
function shotsInPanelOrder(shots, panels) {
|
|
1147
|
+
return panels.flatMap((panel) => shots.filter((s) => s.panelId === panel.id));
|
|
1148
|
+
}
|
|
1149
|
+
var PROJECT_SCHEMA_VERSION = 2;
|
|
1150
|
+
function serializeProject(state) {
|
|
1151
|
+
const ordered = shotsInPanelOrder(state.shots, state.panels);
|
|
1152
|
+
const shots = ordered.map((s) => ({
|
|
1153
|
+
id: s.id,
|
|
1154
|
+
panelId: s.panelId,
|
|
1155
|
+
frameNodeId: s.frameNodeId,
|
|
1156
|
+
frameName: s.frameName,
|
|
1157
|
+
look: s.look,
|
|
1158
|
+
inputAnchor: s.inputAnchor,
|
|
1159
|
+
// v2 provenance — persisted so drift survives reload
|
|
1160
|
+
outputAnchor: s.outputAnchor
|
|
1161
|
+
}));
|
|
1162
|
+
return {
|
|
1163
|
+
schema: PROJECT_SCHEMA_VERSION,
|
|
1164
|
+
shots,
|
|
1165
|
+
panels: state.panels,
|
|
1166
|
+
selectedShotId: state.selectedShotId,
|
|
1167
|
+
// Caption TEXT is content (C1): emitted here on the project record, deliberately NOT
|
|
1168
|
+
// via extractStripStyle — so it can never leak into serializeLook's styling blob.
|
|
1169
|
+
// normalizeCaptionText upgrades the flat v1 input to the locale-nested stored shape
|
|
1170
|
+
// (flat → { [DEFAULT_LOCALE]: … }); a Phase-3 nested input passes through unchanged.
|
|
1171
|
+
captionText: normalizeCaptionText(state.captionText),
|
|
1172
|
+
...extractStripStyle(state)
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
function extractStripStyle(state) {
|
|
1176
|
+
return {
|
|
1177
|
+
panelPresetId: state.panelPresetId,
|
|
1178
|
+
bgMode: state.bgMode,
|
|
1179
|
+
gradientFrom: state.gradientFrom,
|
|
1180
|
+
gradientTo: state.gradientTo,
|
|
1181
|
+
gradientDir: state.gradientDir,
|
|
1182
|
+
panelColors: state.panelColors,
|
|
1183
|
+
shadow: state.shadow,
|
|
1184
|
+
captionStyles: state.captionStyles ?? {}
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// ../mockup-engine/stripFromSlots.ts
|
|
1189
|
+
var FALLBACK_GRADIENT_FROM = "#1b1b2e";
|
|
1190
|
+
var FALLBACK_GRADIENT_TO = "#0a0a14";
|
|
1191
|
+
function staggeredHOffset(k) {
|
|
1192
|
+
return String(Math.max(-100, Math.min(100, k * 35)));
|
|
1193
|
+
}
|
|
1194
|
+
var NUMERIC_STRING_FIELDS = ["roll", "phoneHeight", "hOffset", "vOffset", "finish"];
|
|
1195
|
+
function coerceShotLook(raw) {
|
|
1196
|
+
if (!raw || typeof raw !== "object") return {};
|
|
1197
|
+
const out = { ...raw };
|
|
1198
|
+
for (const f of NUMERIC_STRING_FIELDS) {
|
|
1199
|
+
if (typeof out[f] === "number") out[f] = String(out[f]);
|
|
1200
|
+
}
|
|
1201
|
+
return out;
|
|
1202
|
+
}
|
|
1203
|
+
function remapByOrdinal(record, sourceOrder, targetIds) {
|
|
1204
|
+
const out = {};
|
|
1205
|
+
if (!record) return out;
|
|
1206
|
+
sourceOrder.forEach((sourceId, i) => {
|
|
1207
|
+
const target = targetIds[i];
|
|
1208
|
+
if (target !== void 0 && record[sourceId] !== void 0) out[target] = record[sourceId];
|
|
1209
|
+
});
|
|
1210
|
+
return out;
|
|
1211
|
+
}
|
|
1212
|
+
function stripFromSlots(input) {
|
|
1213
|
+
const slotSizes = input.slotSizes.filter((n) => Number.isInteger(n) && n > 0);
|
|
1214
|
+
const panelIds = slotSizes.map((_, i) => `panel-${i + 1}`);
|
|
1215
|
+
const captionText = {};
|
|
1216
|
+
const opaque = !input.style && input.look && typeof input.look === "object" ? input.look : null;
|
|
1217
|
+
const style2 = input.style ?? null;
|
|
1218
|
+
const opaqueShots = opaque && Array.isArray(opaque.shots) ? opaque.shots : [];
|
|
1219
|
+
const styleShotLook = style2 ? coerceShotLook(style2.shotLook) : null;
|
|
1220
|
+
const shots = [];
|
|
1221
|
+
let globalIdx = 0;
|
|
1222
|
+
slotSizes.forEach((phoneCount, slotIdx) => {
|
|
1223
|
+
for (let k = 0; k < phoneCount; k++) {
|
|
1224
|
+
const base = styleShotLook ?? coerceShotLook(opaqueShots[globalIdx]?.look ?? opaqueShots[0]?.look ?? {});
|
|
1225
|
+
const hOffset = base.hOffset !== void 0 && base.hOffset !== null ? String(base.hOffset) : staggeredHOffset(k);
|
|
1226
|
+
shots.push({ panelId: panelIds[slotIdx], look: { ...DEFAULT_LOOK, ...base, hOffset } });
|
|
1227
|
+
globalIdx++;
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
let panelColors = {};
|
|
1231
|
+
let captionStyles = {};
|
|
1232
|
+
let bgMode = "gradient";
|
|
1233
|
+
let gradientFrom = FALLBACK_GRADIENT_FROM;
|
|
1234
|
+
let gradientTo = FALLBACK_GRADIENT_TO;
|
|
1235
|
+
let gradientDir = "vertical";
|
|
1236
|
+
let shadow = true;
|
|
1237
|
+
if (style2) {
|
|
1238
|
+
const bg = style2.background ?? {};
|
|
1239
|
+
bgMode = bg.mode === "perPanel" ? "perPanel" : "gradient";
|
|
1240
|
+
if (typeof bg.gradientFrom === "string") gradientFrom = bg.gradientFrom;
|
|
1241
|
+
if (typeof bg.gradientTo === "string") gradientTo = bg.gradientTo;
|
|
1242
|
+
if (bg.gradientDir === "horizontal") gradientDir = "horizontal";
|
|
1243
|
+
if (bg.shadow === false) shadow = false;
|
|
1244
|
+
if (Array.isArray(bg.panelColors)) {
|
|
1245
|
+
bg.panelColors.forEach((color, i) => {
|
|
1246
|
+
if (typeof color === "string" && color && panelIds[i] !== void 0) panelColors[panelIds[i]] = color;
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
if (Array.isArray(style2.captions)) {
|
|
1250
|
+
const rawStyles = {};
|
|
1251
|
+
style2.captions.forEach((entry, i) => {
|
|
1252
|
+
if (!entry || typeof entry !== "object" || panelIds[i] === void 0) return;
|
|
1253
|
+
const { text, subtitle, sizePt, maxWidth, ...rest } = entry;
|
|
1254
|
+
rawStyles[panelIds[i]] = {
|
|
1255
|
+
...rest,
|
|
1256
|
+
...sizePt !== void 0 ? { sizePt: Number(sizePt) } : {},
|
|
1257
|
+
...maxWidth !== void 0 ? { maxWidth: Number(maxWidth) } : {}
|
|
1258
|
+
};
|
|
1259
|
+
if (typeof text === "string" && text.trim()) {
|
|
1260
|
+
captionText[panelIds[i]] = {
|
|
1261
|
+
headline: text,
|
|
1262
|
+
...typeof subtitle === "string" && subtitle ? { subtitle } : {}
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
captionStyles = normalizeCaptionStyles(rawStyles);
|
|
1267
|
+
}
|
|
1268
|
+
} else if (opaque) {
|
|
1269
|
+
bgMode = opaque.bgMode === "perPanel" ? "perPanel" : "gradient";
|
|
1270
|
+
if (typeof opaque.gradientFrom === "string") gradientFrom = opaque.gradientFrom;
|
|
1271
|
+
if (typeof opaque.gradientTo === "string") gradientTo = opaque.gradientTo;
|
|
1272
|
+
if (opaque.gradientDir === "horizontal") gradientDir = "horizontal";
|
|
1273
|
+
if (opaque.shadow === false) shadow = false;
|
|
1274
|
+
const sourceOrder = [];
|
|
1275
|
+
for (const s of opaqueShots) {
|
|
1276
|
+
const id = typeof s?.panelId === "string" ? s.panelId : null;
|
|
1277
|
+
if (id && !sourceOrder.includes(id)) sourceOrder.push(id);
|
|
1278
|
+
}
|
|
1279
|
+
panelColors = remapByOrdinal(opaque.panelColors, sourceOrder, panelIds);
|
|
1280
|
+
captionStyles = remapByOrdinal(normalizeCaptionStyles(opaque.captionStyles), sourceOrder, panelIds);
|
|
1281
|
+
}
|
|
1282
|
+
const panelPresetId2 = input.panelPresetId ?? (typeof opaque?.panelPresetId === "string" ? opaque.panelPresetId : void 0) ?? "r69";
|
|
1283
|
+
return {
|
|
1284
|
+
look: { panelPresetId: panelPresetId2, bgMode, gradientFrom, gradientTo, gradientDir, panelColors, shadow, captionStyles, shots },
|
|
1285
|
+
panelIds,
|
|
1286
|
+
captionText
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// ../mockup-engine/recordFromStrip.ts
|
|
1291
|
+
function normalizeSourceName(raw) {
|
|
1292
|
+
const name = raw?.trim();
|
|
1293
|
+
if (!name) return null;
|
|
1294
|
+
return /\.(png|jpe?g|webp)$/i.test(name) ? name : `${name}.png`;
|
|
1295
|
+
}
|
|
1296
|
+
function frameNamesFor(shots, shotNames) {
|
|
1297
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1298
|
+
return shots.map((_, i) => {
|
|
1299
|
+
const real = normalizeSourceName(shotNames?.[i]);
|
|
1300
|
+
if (!real) return `screen-${i + 1}.png`;
|
|
1301
|
+
const count = seen.get(real) ?? 0;
|
|
1302
|
+
seen.set(real, count + 1);
|
|
1303
|
+
if (count === 0) return real;
|
|
1304
|
+
const dot = real.lastIndexOf(".");
|
|
1305
|
+
return `${real.slice(0, dot)}-${count + 1}${real.slice(dot)}`;
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
function recordFromStrip(input) {
|
|
1309
|
+
const { look: look2, panelIds, captionText, locale: locale2, shotNames } = input;
|
|
1310
|
+
const panels = panelIds.map((id) => ({ id }));
|
|
1311
|
+
const frameNames = frameNamesFor(look2.shots, shotNames);
|
|
1312
|
+
const shots = look2.shots.map((s, i) => ({
|
|
1313
|
+
id: `shot-${i + 1}`,
|
|
1314
|
+
panelId: s.panelId,
|
|
1315
|
+
frameNodeId: frameNames[i],
|
|
1316
|
+
frameName: frameNames[i],
|
|
1317
|
+
bytes: null,
|
|
1318
|
+
look: s.look,
|
|
1319
|
+
thumb: null,
|
|
1320
|
+
thumbStale: true
|
|
1321
|
+
}));
|
|
1322
|
+
return serializeProject({
|
|
1323
|
+
shots,
|
|
1324
|
+
panels,
|
|
1325
|
+
selectedShotId: shots[0]?.id ?? null,
|
|
1326
|
+
panelPresetId: look2.panelPresetId,
|
|
1327
|
+
bgMode: look2.bgMode,
|
|
1328
|
+
gradientFrom: look2.gradientFrom,
|
|
1329
|
+
gradientTo: look2.gradientTo,
|
|
1330
|
+
gradientDir: look2.gradientDir,
|
|
1331
|
+
panelColors: look2.panelColors,
|
|
1332
|
+
shadow: look2.shadow,
|
|
1333
|
+
captionStyles: look2.captionStyles,
|
|
1334
|
+
// Nest this render's flat caption text under its locale (default en-US) so the stored blob
|
|
1335
|
+
// is locale-correct; serializeProject's normalizeCaptionText passes a nested map through.
|
|
1336
|
+
captionText: captionText && Object.keys(captionText).length > 0 ? { [locale2 || DEFAULT_LOCALE]: captionText } : {}
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// src/staticServer.ts
|
|
1341
|
+
import { createServer } from "node:http";
|
|
1342
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
1343
|
+
import { extname, join, relative, isAbsolute } from "node:path";
|
|
1344
|
+
var MIME = {
|
|
1345
|
+
".html": "text/html; charset=utf-8",
|
|
1346
|
+
".js": "text/javascript; charset=utf-8",
|
|
1347
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
1348
|
+
".css": "text/css; charset=utf-8",
|
|
1349
|
+
".json": "application/json; charset=utf-8",
|
|
1350
|
+
".glb": "model/gltf-binary",
|
|
1351
|
+
".woff2": "font/woff2",
|
|
1352
|
+
".woff": "font/woff",
|
|
1353
|
+
".png": "image/png",
|
|
1354
|
+
".svg": "image/svg+xml",
|
|
1355
|
+
".wasm": "application/wasm"
|
|
1356
|
+
};
|
|
1357
|
+
async function startStaticServer(rootDir) {
|
|
1358
|
+
const server2 = createServer((req, res) => {
|
|
1359
|
+
const reqPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
1360
|
+
const rel = reqPath === "/" ? "index.html" : reqPath.replace(/^\/+/, "");
|
|
1361
|
+
const filePath = join(rootDir, rel);
|
|
1362
|
+
const within = relative(rootDir, filePath);
|
|
1363
|
+
if (within.startsWith("..") || isAbsolute(within)) {
|
|
1364
|
+
res.statusCode = 403;
|
|
1365
|
+
res.end("forbidden");
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
readFile2(filePath).then(
|
|
1369
|
+
(body) => {
|
|
1370
|
+
res.statusCode = 200;
|
|
1371
|
+
res.setHeader("Content-Type", MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream");
|
|
1372
|
+
res.end(body);
|
|
1373
|
+
},
|
|
1374
|
+
() => {
|
|
1375
|
+
res.statusCode = 404;
|
|
1376
|
+
res.end("not found");
|
|
1377
|
+
}
|
|
1378
|
+
);
|
|
1379
|
+
});
|
|
1380
|
+
await new Promise((resolve, reject) => {
|
|
1381
|
+
server2.once("error", reject);
|
|
1382
|
+
server2.listen(0, "127.0.0.1", resolve);
|
|
1383
|
+
});
|
|
1384
|
+
const addr = server2.address();
|
|
1385
|
+
return {
|
|
1386
|
+
url: `http://127.0.0.1:${addr.port}/`,
|
|
1387
|
+
close: () => new Promise((resolve) => {
|
|
1388
|
+
server2.close(() => resolve());
|
|
1389
|
+
})
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/renderer.ts
|
|
1394
|
+
var StripRenderer = class {
|
|
1395
|
+
serveMode;
|
|
1396
|
+
vite = null;
|
|
1397
|
+
staticServer = null;
|
|
1398
|
+
browser = null;
|
|
1399
|
+
page = null;
|
|
1400
|
+
booting = null;
|
|
1401
|
+
sawContextLost = false;
|
|
1402
|
+
// Default 'vite' so `new StripRenderer()` (server.ts) keeps the hosted path byte-for-byte.
|
|
1403
|
+
constructor(options = {}) {
|
|
1404
|
+
this.serveMode = options.serve ?? "vite";
|
|
1405
|
+
}
|
|
1406
|
+
boot() {
|
|
1407
|
+
if (!this.booting) {
|
|
1408
|
+
this.booting = this.doBoot().catch((err) => {
|
|
1409
|
+
this.booting = null;
|
|
1410
|
+
throw err;
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
return this.booting;
|
|
1414
|
+
}
|
|
1415
|
+
async doBoot() {
|
|
1416
|
+
const url = await this.startHarnessServer();
|
|
1417
|
+
this.browser = await chromium.launch({ headless: true });
|
|
1418
|
+
const page = await this.browser.newPage();
|
|
1419
|
+
page.on("console", (m) => {
|
|
1420
|
+
if (/CONTEXT_LOST|Context Lost/i.test(m.text())) this.sawContextLost = true;
|
|
1421
|
+
});
|
|
1422
|
+
await page.goto(url, { waitUntil: "load" });
|
|
1423
|
+
await page.waitForFunction(() => window.__RENDER_READY === true, { timeout: 9e4 });
|
|
1424
|
+
this.page = page;
|
|
1425
|
+
}
|
|
1426
|
+
// The one line that forks between hosted and local. Everything downstream (browser launch,
|
|
1427
|
+
// the __RENDER_READY handshake, window.renderStrip, boot-retry, CONTEXT_LOST) is identical
|
|
1428
|
+
// — the page is the same harness, only its bytes come from a different server.
|
|
1429
|
+
async startHarnessServer() {
|
|
1430
|
+
if (this.serveMode === "static") {
|
|
1431
|
+
const rootDir = fileURLToPath2(new URL("../harness-dist", import.meta.url));
|
|
1432
|
+
this.staticServer = await startStaticServer(rootDir);
|
|
1433
|
+
return this.staticServer.url;
|
|
1434
|
+
}
|
|
1435
|
+
const configFile = fileURLToPath2(new URL("../vite.config.mjs", import.meta.url));
|
|
1436
|
+
this.vite = await createServer2({ configFile });
|
|
1437
|
+
await this.vite.listen();
|
|
1438
|
+
const addr = this.vite.httpServer?.address();
|
|
1439
|
+
const port = this.vite.config.server.port || (addr && typeof addr === "object" ? addr.port : 0);
|
|
1440
|
+
return `http://localhost:${port}/`;
|
|
1441
|
+
}
|
|
1442
|
+
// buildRecord: the SAME styling seam the render uses (stripFromSlots → recordFromStrip), run
|
|
1443
|
+
// in Node with NO browser. Both are pure, browser-free @engine functions and a record carries
|
|
1444
|
+
// no bytes, so save_project persists an editable project without a multi-second Chromium render
|
|
1445
|
+
// — the fix for the Fly proxy timeout on that path. Kept byte-for-byte aligned with the
|
|
1446
|
+
// harness's buildRecord branch (render.js): same stripFromSlots call, same recordFromStrip
|
|
1447
|
+
// args — so a save_project record matches a render({buildRecord:true}) record exactly.
|
|
1448
|
+
async buildRecord(input) {
|
|
1449
|
+
const { look: look2, panelIds, captionText } = stripFromSlots({
|
|
1450
|
+
slotSizes: input.slotSizes,
|
|
1451
|
+
panelPresetId: input.panelPresetId,
|
|
1452
|
+
look: input.look ?? void 0,
|
|
1453
|
+
style: input.style ?? void 0
|
|
1454
|
+
});
|
|
1455
|
+
return recordFromStrip({
|
|
1456
|
+
look: look2,
|
|
1457
|
+
panelIds,
|
|
1458
|
+
captionText,
|
|
1459
|
+
locale: input.locale,
|
|
1460
|
+
shotNames: input.screenshotNames
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
async render(input) {
|
|
1464
|
+
await this.boot();
|
|
1465
|
+
if (!this.page) return { ok: false, error: "renderer failed to boot" };
|
|
1466
|
+
const result = await this.page.evaluate((inp) => window.renderStrip(inp), input);
|
|
1467
|
+
if (this.sawContextLost) {
|
|
1468
|
+
console.warn("[renderer] CONTEXT_LOST observed since boot");
|
|
1469
|
+
}
|
|
1470
|
+
return result;
|
|
1471
|
+
}
|
|
1472
|
+
async close() {
|
|
1473
|
+
await this.page?.close().catch(() => {
|
|
1474
|
+
});
|
|
1475
|
+
await this.browser?.close().catch(() => {
|
|
1476
|
+
});
|
|
1477
|
+
await this.vite?.close().catch(() => {
|
|
1478
|
+
});
|
|
1479
|
+
await this.staticServer?.close().catch(() => {
|
|
1480
|
+
});
|
|
1481
|
+
this.page = null;
|
|
1482
|
+
this.browser = null;
|
|
1483
|
+
this.vite = null;
|
|
1484
|
+
this.staticServer = null;
|
|
1485
|
+
this.booting = null;
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
// src/hostedBridge.ts
|
|
1490
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
1491
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
1492
|
+
import { dirname } from "node:path";
|
|
1493
|
+
var DEFAULT_HOSTED_MCP_URL = "https://storeframe-mcp.fly.dev/mcp";
|
|
1494
|
+
function hostedMcpUrl() {
|
|
1495
|
+
return (process.env.STOREFRAME_MCP_URL || DEFAULT_HOSTED_MCP_URL).replace(/\/+$/, "");
|
|
1496
|
+
}
|
|
1497
|
+
async function callRemoteTool(client, name, args) {
|
|
1498
|
+
const result = await client.callTool({ name, arguments: args });
|
|
1499
|
+
const text = result.content?.find((c) => c.type === "text")?.text;
|
|
1500
|
+
if (result.isError) throw new Error(text || `${name} failed`);
|
|
1501
|
+
if (!text) throw new Error(`${name} returned no content`);
|
|
1502
|
+
return JSON.parse(text);
|
|
1503
|
+
}
|
|
1504
|
+
function localSourceDir(screenshots2) {
|
|
1505
|
+
if (!Array.isArray(screenshots2)) return void 0;
|
|
1506
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
1507
|
+
for (const slot2 of screenshots2) {
|
|
1508
|
+
if (!Array.isArray(slot2)) continue;
|
|
1509
|
+
for (const entry of slot2) {
|
|
1510
|
+
if (entry && typeof entry === "object" && "path" in entry && typeof entry.path === "string") {
|
|
1511
|
+
dirs.add(dirname(entry.path));
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return dirs.size === 1 ? [...dirs][0] : void 0;
|
|
1516
|
+
}
|
|
1517
|
+
async function callSaveProject(client, ctx, extra) {
|
|
1518
|
+
const res = await callRemoteTool(client, "save_project", {
|
|
1519
|
+
screenshots: ctx?.screenshots,
|
|
1520
|
+
panelPresetId: ctx?.panelPresetId,
|
|
1521
|
+
look: ctx?.look,
|
|
1522
|
+
style: ctx?.style,
|
|
1523
|
+
locale: ctx?.locale,
|
|
1524
|
+
sourceDir: ctx?.sourceDir ?? localSourceDir(ctx?.screenshots),
|
|
1525
|
+
...extra
|
|
1526
|
+
});
|
|
1527
|
+
return { id: res.projectId };
|
|
1528
|
+
}
|
|
1529
|
+
async function connectHostedBridge(token2) {
|
|
1530
|
+
const client = new Client({ name: "storeframe-mcp-local-bridge", version: "0.1.0" }, { capabilities: {} });
|
|
1531
|
+
const transport = new StreamableHTTPClientTransport(new URL(hostedMcpUrl()), {
|
|
1532
|
+
requestInit: { headers: { Authorization: `Bearer ${token2}` } }
|
|
1533
|
+
});
|
|
1534
|
+
await client.connect(transport);
|
|
1535
|
+
return {
|
|
1536
|
+
async resolveProject(_userId, projectId) {
|
|
1537
|
+
const res = await callRemoteTool(client, "read_look", projectId ? { project: projectId } : {});
|
|
1538
|
+
const project2 = res.project;
|
|
1539
|
+
if (!project2?.id) return null;
|
|
1540
|
+
return {
|
|
1541
|
+
id: project2.id,
|
|
1542
|
+
name: typeof project2.name === "string" ? project2.name : "My project",
|
|
1543
|
+
pinnedVersion: typeof res.pinnedVersion === "number" ? res.pinnedVersion : null
|
|
1544
|
+
};
|
|
1545
|
+
},
|
|
1546
|
+
async readLook(projectId) {
|
|
1547
|
+
const res = await callRemoteTool(client, "read_look", { project: projectId });
|
|
1548
|
+
if (!res.saved) return null;
|
|
1549
|
+
return {
|
|
1550
|
+
look: res.look,
|
|
1551
|
+
sourceName: typeof res.sourceName === "string" ? res.sourceName : null,
|
|
1552
|
+
updatedAt: typeof res.updatedAt === "string" ? res.updatedAt : null,
|
|
1553
|
+
version: typeof res.version === "number" ? res.version : 0,
|
|
1554
|
+
pinned: null
|
|
1555
|
+
// known gap — see the module comment above.
|
|
1556
|
+
};
|
|
1557
|
+
},
|
|
1558
|
+
async saveLook(projectId, look2, sourceName) {
|
|
1559
|
+
const res = await callRemoteTool(client, "save_look", {
|
|
1560
|
+
project: projectId,
|
|
1561
|
+
look: look2,
|
|
1562
|
+
...sourceName ? { sourceName } : {}
|
|
1563
|
+
});
|
|
1564
|
+
return { version: res.version };
|
|
1565
|
+
},
|
|
1566
|
+
async createProject(_userId, projectName2, _record, ctx) {
|
|
1567
|
+
return callSaveProject(client, ctx, { projectName: projectName2 });
|
|
1568
|
+
},
|
|
1569
|
+
async updateProjectRecord(projectId, _record, ctx) {
|
|
1570
|
+
await callSaveProject(client, ctx, { project: projectId });
|
|
1571
|
+
},
|
|
1572
|
+
async close() {
|
|
1573
|
+
await client.close();
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// src/localDeps.ts
|
|
1579
|
+
var SIGN_IN = "not available in local (free) mode \u2014 connect the hosted Storeframe MCP and sign in for saved looks, editable projects, and share links.";
|
|
1580
|
+
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.)`;
|
|
1581
|
+
function localToolDeps(token2) {
|
|
1582
|
+
const base = {
|
|
1583
|
+
renderer: new StripRenderer({ serve: "static" }),
|
|
1584
|
+
requestScreenshotUpload: async () => {
|
|
1585
|
+
throw new Error(
|
|
1586
|
+
'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.'
|
|
1587
|
+
);
|
|
1588
|
+
},
|
|
1589
|
+
allowLocalScreenshots: true,
|
|
1590
|
+
forceOutput: "inline",
|
|
1591
|
+
// no Supabase storage locally to upload an `output: "urls"` result to
|
|
1592
|
+
studioOrigin: studioOrigin()
|
|
1593
|
+
};
|
|
1594
|
+
if (!token2) {
|
|
1595
|
+
return {
|
|
1596
|
+
...base,
|
|
1597
|
+
resolveProject: async () => null,
|
|
1598
|
+
readLook: async () => null,
|
|
1599
|
+
saveLook: async () => {
|
|
1600
|
+
throw new Error(`Saving a look is ${SIGN_IN}`);
|
|
1601
|
+
},
|
|
1602
|
+
createProject: async () => {
|
|
1603
|
+
throw new Error(`Saving an editable project is ${SIGN_IN}`);
|
|
1604
|
+
},
|
|
1605
|
+
updateProjectRecord: async () => {
|
|
1606
|
+
throw new Error(`Updating a saved project is ${SIGN_IN}`);
|
|
1607
|
+
},
|
|
1608
|
+
createShareLink: async () => {
|
|
1609
|
+
throw new Error(`Creating a share link is ${SIGN_IN}`);
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
const hostedToken = token2;
|
|
1614
|
+
let bridgePromise = null;
|
|
1615
|
+
function bridge() {
|
|
1616
|
+
if (!bridgePromise) {
|
|
1617
|
+
bridgePromise = connectHostedBridge(hostedToken).catch((err) => {
|
|
1618
|
+
bridgePromise = null;
|
|
1619
|
+
throw new Error(
|
|
1620
|
+
`could not reach the hosted Storeframe MCP (${err.message}) \u2014 check STOREFRAME_TOKEN / network, or drop the token to use local-only mode.`
|
|
1621
|
+
);
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
return bridgePromise;
|
|
1625
|
+
}
|
|
1626
|
+
return {
|
|
1627
|
+
...base,
|
|
1628
|
+
resolveProject: async (userId, projectId) => (await bridge()).resolveProject(userId, projectId),
|
|
1629
|
+
readLook: async (projectId) => (await bridge()).readLook(projectId),
|
|
1630
|
+
saveLook: async (projectId, look2, sourceName) => (await bridge()).saveLook(projectId, look2, sourceName),
|
|
1631
|
+
createProject: async (userId, name, record, ctx) => (await bridge()).createProject(userId, name, record, ctx),
|
|
1632
|
+
updateProjectRecord: async (projectId, record, ctx) => (await bridge()).updateProjectRecord(projectId, record, ctx),
|
|
1633
|
+
createShareLink: async () => {
|
|
1634
|
+
throw new Error(SHARE_LINK_NOT_BRIDGED);
|
|
1635
|
+
},
|
|
1636
|
+
closeBridge: async () => {
|
|
1637
|
+
if (bridgePromise) await (await bridgePromise).close().catch(() => {
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// src/local.ts
|
|
1644
|
+
function tokenFromArgv(argv) {
|
|
1645
|
+
const eq = argv.find((a) => a.startsWith("--token="));
|
|
1646
|
+
if (eq) return eq.slice("--token=".length);
|
|
1647
|
+
const idx = argv.indexOf("--token");
|
|
1648
|
+
return idx !== -1 ? argv[idx + 1] : void 0;
|
|
1649
|
+
}
|
|
1650
|
+
var token = tokenFromArgv(process.argv.slice(2)) || process.env.STOREFRAME_TOKEN || void 0;
|
|
1651
|
+
var deps = localToolDeps(token);
|
|
1652
|
+
var localInstructions = token ? `${SERVER_INSTRUCTIONS}
|
|
1653
|
+
|
|
1654
|
+
## Local mode (this connection)
|
|
1655
|
+
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 Storeframe account); request_screenshot_upload still no-ops \u2014 pass local files as { "path": "..." } screenshot entries instead.` : `${SERVER_INSTRUCTIONS}
|
|
1656
|
+
|
|
1657
|
+
## Local mode (this connection)
|
|
1658
|
+
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") \u2014 pass local files as { "path": "..." } screenshot entries and render_strip / emit_bundle work fully.`;
|
|
1659
|
+
var server = new McpServer(
|
|
1660
|
+
{ name: "storeframe-mcp-local", version: "0.1.0" },
|
|
1661
|
+
{ instructions: localInstructions }
|
|
1662
|
+
);
|
|
1663
|
+
registerTools(server, { userId: "local", ...deps });
|
|
1664
|
+
async function shutdown() {
|
|
1665
|
+
await deps.renderer.close();
|
|
1666
|
+
await deps.closeBridge?.();
|
|
1667
|
+
process.exit(0);
|
|
1668
|
+
}
|
|
1669
|
+
process.on("SIGINT", () => void shutdown());
|
|
1670
|
+
process.on("SIGTERM", () => void shutdown());
|
|
1671
|
+
await server.connect(new StdioServerTransport());
|
|
1672
|
+
console.error(
|
|
1673
|
+
`[storeframe-mcp-local] connected over stdio \u2014 renders run on this machine.${token ? " (hosted bridge configured)" : ""}`
|
|
1674
|
+
);
|