You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
160 lines
5.1 KiB
160 lines
5.1 KiB
/** |
|
* Client-side image compression via the Canvas API. |
|
* |
|
* Runs only in the browser (Canvas); no external compression APIs. |
|
* Called before every media upload to reduce bandwidth and server storage costs. |
|
* Raster images are re-encoded (WebP/JPEG) when possible, not only when over the byte cap. |
|
* GIFs are returned unchanged (canvas flattens animation to a single frame). |
|
* Non-image files are returned unchanged. |
|
*/ |
|
|
|
/** Longest edge cap before re-encoding. */ |
|
const MAX_DIMENSION_PX = 1920 |
|
/** WebP quality — tuned for smaller uploads; JPEG ladder if still over `targetMaxBytes`. */ |
|
const WEBP_QUALITY = 0.72 |
|
/** Starting JPEG quality; stepped down until the file fits. */ |
|
const JPEG_QUALITY_START = 0.74 |
|
/** Never go below this quality during progressive reduction. */ |
|
const JPEG_QUALITY_MIN = 0.32 |
|
|
|
function canvasToBlob( |
|
canvas: HTMLCanvasElement, |
|
type: string, |
|
quality: number |
|
): Promise<Blob | null> { |
|
return new Promise((resolve) => canvas.toBlob(resolve, type, quality)) |
|
} |
|
|
|
/** |
|
* Compress `file` so the result is at most `targetMaxBytes`. |
|
* |
|
* Strategy: |
|
* 1. Down-scale to MAX_DIMENSION_PX on the longest edge. |
|
* 2. Encode as WebP at WEBP_QUALITY — usually the winner. |
|
* 3. If still too big, fall back to JPEG with progressive quality reduction. |
|
* 4. If nothing fits, return the best (smallest) result even if still over limit, |
|
* unless it is bigger than the original — in which case return the original. |
|
*/ |
|
export async function compressImage( |
|
file: File, |
|
targetMaxBytes: number, |
|
onProgress?: (percent: number) => void |
|
): Promise<File> { |
|
const report = (p: number) => onProgress?.(Math.max(0, Math.min(100, Math.round(p)))) |
|
|
|
if (!file.type.startsWith('image/')) return file |
|
if (file.type === 'image/gif') { |
|
report(100) |
|
return file // canvas strips animation |
|
} |
|
if (file.type === 'image/svg+xml') { |
|
report(100) |
|
return file |
|
} |
|
|
|
report(5) |
|
let bitmap: ImageBitmap |
|
try { |
|
bitmap = await createImageBitmap(file) |
|
} catch { |
|
report(100) |
|
return file |
|
} |
|
|
|
let { width, height } = bitmap |
|
if (width > MAX_DIMENSION_PX || height > MAX_DIMENSION_PX) { |
|
const scale = Math.min(MAX_DIMENSION_PX / width, MAX_DIMENSION_PX / height) |
|
width = Math.round(width * scale) |
|
height = Math.round(height * scale) |
|
} |
|
|
|
const canvas = document.createElement('canvas') |
|
canvas.width = width |
|
canvas.height = height |
|
const ctx = canvas.getContext('2d') |
|
if (!ctx) { |
|
bitmap.close() |
|
report(100) |
|
return file |
|
} |
|
ctx.drawImage(bitmap, 0, 0, width, height) |
|
bitmap.close() |
|
report(22) |
|
|
|
const baseName = file.name.replace(/\.[^.]+$/, '') |
|
|
|
// Try WebP first (always prefer a smaller or cap-compliant WebP) |
|
const webpBlob = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY) |
|
report(38) |
|
if (webpBlob && webpBlob.size <= targetMaxBytes) { |
|
report(100) |
|
return new File([webpBlob], `${baseName}.webp`, { type: 'image/webp' }) |
|
} |
|
|
|
// Progressive JPEG quality reduction |
|
let bestBlob: Blob | null = webpBlob |
|
let step = 0 |
|
const jpegSteps = Math.max( |
|
1, |
|
Math.ceil((JPEG_QUALITY_START - JPEG_QUALITY_MIN) / 0.1) + 1 |
|
) |
|
for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) { |
|
const blob = await canvasToBlob(canvas, 'image/jpeg', q) |
|
if (!blob) continue |
|
if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob |
|
if (blob.size <= targetMaxBytes) { |
|
report(100) |
|
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }) |
|
} |
|
step++ |
|
if (step % 2 === 0) { |
|
report(40 + Math.min(35, (step / jpegSteps) * 35)) |
|
} |
|
} |
|
|
|
report(72) |
|
|
|
// If still over budget, shrink canvas further and retry WebP / JPEG |
|
if (bestBlob && bestBlob.size > targetMaxBytes && (width > 640 || height > 640)) { |
|
const factor = 0.72 |
|
const w2 = Math.max(320, Math.round(width * factor)) |
|
const h2 = Math.max(320, Math.round(height * factor)) |
|
const snap = await createImageBitmap(canvas) |
|
canvas.width = w2 |
|
canvas.height = h2 |
|
ctx.drawImage(snap, 0, 0, w2, h2) |
|
snap.close() |
|
report(78) |
|
const smallWebp = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY - 0.08) |
|
if (smallWebp && smallWebp.size < (bestBlob?.size ?? Infinity)) { |
|
bestBlob = smallWebp |
|
} |
|
if (smallWebp && smallWebp.size <= targetMaxBytes) { |
|
report(100) |
|
return new File([smallWebp], `${baseName}.webp`, { type: 'image/webp' }) |
|
} |
|
for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) { |
|
const blob = await canvasToBlob(canvas, 'image/jpeg', q) |
|
if (!blob) continue |
|
if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob |
|
if (blob.size <= targetMaxBytes) { |
|
report(100) |
|
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }) |
|
} |
|
} |
|
} |
|
|
|
// Return best effort if smaller than original |
|
if (bestBlob && bestBlob.size < file.size) { |
|
const isWebp = bestBlob.type === 'image/webp' |
|
report(100) |
|
return new File( |
|
[bestBlob], |
|
`${baseName}${isWebp ? '.webp' : '.jpg'}`, |
|
{ type: bestBlob.type } |
|
) |
|
} |
|
|
|
report(100) |
|
return file |
|
}
|
|
|