@limulus/blur-hash 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/esm/define.js +9 -0
- package/dist/esm/define.js.map +1 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/lib/blur-hash-error.js +8 -0
- package/dist/esm/lib/blur-hash-error.js.map +1 -0
- package/dist/esm/lib/blur-hash.js +207 -0
- package/dist/esm/lib/blur-hash.js.map +1 -0
- package/dist/esm/lib/decode.js +55 -0
- package/dist/esm/lib/decode.js.map +1 -0
- package/dist/esm/lib/worker.js +13 -0
- package/dist/esm/lib/worker.js.map +1 -0
- package/dist/types/define.d.ts +2 -0
- package/dist/types/define.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/lib/blur-hash-error.d.ts +8 -0
- package/dist/types/lib/blur-hash-error.d.ts.map +1 -0
- package/dist/types/lib/blur-hash.d.ts +39 -0
- package/dist/types/lib/blur-hash.d.ts.map +1 -0
- package/dist/types/lib/decode.d.ts +3 -0
- package/dist/types/lib/decode.d.ts.map +1 -0
- package/dist/types/lib/worker.d.ts +2 -0
- package/dist/types/lib/worker.d.ts.map +1 -0
- package/package.json +90 -0
- package/src/define.ts +10 -0
- package/src/index.ts +2 -0
- package/src/lib/blur-hash-error.ts +7 -0
- package/src/lib/blur-hash.ts +259 -0
- package/src/lib/decode.ts +71 -0
- package/src/lib/worker.ts +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric McCarthy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @limulus/blur-hash
|
|
2
|
+
|
|
3
|
+
Web component to render [BlurHash](https://blurha.sh/) placeholders as background images of
|
|
4
|
+
`<img>` elements.
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @limulus/blur-hash
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
Import and automatically define the `<blur-hash>` custom element:
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
import '@limulus/blur-hash/define'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This package makes use of a web worker for BlurHash decoding and creation of BMP blobs.
|
|
23
|
+
Check that your bundler has support for web workers or use a plugin that adds support.
|
|
24
|
+
|
|
25
|
+
Wrap your images in `<blur-hash>`, giving it the image’s BlurHash and the image’s full
|
|
26
|
+
dimensions:
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<blur-hash blurhash="LFG8o]yV?tnO}n%fS0xGx]oynPWA" width="3024" height="4032">
|
|
30
|
+
<img src="photo.jpeg" width="3024" height="4032" alt="…" />
|
|
31
|
+
</blur-hash>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If you are wondering why `<blur-hash>` does not simply use the dimensions from the `<img>`
|
|
35
|
+
element, it’s because it needs the aspect ratio of the image — not its rendered box
|
|
36
|
+
dimensions.
|
|
37
|
+
|
|
38
|
+
## Rationale
|
|
39
|
+
|
|
40
|
+
The approach this package takes to make use of `background-image` for the BlurHash
|
|
41
|
+
placeholder may seem less than ideal. However, this approach trades the potential to animate
|
|
42
|
+
a transition from placeholder to image for fewer potential bugs doing element or `src`
|
|
43
|
+
replacement.
|
|
44
|
+
|
|
45
|
+
Using a worker for BlurHash rendering is a trade-off as well. A `<blur-hash>` will never
|
|
46
|
+
block the main JS thread, but it does mean that images may load without a placeholder ever
|
|
47
|
+
being seen by the user. This introduces inconsistency, but it also ensures that the user
|
|
48
|
+
never waits for an image.
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
### `BlurHash.define(name = 'blur-hash')`
|
|
53
|
+
|
|
54
|
+
Defines the element under `name`, unless that name is already defined.
|
|
55
|
+
|
|
56
|
+
### `BlurHash.workerUrl`
|
|
57
|
+
|
|
58
|
+
Property specifying the location of the decode worker, as a string or `URL`. Leave it unset
|
|
59
|
+
if your bundler bundles the worker. It’s read when the first placeholder is decoded, so set
|
|
60
|
+
it before calling `BlurHash.define()`, and import the main `@limulus/blur-hash` rather than
|
|
61
|
+
`@limulus/blur-hash/define`.
|
|
62
|
+
|
|
63
|
+
Browsers only start workers from the page’s origin, so if you load this package from a CDN,
|
|
64
|
+
start the worker from a `blob:` URL that imports it. The CDN needs to rewrite bare imports
|
|
65
|
+
to URLs, as `esm.sh` does, because the worker imports `fast-blurhash`:
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
import { BlurHash } from 'https://esm.sh/@limulus/blur-hash@1.0.0'
|
|
69
|
+
|
|
70
|
+
BlurHash.workerUrl = URL.createObjectURL(
|
|
71
|
+
new Blob(["import 'https://esm.sh/@limulus/blur-hash@1.0.0/worker'"], {
|
|
72
|
+
type: 'text/javascript',
|
|
73
|
+
})
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
BlurHash.define()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
If your page has a Content Security Policy, it needs to allow `blob:` in `worker-src`.
|
|
80
|
+
|
|
81
|
+
### `BlurHashError`
|
|
82
|
+
|
|
83
|
+
Thrown if an element’s `blurhash`, `width` or `height` attribute is missing or invalid. If a
|
|
84
|
+
BlurHash can’t be decoded, the error logged to the console includes this error’s message.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The main entry, with `<blur-hash>` already defined. If you need to set
|
|
3
|
+
* `BlurHash.workerUrl`, import the main entry instead, since elements on the page start
|
|
4
|
+
* decoding as soon as `<blur-hash>` is defined.
|
|
5
|
+
*/
|
|
6
|
+
import { BlurHash } from './index.js';
|
|
7
|
+
export * from './index.js';
|
|
8
|
+
BlurHash.define();
|
|
9
|
+
//# sourceMappingURL=define.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"define.js","sourceRoot":"","sources":["../../src/define.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAErC,cAAc,YAAY,CAAA;AAE1B,QAAQ,CAAC,MAAM,EAAE,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blur-hash-error.js","sourceRoot":"","sources":["../../../src/lib/blur-hash-error.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,OAAO,aAAc,SAAQ,KAAK;IACtC,IAAI,GAAG,eAAe,CAAA;CACvB"}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { BlurHashError } from './blur-hash-error.js';
|
|
2
|
+
// Placeholders are never revoked or evicted, since any later element may need the same one;
|
|
3
|
+
// each is 3 to 12 KB.
|
|
4
|
+
const cache = new Map();
|
|
5
|
+
// Resolves the decode with each id when the worker replies.
|
|
6
|
+
const pending = new Map();
|
|
7
|
+
// `null` if the worker couldn't be constructed, so that it isn't tried again.
|
|
8
|
+
let worker;
|
|
9
|
+
// The background-size that draws the placeholder where each object-fit draws the image.
|
|
10
|
+
// `none` draws the image at its natural size, which isn't known until it loads; if the
|
|
11
|
+
// photo is bigger than its box, cover is the closest match.
|
|
12
|
+
const backgroundSizes = {
|
|
13
|
+
fill: '100% 100%',
|
|
14
|
+
contain: 'contain',
|
|
15
|
+
'scale-down': 'contain',
|
|
16
|
+
cover: 'cover',
|
|
17
|
+
none: 'cover',
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Paints a BlurHash placeholder as the background image of the `<img>` it wraps, until
|
|
21
|
+
* that image loads:
|
|
22
|
+
*
|
|
23
|
+
* ```html
|
|
24
|
+
* <blur-hash blurhash="LFG8o]yV?tnO}n%fS0xGx]oynPWA" width="3024" height="4032">
|
|
25
|
+
* <img src="photo.jpeg" width="3024" height="4032" alt="…" />
|
|
26
|
+
* </blur-hash>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export class BlurHash extends HTMLElement {
|
|
30
|
+
/**
|
|
31
|
+
* Where to load the decode worker from. Leave unset if your bundler handles
|
|
32
|
+
* `new Worker(new URL('./worker.js', import.meta.url))`; otherwise bundle
|
|
33
|
+
* `@limulus/blur-hash/worker` and set its URL here. It's read when the first placeholder
|
|
34
|
+
* is decoded.
|
|
35
|
+
*/
|
|
36
|
+
static workerUrl;
|
|
37
|
+
/** Defines the element as `name`, unless that name is already taken. */
|
|
38
|
+
static define(name = 'blur-hash') {
|
|
39
|
+
if (!customElements.get(name)) {
|
|
40
|
+
customElements.define(name, this);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
#observer;
|
|
44
|
+
connectedCallback() {
|
|
45
|
+
if (this.#tryPlacehold()) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// The HTML parser connects an already-defined element as soon as it reads the start
|
|
49
|
+
// tag, before any of its children exist, so wait for the <img> to be parsed.
|
|
50
|
+
this.#observer = new MutationObserver(() => {
|
|
51
|
+
this.#tryPlacehold();
|
|
52
|
+
});
|
|
53
|
+
this.#observer.observe(this, { childList: true, subtree: true });
|
|
54
|
+
}
|
|
55
|
+
disconnectedCallback() {
|
|
56
|
+
this.#stopObserving();
|
|
57
|
+
}
|
|
58
|
+
#stopObserving() {
|
|
59
|
+
this.#observer?.disconnect();
|
|
60
|
+
this.#observer = undefined;
|
|
61
|
+
}
|
|
62
|
+
/** Placeholds the `<img>` if it has been parsed, and returns whether it had. */
|
|
63
|
+
#tryPlacehold() {
|
|
64
|
+
const imgEl = this.querySelector('img');
|
|
65
|
+
if (!imgEl) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// Stop first, so that invalid attributes throw once, not on every later mutation.
|
|
69
|
+
this.#stopObserving();
|
|
70
|
+
this.#placehold(imgEl);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
#placehold(imgEl) {
|
|
74
|
+
const blurhash = this.getAttribute('blurhash');
|
|
75
|
+
const width = parseInt(this.getAttribute('width') ?? '0', 10);
|
|
76
|
+
const height = parseInt(this.getAttribute('height') ?? '0', 10);
|
|
77
|
+
if (!blurhash) {
|
|
78
|
+
throw new BlurHashError('No blurhash attribute found');
|
|
79
|
+
}
|
|
80
|
+
// Negated so that NaN, from a missing or non-numeric attribute, fails too.
|
|
81
|
+
if (!(width > 0 && height > 0)) {
|
|
82
|
+
throw new BlurHashError('The width and height attributes must be positive numbers');
|
|
83
|
+
}
|
|
84
|
+
// Don't apply blurhash if the image is already loaded
|
|
85
|
+
if (isLoaded(imgEl)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Named functions, not closures: adding the same listener again is a no-op, so if the
|
|
89
|
+
// element is moved and connects again, its listeners don't stack up.
|
|
90
|
+
imgEl.addEventListener('load', clearPlaceholder);
|
|
91
|
+
imgEl.addEventListener('error', showAltText);
|
|
92
|
+
const { workerUrl } = this.constructor;
|
|
93
|
+
void placeholderUrl(blurhash, width, height, workerUrl).then((blobUrl) => {
|
|
94
|
+
// If the image loaded while the worker was decoding, the load listener above had
|
|
95
|
+
// nothing to clear yet, and painting now would leave the placeholder behind it.
|
|
96
|
+
if (blobUrl === undefined || isLoaded(imgEl)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const { objectFit, objectPosition } = getComputedStyle(imgEl);
|
|
100
|
+
Object.assign(imgEl.style, {
|
|
101
|
+
backgroundImage: `url(${blobUrl})`,
|
|
102
|
+
backgroundSize: backgroundSizes[objectFit],
|
|
103
|
+
backgroundPosition: objectPosition,
|
|
104
|
+
backgroundRepeat: 'no-repeat',
|
|
105
|
+
});
|
|
106
|
+
// Hide alt text that Firefox displays while loading images because we can't know if
|
|
107
|
+
// the text will be legible atop the blurhash background. If the image has already
|
|
108
|
+
// failed, its error event fired before there was anything to restore.
|
|
109
|
+
if (!isBroken(imgEl)) {
|
|
110
|
+
imgEl.style.color = 'transparent';
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Safari seems to sometimes calculate object-fit images differently than the background
|
|
116
|
+
// image, which can lead to the background image peeking through on the edges. We don't need
|
|
117
|
+
// to keep these blur hash images around if they are not being displayed, so we may as well
|
|
118
|
+
// remove them.
|
|
119
|
+
function clearPlaceholder() {
|
|
120
|
+
// Attempt to prevent flash caused by removing the background image before the image is
|
|
121
|
+
// rendered
|
|
122
|
+
requestAnimationFrame(() => {
|
|
123
|
+
Object.assign(this.style, {
|
|
124
|
+
backgroundImage: '',
|
|
125
|
+
backgroundSize: '',
|
|
126
|
+
backgroundPosition: '',
|
|
127
|
+
backgroundRepeat: '',
|
|
128
|
+
color: '',
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// A broken image's alt text is all that describes it, so let the browser draw it.
|
|
133
|
+
function showAltText() {
|
|
134
|
+
this.style.color = '';
|
|
135
|
+
}
|
|
136
|
+
function isLoaded(imgEl) {
|
|
137
|
+
return imgEl.complete && imgEl.naturalWidth > 0;
|
|
138
|
+
}
|
|
139
|
+
// An <img> without a source is also complete with no width, but it has no currentSrc.
|
|
140
|
+
function isBroken(imgEl) {
|
|
141
|
+
return imgEl.complete && imgEl.naturalWidth === 0 && imgEl.currentSrc !== '';
|
|
142
|
+
}
|
|
143
|
+
/** Resolves with a blob URL for the placeholder, or `undefined` if it couldn't be decoded. */
|
|
144
|
+
function placeholderUrl(blurhash, width, height, workerUrl) {
|
|
145
|
+
// Make the short side 32 px, unless that would make the long side more than 128 px.
|
|
146
|
+
// Otherwise an extreme aspect ratio would have the worker decode an enormous image.
|
|
147
|
+
const scale = Math.min(32 / Math.min(width, height), 128 / Math.max(width, height));
|
|
148
|
+
const scaledWidth = Math.max(1, Math.round(width * scale));
|
|
149
|
+
const scaledHeight = Math.max(1, Math.round(height * scale));
|
|
150
|
+
const id = `${blurhash}-${scaledWidth}-${scaledHeight}`;
|
|
151
|
+
let blobUrl = cache.get(id);
|
|
152
|
+
if (!blobUrl) {
|
|
153
|
+
const message = {
|
|
154
|
+
id,
|
|
155
|
+
blurhash,
|
|
156
|
+
width: scaledWidth,
|
|
157
|
+
height: scaledHeight,
|
|
158
|
+
};
|
|
159
|
+
blobUrl = new Promise((resolve) => {
|
|
160
|
+
if (worker === undefined)
|
|
161
|
+
worker = createWorker(workerUrl);
|
|
162
|
+
if (worker) {
|
|
163
|
+
pending.set(id, resolve);
|
|
164
|
+
worker.postMessage(message);
|
|
165
|
+
}
|
|
166
|
+
}).then((result) => {
|
|
167
|
+
if ('error' in result) {
|
|
168
|
+
console.error(`BlurHash: Could not decode "${blurhash}": ${result.error}`);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
return URL.createObjectURL(result.image);
|
|
172
|
+
});
|
|
173
|
+
cache.set(id, blobUrl);
|
|
174
|
+
}
|
|
175
|
+
return blobUrl;
|
|
176
|
+
}
|
|
177
|
+
/** Starts the decode worker, or returns `null` if it can't be constructed. */
|
|
178
|
+
function createWorker(workerUrl) {
|
|
179
|
+
let decoder;
|
|
180
|
+
try {
|
|
181
|
+
// Spelled out in full so that bundlers which recognize this pattern bundle the worker.
|
|
182
|
+
decoder =
|
|
183
|
+
workerUrl === undefined
|
|
184
|
+
? new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
|
|
185
|
+
: new Worker(workerUrl, { type: 'module' });
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
// Chromium throws here for a cross-origin worker, such as one loaded from a CDN, where
|
|
189
|
+
// other browsers fire the error event below instead.
|
|
190
|
+
reportWorkerFailure(error);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
decoder.addEventListener('message', ({ data }) => {
|
|
194
|
+
pending.get(data.id)(data);
|
|
195
|
+
pending.delete(data.id);
|
|
196
|
+
});
|
|
197
|
+
// If the worker fails to load, nothing else reports it in the console. That's what
|
|
198
|
+
// happens with bundlers that leave the URL above alone, like esbuild and plain Rollup.
|
|
199
|
+
decoder.addEventListener('error', reportWorkerFailure);
|
|
200
|
+
return decoder;
|
|
201
|
+
}
|
|
202
|
+
function reportWorkerFailure(cause) {
|
|
203
|
+
console.error("BlurHash: The decode worker couldn't start, so no placeholders will be shown. If your " +
|
|
204
|
+
"bundler doesn't bundle it, or you load this package from a CDN, see " +
|
|
205
|
+
'BlurHash.workerUrl in the README.', cause);
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=blur-hash.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blur-hash.js","sourceRoot":"","sources":["../../../src/lib/blur-hash.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAcpD,4FAA4F;AAC5F,sBAAsB;AACtB,MAAM,KAAK,GAAG,IAAI,GAAG,EAAuC,CAAA;AAC5D,4DAA4D;AAC5D,MAAM,OAAO,GAAG,IAAI,GAAG,EAAyD,CAAA;AAChF,8EAA8E;AAC9E,IAAI,MAAiC,CAAA;AAErC,wFAAwF;AACxF,uFAAuF;AACvF,4DAA4D;AAC5D,MAAM,eAAe,GAA2B;IAC9C,IAAI,EAAE,WAAW;IACjB,OAAO,EAAE,SAAS;IAClB,YAAY,EAAE,SAAS;IACvB,KAAK,EAAE,OAAO;IACd,IAAI,EAAE,OAAO;CACd,CAAA;AAED;;;;;;;;;GASG;AACH,MAAM,OAAO,QAAS,SAAQ,WAAW;IACvC;;;;;OAKG;IACH,MAAM,CAAC,SAAS,CAA0B;IAE1C,wEAAwE;IACxE,MAAM,CAAC,MAAM,CAAC,IAAI,GAAG,WAAW;QAC9B,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YAC9B,cAAc,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QACnC,CAAC;IACH,CAAC;IAED,SAAS,CAA8B;IAEvC,iBAAiB;QACf,IAAI,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC;YACzB,OAAM;QACR,CAAC;QAED,oFAAoF;QACpF,6EAA6E;QAC7E,IAAI,CAAC,SAAS,GAAG,IAAI,gBAAgB,CAAC,GAAG,EAAE;YACzC,IAAI,CAAC,aAAa,EAAE,CAAA;QACtB,CAAC,CAAC,CAAA;QACF,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;IAClE,CAAC;IAED,oBAAoB;QAClB,IAAI,CAAC,cAAc,EAAE,CAAA;IACvB,CAAC;IAED,cAAc;QACZ,IAAI,CAAC,SAAS,EAAE,UAAU,EAAE,CAAA;QAC5B,IAAI,CAAC,SAAS,GAAG,SAAS,CAAA;IAC5B,CAAC;IAED,gFAAgF;IAChF,aAAa;QACX,MAAM,KAAK,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;QACvC,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAO,KAAK,CAAA;QACd,CAAC;QAED,kFAAkF;QAClF,IAAI,CAAC,cAAc,EAAE,CAAA;QACrB,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAA;QACtB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,UAAU,CAAC,KAAuB;QAChC,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC,CAAA;QAC9C,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,IAAI,GAAG,EAAE,EAAE,CAAC,CAAA;QAC7D,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,IAAI,GAAG,EAAE,EAAE,CAAC,CAAA;QAE/D,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,IAAI,aAAa,CAAC,6BAA6B,CAAC,CAAA;QACxD,CAAC;QAED,2EAA2E;QAC3E,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,IAAI,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC;YAC/B,MAAM,IAAI,aAAa,CAAC,0DAA0D,CAAC,CAAA;QACrF,CAAC;QAED,sDAAsD;QACtD,IAAI,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;YACpB,OAAM;QACR,CAAC;QAED,sFAAsF;QACtF,qEAAqE;QACrE,KAAK,CAAC,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;QAChD,KAAK,CAAC,gBAAgB,CAAC,OAAO,EAAE,WAAW,CAAC,CAAA;QAE5C,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,CAAC,WAA8B,CAAA;QACzD,KAAK,cAAc,CAAC,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE;YACvE,iFAAiF;YACjF,gFAAgF;YAChF,IAAI,OAAO,KAAK,SAAS,IAAI,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;gBAC7C,OAAM;YACR,CAAC;YAED,MAAM,EAAE,SAAS,EAAE,cAAc,EAAE,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAA;YAC7D,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE;gBACzB,eAAe,EAAE,OAAO,OAAO,GAAG;gBAClC,cAAc,EAAE,eAAe,CAAC,SAAS,CAAC;gBAC1C,kBAAkB,EAAE,cAAc;gBAClC,gBAAgB,EAAE,WAAW;aAC9B,CAAC,CAAA;YAEF,oFAAoF;YACpF,kFAAkF;YAClF,sEAAsE;YACtE,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;gBACrB,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,aAAa,CAAA;YACnC,CAAC;QACH,CAAC,CAAC,CAAA;IACJ,CAAC;CACF;AAED,wFAAwF;AACxF,4FAA4F;AAC5F,2FAA2F;AAC3F,eAAe;AACf,SAAS,gBAAgB;IACvB,uFAAuF;IACvF,WAAW;IACX,qBAAqB,CAAC,GAAG,EAAE;QACzB,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE;YACxB,eAAe,EAAE,EAAE;YACnB,cAAc,EAAE,EAAE;YAClB,kBAAkB,EAAE,EAAE;YACtB,gBAAgB,EAAE,EAAE;YACpB,KAAK,EAAE,EAAE;SACV,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,kFAAkF;AAClF,SAAS,WAAW;IAClB,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,EAAE,CAAA;AACvB,CAAC;AAED,SAAS,QAAQ,CAAC,KAAuB;IACvC,OAAO,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,YAAY,GAAG,CAAC,CAAA;AACjD,CAAC;AAED,sFAAsF;AACtF,SAAS,QAAQ,CAAC,KAAuB;IACvC,OAAO,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,YAAY,KAAK,CAAC,IAAI,KAAK,CAAC,UAAU,KAAK,EAAE,CAAA;AAC9E,CAAC;AAED,8FAA8F;AAC9F,SAAS,cAAc,CACrB,QAAgB,EAChB,KAAa,EACb,MAAc,EACd,SAAmC;IAEnC,oFAAoF;IACpF,oFAAoF;IACpF,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;IACnF,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC,CAAA;IAC1D,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC,CAAA;IAE5D,MAAM,EAAE,GAAG,GAAG,QAAQ,IAAI,WAAW,IAAI,YAAY,EAAE,CAAA;IACvD,IAAI,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IAE3B,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,OAAO,GAA0B;YACrC,EAAE;YACF,QAAQ;YACR,KAAK,EAAE,WAAW;YAClB,MAAM,EAAE,YAAY;SACrB,CAAA;QACD,OAAO,GAAG,IAAI,OAAO,CAA8B,CAAC,OAAO,EAAE,EAAE;YAC7D,IAAI,MAAM,KAAK,SAAS;gBAAE,MAAM,GAAG,YAAY,CAAC,SAAS,CAAC,CAAA;YAC1D,IAAI,MAAM,EAAE,CAAC;gBACX,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;gBACxB,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,CAAA;YAC7B,CAAC;QACH,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE;YACjB,IAAI,OAAO,IAAI,MAAM,EAAE,CAAC;gBACtB,OAAO,CAAC,KAAK,CAAC,+BAA+B,QAAQ,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,CAAA;gBAC1E,OAAO,SAAS,CAAA;YAClB,CAAC;YACD,OAAO,GAAG,CAAC,eAAe,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QAC1C,CAAC,CAAC,CAAA;QACF,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;IACxB,CAAC;IAED,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,8EAA8E;AAC9E,SAAS,YAAY,CAAC,SAAmC;IACvD,IAAI,OAAe,CAAA;IACnB,IAAI,CAAC;QACH,uFAAuF;QACvF,OAAO;YACL,SAAS,KAAK,SAAS;gBACrB,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,GAAG,CAAC,aAAa,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;gBACzE,CAAC,CAAC,IAAI,MAAM,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAA;IACjD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,uFAAuF;QACvF,qDAAqD;QACrD,mBAAmB,CAAC,KAAK,CAAC,CAAA;QAC1B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,CAAC,gBAAgB,CACtB,SAAS,EACT,CAAC,EAAE,IAAI,EAA6C,EAAE,EAAE;QACtD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAE,CAAC,IAAI,CAAC,CAAA;QAC3B,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IACzB,CAAC,CACF,CAAA;IAED,mFAAmF;IACnF,uFAAuF;IACvF,OAAO,CAAC,gBAAgB,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAA;IAEtD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,SAAS,mBAAmB,CAAC,KAAc;IACzC,OAAO,CAAC,KAAK,CACX,wFAAwF;QACtF,sEAAsE;QACtE,mCAAmC,EACrC,KAAK,CACN,CAAA;AACH,CAAC"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { decodeBlurHash } from 'fast-blurhash';
|
|
2
|
+
import { BlurHashError } from './blur-hash-error.js';
|
|
3
|
+
const BASE83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~';
|
|
4
|
+
/** Decodes `blurhash` into a `width`×`height` BMP. Throws if `blurhash` isn't valid. */
|
|
5
|
+
export function decodeToBmp(blurhash, width, height) {
|
|
6
|
+
validate(blurhash);
|
|
7
|
+
return new Blob([bmp(width, height, decodeBlurHash(blurhash, width, height))], {
|
|
8
|
+
type: 'image/bmp',
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
// fast-blurhash decodes anything without complaint, turning a malformed hash into a black or
|
|
12
|
+
// garbled image, so reject what Wolt's reference decoder would have.
|
|
13
|
+
function validate(blurhash) {
|
|
14
|
+
if (blurhash.length < 6) {
|
|
15
|
+
throw new BlurHashError('The blurhash string must be at least 6 characters');
|
|
16
|
+
}
|
|
17
|
+
for (const char of blurhash) {
|
|
18
|
+
if (!BASE83.includes(char)) {
|
|
19
|
+
throw new BlurHashError(`The blurhash string contains an invalid character: ${char}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const sizeFlag = BASE83.indexOf(blurhash[0]);
|
|
23
|
+
const expected = 4 + 2 * ((sizeFlag % 9) + 1) * (Math.floor(sizeFlag / 9) + 1);
|
|
24
|
+
if (blurhash.length !== expected) {
|
|
25
|
+
throw new BlurHashError(`blurhash length mismatch: length is ${blurhash.length} but it should be ${expected}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// An uncompressed 24-bit BMP with its rows top to bottom. BlurHash images are opaque, so the
|
|
29
|
+
// alpha channel is dropped. Writing this directly is several times faster than drawing to an
|
|
30
|
+
// OffscreenCanvas and encoding a PNG, and needs no canvas at all.
|
|
31
|
+
function bmp(width, height, rgba) {
|
|
32
|
+
const headerSize = 14 + 40; // BITMAPFILEHEADER + BITMAPINFOHEADER
|
|
33
|
+
const rowSize = (width * 3 + 3) & ~3; // rows are padded to 4 bytes
|
|
34
|
+
const out = new Uint8Array(headerSize + rowSize * height);
|
|
35
|
+
const view = new DataView(out.buffer);
|
|
36
|
+
out[0] = 0x42; // 'B'
|
|
37
|
+
out[1] = 0x4d; // 'M'
|
|
38
|
+
view.setUint32(2, out.length, true);
|
|
39
|
+
view.setUint32(10, headerSize, true); // where the pixels start
|
|
40
|
+
view.setUint32(14, 40, true); // BITMAPINFOHEADER size
|
|
41
|
+
view.setInt32(18, width, true);
|
|
42
|
+
view.setInt32(22, -height, true); // negative: rows run top to bottom
|
|
43
|
+
view.setUint16(26, 1, true); // color planes
|
|
44
|
+
view.setUint16(28, 24, true); // bits per pixel; compression and the rest stay 0
|
|
45
|
+
for (let y = 0; y < height; y++) {
|
|
46
|
+
let o = headerSize + y * rowSize;
|
|
47
|
+
for (let i = y * width * 4, end = i + width * 4; i < end; i += 4) {
|
|
48
|
+
out[o++] = rgba[i + 2];
|
|
49
|
+
out[o++] = rgba[i + 1];
|
|
50
|
+
out[o++] = rgba[i];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=decode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decode.js","sourceRoot":"","sources":["../../../src/lib/decode.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { decodeToBmp } from './decode.js';
|
|
2
|
+
self.onmessage = ({ data }) => {
|
|
3
|
+
let response;
|
|
4
|
+
try {
|
|
5
|
+
const image = decodeToBmp(data.blurhash, data.width, data.height);
|
|
6
|
+
response = { id: data.id, image };
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
response = { id: data.id, error: String(error) };
|
|
10
|
+
}
|
|
11
|
+
self.postMessage(response);
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker.js","sourceRoot":"","sources":["../../../src/lib/worker.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEzC,IAAI,CAAC,SAAS,GAAG,CAAC,EAAE,IAAI,EAAuC,EAAE,EAAE;IACjE,IAAI,QAAqC,CAAA;IACzC,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAA;QACjE,QAAQ,GAAG,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,CAAA;IACnC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,QAAQ,GAAG,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IAClD,CAAC;IACD,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAA;AAC5B,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"define.d.ts","sourceRoot":"","sources":["../../src/define.ts"],"names":[],"mappings":"AAOA,cAAc,YAAY,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blur-hash-error.d.ts","sourceRoot":"","sources":["../../../src/lib/blur-hash-error.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,IAAI,SAAkB;CACvB"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface BlurHashDecodeMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
blurhash: string;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
/** The decoded image, or why decoding failed. */
|
|
8
|
+
export type BlurHashDecodeResultMessage = {
|
|
9
|
+
id: string;
|
|
10
|
+
} & ({
|
|
11
|
+
image: Blob;
|
|
12
|
+
} | {
|
|
13
|
+
error: string;
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* Paints a BlurHash placeholder as the background image of the `<img>` it wraps, until
|
|
17
|
+
* that image loads:
|
|
18
|
+
*
|
|
19
|
+
* ```html
|
|
20
|
+
* <blur-hash blurhash="LFG8o]yV?tnO}n%fS0xGx]oynPWA" width="3024" height="4032">
|
|
21
|
+
* <img src="photo.jpeg" width="3024" height="4032" alt="…" />
|
|
22
|
+
* </blur-hash>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare class BlurHash extends HTMLElement {
|
|
26
|
+
#private;
|
|
27
|
+
/**
|
|
28
|
+
* Where to load the decode worker from. Leave unset if your bundler handles
|
|
29
|
+
* `new Worker(new URL('./worker.js', import.meta.url))`; otherwise bundle
|
|
30
|
+
* `@limulus/blur-hash/worker` and set its URL here. It's read when the first placeholder
|
|
31
|
+
* is decoded.
|
|
32
|
+
*/
|
|
33
|
+
static workerUrl: string | URL | undefined;
|
|
34
|
+
/** Defines the element as `name`, unless that name is already taken. */
|
|
35
|
+
static define(name?: string): void;
|
|
36
|
+
connectedCallback(): void;
|
|
37
|
+
disconnectedCallback(): void;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=blur-hash.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blur-hash.d.ts","sourceRoot":"","sources":["../../../src/lib/blur-hash.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iDAAiD;AACjD,MAAM,MAAM,2BAA2B,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,CACzD;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CACpC,CAAA;AAqBD;;;;;;;;;GASG;AACH,qBAAa,QAAS,SAAQ,WAAW;;IACvC;;;;;OAKG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,GAAG,GAAG,SAAS,CAAA;IAE1C,wEAAwE;IACxE,MAAM,CAAC,MAAM,CAAC,IAAI,SAAc,GAAG,IAAI,CAItC;IAID,iBAAiB,IAAI,IAAI,CAWxB;IAED,oBAAoB,IAAI,IAAI,CAE3B;CAoEF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decode.d.ts","sourceRoot":"","sources":["../../../src/lib/decode.ts"],"names":[],"mappings":"AAOA,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAKjF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../src/lib/worker.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@limulus/blur-hash",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Custom element that paints a BlurHash placeholder behind an image until it loads",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"blurhash",
|
|
7
|
+
"custom-element",
|
|
8
|
+
"web-component",
|
|
9
|
+
"placeholder",
|
|
10
|
+
"lazy-loading"
|
|
11
|
+
],
|
|
12
|
+
"module": "dist/esm/index.js",
|
|
13
|
+
"types": "dist/types/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/types/index.d.ts",
|
|
17
|
+
"import": "./dist/esm/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./define": {
|
|
20
|
+
"types": "./dist/types/define.d.ts",
|
|
21
|
+
"import": "./dist/esm/define.js"
|
|
22
|
+
},
|
|
23
|
+
"./worker": {
|
|
24
|
+
"types": "./dist/types/lib/worker.d.ts",
|
|
25
|
+
"import": "./dist/esm/lib/worker.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"!src/**/*.spec.ts",
|
|
32
|
+
"!src/mocks"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"devEngines": {
|
|
36
|
+
"runtime": {
|
|
37
|
+
"name": "node",
|
|
38
|
+
"version": ">=24"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"prebuild": "npm run clean",
|
|
43
|
+
"build": "node node_modules/typescript-7/bin/tsc --build tsconfig.build.json",
|
|
44
|
+
"clean": "del-cli *.tsbuildinfo coverage dist",
|
|
45
|
+
"lint": "eslint .",
|
|
46
|
+
"lint:fix": "npm run lint -- --fix",
|
|
47
|
+
"prepack": "npm run build",
|
|
48
|
+
"prepare": "is-ci || husky",
|
|
49
|
+
"test": "vitest run --coverage --project chromium && vitest run --project webkit --project firefox",
|
|
50
|
+
"tscc": "node node_modules/typescript-7/bin/tsc --build",
|
|
51
|
+
"verify": "concurrently -c cyan,magenta,blue npm:lint npm:test npm:tscc"
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/limulus/blur-hash.git"
|
|
56
|
+
},
|
|
57
|
+
"author": {
|
|
58
|
+
"name": "Eric McCarthy",
|
|
59
|
+
"email": "eric@limulus.net",
|
|
60
|
+
"url": "https://limulus.net/"
|
|
61
|
+
},
|
|
62
|
+
"license": "MIT",
|
|
63
|
+
"bugs": {
|
|
64
|
+
"url": "https://github.com/limulus/blur-hash/issues"
|
|
65
|
+
},
|
|
66
|
+
"homepage": "https://github.com/limulus/blur-hash#readme",
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@commitlint/cli": "^21.2.2",
|
|
72
|
+
"@commitlint/config-conventional": "^21.2.2",
|
|
73
|
+
"@limulus/eslint-config": "^6.2.0",
|
|
74
|
+
"@types/node": "^24.12.0",
|
|
75
|
+
"@vitest/browser-playwright": "^4.1.12",
|
|
76
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
77
|
+
"concurrently": "^10.0.5",
|
|
78
|
+
"del-cli": "^7.0.0",
|
|
79
|
+
"eslint": "^10.8.1",
|
|
80
|
+
"husky": "^9.1.7",
|
|
81
|
+
"is-ci": "^4.1.0",
|
|
82
|
+
"playwright": "^1.64.1",
|
|
83
|
+
"typescript": "^6.0.3",
|
|
84
|
+
"typescript-7": "npm:typescript@7.0.2",
|
|
85
|
+
"vitest": "^4.1.10"
|
|
86
|
+
},
|
|
87
|
+
"dependencies": {
|
|
88
|
+
"fast-blurhash": "^1.2.0"
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/define.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The main entry, with `<blur-hash>` already defined. If you need to set
|
|
3
|
+
* `BlurHash.workerUrl`, import the main entry instead, since elements on the page start
|
|
4
|
+
* decoding as soon as `<blur-hash>` is defined.
|
|
5
|
+
*/
|
|
6
|
+
import { BlurHash } from './index.ts'
|
|
7
|
+
|
|
8
|
+
export * from './index.ts'
|
|
9
|
+
|
|
10
|
+
BlurHash.define()
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { BlurHashError } from './blur-hash-error.ts'
|
|
2
|
+
|
|
3
|
+
export interface BlurHashDecodeMessage {
|
|
4
|
+
id: string
|
|
5
|
+
blurhash: string
|
|
6
|
+
width: number
|
|
7
|
+
height: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The decoded image, or why decoding failed. */
|
|
11
|
+
export type BlurHashDecodeResultMessage = { id: string } & (
|
|
12
|
+
{ image: Blob } | { error: string }
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// Placeholders are never revoked or evicted, since any later element may need the same one;
|
|
16
|
+
// each is 3 to 12 KB.
|
|
17
|
+
const cache = new Map<string, Promise<string | undefined>>()
|
|
18
|
+
// Resolves the decode with each id when the worker replies.
|
|
19
|
+
const pending = new Map<string, (result: BlurHashDecodeResultMessage) => void>()
|
|
20
|
+
// `null` if the worker couldn't be constructed, so that it isn't tried again.
|
|
21
|
+
let worker: Worker | null | undefined
|
|
22
|
+
|
|
23
|
+
// The background-size that draws the placeholder where each object-fit draws the image.
|
|
24
|
+
// `none` draws the image at its natural size, which isn't known until it loads; if the
|
|
25
|
+
// photo is bigger than its box, cover is the closest match.
|
|
26
|
+
const backgroundSizes: Record<string, string> = {
|
|
27
|
+
fill: '100% 100%',
|
|
28
|
+
contain: 'contain',
|
|
29
|
+
'scale-down': 'contain',
|
|
30
|
+
cover: 'cover',
|
|
31
|
+
none: 'cover',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Paints a BlurHash placeholder as the background image of the `<img>` it wraps, until
|
|
36
|
+
* that image loads:
|
|
37
|
+
*
|
|
38
|
+
* ```html
|
|
39
|
+
* <blur-hash blurhash="LFG8o]yV?tnO}n%fS0xGx]oynPWA" width="3024" height="4032">
|
|
40
|
+
* <img src="photo.jpeg" width="3024" height="4032" alt="…" />
|
|
41
|
+
* </blur-hash>
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class BlurHash extends HTMLElement {
|
|
45
|
+
/**
|
|
46
|
+
* Where to load the decode worker from. Leave unset if your bundler handles
|
|
47
|
+
* `new Worker(new URL('./worker.js', import.meta.url))`; otherwise bundle
|
|
48
|
+
* `@limulus/blur-hash/worker` and set its URL here. It's read when the first placeholder
|
|
49
|
+
* is decoded.
|
|
50
|
+
*/
|
|
51
|
+
static workerUrl: string | URL | undefined
|
|
52
|
+
|
|
53
|
+
/** Defines the element as `name`, unless that name is already taken. */
|
|
54
|
+
static define(name = 'blur-hash'): void {
|
|
55
|
+
if (!customElements.get(name)) {
|
|
56
|
+
customElements.define(name, this)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#observer: MutationObserver | undefined
|
|
61
|
+
|
|
62
|
+
connectedCallback(): void {
|
|
63
|
+
if (this.#tryPlacehold()) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The HTML parser connects an already-defined element as soon as it reads the start
|
|
68
|
+
// tag, before any of its children exist, so wait for the <img> to be parsed.
|
|
69
|
+
this.#observer = new MutationObserver(() => {
|
|
70
|
+
this.#tryPlacehold()
|
|
71
|
+
})
|
|
72
|
+
this.#observer.observe(this, { childList: true, subtree: true })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
disconnectedCallback(): void {
|
|
76
|
+
this.#stopObserving()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#stopObserving(): void {
|
|
80
|
+
this.#observer?.disconnect()
|
|
81
|
+
this.#observer = undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Placeholds the `<img>` if it has been parsed, and returns whether it had. */
|
|
85
|
+
#tryPlacehold(): boolean {
|
|
86
|
+
const imgEl = this.querySelector('img')
|
|
87
|
+
if (!imgEl) {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Stop first, so that invalid attributes throw once, not on every later mutation.
|
|
92
|
+
this.#stopObserving()
|
|
93
|
+
this.#placehold(imgEl)
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#placehold(imgEl: HTMLImageElement): void {
|
|
98
|
+
const blurhash = this.getAttribute('blurhash')
|
|
99
|
+
const width = parseInt(this.getAttribute('width') ?? '0', 10)
|
|
100
|
+
const height = parseInt(this.getAttribute('height') ?? '0', 10)
|
|
101
|
+
|
|
102
|
+
if (!blurhash) {
|
|
103
|
+
throw new BlurHashError('No blurhash attribute found')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Negated so that NaN, from a missing or non-numeric attribute, fails too.
|
|
107
|
+
if (!(width > 0 && height > 0)) {
|
|
108
|
+
throw new BlurHashError('The width and height attributes must be positive numbers')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Don't apply blurhash if the image is already loaded
|
|
112
|
+
if (isLoaded(imgEl)) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Named functions, not closures: adding the same listener again is a no-op, so if the
|
|
117
|
+
// element is moved and connects again, its listeners don't stack up.
|
|
118
|
+
imgEl.addEventListener('load', clearPlaceholder)
|
|
119
|
+
imgEl.addEventListener('error', showAltText)
|
|
120
|
+
|
|
121
|
+
const { workerUrl } = this.constructor as typeof BlurHash
|
|
122
|
+
void placeholderUrl(blurhash, width, height, workerUrl).then((blobUrl) => {
|
|
123
|
+
// If the image loaded while the worker was decoding, the load listener above had
|
|
124
|
+
// nothing to clear yet, and painting now would leave the placeholder behind it.
|
|
125
|
+
if (blobUrl === undefined || isLoaded(imgEl)) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { objectFit, objectPosition } = getComputedStyle(imgEl)
|
|
130
|
+
Object.assign(imgEl.style, {
|
|
131
|
+
backgroundImage: `url(${blobUrl})`,
|
|
132
|
+
backgroundSize: backgroundSizes[objectFit],
|
|
133
|
+
backgroundPosition: objectPosition,
|
|
134
|
+
backgroundRepeat: 'no-repeat',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Hide alt text that Firefox displays while loading images because we can't know if
|
|
138
|
+
// the text will be legible atop the blurhash background. If the image has already
|
|
139
|
+
// failed, its error event fired before there was anything to restore.
|
|
140
|
+
if (!isBroken(imgEl)) {
|
|
141
|
+
imgEl.style.color = 'transparent'
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Safari seems to sometimes calculate object-fit images differently than the background
|
|
148
|
+
// image, which can lead to the background image peeking through on the edges. We don't need
|
|
149
|
+
// to keep these blur hash images around if they are not being displayed, so we may as well
|
|
150
|
+
// remove them.
|
|
151
|
+
function clearPlaceholder(this: HTMLImageElement): void {
|
|
152
|
+
// Attempt to prevent flash caused by removing the background image before the image is
|
|
153
|
+
// rendered
|
|
154
|
+
requestAnimationFrame(() => {
|
|
155
|
+
Object.assign(this.style, {
|
|
156
|
+
backgroundImage: '',
|
|
157
|
+
backgroundSize: '',
|
|
158
|
+
backgroundPosition: '',
|
|
159
|
+
backgroundRepeat: '',
|
|
160
|
+
color: '',
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A broken image's alt text is all that describes it, so let the browser draw it.
|
|
166
|
+
function showAltText(this: HTMLImageElement): void {
|
|
167
|
+
this.style.color = ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isLoaded(imgEl: HTMLImageElement): boolean {
|
|
171
|
+
return imgEl.complete && imgEl.naturalWidth > 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// An <img> without a source is also complete with no width, but it has no currentSrc.
|
|
175
|
+
function isBroken(imgEl: HTMLImageElement): boolean {
|
|
176
|
+
return imgEl.complete && imgEl.naturalWidth === 0 && imgEl.currentSrc !== ''
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Resolves with a blob URL for the placeholder, or `undefined` if it couldn't be decoded. */
|
|
180
|
+
function placeholderUrl(
|
|
181
|
+
blurhash: string,
|
|
182
|
+
width: number,
|
|
183
|
+
height: number,
|
|
184
|
+
workerUrl: string | URL | undefined
|
|
185
|
+
): Promise<string | undefined> {
|
|
186
|
+
// Make the short side 32 px, unless that would make the long side more than 128 px.
|
|
187
|
+
// Otherwise an extreme aspect ratio would have the worker decode an enormous image.
|
|
188
|
+
const scale = Math.min(32 / Math.min(width, height), 128 / Math.max(width, height))
|
|
189
|
+
const scaledWidth = Math.max(1, Math.round(width * scale))
|
|
190
|
+
const scaledHeight = Math.max(1, Math.round(height * scale))
|
|
191
|
+
|
|
192
|
+
const id = `${blurhash}-${scaledWidth}-${scaledHeight}`
|
|
193
|
+
let blobUrl = cache.get(id)
|
|
194
|
+
|
|
195
|
+
if (!blobUrl) {
|
|
196
|
+
const message: BlurHashDecodeMessage = {
|
|
197
|
+
id,
|
|
198
|
+
blurhash,
|
|
199
|
+
width: scaledWidth,
|
|
200
|
+
height: scaledHeight,
|
|
201
|
+
}
|
|
202
|
+
blobUrl = new Promise<BlurHashDecodeResultMessage>((resolve) => {
|
|
203
|
+
if (worker === undefined) worker = createWorker(workerUrl)
|
|
204
|
+
if (worker) {
|
|
205
|
+
pending.set(id, resolve)
|
|
206
|
+
worker.postMessage(message)
|
|
207
|
+
}
|
|
208
|
+
}).then((result) => {
|
|
209
|
+
if ('error' in result) {
|
|
210
|
+
console.error(`BlurHash: Could not decode "${blurhash}": ${result.error}`)
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
return URL.createObjectURL(result.image)
|
|
214
|
+
})
|
|
215
|
+
cache.set(id, blobUrl)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return blobUrl
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Starts the decode worker, or returns `null` if it can't be constructed. */
|
|
222
|
+
function createWorker(workerUrl: string | URL | undefined): Worker | null {
|
|
223
|
+
let decoder: Worker
|
|
224
|
+
try {
|
|
225
|
+
// Spelled out in full so that bundlers which recognize this pattern bundle the worker.
|
|
226
|
+
decoder =
|
|
227
|
+
workerUrl === undefined
|
|
228
|
+
? new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
|
|
229
|
+
: new Worker(workerUrl, { type: 'module' })
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Chromium throws here for a cross-origin worker, such as one loaded from a CDN, where
|
|
232
|
+
// other browsers fire the error event below instead.
|
|
233
|
+
reportWorkerFailure(error)
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
decoder.addEventListener(
|
|
238
|
+
'message',
|
|
239
|
+
({ data }: MessageEvent<BlurHashDecodeResultMessage>) => {
|
|
240
|
+
pending.get(data.id)!(data)
|
|
241
|
+
pending.delete(data.id)
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
// If the worker fails to load, nothing else reports it in the console. That's what
|
|
246
|
+
// happens with bundlers that leave the URL above alone, like esbuild and plain Rollup.
|
|
247
|
+
decoder.addEventListener('error', reportWorkerFailure)
|
|
248
|
+
|
|
249
|
+
return decoder
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function reportWorkerFailure(cause: unknown): void {
|
|
253
|
+
console.error(
|
|
254
|
+
"BlurHash: The decode worker couldn't start, so no placeholders will be shown. If your " +
|
|
255
|
+
"bundler doesn't bundle it, or you load this package from a CDN, see " +
|
|
256
|
+
'BlurHash.workerUrl in the README.',
|
|
257
|
+
cause
|
|
258
|
+
)
|
|
259
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { decodeBlurHash } from 'fast-blurhash'
|
|
2
|
+
|
|
3
|
+
import { BlurHashError } from './blur-hash-error.ts'
|
|
4
|
+
|
|
5
|
+
const BASE83 =
|
|
6
|
+
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'
|
|
7
|
+
|
|
8
|
+
/** Decodes `blurhash` into a `width`×`height` BMP. Throws if `blurhash` isn't valid. */
|
|
9
|
+
export function decodeToBmp(blurhash: string, width: number, height: number): Blob {
|
|
10
|
+
validate(blurhash)
|
|
11
|
+
return new Blob([bmp(width, height, decodeBlurHash(blurhash, width, height))], {
|
|
12
|
+
type: 'image/bmp',
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// fast-blurhash decodes anything without complaint, turning a malformed hash into a black or
|
|
17
|
+
// garbled image, so reject what Wolt's reference decoder would have.
|
|
18
|
+
function validate(blurhash: string): void {
|
|
19
|
+
if (blurhash.length < 6) {
|
|
20
|
+
throw new BlurHashError('The blurhash string must be at least 6 characters')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const char of blurhash) {
|
|
24
|
+
if (!BASE83.includes(char)) {
|
|
25
|
+
throw new BlurHashError(`The blurhash string contains an invalid character: ${char}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sizeFlag = BASE83.indexOf(blurhash[0])
|
|
30
|
+
const expected = 4 + 2 * ((sizeFlag % 9) + 1) * (Math.floor(sizeFlag / 9) + 1)
|
|
31
|
+
if (blurhash.length !== expected) {
|
|
32
|
+
throw new BlurHashError(
|
|
33
|
+
`blurhash length mismatch: length is ${blurhash.length} but it should be ${expected}`
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// An uncompressed 24-bit BMP with its rows top to bottom. BlurHash images are opaque, so the
|
|
39
|
+
// alpha channel is dropped. Writing this directly is several times faster than drawing to an
|
|
40
|
+
// OffscreenCanvas and encoding a PNG, and needs no canvas at all.
|
|
41
|
+
function bmp(
|
|
42
|
+
width: number,
|
|
43
|
+
height: number,
|
|
44
|
+
rgba: Uint8ClampedArray
|
|
45
|
+
): Uint8Array<ArrayBuffer> {
|
|
46
|
+
const headerSize = 14 + 40 // BITMAPFILEHEADER + BITMAPINFOHEADER
|
|
47
|
+
const rowSize = (width * 3 + 3) & ~3 // rows are padded to 4 bytes
|
|
48
|
+
const out = new Uint8Array(headerSize + rowSize * height)
|
|
49
|
+
const view = new DataView(out.buffer)
|
|
50
|
+
|
|
51
|
+
out[0] = 0x42 // 'B'
|
|
52
|
+
out[1] = 0x4d // 'M'
|
|
53
|
+
view.setUint32(2, out.length, true)
|
|
54
|
+
view.setUint32(10, headerSize, true) // where the pixels start
|
|
55
|
+
view.setUint32(14, 40, true) // BITMAPINFOHEADER size
|
|
56
|
+
view.setInt32(18, width, true)
|
|
57
|
+
view.setInt32(22, -height, true) // negative: rows run top to bottom
|
|
58
|
+
view.setUint16(26, 1, true) // color planes
|
|
59
|
+
view.setUint16(28, 24, true) // bits per pixel; compression and the rest stay 0
|
|
60
|
+
|
|
61
|
+
for (let y = 0; y < height; y++) {
|
|
62
|
+
let o = headerSize + y * rowSize
|
|
63
|
+
for (let i = y * width * 4, end = i + width * 4; i < end; i += 4) {
|
|
64
|
+
out[o++] = rgba[i + 2]
|
|
65
|
+
out[o++] = rgba[i + 1]
|
|
66
|
+
out[o++] = rgba[i]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Types only: importing a value from the element would load it here, where there is no
|
|
2
|
+
// HTMLElement to extend.
|
|
3
|
+
import type { BlurHashDecodeMessage, BlurHashDecodeResultMessage } from './blur-hash.ts'
|
|
4
|
+
import { decodeToBmp } from './decode.ts'
|
|
5
|
+
|
|
6
|
+
self.onmessage = ({ data }: MessageEvent<BlurHashDecodeMessage>) => {
|
|
7
|
+
let response: BlurHashDecodeResultMessage
|
|
8
|
+
try {
|
|
9
|
+
const image = decodeToBmp(data.blurhash, data.width, data.height)
|
|
10
|
+
response = { id: data.id, image }
|
|
11
|
+
} catch (error) {
|
|
12
|
+
response = { id: data.id, error: String(error) }
|
|
13
|
+
}
|
|
14
|
+
self.postMessage(response)
|
|
15
|
+
}
|