summaryrefslogtreecommitdiff
path: root/packages/frontend/src/utility
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-06-16 02:33:18 +0000
committerGitHub <noreply@github.com>2025-06-16 02:33:18 +0000
commit830e2f0a5b5bada00bfbe036ef6e7ee8d84b83fd (patch)
treeb9ac1c4efb202a62fe34608fb3f42fd73297774b /packages/frontend/src/utility
parentMerge pull request #16134 from misskey-dev/develop (diff)
parentRelease: 2025.6.1 (diff)
downloadmisskey-830e2f0a5b5bada00bfbe036ef6e7ee8d84b83fd.tar.gz
misskey-830e2f0a5b5bada00bfbe036ef6e7ee8d84b83fd.tar.bz2
misskey-830e2f0a5b5bada00bfbe036ef6e7ee8d84b83fd.zip
Merge pull request #16152 from misskey-dev/develop
Release: 2025.6.1
Diffstat (limited to 'packages/frontend/src/utility')
-rw-r--r--packages/frontend/src/utility/drive.ts31
-rw-r--r--packages/frontend/src/utility/get-embed-code.ts4
-rw-r--r--packages/frontend/src/utility/get-note-menu.ts2
-rw-r--r--packages/frontend/src/utility/id.ts25
-rw-r--r--packages/frontend/src/utility/image-effector/ImageEffector.ts415
-rw-r--r--packages/frontend/src/utility/image-effector/fxs.ts39
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/blockNoise.ts119
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/checker.ts89
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts76
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts141
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/colorClamp.ts55
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts95
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/distort.ts77
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/grayscale.ts37
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/invert.ts53
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/mirror.ts58
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/polkadot.ts154
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/stripe.ts101
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/tearing.ts99
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/threshold.ts62
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts148
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/zoomLines.ts97
-rw-r--r--packages/frontend/src/utility/snowfall-effect.ts2
-rw-r--r--packages/frontend/src/utility/theme-editor.ts4
-rw-r--r--packages/frontend/src/utility/watermark.ts189
-rw-r--r--packages/frontend/src/utility/webgl.ts40
26 files changed, 2197 insertions, 15 deletions
diff --git a/packages/frontend/src/utility/drive.ts b/packages/frontend/src/utility/drive.ts
index fcc847653d..1912b3f805 100644
--- a/packages/frontend/src/utility/drive.ts
+++ b/packages/frontend/src/utility/drive.ts
@@ -6,6 +6,7 @@
import { defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { apiUrl } from '@@/js/config.js';
+import type { UploaderFeatures } from '@/composables/use-uploader.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js';
@@ -15,6 +16,7 @@ import { $i } from '@/i.js';
import { instance } from '@/instance.js';
import { globalEvents } from '@/events.js';
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
+import { genId } from '@/utility/id.js';
type UploadReturnType = {
filePromise: Promise<Misskey.entities.DriveFile>;
@@ -30,6 +32,7 @@ export class UploadAbortedError extends Error {
export function uploadFile(file: File | Blob, options: {
name?: string;
folderId?: string | null;
+ isSensitive?: boolean;
onProgress?: (ctx: { total: number; loaded: number; }) => void;
} = {}): UploadReturnType {
const xhr = new XMLHttpRequest();
@@ -138,6 +141,7 @@ export function uploadFile(file: File | Blob, options: {
formData.append('force', 'true');
formData.append('file', file);
formData.append('name', options.name ?? (file instanceof File ? file.name : 'untitled'));
+ formData.append('isSensitive', options.isSensitive ? 'true' : 'false');
if (options.folderId) formData.append('folderId', options.folderId);
xhr.send(formData);
@@ -154,6 +158,7 @@ export function uploadFile(file: File | Blob, options: {
export function chooseFileFromPcAndUpload(
options: {
multiple?: boolean;
+ features?: UploaderFeatures;
folderId?: string | null;
} = {},
): Promise<Misskey.entities.DriveFile[]> {
@@ -162,6 +167,7 @@ export function chooseFileFromPcAndUpload(
if (files.length === 0) return;
os.launchUploader(files, {
folderId: options.folderId,
+ features: options.features,
}).then(driveFiles => {
res(driveFiles);
});
@@ -193,9 +199,9 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => {
- if (canceled) return;
+ if (canceled || url == null) return;
- const marker = Math.random().toString(); // TODO: UUIDとか使う
+ const marker = genId();
// TODO: no websocketモード対応
const connection = useStream().useChannel('main');
@@ -220,7 +226,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
});
}
-function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
+function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean, features?: UploaderDialogFeatures): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
os.popupMenu([label ? {
text: label,
@@ -228,7 +234,7 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string |
} : undefined, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
- action: () => chooseFileFromPcAndUpload({ multiple }).then(files => res(files)),
+ action: () => chooseFileFromPcAndUpload({ multiple, features }).then(files => res(files)),
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
@@ -241,12 +247,19 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string |
});
}
-export function selectFile(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> {
- return select(anchorElement, label, false).then(files => files[0]);
-}
+type SelectFileOptions<M extends boolean> = {
+ anchorElement: HTMLElement | EventTarget | null;
+ multiple: M;
+ label?: string | null;
+ features?: UploaderDialogFeatures;
+};
-export function selectFiles(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> {
- return select(anchorElement, label, true);
+export async function selectFile<
+ M extends boolean,
+ MR extends M extends true ? Misskey.entities.DriveFile[] : Misskey.entities.DriveFile,
+>(opts: SelectFileOptions<M>): Promise<MR> {
+ const files = await select(opts.anchorElement, opts.label ?? null, opts.multiple ?? false, opts.features);
+ return opts.multiple ? (files as MR) : (files[0]! as MR);
}
export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: {
diff --git a/packages/frontend/src/utility/get-embed-code.ts b/packages/frontend/src/utility/get-embed-code.ts
index de36314ac2..5ccd46cfe2 100644
--- a/packages/frontend/src/utility/get-embed-code.ts
+++ b/packages/frontend/src/utility/get-embed-code.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
-import { v4 as uuid } from 'uuid';
+import { genId } from '@/utility/id.js';
import { url } from '@@/js/config.js';
import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js';
@@ -44,7 +44,7 @@ export function normalizeEmbedParams(params: EmbedParams): Record<string, string
* 埋め込みコードを生成(iframe IDの発番もやる)
*/
export function getEmbedCode(path: string, params?: EmbedParams): string {
- const iframeId = 'v1_' + uuid(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく
+ const iframeId = 'v1_' + genId(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく
let paramString = '';
if (params) {
diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts
index 5b99be5fdb..ea93444f08 100644
--- a/packages/frontend/src/utility/get-note-menu.ts
+++ b/packages/frontend/src/utility/get-note-menu.ts
@@ -542,7 +542,7 @@ function smallerVisibility(a: Visibility, b: Visibility): Visibility {
export function getRenoteMenu(props: {
note: Misskey.entities.Note;
- renoteButton: ShallowRef<HTMLElement | undefined>;
+ renoteButton: ShallowRef<HTMLElement | null | undefined>;
mock?: boolean;
}) {
const appearNote = getAppearNote(props.note);
diff --git a/packages/frontend/src/utility/id.ts b/packages/frontend/src/utility/id.ts
new file mode 100644
index 0000000000..63a7f7d74c
--- /dev/null
+++ b/packages/frontend/src/utility/id.ts
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// ランダムな文字列が生成できればなんでも良い(時系列でソートできるなら尚良)が、とりあえずaidの実装を拝借
+
+const TIME2000 = 946684800000;
+let counter = Math.floor(Math.random() * 10000);
+
+function getTime(time: number): string {
+ time = time - TIME2000;
+ if (time < 0) time = 0;
+
+ return time.toString(36).padStart(8, '0');
+}
+
+function getNoise(): string {
+ return counter.toString(36).padStart(2, '0').slice(-2);
+}
+
+export function genId(): string {
+ counter++;
+ return getTime(Date.now()) + getNoise();
+}
diff --git a/packages/frontend/src/utility/image-effector/ImageEffector.ts b/packages/frontend/src/utility/image-effector/ImageEffector.ts
new file mode 100644
index 0000000000..1028c57f35
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/ImageEffector.ts
@@ -0,0 +1,415 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { getProxiedImageUrl } from '../media-proxy.js';
+import { initShaderProgram } from '../webgl.js';
+
+type ParamTypeToPrimitive = {
+ 'number': number;
+ 'number:enum': number;
+ 'boolean': boolean;
+ 'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
+ 'seed': number;
+ 'texture': { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
+ 'color': [r: number, g: number, b: number];
+};
+
+type ImageEffectorFxParamDefs = Record<string, {
+ type: keyof ParamTypeToPrimitive;
+ default: any;
+ label?: string;
+ toViewValue?: (v: any) => string;
+}>;
+
+export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
+ return fx;
+}
+
+export type ImageEffectorFx<ID extends string = string, PS extends ImageEffectorFxParamDefs = ImageEffectorFxParamDefs, US extends string[] = string[]> = {
+ id: ID;
+ name: string;
+ shader: string;
+ uniforms: US;
+ params: PS,
+ main: (ctx: {
+ gl: WebGL2RenderingContext;
+ program: WebGLProgram;
+ params: {
+ [key in keyof PS]: ParamTypeToPrimitive[PS[key]['type']];
+ };
+ u: Record<US[number], WebGLUniformLocation>;
+ width: number;
+ height: number;
+ textures: Record<string, {
+ texture: WebGLTexture;
+ width: number;
+ height: number;
+ } | null>;
+ }) => void;
+};
+
+export type ImageEffectorLayer = {
+ id: string;
+ fxId: string;
+ params: Record<string, any>;
+};
+
+function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, any>, k: string): ParamTypeToPrimitive[T] {
+ return params[k];
+}
+
+export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, any>>> {
+ private gl: WebGL2RenderingContext;
+ private canvas: HTMLCanvasElement | null = null;
+ private renderWidth: number;
+ private renderHeight: number;
+ private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
+ private layers: ImageEffectorLayer[] = [];
+ private originalImageTexture: WebGLTexture;
+ private shaderCache: Map<string, WebGLProgram> = new Map();
+ private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
+ private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
+ private nopProgram: WebGLProgram;
+ private fxs: [...IEX];
+ private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
+
+ constructor(options: {
+ canvas: HTMLCanvasElement;
+ renderWidth: number;
+ renderHeight: number;
+ image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
+ fxs: [...IEX];
+ }) {
+ this.canvas = options.canvas;
+ this.renderWidth = options.renderWidth;
+ this.renderHeight = options.renderHeight;
+ this.originalImage = options.image;
+ this.fxs = options.fxs;
+
+ this.canvas.width = this.renderWidth;
+ this.canvas.height = this.renderHeight;
+
+ const gl = this.canvas.getContext('webgl2', {
+ preserveDrawingBuffer: false,
+ alpha: true,
+ premultipliedAlpha: false,
+ });
+
+ if (gl == null) {
+ throw new Error('Failed to initialize WebGL2 context');
+ }
+
+ this.gl = gl;
+
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+
+ const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
+ const vertexBuffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
+
+ this.originalImageTexture = createTexture(gl);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.originalImage.width, this.originalImage.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+
+ this.nopProgram = initShaderProgram(this.gl, `#version 300 es
+ in vec2 position;
+ out vec2 in_uv;
+
+ void main() {
+ in_uv = (position + 1.0) / 2.0;
+ gl_Position = vec4(position * vec2(1.0, -1.0), 0.0, 1.0);
+ }
+ `, `#version 300 es
+ precision mediump float;
+
+ in vec2 in_uv;
+ uniform sampler2D u_texture;
+ out vec4 out_color;
+
+ void main() {
+ out_color = texture(u_texture, in_uv);
+ }
+ `);
+
+ // レジスタ番号はシェーダープログラムに属しているわけではなく、独立の存在なので、とりあえず nopProgram を使って設定する(その後は効果が持続する)
+ // ref. https://qiita.com/emadurandal/items/5966c8374f03d4de3266
+ const positionLocation = gl.getAttribLocation(this.nopProgram, 'position');
+ gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
+ gl.enableVertexAttribArray(positionLocation);
+ }
+
+ private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture, invert = false) {
+ const gl = this.gl;
+
+ const fx = this.fxs.find(fx => fx.id === layer.fxId);
+ if (fx == null) return;
+
+ const cachedShader = this.shaderCache.get(fx.id);
+ const shaderProgram = cachedShader ?? initShaderProgram(this.gl, `#version 300 es
+ in vec2 position;
+ uniform bool u_invert;
+ out vec2 in_uv;
+
+ void main() {
+ in_uv = (position + 1.0) / 2.0;
+ gl_Position = u_invert ? vec4(position * vec2(1.0, -1.0), 0.0, 1.0) : vec4(position, 0.0, 1.0);
+ }
+ `, fx.shader);
+ if (cachedShader == null) {
+ this.shaderCache.set(fx.id, shaderProgram);
+ }
+
+ gl.useProgram(shaderProgram);
+
+ const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
+ gl.uniform2fv(in_resolution, [this.renderWidth, this.renderHeight]);
+
+ const u_invert = gl.getUniformLocation(shaderProgram, 'u_invert');
+ gl.uniform1i(u_invert, invert ? 1 : 0);
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, preTexture);
+ const in_texture = gl.getUniformLocation(shaderProgram, 'in_texture');
+ gl.uniform1i(in_texture, 0);
+
+ fx.main({
+ gl: gl,
+ program: shaderProgram,
+ params: Object.fromEntries(
+ Object.entries(fx.params as ImageEffectorFxParamDefs).map(([key, param]) => {
+ return [key, layer.params[key] ?? param.default];
+ }),
+ ),
+ u: Object.fromEntries(fx.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
+ width: this.renderWidth,
+ height: this.renderHeight,
+ textures: Object.fromEntries(
+ Object.entries(fx.params as ImageEffectorFxParamDefs).map(([k, v]) => {
+ if (v.type !== 'texture') return [k, null];
+ const param = getValue<typeof v.type>(layer.params, k);
+ if (param == null) return [k, null];
+ const texture = this.paramTextures.get(this.getTextureKeyForParam(param)) ?? null;
+ return [k, texture];
+ })),
+ });
+
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ }
+
+ public render() {
+ const gl = this.gl;
+
+ // 入力をそのまま出力
+ if (this.layers.length === 0) {
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
+
+ gl.useProgram(this.nopProgram);
+ gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0);
+
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ return;
+ }
+
+ let preTexture = this.originalImageTexture;
+
+ for (const layer of this.layers) {
+ const isLast = layer === this.layers.at(-1);
+
+ const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
+ const resultTexture = cachedResultTexture ?? createTexture(gl);
+ if (cachedResultTexture == null) {
+ this.perLayerResultTextures.set(layer.id, resultTexture);
+ }
+ gl.bindTexture(gl.TEXTURE_2D, resultTexture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+
+ if (isLast) {
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ } else {
+ const cachedResultFrameBuffer = this.perLayerResultFrameBuffers.get(layer.id);
+ const resultFrameBuffer = cachedResultFrameBuffer ?? gl.createFramebuffer()!;
+ if (cachedResultFrameBuffer == null) {
+ this.perLayerResultFrameBuffers.set(layer.id, resultFrameBuffer);
+ }
+ gl.bindFramebuffer(gl.FRAMEBUFFER, resultFrameBuffer);
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, resultTexture, 0);
+ }
+
+ this.renderLayer(layer, preTexture, isLast);
+
+ preTexture = resultTexture;
+ }
+ }
+
+ public async setLayers(layers: ImageEffectorLayer[]) {
+ this.layers = layers;
+
+ const unused = new Set(this.paramTextures.keys());
+
+ for (const layer of layers) {
+ const fx = this.fxs.find(fx => fx.id === layer.fxId);
+ if (fx == null) continue;
+
+ for (const k of Object.keys(layer.params)) {
+ const paramDef = fx.params[k];
+ if (paramDef == null) continue;
+ if (paramDef.type !== 'texture') continue;
+ const v = getValue<typeof paramDef.type>(layer.params, k);
+ if (v == null) continue;
+
+ const textureKey = this.getTextureKeyForParam(v);
+ unused.delete(textureKey);
+ if (this.paramTextures.has(textureKey)) continue;
+
+ if (_DEV_) console.log(`Baking texture of <${textureKey}>...`);
+
+ const texture = v.type === 'text' ? await createTextureFromText(this.gl, v.text) : v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : null;
+ if (texture == null) continue;
+
+ this.paramTextures.set(textureKey, texture);
+ }
+ }
+
+ for (const k of unused) {
+ if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
+ this.gl.deleteTexture(this.paramTextures.get(k)!.texture);
+ this.paramTextures.delete(k);
+ }
+
+ this.render();
+ }
+
+ public changeResolution(width: number, height: number) {
+ this.renderWidth = width;
+ this.renderHeight = height;
+ if (this.canvas) {
+ this.canvas.width = this.renderWidth;
+ this.canvas.height = this.renderHeight;
+ }
+ this.gl.viewport(0, 0, this.renderWidth, this.renderHeight);
+ }
+
+ private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) {
+ if (v == null) return '';
+ return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : '';
+ }
+
+ /*
+ * disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
+ */
+ public destroy(disposeCanvas = true) {
+ this.gl.deleteProgram(this.nopProgram);
+
+ for (const shader of this.shaderCache.values()) {
+ this.gl.deleteProgram(shader);
+ }
+ this.shaderCache.clear();
+
+ for (const texture of this.perLayerResultTextures.values()) {
+ this.gl.deleteTexture(texture);
+ }
+ this.perLayerResultTextures.clear();
+
+ for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
+ this.gl.deleteFramebuffer(framebuffer);
+ }
+ this.perLayerResultFrameBuffers.clear();
+
+ for (const texture of this.paramTextures.values()) {
+ this.gl.deleteTexture(texture.texture);
+ }
+ this.paramTextures.clear();
+
+ this.gl.deleteTexture(this.originalImageTexture);
+
+ if (disposeCanvas) {
+ const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
+ if (loseContextExt) loseContextExt.loseContext();
+ }
+ }
+}
+
+function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
+ const texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+ return texture;
+}
+
+async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string | null): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
+ if (imageUrl == null || imageUrl.trim() === '') return null;
+
+ const image = await new Promise<HTMLImageElement>((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = reject;
+ img.src = getProxiedImageUrl(imageUrl); // CORS対策
+ }).catch(() => null);
+
+ if (image == null) return null;
+
+ const texture = createTexture(gl);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+
+ return {
+ texture,
+ width: image.width,
+ height: image.height,
+ };
+}
+
+async function createTextureFromText(gl: WebGL2RenderingContext, text: string | null, resolution = 2048): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
+ if (text == null || text.trim() === '') return null;
+
+ const ctx = window.document.createElement('canvas').getContext('2d')!;
+ ctx.canvas.width = resolution;
+ ctx.canvas.height = resolution / 4;
+ const fontSize = resolution / 32;
+ const margin = fontSize / 2;
+ ctx.shadowColor = '#000000';
+ ctx.shadowBlur = fontSize / 4;
+
+ //ctx.fillStyle = '#00ff00';
+ //ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+
+ ctx.fillStyle = '#ffffff';
+ ctx.font = `bold ${fontSize}px sans-serif`;
+ ctx.textBaseline = 'middle';
+
+ ctx.fillText(text, margin, ctx.canvas.height / 2);
+
+ const textMetrics = ctx.measureText(text);
+ const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin);
+ const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin);
+ const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), ctx.canvas.width, ctx.canvas.height);
+
+ const texture = createTexture(gl);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cropWidth, cropHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+
+ const info = {
+ texture: texture,
+ width: cropWidth,
+ height: cropHeight,
+ };
+
+ ctx.canvas.remove();
+
+ return info;
+}
diff --git a/packages/frontend/src/utility/image-effector/fxs.ts b/packages/frontend/src/utility/image-effector/fxs.ts
new file mode 100644
index 0000000000..1fa48aea15
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { FX_checker } from './fxs/checker.js';
+import { FX_chromaticAberration } from './fxs/chromaticAberration.js';
+import { FX_colorAdjust } from './fxs/colorAdjust.js';
+import { FX_colorClamp } from './fxs/colorClamp.js';
+import { FX_colorClampAdvanced } from './fxs/colorClampAdvanced.js';
+import { FX_distort } from './fxs/distort.js';
+import { FX_polkadot } from './fxs/polkadot.js';
+import { FX_tearing } from './fxs/tearing.js';
+import { FX_grayscale } from './fxs/grayscale.js';
+import { FX_invert } from './fxs/invert.js';
+import { FX_mirror } from './fxs/mirror.js';
+import { FX_stripe } from './fxs/stripe.js';
+import { FX_threshold } from './fxs/threshold.js';
+import { FX_zoomLines } from './fxs/zoomLines.js';
+import { FX_blockNoise } from './fxs/blockNoise.js';
+import type { ImageEffectorFx } from './ImageEffector.js';
+
+export const FXS = [
+ FX_mirror,
+ FX_invert,
+ FX_grayscale,
+ FX_colorAdjust,
+ FX_colorClamp,
+ FX_colorClampAdvanced,
+ FX_distort,
+ FX_threshold,
+ FX_zoomLines,
+ FX_stripe,
+ FX_polkadot,
+ FX_checker,
+ FX_chromaticAberration,
+ FX_tearing,
+ FX_blockNoise,
+] as const satisfies ImageEffectorFx<string, any>[];
diff --git a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts b/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts
new file mode 100644
index 0000000000..bf7eaa8bda
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts
@@ -0,0 +1,119 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import seedrandom from 'seedrandom';
+import { defineImageEffectorFx } from '../ImageEffector.js';
+import { i18n } from '@/i18n.js';
+
+const shader = `#version 300 es
+precision mediump float;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform int u_amount;
+uniform float u_shiftStrengths[128];
+uniform vec2 u_shiftOrigins[128];
+uniform vec2 u_shiftSizes[128];
+uniform float u_channelShift;
+out vec4 out_color;
+
+void main() {
+ // TODO: ピクセル毎に計算する必要はないのでuniformにする
+ float aspect_ratio = min(in_resolution.x, in_resolution.y) / max(in_resolution.x, in_resolution.y);
+ float aspect_ratio_x = in_resolution.x > in_resolution.y ? 1.0 : aspect_ratio;
+ float aspect_ratio_y = in_resolution.x < in_resolution.y ? 1.0 : aspect_ratio;
+
+ float v = 0.0;
+
+ for (int i = 0; i < u_amount; i++) {
+ if (
+ in_uv.x * aspect_ratio_x > ((u_shiftOrigins[i].x * aspect_ratio_x) - u_shiftSizes[i].x) &&
+ in_uv.x * aspect_ratio_x < ((u_shiftOrigins[i].x * aspect_ratio_x) + u_shiftSizes[i].x) &&
+ in_uv.y * aspect_ratio_y > ((u_shiftOrigins[i].y * aspect_ratio_y) - u_shiftSizes[i].y) &&
+ in_uv.y * aspect_ratio_y < ((u_shiftOrigins[i].y * aspect_ratio_y) + u_shiftSizes[i].y)
+ ) {
+ v += u_shiftStrengths[i];
+ }
+ }
+
+ float r = texture(in_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r;
+ float g = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).g;
+ float b = texture(in_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b;
+ float a = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).a;
+ out_color = vec4(r, g, b, a);
+}
+`;
+
+export const FX_blockNoise = defineImageEffectorFx({
+ id: 'blockNoise' as const,
+ name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
+ shader,
+ uniforms: ['amount', 'channelShift'] as const,
+ params: {
+ amount: {
+ type: 'number' as const,
+ default: 50,
+ min: 1,
+ max: 100,
+ step: 1,
+ },
+ strength: {
+ type: 'number' as const,
+ default: 0.05,
+ min: -1,
+ max: 1,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ width: {
+ type: 'number' as const,
+ default: 0.05,
+ min: 0.01,
+ max: 1,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ height: {
+ type: 'number' as const,
+ default: 0.01,
+ min: 0.01,
+ max: 1,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ channelShift: {
+ type: 'number' as const,
+ default: 0,
+ min: 0,
+ max: 10,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ seed: {
+ type: 'seed' as const,
+ default: 100,
+ },
+ },
+ main: ({ gl, program, u, params }) => {
+ gl.uniform1i(u.amount, params.amount);
+ gl.uniform1f(u.channelShift, params.channelShift);
+
+ const margin = 0;
+
+ const rnd = seedrandom(params.seed.toString());
+
+ for (let i = 0; i < params.amount; i++) {
+ const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
+ gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin);
+
+ const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
+ gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
+
+ const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`);
+ gl.uniform2f(sizes, params.width, params.height);
+ }
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.ts b/packages/frontend/src/utility/image-effector/fxs/checker.ts
new file mode 100644
index 0000000000..c426308951
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/checker.ts
@@ -0,0 +1,89 @@
+/*
+ * 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 float u_angle;
+uniform float u_scale;
+uniform vec3 u_color;
+uniform float u_opacity;
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float x_ratio = max(in_resolution.x / in_resolution.y, 1.0);
+ float y_ratio = max(in_resolution.y / in_resolution.x, 1.0);
+
+ float angle = -(u_angle * PI);
+ vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio);
+ vec2 rotatedUV = vec2(
+ centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
+ centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
+ );
+
+ float fmodResult = mod(floor(u_scale * rotatedUV.x) + floor(u_scale * rotatedUV.y), 2.0);
+ float fin = max(sign(fmodResult), 0.0);
+
+ out_color = vec4(
+ mix(in_color.r, u_color.r, fin * u_opacity),
+ mix(in_color.g, u_color.g, fin * u_opacity),
+ mix(in_color.b, u_color.b, fin * u_opacity),
+ in_color.a
+ );
+}
+`;
+
+export const FX_checker = defineImageEffectorFx({
+ id: 'checker' as const,
+ name: i18n.ts._imageEffector._fxs.checker,
+ shader,
+ uniforms: ['angle', 'scale', 'color', 'opacity'] as const,
+ params: {
+ angle: {
+ type: 'number' as const,
+ default: 0,
+ min: -1.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 90) + '°',
+ },
+ scale: {
+ type: 'number' as const,
+ default: 3.0,
+ min: 1.0,
+ max: 10.0,
+ step: 0.1,
+ },
+ color: {
+ type: 'color' as const,
+ default: [1, 1, 1],
+ },
+ opacity: {
+ type: 'number' as const,
+ default: 0.5,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform1f(u.scale, params.scale * params.scale);
+ gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
+ gl.uniform1f(u.opacity, params.opacity);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts b/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts
new file mode 100644
index 0000000000..82d7d883aa
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts
@@ -0,0 +1,76 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+out vec4 out_color;
+uniform float u_amount;
+uniform float u_start;
+uniform bool u_normalize;
+
+void main() {
+ int samples = 64;
+ float r_strength = 1.0;
+ float g_strength = 1.5;
+ float b_strength = 2.0;
+
+ vec2 size = vec2(in_resolution.x, in_resolution.y);
+
+ vec4 accumulator = vec4(0.0);
+ float normalisedValue = length((in_uv - 0.5) * 2.0);
+ float strength = clamp((normalisedValue - u_start) * (1.0 / (1.0 - u_start)), 0.0, 1.0);
+
+ vec2 vector = (u_normalize ? normalize(in_uv - vec2(0.5)) : in_uv - vec2(0.5));
+ vec2 velocity = vector * strength * u_amount;
+
+ vec2 rOffset = -vector * strength * (u_amount * r_strength);
+ vec2 gOffset = -vector * strength * (u_amount * g_strength);
+ vec2 bOffset = -vector * strength * (u_amount * b_strength);
+
+ for (int i = 0; i < samples; i++) {
+ accumulator.r += texture(in_texture, in_uv + rOffset).r;
+ rOffset -= velocity / float(samples);
+
+ accumulator.g += texture(in_texture, in_uv + gOffset).g;
+ gOffset -= velocity / float(samples);
+
+ accumulator.b += texture(in_texture, in_uv + bOffset).b;
+ bOffset -= velocity / float(samples);
+ }
+
+ out_color = vec4(vec3(accumulator / float(samples)), 1.0);
+}
+`;
+
+export const FX_chromaticAberration = defineImageEffectorFx({
+ id: 'chromaticAberration' as const,
+ name: i18n.ts._imageEffector._fxs.chromaticAberration,
+ shader,
+ uniforms: ['amount', 'start', 'normalize'] as const,
+ params: {
+ normalize: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ amount: {
+ type: 'number' as const,
+ default: 0.1,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.amount, params.amount);
+ gl.uniform1i(u.normalize, params.normalize ? 1 : 0);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts b/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts
new file mode 100644
index 0000000000..c38490e198
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts
@@ -0,0 +1,141 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform float u_brightness;
+uniform float u_contrast;
+uniform float u_hue;
+uniform float u_lightness;
+uniform float u_saturation;
+out vec4 out_color;
+
+// RGB to HSL
+vec3 rgb2hsl(vec3 c) {
+ float maxc = max(max(c.r, c.g), c.b);
+ float minc = min(min(c.r, c.g), c.b);
+ float l = (maxc + minc) * 0.5;
+ float s = 0.0;
+ float h = 0.0;
+ if (maxc != minc) {
+ float d = maxc - minc;
+ s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc);
+ if (maxc == c.r) {
+ h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
+ } else if (maxc == c.g) {
+ h = (c.b - c.r) / d + 2.0;
+ } else {
+ h = (c.r - c.g) / d + 4.0;
+ }
+ h /= 6.0;
+ }
+ return vec3(h, s, l);
+}
+
+// HSL to RGB
+float hue2rgb(float p, float q, float t) {
+ if (t < 0.0) t += 1.0;
+ if (t > 1.0) t -= 1.0;
+ if (t < 1.0/6.0) return p + (q - p) * 6.0 * t;
+ if (t < 1.0/2.0) return q;
+ if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0;
+ return p;
+}
+vec3 hsl2rgb(vec3 hsl) {
+ float r, g, b;
+ float h = hsl.x;
+ float s = hsl.y;
+ float l = hsl.z;
+ if (s == 0.0) {
+ r = g = b = l;
+ } else {
+ float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
+ float p = 2.0 * l - q;
+ r = hue2rgb(p, q, h + 1.0/3.0);
+ g = hue2rgb(p, q, h);
+ b = hue2rgb(p, q, h - 1.0/3.0);
+ }
+ return vec3(r, g, b);
+}
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ vec3 color = in_color.rgb;
+
+ color = color * u_brightness;
+ color += vec3(u_lightness);
+ color = (color - 0.5) * u_contrast + 0.5;
+
+ vec3 hsl = rgb2hsl(color);
+ hsl.x = mod(hsl.x + u_hue, 1.0);
+ hsl.y = clamp(hsl.y * u_saturation, 0.0, 1.0);
+
+ color = hsl2rgb(hsl);
+ out_color = vec4(color, in_color.a);
+}
+`;
+
+export const FX_colorAdjust = defineImageEffectorFx({
+ id: 'colorAdjust' as const,
+ name: i18n.ts._imageEffector._fxs.colorAdjust,
+ shader,
+ uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const,
+ params: {
+ lightness: {
+ type: 'number' as const,
+ default: 0,
+ min: -1,
+ max: 1,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ contrast: {
+ type: 'number' as const,
+ default: 1,
+ min: 0,
+ max: 4,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ hue: {
+ type: 'number' as const,
+ default: 0,
+ min: -1,
+ max: 1,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 180) + '°',
+ },
+ brightness: {
+ type: 'number' as const,
+ default: 1,
+ min: 0,
+ max: 4,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ saturation: {
+ type: 'number' as const,
+ default: 1,
+ min: 0,
+ max: 4,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.brightness, params.brightness);
+ gl.uniform1f(u.contrast, params.contrast);
+ gl.uniform1f(u.hue, params.hue / 2);
+ gl.uniform1f(u.lightness, params.lightness);
+ gl.uniform1f(u.saturation, params.saturation);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts b/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts
new file mode 100644
index 0000000000..ae0d92b8ae
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts
@@ -0,0 +1,55 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform float u_max;
+uniform float u_min;
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float r = min(max(in_color.r, u_min), u_max);
+ float g = min(max(in_color.g, u_min), u_max);
+ float b = min(max(in_color.b, u_min), u_max);
+ out_color = vec4(r, g, b, in_color.a);
+}
+`;
+
+export const FX_colorClamp = defineImageEffectorFx({
+ id: 'colorClamp' as const,
+ name: i18n.ts._imageEffector._fxs.colorClamp,
+ shader,
+ uniforms: ['max', 'min'] as const,
+ params: {
+ max: {
+ type: 'number' as const,
+ default: 1.0,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ min: {
+ type: 'number' as const,
+ default: -1.0,
+ min: -1.0,
+ max: 0.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.max, params.max);
+ gl.uniform1f(u.min, 1.0 + params.min);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts b/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts
new file mode 100644
index 0000000000..b9387900fb
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts
@@ -0,0 +1,95 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform float u_rMax;
+uniform float u_rMin;
+uniform float u_gMax;
+uniform float u_gMin;
+uniform float u_bMax;
+uniform float u_bMin;
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float r = min(max(in_color.r, u_rMin), u_rMax);
+ float g = min(max(in_color.g, u_gMin), u_gMax);
+ float b = min(max(in_color.b, u_bMin), u_bMax);
+ out_color = vec4(r, g, b, in_color.a);
+}
+`;
+
+export const FX_colorClampAdvanced = defineImageEffectorFx({
+ id: 'colorClampAdvanced' as const,
+ name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
+ shader,
+ uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
+ params: {
+ rMax: {
+ type: 'number' as const,
+ default: 1.0,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ rMin: {
+ type: 'number' as const,
+ default: -1.0,
+ min: -1.0,
+ max: 0.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ gMax: {
+ type: 'number' as const,
+ default: 1.0,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ gMin: {
+ type: 'number' as const,
+ default: -1.0,
+ min: -1.0,
+ max: 0.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ bMax: {
+ type: 'number' as const,
+ default: 1.0,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ bMin: {
+ type: 'number' as const,
+ default: -1.0,
+ min: -1.0,
+ max: 0.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.rMax, params.rMax);
+ gl.uniform1f(u.rMin, 1.0 + params.rMin);
+ gl.uniform1f(u.gMax, params.gMax);
+ gl.uniform1f(u.gMin, 1.0 + params.gMin);
+ gl.uniform1f(u.bMax, params.bMax);
+ gl.uniform1f(u.bMin, 1.0 + params.bMin);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.ts b/packages/frontend/src/utility/image-effector/fxs/distort.ts
new file mode 100644
index 0000000000..4b1aefc159
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/distort.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 float u_phase;
+uniform float u_frequency;
+uniform float u_strength;
+uniform int u_direction; // 0: vertical, 1: horizontal
+out vec4 out_color;
+
+void main() {
+ float v = u_direction == 0 ?
+ sin((HALF_PI + (u_phase * PI) - (u_frequency / 2.0)) + in_uv.y * u_frequency) * u_strength :
+ sin((HALF_PI + (u_phase * PI) - (u_frequency / 2.0)) + in_uv.x * u_frequency) * u_strength;
+ vec4 in_color = u_direction == 0 ?
+ texture(in_texture, vec2(in_uv.x + v, in_uv.y)) :
+ texture(in_texture, vec2(in_uv.x, in_uv.y + v));
+ out_color = in_color;
+}
+`;
+
+export const FX_distort = defineImageEffectorFx({
+ id: 'distort' as const,
+ name: i18n.ts._imageEffector._fxs.distort,
+ shader,
+ uniforms: ['phase', 'frequency', 'strength', 'direction'] as const,
+ params: {
+ direction: {
+ type: 'number:enum' as const,
+ enum: [{ value: 0, label: 'v' }, { value: 1, label: 'h' }],
+ default: 1,
+ },
+ phase: {
+ type: 'number' as const,
+ default: 0.0,
+ min: -1.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ frequency: {
+ type: 'number' as const,
+ default: 30,
+ min: 0,
+ max: 100,
+ step: 0.1,
+ },
+ strength: {
+ type: 'number' as const,
+ default: 0.05,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.phase, params.phase);
+ gl.uniform1f(u.frequency, params.frequency);
+ gl.uniform1f(u.strength, params.strength);
+ gl.uniform1i(u.direction, params.direction);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/grayscale.ts b/packages/frontend/src/utility/image-effector/fxs/grayscale.ts
new file mode 100644
index 0000000000..8f33706ae7
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/grayscale.ts
@@ -0,0 +1,37 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+out vec4 out_color;
+
+float getBrightness(vec4 color) {
+ return (color.r + color.g + color.b) / 3.0;
+}
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float brightness = getBrightness(in_color);
+ out_color = vec4(brightness, brightness, brightness, in_color.a);
+}
+`;
+
+export const FX_grayscale = defineImageEffectorFx({
+ id: 'grayscale' as const,
+ name: i18n.ts._imageEffector._fxs.grayscale,
+ shader,
+ uniforms: [] as const,
+ params: {
+ },
+ main: ({ gl, params }) => {
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/invert.ts b/packages/frontend/src/utility/image-effector/fxs/invert.ts
new file mode 100644
index 0000000000..220a2dea30
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/invert.ts
@@ -0,0 +1,53 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform bool u_r;
+uniform bool u_g;
+uniform bool u_b;
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ out_color.r = u_r ? 1.0 - in_color.r : in_color.r;
+ out_color.g = u_g ? 1.0 - in_color.g : in_color.g;
+ out_color.b = u_b ? 1.0 - in_color.b : in_color.b;
+ out_color.a = in_color.a;
+}
+`;
+
+export const FX_invert = defineImageEffectorFx({
+ id: 'invert' as const,
+ name: i18n.ts._imageEffector._fxs.invert,
+ shader,
+ uniforms: ['r', 'g', 'b'] as const,
+ params: {
+ r: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ g: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ b: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1i(u.r, params.r ? 1 : 0);
+ gl.uniform1i(u.g, params.g ? 1 : 0);
+ gl.uniform1i(u.b, params.b ? 1 : 0);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.ts b/packages/frontend/src/utility/image-effector/fxs/mirror.ts
new file mode 100644
index 0000000000..5946a2e0dc
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/mirror.ts
@@ -0,0 +1,58 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform int u_h;
+uniform int u_v;
+out vec4 out_color;
+
+void main() {
+ vec2 uv = in_uv;
+ if (u_h == -1 && in_uv.x > 0.5) {
+ uv.x = 1.0 - uv.x;
+ }
+ if (u_h == 1 && in_uv.x < 0.5) {
+ uv.x = 1.0 - uv.x;
+ }
+ if (u_v == -1 && in_uv.y > 0.5) {
+ uv.y = 1.0 - uv.y;
+ }
+ if (u_v == 1 && in_uv.y < 0.5) {
+ uv.y = 1.0 - uv.y;
+ }
+ out_color = texture(in_texture, uv);
+}
+`;
+
+export const FX_mirror = defineImageEffectorFx({
+ id: 'mirror' as const,
+ name: i18n.ts._imageEffector._fxs.mirror,
+ shader,
+ uniforms: ['h', 'v'] as const,
+ params: {
+ h: {
+ type: 'number:enum' as const,
+ enum: [{ value: -1, label: '<-' }, { value: 0, label: '|' }, { value: 1, label: '->' }],
+ default: -1,
+ },
+ v: {
+ type: 'number:enum' as const,
+ enum: [{ value: -1, label: '^' }, { value: 0, label: '-' }, { value: 1, label: 'v' }],
+ default: 0,
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1i(u.h, params.h);
+ gl.uniform1i(u.v, params.v);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts b/packages/frontend/src/utility/image-effector/fxs/polkadot.ts
new file mode 100644
index 0000000000..14f6f91148
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/polkadot.ts
@@ -0,0 +1,154 @@
+/*
+ * 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 float u_angle;
+uniform float u_scale;
+uniform float u_major_radius;
+uniform float u_major_opacity;
+uniform float u_minor_divisions;
+uniform float u_minor_radius;
+uniform float u_minor_opacity;
+uniform vec3 u_color;
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float x_ratio = max(in_resolution.x / in_resolution.y, 1.0);
+ float y_ratio = max(in_resolution.y / in_resolution.x, 1.0);
+
+ float angle = -(u_angle * PI);
+ vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio);
+ vec2 rotatedUV = vec2(
+ centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
+ centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
+ );
+
+ float major_modX = mod(rotatedUV.x, (1.0 / u_scale));
+ float major_modY = mod(rotatedUV.y, (1.0 / u_scale));
+ float major_threshold = ((u_major_radius / 2.0) / u_scale);
+ if (
+ length(vec2(major_modX, major_modY)) < major_threshold ||
+ length(vec2((1.0 / u_scale) - major_modX, major_modY)) < major_threshold ||
+ length(vec2(major_modX, (1.0 / u_scale) - major_modY)) < major_threshold ||
+ length(vec2((1.0 / u_scale) - major_modX, (1.0 / u_scale) - major_modY)) < major_threshold
+ ) {
+ out_color = vec4(
+ mix(in_color.r, u_color.r, u_major_opacity),
+ mix(in_color.g, u_color.g, u_major_opacity),
+ mix(in_color.b, u_color.b, u_major_opacity),
+ in_color.a
+ );
+ return;
+ }
+
+ float minor_modX = mod(rotatedUV.x, (1.0 / u_scale / u_minor_divisions));
+ float minor_modY = mod(rotatedUV.y, (1.0 / u_scale / u_minor_divisions));
+ float minor_threshold = ((u_minor_radius / 2.0) / (u_minor_divisions * u_scale));
+ if (
+ length(vec2(minor_modX, minor_modY)) < minor_threshold ||
+ length(vec2((1.0 / u_scale / u_minor_divisions) - minor_modX, minor_modY)) < minor_threshold ||
+ length(vec2(minor_modX, (1.0 / u_scale / u_minor_divisions) - minor_modY)) < minor_threshold ||
+ length(vec2((1.0 / u_scale / u_minor_divisions) - minor_modX, (1.0 / u_scale / u_minor_divisions) - minor_modY)) < minor_threshold
+ ) {
+ out_color = vec4(
+ mix(in_color.r, u_color.r, u_minor_opacity),
+ mix(in_color.g, u_color.g, u_minor_opacity),
+ mix(in_color.b, u_color.b, u_minor_opacity),
+ in_color.a
+ );
+ return;
+ }
+
+ out_color = in_color;
+}
+`;
+
+export const FX_polkadot = defineImageEffectorFx({
+ id: 'polkadot' as const,
+ name: i18n.ts._imageEffector._fxs.polkadot,
+ shader,
+ uniforms: ['angle', 'scale', 'major_radius', 'major_opacity', 'minor_divisions', 'minor_radius', 'minor_opacity', 'color'] as const,
+ params: {
+ angle: {
+ type: 'number' as const,
+ default: 0,
+ min: -1.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 90) + '°',
+ },
+ scale: {
+ type: 'number' as const,
+ default: 3.0,
+ min: 1.0,
+ max: 10.0,
+ step: 0.1,
+ },
+ majorRadius: {
+ type: 'number' as const,
+ default: 0.1,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ majorOpacity: {
+ type: 'number' as const,
+ default: 0.75,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ minorDivisions: {
+ type: 'number' as const,
+ default: 4,
+ min: 0,
+ max: 16,
+ step: 1,
+ },
+ minorRadius: {
+ type: 'number' as const,
+ default: 0.25,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ minorOpacity: {
+ type: 'number' as const,
+ default: 0.5,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ color: {
+ type: 'color' as const,
+ default: [1, 1, 1],
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform1f(u.scale, params.scale * params.scale);
+ gl.uniform1f(u.major_radius, params.majorRadius);
+ gl.uniform1f(u.major_opacity, params.majorOpacity);
+ gl.uniform1f(u.minor_divisions, params.minorDivisions);
+ gl.uniform1f(u.minor_radius, params.minorRadius);
+ gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
+ gl.uniform1f(u.minor_opacity, params.minorOpacity);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.ts b/packages/frontend/src/utility/image-effector/fxs/stripe.ts
new file mode 100644
index 0000000000..f6c1d2278d
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/stripe.ts
@@ -0,0 +1,101 @@
+/*
+ * 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 float u_angle;
+uniform float u_frequency;
+uniform float u_phase;
+uniform float u_threshold;
+uniform vec3 u_color;
+uniform float u_opacity;
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float x_ratio = max(in_resolution.x / in_resolution.y, 1.0);
+ float y_ratio = max(in_resolution.y / in_resolution.x, 1.0);
+
+ float angle = -(u_angle * PI);
+ vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio);
+ vec2 rotatedUV = vec2(
+ centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
+ centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
+ );
+
+ float phase = u_phase * TWO_PI;
+ float value = (1.0 + sin((rotatedUV.x * u_frequency - HALF_PI) + phase)) / 2.0;
+ value = value < u_threshold ? 1.0 : 0.0;
+ out_color = vec4(
+ mix(in_color.r, u_color.r, value * u_opacity),
+ mix(in_color.g, u_color.g, value * u_opacity),
+ mix(in_color.b, u_color.b, value * u_opacity),
+ in_color.a
+ );
+}
+`;
+
+export const FX_stripe = defineImageEffectorFx({
+ id: 'stripe' as const,
+ name: i18n.ts._imageEffector._fxs.stripe,
+ shader,
+ uniforms: ['angle', 'frequency', 'phase', 'threshold', 'color', 'opacity'] as const,
+ params: {
+ angle: {
+ type: 'number' as const,
+ default: 0.5,
+ min: -1.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 90) + '°',
+ },
+ frequency: {
+ type: 'number' as const,
+ default: 10.0,
+ min: 1.0,
+ max: 30.0,
+ step: 0.1,
+ },
+ threshold: {
+ type: 'number' as const,
+ default: 0.1,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ color: {
+ type: 'color' as const,
+ default: [1, 1, 1],
+ },
+ opacity: {
+ type: 'number' as const,
+ default: 0.5,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform1f(u.frequency, params.frequency * params.frequency);
+ gl.uniform1f(u.phase, 0.0);
+ gl.uniform1f(u.threshold, params.threshold);
+ gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
+ gl.uniform1f(u.opacity, params.opacity);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/tearing.ts b/packages/frontend/src/utility/image-effector/fxs/tearing.ts
new file mode 100644
index 0000000000..d5f1e062ec
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/tearing.ts
@@ -0,0 +1,99 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import seedrandom from 'seedrandom';
+import { defineImageEffectorFx } from '../ImageEffector.js';
+import { i18n } from '@/i18n.js';
+
+const shader = `#version 300 es
+precision mediump float;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform int u_amount;
+uniform float u_shiftStrengths[128];
+uniform float u_shiftOrigins[128];
+uniform float u_shiftHeights[128];
+uniform float u_channelShift;
+out vec4 out_color;
+
+void main() {
+ float v = 0.0;
+
+ for (int i = 0; i < u_amount; i++) {
+ if (in_uv.y > (u_shiftOrigins[i] - u_shiftHeights[i]) && in_uv.y < (u_shiftOrigins[i] + u_shiftHeights[i])) {
+ v += u_shiftStrengths[i];
+ }
+ }
+
+ float r = texture(in_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r;
+ float g = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).g;
+ float b = texture(in_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b;
+ float a = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).a;
+ out_color = vec4(r, g, b, a);
+}
+`;
+
+export const FX_tearing = defineImageEffectorFx({
+ id: 'tearing' as const,
+ name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing,
+ shader,
+ uniforms: ['amount', 'channelShift'] as const,
+ params: {
+ amount: {
+ type: 'number' as const,
+ default: 3,
+ min: 1,
+ max: 100,
+ step: 1,
+ },
+ strength: {
+ type: 'number' as const,
+ default: 0.05,
+ min: -1,
+ max: 1,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ size: {
+ type: 'number' as const,
+ default: 0.2,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ channelShift: {
+ type: 'number' as const,
+ default: 0.5,
+ min: 0,
+ max: 10,
+ step: 0.01,
+ toViewValue: v => Math.round(v * 100) + '%',
+ },
+ seed: {
+ type: 'seed' as const,
+ default: 100,
+ },
+ },
+ main: ({ gl, program, u, params }) => {
+ gl.uniform1i(u.amount, params.amount);
+ gl.uniform1f(u.channelShift, params.channelShift);
+
+ const rnd = seedrandom(params.seed.toString());
+
+ for (let i = 0; i < params.amount; i++) {
+ const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
+ gl.uniform1f(o, rnd());
+
+ const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
+ gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
+
+ const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`);
+ gl.uniform1f(h, rnd() * params.size);
+ }
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.ts b/packages/frontend/src/utility/image-effector/fxs/threshold.ts
new file mode 100644
index 0000000000..f2b8b107fd
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/threshold.ts
@@ -0,0 +1,62 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform float u_r;
+uniform float u_g;
+uniform float u_b;
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float r = in_color.r < u_r ? 0.0 : 1.0;
+ float g = in_color.g < u_g ? 0.0 : 1.0;
+ float b = in_color.b < u_b ? 0.0 : 1.0;
+ out_color = vec4(r, g, b, in_color.a);
+}
+`;
+
+export const FX_threshold = defineImageEffectorFx({
+ id: 'threshold' as const,
+ name: i18n.ts._imageEffector._fxs.threshold,
+ shader,
+ uniforms: ['r', 'g', 'b'] as const,
+ params: {
+ r: {
+ type: 'number' as const,
+ default: 0.5,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ g: {
+ type: 'number' as const,
+ default: 0.5,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ b: {
+ type: 'number' as const,
+ default: 0.5,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.r, params.r);
+ gl.uniform1f(u.g, params.g);
+ gl.uniform1f(u.b, params.b);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts
new file mode 100644
index 0000000000..1c1c95b0c5
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts
@@ -0,0 +1,148 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineImageEffectorFx } from '../ImageEffector.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 sampler2D u_texture_watermark;
+uniform vec2 u_resolution_watermark;
+uniform float u_scale;
+uniform float u_angle;
+uniform float u_opacity;
+uniform bool u_repeat;
+uniform int u_alignX; // 0: left, 1: center, 2: right
+uniform int u_alignY; // 0: top, 1: center, 2: bottom
+uniform int u_fitMode; // 0: contain, 1: cover
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float in_x_ratio = max(in_resolution.x / in_resolution.y, 1.0);
+ float in_y_ratio = max(in_resolution.y / in_resolution.x, 1.0);
+
+ bool contain = u_fitMode == 0;
+
+ float x_ratio = u_resolution_watermark.x / in_resolution.x;
+ float y_ratio = u_resolution_watermark.y / in_resolution.y;
+
+ float aspect_ratio = contain ?
+ (min(x_ratio, y_ratio) / max(x_ratio, y_ratio)) :
+ (max(x_ratio, y_ratio) / min(x_ratio, y_ratio));
+
+ float x_scale = contain ?
+ (x_ratio > y_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) :
+ (x_ratio > y_ratio ? aspect_ratio * u_scale : 1.0 * u_scale);
+
+ float y_scale = contain ?
+ (y_ratio > x_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) :
+ (y_ratio > x_ratio ? aspect_ratio * u_scale : 1.0 * u_scale);
+
+ float x_offset = u_alignX == 0 ? x_scale / 2.0 : u_alignX == 2 ? 1.0 - (x_scale / 2.0) : 0.5;
+ float y_offset = u_alignY == 0 ? y_scale / 2.0 : u_alignY == 2 ? 1.0 - (y_scale / 2.0) : 0.5;
+
+ float angle = -(u_angle * PI);
+ vec2 center = vec2(x_offset, y_offset);
+ //vec2 centeredUv = (in_uv - center) * vec2(in_x_ratio, in_y_ratio);
+ vec2 centeredUv = (in_uv - center);
+ vec2 rotatedUV = vec2(
+ centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
+ centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
+ ) + center;
+
+ // trim
+ if (!u_repeat) {
+ bool isInside = rotatedUV.x > x_offset - (x_scale / 2.0) && rotatedUV.x < x_offset + (x_scale / 2.0) &&
+ rotatedUV.y > y_offset - (y_scale / 2.0) && rotatedUV.y < y_offset + (y_scale / 2.0);
+ if (!isInside) {
+ out_color = in_color;
+ return;
+ }
+ }
+
+ vec4 watermark_color = texture(u_texture_watermark, vec2(
+ (rotatedUV.x - (x_offset - (x_scale / 2.0))) / x_scale,
+ (rotatedUV.y - (y_offset - (y_scale / 2.0))) / y_scale
+ ));
+
+ out_color.r = mix(in_color.r, watermark_color.r, u_opacity * watermark_color.a);
+ out_color.g = mix(in_color.g, watermark_color.g, u_opacity * watermark_color.a);
+ out_color.b = mix(in_color.b, watermark_color.b, u_opacity * watermark_color.a);
+ out_color.a = in_color.a * (1.0 - u_opacity * watermark_color.a) + watermark_color.a * u_opacity;
+}
+`;
+
+export const FX_watermarkPlacement = defineImageEffectorFx({
+ id: 'watermarkPlacement' as const,
+ name: '(internal)',
+ shader,
+ uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'fitMode'] as const,
+ params: {
+ cover: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ repeat: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ scale: {
+ type: 'number' as const,
+ default: 0.3,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ angle: {
+ type: 'number' as const,
+ default: 0,
+ min: -1.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ align: {
+ type: 'align' as const,
+ default: { x: 'right', y: 'bottom' },
+ },
+ opacity: {
+ type: 'number' as const,
+ default: 0.75,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ watermark: {
+ type: 'texture' as const,
+ default: null,
+ },
+ },
+ main: ({ gl, u, params, textures }) => {
+ if (textures.watermark == null) {
+ return;
+ }
+
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, textures.watermark.texture);
+ gl.uniform1i(u.texture_watermark, 1);
+
+ gl.uniform2fv(u.resolution_watermark, [textures.watermark.width, textures.watermark.height]);
+ gl.uniform1f(u.scale, params.scale);
+
+ gl.uniform1f(u.opacity, params.opacity);
+ gl.uniform1f(u.angle, params.angle);
+ gl.uniform1i(u.repeat, params.repeat ? 1 : 0);
+ gl.uniform1i(u.alignX, params.align.x === 'left' ? 0 : params.align.x === 'right' ? 2 : 1);
+ gl.uniform1i(u.alignY, params.align.y === 'top' ? 0 : params.align.y === 'bottom' ? 2 : 1);
+ gl.uniform1i(u.fitMode, params.cover ? 1 : 0);
+ },
+});
diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts
new file mode 100644
index 0000000000..2613362a71
--- /dev/null
+++ b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts
@@ -0,0 +1,97 @@
+/*
+ * 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;
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform vec2 u_pos;
+uniform float u_frequency;
+uniform bool u_thresholdEnabled;
+uniform float u_threshold;
+uniform float u_maskSize;
+uniform bool u_black;
+out vec4 out_color;
+
+void main() {
+ vec4 in_color = texture(in_texture, in_uv);
+ float angle = atan(-u_pos.y + (in_uv.y), -u_pos.x + (in_uv.x));
+ float t = (1.0 + sin(angle * u_frequency)) / 2.0;
+ if (u_thresholdEnabled) t = t < u_threshold ? 1.0 : 0.0;
+ float d = distance(in_uv * vec2(2.0, 2.0), u_pos * vec2(2.0, 2.0));
+ float mask = d < u_maskSize ? 0.0 : ((d - u_maskSize) * (1.0 + (u_maskSize * 2.0)));
+ out_color = vec4(
+ mix(in_color.r, u_black ? 0.0 : 1.0, t * mask),
+ mix(in_color.g, u_black ? 0.0 : 1.0, t * mask),
+ mix(in_color.b, u_black ? 0.0 : 1.0, t * mask),
+ in_color.a
+ );
+}
+`;
+
+export const FX_zoomLines = defineImageEffectorFx({
+ id: 'zoomLines' as const,
+ name: i18n.ts._imageEffector._fxs.zoomLines,
+ shader,
+ uniforms: ['pos', 'frequency', 'thresholdEnabled', 'threshold', 'maskSize', 'black'] as const,
+ params: {
+ x: {
+ type: 'number' as const,
+ default: 0.0,
+ min: -1.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ y: {
+ type: 'number' as const,
+ default: 0.0,
+ min: -1.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ frequency: {
+ type: 'number' as const,
+ default: 30.0,
+ min: 1.0,
+ max: 200.0,
+ step: 0.1,
+ },
+ thresholdEnabled: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ threshold: {
+ type: 'number' as const,
+ default: 0.2,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ maskSize: {
+ type: 'number' as const,
+ default: 0.5,
+ min: 0.0,
+ max: 1.0,
+ step: 0.01,
+ },
+ black: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ },
+ main: ({ gl, u, params }) => {
+ gl.uniform2f(u.pos, (1.0 + params.x) / 2.0, (1.0 + params.y) / 2.0);
+ gl.uniform1f(u.frequency, params.frequency);
+ gl.uniform1i(u.thresholdEnabled, params.thresholdEnabled ? 1 : 0);
+ gl.uniform1f(u.threshold, params.threshold);
+ gl.uniform1f(u.maskSize, params.maskSize);
+ gl.uniform1i(u.black, params.black ? 1 : 0);
+ },
+});
diff --git a/packages/frontend/src/utility/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts
index 5c86969876..65398e6a43 100644
--- a/packages/frontend/src/utility/snowfall-effect.ts
+++ b/packages/frontend/src/utility/snowfall-effect.ts
@@ -38,7 +38,7 @@ export class SnowfallEffect {
`;
private FRAGMENT_SOURCE = `#version 300 es
- precision highp float;
+ precision mediump float;
in vec4 v_color;
in float v_rotation;
diff --git a/packages/frontend/src/utility/theme-editor.ts b/packages/frontend/src/utility/theme-editor.ts
index ea07e5f2ff..74175703c3 100644
--- a/packages/frontend/src/utility/theme-editor.ts
+++ b/packages/frontend/src/utility/theme-editor.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { v4 as uuid } from 'uuid';
+import { genId } from '@/utility/id.js';
import type { Theme } from '@/theme.js';
import { themeProps } from '@/theme.js';
@@ -66,7 +66,7 @@ export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: st
}
return {
- id: uuid(),
+ id: genId(),
name, desc, author, props, base,
};
};
diff --git a/packages/frontend/src/utility/watermark.ts b/packages/frontend/src/utility/watermark.ts
new file mode 100644
index 0000000000..f0b38684f0
--- /dev/null
+++ b/packages/frontend/src/utility/watermark.ts
@@ -0,0 +1,189 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
+import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js';
+import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js';
+import { FX_checker } from '@/utility/image-effector/fxs/checker.js';
+import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
+import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
+
+const WATERMARK_FXS = [
+ FX_watermarkPlacement,
+ FX_stripe,
+ FX_polkadot,
+ FX_checker,
+] as const satisfies ImageEffectorFx<string, any>[];
+
+export type WatermarkPreset = {
+ id: string;
+ name: string;
+ layers: ({
+ id: string;
+ type: 'text';
+ text: string;
+ repeat: boolean;
+ scale: number;
+ angle: number;
+ align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
+ opacity: number;
+ } | {
+ id: string;
+ type: 'image';
+ imageUrl: string | null;
+ imageId: string | null;
+ cover: boolean;
+ repeat: boolean;
+ scale: number;
+ angle: number;
+ align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
+ opacity: number;
+ } | {
+ id: string;
+ type: 'stripe';
+ angle: number;
+ frequency: number;
+ threshold: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+ } | {
+ id: string;
+ type: 'polkadot';
+ angle: number;
+ scale: number;
+ majorRadius: number;
+ majorOpacity: number;
+ minorDivisions: number;
+ minorRadius: number;
+ minorOpacity: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+ } | {
+ id: string;
+ type: 'checker';
+ angle: number;
+ scale: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+ })[];
+};
+
+export class WatermarkRenderer {
+ private effector: ImageEffector<typeof WATERMARK_FXS>;
+ private layers: WatermarkPreset['layers'] = [];
+
+ constructor(options: {
+ canvas: HTMLCanvasElement,
+ renderWidth: number,
+ renderHeight: number,
+ image: HTMLImageElement | ImageBitmap,
+ }) {
+ this.effector = new ImageEffector({
+ canvas: options.canvas,
+ renderWidth: options.renderWidth,
+ renderHeight: options.renderHeight,
+ image: options.image,
+ fxs: WATERMARK_FXS,
+ });
+ }
+
+ private makeImageEffectorLayers(): ImageEffectorLayer[] {
+ return this.layers.map(layer => {
+ if (layer.type === 'text') {
+ return {
+ fxId: 'watermarkPlacement',
+ id: layer.id,
+ params: {
+ repeat: layer.repeat,
+ scale: layer.scale,
+ align: layer.align,
+ angle: layer.angle,
+ opacity: layer.opacity,
+ cover: false,
+ watermark: {
+ type: 'text',
+ text: layer.text,
+ },
+ },
+ };
+ } else if (layer.type === 'image') {
+ return {
+ fxId: 'watermarkPlacement',
+ id: layer.id,
+ params: {
+ repeat: layer.repeat,
+ scale: layer.scale,
+ align: layer.align,
+ angle: layer.angle,
+ opacity: layer.opacity,
+ cover: layer.cover,
+ watermark: {
+ type: 'url',
+ url: layer.imageUrl,
+ },
+ },
+ };
+ } else if (layer.type === 'stripe') {
+ return {
+ fxId: 'stripe',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ frequency: layer.frequency,
+ threshold: layer.threshold,
+ color: layer.color,
+ opacity: layer.opacity,
+ },
+ };
+ } else if (layer.type === 'polkadot') {
+ return {
+ fxId: 'polkadot',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ scale: layer.scale,
+ majorRadius: layer.majorRadius,
+ majorOpacity: layer.majorOpacity,
+ minorDivisions: layer.minorDivisions,
+ minorRadius: layer.minorRadius,
+ minorOpacity: layer.minorOpacity,
+ color: layer.color,
+ opacity: layer.opacity,
+ },
+ };
+ } else if (layer.type === 'checker') {
+ return {
+ fxId: 'checker',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ scale: layer.scale,
+ color: layer.color,
+ opacity: layer.opacity,
+ },
+ };
+ } else {
+ throw new Error(`Unknown layer type`);
+ }
+ });
+ }
+
+ public async setLayers(layers: WatermarkPreset['layers']) {
+ this.layers = layers;
+ await this.effector.setLayers(this.makeImageEffectorLayers());
+ this.render();
+ }
+
+ public render(): void {
+ this.effector.render();
+ }
+
+ /*
+ * disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
+ */
+ public destroy(disposeCanvas = true): void {
+ this.effector.destroy(disposeCanvas);
+ }
+}
diff --git a/packages/frontend/src/utility/webgl.ts b/packages/frontend/src/utility/webgl.ts
new file mode 100644
index 0000000000..ae595b605c
--- /dev/null
+++ b/packages/frontend/src/utility/webgl.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function loadShader(gl: WebGL2RenderingContext, type: GLenum, source: string): WebGLShader {
+ const shader = gl.createShader(type);
+ if (shader == null) {
+ throw new Error('falied to create shader');
+ }
+
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ console.error(`falied to compile shader: ${gl.getShaderInfoLog(shader)}`);
+ gl.deleteShader(shader);
+ throw new Error(`falied to compile shader: ${gl.getShaderInfoLog(shader)}`);
+ }
+
+ return shader;
+}
+
+export function initShaderProgram(gl: WebGL2RenderingContext, vsSource: string, fsSource: string): WebGLProgram {
+ const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
+ const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
+
+ const shaderProgram = gl.createProgram();
+
+ gl.attachShader(shaderProgram, vertexShader);
+ gl.attachShader(shaderProgram, fragmentShader);
+ gl.linkProgram(shaderProgram);
+
+ if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
+ console.error(`failed to init shader: ${gl.getProgramInfoLog(shaderProgram)}`);
+ throw new Error('failed to init shader');
+ }
+
+ return shaderProgram;
+}