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 41e9aa6f9b
.
* 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.
* ✌️
* workerNumber固定は不要
---------
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
parent
3804c6e7ad
commit
59255e11b8
@ -103,6 +103,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
|
|||||||
* 画像が全て隠れた状態で表示されるようになります
|
* 画像が全て隠れた状態で表示されるようになります
|
||||||
- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように
|
- 閲覧注意設定された画像は表示した状態でもそれが閲覧注意だと分かる表示をするように
|
||||||
- モデレーターはノートに添付された画像上から直接NSFW設定できるように
|
- モデレーターはノートに添付された画像上から直接NSFW設定できるように
|
||||||
|
- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように
|
||||||
- プロフィール設定「追加情報」の項目の削除と並び替えができるように
|
- プロフィール設定「追加情報」の項目の削除と並び替えができるように
|
||||||
- 新しい実績を追加
|
- 新しい実績を追加
|
||||||
- AiScriptを0.13.2に更新
|
- AiScriptを0.13.2に更新
|
||||||
|
@ -25,9 +25,9 @@
|
|||||||
"@vue-macros/reactivity-transform": "0.3.7",
|
"@vue-macros/reactivity-transform": "0.3.7",
|
||||||
"@vue/compiler-sfc": "3.3.2",
|
"@vue/compiler-sfc": "3.3.2",
|
||||||
"autosize": "6.0.1",
|
"autosize": "6.0.1",
|
||||||
"blurhash": "2.0.5",
|
|
||||||
"broadcast-channel": "4.20.2",
|
"broadcast-channel": "4.20.2",
|
||||||
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||||
|
"buraha": "github:misskey-dev/buraha",
|
||||||
"canvas-confetti": "1.6.0",
|
"canvas-confetti": "1.6.0",
|
||||||
"chart.js": "4.3.0",
|
"chart.js": "4.3.0",
|
||||||
"chartjs-adapter-date-fns": "3.0.0",
|
"chartjs-adapter-date-fns": "3.0.0",
|
||||||
|
@ -5,12 +5,9 @@
|
|||||||
<ImgWithBlurhash
|
<ImgWithBlurhash
|
||||||
class="img layered"
|
class="img layered"
|
||||||
:transition="safe ? null : {
|
:transition="safe ? null : {
|
||||||
enterActiveClass: $style.transition_toggle_enterActive,
|
duration: 500,
|
||||||
leaveActiveClass: $style.transition_toggle_leaveActive,
|
leaveActiveClass: $style.transition_toggle_leaveActive,
|
||||||
enterFromClass: $style.transition_toggle_enterFrom,
|
|
||||||
leaveToClass: $style.transition_toggle_leaveTo,
|
leaveToClass: $style.transition_toggle_leaveTo,
|
||||||
enterToClass: $style.transition_toggle_enterTo,
|
|
||||||
leaveFromClass: $style.transition_toggle_leaveFrom,
|
|
||||||
}"
|
}"
|
||||||
:src="post.files[0].thumbnailUrl"
|
:src="post.files[0].thumbnailUrl"
|
||||||
:hash="post.files[0].blurhash"
|
:hash="post.files[0].blurhash"
|
||||||
@ -53,24 +50,16 @@ function leaveHover(): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.transition_toggle_enterActive,
|
|
||||||
.transition_toggle_leaveActive {
|
.transition_toggle_leaveActive {
|
||||||
transition: opacity 0.5s;
|
transition: opacity .5s;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition_toggle_enterFrom,
|
|
||||||
.transition_toggle_leaveTo {
|
.transition_toggle_leaveTo {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition_toggle_enterTo,
|
|
||||||
.transition_toggle_leaveFrom {
|
|
||||||
transition: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -1,30 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
|
<div ref="root" :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
|
||||||
<img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/>
|
<TransitionGroup
|
||||||
<Transition
|
:duration="defaultStore.state.animation && props.transition?.duration || undefined"
|
||||||
mode="in-out"
|
:enter-active-class="defaultStore.state.animation && props.transition?.enterActiveClass || undefined"
|
||||||
: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_leaveActive']) || undefined"
|
||||||
:leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_toggle_leaveActive']) || undefined"
|
|
||||||
:enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
|
:enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
|
||||||
:leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || 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"
|
:enter-to-class="defaultStore.state.animation && props.transition?.enterToClass || undefined"
|
||||||
:leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || 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"/>
|
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
|
||||||
<img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? 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"/>
|
||||||
</Transition>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts">
|
||||||
import { onMounted, shallowRef, useCssModule, watch } from 'vue';
|
import DrawBlurhash from '@/workers/draw-blurhash?worker';
|
||||||
import { decode } from 'blurhash';
|
import TestWebGL2 from '@/workers/test-webgl2?worker';
|
||||||
import { defaultStore } from '@/store';
|
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 { 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 $style = useCssModule();
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
transition?: {
|
transition?: {
|
||||||
|
duration?: number | { enter: number; leave: number; };
|
||||||
enterActiveClass?: string;
|
enterActiveClass?: string;
|
||||||
leaveActiveClass?: string;
|
leaveActiveClass?: string;
|
||||||
enterFromClass?: string;
|
enterFromClass?: string;
|
||||||
@ -51,67 +77,141 @@ const props = withDefaults(defineProps<{
|
|||||||
forceBlurhash: false,
|
forceBlurhash: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const viewId = uuid();
|
||||||
const canvas = shallowRef<HTMLCanvasElement>();
|
const canvas = shallowRef<HTMLCanvasElement>();
|
||||||
|
const root = shallowRef<HTMLDivElement>();
|
||||||
|
const img = shallowRef<HTMLImageElement>();
|
||||||
let loaded = $ref(false);
|
let loaded = $ref(false);
|
||||||
let width = $ref(props.width);
|
let canvasWidth = $ref(64);
|
||||||
let height = $ref(props.height);
|
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() {
|
function waitForDecode() {
|
||||||
loaded = true;
|
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;
|
const ratio = props.width / props.height;
|
||||||
if (ratio > 1) {
|
if (ratio > 1) {
|
||||||
width = Math.round(64 * ratio);
|
canvasWidth = Math.round(64 * ratio);
|
||||||
height = 64;
|
canvasHeight = 64;
|
||||||
} else {
|
} else {
|
||||||
width = 64;
|
canvasWidth = 64;
|
||||||
height = Math.round(64 / ratio);
|
canvasHeight = Math.round(64 / ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientWidth = root.value?.clientWidth ?? 300;
|
||||||
|
imgWidth = clientWidth;
|
||||||
|
imgHeight = Math.round(clientWidth / ratio);
|
||||||
}, {
|
}, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function draw() {
|
function drawImage(bitmap: CanvasImageSource) {
|
||||||
if (props.hash == null || !canvas.value) return;
|
// canvasがない(mountedされていない)場合はTmpに保存しておく
|
||||||
const pixels = decode(props.hash, width, height);
|
if (!canvas.value) {
|
||||||
|
bitmapTmp = bitmap;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// canvasがあれば描画する
|
||||||
|
bitmapTmp = undefined;
|
||||||
const ctx = canvas.value.getContext('2d');
|
const ctx = canvas.value.getContext('2d');
|
||||||
const imageData = ctx!.createImageData(width, height);
|
if (!ctx) return;
|
||||||
imageData.data.set(pixels);
|
ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
|
||||||
ctx!.putImageData(imageData, 0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.src, () => {
|
||||||
|
waitForDecode();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.hash, () => {
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
draw();
|
// drawImageがmountedより先に呼ばれている場合はここで描画する
|
||||||
|
if (bitmapTmp) {
|
||||||
|
drawImage(bitmapTmp);
|
||||||
|
}
|
||||||
|
waitForDecode();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
workerPromise.then(worker => {
|
||||||
|
worker?.removeListener(workerOnMessage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.transition_toggle_enterActive,
|
.transition_leaveActive {
|
||||||
.transition_toggle_leaveActive {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition_toggle_enterTo,
|
|
||||||
.transition_toggle_leaveFrom {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -1,29 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="hide" :class="$style.hidden" @click="hide = false">
|
<div :class="hide ? $style.hidden : $style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
|
||||||
<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%);'">
|
|
||||||
<a
|
<a
|
||||||
:class="$style.imageContainer"
|
:class="$style.imageContainer"
|
||||||
:href="image.url"
|
:href="image.url"
|
||||||
:title="image.name"
|
: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>
|
</a>
|
||||||
<div :class="$style.indicators">
|
<template v-if="hide">
|
||||||
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
|
<div :class="$style.hiddenText">
|
||||||
<div v-if="image.comment" :class="$style.indicator">ALT</div>
|
<div :class="$style.hiddenTextWrapper">
|
||||||
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
|
<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>
|
||||||
</div>
|
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
|
||||||
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
|
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||||
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -53,6 +64,12 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
|
|||||||
: props.image.thumbnailUrl,
|
: props.image.thumbnailUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function onclick() {
|
||||||
|
if (hide) {
|
||||||
|
hide = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||||
watch(() => props.image, () => {
|
watch(() => props.image, () => {
|
||||||
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
$style.medias,
|
$style.medias,
|
||||||
count <= 4 ? $style['n' + count] : $style.nMany,
|
count <= 4 ? $style['n' + count] : $style.nMany,
|
||||||
|
$style[`n1${defaultStore.reactiveState.mediaListWithOneImageAppearance.value}`]
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<template v-for="media in mediaList.filter(media => previewable(media))">
|
<template v-for="media in mediaList.filter(media => previewable(media))">
|
||||||
@ -19,7 +20,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 * as misskey from 'misskey-js';
|
||||||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||||||
import PhotoSwipe from 'photoswipe';
|
import PhotoSwipe from 'photoswipe';
|
||||||
@ -38,11 +39,42 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
const gallery = ref<HTMLDivElement>();
|
const gallery = shallowRef<HTMLDivElement>();
|
||||||
const pswpZIndex = os.claimZIndex('middle');
|
const pswpZIndex = os.claimZIndex('middle');
|
||||||
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
|
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
|
||||||
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
|
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(() => {
|
onMounted(() => {
|
||||||
const lightbox = new PhotoSwipeLightbox({
|
const lightbox = new PhotoSwipeLightbox({
|
||||||
dataSource: props.mediaList
|
dataSource: props.mediaList
|
||||||
@ -162,12 +194,37 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: 8px;
|
grid-gap: 8px;
|
||||||
|
|
||||||
// for webkit
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&.n1 {
|
&.n1 {
|
||||||
aspect-ratio: 16/9;
|
|
||||||
grid-template-rows: 1fr;
|
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 {
|
&.n2 {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<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">
|
<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"/>
|
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||||
<div v-if="user.isCat" :class="[$style.ears]">
|
<div v-if="user.isCat" :class="[$style.ears]">
|
||||||
<div :class="$style.earLeft">
|
<div :class="$style.earLeft">
|
||||||
@ -30,6 +30,7 @@ import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-bl
|
|||||||
import { acct, userPage } from '@/filters/user';
|
import { acct, userPage } from '@/filters/user';
|
||||||
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
|
import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
|
import MkImgWithBlurhash from '../MkImgWithBlurhash.vue';
|
||||||
|
|
||||||
const animation = $ref(defaultStore.state.animation);
|
const animation = $ref(defaultStore.state.animation);
|
||||||
const squareAvatars = $ref(defaultStore.state.squareAvatars);
|
const squareAvatars = $ref(defaultStore.state.squareAvatars);
|
||||||
|
@ -56,7 +56,7 @@
|
|||||||
<option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
|
<option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
|
||||||
<option value="force">{{ i18n.ts._nsfw.force }}</option>
|
<option value="force">{{ i18n.ts._nsfw.force }}</option>
|
||||||
</MkSelect>
|
</MkSelect>
|
||||||
<!--
|
|
||||||
<MkRadios v-model="mediaListWithOneImageAppearance">
|
<MkRadios v-model="mediaListWithOneImageAppearance">
|
||||||
<template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template>
|
<template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template>
|
||||||
<option value="expand">{{ i18n.ts.default }}</option>
|
<option value="expand">{{ i18n.ts.default }}</option>
|
||||||
@ -64,7 +64,6 @@
|
|||||||
<option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option>
|
<option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option>
|
||||||
<option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option>
|
<option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option>
|
||||||
</MkRadios>
|
</MkRadios>
|
||||||
-->
|
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
75
packages/frontend/src/scripts/worker-multi-dispatch.ts
Normal file
75
packages/frontend/src/scripts/worker-multi-dispatch.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
15
packages/frontend/src/workers/draw-blurhash.ts
Normal file
15
packages/frontend/src/workers/draw-blurhash.ts
Normal file
@ -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 });
|
||||||
|
};
|
7
packages/frontend/src/workers/test-webgl2.ts
Normal file
7
packages/frontend/src/workers/test-webgl2.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const canvas = new OffscreenCanvas(1, 1);
|
||||||
|
const gl = canvas.getContext('webgl2');
|
||||||
|
if (gl) {
|
||||||
|
postMessage({ result: true });
|
||||||
|
} else {
|
||||||
|
postMessage({ result: false });
|
||||||
|
}
|
5
packages/frontend/src/workers/tsconfig.json
Normal file
5
packages/frontend/src/workers/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["esnext", "webworker"],
|
||||||
|
}
|
||||||
|
}
|
@ -139,6 +139,10 @@ export function getConfig(): UserConfig {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
|
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
deps: {
|
deps: {
|
||||||
|
@ -657,15 +657,15 @@ importers:
|
|||||||
autosize:
|
autosize:
|
||||||
specifier: 6.0.1
|
specifier: 6.0.1
|
||||||
version: 6.0.1
|
version: 6.0.1
|
||||||
blurhash:
|
|
||||||
specifier: 2.0.5
|
|
||||||
version: 2.0.5
|
|
||||||
broadcast-channel:
|
broadcast-channel:
|
||||||
specifier: 4.20.2
|
specifier: 4.20.2
|
||||||
version: 4.20.2
|
version: 4.20.2
|
||||||
browser-image-resizer:
|
browser-image-resizer:
|
||||||
specifier: github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3
|
specifier: github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3
|
||||||
version: github.com/misskey-dev/browser-image-resizer/0227e860621e55cbed0aabe6dc601096a7748c4a
|
version: github.com/misskey-dev/browser-image-resizer/0227e860621e55cbed0aabe6dc601096a7748c4a
|
||||||
|
buraha:
|
||||||
|
specifier: github:misskey-dev/buraha
|
||||||
|
version: github.com/misskey-dev/buraha/92b20c1ab15c5cb5a224cf3b1ecd4f6baca12b7c
|
||||||
canvas-confetti:
|
canvas-confetti:
|
||||||
specifier: 1.6.0
|
specifier: 1.6.0
|
||||||
version: 1.6.0
|
version: 1.6.0
|
||||||
@ -20410,6 +20410,12 @@ packages:
|
|||||||
version: 2.2.1-misskey.3
|
version: 2.2.1-misskey.3
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
github.com/misskey-dev/buraha/92b20c1ab15c5cb5a224cf3b1ecd4f6baca12b7c:
|
||||||
|
resolution: {tarball: https://codeload.github.com/misskey-dev/buraha/tar.gz/92b20c1ab15c5cb5a224cf3b1ecd4f6baca12b7c}
|
||||||
|
name: buraha
|
||||||
|
version: 0.0.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
github.com/misskey-dev/sharp-read-bmp/02d9dc189fa7df0c4bea09330be26741772dac01:
|
github.com/misskey-dev/sharp-read-bmp/02d9dc189fa7df0c4bea09330be26741772dac01:
|
||||||
resolution: {tarball: https://codeload.github.com/misskey-dev/sharp-read-bmp/tar.gz/02d9dc189fa7df0c4bea09330be26741772dac01}
|
resolution: {tarball: https://codeload.github.com/misskey-dev/sharp-read-bmp/tar.gz/02d9dc189fa7df0c4bea09330be26741772dac01}
|
||||||
name: sharp-read-bmp
|
name: sharp-read-bmp
|
||||||
|
Loading…
Reference in New Issue
Block a user