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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user