import { one, query } from './db'; import { getObject, putObject, deleteObject, resultKey, thumbKey } from './storage'; import { generateImage } from './openrouter'; import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts'; import { resolveDimensions, aspectRatio, type Orientation } from './format'; import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline'; import sharp from 'sharp'; const DEFAULT_MODEL = 'google/gemini-3.1-flash-image'; type Mode = 'each' | 'compose' | 'generate'; interface RecipeSnapshot { tasks: Task[]; output_format?: string; orientation?: Orientation; crop_mode?: CropMode; dpi?: number; contour_mm?: number | null; model_key?: string | null; custom_instruction?: string | null; prompt_text?: string | null; // Beschreibung für compose/generate delivery?: 'library' | 'picdrop' | 'both'; picdrop_gallery?: string | null; output_ext?: 'png' | 'jpg' | null; // Dateiformat-Override; sonst globale Einstellung } /** Welches Modell-ID + Alpha-Fähigkeit? Aus models-Tabelle, sonst Default. */ async function pickModel(modelKey?: string | null): Promise<{ id: string; alpha: boolean }> { if (modelKey) { const m = await one<{ model_id: string; supports_alpha: boolean }>( 'SELECT model_id, supports_alpha FROM models WHERE label=$1 OR model_id=$1 LIMIT 1', [modelKey]); if (m) return { id: m.model_id, alpha: m.supports_alpha }; } const def = await one<{ model_id: string; supports_alpha: boolean }>( 'SELECT model_id, supports_alpha FROM models WHERE active AND is_default ORDER BY sort LIMIT 1'); if (def) return { id: def.model_id, alpha: def.supports_alpha }; return { id: DEFAULT_MODEL, alpha: true }; } async function toDataUrl(buf: Buffer): Promise { const small = await sharp(buf, { failOn: 'none' }) .resize(1536, 1536, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 90 }) .toBuffer(); return `data:image/jpeg;base64,${small.toString('base64')}`; } /** Kleines Vorschaubild (webp) für die Bibliothek — spart Bandbreite & Speicher. */ async function makeThumb(buf: Buffer): Promise { return sharp(buf, { failOn: 'none' }) .resize(600, 600, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 72 }) .toBuffer(); } export interface ProcessResult { ok: boolean; cost: number; error?: string; } /** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */ export async function processItem(itemId: string): Promise { const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>( 'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]); if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' }; const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>( 'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]); const r = (job?.recipe_snapshot || {}) as RecipeSnapshot; const mode: Mode = job?.mode || 'each'; const isPrivate = !!job?.private; const tasks = r.tasks || []; const dpi = r.dpi ?? 300; const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop'; const wantCutout = tasks.includes('cutout'); const wantContour = tasks.includes('contour'); const description = (r.prompt_text || '').trim(); await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]); try { // Quellen je Modus einsammeln const sourceKeys: string[] = mode === 'compose' ? (item.source_paths || []).filter(Boolean) : mode === 'generate' ? [] : (item.source_path ? [item.source_path] : []); // Braucht dieser Modus eine Quelle, ist aber keine (mehr) da? Klare Meldung statt roher fs-Fehler. if (mode !== 'generate' && sourceKeys.length === 0) { const e: any = new Error('Quelle nicht mehr vorhanden — bitte das Bild erneut hochladen.'); e.friendly = 'Quelle nicht mehr vorhanden — bitte das Bild erneut hochladen.'; throw e; } const sources = await Promise.all(sourceKeys.map(async (k) => { try { return await getObject(k); } catch { const e: any = new Error('Quelldatei nicht mehr vorhanden — bitte das Bild erneut hochladen.'); e.friendly = 'Quelldatei nicht mehr vorhanden — bitte das Bild erneut hochladen.'; throw e; } })); const target = tasks.includes('format') && r.output_format ? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi }) : null; const model = await pickModel(r.model_key); // Prompt + ob das Modell überhaupt gebraucht wird, je Modus. let prompt: string; let needsModel: boolean; if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend', sourceKeys.length); needsModel = true; } else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; } else { prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction }); needsModel = tasks.includes('clean') || wantCutout || (tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction; } let working = sources[0] || Buffer.alloc(0); let modelUsed: string | null = null; let cost = 0; if (needsModel) { const inputUrls = sources.length ? await Promise.all(sources.map(toDataUrl)) : undefined; const gen = await generateImage({ model: model.id, prompt, inputUrls, aspectRatio: target ? aspectRatio(target.w, target.h) : undefined, background: wantCutout ? 'transparent' : undefined, outputFormat: 'png', }); working = gen.buffer; modelUsed = gen.model; cost = gen.cost; } if (!working.length) throw new Error('Keine Bilddaten erzeugt'); // Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi const fin = await finalizeToFormat(working, target, cropMode, dpi); let outBuf = fin.buffer; let hasAlpha = fin.hasAlpha || wantCutout; if (wantContour && r.contour_mm) { outBuf = await stickerContour(outBuf, Number(r.contour_mm), dpi); hasAlpha = true; } // Ausgabeformat: Rezept-Override, sonst globale Einstellung. JPG nur ohne Transparenz. const gset = await one<{ output_ext: string | null }>('SELECT output_ext FROM settings WHERE id=1'); const wantExt = (r.output_ext || gset?.output_ext || 'png').toLowerCase(); let ext = 'png'; let contentType = 'image/png'; if (wantExt === 'jpg' && !hasAlpha) { outBuf = await sharp(outBuf, { failOn: 'none' }) .flatten({ background: '#ffffff' }) .withMetadata({ density: dpi }) .jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' }) .toBuffer(); ext = 'jpg'; contentType = 'image/jpeg'; } const key = resultKey(item.id); await putObject(key, outBuf, contentType); // Vorschaubild (optional, laut Einstellung) const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; nas_enabled: boolean }>( 'SELECT make_thumbnails, keep_sources, nas_enabled FROM settings WHERE id=1'); let thumbPath: string | null = null; if (cfg?.make_thumbnails !== false) { try { const tk = thumbKey(item.id); await putObject(tk, await makeThumb(outBuf), 'image/webp'); thumbPath = tk; } catch (e) { console.error('[process] Thumbnail fehlgeschlagen', e); } } const filename = buildResultFilename(item.filename, formatToken(r.output_format), ext); const outputPx = `${fin.width}x${fin.height}`; // Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt. const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none'; const nasStatus = (!isPrivate && cfg?.nas_enabled) ? 'pending' : 'none'; await query( `UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6, has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL, delivery_status=$11, nas_status=$12 WHERE id=$1`, [itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed, isPrivate ? null : prompt.slice(0, 1000), cost, deliveryStatus, nasStatus]); // Quellbilder löschen, wenn nicht behalten — bei privaten Aufträgen immer. if (isPrivate || cfg?.keep_sources === false) { for (const k of sourceKeys) await deleteObject(k).catch(() => {}); if (isPrivate) await query(`UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1`, [itemId]); } // Zweite Sicherung auf alle Backup-Ziele — nicht bei privaten Aufträgen. if (!isPrivate) { try { const { backupItem } = await import('./backup'); await backupItem(itemId); } catch (e) { console.error('[process] Backup fehlgeschlagen', e); } } return { ok: true, cost }; } catch (e: any) { const real = e?.message || String(e); const status = e?.status ? ` [status ${e.status}]` : ''; console.error(`[process] Item ${itemId} fehlgeschlagen${status}: ${real}`, e?.stack || ''); const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : ''); await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`, [itemId, String(msg).slice(0, 500)]); if (e?.status === 402) throw e; return { ok: false, cost: 0, error: msg }; } }