feat: deliver in chosen format (not forced JPEG); wire output_ext through jobs/Telegram/MCP

- delivery.ts: upload the stored result in its chosen format (output_ext,
  global or per-recipe) instead of always converting non-alpha to JPEG.
- jobs POST froze the snapshot without output_ext -> per-conversion/per-recipe
  format choice was silently dropped. Now carried through.
- telegram.ts: recipe snapshots carry output_ext + delivery_target_id
  (custom formats already flow via output_format).
- mcp/klarbild-mcp.mjs: process_images gains tasks, output_ext, orientation,
  crop_mode, contour_mm, picdrop_gallery; job_status shows finished_at + errors.
- Docs: llms.txt, mcp/README, changelog updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-24 09:12:07 +00:00
parent ae91f4bfd5
commit ed22a76fda
7 changed files with 47 additions and 43 deletions
+6 -3
View File
@@ -30,9 +30,12 @@ Werkzeugen) und ruft dann die Klarbild-Tools auf.
- **list_recipes** — verfügbare Presets/Rezepte auflisten.
- **process_images** — Bilder (lokale Pfade oder `data:`-URLs) hochladen und verarbeiten.
Entweder `recipeId` (Preset) oder `mode` (`each`/`compose`/`generate`) + Optionen
(`prompt_text`, `output_format`, `delivery`). Liefert die Auftrags-ID.
- **job_status** — Status eines Auftrags abfragen.
Entweder `recipeId` (Preset) oder `mode` (`each`/`compose`/`generate`) + Optionen:
`tasks` (`clean`/`cutout`/`format`/`contour`), `prompt_text`, `output_format`
(fest wie `30x40`/`theframe` **oder frei** `25x35` / `sticker5`), `output_ext` (`png`/`jpg`,
weglassen = globaler Standard), `orientation`, `crop_mode`, `contour_mm`, `picdrop_gallery`,
`delivery`. Liefert die Auftrags-ID.
- **job_status** — Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).
## Sicherheit
+26 -6
View File
@@ -49,13 +49,20 @@ const TOOLS = [
description: 'Bilder an Klarbild übergeben und verarbeiten. Dateien als lokale Pfade ODER data:-URLs. Entweder recipeId ODER mode+Optionen angeben.',
inputSchema: { type: 'object', properties: {
files: { type: 'array', items: { type: 'string' }, description: 'Lokale Pfade oder data:-URLs' },
recipeId: { type: 'string', description: 'Optional: Preset-ID (aus list_recipes)' },
recipeId: { type: 'string', description: 'Optional: Preset-ID (aus list_recipes) — überschreibt die Einzeloptionen' },
mode: { type: 'string', enum: ['each', 'compose', 'generate'], description: 'Falls kein recipeId' },
tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] },
description: 'Nur bei mode=each. Standard: ["format"] wenn output_format gesetzt.' },
prompt_text: { type: 'string', description: 'Beschreibung für compose/generate' },
output_format: { type: 'string', description: 'z. B. 30x40, theframe, keep' },
output_format: { type: 'string', description: 'Fest: 30x40, A4, theframe, hochformat, keep … ODER frei: "25x35" (=25×35 cm) bzw. "sticker5" (=5×5 cm).' },
output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'Dateiformat. Weglassen = globale Standard-Einstellung. JPG nur ohne Transparenz.' },
orientation: { type: 'string', enum: ['portrait', 'landscape'] },
crop_mode: { type: 'string', enum: ['crop', 'extend'] },
contour_mm: { type: 'number', description: 'Stickerrand in mm (nur mit tasks=cutout+contour).' },
picdrop_gallery: { type: 'string', description: 'Ziel-Galerie/Unterordner (bei delivery picdrop/both).' },
delivery: { type: 'string', enum: ['library', 'picdrop', 'both'] },
}, required: ['files'] } },
{ name: 'job_status', description: 'Status eines Auftrags abfragen.',
{ name: 'job_status', description: 'Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).',
inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } },
];
@@ -73,11 +80,21 @@ server.setRequestHandler({ method: 'tools/call' }, async (req) => {
const files = a.files || [];
const mode = a.mode || 'each';
const sources = (mode === 'generate') ? [] : await Promise.all(files.map((f) => uploadFile(f)));
const wantsFormat = a.output_format && a.output_format !== 'keep';
const tasks = Array.isArray(a.tasks) && a.tasks.length ? a.tasks : (wantsFormat ? ['format'] : []);
const body = a.recipeId
? { recipeId: a.recipeId, mode, sources, prompt_text: a.prompt_text }
: { mode, prompt_text: a.prompt_text, sources,
recipe: { tasks: a.output_format && a.output_format !== 'keep' ? ['format'] : [],
output_format: a.output_format || 'keep', delivery: a.delivery || 'library' },
recipe: {
tasks: mode === 'each' ? tasks : (wantsFormat ? ['format'] : []),
output_format: a.output_format || 'keep',
output_ext: a.output_ext || null,
orientation: a.orientation || 'portrait',
crop_mode: a.crop_mode || 'crop',
contour_mm: a.contour_mm ?? null,
picdrop_gallery: a.picdrop_gallery || null,
delivery: a.delivery || 'library',
},
delivery: a.delivery || 'library' };
const j = await (await fetch(api('/api/jobs'), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen');
@@ -86,7 +103,10 @@ server.setRequestHandler({ method: 'tools/call' }, async (req) => {
if (name === 'job_status') {
const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json();
const job = j.job || j;
return { content: [{ type: 'text', text: `Status: ${job.status}${job.done_count}/${job.total} fertig${job.failed_count ? `, ${job.failed_count} fehlgeschlagen` : ''}` }] };
const errs = (j.items || []).map((it) => it.error_message).filter(Boolean);
const fin = job.finished_at ? ` · beendet ${job.finished_at}` : '';
const errTxt = errs.length ? `\nFehler: ${errs.join('; ')}` : '';
return { content: [{ type: 'text', text: `Status: ${job.status}${job.done_count}/${job.total} fertig${job.failed_count ? `, ${job.failed_count} fehlgeschlagen` : ''}${fin}${errTxt}` }] };
}
return { content: [{ type: 'text', text: `Unbekanntes Tool: ${name}` }], isError: true };
} catch (e) {
+6 -30
View File
@@ -45,35 +45,11 @@ async function sidecarForKey(key: string): Promise<boolean> {
return !!t?.metadata_sidecar;
}
/** Picdrop ist ein Foto-Proofing-Tool und erwartet handliche JPGs, keine 30-MB-PNGs.
* Ergebnisse ohne Transparenz werden darum als JPEG (q92) ausgeliefert; freigestellte
* Motive (Sticker mit Alpha) bleiben PNG. Rückgabe: umgewandelter Puffer + passender Name. */
async function deliverableBuffer(
buf: Buffer, filename: string, hasAlpha: boolean,
): Promise<{ buf: Buffer; name: string }> {
if (hasAlpha) return { buf, name: filename };
// Schon JPEG (globales/Rezept-Format = jpg)? Dann unverändert lassen.
if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { buf, name: filename };
try {
const sharp = (await import('sharp')).default;
const meta = await sharp(buf, { failOn: 'none' }).metadata();
const dpi = meta.density && meta.density > 0 ? meta.density : 300;
const jpg = await sharp(buf, { failOn: 'none' })
.flatten({ background: '#ffffff' })
.withMetadata({ density: dpi })
.jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer();
const name = filename.replace(/\.(png|webp|tiff?)$/i, '.jpg');
return { buf: jpg, name: /\.jpe?g$/i.test(name) ? name : name + '.jpg' };
} catch (e) {
console.error('[delivery] JPEG-Wandlung fehlgeschlagen, sende Original', e);
return { buf, name: filename };
}
}
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus.
* Ausgeliefert wird das gespeicherte Ergebnis **im gewählten Format** (PNG/JPG laut
* globaler bzw. Rezept-Einstellung) — keine erzwungene Umwandlung mehr. */
export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> {
const it = await one<any>('SELECT id, result_path, filename, folder_id, job_id, has_alpha FROM items WHERE id=$1', [itemId]);
const it = await one<any>('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]);
if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
const target = await targetFor(it);
if (!target) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Kein Auslieferungsziel konfiguriert.' }; }
@@ -82,8 +58,8 @@ export async function deliverItem(itemId: string): Promise<{ ok: boolean; messag
await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]);
try {
const gallery = await galleryFor(it);
const raw = await getObject(it.result_path);
const { buf, name } = await deliverableBuffer(raw, it.filename || `${it.id}.png`, !!it.has_alpha);
const buf = await getObject(it.result_path);
const name = it.filename || `${it.id}.png`;
await uploadBuffer(cfg, gallery, name, buf);
// Optional: Metadaten als begleitende .md-Datei — pro Quelle steuerbar.
if (await sidecarForKey(key)) {
+5 -2
View File
@@ -165,10 +165,10 @@ async function chatRecipeDefaults(chatId: number): Promise<any> {
if (r) return {
output_format: r.output_format, orientation: r.orientation, crop_mode: r.crop_mode || 'crop',
dpi: r.dpi || 300, model_key: r.model_key, delivery: r.delivery || 'library',
picdrop_gallery: r.picdrop_gallery,
picdrop_gallery: r.picdrop_gallery, output_ext: r.output_ext, delivery_target_id: r.delivery_target_id,
};
}
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library' };
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library', output_ext: null };
}
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
@@ -187,6 +187,7 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
crop_mode: recipe.crop_mode || 'crop', dpi: recipe.dpi || 300, contour_mm: recipe.contour_mm,
model_key: recipe.model_key, custom_instruction: recipe.custom_instruction,
delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery,
output_ext: recipe.output_ext, delivery_target_id: recipe.delivery_target_id,
};
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
@@ -217,6 +218,7 @@ async function dispatchCompose(chatId: number, draftId: string, description: str
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
};
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
@@ -240,6 +242,7 @@ async function dispatchGenerate(chatId: number, description: string, b: Bot) {
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
};
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
+1
View File
@@ -57,6 +57,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
delivery: snapshot.delivery || 'library',
picdrop_gallery: snapshot.picdrop_gallery ?? null,
delivery_target_id: snapshot.delivery_target_id ?? null,
output_ext: (snapshot.output_ext === 'jpg' || snapshot.output_ext === 'png') ? snapshot.output_ext : null,
};
// Private Session: nur wenn global erlaubt oder für den Nutzer erzwungen.
+2 -1
View File
@@ -12,7 +12,8 @@ import Base from '../layouts/Base.astro';
<section>
<div class="ver"><span class="tag">24.07.2026</span><h2>JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku</h2></div>
<ul>
<li><b>Kleinere Dateien für Picdrop:</b> Ergebnisse ohne Transparenz werden als handliches JPG (q92) ausgeliefert — ein 30×40-Poster schrumpft von ~25 MB auf ~2 MB. Freigestellte Motive (mit Transparenz) bleiben PNG. (Die zwischenzeitlich „verschwundenen" Bilder waren übrigens die ganze Zeit da — es lag an der Sortierung in der Picdrop-Weboberfläche.)</li>
<li><b>Picdrop bekommt genau dein Format:</b> Ausgeliefert wird jetzt exakt das Dateiformat, das du eingestellt hast — global in den Admin-Einstellungen <i>oder</i> pro Auftrag im Studio (PNG oder JPG). Keine erzwungene Umwandlung mehr. Freigestellte Motive bleiben immer PNG. (Die zwischenzeitlich „verschwundenen" Bilder waren übrigens die ganze Zeit da — es lag an der Sortierung in der Picdrop-Weboberfläche.)</li>
<li><b>Telegram & MCP ziehen mit:</b> Der Telegram-Bot und der MCP-Server (für Assistenten) kennen jetzt ebenfalls das wählbare Dateiformat und die eigenen Formate — egal, worüber du einen Auftrag startest.</li>
<li><b>Auftrag löschen</b> direkt aus der Warteschlange (für alte/fehlgeschlagene Aufträge). Fehlende Quellen melden jetzt einen klaren Hinweis statt kryptischer Technikfehler.</li>
<li><b>KI-lesbare Doku</b> unter <code>/llms.txt</code> — beschreibt Zweck, Formate, API und MCP-Werkzeuge für Assistenten.</li>
<li><b>Dateiformat wählbar:</b> global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) <i>und</i> pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.</li>
+1 -1
View File
@@ -66,7 +66,7 @@ Werkzeug \`klarbild\` (mcp/klarbild-mcp.mjs), nutzt denselben API-Token:
## Auslieferung
- Picdrop = SFTP/FTPS-Ziel; Galerie = Unterordner unter dem Basispfad. Voller Pfad = Basisordner + Galerie
(wird erst beim Ausliefern zusammengesetzt). Standard-Galerie „POSTER LEA"; „The Frame" → „TheFrame-Backgrounds".
- Nicht-transparente Ergebnisse gehen als JPG an Picdrop (kleiner, zuverlässiger).
- Ausgeliefert wird das Ergebnis im gewählten Dateiformat (output_ext, global oder pro Auftrag) — PNG oder JPG. Freigestellte Motive bleiben PNG.
- Optionaler .md-Metadaten-Beileger je Ziel schaltbar (Picdrop/NAS/Zusatzziel).
## Datenschutz