aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-11 22:06:12 -0700
committerAdam Soltys <[email protected]>2026-10-11 22:06:12 -0700
commit8ff0bf21e308d4907cc2620d80f238a10341a28d (patch)
tree4e6e6a3995fc4d469313100b69ef9768c330aadc /src/features
parenta34769e3f60aa5b6920e4e2c9e0a9ee1013cacd1 (diff)
downloadcoinosv3-8ff0bf21e308d4907cc2620d80f238a10341a28d.tar.gz
coinosv3-8ff0bf21e308d4907cc2620d80f238a10341a28d.zip
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 <img> 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=<picture>: 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.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js61
1 files changed, 57 insertions, 4 deletions
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
+ // <img> 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);