diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 01:25:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 01:25:04 -0700 |
| commit | ff1a8a72c398c77fbac005f1fde25ad8f43503ef (patch) | |
| tree | 43b8f1d87239193667d59ac6071c488065f44cb2 /src | |
| parent | 817bcc3ba7b2aaad388bc5b2e5cda5ccf7b27766 (diff) | |
| download | coinosv3-ff1a8a72c398c77fbac005f1fde25ad8f43503ef.tar.gz coinosv3-ff1a8a72c398c77fbac005f1fde25ad8f43503ef.zip | |
Feed images: the column copy and the grid tile are decoded, resized and WebP-encoded in a worker, one picture at a time, instead of on the page thread between scroll frames (a profile of the live feed showed 1.8 s of convertToBlob there, in tasks up to 383 ms). A picture that hangs or crashes the worker is given up and the worker restarts for the next; three in a row and originals paint for the session, as they do wherever workers can't run
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 49 | ||||
| -rw-r--r-- | src/image-thumbnails.js | 93 |
2 files changed, 105 insertions, 37 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 5cdf09b..e9e238a 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -33,6 +33,7 @@ import { loadMediaIndex, saveMediaIndex, wipeMediaIndex } from '../media-cache.j import { mergeFeedWindow } from '../feed-window.js'; import { createThreadStore } from '../thread-cache.js'; import { createThreadPrefetch } from '../thread-prefetch.js'; +import { makeImageThumbnails } from '../image-thumbnails.js'; import { createFeedCache, FEED_CACHE_POSTS, FEED_SEED_POSTS } from '../feed-cache.js'; import { POPULAR_RELAYS, FIREHOSE_RELAYS, popularCandidates } from '../popular-feed.js'; import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl, primalCache } from '../recipient-search.js'; @@ -5732,28 +5733,14 @@ export function messagesFeature(ctx) { if (e && e.corsRefused) noteNoCors(url); // refused, as corsFetch confirmed return null; } - let bmp; - try { bmp = await createImageBitmap(blob); } catch { return null; } - const dims = { width: bmp.width, height: bmp.height }; - // the Images grid's square comes from the same decode: scrolling the - // feed fills the grid's cache, no second download - if (!/gif/i.test(blob.type) && !tileThumbs.has(url)) await tileFromBitmap(url, bmp).catch(() => {}); - // small enough already, or animated (a GIF's copy would be one frame): - // remember the shape and paint the original - const keep = bmp.width <= MEDIA_THUMB_W * 1.25 || /gif/i.test(blob.type); - let out = null; - if (!keep) { - try { - const h = Math.max(1, Math.round(bmp.height * MEDIA_THUMB_W / bmp.width)); - const c = new OffscreenCanvas(MEDIA_THUMB_W, h); - const g = c.getContext('2d'); - g.imageSmoothingQuality = 'high'; - g.drawImage(bmp, 0, 0, MEDIA_THUMB_W, h); - out = await c.convertToBlob({ type: 'image/webp', quality: 0.82 }); - if (!/webp/.test(out.type)) out = await c.convertToBlob({ type: 'image/jpeg', quality: 0.82 }); - } catch { out = null; } - } - try { bmp.close(); } catch {} + const result = await makeImageThumbnails(blob, { + width: MEDIA_THUMB_W, tileSize: tileThumbs.has(url) ? 0 : TILE_PX, + }); + if (!result) return null; + const dims = { width: result.width, height: result.height }, out = result.media; + // Both sizes share one decode in the worker; only cache writes and object + // URLs come back to the page. The lightbox still uses the original. + if (result.tile) await saveTileThumb(url, result.tile).catch(() => {}); try { const store = await thumbStore(); await store.put(url, new Response(out || new Blob(), { headers: { 'x-dims': JSON.stringify(dims), 'x-original': out ? '0' : '1' } })); @@ -10561,22 +10548,10 @@ export function messagesFeature(ctx) { blob = await res.blob(); } catch { return; } if (/gif/i.test(blob.type)) return; // animated: the tile shows the original - let bmp; - try { bmp = await createImageBitmap(blob); } catch { return; } - try { await tileFromBitmap(url, bmp); } catch {} finally { try { bmp.close(); } catch {} } + const result = await makeImageThumbnails(blob, { tileSize: TILE_PX }); + if (result?.tile) await saveTileThumb(url, result.tile); } - // A tile's square, cut from a decoded picture and kept (the caller closes it). - async function tileFromBitmap(url, bmp) { - if (typeof OffscreenCanvas === 'undefined' || typeof caches === 'undefined') return; - const side = Math.min(bmp.width, bmp.height); - const px = Math.min(TILE_PX, side); - const sq = await createImageBitmap(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side, - { resizeWidth: px, resizeHeight: px, resizeQuality: 'high' }); - const c = new OffscreenCanvas(px, px); - c.getContext('2d').drawImage(sq, 0, 0); - try { sq.close(); } catch {} - let out = await c.convertToBlob({ type: 'image/webp', quality: 0.8 }); - if (!/webp/.test(out.type)) out = await c.convertToBlob({ type: 'image/jpeg', quality: 0.8 }); + async function saveTileThumb(url, out) { tileThumbs.set(url, URL.createObjectURL(out)); const store = await tileStore(); await store.put(tileKey(url), new Response(out, { headers: { 'content-type': out.type } })); diff --git a/src/image-thumbnails.js b/src/image-thumbnails.js new file mode 100644 index 0000000..ffac4b9 --- /dev/null +++ b/src/image-thumbnails.js @@ -0,0 +1,93 @@ +// Keep decode, resize and WebP encoding off the scrolling thread. A blob +// worker also works in the standalone wallet without a separately hosted file. +// This function is serialized: all its dependencies must live inside it. +function thumbnailWorker() { + const encode = async (canvas, quality) => { + let blob = await canvas.convertToBlob({ type: 'image/webp', quality }); + if (!/webp/.test(blob.type)) blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); + return blob; + }; + self.onmessage = async ({ data: { blob, width, tileSize } }) => { + let bitmap; + try { + bitmap = await createImageBitmap(blob); + const result = { width: bitmap.width, height: bitmap.height, media: null, tile: null }; + // GIFs must keep animating; a canvas copy would freeze their first frame. + if (!/gif/i.test(blob.type)) { + if (tileSize) { + const side = Math.min(bitmap.width, bitmap.height), px = Math.min(tileSize, side); + let square; + try { + square = await createImageBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side, + { resizeWidth: px, resizeHeight: px, resizeQuality: 'high' }); + const canvas = new OffscreenCanvas(px, px); + canvas.getContext('2d').drawImage(square, 0, 0); + result.tile = await encode(canvas, 0.8); + } catch {} finally { square?.close(); } + } + if (width && bitmap.width > width * 1.25) { + try { + const height = Math.max(1, Math.round(bitmap.height * width / bitmap.width)); + const canvas = new OffscreenCanvas(width, height), ctx = canvas.getContext('2d'); + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(bitmap, 0, 0, width, height); + result.media = await encode(canvas, 0.82); + } catch {} + } + } + self.postMessage(result); + } catch { self.postMessage(null); } finally { bitmap?.close(); } + }; +} + +// One image at a time bounds concurrent decoding/encoding and its CPU/memory +// pressure. If workers are unavailable, callers keep displaying originals. +const JOB_MS = 15000, MAX_QUEUE = 24, MAX_STRIKES = 3; +let worker = null, disabled = false, current = null, timer = null, strikes = 0; +const queue = []; +function finish(job, data) { clearTimeout(timer); timer = null; job?.resolve(data); } +// Workers can't be made here (constructor refused, script won't load): +// thumbnails are off for this session. +function failWorker() { + disabled = true; + worker?.terminate(); worker = null; + const job = current; current = null; + finish(job, null); + for (const j of queue.splice(0)) j.resolve(null); +} +// A picture that hung or crashed the worker is given up and the worker +// starts fresh for the next one — one bad file mustn't cost every later +// thumbnail. Three failures in a row and it's treated as broken. +function resetWorker() { + if (++strikes >= MAX_STRIKES) return failWorker(); + worker?.terminate(); worker = null; + const job = current; current = null; + finish(job, null); + drain(); +} +function drain() { + if (current || !queue.length || disabled) return; + if (!worker) { + let url; + try { + url = URL.createObjectURL(new Blob(['(' + thumbnailWorker.toString() + ')()'], { type: 'text/javascript' })); + worker = new Worker(url, { name: 'image-thumbnails' }); + worker.onerror = resetWorker; + worker.onmessageerror = resetWorker; + worker.onmessage = ({ data }) => { + strikes = 0; + const job = current; current = null; + finish(job, data); + drain(); + }; + } catch { failWorker(); return; } finally { if (url) URL.revokeObjectURL(url); } + } + current = queue.shift(); + timer = setTimeout(resetWorker, JOB_MS); + try { worker.postMessage(current.input); } catch { resetWorker(); } +} +export function makeImageThumbnails(blob, { width = 0, tileSize = 0 } = {}) { + if (disabled || typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' + || typeof createImageBitmap === 'undefined' || queue.length >= MAX_QUEUE) return Promise.resolve(null); + return new Promise((resolve) => { queue.push({ input: { blob, width, tileSize }, resolve }); drain(); }); +} |
