feat(comic): Mc5 — Wardrobe-Hook "Als Comic-Character"

Brücke von Wardrobe nach Comic: User klickt auf einem Outfit oder
einem einzelnen Kleidungsstück „Als Comic-Character", landet im
Character-Builder mit pre-filltem Add-Prompt ("wearing the
Bühnenoutfit"), picked Stil und rendert die ersten 4 Varianten.

Wardrobe-Buttons:
- DetailOutfitView: unterhalb des TryOnButton ein outline-Link
  navigiert zu `/comic/character/new?title=…&prompt=wearing+the+
  OUTFITNAME+outfit`.
- DetailGarmentView: analog mit `prompt=wearing+GARMENTNAME` für
  ein einzelnes Kleidungsstück. Beide nur sichtbar wenn das
  Outfit/Garment nicht archiviert ist.
- Sparkle-Icon + dezent neutraler Border-Style (nicht primary —
  das ist die TryOn-CTA), hover schaltet auf primary/40.

Comic CharacterBuilder bekommt drei optionale Props:
`initialName?`, `initialAddPrompt?`, `initialStyle?`. Im
extend-Modus ignoriert (Source ist dann der existing-Character),
im create-Modus dienen sie als $state-Initialwerte. Routine read
ist intentional — Mounting passiert frisch pro Route-Visit, also
einmaliges Capture passt.

`/comic/character/new/+page.svelte` parsed jetzt
`page.url.searchParams` für `title`, `prompt`, `style` und reicht
sie als Props durch. style wird gegen die VALID_STYLES-Liste
validiert — defekte URL-Params fallen ohne Crash auf
"unset/default" zurück.

Bewusst NICHT gemacht: Try-On-Output direkt als sourceBodyMediaId
verwenden. Das Try-On-Bild ist im mana-media mit `app='picture'`
getaggt; `verifyMediaOwnership` auf
`/picture/generate-with-reference` akzeptiert nur
`['me','wardrobe','comic']` — der Comic-Generate würde mit
HTTP 404 abbrechen. Lösung wäre eine Server-Route die Picture-
Output als Comic-Asset re-tagged, das ist aber eigene Spec.
Aktueller Pfad ist sauberer: rohe meImages-Refs bleiben Source,
der Add-Prompt steuert den Outfit-Look.

Plan-Doc §11 Mc5 dokumentiert den Pfad + warum kein
Try-On-Reuse.

Comic-Files type-checken sauber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 19:32:29 +02:00
parent ef96948ea0
commit 3d30e39ae7
5 changed files with 80 additions and 13 deletions

View file

@ -31,27 +31,36 @@
* character — name+style+source are locked, only Add-Prompt
* is editable per generation. */
existing?: ComicCharacter;
/** Optional pre-fills for create-mode — used by the wardrobe-
* hook (Mc5) to seed an addPrompt like "wearing the
* Bühnenoutfit" when the user clicks "Als Comic-Character"
* on a Wardrobe-Outfit. Ignored in extend-mode. */
initialName?: string;
initialAddPrompt?: string;
initialStyle?: ComicStyle;
/** Called after the first successful variant batch with the
* resulting character id, so the parent route can navigate. */
onCreated?: (characterId: string) => void;
onClose?: () => void;
}
let { existing, onClose, onCreated }: Props = $props();
let { existing, initialName, initialAddPrompt, initialStyle, onClose, onCreated }: Props =
$props();
const isExtend = $derived(Boolean(existing));
// Builder state. In extend-mode all of these come from `existing`
// at mount time and aren't editable; in create-mode the user fills
// them in. Init-time read of `existing` is intentional — the
// them in (with optional pre-fills from URL-params via the route
// page wrapper). Init-time read is intentional — the
// character is always remounted via {#key} when the route id
// changes, so capturing the snapshot here is correct.
// svelte-ignore state_referenced_locally
let name = $state(existing?.name ?? '');
let name = $state(existing?.name ?? initialName ?? '');
// svelte-ignore state_referenced_locally
let style = $state<ComicStyle>(existing?.style ?? 'comic');
let style = $state<ComicStyle>(existing?.style ?? initialStyle ?? 'comic');
// svelte-ignore state_referenced_locally
let addPrompt = $state(existing?.addPrompt ?? '');
let addPrompt = $state(existing?.addPrompt ?? initialAddPrompt ?? '');
type Quality = 'low' | 'medium' | 'high';
const QUALITIES: readonly Quality[] = ['low', 'medium', 'high'] as const;

View file

@ -6,7 +6,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { CheckCircle, PencilSimple, Archive, Trash } from '@mana/shared-icons';
import { CheckCircle, PencilSimple, Archive, Sparkle, Trash } from '@mana/shared-icons';
import { useGarment, useGarmentSoloTryOns, useOutfitsContainingGarment } from '../queries';
import { wardrobeGarmentsStore } from '../stores/garments.svelte';
import { garmentPhotoUrl } from '../api/media-url';
@ -234,6 +234,20 @@
<!-- Try-on — "wie sähe das an mir aus" -->
<GarmentTryOnButton {garment} />
<!-- Mc5: Comic-Character aus Garment. Pre-fillt den
Add-Prompt im Builder mit "wearing X" — User
picked dann Stil + rendert die ersten 4 Varianten. -->
{#if garment && !garment.isArchived}
<a
href={`/comic/character/new?title=${encodeURIComponent(garment.name)}&prompt=${encodeURIComponent('wearing ' + garment.name)}`}
class="flex w-full items-center justify-center gap-1.5 rounded-md border border-border bg-background px-3 py-2 text-xs font-medium text-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
title="Aus diesem Kleidungsstück einen Comic-Character generieren"
>
<Sparkle size={12} />
Als Comic-Character
</a>
{/if}
<!-- Secondary-action row: "Heute getragen" is the frequent
positive action and takes most of the width; Archive and
Löschen shrink to icon-only buttons on the right so they

View file

@ -12,7 +12,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, Archive, Heart, PencilSimple, Trash } from '@mana/shared-icons';
import { ArrowLeft, Archive, Heart, PencilSimple, Sparkle, Trash } from '@mana/shared-icons';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import { useAllGarments, useOutfit, useOutfitTryOns } from '../queries';
import { wardrobeOutfitsStore } from '../stores/outfits.svelte';
@ -136,6 +136,20 @@
<!-- Try-On action (M4) -->
<TryOnButton {outfit} garments={resolvedGarments} />
<!-- Mc5: Comic-Character aus Outfit. Pre-fillt den
Add-Prompt im Builder, User picked Stil + rendert
selbst die ersten 4 Varianten. -->
{#if outfit && !outfit.isArchived}
<a
href={`/comic/character/new?title=${encodeURIComponent(outfit.name)}&prompt=${encodeURIComponent('wearing the ' + outfit.name + ' outfit')}`}
class="flex w-full items-center justify-center gap-1.5 rounded-md border border-border bg-background px-3 py-2 text-xs font-medium text-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
title="Aus diesem Outfit einen Comic-Character generieren"
>
<Sparkle size={12} />
Als Comic-Character
</a>
{/if}
{#if tryOns.length > 0}
<div>
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">

View file

@ -1,6 +1,24 @@
<script lang="ts">
import { page } from '$app/state';
import { RoutePage } from '$lib/components/shell';
import CharacterBuilder from '$lib/modules/comic/components/CharacterBuilder.svelte';
import type { ComicStyle } from '$lib/modules/comic/types';
const VALID_STYLES: ComicStyle[] = ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'];
function isValidStyle(s: string | null): s is ComicStyle {
return s !== null && (VALID_STYLES as string[]).includes(s);
}
// Optional URL-param prefill — used by the Mc5 wardrobe-hook
// ("Als Comic-Character"-Button auf einem Outfit/Garment): we land
// here with `?prompt=wearing+the+Bühnenoutfit&style=manga`, the
// builder picks them up as initial state. Plain user creates
// (no params) are unaffected.
const initialName = $derived(page.url.searchParams.get('title') ?? undefined);
const initialAddPrompt = $derived(page.url.searchParams.get('prompt') ?? undefined);
const styleParam = $derived(page.url.searchParams.get('style'));
const initialStyle = $derived(isValidStyle(styleParam) ? styleParam : undefined);
</script>
<svelte:head>
@ -16,6 +34,6 @@
Detail kannst du jederzeit weitere generieren.
</p>
</header>
<CharacterBuilder />
<CharacterBuilder {initialName} {initialAddPrompt} {initialStyle} />
</div>
</RoutePage>