Files
ChatApp/apps/desktop/src/lib/imageCompress.ts
T

106 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}