/** * Profilbilder werden als Data-URL in der Datenbank gespeichert. Angezeigt * werden sie höchstens mit 96 px Kantenlänge, deshalb wird vor dem Hochladen * im Browser auf ein Quadrat verkleinert - das drückt ein 4-MB-Handyfoto auf * wenige Kilobyte, und der Server muss kein Bild verarbeiten. */ /** Zielkantenlänge; 256 px reicht für die größte Anzeige auch auf Retina. */ const TARGET_SIZE = 256; /** WebP ist deutlich kleiner als JPEG; ohne Unterstützung wird JPEG genutzt. */ const CANDIDATES: { type: string; quality: number }[] = [ { type: 'image/webp', quality: 0.82 }, { type: 'image/jpeg', quality: 0.82 }, ]; /** Quelldateien werden vor dem Dekodieren begrenzt - ein 100-MB-TIFF soll den Browser nicht lahmlegen. */ export const MAX_SOURCE_BYTES = 25_000_000; export interface CompressedImage { dataUrl: string; sourceBytes: number; resultBytes: number; type: string; } function loadImage(file: File): Promise { const url = URL.createObjectURL(file); return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { URL.revokeObjectURL(url); resolve(image); }; image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Das Bild konnte nicht gelesen werden.')); }; image.src = url; }); } /** Länge einer Data-URL in Bytes (Base64 trägt 4 Zeichen pro 3 Bytes). */ function dataUrlBytes(dataUrl: string): number { const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1); return Math.round((base64.length * 3) / 4); } /** * Verkleinert das Bild auf ein Quadrat und schneidet dabei mittig zu - genau * wie die Anzeige später, damit das Ergebnis dem entspricht, was man sieht. */ export async function compressImage(file: File): Promise { if (!file.type.startsWith('image/')) throw new Error('Bitte eine Bilddatei wählen.'); if (file.size > MAX_SOURCE_BYTES) throw new Error('Die Datei ist zu groß (max. 25 MB).'); const image = await loadImage(file); const { naturalWidth: width, naturalHeight: height } = image; if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.'); // Kleine Bilder werden nicht vergrößert. const side = Math.min(TARGET_SIZE, width, height); const canvas = document.createElement('canvas'); canvas.width = side; canvas.height = side; const context = canvas.getContext('2d'); if (!context) throw new Error('Der Browser stellt keinen Canvas bereit.'); const crop = Math.min(width, height); context.imageSmoothingQuality = 'high'; context.drawImage(image, (width - crop) / 2, (height - crop) / 2, crop, crop, 0, 0, side, side); for (const candidate of CANDIDATES) { const dataUrl = canvas.toDataURL(candidate.type, candidate.quality); // Liefert der Browser das Format nicht, fällt er still auf PNG zurück. if (dataUrl.startsWith(`data:${candidate.type}`)) { return { dataUrl, sourceBytes: file.size, resultBytes: dataUrlBytes(dataUrl), type: candidate.type }; } } const png = canvas.toDataURL('image/png'); return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' }; } /** "2,4 MB" bzw. "18 KB" */ export function formatBytes(bytes: number): string { if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1).replace('.', ',')} MB`; return `${Math.max(1, Math.round(bytes / 1000))} KB`; }