feat(wardrobe,picture): symmetric wardrobeGarmentId FK + garment try-on strips

Solo-garment Try-Ons had no back-reference into Wardrobe — the
generated image landed in Picture.images with wardrobeOutfitId=null
and was unfindable from the garment detail page. The M4.1 comment
called it "deliberate — a standalone preview, not an outfit"; in
practice users open the garment detail expecting to see past tries.

Drop the asymmetry. Picture stays the single source of truth for
every AI-generated image; Wardrobe references by FK, not heuristic.

- Dexie v42: index `images.wardrobeOutfitId` (was unindexed, the
  existing outfit query fell back to a scan) and add the symmetric
  `images.wardrobeGarmentId` column + index. Fresh start — not live,
  no migration of old rows, undefined on existing rows is the correct
  "no back-ref" semantics.
- LocalImage / Image gain `wardrobeGarmentId?: string | null` in the
  picture module's types + converter. Invariant: at most one of
  `wardrobeOutfitId` / `wardrobeGarmentId` set per row (solo try-ons
  write the garment id, outfit try-ons write the outfit id).
- runGarmentTryOn stamps `wardrobeGarmentId: garment.id` on the new
  Picture row. runOutfitTryOn unchanged — it already wrote
  wardrobeOutfitId for the symmetric outfit path.
- New queries in wardrobe/queries.ts:
  - `useGarmentSoloTryOns(garmentId)` — live-queries Picture for
    rows tagged with this garment's id.
  - `useOutfitsContainingGarment(garmentId)` — live-queries
    wardrobeOutfits whose `garmentIds[]` includes the garment, for
    the cross-outfit context strip.
- DetailGarmentView gets two new sections under the existing meta/
  action stack:
  1. "Anproben" — solo-try-on thumbnails (click → full image in a
     new tab; proper lightbox can reuse Picture's modal later).
  2. "In Outfits" — outfits containing this garment, each rendering
     its own `lastTryOn` snapshot as the thumb, click → outfit
     detail. Reuses the outfit row's cached snapshot so no extra
     image lookup.

Crypto registry unchanged: `images` only encrypts prompt +
negativePrompt; the new FK stays plaintext like wardrobeOutfitId.
mana-sync is field-level-generic — no schema work needed, the new
column syncs as a plain field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 14:16:40 +02:00
parent 218cf45005
commit 9fbdc14869
6 changed files with 176 additions and 5 deletions

View file

@ -992,6 +992,25 @@ db.version(41).stores({
wardrobeOutfits: 'id, createdAt, isFavorite, isArchived',
});
// v42 — Index `images.wardrobeOutfitId` + add `images.wardrobeGarmentId`
// index for fast Wardrobe back-reference queries. Until now Try-On
// results in `picture.images` carried a back-ref to `wardrobeOutfits`
// but no index — `useOutfitTryOns` fell back to a linear filter across
// every image in the active space. Solo-Garment Try-Ons (M4.1) didn't
// have a back-ref at all; the result ended up in the Picture gallery
// and was unfindable from the garment detail page.
//
// Both fields are plaintext FKs to Wardrobe rows in the same space.
// Index both together so outfit-detail and garment-detail queries are
// single-field equality lookups (O(log n)) instead of table scans, and
// add the symmetric `wardrobeGarmentId` so Solo-Try-Ons have the same
// first-class back-reference as Outfit-Try-Ons. Invariant (enforced in
// the write path): at most one of the two is set per row.
db.version(42).stores({
images:
'id, isFavorite, isPublic, isArchived, prompt, updatedAt, wardrobeOutfitId, wardrobeGarmentId',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -50,6 +50,7 @@ export function toImage(local: LocalImage): Image {
referenceImageIds: local.referenceImageIds ?? undefined,
generationMode: local.generationMode ?? undefined,
wardrobeOutfitId: local.wardrobeOutfitId ?? undefined,
wardrobeGarmentId: local.wardrobeGarmentId ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -52,6 +52,14 @@ export interface LocalImage extends BaseRecord {
* an extra table. Plaintext it's an FK.
*/
wardrobeOutfitId?: string | null;
/**
* Back-reference to `wardrobeGarments.id` when this image was produced
* by the solo-garment try-on flow (M4.1). Symmetric to
* wardrobeOutfitId pictures are at most one of the two. Lets the
* garment detail view list its own try-ons without scanning by
* `referenceImageIds` containment.
*/
wardrobeGarmentId?: string | null;
}
export interface LocalBoard extends BaseRecord {
@ -122,6 +130,7 @@ export interface Image {
referenceImageIds?: string[];
generationMode?: ImageGenerationMode;
wardrobeOutfitId?: string;
wardrobeGarmentId?: string;
createdAt: string;
updatedAt: string;
}

View file

@ -303,11 +303,13 @@ export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise<Ru
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.
// Symmetric back-ref to wardrobeOutfitId: a solo try-on belongs
// to exactly one garment, so the garment detail page can
// liveQuery `where wardrobeGarmentId === id`. Outfit and
// garment back-refs are mutually exclusive — this row is a
// garment try-on, not an outfit one.
wardrobeOutfitId: null,
wardrobeGarmentId: garment.id,
createdAt: now,
updatedAt: now,
});

View file

@ -137,3 +137,48 @@ export function useOutfitTryOns(outfitId: string | null) {
return decrypted.map(toImage);
}, [] as Image[]);
}
/**
* Every solo try-on rendered for a single garment, newest first.
* Symmetric to `useOutfitTryOns`: filters `picture.images` by the
* `wardrobeGarmentId` FK that `runGarmentTryOn` stamps on write. Does
* NOT include outfit try-ons the garment participates in see
* `useOutfitsContainingGarment` for the cross-outfit view.
*/
export function useGarmentSoloTryOns(garmentId: string | null) {
return useLiveQueryWithDefault<Image[]>(async () => {
if (!garmentId) return [];
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
.and((row) => row.wardrobeGarmentId === garmentId)
.toArray();
const visible = locals
.filter((row) => !row.deletedAt && !row.isArchived)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('images', visible);
return decrypted.map(toImage);
}, [] as Image[]);
}
/**
* All outfits in the active space that include the given garment,
* newest first. Used on the garment detail page to render a "Teil
* dieser Outfits"-Strip so the user can jump back into any outfit
* they've built around the item — each outfit's own `lastTryOn`
* snapshot provides the thumbnail without another image lookup.
*/
export function useOutfitsContainingGarment(garmentId: string | null) {
return useLiveQueryWithDefault<Outfit[]>(async () => {
if (!garmentId) return [];
const locals = await scopedForModule<LocalWardrobeOutfit, string>(
'wardrobe',
'wardrobeOutfits'
).toArray();
const visible = locals
.filter(
(row) => !row.deletedAt && !row.isArchived && (row.garmentIds ?? []).includes(garmentId)
)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const decrypted = await decryptRecords('wardrobeOutfits', visible);
return decrypted.map(toOutfit);
}, [] as Outfit[]);
}

View file

@ -7,7 +7,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, CheckCircle, PencilSimple, Archive, Trash } from '@mana/shared-icons';
import { useGarment } from '../queries';
import { useGarment, useGarmentSoloTryOns, useOutfitsContainingGarment } from '../queries';
import { wardrobeGarmentsStore } from '../stores/garments.svelte';
import { garmentPhotoUrl } from '../api/media-url';
import { CATEGORY_LABELS } from '../constants';
@ -26,6 +26,16 @@
const garment$ = useGarment(id);
const garment = $derived(garment$.value);
// Back-refs into Picture + sibling outfits. Both live-queries so
// a fresh try-on (solo or via a linked outfit) pushes into the
// strips without a manual reload.
// svelte-ignore state_referenced_locally
const soloTryOns$ = useGarmentSoloTryOns(id);
// svelte-ignore state_referenced_locally
const outfits$ = useOutfitsContainingGarment(id);
const soloTryOns = $derived(soloTryOns$.value);
const outfits = $derived(outfits$.value);
let editing = $state(false);
let saving = $state(false);
let markingWorn = $state(false);
@ -221,5 +231,90 @@
{/if}
</div>
</div>
<!-- Solo try-on history — every image produced by
runGarmentTryOn carrying this garment's wardrobeGarmentId FK.
Clicking a thumb opens the full image in the Picture gallery
so favoriting / download / delete etc. live in their canonical
home. -->
{#if soloTryOns.length > 0}
<section class="space-y-2">
<header class="flex items-baseline justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Anproben · {soloTryOns.length}
</h2>
<span class="text-xs text-muted-foreground">Einzelstück auf dir gerendert</span>
</header>
<div class="flex gap-3 overflow-x-auto pb-1">
{#each soloTryOns as image (image.id)}
<!-- Picture module doesn't have a /picture/image/[id] route;
it opens generations inline via a modal in its ListView.
Linking to the full publicUrl in a new tab gives the user
the full-resolution view without a routing detour. A proper
lightbox can come later when we reuse Picture's modal. -->
<a
href={image.publicUrl ?? '#'}
target="_blank"
rel="noopener noreferrer"
class="group block flex-shrink-0 overflow-hidden rounded-xl border border-border bg-muted transition-all hover:border-primary/50"
title={image.prompt}
>
{#if image.publicUrl}
<img
src={image.publicUrl}
alt={image.prompt}
loading="lazy"
class="h-40 w-28 object-cover transition-transform group-hover:scale-[1.02]"
/>
{/if}
</a>
{/each}
</div>
</section>
{/if}
<!-- Outfits containing this garment — reverse of DetailOutfitView's
garment list. Each card links to /wardrobe/outfit/[id]; the
lastTryOn snapshot (cached on the outfit row) gives an instant
thumb without another image round-trip. -->
{#if outfits.length > 0}
<section class="space-y-2">
<header class="flex items-baseline justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
In Outfits · {outfits.length}
</h2>
<span class="text-xs text-muted-foreground">Komposition öffnen</span>
</header>
<div class="flex gap-3 overflow-x-auto pb-1">
{#each outfits as outfit (outfit.id)}
<a
href={`/wardrobe/outfit/${outfit.id}`}
class="group block w-32 flex-shrink-0 space-y-1.5"
title={outfit.name}
>
<div
class="aspect-[2/3] overflow-hidden rounded-xl border border-border bg-muted transition-all group-hover:border-primary/50"
>
{#if outfit.lastTryOn?.imageUrl}
<img
src={outfit.lastTryOn.imageUrl}
alt={outfit.name}
loading="lazy"
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
{:else}
<div
class="flex h-full w-full items-center justify-center text-xs text-muted-foreground"
>
Noch keine Anprobe
</div>
{/if}
</div>
<p class="truncate text-xs font-medium text-foreground">{outfit.name}</p>
</a>
{/each}
</div>
</section>
{/if}
{/if}
</div>