/** Bounded download/decode queue, with at most two image insertions per frame. */ export function createImageLoader({ concurrency = 12, cacheSize = 128 } = {}) { const queue = [], completed = []; const active = new Set(), cache = new Map(); let disposed = false, frame = 0, scheduled = false; function remember(image) { if (!image?.naturalWidth || !image.src || disposed) return; const key = image.src; cache.delete(key); cache.set(key, image); if (cache.size > cacheSize) cache.delete(cache.keys().next().value); } function flush() { frame = 0; for (let i = 0; i < 4 && completed.length; i++) { const job = completed.shift(); if (job.cancelled) remember(job.image); else job.receive(job.image); } if (completed.length) frame = requestAnimationFrame(flush); } function deliver(job) { completed.push(job); if (!frame) frame = requestAnimationFrame(flush); } function pump() { if (disposed) return; queue.sort((a, b) => a.priority - b.priority); while (active.size < concurrency && queue.length) { const job = queue.shift(); if (job.cancelled) continue; active.add(job); const photo = job.image = new Image(); photo.decoding = 'async'; photo.fetchPriority = job.priority < 24 ? 'high' : 'low'; photo.src = job.url; // Decoding completes before this element is attached to the animated field. photo.decode().then(() => { if (disposed) return; if (job.cancelled) remember(photo); else deliver(job); }).catch(() => { /* Keep the BlurHash if an image is unavailable. */ }) .finally(() => { active.delete(job); pump(); }); } } return { request(url, receive, priority = 1000) { const job = { url, receive, priority, cancelled: false, image: null }; const cached = cache.get(url); if (cached) { cache.delete(url); job.image = cached; deliver(job); } else { queue.push(job); // Batch same-turn requests so sorting prioritizes the strongest matches. if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; pump(); }); } } return { cancel() { job.cancelled = true; const i = queue.indexOf(job); if (i >= 0) queue.splice(i, 1); }, prioritize(value) { job.priority = value; }, }; }, release: remember, dispose() { disposed = true; cancelAnimationFrame(frame); for (const job of active) { job.cancelled = true; job.image.removeAttribute('src'); } queue.length = completed.length = 0; cache.clear(); }, }; } const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'; export function averageColor(hash) { if (!hash || hash.length < 6) return '#252823'; let color = 0; for (const c of hash.slice(2, 6)) { const n = alphabet.indexOf(c); if (n < 0) return '#252823'; color = color * 83 + n; } return `#${(color & 0xffffff).toString(16).padStart(6, '0')}`; } export function thumbnailWidth(width, scale = 1, dpr = 1) { return Math.min(512, Math.max(256, Math.ceil(width * Math.max(scale, 1.25) * Math.min(dpr, 2) / 128) * 128)); } export function createPlaceholderLoader() { const cache = new Map(), pending = new Map(); let worker; try { worker = new Worker(new URL('./placeholder.worker.js', import.meta.url), { type: 'module' }); } catch { /* Average color remains usable. */ } if (worker) { worker.onmessage = ({ data: { hash, url } }) => { cache.delete(hash); cache.set(hash, url); if (cache.size > 512) cache.delete(cache.keys().next().value); for (const receive of pending.get(hash) || []) receive(url); pending.delete(hash); }; worker.onerror = () => { worker.terminate(); worker = null; pending.clear(); }; } return { request(hash, receive) { if (!hash || !worker) return () => {}; if (cache.has(hash)) { receive(cache.get(hash)); return () => {}; } let listeners = pending.get(hash); if (!listeners) { listeners = new Set(); pending.set(hash, listeners); worker.postMessage(hash); } listeners.add(receive); return () => listeners.delete(receive); }, dispose() { worker?.terminate(); pending.clear(); cache.clear(); }, }; }