// 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(); }); }