Files
klarbild/src/lib/pipeline.ts
T
till efcb889c96 fix: strip dates/UUIDs in filenames even when adjacent to underscores
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
2026-07-24 08:13:36 +00:00

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, …
}