From ed22a76fda5357648055940df74d269bcd30b254 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Fri, 24 Jul 2026 09:12:07 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6 --- mcp/README.md | 9 ++++++--- mcp/klarbild-mcp.mjs | 32 ++++++++++++++++++++++++++------ src/lib/delivery.ts | 36 ++++++------------------------------ src/lib/telegram.ts | 7 +++++-- src/pages/api/jobs/index.ts | 1 + src/pages/changelog.astro | 3 ++- src/pages/llms.txt.ts | 2 +- 7 files changed, 47 insertions(+), 43 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 27c0f4d..37989a7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -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 diff --git a/mcp/klarbild-mcp.mjs b/mcp/klarbild-mcp.mjs index 57c4169..c90ed06 100644 --- a/mcp/klarbild-mcp.mjs +++ b/mcp/klarbild-mcp.mjs @@ -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) { diff --git a/src/lib/delivery.ts b/src/lib/delivery.ts index 3b268d9..b1e723d 100644 --- a/src/lib/delivery.ts +++ b/src/lib/delivery.ts @@ -45,35 +45,11 @@ async function sidecarForKey(key: string): Promise { 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('SELECT id, result_path, filename, folder_id, job_id, has_alpha FROM items WHERE id=$1', [itemId]); + const it = await one('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)) { diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts index 157dfae..8742fb7 100644 --- a/src/lib/telegram.ts +++ b/src/lib/telegram.ts @@ -165,10 +165,10 @@ async function chatRecipeDefaults(chatId: number): Promise { 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) diff --git a/src/pages/api/jobs/index.ts b/src/pages/api/jobs/index.ts index 992d50e..96b35d1 100644 --- a/src/pages/api/jobs/index.ts +++ b/src/pages/api/jobs/index.ts @@ -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. diff --git a/src/pages/changelog.astro b/src/pages/changelog.astro index 2ea03ab..c5ed875 100644 --- a/src/pages/changelog.astro +++ b/src/pages/changelog.astro @@ -12,7 +12,8 @@ import Base from '../layouts/Base.astro';
24.07.2026

JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku

    -
  • Kleinere Dateien für Picdrop: 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.)
  • +
  • Picdrop bekommt genau dein Format: Ausgeliefert wird jetzt exakt das Dateiformat, das du eingestellt hast — global in den Admin-Einstellungen oder 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.)
  • +
  • Telegram & MCP ziehen mit: 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.
  • Auftrag löschen direkt aus der Warteschlange (für alte/fehlgeschlagene Aufträge). Fehlende Quellen melden jetzt einen klaren Hinweis statt kryptischer Technikfehler.
  • KI-lesbare Doku unter /llms.txt — beschreibt Zweck, Formate, API und MCP-Werkzeuge für Assistenten.
  • Dateiformat wählbar: global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) und pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.
  • diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts index ec922de..fcb4845 100644 --- a/src/pages/llms.txt.ts +++ b/src/pages/llms.txt.ts @@ -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