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

@ -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<string, EncryptionConfig> = {
@ -616,6 +616,19 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
'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<LocalComicCharacter>(['name', 'description', 'addPrompt', 'tags']),
// ─── Augur (signs: omens / fortunes / hunches) ───────────
// docs/plans/augur-module.md M1. Single space-scoped table.
//

View file

@ -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<LocalComicStory>('comicStories');
export const comicCharactersTable = db.table<LocalComicCharacter>('comicCharacters');

View file

@ -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> = {}): 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<string, unknown>);
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<string, unknown>);
await decryptRecord(CHAR_TABLE, row as unknown as Record<string, unknown>);
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<string, unknown>);
// 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<string, unknown>);
expect(row.variantMediaIds).toEqual([]);
expect(row.pinnedVariantId).toBe(null);
});
});

View file

@ -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';

View file

@ -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' }],
};

View file

@ -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<ComicCharacter[]>(async () => {
const locals = await scopedForModule<LocalComicCharacter, string>(
'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<ComicCharacter[]>(async () => {
const locals = await scopedForModule<LocalComicCharacter, string>('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<ComicCharacter | null>(async () => {
if (!id) return null;
const locals = await scopedForModule<LocalComicCharacter, string>('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

View file

@ -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<ComicCharacter> {
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<void> {
const existing = await comicCharactersTable.get(characterId);
if (!existing) throw new Error(`Character ${characterId} not found`);
const nextIds = [...(existing.variantMediaIds ?? []), variantMediaId];
const patch: Partial<LocalComicCharacter> = {
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<void> {
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<void> {
const existing = await comicCharactersTable.get(characterId);
if (!existing) return;
const nextIds = (existing.variantMediaIds ?? []).filter((id) => id !== variantMediaId);
const patch: Partial<LocalComicCharacter> = {
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<Pick<LocalComicCharacter, 'name' | 'description' | 'addPrompt' | 'tags'>>
): Promise<void> {
const wrapped: Record<string, unknown> = { ...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<void> {
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<void> {
await comicCharactersTable.update(id, {
isArchived: archived,
updatedAt: new Date().toISOString(),
});
},
async deleteCharacter(id: string): Promise<void> {
const nowIso = new Date().toISOString();
await comicCharactersTable.update(id, {
deletedAt: nowIso,
updatedAt: nowIso,
});
emitDomainEvent('ComicCharacterDeleted', 'comic', 'comicCharacters', id, {
characterId: id,
});
},
};

View file

@ -140,3 +140,93 @@ export function toStory(local: LocalComicStory): ComicStory {
export function storyCoverPanelId(story: Pick<ComicStory, 'panelImageIds'>): 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<ComicCharacter, 'pinnedVariantId' | 'variantMediaIds'>
): string | null {
return character.pinnedVariantId ?? character.variantMediaIds[0] ?? null;
}

View file

@ -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(),
};

View file

@ -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 <Character>" 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;
}