106 lines
4.2 KiB
TypeScript
106 lines
4.2 KiB
TypeScript
// Client-side image compression before upload. Downscales huge photos
|
||
// (phone cameras regularly emit 4000×3000+ at 5MB+) to a size that
|
||
// actually makes sense for chat display. Preserves aspect ratio.
|
||
//
|
||
// Skips animated formats (gif/apng/webp) to keep motion, and skips
|
||
// already-small files to avoid useless re-encode overhead.
|
||
|
||
const MAX_DIM = 2048;
|
||
const TARGET_QUALITY = 0.85;
|
||
const SKIP_BELOW_BYTES = 512 * 1024; // 512KB — not worth re-encoding
|
||
const ANIMATED_MIME = /^image\/(gif|apng|webp)$/;
|
||
|
||
export async function compressImage(file: File): Promise<File> {
|
||
if (!file.type.startsWith('image/')) return file;
|
||
if (ANIMATED_MIME.test(file.type)) return file;
|
||
if (file.size < SKIP_BELOW_BYTES) return file;
|
||
if (typeof createImageBitmap !== 'function') return file;
|
||
if (typeof OffscreenCanvas !== 'function') return file;
|
||
|
||
try {
|
||
const bitmap = await createImageBitmap(file);
|
||
const largest = Math.max(bitmap.width, bitmap.height);
|
||
const scale = largest > MAX_DIM ? MAX_DIM / largest : 1;
|
||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||
const canvas = new OffscreenCanvas(w, h);
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) {
|
||
bitmap.close();
|
||
return file;
|
||
}
|
||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||
bitmap.close();
|
||
const blob = await canvas.convertToBlob({
|
||
type: 'image/webp',
|
||
quality: TARGET_QUALITY,
|
||
});
|
||
// If the re-encoded blob is actually larger (small PNGs can expand as
|
||
// WebP), keep the original.
|
||
if (blob.size >= file.size) return file;
|
||
const name = renameToWebp(file.name);
|
||
return new File([blob], name, { type: 'image/webp', lastModified: file.lastModified });
|
||
} catch (err: unknown) {
|
||
console.warn('compressImage failed — keeping original', err);
|
||
return file;
|
||
}
|
||
}
|
||
|
||
function renameToWebp(original: string): string {
|
||
const dot = original.lastIndexOf('.');
|
||
const base = dot > 0 ? original.slice(0, dot) : original;
|
||
return base + '.webp';
|
||
}
|
||
|
||
export async function compressImages(files: File[]): Promise<File[]> {
|
||
return Promise.all(files.map((f) => compressImage(f)));
|
||
}
|
||
|
||
// Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead
|
||
// of a fresh re-encode can exceed the original; not worth a second upload.
|
||
const THUMB_SKIP_BELOW_BYTES = 50 * 1024;
|
||
const THUMB_MAX_DIM = 320;
|
||
const THUMB_QUALITY = 0.7;
|
||
// Animated formats lose motion when redrawn onto a Canvas, so we skip them
|
||
// and let the receiver render the full file. GIF is the dominant case; the
|
||
// rest stay too (apng/animated-webp).
|
||
const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/;
|
||
|
||
// Generates a small WebP preview thumb (max 320×320) from an image file.
|
||
// Used by the send path so each image attachment can ship a tiny inline
|
||
// preview alongside the encrypted full blob. Returns `null` when:
|
||
// - the input isn't an image,
|
||
// - the input is animated (GIF/APNG — would lose motion),
|
||
// - the input is already small enough that a thumb wouldn't save bandwidth,
|
||
// - OffscreenCanvas / createImageBitmap aren't available, or
|
||
// - decode/encode threw (corrupt input).
|
||
// The caller treats `null` as "skip thumb" and uploads only the full blob.
|
||
export async function generateWebPThumb(
|
||
file: File,
|
||
maxDim: number = THUMB_MAX_DIM,
|
||
): Promise<Blob | null> {
|
||
if (!file.type.startsWith('image/')) return null;
|
||
if (THUMB_ANIMATED_MIME.test(file.type)) return null;
|
||
if (file.size < THUMB_SKIP_BELOW_BYTES) return null;
|
||
if (typeof createImageBitmap !== 'function') return null;
|
||
if (typeof OffscreenCanvas !== 'function') return null;
|
||
try {
|
||
const bitmap = await createImageBitmap(file);
|
||
const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1);
|
||
const w = Math.max(1, Math.round(bitmap.width * ratio));
|
||
const h = Math.max(1, Math.round(bitmap.height * ratio));
|
||
const canvas = new OffscreenCanvas(w, h);
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) {
|
||
bitmap.close();
|
||
return null;
|
||
}
|
||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||
bitmap.close();
|
||
return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY });
|
||
} catch (err: unknown) {
|
||
console.warn('generateWebPThumb failed', err);
|
||
return null;
|
||
}
|
||
}
|