feat(comic): image-model picker — OpenAI + Nano Banana wählbar

Comic nutzte bisher 'openai/gpt-image-2' hartcodiert auf drei Ebenen
(generate-panel.ts, comic.generatePanel MCP-Tool, generate_comic_panel
AI-Tool). Wardrobe hat seit dem Nano-Banana-Commit einen
TryOnModelPicker mit drei Optionen — Comic spiegelt das jetzt 1:1.

Wählbar in allen drei Editoren (PanelEditor, BatchPanelEditor,
StoryboardSuggester):
- openai/gpt-image-2 (Default) — OpenAI GPT-image Standard
- google/gemini-3-pro-image-preview — Nano Banana Pro, hohe
  Konsistenz, teurer
- google/gemini-3.1-flash-image-preview — Nano Banana 2, neuestes,
  schnell, günstig

Implementierung:
- api/generate-panel.ts: PanelModel Union + DEFAULT_PANEL_MODEL +
  model? Param auf RunPanelGenerateParams + im HTTP-Body
  weitergereicht (vorher hart 'openai/gpt-image-2').
- components/PanelModelPicker.svelte: neue Komponente, Stil/Markup
  identisch zu TryOnModelPicker für Muskel-Memory über beide Flows.
- components/PanelEditor.svelte: `let model = $state(DEFAULT_PANEL_MODEL)`
  + Picker oberhalb der Qualität-/Format-Leiste + model im
  runPanelGenerate-Call.
- components/BatchPanelEditor.svelte: gleiche Änderung — ein Model
  pro Batch (nicht pro Row) damit der Batch konsistent rendert.
- components/StoryboardSuggester.svelte: gleiches Pattern; der
  Picker landet zwischen "Panel manuell"-Button und dem
  Qualität/Format-Block.
- packages/mana-tool-registry/src/modules/comic.ts: generatePanel
  Input-Schema bekommt model mit zod.enum() + default; im Body
  wird input.model durchgereicht.
- packages/shared-ai/src/tools/schemas.ts: generate_comic_panel
  bekommt Parameter 'model' optional mit gleicher Enum-Liste.
- apps/mana/apps/web/src/lib/modules/comic/tools.ts: isValidModel
  Guard + Parameter-Validierung; model an runPanelGenerate.

Keine Story-Level-Persistierung — model bleibt lokaler State pro
Editor-Mount. Eine model-Spalte auf comicStories würde Migration
brauchen und die Wahl ist eh ad-hoc pro Panel/Batch.

Plan-Doc (§2.1) dokumentiert die Entscheidung + die drei Optionen.

107 shared-ai tests weiter grün. check + validate:all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 17:19:40 +02:00
parent 5501f472ae
commit 364522db87
9 changed files with 252 additions and 6 deletions

View file

@ -29,6 +29,29 @@ import type { ComicPanelMeta, ComicStory } from '../types';
*/
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;
@ -39,6 +62,10 @@ export interface RunPanelGenerateParams {
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 {
@ -64,6 +91,7 @@ async function callGenerateWithReference(opts: {
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`, {
@ -75,7 +103,7 @@ async function callGenerateWithReference(opts: {
body: JSON.stringify({
prompt: opts.prompt,
referenceMediaIds: opts.referenceMediaIds,
model: 'openai/gpt-image-2',
model: opts.model,
quality: opts.quality,
size: opts.size,
n: 1,
@ -153,6 +181,7 @@ export async function runPanelGenerate(
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
@ -164,6 +193,7 @@ export async function runPanelGenerate(
referenceMediaIds,
quality: effectiveQuality,
size: effectiveSize,
model: effectiveModel,
});
const now = new Date().toISOString();

View file

@ -23,9 +23,15 @@
WarningCircle,
X,
} from '@mana/shared-icons';
import { runPanelGenerate, type PanelSize } from '../api/generate-panel';
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;
@ -56,6 +62,7 @@
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');
@ -103,6 +110,7 @@
dialogue: row.dialogue.trim() || undefined,
quality,
size,
model,
});
rowStatus[row.id] = { status: 'ok' };
return result.imageId;
@ -312,6 +320,8 @@
</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>

View file

@ -14,9 +14,15 @@
-->
<script lang="ts">
import { Sparkle, SpinnerGap, X } from '@mana/shared-icons';
import { runPanelGenerate, type PanelSize } from '../api/generate-panel';
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;
@ -30,6 +36,7 @@
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.
@ -58,6 +65,7 @@
dialogue: dialogue.trim() || undefined,
quality,
size,
model,
});
onGenerated?.(result.imageId);
// Reset local state so the next panel-add starts fresh.
@ -182,6 +190,8 @@
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>

View file

@ -0,0 +1,118 @@
<!--
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>

View file

@ -25,7 +25,12 @@
WarningCircle,
X,
} from '@mana/shared-icons';
import { runPanelGenerate, type PanelSize } from '../api/generate-panel';
import {
runPanelGenerate,
DEFAULT_PANEL_MODEL,
type PanelModel,
type PanelSize,
} from '../api/generate-panel';
import {
suggestPanels,
type StoryboardSourceModule,
@ -40,6 +45,7 @@
} from '../constants';
import type { ComicStory } from '../types';
import ReferenceInputPicker, { type ReferenceSelection } from './ReferenceInputPicker.svelte';
import PanelModelPicker from './PanelModelPicker.svelte';
interface Props {
story: ComicStory;
@ -65,6 +71,7 @@
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');
@ -123,6 +130,7 @@
dialogue: row.dialogue?.trim() || undefined,
quality,
size,
model,
sourceInput: {
module: selection.module,
entryId: selection.entryId,
@ -391,6 +399,8 @@
</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>

View file

@ -30,9 +30,19 @@ 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 { runPanelGenerate } from './api/generate-panel';
import { runPanelGenerate, DEFAULT_PANEL_MODEL, type PanelModel } from './api/generate-panel';
import { toStory } from './types';
import type { ComicStyle, LocalComicStory } 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';
@ -265,6 +275,14 @@ export const comicTools: ModuleTool[] = [
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();
@ -283,6 +301,7 @@ export const comicTools: ModuleTool[] = [
: 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
@ -306,6 +325,7 @@ export const comicTools: ModuleTool[] = [
caption,
dialogue,
quality: quality as 'low' | 'medium' | 'high',
model,
});
return {

View file

@ -104,6 +104,27 @@ auseinanderdriften, adressieren wir das mit einer zusätzlichen
"Anchor-Panel"-Referenz (erstes erzeugtes Panel wird Referenz für alle
folgenden) — das ist M6+.
### 2.1 Image-Modell als Picker, nicht hartcodiert (nachgezogen)
Comic nutzt die gleiche Model-Auswahl wie Wardrobe's Try-On:
- `openai/gpt-image-2` — Default, mittlerer Preis, fällt server-seitig
auf gpt-image-1 zurück wenn die OpenAI-Org nicht verified ist.
- `google/gemini-3-pro-image-preview` — Nano Banana Pro, hohe
Charakter-Konsistenz, höherer Preis.
- `google/gemini-3.1-flash-image-preview` — Nano Banana 2, neuestes,
schnell, günstig.
`PanelModelPicker` (Analog zu `TryOnModelPicker`) sitzt als
segmentierter Picker in PanelEditor / BatchPanelEditor /
StoryboardSuggester. Die Wahl ist per-Editor-Mount lokal; keine
Story-Level-Persistierung, weil ein Model-Flag auf der Row eine
Migration bräuchte und die Wahl meistens eh ad-hoc ist.
MCP-Tool `comic.generatePanel` und Catalog-Tool `generate_comic_panel`
akzeptieren beide einen optionalen `model`-Parameter mit demselben
Enum. Default bleibt `openai/gpt-image-2`.
### 3. Fünf Stil-Presets, Mapping im Client
```typescript

View file

@ -282,6 +282,21 @@ const generatePanelInput = z.object({
/** 1024×1024 square is the default; pass `1024x1536` for vertical
* framings (e.g. webtoon tall panels). */
size: z.enum(['1024x1024', '1024x1536']).optional(),
/** Rendering backend. Same closed set as Wardrobe's Try-On picker:
* - `openai/gpt-image-2` (default) mid-tier cost, strong
* structure, server-side fallback to gpt-image-1 if org is
* unverified.
* - `google/gemini-3-pro-image-preview` Nano Banana Pro, strong
* character consistency, higher cost.
* - `google/gemini-3.1-flash-image-preview` Nano Banana 2,
* newest + fast + cheap. */
model: z
.enum([
'openai/gpt-image-2',
'google/gemini-3-pro-image-preview',
'google/gemini-3.1-flash-image-preview',
])
.default('openai/gpt-image-2'),
});
const generatePanelOutput = z.object({
@ -353,7 +368,7 @@ export const comicGeneratePanel: ToolSpec<typeof generatePanelInput, typeof gene
body: JSON.stringify({
prompt: composed,
referenceMediaIds,
model: 'openai/gpt-image-2',
model: input.model,
quality: input.quality,
size: effectiveSize,
n: 1,

View file

@ -1973,6 +1973,18 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
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',
],
},
],
},
];