feat: image optimization — client-side resize + server-side thumbnails

Avatars/banners now resize to max 512px/1920px and convert to WebP before
upload (zero server cost). Chat image uploads generate an 800px-wide WebP
thumbnail via Sharp; the feed shows the thumbnail, click opens the full-res
original. Adds lazy loading to avatars. Federation-compatible: remote
instances without this feature fall back gracefully.
This commit is contained in:
Jannis Braun
2026-03-13 16:44:14 +01:00
parent 12b450b7b9
commit 3e97c2b0f5
19 changed files with 449 additions and 20 deletions
+59 -11
View File
@@ -5,20 +5,35 @@ export interface PixelCrop {
height: number;
}
export function cropImage(imageSrc: string, pixelCrop: PixelCrop, outputType = 'image/png'): Promise<Blob> {
interface CropOptions {
maxDimension?: number;
quality?: number;
outputType?: string;
}
export function cropImage(
imageSrc: string,
pixelCrop: PixelCrop,
outputType = 'image/webp',
options: CropOptions = {},
): Promise<Blob> {
const { maxDimension, quality = 0.85 } = options;
const finalType = options.outputType ?? outputType;
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
// Step 1: Draw the crop region at original size
const cropCanvas = document.createElement('canvas');
cropCanvas.width = pixelCrop.width;
cropCanvas.height = pixelCrop.height;
const cropCtx = cropCanvas.getContext('2d');
if (!cropCtx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(
cropCtx.drawImage(
img,
pixelCrop.x,
pixelCrop.y,
@@ -29,12 +44,45 @@ export function cropImage(imageSrc: string, pixelCrop: PixelCrop, outputType = '
pixelCrop.width,
pixelCrop.height,
);
canvas.toBlob(
// Step 2: Downscale if either dimension exceeds maxDimension
let outputCanvas = cropCanvas;
if (maxDimension && (pixelCrop.width > maxDimension || pixelCrop.height > maxDimension)) {
const scale = maxDimension / Math.max(pixelCrop.width, pixelCrop.height);
const scaledW = Math.round(pixelCrop.width * scale);
const scaledH = Math.round(pixelCrop.height * scale);
const scaledCanvas = document.createElement('canvas');
scaledCanvas.width = scaledW;
scaledCanvas.height = scaledH;
const scaledCtx = scaledCanvas.getContext('2d');
if (!scaledCtx) {
reject(new Error('Failed to get scaled canvas context'));
return;
}
scaledCtx.drawImage(cropCanvas, 0, 0, scaledW, scaledH);
outputCanvas = scaledCanvas;
}
// Step 3: Export to blob — try WebP first, fall back to PNG if unsupported
outputCanvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error('Canvas toBlob returned null'));
if (blob) {
resolve(blob);
} else if (finalType === 'image/webp') {
// WebP not supported (old Safari) — fall back to PNG
outputCanvas.toBlob(
(pngBlob) => {
if (pngBlob) resolve(pngBlob);
else reject(new Error('Canvas toBlob returned null'));
},
'image/png',
);
} else {
reject(new Error('Canvas toBlob returned null'));
}
},
outputType,
finalType,
quality,
);
};
img.onerror = () => reject(new Error('Failed to load image'));