shotops-mcp 0.9.9 → 0.10.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 CHANGED
@@ -20,29 +20,25 @@ below, or the public [MCP docs](https://shotops.dev/docs/mcp)).
20
20
 
21
21
  Composing and previewing a strip is free through either door — every device, caption and locale, no
22
22
  watermark, and locally without an account at all. **Store-ready output needs an active trial or
23
- Pro:** a full-resolution `render_strip`/`render_project`, and every `emit_bundle` form, including
24
- one that only re-zips panels you already rendered. That holds wherever the render happens; local
25
- compute being free is not the same as local output being free.
23
+ Pro:** a `render` with `output: "panels"` (full resolution) or `output: "bundle"`. That holds
24
+ wherever the render happens; local compute being free is not the same as local output being free.
26
25
 
27
26
  The two limits are independent and neither substitutes for the other:
28
27
 
29
28
  - **Entitlement** is permission to create store-ready files. One server-owned decision answers it
30
- for Studio, hosted and local alike, so no door can be talked into a different answer. Free and
31
- anonymous callers are refused before a cloud credit is reserved, a screenshot is read or a file is
32
- written — a refused call costs nothing and leaves nothing behind.
29
+ for Studio, hosted and local alike. Free and anonymous callers are refused before a cloud credit
30
+ is reserved, a screenshot is read or a file is written — a refused call costs nothing.
33
31
  - **Cloud credits** price compute ShotOps supplies: 1 per hosted preview panel, 4 per hosted
34
- full-resolution panel, plus AI. Rendering on your own machine spends none, on any plan. More
35
- cloud credits never unlock store-ready output on Free.
32
+ full-resolution panel. Rendering on your own machine spends none, on any plan.
36
33
 
37
34
  Free accounts get 80 cloud credits a month and 2 synced projects. Every new account starts with a
38
35
  7-day Pro trial; after that Pro is €9/month or €90/year. Without any account, the hosted server
39
36
  allows 3 preview strips per rolling 30 days, at most 5 panels each. Call `account_status` to see
40
- exactly where this connection stands — it spends no cloud credits and none of that anonymous
41
- allowance.
37
+ exactly where this connection stands — it spends nothing.
42
38
 
43
39
  ## Zero-custody
44
40
 
45
- The hosted server and every MCP tool **never touch a store-signing credential.** `emit_bundle`
41
+ The hosted server and every MCP tool **never touch a store-signing credential.** A bundle render
46
42
  hands you a zip; **you** upload it with **your own** `fastlane`, signed in as yourself. The
47
43
  generated `Deliverfile` is screenshots-only and **never submits for review**.
48
44
 
@@ -54,240 +50,102 @@ The key is never copied, printed, or sent to ShotOps, and no release tool is ins
54
50
 
55
51
  | Tool | What it does | Key inputs |
56
52
  |------|--------------|------------|
57
- | `render_strip` | Preferred focused render: turn raw screenshots into mocked-up per-panel PNGs without creating or updating a project | `screenshots`, `panelPresetId?`, `clip?`, `style?` **+** `look?` (they compose) / `useSavedLook?`, `project?`, `version?`, `locale?`, `preview?`, `output?` |
58
- | `emit_bundle` | `render_strip` (or pre-rendered `panels` — refs or local `{ path }`, or a saved `project`'s own screenshots) + a `fastlane deliver` zip (+ optional share link) | `screenshots` or `panels` or `project` (bundles what that project holds), `bundleId`, `clip?`, `outputs?` (target devices — one bundle for several sizes; omit and a `project`'s saved outputs are used), `locale?`, `locales?` (multi-locale bundle), `projectName?`, `share?`, `output?` (+ the same styling params) |
59
- | `save_project` | Save the strip as an editable Studio project (structure + look only — does NOT render, so it never hits the render timeout); always returns an `openUrl`. Authenticated saves also return `projectId`; unsigned local saves return a seven-day pending claim the user owns after opening the link and signing in | `screenshots`, `project?` (update in place only when explicitly targeted and authenticated; omit to create a new project or pending claim), `projectName?`, `appListing?` (an App Store URL, numeric app ID or bundle ID — the project takes the real app's name, bundle ID, languages and devices), `outputs?`, `panelPresetId?`, `style?` / `look?` / `useSavedLook?`, `version?`, `locale?`, `sourceDir?` |
60
- | `read_look` | Return a project's saved ShotOps look (styling only), with its latest version, the held version, the `versions` history, and the `layout` template it is composed on (derived from its values, never stored) | `project?` (default: your most recently edited project) |
61
- | `read_project` | Return a project's FULL current state as an opaque `ProjectFile` — frame order, per-locale caption words, locale list, styling — plus the derived `layout` and a `screenshots.cells` report for every shot × target-family × locale. Each cell says whether pixels resolve, where they resolved from, and which axes inherited. `linkedApp` names the App Store app the project is linked to, when it has one | `project?` (default: your most recently edited project) |
62
- | `search_app_listing` | Find the user's app on the App Store so a save can carry its real identity: each result carries the app's name, seller, icon, bundle ID and numeric app ID, plus the App Store Connect languages and iPhone devices ShotOps would apply. Free, renders nothing, writes nothing; needs an account. Finding a public listing proves nothing about who owns the app | `term`, `country?` (two-letter storefront, default `us`) |
63
- | `render_project` | Render a SAVED project. By default it renders the screenshots **the project already holds** — pass none at all, no upload needed — into **every supported device the project targets**. Pixels resolve independently for each device family + locale through the shared manifest; genuine fallback is reported in `shots[].inherited` and `note` | `project?`, `screenshots?` (**optional** — omit to use what the project holds; else a FLAT list matched by original `name`, with optional `variant: { family?, locale? }` per cell), `outputs?` (override the project's saved devices for this call), `locale?`, `preview?`, `output?` |
64
- | `refine_project` | Run one bounded Agent turn against a saved project. A supported request commits once and returns the actual diff; ambiguity, missing input, unsupported intent, conflict, and failure return a typed no-write outcome | `project?`, `instruction`, `focus?` |
65
- | `save_look` | Persist a composed look on a project (styling only — appends a new version) | `look`, `project?`, `sourceName?` |
66
- | `hold_look` | Hold which saved version agents render by default (replaces pinning) — the version must exist (`read_look` `versions`) | `version`, `project?` |
67
- | `release_look` | Clear the held version — agents Follow latest (render the newest saved look) | `project?` |
68
- | `describe_look` | Five named design presets, three named layout templates, the user-intake script, and the full styling field catalog + defaults | *(none)* |
69
- | `request_screenshot_upload` | Mint signed upload slots for real screenshots; every returned slot carries its semantic screenshot `variant` | `count` (1–10), `names?`, `family?`, `locale?` |
70
- | `import_screenshot` | Get screenshots INTO ShotOps — several per call, each from one named source (`{ url }`, `{ file }`, or local-stdio `{ path }`), returning a render-ready ref per entry in input order | `screenshots` (1–10 source entries), `file?` (the ChatGPT top-level attachment), `name?`, `locale?` |
71
- | `account_status` | What THIS connection may do, for free: signed-in account, plan, trial, remaining ShotOps cloud credits, what a preview costs here, and whether store-ready output (full-resolution renders, any `emit_bundle`) is allowed — with a machine-readable `nextStep`. Spends no cloud credits, uses none of the free anonymous preview allowance, uploads and writes nothing | *(none)* |
72
- | `production_operation` | Reconnect to one durable paid Hosted render or rendering bundle, report persisted progress, request cooperative cancellation, or mint fresh result download grants without rerendering or charging again | `action` (`get`, `cancel`, or `result`), `operationId` |
73
- | `delete_assets` | Permanently delete private uploaded screenshots, rendered panels, or generated bundles owned by the signed-in account | `refs` (1–50 ShotOps refs) |
74
-
75
- - **`panelPresetId`** — App Store size: `r69` (default, 6.9″ iPhone 1320×2868), `r65`, `r55`.
76
- **iPhone sizes only** — the iPad presets are deliberately not offered, because no iPad device
77
- model exists yet and rendering one would put an iPhone body in an iPad-shaped canvas. The
78
- accepted set is `RENDERABLE_PANEL_PRESET_IDS` in `shotops-mcp/src/render/panelCatalog.ts`, a subset of
79
- the hand-kept `PANEL_PRESET_IDS` copy of `mockup-engine/appstore.ts`'s `PANEL_PRESETS` — when
80
- Apple revises required sizes, add the preset in the engine first, then mirror it here.
81
- - **`style`** — the structured styling input. On both the hosted MCP and the local
82
- `npx shotops-mcp` stdio server, call `describe_look` for the authoritative complete field
83
- catalog and defaults:
84
- `{ layout?, shotLook?, background?, captions? }`. `shotLook` is **one device for every phone**;
85
- `captions` is one entry PER PANEL in slot order — each entry is EITHER a single caption object OR
86
- an **array of caption layers** stacked on that panel (headline + subline + …). Each layer's
87
- `text` (and a single caption's `subtitle`) is per-RENDER input (never stored — a look carries
88
- styling only). Captions auto-layout in a band at the top of the panel, and the device does NOT
89
- move to make room.
90
- - **`style.layout`** — the panel **composition** in one word: `"standard"`, `"bleed"`, `"top-bleed"`,
91
- `"lean-right"`, `"lean-left"`, `"swipe"` or `"corner-bleed"`. It settles the headline's reserved
92
- region, device placement, camera pose, and roll together. `standard` (the shipped default) puts
93
- the whole device under a 2-line region — about 36 characters — and crops nothing; `bleed` reserves
94
- 4 lines (~72 characters) and runs the device 10% off the bottom edge. The last four run it off a
95
- SIDE edge instead, into the neighbouring screenshot, so the set reads as one continuous swipe —
96
- pick one for **every** panel or none, and never mirror `lean-right` against `lean-left`, which
97
- puts two devices into the same seam. The region is **reserved, not fitted**:
98
- it is held at full size whether or not the headline fills it, which is what makes every panel in
99
- a swiped set land on the same line. **Precedence** — the template expands *first*, then any
100
- explicit `shotLook` composition field or caption layout field you also pass **overrides** it
101
- (`{ layout: "bleed", shotLook: { vOffset: "45" } }` is "bleed, but a bit lower").
102
- A pure macro: nothing stores the template id, so `save_project` persists the expanded values and
103
- `read_look` returns them unchanged. **The id is not lost, though — it is DERIVED back:**
104
- `read_project` and `read_look` both return a `layout` report read out of the values themselves, so
105
- it can never go stale the way a stored id would. When a template was nudged, it reports the near
106
- miss instead of just failing to match:
107
-
108
- ```jsonc
109
- { "layout": "bleed" } // every panel agrees
110
- { "layout": null, "closestLayout": "bleed", // on Bleed, with one adjustment
111
- "differs": { "vOffset": { "is": "45", "template": "38" } } }
112
- { "layout": null, "closestLayout": null } // nothing close
113
- ```
114
-
115
- It also answers per panel (`layout.panels["panel-1"]`), because a strip may mix compositions and a
116
- multi-device panel whose devices were dragged apart is honestly on no template at all. If you set
117
- placement by hand with no `layout`, the response `note` names the template that would have produced
118
- it — templates are the main road, raw fields are how you nudge one.
119
- - **`look`** — a ShotOps look JSON exactly as `read_look` returns it, but you can also
120
- hand-author one: it gives each screenshot/device its **own** styling through `shots[]`, including
121
- two differently coloured devices in one panel. `shots[]` is flattened in panel order; repeat the
122
- same `panelId` for devices sharing a panel. This is what `style.shotLook` can't do. **Validated**
123
- — unknown keys are rejected (a typo won't silently render wrong). Omit for default styling.
124
- - **`style` + `look` compose** — every real App Store strip needs BOTH a different device per
125
- shot AND captions, so pass them **together**: the `look` supplies the per-device styling +
126
- background, `style.captions` supplies the words, in one render. (Alone, `style` styles every
127
- phone identically. If you pass `style.shotLook`/`style.background` *alongside* a `look`, the look
128
- wins and the result carries a note.) Also composes with `useSavedLook`/`version`.
129
- - **Clipping** — set `look.shots[].look.clipToFrame: true` to clip one device's complete composite
130
- (body, screen, shadow, and reflection) to its own panel; omit it or use `false` for overflow.
131
- Pass top-level `clip: "panel"` to clip every device, or `clip: "strip"` to force continuous
132
- overflow. Call `describe_look` for the complete Look contract on either MCP tier.
133
- - **`version`** — render a specific saved look version (implies the saved look). Without it,
134
- a project with a PINNED version renders the pin; otherwise the latest saved look.
135
- - **`emit_bundle` output** — a zip (`README.md`, `fastlane/Deliverfile`,
136
- `fastlane/screenshots/<locale>/NN_shotops.png`) as `zipBase64` or `zipUrl`/`zipRef` (see
137
- Output below), plus `shareLink` when `share: true`.
138
- - **`locale`** — an App Store locale code, e.g. `de-DE` (default `en-US`). Labels the render,
139
- picks which `{ "locales": … }` screenshot variants render (see
140
- [Per-locale screenshots](#per-locale-screenshots--multi-locale-bundles)), and, for
141
- `emit_bundle`, picks the `fastlane/screenshots/<locale>/` folder. **It does not
142
- select caption text** — this server never stores or looks up Studio-typed copy (repo owns
143
- the words). Pass the right words for that locale yourself in `style.captions[].text` /
144
- `subtitle`.
145
-
146
- ### Per-locale screenshots & multi-locale bundles
147
-
148
- Any `screenshots` entry (and any `emit_bundle` `panels` entry) can carry **per-locale
149
- variants** instead of one image:
150
-
151
- ```json
152
- { "locales": { "en-US": { "ref": "…en…" }, "de-DE": { "ref": "…de…" } } }
153
- ```
154
-
155
- - `render_strip({ locale: "de-DE", … })` renders the `de-DE` variant of each such entry. A
156
- locale with **no variant of its own falls back** to the `en-US` variant (else the first
157
- declared) — so you can ship German captions over English screenshots today and upgrade the
158
- pixels later. Plain (non-`locales`) entries serve every locale. The response echoes the
159
- declared locales as `screenshotLocales`.
160
- - `emit_bundle({ locales: ["en-US", "de-DE"], … })` emits **ONE zip** with a
161
- `fastlane/screenshots/<locale>/` folder per listed locale — `fastlane deliver` uploads every
162
- locale in a single run. A single-entry list behaves exactly like `locale`; omitting `locales`
163
- is the unchanged single-locale bundle.
164
- - `request_screenshot_upload({ count, names, family?, locale? })` returns each slot as
165
- `{ ref, uploadUrl, name?, variant }`. For `render_project`, pass that `variant` through beside
166
- the ref: it is the device-family + locale cell the screenshot overrides, not a bookkeeping tag.
167
- Each `name` must exactly match the saved shot's original filename (`frameName`); the filename
168
- chooses the shot while `variant` chooses that shot's device-family + locale cell.
169
-
170
- **Recommended multi-locale flow — render per locale, then compose once.** Caption text is
171
- per-render input, so this is also the only way to get per-locale *captions* into one bundle,
172
- and it keeps each call fast (see "Cheap preview, then compose" below):
173
-
174
- 1. Per locale `L`: `render_strip({ locale: L, output: "urls", style: { captions: [<L's words>] },
175
- screenshots: […] })` → per-panel refs for `L`.
176
- 2. One `emit_bundle({ locales: […], bundleId, panels: [ { "locales": { "en-US": { "ref": "p1-en" },
177
- "de-DE": { "ref": "p1-de" } } }, … ] })` — zips every locale **without re-rendering**.
178
-
179
- The one-call form (`emit_bundle` with `screenshots` + `locales`) works too, but renders once
180
- per locale with the *same* captions for every locale, and multiplies the slow full-res render
181
- by the locale count.
182
-
183
- ### The `captions.<locale>.json` convention
184
-
185
- The canonical source of shipping caption copy is a file **in your own repo**, one per locale,
186
- keyed by **panel ordinal** (0-based, in strip order — not panel id, so the file stays stable
187
- across re-renders):
188
-
189
- A panel's entry is EITHER a single caption (`{ headline, subtitle? }`) OR an **array of layers**
190
- (each `{ text, … }`, stacked in order) — use the array when you want more than a headline + one
191
- subtitle, e.g. an eyebrow over a headline over a caption:
192
-
193
- ```json
194
- // captions.de-DE.json
195
- {
196
- "0": { "headline": "Alles im Blick", "subtitle": "Deine Tage, übersichtlich." },
197
- "1": [ { "text": "NEU" }, { "text": "Schneller planen" } ]
198
- }
199
- ```
200
-
201
- To render a locale: read that file, map it onto `style.captions[]` in the same order (index
202
- `i` → panel `i`, `null` for a panel with no caption), and pass `locale` alongside so the
203
- render/bundle is labeled and routed consistently. A single caption maps to one object; a layer
204
- array maps straight through as an array:
53
+ | `render` | Render store screenshots from a saved project (it holds its screenshots, captions and look) or from supplied screenshots. Preview panels, full-resolution panels or a `fastlane deliver` zip. Never saves a project | `project?` or `screenshots?`, `output?` (`preview` default, `panels`, `bundle`), `outputs?`, `locale?`, `locales?` (bundle), `style?`/`look?` (supplied screenshots), `bundleId` (bundle), `share?`, `delivery?` |
54
+ | `save_project` | Save screenshots and styling as an editable project. Returns an `openUrl`; authenticated saves also return `projectId`, unsigned local saves return a seven-day pending claim the user owns after opening the link and signing in | `screenshots`, `project?`, `projectName?`, `style?`/`look?`, `outputs?`, `locale?`, `appListing?` |
55
+ | `edit_project` | Apply concrete changes to a saved project and save it: caption words per frame and locale, frame order, layout, preset, caption style, background, device pose. Deterministic — no AI, no credits | `project`, `captions?`, `order?`, `layout?`, `preset?`, `captionStyle?`, `background?`, `frames?`, `devices?` |
56
+ | `read_project` | A project's full current state: frame order, per-locale caption words, locales, styling, the derived `layout`, and a `screenshots` report of which pixels the project holds | `project?` |
57
+ | `read_look` | A project's saved look (styling only) with its version history | `project?` |
58
+ | `save_look` | Save a look on a project as a new version | `look`, `project?`, `sourceName?` |
59
+ | `describe_look` | Design presets, layout templates, the intake script, every styling field with its default, the devices, and `edit_project`'s vocabulary | *(none)* |
60
+ | `search_app_listing` | Find the user's app on the App Store so a save carries its real identity. Free | `term`, `country?` |
61
+ | `import_screenshot` | Store screenshots from a URL, a ChatGPT attachment or (local) a path, returning a ref per entry | `screenshots`, `file?`, `name?` |
62
+ | `request_screenshot_upload` | Signed upload slots for screenshots on the user's machine | `count`, `names?`, `family?`, `locale?` |
63
+ | `delete_assets` | Permanently delete private uploads, panels or bundles this account holds | `assetIds?`, `refs?` |
64
+ | `account_status` | Whether this connection may render full resolution and bundles, the plan and the credit balance. Free | *(none)* |
65
+ | `production_operation` | Follow a queued full-resolution render or bundle: progress, result, cancel | `action`, `operationId` |
66
+
67
+ ### Rendering
68
+
69
+ - **A saved project** — `render({ project })` renders the screenshots, frame order, captions and
70
+ look the project holds, with nothing uploaded. `project` takes an id or the exact name. Pass
71
+ `screenshots` with `name` (and optional `variant: { family?, locale? }`) to replace the project's
72
+ file of that name for this render.
73
+ - **Supplied screenshots** — `render({ screenshots: [{ ref }, …], style })`, one panel per entry
74
+ (nest entries in an array to put several phones in one panel). A render never saves;
75
+ `save_project` does, when the user wants to keep the work.
76
+ - **`output`** — `"preview"` (default) renders at about a quarter resolution for iterating;
77
+ `"panels"` renders full resolution; `"bundle"` builds a zip (`README.md`, `fastlane/Deliverfile`,
78
+ `fastlane/screenshots/<locale>/NN_shotops.png`) and needs `bundleId`. `locales: [...]` puts one
79
+ folder per locale in one zip; a project bundle takes each locale's own caption words from the
80
+ project.
81
+ - **`outputs`** — the devices to render (`iphone-6-9`, `iphone-6-5`). Default: the project's saved
82
+ devices, else `iphone-6-9`.
83
+ - **`delivery`** — `"inline"` image blocks or short-lived `"urls"`; omitted, it picks inline for
84
+ small results.
85
+
86
+ ### Styling
87
+
88
+ Call `describe_look` for the authoritative complete field catalog on both the hosted MCP and the
89
+ local `npx shotops-mcp` stdio server. In short:
90
+
91
+ - **`style`** — `{ layout?, shotLook?, background?, captions? }`. `layout` is the panel composition
92
+ in one word (`standard`, `bleed`, `top-bleed`, `lean-right`, `lean-left`, `swipe`,
93
+ `corner-bleed`): it reserves the headline region and places the device, and any field you pass
94
+ explicitly overrides it. `shotLook` styles every phone alike. `captions` has one entry per panel,
95
+ a caption object or an array of stacked layers. Caption words are per-render input and never
96
+ stored in a look.
97
+ - **`look`** — a look exactly as `read_look` returns it; `shots[]` gives every device its own
98
+ styling. Pass `look` for the devices and `style.captions` for the words in one render. Set
99
+ `look.shots[].look.clipToFrame: true` to clip one device to its own panel.
100
+ - Every render grades itself: an optional `layout` block names captions sitting on devices and
101
+ colliding captions, and `layout.correctedLook` is a repaired look to pass straight back.
102
+
103
+ ### Editing a saved project
104
+
105
+ `edit_project` takes the change itself. Frames and devices are numbered from 1, as the user counts
106
+ them:
205
107
 
206
- ```json
108
+ ```jsonc
207
109
  {
208
- "locale": "de-DE",
209
- "style": { "captions": [
210
- { "text": "Alles im Blick", "subtitle": "Deine Tage, übersichtlich." },
211
- [ { "text": "NEU", "sizePt": 28 }, { "text": "Schneller planen" } ]
212
- ] }
110
+ "project": "Darts Tracker",
111
+ "captions": [
112
+ { "frame": 1, "text": "Track every throw" },
113
+ { "frame": 1, "text": "Jeden Wurf im Blick", "locale": "de-DE" } // adds de-DE if missing
114
+ ],
115
+ "order": [2, 1, 3],
116
+ "layout": "bleed",
117
+ "devices": [{ "frame": 2, "roll": 8, "colorway": "blue" }]
213
118
  }
214
119
  ```
215
120
 
216
- There is no MCP tool to read Studio-typed caption text back — Studio's per-locale captions
217
- are for *visual preview* only; your repo's `captions.<locale>.json` is what actually ships.
121
+ A bad value is refused with the exact field and what it accepts, and nothing is written. The edit
122
+ is saved against the version it read, so a concurrent Studio save is never overwritten.
218
123
 
219
- ### Agents: don't inline large images
124
+ ### Getting screenshots in
220
125
 
221
- A real marketing screenshot is easily 1–2MB as a PNG — 30–40% bigger again as base64 — and
222
- that base64 string flows through your OWN tool-call arguments, in YOUR context window, before
223
- it ever reaches this server. Passing full-resolution screenshots inline can blow past your
224
- context budget (or get silently truncated by a file-read tool) long before rendering happens.
126
+ Don't inline full-resolution screenshots: base64 flows through your own context first, and inline
127
+ entries are capped at 3MB. Instead:
225
128
 
226
- **Upload out-of-band instead:**
129
+ - **`import_screenshot`** takes several sources in one call — `{ "url" }` (PNG or JPEG, fetched
130
+ server-side), `{ "file" }` (a ChatGPT attachment, which arrives as the top-level `file`
131
+ parameter, one per call) or `{ "path" }` (local stdio only) — and returns one ref per entry, in
132
+ order.
133
+ - **`request_screenshot_upload({ count, names?, family?, locale? })`** returns signed slots;
134
+ `curl -T screenshot.png "<uploadUrl>"` each one, then pass `{ "ref" }`. With a project, pass the
135
+ slot's `name` and `variant` too.
227
136
 
228
- **`import_screenshot` moves them all in one call.** Each entry of `screenshots` names exactly
229
- one source, and you get one result per entry, in the order you sent them:
137
+ Every raw PNG is palette-optimized before rendering, with dimensions and transparency preserved.
138
+ Private uploads and generated files expire after the retention window unless a project keeps them;
139
+ `delete_assets` removes them sooner when the user asks.
230
140
 
231
- ```jsonc
232
- { "screenshots": [
233
- { "url": "https://example.com/01_home.png", "name": "01_home.png" }, // this server fetches it
234
- { "path": "/abs/02_stats.png" }, // local stdio server ONLY
235
- { "file": { "download_url": "…", "file_id": "…" } } // a ChatGPT attachment
236
- ] }
237
- ```
141
+ ### Per-locale captions
238
142
 
239
- Two sources in one entry is rejected (split them into two entries). A failed entry reports its
240
- own `error` and never cancels the others — re-import just that one. In ChatGPT the attachment
241
- arrives as the top-level `file` parameter instead of an array entry, because the Apps SDK cannot
242
- fill a file parameter nested inside an array; you can pass both in the same call.
243
-
244
- `{ url }` and `{ file }` need the hosted server (it stores the fetched screenshot as PNG under your account);
245
- `{ path }` needs the local stdio server (the hosted server never reads a caller-supplied
246
- filesystem path — that would be an LFI hole). Each refusal names the door that IS open on your
247
- tier. When the screenshots are only on the user's machine and you are on the hosted server, there
248
- is no URL to give, so use the signed-slot flow below:
249
-
250
- 1. Call `request_screenshot_upload({ count: N, names?, family?, locale? })` — returns `N`
251
- `{ ref, uploadUrl, name?, variant }` slots
252
- under your own private prefix, plus a copy-paste `curl -T` example.
253
- 2. `curl -T screenshot.png "<uploadUrl>"` each screenshot directly (bytes never touch this
254
- conversation).
255
- 3. Pass `{ "ref": "<ref>" }` — not inline base64 — as that screenshot's entry in
256
- `render_strip`/`emit_bundle`'s `screenshots` array. For `render_project`, also pass the slot's
257
- `name` and `variant`; `name` must exactly match the saved shot's original filename, and legacy
258
- entries without a variant remain the base/fallback cell.
259
-
260
- `screenshots` entries accept these shapes: an inline base64 string (small payloads only —
261
- inline is capped and returns a clear "payload too large" error above 3MB decoded, pointing
262
- you back at this flow), `{ ref }` from step 1, `{ url }` (an https URL this server fetches
263
- itself: PNG or JPEG, converted to PNG when needed, no redirects, ~20MB cap), `{ path }` (local stdio server only — see below),
264
- or `{ "locales": { "<locale>": <any of those> } }` for per-locale variants (see
265
- [Per-locale screenshots](#per-locale-screenshots--multi-locale-bundles) above).
266
-
267
- Every raw PNG is automatically palette-optimized before rendering (TinyPNG-style, locally inside
268
- ShotOps): dimensions and transparency are preserved, and an already-smaller PNG is left byte-for-byte
269
- unchanged. Fetched JPEGs are converted to palette PNGs at the same boundary without changing their
270
- dimensions. This happens after the input reaches the MCP, so it does **not** raise the inline request
271
- cap — use the ref/upload flow for a large source file.
272
-
273
- **Output** works the same way in reverse: `render_strip`/`render_project` take an `output` field
274
- — `"inline"` (standard MCP `image` blocks), `"urls"` (uploaded under your own prefix and returned
275
- as short-lived `resource_link` blocks), or omit for auto (inline under ~200KB total, `urls` above).
276
- The model-visible `structuredContent.panels` contains ordered delivery descriptors rather than PNG
277
- base64; ChatGPT receives the complete gallery through widget-only result metadata. Programmatic
278
- clients read the standard MCP content blocks. Use `"urls"` for full-resolution work.
279
-
280
- **Cheap preview, then compose:** `render_strip({ preview: true })` renders at ~25% resolution
281
- for fast, cheap styling iteration — small enough to always come back inline; don't ship it,
282
- re-render without `preview` (or call `emit_bundle` directly) once the look is right. Once
283
- you have full-resolution panel refs from a `render_strip({ output: "urls" })` call,
284
- `emit_bundle({ panels: [{ ref }, ...] })` packages them into a bundle WITHOUT rendering again
285
- — skip `screenshots` entirely in that call.
286
-
287
- Private uploaded screenshots and generated files are retained until deleted. Treat refs as
288
- short-lived and single-use, then call `delete_assets({ refs: [...] })` when the files are no
289
- longer needed. Deleting a ref is irreversible and can make a saved project's referenced
290
- screenshot unavailable; public privacy disclosures must accurately state this retention model.
143
+ The canonical source of shipping caption copy is a `captions.<locale>.json` file **in your own
144
+ repo**, keyed by panel ordinal (0-based). To render a locale, map it onto `style.captions[]` in the
145
+ same order and pass `locale`. For a saved project, write the words into the project with
146
+ `edit_project` instead, and `render({ project, locales: [...], output: "bundle" })` ships them all.
147
+ A screenshot entry may carry per-locale variants — `{ "locales": { "en-US": { "ref" }, "de-DE":
148
+ { "ref" } } }` — and a locale with no variant falls back to `en-US`.
291
149
 
292
150
  ## Prerequisites
293
151
 
@@ -307,7 +165,7 @@ npm install
307
165
  npm run dev # boots on http://localhost:8788/mcp (override with MCP_PORT)
308
166
  ```
309
167
 
310
- Chromium boots lazily on the first `render_strip` / `emit_bundle` call, so startup is fast — and
168
+ Chromium boots lazily on the first `render` call, so startup is fast — and
311
169
  that first render also installs Chromium if it isn't cached yet, adding ~20-25s to that one call.
312
170
  `GET /health` is an unauthenticated liveness probe.
313
171
 
@@ -349,7 +207,7 @@ before any tool runs.
349
207
  ## After you get a bundle
350
208
 
351
209
  ```bash
352
- # decode zipBase64 from emit_bundle's result to a file, then:
210
+ # decode zipBase64 from a bundle render's result to a file, then:
353
211
  unzip shotops-appstore-upload.zip -d upload && cd upload
354
212
  fastlane deliver # signs in as YOU, previews, uploads screenshots to a draft — never submits
355
213
  ```
@@ -405,13 +263,13 @@ agent user (not a repo collaborator) can find the connect story, since this repo
405
263
  server serves it from a loopback-only static server. Vite is build/parity tooling only and is
406
264
  pruned from production dependencies; `mockup-engine` stays reachable at build time so the
407
265
  harness can bundle the shared engine + GLB.
408
- - **Hosted render capacity is load-bearing and history-rich.** A `render_strip` boots headless
266
+ - **Hosted render capacity is load-bearing and history-rich.** A `render` boots headless
409
267
  Chromium + WebGL for seconds, inline in the request, and full-res multi-panel renders are slow
410
268
  by design — seconds per panel, one panel at a time. The function duration ceiling, the memory
411
269
  findings behind it, and the two distinct failure shapes (CPU starvation vs. OOM) are recorded in
412
270
  `docs/reference/ops-runbook.md` §Deploy. Never cut a memory or duration setting without a green
413
271
  `verify-mcp.mjs` run behind the claim. Client callers (agents, `verify-mcp.mjs`) should set a
414
- generous per-call timeout (≥180s) for `render_strip`/`emit_bundle`.
272
+ generous per-call timeout (≥180s) for `render`.
415
273
 
416
274
  ## Local — `npx shotops-mcp`
417
275
 
@@ -448,22 +306,13 @@ Chromium, which the first render downloads once as described above.
448
306
  instead of `{ ref }`/`{ url }`:
449
307
 
450
308
  ```json
451
- { "screenshots": [[{ "path": "/Users/you/screens/01_home.png" }]] }
309
+ { "screenshots": [{ "path": "/Users/you/screens/01_home.png" }] }
452
310
  ```
453
311
 
454
312
  (`{ "path": ... }` only works over this LOCAL server — the hosted server rejects it, since
455
313
  reading an arbitrary server-side path there would be a local-file-inclusion hole.)
456
314
 
457
- **`emit_bundle` reads local panels off disk too** — an unsigned local server has no account, so it
458
- can't produce `output: "urls"` refs. Once you are signed in on a trial or Pro, pass the on-disk PNGs
459
- straight to `emit_bundle` (same local-only `{ path }` door as screenshots) to package a
460
- `fastlane deliver` zip with no upload and no cloud credits:
461
-
462
- ```json
463
- { "bundleId": "com.acme.app", "panels": [{ "path": "/abs/panel-01.png" }, { "path": "/abs/panel-02.png" }] }
464
- ```
465
-
466
- `emit_bundle` and full-resolution renders are the store-ready half of the offer, so they need that
315
+ Bundles and full-resolution renders are the store-ready half of the offer, so they need that
467
316
  trial or Pro even here, where the compute is yours. Preview renders need neither an account nor
468
317
  cloud credits. `account_status` reports which side of the line this process is on.
469
318
 
@@ -474,27 +323,27 @@ returns `status: "pending_claim"` plus `claimId` and `openUrl`, and deliberately
474
323
  with its screenshots, and the link expires after 7 days. An existing `project` cannot be updated
475
324
  without the token that owns it. Non-path inputs are refused rather than creating a blank claim.
476
325
 
477
- Ordinary `render_strip` / `emit_bundle` calls still upload **nothing** — pixels and zips are written
326
+ Ordinary `render` calls still upload **nothing** — pixels and zips are written
478
327
  straight to your disk, and no screenshot leaves the machine. Other
479
- account reads/writes (`read_look`, `save_look`, `read_project`, `render_project`, share links)
480
- still need a token or hosted connection. A token-backed local `render_project` reads the cloud
328
+ account reads/writes (`read_project`, `edit_project`, the look tools, rendering a saved `project`,
329
+ share links) still need a token or hosted connection. A token-backed local project render reads the cloud
481
330
  manifest through the Studio control plane, downloads only the resolved cells, and renders locally:
482
331
 
483
332
  | | Local (`npx shotops-mcp`) | Hosted (`shotops_…` token) |
484
333
  |---|---|---|
485
334
  | Render compute | **your machine** ($0, no timeout, no cloud credits on any plan) | our servers — spends your ShotOps cloud credits: 1 per preview panel, 4 per full-resolution panel |
486
335
  | Preview render | free, no account | free on an account; 3 per 30 days, ≤5 panels, without one |
487
- | Full-resolution render, any `emit_bundle` | trial or Pro (costs no cloud credits) | trial or Pro, and spends cloud credits |
336
+ | Full-resolution render or bundle | trial or Pro (costs no cloud credits) | trial or Pro, and spends cloud credits |
488
337
  | Auth | none to preview; a token for account state and store-ready output | account + API token |
489
338
  | Saved looks / editable projects / project read+re-render / share links | new project: pending claim; existing/account state: token (below); share links: hosted | ✅ |
490
339
  | Screenshot input | local `{ path }` off disk | `request_screenshot_upload` → `{ ref }` |
491
340
 
492
341
  ### Optional: bridge a local render into your hosted account
493
342
 
494
- Connect your account and `save_project` / `read_look` / `save_look` / `read_project` /
495
- `render_project` start working too — **rendering still happens locally and spends no cloud
343
+ Connect your account and `save_project` / `edit_project` / `read_project` / the look tools /
344
+ `render({ project })` start working too — **rendering still happens locally and spends no cloud
496
345
  credits**, and
497
- a trial or Pro account unlocks full-resolution output and `emit_bundle` on this machine. A
346
+ a trial or Pro account unlocks full-resolution output and bundles on this machine. A
498
347
  deliberate save also uploads its local source PNGs privately so the project reopens with
499
348
  screenshots on another device; normal renders and exports never upload.
500
349