mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
2b89bf7955
commit
d56ad396d8
5 changed files with 352 additions and 27 deletions
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue