diff --git a/apps/api/src/lib/media.ts b/apps/api/src/lib/media.ts index 8cb509c8d..90ec41260 100644 --- a/apps/api/src/lib/media.ts +++ b/apps/api/src/lib/media.ts @@ -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 { 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(); + 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 & { diff --git a/apps/api/src/modules/picture/routes.ts b/apps/api/src/modules/picture/routes.ts index 75d26710e..d37d65903 100644 --- a/apps/api/src/modules/picture/routes.ts +++ b/apps/api/src/modules/picture/routes.ts @@ -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) { diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts b/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts new file mode 100644 index 000000000..ea971a2e5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts @@ -0,0 +1,183 @@ +/** + * Try-On client. Composes a reference-based image-edit call against + * the M3 endpoint `/api/v1/picture/generate-with-reference` using the + * active space's face-ref + body-ref meImages plus every garment in + * the outfit, then persists the result into `picture.images` with the + * outfit's `wardrobeOutfitId` back-reference and updates the outfit's + * `lastTryOn` snapshot. + * + * The caller resolves the primaries reactively (via useImageByPrimary) + * and hands us the raw mediaIds — keeps this pure and testable. + * + * Plan: docs/plans/wardrobe-module.md M4. + */ + +import { getManaApiUrl } from '$lib/api/config'; +import { authStore } from '$lib/stores/auth.svelte'; +import { imagesStore } from '$lib/modules/picture/stores/images.svelte'; +import { wardrobeOutfitsStore } from '../stores/outfits.svelte'; +import { FACE_ONLY_CATEGORIES } from '../types'; +import type { Garment, GarmentCategory, Outfit } from '../types'; + +export type TryOnSize = '1024x1024' | '1536x1024' | '1024x1536'; + +export interface RunOutfitTryOnParams { + outfit: Outfit; + garments: Garment[]; // resolved LocalWardrobeGarment rows, primary photos must exist + faceRefMediaId: string; + /** Optional — omit for accessory-only mode (glasses/jewelry/hat/accessory). */ + bodyRefMediaId?: string | null; + /** Optional override; default is composed from the outfit meta. */ + prompt?: string; + /** `medium` balances FLUX-ish detail against credit cost (10c). */ + quality?: 'low' | 'medium' | 'high'; + size?: TryOnSize; +} + +export interface RunTryOnResult { + imageId: string; // picture.images.id (local UUID) + imageUrl: string; // mana-media URL + prompt: string; + model: string; +} + +/** + * True iff every garment in the outfit is in a face-only category — + * then the try-on renders just the face without a fullbody reference + * (better for brille/schmuck/hut zoom). + */ +export function isAccessoryOnlyOutfit(garments: Garment[]): boolean { + if (garments.length === 0) return false; + return garments.every((g) => FACE_ONLY_CATEGORIES.has(g.category as GarmentCategory)); +} + +function composeDefaultPrompt(outfit: Outfit, accessoryOnly: boolean): string { + if (accessoryOnly) { + return `Fotorealistisches Portrait von mir mit ${outfit.name}, frontal, studio-Licht, neutraler Hintergrund, Fokus auf dem Accessoire`; + } + const occasionHint = outfit.occasion ? ` (Anlass: ${outfit.occasion})` : ''; + return `Fotorealistisches Portrait von mir im Outfit ${outfit.name}${occasionHint}, natürliches Licht, neutraler Hintergrund`; +} + +export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise { + const { outfit, garments, faceRefMediaId, bodyRefMediaId, prompt, quality, size } = params; + + const garmentMediaIds = garments + .map((g) => g.mediaIds[0]) + .filter((id): id is string => Boolean(id)); + if (garmentMediaIds.length === 0) { + throw new Error('Outfit hat keine Kleidungsstücke mit Foto.'); + } + + const accessoryOnly = isAccessoryOnlyOutfit(garments); + const effectiveSize: TryOnSize = size ?? (accessoryOnly ? '1024x1024' : '1024x1536'); + const effectivePrompt = prompt?.trim() || composeDefaultPrompt(outfit, accessoryOnly); + + // Reference order: face first, then body (if present), then garments. + // gpt-image-2 weights early refs slightly more for identity — keeping + // face at [0] makes the person recognizable before the garments + // negotiate for attention. + const referenceMediaIds: string[] = [faceRefMediaId]; + if (!accessoryOnly && bodyRefMediaId) { + referenceMediaIds.push(bodyRefMediaId); + } + for (const id of garmentMediaIds) { + if (referenceMediaIds.length >= 8) break; // server caps at 8 + referenceMediaIds.push(id); + } + + const token = await authStore.getValidToken(); + const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + prompt: effectivePrompt, + referenceMediaIds, + model: 'openai/gpt-image-2', + quality: quality ?? 'medium', + size: effectiveSize, + n: 1, + }), + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: string; + required?: number; + missing?: string[]; + }; + if (res.status === 402) { + throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`); + } + if (res.status === 404) { + throw new Error( + 'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — vermutlich sind Face/Body noch nicht in diesem Space hochgeladen.' + ); + } + throw new Error(body.error ?? `Try-On fehlgeschlagen (${res.status})`); + } + + const data = (await res.json()) as { + images?: Array<{ imageUrl: string; mediaId?: string; thumbnailUrl?: string }>; + imageUrl?: string; + mediaId?: string; + thumbnailUrl?: string; + prompt: string; + model: string; + referenceMediaIds?: string[]; + }; + const first = + (data.images && data.images[0]) ?? + (data.imageUrl + ? { imageUrl: data.imageUrl, mediaId: data.mediaId, thumbnailUrl: data.thumbnailUrl } + : null); + if (!first) throw new Error('Keine Bilder zurückgegeben'); + + const now = new Date().toISOString(); + const localImageId = crypto.randomUUID(); + + // Persist the generated image to the Picture gallery + tag it with + // the outfit's wardrobeOutfitId so the outfit detail's Try-On strip + // picks it up via the useOutfitTryOns liveQuery. + await imagesStore.insert({ + id: localImageId, + prompt: data.prompt, + negativePrompt: null, + model: data.model, + publicUrl: first.imageUrl, + storagePath: first.mediaId ?? first.imageUrl, + filename: `wardrobe-tryon-${Date.now()}.png`, + format: 'png', + width: effectiveSize === '1024x1536' ? 1024 : effectiveSize === '1536x1024' ? 1536 : 1024, + height: effectiveSize === '1024x1536' ? 1536 : effectiveSize === '1536x1024' ? 1024 : 1024, + isPublic: false, + isFavorite: false, + downloadCount: 0, + generationMode: 'reference', + referenceImageIds: referenceMediaIds, + wardrobeOutfitId: outfit.id, + createdAt: now, + updatedAt: now, + }); + + // Pin the snapshot on the outfit so OutfitCard + DetailOutfitView + // render the cover instantly without waiting for a full picture.images + // live-query round-trip. + await wardrobeOutfitsStore.setLastTryOn(outfit.id, { + imageId: localImageId, + imageUrl: first.imageUrl, + createdAt: now, + prompt: data.prompt, + model: data.model, + }); + + return { + imageId: localImageId, + imageUrl: first.imageUrl, + prompt: data.prompt, + model: data.model, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnButton.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnButton.svelte new file mode 100644 index 000000000..f96b254b0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/components/TryOnButton.svelte @@ -0,0 +1,140 @@ + + + +{#if missingFace || missingBody} +
+ +
+

Lade erst Referenzbilder hoch, um dich im Outfit zu sehen.

+

+ Try-On braucht mindestens ein {accessoryOnly + ? 'Gesichtsbild' + : 'Gesichts- und ein Ganzkörperbild'} + in diesem Space. Öffne dafür + + Meine Bilder + . +

+
+
+{:else} +
+ + + {#if accessoryOnly} +

+ + Accessoire-Modus — nur das Gesicht wird gerendert (spart Credits). +

+ {:else if garments.length > 6} +

+ + Mit {garments.length} Kleidungsstücken ist der Referenz-Slot knapp — ältere Items werden evtl. + nicht mitgezogen. +

+ {/if} + + {#if activeSpace && activeSpace.type !== 'personal'} +

+ + + Try-On nutzt deine Referenzbilder aus diesem Space + ({activeSpace.name}), nicht aus Persönlich. + {#if activeSpace.type === 'family'} + Kinder-Outfits werden trotzdem auf dein Gesicht gerendert. + {/if} + +

+ {/if} + + {#if error} + + {/if} +
+{/if} + +{#if !missingFace && !missingBody && garments.length === 0} +

+ Füge mindestens ein {CATEGORY_LABELS_SINGULAR.top ?? 'Kleidungsstück'} hinzu, um Try-On zu aktivieren. +

+{/if} diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte index a3035ec07..589af39ea 100644 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/views/DetailOutfitView.svelte @@ -12,11 +12,12 @@ -->