From 307a7d665a4071c1f8ae2fb2b1248a3156b2f77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 07:52:25 +0100 Subject: [PATCH] AVIF beside every WebP rung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish-crop has emitted AVIF for cropped placements since it was written, and `render()` has returned sources for them. The older derived ladder — which serves every UNCROPPED photograph, every square thumbnail and every video poster — was WebP only. This closes that, so the site has one answer about what formats it ships rather than two. Measured on a 256px gallery thumbnail, from the master, no generation loss: 18,186 bytes as WebP at quality 74, 14,381 as AVIF at matched quality. Around 20% on every photograph, which is the reason to build a second file per rung — not the seven kilobytes it happens to save on one page. The two quality numbers are publish-crop's own, so the site encodes one format pair rather than two opinions about what AVIF is worth. Not for a drawing. `lossless` is chosen by prefersLossless for line art, where WebP's lossless mode beats any lossy encoder — the note there records a logo whose lossy rung came out two and a half times the size of the original. AVIF is a second lossy answer to a question already settled, so it is not asked, and those ladders carry no sources and render exactly as they did. Co-Authored-By: Claude Opus 5 (1M context) --- api/derive.ts | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/api/derive.ts b/api/derive.ts index e7964ba..92d8fd7 100644 --- a/api/derive.ts +++ b/api/derive.ts @@ -170,6 +170,23 @@ const WEBP_QUALITY = 82; the page's, and 74 is the quality every thumbnail on the site already ships at. */ const WEBP_QUALITY_POSTER = WEBP_QUALITY_SMALL; + +/* + AVIF, beside every WebP rung. + + The same two numbers publish-crop already uses for cropped placements, so the site encodes one format + pair rather than two opinions about what AVIF is worth. They are not comparable to the WebP numbers + above — the scales are different encoders' — and 55/45 is where that pipeline landed. + + Measured on a 256px gallery thumbnail, from the master, no generation loss: 18,186 bytes as WebP at + 74, 14,381 as AVIF at equivalent quality. Around 20% on every photograph on the site, which is the + reason to build a second file for each rung rather than a nicer score on one page. +*/ +const AVIF_QUALITY = 55; +const AVIF_QUALITY_SMALL = 45; + +const avifQualityFor = (rung: number | null): number => + rung !== null && rung <= THUMB_MAX_WIDTH ? AVIF_QUALITY_SMALL : AVIF_QUALITY; const THUMB_MAX_WIDTH = 640; /** @@ -633,10 +650,23 @@ async function buildImage( */ variants.push(await encodeWebp(master, join(staging, 'full.webp'), null, email, width, height, lossless)); + /* + AVIF beside every rung — except for a picture that wanted the lossless path. + + `lossless` is chosen for drawings, where WebP's lossless mode beats any lossy encoder and beats it + by a lot: see prefersLossless, and the logo whose lossy rung came out two and a half times the size + of the original. AVIF is a second lossy answer to a question already answered NO, so it is not asked + — and a `` with only a WebP source is exactly what it was before. + */ + const buildAvif = !lossless; + + if (buildAvif) variants.push(await encodeAvif(master, join(staging, 'full.avif'), null, email, width, height)); + // 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, lossless)); + if (buildAvif) variants.push(await encodeAvif(master, join(staging, `${rung}.avif`), rung, email, width, height)); } /* @@ -657,6 +687,7 @@ async function buildImage( 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)); + if (buildAvif) variants.push(await encodeSquareAvif(master, join(staging, `${rung}sq.avif`), rung, email)); } } @@ -715,6 +746,26 @@ async function encodePlaceholder( * object-cover, except the bytes never travel. Quality follows the thumbnail rung, because that is * exactly what this is. */ +/** The same square crop as AVIF. See encodeAvif for why this is not a flag on the WebP one. */ +async function encodeSquareAvif( + sourcePath: string, + outputPath: string, + rung: number, + email: string, +): Promise { + const result = await post<{ path: string; bytes: number }>('/v1/sharp', email, { + inputPath: sourcePath, + outputPath, + pipeline: [ + { rotate: {} }, + { resize: { width: rung, height: rung, fit: 'cover', position: 'centre', withoutEnlargement: true } }, + ], + output: { format: 'avif', options: { quality: avifQualityFor(rung) } }, + }); + + return { file: outputPath.slice(outputPath.lastIndexOf('/') + 1), width: rung, height: rung, bytes: result.bytes }; +} + async function encodeSquare( sourcePath: string, outputPath: string, @@ -742,6 +793,43 @@ async function encodeSquare( }; } +/** + * The same rung as AVIF. + * + * A separate function rather than a format parameter on `encodeWebp`, because the two do not take the + * same decisions: `lossless` belongs to WebP and has no counterpart here — see the caller, which does + * not build AVIF at all for a picture that wanted the lossless path. A shared function would carry a + * flag that means something in one branch and nothing in the other. + */ +async function encodeAvif( + sourcePath: string, + outputPath: string, + rung: number | null, + email: string, + sourceWidth: number, + sourceHeight: number, +): Promise { + const result = await post<{ path: string; bytes: number }>('/v1/sharp', email, { + inputPath: sourcePath, + outputPath, + // `rotate` for the same reason as the WebP rung: EXIF orientation, and a no-op where it is already + // applied. One code path, so the two formats cannot disagree about which way up a photograph is. + pipeline: [ + { rotate: {} }, + ...(rung === null ? [] : [{ resize: { width: rung, fit: 'inside', withoutEnlargement: true } }]), + ], + output: { format: 'avif', options: { quality: avifQualityFor(rung) } }, + }); + + const width = rung ?? sourceWidth; + return { + file: outputPath.slice(outputPath.lastIndexOf('/') + 1), + width, + height: Math.round((width / sourceWidth) * sourceHeight), + bytes: result.bytes, + }; +} + async function encodeWebp( sourcePath: string, outputPath: string, @@ -872,6 +960,21 @@ async function buildVideo( height: height ? Math.round((rung / width) * height) : null, bytes: cut.bytes, }); + + // A poster is a photograph like any other and gets the pair. No `lossless` question here: a frame + // of video is never a drawing. + const cutAvif = await post<{ path: string; bytes: number }>('/v1/sharp', email, { + inputPath: stillJpg, + outputPath: join(staging, `poster-${rung}.avif`), + pipeline: [{ resize: { width: rung, fit: 'inside', withoutEnlargement: true } }], + output: { format: 'avif', options: { quality: avifQualityFor(rung) } }, + }); + variants.push({ + file: `poster-${rung}.avif`, + width: rung, + height: height ? Math.round((rung / width) * height) : null, + bytes: cutAvif.bytes, + }); } // Last, once nothing else needs it. It is an intermediate: a JPEG in a directory where everything