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 -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)