diff --git a/examples/amp-blurhash.html b/examples/amp-blurhash.html new file mode 100644 index 000000000000..e4ee94676698 --- /dev/null +++ b/examples/amp-blurhash.html @@ -0,0 +1,80 @@ + + + + + amp-blurhash Example + + + + + + + + + + + +

amp-blurhash demo

+ + + + + + diff --git a/extensions/amp-blurhash/0.1/amp-blurhash.css b/extensions/amp-blurhash/0.1/amp-blurhash.css new file mode 100644 index 000000000000..061e4cab79f6 --- /dev/null +++ b/extensions/amp-blurhash/0.1/amp-blurhash.css @@ -0,0 +1,5 @@ +.i-amphtml-blurhash-canvas { + object-fit: cover; + width: 100%; + height: 100%; +} diff --git a/extensions/amp-blurhash/0.1/amp-blurhash.js b/extensions/amp-blurhash/0.1/amp-blurhash.js new file mode 100644 index 000000000000..803040580dba --- /dev/null +++ b/extensions/amp-blurhash/0.1/amp-blurhash.js @@ -0,0 +1,164 @@ +/** + * Copyright 2025 The AMP Project Authors. + * Licensed under the Apache License, Version 2.0. + * + * – lightweight placeholder renderer. + * + * Attributes: + * hash – required • BlurHash string to decode + * width – required • original media pixel width + * height – required • original media pixel height + * punch – optional • contrast boost (default 1.0) + * + * Example: + * + */ + +import {decode} from './blurhash-decode'; // 25‑line helper (embedded below) +import {AmpElement} from '#core/dom/amp-element'; + +export class AmpBlurhash extends AmpElement { + /** @override */ + static ['layoutSizeDefined']() { + // We have intrinsic size; no crawl‑up + return true; + } + + /** @override */ + buildCallback() { + // Parse attributes. + const hash = this.element.getAttribute('hash'); + const w = parseInt(this.element.getAttribute('width'), 10); + const h = parseInt(this.element.getAttribute('height'), 10); + const punch = + parseFloat(this.element.getAttribute('punch')) || /*default*/ 1; + + // Basic validation – AMP validator also checks, but we fail early here + if (!hash || !w || !h) { + this.user().error('Missing required attributes on .'); + return; + } + + // Create canvas placeholder. + const canvas = this.win.document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + canvas.className = 'i-amphtml-blurhash-canvas'; + this.applyFillContent(canvas); + + // Decode BlurHash → RGBA pixel array, then paint onto canvas. + const pixels = decode(hash, w, h, punch); + const ctx = canvas.getContext('2d'); + const imgData = ctx.createImageData(w, h); + imgData.data.set(pixels); + ctx.putImageData(imgData, 0, 0); + + // Append to DOM. + this.element.appendChild(canvas); + } +} + +// Register the custom element. +AMP.extension('amp-blurhash', '0.1', AMP => { + AMP.registerElement('amp-blurhash', AmpBlurhash); +}); + +/* ---------- Minimal BlurHash decoder (≈25 LOC) ------------------------- */ +/* Adapted from https://github.com/woltapp/blurhash (MIT); stripped to decode-only */ +function sRGBToLinear(value) { + value /= 255; + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearTosRGB(value) { + const v = Math.max(0, Math.min(1, value)); + return v <= 0.0031308 + ? v * 12.92 * 255 + 0.5 + : (1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5; +} + +function decodeDC(value) { + const r = value >> 16; + const g = (value >> 8) & 255; + const b = value & 255; + return [sRGBToLinear(r), sRGBToLinear(g), sRGBToLinear(b)]; +} + +function decodeAC(value, max, punch) { + const quantR = Math.floor(value / (19 * 19)); + const quantG = Math.floor((value / 19) % 19); + const quantB = value % 19; + + const sign = x => (x & 1 ? -1 : 1); + const convert = v => + sign(v) * ((Math.abs(v) - 9) / 9) * punch * max; + + return [ + convert(quantR), + convert(quantG), + convert(quantB), + ]; +} + +export function decode(str, width, height, punch = 1) { + const bytes = atob(str.replace(/#/g, '+').replace(/_/g, '/')); + const blurhash = new Uint8Array(bytes.length); + for (let i = 0; i < blurhash.length; ++i) blurhash[i] = bytes.charCodeAt(i); + + const sizeFlag = blurhash[0]; + const numY = (sizeFlag >> 3) + 1; + const numX = (sizeFlag & 7) + 1; + + const quantMaxAc = blurhash[1]; + const maxAc = (quantMaxAc + 1) / 166; + + let idx = 2; + const colors = []; + for (let y = 0; y < numY; ++y) { + for (let x = 0; x < numX; ++x) { + if (x === 0 && y === 0) { + const val = + (blurhash[idx++] << 16) | + (blurhash[idx++] << 8) | + blurhash[idx++]; + colors.push(decodeDC(val)); + } else { + const val = + (blurhash[idx++] << 16) | + (blurhash[idx++] << 8) | + blurhash[idx++]; + colors.push(decodeAC(val, maxAc, punch)); + } + } + } + + const pixels = new Uint8ClampedArray(width * height * 4); + let px = 0; + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + let r = 0, + g = 0, + b = 0; + let idxCol = 0; + for (let j = 0; j < numY; ++j) { + for (let i = 0; i < numX; ++i) { + const basis = + Math.cos((Math.PI * x * i) / width) * + Math.cos((Math.PI * y * j) / height); + const [cr, cg, cb] = colors[idxCol++]; + r += cr * basis; + g += cg * basis; + b += cb * basis; + } + } + pixels[px++] = linearTosRGB(r); + pixels[px++] = linearTosRGB(g); + pixels[px++] = linearTosRGB(b); + pixels[px++] = 255; // alpha + } + } + return pixels; +} diff --git a/extensions/amp-blurhash/0.1/test/test-amp-blurhash.js b/extensions/amp-blurhash/0.1/test/test-amp-blurhash.js new file mode 100644 index 000000000000..6658622bfa97 --- /dev/null +++ b/extensions/amp-blurhash/0.1/test/test-amp-blurhash.js @@ -0,0 +1,17 @@ +describes.realWin('amp-blurhash', { + amp: true, + extensions: ['amp-blurhash'], +}, env => { + it('renders a canvas with decoded pixels', async () => { + const el = env.win.document.createElement('amp-blurhash'); + el.setAttribute('hash', 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'); + el.setAttribute('width', '16'); + el.setAttribute('height', '16'); + el.setAttribute('layout', 'fixed'); + env.win.document.body.appendChild(el); + await el.whenBuilt(); + const canvas = el.querySelector('canvas'); + expect(canvas).to.exist; + expect(canvas.width).to.equal(16); + }); +}); diff --git a/validator/validator-main.protoascii b/validator/validator-main.protoascii index 2cbe832f1281..847cab82b8fc 100644 --- a/validator/validator-main.protoascii +++ b/validator/validator-main.protoascii @@ -3884,6 +3884,23 @@ attr_lists: { "{{\\^" # Section delimiters are disallowed. } } +# +tag: "AMP-BLURHASH" +spec_name: "amp-blurhash" +spec_url: "https://amp.dev/documentation/components/amp-blurhash" +html_format: AMP +requires_extension: "amp-blurhash" +attr_list: { + attrs: { + name: "hash" + mandatory: true + value_regex: "[A-Za-z0-9+/]{6,}" + } + attrs: { name: "width" mandatory: true value_regex: "[0-9]+" } + attrs: { name: "height" mandatory: true value_regex: "[0-9]+" } + attrs: { name: "punch" value_regex: "[0-9]+(\\.[0-9]+)?" } +} + # Same as above except that the attribute is not mandatory. attr_lists: { name: "optional-src-amp4email"