aboutsummaryrefslogtreecommitdiffstats
path: root/src/image-thumbnails.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 01:25:04 -0700
committerAdam Soltys <[email protected]>2026-10-09 01:25:04 -0700
commitff1a8a72c398c77fbac005f1fde25ad8f43503ef (patch)
tree43b8f1d87239193667d59ac6071c488065f44cb2 /src/image-thumbnails.js
parent817bcc3ba7b2aaad388bc5b2e5cda5ccf7b27766 (diff)
downloadcoinosv3-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/image-thumbnails.js')
-rw-r--r--src/image-thumbnails.js93
1 files changed, 93 insertions, 0 deletions
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(); });
+}