/** * Bilder werden als Data-URL in der Datenbank gespeichert. Angezeigt werden sie * hoechstens mit 80 px Kantenlaenge, deshalb wird vor dem Upload im Browser auf * ein Quadrat verkleinert - das druckt ein 4-MB-Handyfoto auf wenige Kilobyte. */ /** Zielkantenlaenge; 256 px reicht fuer die groesste Anzeige auch auf Retina. */ const TARGET_SIZE = 256; /** WebP ist deutlich kleiner als JPEG; ohne Unterstuetzung 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 in die Knie zwingen. */ export const MAX_SOURCE_BYTES = 25_000_000; /** Ergebnis der Komprimierung; die Groessen dienen der Rueckmeldung an den Benutzer. */ export interface CompressedImage { dataUrl: string; sourceBytes: number; resultBytes: number; type: string; } function loadImage(file: File): Promise { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); 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; }); } /** Laenge einer Data-URL in Bytes (Base64 traegt 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 von TARGET_SIZE und schneidet dabei * mittig zu (wie `object-cover` in der Anzeige), damit das Ergebnis exakt dem * entspricht, was spaeter zu sehen ist. */ export async function compressImage(file: File): Promise { if (file.size > MAX_SOURCE_BYTES) { throw new Error('Die Datei ist zu gross (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 vergroessert. 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.'); // Mittiger Ausschnitt des kuerzeren Randes 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, faellt er still auf PNG zurueck. 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`; }