aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
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);