efcb889c96
Word-boundary (\b) anchors don't fire next to underscores, so already-processed names like 2026-07-24_2026-07-24-<uuid>_30x40 kept their doubled date prefix. Drop \b; match dates/hex globally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
124 lines
4.7 KiB
TypeScript
124 lines
4.7 KiB
TypeScript
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);
|
|
}
|
|
|
|
/** 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_<motiv>_<format>.<ext>
|
|
* 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, …
|
|
}
|