/** * Server-only blob fetch + WebP transcoding, extracted from the old * on-request image proxy and now driven by the CDN pipeline at index * time. The memory discipline is unchanged and load-bearing: comic * pages run to ~24MP, ~100MB decoded — a burst of parallel transcodes * OOMs a small VM. One transcode at a time (there's one shared core * anyway), no libvips operation cache pinning decoded pages, and a * modest ceiling on buffered source downloads. */ import sharp from 'sharp' import type { Metadata } from 'sharp' import { resolvePds } from '#/lib/atproto' import type { ImageTier } from '#/lib/atproto' import { TIERS } from '#/lib/cdnUrl' const MAX_SOURCE_BYTES = 30 * 1024 * 1024 sharp.cache(false) sharp.concurrency(1) const withTranscodeSlot = createSemaphore(1) const withFetchSlot = createSemaphore(4) function createSemaphore(slots: number): (fn: () => Promise) => Promise { let active = 0 const waiting: Array<() => void> = [] return async (fn) => { if (active < slots) active++ else await new Promise((resolve) => waiting.push(resolve)) try { return await fn() } finally { // Hand the slot straight to the next waiter (active is unchanged), // so late arrivals can't steal it and overshoot the limit. const next = waiting.shift() if (next) next() else active-- } } } export class TranscodeError extends Error { constructor( readonly status: number, message: string, ) { super(message) } } export async function fetchSourceBlob(did: string, cid: string): Promise { return withFetchSlot(() => fetchBlobNow(did, cid)) } async function fetchBlobNow(did: string, cid: string): Promise { const pds = await resolvePds(did) const params = new URLSearchParams({ did, cid }) const res = await fetch(`${pds}/xrpc/com.atproto.sync.getBlob?${params}`) if (!res.ok) { throw new TranscodeError(res.status === 404 ? 404 : 502, `Blob fetch failed: ${res.status}`) } const declared = Number(res.headers.get('content-length')) if (declared > MAX_SOURCE_BYTES) throw new TranscodeError(413, 'Blob too large') const bytes = Buffer.from(await res.arrayBuffer()) if (bytes.byteLength > MAX_SOURCE_BYTES) throw new TranscodeError(413, 'Blob too large') return bytes } export function transcode(source: Buffer, tier: ImageTier): Promise { return withTranscodeSlot(() => transcodeNow(source, tier)) } async function transcodeNow(source: Buffer, tier: ImageTier): Promise { // 50MP ≈ 200MB decoded — roughly the worst single transcode the small // production VM can absorb; today's largest pages are ~24MP. const image = sharp(source, { limitInputPixels: 50_000_000, sequentialRead: true }) let format: string | undefined try { format = (await image.metadata()).format } catch { throw new TranscodeError(415, 'Blob is not a supported image') } // Lossless sources (comic line art) stay near-lossless at full size so // zooming doesn't reveal ringing around linework; lossy sources gain // nothing from it. const nearLossless = tier === 'high' && format === 'png' const spec = TIERS[tier] return image .resize({ width: spec.width, withoutEnlargement: true }) .webp(nearLossless ? { nearLossless: true, quality: 60 } : { quality: spec.quality }) .toBuffer() } export interface ExportImage { bytes: Buffer /** 'image/jpeg' or 'image/png' — the formats EPUB readers and PDF both take. */ mime: 'image/jpeg' | 'image/png' width: number height: number } /** * Normalize an image for file exports (EPUB, PDF): downscale to * `maxWidth`, JPEG for opaque images, PNG when there's an alpha channel * to keep. Runs under the same single transcode slot as the pipeline — * exports must not add a second concurrent decode to the small VM. */ export function convertForExport(source: Buffer, maxWidth: number): Promise { return withTranscodeSlot(async () => { const image = sharp(source, { limitInputPixels: 50_000_000, sequentialRead: true }).rotate() let meta: Metadata try { meta = await image.metadata() } catch { throw new TranscodeError(415, 'Blob is not a supported image') } const resized = image.resize({ width: maxWidth, withoutEnlargement: true }) const { data, info } = meta.hasAlpha ? await resized.png({ compressionLevel: 8 }).toBuffer({ resolveWithObject: true }) : await resized.jpeg({ quality: 85, mozjpeg: true }).toBuffer({ resolveWithObject: true }) return { bytes: data, mime: meta.hasAlpha ? 'image/png' : 'image/jpeg', width: info.width, height: info.height, } }) }