mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 00:21:26 +02:00
chore(mana): comic aus unified-App entfernen
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
Comic-Surface ist nach Comicello (comicello.mana.how + comicello.com)
umgezogen. Comicello hat seit Phase ω-2 (2026-05-18) Feature-Parität
+ Cross-Module-Hooks via mana-share:
- Character-Variant-Render mit pinned-Variant-Anker
- Panel-Render via mana-image-edits
- Panel-Edit/Delete/Reorder
- POST /api/v1/share/receive für externe Apps (Journal/Notes/Calendar/
Library/Writing können Text-Snippet rüberschicken, Story wird als
Draft angelegt)
- /comics/new?text=…&sourceModule=… URL-Prefill als Alternative zum
Server-Roundtrip
Gelöscht / abgebaut:
- Module: apps/mana/.../modules/comic + Routen + Locales
- Backend: apps/api/src/modules/comic, picture/routes
verifyMediaOwnership-Allowlist auf nur `['me']` reduziert (comic-
Tag war hier Identity-Anchor-Quelle für panel renders, jetzt
Comicello-intern)
- shared-branding: APP_ICONS.comic + MANA_APPS comic-Entry
- shared-ai/tools/schemas: ganzer Comic-Block (list_comic_stories,
create_comic_story, generate_comic_panel, list_comic_characters,
create_comic_character, generate_character_variant,
pin_character_variant)
- shared-ai/agents/templates: comic-author.ts + index.ts Eintrag
- mana-tool-registry: modules/comic.ts + types ModuleId 'comic' raus
- Cross-Module: website/embeds resolveComicStories +
LocalComicStory-Import + 'comic.stories' EmbedSource + Inspector-
Option; crypto-registry comicStories+comicCharacters; exposed-records
comic-Eintrag
- picture/types: comicStoryId, comicPanelIndex, comicCharacterId
Back-Ref-Felder (sowohl LocalImage als auch Image-Public-DTO);
picture/queries to-Public-Mapping
- Registries: app-registry/apps.ts (Comic registerApp + FilmStrip-
Icon + Header), categories, help-content, module-registry,
data/tools/init
- i18n: comic in apps/{de,en,es,fr,it}.json
Was BLEIBT:
- cloudflared `comicello.mana.how` + `comicello-api.mana.how` —
Standalone-Routes
- docker-compose mana-auth CORS_ORIGINS comicello.com + .mana.how —
SSO für Standalone
Dexie v66:
- droppt comicStories + comicCharacters
- Upgrade-Callback strippt comicStoryId/comicPanelIndex/comicCharacterId
aus existierenden Image-Rows (waren nie indiziert, nur Properties)
mana-web svelte-check 0/0 (7281 files), snapshot test 10/10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6c13308cf4
commit
2b08e2f3a2
73 changed files with 36 additions and 8519 deletions
|
|
@ -39,7 +39,6 @@ import { articlesRoutes } from './modules/articles/routes';
|
|||
import { startArticleImportWorker } from './modules/articles/import-worker';
|
||||
import { tracesRoutes } from './modules/traces/routes';
|
||||
import { writingRoutes } from './modules/writing/routes';
|
||||
import { comicRoutes } from './modules/comic/routes';
|
||||
import { presiRoutes } from './modules/presi/routes';
|
||||
import { researchRoutes } from './modules/research/routes';
|
||||
import { websiteRoutes } from './modules/website/routes';
|
||||
|
|
@ -137,7 +136,6 @@ app.route('/api/v1/research', researchRoutes);
|
|||
app.route('/api/v1/website', websiteRoutes);
|
||||
app.route('/api/v1/unlisted', unlistedRoutes);
|
||||
app.route('/api/v1/writing', writingRoutes);
|
||||
app.route('/api/v1/comic', comicRoutes);
|
||||
app.route('/api/v1/personas/admin', personasAdminRoutes);
|
||||
|
||||
// ─── Background Workers ─────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -91,10 +91,9 @@ export async function getMediaBufferAsPng(
|
|||
* doesn't land in the owned set — the caller turns that into an HTTP
|
||||
* response.
|
||||
*
|
||||
* Accepts a single app string or an array. Comic character-ref flows
|
||||
* pass `['me', 'comic']` in one call when both face/body portraits and
|
||||
* comic-specific anchors are legitimate inputs for the same
|
||||
* `/v1/images/edits` POST.
|
||||
* Accepts a single app string or an array. `['me']` covers the
|
||||
* portrait flow; future apps may extend the list with their own
|
||||
* upload tags.
|
||||
*
|
||||
* One `list()` round-trip per app. For N apps this is N calls, each
|
||||
* capped at 500 rows — far beyond the product's intended per-app shape
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
/**
|
||||
* Comic module — server endpoints.
|
||||
*
|
||||
* Current scope (M4):
|
||||
* - POST /storyboard — one-shot panel-sequence suggestion from a text
|
||||
* input (journal entry, note, library review, writing draft,
|
||||
* calendar event description). The client decrypts the source
|
||||
* locally, sends the plaintext + style, and we round-trip to
|
||||
* mana-llm with a JSON-schema system prompt, returning
|
||||
* `{ panels: Array<{ prompt, caption?, dialogue? }> }`. Panel
|
||||
* rendering itself still happens through /picture/generate-with-
|
||||
* reference — this endpoint is pure text → plan.
|
||||
*
|
||||
* Future (M5+):
|
||||
* - Upload endpoint for comic-specific anchor / backdrop images if
|
||||
* M6 character-cast scope happens; the 'comic' upload slot is
|
||||
* already allowed by verifyMediaOwnership (set in M1).
|
||||
*
|
||||
* Why not reuse /api/v1/writing/generations?
|
||||
* That endpoint is a free-text prose endpoint (no JSON parsing) and
|
||||
* is wired for one-shot writing drafts. Comic storyboarding wants a
|
||||
* structured Panel[] envelope the client can iterate over cheaply —
|
||||
* different prompt shape, different parser, different observability
|
||||
* tag. Keeping them apart avoids prompt-contamination between the
|
||||
* two use-cases and keeps each module's logs grep-able.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { llmJson, LlmError } from '../../lib/llm';
|
||||
import { MANA_LLM } from '@mana/shared-ai';
|
||||
import { logger, type AuthVariables } from '@mana/shared-hono';
|
||||
|
||||
const STORYBOARD_MODEL = MANA_LLM.STRUCTURED;
|
||||
|
||||
type ComicStyle = 'comic' | 'manga' | 'cartoon' | 'graphic-novel' | 'webtoon';
|
||||
|
||||
const STYLE_HINTS: Record<ComicStyle, string> = {
|
||||
comic: 'US comic book, bold linework, cell-shading, dramatic framing',
|
||||
manga: 'Japanese manga, black-and-white with screen tones, dynamic perspective',
|
||||
cartoon: 'soft pastel cartoon, rounded shapes, Saturday-morning animation',
|
||||
'graphic-novel': 'graphic novel, painterly watercolor, muted atmospheric palette',
|
||||
webtoon: 'webtoon, vertical framing, bright saturated colors, soft cel-shading',
|
||||
};
|
||||
|
||||
const VALID_STYLES = Object.keys(STYLE_HINTS) as readonly ComicStyle[];
|
||||
const MAX_SOURCE_TEXT_CHARS = 8_000;
|
||||
const MIN_PANEL_COUNT = 2;
|
||||
const MAX_PANEL_COUNT = 8;
|
||||
|
||||
interface StoryboardRequest {
|
||||
style: ComicStyle;
|
||||
sourceText: string;
|
||||
/** Optional — if omitted we ask for 4 panels (plan default). */
|
||||
panelCount?: number;
|
||||
/** Optional story-level briefing the author wrote at create-time.
|
||||
* Gets prepended to the source-text so Claude knows the tonal
|
||||
* register ("make it funny" / "stay serious"). */
|
||||
storyContext?: string | null;
|
||||
/** Where this text came from — logged only, not sent to the LLM.
|
||||
* Useful for observability ("which module drives most storyboards"). */
|
||||
sourceModule?: string;
|
||||
}
|
||||
|
||||
interface StoryboardPanel {
|
||||
prompt: string;
|
||||
caption?: string;
|
||||
dialogue?: string;
|
||||
}
|
||||
|
||||
interface StoryboardResponse {
|
||||
panels: StoryboardPanel[];
|
||||
model: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function isValidStyle(v: unknown): v is ComicStyle {
|
||||
return typeof v === 'string' && (VALID_STYLES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
function buildSystemPrompt(style: ComicStyle): string {
|
||||
const hint = STYLE_HINTS[style];
|
||||
return [
|
||||
`You are a comic-story editor. Given a short piece of text (journal entry, note, review, or event description), break it into a sequence of visual comic panels.`,
|
||||
`Style: ${hint}.`,
|
||||
`Return ONLY a JSON object with this exact shape:`,
|
||||
`{"panels": [{"prompt": string, "caption"?: string, "dialogue"?: string}, ...]}`,
|
||||
`Rules:`,
|
||||
`- "prompt" is the visual scene description (what the artist draws). One or two short English sentences. Focus on composition, action, mood, setting. Do NOT describe style — the style prefix is added downstream.`,
|
||||
`- "caption" (optional) is a short narration line rendered at the top or bottom of the panel, max 80 chars. Use sparingly — only when scene-setting or transitions need it.`,
|
||||
`- "dialogue" (optional) is what the protagonist says inside a speech bubble, max 80 chars. Use when the scene has a spoken moment.`,
|
||||
`- Do not number panels. Do not add meta commentary. Do not explain your choices.`,
|
||||
`- The protagonist of every panel is the same person (the story's author).`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildUserPrompt(
|
||||
sourceText: string,
|
||||
panelCount: number,
|
||||
storyContext: string | null | undefined
|
||||
): string {
|
||||
const trimmed = sourceText.trim().slice(0, MAX_SOURCE_TEXT_CHARS);
|
||||
const contextBlock = storyContext?.trim()
|
||||
? `Story briefing from the author:\n${storyContext.trim()}\n\n---\n\n`
|
||||
: '';
|
||||
return [
|
||||
contextBlock,
|
||||
`Source text:\n${trimmed}\n\n---\n\n`,
|
||||
`Generate exactly ${panelCount} panels that tell this as a comic. Output the JSON object described in the system message.`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
routes.post('/storyboard', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const body = (await c.req.json()) as Partial<StoryboardRequest>;
|
||||
|
||||
if (!isValidStyle(body.style)) {
|
||||
return c.json({ error: `Invalid style, expected one of: ${VALID_STYLES.join(', ')}` }, 400);
|
||||
}
|
||||
if (!body.sourceText || typeof body.sourceText !== 'string') {
|
||||
return c.json({ error: 'sourceText required' }, 400);
|
||||
}
|
||||
if (body.sourceText.trim().length === 0) {
|
||||
return c.json({ error: 'sourceText must not be blank' }, 400);
|
||||
}
|
||||
|
||||
const panelCount = Math.max(
|
||||
MIN_PANEL_COUNT,
|
||||
Math.min(MAX_PANEL_COUNT, Number(body.panelCount) || 4)
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const parsed = await llmJson<{ panels?: unknown }>({
|
||||
model: STORYBOARD_MODEL,
|
||||
system: buildSystemPrompt(body.style),
|
||||
user: buildUserPrompt(body.sourceText, panelCount, body.storyContext),
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
});
|
||||
|
||||
const rawPanels = Array.isArray(parsed?.panels) ? parsed.panels : [];
|
||||
// Defense-in-depth: coerce + strip unknown shapes, clamp to
|
||||
// requested count. If the model returns more panels than asked
|
||||
// for we keep the first N; less is fine (fewer credits later).
|
||||
const panels: StoryboardPanel[] = rawPanels
|
||||
.map((raw): StoryboardPanel | null => {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
const prompt = typeof entry.prompt === 'string' ? entry.prompt.trim() : '';
|
||||
if (!prompt) return null;
|
||||
const caption =
|
||||
typeof entry.caption === 'string' && entry.caption.trim().length > 0
|
||||
? entry.caption.trim().slice(0, 200)
|
||||
: undefined;
|
||||
const dialogue =
|
||||
typeof entry.dialogue === 'string' && entry.dialogue.trim().length > 0
|
||||
? entry.dialogue.trim().slice(0, 200)
|
||||
: undefined;
|
||||
return { prompt: prompt.slice(0, 800), caption, dialogue };
|
||||
})
|
||||
.filter((p): p is StoryboardPanel => p !== null)
|
||||
.slice(0, panelCount);
|
||||
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
if (panels.length === 0) {
|
||||
logger.warn('comic.storyboard_empty', {
|
||||
userId,
|
||||
style: body.style,
|
||||
sourceModule: body.sourceModule,
|
||||
model: STORYBOARD_MODEL,
|
||||
durationMs,
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
error: 'Model returned no usable panels',
|
||||
detail: 'Try again, shorten the input, or pick a different style',
|
||||
durationMs,
|
||||
},
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('comic.storyboard_ok', {
|
||||
userId,
|
||||
style: body.style,
|
||||
sourceModule: body.sourceModule,
|
||||
panelCount: panels.length,
|
||||
model: STORYBOARD_MODEL,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
const response: StoryboardResponse = {
|
||||
panels,
|
||||
model: STORYBOARD_MODEL,
|
||||
durationMs,
|
||||
};
|
||||
return c.json(response);
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('comic.storyboard_failed', {
|
||||
userId,
|
||||
style: body.style,
|
||||
sourceModule: body.sourceModule,
|
||||
model: STORYBOARD_MODEL,
|
||||
error: message,
|
||||
status: err instanceof LlmError ? err.status : undefined,
|
||||
durationMs,
|
||||
});
|
||||
return c.json({ error: 'Storyboard generation failed', detail: message, durationMs }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { routes as comicRoutes };
|
||||
|
|
@ -315,14 +315,12 @@ routes.post('/generate-with-reference', async (c) => {
|
|||
}
|
||||
|
||||
// Ownership check before we spend credits or burn OpenAI quota.
|
||||
// References span two upload tags today:
|
||||
// - `me` — face/body portraits from the profile module
|
||||
// - `comic` — comic-specific anchor / backdrop uploads
|
||||
// Anything outside these apps is treated as not-owned regardless of
|
||||
// mana-media's own view.
|
||||
// Currently only `me` (face/body portraits from the profile module)
|
||||
// is a valid upload tag — anything else is treated as not-owned
|
||||
// regardless of mana-media's own view.
|
||||
try {
|
||||
const { verifyMediaOwnership } = await import('../../lib/media');
|
||||
await verifyMediaOwnership(userId, refIds, ['me', 'comic']);
|
||||
await verifyMediaOwnership(userId, refIds, ['me']);
|
||||
} catch (err) {
|
||||
const e = err as Error & { status?: number; missing?: string[] };
|
||||
if (e.status === 404) {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ import {
|
|||
Exam,
|
||||
Globe,
|
||||
NotePencil,
|
||||
FilmStrip,
|
||||
Hourglass,
|
||||
HeartHalf,
|
||||
Eye,
|
||||
|
|
@ -96,7 +95,7 @@ import {
|
|||
// mood · sleep · activity · times · finance
|
||||
// Knowledge: chat · kontext · cards · quiz · guides ·
|
||||
// news-research · research-lab · articles ·
|
||||
// library · writing · comic · presi
|
||||
// library · writing · presi
|
||||
// Body & life: body · meditate · stretch · period ·
|
||||
// dreams · firsts · lasts · habits · recipes
|
||||
// Places & ev.: places · events
|
||||
|
|
@ -1300,30 +1299,6 @@ registerApp({
|
|||
}),
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'comic',
|
||||
name: 'Comic',
|
||||
color: '#f97316',
|
||||
icon: FilmStrip,
|
||||
views: {
|
||||
// /comic/new (StoryForm) and /comic/[id] (DetailView) live as
|
||||
// SvelteKit routes; the workbench card hosts the ListView root.
|
||||
// Quick-action "Neue Story" navigates to /comic/new directly —
|
||||
// the create flow has its own page, no inline modal in the card.
|
||||
list: { load: () => import('$lib/modules/comic/ListView.svelte') },
|
||||
},
|
||||
contextMenuActions: [
|
||||
{
|
||||
id: 'new-story',
|
||||
label: 'Neue Story',
|
||||
icon: Plus,
|
||||
action: () => {
|
||||
window.location.href = '/comic/new';
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'spaces',
|
||||
name: 'Spaces',
|
||||
|
|
|
|||
|
|
@ -881,24 +881,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
|||
],
|
||||
tips: ['System-Auto folgt deinem OS-Dark-Mode automatisch zur richtigen Uhrzeit'],
|
||||
},
|
||||
comic: {
|
||||
description:
|
||||
'Aus Text wird ein Comic — Tagebuch-Eintrag, Notiz oder Library-Review als Vorlage, gpt-image-2 oder Nano Banana rendert Panels in fünf Stilen (Comic, Manga, Cartoon, Graphic Novel, Webtoon). Du selbst bist der Protagonist — Face-Ref aus deinem Profil-Modul wird automatisch genutzt.',
|
||||
features: [
|
||||
'Drei Generate-Modi: Einzel-Panel, Batch (2-4 parallel), KI-Storyboard aus existierendem Text',
|
||||
'Fünf Stil-Presets pro Story fix gewählt — alle Panels nutzen denselben Prefix für Konsistenz',
|
||||
'Sprechblasen + Captions werden direkt ins Bild gerendert (kein SVG-Overlay)',
|
||||
'Modell wählbar pro Klick: OpenAI gpt-image-2, Nano Banana Pro, Nano Banana 2',
|
||||
'Cross-Modul-Storyboard: Claude liest Journal/Notes/Library und schlägt 4-6 Panels vor',
|
||||
'MCP-Tools: listStories / createStory / generatePanel / reorderPanels für Agents',
|
||||
],
|
||||
tips: [
|
||||
'Ohne Face-Ref im aktiven Space kein Comic — Banner führt direkt zum Upload.',
|
||||
'Englische Captions/Dialoge rendern stabiler als deutsche; kurze Sätze funktionieren am besten.',
|
||||
'Style-Wechsel ist nicht möglich nach Story-Create — dafür einfach neue Story anlegen.',
|
||||
'Ab ~8 Panels pro Story wird Character-Konsistenz spürbar schwerer (gpt-image-2-Limit).',
|
||||
],
|
||||
},
|
||||
'research-lab': {
|
||||
description:
|
||||
'Web-Research-Anbieter Seite-an-Seite vergleichen: gleiche Query an bis zu fünf Provider parallel, Antworten + Latenz + Kosten nebeneinander. Alle Runs werden serverseitig persistiert für spätere Auswertung.',
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ import type {
|
|||
LocalGeneration,
|
||||
LocalWritingStyle,
|
||||
} from '../../modules/writing/types';
|
||||
import type { LocalComicStory, LocalComicCharacter } from '../../modules/comic/types';
|
||||
import type { LocalAugurEntry } from '../../modules/augur/types';
|
||||
import type { LocalForm, LocalFormResponse } from '../../modules/forms/types';
|
||||
|
||||
|
|
@ -544,47 +543,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// lives in MinIO behind owner-RLS, not in Dexie.
|
||||
meImages: entry<LocalMeImage>(['label', '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',
|
||||
]),
|
||||
|
||||
// ─── 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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1591,6 +1591,29 @@ db.version(65).stores({
|
|||
newsCachedFeed: null,
|
||||
});
|
||||
|
||||
// v66 — Comic module retirement (2026-05-18).
|
||||
// Comic-Surface ist nach Comicello (comicello.mana.how / comicello.com)
|
||||
// umgezogen, das mit eigener Postgres-DB läuft. Tabellen werden hier
|
||||
// komplett gedroppt; Picture-Image back-ref-Properties (comicStoryId /
|
||||
// comicPanelIndex / comicCharacterId) waren nie indiziert und werden
|
||||
// per .upgrade() aus alten Image-Rows gestrippt, damit keine orphane
|
||||
// FKs auf nicht-mehr-existierende Comic-Records zurückbleiben.
|
||||
db.version(66)
|
||||
.stores({
|
||||
comicStories: null,
|
||||
comicCharacters: null,
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
await tx
|
||||
.table('images')
|
||||
.toCollection()
|
||||
.modify((image) => {
|
||||
if ('comicStoryId' in image) delete image.comicStoryId;
|
||||
if ('comicPanelIndex' in image) delete image.comicPanelIndex;
|
||||
if ('comicCharacterId' in image) delete image.comicCharacterId;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -270,7 +270,6 @@ describe('module-registry — snapshot', () => {
|
|||
wetter: ['wetterLocations', 'wetterSettings'],
|
||||
website: ['websites', 'websitePages', 'websiteBlocks'],
|
||||
writing: ['writingDrafts', 'writingDraftVersions', 'writingGenerations', 'writingStyles'],
|
||||
comic: ['comicStories', 'comicCharacters'],
|
||||
augur: ['augurEntries'],
|
||||
forms: ['forms', 'formResponses'],
|
||||
ai: ['aiMissions', 'agents', 'agentKontextDocs'],
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ import { broadcastModuleConfig } from '$lib/modules/broadcasts/module.config';
|
|||
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||
import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
||||
import { writingModuleConfig } from '$lib/modules/writing/module.config';
|
||||
import { comicModuleConfig } from '$lib/modules/comic/module.config';
|
||||
import { augurModuleConfig } from '$lib/modules/augur/module.config';
|
||||
import { formsModuleConfig } from '$lib/modules/forms/module.config';
|
||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||
|
|
@ -151,7 +150,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
wetterModuleConfig,
|
||||
websiteModuleConfig,
|
||||
writingModuleConfig,
|
||||
comicModuleConfig,
|
||||
augurModuleConfig,
|
||||
formsModuleConfig,
|
||||
aiModuleConfig,
|
||||
|
|
|
|||
|
|
@ -139,18 +139,6 @@ const TABLES: TableConfig[] = [
|
|||
return recipesStore.setVisibility(id, next);
|
||||
},
|
||||
},
|
||||
{
|
||||
module: 'comic',
|
||||
collection: 'comicStories',
|
||||
moduleLabel: 'Comics',
|
||||
encrypted: true,
|
||||
title: (r) => asString(r.title),
|
||||
href: () => '/comic',
|
||||
setVisibility: async (id, next) => {
|
||||
const { comicStoriesStore } = await import('$lib/modules/comic/stores/stories.svelte');
|
||||
return comicStoriesStore.setVisibility(id, next);
|
||||
},
|
||||
},
|
||||
{
|
||||
module: 'habits',
|
||||
collection: 'habits',
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import { libraryTools } from '$lib/modules/library/tools';
|
|||
import { broadcastTools } from '$lib/modules/broadcasts/tools';
|
||||
import { websiteTools } from '$lib/modules/website/tools';
|
||||
import { writingTools } from '$lib/modules/writing/tools';
|
||||
import { comicTools } from '$lib/modules/comic/tools';
|
||||
import { augurTools } from '$lib/modules/augur/tools';
|
||||
import { formsTools } from '$lib/modules/forms/tools';
|
||||
|
||||
|
|
@ -90,7 +89,6 @@ export function initTools(): void {
|
|||
registerTools(broadcastTools);
|
||||
registerTools(websiteTools);
|
||||
registerTools(writingTools);
|
||||
registerTools(comicTools);
|
||||
registerTools(augurTools);
|
||||
registerTools(formsTools);
|
||||
initialized = true;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,5 @@
|
|||
"spaces": "Bereiche",
|
||||
"website": "Website",
|
||||
"quiz": "Quiz",
|
||||
"guides": "Anleitungen",
|
||||
"comic": "Comic"
|
||||
"guides": "Anleitungen"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,5 @@
|
|||
"spaces": "Spaces",
|
||||
"website": "Website",
|
||||
"quiz": "Quiz",
|
||||
"guides": "Guides",
|
||||
"comic": "Comic"
|
||||
"guides": "Guides"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,5 @@
|
|||
"spaces": "Espacios",
|
||||
"website": "Sitio web",
|
||||
"quiz": "Quiz",
|
||||
"guides": "Guías",
|
||||
"comic": "Cómic"
|
||||
"guides": "Guías"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,5 @@
|
|||
"spaces": "Espaces",
|
||||
"website": "Site web",
|
||||
"quiz": "Quiz",
|
||||
"guides": "Guides",
|
||||
"comic": "Bande dessinée"
|
||||
"guides": "Guides"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,5 @@
|
|||
"spaces": "Spazi",
|
||||
"website": "Sito web",
|
||||
"quiz": "Quiz",
|
||||
"guides": "Guide",
|
||||
"comic": "Fumetto"
|
||||
"guides": "Guide"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
{
|
||||
"detail": {
|
||||
"back_aria": "Zurück zu Comics",
|
||||
"breadcrumb": "Comics",
|
||||
"loading": "Lädt…",
|
||||
"not_found": "Story nicht gefunden.",
|
||||
"not_found_hint": "Gelöscht oder in einem anderen Space.",
|
||||
"panel_one": "{n} Panel",
|
||||
"panel_other": "{n} Panels",
|
||||
"reference_one": "{n} Referenz",
|
||||
"reference_other": "{n} Referenzen",
|
||||
"favorite_remove": "Favorit entfernen",
|
||||
"favorite_set": "Als Favorit markieren",
|
||||
"context_label": "Kontext:",
|
||||
"section_panels": "Panels",
|
||||
"add_panel": "Panel",
|
||||
"add_batch": "Batch",
|
||||
"add_batch_title": "2–4 Panels in einem Rutsch generieren",
|
||||
"add_ai": "Mit KI",
|
||||
"add_ai_title": "KI schlägt Panels aus einem Tagebuch-Eintrag, Notiz oder Review vor",
|
||||
"unarchive": "Wieder aktiv",
|
||||
"archive": "Archivieren",
|
||||
"delete": "Löschen",
|
||||
"archived_hint": "Archivierte Story — keine Panel-Generierung möglich, bis wieder aktiviert.",
|
||||
"confirm_delete_story": "Story \"{title}\" wirklich löschen?",
|
||||
"confirm_remove_panel": "Panel aus der Story entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden."
|
||||
},
|
||||
"styles": {
|
||||
"comic": "US-Comic",
|
||||
"manga": "Manga",
|
||||
"cartoon": "Cartoon",
|
||||
"graphic-novel": "Graphic Novel",
|
||||
"webtoon": "Webtoon"
|
||||
},
|
||||
"picker": {
|
||||
"section_title": "Protagonist",
|
||||
"section_hint": "Dein Gesicht ist Pflicht. Body-Ref und bis zu {max} Kostüm-Fotos sind optional — klicke ein Bild oder das ✕, um es wieder zu entfernen.",
|
||||
"face_required_title": "Face-Ref ist Pflicht — kann nicht entfernt werden",
|
||||
"face_required_badge": "Pflicht",
|
||||
"face_alt": "Face-Ref",
|
||||
"face_missing": "Face fehlt",
|
||||
"face_label": "Face",
|
||||
"body_alt": "Body-Ref",
|
||||
"body_missing": "Body fehlt",
|
||||
"body_label": "Body",
|
||||
"body_no_in_space": "Kein Body-Ref im aktiven Space",
|
||||
"toggle_remove": "Klick zum Entfernen",
|
||||
"toggle_add": "Klick zum Hinzufügen",
|
||||
"garment_remove_aria": "{name} entfernen",
|
||||
"garment_label": "Kostüm",
|
||||
"garment_picker_title": "Kostüm aus dem Schrank wählen",
|
||||
"garment_picker_close": "Schließen",
|
||||
"garment_picker_empty_html": "Keine weiteren Kleidungsstücke verfügbar — lade welche in <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a> hoch.",
|
||||
"no_face_alert_html": "Kein Gesichtsbild in diesem Space. Lade eins in <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Profil → Bilder</a> hoch — ohne Face-Ref kein Comic.",
|
||||
"body_tip": "Tipp: Ein Body-Ref hilft, wenn der Comic Ganzkörper-Panels zeigen soll."
|
||||
},
|
||||
"character_detail": {
|
||||
"back_aria": "Zurück zu Characters",
|
||||
"breadcrumb": "Comic · Characters",
|
||||
"loading": "Lädt…",
|
||||
"not_found": "Character nicht gefunden.",
|
||||
"not_found_hint": "Gelöscht oder in einem anderen Space.",
|
||||
"variant_one": "{n} Variante",
|
||||
"variant_other": "{n} Varianten",
|
||||
"pin_open": "Pin offen",
|
||||
"favorite_remove": "Favorit entfernen",
|
||||
"favorite_set": "Als Favorit markieren",
|
||||
"prompt_add_label": "Prompt-Add:",
|
||||
"section_variants": "Varianten",
|
||||
"action_more_variants": "Mehr Varianten",
|
||||
"empty_variants_title": "Noch keine Varianten.",
|
||||
"empty_variants_hint_html": "Klick oben rechts auf <strong class=\"text-foreground\">+ Mehr Varianten</strong>, um die ersten 4 zu generieren.",
|
||||
"unarchive": "Wieder aktiv",
|
||||
"archive": "Archivieren",
|
||||
"delete": "Löschen",
|
||||
"archived_hint": "Archivierter Character — keine Variant-Generierung möglich, bis wieder aktiviert.",
|
||||
"confirm_delete_character": "Character \"{name}\" wirklich löschen?",
|
||||
"confirm_remove_variant": "Variante aus dem Character entfernen? Das Bild bleibt in deiner Picture-Galerie und kann dort gelöscht werden."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
{
|
||||
"detail": {
|
||||
"back_aria": "Back to comics",
|
||||
"breadcrumb": "Comics",
|
||||
"loading": "Loading…",
|
||||
"not_found": "Story not found.",
|
||||
"not_found_hint": "Deleted or in another space.",
|
||||
"panel_one": "{n} panel",
|
||||
"panel_other": "{n} panels",
|
||||
"reference_one": "{n} reference",
|
||||
"reference_other": "{n} references",
|
||||
"favorite_remove": "Remove favorite",
|
||||
"favorite_set": "Mark as favorite",
|
||||
"context_label": "Context:",
|
||||
"section_panels": "Panels",
|
||||
"add_panel": "Panel",
|
||||
"add_batch": "Batch",
|
||||
"add_batch_title": "Generate 2–4 panels in one go",
|
||||
"add_ai": "With AI",
|
||||
"add_ai_title": "AI suggests panels from a journal entry, note, or review",
|
||||
"unarchive": "Reactivate",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"archived_hint": "Archived story — no panel generation possible until reactivated.",
|
||||
"confirm_delete_story": "Really delete story \"{title}\"?",
|
||||
"confirm_remove_panel": "Remove panel from the story? The image stays in your Picture gallery and can be deleted there."
|
||||
},
|
||||
"styles": {
|
||||
"comic": "US Comic",
|
||||
"manga": "Manga",
|
||||
"cartoon": "Cartoon",
|
||||
"graphic-novel": "Graphic Novel",
|
||||
"webtoon": "Webtoon"
|
||||
},
|
||||
"picker": {
|
||||
"section_title": "Protagonist",
|
||||
"section_hint": "Your face is required. Body-ref and up to {max} costume photos are optional — click an image or the ✕ to remove it.",
|
||||
"face_required_title": "Face-ref is required — cannot be removed",
|
||||
"face_required_badge": "Required",
|
||||
"face_alt": "Face ref",
|
||||
"face_missing": "No face",
|
||||
"face_label": "Face",
|
||||
"body_alt": "Body ref",
|
||||
"body_missing": "No body",
|
||||
"body_label": "Body",
|
||||
"body_no_in_space": "No body-ref in active space",
|
||||
"toggle_remove": "Click to remove",
|
||||
"toggle_add": "Click to add",
|
||||
"garment_remove_aria": "Remove {name}",
|
||||
"garment_label": "Costume",
|
||||
"garment_picker_title": "Pick a costume from the wardrobe",
|
||||
"garment_picker_close": "Close",
|
||||
"garment_picker_empty_html": "No more garments available — upload some in <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a>.",
|
||||
"no_face_alert_html": "No face photo in this space. Upload one in <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Profile → Images</a> — without a face ref no comic.",
|
||||
"body_tip": "Tip: a body ref helps when the comic shows full-body panels."
|
||||
},
|
||||
"character_detail": {
|
||||
"back_aria": "Back to characters",
|
||||
"breadcrumb": "Comic · Characters",
|
||||
"loading": "Loading…",
|
||||
"not_found": "Character not found.",
|
||||
"not_found_hint": "Deleted or in another space.",
|
||||
"variant_one": "{n} variant",
|
||||
"variant_other": "{n} variants",
|
||||
"pin_open": "Pin pending",
|
||||
"favorite_remove": "Remove favorite",
|
||||
"favorite_set": "Mark as favorite",
|
||||
"prompt_add_label": "Prompt add:",
|
||||
"section_variants": "Variants",
|
||||
"action_more_variants": "More variants",
|
||||
"empty_variants_title": "No variants yet.",
|
||||
"empty_variants_hint_html": "Click <strong class=\"text-foreground\">+ More variants</strong> in the top right to generate the first 4.",
|
||||
"unarchive": "Reactivate",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"archived_hint": "Archived character — no variant generation possible until reactivated.",
|
||||
"confirm_delete_character": "Really delete character \"{name}\"?",
|
||||
"confirm_remove_variant": "Remove variant from this character? The image stays in your Picture gallery and can be deleted there."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
{
|
||||
"detail": {
|
||||
"back_aria": "Volver a cómics",
|
||||
"breadcrumb": "Cómics",
|
||||
"loading": "Cargando…",
|
||||
"not_found": "Historia no encontrada.",
|
||||
"not_found_hint": "Eliminada o en otro espacio.",
|
||||
"panel_one": "{n} panel",
|
||||
"panel_other": "{n} paneles",
|
||||
"reference_one": "{n} referencia",
|
||||
"reference_other": "{n} referencias",
|
||||
"favorite_remove": "Quitar favorito",
|
||||
"favorite_set": "Marcar como favorito",
|
||||
"context_label": "Contexto:",
|
||||
"section_panels": "Paneles",
|
||||
"add_panel": "Panel",
|
||||
"add_batch": "Lote",
|
||||
"add_batch_title": "Generar 2–4 paneles de una vez",
|
||||
"add_ai": "Con IA",
|
||||
"add_ai_title": "La IA sugiere paneles a partir de un diario, nota o reseña",
|
||||
"unarchive": "Reactivar",
|
||||
"archive": "Archivar",
|
||||
"delete": "Eliminar",
|
||||
"archived_hint": "Historia archivada — no se pueden generar paneles hasta que se reactive.",
|
||||
"confirm_delete_story": "¿Eliminar realmente la historia \"{title}\"?",
|
||||
"confirm_remove_panel": "¿Quitar panel de la historia? La imagen permanece en tu galería de Picture y puede eliminarse allí."
|
||||
},
|
||||
"styles": {
|
||||
"comic": "Cómic US",
|
||||
"manga": "Manga",
|
||||
"cartoon": "Cartoon",
|
||||
"graphic-novel": "Novela gráfica",
|
||||
"webtoon": "Webtoon"
|
||||
},
|
||||
"picker": {
|
||||
"section_title": "Protagonista",
|
||||
"section_hint": "Tu cara es obligatoria. La body-ref y hasta {max} fotos de vestuario son opcionales — haz clic en una imagen o en la ✕ para quitarla.",
|
||||
"face_required_title": "Face-ref es obligatoria — no se puede quitar",
|
||||
"face_required_badge": "Obligatorio",
|
||||
"face_alt": "Face ref",
|
||||
"face_missing": "Sin cara",
|
||||
"face_label": "Cara",
|
||||
"body_alt": "Body ref",
|
||||
"body_missing": "Sin cuerpo",
|
||||
"body_label": "Cuerpo",
|
||||
"body_no_in_space": "No hay body-ref en el espacio activo",
|
||||
"toggle_remove": "Clic para quitar",
|
||||
"toggle_add": "Clic para añadir",
|
||||
"garment_remove_aria": "Quitar {name}",
|
||||
"garment_label": "Vestuario",
|
||||
"garment_picker_title": "Elegir vestuario del armario",
|
||||
"garment_picker_close": "Cerrar",
|
||||
"garment_picker_empty_html": "No hay más prendas disponibles — sube algunas en <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a>.",
|
||||
"no_face_alert_html": "No hay foto de cara en este espacio. Sube una en <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Perfil → Imágenes</a> — sin face-ref no hay cómic.",
|
||||
"body_tip": "Tip: una body-ref ayuda cuando el cómic muestra paneles de cuerpo entero."
|
||||
},
|
||||
"character_detail": {
|
||||
"back_aria": "Volver a personajes",
|
||||
"breadcrumb": "Cómic · Personajes",
|
||||
"loading": "Cargando…",
|
||||
"not_found": "Personaje no encontrado.",
|
||||
"not_found_hint": "Eliminado o en otro espacio.",
|
||||
"variant_one": "{n} variante",
|
||||
"variant_other": "{n} variantes",
|
||||
"pin_open": "Pin pendiente",
|
||||
"favorite_remove": "Quitar favorito",
|
||||
"favorite_set": "Marcar como favorito",
|
||||
"prompt_add_label": "Prompt extra:",
|
||||
"section_variants": "Variantes",
|
||||
"action_more_variants": "Más variantes",
|
||||
"empty_variants_title": "Aún sin variantes.",
|
||||
"empty_variants_hint_html": "Haz clic en <strong class=\"text-foreground\">+ Más variantes</strong> arriba a la derecha para generar las primeras 4.",
|
||||
"unarchive": "Reactivar",
|
||||
"archive": "Archivar",
|
||||
"delete": "Eliminar",
|
||||
"archived_hint": "Personaje archivado — no se pueden generar variantes hasta reactivar.",
|
||||
"confirm_delete_character": "¿Eliminar realmente el personaje \"{name}\"?",
|
||||
"confirm_remove_variant": "¿Quitar variante del personaje? La imagen permanece en tu galería de Picture y puede eliminarse allí."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
{
|
||||
"detail": {
|
||||
"back_aria": "Retour aux comics",
|
||||
"breadcrumb": "Comics",
|
||||
"loading": "Chargement…",
|
||||
"not_found": "Histoire introuvable.",
|
||||
"not_found_hint": "Supprimée ou dans un autre espace.",
|
||||
"panel_one": "{n} panneau",
|
||||
"panel_other": "{n} panneaux",
|
||||
"reference_one": "{n} référence",
|
||||
"reference_other": "{n} références",
|
||||
"favorite_remove": "Retirer des favoris",
|
||||
"favorite_set": "Marquer comme favori",
|
||||
"context_label": "Contexte :",
|
||||
"section_panels": "Panneaux",
|
||||
"add_panel": "Panneau",
|
||||
"add_batch": "Lot",
|
||||
"add_batch_title": "Générer 2 à 4 panneaux d'un coup",
|
||||
"add_ai": "Avec IA",
|
||||
"add_ai_title": "L'IA propose des panneaux à partir d'une entrée de journal, note ou critique",
|
||||
"unarchive": "Réactiver",
|
||||
"archive": "Archiver",
|
||||
"delete": "Supprimer",
|
||||
"archived_hint": "Histoire archivée — pas de génération de panneaux possible jusqu'à réactivation.",
|
||||
"confirm_delete_story": "Vraiment supprimer l'histoire « {title} » ?",
|
||||
"confirm_remove_panel": "Retirer le panneau de l'histoire ? L'image reste dans ta galerie Picture et peut y être supprimée."
|
||||
},
|
||||
"styles": {
|
||||
"comic": "Comic US",
|
||||
"manga": "Manga",
|
||||
"cartoon": "Cartoon",
|
||||
"graphic-novel": "Roman graphique",
|
||||
"webtoon": "Webtoon"
|
||||
},
|
||||
"picker": {
|
||||
"section_title": "Protagoniste",
|
||||
"section_hint": "Ton visage est obligatoire. La body-ref et jusqu'à {max} photos de costume sont optionnelles — clique sur une image ou la ✕ pour la retirer.",
|
||||
"face_required_title": "Face-ref est obligatoire — impossible à retirer",
|
||||
"face_required_badge": "Obligatoire",
|
||||
"face_alt": "Face ref",
|
||||
"face_missing": "Pas de visage",
|
||||
"face_label": "Visage",
|
||||
"body_alt": "Body ref",
|
||||
"body_missing": "Pas de corps",
|
||||
"body_label": "Corps",
|
||||
"body_no_in_space": "Aucune body-ref dans l'espace actif",
|
||||
"toggle_remove": "Clic pour retirer",
|
||||
"toggle_add": "Clic pour ajouter",
|
||||
"garment_remove_aria": "Retirer {name}",
|
||||
"garment_label": "Costume",
|
||||
"garment_picker_title": "Choisir un costume dans la garde-robe",
|
||||
"garment_picker_close": "Fermer",
|
||||
"garment_picker_empty_html": "Aucun autre vêtement disponible — ajoutes-en dans <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a>.",
|
||||
"no_face_alert_html": "Aucune photo de visage dans cet espace. Ajoute-en une dans <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Profil → Images</a> — sans face-ref pas de comic.",
|
||||
"body_tip": "Astuce : une body-ref aide quand le comic montre des panneaux corps entier."
|
||||
},
|
||||
"character_detail": {
|
||||
"back_aria": "Retour aux personnages",
|
||||
"breadcrumb": "Comic · Personnages",
|
||||
"loading": "Chargement…",
|
||||
"not_found": "Personnage introuvable.",
|
||||
"not_found_hint": "Supprimé ou dans un autre espace.",
|
||||
"variant_one": "{n} variante",
|
||||
"variant_other": "{n} variantes",
|
||||
"pin_open": "Pin en attente",
|
||||
"favorite_remove": "Retirer des favoris",
|
||||
"favorite_set": "Marquer comme favori",
|
||||
"prompt_add_label": "Prompt extra :",
|
||||
"section_variants": "Variantes",
|
||||
"action_more_variants": "Plus de variantes",
|
||||
"empty_variants_title": "Pas encore de variantes.",
|
||||
"empty_variants_hint_html": "Clique sur <strong class=\"text-foreground\">+ Plus de variantes</strong> en haut à droite pour générer les 4 premières.",
|
||||
"unarchive": "Réactiver",
|
||||
"archive": "Archiver",
|
||||
"delete": "Supprimer",
|
||||
"archived_hint": "Personnage archivé — pas de génération de variantes possible jusqu'à réactivation.",
|
||||
"confirm_delete_character": "Vraiment supprimer le personnage « {name} » ?",
|
||||
"confirm_remove_variant": "Retirer la variante du personnage ? L'image reste dans ta galerie Picture et peut y être supprimée."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
{
|
||||
"detail": {
|
||||
"back_aria": "Torna ai fumetti",
|
||||
"breadcrumb": "Fumetti",
|
||||
"loading": "Caricamento…",
|
||||
"not_found": "Storia non trovata.",
|
||||
"not_found_hint": "Eliminata o in un altro spazio.",
|
||||
"panel_one": "{n} pannello",
|
||||
"panel_other": "{n} pannelli",
|
||||
"reference_one": "{n} riferimento",
|
||||
"reference_other": "{n} riferimenti",
|
||||
"favorite_remove": "Rimuovi preferito",
|
||||
"favorite_set": "Segna come preferito",
|
||||
"context_label": "Contesto:",
|
||||
"section_panels": "Pannelli",
|
||||
"add_panel": "Pannello",
|
||||
"add_batch": "Batch",
|
||||
"add_batch_title": "Genera 2–4 pannelli in una volta",
|
||||
"add_ai": "Con IA",
|
||||
"add_ai_title": "L'IA propone pannelli da un diario, nota o recensione",
|
||||
"unarchive": "Riattiva",
|
||||
"archive": "Archivia",
|
||||
"delete": "Elimina",
|
||||
"archived_hint": "Storia archiviata — generazione pannelli non possibile finché non viene riattivata.",
|
||||
"confirm_delete_story": "Eliminare davvero la storia \"{title}\"?",
|
||||
"confirm_remove_panel": "Rimuovere il pannello dalla storia? L'immagine resta nella tua galleria Picture e può essere eliminata lì."
|
||||
},
|
||||
"styles": {
|
||||
"comic": "Comic USA",
|
||||
"manga": "Manga",
|
||||
"cartoon": "Cartoon",
|
||||
"graphic-novel": "Graphic Novel",
|
||||
"webtoon": "Webtoon"
|
||||
},
|
||||
"picker": {
|
||||
"section_title": "Protagonista",
|
||||
"section_hint": "Il tuo volto è obbligatorio. La body-ref e fino a {max} foto di costume sono opzionali — clicca un'immagine o la ✕ per rimuoverla.",
|
||||
"face_required_title": "Face-ref è obbligatoria — non rimovibile",
|
||||
"face_required_badge": "Obbligatorio",
|
||||
"face_alt": "Face ref",
|
||||
"face_missing": "Nessun volto",
|
||||
"face_label": "Volto",
|
||||
"body_alt": "Body ref",
|
||||
"body_missing": "Nessun corpo",
|
||||
"body_label": "Corpo",
|
||||
"body_no_in_space": "Nessuna body-ref nello spazio attivo",
|
||||
"toggle_remove": "Clic per rimuovere",
|
||||
"toggle_add": "Clic per aggiungere",
|
||||
"garment_remove_aria": "Rimuovi {name}",
|
||||
"garment_label": "Costume",
|
||||
"garment_picker_title": "Scegli un costume dall'armadio",
|
||||
"garment_picker_close": "Chiudi",
|
||||
"garment_picker_empty_html": "Nessun altro indumento disponibile — caricane in <a href=\"/wardrobe\" class=\"text-primary hover:underline\">/wardrobe</a>.",
|
||||
"no_face_alert_html": "Nessuna foto del volto in questo spazio. Caricane una in <a href=\"/profile/me-images\" class=\"underline hover:no-underline\">Profilo → Immagini</a> — senza face-ref niente comic.",
|
||||
"body_tip": "Suggerimento: una body-ref aiuta quando il comic mostra pannelli a figura intera."
|
||||
},
|
||||
"character_detail": {
|
||||
"back_aria": "Torna ai personaggi",
|
||||
"breadcrumb": "Comic · Personaggi",
|
||||
"loading": "Caricamento…",
|
||||
"not_found": "Personaggio non trovato.",
|
||||
"not_found_hint": "Eliminato o in un altro spazio.",
|
||||
"variant_one": "{n} variante",
|
||||
"variant_other": "{n} varianti",
|
||||
"pin_open": "Pin in attesa",
|
||||
"favorite_remove": "Rimuovi preferito",
|
||||
"favorite_set": "Segna come preferito",
|
||||
"prompt_add_label": "Prompt extra:",
|
||||
"section_variants": "Varianti",
|
||||
"action_more_variants": "Altre varianti",
|
||||
"empty_variants_title": "Ancora nessuna variante.",
|
||||
"empty_variants_hint_html": "Clicca <strong class=\"text-foreground\">+ Altre varianti</strong> in alto a destra per generare le prime 4.",
|
||||
"unarchive": "Riattiva",
|
||||
"archive": "Archivia",
|
||||
"delete": "Elimina",
|
||||
"archived_hint": "Personaggio archiviato — generazione varianti non possibile fino alla riattivazione.",
|
||||
"confirm_delete_character": "Eliminare davvero il personaggio \"{name}\"?",
|
||||
"confirm_remove_variant": "Rimuovere la variante dal personaggio? L'immagine resta nella tua galleria Picture e può essere eliminata lì."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
<!--
|
||||
Comic module root — Tab-Switcher zwischen Stories und Characters.
|
||||
Stories sind das primäre Output-Artefakt, Characters die
|
||||
wiederverwendbaren Identity-Anchors. Tab-State ist lokal und
|
||||
bleibt erhalten solange ListView gemountet ist (SvelteKit hält
|
||||
uns gemountet bei Navigation innerhalb /comic).
|
||||
|
||||
Face-Ref-Banner (oben, oberhalb der Tabs) übernimmt das Wardrobe-
|
||||
Pattern 1:1 — wenn der aktive Space kein face-ref hat, kann der
|
||||
User das Bild direkt hier inline droppen statt in Profil → Bilder
|
||||
navigieren zu müssen. Banner zeigt sich für beide Tabs (Stories
|
||||
UND Characters brauchen ein Face-Ref) und blendet sich nach
|
||||
erfolgreichem Upload mit einem 2.5s Success-Card aus.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { CheckCircle, SpinnerGap, UserCircle } from '@mana/shared-icons';
|
||||
import { useImageByPrimary } from '$lib/modules/profile/queries';
|
||||
import MeImageUploadZone from '$lib/modules/profile/components/MeImageUploadZone.svelte';
|
||||
import { ingestMeImageFile } from '$lib/modules/profile/api/me-images';
|
||||
import StoriesView from './views/ListView.svelte';
|
||||
import CharactersView from './views/CharactersView.svelte';
|
||||
import { useAllCharacters } from './queries';
|
||||
|
||||
type Tab = 'stories' | 'characters';
|
||||
|
||||
let activeTab = $state<Tab>('stories');
|
||||
|
||||
const characters$ = useAllCharacters();
|
||||
const characterCount = $derived(characters$.value?.length ?? 0);
|
||||
|
||||
const TABS: { key: Tab; label: string; count?: number }[] = $derived([
|
||||
{ key: 'stories', label: 'Stories' },
|
||||
{ key: 'characters', label: 'Characters', count: characterCount },
|
||||
]);
|
||||
|
||||
// Face-ref banner — same UX as Wardrobe's ListView. Without a
|
||||
// face-ref no Comic-Panel and no Comic-Character can render
|
||||
// (they all flow through /picture/generate-with-reference with
|
||||
// face/body refs as required inputs). Banner sits at the
|
||||
// module root above the tabs so both sub-views see it.
|
||||
const face$ = useImageByPrimary('face-ref');
|
||||
const face = $derived(face$.value);
|
||||
|
||||
type UploadPhase = 'idle' | 'uploading' | 'success';
|
||||
let uploadPhase = $state<UploadPhase>('idle');
|
||||
let uploadedPreviewUrl = $state<string | null>(null);
|
||||
let faceUploadError = $state<string | null>(null);
|
||||
let successTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const showBanner = $derived(!face$.loading && (!face || uploadPhase === 'success'));
|
||||
|
||||
async function handleFaceUpload(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
if (successTimeout) {
|
||||
clearTimeout(successTimeout);
|
||||
successTimeout = null;
|
||||
}
|
||||
uploadPhase = 'uploading';
|
||||
faceUploadError = null;
|
||||
try {
|
||||
const image = await ingestMeImageFile(files[0], {
|
||||
kind: 'face',
|
||||
claimSlot: 'face-ref',
|
||||
});
|
||||
uploadedPreviewUrl = image.thumbnailUrl ?? image.publicUrl ?? null;
|
||||
uploadPhase = 'success';
|
||||
// Hold the success card visible briefly so the user sees the
|
||||
// confirmation, then let the banner unmount and the active
|
||||
// tab take over as the next step.
|
||||
successTimeout = setTimeout(() => {
|
||||
uploadPhase = 'idle';
|
||||
uploadedPreviewUrl = null;
|
||||
successTimeout = null;
|
||||
}, 2500);
|
||||
} catch (err) {
|
||||
faceUploadError = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
|
||||
uploadPhase = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
function dismissSuccess() {
|
||||
if (successTimeout) {
|
||||
clearTimeout(successTimeout);
|
||||
successTimeout = null;
|
||||
}
|
||||
uploadPhase = 'idle';
|
||||
uploadedPreviewUrl = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comic-root">
|
||||
<nav class="comic-tabs" aria-label="Ansicht wechseln">
|
||||
{#each TABS as tab (tab.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="comic-tab"
|
||||
class:active={activeTab === tab.key}
|
||||
aria-pressed={activeTab === tab.key}
|
||||
onclick={() => (activeTab = tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{#if tab.count !== undefined && tab.count > 0}
|
||||
<span class="comic-tab-count">{tab.count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if showBanner}
|
||||
<div
|
||||
class="face-banner space-y-3 rounded-xl border border-dashed p-4"
|
||||
class:face-banner-success={uploadPhase === 'success'}
|
||||
transition:fade={{ duration: 250 }}
|
||||
>
|
||||
{#if uploadPhase === 'success'}
|
||||
<div class="flex items-center gap-3" role="status" aria-live="polite">
|
||||
{#if uploadedPreviewUrl}
|
||||
<img
|
||||
src={uploadedPreviewUrl}
|
||||
alt=""
|
||||
class="h-12 w-12 flex-shrink-0 rounded-full border border-primary/30 object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
|
||||
>
|
||||
<CheckCircle size={24} weight="fill" />
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex-1 space-y-0.5">
|
||||
<p class="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<CheckCircle size={14} weight="fill" class="text-primary" />
|
||||
Gesichtsbild gespeichert
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Perfekt — als nächstes baust du deinen ersten Comic-Character oder legst direkt eine
|
||||
Story an.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismissSuccess}
|
||||
class="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start gap-3 text-sm">
|
||||
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-foreground">Lade ein Gesichtsbild hoch</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Wir brauchen dich auf Bild, damit Comic-Panels und Charakter-Varianten von dir
|
||||
gerendert werden können. Das Bild bleibt lokal und wird nur für deine eigenen
|
||||
Generierungen genutzt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<MeImageUploadZone
|
||||
variant="compact"
|
||||
label={uploadPhase === 'uploading' ? 'Wird hochgeladen…' : 'Gesichtsbild hochladen'}
|
||||
hint="Kopf + Schulter, möglichst neutrale Beleuchtung"
|
||||
disabled={uploadPhase === 'uploading'}
|
||||
onFiles={handleFaceUpload}
|
||||
/>
|
||||
{#if uploadPhase === 'uploading'}
|
||||
<span
|
||||
class="pointer-events-none absolute right-3 top-3 flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<SpinnerGap size={12} class="spinner" weight="bold" />
|
||||
Lade…
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if faceUploadError}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
|
||||
role="alert"
|
||||
>
|
||||
{faceUploadError}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="comic-body">
|
||||
{#if activeTab === 'stories'}
|
||||
<StoriesView />
|
||||
{:else}
|
||||
<CharactersView />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comic-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.comic-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.comic-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: -1px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.comic-tab:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.comic-tab.active {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-bottom-color: hsl(var(--color-primary));
|
||||
}
|
||||
.comic-tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.comic-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.face-banner {
|
||||
border-color: hsl(var(--color-border));
|
||||
background: hsl(var(--color-background) / 0.5);
|
||||
transition:
|
||||
background-color 0.25s,
|
||||
border-color 0.25s;
|
||||
}
|
||||
.face-banner-success {
|
||||
border-style: solid;
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
background: hsl(var(--color-primary) / 0.06);
|
||||
}
|
||||
/* Spinner reaches into Phosphor's child SVG via :global(). */
|
||||
.face-banner :global(.spinner) {
|
||||
animation: comic-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes comic-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@container (min-width: 640px) {
|
||||
.comic-root {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
/**
|
||||
* Character-variant generation. Renders N stylised portraits of the
|
||||
* user from face/body meImages with the chosen ComicStyle prefix,
|
||||
* persists each into `picture.images` with a `comicCharacterId`
|
||||
* back-ref, and appends each to the character's `variantMediaIds`.
|
||||
*
|
||||
* The endpoint and the HTTP shape are identical to panel-generation
|
||||
* (`api/generate-panel.ts`); only the prompt-template differs (panel
|
||||
* = "what happens in this panel", character = "portrait of the same
|
||||
* person, identity anchor"). One call with `n=4` returns all four
|
||||
* variants in a single batch — that's the gpt-image-2 multi-image
|
||||
* response shape (`{images: [{imageUrl, mediaId}, ...]}`).
|
||||
*
|
||||
* Plan: docs/plans/comic-module.md §11 (Mc2).
|
||||
*/
|
||||
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
|
||||
import { comicCharactersStore } from '../stores/characters.svelte';
|
||||
import { STYLE_PREFIXES } from '../styles';
|
||||
import { DEFAULT_PANEL_MODEL, type PanelModel } from './generate-panel';
|
||||
import type { ComicCharacter, ComicStyle } from '../types';
|
||||
|
||||
export type CharacterSize = '1024x1024' | '1024x1536';
|
||||
|
||||
export interface RunCharacterGenerateParams {
|
||||
character: ComicCharacter;
|
||||
/** How many variants to render in one batch — 1-4 (gpt-image-2's
|
||||
* hard server cap). Default 4: the picker shows enough options
|
||||
* for a real choice without burning credits on speculative noise. */
|
||||
count?: number;
|
||||
quality?: 'low' | 'medium' | 'high';
|
||||
size?: CharacterSize;
|
||||
model?: PanelModel;
|
||||
}
|
||||
|
||||
export interface RunCharacterGenerateResult {
|
||||
variantMediaIds: string[];
|
||||
imageUrls: string[];
|
||||
prompt: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function dimsForSize(size: CharacterSize): { width: number; height: number } {
|
||||
if (size === '1024x1536') return { width: 1024, height: 1536 };
|
||||
return { width: 1024, height: 1024 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the gpt-image-2 prompt for a character variant. The
|
||||
* style-prefix sets the visual register; the identity-anchor
|
||||
* instruction biases the model toward keeping face features
|
||||
* recognisable across the four variants of one batch.
|
||||
*
|
||||
* Caption / dialogue strings are deliberately left out — characters
|
||||
* are bare portraits, not panels with text.
|
||||
*/
|
||||
export function composeCharacterPrompt(
|
||||
style: ComicStyle,
|
||||
addPrompt: string | null | undefined
|
||||
): string {
|
||||
const parts: string[] = [
|
||||
STYLE_PREFIXES[style],
|
||||
'portrait of the user',
|
||||
'looking natural, head and shoulders visible',
|
||||
'neutral background, clear identity anchor — same face, same eyes, recognisable across panels',
|
||||
];
|
||||
const trimmed = addPrompt?.trim();
|
||||
if (trimmed) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
return parts.join('. ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N variants and append them to the character. Caller
|
||||
* passes the snapshot character (post-create), this function
|
||||
* mutates Dexie via `imagesStore.insert` + `comicCharactersStore.appendVariant`.
|
||||
*/
|
||||
export async function runCharacterGenerate(
|
||||
params: RunCharacterGenerateParams
|
||||
): Promise<RunCharacterGenerateResult> {
|
||||
const { character } = params;
|
||||
const count = Math.max(1, Math.min(4, params.count ?? 4));
|
||||
const quality = params.quality ?? 'medium';
|
||||
const size: CharacterSize = params.size ?? '1024x1024';
|
||||
const model: PanelModel = params.model ?? DEFAULT_PANEL_MODEL;
|
||||
|
||||
if (!character.sourceFaceMediaId) {
|
||||
throw new Error('Character braucht ein Source-Face-Bild.');
|
||||
}
|
||||
|
||||
const referenceMediaIds: string[] = [character.sourceFaceMediaId];
|
||||
if (character.sourceBodyMediaId) {
|
||||
referenceMediaIds.push(character.sourceBodyMediaId);
|
||||
}
|
||||
|
||||
const composed = composeCharacterPrompt(character.style, character.addPrompt);
|
||||
|
||||
const token = await authStore.getValidToken();
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: composed,
|
||||
referenceMediaIds,
|
||||
model,
|
||||
quality,
|
||||
size,
|
||||
n: count,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
detail?: string;
|
||||
required?: number;
|
||||
};
|
||||
if (res.status === 402) {
|
||||
throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new Error(
|
||||
'Source-Bilder im Server-Ownership-Check durchgefallen — Face-/Body-Refs fehlen im aktiven Space.'
|
||||
);
|
||||
}
|
||||
const label = body.error ?? `Character-Generierung fehlgeschlagen (${res.status})`;
|
||||
throw new Error(body.detail ? `${label}: ${body.detail}` : label);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
images?: Array<{ imageUrl: string; mediaId?: string }>;
|
||||
imageUrl?: string;
|
||||
mediaId?: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
// Normalise: the endpoint returns either `images: [...]` (n>=1
|
||||
// path) or a legacy `imageUrl + mediaId` flat shape. Both go
|
||||
// through the same persist loop below.
|
||||
const items =
|
||||
data.images && data.images.length > 0
|
||||
? data.images
|
||||
: data.imageUrl
|
||||
? [{ imageUrl: data.imageUrl, mediaId: data.mediaId }]
|
||||
: [];
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error('Keine Variant-Bilder zurückgegeben');
|
||||
}
|
||||
|
||||
const dims = dimsForSize(size);
|
||||
const variantMediaIds: string[] = [];
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
// Persist each variant in order — auto-pin auf erste Variant
|
||||
// passiert in `appendVariant` falls noch keine gepinnt ist, der
|
||||
// User kann später re-pinnen.
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (!item.imageUrl || !item.mediaId) continue;
|
||||
const localImageId = crypto.randomUUID();
|
||||
const nowIso = new Date().toISOString();
|
||||
const variantIndex = (character.variantMediaIds?.length ?? 0) + i;
|
||||
|
||||
await imagesStore.insert({
|
||||
id: localImageId,
|
||||
prompt: data.prompt,
|
||||
negativePrompt: null,
|
||||
model: data.model,
|
||||
publicUrl: item.imageUrl,
|
||||
storagePath: item.mediaId,
|
||||
filename: `comic-character-${character.id}-${variantIndex + 1}.png`,
|
||||
format: 'png',
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
visibility: 'private',
|
||||
isFavorite: false,
|
||||
downloadCount: 0,
|
||||
generationMode: 'reference',
|
||||
referenceImageIds: referenceMediaIds,
|
||||
comicCharacterId: character.id,
|
||||
createdAt: nowIso,
|
||||
});
|
||||
|
||||
await comicCharactersStore.appendVariant(character.id, localImageId);
|
||||
|
||||
variantMediaIds.push(localImageId);
|
||||
imageUrls.push(item.imageUrl);
|
||||
}
|
||||
|
||||
if (variantMediaIds.length === 0) {
|
||||
throw new Error('Server lieferte Bilder ohne mediaId — kein Variant gespeichert');
|
||||
}
|
||||
|
||||
return {
|
||||
variantMediaIds,
|
||||
imageUrls,
|
||||
prompt: data.prompt,
|
||||
model: data.model,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
/**
|
||||
* Panel generation client. Composes a reference-based image-edit call
|
||||
* against `/api/v1/picture/generate-with-reference` using the story's
|
||||
* fixed `characterMediaIds` plus the story-wide style-prefix, then
|
||||
* persists the result into `picture.images` with `comicStoryId` +
|
||||
* `comicPanelIndex` back-refs and appends the panel to the story via
|
||||
* `comicStoriesStore.appendPanel`.
|
||||
*
|
||||
* Same HTTP shape as `wardrobe/api/try-on.ts` — Comics reuse the
|
||||
* endpoint verbatim. Only difference: character refs come from the
|
||||
* story row (not reactively from useImageByPrimary), and the result
|
||||
* goes through appendPanel into the story's ordered panel list.
|
||||
*
|
||||
* Plan: docs/plans/comic-module.md M2.
|
||||
*/
|
||||
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
|
||||
import { comicStoriesStore } from '../stores/stories.svelte';
|
||||
import { composePanelPrompt } from '../styles';
|
||||
import type { ComicPanelMeta, ComicStory } from '../types';
|
||||
|
||||
/**
|
||||
* Panel size. 1024×1024 is the comic-default — square panels compose
|
||||
* into a strip or grid cleanly. 1024×1536 is available for verticaly-
|
||||
* oriented "Webtoon"-style long shots. The backend supports more but
|
||||
* M2 keeps the picker small.
|
||||
*/
|
||||
export type PanelSize = '1024x1024' | '1024x1536';
|
||||
|
||||
/**
|
||||
* Models that can drive panel rendering. Same closed set as
|
||||
* Wardrobe's Try-On picker so character consistency between a
|
||||
* user's outfit try-ons and their comic panels stays comparable
|
||||
* (different models ≈ different faces).
|
||||
*
|
||||
* - `openai/gpt-image-2` — existing default, mid-tier cost.
|
||||
* Server-side transparent fallback to gpt-image-1 for
|
||||
* unverified OpenAI orgs; see apps/api picture/routes.ts.
|
||||
* - `google/gemini-3-pro-image-preview` — Nano Banana Pro.
|
||||
* Strong character consistency across panels, higher cost.
|
||||
* - `google/gemini-3.1-flash-image-preview` — Nano Banana 2.
|
||||
* Newest + fast + cheap, good default for drafts.
|
||||
*
|
||||
* Credit tarifs are set by creditsFor() in picture/routes.ts.
|
||||
*/
|
||||
export type PanelModel =
|
||||
| 'openai/gpt-image-2'
|
||||
| 'google/gemini-3-pro-image-preview'
|
||||
| 'google/gemini-3.1-flash-image-preview';
|
||||
|
||||
export const DEFAULT_PANEL_MODEL: PanelModel = 'openai/gpt-image-2';
|
||||
|
||||
export interface RunPanelGenerateParams {
|
||||
story: ComicStory;
|
||||
panelPrompt: string;
|
||||
caption?: string;
|
||||
dialogue?: string;
|
||||
/** Tags the panel with the module-entry it was seeded from (M4 AI-
|
||||
* Storyboard). Ignored in M2 single-panel flow. */
|
||||
sourceInput?: ComicPanelMeta['sourceInput'];
|
||||
quality?: 'low' | 'medium' | 'high';
|
||||
size?: PanelSize;
|
||||
/** Rendering backend — defaults to `DEFAULT_PANEL_MODEL`. Mirrored
|
||||
* from Wardrobe so users can pick per-call without a story-level
|
||||
* schema change. See `PanelModelPicker.svelte`. */
|
||||
model?: PanelModel;
|
||||
}
|
||||
|
||||
export interface RunPanelGenerateResult {
|
||||
imageId: string;
|
||||
imageUrl: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
panelIndex: number;
|
||||
}
|
||||
|
||||
function dimsForSize(size: PanelSize): { width: number; height: number } {
|
||||
if (size === '1024x1536') return { width: 1024, height: 1536 };
|
||||
return { width: 1024, height: 1024 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared low-level POST. Mirrors wardrobe's callGenerateWithReference
|
||||
* so the error matrix stays identical across the two consumers of
|
||||
* this endpoint.
|
||||
*/
|
||||
async function callGenerateWithReference(opts: {
|
||||
prompt: string;
|
||||
referenceMediaIds: string[];
|
||||
quality: 'low' | 'medium' | 'high';
|
||||
size: PanelSize;
|
||||
model: PanelModel;
|
||||
}): Promise<{ imageUrl: string; mediaId: string; prompt: string; model: string }> {
|
||||
const token = await authStore.getValidToken();
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: opts.prompt,
|
||||
referenceMediaIds: opts.referenceMediaIds,
|
||||
model: opts.model,
|
||||
quality: opts.quality,
|
||||
size: opts.size,
|
||||
n: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
detail?: string;
|
||||
required?: number;
|
||||
missing?: string[];
|
||||
};
|
||||
if (res.status === 402) {
|
||||
throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new Error(
|
||||
'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — prüfe, ob Face/Body in diesem Space existieren.'
|
||||
);
|
||||
}
|
||||
const label = body.error ?? `Panel-Generierung fehlgeschlagen (${res.status})`;
|
||||
throw new Error(body.detail ? `${label}: ${body.detail}` : label);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
images?: Array<{ imageUrl: string; mediaId?: string }>;
|
||||
imageUrl?: string;
|
||||
mediaId?: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
};
|
||||
const first =
|
||||
(data.images && data.images[0]) ??
|
||||
(data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null);
|
||||
if (!first?.imageUrl || !first.mediaId) {
|
||||
throw new Error('Keine Bilder zurückgegeben');
|
||||
}
|
||||
return {
|
||||
imageUrl: first.imageUrl,
|
||||
mediaId: first.mediaId,
|
||||
prompt: data.prompt,
|
||||
model: data.model,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one panel for a story. The story provides the fixed
|
||||
* reference-image list (face + optional body + optional garments —
|
||||
* chosen once at story-create time); this call only adds the panel
|
||||
* prompt + caption + dialogue on top of the story's style prefix.
|
||||
*/
|
||||
export async function runPanelGenerate(
|
||||
params: RunPanelGenerateParams
|
||||
): Promise<RunPanelGenerateResult> {
|
||||
const { story, panelPrompt, caption, dialogue, sourceInput } = params;
|
||||
|
||||
if (story.characterMediaIds.length === 0) {
|
||||
throw new Error('Story hat keine Character-Referenz — bitte Face-Ref hinterlegen.');
|
||||
}
|
||||
if (!panelPrompt.trim()) {
|
||||
throw new Error('Panel-Prompt ist leer.');
|
||||
}
|
||||
|
||||
// Style-prefix + panelPrompt + caption/dialog hints, composed in
|
||||
// styles.ts. The backend never sees the style enum — it only sees
|
||||
// the final prompt string.
|
||||
const composedPrompt = composePanelPrompt({
|
||||
style: story.style,
|
||||
panelPrompt,
|
||||
caption,
|
||||
dialogue,
|
||||
});
|
||||
|
||||
const effectiveSize: PanelSize =
|
||||
params.size ?? (story.style === 'webtoon' ? '1024x1536' : '1024x1024');
|
||||
const effectiveQuality = params.quality ?? 'medium';
|
||||
const effectiveModel: PanelModel = params.model ?? DEFAULT_PANEL_MODEL;
|
||||
|
||||
// Cap at 8 references (server limit). If the story somehow has more
|
||||
// in its characterMediaIds (shouldn't — UI caps at ~5), truncate and
|
||||
// warn. Face-ref is [0] by convention.
|
||||
const referenceMediaIds = story.characterMediaIds.slice(0, 8);
|
||||
|
||||
const result = await callGenerateWithReference({
|
||||
prompt: composedPrompt,
|
||||
referenceMediaIds,
|
||||
quality: effectiveQuality,
|
||||
size: effectiveSize,
|
||||
model: effectiveModel,
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const localImageId = crypto.randomUUID();
|
||||
const dims = dimsForSize(effectiveSize);
|
||||
const panelIndex = story.panelImageIds.length; // zero-based
|
||||
|
||||
await imagesStore.insert({
|
||||
id: localImageId,
|
||||
prompt: result.prompt,
|
||||
negativePrompt: null,
|
||||
model: result.model,
|
||||
publicUrl: result.imageUrl,
|
||||
storagePath: result.mediaId,
|
||||
filename: `comic-panel-${story.id}-${panelIndex + 1}.png`,
|
||||
format: 'png',
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
visibility: 'private',
|
||||
isFavorite: false,
|
||||
downloadCount: 0,
|
||||
generationMode: 'reference',
|
||||
referenceImageIds: referenceMediaIds,
|
||||
comicStoryId: story.id,
|
||||
comicPanelIndex: panelIndex,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await comicStoriesStore.appendPanel(story.id, localImageId, {
|
||||
caption: caption?.trim() || undefined,
|
||||
dialogue: dialogue?.trim() || undefined,
|
||||
promptUsed: composedPrompt,
|
||||
sourceInput,
|
||||
});
|
||||
|
||||
return {
|
||||
imageId: localImageId,
|
||||
imageUrl: result.imageUrl,
|
||||
prompt: result.prompt,
|
||||
model: result.model,
|
||||
panelIndex,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
/**
|
||||
* Storyboard client. Calls `/api/v1/comic/storyboard` with the
|
||||
* decrypted source text (journal entry, note, library review,
|
||||
* writing draft, calendar event description) and the chosen style,
|
||||
* receives an ordered `Panel[]` suggestion that the user reviews +
|
||||
* edits before firing the batch-gen flow (M3).
|
||||
*
|
||||
* Cross-module decrypt stays client-side — the browser loads the
|
||||
* source module's row, passes it through its own decryptor, and
|
||||
* hands us plaintext. No Key-Grants / server-side decrypts involved
|
||||
* (matches the plan §6 decision: M4 is interactive client-side).
|
||||
*
|
||||
* Plan: docs/plans/comic-module.md M4.
|
||||
*/
|
||||
|
||||
import { getManaApiUrl } from '$lib/api/config';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import type { ComicStyle } from '../types';
|
||||
|
||||
export type StoryboardSourceModule = 'journal' | 'notes' | 'library' | 'writing' | 'calendar';
|
||||
|
||||
export interface StoryboardPanel {
|
||||
prompt: string;
|
||||
caption?: string;
|
||||
dialogue?: string;
|
||||
}
|
||||
|
||||
export interface SuggestPanelsParams {
|
||||
style: ComicStyle;
|
||||
sourceText: string;
|
||||
panelCount: number;
|
||||
/** Story-level briefing the author typed when creating the story.
|
||||
* Gets prepended server-side so Claude knows the tonal register. */
|
||||
storyContext?: string | null;
|
||||
/** Logged for observability only — not sent to the LLM. */
|
||||
sourceModule?: StoryboardSourceModule;
|
||||
}
|
||||
|
||||
export interface SuggestPanelsResult {
|
||||
panels: StoryboardPanel[];
|
||||
model: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export async function suggestPanels(params: SuggestPanelsParams): Promise<SuggestPanelsResult> {
|
||||
const token = await authStore.getValidToken();
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/comic/storyboard`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
style: params.style,
|
||||
sourceText: params.sourceText,
|
||||
panelCount: params.panelCount,
|
||||
storyContext: params.storyContext,
|
||||
sourceModule: params.sourceModule,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string; detail?: string };
|
||||
const label = body.error ?? `Storyboard fehlgeschlagen (${res.status})`;
|
||||
throw new Error(body.detail ? `${label}: ${body.detail}` : label);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as SuggestPanelsResult;
|
||||
if (!Array.isArray(data.panels) || data.panels.length === 0) {
|
||||
throw new Error('Keine Panels vom Modell zurück — versuche es mit anderem Text oder Stil.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* Comic module — Dexie table accessors.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalComicStory, LocalComicCharacter } from './types';
|
||||
|
||||
export const comicStoriesTable = db.table<LocalComicStory>('comicStories');
|
||||
export const comicCharactersTable = db.table<LocalComicCharacter>('comicCharacters');
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
/**
|
||||
* 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, LocalComicCharacter, 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);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
<!--
|
||||
BatchPanelEditor — compose 2..N panels in one go. All entries share
|
||||
the story's style prefix + character refs; the user writes the
|
||||
per-panel prompt/caption/dialog in stacked cards. On submit we fire
|
||||
parallel `runPanelGenerate()` calls via `Promise.allSettled` so one
|
||||
failure doesn't block the others, and render per-panel progress +
|
||||
retry chips below the form.
|
||||
|
||||
The batch executor is a thin layer on top of the M2 single-panel
|
||||
flow: each row goes through the identical HTTP path, appendPanel,
|
||||
and picture.images write, so there's no divergence between single
|
||||
and batch outputs.
|
||||
|
||||
Plan: docs/plans/comic-module.md M3.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
CheckCircle,
|
||||
Plus,
|
||||
Sparkle,
|
||||
SpinnerGap,
|
||||
Trash,
|
||||
WarningCircle,
|
||||
X,
|
||||
} from '@mana/shared-icons';
|
||||
import {
|
||||
runPanelGenerate,
|
||||
DEFAULT_PANEL_MODEL,
|
||||
type PanelModel,
|
||||
type PanelSize,
|
||||
} from '../api/generate-panel';
|
||||
import { MAX_PANELS_PER_STORY, PANEL_COUNT_WARN_THRESHOLD } from '../constants';
|
||||
import type { ComicStory } from '../types';
|
||||
import PanelModelPicker from './PanelModelPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
story: ComicStory;
|
||||
onClose: () => void;
|
||||
onGenerated?: (panelIds: string[]) => void;
|
||||
}
|
||||
|
||||
let { story, onClose, onGenerated }: Props = $props();
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
const QUALITIES: readonly Quality[] = ['low', 'medium', 'high'] as const;
|
||||
const CREDIT_COST: Record<Quality, number> = { low: 3, medium: 10, high: 25 };
|
||||
|
||||
// Max entries per batch — plan cap. N=4 balances "write a short comic
|
||||
// in one sitting" against "one failure takes out too many credits".
|
||||
const MAX_BATCH = 4;
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
prompt: string;
|
||||
caption: string;
|
||||
dialogue: string;
|
||||
}
|
||||
|
||||
function emptyRow(): Row {
|
||||
return { id: crypto.randomUUID(), prompt: '', caption: '', dialogue: '' };
|
||||
}
|
||||
|
||||
let rows = $state<Row[]>([emptyRow(), emptyRow()]);
|
||||
let quality = $state<Quality>('medium');
|
||||
let model = $state<PanelModel>(DEFAULT_PANEL_MODEL);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let size = $state<PanelSize>(story.style === 'webtoon' ? '1024x1536' : '1024x1024');
|
||||
|
||||
// Per-row execution state — mirrors `rows` by id so we can render
|
||||
// chips during/after submit without touching the input fields.
|
||||
type RowStatus = 'idle' | 'pending' | 'ok' | 'error';
|
||||
let rowStatus = $state<Record<string, { status: RowStatus; error?: string }>>({});
|
||||
|
||||
let submitting = $state(false);
|
||||
|
||||
const panelCount = $derived(story.panelImageIds.length);
|
||||
const roomLeft = $derived(Math.max(0, MAX_PANELS_PER_STORY - panelCount));
|
||||
const effectiveRows = $derived(rows.slice(0, Math.min(MAX_BATCH, roomLeft)));
|
||||
|
||||
const filledRows = $derived(effectiveRows.filter((r) => r.prompt.trim().length > 0));
|
||||
const canAdd = $derived(rows.length < MAX_BATCH && rows.length < roomLeft);
|
||||
const canSubmit = $derived(filledRows.length > 0 && !submitting && roomLeft > 0);
|
||||
|
||||
const warn = $derived(
|
||||
panelCount + filledRows.length >= PANEL_COUNT_WARN_THRESHOLD &&
|
||||
panelCount + filledRows.length <= MAX_PANELS_PER_STORY
|
||||
);
|
||||
const atCap = $derived(roomLeft === 0);
|
||||
|
||||
const totalCost = $derived(CREDIT_COST[quality] * filledRows.length);
|
||||
|
||||
function addRow() {
|
||||
if (!canAdd) return;
|
||||
rows.push(emptyRow());
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
if (rows.length <= 1) return;
|
||||
rows = rows.filter((r) => r.id !== id);
|
||||
delete rowStatus[id];
|
||||
}
|
||||
|
||||
async function submitRow(row: Row): Promise<string | null> {
|
||||
rowStatus[row.id] = { status: 'pending' };
|
||||
try {
|
||||
const result = await runPanelGenerate({
|
||||
story,
|
||||
panelPrompt: row.prompt,
|
||||
caption: row.caption.trim() || undefined,
|
||||
dialogue: row.dialogue.trim() || undefined,
|
||||
quality,
|
||||
size,
|
||||
model,
|
||||
});
|
||||
rowStatus[row.id] = { status: 'ok' };
|
||||
return result.imageId;
|
||||
} catch (err) {
|
||||
rowStatus[row.id] = {
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
submitting = true;
|
||||
|
||||
// Re-init status so a retry-submit doesn't leak old chip state.
|
||||
rowStatus = {};
|
||||
|
||||
// Promise.allSettled preserves each row's outcome independently —
|
||||
// a 402 Credits-Error on row 2 won't cancel rows 3+4. The story's
|
||||
// `panelImageIds` grows in whatever order the calls resolve; the
|
||||
// user can manually reorder panels in M5+ if needed.
|
||||
const submissions = filledRows.map((r) => submitRow(r));
|
||||
const results = await Promise.allSettled(submissions);
|
||||
|
||||
submitting = false;
|
||||
|
||||
const successfulIds = results
|
||||
.map((r) => (r.status === 'fulfilled' ? r.value : null))
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
onGenerated?.(successfulIds);
|
||||
|
||||
// Clear successful rows so the user can type the next batch
|
||||
// without them reappearing; keep failed rows filled for retry.
|
||||
const failedIds = Object.entries(rowStatus)
|
||||
.filter(([, s]) => s.status === 'error')
|
||||
.map(([id]) => id);
|
||||
if (failedIds.length === 0) {
|
||||
rows = [emptyRow(), emptyRow()];
|
||||
rowStatus = {};
|
||||
} else {
|
||||
rows = rows.filter((r) => failedIds.includes(r.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function retryRow(row: Row) {
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
await submitRow(row);
|
||||
submitting = false;
|
||||
if (rowStatus[row.id]?.status === 'ok') {
|
||||
// Strip successful row out.
|
||||
rows = rows.filter((r) => r.id !== row.id);
|
||||
delete rowStatus[row.id];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<header class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">Batch-Panels</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{filledRows.length}
|
||||
{filledRows.length === 1 ? 'Panel' : 'Panels'} · {story.characterMediaIds.length} Referenz{story
|
||||
.characterMediaIds.length === 1
|
||||
? ''
|
||||
: 'en'} · {roomLeft}
|
||||
{roomLeft === 1 ? 'Slot' : 'Slots'} frei
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Batch-Editor schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if atCap}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
|
||||
role="alert"
|
||||
>
|
||||
Die Story ist am {MAX_PANELS_PER_STORY}-Panel-Limit. Entferne ältere Panels oder lege eine
|
||||
neue Story an.
|
||||
</div>
|
||||
{:else if warn}
|
||||
<p class="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz spürbar schwerer.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="mt-3 space-y-3"
|
||||
class:pointer-events-none={atCap}
|
||||
class:opacity-60={atCap}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
{#each effectiveRows as row, index (row.id)}
|
||||
{@const status = rowStatus[row.id]}
|
||||
<div class="rounded-lg border border-border bg-background p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] text-foreground"
|
||||
>
|
||||
{panelCount + index + 1}
|
||||
</span>
|
||||
<span>Panel {index + 1}</span>
|
||||
{#if status?.status === 'pending'}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-primary"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<SpinnerGap size={12} class="spinner" weight="bold" />
|
||||
Wird generiert…
|
||||
</span>
|
||||
{:else if status?.status === 'ok'}
|
||||
<span class="inline-flex items-center gap-1 text-primary">
|
||||
<CheckCircle size={12} weight="fill" />
|
||||
Fertig
|
||||
</span>
|
||||
{:else if status?.status === 'error'}
|
||||
<span class="inline-flex items-center gap-1 text-error">
|
||||
<WarningCircle size={12} weight="fill" />
|
||||
Fehlgeschlagen
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if status?.status === 'error'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => retryRow(row)}
|
||||
disabled={submitting}
|
||||
class="text-[11px] font-medium text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
Neu versuchen
|
||||
</button>
|
||||
{/if}
|
||||
{#if rows.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeRow(row.id)}
|
||||
disabled={submitting}
|
||||
class="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-error disabled:opacity-50"
|
||||
aria-label="Zeile entfernen"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
bind:value={row.prompt}
|
||||
rows={2}
|
||||
placeholder="Was passiert in diesem Panel?"
|
||||
maxlength={600}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
></textarea>
|
||||
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={row.caption}
|
||||
placeholder="Caption (optional)"
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={row.dialogue}
|
||||
placeholder="Dialog (optional)"
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if status?.status === 'error' && status.error}
|
||||
<p class="mt-2 text-[11px] text-error" role="alert">{status.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={addRow}
|
||||
disabled={!canAdd || submitting}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Weiteres Panel ({rows.length}/{Math.min(MAX_BATCH, roomLeft)})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PanelModelPicker value={model} onChange={(m) => (model = m)} disabled={submitting} />
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Qualität:</span>
|
||||
{#each QUALITIES as q (q)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (quality = q)}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{quality === q
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={quality === q}
|
||||
>
|
||||
{q} ({CREDIT_COST[q]}c)
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Format:</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1024')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1024'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={size === '1024x1024'}
|
||||
>
|
||||
Quadrat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1536')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1536'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={size === '1024x1536'}
|
||||
>
|
||||
Hoch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<SpinnerGap size={14} class="spinner" weight="bold" />
|
||||
{filledRows.length}
|
||||
{filledRows.length === 1 ? 'Panel' : 'Panels'} werden generiert…
|
||||
{:else}
|
||||
<Sparkle size={14} />
|
||||
{filledRows.length}
|
||||
{filledRows.length === 1 ? 'Panel' : 'Panels'} generieren ({totalCost}c)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.spinner) {
|
||||
animation: panel-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes panel-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
<!--
|
||||
CharacterBuilder — Source picken, Stil picken, Add-Prompt, dann
|
||||
4 Varianten in einem Batch generieren. Im Detail-View des
|
||||
Characters wird derselbe Builder als „Mehr Varianten generieren"
|
||||
wieder benutzt (mit pre-selected Source + Style aus dem Character).
|
||||
|
||||
Two modes:
|
||||
- "create" — Builder erstellt erst die Character-Row (Name +
|
||||
Stil + Source + AddPrompt), dann den ersten Variant-Batch.
|
||||
- "extend" — Character existiert schon; Builder feuert nur
|
||||
weitere Variants und schreibt sie in den existierenden
|
||||
Character.
|
||||
|
||||
Variant-Generierung läuft synchron als ein Server-Call mit
|
||||
n=4 (gpt-image-2-Server-Cap). User wartet ~30-60s auf alle 4
|
||||
Bilder gleichzeitig.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Sparkle, SpinnerGap, X } from '@mana/shared-icons';
|
||||
import { useImageByPrimary } from '$lib/modules/profile/queries';
|
||||
import { comicCharactersStore } from '../stores/characters.svelte';
|
||||
import { runCharacterGenerate } from '../api/generate-character';
|
||||
import { DEFAULT_PANEL_MODEL, type PanelModel } from '../api/generate-panel';
|
||||
import type { ComicCharacter, ComicStyle } from '../types';
|
||||
import StylePicker from './StylePicker.svelte';
|
||||
import PanelModelPicker from './PanelModelPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
/** When set, builder runs in "extend" mode for an existing
|
||||
* character — name+style+source are locked, only Add-Prompt
|
||||
* is editable per generation. */
|
||||
existing?: ComicCharacter;
|
||||
/** Optional pre-fills for create-mode — used by the wardrobe-
|
||||
* hook (Mc5) to seed an addPrompt like "wearing the
|
||||
* Bühnenoutfit" when the user clicks "Als Comic-Character"
|
||||
* on a Wardrobe-Outfit. Ignored in extend-mode. */
|
||||
initialName?: string;
|
||||
initialAddPrompt?: string;
|
||||
initialStyle?: ComicStyle;
|
||||
/** Called after the first successful variant batch with the
|
||||
* resulting character id, so the parent route can navigate. */
|
||||
onCreated?: (characterId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { existing, initialName, initialAddPrompt, initialStyle, onClose, onCreated }: Props =
|
||||
$props();
|
||||
|
||||
const isExtend = $derived(Boolean(existing));
|
||||
|
||||
// Builder state. In extend-mode all of these come from `existing`
|
||||
// at mount time and aren't editable; in create-mode the user fills
|
||||
// them in (with optional pre-fills from URL-params via the route
|
||||
// page wrapper). Init-time read is intentional — the
|
||||
// character is always remounted via {#key} when the route id
|
||||
// changes, so capturing the snapshot here is correct.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let name = $state(existing?.name ?? initialName ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let style = $state<ComicStyle>(existing?.style ?? initialStyle ?? 'comic');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let addPrompt = $state(existing?.addPrompt ?? initialAddPrompt ?? '');
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
const QUALITIES: readonly Quality[] = ['low', 'medium', 'high'] as const;
|
||||
const CREDIT_COST: Record<Quality, number> = { low: 3, medium: 10, high: 25 };
|
||||
let quality = $state<Quality>('medium');
|
||||
let model = $state<PanelModel>(DEFAULT_PANEL_MODEL);
|
||||
|
||||
const face$ = useImageByPrimary('face-ref');
|
||||
const body$ = useImageByPrimary('body-ref');
|
||||
const face = $derived(face$.value);
|
||||
const body = $derived(body$.value);
|
||||
|
||||
const hasFace = $derived(Boolean(existing?.sourceFaceMediaId || face?.mediaId));
|
||||
const sourceFaceMediaId = $derived(existing?.sourceFaceMediaId ?? face?.mediaId ?? null);
|
||||
const sourceBodyMediaId = $derived(existing?.sourceBodyMediaId ?? body?.mediaId ?? null);
|
||||
|
||||
let useBodyRef = $state(true); // toggle in create-mode
|
||||
|
||||
let busy = $state(false);
|
||||
let errorMsg = $state<string | null>(null);
|
||||
|
||||
const VARIANT_COUNT = 4;
|
||||
const totalCost = $derived(CREDIT_COST[quality] * VARIANT_COUNT);
|
||||
|
||||
const canSubmit = $derived(
|
||||
!busy && hasFace && (isExtend || name.trim().length > 0) // create-mode requires a name
|
||||
);
|
||||
|
||||
async function handleGenerate(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit || !sourceFaceMediaId) return;
|
||||
busy = true;
|
||||
errorMsg = null;
|
||||
try {
|
||||
let character: ComicCharacter;
|
||||
if (existing) {
|
||||
character = existing;
|
||||
// Optionally update addPrompt on the existing character
|
||||
// so future "Mehr Varianten"-Calls remember the latest.
|
||||
if (addPrompt.trim() !== (existing.addPrompt ?? '')) {
|
||||
await comicCharactersStore.updateCharacter(existing.id, {
|
||||
addPrompt: addPrompt.trim() || null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
character = await comicCharactersStore.createCharacter({
|
||||
name: name.trim(),
|
||||
style,
|
||||
sourceFaceMediaId,
|
||||
sourceBodyMediaId: useBodyRef ? sourceBodyMediaId : null,
|
||||
addPrompt: addPrompt.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
await runCharacterGenerate({
|
||||
character,
|
||||
count: VARIANT_COUNT,
|
||||
quality,
|
||||
model,
|
||||
});
|
||||
|
||||
busy = false;
|
||||
onCreated?.(character.id);
|
||||
if (!isExtend) {
|
||||
await goto(`/comic/character/${character.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg = err instanceof Error ? err.message : 'Variant-Generierung fehlgeschlagen';
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<header class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{isExtend ? 'Mehr Varianten generieren' : 'Neuer Character'}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{isExtend
|
||||
? `Erweitert "${existing?.name}" um ${VARIANT_COUNT} weitere Varianten — gleicher Stil, gleiche Source.`
|
||||
: `Erstellt einen Character und rendert direkt ${VARIANT_COUNT} Varianten zur Auswahl.`}
|
||||
</p>
|
||||
</div>
|
||||
{#if onClose}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<form onsubmit={handleGenerate} class="space-y-4">
|
||||
{#if !isExtend}
|
||||
<!-- Name -->
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="character-name"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="character-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="Manga-Me, Cartoon-Casual, Action-Pose-Me…"
|
||||
maxlength={120}
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Style picker -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Stil
|
||||
</div>
|
||||
<StylePicker value={style} onChange={(next) => (style = next)} disabled={busy} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add-Prompt -->
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="character-add-prompt"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Zusätzlicher Prompt
|
||||
<span class="font-normal normal-case text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="character-add-prompt"
|
||||
type="text"
|
||||
bind:value={addPrompt}
|
||||
placeholder="z.B. freundlicher Ausdruck, casual outfit, action pose"
|
||||
maxlength={200}
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
disabled={busy}
|
||||
/>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Englisch rendert stabiler. Wird auf alle {VARIANT_COUNT} Varianten in dieser Runde angewendet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !hasFace}
|
||||
<div class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error" role="alert">
|
||||
Kein Gesichtsbild im aktiven Space. Lade eines in
|
||||
<a href="/profile/me-images" class="underline hover:no-underline">Profil → Bilder</a>
|
||||
hoch — ohne Face-Ref kann kein Character generiert werden.
|
||||
</div>
|
||||
{:else if !isExtend}
|
||||
<!-- Source preview + body toggle -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Quelle
|
||||
</div>
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
{#if face?.publicUrl}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="h-20 w-20 overflow-hidden rounded-md border-2 border-primary/40">
|
||||
<img
|
||||
src={face.thumbnailUrl ?? face.publicUrl}
|
||||
alt="Face-Ref"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] font-medium text-muted-foreground">Face</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if body?.publicUrl}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (useBodyRef = !useBodyRef)}
|
||||
disabled={busy}
|
||||
class="group relative h-20 w-20 overflow-hidden rounded-md border-2 transition-all
|
||||
{useBodyRef
|
||||
? 'border-primary shadow-sm shadow-primary/20'
|
||||
: 'border-border opacity-60 hover:border-primary/50 hover:opacity-100'}"
|
||||
aria-pressed={useBodyRef}
|
||||
title={useBodyRef ? 'Body-Ref entfernen' : 'Body-Ref hinzufügen'}
|
||||
>
|
||||
<img
|
||||
src={body.thumbnailUrl ?? body.publicUrl}
|
||||
alt="Body-Ref"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-[10px] font-medium text-muted-foreground">Body</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<PanelModelPicker value={model} onChange={(m) => (model = m)} disabled={busy} />
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Qualität:</span>
|
||||
{#each QUALITIES as q (q)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (quality = q)}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{quality === q
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={busy}
|
||||
aria-pressed={quality === q}
|
||||
>
|
||||
{q} ({CREDIT_COST[q]}c)
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
|
||||
role="alert"
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if busy}
|
||||
<SpinnerGap size={14} class="spinner" weight="bold" />
|
||||
{VARIANT_COUNT} Varianten werden gerendert…
|
||||
{:else}
|
||||
<Sparkle size={14} />
|
||||
{VARIANT_COUNT} Varianten generieren ({totalCost}c)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.spinner) {
|
||||
animation: char-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes char-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
<!--
|
||||
Grid tile for a comic-character. Cover = pinned variant (or first
|
||||
variant if none pinned yet — happens during build). Stories made
|
||||
with this character snapshot the pinned mediaId at create time
|
||||
(re-pinning later doesn't rewrite their refs).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Heart, Sparkle } from '@mana/shared-icons';
|
||||
import { STYLE_LABELS } from '../constants';
|
||||
import { usePanelImage } from '../queries';
|
||||
import { characterCoverVariantId, type ComicCharacter } from '../types';
|
||||
|
||||
interface Props {
|
||||
character: ComicCharacter;
|
||||
}
|
||||
|
||||
let { character }: Props = $props();
|
||||
|
||||
const coverId = $derived(characterCoverVariantId(character));
|
||||
// svelte-ignore state_referenced_locally
|
||||
const cover$ = usePanelImage(coverId);
|
||||
const cover = $derived(cover$.value);
|
||||
|
||||
const variantCount = $derived(character.variantMediaIds.length);
|
||||
const isPinned = $derived(Boolean(character.pinnedVariantId));
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/comic/character/{character.id}"
|
||||
class="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div class="relative aspect-square overflow-hidden bg-muted">
|
||||
{#if cover?.publicUrl}
|
||||
<img
|
||||
src={cover.publicUrl}
|
||||
alt={character.name}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-muted to-muted/50 text-muted-foreground"
|
||||
>
|
||||
<Sparkle size={24} />
|
||||
<span class="text-xs">Noch keine Variante</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="absolute bottom-2 left-2 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-medium text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
{STYLE_LABELS[character.style].de}
|
||||
</span>
|
||||
|
||||
{#if character.isFavorite}
|
||||
<span class="absolute right-2 top-2 text-rose-500" aria-label="Favorit">
|
||||
<Heart size={14} weight="fill" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if !isPinned && variantCount > 0}
|
||||
<span
|
||||
class="absolute right-2 bottom-2 rounded-full bg-amber-500/90 px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm backdrop-blur"
|
||||
title="Kein Variant gepinned — wird beim Story-Create blockiert"
|
||||
>
|
||||
Pin offen
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5 px-3 py-2">
|
||||
<h3 class="truncate text-sm font-medium text-foreground">{character.name}</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{variantCount}
|
||||
{variantCount === 1 ? 'Variante' : 'Varianten'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
<!--
|
||||
CharacterPicker — selects the reference-image set that every panel
|
||||
in the story renders against. At minimum: primary face-ref from the
|
||||
active space's meImages. Optional: primary body-ref (for full-body framing).
|
||||
|
||||
Outputs: `value: string[]` (mediaIds, face-ref at [0]). Emits via
|
||||
`onChange` on every add/remove.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus, X, UserCircle, TShirt } from '@mana/shared-icons';
|
||||
import { useImageByPrimary } from '$lib/modules/profile/queries';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onChange, disabled = false }: Props = $props();
|
||||
|
||||
const face$ = useImageByPrimary('face-ref');
|
||||
const body$ = useImageByPrimary('body-ref');
|
||||
|
||||
const face = $derived(face$.value);
|
||||
const body = $derived(body$.value);
|
||||
|
||||
// Auto-seed face-ref at position [0] the first time it becomes
|
||||
// available and value is still empty. After that, mutations go
|
||||
// through the Add/Remove buttons.
|
||||
let seeded = false;
|
||||
$effect(() => {
|
||||
if (!seeded && face?.mediaId && value.length === 0) {
|
||||
seeded = true;
|
||||
onChange([face.mediaId]);
|
||||
}
|
||||
});
|
||||
|
||||
const hasFace = $derived(Boolean(face?.mediaId));
|
||||
const hasBody = $derived(Boolean(body?.mediaId));
|
||||
|
||||
const bodyInValue = $derived(body?.mediaId ? value.includes(body.mediaId) : false);
|
||||
|
||||
function toggleBody() {
|
||||
if (!body?.mediaId) return;
|
||||
if (bodyInValue) {
|
||||
onChange(value.filter((id) => id !== body.mediaId));
|
||||
} else {
|
||||
const next = [...value];
|
||||
const insertAt = face?.mediaId && next[0] === face.mediaId ? 1 : 0;
|
||||
next.splice(insertAt, 0, body.mediaId);
|
||||
onChange(next);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{$_('comic.picker.section_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
<!-- Face ref tile — mandatory, not deselectable. -->
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
{#if face?.publicUrl}
|
||||
<div
|
||||
class="relative h-20 w-20 overflow-hidden rounded-md border-2 border-primary/40"
|
||||
title={$_('comic.picker.face_required_title')}
|
||||
>
|
||||
<img
|
||||
src={face.thumbnailUrl ?? face.publicUrl}
|
||||
alt={$_('comic.picker.face_alt')}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent px-1 py-0.5 text-center text-[9px] font-semibold uppercase tracking-wider text-white"
|
||||
>
|
||||
{$_('comic.picker.face_required_badge')}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-muted/50 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<UserCircle size={20} />
|
||||
<span>{$_('comic.picker.face_missing')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-[10px] font-medium text-muted-foreground"
|
||||
>{$_('comic.picker.face_label')}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Body ref tile — optional toggle. -->
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
{#if body?.publicUrl}
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
onclick={toggleBody}
|
||||
class="group relative h-20 w-20 overflow-hidden rounded-md border-2 transition-all active:translate-y-px
|
||||
{bodyInValue
|
||||
? 'border-primary shadow-sm shadow-primary/20'
|
||||
: 'border-border opacity-60 hover:border-primary/50 hover:opacity-100 hover:shadow-sm'}"
|
||||
aria-pressed={bodyInValue}
|
||||
title={bodyInValue ? $_('comic.picker.toggle_remove') : $_('comic.picker.toggle_add')}
|
||||
>
|
||||
<img
|
||||
src={body.thumbnailUrl ?? body.publicUrl}
|
||||
alt={$_('comic.picker.body_alt')}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{#if !bodyInValue}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-background/50 text-foreground"
|
||||
>
|
||||
<Plus size={20} weight="bold" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-error/0 text-white opacity-0 transition-all group-hover:bg-error/60 group-hover:opacity-100"
|
||||
>
|
||||
<X size={20} weight="bold" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border bg-muted/30 text-[10px] text-muted-foreground"
|
||||
title={$_('comic.picker.body_no_in_space')}
|
||||
>
|
||||
<UserCircle size={18} />
|
||||
<span>{$_('comic.picker.body_missing')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-[10px] font-medium text-muted-foreground"
|
||||
>{$_('comic.picker.body_label')}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasFace}
|
||||
<div class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error" role="alert">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html $_('comic.picker.no_face_alert_html')}
|
||||
</div>
|
||||
{:else if !hasBody}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<TShirt size={12} class="inline" />
|
||||
{$_('comic.picker.body_tip')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
<!--
|
||||
CharacterRefPicker — Mc3-Replacement für CharacterPicker beim
|
||||
Story-Create.
|
||||
|
||||
Two modes:
|
||||
1. **character-mode** (default wenn Characters existieren):
|
||||
Grid existierender Comic-Characters (filterbar nach Stil).
|
||||
Pick → die pinnedVariantMediaId ist die einzige
|
||||
Story-Character-Ref. "+ Neuer Character" navigiert mit
|
||||
Return-URL zur Builder-Route.
|
||||
2. **quick-mode** (Toggle, oder default wenn keine Characters):
|
||||
Fällt zurück auf das alte Pattern: face-ref + body-ref +
|
||||
optional Wardrobe-Garments. Für "mal eben schnell aus dem
|
||||
Tagebuch ohne Setup".
|
||||
|
||||
Output ist die gleiche `mediaIds: string[]`-Form wie der alte
|
||||
CharacterPicker — der Story-Store bekommt am Ende die gleiche
|
||||
Struktur und runPanelGenerate kennt seinen Pfad nicht mal. Die
|
||||
Story bekommt zusätzlich `characterId` (für Display + Click-
|
||||
Through) wenn character-mode genutzt wurde.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus, UserCircle, Sparkle, Wrench } from '@mana/shared-icons';
|
||||
import { useImageByPrimary } from '$lib/modules/profile/queries';
|
||||
import { useAllCharacters } from '../queries';
|
||||
import { STYLE_LABELS } from '../constants';
|
||||
import { usePanelImage } from '../queries';
|
||||
import CharacterPicker from './CharacterPicker.svelte';
|
||||
import type { ComicCharacter } from '../types';
|
||||
|
||||
interface Props {
|
||||
/** Selected character (in character-mode) — null in quick-mode. */
|
||||
selectedCharacterId: string | null;
|
||||
/** mediaIds the renderer will use as references. In character-
|
||||
* mode this is `[pinnedVariantMediaId]`. In quick-mode it's
|
||||
* the old face/body/garment list. */
|
||||
referenceMediaIds: string[];
|
||||
onChange: (next: { characterId: string | null; referenceMediaIds: string[] }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { selectedCharacterId, referenceMediaIds, onChange, disabled = false }: Props = $props();
|
||||
|
||||
const characters$ = useAllCharacters();
|
||||
const characters = $derived(characters$.value ?? []);
|
||||
|
||||
// Filter out archived/in-progress (no pinned variant) characters —
|
||||
// can't render a story without a pinnedVariantMediaId.
|
||||
const usableCharacters = $derived(characters.filter((c) => !c.isArchived && c.pinnedVariantId));
|
||||
|
||||
const face$ = useImageByPrimary('face-ref');
|
||||
const hasFace = $derived(Boolean(face$.value?.mediaId));
|
||||
|
||||
type Mode = 'character' | 'quick';
|
||||
// Default: character mode if there's at least one usable character,
|
||||
// else quick mode (so first-time users aren't gated on Character-Setup).
|
||||
// Init-time read of `usableCharacters` is intentional — we want to
|
||||
// pick a sensible default once and let the user toggle afterwards.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let mode = $state<Mode>(usableCharacters.length > 0 ? 'character' : 'quick');
|
||||
|
||||
// If user came in with no selection but there ARE characters, auto-flip
|
||||
// to character-mode after first liveQuery hit.
|
||||
let initialModeSet = false;
|
||||
$effect(() => {
|
||||
if (!initialModeSet && characters$.value !== null) {
|
||||
initialModeSet = true;
|
||||
if (usableCharacters.length === 0 && mode === 'character') {
|
||||
mode = 'quick';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function pickCharacter(c: ComicCharacter) {
|
||||
if (!c.pinnedVariantId) return;
|
||||
onChange({
|
||||
characterId: c.id,
|
||||
referenceMediaIds: [c.pinnedVariantId],
|
||||
});
|
||||
}
|
||||
|
||||
function handleQuickModeChange(next: string[]) {
|
||||
onChange({ characterId: null, referenceMediaIds: next });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Mode toggle (only when both modes are available) -->
|
||||
{#if usableCharacters.length > 0}
|
||||
<div class="flex items-center gap-1 rounded-md border border-border bg-background p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center justify-center gap-1.5 rounded-sm px-3 py-1.5 text-xs font-medium transition-colors
|
||||
{mode === 'character'
|
||||
? 'bg-primary/10 text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => (mode = 'character')}
|
||||
{disabled}
|
||||
aria-pressed={mode === 'character'}
|
||||
>
|
||||
<Sparkle size={12} />
|
||||
Character
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center justify-center gap-1.5 rounded-sm px-3 py-1.5 text-xs font-medium transition-colors
|
||||
{mode === 'quick'
|
||||
? 'bg-primary/10 text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => (mode = 'quick')}
|
||||
{disabled}
|
||||
aria-pressed={mode === 'quick'}
|
||||
>
|
||||
<Wrench size={12} />
|
||||
Quick (Roh-Modus)
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'character'}
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Comic-Character wählen
|
||||
</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">
|
||||
Iterier vorher einen Character mit deinem Stil — alle Panels nutzen dann denselben
|
||||
gepinnten Look.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if usableCharacters.length === 0}
|
||||
{#if !hasFace}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/5 p-3 text-xs text-error"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<UserCircle size={14} class="mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
Kein Face-Ref im aktiven Space. Lade eines in
|
||||
<a href="/profile/me-images" class="underline hover:no-underline">Profil → Bilder</a
|
||||
>
|
||||
hoch — ohne Face-Ref kann kein Character gebaut werden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border bg-background/50 p-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
<p class="mb-2 font-medium text-foreground">Noch keine Characters mit Pin.</p>
|
||||
<p>
|
||||
Bau einen Comic-Character aus deinem Foto — Stil wählen, 4 Varianten generieren, beste
|
||||
pinnen.
|
||||
</p>
|
||||
<a
|
||||
href="/comic/character/new"
|
||||
class="mt-3 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Character bauen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each usableCharacters as character (character.id)}
|
||||
{@const isSelected = selectedCharacterId === character.id}
|
||||
{@const cover$ = usePanelImage(character.pinnedVariantId ?? null)}
|
||||
{@const cover = cover$.value}
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
onclick={() => pickCharacter(character)}
|
||||
class="group flex flex-col overflow-hidden rounded-lg border-2 transition-all active:translate-y-px
|
||||
{isSelected
|
||||
? 'border-primary shadow-md shadow-primary/20'
|
||||
: 'border-border hover:border-primary/40 hover:shadow-sm'}"
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<div class="relative aspect-square overflow-hidden bg-muted">
|
||||
{#if cover?.publicUrl}
|
||||
<img
|
||||
src={cover.publicUrl}
|
||||
alt={character.name}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
<Sparkle size={20} />
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="absolute bottom-1 left-1 rounded-full bg-background/90 px-1.5 py-0.5 text-[9px] font-medium text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
{STYLE_LABELS[character.style].de}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-2 py-1.5 text-left">
|
||||
<p class="truncate text-xs font-medium text-foreground">{character.name}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<a
|
||||
href="/comic/character/new"
|
||||
class="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border bg-background text-muted-foreground transition-all hover:border-primary/50 hover:bg-primary/5 hover:text-foreground hover:shadow-sm"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span class="text-[11px] font-medium">Neuer Character</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Quick-Modus: das alte Face-/Body-/Garment-Picker-Pattern -->
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Quick-Modus (Roh-Refs)
|
||||
</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">
|
||||
Direkt face-ref + optional body-ref + Garments aus dem Schrank — ohne Character-Iteration.
|
||||
Konsistenz zwischen Panels schwächer.
|
||||
</p>
|
||||
</div>
|
||||
<CharacterPicker value={referenceMediaIds} onChange={handleQuickModeChange} {disabled} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
<!--
|
||||
PanelCard — single rendered panel with its caption/dialogue sidecar.
|
||||
In M2 captions/dialogue are baked into the image by gpt-image-2, so
|
||||
the text under the image is redundant meta for the author's own
|
||||
reference (quick scan without opening the full image). It's still
|
||||
useful for accessibility and for regenerating / reviewing prompts.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { X } from '@mana/shared-icons';
|
||||
import { usePanelImage } from '../queries';
|
||||
import type { ComicPanelMeta } from '../types';
|
||||
|
||||
interface Props {
|
||||
panelId: string;
|
||||
panelIndex: number;
|
||||
meta: ComicPanelMeta | undefined;
|
||||
/** Shows a small remove-from-story button. Wired by the DetailView. */
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
let { panelId, panelIndex, meta, onRemove }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const image$ = usePanelImage(panelId);
|
||||
const image = $derived(image$.value);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex h-full w-full flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
>
|
||||
<div class="relative aspect-square bg-muted">
|
||||
{#if image?.publicUrl}
|
||||
<img
|
||||
src={image.publicUrl}
|
||||
alt="Panel {panelIndex + 1}"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else if image$.loading}
|
||||
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
Lädt…
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
Panel nicht gefunden
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="absolute left-2 top-2 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-semibold text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
#{panelIndex + 1}
|
||||
</span>
|
||||
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
class="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-background/90 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:text-error"
|
||||
aria-label="Panel aus Story entfernen"
|
||||
title="Panel aus Story entfernen (Bild bleibt in der Galerie)"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if meta?.caption || meta?.dialogue}
|
||||
<div class="space-y-1 px-2.5 py-1.5 text-[11px] leading-snug">
|
||||
{#if meta.caption}
|
||||
<p class="text-muted-foreground"><em>{meta.caption}</em></p>
|
||||
{/if}
|
||||
{#if meta.dialogue}
|
||||
<p class="text-foreground">„{meta.dialogue}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
<!--
|
||||
PanelEditor — inline "new panel" sheet. Three textareas (prompt,
|
||||
caption, dialogue) plus a quality toggle and the Generieren button.
|
||||
On submit, runPanelGenerate fires the API call and appends the new
|
||||
panel to the story; on error the sheet stays mounted so the user
|
||||
can adjust without retyping.
|
||||
|
||||
Prompt and the optional caption/dialogue get composed with the
|
||||
story-wide style prefix inside `composePanelPrompt` before the call
|
||||
— the user doesn't repeat style instructions per panel.
|
||||
|
||||
M2 scope: single panel per click. Batch-mode (n panels in one submit)
|
||||
is M3 via the plan.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Sparkle, SpinnerGap, X } from '@mana/shared-icons';
|
||||
import {
|
||||
runPanelGenerate,
|
||||
DEFAULT_PANEL_MODEL,
|
||||
type PanelModel,
|
||||
type PanelSize,
|
||||
} from '../api/generate-panel';
|
||||
import { MAX_PANELS_PER_STORY, PANEL_COUNT_WARN_THRESHOLD } from '../constants';
|
||||
import type { ComicStory } from '../types';
|
||||
import PanelModelPicker from './PanelModelPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
story: ComicStory;
|
||||
onClose: () => void;
|
||||
onGenerated?: (panelId: string) => void;
|
||||
}
|
||||
|
||||
let { story, onClose, onGenerated }: Props = $props();
|
||||
|
||||
let panelPrompt = $state('');
|
||||
let caption = $state('');
|
||||
let dialogue = $state('');
|
||||
let quality = $state<'low' | 'medium' | 'high'>('medium');
|
||||
let model = $state<PanelModel>(DEFAULT_PANEL_MODEL);
|
||||
// Size defaults based on the story's style at mount time — users
|
||||
// can flip the toggle per panel afterwards, so capturing the
|
||||
// initial value is intentional here.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let size = $state<PanelSize>(story.style === 'webtoon' ? '1024x1536' : '1024x1024');
|
||||
|
||||
let submitting = $state(false);
|
||||
let errorMsg = $state<string | null>(null);
|
||||
|
||||
const panelCount = $derived(story.panelImageIds.length);
|
||||
const atCap = $derived(panelCount >= MAX_PANELS_PER_STORY);
|
||||
const warn = $derived(panelCount >= PANEL_COUNT_WARN_THRESHOLD && !atCap);
|
||||
|
||||
const canSubmit = $derived(panelPrompt.trim().length > 0 && !submitting && !atCap);
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
submitting = true;
|
||||
errorMsg = null;
|
||||
try {
|
||||
const result = await runPanelGenerate({
|
||||
story,
|
||||
panelPrompt,
|
||||
caption: caption.trim() || undefined,
|
||||
dialogue: dialogue.trim() || undefined,
|
||||
quality,
|
||||
size,
|
||||
model,
|
||||
});
|
||||
onGenerated?.(result.imageId);
|
||||
// Reset local state so the next panel-add starts fresh.
|
||||
panelPrompt = '';
|
||||
caption = '';
|
||||
dialogue = '';
|
||||
submitting = false;
|
||||
} catch (err) {
|
||||
errorMsg = err instanceof Error ? err.message : 'Panel-Generierung fehlgeschlagen';
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
const QUALITIES: readonly Quality[] = ['low', 'medium', 'high'] as const;
|
||||
const CREDIT_COST: Record<Quality, number> = {
|
||||
low: 3,
|
||||
medium: 10,
|
||||
high: 25,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<header class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">Neues Panel</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Panel {panelCount + 1} · nutzt {story.characterMediaIds.length} Referenz{story
|
||||
.characterMediaIds.length === 1
|
||||
? ''
|
||||
: 'en'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Panel-Editor schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if atCap}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
|
||||
role="alert"
|
||||
>
|
||||
Hart-Limit von {MAX_PANELS_PER_STORY} Panels erreicht. Ältere Panels entfernen oder neue Story anlegen.
|
||||
</div>
|
||||
{:else if warn}
|
||||
<p class="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz mit gpt-image-2 spürbar
|
||||
schwerer.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="mt-3 space-y-3"
|
||||
class:pointer-events-none={atCap}
|
||||
class:opacity-60={atCap}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="panel-prompt"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Panel-Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="panel-prompt"
|
||||
bind:value={panelPrompt}
|
||||
rows={3}
|
||||
placeholder="Was passiert in diesem Panel? z.B. 'Protagonist sitzt am Schreibtisch, starrt auf Monitor mit rotem X'"
|
||||
maxlength={600}
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="panel-caption"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Caption <span class="font-normal normal-case text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="panel-caption"
|
||||
type="text"
|
||||
bind:value={caption}
|
||||
placeholder="Montag, 9 Uhr."
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="panel-dialogue"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Dialog <span class="font-normal normal-case text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="panel-dialogue"
|
||||
type="text"
|
||||
bind:value={dialogue}
|
||||
placeholder="Schon wieder rot."
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
disabled={submitting || atCap}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Caption und Dialog werden direkt in das Bild gerendert. Englische Texte rendern stabiler als
|
||||
deutsche, kurze Sätze funktionieren am besten.
|
||||
</p>
|
||||
|
||||
<PanelModelPicker value={model} onChange={(m) => (model = m)} disabled={submitting} />
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Qualität:</span>
|
||||
{#each QUALITIES as q (q)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (quality = q)}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{quality === q
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={quality === q}
|
||||
>
|
||||
{q} ({CREDIT_COST[q]}c)
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Format:</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1024')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1024'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={size === '1024x1024'}
|
||||
>
|
||||
Quadrat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1536')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1536'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={submitting}
|
||||
aria-pressed={size === '1024x1536'}
|
||||
>
|
||||
Hoch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
|
||||
role="alert"
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<SpinnerGap size={14} class="spinner" weight="bold" />
|
||||
Generiert…
|
||||
{:else}
|
||||
<Sparkle size={14} />
|
||||
Panel generieren ({CREDIT_COST[quality]}c)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.spinner) {
|
||||
animation: panel-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes panel-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
<!--
|
||||
Compact segmented picker for the panel-rendering model. Three
|
||||
options, identical to Wardrobe's TryOnModelPicker so muscle-memory
|
||||
carries across the two image-edit flows.
|
||||
|
||||
Binds to a parent-owned PanelModel so callers can persist the
|
||||
choice locally (per editor-mount). Story-level persistence of the
|
||||
preferred model is a future concern — storing it on the row would
|
||||
need a migration and isn't worth it for a three-option picker.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { PanelModel } from '../api/generate-panel';
|
||||
|
||||
interface Props {
|
||||
value: PanelModel;
|
||||
onChange: (next: PanelModel) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onChange, disabled = false }: Props = $props();
|
||||
|
||||
const OPTIONS: Array<{ id: PanelModel; label: string; hint: string }> = [
|
||||
{
|
||||
id: 'openai/gpt-image-2',
|
||||
label: 'OpenAI',
|
||||
hint: 'GPT-image · Standard',
|
||||
},
|
||||
{
|
||||
id: 'google/gemini-3-pro-image-preview',
|
||||
label: 'Nano Banana Pro',
|
||||
hint: 'Google · hohe Konsistenz',
|
||||
},
|
||||
{
|
||||
id: 'google/gemini-3.1-flash-image-preview',
|
||||
label: 'Nano Banana 2',
|
||||
hint: 'Google · neuestes · günstig',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<fieldset class="picker" {disabled}>
|
||||
<legend class="legend">Modell</legend>
|
||||
<div class="options">
|
||||
{#each OPTIONS as opt (opt.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="option"
|
||||
class:active={value === opt.id}
|
||||
aria-pressed={value === opt.id}
|
||||
{disabled}
|
||||
onclick={() => onChange(opt.id)}
|
||||
>
|
||||
<span class="label">{opt.label}</span>
|
||||
<span class="hint">{opt.hint}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.legend {
|
||||
padding: 0;
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-background) / 0.5);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.option:hover:not([disabled]) {
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
background: hsl(var(--color-primary) / 0.04);
|
||||
}
|
||||
.option.active {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
.option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<!--
|
||||
PanelStrip — horizontal scrollable list of panels in story order. On
|
||||
small screens the strip overflows horizontally (iOS-style momentum
|
||||
scroll); on wide screens it wraps into a 2–3 column grid. Avoids
|
||||
`grid-flow-col` so a long story doesn't force a monster horizontal
|
||||
scroll on desktop.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ComicPanelMeta } from '../types';
|
||||
import PanelCard from './PanelCard.svelte';
|
||||
|
||||
interface Props {
|
||||
panelImageIds: string[];
|
||||
panelMeta: Record<string, ComicPanelMeta>;
|
||||
onRemove?: (panelId: string) => void;
|
||||
}
|
||||
|
||||
let { panelImageIds, panelMeta, onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if panelImageIds.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-6 text-center">
|
||||
<p class="text-sm font-medium text-foreground">Noch keine Panels.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Klick unten auf <strong class="text-foreground">+ Panel</strong>, um die erste Szene zu
|
||||
generieren.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each panelImageIds as panelId, index (panelId)}
|
||||
<PanelCard
|
||||
{panelId}
|
||||
panelIndex={index}
|
||||
meta={panelMeta[panelId]}
|
||||
onRemove={onRemove ? () => onRemove(panelId) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
<!--
|
||||
ReferenceInputPicker — tabs over the three content-richest modules
|
||||
(Journal / Notes / Library) and picks one entry as the seed for the
|
||||
AI-Storyboard flow. On select, resolves the decrypted plaintext
|
||||
(title + content / review) and emits it to the parent so
|
||||
`suggestPanels` can post it to the server.
|
||||
|
||||
Writing-Drafts and Calendar-Events are intentionally left out of the
|
||||
first cut — writing drafts need version-chain resolution and
|
||||
calendar events rarely carry enough prose to drive a panel sequence.
|
||||
Adding them is a follow-up (tabs + hook + resolver wiring).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useAllJournalEntries } from '$lib/modules/journal/queries';
|
||||
import { useAllNotes } from '$lib/modules/notes/queries';
|
||||
import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
|
||||
import { MagnifyingGlass, Book, NotePencil, BookOpen } from '@mana/shared-icons';
|
||||
import type { StoryboardSourceModule } from '../api/storyboard';
|
||||
|
||||
export interface ReferenceSelection {
|
||||
module: StoryboardSourceModule;
|
||||
entryId: string;
|
||||
/** Human-readable label — shown in the "seeded from…"-chip on the
|
||||
* story detail once panels are generated. */
|
||||
label: string;
|
||||
/** Decrypted plaintext that gets posted to /comic/storyboard. */
|
||||
sourceText: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelect: (sel: ReferenceSelection) => void;
|
||||
}
|
||||
|
||||
let { onSelect }: Props = $props();
|
||||
|
||||
type Tab = 'journal' | 'notes' | 'library';
|
||||
let activeTab = $state<Tab>('journal');
|
||||
let search = $state('');
|
||||
|
||||
const journal$ = useAllJournalEntries();
|
||||
const notes$ = useAllNotes();
|
||||
const library$ = useAllLibraryEntries();
|
||||
|
||||
const journal = $derived(journal$.value ?? []);
|
||||
const notes = $derived(notes$.value ?? []);
|
||||
const library = $derived(library$.value ?? []);
|
||||
|
||||
const q = $derived(search.trim().toLowerCase());
|
||||
|
||||
const journalFiltered = $derived(
|
||||
q.length === 0
|
||||
? journal.slice(0, 30)
|
||||
: journal
|
||||
.filter((e) => {
|
||||
const hay = `${e.title ?? ''} ${e.content}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 30)
|
||||
);
|
||||
const notesFiltered = $derived(
|
||||
q.length === 0
|
||||
? notes.slice(0, 30)
|
||||
: notes.filter((n) => `${n.title} ${n.content}`.toLowerCase().includes(q)).slice(0, 30)
|
||||
);
|
||||
const libraryFiltered = $derived(
|
||||
q.length === 0
|
||||
? library.slice(0, 30)
|
||||
: library
|
||||
.filter((e) => {
|
||||
const review = e.review ?? '';
|
||||
return `${e.title} ${review}`.toLowerCase().includes(q);
|
||||
})
|
||||
.slice(0, 30)
|
||||
);
|
||||
|
||||
function shortPreview(text: string, maxLen = 100): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim();
|
||||
return clean.length > maxLen ? clean.slice(0, maxLen) + '…' : clean;
|
||||
}
|
||||
|
||||
const TABS: { key: Tab; label: string; count: number }[] = $derived([
|
||||
{ key: 'journal', label: 'Tagebuch', count: journal.length },
|
||||
{ key: 'notes', label: 'Notizen', count: notes.length },
|
||||
{ key: 'library', label: 'Bibliothek', count: library.length },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<header class="space-y-1">
|
||||
<h3 class="text-sm font-semibold text-foreground">Quelle wählen</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Aus welchem Text soll die KI eine Panel-Folge bauen? Alles bleibt lokal — erst der
|
||||
verschlüsselte Klartext wird an das Modell gesendet, nur für diesen einen Call.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav class="flex gap-1 border-b border-border" aria-label="Quelle">
|
||||
{#each TABS as tab (tab.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="-mb-px border-b-2 px-2.5 py-1.5 text-xs font-medium transition-colors
|
||||
{activeTab === tab.key
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={activeTab === tab.key}
|
||||
onclick={() => (activeTab = tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
<span class="ml-1 text-[10px] text-muted-foreground">{tab.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="relative">
|
||||
<MagnifyingGlass
|
||||
size={14}
|
||||
class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder="Suchen…"
|
||||
class="block w-full rounded-md border border-border bg-background py-1.5 pl-7 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 space-y-1.5 overflow-y-auto">
|
||||
{#if activeTab === 'journal'}
|
||||
{#if journalFiltered.length === 0}
|
||||
<p class="py-4 text-center text-xs text-muted-foreground">
|
||||
{journal.length === 0
|
||||
? 'Noch keine Tagebuch-Einträge in diesem Space.'
|
||||
: 'Keine Einträge passen zur Suche.'}
|
||||
</p>
|
||||
{:else}
|
||||
{#each journalFiltered as entry (entry.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
onSelect({
|
||||
module: 'journal',
|
||||
entryId: entry.id,
|
||||
label: entry.title?.trim() || entry.entryDate || 'Tagebuch-Eintrag',
|
||||
sourceText: entry.title ? `${entry.title}\n\n${entry.content}` : entry.content,
|
||||
})}
|
||||
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted"
|
||||
>
|
||||
<Book size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<span class="truncate">{entry.title?.trim() || entry.entryDate || 'Eintrag'}</span>
|
||||
</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{shortPreview(entry.content)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if activeTab === 'notes'}
|
||||
{#if notesFiltered.length === 0}
|
||||
<p class="py-4 text-center text-xs text-muted-foreground">
|
||||
{notes.length === 0
|
||||
? 'Noch keine Notizen in diesem Space.'
|
||||
: 'Keine Notizen passen zur Suche.'}
|
||||
</p>
|
||||
{:else}
|
||||
{#each notesFiltered as note (note.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
onSelect({
|
||||
module: 'notes',
|
||||
entryId: note.id,
|
||||
label: note.title.trim() || 'Notiz',
|
||||
sourceText: note.title ? `${note.title}\n\n${note.content}` : note.content,
|
||||
})}
|
||||
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted"
|
||||
>
|
||||
<NotePencil size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium text-foreground">
|
||||
{note.title.trim() || 'Ohne Titel'}
|
||||
</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{shortPreview(note.content)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if activeTab === 'library'}
|
||||
{#if libraryFiltered.length === 0}
|
||||
<p class="py-4 text-center text-xs text-muted-foreground">
|
||||
{library.length === 0
|
||||
? 'Noch keine Bibliotheks-Einträge in diesem Space.'
|
||||
: 'Keine Einträge passen zur Suche.'}
|
||||
</p>
|
||||
{:else}
|
||||
{#each libraryFiltered as entry (entry.id)}
|
||||
{@const hasReview = entry.review && entry.review.trim().length > 0}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasReview}
|
||||
onclick={() => {
|
||||
if (!hasReview || !entry.review) return;
|
||||
onSelect({
|
||||
module: 'library',
|
||||
entryId: entry.id,
|
||||
label: entry.title,
|
||||
sourceText: `${entry.title}\n\n${entry.review}`,
|
||||
});
|
||||
}}
|
||||
class="flex w-full items-start gap-2 rounded-md border border-border bg-background p-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-background"
|
||||
title={hasReview ? '' : 'Kein Review hinterlegt — kein Text zum Rendern'}
|
||||
>
|
||||
<BookOpen size={14} class="mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<span class="truncate">{entry.title}</span>
|
||||
<span
|
||||
class="flex-shrink-0 rounded-sm bg-muted px-1 py-0 text-[9px] uppercase text-muted-foreground"
|
||||
>
|
||||
{entry.kind}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{hasReview ? shortPreview(entry.review ?? '') : 'Kein Review'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<!--
|
||||
Grid tile for a comic story. Cover = first panel's publicUrl from
|
||||
picture.images. Stories without any panels yet render a placeholder
|
||||
with the style badge so the user still has something to click.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Sparkle, Heart } from '@mana/shared-icons';
|
||||
import { STYLE_LABELS } from '../constants';
|
||||
import { usePanelImage } from '../queries';
|
||||
import type { ComicStory } from '../types';
|
||||
|
||||
interface Props {
|
||||
story: ComicStory;
|
||||
}
|
||||
|
||||
let { story }: Props = $props();
|
||||
|
||||
const coverPanelId = $derived(story.panelImageIds[0] ?? null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const cover$ = usePanelImage(coverPanelId);
|
||||
const cover = $derived(cover$.value);
|
||||
|
||||
const panelCount = $derived(story.panelImageIds.length);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/comic/{story.id}"
|
||||
class="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div class="relative aspect-square overflow-hidden bg-muted">
|
||||
{#if cover?.publicUrl}
|
||||
<img
|
||||
src={cover.publicUrl}
|
||||
alt={story.title}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-muted to-muted/50 text-muted-foreground"
|
||||
>
|
||||
<Sparkle size={24} />
|
||||
<span class="text-xs">Noch kein Panel</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Style badge -->
|
||||
<span
|
||||
class="absolute bottom-2 left-2 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-medium text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
{STYLE_LABELS[story.style].de}
|
||||
</span>
|
||||
|
||||
{#if story.isFavorite}
|
||||
<span class="absolute right-2 top-2 text-rose-500" aria-label="Favorit">
|
||||
<Heart size={14} weight="fill" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5 px-3 py-2">
|
||||
<h3 class="truncate text-sm font-medium text-foreground">{story.title}</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{panelCount}
|
||||
{panelCount === 1 ? 'Panel' : 'Panels'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
<!--
|
||||
StoryForm — create a new comic story. Title + Style + Characters +
|
||||
optional Kontext. On submit, createStory() lands the row in Dexie
|
||||
and we navigate to /comic/[id] so the user can start adding panels
|
||||
immediately.
|
||||
|
||||
No edit mode yet — update-story is a future concern (users who want
|
||||
to change the style/characters can just create a new story). The
|
||||
form is tuned for the "fresh idea → first panel in <60s"-flow.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Sparkle } from '@mana/shared-icons';
|
||||
import { getActiveSpace } from '$lib/data/scope';
|
||||
import { comicStoriesStore } from '../stores/stories.svelte';
|
||||
import type { ComicStyle } from '../types';
|
||||
import StylePicker from './StylePicker.svelte';
|
||||
import CharacterRefPicker from './CharacterRefPicker.svelte';
|
||||
|
||||
let title = $state('');
|
||||
let style = $state<ComicStyle>('comic');
|
||||
let characterId = $state<string | null>(null);
|
||||
let characterMediaIds = $state<string[]>([]);
|
||||
let storyContext = $state('');
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<string | null>(null);
|
||||
|
||||
const activeSpace = $derived(getActiveSpace());
|
||||
const canSubmit = $derived(
|
||||
title.trim().length > 0 && characterMediaIds.length > 0 && !submitting
|
||||
);
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
submitting = true;
|
||||
submitError = null;
|
||||
try {
|
||||
const story = await comicStoriesStore.createStory({
|
||||
title: title.trim(),
|
||||
style,
|
||||
characterId,
|
||||
characterMediaIds,
|
||||
storyContext: storyContext.trim() || null,
|
||||
});
|
||||
await goto(`/comic/${story.id}`);
|
||||
} catch (err) {
|
||||
submitError = err instanceof Error ? err.message : 'Erstellung fehlgeschlagen';
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-5">
|
||||
<!-- Title -->
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="comic-title"
|
||||
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Titel
|
||||
</label>
|
||||
<input
|
||||
id="comic-title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Bug-Hunt-Frust, Urlaubs-Abenteuer, …"
|
||||
maxlength={120}
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Style -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Stil</div>
|
||||
<StylePicker value={style} onChange={(next) => (style = next)} disabled={submitting} />
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Der Stil gilt für alle Panels der Geschichte. Wechsel ist später nicht möglich — dafür neue
|
||||
Story anlegen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Character refs (Mc3): default is character-mode if any exist,
|
||||
fallback Quick-Mode für Spontan-Stories ohne Setup. -->
|
||||
<CharacterRefPicker
|
||||
selectedCharacterId={characterId}
|
||||
referenceMediaIds={characterMediaIds}
|
||||
onChange={({ characterId: nextId, referenceMediaIds: nextRefs }) => {
|
||||
characterId = nextId;
|
||||
characterMediaIds = nextRefs;
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<!-- Story context (optional) -->
|
||||
<div class="space-y-1.5">
|
||||
<label
|
||||
for="comic-context"
|
||||
class="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Kontext <span class="font-normal normal-case text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="comic-context"
|
||||
bind:value={storyContext}
|
||||
rows={3}
|
||||
maxlength={800}
|
||||
placeholder="Kurze Zusammenfassung, Ton, Ziel der Geschichte. Wird im AI-Storyboard-Flow (M4) als Briefing genutzt."
|
||||
class="block w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if activeSpace && activeSpace.type !== 'personal'}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Diese Story gehört zu <strong class="text-foreground">{activeSpace.name}</strong> — nur Mitglieder
|
||||
dieses Space sehen sie.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if submitError}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
|
||||
role="alert"
|
||||
>
|
||||
{submitError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Sparkle size={14} />
|
||||
{submitting ? 'Wird erstellt…' : 'Story anlegen'}
|
||||
</button>
|
||||
<a
|
||||
href="/comic"
|
||||
class="rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
Abbrechen
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -1,485 +0,0 @@
|
|||
<!--
|
||||
StoryboardSuggester — the M4 multi-step flow:
|
||||
|
||||
1. Pick source (ReferenceInputPicker)
|
||||
2. Choose panel count + submit to /comic/storyboard
|
||||
3. Review / edit the suggested Panel[] list
|
||||
4. Fire parallel generation — same batch executor as M3, plus
|
||||
`panelMeta[imageId].sourceInput = {module, entryId}` on each
|
||||
generated panel so the "Comics zu diesem Eintrag"-back-query
|
||||
(useStoriesByInput) resolves later.
|
||||
|
||||
Unlike BatchPanelEditor this editor doesn't let the user author
|
||||
panels from scratch — they start with a Claude suggestion and tune.
|
||||
That's the value-add of M4: text in → panels out, author as editor
|
||||
not author-from-scratch.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Plus,
|
||||
Sparkle,
|
||||
SpinnerGap,
|
||||
Trash,
|
||||
WarningCircle,
|
||||
X,
|
||||
} from '@mana/shared-icons';
|
||||
import {
|
||||
runPanelGenerate,
|
||||
DEFAULT_PANEL_MODEL,
|
||||
type PanelModel,
|
||||
type PanelSize,
|
||||
} from '../api/generate-panel';
|
||||
import {
|
||||
suggestPanels,
|
||||
type StoryboardSourceModule,
|
||||
type StoryboardPanel,
|
||||
} from '../api/storyboard';
|
||||
import {
|
||||
DEFAULT_STORYBOARD_PANEL_COUNT,
|
||||
MAX_PANELS_PER_STORY,
|
||||
MAX_STORYBOARD_PANEL_COUNT,
|
||||
MIN_STORYBOARD_PANEL_COUNT,
|
||||
PANEL_COUNT_WARN_THRESHOLD,
|
||||
} from '../constants';
|
||||
import type { ComicStory } from '../types';
|
||||
import ReferenceInputPicker, { type ReferenceSelection } from './ReferenceInputPicker.svelte';
|
||||
import PanelModelPicker from './PanelModelPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
story: ComicStory;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { story, onClose }: Props = $props();
|
||||
|
||||
type Step = 'pick-source' | 'generating-plan' | 'review-plan' | 'rendering';
|
||||
|
||||
let step = $state<Step>('pick-source');
|
||||
let selection = $state<ReferenceSelection | null>(null);
|
||||
let requestedCount = $state(DEFAULT_STORYBOARD_PANEL_COUNT);
|
||||
let planError = $state<string | null>(null);
|
||||
|
||||
interface PlanRow extends StoryboardPanel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
let rows = $state<PlanRow[]>([]);
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
const QUALITIES: readonly Quality[] = ['low', 'medium', 'high'] as const;
|
||||
const CREDIT_COST: Record<Quality, number> = { low: 3, medium: 10, high: 25 };
|
||||
let quality = $state<Quality>('medium');
|
||||
let model = $state<PanelModel>(DEFAULT_PANEL_MODEL);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let size = $state<PanelSize>(story.style === 'webtoon' ? '1024x1536' : '1024x1024');
|
||||
|
||||
type RowStatus = 'idle' | 'pending' | 'ok' | 'error';
|
||||
let rowStatus = $state<Record<string, { status: RowStatus; error?: string }>>({});
|
||||
let renderBusy = $state(false);
|
||||
|
||||
const panelCount = $derived(story.panelImageIds.length);
|
||||
const roomLeft = $derived(Math.max(0, MAX_PANELS_PER_STORY - panelCount));
|
||||
const filledRows = $derived(rows.filter((r) => r.prompt.trim().length > 0));
|
||||
const effectiveCount = $derived(Math.min(filledRows.length, roomLeft));
|
||||
const warn = $derived(
|
||||
panelCount + effectiveCount >= PANEL_COUNT_WARN_THRESHOLD &&
|
||||
panelCount + effectiveCount <= MAX_PANELS_PER_STORY
|
||||
);
|
||||
|
||||
const totalCost = $derived(CREDIT_COST[quality] * effectiveCount);
|
||||
|
||||
async function handleSelect(sel: ReferenceSelection) {
|
||||
selection = sel;
|
||||
step = 'generating-plan';
|
||||
planError = null;
|
||||
try {
|
||||
const result = await suggestPanels({
|
||||
style: story.style,
|
||||
sourceText: sel.sourceText,
|
||||
panelCount: requestedCount,
|
||||
storyContext: story.storyContext,
|
||||
sourceModule: sel.module as StoryboardSourceModule,
|
||||
});
|
||||
rows = result.panels.map((p) => ({ ...p, id: crypto.randomUUID() }));
|
||||
step = 'review-plan';
|
||||
} catch (err) {
|
||||
planError = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
step = 'pick-source';
|
||||
}
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
if (rows.length <= 1) return;
|
||||
rows = rows.filter((r) => r.id !== id);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
rows.push({ id: crypto.randomUUID(), prompt: '', caption: undefined, dialogue: undefined });
|
||||
}
|
||||
|
||||
async function submitRow(row: PlanRow): Promise<string | null> {
|
||||
if (!selection) return null;
|
||||
rowStatus[row.id] = { status: 'pending' };
|
||||
try {
|
||||
const result = await runPanelGenerate({
|
||||
story,
|
||||
panelPrompt: row.prompt,
|
||||
caption: row.caption?.trim() || undefined,
|
||||
dialogue: row.dialogue?.trim() || undefined,
|
||||
quality,
|
||||
size,
|
||||
model,
|
||||
sourceInput: {
|
||||
module: selection.module,
|
||||
entryId: selection.entryId,
|
||||
},
|
||||
});
|
||||
rowStatus[row.id] = { status: 'ok' };
|
||||
return result.imageId;
|
||||
} catch (err) {
|
||||
rowStatus[row.id] = {
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRender() {
|
||||
if (renderBusy || filledRows.length === 0) return;
|
||||
renderBusy = true;
|
||||
step = 'rendering';
|
||||
rowStatus = {};
|
||||
const effectiveRows = filledRows.slice(0, roomLeft);
|
||||
await Promise.allSettled(effectiveRows.map((r) => submitRow(r)));
|
||||
renderBusy = false;
|
||||
// Close the flow once everything succeeded. If any row failed the
|
||||
// user stays in the review step with per-row retry chips.
|
||||
const anyError = Object.values(rowStatus).some((s) => s.status === 'error');
|
||||
if (!anyError) {
|
||||
onClose();
|
||||
} else {
|
||||
step = 'review-plan';
|
||||
}
|
||||
}
|
||||
|
||||
async function retryRow(row: PlanRow) {
|
||||
if (renderBusy) return;
|
||||
renderBusy = true;
|
||||
await submitRow(row);
|
||||
renderBusy = false;
|
||||
}
|
||||
|
||||
function resetToSource() {
|
||||
step = 'pick-source';
|
||||
rows = [];
|
||||
rowStatus = {};
|
||||
selection = null;
|
||||
planError = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<header class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<Sparkle size={14} class="text-primary" />
|
||||
Mit KI aus Text generieren
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{#if step === 'pick-source'}
|
||||
Schritt 1 · Quelle auswählen
|
||||
{:else if step === 'generating-plan'}
|
||||
Schritt 2 · Panels werden vorgeschlagen…
|
||||
{:else if step === 'review-plan'}
|
||||
Schritt 3 · Vorschläge prüfen und generieren
|
||||
{:else}
|
||||
Schritt 4 · Panels werden gerendert…
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="KI-Flow schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if step === 'pick-source'}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
for="panel-count"
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Panel-Anzahl:
|
||||
</label>
|
||||
<input
|
||||
id="panel-count"
|
||||
type="number"
|
||||
min={MIN_STORYBOARD_PANEL_COUNT}
|
||||
max={MAX_STORYBOARD_PANEL_COUNT}
|
||||
bind:value={requestedCount}
|
||||
class="w-16 rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({MIN_STORYBOARD_PANEL_COUNT}–{MAX_STORYBOARD_PANEL_COUNT})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if planError}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
|
||||
role="alert"
|
||||
>
|
||||
{planError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ReferenceInputPicker onSelect={handleSelect} />
|
||||
</div>
|
||||
{:else if step === 'generating-plan'}
|
||||
<div class="flex items-center justify-center gap-3 py-8" role="status" aria-live="polite">
|
||||
<SpinnerGap size={20} class="spinner text-primary" weight="bold" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Das Modell denkt über deine {requestedCount} Panels nach…
|
||||
</p>
|
||||
</div>
|
||||
{:else if step === 'review-plan' || step === 'rendering'}
|
||||
<div class="space-y-3">
|
||||
{#if selection}
|
||||
<div
|
||||
class="flex items-start justify-between gap-2 rounded-md bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-foreground">
|
||||
Quelle: {selection.label}
|
||||
</p>
|
||||
<p class="text-[11px] capitalize text-muted-foreground">{selection.module}</p>
|
||||
</div>
|
||||
{#if !renderBusy}
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetToSource}
|
||||
class="inline-flex items-center gap-1 text-[11px] font-medium text-primary hover:underline"
|
||||
>
|
||||
<ArrowLeft size={10} />
|
||||
Andere Quelle
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if warn && !renderBusy}
|
||||
<p class="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
Hinweis: Ab ~{PANEL_COUNT_WARN_THRESHOLD} Panels wird Character-Konsistenz spürbar schwerer.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if roomLeft < rows.length}
|
||||
<div
|
||||
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-xs text-error"
|
||||
role="alert"
|
||||
>
|
||||
Nur {roomLeft} Slot{roomLeft === 1 ? '' : 's'} frei in dieser Story — die letzten {rows.length -
|
||||
roomLeft} Vorschläge werden nicht gerendert.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each rows as row, index (row.id)}
|
||||
{@const status = rowStatus[row.id]}
|
||||
{@const overRoom = index >= roomLeft}
|
||||
<div
|
||||
class="rounded-lg border border-border bg-background p-3"
|
||||
class:opacity-50={overRoom}
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] text-foreground"
|
||||
>
|
||||
{panelCount + index + 1}
|
||||
</span>
|
||||
<span>Panel {index + 1}</span>
|
||||
{#if status?.status === 'pending'}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-primary"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<SpinnerGap size={12} class="spinner" weight="bold" />
|
||||
Wird generiert…
|
||||
</span>
|
||||
{:else if status?.status === 'ok'}
|
||||
<span class="inline-flex items-center gap-1 text-primary">
|
||||
<CheckCircle size={12} weight="fill" />
|
||||
Fertig
|
||||
</span>
|
||||
{:else if status?.status === 'error'}
|
||||
<span class="inline-flex items-center gap-1 text-error">
|
||||
<WarningCircle size={12} weight="fill" />
|
||||
Fehlgeschlagen
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if status?.status === 'error'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => retryRow(row)}
|
||||
disabled={renderBusy}
|
||||
class="text-[11px] font-medium text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
Neu versuchen
|
||||
</button>
|
||||
{/if}
|
||||
{#if rows.length > 1 && !renderBusy}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeRow(row.id)}
|
||||
class="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-error"
|
||||
aria-label="Vorschlag entfernen"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
bind:value={row.prompt}
|
||||
rows={2}
|
||||
placeholder="Prompt — was passiert im Panel?"
|
||||
maxlength={600}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
disabled={renderBusy}
|
||||
></textarea>
|
||||
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={row.caption}
|
||||
placeholder="Caption (optional)"
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
disabled={renderBusy}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={row.dialogue}
|
||||
placeholder="Dialog (optional)"
|
||||
maxlength={120}
|
||||
class="block w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
disabled={renderBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if status?.status === 'error' && status.error}
|
||||
<p class="mt-2 text-[11px] text-error" role="alert">{status.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !renderBusy}
|
||||
<button
|
||||
type="button"
|
||||
onclick={addRow}
|
||||
disabled={rows.length >= MAX_PANELS_PER_STORY}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Weiteres Panel manuell
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<PanelModelPicker value={model} onChange={(m) => (model = m)} disabled={renderBusy} />
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 border-t border-border pt-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Qualität:</span>
|
||||
{#each QUALITIES as q (q)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (quality = q)}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{quality === q
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={renderBusy}
|
||||
aria-pressed={quality === q}
|
||||
>
|
||||
{q} ({CREDIT_COST[q]}c)
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-medium text-muted-foreground">Format:</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1024')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1024'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={renderBusy}
|
||||
aria-pressed={size === '1024x1024'}
|
||||
>
|
||||
Quadrat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (size = '1024x1536')}
|
||||
class="rounded-md border px-2 py-0.5 text-[11px] transition-colors
|
||||
{size === '1024x1536'
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'}"
|
||||
disabled={renderBusy}
|
||||
aria-pressed={size === '1024x1536'}
|
||||
>
|
||||
Hoch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRender}
|
||||
disabled={renderBusy || effectiveCount === 0}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if renderBusy}
|
||||
<SpinnerGap size={14} class="spinner" weight="bold" />
|
||||
{effectiveCount}
|
||||
{effectiveCount === 1 ? 'Panel' : 'Panels'} werden generiert…
|
||||
{:else}
|
||||
<Sparkle size={14} />
|
||||
{effectiveCount}
|
||||
{effectiveCount === 1 ? 'Panel' : 'Panels'} generieren ({totalCost}c)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.spinner) {
|
||||
animation: panel-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes panel-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
<!--
|
||||
StylePicker — radio-tiles for the five ComicStyle presets. Chosen at
|
||||
story-create time, fixed afterward (restyling = new story). Each
|
||||
tile carries a short "what this looks like" hint so the user can
|
||||
pick without having to memorise the preset mapping in styles.ts.
|
||||
|
||||
Markup pattern matches `PanelModelPicker` / wardrobe's
|
||||
`TryOnModelPicker` so the create-flow has a coherent "click these
|
||||
cards" affordance — both border AND background shift on hover so
|
||||
the tiles read clearly as buttons.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { STYLE_ORDER, STYLE_LABELS } from '../constants';
|
||||
import type { ComicStyle } from '../types';
|
||||
|
||||
interface Props {
|
||||
value: ComicStyle;
|
||||
onChange: (next: ComicStyle) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onChange, disabled = false }: Props = $props();
|
||||
|
||||
// Short descriptive hint per preset, shown under the label. Keep
|
||||
// each line ≤ 60 chars so 2-column layouts don't wrap.
|
||||
const HINTS: Record<ComicStyle, string> = {
|
||||
comic: 'Kräftige Linien, Cell-Shading, US-Comic-Look',
|
||||
manga: 'Schwarz/weiß, Screen-Tones, dynamische Perspektive',
|
||||
cartoon: 'Weich, pastellig, Saturday-Morning-Feeling',
|
||||
'graphic-novel': 'Aquarell / painterly, stimmungsvoll',
|
||||
webtoon: 'Vertikale Panels, moderne Farben, Soft-Shading',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="picker" role="radiogroup" aria-label="Comic-Stil">
|
||||
{#each STYLE_ORDER as style (style)}
|
||||
<button
|
||||
type="button"
|
||||
class="option"
|
||||
class:active={value === style}
|
||||
role="radio"
|
||||
aria-checked={value === style}
|
||||
{disabled}
|
||||
onclick={() => onChange(style)}
|
||||
>
|
||||
<span class="label">{STYLE_LABELS[style].de}</span>
|
||||
<span class="hint">{HINTS[style]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-background) / 0.5);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
transform 0.05s;
|
||||
}
|
||||
.option:hover:not([disabled]):not(.active) {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
.option:active:not([disabled]) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.option.active {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
box-shadow: 0 1px 3px hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
.option:focus-visible {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<!--
|
||||
VariantTile — one variant of a Comic-Character with pin / remove
|
||||
controls. Used in the character-detail's variant grid.
|
||||
|
||||
Two states matter: pinned (= the canonical look, gets a primary
|
||||
ring and a star) vs. unpinned (regular border, hover shows action
|
||||
icons). Removing a pinned variant cascades the pin to the first
|
||||
remaining variant (handled in `comicCharactersStore.removeVariant`).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Star, Trash } from '@mana/shared-icons';
|
||||
import { usePanelImage } from '../queries';
|
||||
|
||||
interface Props {
|
||||
variantId: string;
|
||||
variantIndex: number;
|
||||
isPinned: boolean;
|
||||
onPin: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
let { variantId, variantIndex, isPinned, onPin, onRemove }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const image$ = usePanelImage(variantId);
|
||||
const image = $derived(image$.value);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative aspect-square overflow-hidden rounded-lg border-2 transition-all
|
||||
{isPinned ? 'border-primary shadow-md shadow-primary/20' : 'border-border hover:border-primary/40'}"
|
||||
>
|
||||
{#if image?.publicUrl}
|
||||
<img
|
||||
src={image.publicUrl}
|
||||
alt="Variante {variantIndex + 1}"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else if image$.loading}
|
||||
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
Lädt…
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
Variante nicht gefunden
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Variant index in corner -->
|
||||
<span
|
||||
class="absolute left-2 top-2 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-semibold text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
#{variantIndex + 1}
|
||||
</span>
|
||||
|
||||
<!-- Pin star — always visible if pinned, otherwise on hover -->
|
||||
{#if isPinned}
|
||||
<span
|
||||
class="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-primary text-white shadow-md ring-2 ring-background"
|
||||
aria-label="Gepinned"
|
||||
title="Diese Variante ist gepinned"
|
||||
>
|
||||
<Star size={14} weight="fill" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom action bar — appears on hover -->
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/70 via-black/40 to-transparent px-2 py-1.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
{#if !isPinned}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onPin}
|
||||
class="flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-primary/90"
|
||||
title="Als kanonischen Look pinnen"
|
||||
>
|
||||
<Star size={10} weight="fill" />
|
||||
Pinnen
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-[11px] font-medium text-white drop-shadow">Aktiv</span>
|
||||
{/if}
|
||||
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md bg-error/90 text-white shadow-sm transition-colors hover:bg-error"
|
||||
aria-label="Variante entfernen"
|
||||
title="Variante aus Character entfernen (Bild bleibt in Galerie)"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/**
|
||||
* 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, comicCharactersTable } from './collections';
|
||||
export { comicStoriesStore } from './stores/stories.svelte';
|
||||
export { comicCharactersStore } from './stores/characters.svelte';
|
||||
export {
|
||||
useAllStories,
|
||||
useStoriesByStyle,
|
||||
useStory,
|
||||
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';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const comicModuleConfig: ModuleConfig = {
|
||||
appId: 'comic',
|
||||
tables: [{ name: 'comicStories' }, { name: 'comicCharacters' }],
|
||||
};
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
/**
|
||||
* 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 { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.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,
|
||||
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() {
|
||||
return useScopedLiveQuery<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 useScopedLiveQuery<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[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single picture.images row by id — used for panel rendering
|
||||
* (cover on StoryCard, thumbnails on PanelStrip, full-size on
|
||||
* PanelCard). Lives here (not in picture/queries) because it's
|
||||
* comic-specific convenience; picture's own queries don't need a
|
||||
* single-image hook today.
|
||||
*/
|
||||
export function usePanelImage(imageId: string | null) {
|
||||
return useScopedLiveQuery<Image | null>(async () => {
|
||||
if (!imageId) return null;
|
||||
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
|
||||
.and((row) => row.id === imageId)
|
||||
.toArray();
|
||||
const [local] = locals;
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('images', [local]);
|
||||
return toImage(decrypted);
|
||||
}, null);
|
||||
}
|
||||
|
||||
/** A single story by id, live-updating. Null while loading / missing. */
|
||||
export function useStory(id: string | null) {
|
||||
return useScopedLiveQuery<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 useScopedLiveQuery<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[]);
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
* `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 useScopedLiveQuery<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[]);
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
// 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,
|
||||
});
|
||||
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,
|
||||
};
|
||||
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: Partial<LocalComicCharacter> = { ...patch };
|
||||
if (Array.isArray(wrapped.tags)) {
|
||||
wrapped.tags = [...wrapped.tags];
|
||||
}
|
||||
await encryptRecord('comicCharacters', wrapped);
|
||||
await comicCharactersTable.update(id, wrapped);
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<void> {
|
||||
const existing = await comicCharactersTable.get(id);
|
||||
if (!existing) return;
|
||||
await comicCharactersTable.update(id, {
|
||||
isFavorite: !existing.isFavorite,
|
||||
});
|
||||
},
|
||||
|
||||
async archiveCharacter(id: string, archived: boolean): Promise<void> {
|
||||
await comicCharactersTable.update(id, {
|
||||
isArchived: archived,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteCharacter(id: string): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await comicCharactersTable.update(id, {
|
||||
deletedAt: nowIso,
|
||||
});
|
||||
emitDomainEvent('ComicCharacterDeleted', 'comic', 'comicCharacters', id, {
|
||||
characterId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
/**
|
||||
* 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[];
|
||||
/** When the story is bound to a comicCharacter (Character-Mode), the
|
||||
* FK lands here for display + cross-ref. Quick-Mode stories pass
|
||||
* `null` and only fill `characterMediaIds` with raw face/body/garments. */
|
||||
characterId?: string | null;
|
||||
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');
|
||||
}
|
||||
// Spread incoming arrays to break Svelte 5 $state proxies — the
|
||||
// caller (StoryForm) declares `characterMediaIds`/`tags` as
|
||||
// `$state<string[]>([])` and passes them directly. IndexedDB's
|
||||
// structured-clone refuses to clone proxies, so without this
|
||||
// `comicStoriesTable.add(...)` throws DataCloneError.
|
||||
const newLocal: LocalComicStory = {
|
||||
id: crypto.randomUUID(),
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
style: input.style,
|
||||
characterId: input.characterId ?? null,
|
||||
characterMediaIds: [...input.characterMediaIds],
|
||||
storyContext: input.storyContext ?? null,
|
||||
panelImageIds: [],
|
||||
panelMeta: {},
|
||||
tags: input.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> {
|
||||
// Same proxy-breaking copy as createStory: any array on the patch
|
||||
// might be a $state proxy if the caller is a Svelte 5 component.
|
||||
const wrapped: Partial<LocalComicStory> = { ...patch };
|
||||
if (Array.isArray(wrapped.characterMediaIds)) {
|
||||
wrapped.characterMediaIds = [...wrapped.characterMediaIds];
|
||||
}
|
||||
if (Array.isArray(wrapped.tags)) {
|
||||
wrapped.tags = [...wrapped.tags];
|
||||
}
|
||||
await encryptRecord('comicStories', wrapped);
|
||||
await comicStoriesTable.update(id, wrapped);
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<void> {
|
||||
const existing = await comicStoriesTable.get(id);
|
||||
if (!existing) return;
|
||||
await comicStoriesTable.update(id, {
|
||||
isFavorite: !existing.isFavorite,
|
||||
});
|
||||
},
|
||||
|
||||
async archiveStory(id: string, archived: boolean): Promise<void> {
|
||||
await comicStoriesTable.update(id, {
|
||||
isArchived: archived,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteStory(id: string): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await comicStoriesTable.update(id, {
|
||||
deletedAt: 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(),
|
||||
};
|
||||
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 Partial<LocalComicStory>;
|
||||
await encryptRecord('comicStories', patch);
|
||||
await comicStoriesTable.update(storyId, patch);
|
||||
emitDomainEvent('ComicPanelAppended', 'comic', 'comicStories', storyId, {
|
||||
storyId,
|
||||
panelImageId,
|
||||
panelIndex: nextIds.length - 1,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* 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('. ');
|
||||
}
|
||||
|
|
@ -1,642 +0,0 @@
|
|||
/**
|
||||
* Comic module tools — AI-accessible operations over comic stories.
|
||||
*
|
||||
* Auto (read-only):
|
||||
* - list_comic_stories
|
||||
*
|
||||
* Propose (human approval per the agent's policy — generate burns
|
||||
* credits so it's never auto):
|
||||
* - create_comic_story
|
||||
* - generate_comic_panel
|
||||
*
|
||||
* Character references (face-ref + optional body-ref) resolve
|
||||
* automatically from the active space's primary meImages — the AI
|
||||
* caller doesn't have to know about mediaIds. That's a deliberate
|
||||
* simplification versus the MCP layer (packages/mana-tool-registry/
|
||||
* src/modules/comic.ts) which accepts an explicit `characterMediaIds`
|
||||
* array; the webapp-runner pattern is "compose for the user, then
|
||||
* propose", and forcing the planner to list mediaIds before creating
|
||||
* a story was friction with no upside.
|
||||
*
|
||||
* Panel rendering delegates to the existing `runPanelGenerate` from
|
||||
* api/generate-panel.ts, which is the same code path the DetailView's
|
||||
* PanelEditor uses — so the encryption + picture.images insertion +
|
||||
* story appendPanel happen exactly once regardless of whether the
|
||||
* user or the agent triggered it.
|
||||
*/
|
||||
|
||||
import type { ModuleTool } from '$lib/data/tools/types';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
|
||||
import { meImagesTable } from '$lib/modules/profile/collections';
|
||||
import { comicStoriesStore } from './stores/stories.svelte';
|
||||
import { comicCharactersStore } from './stores/characters.svelte';
|
||||
import { runPanelGenerate, DEFAULT_PANEL_MODEL, type PanelModel } from './api/generate-panel';
|
||||
import { runCharacterGenerate } from './api/generate-character';
|
||||
import { comicCharactersTable } from './collections';
|
||||
import { toStory, toCharacter } from './types';
|
||||
import type { ComicStyle, LocalComicStory, LocalComicCharacter } from './types';
|
||||
|
||||
const VALID_MODELS: readonly PanelModel[] = [
|
||||
'openai/gpt-image-2',
|
||||
'google/gemini-3-pro-image-preview',
|
||||
'google/gemini-3.1-flash-image-preview',
|
||||
] as const;
|
||||
|
||||
function isValidModel(v: unknown): v is PanelModel {
|
||||
return typeof v === 'string' && (VALID_MODELS as readonly string[]).includes(v);
|
||||
}
|
||||
import type { LocalMeImage } from '$lib/modules/profile/types';
|
||||
import { getActiveSpace } from '$lib/data/scope';
|
||||
|
||||
const VALID_STYLES: ComicStyle[] = ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'];
|
||||
|
||||
function isValidStyle(v: unknown): v is ComicStyle {
|
||||
return typeof v === 'string' && (VALID_STYLES as string[]).includes(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active space's primary face-ref (+ optional body-ref) to
|
||||
* a mediaId array suitable for `characterMediaIds`. Non-reactive — we
|
||||
* scan meImagesTable directly instead of going through the svelte
|
||||
* `useImageByPrimary` hook because tools run outside the Svelte
|
||||
* reactivity graph.
|
||||
*/
|
||||
async function resolveCharacterMediaIds(): Promise<{
|
||||
mediaIds: string[];
|
||||
faceRef: string | null;
|
||||
bodyRef: string | null;
|
||||
}> {
|
||||
const space = getActiveSpace();
|
||||
if (!space) return { mediaIds: [], faceRef: null, bodyRef: null };
|
||||
const all = await meImagesTable.toArray();
|
||||
const inSpace = all.filter((m) => !m.deletedAt && m.spaceId === space.id);
|
||||
const face = inSpace.find((m) => m.primaryFor === 'face-ref') ?? null;
|
||||
const body = inSpace.find((m) => m.primaryFor === 'body-ref') ?? null;
|
||||
const mediaIds: string[] = [];
|
||||
if (face?.mediaId) mediaIds.push(face.mediaId);
|
||||
if (body?.mediaId) mediaIds.push(body.mediaId);
|
||||
return { mediaIds, faceRef: face?.mediaId ?? null, bodyRef: body?.mediaId ?? null };
|
||||
}
|
||||
|
||||
export const comicTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'list_comic_stories',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Listet Comic-Stories im aktiven Space (id, title, style, panelCount, isFavorite). Optional nach Stil oder Favoriten filterbar.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Nur einen Stil zeigen',
|
||||
required: false,
|
||||
enum: [...VALID_STYLES],
|
||||
},
|
||||
{
|
||||
name: 'favoriteOnly',
|
||||
type: 'boolean',
|
||||
description: 'Nur Favoriten',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const styleFilter = params.style as ComicStyle | undefined;
|
||||
const favoriteOnly = params.favoriteOnly === true;
|
||||
const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100);
|
||||
|
||||
try {
|
||||
const locals = await scopedForModule<LocalComicStory, string>(
|
||||
'comic',
|
||||
'comicStories'
|
||||
).toArray();
|
||||
const visible = locals.filter((s) => !s.deletedAt && !s.isArchived);
|
||||
const decrypted = await decryptRecords('comicStories', visible);
|
||||
const rows = decrypted
|
||||
.map(toStory)
|
||||
.filter((s) => (styleFilter ? s.style === styleFilter : true))
|
||||
.filter((s) => (favoriteOnly ? s.isFavorite === true : true))
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.slice(0, limit)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
style: s.style,
|
||||
panelCount: s.panelImageIds.length,
|
||||
isFavorite: s.isFavorite === true,
|
||||
description: s.description ?? null,
|
||||
storyContext: s.storyContext ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { stories: rows, total: rows.length },
|
||||
message: `${rows.length} Stor${rows.length === 1 ? 'y' : 'ies'} gelistet`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Vault ist gesperrt — Comic-Stories können nicht entschlüsselt werden',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'create_comic_story',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Legt eine neue Comic-Story an. Charakter-Referenzen werden automatisch aus den primary face-ref + body-ref des aktiven Space aufgeloest — Nutzer muss vorher ein Gesichtsbild in /profile/me-images hochgeladen haben. Stil ist fix, alle spaeteren Panels nutzen denselben Stil-Prefix.',
|
||||
parameters: [
|
||||
{ name: 'title', type: 'string', description: 'Titel der Story', required: true },
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Visueller Stil',
|
||||
required: true,
|
||||
enum: [...VALID_STYLES],
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
description: 'Kurze Story-Beschreibung',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'storyContext',
|
||||
type: 'string',
|
||||
description: 'Freitext-Briefing — Ton, Ziel, Hintergrund',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'string',
|
||||
description: 'Tags durch Komma getrennt',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const title = String(params.title ?? '').trim();
|
||||
if (!title) return { success: false, message: 'title erforderlich' };
|
||||
|
||||
const style = params.style;
|
||||
if (!isValidStyle(style)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `style muss einer von ${VALID_STYLES.join(', ')} sein`,
|
||||
};
|
||||
}
|
||||
|
||||
const refs = await resolveCharacterMediaIds();
|
||||
if (refs.mediaIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Kein Gesichtsbild im aktiven Space. Lade eines in /profile/me-images hoch, dann erneut versuchen.',
|
||||
};
|
||||
}
|
||||
|
||||
const description =
|
||||
typeof params.description === 'string' && params.description.trim()
|
||||
? params.description.trim()
|
||||
: null;
|
||||
const storyContext =
|
||||
typeof params.storyContext === 'string' && params.storyContext.trim()
|
||||
? params.storyContext.trim()
|
||||
: null;
|
||||
const tags =
|
||||
typeof params.tags === 'string' && params.tags.trim()
|
||||
? params.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
: [];
|
||||
|
||||
try {
|
||||
const story = await comicStoriesStore.createStory({
|
||||
title,
|
||||
style,
|
||||
characterMediaIds: refs.mediaIds,
|
||||
description,
|
||||
storyContext,
|
||||
tags,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
style: story.style,
|
||||
characterRefCount: refs.mediaIds.length,
|
||||
hasBodyRef: refs.bodyRef !== null,
|
||||
},
|
||||
message: `Story "${story.title}" angelegt (Stil: ${story.style})`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return { success: false, message: 'Vault ist gesperrt — Story nicht gespeichert' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'generate_comic_panel',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Rendert ein neues Panel in einer bestehenden Story via gpt-image-2. Konsumiert Credits (low=3, medium=10, high=25). Stil-Prefix und Charakter-Refs kommen aus der Story — nur Panel-Prompt + optional Caption/Dialog werden uebergeben. Caption und Dialog werden direkt in das Bild gerendert.',
|
||||
parameters: [
|
||||
{ name: 'storyId', type: 'string', description: 'ID der Story', required: true },
|
||||
{
|
||||
name: 'panelPrompt',
|
||||
type: 'string',
|
||||
description:
|
||||
'Was passiert in diesem Panel (Szene, Aktion, Stimmung). Kurze englische Saetze am stabilsten.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'string',
|
||||
description: 'Erzaehl-Zeile ueber/unter dem Bild',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'dialogue',
|
||||
type: 'string',
|
||||
description: 'Sprechblasen-Text',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
type: 'string',
|
||||
description: 'Render-Qualitaet — hoeher = mehr Credits',
|
||||
required: false,
|
||||
enum: ['low', 'medium', 'high'],
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
description:
|
||||
'Rendering-Backend (Default openai/gpt-image-2). Alternativen: google/gemini-3-pro-image-preview (Nano Banana Pro), google/gemini-3.1-flash-image-preview (Nano Banana 2).',
|
||||
required: false,
|
||||
enum: [...VALID_MODELS],
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const storyId = String(params.storyId ?? '').trim();
|
||||
if (!storyId) return { success: false, message: 'storyId erforderlich' };
|
||||
|
||||
const panelPrompt = String(params.panelPrompt ?? '').trim();
|
||||
if (!panelPrompt) return { success: false, message: 'panelPrompt erforderlich' };
|
||||
|
||||
const caption =
|
||||
typeof params.caption === 'string' && params.caption.trim()
|
||||
? params.caption.trim()
|
||||
: undefined;
|
||||
const dialogue =
|
||||
typeof params.dialogue === 'string' && params.dialogue.trim()
|
||||
? params.dialogue.trim()
|
||||
: undefined;
|
||||
const quality =
|
||||
params.quality === 'low' || params.quality === 'high' ? params.quality : 'medium';
|
||||
const model = isValidModel(params.model) ? params.model : DEFAULT_PANEL_MODEL;
|
||||
|
||||
try {
|
||||
// Load the story for runPanelGenerate — same code path as the
|
||||
// PanelEditor in the web UI.
|
||||
const locals = await scopedForModule<LocalComicStory, string>('comic', 'comicStories')
|
||||
.and((s) => s.id === storyId)
|
||||
.toArray();
|
||||
const [local] = locals;
|
||||
if (!local || local.deletedAt) {
|
||||
return { success: false, message: `Story ${storyId} nicht gefunden` };
|
||||
}
|
||||
const [decrypted] = await decryptRecords('comicStories', [local]);
|
||||
if (!decrypted) {
|
||||
return { success: false, message: 'Entschlüsselung der Story fehlgeschlagen' };
|
||||
}
|
||||
const story = toStory(decrypted);
|
||||
|
||||
const result = await runPanelGenerate({
|
||||
story,
|
||||
panelPrompt,
|
||||
caption,
|
||||
dialogue,
|
||||
quality: quality as 'low' | 'medium' | 'high',
|
||||
model,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
imageId: result.imageId,
|
||||
imageUrl: result.imageUrl,
|
||||
panelIndex: result.panelIndex,
|
||||
model: result.model,
|
||||
},
|
||||
message: `Panel ${result.panelIndex + 1} für Story "${story.title}" generiert`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return { success: false, message: 'Vault ist gesperrt — Panel nicht angehängt' };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : 'Panel-Generierung fehlgeschlagen',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Imported for side-effect types — keeps unused-import warnings quiet
|
||||
// when the LocalMeImage reference in resolveCharacterMediaIds is
|
||||
// compile-time only.
|
||||
export type { LocalMeImage };
|
||||
|
||||
// ─── Character tools (Mc4) ────────────────────────────────────────
|
||||
|
||||
export const comicCharacterTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'list_comic_characters',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Listet Comic-Characters im aktiven Space (id, name, style, variantCount, pinnedVariantId, isFavorite). Optional nach Stil oder Favoriten filterbar.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Nur einen Stil zeigen',
|
||||
required: false,
|
||||
enum: [...VALID_STYLES],
|
||||
},
|
||||
{
|
||||
name: 'favoriteOnly',
|
||||
type: 'boolean',
|
||||
description: 'Nur Favoriten',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const styleFilter = params.style as ComicStyle | undefined;
|
||||
const favoriteOnly = params.favoriteOnly === true;
|
||||
const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100);
|
||||
|
||||
try {
|
||||
const locals = await scopedForModule<LocalComicCharacter, string>(
|
||||
'comic',
|
||||
'comicCharacters'
|
||||
).toArray();
|
||||
const visible = locals.filter((c) => !c.deletedAt && !c.isArchived);
|
||||
const decrypted = await decryptRecords('comicCharacters', visible);
|
||||
const rows = decrypted
|
||||
.map(toCharacter)
|
||||
.filter((c) => (styleFilter ? c.style === styleFilter : true))
|
||||
.filter((c) => (favoriteOnly ? c.isFavorite === true : true))
|
||||
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
.slice(0, limit)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
style: c.style,
|
||||
variantCount: c.variantMediaIds.length,
|
||||
pinnedVariantId: c.pinnedVariantId ?? null,
|
||||
isFavorite: c.isFavorite === true,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { characters: rows, total: rows.length },
|
||||
message: `${rows.length} Character${rows.length === 1 ? '' : 's'} gelistet`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Vault ist gesperrt — Comic-Characters können nicht entschlüsselt werden',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'create_comic_character',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Legt einen neuen Comic-Character an OHNE direkt Varianten zu rendern. Charakter-Refs werden automatisch aus dem primary face-ref + body-ref des aktiven Space aufgeloest. Stil ist fix nach Anlage.',
|
||||
parameters: [
|
||||
{ name: 'name', type: 'string', description: 'Name des Characters', required: true },
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Visueller Stil',
|
||||
required: true,
|
||||
enum: [...VALID_STYLES],
|
||||
},
|
||||
{
|
||||
name: 'addPrompt',
|
||||
type: 'string',
|
||||
description: 'Zusaetzlicher Prompt',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
description: 'Kurze Charakter-Beschreibung',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'tags', type: 'string', description: 'Tags durch Komma getrennt', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const name = String(params.name ?? '').trim();
|
||||
if (!name) return { success: false, message: 'name erforderlich' };
|
||||
|
||||
const style = params.style;
|
||||
if (!isValidStyle(style)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `style muss einer von ${VALID_STYLES.join(', ')} sein`,
|
||||
};
|
||||
}
|
||||
|
||||
const refs = await resolveCharacterMediaIds();
|
||||
if (!refs.faceRef) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Kein Gesichtsbild im aktiven Space. Lade eines in /profile/me-images hoch, dann erneut versuchen.',
|
||||
};
|
||||
}
|
||||
|
||||
const description =
|
||||
typeof params.description === 'string' && params.description.trim()
|
||||
? params.description.trim()
|
||||
: null;
|
||||
const addPrompt =
|
||||
typeof params.addPrompt === 'string' && params.addPrompt.trim()
|
||||
? params.addPrompt.trim()
|
||||
: null;
|
||||
const tags =
|
||||
typeof params.tags === 'string' && params.tags.trim()
|
||||
? params.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
: [];
|
||||
|
||||
try {
|
||||
const character = await comicCharactersStore.createCharacter({
|
||||
name,
|
||||
style,
|
||||
sourceFaceMediaId: refs.faceRef,
|
||||
sourceBodyMediaId: refs.bodyRef,
|
||||
addPrompt,
|
||||
description,
|
||||
tags,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
style: character.style,
|
||||
hasBodyRef: refs.bodyRef !== null,
|
||||
note: 'Character-Row angelegt — jetzt generate_character_variant aufrufen um Varianten zu rendern.',
|
||||
},
|
||||
message: `Character "${character.name}" angelegt (Stil: ${character.style})`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return { success: false, message: 'Vault ist gesperrt — Character nicht gespeichert' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'generate_character_variant',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Rendert N (default 4) Variant-Portraits fuer einen existierenden Comic-Character via gpt-image-2. Konsumiert Credits × count. Auto-pinnt die erste Variante wenn noch keine gepinnt ist.',
|
||||
parameters: [
|
||||
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
|
||||
{
|
||||
name: 'count',
|
||||
type: 'number',
|
||||
description: 'Anzahl Varianten (1-4, default 4)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
type: 'string',
|
||||
description: 'Render-Qualitaet',
|
||||
required: false,
|
||||
enum: ['low', 'medium', 'high'],
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
description: 'Rendering-Backend',
|
||||
required: false,
|
||||
enum: [...VALID_MODELS],
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const characterId = String(params.characterId ?? '').trim();
|
||||
if (!characterId) return { success: false, message: 'characterId erforderlich' };
|
||||
|
||||
const count = Math.max(1, Math.min(4, Number(params.count) || 4));
|
||||
const quality =
|
||||
params.quality === 'low' || params.quality === 'high' ? params.quality : 'medium';
|
||||
const model: PanelModel = isValidModel(params.model) ? params.model : DEFAULT_PANEL_MODEL;
|
||||
|
||||
try {
|
||||
const local = await comicCharactersTable.get(characterId);
|
||||
if (!local || local.deletedAt) {
|
||||
return { success: false, message: `Character ${characterId} nicht gefunden` };
|
||||
}
|
||||
const [decrypted] = await decryptRecords('comicCharacters', [local]);
|
||||
if (!decrypted) {
|
||||
return { success: false, message: 'Entschlüsselung des Characters fehlgeschlagen' };
|
||||
}
|
||||
const character = toCharacter(decrypted);
|
||||
|
||||
const result = await runCharacterGenerate({
|
||||
character,
|
||||
count,
|
||||
quality: quality as 'low' | 'medium' | 'high',
|
||||
model,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
characterId: character.id,
|
||||
newVariantMediaIds: result.variantMediaIds,
|
||||
imageUrls: result.imageUrls,
|
||||
model: result.model,
|
||||
},
|
||||
message: `${result.variantMediaIds.length} Variant${result.variantMediaIds.length === 1 ? '' : 's'} für "${character.name}" generiert`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return { success: false, message: 'Vault ist gesperrt' };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : 'Variant-Generierung fehlgeschlagen',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'pin_character_variant',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Setzt einen anderen Variant als kanonischen Look. Stories danach erstellt nutzen den neuen Pin — bestehende Stories bleiben unveraendert (Snapshot-Pattern).',
|
||||
parameters: [
|
||||
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
|
||||
{
|
||||
name: 'variantMediaId',
|
||||
type: 'string',
|
||||
description: 'ID des neuen Pin-Variants',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const characterId = String(params.characterId ?? '').trim();
|
||||
const variantMediaId = String(params.variantMediaId ?? '').trim();
|
||||
if (!characterId || !variantMediaId) {
|
||||
return { success: false, message: 'characterId und variantMediaId erforderlich' };
|
||||
}
|
||||
|
||||
try {
|
||||
await comicCharactersStore.pinVariant(characterId, variantMediaId);
|
||||
return {
|
||||
success: true,
|
||||
data: { characterId, pinnedVariantId: variantMediaId },
|
||||
message: `Variant gepinned`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : 'Pin fehlgeschlagen',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Append character tools to the main export so init.ts picks them
|
||||
// up via the existing registerTools(comicTools) call.
|
||||
comicTools.push(...comicCharacterTools);
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
/**
|
||||
* 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 { deriveUpdatedAt } from '$lib/data/sync';
|
||||
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;
|
||||
/**
|
||||
* FK to the comicCharacter that drives this story (Character-Mode,
|
||||
* Mc3+). Plaintext — used for "Charakter: <Name>"-Anzeige im
|
||||
* DetailView und für `useStoriesByCharacter`-Cross-Refs.
|
||||
*
|
||||
* `null` when the story was created in **Quick-Mode** (rohes
|
||||
* face/body/garments-Setup ohne Character-Iteration). Beide Modi
|
||||
* funktionieren parallel — die Story hängt am `characterMediaIds`-
|
||||
* Array für die eigentliche Render-Logik (Snapshot-Pattern).
|
||||
*/
|
||||
characterId?: string | null;
|
||||
/**
|
||||
* Reference-image IDs passed unchanged to every panel-generate call.
|
||||
* Character-Mode: enthält genau die `pinnedVariantMediaId` des
|
||||
* referenzierten comicCharacters zum Story-Create-Zeitpunkt
|
||||
* (Snapshot — Re-Pinning ändert das nicht rückwirkend).
|
||||
* Quick-Mode: enthält face-ref + optional body-ref + Wardrobe-
|
||||
* Garment-Photos.
|
||||
* 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;
|
||||
/** FK to the comic-character driving this story; undefined in Quick-Mode. */
|
||||
characterId?: string;
|
||||
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,
|
||||
characterId: local.characterId ?? undefined,
|
||||
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: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// ─── 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: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<!--
|
||||
Comic-Characters list view — grid of all characters in the active
|
||||
space, with a "+ Neuer Character" CTA. The face-ref upload banner
|
||||
lives one level up in the module-root ListView (above the tabs),
|
||||
so we don't repeat it here per tab.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus } from '@mana/shared-icons';
|
||||
import { getActiveSpace } from '$lib/data/scope';
|
||||
import { useAllCharacters } from '../queries';
|
||||
import CharacterCard from '../components/CharacterCard.svelte';
|
||||
|
||||
const characters$ = useAllCharacters();
|
||||
const characters = $derived(characters$.value ?? []);
|
||||
|
||||
const activeSpace = $derived(getActiveSpace());
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-foreground">Deine Comic-Characters</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{characters.length}
|
||||
{characters.length === 1 ? 'Character' : 'Characters'} in
|
||||
<strong class="text-foreground">{activeSpace?.name ?? 'diesem Space'}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/comic/character/new"
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Neuer Character
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{#if characters.length > 0}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each characters as character (character.id)}
|
||||
<CharacterCard {character} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !characters$.loading}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
|
||||
<p class="text-sm font-medium text-foreground">Noch keine Characters.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Bau deinen ersten Comic-Character aus deinem Foto — Stil wählen, 4 Varianten generieren,
|
||||
beste pinnen, fertig.
|
||||
</p>
|
||||
<a
|
||||
href="/comic/character/new"
|
||||
class="mt-4 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Ersten Character bauen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
<!--
|
||||
Comic-Character detail — Meta-Card (name + style + favorite +
|
||||
archive/delete) + Variant-Grid mit Pin/Remove + "Mehr Varianten
|
||||
generieren"-Button (öffnet inline den Builder im extend-mode).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ArrowLeft, Archive, Heart, Plus, Sparkle, Trash } from '@mana/shared-icons';
|
||||
import { comicCharactersStore } from '../stores/characters.svelte';
|
||||
import { useCharacter } from '../queries';
|
||||
import VariantTile from '../components/VariantTile.svelte';
|
||||
import CharacterBuilder from '../components/CharacterBuilder.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
let { id }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const character$ = useCharacter(id);
|
||||
const character = $derived(character$.value);
|
||||
|
||||
let showBuilder = $state(false);
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
if (!character) return;
|
||||
await comicCharactersStore.toggleFavorite(character.id);
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!character) return;
|
||||
await comicCharactersStore.archiveCharacter(character.id, !character.isArchived);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!character) return;
|
||||
if (
|
||||
!confirm(
|
||||
$_('comic.character_detail.confirm_delete_character', {
|
||||
values: { name: character.name },
|
||||
})
|
||||
)
|
||||
)
|
||||
return;
|
||||
await comicCharactersStore.deleteCharacter(character.id);
|
||||
await goto('/comic/character');
|
||||
}
|
||||
|
||||
async function handlePin(variantId: string) {
|
||||
if (!character) return;
|
||||
await comicCharactersStore.pinVariant(character.id, variantId);
|
||||
}
|
||||
|
||||
async function handleRemove(variantId: string) {
|
||||
if (!character) return;
|
||||
if (!confirm($_('comic.character_detail.confirm_remove_variant'))) return;
|
||||
await comicCharactersStore.removeVariant(character.id, variantId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-5 p-4 sm:p-6">
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
<a
|
||||
href="/comic/character"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
aria-label={$_('comic.character_detail.back_aria')}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</a>
|
||||
<span class="text-muted-foreground">{$_('comic.character_detail.breadcrumb')}</span>
|
||||
</nav>
|
||||
|
||||
{#if !character}
|
||||
{#if character$.loading}
|
||||
<p class="text-sm text-muted-foreground">{$_('comic.character_detail.loading')}</p>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{$_('comic.character_detail.not_found')}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{$_('comic.character_detail.not_found_hint')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Meta -->
|
||||
<div class="space-y-3 rounded-2xl border border-border bg-card p-5">
|
||||
<header class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-lg font-semibold text-foreground">{character.name}</h1>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
|
||||
{$_('comic.styles.' + character.style)}
|
||||
</span>
|
||||
<span>
|
||||
{character.variantMediaIds.length === 1
|
||||
? $_('comic.character_detail.variant_one', {
|
||||
values: { n: character.variantMediaIds.length },
|
||||
})
|
||||
: $_('comic.character_detail.variant_other', {
|
||||
values: { n: character.variantMediaIds.length },
|
||||
})}
|
||||
</span>
|
||||
{#if !character.pinnedVariantId && character.variantMediaIds.length > 0}
|
||||
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 font-medium text-amber-700"
|
||||
>{$_('comic.character_detail.pin_open')}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleToggleFavorite}
|
||||
aria-label={character.isFavorite
|
||||
? $_('comic.character_detail.favorite_remove')
|
||||
: $_('comic.character_detail.favorite_set')}
|
||||
title={character.isFavorite
|
||||
? $_('comic.character_detail.favorite_remove')
|
||||
: $_('comic.character_detail.favorite_set')}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors {character.isFavorite
|
||||
? 'text-rose-500 hover:bg-rose-500/10'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
>
|
||||
<Heart size={16} weight={character.isFavorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if character.description}
|
||||
<p class="whitespace-pre-wrap text-sm text-foreground">{character.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if character.addPrompt}
|
||||
<div class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<strong class="text-foreground">{$_('comic.character_detail.prompt_add_label')}</strong>
|
||||
{character.addPrompt}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Variants -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{$_('comic.character_detail.section_variants')}
|
||||
</h2>
|
||||
{#if !showBuilder && !character.isArchived}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showBuilder = true)}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{$_('comic.character_detail.action_more_variants')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if character.variantMediaIds.length === 0}
|
||||
<div
|
||||
class="rounded-2xl border border-dashed border-border bg-background/50 p-6 text-center"
|
||||
>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{$_('comic.character_detail.empty_variants_title')}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html $_('comic.character_detail.empty_variants_hint_html')}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each character.variantMediaIds as variantId, index (variantId)}
|
||||
<VariantTile
|
||||
{variantId}
|
||||
variantIndex={index}
|
||||
isPinned={character.pinnedVariantId === variantId}
|
||||
onPin={() => handlePin(variantId)}
|
||||
onRemove={character.variantMediaIds.length > 1
|
||||
? () => handleRemove(variantId)
|
||||
: undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showBuilder && !character.isArchived}
|
||||
<CharacterBuilder
|
||||
existing={character}
|
||||
onClose={() => (showBuilder = false)}
|
||||
onCreated={() => {
|
||||
// Keep the builder open so the user can iterate without
|
||||
// having to re-open. New variants append + appear in
|
||||
// the grid above via the liveQuery.
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary actions -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleArchive}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<Archive size={14} />
|
||||
{character.isArchived
|
||||
? $_('comic.character_detail.unarchive')
|
||||
: $_('comic.character_detail.archive')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-error transition-colors hover:bg-error/10"
|
||||
>
|
||||
<Trash size={14} />
|
||||
{$_('comic.character_detail.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if character.isArchived}
|
||||
<p
|
||||
class="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<Sparkle size={12} class="inline" />
|
||||
{$_('comic.character_detail.archived_hint')}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
<!--
|
||||
Comic story detail — meta card (title + style + visibility +
|
||||
favorite + archive/delete) and panel strip with a "+ Panel" CTA
|
||||
that opens the PanelEditor sheet inline.
|
||||
|
||||
Removing a panel here strips it from the story's `panelImageIds`
|
||||
and `panelMeta` only — the picture.images row itself survives so
|
||||
the user can keep the render in their Picture gallery. Final
|
||||
deletion happens from Picture, per decision in the plan.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ArrowLeft, Archive, Heart, Plus, Sparkle, Trash } from '@mana/shared-icons';
|
||||
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
|
||||
import { comicStoriesTable } from '../collections';
|
||||
import { comicStoriesStore } from '../stores/stories.svelte';
|
||||
import { useStory } from '../queries';
|
||||
import PanelStrip from '../components/PanelStrip.svelte';
|
||||
import PanelEditor from '../components/PanelEditor.svelte';
|
||||
import BatchPanelEditor from '../components/BatchPanelEditor.svelte';
|
||||
import StoryboardSuggester from '../components/StoryboardSuggester.svelte';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { ComicPanelMeta, LocalComicStory } from '../types';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
let { id }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const story$ = useStory(id);
|
||||
const story = $derived(story$.value);
|
||||
|
||||
type EditorMode = 'off' | 'single' | 'batch' | 'ai';
|
||||
let editorMode = $state<EditorMode>('off');
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
if (!story) return;
|
||||
await comicStoriesStore.toggleFavorite(story.id);
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!story) return;
|
||||
await comicStoriesStore.archiveStory(story.id, !story.isArchived);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!story) return;
|
||||
if (!confirm($_('comic.detail.confirm_delete_story', { values: { title: story.title } })))
|
||||
return;
|
||||
await comicStoriesStore.deleteStory(story.id);
|
||||
await goto('/comic');
|
||||
}
|
||||
|
||||
async function handleVisibilityChange(next: VisibilityLevel) {
|
||||
if (!story) return;
|
||||
await comicStoriesStore.setVisibility(story.id, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a panel from the story without touching the image row.
|
||||
* Re-encrypts `panelMeta` because it's one JSON blob per the
|
||||
* registry; we can't partially update without decrypting first.
|
||||
*/
|
||||
async function handleRemovePanel(panelId: string) {
|
||||
if (!story) return;
|
||||
if (!confirm($_('comic.detail.confirm_remove_panel'))) return;
|
||||
|
||||
const existing = await comicStoriesTable.get(story.id);
|
||||
if (!existing) return;
|
||||
const nextIds = (existing.panelImageIds ?? []).filter((pid) => pid !== panelId);
|
||||
const nextMeta: Record<string, ComicPanelMeta> = { ...(existing.panelMeta ?? {}) };
|
||||
delete nextMeta[panelId];
|
||||
const patch = {
|
||||
panelImageIds: nextIds,
|
||||
panelMeta: nextMeta,
|
||||
} as Partial<LocalComicStory>;
|
||||
await encryptRecord('comicStories', patch);
|
||||
await comicStoriesTable.update(story.id, patch);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-5 p-4 sm:p-6">
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
<a
|
||||
href="/comic"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
aria-label={$_('comic.detail.back_aria')}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</a>
|
||||
<span class="text-muted-foreground">{$_('comic.detail.breadcrumb')}</span>
|
||||
</nav>
|
||||
|
||||
{#if !story}
|
||||
{#if story$.loading}
|
||||
<p class="text-sm text-muted-foreground">{$_('comic.detail.loading')}</p>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
|
||||
<p class="text-sm font-medium text-foreground">{$_('comic.detail.not_found')}</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{$_('comic.detail.not_found_hint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Meta -->
|
||||
<div class="space-y-3 rounded-2xl border border-border bg-card p-5">
|
||||
<header class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-lg font-semibold text-foreground">{story.title}</h1>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
|
||||
{$_('comic.styles.' + story.style)}
|
||||
</span>
|
||||
<span>
|
||||
{story.panelImageIds.length === 1
|
||||
? $_('comic.detail.panel_one', { values: { n: story.panelImageIds.length } })
|
||||
: $_('comic.detail.panel_other', { values: { n: story.panelImageIds.length } })}
|
||||
</span>
|
||||
{#if story.characterMediaIds.length > 0}
|
||||
<span class="text-border">·</span>
|
||||
<span>
|
||||
{story.characterMediaIds.length === 1
|
||||
? $_('comic.detail.reference_one', {
|
||||
values: { n: story.characterMediaIds.length },
|
||||
})
|
||||
: $_('comic.detail.reference_other', {
|
||||
values: { n: story.characterMediaIds.length },
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<VisibilityPicker
|
||||
level={story.visibility ?? 'space'}
|
||||
onChange={handleVisibilityChange}
|
||||
compact
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleToggleFavorite}
|
||||
aria-label={story.isFavorite
|
||||
? $_('comic.detail.favorite_remove')
|
||||
: $_('comic.detail.favorite_set')}
|
||||
title={story.isFavorite
|
||||
? $_('comic.detail.favorite_remove')
|
||||
: $_('comic.detail.favorite_set')}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors {story.isFavorite
|
||||
? 'text-rose-500 hover:bg-rose-500/10'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
>
|
||||
<Heart size={16} weight={story.isFavorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if story.description}
|
||||
<p class="whitespace-pre-wrap text-sm text-foreground">{story.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if story.storyContext}
|
||||
<div class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<strong class="text-foreground">{$_('comic.detail.context_label')}</strong>
|
||||
{story.storyContext}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Panels -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{$_('comic.detail.section_panels')}
|
||||
</h2>
|
||||
{#if editorMode === 'off' && !story.isArchived}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editorMode = 'single')}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{$_('comic.detail.add_panel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editorMode = 'batch')}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
title={$_('comic.detail.add_batch_title')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
{$_('comic.detail.add_batch')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editorMode = 'ai')}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10"
|
||||
title={$_('comic.detail.add_ai_title')}
|
||||
>
|
||||
<Sparkle size={12} weight="fill" />
|
||||
{$_('comic.detail.add_ai')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PanelStrip
|
||||
panelImageIds={story.panelImageIds}
|
||||
panelMeta={story.panelMeta}
|
||||
onRemove={handleRemovePanel}
|
||||
/>
|
||||
|
||||
{#if editorMode === 'single' && !story.isArchived}
|
||||
<PanelEditor
|
||||
{story}
|
||||
onClose={() => (editorMode = 'off')}
|
||||
onGenerated={() => {
|
||||
// Keep the editor open for rapid iteration — the user
|
||||
// usually wants to generate 3–5 panels in a row. Reset
|
||||
// happens inside PanelEditor on success.
|
||||
}}
|
||||
/>
|
||||
{:else if editorMode === 'batch' && !story.isArchived}
|
||||
<BatchPanelEditor {story} onClose={() => (editorMode = 'off')} />
|
||||
{:else if editorMode === 'ai' && !story.isArchived}
|
||||
<StoryboardSuggester {story} onClose={() => (editorMode = 'off')} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary actions -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleArchive}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<Archive size={14} />
|
||||
{story.isArchived ? $_('comic.detail.unarchive') : $_('comic.detail.archive')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-error transition-colors hover:bg-error/10"
|
||||
>
|
||||
<Trash size={14} />
|
||||
{$_('comic.detail.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if story.isArchived}
|
||||
<p
|
||||
class="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<Sparkle size={12} class="inline" />
|
||||
{$_('comic.detail.archived_hint')}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<!--
|
||||
Comic stories list view — grid of stories in the active space.
|
||||
The face-ref upload banner lives one level up in the module-root
|
||||
ListView (above the tabs), so we don't repeat it here per tab.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Plus } from '@mana/shared-icons';
|
||||
import { getActiveSpace } from '$lib/data/scope';
|
||||
import { useAllStories } from '../queries';
|
||||
import StoryCard from '../components/StoryCard.svelte';
|
||||
|
||||
const stories$ = useAllStories();
|
||||
const stories = $derived(stories$.value ?? []);
|
||||
|
||||
const activeSpace = $derived(getActiveSpace());
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-foreground">Deine Comics</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{stories.length}
|
||||
{stories.length === 1 ? 'Story' : 'Stories'} in
|
||||
<strong class="text-foreground">{activeSpace?.name ?? 'diesem Space'}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/comic/new"
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Neue Story
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{#if stories.length > 0}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each stories as story (story.id)}
|
||||
<StoryCard {story} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !stories$.loading}
|
||||
<div class="rounded-2xl border border-dashed border-border bg-background/50 p-8 text-center">
|
||||
<p class="text-sm font-medium text-foreground">Noch keine Comics.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Starte deine erste Geschichte — aus einem Gedanken, einem Tagebuch-Eintrag oder einfach
|
||||
einer Idee.
|
||||
</p>
|
||||
<a
|
||||
href="/comic/new"
|
||||
class="mt-4 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Erste Story anlegen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -48,9 +48,6 @@ export function toImage(local: LocalImage): Image {
|
|||
sourceImageId: local.sourceImageId ?? undefined,
|
||||
referenceImageIds: local.referenceImageIds ?? undefined,
|
||||
generationMode: local.generationMode ?? undefined,
|
||||
comicStoryId: local.comicStoryId ?? undefined,
|
||||
comicPanelIndex: local.comicPanelIndex ?? undefined,
|
||||
comicCharacterId: local.comicCharacterId ?? undefined,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,32 +39,6 @@ export interface LocalImage extends BaseRecord {
|
|||
/** mana-media ids of the me-images that fed a reference-edit. */
|
||||
referenceImageIds?: string[] | null;
|
||||
generationMode?: ImageGenerationMode | 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;
|
||||
/**
|
||||
* 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 {
|
||||
|
|
@ -129,9 +103,6 @@ export interface Image {
|
|||
sourceImageId?: string;
|
||||
referenceImageIds?: string[];
|
||||
generationMode?: ImageGenerationMode;
|
||||
comicStoryId?: string;
|
||||
comicPanelIndex?: number;
|
||||
comicCharacterId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import type { LocalTaskTag } from '$lib/modules/todo/types';
|
|||
import type { LocalGoal } from '$lib/companion/goals/types';
|
||||
import type { LocalPlace } from '$lib/modules/places/types';
|
||||
import type { LocalRecipe } from '$lib/modules/recipes/types';
|
||||
import type { LocalComicStory } from '$lib/modules/comic/types';
|
||||
import type { LocalHabit, LocalHabitLog } from '$lib/modules/habits/types';
|
||||
import type { LocalQuiz } from '$lib/modules/quiz/types';
|
||||
import type { LocalSocialEvent } from '$lib/modules/events/types';
|
||||
|
|
@ -70,9 +69,6 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise<ResolvedEmb
|
|||
case 'recipes.recipes':
|
||||
items = await resolveRecipes(props);
|
||||
break;
|
||||
case 'comic.stories':
|
||||
items = await resolveComicStories(props);
|
||||
break;
|
||||
case 'habits.habits':
|
||||
items = await resolveHabits(props);
|
||||
break;
|
||||
|
|
@ -482,70 +478,6 @@ async function resolveRecipes(props: ModuleEmbedProps): Promise<EmbedItem[]> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Comic-stories: public-comic-portfolio use case. Returns stories
|
||||
* flipped to 'public' with their cover panel as the card image
|
||||
* (panelImageIds[0] → picture.images.publicUrl). Hard-gated on
|
||||
* canEmbedOnWebsite.
|
||||
*
|
||||
* Whitelist (plan §2): title + "N Panels" subtitle + cover-panel URL.
|
||||
* Character references, panel captions/dialogues, storyContext, and
|
||||
* the full panelMeta stay out of the snapshot — the cover image is
|
||||
* already an AI-rendered artifact, the other fields would leak the
|
||||
* author's briefing and source-entry linkage.
|
||||
*/
|
||||
async function resolveComicStories(props: ModuleEmbedProps): Promise<EmbedItem[]> {
|
||||
let stories = await db.table<LocalComicStory>('comicStories').toArray();
|
||||
stories = stories.filter(
|
||||
(s) => !s.deletedAt && !s.isArchived && canEmbedOnWebsite(s.visibility ?? 'private')
|
||||
);
|
||||
|
||||
if (props.filter?.isFavorite === true) {
|
||||
stories = stories.filter((s) => s.isFavorite === true);
|
||||
}
|
||||
if (props.filter?.kind) {
|
||||
// `kind` reuses the generic filter slot as a style filter so the
|
||||
// website editor can restrict to e.g. only manga-style comics.
|
||||
stories = stories.filter((s) => s.style === props.filter?.kind);
|
||||
}
|
||||
if (props.filter?.tagIds?.length) {
|
||||
const wanted = new Set(props.filter.tagIds);
|
||||
stories = stories.filter((s) => (s.tags ?? []).some((t) => wanted.has(t)));
|
||||
}
|
||||
|
||||
const decrypted = (await decryptRecords('comicStories', stories)) as LocalComicStory[];
|
||||
|
||||
// Favourites first, then newest.
|
||||
decrypted.sort((a, b) => {
|
||||
const favA = a.isFavorite ? 0 : 1;
|
||||
const favB = b.isFavorite ? 0 : 1;
|
||||
if (favA !== favB) return favA - favB;
|
||||
return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
|
||||
});
|
||||
|
||||
const coverImageIds = decrypted
|
||||
.map((s) => s.panelImageIds?.[0])
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const coverImages = await db
|
||||
.table<LocalImage>('images')
|
||||
.where('id')
|
||||
.anyOf(coverImageIds)
|
||||
.toArray();
|
||||
const coverById = new Map<string, LocalImage>();
|
||||
for (const img of coverImages) coverById.set(img.id, img);
|
||||
|
||||
return decrypted.map((s) => {
|
||||
const coverId = s.panelImageIds?.[0];
|
||||
const cover = coverId ? coverById.get(coverId) : undefined;
|
||||
const panelCount = s.panelImageIds?.length ?? 0;
|
||||
return {
|
||||
title: s.title,
|
||||
subtitle: `${panelCount} ${panelCount === 1 ? 'Panel' : 'Panels'}`,
|
||||
imageUrl: cover?.publicUrl ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Habits: build-in-public use case. Returns active habits flipped to
|
||||
* 'public' with their current streak as subtitle.
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import ListView from '$lib/modules/comic/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Comic · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic">
|
||||
<ListView />
|
||||
</RoutePage>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import DetailView from '$lib/modules/comic/views/DetailView.svelte';
|
||||
|
||||
const id = $derived(page.params.id ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Comic · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic" backHref="/comic">
|
||||
<!-- Fresh subtree on :id change so liveQuery and local state (panel
|
||||
editor open / close, scroll position) reset cleanly when
|
||||
navigating between /comic/a → /comic/b. -->
|
||||
{#key id}
|
||||
<DetailView {id} />
|
||||
{/key}
|
||||
</RoutePage>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import CharactersView from '$lib/modules/comic/views/CharactersView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Comic-Characters · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic" backHref="/comic">
|
||||
<div class="mx-auto max-w-4xl space-y-4 p-4 sm:p-6">
|
||||
<CharactersView />
|
||||
</div>
|
||||
</RoutePage>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import DetailCharacterView from '$lib/modules/comic/views/DetailCharacterView.svelte';
|
||||
|
||||
const id = $derived(page.params.id ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Comic-Character · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic" backHref="/comic/character">
|
||||
{#key id}
|
||||
<DetailCharacterView {id} />
|
||||
{/key}
|
||||
</RoutePage>
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import CharacterBuilder from '$lib/modules/comic/components/CharacterBuilder.svelte';
|
||||
import type { ComicStyle } from '$lib/modules/comic/types';
|
||||
|
||||
const VALID_STYLES: ComicStyle[] = ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'];
|
||||
|
||||
function isValidStyle(s: string | null): s is ComicStyle {
|
||||
return s !== null && (VALID_STYLES as string[]).includes(s);
|
||||
}
|
||||
|
||||
// Optional URL-param prefill — used by the Mc5 wardrobe-hook
|
||||
// ("Als Comic-Character"-Button auf einem Outfit/Garment): we land
|
||||
// here with `?prompt=wearing+the+Bühnenoutfit&style=manga`, the
|
||||
// builder picks them up as initial state. Plain user creates
|
||||
// (no params) are unaffected.
|
||||
const initialName = $derived(page.url.searchParams.get('title') ?? undefined);
|
||||
const initialAddPrompt = $derived(page.url.searchParams.get('prompt') ?? undefined);
|
||||
const styleParam = $derived(page.url.searchParams.get('style'));
|
||||
const initialStyle = $derived(isValidStyle(styleParam) ? styleParam : undefined);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neuer Comic-Character · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic" backHref="/comic/character">
|
||||
<div class="mx-auto max-w-2xl space-y-4 p-4 sm:p-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-lg font-semibold text-foreground">Neuer Comic-Character</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Wähle Stil + optionalen Add-Prompt — wir rendern direkt 4 Varianten zur Auswahl. Aus dem
|
||||
Detail kannst du jederzeit weitere generieren.
|
||||
</p>
|
||||
</header>
|
||||
<CharacterBuilder {initialName} {initialAddPrompt} {initialStyle} />
|
||||
</div>
|
||||
</RoutePage>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
import StoryForm from '$lib/modules/comic/components/StoryForm.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neuer Comic · Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="comic" backHref="/comic">
|
||||
<div class="mx-auto max-w-2xl space-y-4 p-4 sm:p-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-lg font-semibold text-foreground">Neuer Comic</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Wähle Stil + Protagonist, dann startest du mit dem ersten Panel.
|
||||
</p>
|
||||
</header>
|
||||
<StoryForm />
|
||||
</div>
|
||||
</RoutePage>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,170 +0,0 @@
|
|||
import { AI_PROPOSABLE_TOOL_NAMES } from '../../policy/proposable-tools';
|
||||
import type { AgentTemplate } from './types';
|
||||
import type { AiPolicy } from '../../policy/types';
|
||||
|
||||
/**
|
||||
* Comic-Autor — turns the user's text artifacts (journal entries,
|
||||
* notes, library reviews) into short illustrated comics via the
|
||||
* comic module's panel-generation pipeline.
|
||||
*
|
||||
* Why propose on every write:
|
||||
* Each `comic.generatePanel` call consumes credits (3–25 each, 10
|
||||
* for the medium default). A mis-reading of the source text or an
|
||||
* over-generous panel count would burn spend fast. Propose-on-write
|
||||
* forces the user to approve the Panel[] suggestion list before any
|
||||
* picture.images row lands.
|
||||
*
|
||||
* Reads (journal/notes/library + comic.listStories) stay auto — the
|
||||
* agent may freely peek at existing content to pick which entry to
|
||||
* illustrate or which story to append to, without nagging the user.
|
||||
*
|
||||
* Tools this template uses:
|
||||
* - journal.list* (read) — browse source entries
|
||||
* - notes.list* (read) — browse source notes
|
||||
* - library.list* (read) — browse reviews
|
||||
* - comic.listStories (read) — find an existing story to extend
|
||||
* - comic.createStory (propose) — start a new comic
|
||||
* - comic.generatePanel (propose) — render a single panel (credits!)
|
||||
* - comic.reorderPanels (propose) — rearrange existing panels
|
||||
*
|
||||
* The comic.* tools live in mana-tool-registry (MCP) and are NOT
|
||||
* part of AI_TOOL_CATALOG — they're reachable from persona-runner
|
||||
* and external MCP clients (Claude Desktop). The foreground webapp
|
||||
* runner will pick them up when the comic module gains its
|
||||
* AI_TOOL_CATALOG entries in a later step; until then this template
|
||||
* is primarily useful on the persona-runner side.
|
||||
*/
|
||||
|
||||
// Per-tool propose policy. Start from every proposable tool in the
|
||||
// AI catalog (same seed as the Recherche-Agent) so cross-module
|
||||
// writes this template doesn't anticipate (`create_note` for a
|
||||
// sidecar summary note, etc.) still land as proposals.
|
||||
const COMIC_AUTHOR_POLICY: AiPolicy = {
|
||||
tools: {
|
||||
...Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'propose' as const])),
|
||||
// Web-app catalog names (snake_case). The spread above already
|
||||
// covers propose-defaults; read tools (list_*) get pinned to
|
||||
// auto explicitly for clarity.
|
||||
list_comic_stories: 'auto',
|
||||
create_comic_story: 'propose',
|
||||
generate_comic_panel: 'propose',
|
||||
// Character tools (Mc4) — same auto/propose split as story tools.
|
||||
list_comic_characters: 'auto',
|
||||
create_comic_character: 'propose',
|
||||
generate_character_variant: 'propose',
|
||||
pin_character_variant: 'propose',
|
||||
// MCP-registry names (dot-case). The agent uses these when
|
||||
// running inside persona-runner / Claude Desktop where the
|
||||
// mana-tool-registry surface is what the MCP client sees.
|
||||
// Listing them keeps the policy intent consistent across both
|
||||
// surfaces (foreground runner + MCP).
|
||||
'comic.listStories': 'auto',
|
||||
'comic.createStory': 'propose',
|
||||
'comic.generatePanel': 'propose',
|
||||
'comic.reorderPanels': 'propose',
|
||||
'comic.listCharacters': 'auto',
|
||||
'comic.createCharacter': 'propose',
|
||||
'comic.generateVariant': 'propose',
|
||||
'comic.pinVariant': 'propose',
|
||||
},
|
||||
defaultsByModule: {
|
||||
// Read-only companions the agent uses to find source material.
|
||||
journal: 'auto',
|
||||
notes: 'auto',
|
||||
library: 'auto',
|
||||
// Kontext + goals are referenced as standing context the
|
||||
// planner already auto-injects; keep them auto so the agent can
|
||||
// skim them for tonal cues (is the user in a serious phase, is
|
||||
// there a goal the comic should celebrate).
|
||||
kontext: 'auto',
|
||||
goals: 'auto',
|
||||
// Every comic write requires approval.
|
||||
comic: 'propose',
|
||||
},
|
||||
defaultForAi: 'propose',
|
||||
};
|
||||
|
||||
export const comicAuthorTemplate: AgentTemplate = {
|
||||
id: 'comic-author',
|
||||
version: '1',
|
||||
icon: '🎨',
|
||||
label: 'Comic-Autor',
|
||||
tagline: 'Verwandelt Tagebuch-Einträge und Notizen in kurze Comics',
|
||||
description: `Gib dem Agent einen Tagebuch-Eintrag, eine Notiz oder ein Review aus deiner Bibliothek — er schlägt daraus einen kurzen Comic vor:
|
||||
|
||||
1. Liest den gewählten Text
|
||||
2. Schlägt 4 Panels vor (Prompt + Caption + Dialog pro Panel)
|
||||
3. Du bestätigst die Liste, optional mit Edits
|
||||
4. Jedes Panel wird via gpt-image-2 gerendert und an die Story angehängt
|
||||
|
||||
Jeder Generate-Schritt ist ein Vorschlag — du bestimmst, wann Credits fließen. Für 4 Panels mit Default-Qualität: 4 × 10 = 40 Credits.`,
|
||||
category: 'ai',
|
||||
color: '#F97316',
|
||||
agent: {
|
||||
name: 'Comic-Autor',
|
||||
avatar: '🎨',
|
||||
role: 'Macht aus Text kurze Comics',
|
||||
systemPrompt: `Du bist Comic-Autor. Wenn der User dir einen Moment, ein Erlebnis oder eine Idee gibt, verwandelst du das in einen kurzen Comic.
|
||||
|
||||
Vorgehen:
|
||||
1. Lies den Ausgangstext zu Ende, bevor du mit Panels anfängst — Details aus der Mitte oder dem Ende sind oft der Kern.
|
||||
2. Wähle einen Stil, der zum Ton passt: 'comic' für Humor/Alltag, 'manga' für Drama, 'cartoon' für Kinder/Leichtigkeit, 'graphic-novel' für Reflexion/Melancholie, 'webtoon' für vertikale Long-Reads.
|
||||
3. Wenn der User noch keinen passenden Comic-Character hat: nutze list_comic_characters um zu prüfen, dann create_comic_character (legt Row an) → generate_character_variant (rendert 4 Varianten) → User wählt eine → pin_character_variant. Das ist EINMALIG — der gepinnte Character bleibt für viele Stories der stabile Identity-Anchor.
|
||||
4. Schlage 4 Panels vor (2–6 je nach Textlänge). Jedes Panel hat:
|
||||
- prompt: was passiert bildlich (kurze englische Sätze, Komposition + Aktion + Stimmung)
|
||||
- caption (optional): kurze Erzählzeile über/unter dem Bild
|
||||
- dialogue (optional): was der Protagonist sagt, in Sprechblase
|
||||
5. Protagonist ist IMMER der User selbst (sein gepinnter Character bzw. sein face-ref im Quick-Mode).
|
||||
6. Kein Panel-Nummerieren, keine Meta-Kommentare, keine Style-Beschreibungen im Prompt (Stil kommt aus der Story / aus dem Character).
|
||||
|
||||
Ton:
|
||||
- Humor wenn der User es leicht nimmt, ernst wenn er es ernst nimmt. Nicht belehrend.
|
||||
- Niemals urteilen über das was der User erlebt hat.
|
||||
- Deutsch als Sprache in Captions/Dialogen ist ok; englische Text-Prompts rendern aber stabiler.
|
||||
|
||||
Tools:
|
||||
- journal.listEntries / notes.list / library.listEntries um Quelle zu finden
|
||||
- list_comic_characters / list_comic_stories um Bestand zu prüfen (nicht jede Quelle braucht neuen Character oder neue Story)
|
||||
- create_comic_character → generate_character_variant → pin_character_variant: Character-Aufbau-Pfad (einmalig pro Stil)
|
||||
- create_comic_story um eine Story anzulegen (mit existierendem Character als Anchor oder im Quick-Mode mit face-ref)
|
||||
- generate_comic_panel um einen Panel anzuhängen (teurer Call — nur nach Bestätigung)`,
|
||||
memory: `# Comic-Richtlinien
|
||||
|
||||
(Hier kannst du festhalten wie du Comics magst — z.B. bevorzugter Stil,
|
||||
Panel-Anzahl, wieviel Dialog vs. Caption, Tabu-Themen die nie vorkommen sollen.)
|
||||
`,
|
||||
policy: COMIC_AUTHOR_POLICY,
|
||||
maxConcurrentMissions: 1,
|
||||
},
|
||||
scene: {
|
||||
name: 'Comic-Werkstatt',
|
||||
description: 'Texte lesen, Panels vorschlagen, Comic-Stories bauen',
|
||||
openApps: [
|
||||
{ appId: 'comic', widthPx: 540 },
|
||||
{ appId: 'journal', widthPx: 440 },
|
||||
{ appId: 'ai-missions', widthPx: 360 },
|
||||
{ appId: 'ai-workbench', widthPx: 360 },
|
||||
],
|
||||
},
|
||||
missions: [
|
||||
{
|
||||
title: 'Comic aus einem Tagebuch-Eintrag',
|
||||
objective:
|
||||
'Wähle einen Tagebuch-Eintrag, schlage Titel + Stil vor, und generiere eine Panel-Folge (Default 4). Jeder Generate-Schritt ist ein Vorschlag.',
|
||||
conceptMarkdown: `# Comic-Auftrag
|
||||
|
||||
Ersetze diesen Block mit:
|
||||
|
||||
- **Eintrag:** _Link auf den Tagebuch-Eintrag (oder Zitat daraus)_
|
||||
- **Stil:** _comic / manga / cartoon / graphic-novel / webtoon — oder leer lassen, damit der Agent vorschlägt_
|
||||
- **Panels:** _2-8, default 4_
|
||||
- **Ton:** _frei — "leicht und selbstironisch" / "ernst und reflektiert" / "melancholisch"_
|
||||
|
||||
Der Agent liest den Eintrag, legt eine neue Comic-Story an (als Vorschlag),
|
||||
schlägt die Panel-Folge vor, und rendert die Panels einzeln nach deiner
|
||||
Bestätigung.`,
|
||||
cadence: { kind: 'manual' },
|
||||
startPaused: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -14,7 +14,6 @@ import { calmnessTemplate } from './calmness';
|
|||
import { fitnessTemplate } from './fitness';
|
||||
import { deepWorkTemplate } from './deep-work';
|
||||
import { eventScoutTemplate } from './event-scout';
|
||||
import { comicAuthorTemplate } from './comic-author';
|
||||
|
||||
export type {
|
||||
// Generalised names (T1 of workbench-templates plan):
|
||||
|
|
@ -41,7 +40,6 @@ export const ALL_TEMPLATES = [
|
|||
fitnessTemplate,
|
||||
deepWorkTemplate,
|
||||
eventScoutTemplate,
|
||||
comicAuthorTemplate,
|
||||
] as const;
|
||||
|
||||
export {
|
||||
|
|
@ -52,7 +50,6 @@ export {
|
|||
fitnessTemplate,
|
||||
deepWorkTemplate,
|
||||
eventScoutTemplate,
|
||||
comicAuthorTemplate,
|
||||
};
|
||||
|
||||
/** Lookup helper — returns the template matching the given id, or
|
||||
|
|
|
|||
|
|
@ -1835,223 +1835,6 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
|
|||
parameters: [{ name: 'draftId', type: 'string', description: 'ID des Drafts', required: true }],
|
||||
},
|
||||
|
||||
// ── Comic ───────────────────────────────────────────────
|
||||
{
|
||||
name: 'list_comic_stories',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Listet Comic-Stories im aktiven Space (id, title, style, panelCount, isFavorite). Optional nach Stil oder Favoriten filterbar.',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Nur einen Stil zeigen',
|
||||
required: false,
|
||||
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
|
||||
},
|
||||
{
|
||||
name: 'favoriteOnly',
|
||||
type: 'boolean',
|
||||
description: 'Nur Favoriten',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'create_comic_story',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Legt eine neue Comic-Story an. Charakter-Referenzen werden automatisch aus den primary face-ref + body-ref des aktiven Space aufgeloest — Nutzer muss vorher ein Gesichtsbild in /profile/me-images hochgeladen haben. Stil ist fix, alle spaeteren Panels nutzen denselben Stil-Prefix.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{ name: 'title', type: 'string', description: 'Titel der Story', required: true },
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Visueller Stil',
|
||||
required: true,
|
||||
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
description: 'Kurze Story-Beschreibung',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'storyContext',
|
||||
type: 'string',
|
||||
description:
|
||||
'Freitext-Briefing — Ton, Ziel, Hintergrund. Wird im AI-Storyboard-Flow als Briefing genutzt.',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'string',
|
||||
description: 'Tags durch Komma getrennt',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'generate_comic_panel',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Rendert ein neues Panel in einer bestehenden Story via gpt-image-2. Konsumiert Credits (low=3, medium=10, high=25). Stil-Prefix und Charakter-Refs kommen aus der Story — nur Panel-Prompt + optional Caption/Dialog werden uebergeben. Caption und Dialog werden direkt in das Bild gerendert.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{ name: 'storyId', type: 'string', description: 'ID der Story', required: true },
|
||||
{
|
||||
name: 'panelPrompt',
|
||||
type: 'string',
|
||||
description:
|
||||
'Was passiert in diesem Panel (Szene, Aktion, Stimmung). Kurze englische Saetze am stabilsten.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'string',
|
||||
description: 'Erzaehl-Zeile ueber/unter dem Bild (optional)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'dialogue',
|
||||
type: 'string',
|
||||
description: 'Sprechblasen-Text (optional)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
type: 'string',
|
||||
description: 'Render-Qualitaet — hoeher = mehr Credits',
|
||||
required: false,
|
||||
enum: ['low', 'medium', 'high'],
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
description:
|
||||
'Rendering-Backend. openai/gpt-image-2 ist Standard. google/gemini-3-pro-image-preview = Nano Banana Pro (hoehere Charakter-Konsistenz, teurer). google/gemini-3.1-flash-image-preview = Nano Banana 2 (neuestes, schnell, guenstig).',
|
||||
required: false,
|
||||
enum: [
|
||||
'openai/gpt-image-2',
|
||||
'google/gemini-3-pro-image-preview',
|
||||
'google/gemini-3.1-flash-image-preview',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list_comic_characters',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Listet Comic-Characters im aktiven Space (id, name, style, variantCount, pinnedVariantId, isFavorite). Optional nach Stil oder Favoriten filterbar.',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Nur einen Stil zeigen',
|
||||
required: false,
|
||||
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
|
||||
},
|
||||
{
|
||||
name: 'favoriteOnly',
|
||||
type: 'boolean',
|
||||
description: 'Nur Favoriten',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'create_comic_character',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Legt einen neuen Comic-Character an OHNE direkt Varianten zu rendern (Splittet Anlegen von Generierung — User reviewt erst). Charakter-Refs werden automatisch aus dem primary face-ref + body-ref des aktiven Space aufgeloest. Stil ist fix nach Anlage. Gibt characterId zurueck — danach generate_character_variant aufrufen.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{ name: 'name', type: 'string', description: 'Name des Characters', required: true },
|
||||
{
|
||||
name: 'style',
|
||||
type: 'string',
|
||||
description: 'Visueller Stil',
|
||||
required: true,
|
||||
enum: ['comic', 'manga', 'cartoon', 'graphic-novel', 'webtoon'],
|
||||
},
|
||||
{
|
||||
name: 'addPrompt',
|
||||
type: 'string',
|
||||
description: 'Zusaetzlicher Prompt (z.B. "freundlicher Ausdruck", "casual outfit")',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
description: 'Kurze Charakter-Beschreibung',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'tags', type: 'string', description: 'Tags durch Komma getrennt', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'generate_character_variant',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Rendert N (default 4) Variant-Portraits fuer einen existierenden Comic-Character und appended sie an den Variant-Pool. Konsumiert Credits × count (medium=10c). Auto-pinnt die erste Variante wenn noch keine gepinnt ist. Stil + Source-Refs kommen aus dem Character — nur count + quality + model sind hier waehlbar.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{
|
||||
name: 'characterId',
|
||||
type: 'string',
|
||||
description: 'ID des Characters',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
type: 'number',
|
||||
description: 'Anzahl Varianten (1-4, default 4)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
type: 'string',
|
||||
description: 'Render-Qualitaet — hoeher = mehr Credits',
|
||||
required: false,
|
||||
enum: ['low', 'medium', 'high'],
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
type: 'string',
|
||||
description: 'Rendering-Backend (default openai/gpt-image-2).',
|
||||
required: false,
|
||||
enum: [
|
||||
'openai/gpt-image-2',
|
||||
'google/gemini-3-pro-image-preview',
|
||||
'google/gemini-3.1-flash-image-preview',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'pin_character_variant',
|
||||
module: 'comic',
|
||||
description:
|
||||
'Setzt einen anderen Variant als kanonischen Look des Comic-Characters. Stories die DANACH erstellt werden nutzen den neuen Pin; bestehende Stories bleiben unveraendert (sie haben den alten Variant zum Story-Create-Zeitpunkt fix gespeichert).',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{ name: 'characterId', type: 'string', description: 'ID des Characters', required: true },
|
||||
{
|
||||
name: 'variantMediaId',
|
||||
type: 'string',
|
||||
description: 'ID der Variante die zum neuen Pin werden soll (muss in variantMediaIds sein)',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Augur (signs / fortunes / hunches) ──────────────────────
|
||||
{
|
||||
name: 'capture_sign',
|
||||
|
|
|
|||
|
|
@ -63,10 +63,6 @@ const timesSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="
|
|||
// Calc icon (calculator with pink gradient)
|
||||
const calcSvg = `<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(#calcGrad)"/><rect x="320" y="260" width="384" height="504" rx="32" fill="white"/><rect x="360" y="300" width="304" height="100" rx="16" fill="#ec4899" fill-opacity="0.2"/><rect x="380" y="330" width="200" height="16" rx="4" fill="#ec4899" fill-opacity="0.5"/><rect x="380" y="358" width="120" height="24" rx="4" fill="#ec4899" fill-opacity="0.7"/><rect x="360" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="576" width="64" height="120" rx="12" fill="#ec4899"/><rect x="360" y="644" width="144" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="644" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><defs><linearGradient id="calcGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#ec4899"/><stop offset="1" stop-color="#db2777"/></linearGradient></defs></svg>`;
|
||||
|
||||
// Comic icon — speech bubble with a lightning-bolt panel marker on
|
||||
// orange→red gradient. Warm creative-family tone for the Mana launcher.
|
||||
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>`;
|
||||
|
||||
// Augur icon — open eye with a small star in the iris and three drifting
|
||||
// dots ("signs in the air") on indigo→violet gradient. Sits in the cosmic
|
||||
// family next to Dreams (indigo) and Cards (violet) so the launcher reads
|
||||
|
|
@ -99,7 +95,6 @@ export const APP_ICONS = {
|
|||
todo: svgToDataUrl(todoSvg),
|
||||
mail: svgToDataUrl(mailSvg),
|
||||
inventory: svgToDataUrl(inventorySvg),
|
||||
comic: svgToDataUrl(comicSvg),
|
||||
augur: svgToDataUrl(augurSvg),
|
||||
questions: svgToDataUrl(questionsSvg),
|
||||
times: svgToDataUrl(timesSvg),
|
||||
|
|
|
|||
|
|
@ -345,23 +345,6 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'beta',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
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',
|
||||
name: 'Questions',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
<option value="places.places">Orte</option>
|
||||
<option value="recipes.recipes">Rezepte</option>
|
||||
<option value="wardrobe.outfits">Wardrobe (Outfits)</option>
|
||||
<option value="comic.stories">Comics</option>
|
||||
<option value="habits.habits">Habits</option>
|
||||
<option value="quiz.quizzes">Quizze</option>
|
||||
<option value="events.socialEvents">Events (RSVP)</option>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export const EmbedSourceSchema = z.enum([
|
|||
'goals.goals',
|
||||
'places.places',
|
||||
'recipes.recipes',
|
||||
'comic.stories',
|
||||
'habits.habits',
|
||||
'quiz.quizzes',
|
||||
'events.socialEvents',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue