Files
klarbild/src/lib/process.ts
T
till ae91f4bfd5 feat: llms.txt (AI-readable docs), job delete, friendly missing-source error
- /llms.txt: public machine-readable capability/API/MCP doc for agents.
- Job delete action + queue button for terminal jobs; cleans objects.
- process.ts: clear 'Quelle nicht mehr vorhanden' message instead of raw
  EISDIR/ENOENT when a source was purged.
- Docs corrected: Picdrop 'missing images' was web-UI sorting, not PNG;
  JPG delivery kept as size/perf improvement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 08:57:31 +00:00

216 lines
9.5 KiB
TypeScript

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<string> {
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<Buffer> {
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<ProcessResult> {
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 };
}
}