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;
}