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
+67
View File
@@ -0,0 +1,67 @@
// Zielformate & exakte Pixelberechnung (siehe 01 §5, 06 §3).
// px = round(cm / 2.54 * dpi)
export type Orientation = 'portrait' | 'landscape';
export interface Dimensions { w: number; h: number; cm?: [number, number]; label: string; }
// cm-Formate: [Breite, Höhe] in Hochformat-Konvention (kurze Seite × lange Seite)
const CM: Record<string, [number, number]> = {
'9x13': [9, 13], '10x15': [10, 15], '13x18': [13, 18], '15x20': [15, 20],
'20x30': [20, 30], '30x40': [30, 40], '30x45': [30, 45], '40x50': [40, 50],
'40x60': [40, 60], '50x70': [50, 70], '60x90': [60, 90],
A4: [21, 29.7], A3: [29.7, 42], A2: [42, 59.4],
'20x20': [20, 20], '30x30': [30, 30],
};
// Reine Bildschirmformate (feste Pixel, kein cm/dpi)
const SCREEN: Record<string, [number, number]> = {
theframe: [3840, 2160], // 16:9 quer
hochformat: [2160, 3840], // 9:16 hoch
};
export const cmToPx = (cm: number, dpi: number) => Math.round((cm / 2.54) * dpi);
export interface ResolveInput {
format: string; // Schlüssel oben, 'sticker', 'custom' oder 'keep'
orientation?: Orientation;
dpi?: number;
customCm?: [number, number]; // für 'sticker'/'custom'
}
/** Liefert exakte Zielpixel + Label. `keep` → null (Original behalten). */
export function resolveDimensions(input: ResolveInput): Dimensions | null {
const { format } = input;
const dpi = input.dpi ?? 300;
const orient = input.orientation ?? 'portrait';
if (format === 'keep') return null;
if (format in SCREEN) {
const [w, h] = SCREEN[format];
return { w, h, label: format === 'theframe' ? 'The Frame' : 'Hochformat' };
}
let cm: [number, number] | undefined;
if (format in CM) cm = CM[format];
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
if (!cm) throw new Error(`Unbekanntes Format: ${format}`);
// Hochformat-Konvention → gewünschte Orientierung anwenden
let [wCm, hCm] = cm;
if (orient === 'landscape') [wCm, hCm] = [hCm, wCm];
return { w: cmToPx(wCm, dpi), h: cmToPx(hCm, dpi), cm: [wCm, hCm],
label: `${cm[0]}×${cm[1]} cm` };
}
/** Vereinfachtes Seitenverhältnis als "b:h" für die Bild-API. */
export function aspectRatio(w: number, h: number): string {
const g = gcd(w, h);
return `${Math.round(w / g)}:${Math.round(h / g)}`;
}
function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); }
/** Reicht die native Auflösung für echte 300 dpi? (Hinweis in der UI.) */
export function upscaleWarning(srcW: number, srcH: number, target: Dimensions): boolean {
return srcW < target.w * 0.95 || srcH < target.h * 0.95;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { runMigrations } from './db'; import { runMigrations } from './db';
import { ensureBucket } from './storage'; import { ensureBucket } from './storage';
import { seed } from './seed'; import { seed } from './seed';
import { startImageWorker } from '../worker';
let started: Promise<void> | null = null; let started: Promise<void> | null = null;
@@ -11,8 +12,8 @@ export function ensureInit(): Promise<void> {
await runMigrations(); await runMigrations();
try { await ensureBucket(); } catch (e) { console.error('[init] S3 nicht bereit:', e); } try { await ensureBucket(); } catch (e) { console.error('[init] S3 nicht bereit:', e); }
await seed(); await seed();
try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); }
console.log('[init] Klarbild bereit.'); console.log('[init] Klarbild bereit.');
// TODO (Phase Bildkern): pg-boss-Worker hier starten.
})().catch((e) => { started = null; throw e; }); })().catch((e) => { started = null; throw e; });
} }
return started; return started;
+94
View File
@@ -0,0 +1,94 @@
import sharp from 'sharp';
import type { Dimensions } from './format';
export type CropMode = 'crop' | 'extend';
/**
* Bringt ein (Modell-)Ergebnis exakt auf das Zielmaß und schreibt dpi-Metadaten.
* target=null → Original behalten (nur dpi setzen). Erhält Alpha, falls vorhanden.
*/
export async function finalizeToFormat(
buffer: Buffer,
target: Dimensions | null,
cropMode: CropMode,
dpi: number,
): Promise<{ buffer: Buffer; width: number; height: number; hasAlpha: boolean }> {
let img = sharp(buffer, { failOn: 'none' });
const meta = await img.metadata();
const hasAlpha = !!meta.hasAlpha;
if (target) {
img = sharp(buffer, { failOn: 'none' }).resize(target.w, target.h, {
fit: 'cover',
position: cropMode === 'crop' ? sharp.strategy.attention : 'centre',
});
}
const out = await img
.withMetadata({ density: dpi })
.png({ compressionLevel: 9 })
.toBuffer({ resolveWithObject: true });
return { buffer: out.data, width: out.info.width, height: out.info.height, hasAlpha };
}
/**
* Weißer Stickerrand um das freigestellte Motiv (ohne Modell).
* Alpha extrahieren → Dilatation (Blur+Threshold) → weiße Konturebene → Original darüber.
* Radius = contourMm / 25.4 * dpi.
*/
export async function stickerContour(
buffer: Buffer,
contourMm: number,
dpi: number,
): Promise<Buffer> {
const base = sharp(buffer, { failOn: 'none' }).ensureAlpha();
const { width, height } = await base.metadata();
if (!width || !height) throw new Error('Bildmaße unbekannt');
const radiusPx = Math.max(1, Math.round((contourMm / 25.4) * dpi));
// Alpha als Graustufenmaske, per Blur+Threshold verbreitern (Dilatation).
const mask = await sharp(buffer, { failOn: 'none' })
.ensureAlpha()
.extractChannel('alpha')
.blur(Math.max(0.3, radiusPx / 2))
.threshold(1)
.toColourspace('b-w')
.raw()
.toBuffer();
// Weiße Ebene, die verbreiterte Maske als Alpha.
const whiteLayer = await sharp({
create: { width, height, channels: 3, background: { r: 255, g: 255, b: 255 } },
})
.joinChannel(mask, { raw: { width, height, channels: 1 } })
.png()
.toBuffer();
// Original (RGBA) über die Konturebene legen.
const original = await base.png().toBuffer();
return sharp(whiteLayer)
.composite([{ input: original }])
.withMetadata({ density: dpi })
.png({ compressionLevel: 9 })
.toBuffer();
}
/** HEIC/HEIF → PNG-Puffer (sharp kann HEIC im Standard-Build meist nicht). */
export async function heicToPng(buffer: Buffer): Promise<Buffer> {
const convert = (await import('heic-convert')).default as any;
const out = await convert({ buffer, format: 'PNG' });
return Buffer.from(out);
}
/** Deutscher Datei-Slug: klein, mit Bindestrichen, Format angehängt. */
export function slugFilename(base: string, formatLabel: string, ext = 'png'): string {
const slug = (base || 'bild')
.toLowerCase()
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'bild';
const fmt = formatLabel.toLowerCase().replace(/[^a-z0-9]+/g, '');
return `${slug}${fmt ? '-' + fmt : ''}.${ext}`;
}
+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 };
}
}
+45
View File
@@ -0,0 +1,45 @@
// Aufgaben-Prompts für die Bild-API. Leitprinzip (01 §1): reproduzieren & erweitern,
// nicht neu interpretieren. Farben, Komposition, Texturen und v.a. Lettering treu lassen.
export type Task = 'clean' | 'cutout' | 'format' | 'contour' | 'deliver';
const CLEAN =
'Recreate ONLY the actual artwork/motif as a clean, complete, high-resolution image. ' +
'Remove everything around it that is not part of the motif: picture frames, walls, ' +
'phone/app UI bars, shop overlays, cart icons, price tags, watermarks from your own ' +
'screenshot, timestamps and reflections. Reconstruct edges that were cropped or hidden. ' +
'Keep the original style, colors, textures, composition and ESPECIALLY any lettering ' +
'pixel-faithful. Do not reinterpret, do not add new elements.';
const CUTOUT =
'Isolate only the main subject. The background must be fully transparent (alpha).';
const FORMAT_EXTEND =
'Extend the scene naturally to fill the requested aspect ratio (outpainting), keeping ' +
'the existing composition centered and consistent in style.';
const FORMAT_KEEP_RATIO =
'Produce the motif in the requested aspect ratio without distorting it.';
export interface PromptOpts {
tasks: Task[];
cropMode?: 'crop' | 'extend';
customInstruction?: string | null;
}
/** Baut den kombinierten Prompt aus den Rezept-Aufgaben (feste Reihenfolge). */
export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts): string {
const parts: string[] = [];
if (tasks.includes('clean')) parts.push(CLEAN);
if (tasks.includes('cutout')) parts.push(CUTOUT);
if (tasks.includes('format')) {
parts.push(cropMode === 'extend' ? FORMAT_EXTEND : FORMAT_KEEP_RATIO);
}
if (!tasks.includes('clean') && !tasks.includes('cutout') && parts.length === 0) {
// reines Format ohne Bereinigen: nur reproduzieren
parts.push('Reproduce the image faithfully, no stylistic changes.');
}
if (customInstruction?.trim()) parts.push(`Additional instruction: ${customInstruction.trim()}`);
parts.push('Output only the resulting image.');
return parts.join(' ');
}
+34
View File
@@ -0,0 +1,34 @@
import PgBoss from 'pg-boss';
export const QUEUE = 'generate';
export interface GenerateJob { itemId: string; jobId: string; }
let boss: PgBoss | null = null;
export async function getBoss(): Promise<PgBoss> {
if (!boss) {
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
boss.on('error', (e) => console.error('[pg-boss]', e));
await boss.start();
await boss.createQueue(QUEUE);
}
return boss;
}
export async function enqueue(job: GenerateJob): Promise<void> {
const b = await getBoss();
await b.send(QUEUE, job, { retryLimit: 2, retryBackoff: true });
}
export async function startWorker(
concurrency: number,
handler: (job: GenerateJob) => Promise<void>,
): Promise<void> {
const b = await getBoss();
await b.work<GenerateJob>(
QUEUE,
{ batchSize: Math.max(1, concurrency), pollingIntervalSeconds: 2 },
async (jobs) => { await Promise.all(jobs.map((j) => handler(j.data))); },
);
console.log(`[queue] Worker aktiv (Parallelität ${concurrency}).`);
}
+5
View File
@@ -0,0 +1,5 @@
declare module 'heic-convert' {
interface ConvertOptions { buffer: Buffer; format: 'JPEG' | 'PNG'; quality?: number; }
const convert: (opts: ConvertOptions) => Promise<ArrayBuffer>;
export default convert;
}
+58
View File
@@ -0,0 +1,58 @@
import { one, query } from './lib/db';
import { startWorker, type GenerateJob } from './lib/queue';
import { processItem } from './lib/process';
/** Startet den Warteschlangen-Arbeiter (im selben Node-Prozess wie die App). */
export async function startImageWorker(): Promise<void> {
const s = await one<{ concurrency: number }>('SELECT concurrency FROM settings WHERE id=1');
const concurrency = s?.concurrency ?? 2;
await startWorker(concurrency, handle);
}
async function handle(job: GenerateJob): Promise<void> {
// Angehaltene/abgebrochene Aufträge nicht verarbeiten.
const j = await one<{ status: string }>('SELECT status FROM jobs WHERE id=$1', [job.jobId]);
if (!j || j.status === 'paused' || j.status === 'cancelled') {
await query(`UPDATE items SET status='queued' WHERE id=$1 AND status='running'`, [job.itemId]);
return;
}
await query(`UPDATE jobs SET status='running' WHERE id=$1 AND status='queued'`, [job.jobId]);
try {
const res = await processItem(job.itemId);
if (res.ok) {
await query(`UPDATE jobs SET done_count=done_count+1 WHERE id=$1`, [job.jobId]);
} else {
await query(`UPDATE jobs SET failed_count=failed_count+1 WHERE id=$1`, [job.jobId]);
}
} catch (e: any) {
if (e?.status === 402) {
// Guthaben erschöpft → Auftrag anhalten, Position zurück in die Schlange.
await query(`UPDATE jobs SET status='paused' WHERE id=$1`, [job.jobId]);
await query(`UPDATE items SET status='queued', error_message='Guthaben erschöpft' WHERE id=$1`,
[job.itemId]);
console.error('[worker] 402 — Auftrag angehalten', job.jobId);
return;
}
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
[job.itemId, String(e?.message || e).slice(0, 500)]);
await query(`UPDATE jobs SET failed_count=failed_count+1 WHERE id=$1`, [job.jobId]);
}
await maybeComplete(job.jobId);
}
/** Auftrag abschließen, wenn alle Positionen fertig/fehlgeschlagen sind. */
async function maybeComplete(jobId: string): Promise<void> {
const row = await one<{ total: number; done: number; failed: number; open: number }>(
`SELECT total,
(SELECT count(*) FROM items WHERE job_id=$1 AND status='done')::int AS done,
(SELECT count(*) FROM items WHERE job_id=$1 AND status='failed')::int AS failed,
(SELECT count(*) FROM items WHERE job_id=$1 AND status IN ('queued','running'))::int AS open
FROM jobs WHERE id=$1`, [jobId]);
if (row && row.open === 0) {
await query(`UPDATE jobs SET status='done', finished_at=now() WHERE id=$1 AND status<>'cancelled'`,
[jobId]);
// TODO: Auslieferung (Picdrop) + Benachrichtigung (Telegram/n8n) anstoßen.
}
}