mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +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) {
|
||||
|
|
|
|||
183
apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts
Normal file
183
apps/mana/apps/web/src/lib/modules/wardrobe/api/try-on.ts
Normal file
|
|
@ -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<RunTryOnResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<!--
|
||||
Try-On action for the outfit detail page. Handles three states:
|
||||
ready (face + body present, click to render), waiting (user hasn't
|
||||
uploaded references yet, show link to /profile/me-images), and
|
||||
loading (request in flight).
|
||||
-->
|
||||
<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 { isAccessoryOnlyOutfit, runOutfitTryOn } from '../api/try-on';
|
||||
import { CATEGORY_LABELS_SINGULAR } from '../constants';
|
||||
import type { Garment, Outfit } from '../types';
|
||||
|
||||
interface Props {
|
||||
outfit: Outfit;
|
||||
garments: Garment[]; // the resolved LocalWardrobeGarment rows for this outfit
|
||||
}
|
||||
|
||||
let { outfit, garments }: 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(isAccessoryOnlyOutfit(garments));
|
||||
|
||||
// Face is always required. Body only matters for non-accessory outfits.
|
||||
const missingFace = $derived(!face);
|
||||
const missingBody = $derived(!accessoryOnly && !body);
|
||||
const canTryOn = $derived(!missingFace && !missingBody && garments.length > 0);
|
||||
|
||||
let running = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Rough credit estimate — mirrors the server tariff from the M3 plan
|
||||
// (3 low / 10 medium / 25 high; we default to medium). Shown on the
|
||||
// button so the user knows the hit before clicking.
|
||||
const estimatedCredits = 10;
|
||||
|
||||
async function handleClick() {
|
||||
if (!face || (!accessoryOnly && !body)) return;
|
||||
running = true;
|
||||
error = null;
|
||||
try {
|
||||
await runOutfitTryOn({
|
||||
outfit,
|
||||
garments,
|
||||
faceRefMediaId: face.mediaId,
|
||||
bodyRefMediaId: accessoryOnly ? null : (body?.mediaId ?? null),
|
||||
});
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Try-On fehlgeschlagen';
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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 dich im Outfit zu sehen.</p>
|
||||
<p class="text-xs">
|
||||
Try-On braucht mindestens 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" />
|
||||
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>
|
||||
{:else if garments.length > 6}
|
||||
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Info size={12} weight="regular" class="flex-shrink-0" />
|
||||
Mit {garments.length} Kleidungsstücken ist der Referenz-Slot knapp — ältere Items werden evtl.
|
||||
nicht mitgezogen.
|
||||
</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.
|
||||
{#if activeSpace.type === 'family'}
|
||||
Kinder-Outfits werden trotzdem auf dein Gesicht gerendert.
|
||||
{/if}
|
||||
</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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !missingFace && !missingBody && garments.length === 0}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Füge mindestens ein {CATEGORY_LABELS_SINGULAR.top ?? 'Kleidungsstück'} hinzu, um Try-On zu aktivieren.
|
||||
</p>
|
||||
{/if}
|
||||
|
|
@ -12,11 +12,12 @@
|
|||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ArrowLeft, Archive, Heart, PencilSimple, Sparkle, Trash } from '@mana/shared-icons';
|
||||
import { ArrowLeft, Archive, Heart, PencilSimple, Trash } from '@mana/shared-icons';
|
||||
import { useAllGarments, useOutfit, useOutfitTryOns } from '../queries';
|
||||
import { wardrobeOutfitsStore } from '../stores/outfits.svelte';
|
||||
import { garmentPhotoUrl } from '../api/media-url';
|
||||
import { CATEGORY_LABELS_SINGULAR, OCCASION_LABELS, SEASON_LABELS } from '../constants';
|
||||
import TryOnButton from '../components/TryOnButton.svelte';
|
||||
import type { Garment } from '../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -126,16 +127,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Try-On action (M4 stub) -->
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="Try-On kommt in M4 — bis dahin ist das hier noch stumm."
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary/50 px-4 py-2.5 text-sm font-medium text-primary-foreground opacity-60"
|
||||
>
|
||||
<Sparkle size={16} weight="fill" />
|
||||
Anprobieren (kommt bald)
|
||||
</button>
|
||||
<!-- Try-On action (M4) -->
|
||||
<TryOnButton {outfit} garments={resolvedGarments} />
|
||||
|
||||
{#if tryOns.length > 0}
|
||||
<div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue