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); } /** Klein, mit Bindestrichen, ohne Umlaut-Probleme. */ export function slugify(s: string): string { return (s || '') .toLowerCase() .replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c)) .normalize('NFKD').replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48); } const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']); /** Motiv aus einem beliebigen (evtl. schon verarbeiteten) Namen herausschälen: * Datumspräfixe, UUIDs und lange Hex-Ketten entfernen, damit keine Doppelungen entstehen. */ function cleanMotif(name: string | null): string { let s = (name || '').replace(/\.[^.]+$/, ''); // Ohne \b arbeiten — Wortgrenzen greifen an Unterstrichen nicht (…_2026-07-24…). s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // Datum (auch mehrfach) s = s.replace(/[0-9a-f]{16,}/gi, ' '); // lange Hex-Ketten return slugify(s); } /** Logischer Dateiname: JJJJ-MM-TT__. * z. B. 2026-07-23_lea_the-frame.png */ export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string { const raw = cleanMotif(originalName); const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw; const date = new Date().toISOString().slice(0, 10); const fmt = slugify(formatToken) || 'original'; return `${date}_${motif}_${fmt}.${ext}`; } /** Formatschlüssel für den Dateinamen (aus dem output_format). */ export function formatToken(outputFormat: string | undefined): string { if (!outputFormat || outputFormat === 'keep') return 'original'; if (outputFormat === 'theframe') return 'the-frame'; if (outputFormat === 'hochformat') return 'hochformat'; return outputFormat.toLowerCase(); // 30x40, a4, sticker, … }