images: unified server-side optimization pipeline + /api/upload/image

Single shared sharp-based pipeline (src/lib/images/optimize.ts) used by
every upload surface going forward — PR submissions, contributor
articles, featured-story admin, forum composer attachments, avatars.
Resizes to 4096px longest side max, emits AVIF + WebP + format-preserving
fallback (JPEG/PNG) + 320px thumbnail, all at perceptual-lossless q=82.

POST /api/upload/image is the universal endpoint: auth-gated (signed-in
Supabase user required), 20MB input cap, MIME-validated, processes the
buffer, uploads every derivative to the bb-media bucket under
<user_id>/<yyyy>/<mm>/<random>.{webp,avif,jpg,png} with year-long
immutable caching. Returns dimensions + all derivative URLs + a
primaryUrl convenience field for simple <img src=> embedding.

Bucket reconfigured: file_size_limit bumped 10MB → 25MB and image/avif
added to allowed_mime_types so AVIF derivatives accept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Salazar
2026-05-29 15:43:04 +00:00
parent 976c979e54
commit e24d78cd15
2 changed files with 258 additions and 0 deletions

139
src/lib/images/optimize.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* Unified server-side image optimization pipeline.
*
* Every image upload across the site flows through this module: PR firm
* featured images, contributor uploads, forum post attachments, author
* avatars, banner creatives, anything else. Single source of truth so
* every surface produces the same multi-format ladder and stays under
* sane size caps.
*
* Output per input:
* - AVIF (best compression, all current browsers)
* - WebP (universal fallback)
* - JPEG (final fallback for very old clients, preserved if input was JPEG)
* - thumbnail (320px, JPEG) for in-feed cards
*
* Quality: q=82 visually lossless for photos. Compression by default keeps
* transparency on PNGs.
*
* Resize policy: longest-side cap of 4096px (prevents 12MP DSLR exports from
* sitting un-resized on our CDN). Caller can override `maxSide`.
*
* Returns Buffers for the caller to upload to Supabase Storage.
*/
import sharp from "sharp";
export interface OptimizeOptions {
/** Longest side in pixels — default 4096. */
maxSide?: number;
/** Compression quality 1-100 — default 82 (perceptually lossless for photos). */
quality?: number;
/** Skip thumbnail generation when caller doesn't need it. */
thumbnail?: boolean;
/** Skip AVIF (slow encode) when we only need WebP. */
avif?: boolean;
}
export interface OptimizedImage {
width: number;
height: number;
format: string; // original input format
hasAlpha: boolean;
avif?: Buffer;
webp: Buffer;
jpeg?: Buffer;
png?: Buffer;
thumbnail?: Buffer; // always JPEG, 320px wide
bytes: {
original: number;
avif?: number;
webp: number;
jpeg?: number;
png?: number;
thumbnail?: number;
};
}
export async function optimizeImage(
input: Buffer,
opts: OptimizeOptions = {},
): Promise<OptimizedImage> {
const maxSide = opts.maxSide ?? 4096;
const quality = opts.quality ?? 82;
const wantAvif = opts.avif !== false;
const wantThumb = opts.thumbnail !== false;
// Read metadata up-front so we know what we're dealing with.
const meta = await sharp(input, { failOn: "none" }).metadata();
const inputFormat = (meta.format || "unknown").toLowerCase();
const hasAlpha = !!meta.hasAlpha;
const inputWidth = meta.width || 0;
const inputHeight = meta.height || 0;
// Build a pipeline that's rotation-aware (EXIF) and resized when needed.
function pipeline() {
let p = sharp(input, { failOn: "none" }).rotate();
if (inputWidth > maxSide || inputHeight > maxSide) {
p = p.resize({ width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true });
}
return p;
}
const out: OptimizedImage = {
width: inputWidth,
height: inputHeight,
format: inputFormat,
hasAlpha,
webp: Buffer.alloc(0),
bytes: { original: input.byteLength, webp: 0 },
};
// ── WebP (always; universal modern fallback) ──────────────────────────────
out.webp = await pipeline()
.webp({ quality, effort: 4, alphaQuality: hasAlpha ? quality : undefined })
.toBuffer();
out.bytes.webp = out.webp.byteLength;
// ── AVIF (better compression, slower encode) ──────────────────────────────
if (wantAvif) {
try {
out.avif = await pipeline()
.avif({ quality, effort: 4 })
.toBuffer();
out.bytes.avif = out.avif.byteLength;
} catch {
// libvips without AVIF support — skip, WebP covers us
}
}
// ── Preserve original format for legacy fallback ──────────────────────────
if (hasAlpha) {
out.png = await pipeline()
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer();
out.bytes.png = out.png.byteLength;
} else {
out.jpeg = await pipeline()
.jpeg({ quality, progressive: true, mozjpeg: true })
.toBuffer();
out.bytes.jpeg = out.jpeg.byteLength;
}
// ── Thumbnail (320px wide JPEG, no transparency — feed/card use only) ─────
if (wantThumb) {
out.thumbnail = await sharp(input, { failOn: "none" })
.rotate()
.resize({ width: 320, height: 240, fit: "cover", position: "centre" })
.jpeg({ quality: 80, progressive: true, mozjpeg: true })
.toBuffer();
out.bytes.thumbnail = out.thumbnail.byteLength;
}
return out;
}
/** Convenience: only need a single optimized buffer in best modern format. */
export async function optimizeToWebp(input: Buffer, opts: OptimizeOptions = {}) {
const r = await optimizeImage(input, { ...opts, avif: false, thumbnail: false });
return { buffer: r.webp, width: r.width, height: r.height, bytes: r.bytes };
}