diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-06-03 19:18:29 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-03 19:18:29 +0900 |
| commit | cd9322a8243b12632db2dd9a29a702d7531a5aa0 (patch) | |
| tree | 2828957ed7c27c537386cda13ace2372903185b8 | |
| parent | chore(frontend): remove duplicate declarations (diff) | |
| download | misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.tar.gz misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.tar.bz2 misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.zip | |
feat(frontend): 画像編集機能 (#16121)
* wip
* wip
* wip
* wip
* Update watermarker.ts
* wip
* wip
* Update watermarker.ts
* Update MkUploaderDialog.vue
* wip
* Update ImageEffector.ts
* Update ImageEffector.ts
* wip
* wip
* wip
* wip
* wip
* wip
* Update MkRange.vue
* Update MkRange.vue
* wip
* wip
* Update MkImageEffectorDialog.vue
* Update MkImageEffectorDialog.Layer.vue
* wip
* Update zoomLines.ts
* Update zoomLines.ts
* wip
* wip
* Update ImageEffector.ts
* wip
* Update ImageEffector.ts
* wip
* Update ImageEffector.ts
* swip
* wip
* Update ImageEffector.ts
* wop
* Update MkUploaderDialog.vue
* Update ImageEffector.ts
* wip
* wip
* wip
* Update def.ts
* Update def.ts
* test
* test
* Update manager.ts
* Update manager.ts
* Update manager.ts
* Update manager.ts
* Update MkImageEffectorDialog.vue
* wip
* use WEBGL_lose_context
* wip
* Update MkUploaderDialog.vue
* Update drive.vue
* wip
* Update MkUploaderDialog.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
33 files changed, 3885 insertions, 106 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c91d602692..659aaae694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - ### Client +- Feat: 画像にウォーターマークを付与できるようになりました - Enhance: ノートのリアクション一覧で、押せるリアクションを優先して表示できるようにするオプションを追加 - Fix: ドライブファイルの選択が不安定な問題を修正 - Fix: コントロールパネルのファイル欄などのデザインが崩れている問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 0119b4ae5e..6f3d2b3853 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5481,6 +5481,14 @@ export interface Locale extends ILocale { * 全ての「ヒントとコツ」を非表示 */ "hideAllTips": string; + /** + * デフォルトの画像圧縮度 + */ + "defaultImageCompressionLevel": string; + /** + * 低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。 + */ + "defaultImageCompressionLevel_description": string; "_chat": { /** * まだメッセージはありません @@ -12024,6 +12032,176 @@ export interface Locale extends ILocale { */ "tip": string; }; + /** + * ウォーターマーク + */ + "watermark": string; + /** + * デフォルトのプリセット + */ + "defaultPreset": string; + "_watermarkEditor": { + /** + * 画像にクレジット情報などのウォーターマークを追加することができます。 + */ + "tip": string; + /** + * 保存せずに終了しますか? + */ + "quitWithoutSaveConfirm": string; + /** + * ウォーターマークの編集 + */ + "title": string; + /** + * 全体に被せる + */ + "cover": string; + /** + * 敷き詰める + */ + "repeat": string; + /** + * 不透明度 + */ + "opacity": string; + /** + * サイズ + */ + "scale": string; + /** + * テキスト + */ + "text": string; + /** + * 位置 + */ + "position": string; + /** + * タイプ + */ + "type": string; + /** + * 画像 + */ + "image": string; + /** + * 高度 + */ + "advanced": string; + /** + * ストライプ + */ + "stripe": string; + /** + * ラインの幅 + */ + "stripeWidth": string; + /** + * ラインの数 + */ + "stripeFrequency": string; + /** + * 角度 + */ + "angle": string; + /** + * ポルカドット + */ + "polkadot": string; + /** + * チェッカー + */ + "checker": string; + /** + * メインドットの不透明度 + */ + "polkadotMainDotOpacity": string; + /** + * メインドットの大きさ + */ + "polkadotMainDotRadius": string; + /** + * サブドットの不透明度 + */ + "polkadotSubDotOpacity": string; + /** + * サブドットの大きさ + */ + "polkadotSubDotRadius": string; + /** + * サブドットの数 + */ + "polkadotSubDotDivisions": string; + }; + "_imageEffector": { + /** + * エフェクト + */ + "title": string; + /** + * エフェクトを追加 + */ + "addEffect": string; + /** + * 変更を破棄して終了しますか? + */ + "discardChangesConfirm": string; + "_fxs": { + /** + * 色収差 + */ + "chromaticAberration": string; + /** + * グリッチ + */ + "glitch": string; + /** + * ミラー + */ + "mirror": string; + /** + * 色の反転 + */ + "invert": string; + /** + * 白黒 + */ + "grayscale": string; + /** + * 色の圧縮 + */ + "colorClamp": string; + /** + * 色の圧縮(高度) + */ + "colorClampAdvanced": string; + /** + * 歪み + */ + "distort": string; + /** + * 二値化 + */ + "threshold": string; + /** + * 集中線 + */ + "zoomLines": string; + /** + * ストライプ + */ + "stripe": string; + /** + * ポルカドット + */ + "polkadot": string; + /** + * チェッカー + */ + "checker": string; + }; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b61bbf4970..d4bd2c3116 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1365,6 +1365,8 @@ abort: "中止" tip: "ヒントとコツ" redisplayAllTips: "全ての「ヒントとコツ」を再表示" hideAllTips: "全ての「ヒントとコツ」を非表示" +defaultImageCompressionLevel: "デフォルトの画像圧縮度" +defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。" _chat: noMessagesYet: "まだメッセージはありません" @@ -3219,3 +3221,50 @@ _clip: _userLists: tip: "任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。" + +watermark: "ウォーターマーク" +defaultPreset: "デフォルトのプリセット" +_watermarkEditor: + tip: "画像にクレジット情報などのウォーターマークを追加することができます。" + quitWithoutSaveConfirm: "保存せずに終了しますか?" + title: "ウォーターマークの編集" + cover: "全体に被せる" + repeat: "敷き詰める" + opacity: "不透明度" + scale: "サイズ" + text: "テキスト" + position: "位置" + type: "タイプ" + image: "画像" + advanced: "高度" + stripe: "ストライプ" + stripeWidth: "ラインの幅" + stripeFrequency: "ラインの数" + angle: "角度" + polkadot: "ポルカドット" + checker: "チェッカー" + polkadotMainDotOpacity: "メインドットの不透明度" + polkadotMainDotRadius: "メインドットの大きさ" + polkadotSubDotOpacity: "サブドットの不透明度" + polkadotSubDotRadius: "サブドットの大きさ" + polkadotSubDotDivisions: "サブドットの数" + +_imageEffector: + title: "エフェクト" + addEffect: "エフェクトを追加" + discardChangesConfirm: "変更を破棄して終了しますか?" + + _fxs: + chromaticAberration: "色収差" + glitch: "グリッチ" + mirror: "ミラー" + invert: "色の反転" + grayscale: "白黒" + colorClamp: "色の圧縮" + colorClampAdvanced: "色の圧縮(高度)" + distort: "歪み" + threshold: "二値化" + zoomLines: "集中線" + stripe: "ストライプ" + polkadot: "ポルカドット" + checker: "チェッカー" diff --git a/packages/frontend/assets/sample/2-3.jpg b/packages/frontend/assets/sample/2-3.jpg Binary files differnew file mode 100644 index 0000000000..ee9bff6527 --- /dev/null +++ b/packages/frontend/assets/sample/2-3.jpg diff --git a/packages/frontend/assets/sample/3-2.jpg b/packages/frontend/assets/sample/3-2.jpg Binary files differnew file mode 100644 index 0000000000..400de1649d --- /dev/null +++ b/packages/frontend/assets/sample/3-2.jpg diff --git a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue new file mode 100644 index 0000000000..0312017d86 --- /dev/null +++ b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue @@ -0,0 +1,78 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder :defaultOpen="true" :canPage="false"> + <template #label>{{ fx.name }}</template> + <template #footer> + <div class="_buttons"> + <MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton> + <MkButton iconOnly @click="emit('swapUp')"><i class="ti ti-arrow-up"></i></MkButton> + <MkButton iconOnly @click="emit('swapDown')"><i class="ti ti-arrow-down"></i></MkButton> + </div> + </template> + + <div :class="$style.root" class="_gaps"> + <div v-for="[k, v] in Object.entries(fx.params)" :key="k"> + <MkSwitch v-if="v.type === 'boolean'" v-model="layer.params[k]"> + <template #label>{{ k }}</template> + </MkSwitch> + <MkRange v-else-if="v.type === 'number'" v-model="layer.params[k]" continuousUpdate :min="v.min" :max="v.max" :step="v.step"> + <template #label>{{ k }}</template> + </MkRange> + <MkRadios v-else-if="v.type === 'number:enum'" v-model="layer.params[k]"> + <template #label>{{ k }}</template> + <option v-for="item in v.enum" :value="item.value">{{ item.label }}</option> + </MkRadios> + <div v-else-if="v.type === 'seed'"> + <MkRange v-model="layer.params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1"> + <template #label>{{ k }}</template> + </MkRange> + </div> + <MkInput v-else-if="v.type === 'color'" :modelValue="`#${(layer.params[k][0] * 255).toString(16).padStart(2, '0')}${(layer.params[k][1] * 255).toString(16).padStart(2, '0')}${(layer.params[k][2] * 255).toString(16).padStart(2, '0')}`" type="color" @update:modelValue="v => { const c = v.slice(1).match(/.{2}/g)?.map(x => parseInt(x, 16) / 255); if (c) layer.params[k] = c; }"> + <template #label>{{ k }}</template> + </MkInput> + </div> + </div> +</MkFolder> +</template> + +<script setup lang="ts"> +import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue'; +import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; +import { i18n } from '@/i18n.js'; +import { ImageEffector } from '@/utility/image-effector/ImageEffector.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkRange from '@/components/MkRange.vue'; +import FormSlot from '@/components/form/slot.vue'; +import MkPositionSelector from '@/components/MkPositionSelector.vue'; +import * as os from '@/os.js'; +import { selectFile } from '@/utility/drive.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; +import { FXS } from '@/utility/image-effector/fxs.js'; + +const layer = defineModel<ImageEffectorLayer>('layer', { required: true }); +const fx = FXS.find((fx) => fx.id === layer.value.fxId); +if (fx == null) { + throw new Error(`Unrecognized effect: ${layer.value.fxId}`); +} + +const emit = defineEmits<{ + (e: 'del'): void; + (e: 'swapUp'): void; + (e: 'swapDown'): void; +}>(); +</script> + +<style module> +.root { + +} +</style> diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue new file mode 100644 index 0000000000..997dd4d528 --- /dev/null +++ b/packages/frontend/src/components/MkImageEffectorDialog.vue @@ -0,0 +1,302 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="true" + @close="cancel()" + @ok="save()" + @closed="emit('closed')" +> + <template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template> + + <div :class="$style.root"> + <div :class="$style.container"> + <div :class="$style.preview"> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button> + <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button> + </div> + </div> + </div> + <div :class="$style.controls"> + <div class="_spacer _gaps"> + <XLayer + v-for="(layer, i) in layers" + :key="layer.id" + v-model:layer="layers[i]" + @del="onLayerDelete(layer)" + @swapUp="onLayerSwapUp(layer)" + @swapDown="onLayerSwapDown(layer)" + ></XLayer> + + <MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton> + </div> + </div> + </div> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; +import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; +import { i18n } from '@/i18n.js'; +import { ImageEffector } from '@/utility/image-effector/ImageEffector.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import XLayer from '@/components/MkImageEffectorDialog.Layer.vue'; +import * as os from '@/os.js'; +import { deepClone } from '@/utility/clone.js'; +import { FXS } from '@/utility/image-effector/fxs.js'; +import { genId } from '@/utility/id.js'; + +const props = defineProps<{ + image: File; +}>(); + +const emit = defineEmits<{ + (ev: 'ok', image: File): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +async function cancel() { + if (layers.length > 0) { + const { canceled } = await os.confirm({ + text: i18n.ts._imageEffector.discardChangesConfirm, + }); + if (canceled) return; + } + + emit('cancel'); + dialog.value?.close(); +} + +const layers = reactive<ImageEffectorLayer[]>([]); + +watch(layers, async () => { + if (renderer != null) { + renderer.setLayers(layers); + } +}, { deep: true }); + +function addEffect(ev: MouseEvent) { + os.popupMenu(FXS.filter(fx => fx.id !== 'watermarkPlacement').map((fx) => ({ + text: fx.name, + action: () => { + layers.push({ + id: genId(), + fxId: fx.id, + params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])), + }); + }, + })), ev.currentTarget ?? ev.target); +} + +function onLayerSwapUp(layer: ImageEffectorLayer) { + const index = layers.indexOf(layer); + if (index > 0) { + layers.splice(index, 1); + layers.splice(index - 1, 0, layer); + } +} + +function onLayerSwapDown(layer: ImageEffectorLayer) { + const index = layers.indexOf(layer); + if (index < layers.length - 1) { + layers.splice(index, 1); + layers.splice(index + 1, 0, layer); + } +} + +function onLayerDelete(layer: ImageEffectorLayer) { + const index = layers.indexOf(layer); + if (index !== -1) { + layers.splice(index, 1); + } +} + +const canvasEl = useTemplateRef('canvasEl'); + +let renderer: ImageEffector | null = null; +let imageBitmap: ImageBitmap | null = null; + +onMounted(async () => { + if (canvasEl.value == null) return; + + const closeWaiting = os.waiting(); + + await nextTick(); // waitingがレンダリングされるまで待つ + + imageBitmap = await window.createImageBitmap(props.image); + + const MAX_W = 1000; + const MAX_H = 1000; + let w = imageBitmap.width; + let h = imageBitmap.height; + + if (w > MAX_W || h > MAX_H) { + const scale = Math.min(MAX_W / w, MAX_H / h); + w *= scale; + h *= scale; + } + + renderer = new ImageEffector({ + canvas: canvasEl.value, + renderWidth: w, + renderHeight: h, + image: imageBitmap, + fxs: FXS, + }); + + await renderer.setLayers(layers); + + renderer.render(); + + closeWaiting(); +}); + +onUnmounted(() => { + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + if (imageBitmap != null) { + imageBitmap.close(); + imageBitmap = null; + } +}); + +async function save() { + if (layers.length === 0 || renderer == null || imageBitmap == null || canvasEl.value == null) { + cancel(); + return; + } + + const closeWaiting = os.waiting(); + + await nextTick(); // waitingがレンダリングされるまで待つ + + renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す + renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる + canvasEl.value.toBlob((blob) => { + emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' })); + dialog.value?.close(); + closeWaiting(); + }, 'image/png'); +} + +const enabled = ref(true); +watch(enabled, () => { + if (renderer != null) { + if (enabled.value) { + renderer.setLayers(layers); + } else { + renderer.setLayers([]); + } + renderer.render(); + } +}); +</script> + +<style module> +.root { + container-type: inline-size; + height: 100%; +} + +.container { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.preview { + position: relative; + background-color: var(--MI_THEME-bg); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); +} + +.previewContainer { + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + -webkit-user-drag: none; +} + +.previewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.previewControls { + position: absolute; + z-index: 100; + bottom: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; +} + +.previewControlsButton { + &.active { + color: var(--MI_THEME-accent); + } +} + +.previewSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.previewCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; + object-fit: contain; +} + +.controls { + overflow-y: scroll; +} + +@container (max-width: 800px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> diff --git a/packages/frontend/src/components/MkPositionSelector.vue b/packages/frontend/src/components/MkPositionSelector.vue new file mode 100644 index 0000000000..002950cdf1 --- /dev/null +++ b/packages/frontend/src/components/MkPositionSelector.vue @@ -0,0 +1,53 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root]"> + <div :class="$style.items"> + <button class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-align-box-left-top"></i></button> + <button class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-align-box-center-top"></i></button> + <button class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-align-box-right-top"></i></button> + <button class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-align-box-left-middle"></i></button> + <button class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-align-box-center-middle"></i></button> + <button class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-align-box-right-middle"></i></button> + <button class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-align-box-left-bottom"></i></button> + <button class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-align-box-center-bottom"></i></button> + <button class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-align-box-right-bottom"></i></button> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const x = defineModel<string>('x', { default: 'center' }); +const y = defineModel<string>('y', { default: 'center' }); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.items { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 4px; + border-radius: 8px; + overflow: clip; +} + +.item { + height: 32px; + background: var(--MI_THEME-panel); + border-radius: 4px; + + &.active { + background: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); + } +} +</style> diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index f36e68b687..9a6a207c74 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -12,7 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only <slot name="prefix"></slot> <div ref="containerEl" class="container"> <div class="track"> - <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div> + <div class="highlight right" :style="{ width: ((steppedRawValue - minRatio) * 100) + '%', left: (Math.abs(Math.min(0, min)) / (max + Math.abs(Math.min(0, min)))) * 100 + '%' }"> + <div class="shine right"></div> + </div> + <div class="highlight left" :style="{ width: ((minRatio - steppedRawValue) * 100) + '%', left: (steppedRawValue) * 100 + '%' }"> + <div class="shine left"></div> + </div> </div> <div v-if="steps && showTicks" class="ticks"> <div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div> @@ -24,7 +29,9 @@ SPDX-License-Identifier: AGPL-3.0-only @mouseenter.passive="onMouseenter" @mousedown="onMousedown" @touchstart="onMousedown" - ></div> + > + <div class="thumbInner"></div> + </div> </div> <slot name="suffix"></slot> </div> @@ -63,6 +70,9 @@ const emit = defineEmits<{ const containerEl = useTemplateRef('containerEl'); const thumbEl = useTemplateRef('thumbEl'); +const maxRatio = computed(() => Math.abs(props.max) / (props.max + Math.abs(Math.min(0, props.min)))); +const minRatio = computed(() => Math.abs(Math.min(0, props.min)) / (props.max + Math.abs(Math.min(0, props.min)))); + const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); const steppedRawValue = computed(() => { if (props.step) { @@ -222,15 +232,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) { } } - $thumbHeight: 20px; - $thumbWidth: 20px; + $thumbHeight: 32px; + $thumbWidth: 32px; + $thumbInnerHeight: 19px; + $thumbInnerWidth: 19px; > .body { display: flex; align-items: center; justify-content: center; gap: 8px; - padding: 7px 12px; + padding: 0px 4px; background: var(--MI_THEME-panel); border: solid 1px var(--MI_THEME-panel); border-radius: 6px; @@ -256,10 +268,30 @@ function onMousedown(ev: MouseEvent | TouchEvent) { > .highlight { position: absolute; top: 0; - left: 0; height: 100%; - background: var(--MI_THEME-accent); - opacity: 0.5; + background: color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0.5); + overflow: clip; + + > .shine { + position: absolute; + top: 0; + width: 64px; + height: 100%; + } + } + + > .highlight.right { + > .shine.right { + right: calc(#{$thumbInnerWidth} / 2); + background: linear-gradient(-90deg, var(--MI_THEME-buttonGradateB), color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0)); + } + } + + > .highlight.left { + > .shine.left { + left: calc(#{$thumbInnerWidth} / 2); + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateB), color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0)); + } } } @@ -290,11 +322,25 @@ function onMousedown(ev: MouseEvent | TouchEvent) { width: $thumbWidth; height: $thumbHeight; cursor: grab; - background: var(--MI_THEME-accent); - border-radius: 999px; &:hover { - background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + > .thumbInner { + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + } + } + + > .thumbInner { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: $thumbInnerWidth; + height: $thumbInnerHeight; + background: var(--MI_THEME-accent); + border-radius: 999px; + pointer-events: none; } } } diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index a0d25d08d3..b2e4896ed3 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="ctx in items" :key="ctx.id" v-panel - :class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]" + :class="[$style.item, ctx.preprocessing ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]" :style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }" > <div :class="$style.itemInner"> @@ -40,8 +40,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div> <div :class="$style.itemInfo"> <span>{{ ctx.file.type }}</span> - <span>{{ bytes(ctx.file.size) }}</span> <span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span> + <span v-else>{{ bytes(ctx.file.size) }}</span> </div> <div> </div> @@ -59,19 +59,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton> </div> - <MkSelect - v-if="items.length > 0" - v-model="compressionLevel" - :items="[ - { value: 0, label: i18n.ts.none }, - { value: 1, label: i18n.ts.low }, - { value: 2, label: i18n.ts.middle }, - { value: 3, label: i18n.ts.high }, - ]" - > - <template #label>{{ i18n.ts.compress }}</template> - </MkSelect> - <div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div> <!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく --> @@ -93,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue'; +import { computed, defineAsyncComponent, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { genId } from '@/utility/id.js'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; @@ -109,6 +96,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js'; import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; +import { WatermarkRenderer } from '@/utility/watermark.js'; const $i = ensureSignin(); @@ -125,6 +113,14 @@ const CROPPING_SUPPORTED_TYPES = [ 'image/webp', ]; +const IMAGE_EDITING_SUPPORTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', +]; + +const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES; + const mimeTypeMap = { 'image/webp': 'webp', 'image/jpeg': 'jpg', @@ -148,16 +144,19 @@ const emit = defineEmits<{ const items = ref<{ id: string; name: string; + uploadName?: string; progress: { max: number; value: number } | null; thumbnail: string; - waiting: boolean; + preprocessing: boolean; uploading: boolean; uploaded: Misskey.entities.DriveFile | null; uploadFailed: boolean; aborted: boolean; + compressionLevel: number; compressedSize?: number | null; - compressedImage?: Blob | null; + preprocessedFile?: Blob | null; file: File; + watermarkPresetId: string | null; abort?: (() => void) | null; }[]>([]); @@ -165,7 +164,7 @@ const dialog = useTemplateRef('dialog'); const firstUploadAttempted = ref(false); const isUploading = computed(() => items.value.some(item => item.uploading)); -const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null)); +const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.preprocessing) && items.value.some(item => item.uploaded == null)); const canDone = computed(() => items.value.some(item => item.uploaded != null)); const overallProgress = computed(() => { const max = items.value.length; @@ -178,19 +177,18 @@ const overallProgress = computed(() => { return Math.round((v / max) * 100); }); -const compressionLevel = ref<0 | 1 | 2 | 3>(2); -const compressionSettings = computed(() => { - if (compressionLevel.value === 1) { +function getCompressionSettings(level: 0 | 1 | 2 | 3) { + if (level === 1) { return { maxWidth: 2000, maxHeight: 2000, }; - } else if (compressionLevel.value === 2) { + } else if (level === 2) { return { maxWidth: 2000 * 0.75, // =1500 maxHeight: 2000 * 0.75, // =1500 }; - } else if (compressionLevel.value === 3) { + } else if (level === 3) { return { maxWidth: 2000 * 0.75 * 0.75, // =1125 maxHeight: 2000 * 0.75 * 0.75, // =1125 @@ -198,7 +196,7 @@ const compressionSettings = computed(() => { } else { return null; } -}); +} watch(items, () => { if (items.value.length === 0) { @@ -274,31 +272,151 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) { }, }); - if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) { + if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) { menu.push({ icon: 'ti ti-crop', text: i18n.ts.cropImage, action: async () => { const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); - items.value.splice(items.value.indexOf(item), 1, { + URL.revokeObjectURL(item.thumbnail); + const newItem = { ...item, file: markRaw(cropped), thumbnail: window.URL.createObjectURL(cropped), + }; + items.value.splice(items.value.indexOf(item), 1, newItem); + preprocess(newItem).then(() => { + triggerRef(items); + }); + }, + }); + } + + if (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) { + menu.push({ + icon: 'ti ti-sparkles', + text: i18n.ts._imageEffector.title + ' (BETA)', + action: async () => { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), { + image: item.file, + }, { + ok: (file) => { + URL.revokeObjectURL(item.thumbnail); + const newItem = { + ...item, + file: markRaw(file), + thumbnail: window.URL.createObjectURL(file), + }; + items.value.splice(items.value.indexOf(item), 1, newItem); + preprocess(newItem).then(() => { + triggerRef(items); + }); + }, + closed: () => dispose(), }); }, }); } - if (!item.waiting && !item.uploading && !item.uploaded) { + if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) { + function changeWatermarkPreset(presetId: string | null) { + item.watermarkPresetId = presetId; + preprocess(item).then(() => { + triggerRef(items); + }); + } + + menu.push({ + icon: 'ti ti-copyright', + text: i18n.ts.watermark, + type: 'parent', + children: [{ + type: 'radioOption', + text: i18n.ts.none, + active: computed(() => item.watermarkPresetId == null), + action: () => changeWatermarkPreset(null), + }, { + type: 'divider', + }, ...prefer.s.watermarkPresets.map(preset => ({ + type: 'radioOption', + text: preset.name, + active: computed(() => item.watermarkPresetId === preset.id), + action: () => changeWatermarkPreset(preset.id), + })), { + type: 'divider', + }, { + type: 'button', + icon: 'ti ti-plus', + text: i18n.ts.add, + action: async () => { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + image: item.file, + }, { + ok: (preset) => { + prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]); + changeWatermarkPreset(preset.id); + }, + closed: () => dispose(), + }); + }, + }], + }); + } + + if (COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) { + function changeCompressionLevel(level: 0 | 1 | 2 | 3) { + item.compressionLevel = level; + preprocess(item).then(() => { + triggerRef(items); + }); + } + + menu.push({ + icon: 'ti ti-leaf', + text: i18n.ts.compress, + type: 'parent', + children: [{ + type: 'radioOption', + text: i18n.ts.none, + active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null), + action: () => changeCompressionLevel(0), + }, { + type: 'divider', + }, { + type: 'radioOption', + text: i18n.ts.low, + active: computed(() => item.compressionLevel === 1), + action: () => changeCompressionLevel(1), + }, { + type: 'radioOption', + text: i18n.ts.medium, + active: computed(() => item.compressionLevel === 2), + action: () => changeCompressionLevel(2), + }, { + type: 'radioOption', + text: i18n.ts.high, + active: computed(() => item.compressionLevel === 3), + action: () => changeCompressionLevel(3), + }, + ], + }); + } + + if (!item.preprocessing && !item.uploading && !item.uploaded) { menu.push({ + type: 'divider', + }, { icon: 'ti ti-x', text: i18n.ts.remove, action: () => { + URL.revokeObjectURL(item.thumbnail); items.value.splice(items.value.indexOf(item), 1); }, }); } else if (item.uploading) { menu.push({ + type: 'divider', + }, { icon: 'ti ti-cloud-pause', text: i18n.ts.abort, danger: true, @@ -320,7 +438,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ ...item, aborted: false, uploadFailed: false, - waiting: false, uploading: false, })); @@ -330,40 +447,13 @@ async function upload() { // エラーハンドリングなどを考慮してシ continue; } - item.waiting = true; item.uploadFailed = false; - - const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file)); - - if (shouldCompress) { - const config = { - mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', - maxWidth: compressionSettings.value.maxWidth, - maxHeight: compressionSettings.value.maxHeight, - quality: isWebpSupported() ? 0.85 : 0.8, - }; - - try { - const result = await readAndCompressImage(item.file, config); - if (result.size < item.file.size || item.file.type === 'image/webp') { - // The compression may not always reduce the file size - // (and WebP is not browser safe yet) - item.compressedImage = markRaw(result); - item.compressedSize = result.size; - item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; - } - } catch (err) { - console.error('Failed to resize image', err); - } - } - item.uploading = true; - const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, { - name: item.name, + const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, { + name: item.uploadName ?? item.name, folderId: props.folderId, onProgress: (progress) => { - item.waiting = false; if (item.progress == null) { item.progress = { max: progress.total, value: progress.loaded }; } else { @@ -377,7 +467,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ item.abort = null; abort(); item.uploading = false; - item.waiting = false; item.uploadFailed = true; }; @@ -392,7 +481,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ } }).finally(() => { item.uploading = false; - item.waiting = false; }); } } @@ -419,21 +507,95 @@ async function chooseFile(ev: MouseEvent) { } } +async function preprocess(item: (typeof items)['value'][number]): Promise<void> { + item.preprocessing = true; + + let file: Blob | File = item.file; + const imageBitmap = await window.createImageBitmap(file); + + const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type); + const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId); + if (needsWatermark && preset != null) { + const canvas = window.document.createElement('canvas'); + const renderer = new WatermarkRenderer({ + canvas: canvas, + renderWidth: imageBitmap.width, + renderHeight: imageBitmap.height, + image: imageBitmap, + }); + + await renderer.setLayers(preset.layers); + + renderer.render(); + + file = await new Promise<Blob>((resolve) => { + canvas.toBlob((blob) => { + if (blob == null) { + throw new Error('Failed to convert canvas to blob'); + } + resolve(blob); + renderer.destroy(); + }, 'image/png'); + }); + } + + const compressionSettings = getCompressionSettings(item.compressionLevel); + const needsCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(file.type) && !(await isAnimated(file)); + + if (needsCompress) { + const config = { + mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', + maxWidth: compressionSettings.maxWidth, + maxHeight: compressionSettings.maxHeight, + quality: isWebpSupported() ? 0.85 : 0.8, + }; + + try { + const result = await readAndCompressImage(file, config); + if (result.size < file.size || file.type === 'image/webp') { + // The compression may not always reduce the file size + // (and WebP is not browser safe yet) + file = result; + item.compressedSize = result.size; + item.uploadName = file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; + } + } catch (err) { + console.error('Failed to resize image', err); + } + } else { + item.compressedSize = null; + item.uploadName = item.name; + } + + URL.revokeObjectURL(item.thumbnail); + item.thumbnail = window.URL.createObjectURL(file); + item.preprocessedFile = markRaw(file); + item.preprocessing = false; + + imageBitmap.close(); +} + function initializeFile(file: File) { const id = genId(); const filename = file.name ?? 'untitled'; const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; - items.value.push({ + const item = { id, name: prefer.s.keepOriginalFilename ? filename : id + extension, progress: null, thumbnail: window.URL.createObjectURL(file), - waiting: false, + preprocessing: false, uploading: false, aborted: false, uploaded: null, uploadFailed: false, + compressionLevel: prefer.s.defaultImageCompressionLevel, + watermarkPresetId: prefer.s.defaultWatermarkPresetId, file: markRaw(file), + }; + items.value.push(item); + preprocess(item).then(() => { + triggerRef(items); }); } @@ -442,6 +604,12 @@ onMounted(() => { initializeFile(file); } }); + +onUnmounted(() => { + for (const item of items.value) { + URL.revokeObjectURL(item.thumbnail); + } +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue new file mode 100644 index 0000000000..10de04c16a --- /dev/null +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -0,0 +1,318 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_gaps"> + <template v-if="layer.type === 'text'"> + <MkInput v-model="layer.text"> + <template #label>{{ i18n.ts._watermarkEditor.text }}</template> + </MkInput> + + <FormSlot> + <template #label>{{ i18n.ts._watermarkEditor.position }}</template> + <MkPositionSelector + v-model:x="layer.align.x" + v-model:y="layer.align.y" + ></MkPositionSelector> + </FormSlot> + + <MkRange + v-model="layer.scale" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + + <MkSwitch v-model="layer.repeat"> + <template #label>{{ i18n.ts._watermarkEditor.repeat }}</template> + </MkSwitch> + </template> + + <template v-else-if="layer.type === 'image'"> + <MkButton inline rounded primary @click="chooseFile">{{ i18n.ts.selectFile }}</MkButton> + + <FormSlot> + <template #label>{{ i18n.ts._watermarkEditor.position }}</template> + <MkPositionSelector + v-model:x="layer.align.x" + v-model:y="layer.align.y" + ></MkPositionSelector> + </FormSlot> + + <MkRange + v-model="layer.scale" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + + <MkSwitch v-model="layer.repeat"> + <template #label>{{ i18n.ts._watermarkEditor.repeat }}</template> + </MkSwitch> + + <MkSwitch v-model="layer.cover"> + <template #label>{{ i18n.ts._watermarkEditor.cover }}</template> + </MkSwitch> + </template> + + <template v-else-if="layer.type === 'stripe'"> + <MkRange + v-model="layer.frequency" + :min="1" + :max="30" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.stripeFrequency }}</template> + </MkRange> + + <MkRange + v-model="layer.threshold" + :min="0" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.stripeWidth }}</template> + </MkRange> + + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + </template> + + <template v-else-if="layer.type === 'polkadot'"> + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.scale" + :min="0" + :max="10" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.majorRadius" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotMainDotRadius }}</template> + </MkRange> + + <MkRange + v-model="layer.majorOpacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotMainDotOpacity }}</template> + </MkRange> + + <MkRange + v-model="layer.minorDivisions" + :min="0" + :max="16" + :step="1" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotDivisions }}</template> + </MkRange> + + <MkRange + v-model="layer.minorRadius" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotRadius }}</template> + </MkRange> + + <MkRange + v-model="layer.minorOpacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotOpacity }}</template> + </MkRange> + </template> + + <template v-else-if="layer.type === 'checker'"> + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.scale" + :min="0" + :max="10" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + </template> +</div> +</template> + +<script setup lang="ts"> +import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue'; +import type { WatermarkPreset } from '@/utility/watermark.js'; +import { i18n } from '@/i18n.js'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkRange from '@/components/MkRange.vue'; +import FormSlot from '@/components/form/slot.vue'; +import MkPositionSelector from '@/components/MkPositionSelector.vue'; +import * as os from '@/os.js'; +import { selectFile } from '@/utility/drive.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; + +const layer = defineModel<WatermarkPreset['layers'][number]>('layer', { required: true }); + +const driveFile = ref(); +const driveFileError = ref(false); +onMounted(async () => { + if (layer.value.type === 'image' && layer.value.imageId != null) { + await misskeyApi('drive/files/show', { + fileId: layer.value.imageId, + }).then((res) => { + driveFile.value = res; + }).catch((err) => { + driveFileError.value = true; + }); + } +}); + +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then((file) => { + if (!file.type.startsWith('image')) { + os.alert({ + type: 'warning', + title: i18n.ts._watermarkEditor.driveFileTypeWarn, + text: i18n.ts._watermarkEditor.driveFileTypeWarnDescription, + }); + return; + } + + layer.value.imageId = file.id; + layer.value.imageUrl = file.url; + driveFileError.value = false; + }); +} +</script> + +<style module> +.root { + +} +</style> diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue new file mode 100644 index 0000000000..4cfb4a72bc --- /dev/null +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -0,0 +1,455 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="true" + @close="cancel()" + @ok="save()" + @closed="emit('closed')" +> + <template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template> + + <div :class="$style.root"> + <div :class="$style.container"> + <div :class="$style.preview"> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + </div> + </div> + </div> + <div :class="$style.controls"> + <div class="_spacer _gaps"> + <MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]"> + <template #label>{{ i18n.ts._watermarkEditor.type }}</template> + </MkSelect> + + <div v-if="type === 'text' || type === 'image'"> + <XLayer + v-for="(layer, i) in preset.layers" + :key="layer.id" + v-model:layer="preset.layers[i]" + ></XLayer> + </div> + <div v-else-if="type === 'advanced'" class="_gaps_s"> + <MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false"> + <template #label> + <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> + <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> + <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> + <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> + <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> + </template> + <template #footer> + <div class="_buttons"> + <MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton> + <MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton> + <MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton> + </div> + </template> + + <XLayer + v-model:layer="preset.layers[i]" + ></XLayer> + </MkFolder> + + <MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton> + </div> + </div> + </div> + </div> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; +import type { WatermarkPreset } from '@/utility/watermark.js'; +import { WatermarkRenderer } from '@/utility/watermark.js'; +import { i18n } from '@/i18n.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import XLayer from '@/components/MkWatermarkEditorDialog.Layer.vue'; +import * as os from '@/os.js'; +import { deepClone } from '@/utility/clone.js'; +import { ensureSignin } from '@/i.js'; +import { genId } from '@/utility/id.js'; + +const $i = ensureSignin(); + +function createTextLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'text', + text: `(c) @${$i.username}`, + align: { x: 'right', y: 'bottom' }, + scale: 0.3, + angle: 0, + opacity: 0.75, + repeat: false, + }; +} + +function createImageLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'image', + imageId: null, + imageUrl: null, + align: { x: 'right', y: 'bottom' }, + scale: 0.3, + angle: 0, + opacity: 0.75, + repeat: false, + cover: false, + }; +} + +function createStripeLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'stripe', + angle: 0.5, + frequency: 10, + threshold: 0.1, + black: false, + opacity: 0.75, + }; +} + +function createPolkadotLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'polkadot', + angle: 0.5, + scale: 3, + majorRadius: 0.1, + minorRadius: 0.25, + majorOpacity: 0.75, + minorOpacity: 0.5, + minorDivisions: 4, + black: false, + opacity: 0.75, + }; +} + +function createCheckerLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'checker', + angle: 0.5, + scale: 3, + black: false, + opacity: 0.75, + }; +} + +const props = defineProps<{ + preset?: WatermarkPreset | null; + image?: File | null; +}>(); + +const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? { + id: genId(), + name: '', + layers: [createTextLayer()], +}); + +const emit = defineEmits<{ + (ev: 'ok', preset: WatermarkPreset): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +async function cancel() { + const { canceled } = await os.confirm({ + text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm, + }); + if (canceled) return; + + emit('cancel'); + dialog.value?.close(); +} + +const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type); +watch(type, () => { + if (type.value === 'text') { + preset.layers = [createTextLayer()]; + } else if (type.value === 'image') { + preset.layers = [createImageLayer()]; + } else if (type.value === 'advanced') { + // nop + } +}); + +watch(preset, async (newValue, oldValue) => { + if (renderer != null) { + renderer.setLayers(preset.layers); + } +}, { deep: true }); + +const canvasEl = useTemplateRef('canvasEl'); + +const sampleImage_3_2 = new Image(); +sampleImage_3_2.src = '/client-assets/sample/3-2.jpg'; +const sampleImage_3_2_loading = new Promise<void>(resolve => { + sampleImage_3_2.onload = () => resolve(); +}); + +const sampleImage_2_3 = new Image(); +sampleImage_2_3.src = '/client-assets/sample/2-3.jpg'; +const sampleImage_2_3_loading = new Promise<void>(resolve => { + sampleImage_2_3.onload = () => resolve(); +}); + +const sampleImageType = ref(props.image != null ? 'provided' : '3_2'); +watch(sampleImageType, async () => { + if (renderer != null) { + renderer.destroy(false); + renderer = null; + initRenderer(); + } +}); + +let renderer: WatermarkRenderer | null = null; +let imageBitmap: ImageBitmap | null = null; + +async function initRenderer() { + if (canvasEl.value == null) return; + + if (sampleImageType.value === '3_2') { + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: 1500, + renderHeight: 1000, + image: sampleImage_3_2, + }); + } else if (sampleImageType.value === '2_3') { + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: 1000, + renderHeight: 1500, + image: sampleImage_2_3, + }); + } else if (props.image != null) { + imageBitmap = await window.createImageBitmap(props.image); + + const MAX_W = 1000; + const MAX_H = 1000; + let w = imageBitmap.width; + let h = imageBitmap.height; + + if (w > MAX_W || h > MAX_H) { + const scale = Math.min(MAX_W / w, MAX_H / h); + w *= scale; + h *= scale; + } + + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: w, + renderHeight: h, + image: imageBitmap, + }); + } + + await renderer!.setLayers(preset.layers); + + renderer!.render(); +} + +onMounted(async () => { + const closeWaiting = os.waiting(); + + await nextTick(); // waitingがレンダリングされるまで待つ + + await sampleImage_3_2_loading; + await sampleImage_2_3_loading; + + await initRenderer(); + + closeWaiting(); +}); + +onUnmounted(() => { + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + if (imageBitmap != null) { + imageBitmap.close(); + imageBitmap = null; + } +}); + +async function save() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.name, + default: preset.name, + }); + if (canceled) return; + + preset.name = name || ''; + + dialog.value?.close(); + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + + emit('ok', preset); +} + +function addLayer(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts._watermarkEditor.text, + action: () => { + preset.layers.push(createTextLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.image, + action: () => { + preset.layers.push(createImageLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.stripe, + action: () => { + preset.layers.push(createStripeLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.polkadot, + action: () => { + preset.layers.push(createPolkadotLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.checker, + action: () => { + preset.layers.push(createCheckerLayer()); + }, + }], ev.currentTarget ?? ev.target); +} + +function swapUpLayer(layer: WatermarkPreset['layers'][number]) { + const index = preset.layers.findIndex(l => l.id === layer.id); + if (index > 0) { + const tmp = preset.layers[index - 1]; + preset.layers[index - 1] = preset.layers[index]; + preset.layers[index] = tmp; + } +} + +function swapDownLayer(layer: WatermarkPreset['layers'][number]) { + const index = preset.layers.findIndex(l => l.id === layer.id); + if (index < preset.layers.length - 1) { + const tmp = preset.layers[index + 1]; + preset.layers[index + 1] = preset.layers[index]; + preset.layers[index] = tmp; + } +} + +function removeLayer(layer: WatermarkPreset['layers'][number]) { + preset.layers = preset.layers.filter(l => l.id !== layer.id); +} +</script> + +<style module> +.root { + container-type: inline-size; + height: 100%; +} + +.container { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.preview { + position: relative; + background-color: var(--MI_THEME-bg); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); +} + +.previewContainer { + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + -webkit-user-drag: none; +} + +.previewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.previewControls { + position: absolute; + z-index: 100; + bottom: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; +} + +.previewControlsButton { + &.active { + color: var(--MI_THEME-accent); + } +} + +.previewSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.previewCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; + object-fit: contain; +} + +.controls { + overflow-y: scroll; +} + +@container (max-width: 800px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue new file mode 100644 index 0000000000..b466f35fc5 --- /dev/null +++ b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue @@ -0,0 +1,112 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder :defaultOpen="false" :canPage="false"> + <template #icon><i class="ti ti-pencil"></i></template> + <template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template> + <template #footer> + <div class="_buttons"> + <MkButton @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton> + <MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton> + </div> + </template> + + <div> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import type { WatermarkPreset } from '@/utility/watermark.js'; +import { WatermarkRenderer } from '@/utility/watermark.js'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { deepClone } from '@/utility/clone.js'; +import MkFolder from '@/components/MkFolder.vue'; + +const props = defineProps<{ + preset: WatermarkPreset; +}>(); + +const emit = defineEmits<{ + (ev: 'updatePreset', preset: WatermarkPreset): void, + (ev: 'del'): void, +}>(); + +async function edit() { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), { + preset: deepClone(props.preset), + }, { + ok: (preset: WatermarkPreset) => { + emit('updatePreset', preset); + }, + closed: () => dispose(), + }); +} + +function del(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.delete, + action: () => { + emit('del'); + }, + }], ev.currentTarget ?? ev.target); +} + +const canvasEl = useTemplateRef('canvasEl'); + +const sampleImage = new Image(); +sampleImage.src = '/client-assets/sample/3-2.jpg'; + +let renderer: WatermarkRenderer | null = null; + +onMounted(() => { + sampleImage.onload = async () => { + watch(canvasEl, async () => { + if (canvasEl.value == null) return; + + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: 1500, + renderHeight: 1000, + image: sampleImage, + }); + + await renderer.setLayers(props.preset.layers); + + renderer.render(); + }, { immediate: true }); + }; +}); + +onUnmounted(() => { + if (renderer != null) { + renderer.destroy(); + renderer = null; + } +}); + +watch(() => props.preset, async () => { + if (renderer != null) { + await renderer.setLayers(props.preset.layers); + renderer.render(); + } +}, { deep: true }); +</script> + +<style lang="scss" module> +.previewCanvas { + display: block; + width: 100%; + height: 100%; + max-height: 200px; + box-sizing: border-box; + object-fit: contain; +} +</style> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index d62e487341..0614b1242b 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -39,53 +39,122 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> </SearchMarker> - <FormSection> - <div class="_gaps_m"> - <SearchMarker :keywords="['default', 'upload', 'folder']"> - <FormLink @click="chooseUploadFolder()"> - <SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel> - <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> - <template #suffixIcon><i class="ti ti-folder"></i></template> + <SearchMarker :keywords="['general']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['default', 'upload', 'folder']"> + <FormLink @click="chooseUploadFolder()"> + <SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel> + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="ti ti-folder"></i></template> + </FormLink> + </SearchMarker> + + <FormLink to="/settings/drive/cleaner"> + {{ i18n.ts.drivecleaner }} </FormLink> - </SearchMarker> - <FormLink to="/settings/drive/cleaner"> - {{ i18n.ts.drivecleaner }} - </FormLink> + <SearchMarker :keywords="['keep', 'original', 'filename']"> + <MkPreferenceContainer k="keepOriginalFilename"> + <MkSwitch v-model="keepOriginalFilename"> + <template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']"> + <MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()"> + <template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <SearchMarker :keywords="['keep', 'original', 'filename']"> - <MkPreferenceContainer k="keepOriginalFilename"> - <MkSwitch v-model="keepOriginalFilename"> - <template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template> + <SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']"> + <MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()"> + <template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template> </MkSwitch> - </MkPreferenceContainer> - </SearchMarker> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> - <SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']"> - <MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()"> - <template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> + <SearchMarker :keywords="['image']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.image }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['watermark', 'credit']"> + <MkFolder> + <template #icon><i class="ti ti-copyright"></i></template> + <template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template> + <template #caption>{{ i18n.ts._watermarkEditor.tip }}</template> + + <div class="_gaps"> + <div class="_gaps_s"> + <XWatermarkItem + v-for="(preset, i) in prefer.r.watermarkPresets.value" + :key="preset.id" + :preset="preset" + @updatePreset="onUpdateWatermarkPreset(preset.id, $event)" + @del="onDeleteWatermarkPreset(preset.id)" + /> + + <MkButton iconOnly rounded style="margin: 0 auto;" @click="addWatermarkPreset"><i class="ti ti-plus"></i></MkButton> - <SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']"> - <MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()"> - <template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> - <template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template> - </MkSwitch> - </SearchMarker> - </div> - </FormSection> + <SearchMarker :keywords="['sync', 'watermark', 'preset', 'devices']"> + <MkSwitch :modelValue="watermarkPresetsSyncEnabled" @update:modelValue="changeWatermarkPresetsSyncEnabled"> + <template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts.syncBetweenDevices }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </div> + + <hr> + + <SearchMarker :keywords="['default', 'watermark', 'preset']"> + <MkPreferenceContainer k="defaultWatermarkPresetId"> + <MkSelect v-model="defaultWatermarkPresetId" :items="[{ label: i18n.ts.none, value: null }, ...prefer.r.watermarkPresets.value.map(p => ({ label: p.name || i18n.ts.noName, value: p.id }))]"> + <template #label><SearchLabel>{{ i18n.ts.defaultPreset }}</SearchLabel></template> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['default', 'image', 'compression']"> + <MkPreferenceContainer k="defaultImageCompressionLevel"> + <MkSelect + v-model="defaultImageCompressionLevel" :items="[ + { label: i18n.ts.none, value: 0 }, + { label: i18n.ts.low, value: 1 }, + { label: i18n.ts.medium, value: 2 }, + { label: i18n.ts.high, value: 3 }, + ]" + > + <template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template> + <template #caption><div v-html="i18n.ts.defaultImageCompressionLevel_description"></div></template> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> </div> </SearchMarker> </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; +import { computed, defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import tinycolor from 'tinycolor2'; +import XWatermarkItem from './drive.WatermarkItem.vue'; +import type { WatermarkPreset } from '@/utility/watermark.js'; import FormLink from '@/components/form/link.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import FormSplit from '@/components/form/split.vue'; @@ -100,6 +169,8 @@ import { prefer } from '@/preferences.js'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import { selectDriveFolder } from '@/utility/drive.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; const $i = ensureSignin(); @@ -123,6 +194,22 @@ const meterStyle = computed(() => { }); const keepOriginalFilename = prefer.model('keepOriginalFilename'); +const defaultWatermarkPresetId = prefer.model('defaultWatermarkPresetId'); +const defaultImageCompressionLevel = prefer.model('defaultImageCompressionLevel'); + +const watermarkPresetsSyncEnabled = ref(prefer.isSyncEnabled('watermarkPresets')); + +function changeWatermarkPresetsSyncEnabled(value: boolean) { + if (value) { + prefer.enableSync('watermarkPresets').then((res) => { + if (res == null) return; + if (res.enabled) watermarkPresetsSyncEnabled.value = true; + }); + } else { + prefer.disableSync('watermarkPresets'); + watermarkPresetsSyncEnabled.value = false; + } +} misskeyApi('drive').then(info => { capacity.value = info.capacity; @@ -152,6 +239,41 @@ function chooseUploadFolder() { }); } +async function addWatermarkPreset() { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + }, { + ok: (preset: WatermarkPreset) => { + prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]); + }, + closed: () => dispose(), + }); +} + +function onUpdateWatermarkPreset(id: string, preset: WatermarkPreset) { + const index = prefer.s.watermarkPresets.findIndex(p => p.id === id); + if (index !== -1) { + prefer.commit('watermarkPresets', [ + ...prefer.s.watermarkPresets.slice(0, index), + preset, + ...prefer.s.watermarkPresets.slice(index + 1), + ]); + } +} + +function onDeleteWatermarkPreset(id: string) { + const index = prefer.s.watermarkPresets.findIndex(p => p.id === id); + if (index !== -1) { + prefer.commit('watermarkPresets', [ + ...prefer.s.watermarkPresets.slice(0, index), + ...prefer.s.watermarkPresets.slice(index + 1), + ]); + + if (prefer.s.defaultWatermarkPresetId === id) { + prefer.commit('defaultWatermarkPresetId', null); + } + } +} + function saveProfile() { misskeyApi('i/update', { alwaysMarkNsfw: !!alwaysMarkNsfw.value, diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 6eb9b2408a..727b79e045 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -11,6 +11,7 @@ import type { SoundType } from '@/utility/sound.js'; import type { Plugin } from '@/plugin.js'; import type { DeviceKind } from '@/utility/device-kind.js'; import type { DeckProfile } from '@/deck.js'; +import type { WatermarkPreset } from '@/utility/watermark.js'; import { genId } from '@/utility/id.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; import { deepEqual } from '@/utility/deep-equal.js'; @@ -396,6 +397,33 @@ export const PREF_DEF = definePreferences({ return [...new Set(a.concat(b))]; }, }, + watermarkPresets: { + accountDependent: true, + default: [] as WatermarkPreset[], + mergeStrategy: (a, b) => { + const mergedItems = [] as typeof a; + for (const x of a.concat(b)) { + const sameIdItem = mergedItems.find(y => y.id === x.id); + if (sameIdItem != null) { + if (deepEqual(x, sameIdItem)) { // 完全な重複は無視 + continue; + } else { // IDは同じなのに内容が違う場合はマージ不可とする + throw new Error(); + } + } else { + mergedItems.push(x); + } + } + return mergedItems; + }, + }, + defaultWatermarkPresetId: { + accountDependent: true, + default: null as WatermarkPreset['id'] | null, + }, + defaultImageCompressionLevel: { + default: 2, + }, 'sound.masterVolume': { default: 0.5, diff --git a/packages/frontend/src/utility/image-effector/ImageEffector.ts b/packages/frontend/src/utility/image-effector/ImageEffector.ts new file mode 100644 index 0000000000..fe253017e5 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/ImageEffector.ts @@ -0,0 +1,476 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { getProxiedImageUrl } from '../media-proxy.js'; + +type ParamTypeToPrimitive = { + 'number': number; + 'number:enum': number; + 'boolean': boolean; + 'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; }; + 'seed': number; + 'texture': { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null; + 'color': [r: number, g: number, b: number]; +}; + +type ImageEffectorFxParamDefs = Record<string, { + type: keyof ParamTypeToPrimitive; + default: any; +}>; + +export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) { + return fx; +} + +export type ImageEffectorFx<ID extends string = string, PS extends ImageEffectorFxParamDefs = ImageEffectorFxParamDefs, US extends string[] = string[]> = { + id: ID; + name: string; + shader: string; + uniforms: US; + params: PS, + main: (ctx: { + gl: WebGL2RenderingContext; + program: WebGLProgram; + params: { + [key in keyof PS]: ParamTypeToPrimitive[PS[key]['type']]; + }; + u: Record<US[number], WebGLUniformLocation>; + width: number; + height: number; + textures: Record<string, { + texture: WebGLTexture; + width: number; + height: number; + } | null>; + }) => void; +}; + +export type ImageEffectorLayer = { + id: string; + fxId: string; + params: Record<string, any>; +}; + +function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, any>, k: string): ParamTypeToPrimitive[T] { + return params[k]; +} + +export class ImageEffector { + private gl: WebGL2RenderingContext; + private canvas: HTMLCanvasElement | null = null; + private renderTextureProgram: WebGLProgram; + private renderInvertedTextureProgram: WebGLProgram; + private renderWidth: number; + private renderHeight: number; + private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement; + private layers: ImageEffectorLayer[] = []; + private originalImageTexture: WebGLTexture; + private shaderCache: Map<string, WebGLProgram> = new Map(); + private perLayerResultTextures: Map<string, WebGLTexture> = new Map(); + private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map(); + private fxs: ImageEffectorFx[]; + private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map(); + + constructor(options: { + canvas: HTMLCanvasElement; + renderWidth: number; + renderHeight: number; + image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement; + fxs: ImageEffectorFx[]; + }) { + this.canvas = options.canvas; + this.renderWidth = options.renderWidth; + this.renderHeight = options.renderHeight; + this.originalImage = options.image; + this.fxs = options.fxs; + + this.canvas.width = this.renderWidth; + this.canvas.height = this.renderHeight; + + const gl = this.canvas.getContext('webgl2', { + preserveDrawingBuffer: false, + alpha: true, + premultipliedAlpha: false, + }); + + if (gl == null) { + throw new Error('Failed to initialize WebGL2 context'); + } + + this.gl = gl; + + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + + const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]); + const vertexBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW); + + this.originalImageTexture = createTexture(gl); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.originalImage.width, this.originalImage.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage); + gl.bindTexture(gl.TEXTURE_2D, null); + + this.renderTextureProgram = this.initShaderProgram(`#version 300 es + in vec2 position; + out vec2 in_uv; + + void main() { + in_uv = (position + 1.0) / 2.0; + gl_Position = vec4(position, 0.0, 1.0); + } + `, `#version 300 es + precision mediump float; + + in vec2 in_uv; + uniform sampler2D u_texture; + out vec4 out_color; + + void main() { + out_color = texture(u_texture, in_uv); + } + `); + + this.renderInvertedTextureProgram = this.initShaderProgram(`#version 300 es + in vec2 position; + out vec2 in_uv; + + void main() { + in_uv = (position + 1.0) / 2.0; + in_uv.y = 1.0 - in_uv.y; + gl_Position = vec4(position, 0.0, 1.0); + } + `, `#version 300 es + precision mediump float; + + in vec2 in_uv; + uniform sampler2D u_texture; + out vec4 out_color; + + void main() { + out_color = texture(u_texture, in_uv); + } + `); + } + + public loadShader(type: GLenum, source: string): WebGLShader { + const gl = this.gl; + + const shader = gl.createShader(type); + if (shader == null) { + throw new Error('falied to create shader'); + } + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error(`falied to compile shader: ${gl.getShaderInfoLog(shader)}`); + gl.deleteShader(shader); + throw new Error(`falied to compile shader: ${gl.getShaderInfoLog(shader)}`); + } + + return shader; + } + + public initShaderProgram(vsSource: string, fsSource: string): WebGLProgram { + const gl = this.gl; + + const vertexShader = this.loadShader(gl.VERTEX_SHADER, vsSource); + const fragmentShader = this.loadShader(gl.FRAGMENT_SHADER, fsSource); + + const shaderProgram = gl.createProgram(); + + gl.attachShader(shaderProgram, vertexShader); + gl.attachShader(shaderProgram, fragmentShader); + gl.linkProgram(shaderProgram); + + if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { + console.error(`failed to init shader: ${gl.getProgramInfoLog(shaderProgram)}`); + throw new Error('failed to init shader'); + } + + return shaderProgram; + } + + private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture) { + const gl = this.gl; + + const fx = this.fxs.find(fx => fx.id === layer.fxId); + if (fx == null) return; + + const cachedShader = this.shaderCache.get(fx.id); + const shaderProgram = cachedShader ?? this.initShaderProgram(`#version 300 es + in vec2 position; + out vec2 in_uv; + + void main() { + in_uv = (position + 1.0) / 2.0; + gl_Position = vec4(position, 0.0, 1.0); + } + `, fx.shader); + if (cachedShader == null) { + this.shaderCache.set(fx.id, shaderProgram); + } + + gl.useProgram(shaderProgram); + + const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution'); + gl.uniform2fv(in_resolution, [this.renderWidth, this.renderHeight]); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, preTexture); + const in_texture = gl.getUniformLocation(shaderProgram, 'in_texture'); + gl.uniform1i(in_texture, 0); + + fx.main({ + gl: gl, + program: shaderProgram, + params: Object.fromEntries( + Object.entries(fx.params).map(([key, param]) => { + return [key, layer.params[key] ?? param.default]; + }), + ), + u: Object.fromEntries(fx.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])), + width: this.renderWidth, + height: this.renderHeight, + textures: Object.fromEntries( + Object.entries(fx.params).map(([k, v]) => { + if (v.type !== 'texture') return [k, null]; + const param = getValue<typeof v.type>(layer.params, k); + if (param == null) return [k, null]; + const texture = this.paramTextures.get(this.getTextureKeyForParam(param)) ?? null; + return [k, texture]; + })), + }); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + public render() { + const gl = this.gl; + + { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture); + + gl.useProgram(this.renderTextureProgram); + const u_texture = gl.getUniformLocation(this.renderTextureProgram, 'u_texture'); + gl.uniform1i(u_texture, 0); + const u_resolution = gl.getUniformLocation(this.renderTextureProgram, 'u_resolution'); + gl.uniform2fv(u_resolution, [this.renderWidth, this.renderHeight]); + const positionLocation = gl.getAttribLocation(this.renderTextureProgram, 'position'); + gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(positionLocation); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + // -------------------- + + let preTexture = this.originalImageTexture; + + for (const layer of this.layers) { + const cachedResultTexture = this.perLayerResultTextures.get(layer.id); + const resultTexture = cachedResultTexture ?? createTexture(gl); + if (cachedResultTexture == null) { + this.perLayerResultTextures.set(layer.id, resultTexture); + } + gl.bindTexture(gl.TEXTURE_2D, resultTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.bindTexture(gl.TEXTURE_2D, null); + + const cachedResultFrameBuffer = this.perLayerResultFrameBuffers.get(layer.id); + const resultFrameBuffer = cachedResultFrameBuffer ?? gl.createFramebuffer()!; + if (cachedResultFrameBuffer == null) { + this.perLayerResultFrameBuffers.set(layer.id, resultFrameBuffer); + } + gl.bindFramebuffer(gl.FRAMEBUFFER, resultFrameBuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, resultTexture, 0); + + this.renderLayer(layer, preTexture); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + preTexture = resultTexture; + } + + // -------------------- + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.useProgram(this.renderInvertedTextureProgram); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, preTexture); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + public async setLayers(layers: ImageEffectorLayer[]) { + this.layers = layers; + + const unused = new Set(this.paramTextures.keys()); + + for (const layer of layers) { + const fx = this.fxs.find(fx => fx.id === layer.fxId); + if (fx == null) continue; + + for (const k of Object.keys(layer.params)) { + const paramDef = fx.params[k]; + if (paramDef == null) continue; + if (paramDef.type !== 'texture') continue; + const v = getValue<typeof paramDef.type>(layer.params, k); + if (v == null) continue; + + const textureKey = this.getTextureKeyForParam(v); + unused.delete(textureKey); + if (this.paramTextures.has(textureKey)) continue; + + console.log(`Baking texture of <${textureKey}>...`); + + const texture = v.type === 'text' ? await createTextureFromText(this.gl, v.text) : v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : null; + if (texture == null) continue; + + this.paramTextures.set(textureKey, texture); + } + } + + for (const k of unused) { + console.log(`Dispose unused texture <${k}>...`); + this.gl.deleteTexture(this.paramTextures.get(k)!.texture); + this.paramTextures.delete(k); + } + + this.render(); + } + + public changeResolution(width: number, height: number) { + this.renderWidth = width; + this.renderHeight = height; + if (this.canvas) { + this.canvas.width = this.renderWidth; + this.canvas.height = this.renderHeight; + } + this.gl.viewport(0, 0, this.renderWidth, this.renderHeight); + } + + private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) { + if (v == null) return ''; + return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : ''; + } + + /* + * disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意 + */ + public destroy(disposeCanvas = true) { + for (const shader of this.shaderCache.values()) { + this.gl.deleteProgram(shader); + } + this.shaderCache.clear(); + + for (const texture of this.perLayerResultTextures.values()) { + this.gl.deleteTexture(texture); + } + this.perLayerResultTextures.clear(); + + for (const framebuffer of this.perLayerResultFrameBuffers.values()) { + this.gl.deleteFramebuffer(framebuffer); + } + this.perLayerResultFrameBuffers.clear(); + + for (const texture of this.paramTextures.values()) { + this.gl.deleteTexture(texture.texture); + } + this.paramTextures.clear(); + + this.gl.deleteProgram(this.renderTextureProgram); + this.gl.deleteProgram(this.renderInvertedTextureProgram); + this.gl.deleteTexture(this.originalImageTexture); + + if (disposeCanvas) { + const loseContextExt = this.gl.getExtension('WEBGL_lose_context'); + if (loseContextExt) loseContextExt.loseContext(); + } + } +} + +function createTexture(gl: WebGL2RenderingContext): WebGLTexture { + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.bindTexture(gl.TEXTURE_2D, null); + return texture; +} + +async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string | null): Promise<{ texture: WebGLTexture, width: number, height: number } | null> { + if (imageUrl == null || imageUrl.trim() === '') return null; + + const image = await new Promise<HTMLImageElement>((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = getProxiedImageUrl(imageUrl); // CORS対策 + }).catch(() => null); + + if (image == null) return null; + + const texture = createTexture(gl); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); + gl.bindTexture(gl.TEXTURE_2D, null); + + return { + texture, + width: image.width, + height: image.height, + }; +} + +async function createTextureFromText(gl: WebGL2RenderingContext, text: string | null, resolution = 2048): Promise<{ texture: WebGLTexture, width: number, height: number } | null> { + if (text == null || text.trim() === '') return null; + + const ctx = window.document.createElement('canvas').getContext('2d')!; + ctx.canvas.width = resolution; + ctx.canvas.height = resolution / 4; + const fontSize = resolution / 32; + const margin = fontSize / 2; + ctx.shadowColor = '#000000'; + ctx.shadowBlur = fontSize / 4; + + //ctx.fillStyle = '#00ff00'; + //ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + ctx.fillStyle = '#ffffff'; + ctx.font = `bold ${fontSize}px sans-serif`; + ctx.textBaseline = 'middle'; + + ctx.fillText(text, margin, ctx.canvas.height / 2); + + const textMetrics = ctx.measureText(text); + const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin); + const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin); + const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), ctx.canvas.width, ctx.canvas.height); + + const texture = createTexture(gl); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cropWidth, cropHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); + gl.bindTexture(gl.TEXTURE_2D, null); + + const info = { + texture: texture, + width: cropWidth, + height: cropHeight, + }; + + ctx.canvas.remove(); + + return info; +} diff --git a/packages/frontend/src/utility/image-effector/fxs.ts b/packages/frontend/src/utility/image-effector/fxs.ts new file mode 100644 index 0000000000..5887a68c43 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FX_checker } from './fxs/checker.js'; +import { FX_chromaticAberration } from './fxs/chromaticAberration.js'; +import { FX_colorClamp } from './fxs/colorClamp.js'; +import { FX_colorClampAdvanced } from './fxs/colorClampAdvanced.js'; +import { FX_distort } from './fxs/distort.js'; +import { FX_polkadot } from './fxs/polkadot.js'; +import { FX_glitch } from './fxs/glitch.js'; +import { FX_grayscale } from './fxs/grayscale.js'; +import { FX_invert } from './fxs/invert.js'; +import { FX_mirror } from './fxs/mirror.js'; +import { FX_stripe } from './fxs/stripe.js'; +import { FX_threshold } from './fxs/threshold.js'; +import { FX_watermarkPlacement } from './fxs/watermarkPlacement.js'; +import { FX_zoomLines } from './fxs/zoomLines.js'; +import type { ImageEffectorFx } from './ImageEffector.js'; + +export const FXS = [ + FX_watermarkPlacement, + FX_chromaticAberration, + FX_glitch, + FX_mirror, + FX_invert, + FX_grayscale, + FX_colorClamp, + FX_colorClampAdvanced, + FX_distort, + FX_threshold, + FX_zoomLines, + FX_stripe, + FX_polkadot, + FX_checker, +] as const satisfies ImageEffectorFx<string, any>[]; diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.ts b/packages/frontend/src/utility/image-effector/fxs/checker.ts new file mode 100644 index 0000000000..b799bd0d13 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/checker.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_angle; +uniform float u_scale; +uniform vec3 u_color; +uniform float u_opacity; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + float angle = -(u_angle * PI); + vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ); + + float fmodResult = mod(floor(u_scale * rotatedUV.x) + floor(u_scale * rotatedUV.y), 2.0); + float fin = max(sign(fmodResult), 0.0); + + out_color = vec4( + mix(in_color.r, u_color.r, fin * u_opacity), + mix(in_color.g, u_color.g, fin * u_opacity), + mix(in_color.b, u_color.b, fin * u_opacity), + in_color.a + ); +} +`; + +export const FX_checker = defineImageEffectorFx({ + id: 'checker' as const, + name: i18n.ts._imageEffector._fxs.checker, + shader, + uniforms: ['angle', 'scale', 'color', 'opacity'] as const, + params: { + angle: { + type: 'number' as const, + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + }, + scale: { + type: 'number' as const, + default: 3.0, + min: 1.0, + max: 10.0, + step: 0.1, + }, + color: { + type: 'color' as const, + default: [1, 1, 1], + }, + opacity: { + type: 'number' as const, + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1f(u.angle, params.angle / 2); + gl.uniform1f(u.scale, params.scale * params.scale); + gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]); + gl.uniform1f(u.opacity, params.opacity); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts b/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts new file mode 100644 index 0000000000..82d7d883aa --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +out vec4 out_color; +uniform float u_amount; +uniform float u_start; +uniform bool u_normalize; + +void main() { + int samples = 64; + float r_strength = 1.0; + float g_strength = 1.5; + float b_strength = 2.0; + + vec2 size = vec2(in_resolution.x, in_resolution.y); + + vec4 accumulator = vec4(0.0); + float normalisedValue = length((in_uv - 0.5) * 2.0); + float strength = clamp((normalisedValue - u_start) * (1.0 / (1.0 - u_start)), 0.0, 1.0); + + vec2 vector = (u_normalize ? normalize(in_uv - vec2(0.5)) : in_uv - vec2(0.5)); + vec2 velocity = vector * strength * u_amount; + + vec2 rOffset = -vector * strength * (u_amount * r_strength); + vec2 gOffset = -vector * strength * (u_amount * g_strength); + vec2 bOffset = -vector * strength * (u_amount * b_strength); + + for (int i = 0; i < samples; i++) { + accumulator.r += texture(in_texture, in_uv + rOffset).r; + rOffset -= velocity / float(samples); + + accumulator.g += texture(in_texture, in_uv + gOffset).g; + gOffset -= velocity / float(samples); + + accumulator.b += texture(in_texture, in_uv + bOffset).b; + bOffset -= velocity / float(samples); + } + + out_color = vec4(vec3(accumulator / float(samples)), 1.0); +} +`; + +export const FX_chromaticAberration = defineImageEffectorFx({ + id: 'chromaticAberration' as const, + name: i18n.ts._imageEffector._fxs.chromaticAberration, + shader, + uniforms: ['amount', 'start', 'normalize'] as const, + params: { + normalize: { + type: 'boolean' as const, + default: false, + }, + amount: { + type: 'number' as const, + default: 0.1, + min: 0.0, + max: 1.0, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1f(u.amount, params.amount); + gl.uniform1i(u.normalize, params.normalize ? 1 : 0); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts b/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts new file mode 100644 index 0000000000..5393d73df0 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_max; +uniform float u_min; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float r = min(max(in_color.r, u_min), u_max); + float g = min(max(in_color.g, u_min), u_max); + float b = min(max(in_color.b, u_min), u_max); + out_color = vec4(r, g, b, in_color.a); +} +`; + +export const FX_colorClamp = defineImageEffectorFx({ + id: 'colorClamp' as const, + name: i18n.ts._imageEffector._fxs.colorClamp, + shader, + uniforms: ['max', 'min'] as const, + params: { + max: { + type: 'number' as const, + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + }, + min: { + type: 'number' as const, + default: -1.0, + min: -1.0, + max: 0.0, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1f(u.max, params.max); + gl.uniform1f(u.min, 1.0 + params.min); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts b/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts new file mode 100644 index 0000000000..c66d75a83f --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_rMax; +uniform float u_rMin; +uniform float u_gMax; +uniform float u_gMin; +uniform float u_bMax; +uniform float u_bMin; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float r = min(max(in_color.r, u_rMin), u_rMax); + float g = min(max(in_color.g, u_gMin), u_gMax); + float b = min(max(in_color.b, u_bMin), u_bMax); + out_color = vec4(r, g, b, in_color.a); +} +`; + +export const FX_colorClampAdvanced = defineImageEffectorFx({ + id: 'colorClampAdvanced' as const, + name: i18n.ts._imageEffector._fxs.colorClampAdvanced, + shader, + uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const, + params: { + rMax: { + type: 'number' as const, + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + }, + rMin: { + type: 'number' as const, + default: -1.0, + min: -1.0, + max: 0.0, + step: 0.01, + }, + gMax: { + type: 'number' as const, + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + }, + gMin: { + type: 'number' as const, + default: -1.0, + min: -1.0, + max: 0.0, + step: 0.01, + }, + bMax: { + type: 'number' as const, + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + }, + bMin: { + type: 'number' as const, + default: -1.0, + min: -1.0, + max: 0.0, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1f(u.rMax, params.rMax); + gl.uniform1f(u.rMin, 1.0 + params.rMin); + gl.uniform1f(u.gMax, params.gMax); + gl.uniform1f(u.gMin, 1.0 + params.gMin); + gl.uniform1f(u.bMax, params.bMax); + gl.uniform1f(u.bMin, 1.0 + params.bMin); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.ts b/packages/frontend/src/utility/image-effector/fxs/distort.ts new file mode 100644 index 0000000000..f91287c038 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/distort.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_phase; +uniform float u_frequency; +uniform float u_strength; +uniform int u_direction; // 0: vertical, 1: horizontal +out vec4 out_color; + +void main() { + float v = u_direction == 0 ? + sin(u_phase + in_uv.y * u_frequency) * u_strength : + sin(u_phase + in_uv.x * u_frequency) * u_strength; + vec4 in_color = u_direction == 0 ? + texture(in_texture, vec2(in_uv.x + v, in_uv.y)) : + texture(in_texture, vec2(in_uv.x, in_uv.y + v)); + out_color = in_color; +} +`; + +export const FX_distort = defineImageEffectorFx({ + id: 'distort' as const, + name: i18n.ts._imageEffector._fxs.distort, + shader, + uniforms: ['phase', 'frequency', 'strength', 'direction'] as const, + params: { + direction: { + type: 'number:enum' as const, + enum: [{ value: 0, label: 'v' }, { value: 1, label: 'h' }], + default: 0, + }, + phase: { + type: 'number' as const, + default: 50.0, + min: 0.0, + max: 100, + step: 0.01, + }, + frequency: { + type: 'number' as const, + default: 50, + min: 0, + max: 100, + step: 0.1, + }, + strength: { + type: 'number' as const, + default: 0.1, + min: 0, + max: 1, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1f(u.phase, params.phase / 10); + gl.uniform1f(u.frequency, params.frequency); + gl.uniform1f(u.strength, params.strength); + gl.uniform1i(u.direction, params.direction); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/glitch.ts b/packages/frontend/src/utility/image-effector/fxs/glitch.ts new file mode 100644 index 0000000000..e4939a4302 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/glitch.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import seedrandom from 'seedrandom'; +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform int u_amount; +uniform float u_shiftStrengths[128]; +uniform float u_shiftOrigins[128]; +uniform float u_shiftHeights[128]; +uniform float u_channelShift; +out vec4 out_color; + +void main() { + float v = 0.0; + + for (int i = 0; i < u_amount; i++) { + if (in_uv.y > (u_shiftOrigins[i] - u_shiftHeights[i]) && in_uv.y < (u_shiftOrigins[i] + u_shiftHeights[i])) { + v += u_shiftStrengths[i]; + } + } + + float r = texture(in_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r; + float g = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).g; + float b = texture(in_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b; + float a = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).a; + out_color = vec4(r, g, b, a); +} +`; + +export const FX_glitch = defineImageEffectorFx({ + id: 'glitch' as const, + name: i18n.ts._imageEffector._fxs.glitch, + shader, + uniforms: ['amount', 'channelShift'] as const, + params: { + amount: { + type: 'number' as const, + default: 3, + min: 1, + max: 100, + step: 1, + }, + strength: { + type: 'number' as const, + default: 5, + min: -100, + max: 100, + step: 0.01, + }, + size: { + type: 'number' as const, + default: 20, + min: 0, + max: 100, + step: 0.01, + }, + channelShift: { + type: 'number' as const, + default: 0.5, + min: 0, + max: 10, + step: 0.01, + }, + seed: { + type: 'seed' as const, + default: 100, + }, + }, + main: ({ gl, program, u, params }) => { + gl.uniform1i(u.amount, params.amount); + gl.uniform1f(u.channelShift, params.channelShift); + + const rnd = seedrandom(params.seed.toString()); + + for (let i = 0; i < params.amount; i++) { + const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`); + gl.uniform1f(o, rnd()); + + const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`); + gl.uniform1f(s, (1 - (rnd() * 2)) * (params.strength / 100)); + + const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`); + gl.uniform1f(h, rnd() * (params.size / 100)); + } + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/grayscale.ts b/packages/frontend/src/utility/image-effector/fxs/grayscale.ts new file mode 100644 index 0000000000..8f33706ae7 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/grayscale.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +out vec4 out_color; + +float getBrightness(vec4 color) { + return (color.r + color.g + color.b) / 3.0; +} + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float brightness = getBrightness(in_color); + out_color = vec4(brightness, brightness, brightness, in_color.a); +} +`; + +export const FX_grayscale = defineImageEffectorFx({ + id: 'grayscale' as const, + name: i18n.ts._imageEffector._fxs.grayscale, + shader, + uniforms: [] as const, + params: { + }, + main: ({ gl, params }) => { + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/invert.ts b/packages/frontend/src/utility/image-effector/fxs/invert.ts new file mode 100644 index 0000000000..220a2dea30 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/invert.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform bool u_r; +uniform bool u_g; +uniform bool u_b; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + out_color.r = u_r ? 1.0 - in_color.r : in_color.r; + out_color.g = u_g ? 1.0 - in_color.g : in_color.g; + out_color.b = u_b ? 1.0 - in_color.b : in_color.b; + out_color.a = in_color.a; +} +`; + +export const FX_invert = defineImageEffectorFx({ + id: 'invert' as const, + name: i18n.ts._imageEffector._fxs.invert, + shader, + uniforms: ['r', 'g', 'b'] as const, + params: { + r: { + type: 'boolean' as const, + default: true, + }, + g: { + type: 'boolean' as const, + default: true, + }, + b: { + type: 'boolean' as const, + default: true, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1i(u.r, params.r ? 1 : 0); + gl.uniform1i(u.g, params.g ? 1 : 0); + gl.uniform1i(u.b, params.b ? 1 : 0); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.ts b/packages/frontend/src/utility/image-effector/fxs/mirror.ts new file mode 100644 index 0000000000..5946a2e0dc --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/mirror.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform int u_h; +uniform int u_v; +out vec4 out_color; + +void main() { + vec2 uv = in_uv; + if (u_h == -1 && in_uv.x > 0.5) { + uv.x = 1.0 - uv.x; + } + if (u_h == 1 && in_uv.x < 0.5) { + uv.x = 1.0 - uv.x; + } + if (u_v == -1 && in_uv.y > 0.5) { + uv.y = 1.0 - uv.y; + } + if (u_v == 1 && in_uv.y < 0.5) { + uv.y = 1.0 - uv.y; + } + out_color = texture(in_texture, uv); +} +`; + +export const FX_mirror = defineImageEffectorFx({ + id: 'mirror' as const, + name: i18n.ts._imageEffector._fxs.mirror, + shader, + uniforms: ['h', 'v'] as const, + params: { + h: { + type: 'number:enum' as const, + enum: [{ value: -1, label: '<-' }, { value: 0, label: '|' }, { value: 1, label: '->' }], + default: -1, + }, + v: { + type: 'number:enum' as const, + enum: [{ value: -1, label: '^' }, { value: 0, label: '-' }, { value: 1, label: 'v' }], + default: 0, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1i(u.h, params.h); + gl.uniform1i(u.v, params.v); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts b/packages/frontend/src/utility/image-effector/fxs/polkadot.ts new file mode 100644 index 0000000000..198dd9bad0 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/polkadot.ts @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_angle; +uniform float u_scale; +uniform float u_major_radius; +uniform float u_major_opacity; +uniform float u_minor_divisions; +uniform float u_minor_radius; +uniform float u_minor_opacity; +uniform vec3 u_color; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + float angle = -(u_angle * PI); + vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ); + + float major_modX = mod(rotatedUV.x, (1.0 / u_scale)); + float major_modY = mod(rotatedUV.y, (1.0 / u_scale)); + float major_threshold = ((u_major_radius / 2.0) / u_scale); + if ( + length(vec2(major_modX, major_modY)) < major_threshold || + length(vec2((1.0 / u_scale) - major_modX, major_modY)) < major_threshold || + length(vec2(major_modX, (1.0 / u_scale) - major_modY)) < major_threshold || + length(vec2((1.0 / u_scale) - major_modX, (1.0 / u_scale) - major_modY)) < major_threshold + ) { + out_color = vec4( + mix(in_color.r, u_color.r, u_major_opacity), + mix(in_color.g, u_color.g, u_major_opacity), + mix(in_color.b, u_color.b, u_major_opacity), + in_color.a + ); + return; + } + + float minor_modX = mod(rotatedUV.x, (1.0 / u_scale / u_minor_divisions)); + float minor_modY = mod(rotatedUV.y, (1.0 / u_scale / u_minor_divisions)); + float minor_threshold = ((u_minor_radius / 2.0) / (u_minor_divisions * u_scale)); + if ( + length(vec2(minor_modX, minor_modY)) < minor_threshold || + length(vec2((1.0 / u_scale / u_minor_divisions) - minor_modX, minor_modY)) < minor_threshold || + length(vec2(minor_modX, (1.0 / u_scale / u_minor_divisions) - minor_modY)) < minor_threshold || + length(vec2((1.0 / u_scale / u_minor_divisions) - minor_modX, (1.0 / u_scale / u_minor_divisions) - minor_modY)) < minor_threshold + ) { + out_color = vec4( + mix(in_color.r, u_color.r, u_minor_opacity), + mix(in_color.g, u_color.g, u_minor_opacity), + mix(in_color.b, u_color.b, u_minor_opacity), + in_color.a + ); + return; + } + + out_color = in_color; +} +`; + +export const FX_polkadot = defineImageEffectorFx({ + id: 'polkadot' as const, + name: i18n.ts._imageEffector._fxs.polkadot, + shader, + uniforms: ['angle', 'scale', 'major_radius', 'major_opacity', 'minor_divisions', 'minor_radius', 'minor_opacity', 'color'] as const, + params: { + angle: { + type: 'number' as const, + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + }, + scale: { + type: 'number' as const, + default: 3.0, + min: 1.0, + max: 10.0, + step: 0.1, + }, + majorRadius: { + type: 'number' as const, + default: 0.1, + min: 0.0, + max: 1.0, + step: 0.01, + }, + majorOpacity: { + type: 'number' as const, + default: 0.75, + min: 0.0, + max: 1.0, + step: 0.01, + }, + minorDivisions: { + type: 'number' as const, + default: 4, + min: 0, + max: 16, + step: 1, + }, + minorRadius: { + type: 'number' as const, + default: 0.25, + min: 0.0, + max: 1.0, + step: 0.01, + }, + minorOpacity: { + type: 'number' as const, + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + color: { + type: 'color' as const, + default: [1, 1, 1], + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1f(u.angle, params.angle / 2); + gl.uniform1f(u.scale, params.scale * params.scale); + gl.uniform1f(u.major_radius, params.majorRadius); + gl.uniform1f(u.major_opacity, params.majorOpacity); + gl.uniform1f(u.minor_divisions, params.minorDivisions); + gl.uniform1f(u.minor_radius, params.minorRadius); + gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]); + gl.uniform1f(u.minor_opacity, params.minorOpacity); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.ts b/packages/frontend/src/utility/image-effector/fxs/stripe.ts new file mode 100644 index 0000000000..37766e185d --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/stripe.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_angle; +uniform float u_frequency; +uniform float u_phase; +uniform float u_threshold; +uniform vec3 u_color; +uniform float u_opacity; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + float angle = -(u_angle * PI); + vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ); + + float phase = u_phase * TWO_PI; + float value = (1.0 + sin((rotatedUV.x * u_frequency - HALF_PI) + phase)) / 2.0; + value = value < u_threshold ? 1.0 : 0.0; + out_color = vec4( + mix(in_color.r, u_color.r, value * u_opacity), + mix(in_color.g, u_color.g, value * u_opacity), + mix(in_color.b, u_color.b, value * u_opacity), + in_color.a + ); +} +`; + +export const FX_stripe = defineImageEffectorFx({ + id: 'stripe' as const, + name: i18n.ts._imageEffector._fxs.stripe, + shader, + uniforms: ['angle', 'frequency', 'phase', 'threshold', 'color', 'opacity'] as const, + params: { + angle: { + type: 'number' as const, + default: 0.5, + min: -1.0, + max: 1.0, + step: 0.01, + }, + frequency: { + type: 'number' as const, + default: 10.0, + min: 1.0, + max: 30.0, + step: 0.1, + }, + threshold: { + type: 'number' as const, + default: 0.1, + min: 0.0, + max: 1.0, + step: 0.01, + }, + color: { + type: 'color' as const, + default: [1, 1, 1], + }, + opacity: { + type: 'number' as const, + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1f(u.angle, params.angle / 2); + gl.uniform1f(u.frequency, params.frequency * params.frequency); + gl.uniform1f(u.phase, 0.0); + gl.uniform1f(u.threshold, params.threshold); + gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]); + gl.uniform1f(u.opacity, params.opacity); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.ts b/packages/frontend/src/utility/image-effector/fxs/threshold.ts new file mode 100644 index 0000000000..f2b8b107fd --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/threshold.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_r; +uniform float u_g; +uniform float u_b; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float r = in_color.r < u_r ? 0.0 : 1.0; + float g = in_color.g < u_g ? 0.0 : 1.0; + float b = in_color.b < u_b ? 0.0 : 1.0; + out_color = vec4(r, g, b, in_color.a); +} +`; + +export const FX_threshold = defineImageEffectorFx({ + id: 'threshold' as const, + name: i18n.ts._imageEffector._fxs.threshold, + shader, + uniforms: ['r', 'g', 'b'] as const, + params: { + r: { + type: 'number' as const, + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + g: { + type: 'number' as const, + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + b: { + type: 'number' as const, + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform1f(u.r, params.r); + gl.uniform1f(u.g, params.g); + gl.uniform1f(u.b, params.b); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts new file mode 100644 index 0000000000..1c1c95b0c5 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; + +const shader = `#version 300 es +precision mediump float; + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform sampler2D u_texture_watermark; +uniform vec2 u_resolution_watermark; +uniform float u_scale; +uniform float u_angle; +uniform float u_opacity; +uniform bool u_repeat; +uniform int u_alignX; // 0: left, 1: center, 2: right +uniform int u_alignY; // 0: top, 1: center, 2: bottom +uniform int u_fitMode; // 0: contain, 1: cover +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float in_x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + float in_y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + bool contain = u_fitMode == 0; + + float x_ratio = u_resolution_watermark.x / in_resolution.x; + float y_ratio = u_resolution_watermark.y / in_resolution.y; + + float aspect_ratio = contain ? + (min(x_ratio, y_ratio) / max(x_ratio, y_ratio)) : + (max(x_ratio, y_ratio) / min(x_ratio, y_ratio)); + + float x_scale = contain ? + (x_ratio > y_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) : + (x_ratio > y_ratio ? aspect_ratio * u_scale : 1.0 * u_scale); + + float y_scale = contain ? + (y_ratio > x_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) : + (y_ratio > x_ratio ? aspect_ratio * u_scale : 1.0 * u_scale); + + float x_offset = u_alignX == 0 ? x_scale / 2.0 : u_alignX == 2 ? 1.0 - (x_scale / 2.0) : 0.5; + float y_offset = u_alignY == 0 ? y_scale / 2.0 : u_alignY == 2 ? 1.0 - (y_scale / 2.0) : 0.5; + + float angle = -(u_angle * PI); + vec2 center = vec2(x_offset, y_offset); + //vec2 centeredUv = (in_uv - center) * vec2(in_x_ratio, in_y_ratio); + vec2 centeredUv = (in_uv - center); + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ) + center; + + // trim + if (!u_repeat) { + bool isInside = rotatedUV.x > x_offset - (x_scale / 2.0) && rotatedUV.x < x_offset + (x_scale / 2.0) && + rotatedUV.y > y_offset - (y_scale / 2.0) && rotatedUV.y < y_offset + (y_scale / 2.0); + if (!isInside) { + out_color = in_color; + return; + } + } + + vec4 watermark_color = texture(u_texture_watermark, vec2( + (rotatedUV.x - (x_offset - (x_scale / 2.0))) / x_scale, + (rotatedUV.y - (y_offset - (y_scale / 2.0))) / y_scale + )); + + out_color.r = mix(in_color.r, watermark_color.r, u_opacity * watermark_color.a); + out_color.g = mix(in_color.g, watermark_color.g, u_opacity * watermark_color.a); + out_color.b = mix(in_color.b, watermark_color.b, u_opacity * watermark_color.a); + out_color.a = in_color.a * (1.0 - u_opacity * watermark_color.a) + watermark_color.a * u_opacity; +} +`; + +export const FX_watermarkPlacement = defineImageEffectorFx({ + id: 'watermarkPlacement' as const, + name: '(internal)', + shader, + uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'fitMode'] as const, + params: { + cover: { + type: 'boolean' as const, + default: false, + }, + repeat: { + type: 'boolean' as const, + default: false, + }, + scale: { + type: 'number' as const, + default: 0.3, + min: 0.0, + max: 1.0, + step: 0.01, + }, + angle: { + type: 'number' as const, + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + }, + align: { + type: 'align' as const, + default: { x: 'right', y: 'bottom' }, + }, + opacity: { + type: 'number' as const, + default: 0.75, + min: 0.0, + max: 1.0, + step: 0.01, + }, + watermark: { + type: 'texture' as const, + default: null, + }, + }, + main: ({ gl, u, params, textures }) => { + if (textures.watermark == null) { + return; + } + + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, textures.watermark.texture); + gl.uniform1i(u.texture_watermark, 1); + + gl.uniform2fv(u.resolution_watermark, [textures.watermark.width, textures.watermark.height]); + gl.uniform1f(u.scale, params.scale); + + gl.uniform1f(u.opacity, params.opacity); + gl.uniform1f(u.angle, params.angle); + gl.uniform1i(u.repeat, params.repeat ? 1 : 0); + gl.uniform1i(u.alignX, params.align.x === 'left' ? 0 : params.align.x === 'right' ? 2 : 1); + gl.uniform1i(u.alignY, params.align.y === 'top' ? 0 : params.align.y === 'bottom' ? 2 : 1); + gl.uniform1i(u.fitMode, params.cover ? 1 : 0); + }, +}); diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts new file mode 100644 index 0000000000..2613362a71 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineImageEffectorFx } from '../ImageEffector.js'; +import { i18n } from '@/i18n.js'; + +const shader = `#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform vec2 u_pos; +uniform float u_frequency; +uniform bool u_thresholdEnabled; +uniform float u_threshold; +uniform float u_maskSize; +uniform bool u_black; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float angle = atan(-u_pos.y + (in_uv.y), -u_pos.x + (in_uv.x)); + float t = (1.0 + sin(angle * u_frequency)) / 2.0; + if (u_thresholdEnabled) t = t < u_threshold ? 1.0 : 0.0; + float d = distance(in_uv * vec2(2.0, 2.0), u_pos * vec2(2.0, 2.0)); + float mask = d < u_maskSize ? 0.0 : ((d - u_maskSize) * (1.0 + (u_maskSize * 2.0))); + out_color = vec4( + mix(in_color.r, u_black ? 0.0 : 1.0, t * mask), + mix(in_color.g, u_black ? 0.0 : 1.0, t * mask), + mix(in_color.b, u_black ? 0.0 : 1.0, t * mask), + in_color.a + ); +} +`; + +export const FX_zoomLines = defineImageEffectorFx({ + id: 'zoomLines' as const, + name: i18n.ts._imageEffector._fxs.zoomLines, + shader, + uniforms: ['pos', 'frequency', 'thresholdEnabled', 'threshold', 'maskSize', 'black'] as const, + params: { + x: { + type: 'number' as const, + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + }, + y: { + type: 'number' as const, + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + }, + frequency: { + type: 'number' as const, + default: 30.0, + min: 1.0, + max: 200.0, + step: 0.1, + }, + thresholdEnabled: { + type: 'boolean' as const, + default: true, + }, + threshold: { + type: 'number' as const, + default: 0.2, + min: 0.0, + max: 1.0, + step: 0.01, + }, + maskSize: { + type: 'number' as const, + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + black: { + type: 'boolean' as const, + default: false, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform2f(u.pos, (1.0 + params.x) / 2.0, (1.0 + params.y) / 2.0); + gl.uniform1f(u.frequency, params.frequency); + gl.uniform1i(u.thresholdEnabled, params.thresholdEnabled ? 1 : 0); + gl.uniform1f(u.threshold, params.threshold); + gl.uniform1f(u.maskSize, params.maskSize); + gl.uniform1i(u.black, params.black ? 1 : 0); + }, +}); diff --git a/packages/frontend/src/utility/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts index 5c86969876..65398e6a43 100644 --- a/packages/frontend/src/utility/snowfall-effect.ts +++ b/packages/frontend/src/utility/snowfall-effect.ts @@ -38,7 +38,7 @@ export class SnowfallEffect { `; private FRAGMENT_SOURCE = `#version 300 es - precision highp float; + precision mediump float; in vec4 v_color; in float v_rotation; diff --git a/packages/frontend/src/utility/watermark.ts b/packages/frontend/src/utility/watermark.ts new file mode 100644 index 0000000000..8ee93181a6 --- /dev/null +++ b/packages/frontend/src/utility/watermark.ts @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FX_watermarkPlacement } from './image-effector/fxs/watermarkPlacement.js'; +import { FX_stripe } from './image-effector/fxs/stripe.js'; +import { FX_polkadot } from './image-effector/fxs/polkadot.js'; +import { FX_checker } from './image-effector/fxs/checker.js'; +import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; +import { ImageEffector } from '@/utility/image-effector/ImageEffector.js'; + +export type WatermarkPreset = { + id: string; + name: string; + layers: ({ + id: string; + type: 'text'; + text: string; + repeat: boolean; + scale: number; + angle: number; + align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' }; + opacity: number; + } | { + id: string; + type: 'image'; + imageUrl: string | null; + imageId: string | null; + cover: boolean; + repeat: boolean; + scale: number; + angle: number; + align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' }; + opacity: number; + } | { + id: string; + type: 'stripe'; + angle: number; + frequency: number; + threshold: number; + color: [r: number, g: number, b: number]; + opacity: number; + } | { + id: string; + type: 'polkadot'; + angle: number; + scale: number; + majorRadius: number; + majorOpacity: number; + minorDivisions: number; + minorRadius: number; + minorOpacity: number; + color: [r: number, g: number, b: number]; + opacity: number; + } | { + id: string; + type: 'checker'; + angle: number; + scale: number; + color: [r: number, g: number, b: number]; + opacity: number; + })[]; +}; + +export class WatermarkRenderer { + private effector: ImageEffector; + private layers: WatermarkPreset['layers'] = []; + + constructor(options: { + canvas: HTMLCanvasElement, + renderWidth: number, + renderHeight: number, + image: HTMLImageElement | ImageBitmap, + }) { + this.effector = new ImageEffector({ + canvas: options.canvas, + renderWidth: options.renderWidth, + renderHeight: options.renderHeight, + image: options.image, + fxs: [FX_watermarkPlacement, FX_stripe, FX_polkadot, FX_checker], + }); + } + + private makeImageEffectorLayers(): ImageEffectorLayer[] { + return this.layers.map(layer => { + if (layer.type === 'text') { + return { + fxId: 'watermarkPlacement', + id: layer.id, + params: { + repeat: layer.repeat, + scale: layer.scale, + align: layer.align, + angle: layer.angle, + opacity: layer.opacity, + cover: false, + watermark: { + type: 'text', + text: layer.text, + }, + }, + }; + } else if (layer.type === 'image') { + return { + fxId: 'watermarkPlacement', + id: layer.id, + params: { + repeat: layer.repeat, + scale: layer.scale, + align: layer.align, + angle: layer.angle, + opacity: layer.opacity, + cover: layer.cover, + watermark: { + type: 'url', + url: layer.imageUrl, + }, + }, + }; + } else if (layer.type === 'stripe') { + return { + fxId: 'stripe', + id: layer.id, + params: { + angle: layer.angle, + frequency: layer.frequency, + threshold: layer.threshold, + color: layer.color, + opacity: layer.opacity, + }, + }; + } else if (layer.type === 'polkadot') { + return { + fxId: 'polkadot', + id: layer.id, + params: { + angle: layer.angle, + scale: layer.scale, + majorRadius: layer.majorRadius, + majorOpacity: layer.majorOpacity, + minorDivisions: layer.minorDivisions, + minorRadius: layer.minorRadius, + minorOpacity: layer.minorOpacity, + color: layer.color, + opacity: layer.opacity, + }, + }; + } else if (layer.type === 'checker') { + return { + fxId: 'checker', + id: layer.id, + params: { + angle: layer.angle, + scale: layer.scale, + color: layer.color, + opacity: layer.opacity, + }, + }; + } + }); + } + + public async setLayers(layers: WatermarkPreset['layers']) { + this.layers = layers; + await this.effector.setLayers(this.makeImageEffectorLayers()); + this.render(); + } + + public render(): void { + this.effector.render(); + } + + /* + * disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意 + */ + public destroy(disposeCanvas = true): void { + this.effector.destroy(disposeCanvas); + } +} |