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