From cd9322a8243b12632db2dd9a29a702d7531a5aa0 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Tue, 3 Jun 2025 19:18:29 +0900
Subject: feat(frontend): 画像編集機能 (#16121)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* 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
---
 .../components/MkWatermarkEditorDialog.Layer.vue   | 318 +++++++++++++++++++++
 1 file changed, 318 insertions(+)
 create mode 100644 packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue

(limited to 'packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue')

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>
-- 
cgit v1.2.3-freya


From b43dfa260b1416da153d28bcd46a8bcbce02c18d Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Wed, 4 Jun 2025 16:22:09 +0900
Subject: fix/refactor(frontend): 画像編集機能の修正・型強化 (#16156)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance: refine uploadFile

* fix: missing locale

* refactor: harden types

* refactor: シェーダーファイルをlazy-loadingできるように

* fix(frontend): omit console.log in production environment

* fix: glslのバージョン表記は最初の行になければならない

* fix: シェーダーの読み込みが完了してからレンダリングを行うように

* fix merge failure

* fix: ウォーターマークのプリセットがない場合にdividerが2重に表示される問題を修正

* fix: アップローダーダイアログの機能設定でウォーターマークが無効な場合でもデフォルトのプリセットが適用されてしまう問題を修正

* fix lint

* Revert "fix: シェーダーの読み込みが完了してからレンダリングを行うように"

This reverts commit e06f37a7d453ca581858252eae422d8a9e470dc3.

* Revert "fix: glslのバージョン表記は最初の行になければならない"

This reverts commit afcc37d886106c4acd545e4c2922e67f94e1037b.

* Revert "refactor: シェーダーファイルをlazy-loadingできるように"

This reverts commit a1ab2fa38c2b7485c069f9cd089bc7de59416c9d.

* fix: ウォーターマークのFX定義を分ける

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 locales/index.d.ts                                 |  8 +++
 locales/ja-JP.yml                                  |  2 +
 .../frontend/src/components/MkFormDialog.file.vue  |  5 +-
 .../src/components/MkImageEffectorDialog.vue       |  3 +-
 packages/frontend/src/components/MkPostForm.vue    |  8 ++-
 .../frontend/src/components/MkUploaderDialog.vue   | 69 ++++++++++++++++------
 .../components/MkWatermarkEditorDialog.Layer.vue   | 17 ++++--
 .../src/components/MkWatermarkEditorDialog.vue     |  7 ++-
 packages/frontend/src/os.ts                        |  3 +
 .../admin/custom-emojis-manager.local.list.vue     |  5 +-
 packages/frontend/src/pages/channel-editor.vue     |  5 +-
 packages/frontend/src/pages/chat/room.form.vue     |  6 +-
 .../frontend/src/pages/custom-emojis-manager.vue   |  5 +-
 packages/frontend/src/pages/emoji-edit-dialog.vue  |  5 +-
 packages/frontend/src/pages/gallery/edit.vue       | 11 ++--
 .../frontend/src/pages/page-editor/page-editor.vue |  5 +-
 .../frontend/src/pages/settings/account-data.vue   | 25 ++++++--
 packages/frontend/src/pages/settings/deck.vue      |  5 +-
 .../frontend/src/pages/settings/sounds.sound.vue   |  6 +-
 packages/frontend/src/preferences/def.ts           |  2 +-
 packages/frontend/src/utility/drive.ts             | 26 +++++---
 .../src/utility/image-effector/ImageEffector.ts    | 14 ++---
 packages/frontend/src/utility/watermark.ts         | 23 +++++---
 23 files changed, 196 insertions(+), 69 deletions(-)

(limited to 'packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue')

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 6f3d2b3853..17cdc7ca64 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -12049,6 +12049,14 @@ export interface Locale extends ILocale {
          * 保存せずに終了しますか？
          */
         "quitWithoutSaveConfirm": string;
+        /**
+         * このファイルは対応していません
+         */
+        "driveFileTypeWarn": string;
+        /**
+         * 画像ファイルを選択してください
+         */
+        "driveFileTypeWarnDescription": string;
         /**
          * ウォーターマークの編集
          */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d4bd2c3116..ed1f5354df 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -3227,6 +3227,8 @@ defaultPreset: "デフォルトのプリセット"
 _watermarkEditor:
   tip: "画像にクレジット情報などのウォーターマークを追加することができます。"
   quitWithoutSaveConfirm: "保存せずに終了しますか？"
+  driveFileTypeWarn: "このファイルは対応していません"
+  driveFileTypeWarnDescription: "画像ファイルを選択してください"
   title: "ウォーターマークの編集"
   cover: "全体に被せる"
   repeat: "敷き詰める"
diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue
index a11075c342..182ff3ccf5 100644
--- a/packages/frontend/src/components/MkFormDialog.file.vue
+++ b/packages/frontend/src/components/MkFormDialog.file.vue
@@ -51,7 +51,10 @@ if (props.fileId) {
 }
 
 function selectButton(ev: MouseEvent) {
-	selectFile(ev.currentTarget ?? ev.target).then(async (file) => {
+	selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+	}).then(async (file) => {
 		if (!file) return;
 		if (props.validate && !await props.validate(file)) return;
 
diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue
index 997dd4d528..42502ba449 100644
--- a/packages/frontend/src/components/MkImageEffectorDialog.vue
+++ b/packages/frontend/src/components/MkImageEffectorDialog.vue
@@ -77,6 +77,7 @@ const dialog = useTemplateRef('dialog');
 async function cancel() {
 	if (layers.length > 0) {
 		const { canceled } = await os.confirm({
+			type: 'warning',
 			text: i18n.ts._imageEffector.discardChangesConfirm,
 		});
 		if (canceled) return;
@@ -132,7 +133,7 @@ function onLayerDelete(layer: ImageEffectorLayer) {
 
 const canvasEl = useTemplateRef('canvasEl');
 
-let renderer: ImageEffector | null = null;
+let renderer: ImageEffector<typeof FXS> | null = null;
 let imageBitmap: ImageBitmap | null = null;
 
 onMounted(async () => {
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 982ed88003..cd4fabea02 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -120,7 +120,7 @@ import { formatTimeString } from '@/utility/format-time-string.js';
 import { Autocomplete } from '@/utility/autocomplete.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { selectFiles } from '@/utility/drive.js';
+import { selectFile } from '@/utility/drive.js';
 import { store } from '@/store.js';
 import MkInfo from '@/components/MkInfo.vue';
 import { i18n } from '@/i18n.js';
@@ -437,7 +437,11 @@ function focus() {
 function chooseFileFrom(ev) {
 	if (props.mock) return;
 
-	selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
+	selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: true,
+		label: i18n.ts.attachFile,
+	}).then(files_ => {
 		for (const file of files_) {
 			files.value.push(file);
 		}
diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue
index b2e4896ed3..4c4bc26bfd 100644
--- a/packages/frontend/src/components/MkUploaderDialog.vue
+++ b/packages/frontend/src/components/MkUploaderDialog.vue
@@ -79,8 +79,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 </MkModalWindow>
 </template>
 
+<script lang="ts">
+export type UploaderDialogFeatures = {
+	effect?: boolean;
+	watermark?: boolean;
+	crop?: boolean;
+};
+</script>
+
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue';
+import { computed, 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';
@@ -91,7 +99,6 @@ import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
 import MkButton from '@/components/MkButton.vue';
 import bytes from '@/filters/bytes.js';
-import MkSelect from '@/components/MkSelect.vue';
 import { isWebpSupported } from '@/utility/isWebpSupported.js';
 import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
 import * as os from '@/os.js';
@@ -131,17 +138,26 @@ const props = withDefaults(defineProps<{
 	files: File[];
 	folderId?: string | null;
 	multiple?: boolean;
+	features?: UploaderDialogFeatures;
 }>(), {
 	multiple: true,
 });
 
+const uploaderFeatures = computed<Required<UploaderDialogFeatures>>(() => {
+	return {
+		effect: props.features?.effect ?? true,
+		watermark: props.features?.watermark ?? true,
+		crop: props.features?.crop ?? true,
+	};
+});
+
 const emit = defineEmits<{
 	(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
 	(ev: 'canceled'): void;
 	(ev: 'closed'): void;
 }>();
 
-const items = ref<{
+type UploaderItem = {
 	id: string;
 	name: string;
 	uploadName?: string;
@@ -152,13 +168,15 @@ const items = ref<{
 	uploaded: Misskey.entities.DriveFile | null;
 	uploadFailed: boolean;
 	aborted: boolean;
-	compressionLevel: number;
+	compressionLevel: 0 | 1 | 2 | 3;
 	compressedSize?: number | null;
 	preprocessedFile?: Blob | null;
 	file: File;
 	watermarkPresetId: string | null;
 	abort?: (() => void) | null;
-}[]>([]);
+};
+
+const items = ref<UploaderItem[]>([]);
 
 const dialog = useTemplateRef('dialog');
 
@@ -252,7 +270,7 @@ async function done() {
 	dialog.value?.close();
 }
 
-function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
+function showMenu(ev: MouseEvent, item: UploaderItem) {
 	const menu: MenuItem[] = [];
 
 	menu.push({
@@ -272,7 +290,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
 		},
 	});
 
-	if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
+	if (
+		uploaderFeatures.value.crop &&
+		CROPPING_SUPPORTED_TYPES.includes(item.file.type) &&
+		!item.preprocessing &&
+		!item.uploading &&
+		!item.uploaded
+	) {
 		menu.push({
 			icon: 'ti ti-crop',
 			text: i18n.ts.cropImage,
@@ -292,7 +316,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
 		});
 	}
 
-	if (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
+	if (
+		uploaderFeatures.value.effect &&
+		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)',
@@ -318,7 +348,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
 		});
 	}
 
-	if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
+	if (
+		uploaderFeatures.value.watermark &&
+		WATERMARK_SUPPORTED_TYPES.includes(item.file.type) &&
+		!item.preprocessing &&
+		!item.uploading &&
+		!item.uploaded
+	) {
 		function changeWatermarkPreset(presetId: string | null) {
 			item.watermarkPresetId = presetId;
 			preprocess(item).then(() => {
@@ -338,13 +374,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
 			}, {
 				type: 'divider',
 			}, ...prefer.s.watermarkPresets.map(preset => ({
-				type: 'radioOption',
+				type: 'radioOption' as const,
 				text: preset.name,
 				active: computed(() => item.watermarkPresetId === preset.id),
 				action: () => changeWatermarkPreset(preset.id),
-			})), {
-				type: 'divider',
-			}, {
+			})), ...(prefer.s.watermarkPresets.length > 0 ? [{
+				type: 'divider' as const,
+			}] : []), {
 				type: 'button',
 				icon: 'ti ti-plus',
 				text: i18n.ts.add,
@@ -397,8 +433,7 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
 				text: i18n.ts.high,
 				active: computed(() => item.compressionLevel === 3),
 				action: () => changeCompressionLevel(3),
-			},
-			],
+			}],
 		});
 	}
 
@@ -590,9 +625,9 @@ function initializeFile(file: File) {
 		uploaded: null,
 		uploadFailed: false,
 		compressionLevel: prefer.s.defaultImageCompressionLevel,
-		watermarkPresetId: prefer.s.defaultWatermarkPresetId,
+		watermarkPresetId: uploaderFeatures.value.watermark ? prefer.s.defaultWatermarkPresetId : null,
 		file: markRaw(file),
-	};
+	} satisfies UploaderItem;
 	items.value.push(item);
 	preprocess(item).then(() => {
 		triggerRef(items);
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue
index 10de04c16a..11ae091d90 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue
@@ -262,10 +262,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
+import { ref, onMounted } from 'vue';
+import * as Misskey from 'misskey-js';
 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';
@@ -275,11 +275,10 @@ 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 driveFile = ref<Misskey.entities.DriveFile | null>(null);
 const driveFileError = ref(false);
 onMounted(async () => {
 	if (layer.value.type === 'image' && layer.value.imageId != null) {
@@ -294,7 +293,15 @@ onMounted(async () => {
 });
 
 function chooseFile(ev: MouseEvent) {
-	selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then((file) => {
+	selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+		label: i18n.ts.selectFile,
+		features: {
+			watermark: false,
+		},
+	}).then((file) => {
+		if (layer.value.type !== 'image') return;
 		if (!file.type.startsWith('image')) {
 			os.alert({
 				type: 'warning',
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
index 4cfb4a72bc..206298b194 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
@@ -124,7 +124,7 @@ function createStripeLayer(): WatermarkPreset['layers'][number] {
 		angle: 0.5,
 		frequency: 10,
 		threshold: 0.1,
-		black: false,
+		color: [1, 1, 1],
 		opacity: 0.75,
 	};
 }
@@ -140,7 +140,7 @@ function createPolkadotLayer(): WatermarkPreset['layers'][number] {
 		majorOpacity: 0.75,
 		minorOpacity: 0.5,
 		minorDivisions: 4,
-		black: false,
+		color: [1, 1, 1],
 		opacity: 0.75,
 	};
 }
@@ -151,7 +151,7 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] {
 		type: 'checker',
 		angle: 0.5,
 		scale: 3,
-		black: false,
+		color: [1, 1, 1],
 		opacity: 0.75,
 	};
 }
@@ -177,6 +177,7 @@ const dialog = useTemplateRef('dialog');
 
 async function cancel() {
 	const { canceled } = await os.confirm({
+		type: 'question',
 		text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
 	});
 	if (canceled) return;
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index be247f96c4..83ad0ebdf9 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -13,6 +13,7 @@ import type { ComponentProps as CP } from 'vue-component-type-helpers';
 import type { Form, GetFormResultType } from '@/utility/form.js';
 import type { MenuItem } from '@/types/menu.js';
 import type { PostFormProps } from '@/types/post-form.js';
+import type { UploaderDialogFeatures } from '@/components/MkUploaderDialog.vue';
 import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue';
 import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -836,6 +837,7 @@ export function launchUploader(
 	options?: {
 		folderId?: string | null;
 		multiple?: boolean;
+		features?: UploaderDialogFeatures;
 	},
 ): Promise<Misskey.entities.DriveFile[]> {
 	return new Promise(async (res, rej) => {
@@ -844,6 +846,7 @@ export function launchUploader(
 			files: markRaw(files),
 			folderId: options?.folderId,
 			multiple: options?.multiple,
+			features: options?.features,
 		}, {
 			done: driveFiles => {
 				if (driveFiles.length === 0) return rej();
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
index 4c2c26ec45..a380bd133e 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
@@ -174,7 +174,10 @@ function setupGrid(): GridSetting {
 			{
 				bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required],
 				async customValueEditor(row, col, value, cellElement) {
-					const file = await selectFile(cellElement);
+					const file = await selectFile({
+						anchorElement: cellElement,
+						multiple: false,
+					});
 					gridItems.value[row.index].url = file.url;
 					gridItems.value[row.index].fileId = file.id;
 
diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue
index 355b5464a1..72281ea882 100644
--- a/packages/frontend/src/pages/channel-editor.vue
+++ b/packages/frontend/src/pages/channel-editor.vue
@@ -188,7 +188,10 @@ async function archive() {
 }
 
 function setBannerImage(evt) {
-	selectFile(evt.currentTarget ?? evt.target, null).then(file => {
+	selectFile({
+		anchorElement: evt.currentTarget ?? evt.target,
+		multiple: false,
+	}).then(file => {
 		bannerId.value = file.id;
 	});
 }
diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue
index 7e3be67230..17b68d6eb9 100644
--- a/packages/frontend/src/pages/chat/room.form.vue
+++ b/packages/frontend/src/pages/chat/room.form.vue
@@ -168,7 +168,11 @@ function onKeydown(ev: KeyboardEvent) {
 }
 
 function chooseFile(ev: MouseEvent) {
-	selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
+	selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+		label: i18n.ts.selectFile,
+	}).then(selectedFile => {
 		file.value = selectedFile;
 	});
 }
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 5aba0f68a3..36d638b210 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -214,7 +214,10 @@ const menu = (ev: MouseEvent) => {
 		icon: 'ti ti-upload',
 		text: i18n.ts.import,
 		action: async () => {
-			const file = await selectFile(ev.currentTarget ?? ev.target);
+			const file = await selectFile({
+				anchorElement: ev.currentTarget ?? ev.target,
+				multiple: false,
+			});
 			misskeyApi('admin/emoji/import-zip', {
 				fileId: file.id,
 			})
diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue
index 41de457427..b4fc4a46d9 100644
--- a/packages/frontend/src/pages/emoji-edit-dialog.vue
+++ b/packages/frontend/src/pages/emoji-edit-dialog.vue
@@ -121,7 +121,10 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
 const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null);
 
 async function changeImage(ev: Event) {
-	file.value = await selectFile(ev.currentTarget ?? ev.target, null);
+	file.value = await selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+	});
 	const candidate = file.value.name.replace(/\.(.+)$/, '');
 	if (candidate.match(/^[a-z0-9_]+$/)) {
 		name.value = candidate;
diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue
index 1b8c14a156..9c0078e15a 100644
--- a/packages/frontend/src/pages/gallery/edit.vue
+++ b/packages/frontend/src/pages/gallery/edit.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<div class="name">{{ file.name }}</div>
 					<button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button>
 				</div>
-				<MkButton primary @click="selectFile"><i class="ti ti-plus"></i> {{ i18n.ts.attachFile }}</MkButton>
+				<MkButton primary @click="chooseFile"><i class="ti ti-plus"></i> {{ i18n.ts.attachFile }}</MkButton>
 			</div>
 
 			<MkSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</MkSwitch>
@@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import FormSuspense from '@/components/form/suspense.vue';
-import { selectFiles } from '@/utility/drive.js';
+import { selectFile } from '@/utility/drive.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
@@ -63,8 +63,11 @@ const description = ref<string | null>(null);
 const title = ref<string | null>(null);
 const isSensitive = ref(false);
 
-function selectFile(evt) {
-	selectFiles(evt.currentTarget ?? evt.target, null).then(selected => {
+function chooseFile(evt) {
+	selectFile({
+		anchorElement: evt.currentTarget ?? evt.target,
+		multiple: true,
+	}).then(selected => {
 		files.value = files.value.concat(selected);
 	});
 }
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
index 05eb0bd9d2..8a9b9a9b08 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -205,7 +205,10 @@ async function add() {
 }
 
 function setEyeCatchingImage(img: Event) {
-	selectFile(img.currentTarget ?? img.target, null).then(file => {
+	selectFile({
+		anchorElement: img.currentTarget ?? img.target,
+		multiple: false,
+	}).then(file => {
 		eyeCatchingImageId.value = file.id;
 	});
 }
diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue
index d175c0dc32..5a00d7a9d7 100644
--- a/packages/frontend/src/pages/settings/account-data.vue
+++ b/packages/frontend/src/pages/settings/account-data.vue
@@ -233,7 +233,10 @@ const exportAntennas = () => {
 };
 
 const importFollowing = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
+	const file = await selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+	});
 	misskeyApi('i/import-following', {
 		fileId: file.id,
 		withReplies: withReplies.value,
@@ -241,22 +244,34 @@ const importFollowing = async (ev) => {
 };
 
 const importUserLists = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
+	const file = await selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+	});
 	misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError);
 };
 
 const importMuting = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
+	const file = await selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+	});
 	misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError);
 };
 
 const importBlocking = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
+	const file = await selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+	});
 	misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
 };
 
 const importAntennas = async (ev) => {
-	const file = await selectFile(ev.currentTarget ?? ev.target);
+	const file = await selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+	});
 	misskeyApi('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError);
 };
 
diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue
index 22bd8cbc80..ae882d1ee2 100644
--- a/packages/frontend/src/pages/settings/deck.vue
+++ b/packages/frontend/src/pages/settings/deck.vue
@@ -114,7 +114,10 @@ watch(wallpaper, async () => {
 });
 
 function setWallpaper(ev: MouseEvent) {
-	selectFile(ev.currentTarget ?? ev.target, null).then(file => {
+	selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+	}).then(file => {
 		wallpaper.value = file.url;
 	});
 }
diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue
index ffbbefa122..7aad43b1d0 100644
--- a/packages/frontend/src/pages/settings/sounds.sound.vue
+++ b/packages/frontend/src/pages/settings/sounds.sound.vue
@@ -94,7 +94,11 @@ const friendlyFileName = computed<string>(() => {
 });
 
 function selectSound(ev) {
-	selectFile(ev.currentTarget ?? ev.target, i18n.ts._soundSettings.driveFile).then(async (file) => {
+	selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: false,
+		label: i18n.ts._soundSettings.driveFile,
+	}).then(async (file) => {
 		if (!file.type.startsWith('audio')) {
 			os.alert({
 				type: 'warning',
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index 727b79e045..a83a3153d0 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -422,7 +422,7 @@ export const PREF_DEF = definePreferences({
 		default: null as WatermarkPreset['id'] | null,
 	},
 	defaultImageCompressionLevel: {
-		default: 2,
+		default: 2 as 0 | 1 | 2 | 3,
 	},
 
 	'sound.masterVolume': {
diff --git a/packages/frontend/src/utility/drive.ts b/packages/frontend/src/utility/drive.ts
index 0e10f80145..bc1813f48c 100644
--- a/packages/frontend/src/utility/drive.ts
+++ b/packages/frontend/src/utility/drive.ts
@@ -16,6 +16,7 @@ import { instance } from '@/instance.js';
 import { globalEvents } from '@/events.js';
 import { getProxiedImageUrl } from '@/utility/media-proxy.js';
 import { genId } from '@/utility/id.js';
+import type { UploaderDialogFeatures } from '@/components/MkUploaderDialog.vue';
 
 type UploadReturnType = {
 	filePromise: Promise<Misskey.entities.DriveFile>;
@@ -155,6 +156,7 @@ export function uploadFile(file: File | Blob, options: {
 export function chooseFileFromPcAndUpload(
 	options: {
 		multiple?: boolean;
+		features?: UploaderDialogFeatures;
 		folderId?: string | null;
 	} = {},
 ): Promise<Misskey.entities.DriveFile[]> {
@@ -163,6 +165,7 @@ export function chooseFileFromPcAndUpload(
 			if (files.length === 0) return;
 			os.launchUploader(files, {
 				folderId: options.folderId,
+				features: options.features,
 			}).then(driveFiles => {
 				res(driveFiles);
 			});
@@ -194,7 +197,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
 			type: 'url',
 			placeholder: i18n.ts.uploadFromUrlDescription,
 		}).then(({ canceled, result: url }) => {
-			if (canceled) return;
+			if (canceled || url == null) return;
 
 			const marker = genId();
 
@@ -221,7 +224,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
 	});
 }
 
-function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
+function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean, features?: UploaderDialogFeatures): Promise<Misskey.entities.DriveFile[]> {
 	return new Promise((res, rej) => {
 		os.popupMenu([label ? {
 			text: label,
@@ -229,7 +232,7 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string |
 		} : undefined, {
 			text: i18n.ts.upload,
 			icon: 'ti ti-upload',
-			action: () => chooseFileFromPcAndUpload({ multiple }).then(files => res(files)),
+			action: () => chooseFileFromPcAndUpload({ multiple, features }).then(files => res(files)),
 		}, {
 			text: i18n.ts.fromDrive,
 			icon: 'ti ti-cloud',
@@ -242,12 +245,19 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string |
 	});
 }
 
-export function selectFile(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> {
-	return select(anchorElement, label, false).then(files => files[0]);
-}
+type SelectFileOptions<M extends boolean> = {
+	anchorElement: HTMLElement | EventTarget | null;
+	multiple: M;
+	label?: string | null;
+	features?: UploaderDialogFeatures;
+};
 
-export function selectFiles(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> {
-	return select(anchorElement, label, true);
+export async function selectFile<
+	M extends boolean,
+	MR extends M extends true ? Misskey.entities.DriveFile[] : Misskey.entities.DriveFile
+>(opts: SelectFileOptions<M>): Promise<MR> {
+	const files = await select(opts.anchorElement, opts.label ?? null, opts.multiple ?? false, opts.features);
+	return opts.multiple ? (files as MR) : (files[0]! as MR);
 }
 
 export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: {
diff --git a/packages/frontend/src/utility/image-effector/ImageEffector.ts b/packages/frontend/src/utility/image-effector/ImageEffector.ts
index fe253017e5..80e3ff65de 100644
--- a/packages/frontend/src/utility/image-effector/ImageEffector.ts
+++ b/packages/frontend/src/utility/image-effector/ImageEffector.ts
@@ -57,7 +57,7 @@ function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, a
 	return params[k];
 }
 
-export class ImageEffector {
+export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, any>>> {
 	private gl: WebGL2RenderingContext;
 	private canvas: HTMLCanvasElement | null = null;
 	private renderTextureProgram: WebGLProgram;
@@ -70,7 +70,7 @@ export class ImageEffector {
 	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 fxs: [...IEX];
 	private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
 
 	constructor(options: {
@@ -78,7 +78,7 @@ export class ImageEffector {
 		renderWidth: number;
 		renderHeight: number;
 		image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
-		fxs: ImageEffectorFx[];
+		fxs: [...IEX];
 	}) {
 		this.canvas = options.canvas;
 		this.renderWidth = options.renderWidth;
@@ -230,7 +230,7 @@ export class ImageEffector {
 			gl: gl,
 			program: shaderProgram,
 			params: Object.fromEntries(
-				Object.entries(fx.params).map(([key, param]) => {
+				Object.entries(fx.params as ImageEffectorFxParamDefs).map(([key, param]) => {
 					return [key, layer.params[key] ?? param.default];
 				}),
 			),
@@ -238,7 +238,7 @@ export class ImageEffector {
 			width: this.renderWidth,
 			height: this.renderHeight,
 			textures: Object.fromEntries(
-				Object.entries(fx.params).map(([k, v]) => {
+				Object.entries(fx.params as ImageEffectorFxParamDefs).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];
@@ -329,7 +329,7 @@ export class ImageEffector {
 				unused.delete(textureKey);
 				if (this.paramTextures.has(textureKey)) continue;
 
-				console.log(`Baking texture of <${textureKey}>...`);
+				if (_DEV_) 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;
@@ -339,7 +339,7 @@ export class ImageEffector {
 		}
 
 		for (const k of unused) {
-			console.log(`Dispose unused texture <${k}>...`);
+			if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
 			this.gl.deleteTexture(this.paramTextures.get(k)!.texture);
 			this.paramTextures.delete(k);
 		}
diff --git a/packages/frontend/src/utility/watermark.ts b/packages/frontend/src/utility/watermark.ts
index 8ee93181a6..f0b38684f0 100644
--- a/packages/frontend/src/utility/watermark.ts
+++ b/packages/frontend/src/utility/watermark.ts
@@ -3,13 +3,20 @@
  * 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 { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
+import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js';
+import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js';
+import { FX_checker } from '@/utility/image-effector/fxs/checker.js';
+import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
 import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
 
+const WATERMARK_FXS = [
+	FX_watermarkPlacement,
+	FX_stripe,
+	FX_polkadot,
+	FX_checker,
+] as const satisfies ImageEffectorFx<string, any>[];
+
 export type WatermarkPreset = {
 	id: string;
 	name: string;
@@ -64,7 +71,7 @@ export type WatermarkPreset = {
 };
 
 export class WatermarkRenderer {
-	private effector: ImageEffector;
+	private effector: ImageEffector<typeof WATERMARK_FXS>;
 	private layers: WatermarkPreset['layers'] = [];
 
 	constructor(options: {
@@ -78,7 +85,7 @@ export class WatermarkRenderer {
 			renderWidth: options.renderWidth,
 			renderHeight: options.renderHeight,
 			image: options.image,
-			fxs: [FX_watermarkPlacement, FX_stripe, FX_polkadot, FX_checker],
+			fxs: WATERMARK_FXS,
 		});
 	}
 
@@ -157,6 +164,8 @@ export class WatermarkRenderer {
 						opacity: layer.opacity,
 					},
 				};
+			} else {
+				throw new Error(`Unknown layer type`);
 			}
 		});
 	}
-- 
cgit v1.2.3-freya

