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,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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user