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:
+103
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user