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:
+33
-2
@@ -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) },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user