diff --git a/package.json b/package.json index d0d6735c..2ed54c92 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "performance-now": "^2.1.0", "pinch-zoom-element": "^1.1.1", "preact": "^10.0.0-beta.1", + "promise-worker": "^2.0.1", "prop-types": "^15.7.2", "quick-lru": "^4.0.1", "remount": "^0.11.0", diff --git a/src/routes/_actions/createMakeProps.js b/src/routes/_actions/createMakeProps.js index 08e1dde3..3f2418e1 100644 --- a/src/routes/_actions/createMakeProps.js +++ b/src/routes/_actions/createMakeProps.js @@ -1,4 +1,6 @@ import { database } from '../_database/database' +import { decode as decodeBlurhash, init as initBlurhash } from '../_utils/blurhash' +import { mark, stop } from '../_utils/marks' async function getNotification (instanceName, timelineType, timelineValue, itemId) { return { @@ -16,10 +18,38 @@ async function getStatus (instanceName, timelineType, timelineValue, itemId) { } } +function tryInitBlurhash () { + try { + initBlurhash() + } catch (err) { + console.error('could not start blurhash worker', err) + } +} + +async function decodeAllBlurhashes (statusOrNotification) { + const status = statusOrNotification.status || statusOrNotification.notification.status + if (status && status.media_attachments) { + mark(`decodeBlurhash-${status.id}`) + await Promise.all(status.media_attachments.map(async media => { + if (media.blurhash) { + try { + media.decodedBlurhash = await decodeBlurhash(media.blurhash) + } catch (err) { + console.warn('Could not decode blurhash, ignoring', err) + } + } + })) + stop(`decodeBlurhash-${status.id}`) + } + return statusOrNotification +} + export function createMakeProps (instanceName, timelineType, timelineValue) { let taskCount = 0 let pending = [] + tryInitBlurhash() // start the blurhash worker a bit early to save time + // The worker-powered indexeddb promises can resolve in arbitrary order, // causing the timeline to load in a jerky way. With this function, we // wait for all promises to resolve before resolving them all in one go. @@ -34,14 +64,25 @@ export function createMakeProps (instanceName, timelineType, timelineValue) { }) } + async function fetchFromIndexedDB (itemId) { + mark(`fetchFromIndexedDB-${itemId}`) + try { + const res = await (timelineType === 'notifications' + ? getNotification(instanceName, timelineType, timelineValue, itemId) + : getStatus(instanceName, timelineType, timelineValue, itemId)) + return res + } finally { + stop(`fetchFromIndexedDB-${itemId}`) + } + } + return (itemId) => { taskCount++ - const promise = timelineType === 'notifications' - ? getNotification(instanceName, timelineType, timelineValue, itemId) - : getStatus(instanceName, timelineType, timelineValue, itemId) - return promise.then(res => { - return awaitAllTasksComplete().then(() => res) - }) + return fetchFromIndexedDB(itemId) + .then(decodeAllBlurhashes) + .then(statusOrNotification => { + return awaitAllTasksComplete().then(() => statusOrNotification) + }) } } diff --git a/src/routes/_components/status/Media.html b/src/routes/_components/status/Media.html index 0bbc88f4..e0d70ce6 100644 --- a/src/routes/_components/status/Media.html +++ b/src/routes/_components/status/Media.html @@ -110,16 +110,11 @@ import LazyImage from '../LazyImage.html' import AutoplayVideo from '../AutoplayVideo.html' import { registerClickDelegate } from '../../_utils/delegate' - import { decode } from '../../_utils/blurhash' export default { async oncreate () { - const { elementId, media } = this.get() + const { elementId } = this.get() registerClickDelegate(this, elementId, () => this.onClick()) - - if (media.blurhash) { - this.set({ decodedBlurhash: await decode(media.blurhash) }) - } }, computed: { focus: ({ meta }) => meta && meta.focus, @@ -150,6 +145,7 @@ export default { elementId: ({ media, uuid }) => `media-${uuid}-${media.id}`, description: ({ media }) => media.description || '', previewUrl: ({ media }) => media.preview_url, + decodedBlurhash: ({ media }) => media.decodedBlurhash || ONE_TRANSPARENT_PIXEL, blurhash: ({ showBlurhash, decodedBlurhash }) => showBlurhash && decodedBlurhash, url: ({ media }) => media.url, type: ({ media }) => media.type @@ -166,7 +162,6 @@ export default { }, data: () => ({ oneTransparentPixel: ONE_TRANSPARENT_PIXEL, - decodedBlurhash: ONE_TRANSPARENT_PIXEL, mouseover: void 0 }), store: () => store, diff --git a/src/routes/_static/blurhash.js b/src/routes/_static/blurhash.js new file mode 100644 index 00000000..c2361f64 --- /dev/null +++ b/src/routes/_static/blurhash.js @@ -0,0 +1 @@ +export const BLURHASH_RESOLUTION = 32 diff --git a/src/routes/_utils/blurhash.js b/src/routes/_utils/blurhash.js index bd8ea9f6..9129976b 100644 --- a/src/routes/_utils/blurhash.js +++ b/src/routes/_utils/blurhash.js @@ -1,51 +1,49 @@ import BlurhashWorker from 'worker-loader!../_workers/blurhash' // eslint-disable-line +import PromiseWorker from 'promise-worker' +import { BLURHASH_RESOLUTION as RESOLUTION } from '../_static/blurhash' +import QuickLRU from 'quick-lru' + +const CACHE = new QuickLRU({ maxSize: 100 }) -const RESOLUTION = 32 let worker let canvas let canvasContext2D export function init () { - worker = worker || new BlurhashWorker() + worker = worker || new PromiseWorker(new BlurhashWorker()) +} + +function initCanvas () { + if (!canvas) { + canvas = document.createElement('canvas') + canvas.height = RESOLUTION + canvas.width = RESOLUTION + canvasContext2D = canvas.getContext('2d') + } +} + +// canvas is the backup if we can't use OffscreenCanvas +async function decodeUsingCanvas (imageData) { + initCanvas() + canvasContext2D.putImageData(imageData, 0, 0) + const blob = await new Promise(resolve => canvas.toBlob(resolve)) + return URL.createObjectURL(blob) +} + +async function decodeWithoutCache (blurhash) { + init() + const { decoded, imageData } = await worker.postMessage(blurhash) + if (decoded) { + return decoded + } + return decodeUsingCanvas(imageData) } export async function decode (blurhash) { - return new Promise((resolve, reject) => { - try { - init() - - const onMessage = ({ data: { encoded, decoded, imageData, error } }) => { - if (encoded !== blurhash) { - return - } - - worker.removeEventListener('message', onMessage) - - if (error) { - return reject(error) - } - - if (decoded) { - resolve(decoded) - } else { - if (!canvas) { - canvas = document.createElement('canvas') - canvas.height = RESOLUTION - canvas.width = RESOLUTION - canvasContext2D = canvas.getContext('2d') - } - - canvasContext2D.putImageData(imageData, 0, 0) - canvas.toBlob(blob => { - resolve(URL.createObjectURL(blob)) - }) - } - } - - worker.addEventListener('message', onMessage) - worker.postMessage({ encoded: blurhash }) - } catch (e) { - reject(e) - } - }) + let result = CACHE.get(blurhash) + if (!result) { + result = await decodeWithoutCache(blurhash) + CACHE.set(blurhash, result) + } + return result } diff --git a/src/routes/_workers/blurhash.js b/src/routes/_workers/blurhash.js index acdf03bf..81238780 100644 --- a/src/routes/_workers/blurhash.js +++ b/src/routes/_workers/blurhash.js @@ -1,45 +1,26 @@ import { decode as decodeBlurHash } from 'blurhash' -import QuickLRU from 'quick-lru' +import registerPromiseWorker from 'promise-worker/register' +import { BLURHASH_RESOLUTION as RESOLUTION } from '../_static/blurhash' -const RESOLUTION = 32 const OFFSCREEN_CANVAS = typeof OffscreenCanvas === 'function' ? new OffscreenCanvas(RESOLUTION, RESOLUTION) : null const OFFSCREEN_CANVAS_CONTEXT_2D = OFFSCREEN_CANVAS ? OFFSCREEN_CANVAS.getContext('2d') : null -const CACHE = new QuickLRU({ maxSize: 100 }) -self.addEventListener('message', ({ data: { encoded } }) => { - try { - if (CACHE.has(encoded)) { - if (OFFSCREEN_CANVAS) { - postMessage({ encoded, decoded: CACHE.get(encoded), imageData: null, error: null }) - } else { - postMessage({ encoded, imageData: CACHE.get(encoded), decoded: null, error: null }) - } - } else { - const pixels = decodeBlurHash(encoded, RESOLUTION, RESOLUTION) +registerPromiseWorker(async (encoded) => { + const pixels = decodeBlurHash(encoded, RESOLUTION, RESOLUTION) - if (pixels) { - const imageData = new ImageData(pixels, RESOLUTION, RESOLUTION) + if (!pixels) { + throw new Error('decode did not return any pixels') + } + const imageData = new ImageData(pixels, RESOLUTION, RESOLUTION) - if (OFFSCREEN_CANVAS) { - OFFSCREEN_CANVAS_CONTEXT_2D.putImageData(imageData, 0, 0) - OFFSCREEN_CANVAS.convertToBlob().then(blob => { - const decoded = URL.createObjectURL(blob) - CACHE.set(encoded, decoded) - postMessage({ encoded, decoded, imageData: null, error: null }) - }).catch(error => { - postMessage({ encoded, decoded: null, imageData: null, error }) - }) - } else { - CACHE.set(encoded, imageData) - postMessage({ encoded, imageData, decoded: null, error: null }) - } - } else { - postMessage({ encoded, decoded: null, imageData: null, error: new Error('decode did not return any pixels') }) - } - } - } catch (error) { - postMessage({ encoded, decoded: null, imageData: null, error }) + if (OFFSCREEN_CANVAS) { + OFFSCREEN_CANVAS_CONTEXT_2D.putImageData(imageData, 0, 0) + const blob = await OFFSCREEN_CANVAS.convertToBlob() + const decoded = URL.createObjectURL(blob) + return { decoded, imageData: null } + } else { + return { imageData, decoded: null } } }) diff --git a/yarn.lock b/yarn.lock index a5fec53d..e0a2cb6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5991,6 +5991,11 @@ promise-polyfill@^6.0.1: resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-6.1.0.tgz#dfa96943ea9c121fca4de9b5868cb39d3472e057" integrity sha1-36lpQ+qcEh/KTem1hoyznTRy4Fc= +promise-worker@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/promise-worker/-/promise-worker-2.0.1.tgz#63bb532624ecd40cdb335b51bb7830c3c892aa6c" + integrity sha512-jR7vHqMEwWJ15i9vA3qyCKwRHihyLJp1sAa3RyY5F35m3u5s2lQUfq0nzVjbA8Xc7+3mL3Y9+9MHBO9UFRpFxA== + promisify-event@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/promisify-event/-/promisify-event-1.0.0.tgz#bd7523ea06b70162f370979016b53a686c60e90f"