diff --git a/api/derive.ts b/api/derive.ts index 03343ff..ecb5a4e 100644 --- a/api/derive.ts +++ b/api/derive.ts @@ -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 { + 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,