2026-10-05 17:55:39 +02:00
|
|
|
|
// Blurhash placeholders (https://blurha.sh): an image carrying data-blurhash shows the colours its hash encodes,
|
|
|
|
|
|
// decoded into a small canvas, until it has loaded. The page is watched, so the cards rendered later get theirs too.
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
|
|
|
|
|
const size = 32
|
|
|
|
|
|
|
|
|
|
|
|
const base83 = (text) => {
|
|
|
|
|
|
let value = 0
|
|
|
|
|
|
for (const c of text) {
|
|
|
|
|
|
const digit = alphabet.indexOf(c)
|
|
|
|
|
|
if (digit < 0)
|
|
|
|
|
|
throw new Error("not a blurhash")
|
|
|
|
|
|
value = value * 83 + digit
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
const toLinear = (value) => {
|
|
|
|
|
|
const v = value / 255
|
|
|
|
|
|
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
|
|
|
|
|
|
}
|
|
|
|
|
|
const toSrgb = (value) => {
|
|
|
|
|
|
const v = Math.max(0, Math.min(1, value))
|
|
|
|
|
|
return Math.round(v <= 0.0031308 ? v * 12.92 * 255 : (1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255)
|
|
|
|
|
|
}
|
|
|
|
|
|
const signedSquare = (value) => Math.sign(value) * value * value
|
|
|
|
|
|
|
|
|
|
|
|
// the hash's pixels at size × size, or null when it is not a blurhash
|
|
|
|
|
|
function decode(hash) {
|
|
|
|
|
|
if (!hash || hash.length < 6)
|
|
|
|
|
|
return null
|
|
|
|
|
|
const dimensions = base83(hash[0])
|
|
|
|
|
|
const columns = (dimensions % 9) + 1, rows = Math.floor(dimensions / 9) + 1
|
|
|
|
|
|
if (hash.length !== 4 + 2 * columns * rows)
|
|
|
|
|
|
return null
|
|
|
|
|
|
const maximum = (base83(hash[1]) + 1) / 166
|
|
|
|
|
|
const average = base83(hash.slice(2, 6))
|
|
|
|
|
|
const colours = [[toLinear(average >> 16), toLinear((average >> 8) & 255), toLinear(average & 255)]]
|
|
|
|
|
|
for (let i = 1; i < columns * rows; i++) {
|
|
|
|
|
|
const value = base83(hash.slice(4 + i * 2, 6 + i * 2))
|
|
|
|
|
|
const channel = (quantised) => signedSquare((quantised - 9) / 9) * maximum
|
|
|
|
|
|
colours.push([channel(Math.floor(value / 361)), channel(Math.floor(value / 19) % 19), channel(value % 19)])
|
|
|
|
|
|
}
|
|
|
|
|
|
const pixels = new Uint8ClampedArray(size * size * 4)
|
|
|
|
|
|
for (let y = 0; y < size; y++)
|
|
|
|
|
|
for (let x = 0; x < size; x++) {
|
|
|
|
|
|
let r = 0, g = 0, b = 0
|
|
|
|
|
|
for (let j = 0; j < rows; j++)
|
|
|
|
|
|
for (let i = 0; i < columns; i++) {
|
|
|
|
|
|
const basis = Math.cos(Math.PI * x * i / size) * Math.cos(Math.PI * y * j / size)
|
|
|
|
|
|
const colour = colours[i + j * columns]
|
|
|
|
|
|
r += colour[0] * basis
|
|
|
|
|
|
g += colour[1] * basis
|
|
|
|
|
|
b += colour[2] * basis
|
|
|
|
|
|
}
|
|
|
|
|
|
const p = 4 * (x + y * size)
|
|
|
|
|
|
pixels[p] = toSrgb(r)
|
|
|
|
|
|
pixels[p + 1] = toSrgb(g)
|
|
|
|
|
|
pixels[p + 2] = toSrgb(b)
|
|
|
|
|
|
pixels[p + 3] = 255
|
|
|
|
|
|
}
|
|
|
|
|
|
return pixels
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:56:59 +02:00
|
|
|
|
// paints an element's blurhash as its background: an image's until it has loaded, anything else's (the placeholder of
|
|
|
|
|
|
// sensitive media) for good. A changed hash, or an image given another source, is painted again.
|
|
|
|
|
|
function paint(element) {
|
|
|
|
|
|
const hash = element.dataset.blurhash
|
|
|
|
|
|
const isImage = element.tagName === "IMG"
|
|
|
|
|
|
if (!hash || element.dataset.blurhashPainted === hash || (isImage && element.complete && element.naturalWidth))
|
2026-10-05 17:55:39 +02:00
|
|
|
|
return
|
2026-10-07 11:56:59 +02:00
|
|
|
|
element.dataset.blurhashPainted = hash
|
2026-10-05 17:55:39 +02:00
|
|
|
|
try {
|
2026-10-07 11:56:59 +02:00
|
|
|
|
const pixels = decode(hash)
|
2026-10-05 17:55:39 +02:00
|
|
|
|
if (!pixels)
|
|
|
|
|
|
return
|
|
|
|
|
|
const canvas = document.createElement("canvas")
|
|
|
|
|
|
canvas.width = size
|
|
|
|
|
|
canvas.height = size
|
|
|
|
|
|
canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0)
|
2026-10-07 11:56:59 +02:00
|
|
|
|
element.style.backgroundImage = `url(${canvas.toDataURL()})`
|
|
|
|
|
|
element.style.backgroundSize = "cover"
|
|
|
|
|
|
if (isImage)
|
|
|
|
|
|
element.addEventListener("load", () => element.style.removeProperty("background-image"), { once: true })
|
2026-10-05 17:55:39 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
window.logFromJs?.(e.message, "blurhash")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:56:59 +02:00
|
|
|
|
const scan = (root) => root.querySelectorAll("[data-blurhash]").forEach(paint)
|
2026-10-05 17:55:39 +02:00
|
|
|
|
new MutationObserver(records => {
|
2026-10-07 11:56:59 +02:00
|
|
|
|
for (const record of records) {
|
|
|
|
|
|
if (record.type === "attributes") {
|
|
|
|
|
|
// another picture in the same element (the viewer's previous and next): painted anew
|
|
|
|
|
|
if (record.attributeName === "src")
|
|
|
|
|
|
delete record.target.dataset.blurhashPainted
|
|
|
|
|
|
paint(record.target)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-10-05 17:55:39 +02:00
|
|
|
|
for (const node of record.addedNodes)
|
|
|
|
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
2026-10-07 11:56:59 +02:00
|
|
|
|
if (node.matches("[data-blurhash]"))
|
2026-10-05 17:55:39 +02:00
|
|
|
|
paint(node)
|
|
|
|
|
|
scan(node)
|
|
|
|
|
|
}
|
2026-10-07 11:56:59 +02:00
|
|
|
|
}
|
|
|
|
|
|
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-blurhash", "src"] })
|
2026-10-05 17:55:39 +02:00
|
|
|
|
scan(document)
|
|
|
|
|
|
})()
|