From 8ff0bf21e308d4907cc2620d80f238a10341a28d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 11 Oct 2026 22:06:12 -0700 Subject: Feed: a picture from a host that won't hand over its bytes paints from a server-made copy. The app shrinks a post's photo to the column's size itself (an 1100px copy made once, kept on disk) — but only when the host allows CORS; one that doesn't (primal's blossom, nostrcheck, most personal sites) left the painting the original, and a 9528×6328 camera file froze a wheel scroll for 400 ms on its texture upload. vmedia now has /vmedia/pic.webp?s=col|tile&u=: the picture at 1100px wide (or a 360px square of its middle for the grid), made with libvips (shrink-on-load, so the original is never decoded whole; turned the way its EXIF says; alpha kept; stripped) as WebP, cached on disk and at the CDN for everyone. The usual guards: https on public addresses only, each redirect's host checked, 60 MB cap, the bytes sniffed (a GIF is refused — its copy would be a still), a few jobs at a time, 600 new copies per address per hour, an 8 GB cache. The client asks the server for the column copy when the host is known to keep its bytes (or has just refused, or there's no worker to make one here), keeps it in the same cache as the copies it makes, and takes the server's tile for the grid the same way; a picture the server has no copy for paints as before. The container gets libvips-tools (Dockerfile). tools/pic-proxy-test.js (canned server answers). Server measured on cs: a 3072×4608 photo in 0.5 s the first time, a millisecond after; feed-stability's two pre-existing asserts still fail on 491904e. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/features/messages.js | 61 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index bb2d788..ef427eb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6152,6 +6152,7 @@ export function messagesFeature(ctx) { const READY_MS = 8000; const mediaReady = new Map(); // URL -> decoded dimensions const mediaFailed = new Set(); // URLs whose picture refused to load (not merely slow) + const corsRefused = new Set(); // pictures whose host wouldn't hand over the bytes (this session) const mediaWarming = new Map(); // A post's photo is often the camera's original — 3024px wide, twelve // megapixels — painted into a column a sixth of that. Chrome decodes it @@ -6185,7 +6186,7 @@ export function messagesFeature(ctx) { if (!res.ok) return null; blob = await res.blob(); } catch (e) { - if (e && e.corsRefused) noteNoCors(url); // refused, as corsFetch confirmed + if (e && e.corsRefused) { noteNoCors(url); corsRefused.add(url); } // refused, as corsFetch confirmed return null; } const result = await makeImageThumbnails(blob, { @@ -6216,7 +6217,10 @@ export function messagesFeature(ctx) { if (mediaWarming.has(url)) return mediaWarming.get(url); if (thumb && /^https?:/i.test(url)) { const task = (async () => { - const got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null)); + let got = (await storedMediaThumb(url)) || (await makeMediaThumb(url).catch(() => null)); + // no copy to be made here (the host keeps its bytes, or no worker to + // make it): the server makes one + if (!got && (noCorsHost(url) || corsRefused.has(url) || typeof OffscreenCanvas === 'undefined')) got = await serverMediaThumb(url).catch(() => null); if (got) { // no copy (small already, or a GIF): the original is what paints, // so it's the one decoded ahead — the row mustn't decode it on sight @@ -7383,6 +7387,47 @@ export function messagesFeature(ctx) { const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url); const noPicture = new Set(), h264Failed = new Set(); const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session) + // A picture from a host that won't hand over its bytes (no CORS: primal's + // blossom, nostrcheck, most personal sites) can't have its column copy made + // here, so the server makes it (pic.webp: 1100px wide, or a 360px square + // for the grid; libvips, cached for everyone at the CDN). Without this the + // painted the original, and a 9528×6328 camera file froze a wheel + // scroll for 400 ms on its texture upload. The copy is kept in the same + // cache as the ones made here, so the next visit paints it at once. + const serverPic = (url, shape) => (vmediaOk(url) && !/\.gif(\?|$)/i.test(url) ? VMEDIA + 'pic.webp?s=' + shape + '&u=' + encodeURIComponent(url) : null); + const serverPicFailed = new Set(); // pictures the server had no copy for (this session) + const picDims = (src) => new Promise((resolve) => { + const img = new Image(); + img.onload = () => (img.decode ? img.decode() : Promise.resolve()).then(() => resolve({ width: img.naturalWidth, height: img.naturalHeight }), () => resolve(null)); + img.onerror = () => resolve(null); + img.src = src; + }); + async function serverPicBlob(url, shape) { + const src = serverPic(url, shape); + if (!src || serverPicFailed.has(shape + '|' + url)) return null; + let res; + try { res = await fetch(src, { signal: AbortSignal.timeout(READY_MS) }); } catch { return null; } + if (!res.ok) { + if (res.status !== 429 && res.status !== 503) serverPicFailed.add(shape + '|' + url); // busy is tried again + return null; + } + const blob = await res.blob(); + return /^image\//.test(blob.type) && blob.size ? blob : null; + } + async function serverMediaThumb(url) { + const blob = await serverPicBlob(url, 'col'); + if (!blob) return null; + const thumb = URL.createObjectURL(blob); + const dims = await picDims(thumb); + if (!dims || !dims.width) { URL.revokeObjectURL(thumb); serverPicFailed.add('col|' + url); return null; } + try { + const store = await thumbStore(); + await store.put(url, new Response(blob, { headers: { 'x-dims': JSON.stringify(dims), 'x-original': '0' } })); + const keys = await store.keys(); + for (const k of keys.slice(0, Math.max(0, keys.length - MEDIA_THUMB_MAX))) store.delete(k); + } catch {} + return { ...dims, thumb }; + } let hevcPlays; const canHevc = () => hevcPlays ??= (() => { try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } @@ -11388,13 +11433,21 @@ export function messagesFeature(ctx) { if (typeof createImageBitmap === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof caches === 'undefined') return; // the column copy is already here and smaller than the original const kept = mediaReady.get(url)?.thumb; - if (!kept && noCorsHost(url)) return; + if (!kept && (noCorsHost(url) || corsRefused.has(url))) { + // the host keeps its bytes: the server cuts the tile + const tile = await serverPicBlob(url, 'tile'); + if (tile) await saveTileThumb(url, tile); + return; + } let blob; try { const res = kept ? await fetch(kept) : await corsFetch(url, AbortSignal.timeout(READY_MS)); if (!res.ok) return; blob = await res.blob(); - } catch { return; } + } catch (e) { + if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); } + return; + } if (/gif/i.test(blob.type)) return; // animated: the tile shows the original const result = await makeImageThumbnails(blob, { tileSize: TILE_PX }); if (result?.tile) await saveTileThumb(url, result.tile); -- cgit v1.2.3