mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21: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 { FACE_ONLY_CATEGORIES } from '../types';
|
||||||
import type { Garment, GarmentCategory, Outfit } 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 type TryOnSize = '1024x1024' | '1536x1024' | '1024x1536';
|
||||||
|
|
||||||
export interface RunOutfitTryOnParams {
|
export interface RunOutfitTryOnParams {
|
||||||
|
|
@ -86,73 +157,31 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
|
||||||
referenceMediaIds.push(id);
|
referenceMediaIds.push(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await authStore.getValidToken();
|
const result = await callGenerateWithReference({
|
||||||
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, {
|
prompt: effectivePrompt,
|
||||||
method: 'POST',
|
referenceMediaIds,
|
||||||
headers: {
|
quality: quality ?? 'medium',
|
||||||
'content-type': 'application/json',
|
size: effectiveSize,
|
||||||
...(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 now = new Date().toISOString();
|
||||||
const localImageId = crypto.randomUUID();
|
const localImageId = crypto.randomUUID();
|
||||||
|
const dims = dimsForSize(effectiveSize);
|
||||||
|
|
||||||
// Persist the generated image to the Picture gallery + tag it with
|
// Persist the generated image to the Picture gallery + tag it with
|
||||||
// the outfit's wardrobeOutfitId so the outfit detail's Try-On strip
|
// the outfit's wardrobeOutfitId so the outfit detail's Try-On strip
|
||||||
// picks it up via the useOutfitTryOns liveQuery.
|
// picks it up via the useOutfitTryOns liveQuery.
|
||||||
await imagesStore.insert({
|
await imagesStore.insert({
|
||||||
id: localImageId,
|
id: localImageId,
|
||||||
prompt: data.prompt,
|
prompt: result.prompt,
|
||||||
negativePrompt: null,
|
negativePrompt: null,
|
||||||
model: data.model,
|
model: result.model,
|
||||||
publicUrl: first.imageUrl,
|
publicUrl: result.imageUrl,
|
||||||
storagePath: first.mediaId ?? first.imageUrl,
|
storagePath: result.mediaId,
|
||||||
filename: `wardrobe-tryon-${Date.now()}.png`,
|
filename: `wardrobe-tryon-${Date.now()}.png`,
|
||||||
format: 'png',
|
format: 'png',
|
||||||
width: effectiveSize === '1024x1536' ? 1024 : effectiveSize === '1536x1024' ? 1536 : 1024,
|
width: dims.width,
|
||||||
height: effectiveSize === '1024x1536' ? 1536 : effectiveSize === '1536x1024' ? 1024 : 1024,
|
height: dims.height,
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
|
|
@ -168,16 +197,110 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
|
||||||
// live-query round-trip.
|
// live-query round-trip.
|
||||||
await wardrobeOutfitsStore.setLastTryOn(outfit.id, {
|
await wardrobeOutfitsStore.setLastTryOn(outfit.id, {
|
||||||
imageId: localImageId,
|
imageId: localImageId,
|
||||||
imageUrl: first.imageUrl,
|
imageUrl: result.imageUrl,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
prompt: data.prompt,
|
prompt: result.prompt,
|
||||||
model: data.model,
|
model: result.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageId: localImageId,
|
imageId: localImageId,
|
||||||
imageUrl: first.imageUrl,
|
imageUrl: result.imageUrl,
|
||||||
prompt: data.prompt,
|
prompt: result.prompt,
|
||||||
model: data.model,
|
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 { garmentPhotoUrl } from '../api/media-url';
|
||||||
import { CATEGORY_LABELS } from '../constants';
|
import { CATEGORY_LABELS } from '../constants';
|
||||||
import GarmentForm from '../components/GarmentForm.svelte';
|
import GarmentForm from '../components/GarmentForm.svelte';
|
||||||
|
import GarmentTryOnButton from '../components/GarmentTryOnButton.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -184,14 +185,17 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Primary action: heute getragen -->
|
<!-- Try-on — "wie sähe das an mir aus" -->
|
||||||
|
<GarmentTryOnButton {garment} />
|
||||||
|
|
||||||
|
<!-- Wear-tracking -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleMarkWorn}
|
onclick={handleMarkWorn}
|
||||||
disabled={markingWorn}
|
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'}
|
{markingWorn ? 'Gespeichert…' : 'Heute getragen'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Commits (teils durch parallele Sessions in Commits mit anderer Attribution gelan
|
||||||
| M4 Reference-Picker | in `d087b4744` | `ReferenceImagePicker.svelte`, Model-Auto-Switch, Endpoint-Routing, `generationMode`+`referenceImageIds` auf `LocalImage` |
|
| M4 Reference-Picker | in `d087b4744` | `ReferenceImagePicker.svelte`, Model-Auto-Switch, Endpoint-Routing, `generationMode`+`referenceImageIds` auf `LocalImage` |
|
||||||
| M2.5 Avatar-Migration | `e2b5ac38c` | One-shot `migration/legacy-avatar.ts`, Autosync face-ref→avatar→`auth.users.image`, EditProfileModal-Cleanup |
|
| M2.5 Avatar-Migration | `e2b5ac38c` | One-shot `migration/legacy-avatar.ts`, Autosync face-ref→avatar→`auth.users.image`, EditProfileModal-Cleanup |
|
||||||
| M5 MCP-Tools | `fc635f983` | `packages/mana-tool-registry/src/modules/me.ts` — zwei Tools, auto-registriert |
|
| M5 MCP-Tools | `fc635f983` | `packages/mana-tool-registry/src/modules/me.ts` — zwei Tools, auto-registriert |
|
||||||
|
| Space-Scope-Migration | `cb9a9bb42` | `meImages` aus `USER_LEVEL_TABLES` ausgetragen, Dexie v40 retro-stampt `spaceId = _personal:<uid>`-Sentinel + `authorId` + `visibility`, drops `userId`. Queries/Store/MCP-Tool filtern jetzt auf aktiven Space. `auth.users.image` bleibt an Personal-Space primary-avatar gekoppelt — andere Spaces haben ihren eigenen lokalen Avatar. Sub-Plan: `docs/plans/me-images-space-scope-migration.md` |
|
||||||
|
|
||||||
## Offen (noch nicht angefangen)
|
## Offen (noch nicht angefangen)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,27 @@
|
||||||
# Wardrobe — Module Plan
|
# Wardrobe — Module Plan
|
||||||
|
|
||||||
## Status (2026-04-23)
|
## Status (2026-04-23, Stand nach M5)
|
||||||
|
|
||||||
Greenfield. Das Fundament (meImages + reference-based image generation) ist komplett verfügbar — siehe `docs/plans/me-images-and-reference-generation.md` Stand M1-M5. Dieses Modul konsumiert es.
|
**M1–M5 SHIPPED** — Feature ist end-to-end benutzbar. Nutzer pflegt Garments + Outfits pro Space (alle sechs Space-Typen), komponiert über den Composer, rendert Try-On-Vorschauen via OpenAI `gpt-image-2`-Edits, und kann dasselbe via MCP-Tools an Personas/Agents delegieren. Solo-Garment-Try-On ("nur diese Brille anprobieren") als Follow-up in M4.1.
|
||||||
|
|
||||||
|
| Milestone | Commit | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 Datenschicht | `4fc9d6c59` | Dexie v41 `wardrobeGarments` + `wardrobeOutfits` (space-scoped), Types/Collections/Queries/Stores, module-registry, space-allowlist in allen 6 Typen, `/api/v1/wardrobe/garments/upload`, `MAX_REFERENCE_IMAGES` Cap 4→8, `picture.images.wardrobeOutfitId` Back-Ref |
|
||||||
|
| M2 Garments-UI | `5a49bcbf0` | `/wardrobe` Route, CategoryTabs, GarmentCard, GarmentForm, `/wardrobe/garment/[id]`, Drag-Drop-Upload, edit/archive/delete flows, Active-Space-Badge |
|
||||||
|
| M3 Outfits-Composer | `2b89bf795` | `/wardrobe/compose/[[outfitId]]` Composer (click-to-add, garment-library left, editor right), OutfitsView-Tab, OutfitCard (try-on cover → garment collage fallback), `/wardrobe/outfit/[id]` Detail |
|
||||||
|
| M4 Try-On | `d56ad396d` | `runOutfitTryOn` + `TryOnButton` auf DetailOutfitView, Accessoire-Modus-Detection, Empty-State bei fehlenden Referenzen, non-personal-Space-Hinweis; `verifyMediaOwnership` erweitert auf `['me','wardrobe']` |
|
||||||
|
| M5 MCP-Tools | `7e3f53f8a` (+ `66b7e08df` für types/index) | `wardrobe.listGarments` / `.listOutfits` / `.createOutfit` / `.tryOn` in `packages/mana-tool-registry/src/modules/wardrobe.ts`, registered in `registerAllModules` |
|
||||||
|
|
||||||
|
**Fundament konsumiert:** me-images M1-M5 (siehe `docs/plans/me-images-and-reference-generation.md`) — Space-scoped `meImages` (v40), `/api/v1/picture/generate-with-reference` (gpt-image-2 via `/v1/images/edits`), `useImageByPrimary('face-ref'|'body-ref')`.
|
||||||
|
|
||||||
|
## Offen (nach M5)
|
||||||
|
|
||||||
|
- **M4.1 Solo-Garment-Try-On** — `runGarmentTryOn()` + `GarmentTryOnButton` auf DetailGarmentView. Render eines einzelnen Kleidungsstücks ohne Outfit-Kontext (z.B. Brille an mir ausprobieren). Ergebnis landet in `picture.images` ohne `wardrobeOutfitId`-Back-Ref. Implementiert als Plan-Follow-up.
|
||||||
|
- **M6 Persona-Template "Stil-Coach"** (~0.5 Tag, optional) — neuer Eintrag unter `/agents/templates` mit auto-policy für `wardrobe.list*` + `me.listReferenceImages`, propose-policy für `wardrobe.createOutfit` + `wardrobe.tryOn`. Seed-Prompt: "Du bist der persönliche Stil-Coach. Schlage Outfits aus dem vorhandenen Kleiderschrank vor, basierend auf Kontext (Kalender-Event, Wetter, Nutzer-Stimmung). Nie kritisch, nie body-urteilend."
|
||||||
|
- **M7 "Heute trage ich…"-Tiefe** (~0.5 Tag, optional) — "heute getragen"-Button auf OutfitCard + Stats-Widget ("am häufigsten getragen", "lange nicht mehr angehabt"). Quick-Log-Button ist bereits in DetailGarmentView drin; fehlt nur Card-Ebene + Stats.
|
||||||
|
- **M8 Context-basierte Outfit-Mission** (mehrere Tage, optional) — mana-ai Mission-Template "Outfit des Tages": liest calendar + wetter + wardrobe, erzeugt 3 Vorschläge als Proposals. Workbench-Widget "Heute anziehen" als Card.
|
||||||
|
- **Multi-Variant-Rendering** (n=2/4) im TryOnButton — Picker-UI "Zeig mir 3 Looks" statt 1-Klick-1-Bild.
|
||||||
|
- **Multi-Foto pro Garment** — `mediaIds: string[]` ist vorbereitet (Primary `[0]`); UI rendert aktuell nur Primary. Detail-Strip für alternate Views (front/back/detail) wäre die nächste Ausbau-Stufe.
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
|
|
@ -275,44 +294,52 @@ Vier Tools, alle user-space. Pattern ist 1:1 an `me.ts` aus M5 angelehnt:
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
- **M1 — Datenschicht & Backend-Cap** (~1–1.5 Tage)
|
- **M1 — Datenschicht & Backend-Cap** ✅ SHIPPED `4fc9d6c59`
|
||||||
- [ ] Dexie v39: `wardrobeGarments` + `wardrobeOutfits` mit Indices (space-scoped, also Compound-Index auf `[spaceId+...]` für die hot path Queries)
|
- [x] Dexie v41 (nicht v39 — me-images-space-migration hat v40 belegt): `wardrobeGarments` + `wardrobeOutfits` mit Indices (space-scoped, kein Compound-Index nötig — `scopedTable` filtert in-memory)
|
||||||
- [ ] Types + Encryption-Registry + Collections + Queries (via `scopedForModule<>`, *nicht* in `USER_LEVEL_TABLES` — volle Space-Scope-Behandlung)
|
- [x] Types + Encryption-Registry (`name/brand/color/size/material/tags/notes` für Garments, `name/description/tags` für Outfits) + Collections + Queries via `scopedForModule<>`, *nicht* in `USER_LEVEL_TABLES`
|
||||||
- [ ] Stores (garments, outfits)
|
- [x] Stores (garments, outfits) mit Domain-Events (WardrobeGarmentAdded, WardrobeOutfitCreated, WardrobeOutfitTryOn, etc.)
|
||||||
- [ ] `module.config.ts` registriert `appId='wardrobe'`
|
- [x] `module.config.ts` registriert `appId='wardrobe'`
|
||||||
- [ ] `wardrobe` in *alle* sechs Space-Typen der Allowlist (`personal`, `brand`, `club`, `family`, `team`, `practice`)
|
- [x] `wardrobe` in *alle* sechs Space-Typen der Allowlist
|
||||||
- [ ] `MAX_REFERENCE_IMAGES` Cap auf 8 (`apps/api/src/modules/picture/routes.ts`) mit Comment + ClientCap im Generator
|
- [x] `MAX_REFERENCE_IMAGES` Cap auf 8 (`apps/api/src/modules/picture/routes.ts`) + Client-Default in `ReferenceImagePicker.svelte`
|
||||||
- [ ] Neuer `POST /api/v1/wardrobe/garments/upload`-Endpoint + Route-Registrierung
|
- [x] `POST /api/v1/wardrobe/garments/upload`-Endpoint + Route-Registrierung
|
||||||
- [ ] `wardrobeOutfitId`-Feld auf `LocalImage` + `toImage`-Converter
|
- [x] `wardrobeOutfitId`-Feld auf `LocalImage` + `toImage`-Converter
|
||||||
|
|
||||||
- **M2 — Garments-Grundlayer** (~1–1.5 Tage)
|
- **M2 — Garments-Grundlayer** ✅ SHIPPED `5a49bcbf0`
|
||||||
- [ ] Route `/wardrobe` mit `RoutePage`
|
- [x] Route `/wardrobe` mit `RoutePage`
|
||||||
- [ ] `CategoryTabs`, `GarmentCard`, `GarmentUploadZone`, `GarmentForm`
|
- [x] `CategoryTabs`, `GarmentCard`, `GarmentForm`; Upload-Zone reuses `MeImageUploadZone` (cross-module import, purely presentational)
|
||||||
- [ ] Multi-File-Upload pro Kategorie (wie me-images)
|
- [x] Multi-File-Upload, aktive Kategorie bestimmt den default-Kind für neue Drops
|
||||||
- [ ] Detailseite `/wardrobe/garment/[id]` — Foto, Metadaten, "heute getragen"-Button (incrementiert `wearCount`)
|
- [x] Detailseite `/wardrobe/garment/[id]` — Foto, Metadaten, "heute getragen"-Button
|
||||||
- [ ] Archive / Delete / Edit flows
|
- [x] Archive / Delete / Edit flows
|
||||||
|
- [x] Active-Space-Badge im Intro-Card
|
||||||
|
|
||||||
- **M3 — Outfits-Composer** (~1–1.5 Tage)
|
- **M3 — Outfits-Composer** ✅ SHIPPED `2b89bf795`
|
||||||
- [ ] Route `/wardrobe/compose/[[outfitId]]`
|
- [x] Route `/wardrobe/compose/[[outfitId]]`
|
||||||
- [ ] Drag-drop-Leiste mit Garments (nach Kategorie gruppiert)
|
- [x] Zwei-Spalten-Composer mit Garment-Library (nach Kategorie gruppiert) + Outfit-Editor. Click-to-Add statt Drag-Drop (keyboard-accessible, 100% workflow)
|
||||||
- [ ] Outfit-Preview-Kachel rechts (Stapel der Garment-Thumbnails)
|
- [x] Outfit-Preview-Chips mit Hover-× zum Entfernen
|
||||||
- [ ] Create/Edit an dieselbe Route, `[[outfitId]]` optional
|
- [x] Create/Edit an dieselbe Route, `[[outfitId]]` optional; `{#key outfitId ?? 'new'}` für sauberen Re-Mount
|
||||||
- [ ] Detailseite `/wardrobe/outfit/[id]`
|
- [x] Detailseite `/wardrobe/outfit/[id]` mit Metadata-Card + Komposition-Grid + Try-On-Verlauf-Strip
|
||||||
- [ ] `OutfitsView` als zweiter Tab im Root
|
- [x] `OutfitsView` als zweiter Tab in ListView mit "+ Neues Outfit"-CTA
|
||||||
|
|
||||||
- **M4 — Try-On-Integration** (~1 Tag)
|
- **M4 — Try-On-Integration** ✅ SHIPPED `d56ad396d`
|
||||||
- [ ] `runTryOn(outfit, prompt?)` in `api/try-on.ts` — composed die reference-Liste aus *des Nutzers eigenen* `useImageByPrimary('face-ref' | 'body-ref')` + garment-mediaIds (auch in non-personal Spaces), ruft `/generate-with-reference`
|
- [x] `runOutfitTryOn` in `api/try-on.ts` composed die reference-Liste aus aktivem Space's face-ref + body-ref + garment-mediaIds, ruft `/generate-with-reference`
|
||||||
- [ ] `accessoryOnly`-Preset für `glasses`/`jewelry`/`hat` — nur face-ref, quadratisches Format
|
- [x] `accessoryOnly`-Modus auto-detectiert aus `FACE_ONLY_CATEGORIES` — nur face-ref, 1024×1024 Format
|
||||||
- [ ] `TryOnButton.svelte` auf DetailOutfitView + auf DetailGarmentView (mit impliziten "Solo-Outfit")
|
- [x] `TryOnButton` auf DetailOutfitView (DetailGarmentView folgt in M4.1)
|
||||||
- [ ] Nach Erfolg: `picture.images.wardrobeOutfitId` setzen + `lastTryOn`-Snapshot aufs Outfit
|
- [x] Nach Erfolg: `picture.images.wardrobeOutfitId` + `lastTryOn`-Snapshot aufs Outfit
|
||||||
- [ ] Empty-State wenn `primaryFace` oder `primaryFullbody` fehlen → Link zu `/profile/me-images`
|
- [x] Empty-State bei fehlenden Referenzen → Link zu `/profile/me-images`
|
||||||
- [ ] In Non-Personal-Spaces (`brand`/`club`/`family`/`team`/`practice`): Hinweis "Du siehst dich selbst im Outfit — Try-On nutzt deine persönlichen Referenzbilder, nicht die des Spaces" (Subject ist user-global, siehe Entscheidung #6)
|
- [x] Non-Personal-Space-Hinweis ("Try-On nutzt deine Referenzbilder aus diesem Space"); Family-Space-Sonderhinweis
|
||||||
- [ ] Try-On-History als horizontaler Strip in DetailOutfitView
|
- [x] Try-On-Verlauf-Strip via `useOutfitTryOns` (bereits in M3 angelegt, füllt sich nach erstem Render auto)
|
||||||
|
- [x] Server-side `verifyMediaOwnership` auf `['me','wardrobe']` erweitert
|
||||||
|
|
||||||
- **M5 — MCP-Tools** (~0.5 Tag)
|
- **M4.1 — Solo-Garment-Try-On** ✅ SHIPPED *(folgender Commit)*
|
||||||
- [ ] `packages/mana-tool-registry/src/modules/wardrobe.ts` mit den 4 Tools
|
- [x] `runGarmentTryOn` in `api/try-on.ts` — Single-Garment als "impliziter Solo-Outfit"; `wardrobeOutfitId=null` auf der erzeugten `picture.images`-Row
|
||||||
- [ ] `'wardrobe'` zum `ModuleId`-Union
|
- [x] `GarmentTryOnButton` auf DetailGarmentView mit Inline-Preview des zuletzt erzeugten Bildes
|
||||||
- [ ] `registerWardrobeTools()` in `registerAllModules()`
|
- [x] Gemeinsamer `callGenerateWithReference`-Helper refactored aus `runOutfitTryOn`
|
||||||
|
- [x] `isAccessoryGarment(garment)` Helper für face-only Detection
|
||||||
|
|
||||||
|
- **M5 — MCP-Tools** ✅ SHIPPED `7e3f53f8a` (+ `66b7e08df` für types/index)
|
||||||
|
- [x] `packages/mana-tool-registry/src/modules/wardrobe.ts` mit 4 Tools: listGarments, listOutfits, createOutfit, tryOn
|
||||||
|
- [x] `'wardrobe'` im `ModuleId`-Union
|
||||||
|
- [x] `registerWardrobeTools()` in `registerAllModules()` — MCP exponiert automatisch
|
||||||
|
|
||||||
- **M6 — Persona-Templates** (~0.5 Tag, optional)
|
- **M6 — Persona-Templates** (~0.5 Tag, optional)
|
||||||
- [ ] Persona-Template "Stil-Coach": auto-Policy für `wardrobe.list*` + `me.listReferenceImages`, propose-Policy für `wardrobe.createOutfit` + `wardrobe.tryOn`
|
- [ ] Persona-Template "Stil-Coach": auto-Policy für `wardrobe.list*` + `me.listReferenceImages`, propose-Policy für `wardrobe.createOutfit` + `wardrobe.tryOn`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue