diff --git a/api/derive.ts b/api/derive.ts index 5e6a0f1..6ab3e45 100644 --- a/api/derive.ts +++ b/api/derive.ts @@ -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 { + 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 => + 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 { 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 { 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;