square crops for square slots

A gallery grid is square slots and the ladder is the picture's own shape, so a 480x720 portrait
arrived, object-cover cropped it to 480x480 in the browser, and a third of every file was discarded
before anyone saw it. Measured here: 108,388 bytes shipped where a 480x480 crop is 70,070.

Cropping in the browser is the right default when the shape is unknown. It is the wrong one when the
shape is known at build time, and for a carousel of square thumbnails it always is.

Thumbnail widths only — 320, 480, 640. Anything larger is a picture being looked at, and a square crop
is a loss of information that only makes sense when the slot demands it.

Centre, never sharp's `attention`. Smart cropping decides differently for every picture, so a row of
thumbnails framed by an algorithm has no common logic to it and the one it gets wrong cannot be
predicted or explained. Centre is what a photographer framing for a square already assumes.

It is a default: a placement carrying its own crop keeps the aspect file and frames it in CSS, because
that crop is a person saying which part matters. Nothing here knows about placements, and should not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:06:18 +01:00
co-authored by Claude Opus 5
parent f3dda78ed0
commit dc41cdbf09
+69
View File
@@ -62,6 +62,37 @@ import { mediaPath } from './media';
*/
export const LADDER = [320, 480, 640, 960, 1280, 1920, 2560] as const;
/**
* Square rungs, cropped rather than scaled — `320sq.webp`, and so on.
*
* ── Why a second, differently shaped ladder ──
*
* A gallery grid is square slots. The ladder above is the picture's own shape, so a 480x720 portrait
* arrives, `object-cover` crops it to 480x480 in the browser, and a third of every file that was
* downloaded is discarded before anyone sees it. Measured on this gallery: 108,388 bytes shipped where
* a 480x480 crop is 70,070.
*
* Cropping in the browser is the right default when the shape is unknown. It is the wrong one when the
* shape is known at build time, and for a carousel of square thumbnails it always is.
*
* ── Only at thumbnail widths ──
*
* These exist for grid slots. Anything larger is a picture being looked at, which is what the aspect
* ladder and the lightbox are for — and a square crop is a loss of information that only makes sense
* when the slot demands it.
*
* ── Centre, always ──
*
* sharp offers `attention`, which keeps the region it judges busiest. It is rejected deliberately: it
* decides differently for every picture, so a row of thumbnails framed by an algorithm is a row with
* no common logic to it, and the one it gets wrong is impossible to predict or explain. Centre is what
* a photographer framing for a square already assumes, and it is the same answer every time.
*
* 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 = [320, 480, 640] as const;
/**
* WebP quality, by rung.
*
@@ -407,6 +438,13 @@ async function buildImage(
variants.push(await encodeWebp(master, join(staging, `${rung}.webp`), rung, email, width, height));
}
// 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));
}
const placeholder = await encodePlaceholder(master, join(staging, 'placeholder.webp'), email);
// Before the manifest is written, so a directory that has one never contains this.
@@ -454,6 +492,37 @@ async function encodePlaceholder(
}
}
/**
* One square crop.
*
* `fit: cover` with `position: centre` is sharp doing the work the browser would otherwise do with
* object-cover, except the bytes never travel. Quality follows the thumbnail rung, because that is
* exactly what this is.
*/
async function encodeSquare(
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: 'webp', options: { quality: WEBP_QUALITY_SMALL } },
});
return {
file: outputPath.slice(outputPath.lastIndexOf('/') + 1),
width: rung,
height: rung,
bytes: result.bytes,
};
}
async function encodeWebp(
sourcePath: string,
outputPath: string,