storeframe-mcp 0.1.0 → 0.2.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 +72 -19
- package/dist/local.js +936 -177
- package/harness-dist/assets/{index-BppCs90_.js → index-iutW2Tnu.js} +171 -171
- package/harness-dist/index.html +1 -1
- package/package.json +1 -1
package/dist/local.js
CHANGED
|
@@ -64,6 +64,67 @@ metadata, and never submits. Feel free to inspect it \u2014 it's five lines.
|
|
|
64
64
|
|
|
65
65
|
## Troubleshooting
|
|
66
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
|
+
var MULTI_LOCALE_README_TEMPLATE = `# Upload these screenshots to App Store Connect
|
|
75
|
+
|
|
76
|
+
This bundle was exported from Storeframe Studio for **{{BUNDLE_ID}}** \u2014
|
|
77
|
+
{{LOCALE_COUNT}} locales ({{LOCALES}}) \xB7 {{PER_LOCALE_COUNT}} screenshots each \xB7 {{PANEL_LABEL}}.
|
|
78
|
+
|
|
79
|
+
You upload it yourself with [Fastlane](https://fastlane.tools), signed in with **your own
|
|
80
|
+
Apple account or API key**. Your credentials never touch Storeframe.
|
|
81
|
+
|
|
82
|
+
## Upload
|
|
83
|
+
|
|
84
|
+
\`\`\`
|
|
85
|
+
cd <this folder>
|
|
86
|
+
fastlane deliver
|
|
87
|
+
\`\`\`
|
|
88
|
+
|
|
89
|
+
Fastlane will:
|
|
90
|
+
|
|
91
|
+
1. Ask you to sign in (Apple ID with 2FA, or an App Store Connect API key if you have
|
|
92
|
+
fastlane configured with one).
|
|
93
|
+
2. Detect the device size automatically from the image dimensions.
|
|
94
|
+
3. Upload screenshots for **every locale** below in one run \u2014 it reads the locale from each
|
|
95
|
+
\`fastlane/screenshots/<locale>/\` folder name.
|
|
96
|
+
4. Show you an **HTML preview** of exactly what will be uploaded and ask
|
|
97
|
+
\`Does the Preview look okay for you? (y/n)\` \u2014 nothing is sent until you confirm.
|
|
98
|
+
5. Upload the screenshots to your app's **editable (unreleased) version**, replacing the
|
|
99
|
+
existing set for each device size and locale.
|
|
100
|
+
|
|
101
|
+
**Nothing is ever submitted for review.** This bundle only updates screenshots on a draft
|
|
102
|
+
version; you review and submit in App Store Connect yourself, whenever you're ready.
|
|
103
|
+
|
|
104
|
+
## First time?
|
|
105
|
+
|
|
106
|
+
- Install fastlane: \`brew install fastlane\` (or \`gem install fastlane\`).
|
|
107
|
+
- You need an **editable version** of your app in App Store Connect (any version that
|
|
108
|
+
isn't live yet). If every version is released, create a new version in App Store
|
|
109
|
+
Connect first \u2014 deliver can't attach screenshots to a released version.
|
|
110
|
+
|
|
111
|
+
## What's in this bundle
|
|
112
|
+
|
|
113
|
+
\`\`\`
|
|
114
|
+
README.md this file
|
|
115
|
+
fastlane/
|
|
116
|
+
Deliverfile pre-filled config \u2014 screenshots only, nothing else
|
|
117
|
+
{{LOCALE_FOLDERS}}
|
|
118
|
+
\`\`\`
|
|
119
|
+
|
|
120
|
+
Each locale folder holds that language's screenshots; the \`NN_\` filename prefix is the
|
|
121
|
+
display order in the App Store.
|
|
122
|
+
|
|
123
|
+
The \`Deliverfile\` is deliberately minimal and read-safe: it skips binary upload, skips
|
|
124
|
+
metadata, and never submits. Feel free to inspect it \u2014 it's a handful of lines.
|
|
125
|
+
|
|
126
|
+
## Troubleshooting
|
|
127
|
+
|
|
67
128
|
- **"Could not find app with bundle identifier"** \u2014 the Bundle ID above doesn't match an
|
|
68
129
|
app on the team you signed in with. Re-export from Storeframe with the right Bundle ID.
|
|
69
130
|
- **Screenshots land under the wrong language** \u2014 re-export with the right locale; the
|
|
@@ -75,7 +136,11 @@ function render(template, vars) {
|
|
|
75
136
|
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => vars[key] ?? match);
|
|
76
137
|
}
|
|
77
138
|
function generateDeliverfile(opts) {
|
|
78
|
-
|
|
139
|
+
const base = render(DELIVERFILE_TEMPLATE, { BUNDLE_ID: opts.bundleId });
|
|
140
|
+
const locales = opts.locales?.filter((l) => l.length > 0) ?? [];
|
|
141
|
+
if (locales.length === 0) return base;
|
|
142
|
+
return `# Locales in this bundle: ${locales.join(", ")} (one fastlane/screenshots/<locale>/ folder each).
|
|
143
|
+
${base}`;
|
|
79
144
|
}
|
|
80
145
|
function generateBundleReadme(opts) {
|
|
81
146
|
return render(README_TEMPLATE, {
|
|
@@ -85,6 +150,17 @@ function generateBundleReadme(opts) {
|
|
|
85
150
|
COUNT: String(opts.count)
|
|
86
151
|
});
|
|
87
152
|
}
|
|
153
|
+
function generateMultiLocaleBundleReadme(opts) {
|
|
154
|
+
const folderLines = opts.locales.map((l) => ` fastlane/screenshots/${l}/`.padEnd(33) + `your ${l} screenshots`).join("\n");
|
|
155
|
+
return render(MULTI_LOCALE_README_TEMPLATE, {
|
|
156
|
+
BUNDLE_ID: opts.bundleId,
|
|
157
|
+
LOCALES: opts.locales.join(", "),
|
|
158
|
+
LOCALE_COUNT: String(opts.locales.length),
|
|
159
|
+
PER_LOCALE_COUNT: String(opts.perLocaleCount),
|
|
160
|
+
PANEL_LABEL: opts.panelLabel,
|
|
161
|
+
LOCALE_FOLDERS: folderLines
|
|
162
|
+
});
|
|
163
|
+
}
|
|
88
164
|
function screenshotFilename(index) {
|
|
89
165
|
return `${String(index).padStart(2, "0")}_storeframe.png`;
|
|
90
166
|
}
|
|
@@ -113,6 +189,265 @@ function buildFastlaneBundleZip(slots, opts) {
|
|
|
113
189
|
}
|
|
114
190
|
return zipSync(files, { level: 0 });
|
|
115
191
|
}
|
|
192
|
+
function buildMultiLocaleFastlaneBundleZip(localeSlots, opts) {
|
|
193
|
+
const locales = localeSlots.map((ls) => ls.locale);
|
|
194
|
+
const files = {
|
|
195
|
+
"README.md": strToU8(
|
|
196
|
+
generateMultiLocaleBundleReadme({
|
|
197
|
+
bundleId: opts.bundleId,
|
|
198
|
+
locales,
|
|
199
|
+
panelLabel: opts.panelLabel,
|
|
200
|
+
// Structure is shared across locales (invariant 2), so every locale has the same slot
|
|
201
|
+
// count — take the first (0 for the degenerate empty-input case).
|
|
202
|
+
perLocaleCount: localeSlots[0]?.slots.length ?? 0
|
|
203
|
+
})
|
|
204
|
+
),
|
|
205
|
+
"fastlane/Deliverfile": strToU8(generateDeliverfile({ bundleId: opts.bundleId, locales }))
|
|
206
|
+
};
|
|
207
|
+
for (const { locale: locale2, slots } of localeSlots) {
|
|
208
|
+
for (const slot2 of slots) {
|
|
209
|
+
files[`fastlane/screenshots/${locale2}/${screenshotFilename(slot2.displayPosition)}`] = slot2.bytes;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return zipSync(files, { level: 0 });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ../mockup-engine/appstore.ts
|
|
216
|
+
var DEFAULT_LOOK = {
|
|
217
|
+
angle: "front",
|
|
218
|
+
cameraPos: null,
|
|
219
|
+
roll: "0",
|
|
220
|
+
phoneHeight: "72",
|
|
221
|
+
hOffset: "0",
|
|
222
|
+
vOffset: "0",
|
|
223
|
+
material: "real",
|
|
224
|
+
colorway: "silver",
|
|
225
|
+
customColor: "F5F5F5",
|
|
226
|
+
finish: "0",
|
|
227
|
+
clearcoat: "0",
|
|
228
|
+
clayTone: "grey",
|
|
229
|
+
clayCustom: "B0B0B0",
|
|
230
|
+
flatScreen: false,
|
|
231
|
+
glare: false,
|
|
232
|
+
lighting: true,
|
|
233
|
+
reflections: false
|
|
234
|
+
};
|
|
235
|
+
function normalizePanelBackgrounds(raw) {
|
|
236
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
237
|
+
const out = {};
|
|
238
|
+
for (const [panelId, entry] of Object.entries(raw)) {
|
|
239
|
+
if (!entry || typeof entry !== "object") continue;
|
|
240
|
+
const e = entry;
|
|
241
|
+
if (e.kind === "solid" && typeof e.color === "string") {
|
|
242
|
+
out[panelId] = { kind: "solid", color: e.color };
|
|
243
|
+
} else if (e.kind === "gradient" && typeof e.from === "string" && typeof e.to === "string" && (e.dir === "horizontal" || e.dir === "vertical")) {
|
|
244
|
+
out[panelId] = { kind: "gradient", from: e.from, to: e.to, dir: e.dir };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
var DEFAULT_CAPTION_ANCHOR = { x: 0.5, y: 0.06 };
|
|
250
|
+
function isAutoAnchor(anchor) {
|
|
251
|
+
return !anchor || anchor.x === DEFAULT_CAPTION_ANCHOR.x && anchor.y === DEFAULT_CAPTION_ANCHOR.y;
|
|
252
|
+
}
|
|
253
|
+
function captionLayerStyle(layer) {
|
|
254
|
+
const { text: _text, anchor, ...rest } = layer;
|
|
255
|
+
return isAutoAnchor(anchor) ? { ...rest } : { ...rest, anchor };
|
|
256
|
+
}
|
|
257
|
+
var DEFAULT_CAPTION_STYLE = {
|
|
258
|
+
fontId: "inter",
|
|
259
|
+
sizePt: 44,
|
|
260
|
+
color: "FFFFFF",
|
|
261
|
+
align: "center",
|
|
262
|
+
anchor: DEFAULT_CAPTION_ANCHOR,
|
|
263
|
+
maxWidth: 0.86
|
|
264
|
+
};
|
|
265
|
+
var LEGACY_SUBTITLE_SCALE = 0.55;
|
|
266
|
+
var DEFAULT_LOCALE = "en-US";
|
|
267
|
+
function normalizeCaptionStyles(raw) {
|
|
268
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
269
|
+
const out = {};
|
|
270
|
+
for (const [panelId, entry] of Object.entries(raw)) {
|
|
271
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
272
|
+
out[panelId] = normalizeCaptionLayerStyle(entry);
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
function normalizeAnchor(raw) {
|
|
277
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
278
|
+
const a = raw;
|
|
279
|
+
const x = typeof a.x === "number" ? a.x : DEFAULT_CAPTION_ANCHOR.x;
|
|
280
|
+
const y = typeof a.y === "number" ? a.y : DEFAULT_CAPTION_ANCHOR.y;
|
|
281
|
+
return isAutoAnchor({ x, y }) ? void 0 : { x, y };
|
|
282
|
+
}
|
|
283
|
+
function normalizeCaptionLayerStyle(raw) {
|
|
284
|
+
const e = raw && typeof raw === "object" ? raw : {};
|
|
285
|
+
const anchor = normalizeAnchor(e.anchor);
|
|
286
|
+
const merged = { ...DEFAULT_CAPTION_STYLE, ...e };
|
|
287
|
+
if (anchor) merged.anchor = anchor;
|
|
288
|
+
else delete merged.anchor;
|
|
289
|
+
return merged;
|
|
290
|
+
}
|
|
291
|
+
function normalizeCaptionLayer(raw) {
|
|
292
|
+
const e = raw && typeof raw === "object" ? raw : {};
|
|
293
|
+
return { ...normalizeCaptionLayerStyle(e), text: typeof e.text === "string" ? e.text : "" };
|
|
294
|
+
}
|
|
295
|
+
function normalizeCaptionLayerStyles(raw) {
|
|
296
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
297
|
+
const out = {};
|
|
298
|
+
for (const [panelId, entry] of Object.entries(raw)) {
|
|
299
|
+
if (Array.isArray(entry)) {
|
|
300
|
+
if (entry.length > 0) out[panelId] = entry.map(normalizeCaptionLayerStyle);
|
|
301
|
+
} else if (entry && typeof entry === "object") {
|
|
302
|
+
out[panelId] = [normalizeCaptionLayerStyle(entry)];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
function layersFromCaptionText(text, style2) {
|
|
308
|
+
const layers = [{ ...normalizeCaptionLayerStyle(style2), text: text.headline }];
|
|
309
|
+
if (typeof text.subtitle === "string" && text.subtitle.trim()) {
|
|
310
|
+
layers.push({ ...normalizeCaptionLayerStyle({ ...style2, sizePt: style2.sizePt * LEGACY_SUBTITLE_SCALE }), text: text.subtitle });
|
|
311
|
+
}
|
|
312
|
+
return layers;
|
|
313
|
+
}
|
|
314
|
+
function normalizeCaptionText(raw, captionStyles) {
|
|
315
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
316
|
+
const styles = normalizeCaptionStyles(captionStyles);
|
|
317
|
+
const styleFor = (panelId) => styles[panelId] ?? DEFAULT_CAPTION_STYLE;
|
|
318
|
+
const innerToLayers = (inner) => {
|
|
319
|
+
if (!inner || typeof inner !== "object" || Array.isArray(inner)) return {};
|
|
320
|
+
const out2 = {};
|
|
321
|
+
for (const [panelId, value] of Object.entries(inner)) {
|
|
322
|
+
if (Array.isArray(value)) {
|
|
323
|
+
if (value.length > 0) out2[panelId] = value.map(normalizeCaptionLayer);
|
|
324
|
+
} else if (value && typeof value === "object" && typeof value.headline === "string") {
|
|
325
|
+
out2[panelId] = layersFromCaptionText(value, styleFor(panelId));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out2;
|
|
329
|
+
};
|
|
330
|
+
const entries = Object.entries(raw);
|
|
331
|
+
const looksFlat = entries.some(
|
|
332
|
+
([, value]) => !!value && typeof value === "object" && typeof value.headline === "string"
|
|
333
|
+
);
|
|
334
|
+
if (looksFlat) return { [DEFAULT_LOCALE]: innerToLayers(raw) };
|
|
335
|
+
const out = {};
|
|
336
|
+
for (const [locale2, inner] of entries) out[locale2] = innerToLayers(inner);
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
function resolveCaptionText(captionText, locale2) {
|
|
340
|
+
return captionText[locale2 ?? DEFAULT_LOCALE] ?? captionText[DEFAULT_LOCALE] ?? {};
|
|
341
|
+
}
|
|
342
|
+
function deriveLocales(captionText, explicit) {
|
|
343
|
+
const dedupe = (list) => {
|
|
344
|
+
const seen = /* @__PURE__ */ new Set();
|
|
345
|
+
const out = [];
|
|
346
|
+
for (const l of list) if (typeof l === "string" && l && !seen.has(l)) {
|
|
347
|
+
seen.add(l);
|
|
348
|
+
out.push(l);
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
};
|
|
352
|
+
if (Array.isArray(explicit)) {
|
|
353
|
+
const cleaned = dedupe(explicit);
|
|
354
|
+
if (cleaned.length > 0) return cleaned;
|
|
355
|
+
}
|
|
356
|
+
return dedupe([DEFAULT_LOCALE, ...Object.keys(captionText)]);
|
|
357
|
+
}
|
|
358
|
+
function captionStylesFromLayers(captionText) {
|
|
359
|
+
const out = {};
|
|
360
|
+
const locales = Object.keys(captionText);
|
|
361
|
+
const ordered = locales.includes(DEFAULT_LOCALE) ? [DEFAULT_LOCALE, ...locales.filter((l) => l !== DEFAULT_LOCALE)] : locales;
|
|
362
|
+
for (const locale2 of ordered) {
|
|
363
|
+
const inner = captionText[locale2];
|
|
364
|
+
if (!inner) continue;
|
|
365
|
+
for (const [panelId, layers] of Object.entries(inner)) {
|
|
366
|
+
if (out[panelId] === void 0 && Array.isArray(layers) && layers.length > 0) out[panelId] = layers.map(captionLayerStyle);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
function shotsInPanelOrder(shots, panels) {
|
|
372
|
+
return panels.flatMap((panel) => shots.filter((s) => s.panelId === panel.id));
|
|
373
|
+
}
|
|
374
|
+
function isLocaleVariants(entry) {
|
|
375
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
376
|
+
const locales = entry.locales;
|
|
377
|
+
return !!locales && typeof locales === "object" && !Array.isArray(locales);
|
|
378
|
+
}
|
|
379
|
+
function resolveLocaleVariant(entry, locale2) {
|
|
380
|
+
if (!isLocaleVariants(entry)) return entry;
|
|
381
|
+
const variants = entry.locales;
|
|
382
|
+
const declared = Object.keys(variants);
|
|
383
|
+
if (declared.length === 0) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
'a per-locale screenshot entry has an empty "locales" map \u2014 provide at least one locale, e.g. { "locales": { "en-US": { "ref": "\u2026" } } }'
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
return variants[locale2 ?? DEFAULT_LOCALE] ?? variants[DEFAULT_LOCALE] ?? variants[declared[0]];
|
|
389
|
+
}
|
|
390
|
+
function resolveLocaleVariantSlots(slots, locale2) {
|
|
391
|
+
return slots.map((slot2) => slot2.map((entry) => resolveLocaleVariant(entry, locale2)));
|
|
392
|
+
}
|
|
393
|
+
function collectVariantLocales(entries) {
|
|
394
|
+
const seen = /* @__PURE__ */ new Set();
|
|
395
|
+
const out = [];
|
|
396
|
+
for (const entry of entries) {
|
|
397
|
+
if (!isLocaleVariants(entry)) continue;
|
|
398
|
+
for (const locale2 of Object.keys(entry.locales)) {
|
|
399
|
+
if (!seen.has(locale2)) {
|
|
400
|
+
seen.add(locale2);
|
|
401
|
+
out.push(locale2);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
var PROJECT_SCHEMA_VERSION = 2;
|
|
408
|
+
function serializeProject(state) {
|
|
409
|
+
const ordered = shotsInPanelOrder(state.shots, state.panels);
|
|
410
|
+
const shots = ordered.map((s) => ({
|
|
411
|
+
id: s.id,
|
|
412
|
+
panelId: s.panelId,
|
|
413
|
+
frameNodeId: s.frameNodeId,
|
|
414
|
+
frameName: s.frameName,
|
|
415
|
+
look: s.look,
|
|
416
|
+
inputAnchor: s.inputAnchor,
|
|
417
|
+
// v2 provenance — persisted so drift survives reload
|
|
418
|
+
outputAnchor: s.outputAnchor
|
|
419
|
+
}));
|
|
420
|
+
const captionText = normalizeCaptionText(state.captionText, state.captionStyles);
|
|
421
|
+
return {
|
|
422
|
+
schema: PROJECT_SCHEMA_VERSION,
|
|
423
|
+
shots,
|
|
424
|
+
panels: state.panels,
|
|
425
|
+
selectedShotId: state.selectedShotId,
|
|
426
|
+
// Caption TEXT is content: emitted here on the project record, deliberately NOT via
|
|
427
|
+
// extractStripStyle — so words can never leak into serializeLook's styling blob (invariant 2).
|
|
428
|
+
captionText,
|
|
429
|
+
// The language list is content too — placed here (not the extractStripStyle spread) so languages
|
|
430
|
+
// never leak into a Look (invariant 3). Per-locale screenshot BYTES (state.localeScreens) are
|
|
431
|
+
// DROPPED, exactly as Shot.bytes are: bytes never enter the record (rule 1). They persist only in
|
|
432
|
+
// the byte-cache and re-join by frameName on load.
|
|
433
|
+
locales: deriveLocales(captionText, state.locales),
|
|
434
|
+
...extractStripStyle(state, captionText)
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function extractStripStyle(state, captionText) {
|
|
438
|
+
return {
|
|
439
|
+
panelPresetId: state.panelPresetId,
|
|
440
|
+
bgMode: state.bgMode,
|
|
441
|
+
gradientFrom: state.gradientFrom,
|
|
442
|
+
gradientTo: state.gradientTo,
|
|
443
|
+
gradientDir: state.gradientDir,
|
|
444
|
+
panelColors: state.panelColors,
|
|
445
|
+
shadow: state.shadow,
|
|
446
|
+
floorReflection: state.floorReflection ?? false,
|
|
447
|
+
panelBackgrounds: state.panelBackgrounds ?? {},
|
|
448
|
+
captionStyles: { ...normalizeCaptionLayerStyles(state.captionStyles), ...captionStylesFromLayers(captionText) }
|
|
449
|
+
};
|
|
450
|
+
}
|
|
116
451
|
|
|
117
452
|
// src/screenshotInput.ts
|
|
118
453
|
import { readFile } from "node:fs/promises";
|
|
@@ -309,15 +644,18 @@ function panelDimensionsFor(presetId) {
|
|
|
309
644
|
var nameField = z.string().max(200).optional().describe(
|
|
310
645
|
`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
646
|
);
|
|
312
|
-
var
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
647
|
+
var inlineBase64Entry = z.string().min(1).describe('Inline base64-encoded PNG (no "data:" prefix). Small payloads only.');
|
|
648
|
+
var refEntry = z.object({ ref: z.string().min(1), name: nameField }).strict().describe("A ref returned by request_screenshot_upload \u2014 resolved server-side, never re-sent inline.");
|
|
649
|
+
var urlEntry = z.object({ url: z.string().min(1), name: nameField }).strict().describe("An https URL to a PNG \u2014 fetched server-side (image/png, no redirects, ~20MB cap).");
|
|
650
|
+
var pathEntry = z.object({ path: z.string().min(1), name: nameField }).strict().describe(
|
|
651
|
+
"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."
|
|
652
|
+
);
|
|
653
|
+
var singleScreenshotEntry = z.union([inlineBase64Entry, refEntry, urlEntry, pathEntry]);
|
|
654
|
+
var localeScreenshotEntry = z.object({ locales: z.record(z.string(), singleScreenshotEntry) }).strict().describe(
|
|
655
|
+
'Per-locale variants of ONE screenshot: { "locales": { "en-US": <entry>, "de-DE": <entry> } } (each variant is an inline base64 / ref / url / path entry). render_strip picks the top-level `locale`\'s variant; emit_bundle with `locales` renders/packages every listed locale. A locale with no variant of its own falls back to the en-US variant (else the first declared), so you can localize only some screenshots.'
|
|
656
|
+
);
|
|
657
|
+
var screenshotEntry = z.union([inlineBase64Entry, refEntry, urlEntry, pathEntry, localeScreenshotEntry]);
|
|
658
|
+
var screenshotsDescription = 'An array of App Store slots in display order (1\u201310; the store allows up to 10 per size). Each slot is itself an array of screenshots: one entry = one phone in that slot, several entries = multiple phones composited into that one slot. Each screenshot entry is EITHER an inline base64 PNG string (small payloads only \u2014 large inline payloads are rejected with a clear error), OR { "ref": "..." } from request_screenshot_upload, OR { "url": "https://..." }, OR (local stdio mode only) { "path": "/abs/or/relative/path.png" } to read a file straight off disk, OR { "locales": { "<locale>": <any of those> } } to vary that screenshot per App Store locale (missing locales fall back to the en-US variant). Prefer ref/url for real screenshots so the bytes never transit this conversation.';
|
|
321
659
|
var slot = z.array(screenshotEntry).min(1).max(6).describe(
|
|
322
660
|
"One App Store slot: 1\u20136 screenshots. Several entries = several phones composited into that one slot."
|
|
323
661
|
);
|
|
@@ -326,8 +664,12 @@ var screenshotsForSave = z.array(slot).min(1).max(10).describe(
|
|
|
326
664
|
`${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
665
|
);
|
|
328
666
|
var screenshotsOptional = z.array(slot).min(1).max(10).optional().describe(`${screenshotsDescription} Required unless \`panels\` (pre-rendered refs) is given instead.`);
|
|
329
|
-
var
|
|
330
|
-
|
|
667
|
+
var panelRef = z.object({ ref: z.string().min(1) }).strict();
|
|
668
|
+
var localePanelRef = z.object({ locales: z.record(z.string(), panelRef) }).strict().describe(
|
|
669
|
+
'Per-locale variants of ONE pre-rendered panel: { "locales": { "en-US": { "ref": "\u2026" }, "de-DE": { "ref": "\u2026" } } }. Used with `locales` to compose a multi-locale bundle from per-locale render_strip results without re-rendering; a missing locale falls back to the en-US variant (else the first declared).'
|
|
670
|
+
);
|
|
671
|
+
var panelRefs = z.array(z.union([panelRef, localePanelRef])).min(1).max(10).optional().describe(
|
|
672
|
+
'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. An entry may also be { "locales": { "<locale>": { "ref": "\u2026" } } } to vary that panel per locale (see `locales`).'
|
|
331
673
|
);
|
|
332
674
|
var output = z.enum(["inline", "urls"]).optional().describe(
|
|
333
675
|
'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).'
|
|
@@ -337,11 +679,14 @@ var preview = z.boolean().optional().describe(
|
|
|
337
679
|
);
|
|
338
680
|
var panelPresetId = z.enum(PANEL_PRESET_IDS).optional().describe("App Store screenshot size. Default r69 (6.9\u2033 iPhone, 1290\xD72796).");
|
|
339
681
|
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.'
|
|
682
|
+
'A Storeframe "look" (styling only) JSON, exactly as returned by read_look, applied to the strip. Omit for default styling. Unlike style.shotLook (one device for the whole strip), a look carries shots[] \u2014 one entry per panel in slot order, each with its own look \u2014 so you can style each panel differently (mixed-device / continuous-scene). See describe_look.'
|
|
341
683
|
);
|
|
342
684
|
var useSavedLook = z.boolean().optional().describe("If true, style the strip with the project's saved look (ignored when `look`/`style` is given).");
|
|
343
685
|
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).'
|
|
686
|
+
'App Store locale, e.g. "de-DE" (default en-US). Labels the render, selects which { "locales": \u2026 } screenshot variants render, and, for emit_bundle, selects the fastlane screenshots folder. Does not select caption text \u2014 pass the copy for this locale yourself via `style.captions[].text` (see the README\'s `captions.<locale>.json` convention).'
|
|
687
|
+
);
|
|
688
|
+
var localesField = z.array(z.string().min(1)).min(1).max(40).optional().describe(
|
|
689
|
+
'Emit ONE multi-locale bundle: render (or, with `panels`, just package) each listed App Store locale and lay them out as fastlane/screenshots/<locale>/ folders in a single zip. Screenshot/panel entries may vary per locale via { "locales": \u2026 }; a plain entry \u2014 or a missing locale variant \u2014 falls back to the en-US variant, so a locale with no screens of its own ships the fallback pixels in its own folder (correct for fastlane). With `screenshots` this renders once PER locale (slow \u2014 prefer per-locale render_strip calls with output:"urls", then compose here with per-locale `panels`). A single-entry list behaves exactly like `locale`. Caption text is per-RENDER input: in this one-call form the same style.captions apply to every locale, so for localized captions use the render-per-locale-then-compose flow.'
|
|
345
690
|
);
|
|
346
691
|
var CAPTION_FONT_IDS = ["inter", "manrope", "poppins", "fraunces", "space-grotesk"];
|
|
347
692
|
var DEFAULT_SHOT_LOOK = {
|
|
@@ -355,6 +700,7 @@ var DEFAULT_SHOT_LOOK = {
|
|
|
355
700
|
colorway: "silver",
|
|
356
701
|
customColor: "F5F5F5",
|
|
357
702
|
finish: "0",
|
|
703
|
+
clearcoat: "0",
|
|
358
704
|
clayTone: "grey",
|
|
359
705
|
clayCustom: "B0B0B0",
|
|
360
706
|
flatScreen: false,
|
|
@@ -362,7 +708,7 @@ var DEFAULT_SHOT_LOOK = {
|
|
|
362
708
|
lighting: true,
|
|
363
709
|
reflections: false
|
|
364
710
|
};
|
|
365
|
-
var
|
|
711
|
+
var DEFAULT_CAPTION_STYLE2 = {
|
|
366
712
|
fontId: "inter",
|
|
367
713
|
sizePt: 44,
|
|
368
714
|
color: "FFFFFF",
|
|
@@ -382,6 +728,7 @@ var shotLook = z.object({
|
|
|
382
728
|
colorway: z.enum(["orange", "blue", "silver", "custom"]).optional().describe('Body colour (material "real"). Default silver.'),
|
|
383
729
|
customColor: z.string().optional().describe('Hex body colour when colorway is "custom".'),
|
|
384
730
|
finish: numeric.optional().describe("0 = matte \u2026 1 = glossy. Default 0."),
|
|
731
|
+
clearcoat: numeric.optional().describe("0 = none \u2026 1 = glossy clear-coat lacquer over the body (a wet/ceramic sheen, on top of finish). Default 0."),
|
|
385
732
|
clayTone: z.enum(["grey", "white", "charcoal", "custom"]).optional().describe('Clay tone (material "clay"). Default grey.'),
|
|
386
733
|
clayCustom: z.string().optional().describe('Hex clay colour when clayTone is "custom".'),
|
|
387
734
|
flatScreen: z.boolean().optional().describe("Render the screen flat (no curvature). Default false."),
|
|
@@ -395,7 +742,8 @@ var background = z.object({
|
|
|
395
742
|
gradientTo: z.string().optional().describe("Gradient end (hex). Default #0a0a14."),
|
|
396
743
|
gradientDir: z.enum(["vertical", "horizontal"]).optional().describe("Default vertical."),
|
|
397
744
|
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.")
|
|
745
|
+
shadow: z.boolean().optional().describe("Drop shadow under the phones. Default true."),
|
|
746
|
+
floorReflection: z.boolean().optional().describe("Flipped, faded floor reflection under each phone. Default false.")
|
|
399
747
|
}).strict();
|
|
400
748
|
var captionEntry = z.object({
|
|
401
749
|
fontId: z.enum(CAPTION_FONT_IDS).optional().describe("Bundled font. Default inter."),
|
|
@@ -410,7 +758,13 @@ var captionEntry = z.object({
|
|
|
410
758
|
var style = z.object({
|
|
411
759
|
shotLook: shotLook.optional().describe("Device/camera styling, applied to every phone in the strip."),
|
|
412
760
|
background: background.optional(),
|
|
413
|
-
|
|
761
|
+
// plan-20 Phase 8 (D9): one entry PER PANEL, and each entry is EITHER a single caption object OR
|
|
762
|
+
// an ORDERED ARRAY of caption layers (a multi-line strip: headline + subline + …). Both are
|
|
763
|
+
// accepted forever — this is a public wire contract (published npm + hosted Fly server), so the
|
|
764
|
+
// union is additive, never a breaking swap. null = no caption on that panel.
|
|
765
|
+
captions: z.array(z.union([captionEntry, z.array(captionEntry).max(8)]).nullable()).max(10).optional().describe(
|
|
766
|
+
"One entry PER PANEL in slot order; null = no caption on that panel. An entry is EITHER a single caption object OR an array of caption layers (stacked, in order) on that panel."
|
|
767
|
+
)
|
|
414
768
|
}).strict().optional().describe(
|
|
415
769
|
"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
770
|
);
|
|
@@ -453,6 +807,7 @@ var emitBundleShape = {
|
|
|
453
807
|
"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
808
|
),
|
|
455
809
|
locale,
|
|
810
|
+
locales: localesField,
|
|
456
811
|
projectName: z.string().optional().describe('A name for the bundle (used for the zip filename + any share link). Default "storeframe".'),
|
|
457
812
|
share: z.boolean().optional().describe(
|
|
458
813
|
"Also create an unguessable, 14-day share link to a landing page (preview + bundle download + fastlane how-to), attributed to your account. Default false."
|
|
@@ -478,6 +833,17 @@ var saveProjectShape = {
|
|
|
478
833
|
sourceDir
|
|
479
834
|
};
|
|
480
835
|
var readLookShape = { project };
|
|
836
|
+
var readProjectShape = { project };
|
|
837
|
+
var screenshotFileList = z.array(singleScreenshotEntry).min(1).max(60).describe(
|
|
838
|
+
`A FLAT list of the developer's raw app screenshots \u2014 each an inline base64 PNG, { "ref" }, { "url" }, or (local stdio only) { "path" }, and each with a \`name\` (its original filename). They are matched to the saved project's shots BY FILENAME (name === the shot's frameName), so the order you pass them does NOT matter and you never pre-sort. A shot with no matching file is reported as missing (it does not error the render). Prefer ref/url for real screenshots so the bytes never transit this conversation.`
|
|
839
|
+
);
|
|
840
|
+
var renderProjectShape = {
|
|
841
|
+
project,
|
|
842
|
+
screenshots: screenshotFileList,
|
|
843
|
+
locale,
|
|
844
|
+
preview,
|
|
845
|
+
output
|
|
846
|
+
};
|
|
481
847
|
var saveLookShape = {
|
|
482
848
|
project,
|
|
483
849
|
look: z.record(z.string(), z.unknown()).describe(
|
|
@@ -501,6 +867,7 @@ var DESCRIBE_LOOK_CATALOG = {
|
|
|
501
867
|
material: "real | clay",
|
|
502
868
|
colorway: "orange | blue | silver | custom (with customColor hex)",
|
|
503
869
|
finish: "0 (matte) \u2026 1 (glossy)",
|
|
870
|
+
clearcoat: "0 (none) \u2026 1 (glossy clear-coat lacquer over the body, on top of finish)",
|
|
504
871
|
clayTone: "grey | white | charcoal | custom (with clayCustom hex)",
|
|
505
872
|
flatScreen: "boolean",
|
|
506
873
|
glare: "boolean",
|
|
@@ -510,28 +877,30 @@ var DESCRIBE_LOOK_CATALOG = {
|
|
|
510
877
|
},
|
|
511
878
|
background: {
|
|
512
879
|
description: "The strip background.",
|
|
513
|
-
defaults: { mode: "gradient", gradientFrom: "#1b1b2e", gradientTo: "#0a0a14", gradientDir: "vertical", shadow: true },
|
|
880
|
+
defaults: { mode: "gradient", gradientFrom: "#1b1b2e", gradientTo: "#0a0a14", gradientDir: "vertical", shadow: true, floorReflection: false },
|
|
514
881
|
fields: {
|
|
515
882
|
mode: "gradient | perPanel",
|
|
516
|
-
panelColors: 'per-panel hex colours (mode "perPanel"), one entry per slot in order'
|
|
883
|
+
panelColors: 'per-panel hex colours (mode "perPanel"), one entry per slot in order',
|
|
884
|
+
floorReflection: "boolean \u2014 a flipped, faded floor reflection under each phone"
|
|
517
885
|
}
|
|
518
886
|
},
|
|
519
887
|
captions: {
|
|
520
|
-
description: "One entry PER PANEL in slot order (null = no caption). Styling is part of the look; `text
|
|
521
|
-
defaults:
|
|
888
|
+
description: "One entry PER PANEL in slot order (null = no caption). An entry is EITHER a single caption object OR an array of caption LAYERS stacked in order on that panel (e.g. a headline layer + a subline layer). Styling is part of the look; `text` (and a single caption\u2019s `subtitle`) are per-RENDER input and are never stored (zero-custody of content). Captions AUTO-LAYOUT above the device by default so they never collide with the phone.",
|
|
889
|
+
defaults: DEFAULT_CAPTION_STYLE2,
|
|
522
890
|
fonts: CAPTION_FONT_IDS,
|
|
523
891
|
fields: {
|
|
524
892
|
sizePt: "font size in iOS points \u2014 preset-independent",
|
|
525
|
-
anchor: "{ x, y } normalized 0\u20131 position on the panel",
|
|
893
|
+
anchor: "{ x, y } normalized 0\u20131 position on the panel \u2014 OMIT to auto-place above the device",
|
|
526
894
|
maxWidth: "wrap width as a 0\u20131 fraction of the panel width",
|
|
527
|
-
text: "the headline (per-render input)",
|
|
528
|
-
subtitle: "optional
|
|
895
|
+
text: "the headline / this layer\u2019s words (per-render input)",
|
|
896
|
+
subtitle: "single-caption shape only: an optional second line (per-render input). In the ARRAY shape, add another layer instead."
|
|
529
897
|
}
|
|
530
898
|
}
|
|
531
899
|
},
|
|
532
900
|
panelPresets: PANEL_PRESET_IDS.map((id) => ({ id, ...PANEL_DIMENSIONS[id], label: PANEL_LABELS[id] })),
|
|
533
901
|
notes: [
|
|
534
902
|
"Pass `style` to render_strip/emit_bundle for structured styling, or save a composed look with save_look and render with useSavedLook.",
|
|
903
|
+
'style.shotLook is ONE device for the whole strip. For a DIFFERENT device per panel (mixed-device or continuous-scene strips), pass a `look` instead \u2014 a look has shots[], one entry per panel in slot order, each with its own `look` (same fields as shotLook). e.g. look:{ shots:[ {panelId:"panel-1",look:{angle:"left"}}, {panelId:"panel-2",look:{colorway:"orange"}} ] }.',
|
|
535
904
|
"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
905
|
]
|
|
537
906
|
};
|
|
@@ -542,6 +911,13 @@ var requestScreenshotUploadShape = {
|
|
|
542
911
|
// `{ ref }` bare (no `name`). Optional; a shorter/absent array just leaves those slots nameless.
|
|
543
912
|
names: z.array(z.string().max(200)).optional().describe(
|
|
544
913
|
'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.'
|
|
914
|
+
),
|
|
915
|
+
// plan-21 Phase 5 (D6): an ORGANIZATIONAL tag only — which locale this batch of uploads is
|
|
916
|
+
// for. Echoed back in the response so an agent uploading per-locale batches can keep its
|
|
917
|
+
// refs sorted; it changes nothing about the refs themselves (any ref works in any locale
|
|
918
|
+
// slot of a { locales: … } entry).
|
|
919
|
+
locale: z.string().optional().describe(
|
|
920
|
+
'Optional tag: which App Store locale these screenshots are for (e.g. "de-DE") when uploading one batch per locale. Echoed back for your bookkeeping only \u2014 the refs are locale-agnostic; place each under the matching key of a { "locales": \u2026 } screenshot entry.'
|
|
545
921
|
)
|
|
546
922
|
};
|
|
547
923
|
|
|
@@ -558,6 +934,19 @@ function resolveOutputMode(requested, totalBytes) {
|
|
|
558
934
|
if (requested === "inline" || requested === "urls") return requested;
|
|
559
935
|
return totalBytes > AUTO_INLINE_THRESHOLD_BYTES ? "urls" : "inline";
|
|
560
936
|
}
|
|
937
|
+
function dedupeLocales(list) {
|
|
938
|
+
if (!Array.isArray(list)) return [];
|
|
939
|
+
const seen = /* @__PURE__ */ new Set();
|
|
940
|
+
const out = [];
|
|
941
|
+
for (const raw of list) {
|
|
942
|
+
const locale2 = typeof raw === "string" ? raw.trim() : "";
|
|
943
|
+
if (locale2 && !seen.has(locale2)) {
|
|
944
|
+
seen.add(locale2);
|
|
945
|
+
out.push(locale2);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
561
950
|
async function resolveLook(deps2, args) {
|
|
562
951
|
if (args.style != null) return { look: null, style: args.style };
|
|
563
952
|
if (args.look != null) return { look: args.look };
|
|
@@ -629,9 +1018,16 @@ async function handleRenderStrip(deps2, args) {
|
|
|
629
1018
|
if (args.createProject && args.preview) {
|
|
630
1019
|
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
1020
|
}
|
|
1021
|
+
const screenshotLocales = collectVariantLocales(args.screenshots.flat());
|
|
1022
|
+
let entries;
|
|
1023
|
+
try {
|
|
1024
|
+
entries = resolveLocaleVariantSlots(args.screenshots, locale2);
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
return errorResult(err.message);
|
|
1027
|
+
}
|
|
632
1028
|
let screenshots2;
|
|
633
1029
|
try {
|
|
634
|
-
screenshots2 = await resolveScreenshots(
|
|
1030
|
+
screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
|
|
635
1031
|
} catch (err) {
|
|
636
1032
|
return errorResult(err.message);
|
|
637
1033
|
}
|
|
@@ -644,7 +1040,9 @@ async function handleRenderStrip(deps2, args) {
|
|
|
644
1040
|
locale: locale2,
|
|
645
1041
|
buildRecord: args.createProject === true,
|
|
646
1042
|
// Phase 5A: only compute names when we're building a record (saving a project).
|
|
647
|
-
|
|
1043
|
+
// Per-locale entries: names come from the locale-resolved entries — the record describes
|
|
1044
|
+
// exactly the files this render used.
|
|
1045
|
+
...args.createProject ? { screenshotNames: resolveScreenshotNames(entries) } : {}
|
|
648
1046
|
});
|
|
649
1047
|
if (!result.ok || !result.panels) {
|
|
650
1048
|
return errorResult(`render failed: ${result.error ?? "unknown error"}`);
|
|
@@ -672,6 +1070,9 @@ async function handleRenderStrip(deps2, args) {
|
|
|
672
1070
|
renderMs: result.ms,
|
|
673
1071
|
output: outputMode,
|
|
674
1072
|
locale: locale2,
|
|
1073
|
+
// Only when the payload actually carried per-locale entries — a legacy payload's
|
|
1074
|
+
// response stays byte-identical (D6).
|
|
1075
|
+
...screenshotLocales.length > 0 ? { screenshotLocales } : {},
|
|
675
1076
|
...note ? { note } : {},
|
|
676
1077
|
...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
|
|
677
1078
|
...persistNote ? { projectNote: persistNote } : {},
|
|
@@ -690,8 +1091,17 @@ async function handleEmitBundle(deps2, args) {
|
|
|
690
1091
|
if (!isValidBundleId(args.bundleId)) {
|
|
691
1092
|
return errorResult(`invalid bundleId "${args.bundleId}" \u2014 expected reverse-DNS, e.g. com.acme.app`);
|
|
692
1093
|
}
|
|
693
|
-
const locale2 = args.locale?.trim() || "en-US";
|
|
694
1094
|
const projectName2 = args.projectName?.trim() || "storeframe";
|
|
1095
|
+
const requestedLocales = dedupeLocales(args.locales);
|
|
1096
|
+
if (requestedLocales.length >= 2) {
|
|
1097
|
+
if (args.createProject) {
|
|
1098
|
+
return errorResult(
|
|
1099
|
+
"createProject is not supported with the multi-locale `locales` form \u2014 save the editable project separately with save_project (a project record is structure + look, locale-independent), or emit a single-locale bundle with `locale`."
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
return emitMultiLocaleBundle(deps2, args, requestedLocales, projectName2);
|
|
1103
|
+
}
|
|
1104
|
+
const locale2 = requestedLocales[0] ?? (args.locale?.trim() || "en-US");
|
|
695
1105
|
if (args.createProject && args.panels && args.panels.length > 0) {
|
|
696
1106
|
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
1107
|
}
|
|
@@ -703,9 +1113,15 @@ async function handleEmitBundle(deps2, args) {
|
|
|
703
1113
|
if (args.panels && args.panels.length > 0) {
|
|
704
1114
|
const dims = panelDimensionsFor(args.panelPresetId ?? "r69");
|
|
705
1115
|
panelLabel = panelLabelFor(args.panelPresetId ?? "r69", dims.width, dims.height);
|
|
1116
|
+
let panelEntries;
|
|
1117
|
+
try {
|
|
1118
|
+
panelEntries = args.panels.map((p) => resolveLocaleVariant(p, locale2));
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
return errorResult(err.message);
|
|
1121
|
+
}
|
|
706
1122
|
let bytesList;
|
|
707
1123
|
try {
|
|
708
|
-
bytesList = await Promise.all(
|
|
1124
|
+
bytesList = await Promise.all(panelEntries.map((p) => fetchUploadedRef(p.ref, deps2.userId)));
|
|
709
1125
|
} catch (err) {
|
|
710
1126
|
return errorResult(`failed to fetch panel ref: ${err.message}`);
|
|
711
1127
|
}
|
|
@@ -715,9 +1131,15 @@ async function handleEmitBundle(deps2, args) {
|
|
|
715
1131
|
if (!args.screenshots || args.screenshots.length === 0) {
|
|
716
1132
|
return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
|
|
717
1133
|
}
|
|
1134
|
+
let entries;
|
|
1135
|
+
try {
|
|
1136
|
+
entries = resolveLocaleVariantSlots(args.screenshots, locale2);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
return errorResult(err.message);
|
|
1139
|
+
}
|
|
718
1140
|
let screenshots2;
|
|
719
1141
|
try {
|
|
720
|
-
screenshots2 = await resolveScreenshots(
|
|
1142
|
+
screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
|
|
721
1143
|
} catch (err) {
|
|
722
1144
|
return errorResult(err.message);
|
|
723
1145
|
}
|
|
@@ -730,7 +1152,7 @@ async function handleEmitBundle(deps2, args) {
|
|
|
730
1152
|
style: resolved.style,
|
|
731
1153
|
locale: locale2,
|
|
732
1154
|
buildRecord: args.createProject === true,
|
|
733
|
-
...args.createProject
|
|
1155
|
+
...args.createProject ? { screenshotNames: resolveScreenshotNames(entries) } : {}
|
|
734
1156
|
});
|
|
735
1157
|
if (!result.ok || !result.panels || !result.panelPresetId) {
|
|
736
1158
|
return errorResult(`render failed: ${result.error ?? "unknown error"}`);
|
|
@@ -740,6 +1162,11 @@ async function handleEmitBundle(deps2, args) {
|
|
|
740
1162
|
previewPng = result.stripBase64 ? Buffer.from(result.stripBase64, "base64") : null;
|
|
741
1163
|
projectRecord = result.projectRecord;
|
|
742
1164
|
}
|
|
1165
|
+
const declaredLocales = collectVariantLocales([...(args.screenshots ?? []).flat(), ...args.panels ?? []]);
|
|
1166
|
+
if (declaredLocales.length > 0) {
|
|
1167
|
+
const hint = `per-locale screenshots detected (${declaredLocales.join(", ")}) \u2014 this call bundled only ${locale2}; pass locales: [...] to emit ONE multi-locale bundle.`;
|
|
1168
|
+
note = note ? `${note} ${hint}` : hint;
|
|
1169
|
+
}
|
|
743
1170
|
const zip = buildFastlaneBundleZip(slots, { bundleId: args.bundleId, locale: locale2, panelLabel });
|
|
744
1171
|
const filename = bundleZipFilename(projectName2);
|
|
745
1172
|
let shareLink;
|
|
@@ -790,6 +1217,116 @@ async function handleEmitBundle(deps2, args) {
|
|
|
790
1217
|
...zipOut
|
|
791
1218
|
});
|
|
792
1219
|
}
|
|
1220
|
+
async function emitMultiLocaleBundle(deps2, args, locales, projectName2) {
|
|
1221
|
+
let localeSlots;
|
|
1222
|
+
let panelLabel;
|
|
1223
|
+
let previewPng = null;
|
|
1224
|
+
let note;
|
|
1225
|
+
if (args.panels && args.panels.length > 0) {
|
|
1226
|
+
const dims = panelDimensionsFor(args.panelPresetId ?? "r69");
|
|
1227
|
+
panelLabel = panelLabelFor(args.panelPresetId ?? "r69", dims.width, dims.height);
|
|
1228
|
+
const refCache = /* @__PURE__ */ new Map();
|
|
1229
|
+
const fetchRef = (ref) => {
|
|
1230
|
+
let pending = refCache.get(ref);
|
|
1231
|
+
if (!pending) {
|
|
1232
|
+
pending = fetchUploadedRef(ref, deps2.userId);
|
|
1233
|
+
refCache.set(ref, pending);
|
|
1234
|
+
}
|
|
1235
|
+
return pending;
|
|
1236
|
+
};
|
|
1237
|
+
localeSlots = [];
|
|
1238
|
+
try {
|
|
1239
|
+
for (const loc of locales) {
|
|
1240
|
+
const refs = args.panels.map((p) => resolveLocaleVariant(p, loc));
|
|
1241
|
+
const bytesList = await Promise.all(refs.map((p) => fetchRef(p.ref)));
|
|
1242
|
+
localeSlots.push({
|
|
1243
|
+
locale: loc,
|
|
1244
|
+
slots: bytesList.map((bytes, i) => ({ displayPosition: i + 1, bytes: Buffer.from(bytes) }))
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
return errorResult(`failed to fetch panel ref: ${err.message}`);
|
|
1249
|
+
}
|
|
1250
|
+
const firstPanel = localeSlots[0]?.slots[0];
|
|
1251
|
+
previewPng = firstPanel ? Buffer.from(firstPanel.bytes) : null;
|
|
1252
|
+
} else {
|
|
1253
|
+
if (!args.screenshots || args.screenshots.length === 0) {
|
|
1254
|
+
return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
|
|
1255
|
+
}
|
|
1256
|
+
const resolved = await resolveLook(deps2, args);
|
|
1257
|
+
note = resolved.note;
|
|
1258
|
+
localeSlots = [];
|
|
1259
|
+
panelLabel = "";
|
|
1260
|
+
for (const loc of locales) {
|
|
1261
|
+
let entries;
|
|
1262
|
+
try {
|
|
1263
|
+
entries = resolveLocaleVariantSlots(args.screenshots, loc);
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
return errorResult(err.message);
|
|
1266
|
+
}
|
|
1267
|
+
let screenshots2;
|
|
1268
|
+
try {
|
|
1269
|
+
screenshots2 = await resolveScreenshots(entries, deps2.userId, deps2.allowLocalScreenshots ?? false);
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
return errorResult(err.message);
|
|
1272
|
+
}
|
|
1273
|
+
const result = await deps2.renderer.render({
|
|
1274
|
+
screenshots: screenshots2,
|
|
1275
|
+
panelPresetId: args.panelPresetId,
|
|
1276
|
+
look: resolved.look,
|
|
1277
|
+
style: resolved.style,
|
|
1278
|
+
locale: loc
|
|
1279
|
+
});
|
|
1280
|
+
if (!result.ok || !result.panels || !result.panelPresetId) {
|
|
1281
|
+
return errorResult(`render failed for locale ${loc}: ${result.error ?? "unknown error"}`);
|
|
1282
|
+
}
|
|
1283
|
+
localeSlots.push({
|
|
1284
|
+
locale: loc,
|
|
1285
|
+
slots: result.panels.map((b64, i) => ({ displayPosition: i + 1, bytes: Buffer.from(b64, "base64") }))
|
|
1286
|
+
});
|
|
1287
|
+
if (!panelLabel) panelLabel = panelLabelFor(result.panelPresetId, result.panelWidth ?? 0, result.panelHeight ?? 0);
|
|
1288
|
+
if (!previewPng && result.stripBase64) previewPng = Buffer.from(result.stripBase64, "base64");
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
const zip = buildMultiLocaleFastlaneBundleZip(localeSlots, { bundleId: args.bundleId, panelLabel });
|
|
1292
|
+
const filename = bundleZipFilename(projectName2);
|
|
1293
|
+
let shareLink;
|
|
1294
|
+
if (args.share) {
|
|
1295
|
+
if (!previewPng) return errorResult("cannot create share link: no preview image available");
|
|
1296
|
+
try {
|
|
1297
|
+
shareLink = await deps2.createShareLink({
|
|
1298
|
+
userId: deps2.userId,
|
|
1299
|
+
zip,
|
|
1300
|
+
previewPng,
|
|
1301
|
+
projectName: projectName2,
|
|
1302
|
+
panelCount: localeSlots[0]?.slots.length ?? 0,
|
|
1303
|
+
bundleId: args.bundleId,
|
|
1304
|
+
// The share row's locale is display metadata — name every locale in the bundle.
|
|
1305
|
+
locale: locales.join(", "),
|
|
1306
|
+
origin: deps2.studioOrigin
|
|
1307
|
+
});
|
|
1308
|
+
} catch (err) {
|
|
1309
|
+
return errorResult(`could not create share link: ${err.message}`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, zip.byteLength);
|
|
1313
|
+
const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps2.userId), filename, zip, "application/zip").then((a) => ({
|
|
1314
|
+
zipUrl: a.url,
|
|
1315
|
+
zipRef: a.ref
|
|
1316
|
+
})) : { zipBase64: Buffer.from(zip).toString("base64") };
|
|
1317
|
+
return textResult({
|
|
1318
|
+
ok: true,
|
|
1319
|
+
filename,
|
|
1320
|
+
bundleId: args.bundleId,
|
|
1321
|
+
locales,
|
|
1322
|
+
panelCount: localeSlots[0]?.slots.length ?? 0,
|
|
1323
|
+
// per locale — total files = panelCount × locales.length
|
|
1324
|
+
output: outputMode,
|
|
1325
|
+
...note ? { note } : {},
|
|
1326
|
+
...shareLink ? { shareLink } : {},
|
|
1327
|
+
...zipOut
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
793
1330
|
async function handleSaveProject(deps2, args) {
|
|
794
1331
|
const { look: look2, style: style2 } = await resolveLook(deps2, args);
|
|
795
1332
|
const locale2 = args.locale?.trim() || "en-US";
|
|
@@ -800,7 +1337,13 @@ async function handleSaveProject(deps2, args) {
|
|
|
800
1337
|
if (slotSizes.some((n) => n < 1)) {
|
|
801
1338
|
return errorResult("every `screenshots` slot must contain at least one screenshot entry.");
|
|
802
1339
|
}
|
|
803
|
-
|
|
1340
|
+
let entries;
|
|
1341
|
+
try {
|
|
1342
|
+
entries = resolveLocaleVariantSlots(args.screenshots, locale2);
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
return errorResult(err.message);
|
|
1345
|
+
}
|
|
1346
|
+
const screenshotNames = resolveScreenshotNames(entries).flat();
|
|
804
1347
|
let record;
|
|
805
1348
|
try {
|
|
806
1349
|
record = await deps2.renderer.buildRecord({
|
|
@@ -856,6 +1399,145 @@ async function handleReadLook(deps2, args) {
|
|
|
856
1399
|
look: saved.look
|
|
857
1400
|
});
|
|
858
1401
|
}
|
|
1402
|
+
function projectFileFromRecord(record) {
|
|
1403
|
+
const inner = record?.project;
|
|
1404
|
+
return inner != null && typeof inner === "object" ? inner : record;
|
|
1405
|
+
}
|
|
1406
|
+
async function handleReadProject(deps2, args) {
|
|
1407
|
+
const project2 = await deps2.resolveProject(deps2.userId, args.project);
|
|
1408
|
+
if (!project2) {
|
|
1409
|
+
return textResult({
|
|
1410
|
+
saved: false,
|
|
1411
|
+
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."
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
const rec = await deps2.readProjectRecord(project2.id);
|
|
1415
|
+
if (!rec) {
|
|
1416
|
+
return textResult({
|
|
1417
|
+
saved: false,
|
|
1418
|
+
project: { id: project2.id, name: project2.name },
|
|
1419
|
+
message: `Project "${project2.name}" has no saved record yet \u2014 open it in Storeframe Studio and save a strip first.`
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
return textResult({
|
|
1423
|
+
saved: true,
|
|
1424
|
+
project: { id: project2.id, name: project2.name },
|
|
1425
|
+
updatedAt: rec.updatedAt,
|
|
1426
|
+
pinnedVersion: project2.pinnedVersion,
|
|
1427
|
+
// null = renders follow the latest look version
|
|
1428
|
+
project_file: projectFileFromRecord(rec.record),
|
|
1429
|
+
message: `Full project for "${project2.name}". project_file is the designer's current strip (frame order in \`panels\`, per-locale words in \`captionText\`, \`locales\`, styling). To detect changes, remember updatedAt and compare on your next read_project \u2014 a newer value means the designer edited it.`
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
function frameNamesFromProjectFile(projectFile) {
|
|
1433
|
+
const shots = projectFile?.shots;
|
|
1434
|
+
if (!Array.isArray(shots)) return [];
|
|
1435
|
+
return shots.map((s) => s?.frameName).filter((n) => typeof n === "string" && n.length > 0);
|
|
1436
|
+
}
|
|
1437
|
+
function handoffRefsFromRecord(record) {
|
|
1438
|
+
const raw = record?.handoffRefs;
|
|
1439
|
+
if (!raw || typeof raw !== "object") return {};
|
|
1440
|
+
const out = {};
|
|
1441
|
+
for (const [name, ref] of Object.entries(raw)) {
|
|
1442
|
+
if (typeof ref === "string" && ref.length > 0) out[name] = ref;
|
|
1443
|
+
}
|
|
1444
|
+
return out;
|
|
1445
|
+
}
|
|
1446
|
+
async function handleRenderProject(deps2, args) {
|
|
1447
|
+
const project2 = await deps2.resolveProject(deps2.userId, args.project);
|
|
1448
|
+
if (!project2) {
|
|
1449
|
+
return errorResult(
|
|
1450
|
+
args.project ? "No such project on this account." : "No Storeframe project on this account yet \u2014 open Storeframe Studio and load a strip first."
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
const rec = await deps2.readProjectRecord(project2.id);
|
|
1454
|
+
if (!rec) {
|
|
1455
|
+
return errorResult(`Project "${project2.name}" has no saved record yet \u2014 open it in Storeframe Studio and save a strip first.`);
|
|
1456
|
+
}
|
|
1457
|
+
const projectFile = projectFileFromRecord(rec.record);
|
|
1458
|
+
const nested = args.screenshots.map((e) => [e]);
|
|
1459
|
+
let resolvedBytes;
|
|
1460
|
+
try {
|
|
1461
|
+
resolvedBytes = await resolveScreenshots(nested, deps2.userId, deps2.allowLocalScreenshots ?? false);
|
|
1462
|
+
} catch (err) {
|
|
1463
|
+
return errorResult(err.message);
|
|
1464
|
+
}
|
|
1465
|
+
const names = resolveScreenshotNames(nested);
|
|
1466
|
+
const screensByName = {};
|
|
1467
|
+
let unnamed = 0;
|
|
1468
|
+
names.forEach((slot2, i) => {
|
|
1469
|
+
const name = slot2[0];
|
|
1470
|
+
if (name) screensByName[name] = resolvedBytes[i][0];
|
|
1471
|
+
else unnamed++;
|
|
1472
|
+
});
|
|
1473
|
+
const expected = frameNamesFromProjectFile(projectFile);
|
|
1474
|
+
const handoffRefs = handoffRefsFromRecord(rec.record);
|
|
1475
|
+
const handoffUsed = [];
|
|
1476
|
+
if (deps2.fetchHandoffBytes) {
|
|
1477
|
+
for (const frameName of expected) {
|
|
1478
|
+
if (screensByName[frameName]) continue;
|
|
1479
|
+
const ref = handoffRefs[frameName];
|
|
1480
|
+
if (!ref) continue;
|
|
1481
|
+
try {
|
|
1482
|
+
const bytes = await deps2.fetchHandoffBytes(ref);
|
|
1483
|
+
screensByName[frameName] = Buffer.from(bytes).toString("base64");
|
|
1484
|
+
handoffUsed.push(frameName);
|
|
1485
|
+
} catch {
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (expected.length > 0 && !expected.some((n) => screensByName[n])) {
|
|
1490
|
+
return errorResult(
|
|
1491
|
+
`None of the supplied screenshots matched this project's frames by filename. This project expects: ${expected.join(", ")}. Pass each raw screenshot with its original \`name\` (its filename), in any order \u2014 render_project matches by name.`
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
const locale2 = args.locale?.trim() || void 0;
|
|
1495
|
+
const result = await deps2.renderer.renderProject({ projectFile, screensByName, locale: locale2, preview: args.preview });
|
|
1496
|
+
if (!result.ok || !result.panels) {
|
|
1497
|
+
return errorResult(`render failed: ${result.error ?? "unknown error"}`);
|
|
1498
|
+
}
|
|
1499
|
+
const shots = result.shots ?? [];
|
|
1500
|
+
const missing = shots.filter((s) => !s.matched).map((s) => s.frameName);
|
|
1501
|
+
const totalBytes = result.panels.reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
|
|
1502
|
+
const outputMode = resolveOutputMode(deps2.forceOutput ?? args.output, totalBytes);
|
|
1503
|
+
const panels = outputMode === "urls" ? await uploadPanels(deps2.userId, result.panels) : result.panels;
|
|
1504
|
+
const notes = [];
|
|
1505
|
+
if (handoffUsed.length > 0) {
|
|
1506
|
+
notes.push(
|
|
1507
|
+
`${handoffUsed.length} shot(s) were resolved from the designer's uploaded screenshots (auto-handoff): ${handoffUsed.join(", ")}.`
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
if (missing.length > 0) {
|
|
1511
|
+
notes.push(
|
|
1512
|
+
`${missing.length} shot(s) had no matching screenshot and rendered without a device: ${missing.join(", ")}. Supply those files (by filename) to complete the strip.`
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
if (unnamed > 0) {
|
|
1516
|
+
notes.push(
|
|
1517
|
+
`${unnamed} supplied screenshot(s) had no filename (name) and could not be matched \u2014 pass each screenshot's original filename via \`name\`.`
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
return textResult({
|
|
1521
|
+
ok: true,
|
|
1522
|
+
count: result.count,
|
|
1523
|
+
panelPresetId: result.panelPresetId,
|
|
1524
|
+
panelWidth: result.panelWidth,
|
|
1525
|
+
panelHeight: result.panelHeight,
|
|
1526
|
+
renderMs: result.ms,
|
|
1527
|
+
output: outputMode,
|
|
1528
|
+
locale: result.locale ?? locale2 ?? "en-US",
|
|
1529
|
+
project: { id: project2.id, name: project2.name },
|
|
1530
|
+
pinnedVersion: project2.pinnedVersion,
|
|
1531
|
+
// echoed for transparency; the render uses the record's saved look
|
|
1532
|
+
shots,
|
|
1533
|
+
// [{ frameName, matched }] — which of the project's shots got developer bytes
|
|
1534
|
+
...handoffUsed.length > 0 ? { handoffUsed } : {},
|
|
1535
|
+
// shots resolved from the designer's uploads
|
|
1536
|
+
...missing.length > 0 ? { missing } : {},
|
|
1537
|
+
...notes.length > 0 ? { note: notes.join(" ") } : {},
|
|
1538
|
+
panels
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
859
1541
|
async function handleSaveLook(deps2, args) {
|
|
860
1542
|
const project2 = await deps2.resolveProject(deps2.userId, args.project);
|
|
861
1543
|
if (!project2) {
|
|
@@ -888,10 +1570,12 @@ async function handleRequestScreenshotUpload(deps2, args) {
|
|
|
888
1570
|
} catch (err) {
|
|
889
1571
|
return errorResult(err.message);
|
|
890
1572
|
}
|
|
1573
|
+
const locale2 = args.locale?.trim() || void 0;
|
|
891
1574
|
return textResult({
|
|
892
1575
|
ok: true,
|
|
893
1576
|
slots,
|
|
894
|
-
|
|
1577
|
+
...locale2 ? { locale: locale2 } : {},
|
|
1578
|
+
instructions: 'PUT each screenshot\'s raw PNG bytes to its uploadUrl, e.g.: curl -T screenshot.png "<uploadUrl>" \u2014 then pass { "ref": "<the matching ref>" } as that screenshot\'s entry in render_strip/emit_bundle\'s `screenshots` array. If you passed `names`, that ref already carries its filename \u2014 no need to also set `name` on the screenshot entry. Uploads are not auto-purged yet \u2014 treat refs as short-lived and single-use.' + (locale2 ? ` These slots are tagged for ${locale2}: place each ref under that key of a per-locale screenshot entry, e.g. { "locales": { "${locale2}": { "ref": "<ref>" } } }.` : "")
|
|
895
1579
|
});
|
|
896
1580
|
}
|
|
897
1581
|
var SERVER_INSTRUCTIONS = [
|
|
@@ -910,6 +1594,13 @@ var SERVER_INSTRUCTIONS = [
|
|
|
910
1594
|
"3. emit_bundle({ screenshots|panels, bundleId }) \u2014 a fastlane deliver-ready zip. Pass a prior",
|
|
911
1595
|
' render_strip output:"urls" result as `panels` to package without re-rendering.',
|
|
912
1596
|
"",
|
|
1597
|
+
"## Input hygiene \u2014 feed RAW app screenshots, never already-composed panels",
|
|
1598
|
+
"render_strip expects RAW in-app screen captures (no device frame, no caption). Do NOT feed it a",
|
|
1599
|
+
"previously-rendered Storeframe panel, an exported fastlane image, or any screenshot that already",
|
|
1600
|
+
"has a phone frame or caption baked in \u2014 it double-frames (a phone inside a phone). If a folder",
|
|
1601
|
+
"holds both raw captures and finished panels, use the raw ones. Rule of thumb: if it already",
|
|
1602
|
+
"looks like an App Store screenshot, it is the WRONG input.",
|
|
1603
|
+
"",
|
|
913
1604
|
"## Big jobs: render, THEN bundle (do NOT render+zip in one call for 4+ panels)",
|
|
914
1605
|
"A full-resolution render is seconds PER PANEL and runs one panel at a time, so a 5-panel",
|
|
915
1606
|
"full-res render can take a couple of minutes \u2014 long enough that a single render+zip call",
|
|
@@ -920,32 +1611,63 @@ var SERVER_INSTRUCTIONS = [
|
|
|
920
1611
|
"(near-instant, no re-render). Preview (preview:true) is always fast and safe for iteration \u2014",
|
|
921
1612
|
"only the FINAL full-res pass is the slow one, so split that one.",
|
|
922
1613
|
"",
|
|
1614
|
+
"## Per-locale screenshots \u2192 ONE multi-locale bundle",
|
|
1615
|
+
'A screenshot entry can carry per-locale variants: { "locales": { "en-US": { "ref": "\u2026" },',
|
|
1616
|
+
'"de-DE": { "ref": "\u2026" } } }. render_strip picks the top-level `locale`\'s variant; a locale',
|
|
1617
|
+
"with no variant of its own falls back to the en-US one (ship German captions over English",
|
|
1618
|
+
'screens now, upgrade the pixels later). emit_bundle({ locales: ["en-US","de-DE"], \u2026 }) emits',
|
|
1619
|
+
"ONE zip with a fastlane/screenshots/<locale>/ folder per locale \u2014 fastlane deliver uploads",
|
|
1620
|
+
"every locale in a single run. request_screenshot_upload({ count, names, locale }) tags a",
|
|
1621
|
+
"batch of upload slots with the locale it is for (echoed back; bookkeeping only).",
|
|
1622
|
+
"RECOMMENDED multi-locale flow \u2014 render per locale, then compose ONCE (this is also the only",
|
|
1623
|
+
"way to get per-locale CAPTION text into one bundle, since caption text is per-render input):",
|
|
1624
|
+
`1. per locale L: render_strip({ locale: L, output: "urls", style: { captions: [<L's words>] },`,
|
|
1625
|
+
` screenshots: [<L's screens or { "locales": \u2026 } entries>] }) \u2192 per-panel refs for L.`,
|
|
1626
|
+
'2. emit_bundle({ locales: [...], bundleId, panels: [ { "locales": { "en-US": { "ref": p1en },',
|
|
1627
|
+
' "de-DE": { "ref": p1de } } }, \u2026 ] }) \u2014 zips every locale with NO re-render, near-instant.',
|
|
1628
|
+
"The one-call form (emit_bundle with `screenshots` + `locales`) works but renders once PER",
|
|
1629
|
+
"locale with the SAME captions for every locale \u2014 and multiplies the slow full-res render by",
|
|
1630
|
+
"the locale count (see Big jobs above), so prefer the compose flow for real jobs.",
|
|
1631
|
+
"",
|
|
923
1632
|
"## Styling \u2014 you can fully art-direct the strip via the `style` object",
|
|
924
1633
|
"Call describe_look for the exact field catalog + defaults; this is the map of what to reach for.",
|
|
925
1634
|
"Three groups (all optional \u2014 omit for a sensible dark default):",
|
|
926
1635
|
"- style.shotLook (the device, same for every phone): angle (front|left|right), material",
|
|
927
1636
|
" (real|clay), colorway (orange|blue|silver|custom+customColor), finish (0 matte\u20261 glossy),",
|
|
928
|
-
"
|
|
929
|
-
"
|
|
1637
|
+
" clearcoat (0\u20261 glossy clear-coat lacquer, on top of finish), roll (-45\u202645\xB0 tilt),",
|
|
1638
|
+
" phoneHeight (20\u2026100 % of panel), hOffset/vOffset to nudge position, glare, reflections,",
|
|
1639
|
+
" lighting, flatScreen.",
|
|
930
1640
|
"- 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)
|
|
933
|
-
"
|
|
934
|
-
"
|
|
1641
|
+
" gradientDir vertical|horizontal, shadow, floorReflection; or panelColors[] (one hex per slot) for perPanel.",
|
|
1642
|
+
"- style.captions[] (ONE entry per panel, in slot order; null = no caption). An entry is EITHER a",
|
|
1643
|
+
" single caption object OR an array of layers stacked on that panel: [{text,\u2026},{text,\u2026}]. Fields:",
|
|
1644
|
+
" text (this layer\u2019s words), subtitle (single-caption shape only \u2014 in the array shape add another",
|
|
1645
|
+
" layer instead), fontId (inter|manrope|poppins|fraunces|space-grotesk), sizePt, color (hex),",
|
|
1646
|
+
" align, anchor {x,y} (0\u20131 position; OMIT to auto-place above the device), maxWidth (0\u20131 fraction).",
|
|
935
1647
|
"panelPresetId picks the App Store size: r69 (6.9\u2033, default, 1290\xD72796) | r65 | r55 | ipad13 |",
|
|
936
1648
|
"ipad129. Match it to the pixel size of your source screenshots (1320\xD72868 or 1290\xD72796 \u2192 r69).",
|
|
1649
|
+
"Per-panel variety (a DIFFERENT device per panel \u2014 mixed-device or continuous-scene strips):",
|
|
1650
|
+
"style.shotLook is ONE device for the WHOLE strip. To style each panel differently, pass a `look`",
|
|
1651
|
+
"instead of style \u2014 a look carries `shots[]`, ONE entry per panel in slot order, each with its own",
|
|
1652
|
+
'`look` (same fields as shotLook). e.g. look:{ shots:[ {panelId:"panel-1",look:{angle:"left"}},',
|
|
1653
|
+
'{panelId:"panel-2",look:{angle:"front",colorway:"orange"}} ] } tilts panel 1 and recolours panel 2',
|
|
1654
|
+
"in one render_strip call. This is the exact shape read_look/save_look use; describe_look details it.",
|
|
1655
|
+
"Continuous scene: panels have no hard divider \u2014 a phone pushed past its panel edge with a large",
|
|
1656
|
+
"hOffset overflows into the NEIGHBOURING panel, so one device (or a multi-phone fan) can span",
|
|
1657
|
+
"several panels and the strip reads as one swipe-through image. Deliberate \u2014 preview to place it.",
|
|
937
1658
|
"",
|
|
938
|
-
"## Caption guardrails (do this or captions crowd
|
|
939
|
-
'Defaults are fontId "inter", sizePt 44,
|
|
940
|
-
"
|
|
941
|
-
"
|
|
942
|
-
"
|
|
943
|
-
|
|
944
|
-
"
|
|
945
|
-
"
|
|
946
|
-
" the
|
|
947
|
-
|
|
948
|
-
"
|
|
1659
|
+
"## Caption guardrails (do this or captions crowd)",
|
|
1660
|
+
'Defaults are fontId "inter", sizePt 44, maxWidth 0.86, and NO anchor (auto). By default a caption',
|
|
1661
|
+
"AUTO-LAYOUTS in a band above the device and the phone is pushed down so the two never collide \u2014",
|
|
1662
|
+
"even a headline that wraps to two lines, or several stacked layers, clears the phone automatically.",
|
|
1663
|
+
"So you rarely need to set anchor at all. To keep it reading well:",
|
|
1664
|
+
'- Punchy, benefit-led headlines read best ("Track every throw", "See your stats"), one idea per',
|
|
1665
|
+
" panel. A consistent fontId + color across all panels makes the strip feel designed.",
|
|
1666
|
+
"- Want a headline + a smaller subline? Pass an ARRAY of layers for that panel \u2014 the first larger,",
|
|
1667
|
+
" the second smaller (lower sizePt) \u2014 instead of one caption; they stack in order in the band.",
|
|
1668
|
+
"- Keep headlines fairly short; or drop sizePt to ~36\u201340, or raise maxWidth toward 1.0, to reduce",
|
|
1669
|
+
" wrapping if a line looks too tight. Setting anchor {x,y} OVERRIDES auto-layout \u2014 the caption is",
|
|
1670
|
+
" then pinned exactly there (it will not dodge the phone), so only set it when you want that.",
|
|
949
1671
|
"",
|
|
950
1672
|
"## Two kinds of state, kept separate on purpose",
|
|
951
1673
|
"- STYLING (the look \u2014 device, background, caption styling) is reusable: save_look persists it",
|
|
@@ -988,6 +1710,27 @@ var SERVER_INSTRUCTIONS = [
|
|
|
988
1710
|
"Skip the names and the record falls back to positional filenames (screen-1.png, screen-2.png\u2026)",
|
|
989
1711
|
"the user's folder can't match \u2014 the single worst create-project failure mode. Pass the real filenames.",
|
|
990
1712
|
"",
|
|
1713
|
+
'## "Any updates?" \u2014 read back what a designer changed (read_project)',
|
|
1714
|
+
"read_look returns only STYLING. To see what a designer edited in the web app \u2014 frame ORDER, the",
|
|
1715
|
+
"per-locale caption WORDS, the locale list, structural changes \u2014 call read_project. It returns the",
|
|
1716
|
+
"full project as `project_file` (an opaque ProjectFile: `panels` in the designer's order,",
|
|
1717
|
+
"`captionText` keyed by locale then panel, `locales`, plus the styling) together with `updatedAt`.",
|
|
1718
|
+
'To answer a recurring "any updates?": remember the `updatedAt` you last saw and compare on the next',
|
|
1719
|
+
"read_project \u2014 a newer value means the designer changed something; re-read project_file to see what.",
|
|
1720
|
+
"read_project is read-only and never returns screenshots (zero-custody). To reproduce the designer's",
|
|
1721
|
+
"exact strip, feed the project to render_project with YOUR OWN raw screenshots (matched by filename).",
|
|
1722
|
+
"",
|
|
1723
|
+
"## Reproduce a saved project (render_project)",
|
|
1724
|
+
"render_project({ project?, screenshots }) re-renders a SAVED project with YOUR OWN raw screenshots \u2014",
|
|
1725
|
+
"the headless twin of opening the project in the web app and re-loading the raws. The PROJECT owns the",
|
|
1726
|
+
"structure (frame order, per-locale captions, styling); you just supply the pixels. Pass `screenshots`",
|
|
1727
|
+
"as a FLAT list of entries (base64 / { ref } / { url }), EACH with its original filename as `name` \u2014",
|
|
1728
|
+
"they are matched to the project's shots BY filename, so the ORDER you pass them in does not matter and",
|
|
1729
|
+
"you must NOT pre-sort. Pass `locale` to render that locale's caption words (the project already holds",
|
|
1730
|
+
"them). A shot with no matching file renders without its device and is listed in the result's `shots`",
|
|
1731
|
+
"/`missing` report \u2014 supply that filename to complete the strip. Typical flow: read_project to see what",
|
|
1732
|
+
"the designer changed, then render_project passing your raws by their real filenames.",
|
|
1733
|
+
"",
|
|
991
1734
|
"## Refine visually, then re-render headlessly",
|
|
992
1735
|
"A human can refine the look in Storeframe Studio (https://storeframe-studio.vercel.app \u2014 sign",
|
|
993
1736
|
"in as the SAME account) and Save look; read_look then returns exactly that, so an agent",
|
|
@@ -999,7 +1742,7 @@ function registerTools(server2, deps2) {
|
|
|
999
1742
|
"render_strip",
|
|
1000
1743
|
{
|
|
1001
1744
|
title: "Render App Store strip",
|
|
1002
|
-
description:
|
|
1745
|
+
description: 'Turn app screenshots into styled 3D device mockups laid out as an App Store screenshot strip, and return the finished per-panel PNGs. Optionally style with a saved look, render a cheap low-res `preview`, and get results `inline` or as `urls` for large payloads. Screenshots can vary per App Store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots, call request_screenshot_upload first \u2014 inline base64 is for small payloads only. No store credential is involved \u2014 this only renders images.',
|
|
1003
1746
|
inputSchema: renderStripShape
|
|
1004
1747
|
},
|
|
1005
1748
|
(args) => handleRenderStrip(deps2, args)
|
|
@@ -1008,7 +1751,7 @@ function registerTools(server2, deps2) {
|
|
|
1008
1751
|
"emit_bundle",
|
|
1009
1752
|
{
|
|
1010
1753
|
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.',
|
|
1754
|
+
description: 'Render the strip (or package pre-rendered `panels` refs from a prior render_strip `output: "urls"` call, without re-rendering), then package it as a `fastlane deliver`-ready zip (screenshots + a pre-filled, never-submitting Deliverfile + a README). Pass `locales: [...]` for ONE multi-locale bundle \u2014 a fastlane/screenshots/<locale>/ folder per locale, best combined with per-locale { "locales": \u2026 } screenshot/panel entries. Returns the zip `inline` or as a `url` for large payloads, and can also mint a 14-day share link to a landing page. YOU upload it with your own fastlane \u2014 this server never touches a store credential and never submits for review.',
|
|
1012
1755
|
inputSchema: emitBundleShape
|
|
1013
1756
|
},
|
|
1014
1757
|
(args) => handleEmitBundle(deps2, args)
|
|
@@ -1031,6 +1774,24 @@ function registerTools(server2, deps2) {
|
|
|
1031
1774
|
},
|
|
1032
1775
|
(args) => handleReadLook(deps2, args)
|
|
1033
1776
|
);
|
|
1777
|
+
server2.registerTool(
|
|
1778
|
+
"read_project",
|
|
1779
|
+
{
|
|
1780
|
+
title: "Read full project",
|
|
1781
|
+
description: "Return a project's FULL current state \u2014 the designer's frame order (panels), per-locale caption words (captionText), locale list, and styling \u2014 as an opaque ProjectFile, NOT just the look. Use it to answer \"what did the designer change?\": remember the returned `updatedAt` and re-read later; a newer value means the strip was edited. Read-only, no store credential. To re-render this project with your own screenshots, pass it to render_project.",
|
|
1782
|
+
inputSchema: readProjectShape
|
|
1783
|
+
},
|
|
1784
|
+
(args) => handleReadProject(deps2, args)
|
|
1785
|
+
);
|
|
1786
|
+
server2.registerTool(
|
|
1787
|
+
"render_project",
|
|
1788
|
+
{
|
|
1789
|
+
title: "Render a saved project",
|
|
1790
|
+
description: "Re-render a SAVED Storeframe project with your OWN raw screenshots \u2014 the headless twin of opening the project in the web app and re-loading the raws. Pass the developer's screenshots as a FLAT list (each with its original filename as `name`); they are matched to the project's shots BY filename, so ORDER does not matter and you never pre-sort. The project supplies the structure \u2014 frame order, per-locale caption words (pick one with `locale`), styling \u2014 so the result reproduces the designer's exact strip. Shots with no matching file are reported as missing (not an error). Returns per-panel PNGs (`inline` or `urls`). No store credential.",
|
|
1791
|
+
inputSchema: renderProjectShape
|
|
1792
|
+
},
|
|
1793
|
+
(args) => handleRenderProject(deps2, args)
|
|
1794
|
+
);
|
|
1034
1795
|
server2.registerTool(
|
|
1035
1796
|
"save_look",
|
|
1036
1797
|
{
|
|
@@ -1053,7 +1814,7 @@ function registerTools(server2, deps2) {
|
|
|
1053
1814
|
"request_screenshot_upload",
|
|
1054
1815
|
{
|
|
1055
1816
|
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.",
|
|
1817
|
+
description: "Mint signed upload URLs so real screenshots' bytes never transit this conversation's context \u2014 PUT bytes directly to the returned URLs, then pass the returned `ref`s into render_strip/emit_bundle. Use this instead of inline base64 for real (full-resolution) screenshots. Uploading one batch per App Store locale? Tag each batch with `locale` (echoed back for your bookkeeping).",
|
|
1057
1818
|
inputSchema: requestScreenshotUploadShape
|
|
1058
1819
|
},
|
|
1059
1820
|
(args) => handleRequestScreenshotUpload(deps2, args)
|
|
@@ -1079,119 +1840,13 @@ import { chromium } from "playwright";
|
|
|
1079
1840
|
import { createServer as createServer2 } from "vite";
|
|
1080
1841
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1081
1842
|
|
|
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
1843
|
// ../mockup-engine/stripFromSlots.ts
|
|
1189
1844
|
var FALLBACK_GRADIENT_FROM = "#1b1b2e";
|
|
1190
1845
|
var FALLBACK_GRADIENT_TO = "#0a0a14";
|
|
1191
1846
|
function staggeredHOffset(k) {
|
|
1192
1847
|
return String(Math.max(-100, Math.min(100, k * 35)));
|
|
1193
1848
|
}
|
|
1194
|
-
var NUMERIC_STRING_FIELDS = ["roll", "phoneHeight", "hOffset", "vOffset", "finish"];
|
|
1849
|
+
var NUMERIC_STRING_FIELDS = ["roll", "phoneHeight", "hOffset", "vOffset", "finish", "clearcoat"];
|
|
1195
1850
|
function coerceShotLook(raw) {
|
|
1196
1851
|
if (!raw || typeof raw !== "object") return {};
|
|
1197
1852
|
const out = { ...raw };
|
|
@@ -1200,6 +1855,14 @@ function coerceShotLook(raw) {
|
|
|
1200
1855
|
}
|
|
1201
1856
|
return out;
|
|
1202
1857
|
}
|
|
1858
|
+
function coerceCaptionLayer(el) {
|
|
1859
|
+
const { subtitle: _subtitle, sizePt, maxWidth, ...rest } = el;
|
|
1860
|
+
return {
|
|
1861
|
+
...rest,
|
|
1862
|
+
...sizePt !== void 0 ? { sizePt: Number(sizePt) } : {},
|
|
1863
|
+
...maxWidth !== void 0 ? { maxWidth: Number(maxWidth) } : {}
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1203
1866
|
function remapByOrdinal(record, sourceOrder, targetIds) {
|
|
1204
1867
|
const out = {};
|
|
1205
1868
|
if (!record) return out;
|
|
@@ -1212,7 +1875,7 @@ function remapByOrdinal(record, sourceOrder, targetIds) {
|
|
|
1212
1875
|
function stripFromSlots(input) {
|
|
1213
1876
|
const slotSizes = input.slotSizes.filter((n) => Number.isInteger(n) && n > 0);
|
|
1214
1877
|
const panelIds = slotSizes.map((_, i) => `panel-${i + 1}`);
|
|
1215
|
-
|
|
1878
|
+
let captionLayers = {};
|
|
1216
1879
|
const opaque = !input.style && input.look && typeof input.look === "object" ? input.look : null;
|
|
1217
1880
|
const style2 = input.style ?? null;
|
|
1218
1881
|
const opaqueShots = opaque && Array.isArray(opaque.shots) ? opaque.shots : [];
|
|
@@ -1229,11 +1892,13 @@ function stripFromSlots(input) {
|
|
|
1229
1892
|
});
|
|
1230
1893
|
let panelColors = {};
|
|
1231
1894
|
let captionStyles = {};
|
|
1895
|
+
let panelBackgrounds = {};
|
|
1232
1896
|
let bgMode = "gradient";
|
|
1233
1897
|
let gradientFrom = FALLBACK_GRADIENT_FROM;
|
|
1234
1898
|
let gradientTo = FALLBACK_GRADIENT_TO;
|
|
1235
1899
|
let gradientDir = "vertical";
|
|
1236
1900
|
let shadow = true;
|
|
1901
|
+
let floorReflection = false;
|
|
1237
1902
|
if (style2) {
|
|
1238
1903
|
const bg = style2.background ?? {};
|
|
1239
1904
|
bgMode = bg.mode === "perPanel" ? "perPanel" : "gradient";
|
|
@@ -1241,6 +1906,7 @@ function stripFromSlots(input) {
|
|
|
1241
1906
|
if (typeof bg.gradientTo === "string") gradientTo = bg.gradientTo;
|
|
1242
1907
|
if (bg.gradientDir === "horizontal") gradientDir = "horizontal";
|
|
1243
1908
|
if (bg.shadow === false) shadow = false;
|
|
1909
|
+
if (bg.floorReflection === true) floorReflection = true;
|
|
1244
1910
|
if (Array.isArray(bg.panelColors)) {
|
|
1245
1911
|
bg.panelColors.forEach((color, i) => {
|
|
1246
1912
|
if (typeof color === "string" && color && panelIds[i] !== void 0) panelColors[panelIds[i]] = color;
|
|
@@ -1248,22 +1914,28 @@ function stripFromSlots(input) {
|
|
|
1248
1914
|
}
|
|
1249
1915
|
if (Array.isArray(style2.captions)) {
|
|
1250
1916
|
const rawStyles = {};
|
|
1917
|
+
const inner = {};
|
|
1251
1918
|
style2.captions.forEach((entry, i) => {
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
...
|
|
1919
|
+
const panelId = panelIds[i];
|
|
1920
|
+
if (panelId === void 0 || entry === null || entry === void 0) return;
|
|
1921
|
+
if (Array.isArray(entry)) {
|
|
1922
|
+
const layers = entry.filter((el) => !!el && typeof el === "object" && typeof el.text === "string" && el.text.trim() !== "").map(coerceCaptionLayer);
|
|
1923
|
+
if (layers.length > 0) inner[panelId] = layers;
|
|
1924
|
+
} else if (typeof entry === "object") {
|
|
1925
|
+
const { text, subtitle, sizePt, maxWidth, ...rest } = entry;
|
|
1926
|
+
rawStyles[panelId] = {
|
|
1927
|
+
...rest,
|
|
1928
|
+
...sizePt !== void 0 ? { sizePt: Number(sizePt) } : {},
|
|
1929
|
+
...maxWidth !== void 0 ? { maxWidth: Number(maxWidth) } : {}
|
|
1263
1930
|
};
|
|
1931
|
+
if (typeof text === "string" && text.trim()) {
|
|
1932
|
+
inner[panelId] = { headline: text, ...typeof subtitle === "string" && subtitle ? { subtitle } : {} };
|
|
1933
|
+
}
|
|
1264
1934
|
}
|
|
1265
1935
|
});
|
|
1266
|
-
|
|
1936
|
+
const localeLayers = normalizeCaptionText({ [DEFAULT_LOCALE]: inner }, normalizeCaptionStyles(rawStyles));
|
|
1937
|
+
captionStyles = captionStylesFromLayers(localeLayers);
|
|
1938
|
+
captionLayers = resolveCaptionText(localeLayers);
|
|
1267
1939
|
}
|
|
1268
1940
|
} else if (opaque) {
|
|
1269
1941
|
bgMode = opaque.bgMode === "perPanel" ? "perPanel" : "gradient";
|
|
@@ -1271,19 +1943,33 @@ function stripFromSlots(input) {
|
|
|
1271
1943
|
if (typeof opaque.gradientTo === "string") gradientTo = opaque.gradientTo;
|
|
1272
1944
|
if (opaque.gradientDir === "horizontal") gradientDir = "horizontal";
|
|
1273
1945
|
if (opaque.shadow === false) shadow = false;
|
|
1946
|
+
if (opaque.floorReflection === true) floorReflection = true;
|
|
1274
1947
|
const sourceOrder = [];
|
|
1275
1948
|
for (const s of opaqueShots) {
|
|
1276
1949
|
const id = typeof s?.panelId === "string" ? s.panelId : null;
|
|
1277
1950
|
if (id && !sourceOrder.includes(id)) sourceOrder.push(id);
|
|
1278
1951
|
}
|
|
1279
1952
|
panelColors = remapByOrdinal(opaque.panelColors, sourceOrder, panelIds);
|
|
1280
|
-
captionStyles = remapByOrdinal(
|
|
1953
|
+
captionStyles = remapByOrdinal(normalizeCaptionLayerStyles(opaque.captionStyles), sourceOrder, panelIds);
|
|
1954
|
+
panelBackgrounds = remapByOrdinal(normalizePanelBackgrounds(opaque.panelBackgrounds), sourceOrder, panelIds);
|
|
1281
1955
|
}
|
|
1282
1956
|
const panelPresetId2 = input.panelPresetId ?? (typeof opaque?.panelPresetId === "string" ? opaque.panelPresetId : void 0) ?? "r69";
|
|
1283
1957
|
return {
|
|
1284
|
-
look: {
|
|
1958
|
+
look: {
|
|
1959
|
+
panelPresetId: panelPresetId2,
|
|
1960
|
+
bgMode,
|
|
1961
|
+
gradientFrom,
|
|
1962
|
+
gradientTo,
|
|
1963
|
+
gradientDir,
|
|
1964
|
+
panelColors,
|
|
1965
|
+
shadow,
|
|
1966
|
+
floorReflection,
|
|
1967
|
+
panelBackgrounds,
|
|
1968
|
+
captionStyles,
|
|
1969
|
+
shots
|
|
1970
|
+
},
|
|
1285
1971
|
panelIds,
|
|
1286
|
-
captionText
|
|
1972
|
+
captionText: captionLayers
|
|
1287
1973
|
};
|
|
1288
1974
|
}
|
|
1289
1975
|
|
|
@@ -1330,6 +2016,7 @@ function recordFromStrip(input) {
|
|
|
1330
2016
|
gradientDir: look2.gradientDir,
|
|
1331
2017
|
panelColors: look2.panelColors,
|
|
1332
2018
|
shadow: look2.shadow,
|
|
2019
|
+
panelBackgrounds: look2.panelBackgrounds,
|
|
1333
2020
|
captionStyles: look2.captionStyles,
|
|
1334
2021
|
// Nest this render's flat caption text under its locale (default en-US) so the stored blob
|
|
1335
2022
|
// is locale-correct; serializeProject's normalizeCaptionText passes a nested map through.
|
|
@@ -1469,6 +2156,17 @@ var StripRenderer = class {
|
|
|
1469
2156
|
}
|
|
1470
2157
|
return result;
|
|
1471
2158
|
}
|
|
2159
|
+
// render_project: same page + boot handshake as render(); only the harness entry differs
|
|
2160
|
+
// (window.renderProject restores the saved ProjectFile and joins bytes by frameName).
|
|
2161
|
+
async renderProject(input) {
|
|
2162
|
+
await this.boot();
|
|
2163
|
+
if (!this.page) return { ok: false, error: "renderer failed to boot" };
|
|
2164
|
+
const result = await this.page.evaluate((inp) => window.renderProject(inp), input);
|
|
2165
|
+
if (this.sawContextLost) {
|
|
2166
|
+
console.warn("[renderer] CONTEXT_LOST observed since boot");
|
|
2167
|
+
}
|
|
2168
|
+
return result;
|
|
2169
|
+
}
|
|
1472
2170
|
async close() {
|
|
1473
2171
|
await this.page?.close().catch(() => {
|
|
1474
2172
|
});
|
|
@@ -1490,6 +2188,57 @@ var StripRenderer = class {
|
|
|
1490
2188
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
1491
2189
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
1492
2190
|
import { dirname } from "node:path";
|
|
2191
|
+
|
|
2192
|
+
// package.json
|
|
2193
|
+
var package_default = {
|
|
2194
|
+
name: "storeframe-mcp",
|
|
2195
|
+
version: "0.2.0",
|
|
2196
|
+
private: false,
|
|
2197
|
+
type: "module",
|
|
2198
|
+
description: "The bundle-emitting MCP server over the @engine/@sync spine. Exposes render_strip, emit_bundle, read_look to agents (Claude Code / Cursor / CI) over Streamable HTTP, auth'd with personal Storeframe API tokens. Zero-custody: no code path accepts, stores, or forwards a store-signing credential \u2014 upload stays the user's own fastlane. Renders via headless Playwright Chromium, the engine's native non-browser habitat (same path as mockup-mcp). Also ships as a free LOCAL stdio server (`npx storeframe-mcp`) \u2014 same render, on your own machine, no account needed.",
|
|
2199
|
+
license: "UNLICENSED",
|
|
2200
|
+
bin: {
|
|
2201
|
+
"storeframe-mcp": "./dist/local.js"
|
|
2202
|
+
},
|
|
2203
|
+
files: [
|
|
2204
|
+
"dist",
|
|
2205
|
+
"harness-dist",
|
|
2206
|
+
"README.md"
|
|
2207
|
+
],
|
|
2208
|
+
engines: {
|
|
2209
|
+
node: ">=20.12"
|
|
2210
|
+
},
|
|
2211
|
+
scripts: {
|
|
2212
|
+
dev: "tsx src/server.ts",
|
|
2213
|
+
start: "tsx src/server.ts",
|
|
2214
|
+
"build:harness": "vite build --config vite.harness.config.mjs",
|
|
2215
|
+
"build:node": "node build.mjs",
|
|
2216
|
+
build: "npm run build:harness && npm run build:node",
|
|
2217
|
+
prepublishOnly: "npm run build",
|
|
2218
|
+
typecheck: "tsc --noEmit"
|
|
2219
|
+
},
|
|
2220
|
+
dependencies: {
|
|
2221
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
2222
|
+
express: "^5.2.1",
|
|
2223
|
+
fflate: "^0.8.3",
|
|
2224
|
+
playwright: "1.61.1",
|
|
2225
|
+
vite: "^5.4.0",
|
|
2226
|
+
zod: "^3.25.76"
|
|
2227
|
+
},
|
|
2228
|
+
devDependencies: {
|
|
2229
|
+
"@types/express": "^5.0.6",
|
|
2230
|
+
esbuild: "^0.28.1",
|
|
2231
|
+
pixelmatch: "^7.2.0",
|
|
2232
|
+
pngjs: "^7.0.0",
|
|
2233
|
+
tsx: "^4.23.0",
|
|
2234
|
+
typescript: "^5.6.0"
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
|
|
2238
|
+
// src/version.ts
|
|
2239
|
+
var VERSION = package_default.version;
|
|
2240
|
+
|
|
2241
|
+
// src/hostedBridge.ts
|
|
1493
2242
|
var DEFAULT_HOSTED_MCP_URL = "https://storeframe-mcp.fly.dev/mcp";
|
|
1494
2243
|
function hostedMcpUrl() {
|
|
1495
2244
|
return (process.env.STOREFRAME_MCP_URL || DEFAULT_HOSTED_MCP_URL).replace(/\/+$/, "");
|
|
@@ -1527,7 +2276,7 @@ async function callSaveProject(client, ctx, extra) {
|
|
|
1527
2276
|
return { id: res.projectId };
|
|
1528
2277
|
}
|
|
1529
2278
|
async function connectHostedBridge(token2) {
|
|
1530
|
-
const client = new Client({ name: "storeframe-mcp-local-bridge", version:
|
|
2279
|
+
const client = new Client({ name: "storeframe-mcp-local-bridge", version: VERSION }, { capabilities: {} });
|
|
1531
2280
|
const transport = new StreamableHTTPClientTransport(new URL(hostedMcpUrl()), {
|
|
1532
2281
|
requestInit: { headers: { Authorization: `Bearer ${token2}` } }
|
|
1533
2282
|
});
|
|
@@ -1555,6 +2304,14 @@ async function connectHostedBridge(token2) {
|
|
|
1555
2304
|
// known gap — see the module comment above.
|
|
1556
2305
|
};
|
|
1557
2306
|
},
|
|
2307
|
+
async readProjectRecord(projectId) {
|
|
2308
|
+
const res = await callRemoteTool(client, "read_project", { project: projectId });
|
|
2309
|
+
if (!res.saved) return null;
|
|
2310
|
+
return {
|
|
2311
|
+
record: { project: res.project_file },
|
|
2312
|
+
updatedAt: typeof res.updatedAt === "string" ? res.updatedAt : null
|
|
2313
|
+
};
|
|
2314
|
+
},
|
|
1558
2315
|
async saveLook(projectId, look2, sourceName) {
|
|
1559
2316
|
const res = await callRemoteTool(client, "save_look", {
|
|
1560
2317
|
project: projectId,
|
|
@@ -1596,6 +2353,7 @@ function localToolDeps(token2) {
|
|
|
1596
2353
|
...base,
|
|
1597
2354
|
resolveProject: async () => null,
|
|
1598
2355
|
readLook: async () => null,
|
|
2356
|
+
readProjectRecord: async () => null,
|
|
1599
2357
|
saveLook: async () => {
|
|
1600
2358
|
throw new Error(`Saving a look is ${SIGN_IN}`);
|
|
1601
2359
|
},
|
|
@@ -1627,6 +2385,7 @@ function localToolDeps(token2) {
|
|
|
1627
2385
|
...base,
|
|
1628
2386
|
resolveProject: async (userId, projectId) => (await bridge()).resolveProject(userId, projectId),
|
|
1629
2387
|
readLook: async (projectId) => (await bridge()).readLook(projectId),
|
|
2388
|
+
readProjectRecord: async (projectId) => (await bridge()).readProjectRecord(projectId),
|
|
1630
2389
|
saveLook: async (projectId, look2, sourceName) => (await bridge()).saveLook(projectId, look2, sourceName),
|
|
1631
2390
|
createProject: async (userId, name, record, ctx) => (await bridge()).createProject(userId, name, record, ctx),
|
|
1632
2391
|
updateProjectRecord: async (projectId, record, ctx) => (await bridge()).updateProjectRecord(projectId, record, ctx),
|
|
@@ -1657,7 +2416,7 @@ Rendering runs on THIS machine \u2014 free, no timeout ceiling. A hosted token i
|
|
|
1657
2416
|
## Local mode (this connection)
|
|
1658
2417
|
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
2418
|
var server = new McpServer(
|
|
1660
|
-
{ name: "storeframe-mcp-local", version:
|
|
2419
|
+
{ name: "storeframe-mcp-local", version: VERSION },
|
|
1661
2420
|
{ instructions: localInstructions }
|
|
1662
2421
|
);
|
|
1663
2422
|
registerTools(server, { userId: "local", ...deps });
|