diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-11-06 20:25:17 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-06 20:25:17 +0900 |
| commit | 4ba18690d7abd7eea086bb59e6cbcc8ead9e121a (patch) | |
| tree | 7d25ec47d8711d945b08e3903642f2e982f40048 /packages/frontend/src/utility | |
| parent | fix(frontend): improve startViewTransition handling (diff) | |
| download | misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.gz misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.bz2 misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.zip | |
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
Diffstat (limited to 'packages/frontend/src/utility')
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/blockNoise.ts (renamed from packages/frontend/src/utility/image-effector/fxs/blockNoise.ts) | 59 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/blur.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/blur.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/blur.ts (renamed from packages/frontend/src/utility/image-effector/fxs/blur.ts) | 38 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/checker.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/checker.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/checker.ts (renamed from packages/frontend/src/utility/image-effector/fxs/checker.ts) | 31 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts (renamed from packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts) | 25 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts (renamed from packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts) | 34 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/colorClamp.ts (renamed from packages/frontend/src/utility/image-effector/fxs/colorClamp.ts) | 33 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts (renamed from packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts) | 37 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/distort.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/distort.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/distort.ts (renamed from packages/frontend/src/utility/image-effector/fxs/distort.ts) | 31 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/fill.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/fill.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/fill.ts (renamed from packages/frontend/src/utility/image-effector/fxs/fill.ts) | 39 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/grayscale.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/grayscale.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/grayscale.ts | 21 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/invert.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/invert.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/invert.ts (renamed from packages/frontend/src/utility/image-effector/fxs/invert.ts) | 28 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/mirror.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/mirror.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/mirror.ts (renamed from packages/frontend/src/utility/image-effector/fxs/mirror.ts) | 29 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/pixelate.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/pixelate.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/pixelate.ts (renamed from packages/frontend/src/utility/image-effector/fxs/pixelate.ts) | 38 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/polkadot.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/polkadot.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/polkadot.ts (renamed from packages/frontend/src/utility/image-effector/fxs/polkadot.ts) | 44 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/stripe.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/stripe.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/stripe.ts (renamed from packages/frontend/src/utility/image-effector/fxs/stripe.ts) | 37 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/tearing.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/tearing.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/tearing.ts (renamed from packages/frontend/src/utility/image-effector/fxs/tearing.ts) | 54 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/threshold.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/threshold.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/threshold.ts (renamed from packages/frontend/src/utility/image-effector/fxs/threshold.ts) | 28 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-compositor-functions/zoomLines.ts (renamed from packages/frontend/src/utility/image-effector/fxs/zoomLines.ts) | 40 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-effector/ImageEffector.ts | 482 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-effector/fxs.ts | 82 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-effector/fxs/grayscale.ts | 19 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts | 270 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-frame-renderer/frame.glsl | 61 | ||||
| -rw-r--r-- | packages/frontend/src/utility/image-frame-renderer/frame.ts | 57 | ||||
| -rw-r--r-- | packages/frontend/src/utility/watermark.ts | 218 | ||||
| -rw-r--r-- | packages/frontend/src/utility/watermark/WatermarkRenderer.ts | 332 | ||||
| -rw-r--r-- | packages/frontend/src/utility/watermark/watermark.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl) | 0 | ||||
| -rw-r--r-- | packages/frontend/src/utility/watermark/watermark.ts (renamed from packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts) | 63 | ||||
| -rw-r--r-- | packages/frontend/src/utility/webgl.ts | 11 |
46 files changed, 1224 insertions, 1017 deletions
diff --git a/packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl b/packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl index 84c4ecbed4..84c4ecbed4 100644 --- a/packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts b/packages/frontend/src/utility/image-compositor-functions/blockNoise.ts index 355ab4536c..8c83ef51a0 100644 --- a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts +++ b/packages/frontend/src/utility/image-compositor-functions/blockNoise.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/blur.glsl b/packages/frontend/src/utility/image-compositor-functions/blur.glsl index e591267887..e591267887 100644 --- a/packages/frontend/src/utility/image-effector/fxs/blur.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/blur.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/blur.ts b/packages/frontend/src/utility/image-compositor-functions/blur.ts index 40f51fa646..1ab8eee6ba 100644 --- a/packages/frontend/src/utility/image-effector/fxs/blur.ts +++ b/packages/frontend/src/utility/image-compositor-functions/blur.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.glsl b/packages/frontend/src/utility/image-compositor-functions/checker.glsl index 09d11c15d2..09d11c15d2 100644 --- a/packages/frontend/src/utility/image-effector/fxs/checker.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/checker.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.ts b/packages/frontend/src/utility/image-compositor-functions/checker.ts index 7d1938eeb7..e0476bb126 100644 --- a/packages/frontend/src/utility/image-effector/fxs/checker.ts +++ b/packages/frontend/src/utility/image-compositor-functions/checker.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl index 60bb4f5318..60bb4f5318 100644 --- a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts index ed4d134251..5e327dd6ac 100644 --- a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts +++ b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl index 2d0c87ce95..2d0c87ce95 100644 --- a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts index 989ca79a2c..33ca05ace7 100644 --- a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts +++ b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl b/packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl index bf37f5ab43..bf37f5ab43 100644 --- a/packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts b/packages/frontend/src/utility/image-compositor-functions/colorClamp.ts index f3513011fa..d4e7b786d0 100644 --- a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts +++ b/packages/frontend/src/utility/image-compositor-functions/colorClamp.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts b/packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts index 397e16c1ba..492524ec06 100644 --- a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts +++ b/packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.glsl b/packages/frontend/src/utility/image-compositor-functions/distort.glsl index 7e0d1e3252..7e0d1e3252 100644 --- a/packages/frontend/src/utility/image-effector/fxs/distort.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/distort.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.ts b/packages/frontend/src/utility/image-compositor-functions/distort.ts index 3ea93a0266..bd0fcdf42f 100644 --- a/packages/frontend/src/utility/image-effector/fxs/distort.ts +++ b/packages/frontend/src/utility/image-compositor-functions/distort.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/fill.glsl b/packages/frontend/src/utility/image-compositor-functions/fill.glsl index f04dc5545a..f04dc5545a 100644 --- a/packages/frontend/src/utility/image-effector/fxs/fill.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/fill.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/fill.ts b/packages/frontend/src/utility/image-compositor-functions/fill.ts index 772cd76cf7..901bdadfe5 100644 --- a/packages/frontend/src/utility/image-effector/fxs/fill.ts +++ b/packages/frontend/src/utility/image-compositor-functions/fill.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/grayscale.glsl b/packages/frontend/src/utility/image-compositor-functions/grayscale.glsl index 54ca719976..54ca719976 100644 --- a/packages/frontend/src/utility/image-effector/fxs/grayscale.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/grayscale.glsl diff --git a/packages/frontend/src/utility/image-compositor-functions/grayscale.ts b/packages/frontend/src/utility/image-compositor-functions/grayscale.ts new file mode 100644 index 0000000000..b6860de0a2 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/grayscale.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/invert.glsl b/packages/frontend/src/utility/image-compositor-functions/invert.glsl index a2d1574f5b..a2d1574f5b 100644 --- a/packages/frontend/src/utility/image-effector/fxs/invert.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/invert.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/invert.ts b/packages/frontend/src/utility/image-compositor-functions/invert.ts index 9417047931..f64e68034e 100644 --- a/packages/frontend/src/utility/image-effector/fxs/invert.ts +++ b/packages/frontend/src/utility/image-compositor-functions/invert.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.glsl b/packages/frontend/src/utility/image-compositor-functions/mirror.glsl index b27934e9ef..b27934e9ef 100644 --- a/packages/frontend/src/utility/image-effector/fxs/mirror.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/mirror.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.ts b/packages/frontend/src/utility/image-compositor-functions/mirror.ts index 6515454ead..47d19c0553 100644 --- a/packages/frontend/src/utility/image-effector/fxs/mirror.ts +++ b/packages/frontend/src/utility/image-compositor-functions/mirror.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/pixelate.glsl b/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl index 4de3f27397..4de3f27397 100644 --- a/packages/frontend/src/utility/image-effector/fxs/pixelate.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/pixelate.ts b/packages/frontend/src/utility/image-compositor-functions/pixelate.ts index e3eef49b23..249d272e7e 100644 --- a/packages/frontend/src/utility/image-effector/fxs/pixelate.ts +++ b/packages/frontend/src/utility/image-compositor-functions/pixelate.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.glsl b/packages/frontend/src/utility/image-compositor-functions/polkadot.glsl index 39ecad34b5..39ecad34b5 100644 --- a/packages/frontend/src/utility/image-effector/fxs/polkadot.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/polkadot.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts b/packages/frontend/src/utility/image-compositor-functions/polkadot.ts index 521e08cc7b..d94d704be3 100644 --- a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts +++ b/packages/frontend/src/utility/image-compositor-functions/polkadot.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.glsl b/packages/frontend/src/utility/image-compositor-functions/stripe.glsl index bb18d8fcb8..bb18d8fcb8 100644 --- a/packages/frontend/src/utility/image-effector/fxs/stripe.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/stripe.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.ts b/packages/frontend/src/utility/image-compositor-functions/stripe.ts index 3a6ecf970c..d429a124bc 100644 --- a/packages/frontend/src/utility/image-effector/fxs/stripe.ts +++ b/packages/frontend/src/utility/image-compositor-functions/stripe.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/tearing.glsl b/packages/frontend/src/utility/image-compositor-functions/tearing.glsl index 3fb2fc2cad..3fb2fc2cad 100644 --- a/packages/frontend/src/utility/image-effector/fxs/tearing.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/tearing.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/tearing.ts b/packages/frontend/src/utility/image-compositor-functions/tearing.ts index 453b16bb19..66c61b7ca8 100644 --- a/packages/frontend/src/utility/image-effector/fxs/tearing.ts +++ b/packages/frontend/src/utility/image-compositor-functions/tearing.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.glsl b/packages/frontend/src/utility/image-compositor-functions/threshold.glsl index 5ca8c46c39..5ca8c46c39 100644 --- a/packages/frontend/src/utility/image-effector/fxs/threshold.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/threshold.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.ts b/packages/frontend/src/utility/image-compositor-functions/threshold.ts index d0bb8305ae..83ea788771 100644 --- a/packages/frontend/src/utility/image-effector/fxs/threshold.ts +++ b/packages/frontend/src/utility/image-compositor-functions/threshold.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl b/packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl index a0f11fcb5b..a0f11fcb5b 100644 --- a/packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts b/packages/frontend/src/utility/image-compositor-functions/zoomLines.ts index 8c0956d24e..f8768e4ec3 100644 --- a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts +++ b/packages/frontend/src/utility/image-compositor-functions/zoomLines.ts @@ -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>; diff --git a/packages/frontend/src/utility/image-effector/ImageEffector.ts b/packages/frontend/src/utility/image-effector/ImageEffector.ts index 26c74bfae5..b4295c4637 100644 --- a/packages/frontend/src/utility/image-effector/ImageEffector.ts +++ b/packages/frontend/src/utility/image-effector/ImageEffector.ts @@ -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 ImageEffectorLayer = { + [K in keyof typeof FXS]: { + id: string; + fxId: K; + params: Parameters<(typeof FXS)[K]['fn']['main']>[0]['params']; + }; +}[keyof typeof FXS]; -export type ImageEffectorFx<ID extends string = string, PS extends ImageEffectorFxParamDefs = ImageEffectorFxParamDefs, US extends string[] = string[]> = { - id: ID; +export type ImageEffectorUiDefinition<Fn extends ImageCompositorFunction<any> = ImageCompositorFunction> = { 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>; + 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, - }); - - 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]; - })), + 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])), }); - - 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, - }; -} diff --git a/packages/frontend/src/utility/image-effector/fxs.ts b/packages/frontend/src/utility/image-effector/fxs.ts index 2b20cc1f99..1fd0ad6ed7 100644 --- a/packages/frontend/src/utility/image-effector/fxs.ts +++ b/packages/frontend/src/utility/image-effector/fxs.ts @@ -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>; +}>; diff --git a/packages/frontend/src/utility/image-effector/fxs/grayscale.ts b/packages/frontend/src/utility/image-effector/fxs/grayscale.ts deleted file mode 100644 index 055e8b4618..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/grayscale.ts +++ /dev/null @@ -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 }) => { - }, -}); diff --git a/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts b/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts new file mode 100644 index 0000000000..9e97728785 --- /dev/null +++ b/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts @@ -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); + } +} diff --git a/packages/frontend/src/utility/image-frame-renderer/frame.glsl b/packages/frontend/src/utility/image-frame-renderer/frame.glsl new file mode 100644 index 0000000000..aa9dde5ad8 --- /dev/null +++ b/packages/frontend/src/utility/image-frame-renderer/frame.glsl @@ -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; + } + } +} diff --git a/packages/frontend/src/utility/image-frame-renderer/frame.ts b/packages/frontend/src/utility/image-frame-renderer/frame.ts new file mode 100644 index 0000000000..aeca45c1ec --- /dev/null +++ b/packages/frontend/src/utility/image-frame-renderer/frame.ts @@ -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); + } + } + }, +}); diff --git a/packages/frontend/src/utility/watermark.ts b/packages/frontend/src/utility/watermark.ts deleted file mode 100644 index 1b46721a2b..0000000000 --- a/packages/frontend/src/utility/watermark.ts +++ /dev/null @@ -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); - } -} diff --git a/packages/frontend/src/utility/watermark/WatermarkRenderer.ts b/packages/frontend/src/utility/watermark/WatermarkRenderer.ts new file mode 100644 index 0000000000..766d45148a --- /dev/null +++ b/packages/frontend/src/utility/watermark/WatermarkRenderer.ts @@ -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; +} diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl b/packages/frontend/src/utility/watermark/watermark.glsl index d6a1ef1820..d6a1ef1820 100644 --- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl +++ b/packages/frontend/src/utility/watermark/watermark.glsl diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts b/packages/frontend/src/utility/watermark/watermark.ts index bb51ed796b..62efcd12b6 100644 --- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts +++ b/packages/frontend/src/utility/watermark/watermark.ts @@ -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); diff --git a/packages/frontend/src/utility/webgl.ts b/packages/frontend/src/utility/webgl.ts index ae595b605c..334663b1a1 100644 --- a/packages/frontend/src/utility/webgl.ts +++ b/packages/frontend/src/utility/webgl.ts @@ -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; +} |