Vitrina wraps a catalog-driven generation studio with the objects an e-commerce team actually works with — products, a brand kit, presets, channels, campaigns — and the plumbing needed to sell it: accounts, workspaces, credits, billing.
browser ──── server actions ────► platform API (Higgsfield)
│ │ ▲
│ ├── credits ledger │ Authorization: Key id:secret
│ ├── run log │ (operator key or workspace BYOK)
│ └── libsql DB ───────┘
└──── /api/upload | /api/blob ───► local disk | Vercel Blob
| Layer | Path | Depends on |
|---|---|---|
| Domain (vertical as data) | src/domain |
catalog types only |
| Generation engine | src/generation |
domain (pricing), server (runs, credits, session) |
| Server services & actions | src/server |
db, domain |
| Studio surface | src/studio |
generation, domain, server actions |
| App pages | src/app, src/ui |
everything above |
Dependencies point downward. The domain never imports from the server or
the UI, so its tests run in plain Node and it can be swapped for another
industry without touching the rest (see VERTICAL.md).
- The composer holds a job: product, preset, channel (
src/studio/stores/job.ts). Picking a preset selects the model it prefers; a preset or channel writes the model's settings (resolveSettings); the product's photos are attached under the role the preset wants. - On Generate, the studio assembles the plane (model, media, settings) and
compiles the prompt:
compilePrompt(preset.template, { product, brand, channel, scene }). With no preset, the free template folds the product's appearance and the brand's style and avoid-list around the visitor's words. submitGeneration(server action) checks the session, resolves the key (workspace BYOK, else operatorPLATFORM_API_KEY), prices the run (creditsFor), reserves credits atomically, submits to the platform, and inserts one running row per expected result. A submit failure refunds.- The browser polls
getGenerationStatusesevery 4 s for every request in flight (one round trip). The server settles terminal answers: rows are re-cut to one per delivered URL, failures are recorded and refunded once (the ledger's(requestId, reason)uniqueness guarantees it). A request nobody polls for ten minutes is abandoned and refunded by the same path. - Tiles bloom in the grid; the viewer shows the scene, the final prompt,
product / preset / channel, and Approve / Reject. Approved runs are what a
campaign export packs, filed
brand/product/channel/preset-NN.ext.
user, session, account, verification — better-auth.
workspace (plan, cached credit balance, Stripe ids, sealed BYOK key) ·
membership · invite (tokened links, spent once, seats per plan) ·
brand_kit · product · product_asset · upload · campaign · run ·
credit_ledger · stripe_event.
A user can belong to several workspaces; the vt_workspace cookie names the
one on screen and workspaceForUser falls back to the first membership.
workspace.credit_balance is a cache of sum(credit_ledger.delta) and is
only written inside src/server/credits.ts, in the same statement or batch
as the ledger row.
- Operator mode: the installation's
PLATFORM_API_KEYpays the platform; customers pay in credits (Stripe subscriptions grant a monthly allowance). - BYOK: a workspace owner pastes their own platform key; it is sealed with
AES-256-GCM under
APP_SECRETand runs cost zero credits. - Prices and credit grants live in
src/domain/plans.ts; Stripe price ids in the environment. The webhook (/api/billing/webhook) is idempotent by event id.
Uploads are public URLs the platform fetches. local writes under
DATA_DIR/uploads/<workspace>/<uuid>-<name> and serves via /api/files
(unauthenticated, unguessable path). blob issues scoped Vercel Blob client
tokens to signed-in members only.
vitest: domain (prompt compiler, settings resolution against the real catalog, pricing, export paths), credits ledger races and idempotency, Stripe event application, sealing.- Playwright: sign-up → brand kit → product photo → studio job rail →
generate → review → refund on failure → campaign plan → export zip, all
against
scripts/mock-platform.mjs.