shotops-mcp 0.5.0 → 0.6.1
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 +138 -61
- package/dist/local.js +4598 -1404
- package/harness-dist/assets/{index-D1u7TBPd.js → index-XdyOXKM2.js} +168 -168
- package/harness-dist/index.html +1 -1
- package/package.json +16 -3
- package/widget-dist/bundle.js +221 -0
- package/widget-dist/panels.js +188 -0
package/README.md
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# shotops-mcp
|
|
2
2
|
|
|
3
|
-
A hosted-ready **MCP server** that
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
A hosted-ready **MCP server** that makes the marketing screenshots an app store listing needs.
|
|
4
|
+
Give it the raw screen captures from an app and it composites each one into a styled 3D device
|
|
5
|
+
mockup, lays them out as a screenshot strip, sets your headlines over them, and hands back the
|
|
6
|
+
panels as PNGs plus a `fastlane deliver`-ready bundle. Captions are held per locale, so one strip
|
|
7
|
+
ships in every language you list in. It renders at App Store panel sizes and bundles for fastlane
|
|
8
|
+
deliver, so a Google Play listing reuses the designs rather than the exact files. Designs save as
|
|
9
|
+
reusable **looks** and editable **projects**, so the next release matches the last one.
|
|
10
|
+
|
|
11
|
+
Built for agents (ChatGPT / Claude Code / Cursor / CI). It runs over the same `@engine` render
|
|
12
|
+
spine the Studio web app and the `mockup-mcp` CLI use, driving headless Chromium (Playwright).
|
|
7
13
|
|
|
8
14
|
Ships as **two tiers, one engine**: a free `npx shotops-mcp` **local stdio server** that
|
|
9
15
|
renders on your own machine with no account (see [Local (free tier)](#local-free-tier---npx-shotops-mcp)
|
|
10
|
-
below), and a **hosted** server on
|
|
11
|
-
[Hosted
|
|
12
|
-
[`/connect`](https://
|
|
16
|
+
below), and a **hosted** server on Vercel with an account, saved looks, and share links (see
|
|
17
|
+
[Hosted](#hosted) below, or the public
|
|
18
|
+
[`/connect`](https://shotops.dev/connect) page in the studio app).
|
|
13
19
|
|
|
14
20
|
## Zero-custody
|
|
15
21
|
|
|
@@ -24,37 +30,66 @@ share links) — nothing that could reach a store on your behalf.
|
|
|
24
30
|
|
|
25
31
|
| Tool | What it does | Key inputs |
|
|
26
32
|
|------|--------------|------------|
|
|
27
|
-
| `render_strip` |
|
|
28
|
-
| `emit_bundle` | `render_strip` (or pre-rendered `panels` — refs or local `{ path }`) + a `fastlane deliver` zip (+ optional share link) | `screenshots` or `panels
|
|
29
|
-
| `save_project` | Save the strip as an editable Studio project (structure + look only — does NOT render, so it never hits the render timeout); returns `
|
|
30
|
-
| `read_look` | Return a project's saved ShotOps look (styling only), with its latest version, the held version,
|
|
31
|
-
| `read_project` | Return a project's FULL current state as an opaque `ProjectFile` — frame order, per-locale caption words, locale list, styling
|
|
32
|
-
| `render_project` |
|
|
33
|
+
| `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?` |
|
|
34
|
+
| `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) |
|
|
35
|
+
| `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?`, `outputs?`, `panelPresetId?`, `style?` / `look?` / `useSavedLook?`, `version?`, `locale?`, `sourceDir?` |
|
|
36
|
+
| `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) |
|
|
37
|
+
| `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 | `project?` (default: your most recently edited project) |
|
|
38
|
+
| `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?` |
|
|
33
39
|
| `save_look` | Persist a composed look on a project (styling only — appends a new version) | `look`, `project?`, `sourceName?` |
|
|
34
40
|
| `hold_look` | Hold which saved version agents render by default (replaces pinning) — the version must exist (`read_look` `versions`) | `version`, `project?` |
|
|
35
41
|
| `release_look` | Clear the held version — agents Follow latest (render the newest saved look) | `project?` |
|
|
36
|
-
| `describe_look` |
|
|
37
|
-
| `request_screenshot_upload` | Mint signed upload slots for real screenshots | `count` (1–10), `names?`, `locale?`
|
|
38
|
-
| `import_screenshot` |
|
|
42
|
+
| `describe_look` | Five named design presets, two named layout templates, the user-intake script, and the full styling field catalog + defaults | *(none)* |
|
|
43
|
+
| `request_screenshot_upload` | Mint signed upload slots for real screenshots; every returned slot carries its semantic screenshot `variant` | `count` (1–10), `names?`, `family?`, `locale?` |
|
|
44
|
+
| `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?` |
|
|
39
45
|
| `delete_assets` | Permanently delete private uploaded screenshots, rendered panels, or generated bundles owned by the signed-in account | `refs` (1–50 ShotOps refs) |
|
|
40
46
|
|
|
41
|
-
- **`panelPresetId`** — App Store size: `r69` (default, 6.9″ iPhone 1290×2796), `r65`, `r55
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
- **`panelPresetId`** — App Store size: `r69` (default, 6.9″ iPhone 1290×2796), `r65`, `r55`.
|
|
48
|
+
**iPhone sizes only** — the iPad presets are deliberately not offered, because no iPad device
|
|
49
|
+
model exists yet and rendering one would put an iPhone body in an iPad-shaped canvas. The
|
|
50
|
+
accepted set is `RENDERABLE_PANEL_PRESET_IDS` in `shotops-mcp/src/panelCatalog.ts`, a subset of
|
|
51
|
+
the hand-kept `PANEL_PRESET_IDS` copy of `mockup-engine/appstore.ts`'s `PANEL_PRESETS` — when
|
|
52
|
+
Apple revises required sizes, add the preset in the engine first, then mirror it here.
|
|
46
53
|
- **`style`** — the structured styling input (call `describe_look` for the full catalog):
|
|
47
|
-
`{ shotLook?, background?, captions? }`. `shotLook` is **one device for every phone**;
|
|
48
|
-
is one entry PER PANEL in slot order — each entry is EITHER a single caption object OR
|
|
49
|
-
of caption layers** stacked on that panel (headline + subline + …). Each layer's
|
|
50
|
-
single caption's `subtitle`) is per-RENDER input (never stored — a look carries
|
|
51
|
-
Captions auto-layout
|
|
54
|
+
`{ layout?, shotLook?, background?, captions? }`. `shotLook` is **one device for every phone**;
|
|
55
|
+
`captions` is one entry PER PANEL in slot order — each entry is EITHER a single caption object OR
|
|
56
|
+
an **array of caption layers** stacked on that panel (headline + subline + …). Each layer's
|
|
57
|
+
`text` (and a single caption's `subtitle`) is per-RENDER input (never stored — a look carries
|
|
58
|
+
styling only). Captions auto-layout in a band at the top of the panel, and the device does NOT
|
|
59
|
+
move to make room.
|
|
60
|
+
- **`style.layout`** — the panel **composition** in one word: `"standard"` or `"bleed"`. It settles
|
|
61
|
+
the headline's reserved region *and* the device size + position together, which is three numbers
|
|
62
|
+
that are not independent of each other. `standard` (the shipped default) puts the whole device
|
|
63
|
+
under a 2-line region — about 36 characters — and crops nothing; `bleed` reserves 4 lines (~72
|
|
64
|
+
characters) and runs the device 10% off the bottom edge. The region is **reserved, not fitted**:
|
|
65
|
+
it is held at full size whether or not the headline fills it, which is what makes every panel in
|
|
66
|
+
a swiped set land on the same line. **Precedence** — the template expands *first*, then any
|
|
67
|
+
`shotLook.phoneHeight`/`hOffset`/`vOffset` or `captions[].sizePt`/`maxWidth` you also pass
|
|
68
|
+
**overrides** it (`{ layout: "bleed", shotLook: { vOffset: "45" } }` is "bleed, but a bit lower").
|
|
69
|
+
A pure macro: nothing stores the template id, so `save_project` persists the expanded values and
|
|
70
|
+
`read_look` returns them unchanged. **The id is not lost, though — it is DERIVED back:**
|
|
71
|
+
`read_project` and `read_look` both return a `layout` report read out of the values themselves, so
|
|
72
|
+
it can never go stale the way a stored id would. When a template was nudged, it reports the near
|
|
73
|
+
miss instead of just failing to match:
|
|
74
|
+
|
|
75
|
+
```jsonc
|
|
76
|
+
{ "layout": "bleed" } // every panel agrees
|
|
77
|
+
{ "layout": null, "closestLayout": "bleed", // on Bleed, with one adjustment
|
|
78
|
+
"differs": { "vOffset": { "is": "45", "template": "38" } } }
|
|
79
|
+
{ "layout": null, "closestLayout": null } // nothing close
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
It also answers per panel (`layout.panels["panel-1"]`), because a strip may mix compositions and a
|
|
83
|
+
multi-device panel whose devices were dragged apart is honestly on no template at all. If you set
|
|
84
|
+
placement by hand with no `layout`, the response `note` names the template that would have produced
|
|
85
|
+
it — templates are the main road, raw fields are how you nudge one.
|
|
52
86
|
- **`look`** — a ShotOps look JSON exactly as `read_look` returns it, but you can also
|
|
53
|
-
hand-author one: it gives each
|
|
54
|
-
|
|
55
|
-
|
|
87
|
+
hand-author one: it gives each screenshot/device its **own** styling through `shots[]`, including
|
|
88
|
+
two differently coloured devices in one panel. `shots[]` is flattened in panel order; repeat the
|
|
89
|
+
same `panelId` for devices sharing a panel. This is what `style.shotLook` can't do. **Validated**
|
|
90
|
+
— unknown keys are rejected (a typo won't silently render wrong). Omit for default styling.
|
|
56
91
|
- **`style` + `look` compose** — every real App Store strip needs BOTH a different device per
|
|
57
|
-
|
|
92
|
+
shot AND captions, so pass them **together**: the `look` supplies the per-device styling +
|
|
58
93
|
background, `style.captions` supplies the words, in one render. (Alone, `style` styles every
|
|
59
94
|
phone identically. If you pass `style.shotLook`/`style.background` *alongside* a `look`, the look
|
|
60
95
|
wins and the result carries a note.) Also composes with `useSavedLook`/`version`.
|
|
@@ -93,8 +128,11 @@ variants** instead of one image:
|
|
|
93
128
|
`fastlane/screenshots/<locale>/` folder per listed locale — `fastlane deliver` uploads every
|
|
94
129
|
locale in a single run. A single-entry list behaves exactly like `locale`; omitting `locales`
|
|
95
130
|
is the unchanged single-locale bundle.
|
|
96
|
-
- `request_screenshot_upload({ count, names, locale })`
|
|
97
|
-
|
|
131
|
+
- `request_screenshot_upload({ count, names, family?, locale? })` returns each slot as
|
|
132
|
+
`{ ref, uploadUrl, name?, variant }`. For `render_project`, pass that `variant` through beside
|
|
133
|
+
the ref: it is the device-family + locale cell the screenshot overrides, not a bookkeeping tag.
|
|
134
|
+
Each `name` must exactly match the saved shot's original filename (`frameName`); the filename
|
|
135
|
+
chooses the shot while `variant` chooses that shot's device-family + locale cell.
|
|
98
136
|
|
|
99
137
|
**Recommended multi-locale flow — render per locale, then compose once.** Caption text is
|
|
100
138
|
per-render input, so this is also the only way to get per-locale *captions* into one bundle,
|
|
@@ -154,25 +192,50 @@ context budget (or get silently truncated by a file-read tool) long before rende
|
|
|
154
192
|
|
|
155
193
|
**Upload out-of-band instead:**
|
|
156
194
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
195
|
+
**`import_screenshot` moves them all in one call.** Each entry of `screenshots` names exactly
|
|
196
|
+
one source, and you get one result per entry, in the order you sent them:
|
|
197
|
+
|
|
198
|
+
```jsonc
|
|
199
|
+
{ "screenshots": [
|
|
200
|
+
{ "url": "https://example.com/01_home.png", "name": "01_home.png" }, // this server fetches it
|
|
201
|
+
{ "path": "/abs/02_stats.png" }, // local stdio server ONLY
|
|
202
|
+
{ "file": { "download_url": "…", "file_id": "…" } } // a ChatGPT attachment
|
|
203
|
+
] }
|
|
204
|
+
```
|
|
161
205
|
|
|
162
|
-
|
|
206
|
+
Two sources in one entry is rejected (split them into two entries). A failed entry reports its
|
|
207
|
+
own `error` and never cancels the others — re-import just that one. In ChatGPT the attachment
|
|
208
|
+
arrives as the top-level `file` parameter instead of an array entry, because the Apps SDK cannot
|
|
209
|
+
fill a file parameter nested inside an array; you can pass both in the same call.
|
|
210
|
+
|
|
211
|
+
`{ url }` and `{ file }` need the hosted server (it stores the fetched PNG under your account);
|
|
212
|
+
`{ path }` needs the local stdio server (the hosted server never reads a caller-supplied
|
|
213
|
+
filesystem path — that would be an LFI hole). Each refusal names the door that IS open on your
|
|
214
|
+
tier. When the screenshots are only on the user's machine and you are on the hosted server, there
|
|
215
|
+
is no URL to give, so use the signed-slot flow below:
|
|
216
|
+
|
|
217
|
+
1. Call `request_screenshot_upload({ count: N, names?, family?, locale? })` — returns `N`
|
|
218
|
+
`{ ref, uploadUrl, name?, variant }` slots
|
|
163
219
|
under your own private prefix, plus a copy-paste `curl -T` example.
|
|
164
220
|
2. `curl -T screenshot.png "<uploadUrl>"` each screenshot directly (bytes never touch this
|
|
165
221
|
conversation).
|
|
166
222
|
3. Pass `{ "ref": "<ref>" }` — not inline base64 — as that screenshot's entry in
|
|
167
|
-
`render_strip`/`emit_bundle`'s `screenshots` array.
|
|
223
|
+
`render_strip`/`emit_bundle`'s `screenshots` array. For `render_project`, also pass the slot's
|
|
224
|
+
`name` and `variant`; `name` must exactly match the saved shot's original filename, and legacy
|
|
225
|
+
entries without a variant remain the base/fallback cell.
|
|
168
226
|
|
|
169
227
|
`screenshots` entries accept these shapes: an inline base64 string (small payloads only —
|
|
170
|
-
inline is capped and returns a clear "payload too large" error above
|
|
228
|
+
inline is capped and returns a clear "payload too large" error above 3MB decoded, pointing
|
|
171
229
|
you back at this flow), `{ ref }` from step 1, `{ url }` (an https URL this server fetches
|
|
172
230
|
itself: image/png, no redirects, ~20MB cap), `{ path }` (local stdio server only — see below),
|
|
173
231
|
or `{ "locales": { "<locale>": <any of those> } }` for per-locale variants (see
|
|
174
232
|
[Per-locale screenshots](#per-locale-screenshots--multi-locale-bundles) above).
|
|
175
233
|
|
|
234
|
+
Every raw PNG is automatically palette-optimized before rendering (TinyPNG-style, locally inside
|
|
235
|
+
ShotOps): dimensions and transparency are preserved, and an already-smaller PNG is left byte-for-byte
|
|
236
|
+
unchanged. This happens after the input reaches the MCP, so it does **not** raise the inline request
|
|
237
|
+
cap — use the ref/upload flow for a large source file.
|
|
238
|
+
|
|
176
239
|
**Output** works the same way in reverse: `render_strip`/`emit_bundle` take an `output` field
|
|
177
240
|
— `"inline"` (base64), `"urls"` (uploaded under your own prefix, short-lived signed download
|
|
178
241
|
URLs), or omit for auto (inline under ~200KB total, `urls` above). Use `"urls"` for
|
|
@@ -224,7 +287,7 @@ is entered in the Platform dashboard. Set it on the hosted server as
|
|
|
224
287
|
`OPENAI_APPS_CHALLENGE_TOKEN`; the server then returns that exact value from
|
|
225
288
|
`/.well-known/openai-apps-challenge`. With no token configured, the route returns 404.
|
|
226
289
|
The server also exposes a synthetic, non-user PNG at
|
|
227
|
-
`/review-fixtures/sample-app-screen.png` so reviewers can reproduce attachment and URL-input
|
|
290
|
+
`/review-fixtures/sample-app-screen-v2.png` so reviewers can reproduce attachment and URL-input
|
|
228
291
|
tests without private fixture data or third-party hosting.
|
|
229
292
|
|
|
230
293
|
## Connect Claude Code
|
|
@@ -254,23 +317,23 @@ unzip shotops-appstore-upload.zip -d upload && cd upload
|
|
|
254
317
|
fastlane deliver # signs in as YOU, previews, uploads screenshots to a draft — never submits
|
|
255
318
|
```
|
|
256
319
|
|
|
257
|
-
## Hosted
|
|
320
|
+
## Hosted
|
|
258
321
|
|
|
259
322
|
The server also runs hosted, 24/7-reachable (scale-to-zero when idle), at:
|
|
260
323
|
|
|
261
324
|
```
|
|
262
|
-
https://shotops
|
|
325
|
+
https://shotops.dev/mcp
|
|
263
326
|
```
|
|
264
327
|
|
|
265
328
|
Nothing to install — point any MCP client at that URL with your `Authorization: Bearer
|
|
266
329
|
shotops_…` header, same as local dev, just swap the base URL:
|
|
267
330
|
|
|
268
331
|
```bash
|
|
269
|
-
claude mcp add --transport http shotops https://shotops
|
|
332
|
+
claude mcp add --transport http shotops https://shotops.dev/mcp \
|
|
270
333
|
--header "Authorization: Bearer shotops_…"
|
|
271
334
|
```
|
|
272
335
|
|
|
273
|
-
The public [`/connect`](https://
|
|
336
|
+
The public [`/connect`](https://shotops.dev/connect) page in the studio
|
|
274
337
|
app has the same instructions for Cursor/CI, plus a link to where to create a token — it's
|
|
275
338
|
the one place an external agent user (not a repo collaborator) can find the connect story,
|
|
276
339
|
since this repo is private.
|
|
@@ -288,7 +351,7 @@ since this repo is private.
|
|
|
288
351
|
- **Verify after any deploy** — a green `flyctl deploy` isn't proof the server answers MCP
|
|
289
352
|
calls:
|
|
290
353
|
```bash
|
|
291
|
-
node verify-mcp.mjs https://shotops
|
|
354
|
+
node verify-mcp.mjs https://shotops.dev/mcp <a-real-shotops_-token>
|
|
292
355
|
```
|
|
293
356
|
Exercises `tools/list`, `read_look`, `emit_bundle` (asserts the zip unzips to
|
|
294
357
|
README/Deliverfile/screenshots), and asserts no-token / bad-token both get `401`.
|
|
@@ -299,11 +362,19 @@ since this repo is private.
|
|
|
299
362
|
server at runtime. Vite is build/parity tooling only and is pruned from production
|
|
300
363
|
dependencies; the Docker build context still includes `mockup-engine` so the harness can
|
|
301
364
|
bundle the shared engine + GLB.
|
|
302
|
-
- **VM size is `shared-cpu-2x`
|
|
303
|
-
|
|
365
|
+
- **VM size is `shared-cpu-2x` with 4GB — and BOTH numbers are load-bearing for different
|
|
366
|
+
reasons.** The **memory** was raised 2gb → 4gb on 2026-07-26 (#134): a grouped multi-panel
|
|
367
|
+
`render_strip` drove `chrome-headless` to ~1.4gb resident, the kernel OOM-killed it, and the
|
|
368
|
+
server went unhealthy for minutes — `verify-mcp.mjs` could not run to completion at 2gb and now
|
|
369
|
+
does. The **2 vCPUs** are the older finding below; don't conflate them, and don't cut either.
|
|
370
|
+
- **Why 2 vCPU and not `shared-cpu-1x`** (found live, Phase 10):
|
|
371
|
+
Chromium's software WebGL render (no GPU on the hosted tier) is CPU-heavy enough that on
|
|
304
372
|
a single shared vCPU it can starve the whole process — health checks stop responding,
|
|
305
|
-
SIGTERM/SIGINT get ignored, and
|
|
306
|
-
mid-render.
|
|
373
|
+
SIGTERM/SIGINT get ignored, and the platform force-kills the machine
|
|
374
|
+
mid-render. **`Virtual machine exited abruptly` has TWO causes and they look identical in the
|
|
375
|
+
logs:** on 1 vCPU it is this CPU starvation; on 2 vCPU under load it was the #134 OOM, i.e.
|
|
376
|
+
MEMORY, fixed by the `4gb` above (`docs/reference/ops-runbook.md` carries that half). Check
|
|
377
|
+
which resource ran out before reaching for either fix. On 2 vCPUs the same 3-panel real-screenshot render completes in ~8s. Don't
|
|
307
378
|
drop back to `shared-cpu-1x` to save cost — a render-triggering tool call is expected to
|
|
308
379
|
occasionally saturate a whole core, and 1x has no second core to keep the process alive
|
|
309
380
|
while that happens. Client callers (agents, `verify-mcp.mjs`) should still set a generous
|
|
@@ -314,7 +385,7 @@ since this repo is private.
|
|
|
314
385
|
## Local (free tier) — `npx shotops-mcp`
|
|
315
386
|
|
|
316
387
|
The SAME server also runs as a **local stdio MCP**, entirely on your own machine — no URL, no
|
|
317
|
-
account, no
|
|
388
|
+
account, no hosted cost, and no render-timeout ceiling (the hosted server's one real limit — see
|
|
318
389
|
"Ops" above). It's the same `registerTools`/render engine as the hosted server; only the
|
|
319
390
|
transport and a few account-shaped tools differ.
|
|
320
391
|
|
|
@@ -347,17 +418,23 @@ produce `output: "urls"` refs. After rendering, pass the on-disk PNGs straight t
|
|
|
347
418
|
{ "bundleId": "com.acme.app", "panels": [{ "path": "/abs/panel-01.png" }, { "path": "/abs/panel-02.png" }] }
|
|
348
419
|
```
|
|
349
420
|
|
|
350
|
-
**
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
`
|
|
354
|
-
|
|
421
|
+
**Saving without a token:** a new `save_project` made from local `{ path }` PNGs creates a private
|
|
422
|
+
**pending claim**. It uploads only that saved project's byte-free record and raw source PNGs,
|
|
423
|
+
returns `status: "pending_claim"` plus `claimId` and `openUrl`, and deliberately returns no
|
|
424
|
+
`projectId` yet. Give the `openUrl` to the user: after one Google sign-in they own the project,
|
|
425
|
+
with its screenshots, and the link expires after 7 days. An existing `project` cannot be updated
|
|
426
|
+
without the token that owns it. Non-path inputs are refused rather than creating a blank claim.
|
|
427
|
+
|
|
428
|
+
Ordinary `render_strip` / `emit_bundle` calls still upload **nothing** and work fully. Other
|
|
429
|
+
account reads/writes (`read_look`, `save_look`, `read_project`, `render_project`, share links)
|
|
430
|
+
still need a token or hosted connection. A token-backed local `render_project` reads the cloud
|
|
431
|
+
manifest through the Studio control plane, downloads only the resolved cells, and renders locally:
|
|
355
432
|
|
|
356
|
-
| | Local (free, `npx shotops-mcp`) | Hosted (
|
|
433
|
+
| | Local (free, `npx shotops-mcp`) | Hosted (`shotops_…` token) |
|
|
357
434
|
|---|---|---|
|
|
358
|
-
| Render compute | **your machine** ($0, no timeout) | our
|
|
435
|
+
| Render compute | **your machine** ($0, no timeout) | our servers |
|
|
359
436
|
| Auth | none (the process runs as you) | account + API token |
|
|
360
|
-
| Saved looks / editable projects / project read+re-render / share links |
|
|
437
|
+
| Saved looks / editable projects / project read+re-render / share links | new project: pending claim; existing/account state: token (below); share links: hosted | ✅ |
|
|
361
438
|
| Screenshot input | local `{ path }` off disk | `request_screenshot_upload` → `{ ref }` |
|
|
362
439
|
|
|
363
440
|
### Optional: bridge a local render into your hosted account
|
|
@@ -365,8 +442,8 @@ itself, only on persistence, which is exactly the free/paid line:
|
|
|
365
442
|
Pass a personal ShotOps API token (Studio → account menu → **API tokens** — the same
|
|
366
443
|
`shotops_…` token the hosted server takes as a bearer) and `save_project` / `read_look` /
|
|
367
444
|
`save_look` / `read_project` / `render_project` start working too — **rendering still happens
|
|
368
|
-
locally and for free
|
|
369
|
-
|
|
445
|
+
locally and for free.** A deliberate save also uploads its local source PNGs privately so the
|
|
446
|
+
project reopens with screenshots on another device; normal renders and exports never upload:
|
|
370
447
|
|
|
371
448
|
```bash
|
|
372
449
|
SHOTOPS_TOKEN=shotops_… claude mcp add shotops -- npx -y shotops-mcp
|
|
@@ -411,7 +488,7 @@ shotops-mcp/
|
|
|
411
488
|
vite.harness.config.mjs the STATIC harness build (local mode's publish prerequisite) → harness-dist/
|
|
412
489
|
build.mjs esbuild bundle of src/local.ts (+ @engine/@api inlined) → dist/local.js
|
|
413
490
|
Dockerfile hosted image — Playwright's Chromium base + this repo's 3 npm installs
|
|
414
|
-
fly.toml Fly app config
|
|
491
|
+
fly.toml Fly app config — ROLLBACK ONLY since the Vercel cutover; see #68
|
|
415
492
|
verify-mcp.mjs post-deploy smoke test — real MCP round-trip against a hosted URL
|
|
416
493
|
verify-mcp-local.ts local stdio smoke test (tsx src/local.ts, in-repo)
|
|
417
494
|
static-parity.ts pixel-diffs the static-serve render against the Vite-dev render (must be 0)
|