perf: add generic worker, divide blurhash worker

Canvas drawing and blurhash decoding are no longer blocking.

Blurhash decoding decoupling is needed for caching it later alongside the api store.

Signed-off-by: Fernando Fernández <ferferga@hotmail.com>
This commit is contained in:
Fernando Fernández 2024-07-11 22:57:03 +02:00
parent eb94694469
commit 353a6784ad
9 changed files with 182 additions and 118 deletions

View File

@ -312,10 +312,10 @@ export default tseslint.config(
'import/no-nodejs-modules': 'off'
}
},
/** Settings for WebWorkers (the pattern matches any file that includes the word 'worker', regardless it's capitalization) */
/** Settings for WebWorkers (the pattern matches any file that ends in .worker.ts) */
{
name: 'Environment config for WebWorker files',
files: ['**/*[Ww][Oo][Rr][Kk][Ee][Rr]*.ts'],
files: ['**/*.worker.ts'],
languageOptions: {
globals: {
...globals.worker

View File

@ -9,30 +9,12 @@
<slot v-else />
</template>
<script lang="ts">
import { wrap, transfer } from 'comlink';
import { shallowRef, watch } from 'vue';
import { DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_PUNCH, type IBlurhashWorker } from './BlurhashWorker';
import BlurhashWorker from './BlurhashWorker?worker';
import { remote } from '@/plugins/remote';
const worker = new BlurhashWorker();
const blurhashWorker = wrap<IBlurhashWorker>(worker);
/**
* Clear cached blurhashes on logout
*/
watch(
() => remote.auth.currentUser,
async () => {
if (remote.auth.currentUser === undefined) {
await blurhashWorker.clearCache();
}
}, { flush: 'post' }
);
</script>
<script setup lang="ts">
import { transfer } from 'comlink';
import { shallowRef, watch } from 'vue';
import { blurhashDecoder, canvasDrawer } from '@/plugins/workers';
import { BLURHASH_DEFAULT_HEIGHT, BLURHASH_DEFAULT_WIDTH, BLURHASH_DEFAULT_PUNCH } from '@/store';
const props = withDefaults(
defineProps<{
hash: string;
@ -40,7 +22,7 @@ const props = withDefaults(
height?: number;
punch?: number;
}>(),
{ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, punch: DEFAULT_PUNCH }
{ width: BLURHASH_DEFAULT_WIDTH, height: BLURHASH_DEFAULT_HEIGHT, punch: BLURHASH_DEFAULT_PUNCH }
);
const error = shallowRef(false);
@ -48,6 +30,8 @@ const canvas = shallowRef<HTMLCanvasElement>();
let offscreen: OffscreenCanvas | undefined;
watch([props, canvas], async () => {
const pixels = await blurhashDecoder.getPixels(props.hash, props.width, props.height, props.punch);
if (canvas.value) {
if (!offscreen) {
offscreen = canvas.value.transferControlToOffscreen();
@ -55,12 +39,11 @@ watch([props, canvas], async () => {
try {
error.value = false;
await blurhashWorker.drawCanvas(transfer(
{ hash: props.hash,
canvas: offscreen,
await canvasDrawer.drawBlurhash(transfer(
{ canvas: offscreen,
pixels,
width: props.width,
height: props.height,
punch: props.punch
height: props.height
}, [offscreen]));
} catch {
error.value = true;

View File

@ -1,81 +0,0 @@
import { decode } from 'blurhash';
import { expose } from 'comlink';
import { sealed } from '@/utils/validation';
/**
* By default, 20x20 pixels with a punch of 1 is returned.
* Although the default values recommended by Blurhash developers is 32x32,
* a size of 20x20 seems to be the sweet spot for us, improving the performance
* and reducing the memory usage, while retaining almost full blur quality.
* Lower values had more visible pixelation
*/
export const DEFAULT_WIDTH = 20;
export const DEFAULT_HEIGHT = 20;
export const DEFAULT_PUNCH = 1;
@sealed
class BlurhashWorker {
private readonly _pixelsCache = new Map<string, Uint8ClampedArray>();
/**
* Decodes blurhash outside the main thread, in a web worker.
*
* @param hash - Hash to decode.
* @param width - Width of the decoded pixel array
* @param height - Height of the decoded pixel array.
* @param punch - Contrast of the decoded pixels
* @returns - Returns the decoded pixels in the proxied response by Comlink
*/
public readonly getPixels = (
hash: string,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT,
punch: number = DEFAULT_PUNCH
): Uint8ClampedArray => {
try {
const params = String([hash, width, height, punch]);
let pixels = this._pixelsCache.get(params);
if (!pixels) {
pixels = decode(hash, width, height, punch);
this._pixelsCache.set(params, pixels);
}
return pixels;
} catch {
throw new TypeError(`Blurhash ${hash} is not valid`);
}
};
/**
* Draws the transferred canvas from the main thread
*
* @param hash - Hash to decode.
* @param canvas - Canvas to draw the decoded pixels. Must come from main thread's canvas.transferControlToOffscreen()
* @param width - Width of the decoded pixel array
* @param height - Height of the decoded pixel array.
* @param punch - Contrast of the decoded pixels
*/
public readonly drawCanvas = ({
hash, canvas, width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, punch = DEFAULT_PUNCH
}: { hash: string; canvas: OffscreenCanvas; width: number; height: number; punch: number }) => {
const pixels = this.getPixels(hash, width, height, punch);
const ctx = canvas.getContext('2d');
const imageData = ctx!.createImageData(width, height);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
};
/**
* Clear the blurhashes cache
*/
public readonly clearCache = (): void => {
this._pixelsCache.clear();
};
}
const instance = new BlurhashWorker();
export default instance;
export type IBlurhashWorker = typeof instance;
expose(instance);

View File

@ -0,0 +1,54 @@
import { decode } from 'blurhash';
import { expose } from 'comlink';
import { sealed } from '@/utils/validation';
/**
* Decodes blurhash strings into pixels
*/
@sealed
class BlurhashDecoder {
private readonly _pixelsCache = new Map<string, Uint8ClampedArray>();
/**
* Decodes blurhash outside the main thread, in a web worker.
*
* @param hash - Hash to decode.
* @param width - Width of the decoded pixel array
* @param height - Height of the decoded pixel array.
* @param punch - Contrast of the decoded pixels
* @returns - Returns the decoded pixels in the proxied response by Comlink
*/
public readonly getPixels = (
hash: string,
width: number,
height: number,
punch: number
): Uint8ClampedArray => {
try {
const params = String([hash, width, height, punch]);
let pixels = this._pixelsCache.get(params);
if (!pixels) {
pixels = decode(hash, width, height, punch);
this._pixelsCache.set(params, pixels);
}
return pixels;
} catch {
throw new TypeError(`Blurhash ${hash} is not valid`);
}
};
/**
* Clear the blurhashes cache
*/
public readonly clearCache = (): void => {
this._pixelsCache.clear();
};
}
const instance = new BlurhashDecoder();
export default instance;
export type IBlurhashDecoder = typeof instance;
expose(instance);

View File

@ -0,0 +1,30 @@
import { expose } from 'comlink';
import { sealed } from '@/utils/validation';
/**
* Draws canvases offscreen
*/
@sealed
class CanvasDrawer {
/**
* Draws a transferred canvas from the main thread
*
* @param canvas - Canvas to draw the decoded pixels. Must come from main thread's canvas.transferControlToOffscreen()
* @param width - Width of the target imageData
* @param height - Height of the target imageData
*/
public readonly drawBlurhash = ({
canvas, pixels, width, height
}: { canvas: OffscreenCanvas; pixels: Uint8ClampedArray; width: number; height: number }) => {
const ctx = canvas.getContext('2d');
const imageData = ctx!.createImageData(width, height);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
};
}
const instance = new CanvasDrawer();
export default instance;
export type ICanvasDrawer = typeof instance;
expose(instance);

View File

@ -0,0 +1,18 @@
import { expose } from 'comlink';
import { shuffle as _shuffle } from 'lodash-es';
import { sealed } from '@/utils/validation';
/**
* All functions that could take some time to complete and block the main thread
* must be offloaded to this worker
*/
@sealed
class GenericWorker {
public readonly shuffle = (...args: Parameters<typeof _shuffle>) => _shuffle(...args);
}
const instance = new GenericWorker();
export default instance;
export type IGenericWorker = typeof instance;
expose(instance);

View File

@ -0,0 +1,47 @@
import { wrap } from 'comlink';
import { watch } from 'vue';
import type { IBlurhashDecoder } from './blurhash-decoder.worker';
import BlurhashDecoder from './blurhash-decoder.worker?worker';
import type { ICanvasDrawer } from './canvas-drawer.worker';
import CanvasDrawer from './canvas-drawer.worker?worker';
import type { IGenericWorker } from './generic.worker';
import GenericWorker from './generic.worker?worker';
import { remote } from '@/plugins/remote';
/**
* A worker for decoding blurhash strings into pixels
*/
export const blurhashDecoder = wrap<IBlurhashDecoder>(new BlurhashDecoder());
/**
* A worker for drawing canvas offscreen. The canvas must be transferred like this:
* ```ts
* import { transfer } from 'comlink';
*
* await canvasDrawer.drawBlurhash(transfer(
* { canvas: offscreen,
* pixels,
* width,
* height
* }, [offscreen]));
* ```
*/
export const canvasDrawer = wrap<ICanvasDrawer>(new CanvasDrawer());
/**
* A worker for running any non-specific function that could be expensive and take some time to complete,
* blocking the main thread
*/
export const genericWorker = wrap<IGenericWorker>(new GenericWorker());
/**
* Clear cached blurhashes on logout
*/
watch(
() => remote.auth.currentUser,
async () => {
if (remote.auth.currentUser === undefined) {
await blurhashDecoder.clearCache();
}
}, { flush: 'post' }
);

View File

@ -3,12 +3,25 @@ import { computedAsync, useFps, useMediaControls, useMediaQuery, useNetwork, use
import { shallowRef, computed } from 'vue';
import { remote } from '@/plugins/remote';
import { isNil } from '@/utils/validation';
/**
* This file contains global variables (specially VueUse refs) that are used multiple times across the client.
* VueUse composables will set new event handlers, so it's more
* efficient to reuse those, both in components and TS files.
*/
/**
* == BLURHASH DEFAULTS ==
* By default, 20x20 pixels with a punch of 1 is returned.
* Although the default values recommended by Blurhash developers is 32x32,
* a size of 20x20 seems to be the sweet spot for us, improving the performance
* and reducing the memory usage, while retaining almost full blur quality.
* Lower values had more visible pixelation
*/
export const BLURHASH_DEFAULT_WIDTH = 20;
export const BLURHASH_DEFAULT_HEIGHT = 20;
export const BLURHASH_DEFAULT_PUNCH = 1;
/**
* Reactive Date.now() instance
*/

View File

@ -21,9 +21,8 @@ import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api/media-info-api';
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api';
import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api';
import { useEventListener, watchThrottled } from '@vueuse/core';
import { shuffle } from 'lodash-es';
import { v4 } from 'uuid';
import { watch, watchEffect } from 'vue';
import { toRaw, watch, watchEffect } from 'vue';
import { isNil, sealed } from '@/utils/validation';
import { useBaseItem } from '@/composables/apis';
import { useSnackbar } from '@/composables/use-snackbar';
@ -36,6 +35,7 @@ import playbackProfile from '@/utils/playback-profiles';
import { msToTicks } from '@/utils/time';
import { mediaControls, mediaElementRef } from '@/store';
import { CommonStore } from '@/store/super/common-store';
import { genericWorker } from '@/plugins/workers';
/**
* == INTERFACES AND TYPES ==
@ -600,7 +600,7 @@ class PlaybackManagerStore extends CommonStore<PlaybackManagerState> {
this._state.currentItemIndex = startFromIndex;
if (startShuffled) {
this.toggleShuffle(false);
void this.toggleShuffle(false);
}
this.currentTime = startFromTime;
@ -755,8 +755,8 @@ class PlaybackManagerStore extends CommonStore<PlaybackManagerState> {
this.currentVolume = this.currentVolume - 5;
};
public readonly toggleShuffle = (preserveCurrentItem = true): void => {
if (this._state.queue && !isNil(this._state.currentItemIndex)) {
public readonly toggleShuffle = async (preserveCurrentItem = true): Promise<void> => {
if (!isNil(this._state.currentItemIndex)) {
if (this._state.isShuffling) {
const item = this._state.queue[this._state.currentItemIndex];
@ -765,7 +765,7 @@ class PlaybackManagerStore extends CommonStore<PlaybackManagerState> {
this._state.originalQueue = [];
this._state.isShuffling = false;
} else {
const queue = shuffle(this._state.queue);
const queue = await genericWorker.shuffle(toRaw(this._state.queue));
this._state.originalQueue = this._state.queue;