summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authortamaina <tamaina@hotmail.co.jp>2023-05-19 09:44:06 +0900
committerGitHub <noreply@github.com>2023-05-19 09:44:06 +0900
commit59255e11b8989d1e4f60c222590d2918a61adb54 (patch)
tree251bcacca9437acba08f415e09a8d3ba0dd9e83a /packages/frontend/src
parentfeat: センシティブなカスタム絵文字のリアクションを受... (diff)
downloadmisskey-59255e11b8989d1e4f60c222590d2918a61adb54.tar.gz
misskey-59255e11b8989d1e4f60c222590d2918a61adb54.tar.bz2
misskey-59255e11b8989d1e4f60c222590d2918a61adb54.zip
perf: MkImgWithBlurhashとMkMediaImageを最適化 (#10782)
* #10781 * fix tsconfig * fetch image?? * Revert "fetch image??" This reverts commit 0925c28d5a4f328264c39d5591dc736795541683. * wip * Revert "wip" This reverts commit be97c6cb88318bcea441edeeecb69b6d6ed0dd8f. * loading="eager" * loading="eager" 2 * error * wip * wip * wip * wip * clean up * fix * 生成するworkerを1つにする? * clean up * use buraha * wip * smaller width, height * update buraha * clean up * fix * Update MkMediaImage.vue * Update MkImgWithBlurhash.vue * Revert "fix(frontend): センシティブ設定された画像を開くとき一瞬レイアウトが崩れる問題を修正" This reverts commit 41e9aa6f9b03107518224e2ebde8889c64408204. * Update MkMediaList.vue * Update MkMediaList.vue * Update MkMediaList.vue * Update CHANGELOG.md * wait for decode * fix * ? * (test) remove container-type: inline-size; * Revert "(test) remove container-type: inline-size;" This reverts commit 9448e64228428175a3d624c04df1bfad0f59cb69. * container-name * Revert "container-name" This reverts commit 94385d32213a00a06a59fbd2296d6ef1b5f91785. * width: 100%; * improve performance * refactor * wip * WIP * wip * Revert "wip" This reverts commit 36e3b75cab8114e423544b79a8e2df353880f43b. * Revert "WIP" This reverts commit 05b729ef9189aea052ba411ac10f30a46cc668c8. * Revert "wip" This reverts commit 0801e7936116c58154d7cecfea955dd15fa61a77. * #10860 * wip * no worker * Revert "no worker" This reverts commit a9c49e4fb49976958a7594393343d52be0e082d7. * :v: * workerNumber固定は不要 --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/components/MkGalleryPostPreview.vue15
-rw-r--r--packages/frontend/src/components/MkImgWithBlurhash.vue194
-rw-r--r--packages/frontend/src/components/MkMediaImage.vue55
-rw-r--r--packages/frontend/src/components/MkMediaList.vue65
-rw-r--r--packages/frontend/src/components/global/MkAvatar.vue3
-rw-r--r--packages/frontend/src/pages/settings/general.vue3
-rw-r--r--packages/frontend/src/scripts/worker-multi-dispatch.ts75
-rw-r--r--packages/frontend/src/workers/draw-blurhash.ts15
-rw-r--r--packages/frontend/src/workers/test-webgl2.ts7
-rw-r--r--packages/frontend/src/workers/tsconfig.json5
10 files changed, 351 insertions, 86 deletions
diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue
index 4f8f7b945a..fccc33dcc2 100644
--- a/packages/frontend/src/components/MkGalleryPostPreview.vue
+++ b/packages/frontend/src/components/MkGalleryPostPreview.vue
@@ -5,12 +5,9 @@
<ImgWithBlurhash
class="img layered"
:transition="safe ? null : {
- enterActiveClass: $style.transition_toggle_enterActive,
+ duration: 500,
leaveActiveClass: $style.transition_toggle_leaveActive,
- enterFromClass: $style.transition_toggle_enterFrom,
leaveToClass: $style.transition_toggle_leaveTo,
- enterToClass: $style.transition_toggle_enterTo,
- leaveFromClass: $style.transition_toggle_leaveFrom,
}"
:src="post.files[0].thumbnailUrl"
:hash="post.files[0].blurhash"
@@ -53,24 +50,16 @@ function leaveHover(): void {
</script>
<style lang="scss" module>
-.transition_toggle_enterActive,
.transition_toggle_leaveActive {
- transition: opacity 0.5s;
+ transition: opacity .5s;
position: absolute;
top: 0;
left: 0;
}
-.transition_toggle_enterFrom,
.transition_toggle_leaveTo {
opacity: 0;
}
-
-.transition_toggle_enterTo,
-.transition_toggle_leaveFrom {
- transition: none;
- opacity: 1;
-}
</style>
<style lang="scss" scoped>
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index 6406a35060..de14d9ea28 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -1,30 +1,56 @@
<template>
-<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
- <img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/>
- <Transition
- mode="in-out"
- :enter-active-class="defaultStore.state.animation && (props.transition?.enterActiveClass ?? $style['transition_toggle_enterActive']) || undefined"
- :leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_toggle_leaveActive']) || undefined"
+<div ref="root" :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
+ <TransitionGroup
+ :duration="defaultStore.state.animation && props.transition?.duration || undefined"
+ :enter-active-class="defaultStore.state.animation && props.transition?.enterActiveClass || undefined"
+ :leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_leaveActive']) || undefined"
:enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
:leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
- :enter-to-class="defaultStore.state.animation && (props.transition?.enterToClass ?? $style['transition_toggle_enterTo']) || undefined"
- :leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || undefined"
+ :enter-to-class="defaultStore.state.animation && props.transition?.enterToClass || undefined"
+ :leave-from-class="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
>
- <canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="width" :height="height" :title="title ?? undefined"/>
- <img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined"/>
- </Transition>
+ <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
+ <img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
+ </TransitionGroup>
</div>
</template>
+<script lang="ts">
+import DrawBlurhash from '@/workers/draw-blurhash?worker';
+import TestWebGL2 from '@/workers/test-webgl2?worker';
+import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch';
+import { $ref } from 'vue/macros';
+import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
+
+const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => {
+ const testWorker = new TestWebGL2();
+ testWorker.addEventListener('message', event => {
+ if (event.data.result) {
+ const workers = new WorkerMultiDispatch(
+ () => new DrawBlurhash(),
+ Math.min(navigator.hardwareConcurrency - 1, 4),
+ );
+ resolve(workers);
+ if (_DEV_) console.log('WebGL2 in worker is supported!');
+ } else {
+ resolve(null);
+ if (_DEV_) console.log('WebGL2 in worker is not supported...');
+ }
+ testWorker.terminate();
+ });
+});
+</script>
+
<script lang="ts" setup>
-import { onMounted, shallowRef, useCssModule, watch } from 'vue';
-import { decode } from 'blurhash';
+import { computed, nextTick, onMounted, onUnmounted, shallowRef, useCssModule, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import { render } from 'buraha';
import { defaultStore } from '@/store';
-
const $style = useCssModule();
const props = withDefaults(defineProps<{
transition?: {
+ duration?: number | { enter: number; leave: number; };
enterActiveClass?: string;
leaveActiveClass?: string;
enterFromClass?: string;
@@ -51,67 +77,141 @@ const props = withDefaults(defineProps<{
forceBlurhash: false,
});
+const viewId = uuid();
const canvas = shallowRef<HTMLCanvasElement>();
+const root = shallowRef<HTMLDivElement>();
+const img = shallowRef<HTMLImageElement>();
let loaded = $ref(false);
-let width = $ref(props.width);
-let height = $ref(props.height);
+let canvasWidth = $ref(64);
+let canvasHeight = $ref(64);
+let imgWidth = $ref(props.width);
+let imgHeight = $ref(props.height);
+let bitmapTmp = $ref<CanvasImageSource | undefined>();
+const hide = computed(() => !loaded || props.forceBlurhash);
-function onLoad() {
- loaded = true;
+function waitForDecode() {
+ if (props.src != null && props.src !== '') {
+ nextTick()
+ .then(() => img.value?.decode())
+ .then(() => {
+ loaded = true;
+ }, error => {
+ console.error('Error occured during decoding image', img.value, error);
+ throw Error(error);
+ });
+ } else {
+ loaded = false;
+ }
}
-watch([() => props.width, () => props.height], () => {
+watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
- width = Math.round(64 * ratio);
- height = 64;
+ canvasWidth = Math.round(64 * ratio);
+ canvasHeight = 64;
} else {
- width = 64;
- height = Math.round(64 / ratio);
+ canvasWidth = 64;
+ canvasHeight = Math.round(64 / ratio);
}
+
+ const clientWidth = root.value?.clientWidth ?? 300;
+ imgWidth = clientWidth;
+ imgHeight = Math.round(clientWidth / ratio);
}, {
immediate: true,
});
-function draw() {
- if (props.hash == null || !canvas.value) return;
- const pixels = decode(props.hash, width, height);
+function drawImage(bitmap: CanvasImageSource) {
+ // canvasがない(mountedされていない)場合はTmpに保存しておく
+ if (!canvas.value) {
+ bitmapTmp = bitmap;
+ return;
+ }
+
+ // canvasがあれば描画する
+ bitmapTmp = undefined;
const ctx = canvas.value.getContext('2d');
- const imageData = ctx!.createImageData(width, height);
- imageData.data.set(pixels);
- ctx!.putImageData(imageData, 0, 0);
+ if (!ctx) return;
+ ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
}
-watch([() => props.hash, canvas], () => {
+async function draw() {
+ if (!canvas.value || props.hash == null) return;
+
+ const ctx = canvas.value.getContext('2d');
+ if (!ctx) return;
+
+ // avgColorでお茶をにごす
+ ctx.beginPath();
+ ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+
+ const workers = await workerPromise;
+ if (workers) {
+ workers.postMessage(
+ {
+ id: viewId,
+ hash: props.hash,
+ width: canvasWidth,
+ height: canvasHeight,
+ },
+ undefined,
+ );
+ } else {
+ try {
+ const work = document.createElement('canvas');
+ work.width = canvasWidth;
+ work.height = canvasHeight;
+ render(props.hash, work);
+ ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight);
+ } catch (error) {
+ console.error('Error occured during drawing blurhash', error);
+ }
+ }
+}
+
+function workerOnMessage(event: MessageEvent) {
+ if (event.data.id !== viewId) return;
+ drawImage(event.data.bitmap as ImageBitmap);
+}
+
+workerPromise.then(worker => {
+ if (worker) {
+ worker.addListener(workerOnMessage);
+ }
+
draw();
});
-onMounted(() => {
+watch(() => props.src, () => {
+ waitForDecode();
+});
+
+watch(() => props.hash, () => {
draw();
});
-</script>
-<style lang="scss" module>
-.transition_toggle_enterActive,
-.transition_toggle_leaveActive {
- position: absolute;
- top: 0;
- left: 0;
-}
+onMounted(() => {
+ // drawImageがmountedより先に呼ばれている場合はここで描画する
+ if (bitmapTmp) {
+ drawImage(bitmapTmp);
+ }
+ waitForDecode();
+});
-.transition_toggle_enterTo,
-.transition_toggle_leaveFrom {
- opacity: 0;
-}
+onUnmounted(() => {
+ workerPromise.then(worker => {
+ worker?.removeListener(workerOnMessage);
+ });
+});
+</script>
-.loader {
+<style lang="scss" module>
+.transition_leaveActive {
position: absolute;
top: 0;
left: 0;
- width: 0;
- height: 0;
}
-
.root {
position: relative;
width: 100%;
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 42dc9e79ff..b235dd8bd5 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -1,29 +1,40 @@
<template>
-<div v-if="hide" :class="$style.hidden" @click="hide = false">
- <ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/>
- <div :class="$style.hiddenText">
- <div :class="$style.hiddenTextWrapper">
- <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
- <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
- <span style="display: block;">{{ i18n.ts.clickToShow }}</span>
- </div>
- </div>
-</div>
-<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
+<div :class="hide ? $style.hidden : $style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
<a
:class="$style.imageContainer"
:href="image.url"
:title="image.name"
>
- <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :width="image.properties.width" :height="image.properties.height" :cover="false"/>
+ <ImgWithBlurhash
+ :hash="image.blurhash"
+ :src="(defaultStore.state.enableDataSaverMode && hide) ? null : url"
+ :force-blurhash="hide"
+ :cover="hide"
+ :alt="image.comment || image.name"
+ :title="image.comment || image.name"
+ :width="image.properties.width"
+ :height="image.properties.height"
+ :style="hide ? 'filter: brightness(0.5);' : null"
+ />
</a>
- <div :class="$style.indicators">
- <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
- <div v-if="image.comment" :class="$style.indicator">ALT</div>
- <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
- </div>
- <button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
- <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
+ <template v-if="hide">
+ <div :class="$style.hiddenText">
+ <div :class="$style.hiddenTextWrapper">
+ <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
+ <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
+ <span style="display: block;">{{ i18n.ts.clickToShow }}</span>
+ </div>
+ </div>
+ </template>
+ <template v-else>
+ <div :class="$style.indicators">
+ <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
+ <div v-if="image.comment" :class="$style.indicator">ALT</div>
+ <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
+ </div>
+ <button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click.stop.prevent="hide = true"><i class="ti ti-eye-off"></i></button>
+ <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
+ </template>
</div>
</template>
@@ -53,6 +64,12 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
: props.image.thumbnailUrl,
);
+function onclick() {
+ if (hide) {
+ hide = false;
+ }
+}
+
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
watch(() => props.image, () => {
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index e456ff3eec..0f41ef248f 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -7,6 +7,7 @@
:class="[
$style.medias,
count <= 4 ? $style['n' + count] : $style.nMany,
+ $style[`n1${defaultStore.reactiveState.mediaListWithOneImageAppearance.value}`]
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
@@ -19,7 +20,7 @@
</template>
<script lang="ts" setup>
-import { onMounted, ref, useCssModule, watch } from 'vue';
+import { onMounted, ref, useCssModule, watch, shallowRef } from 'vue';
import * as misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
@@ -38,11 +39,42 @@ const props = defineProps<{
const $style = useCssModule();
-const gallery = ref<HTMLDivElement>();
+const gallery = shallowRef<HTMLDivElement>();
const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
+function calcAspectRatio() {
+ if (!gallery.value) return;
+
+ let img = props.mediaList[0];
+
+ if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
+ gallery.value.style.aspectRatio = '';
+ return;
+ }
+
+ // アスペクト比上限設定では、横長の場合は高さを縮小させる
+ const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`;
+
+ switch (defaultStore.state.mediaListWithOneImageAppearance) {
+ case '16_9':
+ gallery.value.style.aspectRatio = ratioMax(16 / 9);
+ break;
+ case '1_1':
+ gallery.value.style.aspectRatio = ratioMax(1);
+ break;
+ case '2_3':
+ gallery.value.style.aspectRatio = ratioMax(2 / 3);
+ break;
+ default:
+ gallery.value.style.aspectRatio = '';
+ break;
+ }
+}
+
+watch([defaultStore.reactiveState.mediaListWithOneImageAppearance, gallery], () => calcAspectRatio());
+
onMounted(() => {
const lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList
@@ -162,12 +194,37 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
display: grid;
grid-gap: 8px;
- // for webkit
height: 100%;
+ width: 100%;
&.n1 {
- aspect-ratio: 16/9;
grid-template-rows: 1fr;
+
+ // default (expand)
+ min-height: 64px;
+ max-height: clamp(
+ 64px,
+ 50cqh,
+ min(360px, 50vh)
+ );
+
+ &.n116_9 {
+ min-height: none;
+ max-height: none;
+ aspect-ratio: 16 / 9; // fallback
+ }
+
+ &.n11_1{
+ min-height: none;
+ max-height: none;
+ aspect-ratio: 1 / 1; // fallback
+ }
+
+ &.n12_3 {
+ min-height: none;
+ max-height: none;
+ aspect-ratio: 2 / 3; // fallback
+ }
}
&.n2 {
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index 42abdcbdcc..df26ca3171 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -1,6 +1,6 @@
<template>
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
- <img :class="$style.inner" :src="url" decoding="async"/>
+ <MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<div v-if="user.isCat" :class="[$style.ears]">
<div :class="$style.earLeft">
@@ -30,6 +30,7 @@ import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-bl
import { acct, userPage } from '@/filters/user';
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
import { defaultStore } from '@/store';
+import MkImgWithBlurhash from '../MkImgWithBlurhash.vue';
const animation = $ref(defaultStore.state.animation);
const squareAvatars = $ref(defaultStore.state.squareAvatars);
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 0dfc847049..9d06d35e60 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -56,7 +56,7 @@
<option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
<option value="force">{{ i18n.ts._nsfw.force }}</option>
</MkSelect>
- <!--
+
<MkRadios v-model="mediaListWithOneImageAppearance">
<template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template>
<option value="expand">{{ i18n.ts.default }}</option>
@@ -64,7 +64,6 @@
<option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option>
<option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option>
</MkRadios>
- -->
</div>
</FormSection>
diff --git a/packages/frontend/src/scripts/worker-multi-dispatch.ts b/packages/frontend/src/scripts/worker-multi-dispatch.ts
new file mode 100644
index 0000000000..1847a8ccff
--- /dev/null
+++ b/packages/frontend/src/scripts/worker-multi-dispatch.ts
@@ -0,0 +1,75 @@
+function defaultUseWorkerNumber(prev: number, totalWorkers: number) {
+ return prev + 1;
+}
+
+export class WorkerMultiDispatch<POST = any, RETURN = any> {
+ private symbol = Symbol('WorkerMultiDispatch');
+ private workers: Worker[] = [];
+ private terminated = false;
+ private prevWorkerNumber = 0;
+ private getUseWorkerNumber = defaultUseWorkerNumber;
+ private finalizationRegistry: FinalizationRegistry<symbol>;
+
+ constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) {
+ this.getUseWorkerNumber = getUseWorkerNumber;
+ for (let i = 0; i < concurrency; i++) {
+ this.workers.push(workerConstructor());
+ }
+
+ this.finalizationRegistry = new FinalizationRegistry(() => {
+ this.terminate();
+ });
+ this.finalizationRegistry.register(this, this.symbol);
+
+ if (_DEV_) console.log('WorkerMultiDispatch: Created', this);
+ }
+
+ public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: typeof defaultUseWorkerNumber = this.getUseWorkerNumber) {
+ let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length);
+ workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length;
+ if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber);
+ this.prevWorkerNumber = workerNumber;
+
+ // 不毛だがunionをoverloadに突っ込めない
+ // https://stackoverflow.com/questions/66507585/overload-signatures-union-types-and-no-overload-matches-this-call-error
+ // https://github.com/microsoft/TypeScript/issues/14107
+ if (Array.isArray(options)) {
+ this.workers[workerNumber].postMessage(message, options);
+ } else {
+ this.workers[workerNumber].postMessage(message, options);
+ }
+ return workerNumber;
+ }
+
+ public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
+ this.workers.forEach(worker => {
+ worker.addEventListener('message', callback, options);
+ });
+ }
+
+ public removeListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
+ this.workers.forEach(worker => {
+ worker.removeEventListener('message', callback, options);
+ });
+ }
+
+ public terminate() {
+ this.terminated = true;
+ if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this);
+ this.workers.forEach(worker => {
+ worker.terminate();
+ });
+ this.workers = [];
+ this.finalizationRegistry.unregister(this);
+ }
+
+ public isTerminated() {
+ return this.terminated;
+ }
+ public getWorkers() {
+ return this.workers;
+ }
+ public getSymbol() {
+ return this.symbol;
+ }
+}
diff --git a/packages/frontend/src/workers/draw-blurhash.ts b/packages/frontend/src/workers/draw-blurhash.ts
new file mode 100644
index 0000000000..5f2168a44a
--- /dev/null
+++ b/packages/frontend/src/workers/draw-blurhash.ts
@@ -0,0 +1,15 @@
+import { render } from 'buraha';
+
+onmessage = (event) => {
+ // console.log(event.data);
+ if (!('id' in event.data && typeof event.data.id === 'string')) {
+ return;
+ }
+ if (!('hash' in event.data && typeof event.data.hash === 'string')) {
+ return;
+ }
+ const work = new OffscreenCanvas(event.data.width ?? 64, event.data.height ?? 64);
+ render(event.data.hash, work);
+ const bitmap = work.transferToImageBitmap();
+ postMessage({ id: event.data.id, bitmap });
+};
diff --git a/packages/frontend/src/workers/test-webgl2.ts b/packages/frontend/src/workers/test-webgl2.ts
new file mode 100644
index 0000000000..4769524d9c
--- /dev/null
+++ b/packages/frontend/src/workers/test-webgl2.ts
@@ -0,0 +1,7 @@
+const canvas = new OffscreenCanvas(1, 1);
+const gl = canvas.getContext('webgl2');
+if (gl) {
+ postMessage({ result: true });
+} else {
+ postMessage({ result: false });
+}
diff --git a/packages/frontend/src/workers/tsconfig.json b/packages/frontend/src/workers/tsconfig.json
new file mode 100644
index 0000000000..8ee8930465
--- /dev/null
+++ b/packages/frontend/src/workers/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "compilerOptions": {
+ "lib": ["esnext", "webworker"],
+ }
+}