AVIF beside every WebP rung

publish-crop has emitted AVIF for cropped placements since it was written, and `render()` has returned
<picture> 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 07:52:25 +01:00
co-authored by Claude Opus 5
parent f8ecbe371c
commit 307a7d665a
+103
View File
@@ -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 `<picture>` 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<Variant> {
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<Variant> {
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