Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135import 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;}