diff --git a/api/derive.ts b/api/derive.ts index a257089..776b4b0 100644 --- a/api/derive.ts +++ b/api/derive.ts @@ -109,6 +109,26 @@ export const LADDER = [320, 480, 640, 960, 1280, 1920, 2560] as const; */ export const SQUARE_LADDER = [256, 384, 480, 640, 960] as const; +/** + * Poster rungs, in pixels of width — `poster-768.webp`, and so on. + * + * A video's poster was one file at the source's full size: 1280x720 for a hero displayed 412 CSS + * pixels wide. That is three times the pixels a phone can show, on the element that IS the page's + * Largest Contentful Paint — the most expensive place on the site to be careless. + * + * A hero is full-bleed, so the widths are viewport widths times the density behind them: + * + * 412 x 1.75 = 721 -> 768 (the emulated phone, and most of the real ones) + * 412 x 3 = 1236 -> 1280 (a dense phone, from the full-size poster) + * 768 x 1 = 768 -> 768 (a tablet) + * 1280 x 1 = 1280 -> 1280 (a desktop) + * + * `poster.webp` keeps its name and stays the widest rung. Everything already refers to it — the + * placeholder is cut from it, the site falls back to it, and a directory built before this has only + * that one. + */ +export const POSTER_LADDER = [384, 512, 768, 1024] as const; + /** * WebP quality, by rung. * @@ -639,7 +659,32 @@ async function buildVideo( */ const placeholder = await encodePlaceholder(stillJpg, join(staging, 'placeholder.webp'), email); await rm(stillJpg, { force: true }); - variants.push({ file: 'poster.webp', width: null, height: null, bytes: poster.bytes }); + variants.push({ file: 'poster.webp', width: width || null, height: height || null, bytes: poster.bytes }); + + /* + The poster's own ladder, cut from the same still. + + From `stillJpg` rather than from `poster.webp`, so each rung is scaled once from the original frame + instead of being a re-encode of an encode. The still is a JPEG at the video's full size and it is + about to be deleted anyway. + + Rungs narrower than the source only: one at or above it would be `poster.webp` under another name. + */ + for (const rung of POSTER_LADDER) { + if (!width || rung >= width) continue; + const cut = await post<{ path: string; bytes: number }>('/v1/sharp', email, { + inputPath: stillJpg, + outputPath: join(staging, `poster-${rung}.webp`), + pipeline: [{ resize: { width: rung, fit: 'inside', withoutEnlargement: true } }], + output: { format: 'webp', options: { quality: qualityFor(rung) } }, + }); + variants.push({ + file: `poster-${rung}.webp`, + width: rung, + height: height ? Math.round((rung / width) * height) : null, + bytes: cut.bytes, + }); + } return { kind: 'video',