a drawing and a photograph are encoded differently

Lossy WebP is built for photographs — it discards detail the eye does not miss in something full of
texture. A logo is the opposite: flat areas, hard edges, a handful of colours. Encoding one lossily is
worse than useless twice over. It softens the edges, and it makes the file BIGGER, because flat
regions are exactly what lossless compression is good at and exactly what lossy encoding turns into
gradients it then has to describe.

Measured, not theorised: fofos-logo-saffron.png is 300x300 and produced a 22,432-byte 256x256 rung
beside a 9,144-byte full-size one. A smaller picture, two and a half times the file.

The decision is measured too. Every heuristic for this is wrong somewhere — a PNG can hold a
photograph, a JPEG can hold a diagram, plenty of logos are opaque — so it encodes the smallest rung
both ways and keeps the answer. One extra encode of a 160-pixel image per original, and what comes out
is not an opinion about the file: it is the file saying which encoder suits it. Applied to every rung,
because an image does not stop being a drawing at 640 pixels. Recorded in the manifest, because
somebody wondering why one directory is shaped differently should find the answer rather than
re-derive it.

Two other things the logos exposed:

The ladder had no floor. It started at 320 and a logo renders at 80, so fofos-logo-black got a single
12,350-byte rung against a 12,678-byte original — a ladder that saved 328 bytes. 160 and 256 are now
the bottom, and 128 and 192 on the square side.

And square crops of things already square. Both logos are 300x300, so their square rung cropped
nothing: the same image under a second name. The aspect ladder already produces squares for a square
source. 2% tolerance, because a 1000x1002 photograph is square for every purpose this serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 09:46:56 +01:00
co-authored by Claude Opus 5
parent ccf5768a3d
commit 53b834a7a6
+123 -11
View File
@@ -52,6 +52,11 @@ import { mediaPath } from './media';
/**
* Image rungs, in pixels of width. Rungs wider than the original are skipped — see above.
*
* 160 and 256 are the FLOOR, added because there was none. A logo renders at 80 CSS pixels and the
* smallest rung was 320, so the whole ladder had nothing to offer it: `fofos-logo-black` came out with
* one 12,350-byte rung against a 12,678-byte original, a ladder that saved 328 bytes. Anything shown
* smaller than a thumbnail — a logo, an icon, an avatar — lives down here.
*
* 480 was added on 2026-08-29 because the gap between 320 and 640 is where a phone lands. Lighthouse
* measured the home gallery's slots at 454 device pixels and the browser, having only those two rungs
* to choose from, correctly took 640 — a 184 KB file for a 454-pixel slot, and the same story on
@@ -60,7 +65,7 @@ import { mediaPath } from './media';
* A rung is cheap: it is one more WebP beside files that already exist, chosen only when it fits
* better than its neighbours. The cost of NOT having one is paid on every visit.
*/
export const LADDER = [320, 480, 640, 960, 1280, 1920, 2560] as const;
export const LADDER = [160, 256, 320, 480, 640, 960, 1280, 1920, 2560] as const;
/**
* Square rungs, cropped rather than scaled — `320sq.webp`, and so on.
@@ -107,7 +112,7 @@ export const LADDER = [320, 480, 640, 960, 1280, 1920, 2560] as const;
* It is a DEFAULT. A placement that sets its own crop keeps the aspect file and frames it in CSS —
* see how the site chooses in src/media/render.ts. Nothing here knows about placements, and should not.
*/
export const SQUARE_LADDER = [256, 384, 480, 640, 960] as const;
export const SQUARE_LADDER = [128, 192, 256, 384, 480, 640, 960] as const;
/**
* Poster rungs, in pixels of width — `poster-768.webp`, and so on.
@@ -150,10 +155,85 @@ const WEBP_QUALITY_SMALL = 74;
const WEBP_QUALITY = 82;
const THUMB_MAX_WIDTH = 640;
/**
* How far from square still counts as square.
*
* 2% of the longer side. A 1000x1002 photograph is square for every purpose this serves, and building
* a parallel ladder to crop two pixels off it is work nobody benefits from.
*/
const SQUARE_TOLERANCE = 0.02;
/**
* Is this a DRAWING rather than a photograph?
*
* ── Why it has to be asked at all ──
*
* Lossy WebP is built for photographs: it throws away detail the eye does not miss in a picture full
* of texture and noise. A logo is the opposite — flat areas, hard edges, a handful of colours — and
* lossy encoding is worse than useless on it twice over. It softens the edges, and it makes the file
* BIGGER, because smooth flat regions are exactly what lossless compression is good at and exactly
* what lossy encoding turns into gradients it then has to describe.
*
* Measured here: `fofos-logo-saffron.png` is 300x300 and became a 22,432-byte 256x256 rung beside a
* 9,144-byte full-size one. A smaller picture, two and a half times the file.
*
* ── Measured, not guessed ──
*
* The obvious approach is a heuristic — the file extension, an alpha channel, a colour count — and
* every one of them is wrong somewhere. A PNG can hold a photograph, a JPEG can hold a diagram, and
* plenty of logos are opaque.
*
* So it encodes the smallest rung BOTH ways and keeps the answer. One extra encode of a 160-pixel
* image per original, and what comes out is not an opinion about the file: it is the file telling us
* which encoder suits it. The decision is then applied to every rung, because an image does not stop
* being a drawing at 640 pixels.
*
* Ties and failures go to lossy, which is the right default for a library that is mostly photographs.
*/
async function prefersLossless(sourcePath: string, staging: string, email: string): Promise<boolean> {
const probe = LADDER[0]!;
const lossyPath = join(staging, '.probe-lossy.webp');
const losslessPath = join(staging, '.probe-lossless.webp');
try {
const [lossy, lossless] = await Promise.all([
post<{ bytes: number }>('/v1/sharp', email, {
inputPath: sourcePath,
outputPath: lossyPath,
pipeline: [{ resize: { width: probe, fit: 'inside', withoutEnlargement: true } }],
output: { format: 'webp', options: { quality: WEBP_QUALITY_SMALL } },
}),
post<{ bytes: number }>('/v1/sharp', email, {
inputPath: sourcePath,
outputPath: losslessPath,
pipeline: [{ resize: { width: probe, fit: 'inside', withoutEnlargement: true } }],
output: { format: 'webp', options: { lossless: true } },
}),
]);
return lossless.bytes < lossy.bytes;
} catch {
// A probe that could not run is not a reason to refuse the ladder. Lossy is the right default.
return false;
} finally {
await rm(lossyPath, { force: true }).catch(() => {});
await rm(losslessPath, { force: true }).catch(() => {});
}
}
/** The quality a rung is worth. `null` is `full.webp`, which is the one people open. */
const qualityFor = (rung: number | null): number =>
rung !== null && rung <= THUMB_MAX_WIDTH ? WEBP_QUALITY_SMALL : WEBP_QUALITY;
/**
* The WebP settings for one rung, given what the picture turned out to be.
*
* `lossless` and `quality` are not combined: sharp ignores quality in lossless mode, so passing both
* would read as a setting that does something and does not.
*/
const webpOptions = (rung: number | null, lossless: boolean): Record<string, unknown> =>
lossless ? { lossless: true } : { quality: qualityFor(rung) };
/**
* The placeholder: a picture small enough to travel INSIDE the manifest.
*
@@ -244,6 +324,14 @@ export type Manifest = {
* this small is smaller than the request that would fetch it.
*/
placeholder?: string;
/**
* True when this picture encoded smaller losslessly — a drawing rather than a photograph.
*
* Recorded because it is a measurement, not a setting. Somebody looking at a logo's directory and
* wondering why its files are shaped differently from every other one finds the answer here rather
* than having to re-derive it to find out.
*/
lossless?: boolean;
/** ISO. For a person reading the folder, not for the code. */
builtAt: string;
};
@@ -460,6 +548,13 @@ async function buildImage(
});
}
/*
Asked once, of the picture itself, before anything is built. See prefersLossless: a drawing and a
photograph want opposite encoders, and getting it wrong on a logo produced a smaller rung two and
a half times the size of the original.
*/
const lossless = await prefersLossless(master, staging, email);
const variants: Variant[] = [];
/*
@@ -469,19 +564,33 @@ async function buildImage(
exists at all — the original renders nowhere. A lightbox, a zoom, or handing the client their own
photograph back all need it.
*/
variants.push(await encodeWebp(master, join(staging, 'full.webp'), null, email, width, height));
variants.push(await encodeWebp(master, join(staging, 'full.webp'), null, email, width, height, lossless));
// Strictly less than the original: a rung at or above it would duplicate `full.webp` byte for byte.
for (const rung of LADDER) {
if (rung >= width) continue;
variants.push(await encodeWebp(master, join(staging, `${rung}.webp`), rung, email, width, height));
variants.push(await encodeWebp(master, join(staging, `${rung}.webp`), rung, email, width, height, lossless));
}
// The square crops, for grid slots. Skipped when the source is smaller than the rung — enlarging to
// make a thumbnail is the one thing worse than cropping in the browser.
for (const rung of SQUARE_LADDER) {
if (rung > Math.min(width, height)) continue;
variants.push(await encodeSquare(master, join(staging, `${rung}sq.webp`), rung, email));
/*
The square crops — but only if the picture is not already square.
A 300x300 logo was getting a `256sq.webp` that cropped nothing: the same image at a different name,
a second file to store, serve and keep in step. The aspect ladder above already produces squares
for a square source, so there is nothing a square rung could add.
`SQUARE_TOLERANCE` rather than an exact match, because a 1000x1002 photograph is square for every
purpose this serves and cropping two pixels off it is not worth a parallel ladder.
Skipped too when the source is smaller than the rung — enlarging to make a thumbnail is the one
thing worse than cropping in the browser.
*/
const squareEnough = Math.abs(width - height) / Math.max(width, height) <= SQUARE_TOLERANCE;
if (!squareEnough) {
for (const rung of SQUARE_LADDER) {
if (rung > Math.min(width, height)) continue;
variants.push(await encodeSquare(master, join(staging, `${rung}sq.webp`), rung, email, lossless));
}
}
const placeholder = await encodePlaceholder(master, join(staging, 'placeholder.webp'), email);
@@ -494,6 +603,7 @@ async function buildImage(
source: { name, bytes: source.bytes, mtimeMs: source.mtimeMs, width, height },
variants,
placeholder,
lossless,
builtAt: new Date().toISOString(),
};
}
@@ -543,6 +653,7 @@ async function encodeSquare(
outputPath: string,
rung: number,
email: string,
lossless: boolean,
): Promise<Variant> {
const result = await post<{ path: string; bytes: number }>('/v1/sharp', email, {
inputPath: sourcePath,
@@ -551,7 +662,7 @@ async function encodeSquare(
{ rotate: {} },
{ resize: { width: rung, height: rung, fit: 'cover', position: 'centre', withoutEnlargement: true } },
],
output: { format: 'webp', options: { quality: WEBP_QUALITY_SMALL } },
output: { format: 'webp', options: webpOptions(rung, lossless) },
});
return {
@@ -569,6 +680,7 @@ async function encodeWebp(
email: string,
sourceWidth: number,
sourceHeight: number,
lossless: boolean,
): Promise<Variant> {
const result = await post<{ path: string; bytes: number }>('/v1/sharp', email, {
inputPath: sourcePath,
@@ -581,7 +693,7 @@ async function encodeWebp(
{ rotate: {} },
...(rung === null ? [] : [{ resize: { width: rung, fit: 'inside', withoutEnlargement: true } }]),
],
output: { format: 'webp', options: { quality: qualityFor(rung) } },
output: { format: 'webp', options: webpOptions(rung, lossless) },
});
const width = rung ?? sourceWidth;