feat: image core — format math, sharp pipeline, sticker contour, prompts, pg-boss queue+worker

- format.ts: exact px (cm/2.54*dpi), catalog incl. The Frame/portrait, aspect ratio
- pipeline.ts: finalizeToFormat (cover/attention crop, dpi metadata), stickerContour
  (alpha dilation -> white border), heic->png, german filename slug
- prompts.ts: task prompts (clean/cutout/format) — reproduce, don't reinterpret
- process.ts: per-item chain (load -> model /v1/images -> finalize -> contour -> store)
- queue.ts + worker.ts: pg-boss (concurrency, retry, counters, 402 pause, completion)
- verified locally: 30x40@300dpi = exact 3543x4724 + dpi meta; sticker white border + alpha
This commit is contained in:
2026-07-23 11:20:55 +00:00
parent b46dbbe889
commit 577b13f680
8 changed files with 434 additions and 1 deletions
+129
View File
@@ -0,0 +1,129 @@
import { one, query } from './db';
import { getObject, putObject, resultKey } from './storage';
import { generateImage } from './openrouter';
import { buildPrompt, type Task } from './prompts';
import { resolveDimensions, aspectRatio, type Orientation } from './format';
import { finalizeToFormat, stickerContour, slugFilename, type CropMode } from './pipeline';
import sharp from 'sharp';
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
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;
delivery?: 'library' | 'picdrop' | 'both';
picdrop_gallery?: string | null;
}
/** 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')}`;
}
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 }>(
'SELECT id, job_id, source_path FROM items WHERE id=$1', [itemId]);
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
const job = await one<{ recipe_snapshot: RecipeSnapshot }>(
'SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
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');
await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]);
try {
const source = await getObject(item.source_path);
const target = r.tasks.includes('format') && r.output_format
? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi })
: null;
const model = await pickModel(r.model_key);
const prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
// Nur Modell aufrufen, wenn eine generative Aufgabe dabei ist.
const needsModel = tasks.includes('clean') || wantCutout ||
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction;
let working = source;
let modelUsed: string | null = null;
let cost = 0;
if (needsModel) {
const gen = await generateImage({
model: model.id,
prompt,
inputUrl: await toDataUrl(source),
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
background: wantCutout ? 'transparent' : undefined,
outputFormat: 'png',
});
working = gen.buffer;
modelUsed = gen.model;
cost = gen.cost;
}
// 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;
}
const key = resultKey(item.id);
await putObject(key, outBuf, 'image/png');
const label = target?.label || 'original';
const filename = slugFilename(label.replace(/\s+/g, '-'), target?.label || '');
const outputPx = `${fin.width}x${fin.height}`;
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none';
await query(
`UPDATE items SET status='done', result_path=$2, filename=$3, output_px=$4, dpi=$5,
has_alpha=$6, model_used=$7, prompt_used=$8, cost=$9, error_message=NULL,
delivery_status=$10 WHERE id=$1`,
[itemId, key, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000),
cost, deliveryStatus]);
return { ok: true, cost };
} catch (e: any) {
const msg = e?.friendly || e?.message || 'Unbekannter Fehler';
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
[itemId, String(msg).slice(0, 500)]);
// 402/Guthaben: nach oben durchreichen, damit der Worker den Auftrag anhält
if (e?.status === 402) throw e;
return { ok: false, cost: 0, error: msg };
}
}