diff --git a/api/derive.ts b/api/derive.ts index 9c5cf83..d7b1917 100644 --- a/api/derive.ts +++ b/api/derive.ts @@ -258,6 +258,33 @@ const qualityFor = (rung: number | null): number => const webpOptions = (rung: number | null, lossless: boolean): Record => lossless ? { lossless: true } : { quality: qualityFor(rung) }; +/** + * How to resample, given what the picture is. + * + * ── Why a drawing needs a different filter ── + * + * A smooth kernel interpolates. On a photograph that is exactly right; on a drawing it is destructive + * in a way that is easy to miss, because the damage shows up as SIZE rather than as blur. A logo with + * 174 flat colours downscaled with lanczos3 comes out with thousands of interpolated ones, and + * lossless compression — which was winning until that moment — then has to encode every one of them. + * + * Measured on fofos-logo-saffron at 160 pixels, all lossless: + * + * lanczos3 13,356 mitchell + nearLossless 7,982 + * mitchell 9,854 NEAREST 3,436 + * + * The whole 300-pixel original is 8,886. Every smooth option produced a 160-pixel file heavier than + * the full-size one; nearest produced one a third of its size. + * + * ── The cost, stated ── + * + * Nearest at a fractional scale drops rows and columns rather than blending them, so edges alias. That + * is the real trade and it is accepted here for one reason: these renditions are shown SMALLER than + * they are, and the browser's own downscale smooths what nearest left behind. A 160-pixel logo in an + * 80-pixel slot is where this lives. + */ +const kernelFor = (lossless: boolean): Record => (lossless ? { kernel: 'nearest' } : {}); + /** * The placeholder: a picture small enough to travel INSIDE the manifest. * @@ -684,7 +711,9 @@ async function encodeSquare( outputPath, pipeline: [ { rotate: {} }, - { resize: { width: rung, height: rung, fit: 'cover', position: 'centre', withoutEnlargement: true } }, + { + resize: { width: rung, height: rung, fit: 'cover', position: 'centre', withoutEnlargement: true, ...kernelFor(lossless) }, + }, ], output: { format: 'webp', options: webpOptions(rung, lossless) }, }); @@ -715,7 +744,9 @@ async function encodeWebp( // rotation already applied. One code path beats two that can disagree about which way up a photo is. pipeline: [ { rotate: {} }, - ...(rung === null ? [] : [{ resize: { width: rung, fit: 'inside', withoutEnlargement: true } }]), + ...(rung === null + ? [] + : [{ resize: { width: rung, fit: 'inside', withoutEnlargement: true, ...kernelFor(lossless) } }]), ], output: { format: 'webp', options: webpOptions(rung, lossless) }, });