From c5f9c0ce5c0fbfd720d6d4100fda89252abadee4 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:27:53 +0900 Subject: [PATCH] enhance(frontend): add pixelate mask effect --- CHANGELOG.md | 2 +- locales/index.d.ts | 4 + locales/ja-JP.yml | 1 + .../src/components/MkImageEffectorDialog.vue | 20 ++- .../src/utility/image-effector/fxs.ts | 2 + .../utility/image-effector/fxs/pixelate.ts | 147 ++++++++++++++++++ 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/src/utility/image-effector/fxs/pixelate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b721c04bb..fd64126227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Feat: アカウントのQRコードを表示・読み取りできるようになりました - Feat: 動画を圧縮してアップロードできるようになりました - Enhance: チャットの日本語名称がダイレクトメッセージに戻るとともに、ベータ版機能ではなくなりました -- Enhance: 画像編集にマスクエフェクト(塗りつぶし、ぼかし)を追加 +- Enhance: 画像編集にマスクエフェクト(塗りつぶし、ぼかし、モザイク)を追加 - Enhance: ウォーターマークにアカウントのQRコードを追加できるように - Enhance: テーマをドラッグ&ドロップできるように - Enhance: 絵文字ピッカーのサイズをより大きくできるように diff --git a/locales/index.d.ts b/locales/index.d.ts index 43e7d6e2a8..fbe8c30c94 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12428,6 +12428,10 @@ export interface Locale extends ILocale { * ぼかし */ "blur": string; + /** + * モザイク + */ + "pixelate": string; /** * 色調補正 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index e193abeb09..d21df5d5e1 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3329,6 +3329,7 @@ _imageEffector: invert: "色の反転" grayscale: "白黒" blur: "ぼかし" + pixelate: "モザイク" colorAdjust: "色調補正" colorClamp: "色の圧縮" colorClampAdvanced: "色の圧縮(高度)" diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue index 96fb01bb8c..5ce514f93e 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.vue @@ -216,7 +216,7 @@ watch(enabled, () => { } }); -const penMode = ref<'fill' | 'blur' | null>(null); +const penMode = ref<'fill' | 'blur' | 'pixelate' | null>(null); function showPenMenu(ev: MouseEvent) { os.popupMenu([{ @@ -229,6 +229,11 @@ function showPenMenu(ev: MouseEvent) { action: () => { penMode.value = 'blur'; }, + }, { + text: i18n.ts._imageEffector._fxs.pixelate, + action: () => { + penMode.value = 'pixelate'; + }, }], ev.currentTarget ?? ev.target); } @@ -291,6 +296,19 @@ function onImagePointerdown(ev: PointerEvent) { radius: 3, }, }); + } else if (penMode.value === 'pixelate') { + layers.push({ + id, + fxId: 'pixelate', + params: { + offsetX: 0, + offsetY: 0, + scaleX: 0.1, + scaleY: 0.1, + angle: 0, + strength: 0.2, + }, + }); } _move(ev.offsetX, ev.offsetY); diff --git a/packages/frontend/src/utility/image-effector/fxs.ts b/packages/frontend/src/utility/image-effector/fxs.ts index 83ec20823d..2b20cc1f99 100644 --- a/packages/frontend/src/utility/image-effector/fxs.ts +++ b/packages/frontend/src/utility/image-effector/fxs.ts @@ -20,6 +20,7 @@ import { FX_zoomLines } from './fxs/zoomLines.js'; import { FX_blockNoise } from './fxs/blockNoise.js'; import { FX_fill } from './fxs/fill.js'; import { FX_blur } from './fxs/blur.js'; +import { FX_pixelate } from './fxs/pixelate.js'; import type { ImageEffectorFx } from './ImageEffector.js'; export const FXS = [ @@ -40,4 +41,5 @@ export const FXS = [ FX_blockNoise, FX_fill, FX_blur, + FX_pixelate, ] as const satisfies ImageEffectorFx[]; diff --git a/packages/frontend/src/utility/image-effector/fxs/pixelate.ts b/packages/frontend/src/utility/image-effector/fxs/pixelate.ts new file mode 100644 index 0000000000..d9a5f454f3 --- /dev/null +++ b/packages/frontend/src/utility/image-effector/fxs/pixelate.ts @@ -0,0 +1,147 @@ +/* + * 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 vec2 u_offset; +uniform vec2 u_scale; +uniform bool u_ellipse; +uniform float u_angle; +uniform int u_samples; +uniform float u_strength; +out vec4 out_color; + +// TODO: pixelateの中心を画像中心ではなく範囲の中心にする +// TODO: 画像のアスペクト比に関わらず各画素は正方形にする + +void main() { + if (u_strength <= 0.0) { + out_color = texture(in_texture, in_uv); + return; + } + + float angle = -(u_angle * PI); + vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ) + u_offset; + + bool isInside = false; + if (u_ellipse) { + vec2 norm = (rotatedUV - u_offset) / u_scale; + isInside = dot(norm, norm) <= 1.0; + } else { + isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; + } + + if (!isInside) { + out_color = texture(in_texture, in_uv); + return; + } + + float dx = u_strength / 1.0; + float dy = u_strength / 1.0; + vec2 new_uv = vec2( + (dx * (floor((in_uv.x - 0.5 - (dx / 2.0)) / dx) + 0.5)), + (dy * (floor((in_uv.y - 0.5 - (dy / 2.0)) / dy) + 0.5)) + ) + vec2(0.5 + (dx / 2.0), 0.5 + (dy / 2.0)); + + vec4 result = vec4(0.0); + float totalSamples = 0.0; + + // TODO: より多くのサンプリング + result += texture(in_texture, new_uv); + totalSamples += 1.0; + + out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv); +} +`; + +export const FX_pixelate = defineImageEffectorFx({ + id: 'pixelate', + name: i18n.ts._imageEffector._fxs.pixelate, + shader, + uniforms: ['offset', 'scale', 'ellipse', 'angle', 'strength', 'samples'] as const, + params: { + offsetX: { + label: i18n.ts._imageEffector._fxProps.offset + ' X', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + offsetY: { + label: i18n.ts._imageEffector._fxProps.offset + ' Y', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleX: { + label: i18n.ts._imageEffector._fxProps.scale + ' W', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleY: { + label: i18n.ts._imageEffector._fxProps.scale + ' H', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + ellipse: { + label: i18n.ts._imageEffector._fxProps.circle, + type: 'boolean', + default: false, + }, + angle: { + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + strength: { + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', + default: 0.2, + min: 0.0, + max: 0.5, + step: 0.01, + }, + }, + main: ({ gl, u, params }) => { + gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2); + gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2); + gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0); + gl.uniform1f(u.angle, params.angle / 2); + gl.uniform1f(u.strength, params.strength * params.strength); + gl.uniform1i(u.samples, 256); + }, +});