From cd0d3fe09eee3de83cd4159439306414b91aec9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 22:27:49 +0100 Subject: [PATCH] every ladder carries a 16-pixel placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A photograph here is 189 KB of tufted yarn — high-frequency detail in every pixel, which is the worst case for any lossy codec and not something a quality setting can fix. Re-encoding the same original with ImageMagick at the same quality 82 gives 199 KB, so av-club is already slightly ahead of a reference encoder. The 858 KB Lighthouse wants back is not available without softening the texture the site exists to sell. What was available was the wait. Each ladder now carries the picture at 16 pixels wide as a `data:` URI inside its manifest — 300 to 500 bytes — and the site paints it as the image's own background. It costs no request, which is the point: a placeholder you have to fetch is a smaller version of the problem. When the real photograph arrives it paints over its own background and the placeholder is gone, with no load handler, no class to remove and nothing to clean up if the script never ran. The blur is the browser stretching 16 pixels across the box. Videos take theirs from the poster still, before it is deleted — the same frame, so the two agree, and sharp can read a JPEG where it cannot read an MP4. ── Existing directories are topped up, not rebuilt ── The obvious move is to make `upToDate` insist on a placeholder, which turns all 62 ladders stale and re-derives the library: minutes of CPU and 67 MB rewritten to add 400 bytes to each. Every rung in them is already correct. So `topUp` makes only the missing piece, and reads `320.webp` rather than the original — already small, already browser-readable, already rotated. Going back to a 4000×6000 HEIC to produce a 16-pixel picture is the expensive half of a rebuild and precisely what this avoids. A directory with no small rung is skipped rather than forced. Co-Authored-By: Claude Opus 5 (1M context) --- api/derive.ts | 117 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) 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(), }; }