97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
export async function compressPhoto(
|
|
file: File,
|
|
opts: { targetBytes?: number; maxEdge?: number; qualityStart?: number } = {}
|
|
): Promise<File> {
|
|
const targetBytes = opts.targetBytes ?? 1_500_000; // 1.5 MB
|
|
const maxEdge = opts.maxEdge ?? 2560;
|
|
const qualityStart = opts.qualityStart ?? 0.85;
|
|
|
|
// If already small and jpeg, return as-is
|
|
if (file.size <= targetBytes && file.type === 'image/jpeg') return file;
|
|
|
|
const img = await loadImageBitmap(file);
|
|
const { width, height } = fitWithin(img.width, img.height, maxEdge);
|
|
|
|
const canvas = createCanvas(width, height);
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) throw new Error('Canvas unsupported');
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
// Iteratively lower quality to fit target size
|
|
let quality = qualityStart;
|
|
let blob: Blob | null = await toBlob(canvas, 'image/jpeg', quality);
|
|
if (!blob) throw new Error('Failed to encode image');
|
|
|
|
while (blob.size > targetBytes && quality > 0.5) {
|
|
quality -= 0.05;
|
|
const attempt = await toBlob(canvas, 'image/jpeg', quality);
|
|
if (attempt) blob = attempt;
|
|
else break;
|
|
}
|
|
|
|
// If still too large, downscale further by 0.9 until it fits or edge < 800
|
|
let currentWidth = width;
|
|
let currentHeight = height;
|
|
while (blob.size > targetBytes && Math.max(currentWidth, currentHeight) > 800) {
|
|
currentWidth = Math.round(currentWidth * 0.9);
|
|
currentHeight = Math.round(currentHeight * 0.9);
|
|
const c2 = createCanvas(currentWidth, currentHeight);
|
|
const c2ctx = c2.getContext('2d');
|
|
if (!c2ctx) break;
|
|
c2ctx.drawImage(canvas, 0, 0, currentWidth, currentHeight);
|
|
const attempt = await toBlob(c2, 'image/jpeg', quality);
|
|
if (attempt) blob = attempt;
|
|
}
|
|
|
|
const outName = ensureJpegExtension(file.name);
|
|
return new File([blob], outName, { type: 'image/jpeg', lastModified: Date.now() });
|
|
}
|
|
|
|
function fitWithin(w: number, h: number, maxEdge: number) {
|
|
const scale = Math.min(1, maxEdge / Math.max(w, h));
|
|
return { width: Math.round(w * scale), height: Math.round(h * scale) };
|
|
}
|
|
|
|
function createCanvas(w: number, h: number): HTMLCanvasElement {
|
|
const c = document.createElement('canvas');
|
|
c.width = w; c.height = h; return c;
|
|
}
|
|
|
|
function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
|
|
return new Promise(resolve => canvas.toBlob(resolve, type, quality));
|
|
}
|
|
|
|
async function loadImageBitmap(file: File): Promise<CanvasImageSource> {
|
|
const canBitmap = 'createImageBitmap' in window;
|
|
|
|
if (canBitmap) {
|
|
try {
|
|
return await createImageBitmap(file);
|
|
} catch (error) {
|
|
console.warn('Falling back to HTML image decode', error);
|
|
}
|
|
}
|
|
|
|
return await loadHtmlImage(file);
|
|
}
|
|
|
|
function loadHtmlImage(file: File): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const url = URL.createObjectURL(file);
|
|
const img = new Image();
|
|
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
|
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
function ensureJpegExtension(name: string) {
|
|
return name.replace(/\.(heic|heif|png|webp|jpg|jpeg)$/i, '') + '.jpg';
|
|
}
|
|
|
|
export function formatBytes(bytes: number) {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
}
|