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
---
 .../frontend/src/components/MkUploaderDialog.vue   | 292 ++++++++++++++++-----
 1 file changed, 230 insertions(+), 62 deletions(-)

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

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

