The composer uploads each file as soon as it is attached (streamed from the browser file, under its own ten-minute
timeout), shows it uploading, processing (a 202 followed until ready), ready or failed with Retry, and cancels one
removed; publishing sends the ids, so a failed publish or a retried upload sends nothing twice. Size limits and types
come from /api/v2/instance, a file without a browser type gets one from its extension, and each refusal names the file
and why. Previews are 320 px thumbnails, not whole files in memory. A picture without a description is asked about once
("Publish anyway"; a setting turns it off), descriptions are sent with PUT, and an edit keeps the post's media, language
and sensitive flag.
Profile pictures are checked against the server's types and can be removed. A card shows "Media unavailable" with the
original's link when a picture fails, the blurhash behind a sensitive placeholder, and plays a GIF only on tap under
reduced motion. A failed export says so instead of downloading part of a file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
109 lines
4.1 KiB
JavaScript
109 lines
4.1 KiB
JavaScript
// 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
|
||
}
|
||
|
||
// 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))
|
||
return
|
||
element.dataset.blurhashPainted = hash
|
||
try {
|
||
const pixels = decode(hash)
|
||
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)
|
||
element.style.backgroundImage = `url(${canvas.toDataURL()})`
|
||
element.style.backgroundSize = "cover"
|
||
if (isImage)
|
||
element.addEventListener("load", () => element.style.removeProperty("background-image"), { once: true })
|
||
} catch (e) {
|
||
window.logFromJs?.(e.message, "blurhash")
|
||
}
|
||
}
|
||
|
||
const scan = (root) => root.querySelectorAll("[data-blurhash]").forEach(paint)
|
||
new MutationObserver(records => {
|
||
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
|
||
}
|
||
for (const node of record.addedNodes)
|
||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||
if (node.matches("[data-blurhash]"))
|
||
paint(node)
|
||
scan(node)
|
||
}
|
||
}
|
||
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-blurhash", "src"] })
|
||
scan(document)
|
||
})()
|