feat(wardrobe,picture): try-on integration — outfit → OpenAI edit (M4)

M4 of docs/plans/wardrobe-module.md — the loop closes. A user with at
least a face-ref in the active space can click "Anprobieren" on an
outfit detail page; the client composes a reference call against the
existing M3 `/generate-with-reference` endpoint, persists the result
into the Picture gallery with a `wardrobeOutfitId` back-reference,
and pins a `lastTryOn` snapshot on the outfit so its card instantly
shows the AI preview next time.

Server side — picture/routes.ts:
- verifyMediaOwnership now accepts `apps: string | readonly string[]`.
  Under the hood it runs one list() per app-tag and unions the owned
  set before the missing-id check. Preserves the 500-row per-app
  sanity cap. Single-tag callers unchanged — it's an additive widen.
- Picture /generate-with-reference passes `['me', 'wardrobe']` so
  face/body portraits (me-images) and garment photos (wardrobe) can
  ride in the same referenceMediaIds array. Anything outside those
  two tags still 404s — no expansion of the trust surface.

Client side — wardrobe/api/try-on.ts:
- `runOutfitTryOn({ outfit, garments, faceRefMediaId, bodyRefMediaId?, ... })`
  composes the ref list (face → body → up to 6 garments, respecting
  the 8-slot server cap), picks portrait 1024x1536 by default (or
  1024x1024 in accessory-only mode), and POSTs with
  `model='openai/gpt-image-2'`, `quality='medium'`, `n=1`. One render
  per click; multi-variant is a future Generator-style extension.
- Default prompts are composed in DE from the outfit meta (name +
  occasion); callers can override via `prompt`. Accessory-only mode
  uses a tighter studio-portrait phrasing since the fullbody ref is
  dropped there.
- `isAccessoryOnlyOutfit()` helper — iff every garment is in
  FACE_ONLY_CATEGORIES, skip body-ref and render square. Covers the
  Brille-Try-On headline use case.
- On success: inserts a `picture.images` row with generationMode=
  'reference', referenceImageIds, and wardrobeOutfitId set; then
  calls wardrobeOutfitsStore.setLastTryOn() with imageId + imageUrl
  so OutfitCard + DetailOutfitView immediately flip to the AI cover.

TryOnButton — wardrobe/components/TryOnButton.svelte:
- Three states: ready (click to render), missing-references (shows
  UserCircle + link to /profile/me-images, with the right hint for
  accessory-only vs. fullbody), loading (spinner).
- Credit estimate on the button (10c medium quality).
- Hints: accessory-only, too-many-garments (>6, over server cap),
  and non-personal-space disclosure — the family-space case gets its
  own sentence since "Try-On rendert dich, nicht dein Kind" is
  non-obvious.
- Reads face-ref/body-ref via useImageByPrimary (space-scoped after
  the v40 meImages migration — brand/club/family spaces need their
  own references uploaded).

UI wiring:
- DetailOutfitView replaces the M3 stub button with <TryOnButton/>.
  The existing "Try-On Verlauf"-Strip already reads
  `useOutfitTryOns(outfit.id)` which filters `picture.images` by
  wardrobeOutfitId — it lights up automatically on first render.

Not in M4 (punted to follow-ups):
- Solo-garment try-on on DetailGarmentView ("nur diese Brille auf
  mein Gesicht"). Plan called it out as optional; the outfit flow
  already covers it when the outfit contains only that one garment.
- Multi-variant rendering (n=2/4). Usable "show me 3 looks" needs a
  picker UI on top, not just a param bump.
- Quality + prompt override in the button. A power-user panel can
  come later; default medium + auto-prompt keeps M4's click-to-try-on
  one-tap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 18:52:57 +02:00
parent 2b89bf7955
commit d56ad396d8
5 changed files with 352 additions and 27 deletions

View file

@ -58,25 +58,33 @@ export async function getMediaBuffer(
}
/**
* Verify that every id in `mediaIds` is owned by `userId` under the given
* `app` scope. Throws { status: 404 } when one or more ids are not in the
* user's reference set the caller turns that into an HTTP response.
* Verify that every id in `mediaIds` is owned by `userId` under one of
* the given app scopes. Throws `{ status: 404, missing }` when any id
* doesn't land in the owned set the caller turns that into an HTTP
* response.
*
* One `list()` round-trip is all we need: the response is the full set of
* the user's uploads under that app tag, so set-membership check is O(N)
* in memory. The `limit: 500` cap is the sanity fence a single user with
* more than 500 reference images under one app is already far beyond the
* product's intended shape; we'd catch that as a design regression long
* before it breaks this check.
* Accepts a single app string or an array. The Wardrobe try-on flow
* (plan docs/plans/wardrobe-module.md M4) passes `['me', 'wardrobe']`
* in one call face-ref and body-ref live under `me`, garments live
* under `wardrobe`, both legitimate inputs for the same `/v1/images/
* edits` POST.
*
* One `list()` round-trip per app. For N apps this is N calls, each
* capped at 500 rows far beyond the product's intended per-app shape
* but the cap is the sanity fence.
*/
export async function verifyMediaOwnership(
userId: string,
mediaIds: readonly string[],
app: string
apps: string | readonly string[]
): Promise<void> {
if (mediaIds.length === 0) return;
const owned = await getMediaClient().list({ userId, app, limit: 500 });
const ownedSet = new Set(owned.map((m) => m.id));
const appList = typeof apps === 'string' ? [apps] : apps;
const ownedSet = new Set<string>();
for (const app of appList) {
const list = await getMediaClient().list({ userId, app, limit: 500 });
for (const m of list) ownedSet.add(m.id);
}
const missing = mediaIds.filter((id) => !ownedSet.has(id));
if (missing.length > 0) {
const err = new Error(`Reference media not owned: ${missing.join(', ')}`) as Error & {

View file

@ -297,12 +297,13 @@ routes.post('/generate-with-reference', async (c) => {
}
// Ownership check before we spend credits or burn OpenAI quota.
// meImages are tagged `app='me'` at upload time by the profile
// module; a mediaId that isn't in the caller's set is either stale
// or malicious, treat both as 404.
// References span two upload tags: `me` for face/body portraits
// (profile module) and `wardrobe` for garment photos (wardrobe
// module, M4 try-on flow). Anything outside those two apps is
// treated as not-owned regardless of mana-media's own view.
try {
const { verifyMediaOwnership } = await import('../../lib/media');
await verifyMediaOwnership(userId, refIds, 'me');
await verifyMediaOwnership(userId, refIds, ['me', 'wardrobe']);
} catch (err) {
const e = err as Error & { status?: number; missing?: string[] };
if (e.status === 404) {