feat(comic): Mc1 — Character-Datenschicht (Iteration + Pinning)

Comic-Modul nutzte bisher rohe meImages direkt als Story-Refs:
gpt-image-2 / Nano Banana variieren zwischen Calls, Panel 1 sah
anders aus als Panel 4, User hatte keine Iteration vor der Story.
Lösung: Comic-Character als eigene Entität, einmal aufgebaut +
iteriert + gepinnt, danach Story-Anchor.

Datenschicht:
- Dexie v49 `comicCharacters` (space-scoped, indices createdAt /
  style / isFavorite / isArchived).
- types.ts: LocalComicCharacter mit name + style + addPrompt +
  sourceFaceMediaId + sourceBodyMediaId? + variantMediaIds[] +
  pinnedVariantId?, plus toCharacter + characterCoverVariantId
  helper (pinned > erste Variant > null).
- crypto/registry.ts: comicCharacters entry — name + description
  + addPrompt + tags encrypted; style + IDs + Variant-Liste +
  Booleans plaintext.
- collections.ts: comicCharactersTable.
- queries.ts: useAllCharacters, useCharactersByStyle, useCharacter
  via scopedForModule (alle space-scoped).
- stores/characters.svelte.ts: createCharacter (auto-pin first
  variant fallback), appendVariant (auto-pin if none yet),
  pinVariant, removeVariant (mit pin-fallback auf erste
  remaining), updateCharacter, toggleFavorite, archiveCharacter,
  deleteCharacter. Arrays werden via [...arr] entproxiet (Svelte
  5 $state defense).
- module.config.ts: comicCharacters in tables-Liste.
- picture/types.ts + queries.ts: comicCharacterId Back-Ref auf
  LocalImage + Image, mutually exclusive mit comicStoryId.
- 3 neue Encryption-Roundtrip-Tests (insgesamt 8 grün) für
  charakter-Row, Build-in-progress (no variants), Roundtrip.

Architektur-Entscheidungen (Plan-Doc §11 dokumentiert):
- **space-scoped**, nicht user-global: Source-meImages sind ja
  selbst space-scoped post-v40, sonst orphan-Refs nach
  Space-Wechsel.
- **Snapshot at story-create**, kein Live-Lookup: Stories
  speichern die mediaId der gepinnten Variant zum Erstellungs-
  zeitpunkt → re-pinning eines Characters lässt bestehende
  Stories unverändert.
- **n=4 fixes Variant-Count**: in einem gpt-image-2-Call
  parallel; sweet-spot für Auswahl ohne Decision-Fatigue.
- **Mutually-exclusive Back-Refs** auf picture.images:
  comicStoryId XOR comicCharacterId — Image ist Panel ODER
  Variant, nie beides.

Mc2 (UI: Builder + Variant-Grid + Routes), Mc3 (Story-Create-
Update + Soft-Migration), Mc4 (MCP/Catalog), Mc5 (Wardrobe-Hook)
folgen separat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 15:52:58 +02:00
parent b385839204
commit 313809bc95
11 changed files with 567 additions and 7 deletions

View file

@ -574,6 +574,140 @@ funktional weil die Decrypts client-side passieren.
behalten oder dort final löschen. Symmetrisch zu Wardrobe (Try-On-
Bilder überleben eine Outfit-Löschung).
## §11 Character-System (Mc1Mc5)
Nachgezogen 2026-04-25, weil sich im Soak gezeigt hat: rohe meImages
direkt als Story-Refs sind kein guter „Identity-Anchor". gpt-image-2
und Nano Banana variieren zwischen Calls — Panel 1 sieht anders aus
als Panel 4. User hat zwischen den Panels keine Iteration, kein
„nochmal probieren bis das Aussehen stimmt".
Lösung: ein **Comic-Character** als eigene Entität, die der Nutzer
einmal aufbaut + iteriert + pinnt, und die dann als stabiler
Story-Anchor dient.
### Datenmodell
Eigenes Table `comicCharacters` (Sibling zu `comicStories`,
**space-scoped** wie comicStories — Source-meImages sind ja auch
space-scoped post-v40, sonst orphan-Refs nach Space-Wechsel).
```typescript
interface LocalComicCharacter extends BaseRecord {
id: string;
name: string; // "Manga-Me", "Cartoon-Casual"
description?: string | null;
style: ComicStyle; // mit welchem Stil generiert
addPrompt?: string | null; // user-typed Add-Prompt zum Stil
sourceFaceMediaId: string; // welche meImages dienten als Source
sourceBodyMediaId?: string | null;
variantMediaIds: string[]; // alle generierten Versuche (FK auf picture.images)
pinnedVariantId?: string | null; // welcher Versuch IST der Charakter
tags: string[];
isFavorite?: boolean;
isArchived?: boolean;
}
```
**Encryption**: name / description / addPrompt / tags. Style + IDs
+ Variant-Liste + Booleans bleiben plaintext.
`picture.images` bekommt einen `comicCharacterId`-Back-Ref (analog
zu `comicStoryId`/`wardrobeOutfitId`/`wardrobeGarmentId`). Mutually
exclusive mit `comicStoryId` — eine Image-Row ist entweder Panel
ODER Variant, nie beides.
### Snapshot-Semantik
Stories speichern **mediaId at create time**, nicht den
`characterId` als Live-Lookup. Re-Pinning eines Characters ändert
also keine bestehenden Stories — die haben den alten Variant
weiter als Ref. Neue Stories nach dem Re-Pin nutzen den neuen.
### UX-Flow
**Mc1 — Datenschicht** (3h): Dexie v49 + types + crypto-registry +
collections + queries (`useAllCharacters`, `useCharacter`,
`useCharactersByStyle`) + Store (`createCharacter`, `appendVariant`,
`pinVariant`, `removeVariant`, `updateCharacter`, `archive`, `delete`).
`picture.images.comicCharacterId` + Module-Registry-Tabellenliste +
Encryption-Roundtrip-Test.
**Mc2 — UI** (5h):
- Routes `/comic/character`, `/comic/character/new`,
`/comic/character/[id]`
- ListView-Root bekommt 2-Tab-UI: **Stories | Characters**
- `CharacterBuilder.svelte`: Source picken (face Pflicht, body
optional), Stil picken, Add-Prompt optional, „Generieren"-Button
feuert 4 parallele Variant-Calls (n=4 in einem gpt-image-2-Call).
Variant-Grid darunter, User pinnt eine, „Mehr Varianten" appendet
weitere 4.
- `CharacterCard.svelte`: Cover = pinned-variant (oder erste
Variant als Fallback), Style-Badge, Favorit-Heart.
- `api/generate-character.ts`: `runCharacterGenerate({character,
n=4})` ruft `/picture/generate-with-reference` mit
`[face, body?]`-Refs + Stil-Prefix + Add-Prompt, schreibt N
picture.images mit `comicCharacterId`-Back-Ref, ruft
`appendVariant` für jeden.
**Mc3 — Story-Create-Update** (3h):
- StoryForm wechselt von „face/body/garments-Picker" auf
`CharacterRefPicker.svelte`:
- Default-Modus: Grid existierender Characters (gefiltert
nach Stil oder „Alle"). Pick = einzige Story-Character-Ref.
- „+ Neuer Character" navigiert zu `/comic/character/new` mit
Return-URL.
- Toggle „Quick-Modus (kein Character)": fällt zurück auf
altes Pattern (face + body + garments) — für „mal eben
schnell aus dem Tagebuch ohne Setup".
- Story-Type bekommt:
- `characterId?: string` (FK auf comicCharacters, für
Anzeige + Click-Through; null im Quick-Modus)
- `characterMediaId?: string` (Snapshot der gepinnten
Variant zum Story-Create-Zeitpunkt — was der Renderer
nutzt)
- **Soft-Migration**: bestehende Stories mit `characterMediaIds[]`
bleiben kompatibel; runPanelGenerate prüft erst
`characterMediaId` (Snapshot), dann fällt zurück auf
`characterMediaIds[0..n]`. Hard-Migration in einem Folge-Commit
wenn alle Stories migrert sind.
- Optional `costumeGarmentIds: string[]` für Wardrobe-Refs
zusätzlich zum Character (Kostüm über dem Character).
**Mc4 — MCP + AI-Catalog** (~2h, optional):
- `comic.listCharacters`, `comic.createCharacter`,
`comic.generateVariant`, `comic.pinVariant` in
packages/mana-tool-registry.
- `list_comic_characters`, `create_comic_character`,
`generate_character_variant` in AI_TOOL_CATALOG.
- Persona kann „mach mir einen Manga-Character für Story X" sagen.
**Mc5 — Wardrobe-Hook** (~2h, optional):
- In Wardrobe-DetailOutfitView nach erfolgreichem Try-On ein
Knopf „Als Comic-Character speichern" → öffnet Builder mit
Try-On-Result als optionalem `sourceBodyMediaId`.
- In DetailGarmentView analog für ein einzelnes Kleidungsstück.
### Tradeoffs
- **Variant-Count fix bei 4** statt Slider 1-4: 4 ist sweet-spot
für Auswahl ohne Decision-Fatigue, in einem API-Call ausführbar,
Credits ~10c × 4 = 40c pro Generate-Round (medium-Quality).
- **Quick-Modus behalten**: nicht jede Story braucht Setup. Soft
defaults: existieren Characters → Default-Modus „Pick", sonst
Default „Quick".
- **Snapshot statt Live-Ref**: Stories sind stabil. Trade-off:
re-pinned Characters reflektieren nicht in alten Stories — User
muss explizit „Story-Charakter aktualisieren"-Flow nutzen
(M5+ Feature).
- **Space-scoped Characters**: bewusst nicht user-global, weil
Source-meImages space-scoped sind. Trade-off: man muss in jedem
Space einen eigenen Manga-Me bauen. Akzeptabel weil Spaces
unterschiedliche Settings sind (personal vs. brand).
## Verweise
- Fundament Picture-Generate-Reference: `apps/api/src/modules/picture/routes.ts:250-430`