From 4ba18690d7abd7eea086bb59e6cbcc8ead9e121a Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Thu, 6 Nov 2025 20:25:17 +0900
Subject: feat(frontend): EXIFフレーム機能 (#16725)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* Update ImageEffector.ts

* Update image-label-renderer.ts

* Update image-label-renderer.ts

* wip

* Update image-label-renderer.ts

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update use-uploader.ts

* Update watermark.ts

* wip

* wu

* wip

* Update image-frame-renderer.ts

* wip

* wip

* Update image-frame-renderer.ts

* Create ImageCompositor.ts

* Update ImageCompositor.ts

* wip

* wip

* Update ImageEffector.ts

* wip

* Update use-uploader.ts

* wip

* wip

* wip

* wip

* Update fxs.ts

* wip

* wip

* wip

* Update CHANGELOG.md

* wip

* wip

* Update MkImageEffectorDialog.vue

* Update MkImageEffectorDialog.vue

* Update MkImageFrameEditorDialog.vue

* Update use-uploader.ts

* improve error handling

* Update use-uploader.ts

* 🎨

* wip

* wip

* lazy load

* lazy load

* wip

* wip

* wip
---
 .../src/components/MkWatermarkEditorDialog.vue     | 162 ++++++++++++++-------
 1 file changed, 108 insertions(+), 54 deletions(-)

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

diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
index 3b3f20d8d1..6cd2111598 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
@@ -18,20 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 	<div :class="$style.root">
 		<div :class="$style.container">
-			<div :class="$style.preview">
+			<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
 				<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>
+						<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
 					</div>
 				</div>
 			</div>
 			<div :class="$style.controls">
 				<div class="_spacer _gaps">
 					<div class="_gaps_s">
-						<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
+						<MkFolder v-for="(layer, i) in 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>
@@ -49,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 							</template>
 
 							<XLayer
-								v-model:layer="preset.layers[i]"
+								v-model:layer="layers[i]"
 							></XLayer>
 						</MkFolder>
 
@@ -64,8 +65,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <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 type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
+import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
 import { i18n } from '@/i18n.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkSelect from '@/components/MkSelect.vue';
@@ -77,6 +78,7 @@ import { deepClone } from '@/utility/clone.js';
 import { ensureSignin } from '@/i.js';
 import { genId } from '@/utility/id.js';
 import { useMkSelect } from '@/composables/use-mkselect.js';
+import { prefer } from '@/preferences.js';
 
 const $i = ensureSignin();
 
@@ -161,18 +163,22 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] {
 }
 
 const props = defineProps<{
+	presetEditMode?: boolean;
 	preset?: WatermarkPreset | null;
+	layers?: WatermarkLayers | null;
 	image?: File | null;
 }>();
 
-const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
+const preset = deepClone(props.preset) ?? {
 	id: genId(),
 	name: '',
-	layers: [],
-});
+};
+
+const layers = reactive<WatermarkLayers>(props.layers ?? []);
 
 const emit = defineEmits<{
-	(ev: 'ok', preset: WatermarkPreset): void;
+	(ev: 'ok', layers: WatermarkLayers): void;
+	(ev: 'presetOk', preset: WatermarkPreset): void;
 	(ev: 'cancel'): void;
 	(ev: 'closed'): void;
 }>();
@@ -180,19 +186,21 @@ const emit = defineEmits<{
 const dialog = useTemplateRef('dialog');
 
 async function cancel() {
-	const { canceled } = await os.confirm({
-		type: 'question',
-		text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
-	});
-	if (canceled) return;
+	if (props.presetEditMode) {
+		const { canceled } = await os.confirm({
+			type: 'question',
+			text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
+		});
+		if (canceled) return;
+	}
 
 	emit('cancel');
 	dialog.value?.close();
 }
 
-watch(preset, async (newValue, oldValue) => {
+watch(layers, async (newValue, oldValue) => {
 	if (renderer != null) {
-		renderer.setLayers(preset.layers);
+		renderer.render(layers);
 	}
 }, { deep: true });
 
@@ -212,6 +220,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
 
 const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
 watch(sampleImageType, async () => {
+	if (sampleImageType.value === 'provided') return;
 	if (renderer != null) {
 		renderer.destroy(false);
 		renderer = null;
@@ -219,6 +228,20 @@ watch(sampleImageType, async () => {
 	}
 });
 
+let imageFile = props.image;
+
+async function choiceImage() {
+	const files = await os.chooseFileFromPc({ multiple: false });
+	if (files.length === 0) return;
+	imageFile = files[0];
+	sampleImageType.value = 'provided';
+	if (renderer != null) {
+		renderer.destroy(false);
+		renderer = null;
+		initRenderer();
+	}
+}
+
 let renderer: WatermarkRenderer | null = null;
 let imageBitmap: ImageBitmap | null = null;
 
@@ -239,8 +262,8 @@ async function initRenderer() {
 			renderHeight: 1500,
 			image: sampleImage_2_3,
 		});
-	} else if (props.image != null) {
-		imageBitmap = await window.createImageBitmap(props.image);
+	} else if (imageFile != null) {
+		imageBitmap = await window.createImageBitmap(imageFile);
 
 		const MAX_W = 1000;
 		const MAX_H = 1000;
@@ -249,8 +272,8 @@ async function initRenderer() {
 
 		if (w > MAX_W || h > MAX_H) {
 			const scale = Math.min(MAX_W / w, MAX_H / h);
-			w *= scale;
-			h *= scale;
+			w = Math.floor(w * scale);
+			h = Math.floor(h * scale);
 		}
 
 		renderer = new WatermarkRenderer({
@@ -261,9 +284,7 @@ async function initRenderer() {
 		});
 	}
 
-	await renderer!.setLayers(preset.layers);
-
-	renderer!.render();
+	await renderer!.render(layers);
 }
 
 onMounted(async () => {
@@ -274,7 +295,15 @@ onMounted(async () => {
 	await sampleImage_3_2_loading;
 	await sampleImage_2_3_loading;
 
-	await initRenderer();
+	try {
+		await initRenderer();
+	} catch (err) {
+		console.error(err);
+		os.alert({
+			type: 'error',
+			text: i18n.ts._watermarkEditor.failedToLoadImage,
+		});
+	}
 
 	closeWaiting();
 });
@@ -291,77 +320,93 @@ onUnmounted(() => {
 });
 
 async function save() {
-	const { canceled, result: name } = await os.inputText({
-		title: i18n.ts.name,
-		default: preset.name,
-	});
-	if (canceled) return;
+	if (props.presetEditMode) {
+		const { canceled, result: name } = await os.inputText({
+			title: i18n.ts.name,
+			default: preset.name,
+		});
+		if (canceled) return;
 
-	preset.name = name || '';
+		preset.name = name || '';
 
-	dialog.value?.close();
-	if (renderer != null) {
-		renderer.destroy();
-		renderer = null;
-	}
+		dialog.value?.close();
+		if (renderer != null) {
+			renderer.destroy();
+			renderer = null;
+		}
 
-	emit('ok', preset);
+		emit('presetOk', {
+			...preset,
+			layers: deepClone(layers),
+		});
+	} else {
+		dialog.value?.close();
+		if (renderer != null) {
+			renderer.destroy();
+			renderer = null;
+		}
+
+		emit('ok', layers);
+	}
 }
 
 function addLayer(ev: MouseEvent) {
 	os.popupMenu([{
 		text: i18n.ts._watermarkEditor.text,
 		action: () => {
-			preset.layers.push(createTextLayer());
+			layers.push(createTextLayer());
 		},
 	}, {
 		text: i18n.ts._watermarkEditor.image,
 		action: () => {
-			preset.layers.push(createImageLayer());
+			layers.push(createImageLayer());
 		},
 	}, {
 		text: i18n.ts._watermarkEditor.qr,
 		action: () => {
-			preset.layers.push(createQrLayer());
+			layers.push(createQrLayer());
 		},
 	}, {
 		text: i18n.ts._watermarkEditor.stripe,
 		action: () => {
-			preset.layers.push(createStripeLayer());
+			layers.push(createStripeLayer());
 		},
 	}, {
 		text: i18n.ts._watermarkEditor.polkadot,
 		action: () => {
-			preset.layers.push(createPolkadotLayer());
+			layers.push(createPolkadotLayer());
 		},
 	}, {
 		text: i18n.ts._watermarkEditor.checker,
 		action: () => {
-			preset.layers.push(createCheckerLayer());
+			layers.push(createCheckerLayer());
 		},
 	}], ev.currentTarget ?? ev.target);
 }
 
 function swapUpLayer(layer: WatermarkPreset['layers'][number]) {
-	const index = preset.layers.findIndex(l => l.id === layer.id);
+	const index = 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;
+		const tmp = layers[index - 1];
+		layers[index - 1] = layers[index];
+		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;
+	const index = layers.findIndex(l => l.id === layer.id);
+	if (index < layers.length - 1) {
+		const tmp = layers[index + 1];
+		layers[index + 1] = layers[index];
+		layers[index] = tmp;
 	}
 }
 
 function removeLayer(layer: WatermarkPreset['layers'][number]) {
-	preset.layers = preset.layers.filter(l => l.id !== layer.id);
+	const index = layers.findIndex(l => l.id === layer.id);
+	if (index !== -1) {
+		layers.splice(index, 1);
+	}
 }
 </script>
 
@@ -380,8 +425,17 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
 .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);
+	background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
+	background-size: 20px 20px;
+}
+
+.animatedBg {
+	animation: bg 1.2s linear infinite;
+}
+
+@keyframes bg {
+	0% { background-position: 0 0; }
+	100% { background-position: -20px -20px; }
 }
 
 .previewContainer {
-- 
cgit v1.2.3-freya

