mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
f20ace0358
commit
e0820331b0
5 changed files with 402 additions and 99 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue