UNPKG

@simon_he/browser-compress-image

Version:

🚀 A powerful, lightweight browser image compression library with TypeScript support. Compress JPEG, PNG, GIF images with multiple output formats (Blob, File, Base64, ArrayBuffer) and zero dependencies.

517 lines (510 loc) • 18.9 kB
import imageCompression from "browser-image-compression"; import Compressor from "compressorjs"; import gifsicle from "gifsicle-wasm-browser"; //#region src/compressWithBrowserImageCompression.ts async function compressWithBrowserImageCompression(file, options) { const { quality, mode, targetWidth, targetHeight, maxWidth, maxHeight, preserveExif = false } = options; const compressionOptions = { useWebWorker: true, initialQuality: quality, alwaysKeepResolution: mode === "keepSize", exifOrientation: 1, fileType: file.type, preserveExif, maxSizeMB: file.size * .8 / (1024 * 1024), maxWidthOrHeight: Math.min(maxWidth || targetWidth, maxHeight || targetHeight) || void 0 }; return await imageCompression(file, compressionOptions); } //#endregion //#region src/compressWithCompressorJS.ts async function compressWithCompressorJS(file, options) { const { quality, mode, targetWidth, targetHeight, maxWidth, maxHeight, preserveExif = false } = options; if (!file.type.includes("jpeg") && !file.type.includes("jpg")) throw new Error("CompressorJS is optimized for JPEG files"); return new Promise((resolve, reject) => { const compressorOptions = { quality, retainExif: preserveExif, mimeType: file.type, success: (compressedBlob) => resolve(compressedBlob), error: reject }; if (mode === "keepQuality") { if (targetWidth) compressorOptions.width = targetWidth; if (targetHeight) compressorOptions.height = targetHeight; if (maxWidth) compressorOptions.maxWidth = maxWidth; if (maxHeight) compressorOptions.maxHeight = maxHeight; } new Compressor(file, compressorOptions); }); } //#endregion //#region src/compressWithCanvas.ts async function compressWithCanvas(file, options) { const { quality, targetWidth, targetHeight, maxWidth, maxHeight } = options; let finalWidth = targetWidth || maxWidth, finalHeight = targetHeight || maxHeight; if (!finalWidth && !finalHeight) { const { width, height } = await getImageDimensions(file); finalWidth = width; finalHeight = height; } return await smartCanvasCompress(file, finalWidth, finalHeight, quality); } async function smartCanvasCompress(file, targetWidth, targetHeight, quality) { const originalSize = file.size; const { width: originalWidth, height: originalHeight } = await getImageDimensions(file); if (targetWidth === originalWidth && targetHeight === originalHeight && originalSize < 100 * 1024) return file; if (file.type.includes("png")) { const pngResult = await canvasCompressWithFormat(file, targetWidth, targetHeight, "image/png", void 0); if (pngResult.size < originalSize * .8) return pngResult; if (quality < .8) { const jpegResult = await canvasCompressWithFormat(file, targetWidth, targetHeight, "image/jpeg", Math.max(.7, quality)); if (jpegResult.size < Math.min(pngResult.size, originalSize * .9)) return jpegResult; } if (pngResult.size >= originalSize * .95) return file; return pngResult; } if (file.type.includes("jpeg") || file.type.includes("jpg")) { const qualities = [ quality, Math.max(.5, quality - .2), Math.max(.3, quality - .4) ]; for (const q of qualities) { const result$1 = await canvasCompressWithFormat(file, targetWidth, targetHeight, file.type, q); if (result$1.size < originalSize * .8) return result$1; } return file; } const result = await canvasCompressWithFormat(file, targetWidth, targetHeight, file.type, quality); if (result.size >= originalSize * .95) return file; return result; } async function canvasCompressWithFormat(file, targetWidth, targetHeight, outputType, quality) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d", { alpha: outputType.includes("png"), willReadFrequently: false }); if (!ctx) { reject(/* @__PURE__ */ new Error("Failed to get canvas context")); return; } canvas.width = targetWidth; canvas.height = targetHeight; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; if (outputType.includes("jpeg") && !file.type.includes("jpeg")) { ctx.fillStyle = "#FFFFFF"; ctx.fillRect(0, 0, targetWidth, targetHeight); } ctx.drawImage(img, 0, 0, targetWidth, targetHeight); canvas.toBlob((blob) => { if (blob) resolve(blob); else reject(/* @__PURE__ */ new Error("Failed to create blob")); }, outputType, quality); }; img.onerror = () => { URL.revokeObjectURL(url); reject(/* @__PURE__ */ new Error("Failed to load image")); }; img.crossOrigin = "anonymous"; img.src = url; }); } function getImageDimensions(file) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); resolve({ width: img.width, height: img.height }); }; img.onerror = () => { URL.revokeObjectURL(url); reject(/* @__PURE__ */ new Error("Failed to load image")); }; img.src = url; }); } //#endregion //#region src/compressWithGifsicle.ts async function compressWithGifsicle(file, options) { const { quality, mode, targetWidth, targetHeight, maxWidth, maxHeight, preserveExif = false } = options; if (!file.type.includes("gif")) throw new Error("Gifsicle is only for GIF files"); let command; if (mode === "keepSize") command = ` -O1 --lossy=${Math.round((1 - quality) * 100)} ${file.name} -o /out/${file.name} `; else { let resizeOption = ""; if (targetWidth && targetHeight) resizeOption = `--resize ${targetWidth}x${targetHeight}`; else if (maxWidth || maxHeight) { const maxSize = Math.min(maxWidth || 9999, maxHeight || 9999); resizeOption = `--resize-fit ${maxSize}x${maxSize}`; } command = ` -O1 ${resizeOption} ${file.name} -o /out/${file.name} `; } const result = await gifsicle.run({ input: [{ file, name: file.name }], command: [command] }); return result[0]; } //#endregion //#region src/convertBlobToType.ts async function convertBlobToType(blob, type, originalFileName) { switch (type) { case "blob": return blob; case "file": return new File([blob], originalFileName || "compressed", { type: blob.type }); case "base64": return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); case "arrayBuffer": return blob.arrayBuffer(); default: throw new Error(`Unsupported type: ${type}`); } } //#endregion //#region src/compress.ts const devLog = { log: (...args) => { if (process.env.NODE_ENV === "development") console.log(...args); }, warn: (...args) => { if (process.env.NODE_ENV === "development") console.warn(...args); }, table: (data) => { if (process.env.NODE_ENV === "development") console.table(data); } }; const EXIF_SUPPORTED_TOOLS = ["browser-image-compression", "compressorjs"]; const toolsCollections = { png: ["browser-image-compression", "canvas"], gif: ["gifsicle"], webp: ["canvas", "browser-image-compression"], others: [ "browser-image-compression", "compressorjs", "canvas" ] }; async function compress(file, qualityOrOptions, type) { let options; if (typeof qualityOrOptions === "object") options = qualityOrOptions; else options = { quality: qualityOrOptions || .6, mode: "keepSize", type: type || "blob" }; const { quality = .6, mode = "keepSize", targetWidth, targetHeight, maxWidth, maxHeight, preserveExif = false, returnAllResults = false, type: resultType = "blob" } = options; const compressionOptions = { quality, mode, targetWidth, targetHeight, maxWidth, maxHeight, preserveExif }; const tools = file.type.includes("png") ? toolsCollections["png"] : file.type.includes("gif") ? toolsCollections["gif"] : file.type.includes("webp") ? toolsCollections["webp"] : toolsCollections["others"]; if (returnAllResults) return await compressWithMultipleToolsAndReturnAll(file, compressionOptions, tools, resultType); const bestResult = await compressWithMultipleTools(file, compressionOptions, tools); return convertBlobToType(bestResult, resultType, file.name); } async function compressWithMultipleTools(file, options, tools) { const totalStartTime = performance.now(); if (options.preserveExif) { tools = tools.filter((tool) => EXIF_SUPPORTED_TOOLS.includes(tool)); if (tools.length === 0) throw new Error("No EXIF-supporting tools available for this file type. Please disable preserveExif or use a different file format."); devLog.log("preserveExif=true, filtered tools:", tools); } const attempts = []; const promises = tools.map(async (tool) => { const startTime = performance.now(); try { let compressedBlob; switch (tool) { case "browser-image-compression": compressedBlob = await compressWithBrowserImageCompression(file, options); break; case "compressorjs": compressedBlob = await compressWithCompressorJS(file, options); break; case "gifsicle": compressedBlob = await compressWithGifsicle(file, options); break; case "canvas": compressedBlob = await compressWithCanvas(file, options); break; default: throw new Error(`Unknown compression tool: ${tool}`); } const endTime = performance.now(); const duration = Math.round(endTime - startTime); return { tool, blob: compressedBlob, size: compressedBlob.size, success: true, duration }; } catch (error) { const endTime = performance.now(); const duration = Math.round(endTime - startTime); return { tool, blob: file, size: file.size, success: false, error: error instanceof Error ? error.message : String(error), duration }; } }); const results = await Promise.allSettled(promises); results.forEach((result) => { if (result.status === "fulfilled") attempts.push(result.value); else devLog.warn("Compression tool failed:", result.reason); }); const successfulAttempts = attempts.filter((attempt) => attempt.success); if (successfulAttempts.length === 0) { devLog.warn("All compression attempts failed, returning original file"); return file; } const bestAttempt = successfulAttempts.reduce((best, current) => current.size < best.size ? current : best); if (bestAttempt.size >= file.size * .98 && options.quality > .85) { const totalEndTime$1 = performance.now(); const totalDuration$1 = Math.round(totalEndTime$1 - totalStartTime); devLog.log(`Best compression (${bestAttempt.tool}) size: ${bestAttempt.size}, original: ${file.size}, using original (total: ${totalDuration$1}ms)`); return file; } const totalEndTime = performance.now(); const totalDuration = Math.round(totalEndTime - totalStartTime); devLog.log(`Best compression result: ${bestAttempt.tool} (${bestAttempt.size} bytes, ${((file.size - bestAttempt.size) / file.size * 100).toFixed(1)}% reduction, ${bestAttempt.duration}ms) - Total time: ${totalDuration}ms`); if (successfulAttempts.length > 1) devLog.table(successfulAttempts.map((attempt) => ({ Tool: attempt.tool, "Size (bytes)": attempt.size, "Reduction (%)": `${((file.size - attempt.size) / file.size * 100).toFixed(1)}%`, "Duration (ms)": attempt.duration, "Speed (MB/s)": `${(file.size / 1024 / 1024 / (attempt.duration / 1e3)).toFixed(2)}` }))); return bestAttempt.blob; } async function compressWithMultipleToolsAndReturnAll(file, options, tools, resultType) { const totalStartTime = performance.now(); if (options.preserveExif) { tools = tools.filter((tool) => EXIF_SUPPORTED_TOOLS.includes(tool)); if (tools.length === 0) throw new Error("No EXIF-supporting tools available for this file type. Please disable preserveExif or use a different file format."); devLog.log("preserveExif=true, filtered tools:", tools); } const attempts = []; const promises = tools.map(async (tool) => { const startTime = performance.now(); try { let compressedBlob; switch (tool) { case "browser-image-compression": compressedBlob = await compressWithBrowserImageCompression(file, options); break; case "compressorjs": compressedBlob = await compressWithCompressorJS(file, options); break; case "gifsicle": compressedBlob = await compressWithGifsicle(file, options); break; case "canvas": compressedBlob = await compressWithCanvas(file, options); break; default: throw new Error(`Unknown compression tool: ${tool}`); } const endTime = performance.now(); const duration = Math.round(endTime - startTime); return { tool, blob: compressedBlob, size: compressedBlob.size, success: true, duration }; } catch (error) { const endTime = performance.now(); const duration = Math.round(endTime - startTime); return { tool, blob: file, size: file.size, success: false, error: error instanceof Error ? error.message : String(error), duration }; } }); const results = await Promise.allSettled(promises); results.forEach((result) => { if (result.status === "fulfilled") attempts.push(result.value); else devLog.warn("Compression tool failed:", result.reason); }); if (attempts.length === 0) throw new Error("All compression attempts failed"); const totalEndTime = performance.now(); const totalDuration = Math.round(totalEndTime - totalStartTime); const allResults = await Promise.all(attempts.map(async (attempt) => { const convertedResult = await convertBlobToType(attempt.blob, resultType, file.name); return { tool: attempt.tool, result: convertedResult, originalSize: file.size, compressedSize: attempt.size, compressionRatio: (file.size - attempt.size) / file.size * 100, duration: attempt.duration, success: attempt.success, error: attempt.error }; })); const successfulAttempts = attempts.filter((attempt) => attempt.success); let bestAttempt; if (successfulAttempts.length > 0) { bestAttempt = successfulAttempts.reduce((best, current) => current.size < best.size ? current : best); if (bestAttempt.size >= file.size * .98 && options.quality > .85) bestAttempt = { tool: "original", blob: file, size: file.size, success: true, duration: 0 }; } else bestAttempt = { tool: "original", blob: file, size: file.size, success: true, duration: 0 }; const bestResult = await convertBlobToType(bestAttempt.blob, resultType, file.name); devLog.log(`Best compression result: ${bestAttempt.tool} (${bestAttempt.size} bytes, ${((file.size - bestAttempt.size) / file.size * 100).toFixed(1)}% reduction) - Total time: ${totalDuration}ms`); if (successfulAttempts.length > 1) devLog.table(successfulAttempts.map((attempt) => ({ Tool: attempt.tool, "Size (bytes)": attempt.size, "Reduction (%)": `${((file.size - attempt.size) / file.size * 100).toFixed(1)}%`, "Duration (ms)": attempt.duration, "Speed (MB/s)": `${(file.size / 1024 / 1024 / (attempt.duration / 1e3)).toFixed(2)}` }))); return { bestResult, bestTool: bestAttempt.tool, allResults, totalDuration }; } async function compressWithStats(file, qualityOrOptions) { return await compressWithMultipleToolsWithStats(file, { quality: typeof qualityOrOptions === "object" ? qualityOrOptions.quality || .6 : qualityOrOptions || .6, mode: typeof qualityOrOptions === "object" ? qualityOrOptions.mode || "keepSize" : "keepSize", targetWidth: typeof qualityOrOptions === "object" ? qualityOrOptions.targetWidth : void 0, targetHeight: typeof qualityOrOptions === "object" ? qualityOrOptions.targetHeight : void 0, maxWidth: typeof qualityOrOptions === "object" ? qualityOrOptions.maxWidth : void 0, maxHeight: typeof qualityOrOptions === "object" ? qualityOrOptions.maxHeight : void 0, preserveExif: typeof qualityOrOptions === "object" ? qualityOrOptions.preserveExif || false : false }); } async function compressWithMultipleToolsWithStats(file, options) { const totalStartTime = performance.now(); let tools = file.type.includes("png") ? toolsCollections["png"] : file.type.includes("gif") ? toolsCollections["gif"] : file.type.includes("webp") ? toolsCollections["webp"] : toolsCollections["others"]; if (options.preserveExif) { tools = tools.filter((tool) => EXIF_SUPPORTED_TOOLS.includes(tool)); if (tools.length === 0) throw new Error("No EXIF-supporting tools available for this file type. Please disable preserveExif or use a different file format."); devLog.log("preserveExif=true, filtered tools:", tools); } const attempts = []; const promises = tools.map(async (tool) => { const startTime = performance.now(); try { let compressedBlob; switch (tool) { case "browser-image-compression": compressedBlob = await compressWithBrowserImageCompression(file, options); break; case "compressorjs": compressedBlob = await compressWithCompressorJS(file, options); break; case "gifsicle": compressedBlob = await compressWithGifsicle(file, options); break; case "canvas": compressedBlob = await compressWithCanvas(file, options); break; default: throw new Error(`Unknown compression tool: ${tool}`); } const endTime = performance.now(); const duration = Math.round(endTime - startTime); return { tool, blob: compressedBlob, size: compressedBlob.size, success: true, duration }; } catch (error) { const endTime = performance.now(); const duration = Math.round(endTime - startTime); return { tool, blob: file, size: file.size, success: false, error: error instanceof Error ? error.message : String(error), duration }; } }); const results = await Promise.allSettled(promises); results.forEach((result) => { if (result.status === "fulfilled") attempts.push(result.value); }); const successfulAttempts = attempts.filter((attempt) => attempt.success); const bestAttempt = successfulAttempts.length > 0 ? successfulAttempts.reduce((best, current) => current.size < best.size ? current : best) : { tool: "none", blob: file, size: file.size, success: false, duration: 0 }; const totalEndTime = performance.now(); const totalDuration = Math.round(totalEndTime - totalStartTime); return { bestTool: bestAttempt.tool, compressedFile: bestAttempt.blob, originalSize: file.size, compressedSize: bestAttempt.size, compressionRatio: (file.size - bestAttempt.size) / file.size * 100, totalDuration, toolsUsed: attempts.map((attempt) => ({ tool: attempt.tool, size: attempt.size, duration: attempt.duration, compressionRatio: (file.size - attempt.size) / file.size * 100, success: attempt.success, error: attempt.error })) }; } //#endregion export { compress, compressWithStats };