mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +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,
|
||||
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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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,
|
||||
comicStoryId: local.comicStoryId ?? undefined,
|
||||
comicPanelIndex: local.comicPanelIndex ?? undefined,
|
||||
comicCharacterId: local.comicCharacterId ?? undefined,
|
||||
createdAt: local.createdAt ?? 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue