mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
5501f472ae
commit
364522db87
9 changed files with 252 additions and 6 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue