a drawing is resampled with nearest, not lanczos3

A smooth kernel interpolates. On a photograph that is right; on a drawing it is destructive in a way
that 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 the lossless encoder that was winning a moment ago
then has to describe every one.

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, which is what "the ladder made it worse" looks like from the inside.

The cost is real and accepted: nearest at a fractional scale drops rows rather than blending them, so
edges alias. 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 09:57:31 +01:00
co-authored by Claude Opus 5
parent 193f8cb03d
commit 7241848d36
+33 -2
View File
@@ -258,6 +258,33 @@ const qualityFor = (rung: number | null): number =>
const webpOptions = (rung: number | null, lossless: boolean): Record<string, unknown> =>
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<string, unknown> => (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) },
});