mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
feat(comic): M1 — Datenschicht + Modul-Registrierung
Neues Comic-Modul: aus Text-Inputs (Journal / Notes / Writing / Library
/ Calendar) entsteht ein mehrseitiger Comic, generiert mit gpt-image-2
über die bestehende /picture/generate-with-reference-Route. Plan in
docs/plans/comic-module.md (M1–M5 + optional M6–M8).
M1 schafft die Datenschicht ohne UI:
- Dexie v44 `comicStories` (space-scoped, Indices createdAt/style/
isFavorite/isArchived). Story hält `panelImageIds: string[]` und
`panelMeta: Record<panelImageId, {caption, dialogue, promptUsed,
sourceInput?}>` — Panels selbst sind picture.images-Rows mit
comicStoryId + comicPanelIndex Back-Refs.
- Fünf Stil-Presets (comic / manga / cartoon / graphic-novel / webtoon)
mit Prompt-Prefix-Templates in styles.ts; composePanelPrompt webt
Stil + Panel-Prompt + Caption + Dialog zusammen. Sprechblasen
werden von gpt-image-2 direkt ins Bild gerendert — kein SVG-Overlay.
- Encryption-Registry-Eintrag: title / description / storyContext /
tags / panelMeta als JSON-Blob. Struktur (id, style, character-
MediaIds, panelImageIds, Flags, visibility) bleibt plaintext.
- Module-Registry registriert appId='comic', verifyMediaOwnership auf
der /picture/generate-with-reference-Route akzeptiert jetzt
['me', 'wardrobe', 'comic'] — 'comic'-Slot ist reserviert für M6+
Anchor-/Backdrop-Uploads.
- Space-Allowlist: comic in brand (Marken-Storys), club (Vereins-
geschichte), family (Kinder-Abenteuer), team (Release-Comics),
practice (Patienten-Aufklärung). Personal via '*'-Sentinel.
- mana-apps.ts Eintrag mit comic-Icon (Sprechblase + Lightning-Bolt,
f97316→dc2626 Gradient). Lokal tier='guest' mit LOCAL TIER PATCH-
Comment wie Wardrobe, canonical ist 'beta'.
Visibility-System von Anfang an adopted (setVisibility-Methode im
Store, unlistedToken-Generierung inklusive). appendPanel() als
Vorarbeit für M2 bereits da, ohne Aufrufer.
5 Encryption-Roundtrip-Tests grün (panelMeta nested JSON, leeres
panelMeta, partielle panelMeta ohne sourceInput, null-description).
pnpm run check + validate:all sauber (207 Dexie-Tabellen klassifiziert,
comicStories unter den 106 encrypted).
Kein UI, keine Panel-Generierung, keine MCP-Tools — alles M2/M3/M5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c82a374fe
commit
27c1860f82
19 changed files with 1385 additions and 5 deletions
|
|
@ -297,13 +297,18 @@ routes.post('/generate-with-reference', async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ownership check before we spend credits or burn OpenAI quota.
|
// Ownership check before we spend credits or burn OpenAI quota.
|
||||||
// References span two upload tags: `me` for face/body portraits
|
// References span three upload tags today:
|
||||||
// (profile module) and `wardrobe` for garment photos (wardrobe
|
// - `me` — face/body portraits from the profile module
|
||||||
// module, M4 try-on flow). Anything outside those two apps is
|
// - `wardrobe` — garment photos (M4 try-on flow)
|
||||||
// treated as not-owned regardless of mana-media's own view.
|
// - `comic` — comic-specific anchor / backdrop uploads
|
||||||
|
// (slot reserved for M6+; no writer lands in
|
||||||
|
// this app today, M1 character refs come from
|
||||||
|
// me + wardrobe only).
|
||||||
|
// Anything outside these apps is treated as not-owned regardless of
|
||||||
|
// mana-media's own view.
|
||||||
try {
|
try {
|
||||||
const { verifyMediaOwnership } = await import('../../lib/media');
|
const { verifyMediaOwnership } = await import('../../lib/media');
|
||||||
await verifyMediaOwnership(userId, refIds, ['me', 'wardrobe']);
|
await verifyMediaOwnership(userId, refIds, ['me', 'wardrobe', 'comic']);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error & { status?: number; missing?: string[] };
|
const e = err as Error & { status?: number; missing?: string[] };
|
||||||
if (e.status === 404) {
|
if (e.status === 404) {
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ import type {
|
||||||
LocalGeneration,
|
LocalGeneration,
|
||||||
LocalWritingStyle,
|
LocalWritingStyle,
|
||||||
} from '../../modules/writing/types';
|
} from '../../modules/writing/types';
|
||||||
|
import type { LocalComicStory } from '../../modules/comic/types';
|
||||||
|
|
||||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||||
// ─── Chat ────────────────────────────────────────────────
|
// ─── Chat ────────────────────────────────────────────────
|
||||||
|
|
@ -586,6 +587,34 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||||
// it plaintext and revisit if prompts later carry personal data.
|
// it plaintext and revisit if prompts later carry personal data.
|
||||||
wardrobeOutfits: entry<LocalWardrobeOutfit>(['name', 'description', 'tags']),
|
wardrobeOutfits: entry<LocalWardrobeOutfit>(['name', 'description', 'tags']),
|
||||||
|
|
||||||
|
// ─── Comic (stories + inline panel metadata) ─────────────
|
||||||
|
// docs/plans/comic-module.md M1. Single space-scoped table.
|
||||||
|
//
|
||||||
|
// `title`, `description`, `storyContext`, `tags` are user-typed
|
||||||
|
// prose and get the same treatment as journal.title / notes.content.
|
||||||
|
// `panelMeta` is the per-panel sidecar (Record<panelImageId,
|
||||||
|
// {caption, dialogue, promptUsed, sourceInput}>) — aes.ts JSON-
|
||||||
|
// stringifies the whole blob before wrap, same pattern as
|
||||||
|
// food.foods / recipes.ingredients / quiz.options. Caption +
|
||||||
|
// dialogue are prose fragments the user authored; promptUsed is
|
||||||
|
// the reproduce-key (would-be-convenient for regeneration but
|
||||||
|
// leaks story content if plaintext); sourceInput FKs are
|
||||||
|
// low-risk but ship inside the encrypted blob anyway because
|
||||||
|
// splitting the Record per-field would double the storage cost.
|
||||||
|
//
|
||||||
|
// Plaintext (intentional): id, style enum (drives listStories
|
||||||
|
// filter + per-style prompt-prefix lookup), characterMediaIds
|
||||||
|
// (FKs to meImages / wardrobeGarments), panelImageIds (ordered
|
||||||
|
// FKs to picture.images), isFavorite / isArchived / visibility
|
||||||
|
// fields — all needed by the index or query layer.
|
||||||
|
comicStories: entry<LocalComicStory>([
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'storyContext',
|
||||||
|
'tags',
|
||||||
|
'panelMeta',
|
||||||
|
]),
|
||||||
|
|
||||||
// Per-agent kontext documents — same schema as kontextDoc but keyed
|
// Per-agent kontext documents — same schema as kontextDoc but keyed
|
||||||
// per agent. Content is free-form markdown.
|
// per agent. Content is free-form markdown.
|
||||||
agentKontextDocs: { enabled: true, fields: ['content'] },
|
agentKontextDocs: { enabled: true, fields: ['content'] },
|
||||||
|
|
|
||||||
|
|
@ -1032,6 +1032,26 @@ db.version(43).stores({
|
||||||
writingStyles: 'id, source, isSpaceDefault, isFavorite, updatedAt',
|
writingStyles: 'id, source, isSpaceDefault, isFavorite, updatedAt',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v44 — Comic module (docs/plans/comic-module.md M1).
|
||||||
|
// Single space-scoped table: each row is a comic story holding an
|
||||||
|
// ordered `panelImageIds: string[]` pointing at picture.images rows
|
||||||
|
// generated via /picture/generate-with-reference. No separate panel
|
||||||
|
// table — the `picture.images` entry IS the panel, with `comicStoryId`
|
||||||
|
// + `comicPanelIndex` plaintext back-refs (added as type-level fields
|
||||||
|
// on LocalImage; no schema index needed because the story holds the
|
||||||
|
// canonical order and loads its panels by id-list, not by scan).
|
||||||
|
//
|
||||||
|
// Indices:
|
||||||
|
// - comicStories.createdAt for "newest first" grid ordering
|
||||||
|
// - comicStories.style for the style-filter query (M5 MCP listStories)
|
||||||
|
// - comicStories.isFavorite for the favorites filter
|
||||||
|
// - comicStories.isArchived for the archive-hide filter
|
||||||
|
// Gets standard spaceId/authorId/visibility stamping via the Dexie hook
|
||||||
|
// (NOT in USER_LEVEL_TABLES).
|
||||||
|
db.version(44).stores({
|
||||||
|
comicStories: 'id, createdAt, style, isFavorite, isArchived',
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Sync Routing ──────────────────────────────────────────
|
// ─── Sync Routing ──────────────────────────────────────────
|
||||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||||
// toSyncName() and fromSyncName() are now derived from per-module
|
// toSyncName() and fromSyncName() are now derived from per-module
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||||
import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
||||||
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
||||||
import { writingModuleConfig } from '$lib/modules/writing/module.config';
|
import { writingModuleConfig } from '$lib/modules/writing/module.config';
|
||||||
|
import { comicModuleConfig } from '$lib/modules/comic/module.config';
|
||||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||||
|
|
||||||
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
||||||
|
|
@ -168,6 +169,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
||||||
websiteModuleConfig,
|
websiteModuleConfig,
|
||||||
wardrobeModuleConfig,
|
wardrobeModuleConfig,
|
||||||
writingModuleConfig,
|
writingModuleConfig,
|
||||||
|
comicModuleConfig,
|
||||||
aiModuleConfig,
|
aiModuleConfig,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
8
apps/mana/apps/web/src/lib/modules/comic/collections.ts
Normal file
8
apps/mana/apps/web/src/lib/modules/comic/collections.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* Comic module — Dexie table accessor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '$lib/data/database';
|
||||||
|
import type { LocalComicStory } from './types';
|
||||||
|
|
||||||
|
export const comicStoriesTable = db.table<LocalComicStory>('comicStories');
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
/**
|
||||||
|
* Comic encryption roundtrip test.
|
||||||
|
*
|
||||||
|
* `comicStories` ships with `panelMeta: Record<panelImageId, {caption,
|
||||||
|
* dialogue, promptUsed, sourceInput}>` as an encrypted JSON blob via the
|
||||||
|
* registry entry `entry<LocalComicStory>(['title', 'description',
|
||||||
|
* 'storyContext', 'tags', 'panelMeta'])`. This test locks in the
|
||||||
|
* roundtrip contract: every encrypted field recovers its exact value
|
||||||
|
* after an encrypt→decrypt cycle, the structural fields (id, style,
|
||||||
|
* characterMediaIds, panelImageIds, booleans, timestamps) stay
|
||||||
|
* plaintext, and the nested panelMeta object (including its
|
||||||
|
* sourceInput.module enum and sourceInput.entryId FK) survives
|
||||||
|
* untouched.
|
||||||
|
*
|
||||||
|
* Modeled after notes-encryption.test.ts but uses encryptRecord /
|
||||||
|
* decryptRecord directly — no Dexie round-trip needed to prove the
|
||||||
|
* registry contract, and skipping fake-indexeddb keeps the test fast.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
encryptRecord,
|
||||||
|
decryptRecord,
|
||||||
|
generateMasterKey,
|
||||||
|
MemoryKeyProvider,
|
||||||
|
setKeyProvider,
|
||||||
|
isEncrypted,
|
||||||
|
} from '$lib/data/crypto';
|
||||||
|
import { setCurrentUserId } from '$lib/data/current-user';
|
||||||
|
import type { ComicPanelMeta, LocalComicStory } from './types';
|
||||||
|
|
||||||
|
const TABLE = 'comicStories';
|
||||||
|
|
||||||
|
let provider: MemoryKeyProvider;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const key = await generateMasterKey();
|
||||||
|
provider = new MemoryKeyProvider();
|
||||||
|
provider.setKey(key);
|
||||||
|
setKeyProvider(provider);
|
||||||
|
setCurrentUserId('test-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
provider.setKey(null);
|
||||||
|
setCurrentUserId(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeStory(overrides: Partial<LocalComicStory> = {}): LocalComicStory {
|
||||||
|
return {
|
||||||
|
id: 'story-1',
|
||||||
|
title: 'Bug-Hunt-Frust',
|
||||||
|
description: 'Ein 4-Panel-Comic zum Sync-Bug vom Dienstag',
|
||||||
|
style: 'comic',
|
||||||
|
characterMediaIds: ['me-face-123', 'wardrobe-tee-456'],
|
||||||
|
storyContext: 'Ich ärgere mich über einen Off-by-one in der LWW-Logik.',
|
||||||
|
panelImageIds: ['img-a', 'img-b'],
|
||||||
|
panelMeta: {
|
||||||
|
'img-a': {
|
||||||
|
caption: 'Montag, 9 Uhr.',
|
||||||
|
dialogue: 'Der Test ist grün.',
|
||||||
|
promptUsed: 'developer sitting at desk, confident expression',
|
||||||
|
sourceInput: { module: 'journal', entryId: 'journal-42' },
|
||||||
|
},
|
||||||
|
'img-b': {
|
||||||
|
caption: 'Eine Stunde später...',
|
||||||
|
dialogue: 'Der Test ist rot. WARUM.',
|
||||||
|
promptUsed: 'same developer, panicked expression, dark lighting',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ['frust', 'devlog', '2026'],
|
||||||
|
isFavorite: true,
|
||||||
|
isArchived: false,
|
||||||
|
visibility: 'private',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('comicStories encryption registry', () => {
|
||||||
|
it('encrypts title, description, storyContext, tags, panelMeta; leaves structural fields plaintext', async () => {
|
||||||
|
const row = makeStory();
|
||||||
|
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
|
// Encrypted fields are ciphertext
|
||||||
|
expect(isEncrypted(row.title)).toBe(true);
|
||||||
|
expect(isEncrypted(row.description)).toBe(true);
|
||||||
|
expect(isEncrypted(row.storyContext)).toBe(true);
|
||||||
|
// tags is a string[] — aes.ts JSON-stringifies before wrap, the
|
||||||
|
// resulting value is still detected as encrypted via isEncrypted.
|
||||||
|
expect(isEncrypted(row.tags)).toBe(true);
|
||||||
|
// panelMeta is a nested object — same array-path pattern.
|
||||||
|
expect(isEncrypted(row.panelMeta)).toBe(true);
|
||||||
|
|
||||||
|
// Nothing user-typed slipped through
|
||||||
|
expect(String(row.title)).not.toContain('Bug-Hunt');
|
||||||
|
expect(String(row.description)).not.toContain('4-Panel');
|
||||||
|
expect(String(row.storyContext)).not.toContain('Off-by-one');
|
||||||
|
expect(JSON.stringify(row.panelMeta)).not.toContain('grün');
|
||||||
|
expect(JSON.stringify(row.panelMeta)).not.toContain('WARUM');
|
||||||
|
expect(JSON.stringify(row.tags)).not.toContain('devlog');
|
||||||
|
|
||||||
|
// Structural fields untouched
|
||||||
|
expect(row.id).toBe('story-1');
|
||||||
|
expect(row.style).toBe('comic');
|
||||||
|
expect(row.characterMediaIds).toEqual(['me-face-123', 'wardrobe-tee-456']);
|
||||||
|
expect(row.panelImageIds).toEqual(['img-a', 'img-b']);
|
||||||
|
expect(row.isFavorite).toBe(true);
|
||||||
|
expect(row.isArchived).toBe(false);
|
||||||
|
expect(row.visibility).toBe('private');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips the full panelMeta nested object', async () => {
|
||||||
|
const row = makeStory();
|
||||||
|
const originalMeta: Record<string, ComicPanelMeta> = JSON.parse(JSON.stringify(row.panelMeta));
|
||||||
|
|
||||||
|
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
await decryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
|
expect(row.title).toBe('Bug-Hunt-Frust');
|
||||||
|
expect(row.description).toBe('Ein 4-Panel-Comic zum Sync-Bug vom Dienstag');
|
||||||
|
expect(row.storyContext).toBe('Ich ärgere mich über einen Off-by-one in der LWW-Logik.');
|
||||||
|
expect(row.tags).toEqual(['frust', 'devlog', '2026']);
|
||||||
|
// Nested shape survives intact — caption / dialogue / promptUsed /
|
||||||
|
// sourceInput (module + entryId) all present and equal.
|
||||||
|
expect(row.panelMeta).toEqual(originalMeta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty panelMeta record (freshly created story with no panels yet)', async () => {
|
||||||
|
const row = makeStory({
|
||||||
|
panelImageIds: [],
|
||||||
|
panelMeta: {},
|
||||||
|
});
|
||||||
|
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
// Even the empty object ships encrypted — registry doesn't skip
|
||||||
|
// empty non-null values.
|
||||||
|
expect(isEncrypted(row.panelMeta)).toBe(true);
|
||||||
|
|
||||||
|
await decryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
expect(row.panelMeta).toEqual({});
|
||||||
|
expect(row.panelImageIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a panelMeta entry without sourceInput (manual panel, not AI-Storyboard)', async () => {
|
||||||
|
const row = makeStory({
|
||||||
|
panelMeta: {
|
||||||
|
'img-a': {
|
||||||
|
caption: 'Manuell geschrieben',
|
||||||
|
promptUsed: 'character looking at sunset',
|
||||||
|
// no dialogue, no sourceInput
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
await decryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
expect(row.panelMeta['img-a']).toEqual({
|
||||||
|
caption: 'Manuell geschrieben',
|
||||||
|
promptUsed: 'character looking at sunset',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves null-valued description unchanged (no crash, no wrap)', async () => {
|
||||||
|
const row = makeStory({ description: null });
|
||||||
|
await encryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
expect(row.description).toBe(null);
|
||||||
|
await decryptRecord(TABLE, row as unknown as Record<string, unknown>);
|
||||||
|
expect(row.description).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
apps/mana/apps/web/src/lib/modules/comic/constants.ts
Normal file
38
apps/mana/apps/web/src/lib/modules/comic/constants.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Comic module — labels, caps, defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComicStyle } from './types';
|
||||||
|
|
||||||
|
export const STYLE_LABELS: Record<ComicStyle, { de: string; en: string }> = {
|
||||||
|
comic: { de: 'US-Comic', en: 'US-Comic' },
|
||||||
|
manga: { de: 'Manga', en: 'Manga' },
|
||||||
|
cartoon: { de: 'Cartoon', en: 'Cartoon' },
|
||||||
|
'graphic-novel': { de: 'Graphic Novel', en: 'Graphic Novel' },
|
||||||
|
webtoon: { de: 'Webtoon', en: 'Webtoon' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STYLE_ORDER: readonly ComicStyle[] = [
|
||||||
|
'comic',
|
||||||
|
'manga',
|
||||||
|
'cartoon',
|
||||||
|
'graphic-novel',
|
||||||
|
'webtoon',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard client-side cap on panels per story. gpt-image-2 consistency
|
||||||
|
* degrades beyond ~8–10 panels even with identical refs; 12 is the
|
||||||
|
* "long comic" ceiling before restyling. UI warns softly ≥ 8. Plan
|
||||||
|
* offene-frage #1.
|
||||||
|
*/
|
||||||
|
export const MAX_PANELS_PER_STORY = 12;
|
||||||
|
export const PANEL_COUNT_WARN_THRESHOLD = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default panel count the AI-Storyboard flow (M4) asks Claude to
|
||||||
|
* generate when no explicit number is chosen. Slider range 2–8 in UI.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_STORYBOARD_PANEL_COUNT = 4;
|
||||||
|
export const MIN_STORYBOARD_PANEL_COUNT = 2;
|
||||||
|
export const MAX_STORYBOARD_PANEL_COUNT = 8;
|
||||||
20
apps/mana/apps/web/src/lib/modules/comic/index.ts
Normal file
20
apps/mana/apps/web/src/lib/modules/comic/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* Comic module — public surface.
|
||||||
|
*
|
||||||
|
* Plan: docs/plans/comic-module.md. M1 ships the datenschicht only
|
||||||
|
* (types, collections, queries, stores, module registration). UI +
|
||||||
|
* generate-flow follows in M2.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export { comicStoriesTable } from './collections';
|
||||||
|
export { comicStoriesStore } from './stores/stories.svelte';
|
||||||
|
export {
|
||||||
|
useAllStories,
|
||||||
|
useStoriesByStyle,
|
||||||
|
useStory,
|
||||||
|
useStoryPanels,
|
||||||
|
useStoriesByInput,
|
||||||
|
} from './queries';
|
||||||
|
export { STYLE_LABELS, STYLE_ORDER, MAX_PANELS_PER_STORY } from './constants';
|
||||||
|
export { STYLE_PREFIXES, composePanelPrompt } from './styles';
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||||
|
|
||||||
|
export const comicModuleConfig: ModuleConfig = {
|
||||||
|
appId: 'comic',
|
||||||
|
tables: [{ name: 'comicStories' }],
|
||||||
|
};
|
||||||
110
apps/mana/apps/web/src/lib/modules/comic/queries.ts
Normal file
110
apps/mana/apps/web/src/lib/modules/comic/queries.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* Comic module — read-side queries.
|
||||||
|
*
|
||||||
|
* Stories are space-scoped: switching the active space swaps the
|
||||||
|
* visible pool automatically via `scopedForModule`. Panel history
|
||||||
|
* lives in `picture.images` filtered by `comicStoryId` — kept on the
|
||||||
|
* picture side rather than here (decision #1 in the plan: one table
|
||||||
|
* in this module, panels are picture rows).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** All non-archived, non-deleted stories in the active space, newest first. */
|
||||||
|
export function useAllStories() {
|
||||||
|
return useLiveQueryWithDefault<ComicStory[]>(async () => {
|
||||||
|
const locals = await scopedForModule<LocalComicStory, string>(
|
||||||
|
'comic',
|
||||||
|
'comicStories'
|
||||||
|
).toArray();
|
||||||
|
const visible = locals
|
||||||
|
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||||
|
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||||
|
const decrypted = await decryptRecords('comicStories', visible);
|
||||||
|
return decrypted.map(toStory);
|
||||||
|
}, [] as ComicStory[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stories filtered by style — used by the style-tabs view in M5 list tool. */
|
||||||
|
export function useStoriesByStyle(style: ComicStyle) {
|
||||||
|
return useLiveQueryWithDefault<ComicStory[]>(async () => {
|
||||||
|
const locals = await scopedForModule<LocalComicStory, string>('comic', 'comicStories')
|
||||||
|
.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('comicStories', visible);
|
||||||
|
return decrypted.map(toStory);
|
||||||
|
}, [] as ComicStory[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single story by id, live-updating. Null while loading / missing. */
|
||||||
|
export function useStory(id: string | null) {
|
||||||
|
return useLiveQueryWithDefault<ComicStory | null>(async () => {
|
||||||
|
if (!id) return null;
|
||||||
|
const locals = await scopedForModule<LocalComicStory, string>('comic', 'comicStories')
|
||||||
|
.and((row) => row.id === id)
|
||||||
|
.toArray();
|
||||||
|
const [local] = locals;
|
||||||
|
if (!local || local.deletedAt) return null;
|
||||||
|
const [decrypted] = await decryptRecords('comicStories', [local]);
|
||||||
|
return toStory(decrypted);
|
||||||
|
}, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every panel rendered for a story, newest first. Pulls from
|
||||||
|
* `picture.images` filtered by `comicStoryId`. Typically the Detail-
|
||||||
|
* View uses `story.panelImageIds` directly for ordered rendering; this
|
||||||
|
* query is for gallery-style "all renders across regenerations" views
|
||||||
|
* where users want to see panels that were dropped from the story's
|
||||||
|
* ordered list but not deleted.
|
||||||
|
*/
|
||||||
|
export function useStoryPanels(storyId: string | null) {
|
||||||
|
return useLiveQueryWithDefault<Image[]>(async () => {
|
||||||
|
if (!storyId) return [];
|
||||||
|
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
|
||||||
|
.and((row) => row.comicStoryId === storyId)
|
||||||
|
.toArray();
|
||||||
|
const visible = locals
|
||||||
|
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||||
|
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||||
|
const decrypted = await decryptRecords('images', visible);
|
||||||
|
return decrypted.map(toImage);
|
||||||
|
}, [] as Image[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stories that were seeded by a given module entry (M4 AI-Storyboard
|
||||||
|
* back-reference). Matches when *any* panel in the story has a
|
||||||
|
* `panelMeta[id].sourceInput` pointing at the given {module, entryId}.
|
||||||
|
* Used for the "Comics zu diesem Journal-Eintrag" cross-reference
|
||||||
|
* widget that renders on module detail pages.
|
||||||
|
*/
|
||||||
|
export function useStoriesByInput(
|
||||||
|
module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar' | null,
|
||||||
|
entryId: string | null
|
||||||
|
) {
|
||||||
|
return useLiveQueryWithDefault<ComicStory[]>(async () => {
|
||||||
|
if (!module || !entryId) return [];
|
||||||
|
const locals = await scopedForModule<LocalComicStory, string>(
|
||||||
|
'comic',
|
||||||
|
'comicStories'
|
||||||
|
).toArray();
|
||||||
|
const visible = locals.filter((row) => !row.deletedAt && !row.isArchived);
|
||||||
|
const decrypted = await decryptRecords('comicStories', visible);
|
||||||
|
const stories = decrypted.map(toStory);
|
||||||
|
return stories.filter((s) => {
|
||||||
|
const metas = Object.values(s.panelMeta);
|
||||||
|
return metas.some(
|
||||||
|
(meta) => meta.sourceInput?.module === module && meta.sourceInput.entryId === entryId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [] as ComicStory[]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
/**
|
||||||
|
* Comic stories store — mutation-only service.
|
||||||
|
*
|
||||||
|
* A story holds an ordered `panelImageIds: string[]` plus a
|
||||||
|
* `panelMeta` record keyed by panel id. Panel mutations (append,
|
||||||
|
* reorder, remove, updateMeta) are the M2+ shape; M1 covers the
|
||||||
|
* shell: create/update/archive/delete/setVisibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encryptRecord } from '$lib/data/crypto';
|
||||||
|
import { emitDomainEvent } from '$lib/data/events';
|
||||||
|
import { getActiveSpace } from '$lib/data/scope';
|
||||||
|
import { getEffectiveUserId } from '$lib/data/current-user';
|
||||||
|
import {
|
||||||
|
defaultVisibilityFor,
|
||||||
|
generateUnlistedToken,
|
||||||
|
type VisibilityLevel,
|
||||||
|
} from '@mana/shared-privacy';
|
||||||
|
import { comicStoriesTable } from '../collections';
|
||||||
|
import { toStory } from '../types';
|
||||||
|
import type { ComicPanelMeta, ComicStory, ComicStyle, LocalComicStory } from '../types';
|
||||||
|
|
||||||
|
export interface CreateStoryInput {
|
||||||
|
title: string;
|
||||||
|
style: ComicStyle;
|
||||||
|
characterMediaIds: string[];
|
||||||
|
description?: string | null;
|
||||||
|
storyContext?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
isFavorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const comicStoriesStore = {
|
||||||
|
async createStory(input: CreateStoryInput): Promise<ComicStory> {
|
||||||
|
if (input.characterMediaIds.length === 0) {
|
||||||
|
throw new Error('Story needs at least one character reference image');
|
||||||
|
}
|
||||||
|
const newLocal: LocalComicStory = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
style: input.style,
|
||||||
|
characterMediaIds: input.characterMediaIds,
|
||||||
|
storyContext: input.storyContext ?? null,
|
||||||
|
panelImageIds: [],
|
||||||
|
panelMeta: {},
|
||||||
|
tags: input.tags ?? [],
|
||||||
|
isFavorite: input.isFavorite ?? false,
|
||||||
|
visibility: defaultVisibilityFor(getActiveSpace()?.type),
|
||||||
|
};
|
||||||
|
const snapshot = toStory({ ...newLocal });
|
||||||
|
await encryptRecord('comicStories', newLocal);
|
||||||
|
await comicStoriesTable.add(newLocal);
|
||||||
|
emitDomainEvent('ComicStoryCreated', 'comic', 'comicStories', newLocal.id, {
|
||||||
|
storyId: newLocal.id,
|
||||||
|
style: input.style,
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStory(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<
|
||||||
|
Pick<LocalComicStory, 'title' | 'description' | 'storyContext' | 'tags' | 'characterMediaIds'>
|
||||||
|
>
|
||||||
|
): Promise<void> {
|
||||||
|
const wrapped = { ...patch } as Record<string, unknown>;
|
||||||
|
await encryptRecord('comicStories', wrapped);
|
||||||
|
await comicStoriesTable.update(id, {
|
||||||
|
...wrapped,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleFavorite(id: string): Promise<void> {
|
||||||
|
const existing = await comicStoriesTable.get(id);
|
||||||
|
if (!existing) return;
|
||||||
|
await comicStoriesTable.update(id, {
|
||||||
|
isFavorite: !existing.isFavorite,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async archiveStory(id: string, archived: boolean): Promise<void> {
|
||||||
|
await comicStoriesTable.update(id, {
|
||||||
|
isArchived: archived,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteStory(id: string): Promise<void> {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
await comicStoriesTable.update(id, {
|
||||||
|
deletedAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
});
|
||||||
|
emitDomainEvent('ComicStoryDeleted', 'comic', 'comicStories', id, {
|
||||||
|
storyId: id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip a story's visibility. Comics are a natural share-surface
|
||||||
|
* (4-panel jokes, work-anecdotes) — marking a story `public` makes
|
||||||
|
* it eligible for `/embed/comic/:id` in M5.
|
||||||
|
*/
|
||||||
|
async setVisibility(id: string, next: VisibilityLevel): Promise<void> {
|
||||||
|
const existing = await comicStoriesTable.get(id);
|
||||||
|
if (!existing) throw new Error(`Comic story ${id} not found`);
|
||||||
|
const before: VisibilityLevel = existing.visibility ?? 'space';
|
||||||
|
if (before === next) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch: Partial<LocalComicStory> = {
|
||||||
|
visibility: next,
|
||||||
|
visibilityChangedAt: now,
|
||||||
|
visibilityChangedBy: getEffectiveUserId(),
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
if (next === 'unlisted' && !existing.unlistedToken) {
|
||||||
|
patch.unlistedToken = generateUnlistedToken();
|
||||||
|
} else if (next !== 'unlisted' && existing.unlistedToken) {
|
||||||
|
patch.unlistedToken = undefined;
|
||||||
|
}
|
||||||
|
await comicStoriesTable.update(id, patch);
|
||||||
|
|
||||||
|
emitDomainEvent('VisibilityChanged', 'comic', 'comicStories', id, {
|
||||||
|
recordId: id,
|
||||||
|
collection: 'comicStories',
|
||||||
|
before,
|
||||||
|
after: next,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a freshly generated panel to the end of the story. Called
|
||||||
|
* by `runPanelGenerate` (M2) right after `picture.images` lands the
|
||||||
|
* new row. `meta` carries the prompt used + optional caption /
|
||||||
|
* dialogue / sourceInput.
|
||||||
|
*
|
||||||
|
* Re-encrypts the whole panelMeta Record because it's one JSON
|
||||||
|
* blob in the registry — we can't partially update individual keys
|
||||||
|
* without decrypting first.
|
||||||
|
*/
|
||||||
|
async appendPanel(storyId: string, panelImageId: string, meta: ComicPanelMeta): Promise<void> {
|
||||||
|
const existing = await comicStoriesTable.get(storyId);
|
||||||
|
if (!existing) throw new Error(`Comic story ${storyId} not found`);
|
||||||
|
const nextIds = [...(existing.panelImageIds ?? []), panelImageId];
|
||||||
|
const nextMeta = { ...(existing.panelMeta ?? {}), [panelImageId]: meta };
|
||||||
|
const patch = {
|
||||||
|
panelImageIds: nextIds,
|
||||||
|
panelMeta: nextMeta,
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
await encryptRecord('comicStories', patch);
|
||||||
|
await comicStoriesTable.update(storyId, {
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
emitDomainEvent('ComicPanelAppended', 'comic', 'comicStories', storyId, {
|
||||||
|
storyId,
|
||||||
|
panelImageId,
|
||||||
|
panelIndex: nextIds.length - 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
53
apps/mana/apps/web/src/lib/modules/comic/styles.ts
Normal file
53
apps/mana/apps/web/src/lib/modules/comic/styles.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* Prompt-prefix templates per visual style. The prefix is prepended to
|
||||||
|
* every panel prompt in `runPanelGenerate` (M2); gpt-image-2 sees the
|
||||||
|
* composite (stylePrefix + panelPrompt + captionHint + dialogueHint),
|
||||||
|
* never the enum itself. Keep prefixes short and directive — they're
|
||||||
|
* spent on every call.
|
||||||
|
*
|
||||||
|
* Adding a style = extending `ComicStyle` in types.ts + `STYLE_LABELS`
|
||||||
|
* in constants.ts + a prefix here. The three stay in lockstep because
|
||||||
|
* Record<ComicStyle, …> forces exhaustive coverage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComicStyle } from './types';
|
||||||
|
|
||||||
|
export const STYLE_PREFIXES: Record<ComicStyle, string> = {
|
||||||
|
comic:
|
||||||
|
'US comic book illustration, bold clean linework, vivid cell-shaded coloring, dramatic lighting, high contrast, comic-panel framing',
|
||||||
|
manga:
|
||||||
|
'Japanese manga illustration, black and white line art with screen tones, dynamic perspective, expressive character design, dramatic motion lines',
|
||||||
|
cartoon:
|
||||||
|
'soft pastel cartoon illustration, rounded friendly shapes, warm saturated colors, Saturday-morning animation style, simple clean backgrounds',
|
||||||
|
'graphic-novel':
|
||||||
|
'graphic novel illustration, painterly watercolor style, muted atmospheric palette, cinematic composition, moody naturalistic lighting',
|
||||||
|
webtoon:
|
||||||
|
'modern webtoon illustration, clean vertical-scroll framing, bright saturated colors, soft cel-shading, expressive character close-ups',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose the final gpt-image-2 prompt for a single panel. Caption and
|
||||||
|
* dialogue (both optional) are rendered directly into the image by
|
||||||
|
* gpt-image-2 — no SVG overlay. Decision #4 in docs/plans/comic-module.md.
|
||||||
|
*
|
||||||
|
* The text-rendering language is whatever the user typed (gpt-image-2
|
||||||
|
* handles multiple languages, English is most stable but German works
|
||||||
|
* for short strings). UI surfaces an English-preferred hint.
|
||||||
|
*/
|
||||||
|
export function composePanelPrompt(input: {
|
||||||
|
style: ComicStyle;
|
||||||
|
panelPrompt: string;
|
||||||
|
caption?: string;
|
||||||
|
dialogue?: string;
|
||||||
|
}): string {
|
||||||
|
const parts: string[] = [STYLE_PREFIXES[input.style], input.panelPrompt.trim()];
|
||||||
|
const caption = input.caption?.trim();
|
||||||
|
const dialogue = input.dialogue?.trim();
|
||||||
|
if (caption) {
|
||||||
|
parts.push(`narration caption at the top reading: "${caption}"`);
|
||||||
|
}
|
||||||
|
if (dialogue) {
|
||||||
|
parts.push(`character speaking in a speech bubble saying: "${dialogue}"`);
|
||||||
|
}
|
||||||
|
return parts.join('. ');
|
||||||
|
}
|
||||||
142
apps/mana/apps/web/src/lib/modules/comic/types.ts
Normal file
142
apps/mana/apps/web/src/lib/modules/comic/types.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/**
|
||||||
|
* Comic module types — one table:
|
||||||
|
*
|
||||||
|
* - `comicStories`: a comic story with title, style, fixed character
|
||||||
|
* reference list, and an ordered `panelImageIds: string[]` pointing
|
||||||
|
* at `picture.images` rows generated via the reference-edit flow.
|
||||||
|
*
|
||||||
|
* Panels themselves live in `picture.images` with `comicStoryId` +
|
||||||
|
* `comicPanelIndex` plaintext back-refs — see apps/mana/apps/web/src/
|
||||||
|
* lib/modules/picture/types.ts. No second table in this module.
|
||||||
|
*
|
||||||
|
* Plan: docs/plans/comic-module.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseRecord } from '@mana/local-store';
|
||||||
|
import type { VisibilityLevel } from '@mana/shared-privacy';
|
||||||
|
|
||||||
|
// ─── Style ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed enum of five visual presets. Each preset is mapped to a
|
||||||
|
* prompt-prefix template in `styles.ts`; the backend never sees the
|
||||||
|
* enum, only the final composed prompt. Chosen at story-create time
|
||||||
|
* and fixed — restyling = new story (or regenerate panels one by one).
|
||||||
|
*/
|
||||||
|
export type ComicStyle =
|
||||||
|
| 'comic' // US-Comic: Linework + Cell-Shading, kräftige Farben
|
||||||
|
| 'manga' // Schwarz/weiß, Screen-Tones, dynamische Perspektiven
|
||||||
|
| 'cartoon' // Weich, pastellig, Saturday-Morning-Cartoon
|
||||||
|
| 'graphic-novel' // Realistischer, Aquarell/Painterly, stimmungsvoll
|
||||||
|
| 'webtoon'; // Vertikal-lesbar, moderne Farbpalette, Soft-Shading
|
||||||
|
|
||||||
|
// ─── Panel-Meta ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-panel sidecar data that sits on the story (keyed by the panel's
|
||||||
|
* `picture.images.id`). The image itself carries only the rendered
|
||||||
|
* pixels + structural fields; everything that describes *why* the
|
||||||
|
* panel exists — user caption, dialogue text, the exact prompt used,
|
||||||
|
* and optional Cross-Module source ref — lives here.
|
||||||
|
*
|
||||||
|
* Whole object is encrypted as one JSON blob via the encryption
|
||||||
|
* registry (same pattern as food.foods / recipes.ingredients).
|
||||||
|
*/
|
||||||
|
export interface ComicPanelMeta {
|
||||||
|
caption?: string;
|
||||||
|
dialogue?: string;
|
||||||
|
/** The final prompt passed to gpt-image-2, stored so a user can
|
||||||
|
* regenerate or tweak without retyping. */
|
||||||
|
promptUsed?: string;
|
||||||
|
/** Which module-entry, if any, seeded this panel in the AI-Storyboard
|
||||||
|
* flow (M4). Lets `useStoriesByInput` answer "which comics did I
|
||||||
|
* make from this journal entry?". Plaintext FKs inside the
|
||||||
|
* encrypted blob. */
|
||||||
|
sourceInput?: {
|
||||||
|
module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar';
|
||||||
|
entryId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Story ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LocalComicStory extends BaseRecord {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
style: ComicStyle;
|
||||||
|
/**
|
||||||
|
* Reference-image IDs passed unchanged to every panel-generate call.
|
||||||
|
* Minimum: the primary face-ref from meImages. Optional additions:
|
||||||
|
* body-ref + up to ~3 wardrobe-garment photos for a costume-setup.
|
||||||
|
* Capped at 8 by the backend (MAX_REFERENCE_IMAGES in the /picture/
|
||||||
|
* generate-with-reference endpoint).
|
||||||
|
*/
|
||||||
|
characterMediaIds: string[];
|
||||||
|
/**
|
||||||
|
* Free-text briefing the author writes once, surfaced in the
|
||||||
|
* AI-Storyboard flow (M4) as context Claude sees before suggesting
|
||||||
|
* panel descriptions. Typical: 1–3 sentences ("Ich ärgere mich über
|
||||||
|
* einen Bug in unserer Sync-Logik — mach daraus einen 4-Panel-
|
||||||
|
* Frust-Comic.").
|
||||||
|
*/
|
||||||
|
storyContext?: string | null;
|
||||||
|
/**
|
||||||
|
* Ordered list of `picture.images.id` — the reading order of the
|
||||||
|
* comic. Reorder = rewrite this array. Length implicitly bounded
|
||||||
|
* by `MAX_PANELS_PER_STORY` at the UI layer; the type doesn't
|
||||||
|
* enforce it.
|
||||||
|
*/
|
||||||
|
panelImageIds: string[];
|
||||||
|
/** Keyed by panel image id. Encrypted as a whole JSON blob. */
|
||||||
|
panelMeta: Record<string, ComicPanelMeta>;
|
||||||
|
tags: string[];
|
||||||
|
isFavorite?: boolean;
|
||||||
|
isArchived?: boolean;
|
||||||
|
visibility?: VisibilityLevel;
|
||||||
|
visibilityChangedAt?: string;
|
||||||
|
visibilityChangedBy?: string;
|
||||||
|
unlistedToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComicStory {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
style: ComicStyle;
|
||||||
|
characterMediaIds: string[];
|
||||||
|
storyContext?: string;
|
||||||
|
panelImageIds: string[];
|
||||||
|
panelMeta: Record<string, ComicPanelMeta>;
|
||||||
|
tags: string[];
|
||||||
|
isFavorite?: boolean;
|
||||||
|
isArchived?: boolean;
|
||||||
|
visibility: VisibilityLevel;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toStory(local: LocalComicStory): ComicStory {
|
||||||
|
return {
|
||||||
|
id: local.id,
|
||||||
|
title: local.title,
|
||||||
|
description: local.description ?? undefined,
|
||||||
|
style: local.style,
|
||||||
|
characterMediaIds: local.characterMediaIds ?? [],
|
||||||
|
storyContext: local.storyContext ?? undefined,
|
||||||
|
panelImageIds: local.panelImageIds ?? [],
|
||||||
|
panelMeta: local.panelMeta ?? {},
|
||||||
|
tags: local.tags ?? [],
|
||||||
|
isFavorite: local.isFavorite,
|
||||||
|
isArchived: local.isArchived,
|
||||||
|
visibility: local.visibility ?? 'space',
|
||||||
|
createdAt: local.createdAt ?? '',
|
||||||
|
updatedAt: local.updatedAt ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thumbnail / cover panel for a story. `null` for stories without any
|
||||||
|
* generated panel yet (they render a placeholder in StoryCard). */
|
||||||
|
export function storyCoverPanelId(story: Pick<ComicStory, 'panelImageIds'>): string | null {
|
||||||
|
return story.panelImageIds[0] ?? null;
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,8 @@ export function toImage(local: LocalImage): Image {
|
||||||
generationMode: local.generationMode ?? undefined,
|
generationMode: local.generationMode ?? undefined,
|
||||||
wardrobeOutfitId: local.wardrobeOutfitId ?? undefined,
|
wardrobeOutfitId: local.wardrobeOutfitId ?? undefined,
|
||||||
wardrobeGarmentId: local.wardrobeGarmentId ?? undefined,
|
wardrobeGarmentId: local.wardrobeGarmentId ?? undefined,
|
||||||
|
comicStoryId: local.comicStoryId ?? undefined,
|
||||||
|
comicPanelIndex: local.comicPanelIndex ?? 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(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,22 @@ export interface LocalImage extends BaseRecord {
|
||||||
* `referenceImageIds` containment.
|
* `referenceImageIds` containment.
|
||||||
*/
|
*/
|
||||||
wardrobeGarmentId?: string | null;
|
wardrobeGarmentId?: string | null;
|
||||||
|
/**
|
||||||
|
* Back-reference to `comicStories.id` when this image was produced as
|
||||||
|
* a comic panel (docs/plans/comic-module.md). The canonical reading
|
||||||
|
* order lives on the story in `panelImageIds`; this field lets the
|
||||||
|
* Picture-gallery show a "Panel von Comic X" chip without having to
|
||||||
|
* load every story to check which one owns the image. Plaintext FK.
|
||||||
|
*/
|
||||||
|
comicStoryId?: string | null;
|
||||||
|
/**
|
||||||
|
* Zero-based reading position inside the owning story at write time.
|
||||||
|
* Denormalised copy of `panelImageIds.indexOf(imageId)` — used for
|
||||||
|
* the gallery's "Panel 3" label. Goes stale if the story is
|
||||||
|
* reordered (M3+); the Detail-View re-reads from `panelImageIds` so
|
||||||
|
* the canonical order is never wrong even if this drifts.
|
||||||
|
*/
|
||||||
|
comicPanelIndex?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalBoard extends BaseRecord {
|
export interface LocalBoard extends BaseRecord {
|
||||||
|
|
@ -131,6 +147,8 @@ export interface Image {
|
||||||
generationMode?: ImageGenerationMode;
|
generationMode?: ImageGenerationMode;
|
||||||
wardrobeOutfitId?: string;
|
wardrobeOutfitId?: string;
|
||||||
wardrobeGarmentId?: string;
|
wardrobeGarmentId?: string;
|
||||||
|
comicStoryId?: string;
|
||||||
|
comicPanelIndex?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
565
docs/plans/comic-module.md
Normal file
565
docs/plans/comic-module.md
Normal file
|
|
@ -0,0 +1,565 @@
|
||||||
|
# Comic — Module Plan
|
||||||
|
|
||||||
|
## Status (2026-04-24, vor M1)
|
||||||
|
|
||||||
|
**Geplant, noch nichts geshipped.** Dieses Dokument legt Datenmodell, UI und
|
||||||
|
KI-Integration fest; die Meilensteine M1–M5 bringen das Feature auf
|
||||||
|
Produktions-Qualität, M6+ sind Ausbau.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein Nutzer erzeugt aus sich selbst und beliebigen Text-Inputs (Tagebuch,
|
||||||
|
Notizen, Writing-Drafts, Library-Einträge, Kalender-Events) einen **Comic**
|
||||||
|
— eine geordnete Folge von Bild-Panels in konsistentem Stil. gpt-image-2
|
||||||
|
rendert jedes Panel aus einer Referenz-Komposition (Face-Ref + optionale
|
||||||
|
Szene) und einem Panel-Prompt; Sprechblasen und Caption-Text werden
|
||||||
|
direkt ins Bild reinrendered, kein separater Overlay-Layer.
|
||||||
|
|
||||||
|
Kernfragen, die dieser Plan beantwortet:
|
||||||
|
|
||||||
|
1. Wie bilden wir eine Comic-Story im Datenmodell ab — als Liste
|
||||||
|
geordneter Panel-Referenzen oder als eigenständige Entität?
|
||||||
|
2. Wie fließt Input aus anderen Modulen (Journal-Eintrag, Notes,
|
||||||
|
Library-Review, Writing-Draft) in die Panel-Generierung ein?
|
||||||
|
3. Wie halten wir Character-Konsistenz über Panels hinweg, ohne ein
|
||||||
|
separates Character-Management-System zu bauen?
|
||||||
|
4. Wie integrieren wir gpt-image-2 mit den fünf unterschiedlichen
|
||||||
|
Comic-Stilen (comic/manga/cartoon/graphic-novel/webtoon), ohne pro
|
||||||
|
Stil einen eigenen Backend-Pfad zu bauen?
|
||||||
|
|
||||||
|
## Abgrenzung
|
||||||
|
|
||||||
|
- **Kein eigener Image-Editor**: Panels sind `picture.images`-Rows wie
|
||||||
|
alle anderen generierten Bilder. Wer Panel X nachbearbeiten will,
|
||||||
|
tut das im Picture-Modul (oder generiert neu). Comic verwaltet die
|
||||||
|
*Reihenfolge und den Story-Kontext*, nicht die einzelnen Pixel.
|
||||||
|
- **Kein Storyboard-Canvas in M1–M4**: Panels leben in einer geordneten
|
||||||
|
Liste mit optionaler Caption. Ein Comic-Strip-Canvas mit
|
||||||
|
Drag-und-Drop-Positionierung (wie Picture-Boards) ist M6+.
|
||||||
|
- **Keine SVG-Speech-Bubble-Overlays**: Sprechblasen/Captions werden
|
||||||
|
gpt-image-2 über den Prompt reingekippt, nicht nachträglich über SVG
|
||||||
|
aufs Bild gelegt. Weniger Kontrolle, einfacher Datenweg, ein
|
||||||
|
Asset-Export pro Panel.
|
||||||
|
- **Keine eigene Character-DB**: Character-Referenzen sind
|
||||||
|
`meImages`-Einträge (Face-Ref, Body-Ref, plus optionale
|
||||||
|
Costume-Referenzen aus `wardrobe`). Kein neues Konzept
|
||||||
|
"Comic-Character" als eigene Table.
|
||||||
|
- **Kein Multi-Character-Crew in M1–M5**: Ein Comic hat *einen*
|
||||||
|
Protagonisten (der Nutzer oder eine Kostüm-Variante von ihm). Crew
|
||||||
|
mit mehreren Gesichtern ist M6+ — braucht Konsistenz-Tricks, die
|
||||||
|
wir nicht auf den MVP-Weg packen wollen.
|
||||||
|
- **Cross-Link zu `picture`**: Panel-Ergebnisse landen in
|
||||||
|
`picture.images` wie jede andere Generierung. `LocalImage` bekommt
|
||||||
|
einen `comicStoryId`-Back-Ref + optional `comicPanelIndex`.
|
||||||
|
- **Cross-Link zu `me-images`**: Ohne `useImageByPrimary('face-ref')`
|
||||||
|
kein Comic — identisch zu Wardrobe's Try-On-Flow.
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
### 1. Ein Modul, eine Tabelle
|
||||||
|
|
||||||
|
Im Gegensatz zu Wardrobe (Garments + Outfits) reicht für Comic **eine**
|
||||||
|
Tabelle:
|
||||||
|
|
||||||
|
- **`comicStories`** — eine Comic-Story mit Titel, Stil, Character-Refs,
|
||||||
|
Story-Kontext, Panel-Liste (als `panelImageIds: string[]` in Plaintext)
|
||||||
|
|
||||||
|
Kein zweites Table `comicPanels`, weil ein Panel kein eigenständiges
|
||||||
|
Primitiv ist — es ist ein `picture.images`-Eintrag mit Back-Ref. Das
|
||||||
|
spart Sync-Volumen, vermeidet FK-Cleanup beim Löschen, und hält die
|
||||||
|
Panel-Reihenfolge an *einem* Ort (im Story-Record als ID-Array, statt
|
||||||
|
als `orderIndex`-Feld auf jedem Panel).
|
||||||
|
|
||||||
|
Die zusätzlichen Panel-Metadaten (Caption-Text, Dialogue-Vorschläge vom
|
||||||
|
AI-Storyboard, Prompt-Varianten) wandern in einen nested-JSON-Feld auf
|
||||||
|
der Story:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
panelMeta: Record<string /* panelImageId */, {
|
||||||
|
caption?: string; // freitext, encrypted
|
||||||
|
dialogue?: string; // freitext, encrypted
|
||||||
|
promptUsed?: string; // encrypted — reproduce/regenerate
|
||||||
|
sourceInput?: { // ref auf Cross-Modul-Input für dieses Panel
|
||||||
|
module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar';
|
||||||
|
entryId: string;
|
||||||
|
};
|
||||||
|
}>
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist denormalisiert-aber-handhabbar: wer eine Story löscht, löscht
|
||||||
|
automatisch die Meta; wer ein Panel löscht, muss aus `panelImageIds`
|
||||||
|
+ `panelMeta` den Eintrag rausnehmen. Trivialer Store-Helper.
|
||||||
|
|
||||||
|
### 2. Character-Konsistenz via fixe Referenz-Liste pro Story
|
||||||
|
|
||||||
|
Jede Story speichert bei Erstellung einmal `characterMediaIds: string[]`
|
||||||
|
— Face-Ref + optional Body-Ref + optional Kostüm-Fotos aus Wardrobe.
|
||||||
|
Alle Panel-Generierungen übergeben diese Referenz-Liste unverändert an
|
||||||
|
`/api/v1/picture/generate-with-reference`. gpt-image-2 ist nicht
|
||||||
|
deterministisch, aber identische Refs + identischer Stil-Preset-Prefix
|
||||||
|
im Prompt ergeben über 4–8 Panels einen *erkennbaren* Character.
|
||||||
|
|
||||||
|
Kein Feinschliff-Tuning in M1–M5. Wenn sich nach M3 zeigt, dass Panels
|
||||||
|
auseinanderdriften, adressieren wir das mit einer zusätzlichen
|
||||||
|
"Anchor-Panel"-Referenz (erstes erzeugtes Panel wird Referenz für alle
|
||||||
|
folgenden) — das ist M6+.
|
||||||
|
|
||||||
|
### 3. Fünf Stil-Presets, Mapping im Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ComicStyle =
|
||||||
|
| 'comic' // US-Comic, Linework + Cell-Shading, kräftige Farben
|
||||||
|
| 'manga' // S/W, Screen-Tones, dynamische Perspektiven
|
||||||
|
| 'cartoon' // weicher, pastellig, Saturday-Morning-Cartoon
|
||||||
|
| 'graphic-novel' // realistischer, Aquarell/Painterly, stimmungsvoll
|
||||||
|
| 'webtoon'; // vertikal-lesbar, moderne Farbpalette, Soft-Shading
|
||||||
|
```
|
||||||
|
|
||||||
|
Pro Stil ein Prompt-Prefix-Template im Client (`lib/modules/comic/styles.ts`),
|
||||||
|
das in jede Panel-Generierung eingewoben wird. Das Backend kennt die
|
||||||
|
Stile *nicht* — es sieht nur den finalen Prompt. Gleicher Ansatz wie
|
||||||
|
Wardrobe's `accessoryOnly`-Prompt-Detection.
|
||||||
|
|
||||||
|
Stil wird bei Story-Erstellung gewählt und ist danach fix. Stil-Wechsel
|
||||||
|
= neue Story (oder Panels einzeln neu generieren).
|
||||||
|
|
||||||
|
### 4. Sprechblasen & Captions direkt im Bild
|
||||||
|
|
||||||
|
gpt-image-2 kann Text ins Bild rendern — nicht perfekt, aber für
|
||||||
|
Comic-Panels akzeptabel. Vorteil: ein einziger Asset-Export pro Panel,
|
||||||
|
kein zweiter Overlay-Layer, kein extra Canvas-Render-Schritt beim
|
||||||
|
Teilen/Drucken. Nachteil: Text-Korrekturen erfordern Neu-Generierung
|
||||||
|
des Panels (= neuer Credit-Call).
|
||||||
|
|
||||||
|
Im Panel-Editor gibt's zwei Freitext-Felder neben dem Prompt:
|
||||||
|
**"Caption"** (Off-Voice-Erzähltext) und **"Dialog"** (Sprechblasen-
|
||||||
|
Inhalt). Beide werden in den Prompt eingewoben: `…, caption reading
|
||||||
|
"[caption]", character saying "[dialog]" in speech bubble, …`.
|
||||||
|
Deutsch-Text funktioniert; User-Erwartungshaltung aber auf
|
||||||
|
Englisch-Text einstellen (die Modelle sind auf Englisch stabiler) und
|
||||||
|
im UI-Hint vermerken.
|
||||||
|
|
||||||
|
Der Nutzer kann Caption und Dialog leer lassen → stummes Panel.
|
||||||
|
|
||||||
|
### 5. Panel-Generierung in drei Modi (evolvierend über M2–M4)
|
||||||
|
|
||||||
|
- **M2 Single-Panel**: User klickt "+ Panel", schreibt Prompt + optional
|
||||||
|
Caption/Dialog, drückt "Generieren". Kosten: 1 gpt-image-2-Call
|
||||||
|
(Default `quality='medium'`, 10 Credits).
|
||||||
|
- **M3 Batch**: User schreibt 2–4 Panel-Prompts im Voraus, drückt
|
||||||
|
"Alle generieren". Backend bekommt `n=1` pro Panel, aber UI startet
|
||||||
|
die Calls parallel. Kosten: N × Credits.
|
||||||
|
- **M4 AI-Storyboard**: User wählt einen Input (Journal-Eintrag,
|
||||||
|
Notes, Writing-Draft, Library-Review, Calendar-Event), Claude liest
|
||||||
|
den Text und schlägt 4–6 Panel-Beschreibungen vor (Text-Only,
|
||||||
|
kein Bild). User bestätigt/editiert, dann läuft Batch-Gen.
|
||||||
|
Claude-Call läuft client-side über bestehende `@mana/shared-ai`
|
||||||
|
Helper (kein neuer Service-Pfad nötig).
|
||||||
|
|
||||||
|
### 6. Cross-Modul-Input: lesend, nicht schreibend
|
||||||
|
|
||||||
|
Das Comic-Modul *liest* aus den Stores anderer Module (`journal`,
|
||||||
|
`notes`, `library`, `writing`, `calendar`), schreibt aber niemals
|
||||||
|
dorthin zurück. Ein Journal-Eintrag bleibt im Journal, ein
|
||||||
|
Library-Review bleibt in der Library — Comic merkt sich nur per
|
||||||
|
`panelMeta[id].sourceInput` dass dieses Panel aus Input X entstanden
|
||||||
|
ist. Das erlaubt später "zeig mir alle Comics, die aus diesem
|
||||||
|
Journal-Eintrag entstanden sind" als einfache Query.
|
||||||
|
|
||||||
|
Das Decrypt läuft client-side via `<module>Store.getEntry(id)` →
|
||||||
|
`decryptRecords(…)` → übergeben an Claude. Keine Server-Side-Decrypts,
|
||||||
|
keine Key-Grants, kein Mission-Flow nötig — weil der Nutzer selbst
|
||||||
|
interaktiv am UI steht.
|
||||||
|
|
||||||
|
### 7. Space-scoped Katalog, user-scoped Protagonist
|
||||||
|
|
||||||
|
Wie bei Wardrobe: **`comicStories` sind space-scoped** (Brand kann
|
||||||
|
Comics über sein Produkt machen, Club über Vereinsgeschichte, Family
|
||||||
|
über Kinder-Abenteuer, Team über Bühnenproduktion, Practice als
|
||||||
|
Patienten-Aufklärungs-Comic). **Face-Refs bleiben user-global** aus
|
||||||
|
`meImages` — wer in einem Brand-Space einen Comic erstellt, ist selbst
|
||||||
|
der Protagonist.
|
||||||
|
|
||||||
|
Family-Edge-Case: Kinder haben keinen eigenen Account, also auch keine
|
||||||
|
`meImages`. Wer eine Kinder-Geschichte als Comic machen will, nutzt
|
||||||
|
entweder ein eigenes Face-Ref ("Opa erzählt aus dem Krieg, gerendert
|
||||||
|
als Opa") oder das Comic-Modul zeigt den Family-Space-Hinweis (analog
|
||||||
|
zu Wardrobe): "Protagonist-Rendering nutzt deine eigenen
|
||||||
|
Referenzbilder." Kein Multi-Subject-Konzept in M1–M5.
|
||||||
|
|
||||||
|
Alle sechs Space-Typen bekommen `comic` in die Allowlist.
|
||||||
|
|
||||||
|
### 8. Visibility-System von Anfang an
|
||||||
|
|
||||||
|
Comics sind ein Format das Nutzer möglicherweise teilen wollen
|
||||||
|
("mein 4-Panel-Comic zum gestrigen Bug-Report"). Wir adoptieren das
|
||||||
|
Visibility-System (`shared-privacy`) von M1 an — `visibility`,
|
||||||
|
`visibilityChangedAt/By`, `unlistedToken`, `<VisibilityPicker>` im
|
||||||
|
Detail-View. Comics mit `visibility='public'` können später via
|
||||||
|
`/embed/comic/:id` auf Webseiten eingebettet werden (Plan-Punkt von
|
||||||
|
`visibility-system.md` passt 1:1).
|
||||||
|
|
||||||
|
## Architektur-Überblick
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Client (SvelteKit) ────────────────────────────────────┐
|
||||||
|
│ /comic │
|
||||||
|
│ ListView: alle Stories (Cards mit erstem Panel) │
|
||||||
|
│ /comic/[id] │
|
||||||
|
│ Detail: Story-Meta + Panel-Strip (horizontal) │
|
||||||
|
│ "+ Panel" CTA, pro Panel Caption/Dialog-Editor │
|
||||||
|
│ /comic/new │
|
||||||
|
│ CreateForm: Titel, Stil, Character-Picker, Kontext │
|
||||||
|
│ Dexie: comicStories │
|
||||||
|
└──────┬──────────────────────────────────────────────────┘
|
||||||
|
│ mana-sync (encrypted title/description/panelMeta)
|
||||||
|
▼
|
||||||
|
┌─ Panel-Generierung (reuses M3 /picture endpoint) ───────┐
|
||||||
|
│ POST /api/v1/picture/generate-with-reference │
|
||||||
|
│ referenceMediaIds = story.characterMediaIds │
|
||||||
|
│ prompt = stylePrefix + panelPrompt + captionHint │
|
||||||
|
│ Result → picture.images row │
|
||||||
|
│ Client writes: image.comicStoryId = story.id │
|
||||||
|
│ image.comicPanelIndex = N │
|
||||||
|
│ story.panelImageIds.push(imageId) │
|
||||||
|
│ story.panelMeta[imageId] = {...} │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ AI-Storyboard (M4, client-side Claude) ────────────────┐
|
||||||
|
│ User selects input (journal entry / note / …) │
|
||||||
|
│ decryptedText = moduleStore.getEntry(id).content │
|
||||||
|
│ Claude.suggest({ style, text }) → Panel[] │
|
||||||
|
│ User reviews/edits panels │
|
||||||
|
│ Batch-Gen via /picture endpoint │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ MCP / Agent tools ─────────────────────────────────────┐
|
||||||
|
│ comic.listStories (read) │
|
||||||
|
│ comic.createStory (write) │
|
||||||
|
│ comic.generatePanel (write — consumes credits) │
|
||||||
|
│ comic.reorderPanels (write) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
### `LocalComicStory`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ComicStyle =
|
||||||
|
| 'comic'
|
||||||
|
| 'manga'
|
||||||
|
| 'cartoon'
|
||||||
|
| 'graphic-novel'
|
||||||
|
| 'webtoon';
|
||||||
|
|
||||||
|
export interface ComicPanelMeta {
|
||||||
|
caption?: string; // encrypted
|
||||||
|
dialogue?: string; // encrypted
|
||||||
|
promptUsed?: string; // encrypted
|
||||||
|
sourceInput?: { // plaintext refs
|
||||||
|
module: 'journal' | 'notes' | 'library' | 'writing' | 'calendar';
|
||||||
|
entryId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalComicStory extends BaseRecord {
|
||||||
|
id: string;
|
||||||
|
title: string; // encrypted
|
||||||
|
description?: string | null; // encrypted
|
||||||
|
style: ComicStyle; // plaintext enum
|
||||||
|
/**
|
||||||
|
* Referenz-Liste die für jedes Panel-Generate identisch übergeben wird.
|
||||||
|
* Mindestens der primary face-ref aus meImages; optional body-ref +
|
||||||
|
* bis zu 3 Wardrobe-Garment-Fotos für ein Kostüm-Setup. Cap 8 wie bei
|
||||||
|
* Wardrobe (MAX_REFERENCE_IMAGES im /generate-with-reference endpoint).
|
||||||
|
*/
|
||||||
|
characterMediaIds: string[]; // plaintext FKs
|
||||||
|
/**
|
||||||
|
* Kontext den Claude in M4 als Briefing für die Storyboard-Generierung
|
||||||
|
* sieht. Freitext, typisch 1–3 Sätze ("Ich ärgere mich über einen Bug
|
||||||
|
* in unserer Sync-Logik — mach daraus einen 4-Panel-Frust-Comic.").
|
||||||
|
*/
|
||||||
|
storyContext?: string | null; // encrypted
|
||||||
|
/**
|
||||||
|
* Geordnete Liste der Panel-picture.images-IDs. Reihenfolge = Lese-
|
||||||
|
* reihenfolge. Reorder = neu schreiben.
|
||||||
|
*/
|
||||||
|
panelImageIds: string[]; // plaintext FKs
|
||||||
|
panelMeta: Record<string, ComicPanelMeta>; // keyed by panel image id
|
||||||
|
tags: string[]; // encrypted
|
||||||
|
isFavorite?: boolean;
|
||||||
|
isArchived?: boolean;
|
||||||
|
visibility?: VisibilityLevel;
|
||||||
|
visibilityChangedAt?: string;
|
||||||
|
visibilityChangedBy?: string;
|
||||||
|
unlistedToken?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Encryption-Registry-Eintrag:** `['title', 'description', 'storyContext',
|
||||||
|
'tags', 'panelMeta']` — `panelMeta` komplett encrypted (JSON-Blob,
|
||||||
|
der Freitext-Felder enthält). Style-Enum, IDs, Booleans, visibility
|
||||||
|
bleiben plaintext.
|
||||||
|
|
||||||
|
### Erweiterung auf `picture.images`
|
||||||
|
|
||||||
|
Zwei neue optionale Plaintext-Felder:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/mana/apps/web/src/lib/modules/picture/types.ts
|
||||||
|
interface LocalImage {
|
||||||
|
// ... bestehend
|
||||||
|
wardrobeOutfitId?: string | null;
|
||||||
|
wardrobeGarmentId?: string | null;
|
||||||
|
comicStoryId?: string | null; // NEU
|
||||||
|
comicPanelIndex?: number | null; // NEU — 0-basiert, Lese-Position
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Das `comicPanelIndex`-Feld ist redundant mit `story.panelImageIds`, aber
|
||||||
|
erlaubt der Picture-Galerie-Ansicht, direkt "Panel 3 von Story X"
|
||||||
|
anzuzeigen ohne die Story zu laden. Plaintext-Zahl, kein
|
||||||
|
Registry-Change.
|
||||||
|
|
||||||
|
### `verifyMediaOwnership` erweitert
|
||||||
|
|
||||||
|
`apps/api/src/modules/picture/routes.ts:299-318` — die erlaubten Apps
|
||||||
|
um `'comic'` erweitern, damit Wardrobe-Garments als Kostüm-Referenz in
|
||||||
|
Comic-Panel-Generierungen verwendet werden können:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
verifyMediaOwnership(userId, refIds, ['me', 'wardrobe', 'comic'])
|
||||||
|
```
|
||||||
|
|
||||||
|
(`'comic'` für zukünftige comic-eigene Referenz-Uploads wie
|
||||||
|
Panel-Anker-Bilder in M6+; aktuell leer, aber der Slot ist reserviert.)
|
||||||
|
|
||||||
|
## Modul-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/mana/apps/web/src/lib/modules/comic/
|
||||||
|
├── types.ts # ComicStyle, LocalComicStory, ComicPanelMeta
|
||||||
|
├── collections.ts # comicStoriesTable
|
||||||
|
├── queries.ts # useAllStories, useStoryById, useStoriesByInput
|
||||||
|
├── module.config.ts # { appId: 'comic', tables: ['comicStories'] }
|
||||||
|
├── styles.ts # STYLE_PREFIXES: Record<ComicStyle, string>
|
||||||
|
├── stores/
|
||||||
|
│ └── stories.svelte.ts # createStory, updateStory, appendPanel,
|
||||||
|
│ # reorderPanels, removePanel, updatePanelMeta,
|
||||||
|
│ # archive, delete
|
||||||
|
├── api/
|
||||||
|
│ ├── generate-panel.ts # runPanelGenerate({story, prompt, caption, dialogue})
|
||||||
|
│ │ # — wraps /picture/generate-with-reference
|
||||||
|
│ └── storyboard.ts # (M4) suggestPanels({style, sourceText, panelCount})
|
||||||
|
│ # — client-side Claude-Call via @mana/shared-ai
|
||||||
|
├── components/
|
||||||
|
│ ├── StoryCard.svelte # Grid tile (Cover = panelImageIds[0])
|
||||||
|
│ ├── StoryForm.svelte # Create/edit Sheet (title, style, character, context)
|
||||||
|
│ ├── StylePicker.svelte # 5 Presets als radio-tiles
|
||||||
|
│ ├── CharacterPicker.svelte # meImages face-ref auto-select + optional garments
|
||||||
|
│ ├── PanelStrip.svelte # horizontal scroll, panel thumbnails
|
||||||
|
│ ├── PanelCard.svelte # einzelnes Panel mit Caption/Dialog-Anzeige
|
||||||
|
│ ├── PanelEditor.svelte # Prompt + Caption + Dialog + "Generieren"-Button
|
||||||
|
│ ├── StoryboardSuggester.svelte # (M4) Input-Picker + Claude-Suggestion-Liste
|
||||||
|
│ └── ReferenceInputPicker.svelte # (M4) wählt Journal/Notes/Library/Writing/Calendar
|
||||||
|
├── views/
|
||||||
|
│ ├── ListView.svelte # Grid aller Stories
|
||||||
|
│ └── DetailView.svelte # Story-Meta + PanelStrip + "+ Panel" CTA
|
||||||
|
├── constants.ts # STYLE_LABELS, MAX_PANELS_PER_STORY (default 12)
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Route-Seiten:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/mana/apps/web/src/routes/(app)/comic/
|
||||||
|
├── +page.svelte # → ListView
|
||||||
|
├── [id]/+page.svelte # → DetailView
|
||||||
|
└── new/+page.svelte # → StoryForm (create)
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein Composer-Route wie bei Wardrobe — Comic-Erstellung ist kurz
|
||||||
|
(Titel + Stil + Character = 3 Felder), Panel-Editing läuft im
|
||||||
|
Detail-View als inline-Sheet.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
**Neuer App-Slot `'comic'`** für zukünftige Uploads (Panel-Anker,
|
||||||
|
Custom-Backgrounds in M6+). In M1 genügt die Registrierung des Slots
|
||||||
|
in `verifyMediaOwnership` + der App-Allowlist; eigener Upload-Endpoint
|
||||||
|
ist M1 nicht nötig, weil Panel-Bilder als `picture.images` über den
|
||||||
|
bestehenden Generate-Flow entstehen.
|
||||||
|
|
||||||
|
**Keine eigene Generate-Route:** `runPanelGenerate()` ruft direkt
|
||||||
|
`/api/v1/picture/generate-with-reference`, analog zu Wardrobe. Nach
|
||||||
|
Erfolg schreibt der Client die `comicStoryId` + `comicPanelIndex`-
|
||||||
|
Back-Refs auf die `picture.images`-Row *und* appendet die imageId auf
|
||||||
|
`story.panelImageIds` + setzt `story.panelMeta[imageId]`.
|
||||||
|
|
||||||
|
**Cap-Prüfung:** `MAX_REFERENCE_IMAGES=8` (bereits in Wardrobe M1
|
||||||
|
gesetzt) deckt Comic ab — Face (1) + Body (1) + bis zu 3 Kostüm-Fotos
|
||||||
|
= 5, mit Puffer für M6+ Anchor-Panel.
|
||||||
|
|
||||||
|
**mana-apps.ts Eintrag:** `packages/shared-branding/src/mana-apps.ts`
|
||||||
|
bekommt einen neuen Eintrag:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'comic',
|
||||||
|
name: 'Comic',
|
||||||
|
description: 'Aus Text wird ein Comic',
|
||||||
|
icon: 'BookImage' /* oder similar */,
|
||||||
|
color: '#…' /* TBD, siehe design-ux.md für Palette */,
|
||||||
|
requiredTier: 'beta',
|
||||||
|
route: '/comic',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP-Tools (`packages/mana-tool-registry/src/modules/comic.ts`)
|
||||||
|
|
||||||
|
Vier Tools, Pattern 1:1 an `wardrobe.ts` angelehnt:
|
||||||
|
|
||||||
|
- **`comic.listStories({style?, favoriteOnly?, limit?})`** — read, auto.
|
||||||
|
Pullt via mana-sync `app='comic'`, entschlüsselt `title`+`description`+
|
||||||
|
`tags`+`panelMeta`. Filter client-side.
|
||||||
|
- **`comic.createStory({title, style, characterMediaIds, description?, storyContext?})`** —
|
||||||
|
write, propose. Validiert dass alle `characterMediaIds` dem User
|
||||||
|
gehören (`app='me'|'wardrobe'`). Schreibt via `pushInsert`.
|
||||||
|
- **`comic.generatePanel({storyId, panelPrompt, caption?, dialogue?, sourceInput?})`** —
|
||||||
|
write (kostet Credits), propose. Liest die Story, composed den finalen
|
||||||
|
Prompt (stylePrefix + panelPrompt + caption/dialog-Hints), ruft
|
||||||
|
`/picture/generate-with-reference`, appendet das Ergebnis auf
|
||||||
|
`panelImageIds` + `panelMeta`.
|
||||||
|
- **`comic.reorderPanels({storyId, panelImageIds})`** — write, propose.
|
||||||
|
Validiert Set-Equality (keine neuen/fehlenden IDs), schreibt die neue
|
||||||
|
Reihenfolge.
|
||||||
|
|
||||||
|
`AI_TOOL_CATALOG` in `@mana/shared-ai/src/tools/schemas.ts` bekommt die
|
||||||
|
vier Tools, `comic` kommt in die `ModuleId`-Union.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
- **M1 — Datenschicht & Modul-Registrierung**
|
||||||
|
- [ ] Dexie v43: `comicStories` mit Indices `[createdAt, style, isFavorite, isArchived]` (space-scoped, kein Compound-Index)
|
||||||
|
- [ ] `types.ts`: `ComicStyle`, `LocalComicStory`, `ComicPanelMeta`, `toStory`-Converter
|
||||||
|
- [ ] Encryption-Registry-Eintrag für `comicStories` (`title/description/storyContext/tags/panelMeta`)
|
||||||
|
- [ ] `collections.ts`, `queries.ts` (useAllStories, useStoryById) via `scopedForModule<>`
|
||||||
|
- [ ] `stores/stories.svelte.ts` mit createStory + archive + delete (Panel-Methoden kommen in M2)
|
||||||
|
- [ ] `module.config.ts` registriert `appId='comic'`
|
||||||
|
- [ ] `comic` in alle sechs Space-Typen der Allowlist (`packages/shared-types/src/spaces.ts`)
|
||||||
|
- [ ] `mana-apps.ts` Eintrag mit `requiredTier: 'beta'`
|
||||||
|
- [ ] `picture.images.comicStoryId` + `comicPanelIndex` Felder + `toImage`-Converter
|
||||||
|
- [ ] `verifyMediaOwnership` um `'comic'` erweitern
|
||||||
|
- [ ] Encryption-Roundtrip-Test für `panelMeta`-JSON (wie library M1 für kind-discriminator)
|
||||||
|
|
||||||
|
- **M2 — Story-CRUD + Single-Panel-Generierung**
|
||||||
|
- [ ] Route `/comic` → `ListView`, Story-Grid mit `StoryCard` (Cover = `panelImageIds[0]` → mana-media URL, Fallback Placeholder für Stories ohne Panels)
|
||||||
|
- [ ] Route `/comic/new` → `StoryForm` (Title, `StylePicker` mit 5 Presets, `CharacterPicker` bindet an `useImageByPrimary('face-ref')` + optional body-ref-Add + Wardrobe-Garment-Picker für bis zu 3 Kostüme, optional `storyContext`-Textarea)
|
||||||
|
- [ ] Route `/comic/[id]` → `DetailView`: Meta-Card + `PanelStrip` (horizontal scroll) + "+ Panel" CTA
|
||||||
|
- [ ] `PanelEditor` inline-Sheet: Prompt-Textarea, Caption-Freitext, Dialog-Freitext, "Generieren"-Button
|
||||||
|
- [ ] `api/generate-panel.ts`: `runPanelGenerate({story, prompt, caption, dialogue})` composed den Prompt (`styles.ts` liefert stylePrefix) und ruft `/picture/generate-with-reference`
|
||||||
|
- [ ] Nach Erfolg: `picture.images.comicStoryId` + `comicPanelIndex` setzen + `story.panelImageIds.push()` + `panelMeta[imageId] = {…}`
|
||||||
|
- [ ] Panel-Lösch-Button (Dexie-Row der `picture.images` bleibt — nur aus `panelImageIds` und `panelMeta` entfernen; User kann im Picture-Modul final löschen)
|
||||||
|
- [ ] Non-personal-Space-Hinweis + Empty-State bei fehlenden meImages (Link zu `/profile/me-images`)
|
||||||
|
- [ ] Visibility-Felder setzbar via `<VisibilityPicker>` in DetailView
|
||||||
|
|
||||||
|
- **M3 — Batch-Panel-Generierung**
|
||||||
|
- [ ] `PanelEditor` unterstützt Multi-Panel-Modus: 2–4 Prompts im Formular, "Alle generieren"-Button
|
||||||
|
- [ ] Client startet N parallele `/picture/generate-with-reference`-Calls, zeigt Progress-Bar pro Panel
|
||||||
|
- [ ] Credit-Hinweis zeigt Gesamtkosten vorher (`n × creditsForQuality(medium)`)
|
||||||
|
- [ ] Retry-UI falls 1 von N fehlschlägt (nur der fehlgeschlagene wird erneut generiert)
|
||||||
|
- [ ] `comic.generatePanel` MCP-Tool bekommt optional `count?: 1..4`-Parameter (default 1)
|
||||||
|
|
||||||
|
- **M4 — AI-Storyboard aus Cross-Modul-Input**
|
||||||
|
- [ ] `ReferenceInputPicker`-Komponente: Modul-Tabs (Journal / Notes / Library / Writing / Calendar), pro Tab Live-Query der letzten N Einträge mit Suche
|
||||||
|
- [ ] Per ausgewähltem Entry: `<module>Store.getEntry(id)` → decrypt content → in Storyboard-Flow reichen
|
||||||
|
- [ ] `api/storyboard.ts`: `suggestPanels({style, sourceText, panelCount=4})` ruft Claude (via `@mana/shared-ai`, client-side, genau wie AI-Workbench-Planer — kein neuer Service-Pfad), erwartet `Panel[]` als strukturierte Antwort `{prompt, caption, dialogue}`
|
||||||
|
- [ ] `StoryboardSuggester`-Komponente zeigt Claude-Vorschläge als editierbare Liste (Prompt + Caption + Dialog pro Panel), User kann editieren/löschen/Reihenfolge ändern
|
||||||
|
- [ ] "Alle generieren"-Button übergibt die bestätigte Panel-Liste an den M3-Batch-Pfad
|
||||||
|
- [ ] `panelMeta[imageId].sourceInput = {module, entryId}` beim Erzeugen gesetzt
|
||||||
|
- [ ] `useStoriesByInput({module, entryId})` Query für künftige Cross-Reference-UI ("Comics zu diesem Journal-Eintrag")
|
||||||
|
|
||||||
|
- **M5 — MCP-Tools + Visibility-Polish**
|
||||||
|
- [ ] `packages/mana-tool-registry/src/modules/comic.ts` mit 4 Tools: listStories, createStory, generatePanel, reorderPanels
|
||||||
|
- [ ] `'comic'` in `ModuleId`-Union
|
||||||
|
- [ ] `registerComicTools()` in `registerAllModules()`
|
||||||
|
- [ ] `AI_TOOL_CATALOG` in `@mana/shared-ai/src/tools/schemas.ts` erweitert
|
||||||
|
- [ ] Propose-Policy für `createStory`/`generatePanel`/`reorderPanels`, auto-Policy für `listStories`
|
||||||
|
- [ ] `<VisibilityPicker>` voll integriert inkl. `unlistedToken`-Generierung, `canEmbedOnWebsite` check für public Comics
|
||||||
|
- [ ] Embed-Route `/embed/comic/[id]` (public + unlisted) mit Panel-Strip-Render (wie andere Visibility-adoptierte Module)
|
||||||
|
|
||||||
|
- **M6 — Persona-Template "Comic-Autor"** (optional, ~0.5 Tag)
|
||||||
|
- [ ] Persona-Template: auto-Policy für `comic.listStories` + `journal.list*` + `notes.list*`, propose-Policy für `comic.createStory` + `comic.generatePanel`
|
||||||
|
- [ ] Seed-Prompt: "Du bist Comic-Autor. Wenn der User dir einen Moment, ein Erlebnis oder eine Idee gibt, schlag ihm einen kurzen Comic vor — Titel, Stil, 4 Panels mit Prompt + Caption + Dialog. Humor wenn der User es leicht nimmt, ernst wenn er es ernst nimmt."
|
||||||
|
|
||||||
|
- **M7 — Comic-Strip-Canvas** (optional, mehrere Tage)
|
||||||
|
- [ ] Picture-Boards-Pattern adaptieren für Comic: freie Panel-Positionierung, variable Panel-Größen, Gutter, Speech-Bubble-Overlay (dann doch SVG, opt-in pro Story)
|
||||||
|
- [ ] Export als einzelnes PNG/PDF-Asset (Panel-Strip → Canvas → Blob)
|
||||||
|
- [ ] Rechtfertigt sich nur, wenn Nutzer Feedback-Signal senden dass die lineare Liste nicht reicht
|
||||||
|
|
||||||
|
- **M8 — Multi-Character-Crew** (optional, mehrere Tage)
|
||||||
|
- [ ] Story bekommt `characterCast: CharacterRef[]` statt flaches `characterMediaIds[]`
|
||||||
|
- [ ] Pro Panel kann der Autor einen oder mehrere Cast-Member auswählen; `referenceMediaIds` wird pro Panel zusammengesetzt
|
||||||
|
- [ ] Namens-Mapping (Cast-Member bekommt Namen → Dialog kann "Alice sagt:" taggen)
|
||||||
|
- [ ] Nur starten wenn Single-Character-Flow nach M5-Soak stabil
|
||||||
|
|
||||||
|
## Verschlüsselung
|
||||||
|
|
||||||
|
Alle user-typed Felder verschlüsselt (siehe Registry-Einträge oben).
|
||||||
|
`panelMeta` als ganzer JSON-Blob verschlüsselt (nicht per-Feld) — einfacher
|
||||||
|
Roundtrip, gleiche Semantik wie bei Library's kind-spezifischen
|
||||||
|
Metadaten.
|
||||||
|
|
||||||
|
Bild-Blobs selbst bleiben in mana-media mit Owner-RLS, identisch zu
|
||||||
|
Picture/Wardrobe/Me-Images. Zero-Knowledge-Nutzer: MCP-Tools fallen
|
||||||
|
stumm aus (kein MK → `ctx.getMasterKey()` throwt), UI-Flow bleibt
|
||||||
|
funktional weil die Decrypts client-side passieren.
|
||||||
|
|
||||||
|
## Cross-Modul-Impact
|
||||||
|
|
||||||
|
| Modul | Impact |
|
||||||
|
|---|---|
|
||||||
|
| `picture` | Zwei neue optionale Felder auf `LocalImage`: `comicStoryId`, `comicPanelIndex`. Keine Registry-Änderung (beide plaintext). Galerie-View könnte optional ein "Teil von Comic X"-Chip zeigen (M5+ optional). |
|
||||||
|
| `me-images` | Nichts — Comic konsumiert nur `useImageByPrimary`. |
|
||||||
|
| `wardrobe` | Nichts — Comic liest Garments als referenzielle `mediaIds`, schreibt nicht zurück. |
|
||||||
|
| `journal`, `notes`, `library`, `writing`, `calendar` | Nichts — nur lesende Cross-Module-Reads über die Module-Stores. |
|
||||||
|
| `shared-branding` | Neuer App-Eintrag `comic` (Icon, Farbe, Tier=beta). |
|
||||||
|
| `shared-types/spaces.ts` | `comic` in alle sechs Space-Typen der Allowlist (`personal`, `brand`, `club`, `family`, `team`, `practice`). |
|
||||||
|
| `shared-ai/tools/schemas.ts` | 4 neue Einträge im `AI_TOOL_CATALOG`. |
|
||||||
|
| `mana-tool-registry` | Neues Modul `comic.ts` + `registerComicTools()`. |
|
||||||
|
| `apps/api/picture/routes.ts` | `verifyMediaOwnership` um `'comic'` erweitern. |
|
||||||
|
|
||||||
|
## Offene Fragen (vor M1 klären)
|
||||||
|
|
||||||
|
1. **Panel-Count-Limit pro Story**: 8? 12? 20? → Empfehlung: hartes
|
||||||
|
Client-Limit 12 in `constants.ts`, weicher Hinweis ab 8 ("lange Comics
|
||||||
|
sind mit gpt-image-2 schwer konsistent zu halten"). Erhöhen nach
|
||||||
|
M5-Soak möglich.
|
||||||
|
2. **Quality-Default für Panels**: `medium` (10 Credits)? → Ja, wie
|
||||||
|
Wardrobe. User kann pro Panel overriden (low/medium/high); Batch-Modus
|
||||||
|
nutzt eine Story-weite Default-Setting.
|
||||||
|
3. **Stil-Wechsel nachträglich**: erlaubt? → Nein, Stil ist fix nach
|
||||||
|
Story-Create. Wer wechseln will, dupliziert die Story (M6+ Feature)
|
||||||
|
oder erstellt neu.
|
||||||
|
4. **Dialog/Caption Sprache**: User-Sprache oder Englisch? → Default
|
||||||
|
User-Sprache (Deutsch in unserem primären Markt). UI-Hinweis dass
|
||||||
|
Englisch stabiler rendert. Kein Auto-Translate in M1–M5.
|
||||||
|
5. **AI-Storyboard-Panel-Count**: Claude schlägt 4–6 Panels vor, der
|
||||||
|
User kann mehr/weniger anfordern? → Default 4, Slider 2–8 im UI, Hard-Cap 8.
|
||||||
|
6. **Panel-Lösch-Semantik**: beim Entfernen aus `panelImageIds` auch die
|
||||||
|
`picture.images`-Row löschen? → Nein. Row bleibt, nur die
|
||||||
|
Story-Referenz geht weg. User kann das Panel in der Picture-Galerie
|
||||||
|
behalten oder dort final löschen. Symmetrisch zu Wardrobe (Try-On-
|
||||||
|
Bilder überleben eine Outfit-Löschung).
|
||||||
|
|
||||||
|
## Verweise
|
||||||
|
|
||||||
|
- Fundament Picture-Generate-Reference: `apps/api/src/modules/picture/routes.ts:250-430`
|
||||||
|
- Wardrobe als Modul-Blaupause: `docs/plans/wardrobe-module.md`
|
||||||
|
- Library als Single-Table-Modul mit Discriminator-Pattern: `docs/plans/library-module.md`
|
||||||
|
- Writing-Plan für Cross-Modul-Input-Pattern: `docs/plans/writing-module.md`
|
||||||
|
- Visibility-System: `docs/plans/visibility-system.md`, `packages/shared-privacy/`
|
||||||
|
- Spaces-Modul-Allowlist: `packages/shared-types/src/spaces.ts`
|
||||||
|
- Tool-Registry-Pattern: `packages/mana-tool-registry/src/modules/wardrobe.ts`
|
||||||
|
- Me-Images (Face/Body-Ref-Konzept): `docs/plans/me-images-and-reference-generation.md`
|
||||||
|
|
@ -75,6 +75,11 @@ const calcSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="n
|
||||||
// Context icon (document/knowledge with sky blue gradient)
|
// Context icon (document/knowledge with sky blue gradient)
|
||||||
const contextSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contextGrad)"/><rect x="300" y="240" width="424" height="544" rx="24" fill="white"/><path d="M400 400H624" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round"/><path d="M400 480H580" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.6"/><path d="M400 560H540" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.4"/><path d="M400 640H600" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.3"/><path d="M620 240V380H760" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><path d="M620 240L760 380" stroke="#0ea5e9" stroke-width="16" stroke-linecap="round" stroke-opacity="0.3"/><circle cx="680" cy="620" r="100" fill="#0ea5e9" fill-opacity="0.2" stroke="white" stroke-width="16"/><path d="M660 620L680 640L720 600" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="contextGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#0ea5e9"/><stop offset="1" stop-color="#0284c7"/></linearGradient></defs></svg>`;
|
const contextSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contextGrad)"/><rect x="300" y="240" width="424" height="544" rx="24" fill="white"/><path d="M400 400H624" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round"/><path d="M400 480H580" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.6"/><path d="M400 560H540" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.4"/><path d="M400 640H600" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.3"/><path d="M620 240V380H760" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><path d="M620 240L760 380" stroke="#0ea5e9" stroke-width="16" stroke-linecap="round" stroke-opacity="0.3"/><circle cx="680" cy="620" r="100" fill="#0ea5e9" fill-opacity="0.2" stroke="white" stroke-width="16"/><path d="M660 620L680 640L720 600" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="contextGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#0ea5e9"/><stop offset="1" stop-color="#0284c7"/></linearGradient></defs></svg>`;
|
||||||
|
|
||||||
|
// Comic icon — speech bubble with a lightning-bolt panel marker on
|
||||||
|
// orange→red gradient. Sits warm between Picture (green) and Wardrobe
|
||||||
|
// (rose) so the Mana launcher reads as a coherent creative family.
|
||||||
|
const comicSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#comicGrad)"/><path d="M260 340c0-33 27-60 60-60h384c33 0 60 27 60 60v288c0 33-27 60-60 60H480l-108 90v-90h-52c-33 0-60-27-60-60V340z" fill="white"/><path d="M540 370l-90 156h72l-30 128 108-172h-78l28-112h-10z" fill="#ea580c"/><circle cx="360" cy="460" r="18" fill="#ea580c" fill-opacity="0.35"/><circle cx="410" cy="460" r="18" fill="#ea580c" fill-opacity="0.35"/><circle cx="460" cy="460" r="18" fill="#ea580c" fill-opacity="0.35"/><defs><linearGradient id="comicGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f97316"/><stop offset="1" stop-color="#dc2626"/></linearGradient></defs></svg>`;
|
||||||
|
|
||||||
// Wardrobe icon — T-shirt on hanger with rose-violet gradient.
|
// Wardrobe icon — T-shirt on hanger with rose-violet gradient.
|
||||||
// Rose/violet to sit between Picture (green) and Calc (pink) without
|
// Rose/violet to sit between Picture (green) and Calc (pink) without
|
||||||
// clashing; the hanger loop sits on the shoulder line so the silhouette
|
// clashing; the hanger loop sits on the shoulder line so the silhouette
|
||||||
|
|
@ -109,6 +114,7 @@ export const APP_ICONS = {
|
||||||
mail: svgToDataUrl(mailSvg),
|
mail: svgToDataUrl(mailSvg),
|
||||||
inventory: svgToDataUrl(inventorySvg),
|
inventory: svgToDataUrl(inventorySvg),
|
||||||
wardrobe: svgToDataUrl(wardrobeSvg),
|
wardrobe: svgToDataUrl(wardrobeSvg),
|
||||||
|
comic: svgToDataUrl(comicSvg),
|
||||||
questions: svgToDataUrl(questionsSvg),
|
questions: svgToDataUrl(questionsSvg),
|
||||||
context: svgToDataUrl(contextSvg),
|
context: svgToDataUrl(contextSvg),
|
||||||
citycorners: svgToDataUrl(citycornersSvg),
|
citycorners: svgToDataUrl(citycornersSvg),
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,23 @@ export const MANA_APPS: ManaApp[] = [
|
||||||
status: 'beta',
|
status: 'beta',
|
||||||
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release
|
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'comic',
|
||||||
|
name: 'Comic',
|
||||||
|
description: {
|
||||||
|
de: 'Aus Text wird ein Comic',
|
||||||
|
en: 'Turn text into comics',
|
||||||
|
},
|
||||||
|
longDescription: {
|
||||||
|
de: 'Erstelle mehrseitige Comics mit KI. Starte mit einem Tagebuch-Eintrag, einer Notiz oder einem Kalender-Event und generiere Panels in fünf Stilen — Comic, Manga, Cartoon, Graphic Novel oder Webtoon. Du selbst bist der Protagonist.',
|
||||||
|
en: 'Create multi-panel comics with AI. Start from a journal entry, note, or calendar event and generate panels in five styles — comic, manga, cartoon, graphic novel, or webtoon. You are the protagonist.',
|
||||||
|
},
|
||||||
|
icon: APP_ICONS.comic,
|
||||||
|
color: '#f97316',
|
||||||
|
comingSoon: false,
|
||||||
|
status: 'beta',
|
||||||
|
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'questions',
|
id: 'questions',
|
||||||
name: 'Questions',
|
name: 'Questions',
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
||||||
'activity',
|
'activity',
|
||||||
'goals',
|
'goals',
|
||||||
'wardrobe', // Merch-Katalog (T-Shirts, Caps, Zip-Hoodies)
|
'wardrobe', // Merch-Katalog (T-Shirts, Caps, Zip-Hoodies)
|
||||||
|
'comic', // Marken-Comics / Launch-Stories / Produkt-Storys
|
||||||
],
|
],
|
||||||
|
|
||||||
club: [
|
club: [
|
||||||
|
|
@ -117,6 +118,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
||||||
'activity',
|
'activity',
|
||||||
'goals',
|
'goals',
|
||||||
'wardrobe', // Vereinstrikots, Club-Bekleidung
|
'wardrobe', // Vereinstrikots, Club-Bekleidung
|
||||||
|
'comic', // Vereinsgeschichte, Event-Rückblicke als Comic
|
||||||
],
|
],
|
||||||
|
|
||||||
family: [
|
family: [
|
||||||
|
|
@ -143,6 +145,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
||||||
'firsts',
|
'firsts',
|
||||||
'wardrobe', // Familien-Kleiderschrank (Kinder inkl.); Try-On
|
'wardrobe', // Familien-Kleiderschrank (Kinder inkl.); Try-On
|
||||||
// rendert auf dem aufrufenden Elternteil, nicht auf Kindern
|
// rendert auf dem aufrufenden Elternteil, nicht auf Kindern
|
||||||
|
'comic', // Familien-Erinnerungen / Kinder-Abenteuer als Comic
|
||||||
],
|
],
|
||||||
|
|
||||||
team: [
|
team: [
|
||||||
|
|
@ -169,6 +172,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
||||||
'activity',
|
'activity',
|
||||||
'goals',
|
'goals',
|
||||||
'wardrobe', // Bühnen-Kostüme, Uniformen, Produktions-Wardrobe
|
'wardrobe', // Bühnen-Kostüme, Uniformen, Produktions-Wardrobe
|
||||||
|
'comic', // Team-Anekdoten, Retro-Storytelling, Release-Comics
|
||||||
],
|
],
|
||||||
|
|
||||||
practice: [
|
practice: [
|
||||||
|
|
@ -191,6 +195,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
||||||
'activity',
|
'activity',
|
||||||
'goals',
|
'goals',
|
||||||
'wardrobe', // Praxis-Kittel, Dresscode-Items
|
'wardrobe', // Praxis-Kittel, Dresscode-Items
|
||||||
|
'comic', // Patienten-Aufklärungs-Comics, Praxis-Storys
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue