mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 23:57:43 +02:00
feat(comic): AI_TOOL_CATALOG bridge — webapp-runner kann jetzt Comics
Macht den Comic-Autor-Template (M6) auch im Web-App-Mission-Runner
nutzbar. Bisher war der Template nur über persona-runner/Claude
Desktop sinnvoll, weil die comic.*-Tools nur im mana-tool-registry
(MCP) lagen. Jetzt kennt die AI Workbench drei neue Tools und der
Template-Policy-Map trägt beide Naming-Konventionen.
AI_TOOL_CATALOG-Einträge (packages/shared-ai/src/tools/schemas.ts):
- list_comic_stories (auto) — filter style?/favoriteOnly?/limit?
- create_comic_story (propose) — title + style + optional
description/storyContext/tags. Character-Refs werden vom Executor
automatisch aus meImages primary face-ref + body-ref gezogen,
also muss der Planner keine mediaIds kennen.
- generate_comic_panel (propose) — storyId + panelPrompt + optional
caption/dialogue + quality. Kostet Credits.
Executors (apps/mana/apps/web/src/lib/modules/comic/tools.ts):
- list: scopedForModule pull + decrypt + filter + sort newest.
- create: resolveCharacterMediaIds() scannt meImagesTable für das
aktive Space, nimmt face-ref+body-ref. Fehler wenn kein Face
hinterlegt ("Lade eines in /profile/me-images hoch"). Delegiert
an comicStoriesStore.createStory — gleiche encryption/event-
pipeline wie StoryForm.
- generate: lädt Story decrypted, delegiert an runPanelGenerate
(identischer Pfad wie PanelEditor in der UI), liefert
panelIndex + imageUrl zurück.
Registrierung in data/tools/init.ts (registerTools(comicTools)).
Template-Policy (comic-author.ts) jetzt bi-lingual: snake_case
(AI_TOOL_CATALOG) UND dot-case (MCP) nebeneinander in tools-Map.
So gilt die Intent-Policy konsistent egal welche Runner-Oberfläche
das Tool nennt — auto für list_comic_stories / comic.listStories,
propose für create_comic_story / comic.createStory /
generate_comic_panel / comic.generatePanel / comic.reorderPanels.
apps/mana/CLAUDE.md Tool-Coverage-Tabelle bekommt eine Comic-Zeile.
Tool-Count jetzt 75→78, Module 22→23. 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
6545498dc2
commit
6f37e00bf4
5 changed files with 451 additions and 4 deletions
|
|
@ -238,6 +238,7 @@ Agents interact with the app through tools — each one either auto (executes si
|
|||
| invoices | `create_invoice`, `mark_invoice_paid` | `list_invoices`, `get_invoice_stats` |
|
||||
| library | `create_library_entry`, `update_library_entry_status`, `rate_library_entry` | `list_library_entries` |
|
||||
| writing | `create_draft`, `generate_draft_content`, `refine_draft_selection`, `set_draft_status`, `save_draft_as_article` | `list_drafts`, `get_draft`, `list_writing_styles` |
|
||||
| comic | `create_comic_story`, `generate_comic_panel` | `list_comic_stories` |
|
||||
|
||||
**Server-side web-research**: mana-ai calls mana-api's `/api/v1/news-research/discover` + `/search` directly before the planner prompt is built (pre-planning injection). Missions with research-keyword objectives get real article URLs + excerpts injected as a synthetic ResolvedInput. See `services/mana-ai/src/planner/news-research-client.ts`.
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { libraryTools } from '$lib/modules/library/tools';
|
|||
import { broadcastTools } from '$lib/modules/broadcast/tools';
|
||||
import { websiteTools } from '$lib/modules/website/tools';
|
||||
import { writingTools } from '$lib/modules/writing/tools';
|
||||
import { comicTools } from '$lib/modules/comic/tools';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
|
|
@ -95,5 +96,6 @@ export function initTools(): void {
|
|||
registerTools(broadcastTools);
|
||||
registerTools(websiteTools);
|
||||
registerTools(writingTools);
|
||||
registerTools(comicTools);
|
||||
initialized = true;
|
||||
}
|
||||
|
|
|
|||
337
apps/mana/apps/web/src/lib/modules/comic/tools.ts
Normal file
337
apps/mana/apps/web/src/lib/modules/comic/tools.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/**
|
||||
* 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 { runPanelGenerate } from './api/generate-panel';
|
||||
import { toStory } from './types';
|
||||
import type { ComicStyle, LocalComicStory } from './types';
|
||||
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'],
|
||||
},
|
||||
],
|
||||
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';
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
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 };
|
||||
|
|
@ -42,10 +42,20 @@ import type { AiPolicy } from '../../policy/types';
|
|||
const COMIC_AUTHOR_POLICY: AiPolicy = {
|
||||
tools: {
|
||||
...Object.fromEntries(AI_PROPOSABLE_TOOL_NAMES.map((n) => [n, 'propose' as const])),
|
||||
// MCP-tools explicit: they're not in AI_TOOL_CATALOG so the
|
||||
// spread above doesn't cover them. Listing them here is the
|
||||
// only way to pin the policy — defaultsByModule wouldn't help
|
||||
// because the tool-level entry wins over module defaults.
|
||||
// Web-app catalog names (snake_case). The spread above already
|
||||
// covers create_comic_story / generate_comic_panel because both
|
||||
// are defaultPolicy='propose' in AI_TOOL_CATALOG, but we pin
|
||||
// list_comic_stories explicitly as auto (read-only tools come
|
||||
// from the catalog as 'auto' already, so this is defensive
|
||||
// rather than strictly required).
|
||||
list_comic_stories: 'auto',
|
||||
create_comic_story: 'propose',
|
||||
generate_comic_panel: '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',
|
||||
|
|
|
|||
|
|
@ -1878,6 +1878,103 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
|
|||
defaultPolicy: 'propose',
|
||||
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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue