diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 5a210da3f..0edf131ef 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -97,7 +97,7 @@ import type { LocalGeneration, LocalWritingStyle, } from '../../modules/writing/types'; -import type { LocalComicStory } from '../../modules/comic/types'; +import type { LocalComicStory, LocalComicCharacter } from '../../modules/comic/types'; import type { LocalAugurEntry } from '../../modules/augur/types'; export const ENCRYPTION_REGISTRY: Record = { @@ -616,6 +616,19 @@ export const ENCRYPTION_REGISTRY: Record = { 'panelMeta', ]), + // ─── Comic-Characters (variant pool + pinned identity) ──── + // docs/plans/comic-module.md §11. User-scoped sibling table to + // comicStories. Encrypted: `name` (display label), `description` + // (optional context), `addPrompt` (the user's free-text prompt + // add-on like "freundlicher Ausdruck"), `tags`. Plaintext: + // `style` (filter discriminator), `sourceFaceMediaId` / + // `sourceBodyMediaId` (FKs to meImages), `variantMediaIds` (FK + // array to picture.images), `pinnedVariantId`, booleans. + // Same encryption envelope as a wardrobe-outfit — name + free- + // text + tags travel encrypted, structural fields stay plaintext + // for query/sort. + comicCharacters: entry(['name', 'description', 'addPrompt', 'tags']), + // ─── Augur (signs: omens / fortunes / hunches) ─────────── // docs/plans/augur-module.md M1. Single space-scoped table. // diff --git a/apps/mana/apps/web/src/lib/modules/comic/collections.ts b/apps/mana/apps/web/src/lib/modules/comic/collections.ts index fb7b506bf..dce84e3dd 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/collections.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/collections.ts @@ -1,8 +1,9 @@ /** - * Comic module — Dexie table accessor. + * Comic module — Dexie table accessors. */ import { db } from '$lib/data/database'; -import type { LocalComicStory } from './types'; +import type { LocalComicStory, LocalComicCharacter } from './types'; export const comicStoriesTable = db.table('comicStories'); +export const comicCharactersTable = db.table('comicCharacters'); diff --git a/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts b/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts index 1a4b8ec73..9209eecf3 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/comic-encryption.test.ts @@ -28,7 +28,7 @@ import { isEncrypted, } from '$lib/data/crypto'; import { setCurrentUserId } from '$lib/data/current-user'; -import type { ComicPanelMeta, LocalComicStory } from './types'; +import type { ComicPanelMeta, LocalComicCharacter, LocalComicStory } from './types'; const TABLE = 'comicStories'; @@ -167,3 +167,81 @@ describe('comicStories encryption registry', () => { expect(row.description).toBe(null); }); }); + +// ─── Comic-Characters ───────────────────────────────────────────── + +const CHAR_TABLE = 'comicCharacters'; + +function makeCharacter(overrides: Partial = {}): LocalComicCharacter { + return { + id: 'char-1', + name: 'Manga-Me', + description: 'Mein Manga-Stil mit freundlichem Ausdruck', + style: 'manga', + addPrompt: 'Casual Outfit, freundliches Lächeln', + sourceFaceMediaId: 'me-face-99', + sourceBodyMediaId: 'me-body-77', + variantMediaIds: ['variant-a', 'variant-b', 'variant-c'], + pinnedVariantId: 'variant-b', + tags: ['casual', 'manga', 'standard'], + isFavorite: true, + isArchived: false, + ...overrides, + }; +} + +describe('comicCharacters encryption registry', () => { + it('encrypts name + description + addPrompt + tags; leaves structural fields plaintext', async () => { + const row = makeCharacter(); + await encryptRecord(CHAR_TABLE, row as unknown as Record); + + expect(isEncrypted(row.name)).toBe(true); + expect(isEncrypted(row.description)).toBe(true); + expect(isEncrypted(row.addPrompt)).toBe(true); + expect(isEncrypted(row.tags)).toBe(true); + + // User-typed prose nicht im Klartext durchgerutscht + expect(String(row.name)).not.toContain('Manga-Me'); + expect(String(row.description)).not.toContain('freundlichem'); + expect(String(row.addPrompt)).not.toContain('Lächeln'); + expect(JSON.stringify(row.tags)).not.toContain('manga'); + + // Strukturelle Felder unangetastet — Style-Filter, Source-FKs, + // Variant-Liste und Pin müssen im Index lesbar bleiben. + expect(row.id).toBe('char-1'); + expect(row.style).toBe('manga'); + expect(row.sourceFaceMediaId).toBe('me-face-99'); + expect(row.sourceBodyMediaId).toBe('me-body-77'); + expect(row.variantMediaIds).toEqual(['variant-a', 'variant-b', 'variant-c']); + expect(row.pinnedVariantId).toBe('variant-b'); + expect(row.isFavorite).toBe(true); + expect(row.isArchived).toBe(false); + }); + + it('roundtrips name / description / addPrompt / tags', async () => { + const row = makeCharacter(); + await encryptRecord(CHAR_TABLE, row as unknown as Record); + await decryptRecord(CHAR_TABLE, row as unknown as Record); + + expect(row.name).toBe('Manga-Me'); + expect(row.description).toBe('Mein Manga-Stil mit freundlichem Ausdruck'); + expect(row.addPrompt).toBe('Casual Outfit, freundliches Lächeln'); + expect(row.tags).toEqual(['casual', 'manga', 'standard']); + }); + + it('handles a build-in-progress character with no variants yet', async () => { + const row = makeCharacter({ + variantMediaIds: [], + pinnedVariantId: null, + addPrompt: null, + description: null, + }); + await encryptRecord(CHAR_TABLE, row as unknown as Record); + // addPrompt and description are null — no-wrap path + expect(row.addPrompt).toBe(null); + expect(row.description).toBe(null); + await decryptRecord(CHAR_TABLE, row as unknown as Record); + expect(row.variantMediaIds).toEqual([]); + expect(row.pinnedVariantId).toBe(null); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/comic/index.ts b/apps/mana/apps/web/src/lib/modules/comic/index.ts index 0f723ad01..47472869d 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/index.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/index.ts @@ -7,8 +7,9 @@ */ export * from './types'; -export { comicStoriesTable } from './collections'; +export { comicStoriesTable, comicCharactersTable } from './collections'; export { comicStoriesStore } from './stores/stories.svelte'; +export { comicCharactersStore } from './stores/characters.svelte'; export { useAllStories, useStoriesByStyle, @@ -16,6 +17,9 @@ export { useStoryPanels, useStoriesByInput, usePanelImage, + useAllCharacters, + useCharactersByStyle, + useCharacter, } from './queries'; export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants'; export { STYLE_PREFIXES, composePanelPrompt } from './styles'; diff --git a/apps/mana/apps/web/src/lib/modules/comic/module.config.ts b/apps/mana/apps/web/src/lib/modules/comic/module.config.ts index 6fac82d77..841201d32 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/module.config.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/module.config.ts @@ -2,5 +2,5 @@ import type { ModuleConfig } from '$lib/data/module-registry'; export const comicModuleConfig: ModuleConfig = { appId: 'comic', - tables: [{ name: 'comicStories' }], + tables: [{ name: 'comicStories' }, { name: 'comicCharacters' }], }; diff --git a/apps/mana/apps/web/src/lib/modules/comic/queries.ts b/apps/mana/apps/web/src/lib/modules/comic/queries.ts index a6c9fadd9..bda3cd68f 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/queries.ts @@ -13,7 +13,15 @@ import { scopedForModule } from '$lib/data/scope'; import { decryptRecords } from '$lib/data/crypto'; import type { LocalImage, Image } from '$lib/modules/picture/types'; import { toImage } from '$lib/modules/picture/queries'; -import { toStory, type ComicStory, type ComicStyle, type LocalComicStory } from './types'; +import { + toStory, + toCharacter, + type ComicStory, + type ComicStyle, + type ComicCharacter, + type LocalComicStory, + type LocalComicCharacter, +} from './types'; /** All non-archived, non-deleted stories in the active space, newest first. */ export function useAllStories() { @@ -100,6 +108,53 @@ export function useStoryPanels(storyId: string | null) { }, [] as Image[]); } +// ─── Characters ────────────────────────────────────────────────── + +/** All non-archived, non-deleted comic-characters in the active space, + * newest first. Characters travel with their source meImages, so they're + * space-scoped. */ +export function useAllCharacters() { + return useScopedLiveQuery(async () => { + const locals = await scopedForModule( + 'comic', + 'comicCharacters' + ).toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('comicCharacters', visible); + return decrypted.map(toCharacter); + }, [] as ComicCharacter[]); +} + +/** Characters filtered by style — used by style-tabs in the picker. */ +export function useCharactersByStyle(style: ComicStyle) { + return useScopedLiveQuery(async () => { + const locals = await scopedForModule('comic', 'comicCharacters') + .and((row) => row.style === style) + .toArray(); + const visible = locals + .filter((row) => !row.deletedAt && !row.isArchived) + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + const decrypted = await decryptRecords('comicCharacters', visible); + return decrypted.map(toCharacter); + }, [] as ComicCharacter[]); +} + +/** A single character by id, live-updating. Null while loading / missing. */ +export function useCharacter(id: string | null) { + return useScopedLiveQuery(async () => { + if (!id) return null; + const locals = await scopedForModule('comic', 'comicCharacters') + .and((row) => row.id === id) + .toArray(); + const [local] = locals; + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('comicCharacters', [local]); + return toCharacter(decrypted); + }, null); +} + /** * Stories that were seeded by a given module entry (M4 AI-Storyboard * back-reference). Matches when *any* panel in the story has a diff --git a/apps/mana/apps/web/src/lib/modules/comic/stores/characters.svelte.ts b/apps/mana/apps/web/src/lib/modules/comic/stores/characters.svelte.ts new file mode 100644 index 000000000..4116cfbb7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/comic/stores/characters.svelte.ts @@ -0,0 +1,173 @@ +/** + * Comic-Characters store — mutation-only service. + * + * A character holds an unbounded `variantMediaIds: string[]` of + * generated picture.images-rows plus a `pinnedVariantId` that + * picks one as the canonical look. Variant generation itself + * lives in `api/generate-character.ts` (Mc2.1) — this store only + * mutates the row. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { emitDomainEvent } from '$lib/data/events'; +import { comicCharactersTable } from '../collections'; +import { toCharacter } from '../types'; +import type { ComicCharacter, ComicStyle, LocalComicCharacter } from '../types'; + +export interface CreateCharacterInput { + name: string; + style: ComicStyle; + sourceFaceMediaId: string; + sourceBodyMediaId?: string | null; + description?: string | null; + addPrompt?: string | null; + tags?: string[]; +} + +export const comicCharactersStore = { + /** + * Create a fresh character row WITHOUT any variants yet — the + * builder calls this first to obtain an id, then runs N variant + * generations and pushes each through `appendVariant`. The user + * pins one once they're happy. + */ + async createCharacter(input: CreateCharacterInput): Promise { + const trimmedName = input.name.trim(); + if (!trimmedName) { + throw new Error('Character braucht einen Namen'); + } + if (!input.sourceFaceMediaId) { + throw new Error('Character braucht ein Face-Bild als Quelle'); + } + // Spread incoming arrays to break any Svelte 5 $state proxies + // the form might pass through. Same defense as comicStoriesStore. + const newLocal: LocalComicCharacter = { + id: crypto.randomUUID(), + name: trimmedName, + description: input.description ?? null, + style: input.style, + addPrompt: input.addPrompt ?? null, + sourceFaceMediaId: input.sourceFaceMediaId, + sourceBodyMediaId: input.sourceBodyMediaId ?? null, + variantMediaIds: [], + pinnedVariantId: null, + tags: input.tags ? [...input.tags] : [], + isFavorite: false, + }; + const snapshot = toCharacter({ ...newLocal }); + await encryptRecord('comicCharacters', newLocal); + await comicCharactersTable.add(newLocal); + emitDomainEvent('ComicCharacterCreated', 'comic', 'comicCharacters', newLocal.id, { + characterId: newLocal.id, + style: input.style, + }); + return snapshot; + }, + + /** + * Append a freshly generated variant to the character's variant + * list. Called by the builder after each gpt-image-2 / Nano Banana + * call lands a picture.images row. The first variant auto-pins + * (build-in-progress fallback) so the character has a cover even + * before the user explicitly chooses. + */ + async appendVariant(characterId: string, variantMediaId: string): Promise { + const existing = await comicCharactersTable.get(characterId); + if (!existing) throw new Error(`Character ${characterId} not found`); + const nextIds = [...(existing.variantMediaIds ?? []), variantMediaId]; + const patch: Partial = { + variantMediaIds: nextIds, + updatedAt: new Date().toISOString(), + }; + // Auto-pin the first variant so the cover isn't blank during + // build. User can re-pin afterwards. + if (!existing.pinnedVariantId) { + patch.pinnedVariantId = variantMediaId; + } + await comicCharactersTable.update(characterId, patch); + emitDomainEvent('ComicCharacterVariantAdded', 'comic', 'comicCharacters', characterId, { + characterId, + variantMediaId, + variantIndex: nextIds.length - 1, + }); + }, + + /** Pin a different variant as the canonical look. Stories generated + * AFTER the re-pin get the new variant; existing stories are + * unchanged because they snapshot the mediaId at story-create. */ + async pinVariant(characterId: string, variantMediaId: string): Promise { + const existing = await comicCharactersTable.get(characterId); + if (!existing) throw new Error(`Character ${characterId} not found`); + if (!(existing.variantMediaIds ?? []).includes(variantMediaId)) { + throw new Error(`Variant ${variantMediaId} not in this character`); + } + await comicCharactersTable.update(characterId, { + pinnedVariantId: variantMediaId, + updatedAt: new Date().toISOString(), + }); + emitDomainEvent('ComicCharacterVariantPinned', 'comic', 'comicCharacters', characterId, { + characterId, + variantMediaId, + }); + }, + + /** Remove a variant from the character's pool. Doesn't touch the + * underlying picture.images-row (user can keep the render in their + * Picture gallery). If the removed variant was pinned, falls back + * to the first remaining variant; if none remain, pin = null. */ + async removeVariant(characterId: string, variantMediaId: string): Promise { + const existing = await comicCharactersTable.get(characterId); + if (!existing) return; + const nextIds = (existing.variantMediaIds ?? []).filter((id) => id !== variantMediaId); + const patch: Partial = { + variantMediaIds: nextIds, + updatedAt: new Date().toISOString(), + }; + if (existing.pinnedVariantId === variantMediaId) { + patch.pinnedVariantId = nextIds[0] ?? null; + } + await comicCharactersTable.update(characterId, patch); + }, + + async updateCharacter( + id: string, + patch: Partial> + ): Promise { + const wrapped: Record = { ...patch }; + if (Array.isArray(wrapped.tags)) { + wrapped.tags = [...(wrapped.tags as string[])]; + } + await encryptRecord('comicCharacters', wrapped); + await comicCharactersTable.update(id, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleFavorite(id: string): Promise { + const existing = await comicCharactersTable.get(id); + if (!existing) return; + await comicCharactersTable.update(id, { + isFavorite: !existing.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async archiveCharacter(id: string, archived: boolean): Promise { + await comicCharactersTable.update(id, { + isArchived: archived, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteCharacter(id: string): Promise { + const nowIso = new Date().toISOString(); + await comicCharactersTable.update(id, { + deletedAt: nowIso, + updatedAt: nowIso, + }); + emitDomainEvent('ComicCharacterDeleted', 'comic', 'comicCharacters', id, { + characterId: id, + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/comic/types.ts b/apps/mana/apps/web/src/lib/modules/comic/types.ts index c019a0481..5922652de 100644 --- a/apps/mana/apps/web/src/lib/modules/comic/types.ts +++ b/apps/mana/apps/web/src/lib/modules/comic/types.ts @@ -140,3 +140,93 @@ export function toStory(local: LocalComicStory): ComicStory { export function storyCoverPanelId(story: Pick): string | null { return story.panelImageIds[0] ?? null; } + +// ─── Character ──────────────────────────────────────────────────── + +/** + * A reusable comic-style stand-in for the user. Generated once, refined + * across N variant renders (gpt-image-2 / Nano Banana edits over the + * raw face/body meImages with a style-prefix), and pinned to one + * variant that becomes the character's canonical look. Stories then + * reference the pinned variant's mediaId rather than the raw face-ref + * — that's how a "Manga-Me" stays consistent across many stories. + * + * One character → many variants (all kept in `variantMediaIds[]`). + * The pinned variant is the cover + the ref every story-create + * snapshots into the new story. Re-pinning later doesn't touch + * existing stories (those snapshotted at story-create time). + * + * Variants are written into `picture.images` with a `comicCharacterId` + * back-ref so the gallery can show "all renders of Manga-Me" if the + * user ever wants that view. + */ +export interface LocalComicCharacter extends BaseRecord { + id: string; + name: string; + description?: string | null; + style: ComicStyle; + /** Optional add-on prompt the user typed during character-build, + * e.g. "freundlicher Ausdruck", "casual outfit", "action pose". + * Re-used as default when the user clicks "Mehr Varianten" later. */ + addPrompt?: string | null; + /** Source meImages that fed every variant generation. Pinned in the + * character so re-generating later keeps the same identity anchor. */ + sourceFaceMediaId: string; + sourceBodyMediaId?: string | null; + /** All generated variant images (mana-media ids on `picture.images`). + * Newest-first by convention; a future "regenerate" appends to the + * end. Unbounded but rendered as a paginated grid in the detail view. */ + variantMediaIds: string[]; + /** Which variant IS the character — used as the cover and as the + * ref every story-create snapshots. `null` if the user hasn't + * picked one yet (build-in-progress). */ + pinnedVariantId?: string | null; + tags: string[]; + isFavorite?: boolean; + isArchived?: boolean; +} + +export interface ComicCharacter { + id: string; + name: string; + description?: string; + style: ComicStyle; + addPrompt?: string; + sourceFaceMediaId: string; + sourceBodyMediaId?: string; + variantMediaIds: string[]; + pinnedVariantId?: string; + tags: string[]; + isFavorite?: boolean; + isArchived?: boolean; + createdAt: string; + updatedAt: string; +} + +export function toCharacter(local: LocalComicCharacter): ComicCharacter { + return { + id: local.id, + name: local.name, + description: local.description ?? undefined, + style: local.style, + addPrompt: local.addPrompt ?? undefined, + sourceFaceMediaId: local.sourceFaceMediaId, + sourceBodyMediaId: local.sourceBodyMediaId ?? undefined, + variantMediaIds: local.variantMediaIds ?? [], + pinnedVariantId: local.pinnedVariantId ?? undefined, + tags: local.tags ?? [], + isFavorite: local.isFavorite, + isArchived: local.isArchived, + createdAt: local.createdAt ?? '', + updatedAt: local.updatedAt ?? '', + }; +} + +/** Cover variant for a character — pinned variant if set, otherwise + * the first variant in `variantMediaIds` (so a build-in-progress + * character still shows something). `null` if no variants generated. */ +export function characterCoverVariantId( + character: Pick +): string | null { + return character.pinnedVariantId ?? character.variantMediaIds[0] ?? null; +} diff --git a/apps/mana/apps/web/src/lib/modules/picture/queries.ts b/apps/mana/apps/web/src/lib/modules/picture/queries.ts index 720a7a49b..e2bf198a8 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/queries.ts @@ -53,6 +53,7 @@ export function toImage(local: LocalImage): Image { wardrobeGarmentId: local.wardrobeGarmentId ?? undefined, comicStoryId: local.comicStoryId ?? undefined, comicPanelIndex: local.comicPanelIndex ?? undefined, + comicCharacterId: local.comicCharacterId ?? undefined, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/picture/types.ts b/apps/mana/apps/web/src/lib/modules/picture/types.ts index 0bb529608..5436cbc6a 100644 --- a/apps/mana/apps/web/src/lib/modules/picture/types.ts +++ b/apps/mana/apps/web/src/lib/modules/picture/types.ts @@ -76,6 +76,16 @@ export interface LocalImage extends BaseRecord { * the canonical order is never wrong even if this drifts. */ comicPanelIndex?: number | null; + /** + * Back-reference to `comicCharacters.id` when this image was produced + * as a character-variant render (docs/plans/comic-module.md §11). + * Lets the Picture gallery show "Variant of " without + * loading every character row, and keeps the variant identifiable + * in cross-module embeds. Plaintext FK. Mutually exclusive with + * `comicStoryId` — a single image is either a panel OR a variant, + * never both. + */ + comicCharacterId?: string | null; } export interface LocalBoard extends BaseRecord { @@ -149,6 +159,7 @@ export interface Image { wardrobeGarmentId?: string; comicStoryId?: string; comicPanelIndex?: number; + comicCharacterId?: string; createdAt: string; updatedAt: string; } diff --git a/docs/plans/comic-module.md b/docs/plans/comic-module.md index a3f8fad0c..40d62f931 100644 --- a/docs/plans/comic-module.md +++ b/docs/plans/comic-module.md @@ -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 (Mc1–Mc5) + +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`