import 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", mozjpeg: true }; const JPEG_SOURCE_OPTIONS = { quality: 80, mozjpeg: true }; // 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; const EMAIL_VARIANT_DIR = "email-v2"; export function variantFormatDir(format: string | undefined) { return format === "email" ? `${EMAIL_VARIANT_DIR}/` : ""; } // 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" | "mp4" | undefined { return value === "email" || value === "mp4" ? value : undefined; } // Encodes a stored variant for an image-proxy request, or null when the // original bytes already satisfy it and the caller should redirect to the // source instead of storing anything: the source is already within the // requested size and in a format the target renders, and re-encoding // wouldn't make it smaller. // // 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(); const animated = (metadata.pages ?? 1) > 1; const { width = 0, height = 0 } = metadata.autoOrient ?? metadata; const shrinks = !!opts.resize && ((!!opts.resize.width && width > opts.resize.width) || (!!opts.resize.height && height > opts.resize.height)); let encoded: { data: Buffer; format: string }; if (opts.format === "email") { encoded = await encodeForEmail(input, pipeline, metadata, animated); } else if (animated) { if (!opts.resize) return null; encoded = await encode(pipeline()); } else { encoded = await encode(pipeline().webp({ quality: WEB_WEBP_QUALITY })); } const acceptable = opts.format === "email" ? EMAIL_SAFE_FORMATS.has(metadata.format ?? "") : true; const upright = (metadata.orientation ?? 1) === 1; if (!shrinks && upright && acceptable && encoded.data.length >= input.length) return null; return encoded; } async function encode(pipeline: sharp.Sharp) { const { data, info } = await pipeline.toBuffer({ resolveWithObject: true }); return { data, format: info.format }; } // Still images become PNG or JPEG, whichever is right for the content (see // PNG_SIZE_TOLERANCE); animation survives only as GIF. Costs several encodes // 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, animated: boolean, ) { if (animated) { return encode(metadata.format === "gif" ? pipeline() : pipeline().gif()); } const encodePng = () => encode(pipeline().png({ compressionLevel: 9 })); // 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 encodePng(); if (metadata.format === "jpeg") return encode( pipeline().jpeg({ ...JPEG_SOURCE_OPTIONS, chromaSubsampling: metadata.chromaSubsampling?.startsWith("4:4:4") ? "4:4:4" : "4:2:0", }), ); const [png, jpeg] = await Promise.all([ encodePng(), encode(pipeline().jpeg(JPEG_OPTIONS)), ]); return png.data.length <= jpeg.data.length * PNG_SIZE_TOLERANCE ? png : jpeg; }