Something went wrong. Try again.
The Cavu Reader
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128/** * 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): <T>(fn: () => Promise<T>) => Promise<T> { let active = 0 const waiting: Array<() => void> = [] return async (fn) => { if (active < slots) active++ else await new Promise<void>((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<Buffer> { return withFetchSlot(() => fetchBlobNow(did, cid))}
async function fetchBlobNow(did: string, cid: string): Promise<Buffer> { 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<Buffer> { return withTranscodeSlot(() => transcodeNow(source, tier))}
async function transcodeNow(source: Buffer, tier: ImageTier): Promise<Buffer> { // 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<ExportImage> { 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, } })}