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,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
@@ -1,6 +1,7 @@
|
||||
import { runMigrations } from './db';
|
||||
import { ensureBucket } from './storage';
|
||||
import { seed } from './seed';
|
||||
import { startImageWorker } from '../worker';
|
||||
|
||||
let started: Promise<void> | null = null;
|
||||
|
||||
@@ -11,8 +12,8 @@ export function ensureInit(): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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}).`);
|
||||
}
|
||||
Reference in New Issue
Block a user