Files

174 lines
5.6 KiB
TypeScript
Raw Permalink 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.
/**
* Browser-side image compression.
*
* Resizes the longest dimension to `maxDimension` (preserving aspect
* ratio) and re-encodes as JPEG at `quality`. EXIF is stripped as a
* side effect of the canvas round-trip — smaller files plus a tiny
* privacy win since location data doesn't ride along to R2.
*
* Why client-side: every upload path (post images, collection covers,
* avatars, post background swaps) presigns directly to R2. Putting
* the size cut in the browser keeps that flow intact — no proxy hop,
* no queue, no temp storage. The trade-off is that the source bytes
* are lossy from the moment they leave the device; for a photo-share
* product this is the right call.
*
* Safe-by-default failure modes — any of these return the ORIGINAL
* file unchanged rather than blocking the upload:
*
* - The file is not an image (defensive — input is gated by accept= too).
* - The file is a GIF (canvas re-encode would flatten the animation).
* - The decoder fails (e.g. an old browser meeting a HEIC).
* - Compression actually grew the file (rare; tiny already-compressed inputs).
*
* Callers can therefore wrap their existing upload calls without
* adding new error paths.
*/
export type CompressOptions = {
/** Longest edge in px after resize. Default 1920. */
maxDimension?: number;
/** JPEG quality 0–1. Default 0.85 (visually lossless for photos). */
quality?: number;
/** Output MIME type. Default "image/jpeg". */
mimeType?: string;
};
const DEFAULT_MAX = 1920;
const DEFAULT_QUALITY = 0.85;
const DEFAULT_MIME = "image/jpeg";
export async function compressImage(
file: File,
opts: CompressOptions = {},
): Promise<File> {
const maxDimension = opts.maxDimension ?? DEFAULT_MAX;
const quality = opts.quality ?? DEFAULT_QUALITY;
const mimeType = opts.mimeType ?? DEFAULT_MIME;
// Bail-outs that should return the original untouched.
if (typeof window === "undefined") return file;
if (!file.type.startsWith("image/")) return file;
if (file.type === "image/gif") return file;
let drawable: ImageBitmap | HTMLImageElement;
try {
drawable = await loadDrawable(file);
} catch {
// Decoder couldn't read this image (e.g. corrupt, or HEIC on a
// browser that doesn't decode HEIC). Send the original up — R2
// will accept it; the share-image proxy + Cloudflare will sort
// out display sizing downstream.
return file;
}
const { width: srcW, height: srcH } = drawableDimensions(drawable);
const { width, height } = scaleToFit(srcW, srcH, maxDimension);
// No-op fast path: already within the cap AND a reasonable size.
// Skip the canvas dance because re-encoding a 200 KB image as JPEG
// q=85 might actually grow it.
if (width === srcW && height === srcH && file.size < 500_000) {
closeBitmap(drawable);
return file;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
closeBitmap(drawable);
return file;
}
// White backdrop so PNG/HEIC transparency doesn't become black
// pixels in the JPEG output.
if (mimeType === "image/jpeg") {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(drawable, 0, 0, width, height);
closeBitmap(drawable);
const blob = await canvasToBlob(canvas, mimeType, quality);
if (!blob) return file;
// If the "compressed" version ended up larger (small already, or a
// weird input), keep the original.
if (blob.size >= file.size) return file;
const ext =
mimeType === "image/jpeg"
? "jpg"
: mimeType.split("/")[1]?.toLowerCase() || "bin";
const newName = file.name.replace(/\.[^.]+$/, "") + `.${ext}`;
return new File([blob], newName, {
type: mimeType,
lastModified: Date.now(),
});
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
async function loadDrawable(
file: File,
): Promise<ImageBitmap | HTMLImageElement> {
// createImageBitmap is the fast path on modern browsers — it decodes
// off the main thread when supported.
if (typeof createImageBitmap === "function") {
try {
return await createImageBitmap(file);
} catch {
// Fall through to the HTMLImageElement path.
}
}
const url = URL.createObjectURL(file);
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("image decode failed"));
img.src = url;
});
// Defer revoke so the image stays valid through the next canvas
// draw — we revoke a tick later when it's safe.
setTimeout(() => URL.revokeObjectURL(url), 0);
return img;
}
function drawableDimensions(
d: ImageBitmap | HTMLImageElement,
): { width: number; height: number } {
if ("naturalWidth" in d) {
return { width: d.naturalWidth, height: d.naturalHeight };
}
return { width: d.width, height: d.height };
}
function scaleToFit(
width: number,
height: number,
max: number,
): { width: number; height: number } {
if (width <= max && height <= max) return { width, height };
const ratio = width > height ? max / width : max / height;
return {
width: Math.round(width * ratio),
height: Math.round(height * ratio),
};
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality: number,
): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((b) => resolve(b), type, quality);
});
}
function closeBitmap(d: ImageBitmap | HTMLImageElement) {
if ("close" in d && typeof d.close === "function") d.close();
}