feat(wardrobe): solo-garment try-on + plan-doc status updates (M4.1)

Closes the one checklist item M4 left for later — "TryOnButton auf
DetailGarmentView (mit impliziten 'Solo-Outfit')". A user can now open
a single garment's detail page, see "An mir anprobieren · 10 Credits",
and get an inline preview of themselves wearing just that one item
(or just that accessory, for glasses/jewelry/hat/accessory).

Client:
- api/try-on.ts: extracts a shared callGenerateWithReference() helper
  and a dimsForSize() utility from runOutfitTryOn so the new
  runGarmentTryOn can share the HTTP-error matrix + picture.images
  row shape without a refactor of the outfit path.
- runGarmentTryOn({ garment, faceRefMediaId, bodyRefMediaId?, prompt?,
  quality? }): auto-detects accessoryOnly from the garment's category
  (FACE_ONLY_CATEGORIES), composes the DE default prompt ("im/in
  <Name>", "mit <Name>" für Accessoires), writes a picture.images row
  with wardrobeOutfitId=null so it doesn't pollute any outfit's
  try-on history. Does NOT update any outfit.lastTryOn — it's a
  standalone preview, on purpose.
- GarmentTryOnButton.svelte: thinner sibling of TryOnButton. Same
  three states (ready / missing-refs / loading), same non-personal-
  space disclaimer. Extra: inline preview panel showing the last
  rendered result, with a link to the Picture gallery ("Gefunden in
  der Picture-Galerie als normale Generierung.").
- DetailGarmentView now puts the try-on action above the existing
  wear-tracking button. Try-on is the more engaging action for this
  page; demoting "heute getragen" to a secondary-styled button
  respects that without removing it.

Plan docs:
- docs/plans/wardrobe-module.md — rewrites the Status block to M1-M5
  with actual commit hashes, and checks off the per-milestone task
  lists. Adds a new M4.1 block for solo-garment try-on.
- docs/plans/me-images-and-reference-generation.md — adds the v40
  space-scope migration (cb9a9bb42) as its own row in the commit
  table, with a pointer to the sub-plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 21:14:35 +02:00
parent f20ace0358
commit e0820331b0
5 changed files with 402 additions and 99 deletions

View file

@ -19,6 +19,77 @@ import { wardrobeOutfitsStore } from '../stores/outfits.svelte';
import { FACE_ONLY_CATEGORIES } from '../types';
import type { Garment, GarmentCategory, Outfit } from '../types';
/** Shared low-level POST to /generate-with-reference. Returns the first
* generated image's URL + mediaId + prompt + model outfit and solo
* variants both go through here to keep the HTTP error matrix identical.
*/
async function callGenerateWithReference(opts: {
prompt: string;
referenceMediaIds: string[];
quality: 'low' | 'medium' | 'high';
size: TryOnSize;
}): Promise<{ imageUrl: string; mediaId: string; prompt: string; model: string }> {
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: opts.prompt,
referenceMediaIds: opts.referenceMediaIds,
model: 'openai/gpt-image-2',
quality: opts.quality,
size: opts.size,
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 }>;
imageUrl?: string;
mediaId?: string;
prompt: string;
model: string;
};
const first =
(data.images && data.images[0]) ??
(data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null);
if (!first?.imageUrl || !first.mediaId) {
throw new Error('Keine Bilder zurückgegeben');
}
return {
imageUrl: first.imageUrl,
mediaId: first.mediaId,
prompt: data.prompt,
model: data.model,
};
}
function dimsForSize(size: TryOnSize): { width: number; height: number } {
if (size === '1024x1536') return { width: 1024, height: 1536 };
if (size === '1536x1024') return { width: 1536, height: 1024 };
return { width: 1024, height: 1024 };
}
export type TryOnSize = '1024x1024' | '1536x1024' | '1024x1536';
export interface RunOutfitTryOnParams {
@ -86,73 +157,31 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
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,
}),
const result = await callGenerateWithReference({
prompt: effectivePrompt,
referenceMediaIds,
quality: quality ?? 'medium',
size: effectiveSize,
});
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();
const dims = dimsForSize(effectiveSize);
// 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,
prompt: result.prompt,
negativePrompt: null,
model: data.model,
publicUrl: first.imageUrl,
storagePath: first.mediaId ?? first.imageUrl,
model: result.model,
publicUrl: result.imageUrl,
storagePath: result.mediaId,
filename: `wardrobe-tryon-${Date.now()}.png`,
format: 'png',
width: effectiveSize === '1024x1536' ? 1024 : effectiveSize === '1536x1024' ? 1536 : 1024,
height: effectiveSize === '1024x1536' ? 1536 : effectiveSize === '1536x1024' ? 1024 : 1024,
width: dims.width,
height: dims.height,
isPublic: false,
isFavorite: false,
downloadCount: 0,
@ -168,16 +197,110 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
// live-query round-trip.
await wardrobeOutfitsStore.setLastTryOn(outfit.id, {
imageId: localImageId,
imageUrl: first.imageUrl,
imageUrl: result.imageUrl,
createdAt: now,
prompt: data.prompt,
model: data.model,
prompt: result.prompt,
model: result.model,
});
return {
imageId: localImageId,
imageUrl: first.imageUrl,
prompt: data.prompt,
model: data.model,
imageUrl: result.imageUrl,
prompt: result.prompt,
model: result.model,
};
}
// ─── Solo-Garment Try-On ─────────────────────────────────────────
export interface RunGarmentTryOnParams {
garment: Garment;
faceRefMediaId: string;
/** Null for accessory categories (glasses/jewelry/hat/accessory) the
* category check happens here, callers don't need to pre-filter. */
bodyRefMediaId?: string | null;
prompt?: string;
quality?: 'low' | 'medium' | 'high';
}
/** True iff the garment category implies a face-only render. Exposed so
* the button can decide whether body-ref is required. */
export function isAccessoryGarment(garment: Garment): boolean {
return FACE_ONLY_CATEGORIES.has(garment.category as GarmentCategory);
}
function composeGarmentPrompt(garment: Garment, accessoryOnly: boolean): string {
if (accessoryOnly) {
return `Fotorealistisches Portrait von mir mit ${garment.name}, frontal, studio-Licht, neutraler Hintergrund, Fokus auf dem Accessoire`;
}
return `Fotorealistisches Portrait von mir im/in ${garment.name}, natürliches Licht, neutraler Hintergrund`;
}
/**
* Single-garment try-on "nur diese Brille auf mein Gesicht" / "wie
* sähe dieses Hemd an mir aus". Writes a picture.images row WITHOUT a
* wardrobeOutfitId back-reference (it's not an outfit) and does NOT
* update any outfit's lastTryOn. The Picture gallery picks it up like
* any other generated image.
*
* Plan follow-up to docs/plans/wardrobe-module.md M4.
*/
export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise<RunTryOnResult> {
const { garment, faceRefMediaId, bodyRefMediaId, prompt, quality } = params;
const garmentMediaId = garment.mediaIds[0];
if (!garmentMediaId) {
throw new Error('Dieses Kleidungsstück hat kein Foto.');
}
const accessoryOnly = isAccessoryGarment(garment);
const effectiveSize: TryOnSize = accessoryOnly ? '1024x1024' : '1024x1536';
const effectivePrompt = prompt?.trim() || composeGarmentPrompt(garment, accessoryOnly);
const referenceMediaIds: string[] = [faceRefMediaId];
if (!accessoryOnly && bodyRefMediaId) referenceMediaIds.push(bodyRefMediaId);
referenceMediaIds.push(garmentMediaId);
const result = await callGenerateWithReference({
prompt: effectivePrompt,
referenceMediaIds,
quality: quality ?? 'medium',
size: effectiveSize,
});
const now = new Date().toISOString();
const localImageId = crypto.randomUUID();
const dims = dimsForSize(effectiveSize);
await imagesStore.insert({
id: localImageId,
prompt: result.prompt,
negativePrompt: null,
model: result.model,
publicUrl: result.imageUrl,
storagePath: result.mediaId,
filename: `wardrobe-garment-tryon-${Date.now()}.png`,
format: 'png',
width: dims.width,
height: dims.height,
isPublic: false,
isFavorite: false,
downloadCount: 0,
generationMode: 'reference',
referenceImageIds: referenceMediaIds,
// Deliberately null — this is a standalone preview, not an outfit.
// If users later compose outfits from these, the outfit-level
// try-on writes its own picture.images row with the wardrobeOutfitId
// set; no retroactive linking from the garment row.
wardrobeOutfitId: null,
createdAt: now,
updatedAt: now,
});
return {
imageId: localImageId,
imageUrl: result.imageUrl,
prompt: result.prompt,
model: result.model,
};
}

View file

@ -0,0 +1,148 @@
<!--
Single-garment try-on action for the garment detail page. Thinner
sibling of TryOnButton — no outfit context, no occasion hint. Still
handles the three states (ready / missing refs / loading) and
disclaimer in non-personal spaces.
Plan follow-up: docs/plans/wardrobe-module.md M4 called out solo-
garment try-on ("mit impliziten 'Solo-Outfit'"); the runGarmentTryOn
helper writes a picture.images row WITHOUT a wardrobeOutfitId, so
the result lives in the Picture gallery but doesn't pollute any
outfit's try-on history.
-->
<script lang="ts">
import { Sparkle, UserCircle, Info } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import { isAccessoryGarment, runGarmentTryOn } from '../api/try-on';
import type { Garment } from '../types';
interface Props {
garment: Garment;
}
let { garment }: Props = $props();
const face$ = useImageByPrimary('face-ref');
const body$ = useImageByPrimary('body-ref');
const activeSpace = $derived(getActiveSpace());
const face = $derived(face$.value);
const body = $derived(body$.value);
const accessoryOnly = $derived(isAccessoryGarment(garment));
const missingFace = $derived(!face);
const missingBody = $derived(!accessoryOnly && !body);
const hasPhoto = $derived((garment.mediaIds?.length ?? 0) > 0);
const canTryOn = $derived(!missingFace && !missingBody && hasPhoto);
let running = $state(false);
let error = $state<string | null>(null);
let lastResultUrl = $state<string | null>(null);
const estimatedCredits = 10;
async function handleClick() {
if (!face || (!accessoryOnly && !body)) return;
running = true;
error = null;
lastResultUrl = null;
try {
const result = await runGarmentTryOn({
garment,
faceRefMediaId: face.mediaId,
bodyRefMediaId: accessoryOnly ? null : (body?.mediaId ?? null),
});
lastResultUrl = result.imageUrl;
} catch (err) {
error = err instanceof Error ? err.message : 'Try-On fehlgeschlagen';
} finally {
running = false;
}
}
</script>
{#if !hasPhoto}
<p class="text-xs text-muted-foreground">
Lade erst ein Foto hoch, um dieses Stück an dir zu visualisieren.
</p>
{:else if missingFace || missingBody}
<div
class="flex items-start gap-3 rounded-xl border border-dashed border-border bg-background/50 p-4 text-sm text-muted-foreground"
>
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
<div class="space-y-1">
<p class="text-foreground">Lade erst Referenzbilder hoch, um das Stück an dir zu sehen.</p>
<p class="text-xs">
Solo-Try-On braucht ein {accessoryOnly
? 'Gesichtsbild'
: 'Gesichts- und ein Ganzkörperbild'}
in diesem Space. Öffne dafür
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
</div>
</div>
{:else}
<div class="space-y-2">
<button
type="button"
onclick={handleClick}
disabled={running || !canTryOn}
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if running}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></div>
Rendere…
{:else}
<Sparkle size={16} weight="fill" />
An mir anprobieren · {estimatedCredits} Credits
{/if}
</button>
{#if accessoryOnly}
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Info size={12} weight="regular" class="flex-shrink-0" />
Accessoire-Modus — nur das Gesicht wird gerendert (spart Credits).
</p>
{/if}
{#if activeSpace && activeSpace.type !== 'personal'}
<p class="flex items-start gap-1.5 text-xs text-muted-foreground">
<Info size={12} weight="regular" class="mt-0.5 flex-shrink-0" />
<span>
Try-On nutzt deine Referenzbilder aus diesem Space
<strong class="text-foreground">({activeSpace.name})</strong>, nicht aus Persönlich.
</span>
</p>
{/if}
{#if error}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
role="alert"
>
{error}
</div>
{/if}
{#if lastResultUrl}
<div class="space-y-1.5 rounded-xl border border-border bg-card p-3">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Ergebnis</p>
<img
src={lastResultUrl}
alt="Try-On"
class="w-full rounded-md border border-border bg-muted"
/>
<p class="text-xs text-muted-foreground">
Gefunden in der
<a href="/picture" class="font-medium text-primary hover:underline">Picture-Galerie</a>
als normale Generierung.
</p>
</div>
{/if}
</div>
{/if}

View file

@ -12,6 +12,7 @@
import { garmentPhotoUrl } from '../api/media-url';
import { CATEGORY_LABELS } from '../constants';
import GarmentForm from '../components/GarmentForm.svelte';
import GarmentTryOnButton from '../components/GarmentTryOnButton.svelte';
interface Props {
id: string;
@ -184,14 +185,17 @@
{/if}
</div>
<!-- Primary action: heute getragen -->
<!-- Try-on — "wie sähe das an mir aus" -->
<GarmentTryOnButton {garment} />
<!-- Wear-tracking -->
<button
type="button"
onclick={handleMarkWorn}
disabled={markingWorn}
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
class="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50"
>
<CheckCircle size={16} weight="fill" />
<CheckCircle size={14} />
{markingWorn ? 'Gespeichert…' : 'Heute getragen'}
</button>