1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-14 12:15:44 +02:00

feat(frontend): EXIFフレーム機能 (#16725)

* 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
This commit is contained in:
syuilo
2025-11-06 20:25:17 +09:00
committed by GitHub
parent 26c8914a26
commit 4ba18690d7
64 changed files with 2838 additions and 1186 deletions

View File

@@ -5,14 +5,42 @@
import seedrandom from 'seedrandom';
import shader from './blockNoise.glsl';
import { defineImageEffectorFx } from '../ImageEffector.js';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_blockNoise = defineImageEffectorFx({
id: 'blockNoise',
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
export const fn = defineImageCompositorFunction<{
amount: number;
strength: number;
width: number;
height: number;
channelShift: number;
seed: number;
}>({
shader,
uniforms: ['amount', 'channelShift'] as const,
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);
}
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
params: {
amount: {
label: i18n.ts._imageEffector._fxProps.amount,
@@ -64,23 +92,4 @@ export const FX_blockNoise = defineImageEffectorFx({
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);
}
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,33 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './blur.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_blur = defineImageEffectorFx({
id: 'blur',
name: i18n.ts._imageEffector._fxs.blur,
export const fn = defineImageCompositorFunction<{
offsetX: number;
offsetY: number;
scaleX: number;
scaleY: number;
ellipse: boolean;
angle: number;
radius: number;
}>({
shader,
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'radius', 'samples'] as const,
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.radius, params.radius);
gl.uniform1i(u.samples, 256);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.blur,
params: {
offsetX: {
label: i18n.ts._imageEffector._fxProps.offset + ' X',
@@ -72,12 +90,4 @@ export const FX_blur = defineImageEffectorFx({
step: 0.5,
},
},
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.radius, params.radius);
gl.uniform1i(u.samples, 256);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './checker.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_checker = defineImageEffectorFx({
id: 'checker',
name: i18n.ts._imageEffector._fxs.checker,
export const fn = defineImageCompositorFunction<{
angle: number;
scale: number;
color: [number, number, number];
opacity: number;
}>({
shader,
uniforms: ['angle', 'scale', 'color', 'opacity'] as const,
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);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.checker,
params: {
angle: {
label: i18n.ts._imageEffector._fxProps.angle,
@@ -45,10 +58,4 @@ export const FX_checker = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,24 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './chromaticAberration.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_chromaticAberration = defineImageEffectorFx({
id: 'chromaticAberration',
name: i18n.ts._imageEffector._fxs.chromaticAberration,
export const fn = defineImageCompositorFunction<{
normalize: boolean;
amount: number;
}>({
shader,
uniforms: ['amount', 'start', 'normalize'] as const,
main: ({ gl, u, params }) => {
gl.uniform1f(u.amount, params.amount);
gl.uniform1i(u.normalize, params.normalize ? 1 : 0);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.chromaticAberration,
params: {
normalize: {
label: i18n.ts._imageEffector._fxProps.normalize,
@@ -27,8 +36,4 @@ export const FX_chromaticAberration = defineImageEffectorFx({
step: 0.01,
},
},
main: ({ gl, u, params }) => {
gl.uniform1f(u.amount, params.amount);
gl.uniform1i(u.normalize, params.normalize ? 1 : 0);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,30 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './colorAdjust.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_colorAdjust = defineImageEffectorFx({
id: 'colorAdjust',
name: i18n.ts._imageEffector._fxs.colorAdjust,
export const fn = defineImageCompositorFunction<{
lightness: number;
contrast: number;
hue: number;
brightness: number;
saturation: number;
}>({
shader,
uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const,
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);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.colorAdjust,
params: {
lightness: {
label: i18n.ts._imageEffector._fxProps.lightness,
@@ -59,11 +74,4 @@ export const FX_colorAdjust = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './colorClamp.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_colorClamp = defineImageEffectorFx({
id: 'colorClamp',
name: i18n.ts._imageEffector._fxs.colorClamp,
export const fn = defineImageCompositorFunction<{
max: number;
min: number;
}>({
shader,
uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
main: ({ gl, u, params }) => {
gl.uniform1f(u.rMax, params.max);
gl.uniform1f(u.rMin, 1.0 + params.min);
gl.uniform1f(u.gMax, params.max);
gl.uniform1f(u.gMin, 1.0 + params.min);
gl.uniform1f(u.bMax, params.max);
gl.uniform1f(u.bMin, 1.0 + params.min);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.colorClamp,
params: {
max: {
label: i18n.ts._imageEffector._fxProps.max,
@@ -32,12 +45,4 @@ export const FX_colorClamp = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
main: ({ gl, u, params }) => {
gl.uniform1f(u.rMax, params.max);
gl.uniform1f(u.rMin, 1.0 + params.min);
gl.uniform1f(u.gMax, params.max);
gl.uniform1f(u.gMin, 1.0 + params.min);
gl.uniform1f(u.bMax, params.max);
gl.uniform1f(u.bMin, 1.0 + params.min);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,32 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './colorClamp.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_colorClampAdvanced = defineImageEffectorFx({
id: 'colorClampAdvanced',
name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
export const fn = defineImageCompositorFunction<{
rMax: number;
rMin: number;
gMax: number;
gMin: number;
bMax: number;
bMin: number;
}>({
shader,
uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
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);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
params: {
rMax: {
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.redComponent})`,
@@ -68,12 +85,4 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './distort.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_distort = defineImageEffectorFx({
id: 'distort',
name: i18n.ts._imageEffector._fxs.distort,
export const fn = defineImageCompositorFunction<{
direction: number;
phase: number;
frequency: number;
strength: number;
}>({
shader,
uniforms: ['phase', 'frequency', 'strength', 'direction'] as const,
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);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.distort,
params: {
direction: {
label: i18n.ts._imageEffector._fxProps.direction,
@@ -49,10 +62,4 @@ export const FX_distort = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,34 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './fill.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_fill = defineImageEffectorFx({
id: 'fill',
name: i18n.ts._imageEffector._fxs.fill,
export const fn = defineImageCompositorFunction<{
offsetX: number;
offsetY: number;
scaleX: number;
scaleY: number;
ellipse: boolean;
angle: number;
color: [number, number, number];
opacity: number;
}>({
shader,
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'color', 'opacity'] as const,
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.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
gl.uniform1f(u.opacity, params.opacity);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.fill,
params: {
offsetX: {
label: i18n.ts._imageEffector._fxProps.offset + ' X',
@@ -78,12 +97,4 @@ export const FX_fill = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
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.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
gl.uniform1f(u.opacity, params.opacity);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import shader from './grayscale.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const fn = defineImageCompositorFunction({
shader,
main: ({ gl, u, params }) => {
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.grayscale,
params: {
},
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './invert.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_invert = defineImageEffectorFx({
id: 'invert',
name: i18n.ts._imageEffector._fxs.invert,
export const fn = defineImageCompositorFunction<{
r: boolean;
g: boolean;
b: boolean;
}>({
shader,
uniforms: ['r', 'g', 'b'] as const,
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);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.invert,
params: {
r: {
label: i18n.ts._imageEffector._fxProps.redComponent,
@@ -29,9 +40,4 @@ export const FX_invert = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,24 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './mirror.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_mirror = defineImageEffectorFx({
id: 'mirror',
name: i18n.ts._imageEffector._fxs.mirror,
export const fn = defineImageCompositorFunction<{
h: number;
v: number;
}>({
shader,
uniforms: ['h', 'v'] as const,
main: ({ gl, u, params }) => {
gl.uniform1i(u.h, params.h);
gl.uniform1i(u.v, params.v);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.mirror,
params: {
h: {
label: i18n.ts.horizontal,
@@ -19,7 +28,7 @@ export const FX_mirror = defineImageEffectorFx({
enum: [
{ value: -1 as const, icon: 'ti ti-arrow-bar-right' },
{ value: 0 as const, icon: 'ti ti-minus-vertical' },
{ value: 1 as const, icon: 'ti ti-arrow-bar-left' }
{ value: 1 as const, icon: 'ti ti-arrow-bar-left' },
],
default: -1,
},
@@ -29,13 +38,9 @@ export const FX_mirror = defineImageEffectorFx({
enum: [
{ value: -1 as const, icon: 'ti ti-arrow-bar-down' },
{ value: 0 as const, icon: 'ti ti-minus' },
{ value: 1 as const, icon: 'ti ti-arrow-bar-up' }
{ value: 1 as const, icon: 'ti ti-arrow-bar-up' },
],
default: 0,
},
},
main: ({ gl, u, params }) => {
gl.uniform1i(u.h, params.h);
gl.uniform1i(u.v, params.v);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,33 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './pixelate.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_pixelate = defineImageEffectorFx({
id: 'pixelate',
name: i18n.ts._imageEffector._fxs.pixelate,
export const fn = defineImageCompositorFunction<{
offsetX: number;
offsetY: number;
scaleX: number;
scaleY: number;
ellipse: boolean;
angle: number;
strength: number;
}>({
shader,
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'strength', 'samples'] as const,
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);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.pixelate,
params: {
offsetX: {
label: i18n.ts._imageEffector._fxProps.offset + ' X',
@@ -72,12 +90,4 @@ export const FX_pixelate = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,16 +3,36 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './polkadot.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
// Primarily used for watermark
export const FX_polkadot = defineImageEffectorFx({
id: 'polkadot',
name: i18n.ts._imageEffector._fxs.polkadot,
export const fn = defineImageCompositorFunction<{
angle: number;
scale: number;
majorRadius: number;
majorOpacity: number;
minorDivisions: number;
minorRadius: number;
minorOpacity: number;
color: [number, number, number];
}>({
shader,
uniforms: ['angle', 'scale', 'major_radius', 'major_opacity', 'minor_divisions', 'minor_radius', 'minor_opacity', 'color'] as const,
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);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.polkadot,
params: {
angle: {
label: i18n.ts._imageEffector._fxProps.angle,
@@ -79,14 +99,4 @@ export const FX_polkadot = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,16 +3,31 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './stripe.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
// Primarily used for watermark
export const FX_stripe = defineImageEffectorFx({
id: 'stripe',
name: i18n.ts._imageEffector._fxs.stripe,
export const fn = defineImageCompositorFunction<{
angle: number;
frequency: number;
threshold: number;
color: [number, number, number];
opacity: number;
}>({
shader,
uniforms: ['angle', 'frequency', 'phase', 'threshold', 'color', 'opacity'] as const,
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);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.stripe,
params: {
angle: {
label: i18n.ts._imageEffector._fxProps.angle,
@@ -55,12 +70,4 @@ export const FX_stripe = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -5,14 +5,39 @@
import seedrandom from 'seedrandom';
import shader from './tearing.glsl';
import { defineImageEffectorFx } from '../ImageEffector.js';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_tearing = defineImageEffectorFx({
id: 'tearing',
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing,
export const fn = defineImageCompositorFunction<{
amount: number;
strength: number;
size: number;
channelShift: number;
seed: number;
}>({
shader,
uniforms: ['amount', 'channelShift'] as const,
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);
}
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing,
params: {
amount: {
label: i18n.ts._imageEffector._fxProps.amount,
@@ -55,21 +80,4 @@ export const FX_tearing = defineImageEffectorFx({
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);
}
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './threshold.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_threshold = defineImageEffectorFx({
id: 'threshold',
name: i18n.ts._imageEffector._fxs.threshold,
export const fn = defineImageCompositorFunction<{
r: number;
g: number;
b: number;
}>({
shader,
uniforms: ['r', 'g', 'b'] as const,
main: ({ gl, u, params }) => {
gl.uniform1f(u.r, params.r);
gl.uniform1f(u.g, params.g);
gl.uniform1f(u.b, params.b);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.threshold,
params: {
r: {
label: i18n.ts._imageEffector._fxProps.redComponent,
@@ -38,9 +49,4 @@ export const FX_threshold = defineImageEffectorFx({
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);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,15 +3,34 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './zoomLines.glsl';
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
export const FX_zoomLines = defineImageEffectorFx({
id: 'zoomLines',
name: i18n.ts._imageEffector._fxs.zoomLines,
export const fn = defineImageCompositorFunction<{
x: number;
y: number;
frequency: number;
smoothing: boolean;
threshold: number;
maskSize: number;
black: boolean;
}>({
shader,
uniforms: ['pos', 'frequency', 'thresholdEnabled', 'threshold', 'maskSize', 'black'] as const,
main: ({ gl, u, params }) => {
gl.uniform2f(u.pos, params.x / 2, params.y / 2);
gl.uniform1f(u.frequency, params.frequency * params.frequency);
// thresholdの調整が有効な間はsmoothingが利用できない
gl.uniform1i(u.thresholdEnabled, params.smoothing ? 0 : 1);
gl.uniform1f(u.threshold, params.threshold);
gl.uniform1f(u.maskSize, params.maskSize);
gl.uniform1i(u.black, params.black ? 1 : 0);
},
});
export const uiDefinition = {
name: i18n.ts._imageEffector._fxs.zoomLines,
params: {
x: {
label: i18n.ts._imageEffector._fxProps.centerX,
@@ -65,13 +84,4 @@ export const FX_zoomLines = defineImageEffectorFx({
default: false,
},
},
main: ({ gl, u, params }) => {
gl.uniform2f(u.pos, params.x / 2, params.y / 2);
gl.uniform1f(u.frequency, params.frequency * params.frequency);
// thresholdの調整が有効な間はsmoothingが利用できない
gl.uniform1i(u.thresholdEnabled, params.smoothing ? 0 : 1);
gl.uniform1f(u.threshold, params.threshold);
gl.uniform1f(u.maskSize, params.maskSize);
gl.uniform1i(u.black, params.black ? 1 : 0);
},
});
} satisfies ImageEffectorUiDefinition<typeof fn>;

View File

@@ -3,18 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import QRCodeStyling from 'qr-code-styling';
import { url, host } from '@@/js/config.js';
import { getProxiedImageUrl } from '../media-proxy.js';
import { initShaderProgram } from '../webgl.js';
import { ensureSignin } from '@/i.js';
import { FXS } from './fxs.js';
import type { ImageCompositorFunction, ImageCompositorLayer } from '@/lib/ImageCompositor.js';
import { ImageCompositor } from '@/lib/ImageCompositor.js';
export type ImageEffectorRGB = [r: number, g: number, b: number];
type ParamTypeToPrimitive = {
[K in ImageEffectorFxParamDef['type']]: (ImageEffectorFxParamDef & { type: K })['default'];
};
interface CommonParamDef {
type: string;
label?: string;
@@ -60,479 +54,77 @@ interface SeedParamDef extends CommonParamDef {
default: number;
};
interface TextureParamDef extends CommonParamDef {
type: 'texture';
default: {
type: 'text'; text: string | null;
} | {
type: 'url'; url: string | null;
} | {
type: 'qr'; data: string | null;
} | null;
};
interface ColorParamDef extends CommonParamDef {
type: 'color';
default: ImageEffectorRGB;
};
type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | TextureParamDef | ColorParamDef;
type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | ColorParamDef;
export type ImageEffectorFxParamDefs = Record<string, ImageEffectorFxParamDef>;
export type GetParamType<T extends ImageEffectorFxParamDef> =
T extends NumberEnumParamDef
? T['enum'][number]['value']
: ParamTypeToPrimitive[T['type']];
export type ParamsRecordTypeToDefRecord<PS extends ImageEffectorFxParamDefs> = {
[K in keyof PS]: GetParamType<PS[K]>;
};
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: ParamsRecordTypeToDefRecord<PS>;
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>;
[K in keyof typeof FXS]: {
id: string;
fxId: K;
params: Parameters<(typeof FXS)[K]['fn']['main']>[0]['params'];
};
}[keyof typeof FXS];
export type ImageEffectorUiDefinition<Fn extends ImageCompositorFunction<any> = ImageCompositorFunction> = {
name: string;
params: Fn extends ImageCompositorFunction<infer P> ? {
[K in keyof P]: ImageEffectorFxParamDef;
} : never;
};
function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, any>, k: string): ParamTypeToPrimitive[T] {
return params[k];
}
type ImageEffectorImageCompositor = ImageCompositor<{
[K in keyof typeof FXS]: typeof FXS[K]['fn'];
}>;
export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, any>>> {
private gl: WebGL2RenderingContext;
export class ImageEffector {
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();
private compositor: ImageEffectorImageCompositor;
constructor(options: {
canvas: HTMLCanvasElement;
renderWidth: number;
renderHeight: number;
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
fxs: [...IEX];
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null;
}) {
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,
this.compositor = new ImageCompositor({
canvas: this.canvas,
renderWidth: options.renderWidth,
renderHeight: options.renderHeight,
image: options.image,
functions: Object.fromEntries(Object.entries(FXS).map(([fxId, fx]) => [fxId, fx.fn])),
});
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());
public async render(layers: ImageEffectorLayer[]) {
const compositorLayers: Parameters<ImageCompositor<any>['render']>[0] = [];
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) :
v.type === 'qr' ? await createTextureFromQr(this.gl, { data: v.data }) :
null;
if (texture == null) continue;
this.paramTextures.set(textureKey, texture);
}
compositorLayers.push({
id: layer.id,
functionId: layer.fxId,
params: layer.params,
});
}
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();
this.compositor.render(compositorLayers as Parameters<ImageEffectorImageCompositor['render']>[0]);
}
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}` :
v.type === 'qr' ? `qr:${v.data}` :
''
);
this.compositor.changeResolution(width, height);
}
/*
* 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();
}
this.compositor.destroy(disposeCanvas);
}
}
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;
}
async function createTextureFromQr(gl: WebGL2RenderingContext, options: { data: string | null }, resolution = 512): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
const $i = ensureSignin();
const qrCodeInstance = new QRCodeStyling({
width: resolution,
height: resolution,
margin: 42,
type: 'canvas',
data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
image: $i.avatarUrl,
qrOptions: {
typeNumber: 0,
mode: 'Byte',
errorCorrectionLevel: 'H',
},
imageOptions: {
hideBackgroundDots: true,
imageSize: 0.3,
margin: 16,
crossOrigin: 'anonymous',
},
dotsOptions: {
type: 'dots',
},
cornersDotOptions: {
type: 'dot',
},
cornersSquareOptions: {
type: 'extra-rounded',
},
});
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
if (blob == null) return null;
const image = await window.createImageBitmap(blob);
const texture = createTexture(gl);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, resolution, resolution, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindTexture(gl.TEXTURE_2D, null);
return {
texture,
width: resolution,
height: resolution,
};
}

View File

@@ -3,43 +3,47 @@
* 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 { 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';
import * as checker from '../image-compositor-functions/checker.js';
import * as chromaticAberration from '../image-compositor-functions/chromaticAberration.js';
import * as colorAdjust from '../image-compositor-functions/colorAdjust.js';
import * as colorClamp from '../image-compositor-functions/colorClamp.js';
import * as colorClampAdvanced from '../image-compositor-functions/colorClampAdvanced.js';
import * as distort from '../image-compositor-functions/distort.js';
import * as polkadot from '../image-compositor-functions/polkadot.js';
import * as tearing from '../image-compositor-functions/tearing.js';
import * as grayscale from '../image-compositor-functions/grayscale.js';
import * as invert from '../image-compositor-functions/invert.js';
import * as mirror from '../image-compositor-functions/mirror.js';
import * as stripe from '../image-compositor-functions/stripe.js';
import * as threshold from '../image-compositor-functions/threshold.js';
import * as zoomLines from '../image-compositor-functions/zoomLines.js';
import * as blockNoise from '../image-compositor-functions/blockNoise.js';
import * as fill from '../image-compositor-functions/fill.js';
import * as blur from '../image-compositor-functions/blur.js';
import * as pixelate from '../image-compositor-functions/pixelate.js';
import type { ImageCompositorFunction } from '@/lib/ImageCompositor.js';
import type { ImageEffectorUiDefinition } 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,
FX_fill,
FX_blur,
FX_pixelate,
] as const satisfies ImageEffectorFx<string, any>[];
export const FXS = {
checker,
chromaticAberration,
colorAdjust,
colorClamp,
colorClampAdvanced,
distort,
polkadot,
tearing,
grayscale,
invert,
mirror,
stripe,
threshold,
zoomLines,
blockNoise,
fill,
blur,
pixelate,
} as const satisfies Record<string, {
readonly fn: ImageCompositorFunction<any>;
readonly uiDefinition: ImageEffectorUiDefinition<any>;
}>;

View File

@@ -1,19 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './grayscale.glsl';
import { i18n } from '@/i18n.js';
export const FX_grayscale = defineImageEffectorFx({
id: 'grayscale',
name: i18n.ts._imageEffector._fxs.grayscale,
shader,
uniforms: [] as const,
params: {
},
main: ({ gl, params }) => {
},
});

View File

@@ -0,0 +1,270 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import QRCodeStyling from 'qr-code-styling';
import { url } from '@@/js/config.js';
import ExifReader from 'exifreader';
import { FN_frame } from './frame.js';
import { ImageCompositor } from '@/lib/ImageCompositor.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
type LabelParams = {
enabled: boolean;
scale: number;
padding: number;
textBig: string;
textSmall: string;
centered: boolean;
withQrCode: boolean;
};
export type ImageFrameParams = {
borderThickness: number;
labelTop: LabelParams;
labelBottom: LabelParams;
bgColor: [r: number, g: number, b: number];
fgColor: [r: number, g: number, b: number];
font: 'serif' | 'sans-serif';
borderRadius: number; // TODO
};
export type ImageFramePreset = {
id: string;
name: string;
params: ImageFrameParams;
};
export class ImageFrameRenderer {
private compositor: ImageCompositor<{ frame: typeof FN_frame }>;
private image: HTMLImageElement | ImageBitmap;
private exif: ExifReader.Tags | null;
private caption: string | null = null;
private filename: string | null = null;
private renderAsPreview = false;
constructor(options: {
canvas: HTMLCanvasElement,
image: HTMLImageElement | ImageBitmap,
exif: ExifReader.Tags | null,
filename: string | null,
caption: string | null,
renderAsPreview?: boolean,
}) {
this.image = options.image;
this.exif = options.exif;
this.caption = options.caption ?? null;
this.filename = options.filename ?? null;
this.renderAsPreview = options.renderAsPreview ?? false;
this.compositor = new ImageCompositor({
canvas: options.canvas,
renderWidth: 1,
renderHeight: 1,
image: null,
functions: { frame: FN_frame },
});
this.compositor.registerTexture('image', this.image);
}
private interpolateTemplateText(text: string) {
const DateTimeOriginal = this.exif == null ? '2012:03:04 5:06:07' : this.exif.DateTimeOriginal?.description;
const Model = this.exif == null ? 'Example camera' : this.exif.Model?.description;
const LensModel = this.exif == null ? 'Example lens 123mm f/1.23' : this.exif.LensModel?.description;
const FocalLength = this.exif == null ? '123mm' : this.exif.FocalLength?.description;
const FocalLengthIn35mmFilm = this.exif == null ? '123mm' : this.exif.FocalLengthIn35mmFilm?.description;
const ExposureTime = this.exif == null ? '1/234' : this.exif.ExposureTime?.description;
const FNumber = this.exif == null ? '1.23' : this.exif.FNumber?.description;
const ISOSpeedRatings = this.exif == null ? '123' : this.exif.ISOSpeedRatings?.description;
const GPSLatitude = this.exif == null ? '123.000000000000123' : this.exif.GPSLatitude?.description;
const GPSLongitude = this.exif == null ? '456.000000000000123' : this.exif.GPSLongitude?.description;
return text.replaceAll(/\{(\w+)\}/g, (_: string, key: string) => {
const meta_date = DateTimeOriginal ?? '????:??:?? ??:??:??';
const date = meta_date.split(' ')[0].replaceAll(':', '/');
switch (key) {
case 'caption': return this.caption ?? '?';
case 'filename': return this.filename ?? '?';
case 'filename_without_ext': return this.filename?.replace(/\.[^/.]+$/, '') ?? '?';
case 'year': return date.split('/')[0];
case 'month': return date.split('/')[1].replace(/^0/, '');
case 'day': return date.split('/')[2].replace(/^0/, '');
case 'hour': return meta_date.split(' ')[1].split(':')[0].replace(/^0/, '');
case 'minute': return meta_date.split(' ')[1].split(':')[1].replace(/^0/, '');
case 'second': return meta_date.split(' ')[1].split(':')[2].replace(/^0/, '');
case '0month': return date.split('/')[1];
case '0day': return date.split('/')[2];
case '0hour': return meta_date.split(' ')[1].split(':')[0];
case '0minute': return meta_date.split(' ')[1].split(':')[1];
case '0second': return meta_date.split(' ')[1].split(':')[2];
case 'camera_model': return Model ?? '?';
case 'camera_lens_model': return LensModel ?? '?';
case 'camera_mm': return FocalLength?.replace(' mm', '').replace('mm', '') ?? '?';
case 'camera_mm_35': return FocalLengthIn35mmFilm?.replace(' mm', '').replace('mm', '') ?? '?';
case 'camera_f': return FNumber?.replace('f/', '') ?? '?';
case 'camera_s': return ExposureTime ?? '?';
case 'camera_iso': return ISOSpeedRatings ?? '?';
case 'gps_lat': return GPSLatitude ?? '?';
case 'gps_long': return GPSLongitude ?? '?';
default: return '?';
}
});
}
private async renderLabel(renderWidth: number, renderHeight: number, paddingLeft: number, paddingRight: number, imageAreaH: number, fgColor: [number, number, number], font: string, params: LabelParams) {
const scaleBase = imageAreaH * params.scale;
const labelCanvasCtx = window.document.createElement('canvas').getContext('2d')!;
labelCanvasCtx.canvas.width = renderWidth;
labelCanvasCtx.canvas.height = renderHeight;
const fontSize = scaleBase / 30;
const textsMarginLeft = Math.max(fontSize * 2, paddingLeft);
const textsMarginRight = textsMarginLeft;
const withQrCode = params.withQrCode;
const qrSize = scaleBase * 0.1;
const qrMarginRight = Math.max((labelCanvasCtx.canvas.height - qrSize) / 2, paddingRight);
labelCanvasCtx.fillStyle = `rgb(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)})`;
labelCanvasCtx.font = `bold ${fontSize}px ${font}`;
labelCanvasCtx.textBaseline = 'middle';
const titleY = params.textSmall === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) - (fontSize * 0.9);
if (params.centered) {
labelCanvasCtx.textAlign = 'center';
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), labelCanvasCtx.canvas.width / 2, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
} else {
labelCanvasCtx.textAlign = 'left';
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), textsMarginLeft, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
}
labelCanvasCtx.fillStyle = `rgba(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)}, 0.5)`;
labelCanvasCtx.font = `${fontSize * 0.85}px ${font}`;
labelCanvasCtx.textBaseline = 'middle';
const textY = params.textBig === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) + (fontSize * 0.9);
if (params.centered) {
labelCanvasCtx.textAlign = 'center';
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), labelCanvasCtx.canvas.width / 2, textY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
} else {
labelCanvasCtx.textAlign = 'left';
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), textsMarginLeft, textY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
}
if (withQrCode) {
try {
const qrCodeInstance = new QRCodeStyling({
width: labelCanvasCtx.canvas.height,
height: labelCanvasCtx.canvas.height,
margin: 0,
type: 'canvas',
data: `${url}/users/${$i.id}`,
//image: $i.avatarUrl,
qrOptions: {
typeNumber: 0,
mode: 'Byte',
errorCorrectionLevel: 'H',
},
imageOptions: {
hideBackgroundDots: true,
imageSize: 0.3,
margin: 16,
crossOrigin: 'anonymous',
},
dotsOptions: {
type: 'dots',
roundSize: false,
color: `rgb(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)})`,
},
backgroundOptions: {
color: 'transparent',
},
cornersDotOptions: {
type: 'dot',
},
cornersSquareOptions: {
type: 'extra-rounded',
},
});
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
if (blob == null) throw new Error('Failed to generate QR code');
const qrImageBitmap = await window.createImageBitmap(blob);
labelCanvasCtx.drawImage(
qrImageBitmap,
labelCanvasCtx.canvas.width - qrSize - qrMarginRight,
(labelCanvasCtx.canvas.height - qrSize) / 2,
qrSize,
qrSize,
);
qrImageBitmap.close();
} catch (err) {
// nop
}
}
return labelCanvasCtx.getImageData(0, 0, labelCanvasCtx.canvas.width, labelCanvasCtx.canvas.height); ;
}
public async render(params: ImageFrameParams): Promise<void> {
let imageAreaW = this.image.width;
let imageAreaH = this.image.height;
if (this.renderAsPreview) {
const MAX_W = 1000;
const MAX_H = 1000;
if (imageAreaW > MAX_W || imageAreaH > MAX_H) {
const scale = Math.min(MAX_W / imageAreaW, MAX_H / imageAreaH);
imageAreaW = Math.floor(imageAreaW * scale);
imageAreaH = Math.floor(imageAreaH * scale);
}
}
const paddingLeft = Math.floor(imageAreaH * params.borderThickness);
const paddingRight = Math.floor(imageAreaH * params.borderThickness);
const paddingTop = params.labelTop.enabled ? Math.floor(imageAreaH * params.labelTop.padding) : Math.floor(imageAreaH * params.borderThickness);
const paddingBottom = params.labelBottom.enabled ? Math.floor(imageAreaH * params.labelBottom.padding) : Math.floor(imageAreaH * params.borderThickness);
const renderWidth = imageAreaW + paddingLeft + paddingRight;
const renderHeight = imageAreaH + paddingTop + paddingBottom;
if (params.labelTop.enabled) {
const topLabelImage = await this.renderLabel(renderWidth, paddingTop, paddingLeft, paddingRight, imageAreaH, params.fgColor, params.font, params.labelTop);
this.compositor.registerTexture('topLabel', topLabelImage);
}
if (params.labelBottom.enabled) {
const bottomLabelImage = await this.renderLabel(renderWidth, paddingBottom, paddingLeft, paddingRight, imageAreaH, params.fgColor, params.font, params.labelBottom);
this.compositor.registerTexture('bottomLabel', bottomLabelImage);
}
this.compositor.changeResolution(renderWidth, renderHeight);
this.compositor.render([{
functionId: 'frame',
id: 'a',
params: {
image: 'image',
topLabel: 'topLabel',
bottomLabel: 'bottomLabel',
topLabelEnabled: params.labelTop.enabled,
bottomLabelEnabled: params.labelBottom.enabled,
paddingLeft: paddingLeft / renderWidth,
paddingRight: paddingRight / renderWidth,
paddingTop: paddingTop / renderHeight,
paddingBottom: paddingBottom / renderHeight,
bg: params.bgColor,
},
}]);
}
/*
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
*/
public destroy(disposeCanvas = true): void {
this.compositor.destroy(disposeCanvas);
}
}

View File

@@ -0,0 +1,61 @@
#version 300 es
precision mediump float;
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
in vec2 in_uv;
uniform sampler2D in_texture;
uniform vec2 in_resolution;
uniform sampler2D u_image;
uniform sampler2D u_topLabel;
uniform sampler2D u_bottomLabel;
uniform bool u_topLabelEnabled;
uniform bool u_bottomLabelEnabled;
uniform float u_paddingTop;
uniform float u_paddingBottom;
uniform float u_paddingLeft;
uniform float u_paddingRight;
uniform vec3 u_bg;
out vec4 out_color;
float remap(float value, float inputMin, float inputMax, float outputMin, float outputMax) {
return outputMin + (outputMax - outputMin) * ((value - inputMin) / (inputMax - inputMin));
}
vec3 blendAlpha(vec3 bg, vec4 fg) {
return fg.a * fg.rgb + (1.0 - fg.a) * bg;
}
void main() {
vec4 bg = vec4(u_bg, 1.0);
vec4 image_color = texture(u_image, vec2(
remap(in_uv.x, u_paddingLeft, 1.0 - u_paddingRight, 0.0, 1.0),
remap(in_uv.y, u_paddingTop, 1.0 - u_paddingBottom, 0.0, 1.0)
));
vec4 topLabel_color = u_topLabelEnabled ? texture(u_topLabel, vec2(
in_uv.x,
remap(in_uv.y, 0.0, u_paddingTop, 0.0, 1.0)
)) : bg;
vec4 bottomLabel_color = u_bottomLabelEnabled ? texture(u_bottomLabel, vec2(
in_uv.x,
remap(in_uv.y, 1.0 - u_paddingBottom, 1.0, 0.0, 1.0)
)) : bg;
if (in_uv.y < u_paddingTop) {
out_color = vec4(blendAlpha(bg.rgb, topLabel_color), 1.0);
} else if (in_uv.y > (1.0 - u_paddingBottom)) {
out_color = vec4(blendAlpha(bg.rgb, bottomLabel_color), 1.0);
} else {
if (in_uv.y > u_paddingTop && in_uv.x > u_paddingLeft && in_uv.x < (1.0 - u_paddingRight)) {
out_color = image_color;
} else {
out_color = bg;
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import shader from './frame.glsl';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
export const FN_frame = defineImageCompositorFunction<{
image: string | null;
topLabel: string | null;
bottomLabel: string | null;
topLabelEnabled: boolean;
bottomLabelEnabled: boolean;
paddingTop: number;
paddingBottom: number;
paddingLeft: number;
paddingRight: number;
bg: [number, number, number];
}>({
shader,
main: ({ gl, u, params, textures }) => {
if (params.image == null) return;
const image = textures.get(params.image);
if (image == null) return;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, image.texture);
gl.uniform1i(u.image, 1);
gl.uniform1i(u.topLabelEnabled, params.topLabelEnabled ? 1 : 0);
gl.uniform1i(u.bottomLabelEnabled, params.bottomLabelEnabled ? 1 : 0);
gl.uniform1f(u.paddingTop, params.paddingTop);
gl.uniform1f(u.paddingBottom, params.paddingBottom);
gl.uniform1f(u.paddingLeft, params.paddingLeft);
gl.uniform1f(u.paddingRight, params.paddingRight);
gl.uniform3f(u.bg, params.bg[0], params.bg[1], params.bg[2]);
if (params.topLabelEnabled && params.topLabel != null) {
const topLabel = textures.get(params.topLabel);
if (topLabel) {
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, topLabel.texture);
gl.uniform1i(u.topLabel, 2);
}
}
if (params.bottomLabelEnabled && params.bottomLabel != null) {
const bottomLabel = textures.get(params.bottomLabel);
if (bottomLabel) {
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, bottomLabel.texture);
gl.uniform1i(u.bottomLabel, 3);
}
}
},
});

View File

@@ -1,218 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
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 { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
const WATERMARK_FXS = [
FX_watermarkPlacement,
FX_stripe,
FX_polkadot,
FX_checker,
] as const satisfies ImageEffectorFx<string, any>[];
type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
export type WatermarkPreset = {
id: string;
name: string;
layers: ({
id: string;
type: 'text';
text: string;
repeat: boolean;
noBoundingBoxExpansion: boolean;
scale: number;
angle: number;
align: Align;
opacity: number;
} | {
id: string;
type: 'image';
imageUrl: string | null;
imageId: string | null;
cover: boolean;
repeat: boolean;
noBoundingBoxExpansion: boolean;
scale: number;
angle: number;
align: Align;
opacity: number;
} | {
id: string;
type: 'qr';
data: string;
scale: number;
align: Align;
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,
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
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,
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
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 === 'qr') {
return {
fxId: 'watermarkPlacement',
id: layer.id,
params: {
repeat: false,
scale: layer.scale,
align: layer.align,
angle: 0,
opacity: layer.opacity,
cover: false,
watermark: {
type: 'qr',
data: layer.data,
},
},
};
} 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,
},
};
} 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(`Unrecognized layer type: ${(layer as any).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);
}
}

View File

@@ -0,0 +1,332 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import QRCodeStyling from 'qr-code-styling';
import { url, host } from '@@/js/config.js';
import { getProxiedImageUrl } from '../media-proxy.js';
import { fn as fn_watermark } from './watermark.js';
import { fn as fn_stripe } from '@/utility/image-compositor-functions/stripe.js';
import { fn as fn_poladot } from '@/utility/image-compositor-functions/polkadot.js';
import { fn as fn_checker } from '@/utility/image-compositor-functions/checker.js';
import { ImageCompositor } from '@/lib/ImageCompositor.js';
import { ensureSignin } from '@/i.js';
type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
export type WatermarkLayers = ({
id: string;
type: 'text';
text: string;
repeat: boolean;
noBoundingBoxExpansion: boolean;
scale: number;
angle: number;
align: Align;
opacity: number;
} | {
id: string;
type: 'image';
imageUrl: string | null;
imageId: string | null;
cover: boolean;
repeat: boolean;
noBoundingBoxExpansion: boolean;
scale: number;
angle: number;
align: Align;
opacity: number;
} | {
id: string;
type: 'qr';
data: string;
scale: number;
align: Align;
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 type WatermarkPreset = {
id: string;
name: string;
layers: WatermarkLayers;
};
type WatermarkRendererImageCompositor = ImageCompositor<{
watermark: typeof fn_watermark;
stripe: typeof fn_stripe;
polkadot: typeof fn_poladot;
checker: typeof fn_checker;
}>;
export class WatermarkRenderer {
private compositor: WatermarkRendererImageCompositor;
constructor(options: {
canvas: HTMLCanvasElement,
renderWidth: number,
renderHeight: number,
image: HTMLImageElement | ImageBitmap,
}) {
this.compositor = new ImageCompositor({
canvas: options.canvas,
renderWidth: options.renderWidth,
renderHeight: options.renderHeight,
image: options.image,
functions: {
watermark: fn_watermark,
stripe: fn_stripe,
polkadot: fn_poladot,
checker: fn_checker,
},
});
}
public async render(layers: WatermarkLayers) {
const compositorLayers: Parameters<WatermarkRendererImageCompositor['render']>[0] = [];
const unused = new Set(this.compositor.getKeysOfRegisteredTextures());
for (const layer of layers) {
if (layer.type === 'text') {
const textureKey = `text:${layer.text}`;
unused.delete(textureKey);
if (!this.compositor.hasTexture(textureKey)) {
if (_DEV_) console.log(`Baking text texture of <${textureKey}>...`);
const image = await createTextureFromText(layer.text);
if (image != null) this.compositor.registerTexture(textureKey, image);
}
compositorLayers.push({
functionId: 'watermark',
id: layer.id,
params: {
repeat: layer.repeat,
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
scale: layer.scale,
align: layer.align,
angle: layer.angle,
opacity: layer.opacity,
cover: false,
watermark: textureKey,
},
});
} else if (layer.type === 'image') {
const textureKey = `url:${layer.imageUrl}`;
unused.delete(textureKey);
if (!this.compositor.hasTexture(textureKey)) {
if (_DEV_) console.log(`Baking url image texture of <${textureKey}>...`);
const image = await createTextureFromUrl(layer.imageUrl);
if (image != null) this.compositor.registerTexture(textureKey, image);
}
compositorLayers.push({
functionId: 'watermark',
id: layer.id,
params: {
repeat: layer.repeat,
noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
scale: layer.scale,
align: layer.align,
angle: layer.angle,
opacity: layer.opacity,
cover: layer.cover,
watermark: textureKey,
},
});
} else if (layer.type === 'qr') {
const textureKey = `qr:${layer.data}`;
unused.delete(textureKey);
if (!this.compositor.hasTexture(textureKey)) {
if (_DEV_) console.log(`Baking qr texture of <${textureKey}>...`);
const image = await createTextureFromQr({ data: layer.data });
if (image != null) this.compositor.registerTexture(textureKey, image);
}
compositorLayers.push({
functionId: 'watermark',
id: layer.id,
params: {
repeat: false,
scale: layer.scale,
align: layer.align,
angle: 0,
opacity: layer.opacity,
cover: false,
watermark: textureKey,
},
});
} else if (layer.type === 'stripe') {
compositorLayers.push({
functionId: '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') {
compositorLayers.push({
functionId: '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,
},
});
} else if (layer.type === 'checker') {
compositorLayers.push({
functionId: 'checker',
id: layer.id,
params: {
angle: layer.angle,
scale: layer.scale,
color: layer.color,
opacity: layer.opacity,
},
});
} else {
throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
}
}
for (const k of unused) {
if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
this.compositor.unregisterTexture(k);
}
this.compositor.render(compositorLayers);
}
public changeResolution(width: number, height: number) {
this.compositor.changeResolution(width, height);
}
/*
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
*/
public destroy(disposeCanvas = true): void {
this.compositor.destroy(disposeCanvas);
}
}
async function createTextureFromUrl(imageUrl: string | 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;
return image;
}
async function createTextureFromText(text: string | null, resolution = 2048) {
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), cropWidth, cropHeight);
ctx.canvas.remove();
return data;
}
async function createTextureFromQr(options: { data: string | null }, resolution = 512) {
const $i = ensureSignin();
const qrCodeInstance = new QRCodeStyling({
width: resolution,
height: resolution,
margin: 42,
type: 'canvas',
data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
image: $i.avatarUrl,
qrOptions: {
typeNumber: 0,
mode: 'Byte',
errorCorrectionLevel: 'H',
},
imageOptions: {
hideBackgroundDots: true,
imageSize: 0.3,
margin: 16,
crossOrigin: 'anonymous',
},
dotsOptions: {
type: 'dots',
},
cornersDotOptions: {
type: 'dot',
},
cornersSquareOptions: {
type: 'extra-rounded',
},
});
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
if (blob == null) return null;
const image = await window.createImageBitmap(blob);
return image;
}

View File

@@ -3,57 +3,20 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './watermarkPlacement.glsl';
import shader from './watermark.glsl';
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
export const FX_watermarkPlacement = defineImageEffectorFx({
id: 'watermarkPlacement',
name: '(internal)',
export const fn = defineImageCompositorFunction<Partial<{
cover: boolean;
repeat: boolean;
scale: number;
angle: number;
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
opacity: number;
noBoundingBoxExpansion: boolean;
watermark: string | null;
}>>({
shader,
uniforms: ['opacity', 'scale', 'angle', 'cover', 'repeat', 'alignX', 'alignY', 'margin', 'repeatMargin', 'noBBoxExpansion', 'wmResolution', 'wmEnabled', 'watermark'] as const,
params: {
cover: {
type: 'boolean',
default: false,
},
repeat: {
type: 'boolean',
default: false,
},
scale: {
type: 'number',
default: 0.3,
min: 0.0,
max: 1.0,
step: 0.01,
},
angle: {
type: 'number',
default: 0,
min: -1.0,
max: 1.0,
step: 0.01,
},
align: {
type: 'align',
default: { x: 'right', y: 'bottom', margin: 0 },
},
opacity: {
type: 'number',
default: 0.75,
min: 0.0,
max: 1.0,
step: 0.01,
},
noBoundingBoxExpansion: {
type: 'boolean',
default: false,
},
watermark: {
type: 'texture',
default: null,
},
},
main: ({ gl, u, params, textures }) => {
// 基本パラメータ
gl.uniform1f(u.opacity, params.opacity ?? 1.0);
@@ -70,7 +33,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
gl.uniform1i(u.noBBoxExpansion, params.noBoundingBoxExpansion ? 1 : 0);
// ウォーターマークテクスチャ
const wm = textures.watermark;
const wm = textures.get(params.watermark);
if (wm) {
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, wm.texture);

View File

@@ -38,3 +38,14 @@ export function initShaderProgram(gl: WebGL2RenderingContext, vsSource: string,
return shaderProgram;
}
export 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;
}