enhance(frontend): add pixelate mask effect

This commit is contained in:
syuilo 2025-09-26 18:27:53 +09:00
parent cce302ae4f
commit c5f9c0ce5c
6 changed files with 174 additions and 2 deletions

View File

@ -12,7 +12,7 @@
- Feat: アカウントのQRコードを表示・読み取りできるようになりました
- Feat: 動画を圧縮してアップロードできるようになりました
- Enhance: チャットの日本語名称がダイレクトメッセージに戻るとともに、ベータ版機能ではなくなりました
- Enhance: 画像編集にマスクエフェクト(塗りつぶし、ぼかし)を追加
- Enhance: 画像編集にマスクエフェクト(塗りつぶし、ぼかし、モザイク)を追加
- Enhance: ウォーターマークにアカウントのQRコードを追加できるように
- Enhance: テーマをドラッグ&ドロップできるように
- Enhance: 絵文字ピッカーのサイズをより大きくできるように

4
locales/index.d.ts vendored
View File

@ -12428,6 +12428,10 @@ export interface Locale extends ILocale {
*
*/
"blur": string;
/**
*
*/
"pixelate": string;
/**
* 調
*/

View File

@ -3329,6 +3329,7 @@ _imageEffector:
invert: "色の反転"
grayscale: "白黒"
blur: "ぼかし"
pixelate: "モザイク"
colorAdjust: "色調補正"
colorClamp: "色の圧縮"
colorClampAdvanced: "色の圧縮(高度)"

View File

@ -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);

View File

@ -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<string, any>[];

View File

@ -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);
},
});