diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..7da9e37 --- /dev/null +++ b/src/lib/format.ts @@ -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 = { + '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 = { + 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; +} diff --git a/src/lib/init.ts b/src/lib/init.ts index 57b6482..24579fb 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -1,6 +1,7 @@ import { runMigrations } from './db'; import { ensureBucket } from './storage'; import { seed } from './seed'; +import { startImageWorker } from '../worker'; let started: Promise | null = null; @@ -11,8 +12,8 @@ export function ensureInit(): Promise { await runMigrations(); try { await ensureBucket(); } catch (e) { console.error('[init] S3 nicht bereit:', e); } await seed(); + try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); } console.log('[init] Klarbild bereit.'); - // TODO (Phase Bildkern): pg-boss-Worker hier starten. })().catch((e) => { started = null; throw e; }); } return started; diff --git a/src/lib/pipeline.ts b/src/lib/pipeline.ts new file mode 100644 index 0000000..1bd6735 --- /dev/null +++ b/src/lib/pipeline.ts @@ -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 { + 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 { + 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}`; +} diff --git a/src/lib/process.ts b/src/lib/process.ts new file mode 100644 index 0000000..7d351c2 --- /dev/null +++ b/src/lib/process.ts @@ -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 { + 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 { + 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 }; + } +} diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts new file mode 100644 index 0000000..7db9814 --- /dev/null +++ b/src/lib/prompts.ts @@ -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(' '); +} diff --git a/src/lib/queue.ts b/src/lib/queue.ts new file mode 100644 index 0000000..18ecc02 --- /dev/null +++ b/src/lib/queue.ts @@ -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 { + 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 { + const b = await getBoss(); + await b.send(QUEUE, job, { retryLimit: 2, retryBackoff: true }); +} + +export async function startWorker( + concurrency: number, + handler: (job: GenerateJob) => Promise, +): Promise { + const b = await getBoss(); + await b.work( + 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}).`); +} diff --git a/src/types/heic-convert.d.ts b/src/types/heic-convert.d.ts new file mode 100644 index 0000000..c325f7a --- /dev/null +++ b/src/types/heic-convert.d.ts @@ -0,0 +1,5 @@ +declare module 'heic-convert' { + interface ConvertOptions { buffer: Buffer; format: 'JPEG' | 'PNG'; quality?: number; } + const convert: (opts: ConvertOptions) => Promise; + export default convert; +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..5469e07 --- /dev/null +++ b/src/worker.ts @@ -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 { + 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 { + // 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 { + 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. + } +}