ae91f4bfd5
- /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
216 lines
9.5 KiB
TypeScript
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 };
|
|
}
|
|
}
|