diff --git a/api/derive.ts b/api/derive.ts index bf2bfa2..f5fd848 100644 --- a/api/derive.ts +++ b/api/derive.ts @@ -55,6 +55,25 @@ export const LADDER = [320, 640, 960, 1280, 1920, 2560] as const; /** WebP quality. 82 is the usual knee: visually clean on photographs, well off the top of the curve. */ const WEBP_QUALITY = 82; +/** + * The placeholder: a picture small enough to travel INSIDE the manifest. + * + * 16 pixels wide, which is about 300–500 bytes of WebP. The site inlines it as the image's own + * background, so it costs no request at all — the point of a placeholder is to be there before + * anything has been fetched, and a placeholder you have to fetch is just a smaller version of the + * problem. + * + * It is never displayed at 16px. Stretched over the real box the browser's own smoothing does the + * blurring, so there is no filter to apply and nothing to undo: when the real photograph arrives it + * paints over its own background and the placeholder is simply gone. No JavaScript, no load handler, + * nothing to clean up if the script fails to run. + * + * Quality 50 because it is going to be scaled up two hundred times. Detail is not the job — colour + * and where the light is, is. + */ +const PLACEHOLDER_WIDTH = 16; +const PLACEHOLDER_QUALITY = 50; + /** * Video renditions, tallest first. `height` is what the preset scales TO. * @@ -100,6 +119,13 @@ export type Manifest = { kind: Kind; source: { name: string; bytes: number; mtimeMs: number; width: number | null; height: number | null }; variants: Variant[]; + /** + * A `data:` URI of the picture at 16px, or absent on a manifest built before this existed. + * + * In the manifest rather than beside it as a file, because it exists to be inlined — a rendition + * this small is smaller than the request that would fetch it. + */ + placeholder?: string; /** ISO. For a person reading the folder, not for the code. */ builtAt: string; }; @@ -168,6 +194,50 @@ export async function upToDate(dir: string, source: { bytes: number; mtimeMs: nu } } +/** + * Add what a correct-but-older directory is missing, without rebuilding it. + * + * ── Why this is not just "mark it stale" ── + * + * Placeholders arrived after 62 ladders had already been built. The obvious move is to make `upToDate` + * insist on one, which turns every existing directory stale and re-derives the whole library: minutes + * of CPU and 67 MB rewritten, to add about 400 bytes to each of them. Every rung in those directories + * is already correct. + * + * So the ladders are left alone and only the missing piece is made. + * + * ── It reads a RUNG, never the original ── + * + * `320.webp` — or `poster.webp` for a video — is already small, already browser-readable, and already + * has the EXIF rotation baked in. Going back to the original would mean decoding a 4000×6000 HEIC to + * produce a 16-pixel picture, which is the expensive half of a full rebuild and the thing this exists + * to avoid. + * + * A picture that had no small rung is skipped rather than forced: it keeps no placeholder, the page + * renders exactly as it did before, and the next real rebuild will make one. + */ +async function topUp(dir: string, manifest: Manifest, email: string): Promise { + if (manifest.placeholder) return manifest; + + const smallest = manifest.variants + .filter((variant) => variant.file.endsWith('.webp') && variant.file !== 'full.webp') + .sort((a, b) => (a.width ?? Number.MAX_SAFE_INTEGER) - (b.width ?? Number.MAX_SAFE_INTEGER))[0]; + if (!smallest) return manifest; + + const placeholder = await encodePlaceholder(join(dir, smallest.file), join(dir, '.placeholder.webp'), email); + if (!placeholder) return manifest; + + const updated = { ...manifest, placeholder }; + + /* + Rewritten in place rather than through the build-beside-and-swap dance the rest of this file uses. + Nothing else in the directory is changing, and the manifest is a single small write — the swap + exists to stop a half-built LADDER being published, which is not what is happening here. + */ + await writeFile(join(dir, MANIFEST), `${JSON.stringify(updated, null, 2)}\n`, 'utf8'); + return updated; +} + /** * Build one original's directory of renditions. * @@ -193,7 +263,7 @@ export async function deriveOne( if (!options.force && (await upToDate(targetDir, source))) { const manifest = JSON.parse(await readFile(join(targetDir, MANIFEST), 'utf8')) as Manifest; - return { built: false, manifest }; + return { built: false, manifest: await topUp(targetDir, manifest, email) }; } const staging = `${targetDir}.partial`; @@ -289,6 +359,8 @@ async function buildImage( variants.push(await encodeWebp(master, join(staging, `${rung}.webp`), rung, email, width, height)); } + const placeholder = await encodePlaceholder(master, join(staging, 'placeholder.webp'), email); + // Before the manifest is written, so a directory that has one never contains this. if (meta.needsConversion) await rm(master, { force: true }); @@ -296,10 +368,44 @@ async function buildImage( kind: 'image', source: { name, bytes: source.bytes, mtimeMs: source.mtimeMs, width, height }, variants, + placeholder, builtAt: new Date().toISOString(), }; } +/** + * The inline placeholder, as a `data:` URI. + * + * Written to disk and read back rather than asked for as bytes, because the sharp sidecar's contract + * is file-in, file-out — the same call every other rung makes. The file is removed immediately: the + * staging directory becomes the published one, and a `placeholder.webp` sitting in it would be a + * rendition nothing references and everything would have to learn to ignore. + * + * Returns undefined rather than throwing. A ladder that built is worth keeping even if this one + * 500-byte extra did not, and the site treats a missing placeholder as "no placeholder" already. + */ +async function encodePlaceholder( + sourcePath: string, + outputPath: string, + email: string, +): Promise { + try { + await post<{ path: string; bytes: number }>('/v1/sharp', email, { + inputPath: sourcePath, + outputPath, + pipeline: [{ rotate: {} }, { resize: { width: PLACEHOLDER_WIDTH, fit: 'inside', withoutEnlargement: true } }], + output: { format: 'webp', options: { quality: PLACEHOLDER_QUALITY } }, + }); + + const bytes = await readFile(outputPath); + await rm(outputPath, { force: true }); + return `data:image/webp;base64,${bytes.toString('base64')}`; + } catch { + await rm(outputPath, { force: true }).catch(() => {}); + return undefined; + } +} + async function encodeWebp( sourcePath: string, outputPath: string, @@ -388,6 +494,14 @@ async function buildVideo( outputPath: join(staging, 'poster.webp'), output: { format: 'webp', options: { quality: WEBP_QUALITY } }, }); + /* + From the STILL, not from the video. + + The same frame the poster comes from, so the two agree — and sharp can read a JPEG where it cannot + read an MP4. Before the still is deleted, which is the only reason these three lines are in this + order rather than the other one. + */ + 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 }); @@ -395,6 +509,7 @@ async function buildVideo( kind: 'video', source: { name, bytes: source.bytes, mtimeMs: source.mtimeMs, width: width || null, height: height || null }, variants, + placeholder, builtAt: new Date().toISOString(), }; }