7 changed files with 216 additions and 39 deletions
@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
|
||||
/** |
||||
* Client-side image compression via the Canvas API. |
||||
* |
||||
* Called before every media upload to reduce bandwidth and server storage costs. |
||||
* 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 = 2048 |
||||
/** Try WebP at this quality first — typically 30-50 % smaller than JPEG at same perceptual quality. */ |
||||
const WEBP_QUALITY = 0.85 |
||||
/** Starting JPEG quality; stepped down by 0.1 until the file fits. */ |
||||
const JPEG_QUALITY_START = 0.82 |
||||
/** Never go below this quality during progressive reduction. */ |
||||
const JPEG_QUALITY_MIN = 0.35 |
||||
|
||||
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): Promise<File> { |
||||
if (!file.type.startsWith('image/')) return file |
||||
if (file.type === 'image/gif') return file // canvas strips animation
|
||||
if (file.type === 'image/svg+xml') return file |
||||
if (file.size <= targetMaxBytes) return file |
||||
|
||||
let bitmap: ImageBitmap |
||||
try { |
||||
bitmap = await createImageBitmap(file) |
||||
} catch { |
||||
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() |
||||
return file |
||||
} |
||||
ctx.drawImage(bitmap, 0, 0, width, height) |
||||
bitmap.close() |
||||
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '') |
||||
|
||||
// Try WebP first
|
||||
const webpBlob = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY) |
||||
if (webpBlob && webpBlob.size <= targetMaxBytes) { |
||||
return new File([webpBlob], `${baseName}.webp`, { type: 'image/webp' }) |
||||
} |
||||
|
||||
// Progressive JPEG quality reduction
|
||||
let bestBlob: Blob | null = webpBlob |
||||
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) { |
||||
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }) |
||||
} |
||||
} |
||||
|
||||
// Return best effort result if it's at least smaller than the original
|
||||
if (bestBlob && bestBlob.size < file.size) { |
||||
const isWebp = bestBlob.type === 'image/webp' |
||||
return new File( |
||||
[bestBlob], |
||||
`${baseName}${isWebp ? '.webp' : '.jpg'}`, |
||||
{ type: bestBlob.type } |
||||
) |
||||
} |
||||
|
||||
return file |
||||
} |
||||
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
/** |
||||
* Utilities for nostr.build CDN URLs. |
||||
* |
||||
* nostr.build generates a lightweight thumbnail at /thumb/<filename> for every |
||||
* uploaded image. Thumbnails are typically < 50 KB regardless of the original |
||||
* file size — a huge bandwidth win for profile pictures and feed previews. |
||||
*/ |
||||
|
||||
/** Returns true when a URL is hosted on any nostr.build domain. */ |
||||
export function isNostrBuildUrl(url: string): boolean { |
||||
const u = (url ?? '').trim() |
||||
if (!u) return false |
||||
try { |
||||
return new URL(u).hostname.endsWith('nostr.build') |
||||
} catch { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
/** Returns true when the URL is on nostr.build but does NOT yet use the /thumb/ path. */ |
||||
export function canUseNostrBuildThumb(url: string): boolean { |
||||
const u = (url ?? '').trim() |
||||
if (!u) return false |
||||
try { |
||||
const parsed = new URL(u) |
||||
if (!parsed.hostname.endsWith('nostr.build')) return false |
||||
const p = parsed.pathname |
||||
return p !== '/thumb' && !p.startsWith('/thumb/') |
||||
} catch { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Returns the nostr.build thumbnail URL for `url`, inserting `/thumb` before the |
||||
* filename path segment. Returns the original URL unchanged if it is not on |
||||
* nostr.build, already uses /thumb/, or cannot be parsed. |
||||
*/ |
||||
export function toNostrBuildThumbUrl(url: string): string { |
||||
const u = (url ?? '').trim() |
||||
if (!canUseNostrBuildThumb(u)) return u |
||||
try { |
||||
const parsed = new URL(u) |
||||
const p = parsed.pathname || '/' |
||||
parsed.pathname = '/thumb' + (p.startsWith('/') ? p : `/${p}`) |
||||
return parsed.toString() |
||||
} catch { |
||||
return u |
||||
} |
||||
} |
||||
Loading…
Reference in new issue