function thumbHashToApproximateAspectRatio(hash) { let header = hash[3]; let hasAlpha = hash[2] & 0x80; let isLandscape = hash[4] & 0x80; let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7; let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7; return lx / ly; } function thumbHashToRGBA(hash) { let { PI, min, max, cos, round } = Math; // Read the constants let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16); let header16 = hash[3] | (hash[4] << 8); let l_dc = (header24 & 63) / 63; let p_dc = ((header24 >> 6) & 63) / 31.5 - 1; let q_dc = ((header24 >> 12) & 63) / 31.5 - 1; let l_scale = ((header24 >> 18) & 31) / 31; let hasAlpha = header24 >> 23; let p_scale = ((header16 >> 3) & 63) / 63; let q_scale = ((header16 >> 9) & 63) / 63; let isLandscape = header16 >> 15; let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7); let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7); let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1; let a_scale = (hash[5] >> 4) / 15; // Read the varying factors (boost saturation by 1.25x to compensate for quantization) let ac_start = hasAlpha ? 6 : 5; let ac_index = 0; let decodeChannel = (nx, ny, scale) => { let ac = []; for (let cy = 0; cy < ny; cy++) { for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) { ac.push( (((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale, ); } } return ac; }; const l_ac = decodeChannel(lx, ly, l_scale); const p_ac = decodeChannel(3, 3, p_scale * 1.25); const q_ac = decodeChannel(3, 3, q_scale * 1.25); const a_ac = hasAlpha && decodeChannel(5, 5, a_scale); // Decode using the DCT into RGB const ratio = thumbHashToApproximateAspectRatio(hash); const w = round(ratio > 1 ? 32 : 32 * ratio); const h = round(ratio > 1 ? 32 / ratio : 32); const rgba = new Uint8Array(w * h * 4), fx = [], fy = []; for (let y = 0, i = 0; y < h; y++) { for (let x = 0; x < w; x++, i += 4) { let l = l_dc, p = p_dc, q = q_dc, a = a_dc; // Precompute the coefficients for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) { fx[cx] = cos(PI / w * (x + 0.5) * cx); } for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) { fy[cy] = cos(PI / h * (y + 0.5) * cy); } // Decode L for (let cy = 0, j = 0; cy < ly; cy++) { for ( let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++ ) { l += l_ac[j] * fx[cx] * fy2; } } // Decode P and Q for (let cy = 0, j = 0; cy < 3; cy++) { for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) { const f = fx[cx] * fy2; p += p_ac[j] * f; q += q_ac[j] * f; } } // Decode A if (hasAlpha) { for (let cy = 0, j = 0; cy < 5; cy++) { for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) { a += a_ac[j] * fx[cx] * fy2; } } } // Convert to RGB const b = l - 2 / 3 * p; const r = (3 * l - b + q) / 2; const g = r - q; rgba[i] = max(0, 255 * min(1, r)); rgba[i + 1] = max(0, 255 * min(1, g)); rgba[i + 2] = max(0, 255 * min(1, b)); rgba[i + 3] = max(0, 255 * min(1, a)); } } return { w, h, rgba }; } function rgbaToDataURL(w, h, rgba) { const row = w * 4 + 1; const idat = 6 + h * (5 + row); const bytes = [ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, w >> 8, w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0, idat >>> 24, (idat >> 16) & 255, (idat >> 8) & 255, idat & 255, 73, 68, 65, 84, 120, 1, ]; const table = [ 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, 1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376, -2032938284, -1609899400, -1111625188, ]; let a = 1, b = 0; for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) { bytes.push( y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0, ); for (b = (b + a) % 65521; i < end; i++) { let u = rgba[i] & 255; bytes.push(u); a = (a + u) % 65521; b = (b + a) % 65521; } } bytes.push( b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ); for (let [start, end] of [[12, 29], [37, 41 + idat]]) { let c = ~0; for (let i = start; i < end; i++) { c ^= bytes[i]; c = (c >>> 4) ^ table[c & 15]; c = (c >>> 4) ^ table[c & 15]; } c = ~c; bytes[end++] = c >>> 24; bytes[end++] = (c >> 16) & 255; bytes[end++] = (c >> 8) & 255; bytes[end++] = c & 255; } return "data:image/png;base64," + btoa(String.fromCharCode(...bytes)); } function updateThumbnailImages() { document.querySelectorAll("[data-thumb]").forEach((entry) => { const hash = entry.getAttribute("data-thumb"); if (!hash) return; const decodedString = atob(hash); // Create Uint8Array from decoded string const buffer = new Uint8Array(decodedString.length); for (let i = 0; i < decodedString.length; i++) { buffer[i] = decodedString.charCodeAt(i); } const image = thumbHashToRGBA(buffer); const dataURL = rgbaToDataURL(image.w, image.h, image.rgba); entry.style.background = `url(${dataURL})`; entry.style.backgroundSize = "cover"; const child = entry.querySelector("img[data-thumb-img]"); setTimeout(() => { const isLoaded = child && child.complete && child.naturalHeight !== 0; if (child && !isLoaded) { child.style.opacity = 0; child.style.filter = "blur(5px)"; child.addEventListener("load", () => { child.style.transition = "opacity 0.3s ease, filter 0.6s ease"; child.style.opacity = 1; child.style.filter = "blur(0px)"; setTimeout(() => { entry.style.background = ""; }, 400); }); } }, 50); }); } globalThis.addEventListener("load", updateThumbnailImages); globalThis.addEventListener("loading-finished", updateThumbnailImages);