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/MkImageEffectorDialog.vue       | 87 +++++++++++++---------
 1 file changed, 52 insertions(+), 35 deletions(-)

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

diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue
index 19ddb81919..3d7801f925 100644
--- a/packages/frontend/src/components/MkImageEffectorDialog.vue
+++ b/packages/frontend/src/components/MkImageEffectorDialog.vue
@@ -18,7 +18,7 @@ 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" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
 				<div :class="$style.previewContainer">
 					<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
@@ -64,6 +64,7 @@ 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';
+import { prefer } from '@/preferences.js';
 
 const props = defineProps<{
 	image: File;
@@ -94,19 +95,19 @@ const layers = reactive<ImageEffectorLayer[]>([]);
 
 watch(layers, async () => {
 	if (renderer != null) {
-		renderer.setLayers(layers);
+		renderer.render(layers);
 	}
 }, { deep: true });
 
 function addEffect(ev: MouseEvent) {
-	os.popupMenu(FXS.map((fx) => ({
-		text: fx.name,
+	os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({
+		text: fx.uiDefinition.name,
 		action: () => {
 			layers.push({
 				id: genId(),
-				fxId: fx.id,
-				params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
-			});
+				fxId: id as keyof typeof FXS,
+				params: Object.fromEntries(Object.entries(fx.uiDefinition.params).map(([k, v]) => [k, v.default])),
+			} as ImageEffectorLayer);
 		},
 	})), ev.currentTarget ?? ev.target);
 }
@@ -136,7 +137,7 @@ function onLayerDelete(layer: ImageEffectorLayer) {
 
 const canvasEl = useTemplateRef('canvasEl');
 
-let renderer: ImageEffector<typeof FXS> | null = null;
+let renderer: ImageEffector | null = null;
 let imageBitmap: ImageBitmap | null = null;
 
 onMounted(async () => {
@@ -146,30 +147,35 @@ onMounted(async () => {
 
 	await nextTick(); // waitingがレンダリングされるまで待つ
 
-	imageBitmap = await window.createImageBitmap(props.image);
+	try {
+		imageBitmap = await window.createImageBitmap(props.image);
 
-	const MAX_W = 1000;
-	const MAX_H = 1000;
-	let w = imageBitmap.width;
-	let h = imageBitmap.height;
+		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,
-	});
+		if (w > MAX_W || h > MAX_H) {
+			const scale = Math.min(MAX_W / w, MAX_H / h);
+			w = Math.floor(w * scale);
+			h = Math.floor(h * scale);
+		}
 
-	await renderer.setLayers(layers);
+		renderer = new ImageEffector({
+			canvas: canvasEl.value,
+			renderWidth: w,
+			renderHeight: h,
+			image: imageBitmap,
+		});
 
-	renderer.render();
+		await renderer.render(layers);
+	} catch (err) {
+		console.error(err);
+		os.alert({
+			type: 'error',
+			text: i18n.ts._imageEffector.failedToLoadImage,
+		});
+	}
 
 	closeWaiting();
 });
@@ -196,7 +202,7 @@ async function save() {
 	await nextTick(); // waitingがレンダリングされるまで待つ
 
 	renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
-	renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
+	await renderer.render(layers); // toBlobの直前にレンダリングしないと何故か壊れる
 	canvasEl.value.toBlob((blob) => {
 		emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
 		dialog.value?.close();
@@ -208,11 +214,10 @@ const enabled = ref(true);
 watch(enabled, () => {
 	if (renderer != null) {
 		if (enabled.value) {
-			renderer.setLayers(layers);
+			renderer.render(layers);
 		} else {
-			renderer.setLayers([]);
+			renderer.render([]);
 		}
-		renderer.render();
 	}
 });
 
@@ -281,6 +286,7 @@ function onImagePointerdown(ev: PointerEvent) {
 				angle: 0,
 				opacity: 1,
 				color: [1, 1, 1],
+				ellipse: false,
 			},
 		});
 	} else if (penMode.value === 'blur') {
@@ -294,6 +300,7 @@ function onImagePointerdown(ev: PointerEvent) {
 				scaleY: 0.1,
 				angle: 0,
 				radius: 3,
+				ellipse: false,
 			},
 		});
 	} else if (penMode.value === 'pixelate') {
@@ -307,6 +314,7 @@ function onImagePointerdown(ev: PointerEvent) {
 				scaleY: 0.1,
 				angle: 0,
 				strength: 0.2,
+				ellipse: false,
 			},
 		});
 	}
@@ -329,7 +337,7 @@ function onImagePointerdown(ev: PointerEvent) {
 		const scaleY = Math.abs(y - startY);
 
 		const layerIndex = layers.findIndex((l) => l.id === id);
-		const layer = layerIndex !== -1 ? layers[layerIndex] : null;
+		const layer = layerIndex !== -1 ? (layers[layerIndex] as Extract<ImageEffectorLayer, { fxId: 'fill' } | { fxId: 'blur' } | { fxId: 'pixelate' }>) : null;
 		if (layer != null) {
 			layer.params.offsetX = (x + startX) - 1;
 			layer.params.offsetY = (y + startY) - 1;
@@ -373,8 +381,17 @@ function onImagePointerdown(ev: PointerEvent) {
 .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

