File size: 4,338 Bytes
c9e39f3
76646ca
c9e39f3
 
 
 
 
 
 
 
 
 
 
 
 
 
76646ca
c9e39f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76646ca
c9e39f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
/** 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(); },
  };
}