Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108import sharp from "sharp";
// Outlook's Word rendering engine and older Apple Mail can't display WebP,// which is what the editor uploads (src/utils/addImage.ts) and therefore what// most published blobs are. Images bound for an inbox get transcoded to one of// these on the way out.const EMAIL_SAFE_FORMATS = new Set(["png", "jpeg", "gif"]);
// The source has already been through one lossy encode, so these settings buy// headroom against a second: 4:4:4 keeps colour detail at the hard edges// (text, UI chrome, line art) that subsampling smears and that compound most// visibly across generations.const JPEG_OPTIONS = { quality: 90, chromaSubsampling: "4:4:4" } as const;
// How much larger a lossless PNG may be than the JPEG before it stops being// worth it. Flat, low-colour content — screenshots and diagrams, where a// second lossy pass is most visible — compresses to at or below JPEG's size,// so it wins outright. Photographs run 5-10x and fall through to JPEG, where// the recompression is imperceptible anyway.const PNG_SIZE_TOLERANCE = 1.5;
// Display variants get re-encoded rather than inheriting the source's// fidelity: the editor uploads lossless WebP for PNG sources// (src/utils/imageEncoding.ts), and serving those bytes at every display size// is the byte volume the CDN work exists to avoid. sharp's implicit WebP// default already lands on lossy q80, but a display path that silently// depends on an encoder default is a regression waiting to happen.const WEB_WEBP_QUALITY = 80;
// The only variant format the image proxies will produce. Kept to a closed// allowlist for the same reason widths are snapped to a ladder: the value is// caller-controlled and each distinct one mints a permanent stored variant.export function parseImageFormat(value: string | null): "email" | undefined { return value === "email" ? "email" : undefined;}
// Encodes a stored variant for an image-proxy request, or null when the// original bytes already satisfy it (email-safe source, no resize asked for)// and the caller should redirect to the source instead of storing anything.//// rotate() applies EXIF orientation, which imgproxy did implicitly; callers// pass withoutEnlargement in `resize` to mirror imgproxy's no-upscale default.export async function encodeImageVariant( input: Buffer, opts: { resize?: sharp.ResizeOptions; format?: "email" },): Promise<{ data: Buffer; format: string } | null> { // A fresh instance per call: output format is sticky on a sharp instance, // and the email path encodes more than once to compare sizes. const pipeline = () => { const p = sharp(input, { animated: true }).rotate(); return opts.resize ? p.resize(opts.resize) : p; }; const metadata = await sharp(input).metadata();
if (opts.format === "email") { const encoded = await encodeForEmail(input, pipeline, metadata); if (encoded) return encoded; } if (!opts.resize) return null;
const resized = await applyWebEncoding(pipeline(), metadata.format).toBuffer({ resolveWithObject: true, }); return { data: resized.data, format: resized.info.format };}
function applyWebEncoding( pipeline: sharp.Sharp, sourceFormat: string | undefined,): sharp.Sharp { return sourceFormat === "webp" ? pipeline.webp({ quality: WEB_WEBP_QUALITY }) : pipeline;}
// Null when the source is already something every mail client renders. Costs// several decodes of the same input, paid once per stored variant and then// served from cache indefinitely.async function encodeForEmail( input: Buffer, pipeline: () => sharp.Sharp, metadata: sharp.Metadata,): Promise<{ data: Buffer; format: "png" | "jpeg" | "gif" } | null> { // Flattening an animated source to a still would silently drop frames, and // GIF is the only animated format with universal client support. if ((metadata.pages ?? 1) > 1) { if (metadata.format === "gif") return null; return { data: await pipeline().gif().toBuffer(), format: "gif" }; }
if (metadata.format && EMAIL_SAFE_FORMATS.has(metadata.format)) return null;
// JPEG can't carry transparency, so genuine alpha settles it. `hasAlpha` // only reports that a channel exists, and treating an unused one as // load-bearing would put multi-megabyte stills in people's inboxes. const { isOpaque } = await sharp(input).stats(); if (!isOpaque) return { data: await pipeline().png().toBuffer(), format: "png" };
const [png, jpeg] = await Promise.all([ pipeline().png({ compressionLevel: 9 }).toBuffer(), pipeline().jpeg(JPEG_OPTIONS).toBuffer(), ]); return png.length <= jpeg.length * PNG_SIZE_TOLERANCE ? { data: png, format: "png" } : { data: jpeg, format: "jpeg" };}