mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 09:06:42 +02:00
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:
parent
b385839204
commit
313809bc95
11 changed files with 567 additions and 7 deletions
|
|
@ -97,7 +97,7 @@ import type {
|
||||||
LocalGeneration,
|
LocalGeneration,
|
||||||
LocalWritingStyle,
|
LocalWritingStyle,
|
||||||
} from '../../modules/writing/types';
|
} 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';
|
import type { LocalAugurEntry } from '../../modules/augur/types';
|
||||||
|
|
||||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||||
|
|
@ -616,6 +616,19 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||||
'panelMeta',
|
'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) ───────────
|
// ─── Augur (signs: omens / fortunes / hunches) ───────────
|
||||||
// docs/plans/augur-module.md M1. Single space-scoped table.
|
// docs/plans/augur-module.md M1. Single space-scoped table.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Comic module — Dexie table accessor.
|
* Comic module — Dexie table accessors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db } from '$lib/data/database';
|
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 comicStoriesTable = db.table<LocalComicStory>('comicStories');
|
||||||
|
export const comicCharactersTable = db.table<LocalComicCharacter>('comicCharacters');
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
isEncrypted,
|
isEncrypted,
|
||||||
} from '$lib/data/crypto';
|
} from '$lib/data/crypto';
|
||||||
import { setCurrentUserId } from '$lib/data/current-user';
|
import { setCurrentUserId } from '$lib/data/current-user';
|
||||||
import type { ComicPanelMeta, LocalComicStory } from './types';
|
import type { ComicPanelMeta, LocalComicCharacter, LocalComicStory } from './types';
|
||||||
|
|
||||||
const TABLE = 'comicStories';
|
const TABLE = 'comicStories';
|
||||||
|
|
||||||
|
|
@ -167,3 +167,81 @@ describe('comicStories encryption registry', () => {
|
||||||
expect(row.description).toBe(null);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export { comicStoriesTable } from './collections';
|
export { comicStoriesTable, comicCharactersTable } from './collections';
|
||||||
export { comicStoriesStore } from './stores/stories.svelte';
|
export { comicStoriesStore } from './stores/stories.svelte';
|
||||||
|
export { comicCharactersStore } from './stores/characters.svelte';
|
||||||
export {
|
export {
|
||||||
useAllStories,
|
useAllStories,
|
||||||
useStoriesByStyle,
|
useStoriesByStyle,
|
||||||
|
|
@ -16,6 +17,9 @@ export {
|
||||||
useStoryPanels,
|
useStoryPanels,
|
||||||
useStoriesByInput,
|
useStoriesByInput,
|
||||||
usePanelImage,
|
usePanelImage,
|
||||||
|
useAllCharacters,
|
||||||
|
useCharactersByStyle,
|
||||||
|
useCharacter,
|
||||||
} from './queries';
|
} from './queries';
|
||||||
export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants';
|
export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants';
|
||||||
export { STYLE_PREFIXES, composePanelPrompt } from './styles';
|
export { STYLE_PREFIXES, composePanelPrompt } from './styles';
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@ import type { ModuleConfig } from '$lib/data/module-registry';
|
||||||
|
|
||||||
export const comicModuleConfig: ModuleConfig = {
|
export const comicModuleConfig: ModuleConfig = {
|
||||||
appId: 'comic',
|
appId: 'comic',
|
||||||
tables: [{ name: 'comicStories' }],
|
tables: [{ name: 'comicStories' }, { name: 'comicCharacters' }],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,15 @@ import { scopedForModule } from '$lib/data/scope';
|
||||||
import { decryptRecords } from '$lib/data/crypto';
|
import { decryptRecords } from '$lib/data/crypto';
|
||||||
import type { LocalImage, Image } from '$lib/modules/picture/types';
|
import type { LocalImage, Image } from '$lib/modules/picture/types';
|
||||||
import { toImage } from '$lib/modules/picture/queries';
|
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. */
|
/** All non-archived, non-deleted stories in the active space, newest first. */
|
||||||
export function useAllStories() {
|
export function useAllStories() {
|
||||||
|
|
@ -100,6 +108,53 @@ export function useStoryPanels(storyId: string | null) {
|
||||||
}, [] as Image[]);
|
}, [] 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
|
* Stories that were seeded by a given module entry (M4 AI-Storyboard
|
||||||
* back-reference). Matches when *any* panel in the story has a
|
* back-reference). Matches when *any* panel in the story has a
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -140,3 +140,93 @@ export function toStory(local: LocalComicStory): ComicStory {
|
||||||
export function storyCoverPanelId(story: Pick<ComicStory, 'panelImageIds'>): string | null {
|
export function storyCoverPanelId(story: Pick<ComicStory, 'panelImageIds'>): string | null {
|
||||||
return story.panelImageIds[0] ?? 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export function toImage(local: LocalImage): Image {
|
||||||
wardrobeGarmentId: local.wardrobeGarmentId ?? undefined,
|
wardrobeGarmentId: local.wardrobeGarmentId ?? undefined,
|
||||||
comicStoryId: local.comicStoryId ?? undefined,
|
comicStoryId: local.comicStoryId ?? undefined,
|
||||||
comicPanelIndex: local.comicPanelIndex ?? undefined,
|
comicPanelIndex: local.comicPanelIndex ?? undefined,
|
||||||
|
comicCharacterId: local.comicCharacterId ?? undefined,
|
||||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,16 @@ export interface LocalImage extends BaseRecord {
|
||||||
* the canonical order is never wrong even if this drifts.
|
* the canonical order is never wrong even if this drifts.
|
||||||
*/
|
*/
|
||||||
comicPanelIndex?: number | null;
|
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 {
|
export interface LocalBoard extends BaseRecord {
|
||||||
|
|
@ -149,6 +159,7 @@ export interface Image {
|
||||||
wardrobeGarmentId?: string;
|
wardrobeGarmentId?: string;
|
||||||
comicStoryId?: string;
|
comicStoryId?: string;
|
||||||
comicPanelIndex?: number;
|
comicPanelIndex?: number;
|
||||||
|
comicCharacterId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -574,6 +574,140 @@ funktional weil die Decrypts client-side passieren.
|
||||||
behalten oder dort final löschen. Symmetrisch zu Wardrobe (Try-On-
|
behalten oder dort final löschen. Symmetrisch zu Wardrobe (Try-On-
|
||||||
Bilder überleben eine Outfit-Löschung).
|
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
|
## Verweise
|
||||||
|
|
||||||
- Fundament Picture-Generate-Reference: `apps/api/src/modules/picture/routes.ts:250-430`
|
- Fundament Picture-Generate-Reference: `apps/api/src/modules/picture/routes.ts:250-430`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue