From 4ba18690d7abd7eea086bb59e6cbcc8ead9e121a Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:25:17 +0900 Subject: feat(frontend): EXIFフレーム機能 (#16725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- packages/frontend/package.json | 1 + .../src/components/MkEmbedCodeGenDialog.vue | 23 +- .../src/components/MkImageEffectorDialog.Layer.vue | 12 +- .../src/components/MkImageEffectorDialog.vue | 87 ++-- .../src/components/MkImageFrameEditorDialog.vue | 509 +++++++++++++++++++++ .../components/MkWatermarkEditorDialog.Layer.vue | 2 +- .../src/components/MkWatermarkEditorDialog.vue | 162 ++++--- packages/frontend/src/composables/use-uploader.ts | 176 +++++-- packages/frontend/src/lib/ImageCompositor.ts | 313 +++++++++++++ packages/frontend/src/lib/pizzax.ts | 2 + .../src/pages/settings/drive.ImageFrameItem.vue | 113 +++++ .../src/pages/settings/drive.WatermarkItem.vue | 15 +- packages/frontend/src/pages/settings/drive.vue | 86 +++- packages/frontend/src/preferences/def.ts | 23 +- .../image-compositor-functions/blockNoise.glsl | 43 ++ .../image-compositor-functions/blockNoise.ts | 95 ++++ .../utility/image-compositor-functions/blur.glsl | 78 ++++ .../src/utility/image-compositor-functions/blur.ts | 93 ++++ .../image-compositor-functions/checker.glsl | 43 ++ .../utility/image-compositor-functions/checker.ts | 61 +++ .../chromaticAberration.glsl | 49 ++ .../chromaticAberration.ts | 39 ++ .../image-compositor-functions/colorAdjust.glsl | 82 ++++ .../image-compositor-functions/colorAdjust.ts | 77 ++++ .../image-compositor-functions/colorClamp.glsl | 29 ++ .../image-compositor-functions/colorClamp.ts | 48 ++ .../colorClampAdvanced.ts | 88 ++++ .../image-compositor-functions/distort.glsl | 30 ++ .../utility/image-compositor-functions/distort.ts | 65 +++ .../utility/image-compositor-functions/fill.glsl | 50 ++ .../src/utility/image-compositor-functions/fill.ts | 100 ++++ .../image-compositor-functions/grayscale.glsl | 22 + .../image-compositor-functions/grayscale.ts | 21 + .../utility/image-compositor-functions/invert.glsl | 23 + .../utility/image-compositor-functions/invert.ts | 43 ++ .../utility/image-compositor-functions/mirror.glsl | 26 ++ .../utility/image-compositor-functions/mirror.ts | 46 ++ .../image-compositor-functions/pixelate.glsl | 68 +++ .../utility/image-compositor-functions/pixelate.ts | 93 ++++ .../image-compositor-functions/polkadot.glsl | 75 +++ .../utility/image-compositor-functions/polkadot.ts | 102 +++++ .../utility/image-compositor-functions/stripe.glsl | 45 ++ .../utility/image-compositor-functions/stripe.ts | 73 +++ .../image-compositor-functions/tearing.glsl | 33 ++ .../utility/image-compositor-functions/tearing.ts | 83 ++++ .../image-compositor-functions/threshold.glsl | 23 + .../image-compositor-functions/threshold.ts | 52 +++ .../image-compositor-functions/zoomLines.glsl | 48 ++ .../image-compositor-functions/zoomLines.ts | 87 ++++ .../src/utility/image-effector/ImageEffector.ts | 482 ++----------------- .../frontend/src/utility/image-effector/fxs.ts | 82 ++-- .../src/utility/image-effector/fxs/blockNoise.glsl | 43 -- .../src/utility/image-effector/fxs/blockNoise.ts | 86 ---- .../src/utility/image-effector/fxs/blur.glsl | 78 ---- .../src/utility/image-effector/fxs/blur.ts | 83 ---- .../src/utility/image-effector/fxs/checker.glsl | 43 -- .../src/utility/image-effector/fxs/checker.ts | 54 --- .../image-effector/fxs/chromaticAberration.glsl | 49 -- .../image-effector/fxs/chromaticAberration.ts | 34 -- .../utility/image-effector/fxs/colorAdjust.glsl | 82 ---- .../src/utility/image-effector/fxs/colorAdjust.ts | 69 --- .../src/utility/image-effector/fxs/colorClamp.glsl | 29 -- .../src/utility/image-effector/fxs/colorClamp.ts | 43 -- .../image-effector/fxs/colorClampAdvanced.ts | 79 ---- .../src/utility/image-effector/fxs/distort.glsl | 30 -- .../src/utility/image-effector/fxs/distort.ts | 58 --- .../src/utility/image-effector/fxs/fill.glsl | 50 -- .../src/utility/image-effector/fxs/fill.ts | 89 ---- .../src/utility/image-effector/fxs/grayscale.glsl | 22 - .../src/utility/image-effector/fxs/grayscale.ts | 19 - .../src/utility/image-effector/fxs/invert.glsl | 23 - .../src/utility/image-effector/fxs/invert.ts | 37 -- .../src/utility/image-effector/fxs/mirror.glsl | 26 -- .../src/utility/image-effector/fxs/mirror.ts | 41 -- .../src/utility/image-effector/fxs/pixelate.glsl | 68 --- .../src/utility/image-effector/fxs/pixelate.ts | 83 ---- .../src/utility/image-effector/fxs/polkadot.glsl | 75 --- .../src/utility/image-effector/fxs/polkadot.ts | 92 ---- .../src/utility/image-effector/fxs/stripe.glsl | 45 -- .../src/utility/image-effector/fxs/stripe.ts | 66 --- .../src/utility/image-effector/fxs/tearing.glsl | 33 -- .../src/utility/image-effector/fxs/tearing.ts | 75 --- .../src/utility/image-effector/fxs/threshold.glsl | 23 - .../src/utility/image-effector/fxs/threshold.ts | 46 -- .../image-effector/fxs/watermarkPlacement.glsl | 147 ------ .../image-effector/fxs/watermarkPlacement.ts | 94 ---- .../src/utility/image-effector/fxs/zoomLines.glsl | 48 -- .../src/utility/image-effector/fxs/zoomLines.ts | 77 ---- .../image-frame-renderer/ImageFrameRenderer.ts | 270 +++++++++++ .../src/utility/image-frame-renderer/frame.glsl | 61 +++ .../src/utility/image-frame-renderer/frame.ts | 57 +++ packages/frontend/src/utility/watermark.ts | 218 --------- .../src/utility/watermark/WatermarkRenderer.ts | 332 ++++++++++++++ .../frontend/src/utility/watermark/watermark.glsl | 147 ++++++ .../frontend/src/utility/watermark/watermark.ts | 57 +++ packages/frontend/src/utility/webgl.ts | 11 + 96 files changed, 4412 insertions(+), 3001 deletions(-) create mode 100644 packages/frontend/src/components/MkImageFrameEditorDialog.vue create mode 100644 packages/frontend/src/lib/ImageCompositor.ts create mode 100644 packages/frontend/src/pages/settings/drive.ImageFrameItem.vue create mode 100644 packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/blockNoise.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/blur.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/blur.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/checker.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/checker.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/colorClamp.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/distort.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/distort.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/fill.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/fill.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/grayscale.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/grayscale.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/invert.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/invert.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/mirror.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/mirror.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/pixelate.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/pixelate.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/polkadot.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/polkadot.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/stripe.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/stripe.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/tearing.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/tearing.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/threshold.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/threshold.ts create mode 100644 packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl create mode 100644 packages/frontend/src/utility/image-compositor-functions/zoomLines.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/blockNoise.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/blur.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/blur.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/checker.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/checker.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/colorClamp.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/distort.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/distort.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/fill.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/fill.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/grayscale.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/grayscale.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/invert.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/invert.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/mirror.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/mirror.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/pixelate.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/pixelate.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/polkadot.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/polkadot.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/stripe.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/stripe.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/tearing.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/tearing.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/threshold.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/threshold.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts delete mode 100644 packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl delete mode 100644 packages/frontend/src/utility/image-effector/fxs/zoomLines.ts create mode 100644 packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts create mode 100644 packages/frontend/src/utility/image-frame-renderer/frame.glsl create mode 100644 packages/frontend/src/utility/image-frame-renderer/frame.ts delete mode 100644 packages/frontend/src/utility/watermark.ts create mode 100644 packages/frontend/src/utility/watermark/WatermarkRenderer.ts create mode 100644 packages/frontend/src/utility/watermark/watermark.glsl create mode 100644 packages/frontend/src/utility/watermark/watermark.ts (limited to 'packages') diff --git a/packages/frontend/package.json b/packages/frontend/package.json index bd81d1d2c6..1ad3437e86 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -48,6 +48,7 @@ "estree-walker": "3.0.3", "eventemitter3": "5.0.1", "execa": "9.6.0", + "exifreader": "4.32.0", "frontend-shared": "workspace:*", "icons-subsetter": "workspace:*", "idb-keyval": "6.2.2", diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 0cb8499699..4f16149caa 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -24,9 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_x_leaveTo" >
-
+
{{ i18n.ts.preview }}
@@ -91,20 +89,18 @@ import { url } from '@@/js/config.js'; import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; - import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; - import MkCode from '@/components/MkCode.vue'; import MkInfo from '@/components/MkInfo.vue'; - import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; +import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'ok'): void; @@ -314,10 +310,19 @@ onUnmounted(() => { .embedCodeGenPreviewRoot { position: relative; - background-color: var(--MI_THEME-bg); - background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); cursor: not-allowed; + background-color: var(--MI_THEME-bg); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } } .embedCodeGenPreviewWrapper { diff --git a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue index f734325039..bc7e8b0946 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -26,14 +26,14 @@ import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue'; import { FXS } from '@/utility/image-effector/fxs.js'; const layer = defineModel('layer', { required: true }); -const fx = FXS.find((fx) => fx.id === layer.value.fxId); +const fx = FXS[layer.value.fxId]; if (fx == null) { throw new Error(`Unrecognized effect: ${layer.value.fxId}`); } const emit = defineEmits<{ - (e: 'del'): void; - (e: 'swapUp'): void; - (e: 'swapDown'): void; + (ev: 'del'): void; + (ev: 'swapUp'): void; + (ev: 'swapDown'): void; }>(); diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue index 19ddb81919..3d7801f925 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
{{ i18n.ts.preview }}
@@ -64,6 +64,7 @@ import * as os from '@/os.js'; import { deepClone } from '@/utility/clone.js'; import { FXS } from '@/utility/image-effector/fxs.js'; import { genId } from '@/utility/id.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ image: File; @@ -94,19 +95,19 @@ const layers = reactive([]); watch(layers, async () => { if (renderer != null) { - renderer.setLayers(layers); + renderer.render(layers); } }, { deep: true }); function addEffect(ev: MouseEvent) { - os.popupMenu(FXS.map((fx) => ({ - text: fx.name, + os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({ + text: fx.uiDefinition.name, action: () => { layers.push({ id: genId(), - fxId: fx.id, - params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])), - }); + fxId: id as keyof typeof FXS, + params: Object.fromEntries(Object.entries(fx.uiDefinition.params).map(([k, v]) => [k, v.default])), + } as ImageEffectorLayer); }, })), ev.currentTarget ?? ev.target); } @@ -136,7 +137,7 @@ function onLayerDelete(layer: ImageEffectorLayer) { const canvasEl = useTemplateRef('canvasEl'); -let renderer: ImageEffector | null = null; +let renderer: ImageEffector | null = null; let imageBitmap: ImageBitmap | null = null; onMounted(async () => { @@ -146,30 +147,35 @@ onMounted(async () => { await nextTick(); // waitingがレンダリングされるまで待つ - imageBitmap = await window.createImageBitmap(props.image); + try { + imageBitmap = await window.createImageBitmap(props.image); - const MAX_W = 1000; - const MAX_H = 1000; - let w = imageBitmap.width; - let h = imageBitmap.height; + const MAX_W = 1000; + const MAX_H = 1000; + let w = imageBitmap.width; + let h = imageBitmap.height; - if (w > MAX_W || h > MAX_H) { - const scale = Math.min(MAX_W / w, MAX_H / h); - w *= scale; - h *= scale; - } - - renderer = new ImageEffector({ - canvas: canvasEl.value, - renderWidth: w, - renderHeight: h, - image: imageBitmap, - fxs: FXS, - }); + if (w > MAX_W || h > MAX_H) { + const scale = Math.min(MAX_W / w, MAX_H / h); + w = Math.floor(w * scale); + h = Math.floor(h * scale); + } - await renderer.setLayers(layers); + renderer = new ImageEffector({ + canvas: canvasEl.value, + renderWidth: w, + renderHeight: h, + image: imageBitmap, + }); - renderer.render(); + await renderer.render(layers); + } catch (err) { + console.error(err); + os.alert({ + type: 'error', + text: i18n.ts._imageEffector.failedToLoadImage, + }); + } closeWaiting(); }); @@ -196,7 +202,7 @@ async function save() { await nextTick(); // waitingがレンダリングされるまで待つ renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す - renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる + await renderer.render(layers); // toBlobの直前にレンダリングしないと何故か壊れる canvasEl.value.toBlob((blob) => { emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' })); dialog.value?.close(); @@ -208,11 +214,10 @@ const enabled = ref(true); watch(enabled, () => { if (renderer != null) { if (enabled.value) { - renderer.setLayers(layers); + renderer.render(layers); } else { - renderer.setLayers([]); + renderer.render([]); } - renderer.render(); } }); @@ -281,6 +286,7 @@ function onImagePointerdown(ev: PointerEvent) { angle: 0, opacity: 1, color: [1, 1, 1], + ellipse: false, }, }); } else if (penMode.value === 'blur') { @@ -294,6 +300,7 @@ function onImagePointerdown(ev: PointerEvent) { scaleY: 0.1, angle: 0, radius: 3, + ellipse: false, }, }); } else if (penMode.value === 'pixelate') { @@ -307,6 +314,7 @@ function onImagePointerdown(ev: PointerEvent) { scaleY: 0.1, angle: 0, strength: 0.2, + ellipse: false, }, }); } @@ -329,7 +337,7 @@ function onImagePointerdown(ev: PointerEvent) { const scaleY = Math.abs(y - startY); const layerIndex = layers.findIndex((l) => l.id === id); - const layer = layerIndex !== -1 ? layers[layerIndex] : null; + const layer = layerIndex !== -1 ? (layers[layerIndex] as Extract) : null; if (layer != null) { layer.params.offsetX = (x + startX) - 1; layer.params.offsetY = (y + startY) - 1; @@ -373,8 +381,17 @@ function onImagePointerdown(ev: PointerEvent) { .preview { position: relative; background-color: var(--MI_THEME-bg); - background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } } .previewContainer { diff --git a/packages/frontend/src/components/MkImageFrameEditorDialog.vue b/packages/frontend/src/components/MkImageFrameEditorDialog.vue new file mode 100644 index 0000000000..2a91c85952 --- /dev/null +++ b/packages/frontend/src/components/MkImageFrameEditorDialog.vue @@ -0,0 +1,509 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue index b34181e5cc..154b3ffc27 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -345,7 +345,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -380,8 +425,17 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) { .preview { position: relative; background-color: var(--MI_THEME-bg); - background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } } .previewContainer { diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts index e4aa1fda53..8ffb1e656b 100644 --- a/packages/frontend/src/composables/use-uploader.ts +++ b/packages/frontend/src/composables/use-uploader.ts @@ -9,6 +9,8 @@ import isAnimated from 'is-file-animated'; import { EventEmitter } from 'eventemitter3'; import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue'; import type { MenuItem } from '@/types/menu.js'; +import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js'; +import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; import { genId } from '@/utility/id.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; @@ -16,7 +18,6 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js'; import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; -import { WatermarkRenderer } from '@/utility/watermark.js'; export type UploaderFeatures = { imageEditing?: boolean; @@ -28,13 +29,7 @@ const THUMBNAIL_SUPPORTED_TYPES = [ 'image/png', 'image/webp', 'image/svg+xml', -]; - -const IMAGE_COMPRESSION_SUPPORTED_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/webp', - 'image/svg+xml', + 'image/gif', ]; const IMAGE_EDITING_SUPPORTED_TYPES = [ @@ -49,11 +44,7 @@ const VIDEO_COMPRESSION_SUPPORTED_TYPES = [ // TODO 'video/x-matroska', ]; -const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES; - const IMAGE_PREPROCESS_NEEDED_TYPES = [ - ...WATERMARK_SUPPORTED_TYPES, - ...IMAGE_COMPRESSION_SUPPORTED_TYPES, ...IMAGE_EDITING_SUPPORTED_TYPES, ]; @@ -83,7 +74,9 @@ export type UploaderItem = { compressedSize?: number | null; preprocessedFile?: Blob | null; file: File; - watermarkPresetId: string | null; + watermarkPreset: WatermarkPreset | null; + watermarkLayers: WatermarkLayers | null; + imageFrameParams: ImageFrameParams | null; isSensitive?: boolean; caption?: string | null; abort?: (() => void) | null; @@ -135,6 +128,7 @@ export function useUploader(options: { const id = genId(); const filename = file.name ?? 'untitled'; const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; + const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null; items.value.push({ id, name: prefer.s.keepOriginalFilename ? filename : id + extension, @@ -146,8 +140,10 @@ export function useUploader(options: { aborted: false, uploaded: null, uploadFailed: false, - compressionLevel: IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0, - watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null, + compressionLevel: IMAGE_EDITING_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0, + watermarkPreset, + watermarkLayers: watermarkPreset?.layers ?? null, + imageFrameParams: null, file: markRaw(file), }); const reactiveItem = items.value.at(-1)!; @@ -253,7 +249,7 @@ export function useUploader(options: { }, },*/ { icon: 'ti ti-sparkles', - text: i18n.ts._imageEffector.title + ' (BETA)', + text: i18n.ts._imageEffector.title, action: async () => { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), { image: item.file, @@ -280,13 +276,14 @@ export function useUploader(options: { if ( uploaderFeatures.value.watermark && $i.policies.watermarkAvailable && - WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && + IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded ) { - function changeWatermarkPreset(presetId: string | null) { - item.watermarkPresetId = presetId; + function change(layers: WatermarkLayers | null, preset?: WatermarkPreset | null) { + item.watermarkPreset = preset ?? null; + item.watermarkLayers = layers; preprocess(item).then(() => { triggerRef(items); }); @@ -295,43 +292,109 @@ export function useUploader(options: { menu.push({ icon: 'ti ti-copyright', text: i18n.ts.watermark, - caption: computed(() => item.watermarkPresetId == null ? null : prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId)?.name), + caption: computed(() => item.watermarkPreset != null ? item.watermarkPreset.name : item.watermarkLayers != null ? i18n.ts.custom : null), type: 'parent', children: [{ - type: 'radioOption', - text: i18n.ts.none, - active: computed(() => item.watermarkPresetId == null), - action: () => changeWatermarkPreset(null), + type: 'button' as const, + icon: 'ti ti-pencil', + text: i18n.ts.edit, + action: async () => { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + layers: item.watermarkLayers, + image: item.file, + }, { + ok: (layers) => { + change(layers); + }, + closed: () => dispose(), + }); + }, + }, { + type: 'button' as const, + icon: 'ti ti-x', + text: i18n.ts.remove, + action: () => change(null), }, { type: 'divider', + }, { + type: 'label', + text: i18n.ts.presets, }, ...prefer.s.watermarkPresets.map(preset => ({ type: 'radioOption' as const, text: preset.name, - active: computed(() => item.watermarkPresetId === preset.id), - action: () => changeWatermarkPreset(preset.id), - })), ...(prefer.s.watermarkPresets.length > 0 ? [{ - type: 'divider' as const, + active: computed(() => item.watermarkPreset?.id === preset.id), + action: () => change(preset.layers, preset), + }))], + }); + } + + if ( + uploaderFeatures.value.imageEditing && + IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && + !item.preprocessing && + !item.uploading && + !item.uploaded + ) { + function change(params: ImageFrameParams | null) { + item.imageFrameParams = params; + preprocess(item).then(() => { + triggerRef(items); + }); + } + + menu.push({ + icon: 'ti ti-device-ipad-horizontal', + text: i18n.ts.frame, + type: 'parent' as const, + children: [{ + type: 'button' as const, + icon: 'ti ti-pencil', + text: i18n.ts.edit, + action: async () => { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), { + params: item.imageFrameParams, + image: item.file, + imageCaption: item.caption ?? null, + imageFilename: item.name, + }, { + ok: (params) => { + change(params); + }, + closed: () => dispose(), + }); + }, + }, ...(item.imageFrameParams != null ? [{ + type: 'button' as const, + icon: 'ti ti-x', + text: i18n.ts.remove, + action: () => change(null), }] : []), { - type: 'button', - icon: 'ti ti-plus', - text: i18n.ts.add, + type: 'divider' as const, + }, { + type: 'label' as const, + text: i18n.ts.presets, + }, ...prefer.s.imageFramePresets.map(preset => ({ + type: 'button' as const, + text: preset.name, action: async () => { - const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), { + params: preset.params, image: item.file, + imageCaption: item.caption ?? null, + imageFilename: item.name, }, { - ok: (preset) => { - prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]); - changeWatermarkPreset(preset.id); + ok: (params) => { + change(params); }, closed: () => dispose(), }); }, - }], + }))], }); } if ( - (IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) && + (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) && !item.preprocessing && !item.uploading && !item.uploaded @@ -545,10 +608,10 @@ export function useUploader(options: { let preprocessedFile: Blob | File = item.file; - const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable; - const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId); - if (needsWatermark && preset != null) { + const needsWatermark = item.watermarkLayers != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable; + if (needsWatermark && item.watermarkLayers != null) { const canvas = window.document.createElement('canvas'); + const WatermarkRenderer = await import('@/utility/watermark/WatermarkRenderer.js').then(x => x.WatermarkRenderer); const renderer = new WatermarkRenderer({ canvas: canvas, renderWidth: imageBitmap.width, @@ -556,9 +619,7 @@ export function useUploader(options: { image: imageBitmap, }); - await renderer.setLayers(preset.layers); - - renderer.render(); + await renderer.render(item.watermarkLayers); preprocessedFile = await new Promise((resolve) => { canvas.toBlob((blob) => { @@ -571,8 +632,35 @@ export function useUploader(options: { }); } + const needsImageFrame = item.imageFrameParams != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type); + if (needsImageFrame && item.imageFrameParams != null) { + const canvas = window.document.createElement('canvas'); + const ExifReader = await import('exifreader'); + const exif = await ExifReader.load(await item.file.arrayBuffer()); + const ImageFrameRenderer = await import('@/utility/image-frame-renderer/ImageFrameRenderer.js').then(x => x.ImageFrameRenderer); + const frameRenderer = new ImageFrameRenderer({ + canvas: canvas, + image: await window.createImageBitmap(preprocessedFile), + exif, + caption: item.caption ?? null, + filename: item.name, + }); + + await frameRenderer.render(item.imageFrameParams); + + preprocessedFile = await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (blob == null) { + throw new Error('Failed to convert canvas to blob'); + } + resolve(blob); + frameRenderer.destroy(); + }, 'image/png'); + }); + } + const compressionSettings = getCompressionSettings(item.compressionLevel); - const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile)); + const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile)); if (needsCompress) { const config = { diff --git a/packages/frontend/src/lib/ImageCompositor.ts b/packages/frontend/src/lib/ImageCompositor.ts new file mode 100644 index 0000000000..a26302af77 --- /dev/null +++ b/packages/frontend/src/lib/ImageCompositor.ts @@ -0,0 +1,313 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createTexture, initShaderProgram } from '../utility/webgl.js'; + +export type ImageCompositorFunctionParams = Record; + +export type ImageCompositorFunction = { + shader: string; + main: (ctx: { + gl: WebGL2RenderingContext; + program: WebGLProgram; + params: PS; + u: Record; + width: number; + height: number; + textures: Map; + }) => void; +}; + +export type ImageCompositorLayer = any> = { + [K in keyof FNS]: { + id: string; + functionId: K; + params: Parameters[0]['params']; + }; +}[keyof FNS]; + +export function defineImageCompositorFunction(fn: ImageCompositorFunction) { + return fn; +} + +// TODO: per layer cache + +export class ImageCompositor>> { + private gl: WebGL2RenderingContext; + private canvas: HTMLCanvasElement | null = null; + private renderWidth: number; + private renderHeight: number; + private baseTexture: WebGLTexture; + private shaderCache: Map = new Map(); + private perLayerResultTextures: Map = new Map(); + private perLayerResultFrameBuffers: Map = new Map(); + private nopProgram: WebGLProgram; + private registeredTextures: Map = new Map(); + private registeredFunctions: Map = new Map(); + + constructor(options: { + canvas: HTMLCanvasElement; + renderWidth: number; + renderHeight: number; + image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null; + functions: FNS; + }) { + this.canvas = options.canvas; + this.renderWidth = options.renderWidth; + this.renderHeight = options.renderHeight; + + 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); + + if (options.image != null) { + this.baseTexture = createTexture(gl); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.baseTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, options.image.width, options.image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, options.image); + gl.bindTexture(gl.TEXTURE_2D, null); + } else { + this.baseTexture = createTexture(gl); + gl.activeTexture(gl.TEXTURE0); + } + + 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); + + for (const [id, fn] of Object.entries(options.functions)) { + const uniforms = this.extractUniformNamesFromShader(fn.shader); + this.registeredFunctions.set(id, { ...fn, id, uniforms }); + } + } + + private extractUniformNamesFromShader(shader: string): string[] { + const uniformRegex = /uniform\s+\w+\s+(\w+)\s*;/g; + const uniforms: string[] = []; + let match; + while ((match = uniformRegex.exec(shader)) !== null) { + uniforms.push(match[1].replace(/^u_/, '')); + } + return uniforms; + } + + private renderLayer(layer: ImageCompositorLayer, preTexture: WebGLTexture, invert = false) { + const gl = this.gl; + + const fn = this.registeredFunctions.get(layer.functionId); + if (fn == null) return; + + const cachedShader = this.shaderCache.get(fn.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); + } + `, fn.shader); + if (cachedShader == null) { + this.shaderCache.set(fn.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); + + fn.main({ + gl: gl, + program: shaderProgram, + params: layer.params, + u: Object.fromEntries(fn.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])), + width: this.renderWidth, + height: this.renderHeight, + textures: this.registeredTextures, + }); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + public render(layers: (ImageCompositorLayer)[]) { + const gl = this.gl; + + // 入力をそのまま出力 + if (layers.length === 0) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.baseTexture); + + gl.useProgram(this.nopProgram); + gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + return; + } + + let preTexture = this.baseTexture; + + for (const layer of layers) { + const isLast = layer === 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 registerTexture(key: string, image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement) { + const gl = this.gl; + + const existing = this.registeredTextures.get(key); + if (existing != null) { + gl.deleteTexture(existing.texture); + this.registeredTextures.delete(key); + } + + 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); + + this.registeredTextures.set(key, { + texture: texture, + width: image.width, + height: image.height, + }); + } + + public unregisterTexture(key: string) { + const gl = this.gl; + + const existing = this.registeredTextures.get(key); + if (existing != null) { + gl.deleteTexture(existing.texture); + this.registeredTextures.delete(key); + } + } + + public hasTexture(key: string) { + return this.registeredTextures.has(key); + } + + public getKeysOfRegisteredTextures() { + return this.registeredTextures.keys(); + } + + public changeResolution(width: number, height: number) { + if (this.renderWidth === width && this.renderHeight === height) return; + + 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); + } + + /* + * 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.registeredTextures.values()) { + this.gl.deleteTexture(texture.texture); + } + this.registeredTextures.clear(); + + this.gl.deleteTexture(this.baseTexture); + + if (disposeCanvas) { + const loseContextExt = this.gl.getExtension('WEBGL_lose_context'); + if (loseContextExt) loseContextExt.loseContext(); + } + } +} diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index 6dffcf9478..8faac6155c 100644 --- a/packages/frontend/src/lib/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -5,6 +5,8 @@ // PIZZAX --- A lightweight store +// TODO: Misskeyのドメイン知識があるのでutilityなどに移動する + import { onUnmounted, ref, watch } from 'vue'; import { BroadcastChannel } from 'broadcast-channel'; import type { Ref } from 'vue'; diff --git a/packages/frontend/src/pages/settings/drive.ImageFrameItem.vue b/packages/frontend/src/pages/settings/drive.ImageFrameItem.vue new file mode 100644 index 0000000000..62922fc964 --- /dev/null +++ b/packages/frontend/src/pages/settings/drive.ImageFrameItem.vue @@ -0,0 +1,113 @@ + + + + + + + diff --git a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue index bb91d5e212..0c03a4493a 100644 --- a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue +++ b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue @@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index f58ff4c78c..8d443921a9 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -124,6 +124,34 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + +
+
+ + + + + + + + + +
+
+
+
+ { + if (res == null) return; + if (res.enabled) imageFramePresetsSyncEnabled.value = true; + }); + } else { + prefer.disableSync('imageFramePresets'); + imageFramePresetsSyncEnabled.value = false; + } +} + misskeyApi('drive').then(info => { capacity.value = info.capacity; usage.value = info.usage; @@ -266,8 +311,11 @@ function chooseUploadFolder() { async function addWatermarkPreset() { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + presetEditMode: true, + preset: null, + layers: [], }, { - ok: (preset: WatermarkPreset) => { + presetOk: (preset) => { prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]); }, closed: () => dispose(), @@ -299,6 +347,40 @@ function onDeleteWatermarkPreset(id: string) { } } +function onUpdateImageFramePreset(id: string, preset: ImageFramePreset) { + const index = prefer.s.imageFramePresets.findIndex(p => p.id === id); + if (index !== -1) { + prefer.commit('imageFramePresets', [ + ...prefer.s.imageFramePresets.slice(0, index), + preset, + ...prefer.s.imageFramePresets.slice(index + 1), + ]); + } +} + +function onDeleteImageFramePreset(id: string) { + const index = prefer.s.imageFramePresets.findIndex(p => p.id === id); + if (index !== -1) { + prefer.commit('imageFramePresets', [ + ...prefer.s.imageFramePresets.slice(0, index), + ...prefer.s.imageFramePresets.slice(index + 1), + ]); + } +} + +async function addImageFramePreset() { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), { + presetEditMode: true, + preset: null, + params: null, + }, { + presetOk: (preset) => { + prefer.commit('imageFramePresets', [...prefer.s.imageFramePresets, preset]); + }, + closed: () => dispose(), + }); +} + function saveProfile() { misskeyApi('i/update', { alwaysMarkNsfw: !!alwaysMarkNsfw.value, diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 915b192605..2f2107d9ed 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -12,7 +12,8 @@ import type { SoundType } from '@/utility/sound.js'; import type { Plugin } from '@/plugin.js'; import type { DeviceKind } from '@/utility/device-kind.js'; import type { DeckProfile } from '@/deck.js'; -import type { WatermarkPreset } from '@/utility/watermark.js'; +import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js'; +import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; import { genId } from '@/utility/id.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; import { deepEqual } from '@/utility/deep-equal.js'; @@ -437,6 +438,26 @@ export const PREF_DEF = definePreferences({ accountDependent: true, default: null as WatermarkPreset['id'] | null, }, + imageFramePresets: { + accountDependent: true, + default: [] as ImageFramePreset[], + mergeStrategy: (a, b) => { + const mergedItems = [] as typeof a; + for (const x of a.concat(b)) { + const sameIdItem = mergedItems.find(y => y.id === x.id); + if (sameIdItem != null) { + if (deepEqual(x, sameIdItem)) { // 完全な重複は無視 + continue; + } else { // IDは同じなのに内容が違う場合はマージ不可とする + throw new Error(); + } + } else { + mergedItems.push(x); + } + } + return mergedItems; + }, + }, defaultImageCompressionLevel: { default: 2 as 0 | 1 | 2 | 3, }, diff --git a/packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl b/packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl new file mode 100644 index 0000000000..84c4ecbed4 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl @@ -0,0 +1,43 @@ +#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 int u_amount; +uniform float u_shiftStrengths[128]; +uniform vec2 u_shiftOrigins[128]; +uniform vec2 u_shiftSizes[128]; +uniform float u_channelShift; +out vec4 out_color; + +void main() { + // TODO: ピクセル毎に計算する必要はないのでuniformにする + float aspect_ratio = min(in_resolution.x, in_resolution.y) / max(in_resolution.x, in_resolution.y); + float aspect_ratio_x = in_resolution.x > in_resolution.y ? 1.0 : aspect_ratio; + float aspect_ratio_y = in_resolution.x < in_resolution.y ? 1.0 : aspect_ratio; + + float v = 0.0; + + for (int i = 0; i < u_amount; i++) { + if ( + in_uv.x * aspect_ratio_x > ((u_shiftOrigins[i].x * aspect_ratio_x) - u_shiftSizes[i].x) && + in_uv.x * aspect_ratio_x < ((u_shiftOrigins[i].x * aspect_ratio_x) + u_shiftSizes[i].x) && + in_uv.y * aspect_ratio_y > ((u_shiftOrigins[i].y * aspect_ratio_y) - u_shiftSizes[i].y) && + in_uv.y * aspect_ratio_y < ((u_shiftOrigins[i].y * aspect_ratio_y) + u_shiftSizes[i].y) + ) { + v += u_shiftStrengths[i]; + } + } + + float r = texture(in_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r; + float g = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).g; + float b = texture(in_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b; + float a = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).a; + out_color = vec4(r, g, b, a); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/blockNoise.ts b/packages/frontend/src/utility/image-compositor-functions/blockNoise.ts new file mode 100644 index 0000000000..8c83ef51a0 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/blockNoise.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import seedrandom from 'seedrandom'; +import shader from './blockNoise.glsl'; +import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js'; +import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js'; +import { i18n } from '@/i18n.js'; + +export const fn = defineImageCompositorFunction<{ + amount: number; + strength: number; + width: number; + height: number; + channelShift: number; + seed: number; +}>({ + shader, + 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, + type: 'number', + default: 50, + min: 1, + max: 100, + step: 1, + }, + strength: { + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', + default: 0.05, + min: -1, + max: 1, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + width: { + label: i18n.ts.width, + type: 'number', + default: 0.05, + min: 0.01, + max: 1, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + height: { + label: i18n.ts.height, + type: 'number', + default: 0.01, + min: 0.01, + max: 1, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + channelShift: { + label: i18n.ts._imageEffector._fxProps.glitchChannelShift, + type: 'number', + default: 0, + min: 0, + max: 10, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + seed: { + label: i18n.ts._imageEffector._fxProps.seed, + type: 'seed', + default: 100, + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/blur.glsl b/packages/frontend/src/utility/image-compositor-functions/blur.glsl new file mode 100644 index 0000000000..e591267887 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/blur.glsl @@ -0,0 +1,78 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform vec2 u_offset; +uniform vec2 u_scale; +uniform bool u_ellipse; +uniform float u_angle; +uniform float u_radius; +uniform int u_samples; +out vec4 out_color; + +void main() { + float angle = -(u_angle * PI); + vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ) + u_offset; + + bool isInside = false; + if (u_ellipse) { + vec2 norm = (rotatedUV - u_offset) / u_scale; + isInside = dot(norm, norm) <= 1.0; + } else { + isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; + } + + if (!isInside) { + out_color = texture(in_texture, in_uv); + return; + } + + vec4 result = vec4(0.0); + float totalSamples = 0.0; + + // Make blur radius resolution-independent by using a percentage of image size + // This ensures consistent visual blur regardless of image resolution + float referenceSize = min(in_resolution.x, in_resolution.y); + float normalizedRadius = u_radius / 100.0; // Convert radius to percentage (0-15 -> 0-0.15) + vec2 blurOffset = vec2(normalizedRadius) / in_resolution * referenceSize; + + // Calculate how many samples to take in each direction + // This determines the grid density, not the blur extent + int sampleRadius = int(sqrt(float(u_samples)) / 2.0); + + // Sample in a grid pattern within the specified radius + for (int x = -sampleRadius; x <= sampleRadius; x++) { + for (int y = -sampleRadius; y <= sampleRadius; y++) { + // Normalize the grid position to [-1, 1] range + float normalizedX = float(x) / float(sampleRadius); + float normalizedY = float(y) / float(sampleRadius); + + // Scale by radius to get the actual sampling offset + vec2 offset = vec2(normalizedX, normalizedY) * blurOffset; + vec2 sampleUV = in_uv + offset; + + // Only sample if within texture bounds + if (sampleUV.x >= 0.0 && sampleUV.x <= 1.0 && sampleUV.y >= 0.0 && sampleUV.y <= 1.0) { + result += texture(in_texture, sampleUV); + totalSamples += 1.0; + } + } + } + + out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/blur.ts b/packages/frontend/src/utility/image-compositor-functions/blur.ts new file mode 100644 index 0000000000..1ab8eee6ba --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/blur.ts @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + offsetX: number; + offsetY: number; + scaleX: number; + scaleY: number; + ellipse: boolean; + angle: number; + radius: number; +}>({ + shader, + 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', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + offsetY: { + label: i18n.ts._imageEffector._fxProps.offset + ' Y', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleX: { + label: i18n.ts._imageEffector._fxProps.scale + ' W', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleY: { + label: i18n.ts._imageEffector._fxProps.scale + ' H', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + ellipse: { + label: i18n.ts._imageEffector._fxProps.circle, + type: 'boolean', + default: false, + }, + angle: { + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + radius: { + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', + default: 3.0, + min: 0.0, + max: 10.0, + step: 0.5, + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/checker.glsl b/packages/frontend/src/utility/image-compositor-functions/checker.glsl new file mode 100644 index 0000000000..09d11c15d2 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/checker.glsl @@ -0,0 +1,43 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_angle; +uniform float u_scale; +uniform vec3 u_color; +uniform float u_opacity; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + float angle = -(u_angle * PI); + vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ); + + float fmodResult = mod(floor(u_scale * rotatedUV.x) + floor(u_scale * rotatedUV.y), 2.0); + float fin = max(sign(fmodResult), 0.0); + + out_color = vec4( + mix(in_color.r, u_color.r, fin * u_opacity), + mix(in_color.g, u_color.g, fin * u_opacity), + mix(in_color.b, u_color.b, fin * u_opacity), + in_color.a + ); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/checker.ts b/packages/frontend/src/utility/image-compositor-functions/checker.ts new file mode 100644 index 0000000000..e0476bb126 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/checker.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + angle: number; + scale: number; + color: [number, number, number]; + opacity: number; +}>({ + shader, + 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, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + scale: { + label: i18n.ts._imageEffector._fxProps.scale, + type: 'number', + default: 3.0, + min: 1.0, + max: 10.0, + step: 0.1, + }, + color: { + label: i18n.ts._imageEffector._fxProps.color, + type: 'color', + default: [1, 1, 1], + }, + opacity: { + label: i18n.ts._imageEffector._fxProps.opacity, + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl new file mode 100644 index 0000000000..60bb4f5318 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl @@ -0,0 +1,49 @@ +#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; +out vec4 out_color; +uniform float u_amount; +uniform float u_start; +uniform bool u_normalize; + +void main() { + int samples = 64; + float r_strength = 1.0; + float g_strength = 1.5; + float b_strength = 2.0; + + vec2 size = vec2(in_resolution.x, in_resolution.y); + + vec4 accumulator = vec4(0.0); + float normalisedValue = length((in_uv - 0.5) * 2.0); + float strength = clamp((normalisedValue - u_start) * (1.0 / (1.0 - u_start)), 0.0, 1.0); + + vec2 vector = (u_normalize ? normalize(in_uv - vec2(0.5)) : in_uv - vec2(0.5)); + vec2 velocity = vector * strength * u_amount; + + vec2 rOffset = -vector * strength * (u_amount * r_strength); + vec2 gOffset = -vector * strength * (u_amount * g_strength); + vec2 bOffset = -vector * strength * (u_amount * b_strength); + + for (int i = 0; i < samples; i++) { + accumulator.r += texture(in_texture, in_uv + rOffset).r; + rOffset -= velocity / float(samples); + + accumulator.g += texture(in_texture, in_uv + gOffset).g; + gOffset -= velocity / float(samples); + + accumulator.b += texture(in_texture, in_uv + bOffset).b; + bOffset -= velocity / float(samples); + } + + out_color = vec4(vec3(accumulator / float(samples)), 1.0); +} + diff --git a/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts new file mode 100644 index 0000000000..5e327dd6ac --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + normalize: boolean; + amount: number; +}>({ + shader, + 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, + type: 'boolean', + default: false, + }, + amount: { + label: i18n.ts._imageEffector._fxProps.amount, + type: 'number', + default: 0.1, + min: 0.0, + max: 1.0, + step: 0.01, + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl new file mode 100644 index 0000000000..2d0c87ce95 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl @@ -0,0 +1,82 @@ +#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 float u_brightness; +uniform float u_contrast; +uniform float u_hue; +uniform float u_lightness; +uniform float u_saturation; +out vec4 out_color; + +// RGB to HSL +vec3 rgb2hsl(vec3 c) { + float maxc = max(max(c.r, c.g), c.b); + float minc = min(min(c.r, c.g), c.b); + float l = (maxc + minc) * 0.5; + float s = 0.0; + float h = 0.0; + if (maxc != minc) { + float d = maxc - minc; + s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc); + if (maxc == c.r) { + h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); + } else if (maxc == c.g) { + h = (c.b - c.r) / d + 2.0; + } else { + h = (c.r - c.g) / d + 4.0; + } + h /= 6.0; + } + return vec3(h, s, l); +} + +// HSL to RGB +float hue2rgb(float p, float q, float t) { + if (t < 0.0) t += 1.0; + if (t > 1.0) t -= 1.0; + if (t < 1.0/6.0) return p + (q - p) * 6.0 * t; + if (t < 1.0/2.0) return q; + if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0; + return p; +} + +vec3 hsl2rgb(vec3 hsl) { + float r, g, b; + float h = hsl.x; + float s = hsl.y; + float l = hsl.z; + if (s == 0.0) { + r = g = b = l; + } else { + float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + float p = 2.0 * l - q; + r = hue2rgb(p, q, h + 1.0/3.0); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1.0/3.0); + } + return vec3(r, g, b); +} + +void main() { + vec4 in_color = texture(in_texture, in_uv); + vec3 color = in_color.rgb; + + color = color * u_brightness; + color += vec3(u_lightness); + color = (color - 0.5) * u_contrast + 0.5; + + vec3 hsl = rgb2hsl(color); + hsl.x = mod(hsl.x + u_hue, 1.0); + hsl.y = clamp(hsl.y * u_saturation, 0.0, 1.0); + + color = hsl2rgb(hsl); + out_color = vec4(color, in_color.a); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts new file mode 100644 index 0000000000..33ca05ace7 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + lightness: number; + contrast: number; + hue: number; + brightness: number; + saturation: number; +}>({ + shader, + 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, + type: 'number', + default: 0, + min: -1, + max: 1, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + contrast: { + label: i18n.ts._imageEffector._fxProps.contrast, + type: 'number', + default: 1, + min: 0, + max: 4, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + hue: { + label: i18n.ts._imageEffector._fxProps.hue, + type: 'number', + default: 0, + min: -1, + max: 1, + step: 0.01, + toViewValue: v => Math.round(v * 180) + '°', + }, + brightness: { + label: i18n.ts._imageEffector._fxProps.brightness, + type: 'number', + default: 1, + min: 0, + max: 4, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + saturation: { + label: i18n.ts._imageEffector._fxProps.saturation, + type: 'number', + default: 1, + min: 0, + max: 4, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl b/packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl new file mode 100644 index 0000000000..bf37f5ab43 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl @@ -0,0 +1,29 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// colorClamp, colorClampAdvanced共通 +// colorClampではmax, minがすべて同じ値となる + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_rMax; +uniform float u_rMin; +uniform float u_gMax; +uniform float u_gMin; +uniform float u_bMax; +uniform float u_bMin; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float r = min(max(in_color.r, u_rMin), u_rMax); + float g = min(max(in_color.g, u_gMin), u_gMax); + float b = min(max(in_color.b, u_bMin), u_bMax); + out_color = vec4(r, g, b, in_color.a); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/colorClamp.ts b/packages/frontend/src/utility/image-compositor-functions/colorClamp.ts new file mode 100644 index 0000000000..d4e7b786d0 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/colorClamp.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + max: number; + min: number; +}>({ + shader, + 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, + type: 'number', + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + min: { + label: i18n.ts._imageEffector._fxProps.min, + type: 'number', + default: -1.0, + min: -1.0, + max: 0.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts b/packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts new file mode 100644 index 0000000000..492524ec06 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + rMax: number; + rMin: number; + gMax: number; + gMin: number; + bMax: number; + bMin: number; +}>({ + shader, + 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})`, + type: 'number', + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + rMin: { + label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.redComponent})`, + type: 'number', + default: -1.0, + min: -1.0, + max: 0.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + gMax: { + label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.greenComponent})`, + type: 'number', + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + gMin: { + label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.greenComponent})`, + type: 'number', + default: -1.0, + min: -1.0, + max: 0.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + bMax: { + label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.blueComponent})`, + type: 'number', + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + bMin: { + label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.blueComponent})`, + type: 'number', + default: -1.0, + min: -1.0, + max: 0.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/distort.glsl b/packages/frontend/src/utility/image-compositor-functions/distort.glsl new file mode 100644 index 0000000000..7e0d1e3252 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/distort.glsl @@ -0,0 +1,30 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_phase; +uniform float u_frequency; +uniform float u_strength; +uniform int u_direction; // 0: vertical, 1: horizontal +out vec4 out_color; + +void main() { + float v = u_direction == 0 ? + sin((HALF_PI + (u_phase * PI) - (u_frequency / 2.0)) + in_uv.y * u_frequency) * u_strength : + sin((HALF_PI + (u_phase * PI) - (u_frequency / 2.0)) + in_uv.x * u_frequency) * u_strength; + vec4 in_color = u_direction == 0 ? + texture(in_texture, vec2(in_uv.x + v, in_uv.y)) : + texture(in_texture, vec2(in_uv.x, in_uv.y + v)); + out_color = in_color; +} diff --git a/packages/frontend/src/utility/image-compositor-functions/distort.ts b/packages/frontend/src/utility/image-compositor-functions/distort.ts new file mode 100644 index 0000000000..bd0fcdf42f --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/distort.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + direction: number; + phase: number; + frequency: number; + strength: number; +}>({ + shader, + 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, + type: 'number:enum', + enum: [ + { value: 0 as const, label: i18n.ts.horizontal }, + { value: 1 as const, label: i18n.ts.vertical }, + ], + default: 1, + }, + phase: { + label: i18n.ts._imageEffector._fxProps.phase, + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + frequency: { + label: i18n.ts._imageEffector._fxProps.frequency, + type: 'number', + default: 30, + min: 0, + max: 100, + step: 0.1, + }, + strength: { + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', + default: 0.05, + min: 0, + max: 1, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/fill.glsl b/packages/frontend/src/utility/image-compositor-functions/fill.glsl new file mode 100644 index 0000000000..f04dc5545a --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/fill.glsl @@ -0,0 +1,50 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform vec2 u_offset; +uniform vec2 u_scale; +uniform bool u_ellipse; +uniform float u_angle; +uniform vec3 u_color; +uniform float u_opacity; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + //float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + //float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + float angle = -(u_angle * PI); + vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ) + u_offset; + + bool isInside = false; + if (u_ellipse) { + vec2 norm = (rotatedUV - u_offset) / u_scale; + isInside = dot(norm, norm) <= 1.0; + } else { + isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; + } + + out_color = isInside ? vec4( + mix(in_color.r, u_color.r, u_opacity), + mix(in_color.g, u_color.g, u_opacity), + mix(in_color.b, u_color.b, u_opacity), + in_color.a + ) : in_color; +} diff --git a/packages/frontend/src/utility/image-compositor-functions/fill.ts b/packages/frontend/src/utility/image-compositor-functions/fill.ts new file mode 100644 index 0000000000..901bdadfe5 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/fill.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + offsetX: number; + offsetY: number; + scaleX: number; + scaleY: number; + ellipse: boolean; + angle: number; + color: [number, number, number]; + opacity: number; +}>({ + shader, + 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', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + offsetY: { + label: i18n.ts._imageEffector._fxProps.offset + ' Y', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleX: { + label: i18n.ts._imageEffector._fxProps.scale + ' W', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleY: { + label: i18n.ts._imageEffector._fxProps.scale + ' H', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + ellipse: { + label: i18n.ts._imageEffector._fxProps.circle, + type: 'boolean', + default: false, + }, + angle: { + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + color: { + label: i18n.ts._imageEffector._fxProps.color, + type: 'color', + default: [1, 1, 1], + }, + opacity: { + label: i18n.ts._imageEffector._fxProps.opacity, + type: 'number', + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/grayscale.glsl b/packages/frontend/src/utility/image-compositor-functions/grayscale.glsl new file mode 100644 index 0000000000..54ca719976 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/grayscale.glsl @@ -0,0 +1,22 @@ +#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; +out vec4 out_color; + +float getBrightness(vec4 color) { + return (color.r + color.g + color.b) / 3.0; +} + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float brightness = getBrightness(in_color); + out_color = vec4(brightness, brightness, brightness, in_color.a); +} 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; diff --git a/packages/frontend/src/utility/image-compositor-functions/invert.glsl b/packages/frontend/src/utility/image-compositor-functions/invert.glsl new file mode 100644 index 0000000000..a2d1574f5b --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/invert.glsl @@ -0,0 +1,23 @@ +#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 bool u_r; +uniform bool u_g; +uniform bool u_b; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + out_color.r = u_r ? 1.0 - in_color.r : in_color.r; + out_color.g = u_g ? 1.0 - in_color.g : in_color.g; + out_color.b = u_b ? 1.0 - in_color.b : in_color.b; + out_color.a = in_color.a; +} diff --git a/packages/frontend/src/utility/image-compositor-functions/invert.ts b/packages/frontend/src/utility/image-compositor-functions/invert.ts new file mode 100644 index 0000000000..f64e68034e --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/invert.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + r: boolean; + g: boolean; + b: boolean; +}>({ + shader, + 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, + type: 'boolean', + default: true, + }, + g: { + label: i18n.ts._imageEffector._fxProps.greenComponent, + type: 'boolean', + default: true, + }, + b: { + label: i18n.ts._imageEffector._fxProps.blueComponent, + type: 'boolean', + default: true, + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/mirror.glsl b/packages/frontend/src/utility/image-compositor-functions/mirror.glsl new file mode 100644 index 0000000000..b27934e9ef --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/mirror.glsl @@ -0,0 +1,26 @@ +#version 300 es +precision mediump float; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform int u_h; +uniform int u_v; +out vec4 out_color; + +void main() { + vec2 uv = in_uv; + if (u_h == -1 && in_uv.x > 0.5) { + uv.x = 1.0 - uv.x; + } + if (u_h == 1 && in_uv.x < 0.5) { + uv.x = 1.0 - uv.x; + } + if (u_v == -1 && in_uv.y > 0.5) { + uv.y = 1.0 - uv.y; + } + if (u_v == 1 && in_uv.y < 0.5) { + uv.y = 1.0 - uv.y; + } + out_color = texture(in_texture, uv); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/mirror.ts b/packages/frontend/src/utility/image-compositor-functions/mirror.ts new file mode 100644 index 0000000000..47d19c0553 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/mirror.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + h: number; + v: number; +}>({ + shader, + 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, + type: 'number:enum', + 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' }, + ], + default: -1, + }, + v: { + label: i18n.ts.vertical, + type: 'number:enum', + 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' }, + ], + default: 0, + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl b/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl new file mode 100644 index 0000000000..4de3f27397 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl @@ -0,0 +1,68 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform vec2 u_offset; +uniform vec2 u_scale; +uniform bool u_ellipse; +uniform float u_angle; +uniform int u_samples; +uniform float u_strength; +out vec4 out_color; + +// TODO: pixelateの中心を画像中心ではなく範囲の中心にする +// TODO: 画像のアスペクト比に関わらず各画素は正方形にする + +void main() { + if (u_strength <= 0.0) { + out_color = texture(in_texture, in_uv); + return; + } + + float angle = -(u_angle * PI); + vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ) + u_offset; + + bool isInside = false; + if (u_ellipse) { + vec2 norm = (rotatedUV - u_offset) / u_scale; + isInside = dot(norm, norm) <= 1.0; + } else { + isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; + } + + if (!isInside) { + out_color = texture(in_texture, in_uv); + return; + } + + float dx = u_strength / 1.0; + float dy = u_strength / 1.0; + vec2 new_uv = vec2( + (dx * (floor((in_uv.x - 0.5 - (dx / 2.0)) / dx) + 0.5)), + (dy * (floor((in_uv.y - 0.5 - (dy / 2.0)) / dy) + 0.5)) + ) + vec2(0.5 + (dx / 2.0), 0.5 + (dy / 2.0)); + + vec4 result = vec4(0.0); + float totalSamples = 0.0; + + // TODO: より多くのサンプリング + result += texture(in_texture, new_uv); + totalSamples += 1.0; + + out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/pixelate.ts b/packages/frontend/src/utility/image-compositor-functions/pixelate.ts new file mode 100644 index 0000000000..249d272e7e --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/pixelate.ts @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + offsetX: number; + offsetY: number; + scaleX: number; + scaleY: number; + ellipse: boolean; + angle: number; + strength: number; +}>({ + shader, + 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', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + offsetY: { + label: i18n.ts._imageEffector._fxProps.offset + ' Y', + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleX: { + label: i18n.ts._imageEffector._fxProps.scale + ' W', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + scaleY: { + label: i18n.ts._imageEffector._fxProps.scale + ' H', + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + ellipse: { + label: i18n.ts._imageEffector._fxProps.circle, + type: 'boolean', + default: false, + }, + angle: { + label: i18n.ts._imageEffector._fxProps.angle, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + strength: { + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', + default: 0.2, + min: 0.0, + max: 0.5, + step: 0.01, + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/polkadot.glsl b/packages/frontend/src/utility/image-compositor-functions/polkadot.glsl new file mode 100644 index 0000000000..39ecad34b5 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/polkadot.glsl @@ -0,0 +1,75 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_angle; +uniform float u_scale; +uniform float u_major_radius; +uniform float u_major_opacity; +uniform float u_minor_divisions; +uniform float u_minor_radius; +uniform float u_minor_opacity; +uniform vec3 u_color; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + float angle = -(u_angle * PI); + vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ); + + float major_modX = mod(rotatedUV.x, (1.0 / u_scale)); + float major_modY = mod(rotatedUV.y, (1.0 / u_scale)); + float major_threshold = ((u_major_radius / 2.0) / u_scale); + if ( + length(vec2(major_modX, major_modY)) < major_threshold || + length(vec2((1.0 / u_scale) - major_modX, major_modY)) < major_threshold || + length(vec2(major_modX, (1.0 / u_scale) - major_modY)) < major_threshold || + length(vec2((1.0 / u_scale) - major_modX, (1.0 / u_scale) - major_modY)) < major_threshold + ) { + out_color = vec4( + mix(in_color.r, u_color.r, u_major_opacity), + mix(in_color.g, u_color.g, u_major_opacity), + mix(in_color.b, u_color.b, u_major_opacity), + in_color.a + ); + return; + } + + float minor_modX = mod(rotatedUV.x, (1.0 / u_scale / u_minor_divisions)); + float minor_modY = mod(rotatedUV.y, (1.0 / u_scale / u_minor_divisions)); + float minor_threshold = ((u_minor_radius / 2.0) / (u_minor_divisions * u_scale)); + if ( + length(vec2(minor_modX, minor_modY)) < minor_threshold || + length(vec2((1.0 / u_scale / u_minor_divisions) - minor_modX, minor_modY)) < minor_threshold || + length(vec2(minor_modX, (1.0 / u_scale / u_minor_divisions) - minor_modY)) < minor_threshold || + length(vec2((1.0 / u_scale / u_minor_divisions) - minor_modX, (1.0 / u_scale / u_minor_divisions) - minor_modY)) < minor_threshold + ) { + out_color = vec4( + mix(in_color.r, u_color.r, u_minor_opacity), + mix(in_color.g, u_color.g, u_minor_opacity), + mix(in_color.b, u_color.b, u_minor_opacity), + in_color.a + ); + return; + } + + out_color = in_color; +} diff --git a/packages/frontend/src/utility/image-compositor-functions/polkadot.ts b/packages/frontend/src/utility/image-compositor-functions/polkadot.ts new file mode 100644 index 0000000000..d94d704be3 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/polkadot.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import shader from './polkadot.glsl'; +import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js'; +import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js'; +import { i18n } from '@/i18n.js'; + +export const fn = defineImageCompositorFunction<{ + angle: number; + scale: number; + majorRadius: number; + majorOpacity: number; + minorDivisions: number; + minorRadius: number; + minorOpacity: number; + color: [number, number, number]; +}>({ + shader, + 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, + type: 'number', + default: 0, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + scale: { + label: i18n.ts._imageEffector._fxProps.scale, + type: 'number', + default: 3.0, + min: 1.0, + max: 10.0, + step: 0.1, + }, + majorRadius: { + label: i18n.ts._watermarkEditor.polkadotMainDotRadius, + type: 'number', + default: 0.1, + min: 0.0, + max: 1.0, + step: 0.01, + }, + majorOpacity: { + label: i18n.ts._watermarkEditor.polkadotMainDotOpacity, + type: 'number', + default: 0.75, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + minorDivisions: { + label: i18n.ts._watermarkEditor.polkadotSubDotDivisions, + type: 'number', + default: 4, + min: 0, + max: 16, + step: 1, + }, + minorRadius: { + label: i18n.ts._watermarkEditor.polkadotSubDotRadius, + type: 'number', + default: 0.25, + min: 0.0, + max: 1.0, + step: 0.01, + }, + minorOpacity: { + label: i18n.ts._watermarkEditor.polkadotSubDotOpacity, + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + color: { + label: i18n.ts._imageEffector._fxProps.color, + type: 'color', + default: [1, 1, 1], + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/stripe.glsl b/packages/frontend/src/utility/image-compositor-functions/stripe.glsl new file mode 100644 index 0000000000..bb18d8fcb8 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/stripe.glsl @@ -0,0 +1,45 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const float PI = 3.141592653589793; +const float TWO_PI = 6.283185307179586; +const float HALF_PI = 1.5707963267948966; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform float u_angle; +uniform float u_frequency; +uniform float u_phase; +uniform float u_threshold; +uniform vec3 u_color; +uniform float u_opacity; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); + float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); + + float angle = -(u_angle * PI); + vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); + vec2 rotatedUV = vec2( + centeredUv.x * cos(angle) - centeredUv.y * sin(angle), + centeredUv.x * sin(angle) + centeredUv.y * cos(angle) + ); + + float phase = u_phase * TWO_PI; + float value = (1.0 + sin((rotatedUV.x * u_frequency - HALF_PI) + phase)) / 2.0; + value = value < u_threshold ? 1.0 : 0.0; + out_color = vec4( + mix(in_color.r, u_color.r, value * u_opacity), + mix(in_color.g, u_color.g, value * u_opacity), + mix(in_color.b, u_color.b, value * u_opacity), + in_color.a + ); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/stripe.ts b/packages/frontend/src/utility/image-compositor-functions/stripe.ts new file mode 100644 index 0000000000..d429a124bc --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/stripe.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import shader from './stripe.glsl'; +import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js'; +import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js'; +import { i18n } from '@/i18n.js'; + +export const fn = defineImageCompositorFunction<{ + angle: number; + frequency: number; + threshold: number; + color: [number, number, number]; + opacity: number; +}>({ + shader, + 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, + type: 'number', + default: 0.5, + min: -1.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 90) + '°', + }, + frequency: { + label: i18n.ts._watermarkEditor.stripeFrequency, + type: 'number', + default: 10.0, + min: 1.0, + max: 30.0, + step: 0.1, + }, + threshold: { + label: i18n.ts._watermarkEditor.stripeWidth, + type: 'number', + default: 0.1, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + color: { + label: i18n.ts._imageEffector._fxProps.color, + type: 'color', + default: [1, 1, 1], + }, + opacity: { + label: i18n.ts._imageEffector._fxProps.opacity, + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/tearing.glsl b/packages/frontend/src/utility/image-compositor-functions/tearing.glsl new file mode 100644 index 0000000000..3fb2fc2cad --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/tearing.glsl @@ -0,0 +1,33 @@ +#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 int u_amount; +uniform float u_shiftStrengths[128]; +uniform float u_shiftOrigins[128]; +uniform float u_shiftHeights[128]; +uniform float u_channelShift; +out vec4 out_color; + +void main() { + float v = 0.0; + + for (int i = 0; i < u_amount; i++) { + if (in_uv.y > (u_shiftOrigins[i] - u_shiftHeights[i]) && in_uv.y < (u_shiftOrigins[i] + u_shiftHeights[i])) { + v += u_shiftStrengths[i]; + } + } + + float r = texture(in_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r; + float g = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).g; + float b = texture(in_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b; + float a = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).a; + out_color = vec4(r, g, b, a); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/tearing.ts b/packages/frontend/src/utility/image-compositor-functions/tearing.ts new file mode 100644 index 0000000000..66c61b7ca8 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/tearing.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import seedrandom from 'seedrandom'; +import shader from './tearing.glsl'; +import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js'; +import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js'; +import { i18n } from '@/i18n.js'; + +export const fn = defineImageCompositorFunction<{ + amount: number; + strength: number; + size: number; + channelShift: number; + seed: number; +}>({ + shader, + 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, + type: 'number', + default: 3, + min: 1, + max: 100, + step: 1, + }, + strength: { + label: i18n.ts._imageEffector._fxProps.strength, + type: 'number', + default: 0.05, + min: -1, + max: 1, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + size: { + label: i18n.ts._imageEffector._fxProps.size, + type: 'number', + default: 0.2, + min: 0, + max: 1, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + channelShift: { + label: i18n.ts._imageEffector._fxProps.glitchChannelShift, + type: 'number', + default: 0.5, + min: 0, + max: 10, + step: 0.01, + toViewValue: v => Math.round(v * 100) + '%', + }, + seed: { + label: i18n.ts._imageEffector._fxProps.seed, + type: 'seed', + default: 100, + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/threshold.glsl b/packages/frontend/src/utility/image-compositor-functions/threshold.glsl new file mode 100644 index 0000000000..5ca8c46c39 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/threshold.glsl @@ -0,0 +1,23 @@ +#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 float u_r; +uniform float u_g; +uniform float u_b; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + float r = in_color.r < u_r ? 0.0 : 1.0; + float g = in_color.g < u_g ? 0.0 : 1.0; + float b = in_color.b < u_b ? 0.0 : 1.0; + out_color = vec4(r, g, b, in_color.a); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/threshold.ts b/packages/frontend/src/utility/image-compositor-functions/threshold.ts new file mode 100644 index 0000000000..83ea788771 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/threshold.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + r: number; + g: number; + b: number; +}>({ + shader, + 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, + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + g: { + label: i18n.ts._imageEffector._fxProps.greenComponent, + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + b: { + label: i18n.ts._imageEffector._fxProps.blueComponent, + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + }, +} satisfies ImageEffectorUiDefinition; diff --git a/packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl b/packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl new file mode 100644 index 0000000000..a0f11fcb5b --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl @@ -0,0 +1,48 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// エイリアスを解決してくれないので、プロジェクトルートからの絶対パスにする必要がある +#include /src/shaders/snoise; + +in vec2 in_uv; +uniform sampler2D in_texture; +uniform vec2 in_resolution; +uniform vec2 u_pos; +uniform float u_frequency; +uniform bool u_thresholdEnabled; +uniform float u_threshold; +uniform float u_maskSize; +uniform bool u_black; +out vec4 out_color; + +void main() { + vec4 in_color = texture(in_texture, in_uv); + vec2 centeredUv = (in_uv - vec2(0.5, 0.5)); + vec2 uv = centeredUv; + + float seed = 1.0; + float time = 0.0; + + vec2 noiseUV = (uv - u_pos) / distance((uv - u_pos), vec2(0.0)); + float noiseX = (noiseUV.x + seed) * u_frequency; + float noiseY = (noiseUV.y + seed) * u_frequency; + float noise = (1.0 + snoise(vec3(noiseX, noiseY, time))) / 2.0; + + float t = noise; + if (u_thresholdEnabled) t = t < u_threshold ? 1.0 : 0.0; + + // TODO: マスクの形自体も揺らぎを与える + float d = distance(uv * vec2(2.0, 2.0), u_pos * vec2(2.0, 2.0)); + float mask = d < u_maskSize ? 0.0 : ((d - u_maskSize) * (1.0 + (u_maskSize * 2.0))); + out_color = vec4( + mix(in_color.r, u_black ? 0.0 : 1.0, t * mask), + mix(in_color.g, u_black ? 0.0 : 1.0, t * mask), + mix(in_color.b, u_black ? 0.0 : 1.0, t * mask), + in_color.a + ); +} diff --git a/packages/frontend/src/utility/image-compositor-functions/zoomLines.ts b/packages/frontend/src/utility/image-compositor-functions/zoomLines.ts new file mode 100644 index 0000000000..f8768e4ec3 --- /dev/null +++ b/packages/frontend/src/utility/image-compositor-functions/zoomLines.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 fn = defineImageCompositorFunction<{ + x: number; + y: number; + frequency: number; + smoothing: boolean; + threshold: number; + maskSize: number; + black: boolean; +}>({ + shader, + 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, + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + }, + y: { + label: i18n.ts._imageEffector._fxProps.centerY, + type: 'number', + default: 0.0, + min: -1.0, + max: 1.0, + step: 0.01, + }, + frequency: { + label: i18n.ts._imageEffector._fxProps.frequency, + type: 'number', + default: 5.0, + min: 0.0, + max: 15.0, + step: 0.1, + }, + smoothing: { + label: i18n.ts._imageEffector._fxProps.zoomLinesSmoothing, + caption: i18n.ts._imageEffector._fxProps.zoomLinesSmoothingDescription, + type: 'boolean', + default: false, + }, + threshold: { + label: i18n.ts._imageEffector._fxProps.zoomLinesThreshold, + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + maskSize: { + label: i18n.ts._imageEffector._fxProps.zoomLinesMaskSize, + type: 'number', + default: 0.5, + min: 0.0, + max: 1.0, + step: 0.01, + }, + black: { + label: i18n.ts._imageEffector._fxProps.zoomLinesBlack, + type: 'boolean', + default: false, + }, + }, +} satisfies ImageEffectorUiDefinition; 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; -export type GetParamType = - T extends NumberEnumParamDef - ? T['enum'][number]['value'] - : ParamTypeToPrimitive[T['type']]; - -export type ParamsRecordTypeToDefRecord = { - [K in keyof PS]: GetParamType; -}; - -export function defineImageEffectorFx(fx: ImageEffectorFx) { - 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: ID; +export type ImageEffectorUiDefinition = ImageCompositorFunction> = { name: string; - shader: string; - uniforms: US; - params: PS, - main: (ctx: { - gl: WebGL2RenderingContext; - program: WebGLProgram; - params: ParamsRecordTypeToDefRecord; - u: Record; - width: number; - height: number; - textures: Record; - }) => void; -}; - -export type ImageEffectorLayer = { - id: string; - fxId: string; - params: Record; + params: Fn extends ImageCompositorFunction ? { + [K in keyof P]: ImageEffectorFxParamDef; + } : never; }; -function getValue(params: Record, k: string): ParamTypeToPrimitive[T] { - return params[k]; -} +type ImageEffectorImageCompositor = ImageCompositor<{ + [K in keyof typeof FXS]: typeof FXS[K]['fn']; +}>; -export class ImageEffector>> { - 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 = new Map(); - private perLayerResultTextures: Map = new Map(); - private perLayerResultFrameBuffers: Map = new Map(); - private nopProgram: WebGLProgram; - private fxs: [...IEX]; - private paramTextures: Map = 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(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['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(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[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((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[]; +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; + readonly uiDefinition: ImageEffectorUiDefinition; +}>; diff --git a/packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl b/packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl deleted file mode 100644 index 84c4ecbed4..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl +++ /dev/null @@ -1,43 +0,0 @@ -#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 int u_amount; -uniform float u_shiftStrengths[128]; -uniform vec2 u_shiftOrigins[128]; -uniform vec2 u_shiftSizes[128]; -uniform float u_channelShift; -out vec4 out_color; - -void main() { - // TODO: ピクセル毎に計算する必要はないのでuniformにする - float aspect_ratio = min(in_resolution.x, in_resolution.y) / max(in_resolution.x, in_resolution.y); - float aspect_ratio_x = in_resolution.x > in_resolution.y ? 1.0 : aspect_ratio; - float aspect_ratio_y = in_resolution.x < in_resolution.y ? 1.0 : aspect_ratio; - - float v = 0.0; - - for (int i = 0; i < u_amount; i++) { - if ( - in_uv.x * aspect_ratio_x > ((u_shiftOrigins[i].x * aspect_ratio_x) - u_shiftSizes[i].x) && - in_uv.x * aspect_ratio_x < ((u_shiftOrigins[i].x * aspect_ratio_x) + u_shiftSizes[i].x) && - in_uv.y * aspect_ratio_y > ((u_shiftOrigins[i].y * aspect_ratio_y) - u_shiftSizes[i].y) && - in_uv.y * aspect_ratio_y < ((u_shiftOrigins[i].y * aspect_ratio_y) + u_shiftSizes[i].y) - ) { - v += u_shiftStrengths[i]; - } - } - - float r = texture(in_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r; - float g = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).g; - float b = texture(in_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b; - float a = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).a; - out_color = vec4(r, g, b, a); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts b/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts deleted file mode 100644 index 355ab4536c..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import seedrandom from 'seedrandom'; -import shader from './blockNoise.glsl'; -import { defineImageEffectorFx } from '../ImageEffector.js'; -import { i18n } from '@/i18n.js'; - -export const FX_blockNoise = defineImageEffectorFx({ - id: 'blockNoise', - name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise, - shader, - uniforms: ['amount', 'channelShift'] as const, - params: { - amount: { - label: i18n.ts._imageEffector._fxProps.amount, - type: 'number', - default: 50, - min: 1, - max: 100, - step: 1, - }, - strength: { - label: i18n.ts._imageEffector._fxProps.strength, - type: 'number', - default: 0.05, - min: -1, - max: 1, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - width: { - label: i18n.ts.width, - type: 'number', - default: 0.05, - min: 0.01, - max: 1, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - height: { - label: i18n.ts.height, - type: 'number', - default: 0.01, - min: 0.01, - max: 1, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - channelShift: { - label: i18n.ts._imageEffector._fxProps.glitchChannelShift, - type: 'number', - default: 0, - min: 0, - max: 10, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - seed: { - label: i18n.ts._imageEffector._fxProps.seed, - type: 'seed', - default: 100, - }, - }, - main: ({ gl, program, u, params }) => { - gl.uniform1i(u.amount, params.amount); - gl.uniform1f(u.channelShift, params.channelShift); - - const margin = 0; - - const rnd = seedrandom(params.seed.toString()); - - for (let i = 0; i < params.amount; i++) { - const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`); - gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin); - - const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`); - gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength); - - const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`); - gl.uniform2f(sizes, params.width, params.height); - } - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/blur.glsl b/packages/frontend/src/utility/image-effector/fxs/blur.glsl deleted file mode 100644 index e591267887..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/blur.glsl +++ /dev/null @@ -1,78 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const float PI = 3.141592653589793; -const float TWO_PI = 6.283185307179586; -const float HALF_PI = 1.5707963267948966; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform vec2 u_offset; -uniform vec2 u_scale; -uniform bool u_ellipse; -uniform float u_angle; -uniform float u_radius; -uniform int u_samples; -out vec4 out_color; - -void main() { - float angle = -(u_angle * PI); - vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ) + u_offset; - - bool isInside = false; - if (u_ellipse) { - vec2 norm = (rotatedUV - u_offset) / u_scale; - isInside = dot(norm, norm) <= 1.0; - } else { - isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; - } - - if (!isInside) { - out_color = texture(in_texture, in_uv); - return; - } - - vec4 result = vec4(0.0); - float totalSamples = 0.0; - - // Make blur radius resolution-independent by using a percentage of image size - // This ensures consistent visual blur regardless of image resolution - float referenceSize = min(in_resolution.x, in_resolution.y); - float normalizedRadius = u_radius / 100.0; // Convert radius to percentage (0-15 -> 0-0.15) - vec2 blurOffset = vec2(normalizedRadius) / in_resolution * referenceSize; - - // Calculate how many samples to take in each direction - // This determines the grid density, not the blur extent - int sampleRadius = int(sqrt(float(u_samples)) / 2.0); - - // Sample in a grid pattern within the specified radius - for (int x = -sampleRadius; x <= sampleRadius; x++) { - for (int y = -sampleRadius; y <= sampleRadius; y++) { - // Normalize the grid position to [-1, 1] range - float normalizedX = float(x) / float(sampleRadius); - float normalizedY = float(y) / float(sampleRadius); - - // Scale by radius to get the actual sampling offset - vec2 offset = vec2(normalizedX, normalizedY) * blurOffset; - vec2 sampleUV = in_uv + offset; - - // Only sample if within texture bounds - if (sampleUV.x >= 0.0 && sampleUV.x <= 1.0 && sampleUV.y >= 0.0 && sampleUV.y <= 1.0) { - result += texture(in_texture, sampleUV); - totalSamples += 1.0; - } - } - } - - out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/blur.ts b/packages/frontend/src/utility/image-effector/fxs/blur.ts deleted file mode 100644 index 40f51fa646..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/blur.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './blur.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_blur = defineImageEffectorFx({ - id: 'blur', - name: i18n.ts._imageEffector._fxs.blur, - shader, - uniforms: ['offset', 'scale', 'ellipse', 'angle', 'radius', 'samples'] as const, - params: { - offsetX: { - label: i18n.ts._imageEffector._fxProps.offset + ' X', - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - offsetY: { - label: i18n.ts._imageEffector._fxProps.offset + ' Y', - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - scaleX: { - label: i18n.ts._imageEffector._fxProps.scale + ' W', - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - scaleY: { - label: i18n.ts._imageEffector._fxProps.scale + ' H', - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - ellipse: { - label: i18n.ts._imageEffector._fxProps.circle, - type: 'boolean', - default: false, - }, - angle: { - label: i18n.ts._imageEffector._fxProps.angle, - type: 'number', - default: 0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 90) + '°', - }, - radius: { - label: i18n.ts._imageEffector._fxProps.strength, - type: 'number', - default: 3.0, - min: 0.0, - max: 10.0, - 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); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.glsl b/packages/frontend/src/utility/image-effector/fxs/checker.glsl deleted file mode 100644 index 09d11c15d2..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/checker.glsl +++ /dev/null @@ -1,43 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const float PI = 3.141592653589793; -const float TWO_PI = 6.283185307179586; -const float HALF_PI = 1.5707963267948966; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform float u_angle; -uniform float u_scale; -uniform vec3 u_color; -uniform float u_opacity; -out vec4 out_color; - -void main() { - vec4 in_color = texture(in_texture, in_uv); - float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); - float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); - - float angle = -(u_angle * PI); - vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ); - - float fmodResult = mod(floor(u_scale * rotatedUV.x) + floor(u_scale * rotatedUV.y), 2.0); - float fin = max(sign(fmodResult), 0.0); - - out_color = vec4( - mix(in_color.r, u_color.r, fin * u_opacity), - mix(in_color.g, u_color.g, fin * u_opacity), - mix(in_color.b, u_color.b, fin * u_opacity), - in_color.a - ); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.ts b/packages/frontend/src/utility/image-effector/fxs/checker.ts deleted file mode 100644 index 7d1938eeb7..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/checker.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './checker.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_checker = defineImageEffectorFx({ - id: 'checker', - name: i18n.ts._imageEffector._fxs.checker, - shader, - uniforms: ['angle', 'scale', 'color', 'opacity'] as const, - params: { - angle: { - label: i18n.ts._imageEffector._fxProps.angle, - type: 'number', - default: 0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 90) + '°', - }, - scale: { - label: i18n.ts._imageEffector._fxProps.scale, - type: 'number', - default: 3.0, - min: 1.0, - max: 10.0, - step: 0.1, - }, - color: { - label: i18n.ts._imageEffector._fxProps.color, - type: 'color', - default: [1, 1, 1], - }, - opacity: { - label: i18n.ts._imageEffector._fxProps.opacity, - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.angle, params.angle / 2); - gl.uniform1f(u.scale, params.scale * params.scale); - gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]); - gl.uniform1f(u.opacity, params.opacity); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl b/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl deleted file mode 100644 index 60bb4f5318..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl +++ /dev/null @@ -1,49 +0,0 @@ -#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; -out vec4 out_color; -uniform float u_amount; -uniform float u_start; -uniform bool u_normalize; - -void main() { - int samples = 64; - float r_strength = 1.0; - float g_strength = 1.5; - float b_strength = 2.0; - - vec2 size = vec2(in_resolution.x, in_resolution.y); - - vec4 accumulator = vec4(0.0); - float normalisedValue = length((in_uv - 0.5) * 2.0); - float strength = clamp((normalisedValue - u_start) * (1.0 / (1.0 - u_start)), 0.0, 1.0); - - vec2 vector = (u_normalize ? normalize(in_uv - vec2(0.5)) : in_uv - vec2(0.5)); - vec2 velocity = vector * strength * u_amount; - - vec2 rOffset = -vector * strength * (u_amount * r_strength); - vec2 gOffset = -vector * strength * (u_amount * g_strength); - vec2 bOffset = -vector * strength * (u_amount * b_strength); - - for (int i = 0; i < samples; i++) { - accumulator.r += texture(in_texture, in_uv + rOffset).r; - rOffset -= velocity / float(samples); - - accumulator.g += texture(in_texture, in_uv + gOffset).g; - gOffset -= velocity / float(samples); - - accumulator.b += texture(in_texture, in_uv + bOffset).b; - bOffset -= velocity / float(samples); - } - - out_color = vec4(vec3(accumulator / float(samples)), 1.0); -} - diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts b/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts deleted file mode 100644 index ed4d134251..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './chromaticAberration.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_chromaticAberration = defineImageEffectorFx({ - id: 'chromaticAberration', - name: i18n.ts._imageEffector._fxs.chromaticAberration, - shader, - uniforms: ['amount', 'start', 'normalize'] as const, - params: { - normalize: { - label: i18n.ts._imageEffector._fxProps.normalize, - type: 'boolean', - default: false, - }, - amount: { - label: i18n.ts._imageEffector._fxProps.amount, - type: 'number', - default: 0.1, - min: 0.0, - max: 1.0, - step: 0.01, - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.amount, params.amount); - gl.uniform1i(u.normalize, params.normalize ? 1 : 0); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl b/packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl deleted file mode 100644 index 2d0c87ce95..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl +++ /dev/null @@ -1,82 +0,0 @@ -#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 float u_brightness; -uniform float u_contrast; -uniform float u_hue; -uniform float u_lightness; -uniform float u_saturation; -out vec4 out_color; - -// RGB to HSL -vec3 rgb2hsl(vec3 c) { - float maxc = max(max(c.r, c.g), c.b); - float minc = min(min(c.r, c.g), c.b); - float l = (maxc + minc) * 0.5; - float s = 0.0; - float h = 0.0; - if (maxc != minc) { - float d = maxc - minc; - s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc); - if (maxc == c.r) { - h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); - } else if (maxc == c.g) { - h = (c.b - c.r) / d + 2.0; - } else { - h = (c.r - c.g) / d + 4.0; - } - h /= 6.0; - } - return vec3(h, s, l); -} - -// HSL to RGB -float hue2rgb(float p, float q, float t) { - if (t < 0.0) t += 1.0; - if (t > 1.0) t -= 1.0; - if (t < 1.0/6.0) return p + (q - p) * 6.0 * t; - if (t < 1.0/2.0) return q; - if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0; - return p; -} - -vec3 hsl2rgb(vec3 hsl) { - float r, g, b; - float h = hsl.x; - float s = hsl.y; - float l = hsl.z; - if (s == 0.0) { - r = g = b = l; - } else { - float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; - float p = 2.0 * l - q; - r = hue2rgb(p, q, h + 1.0/3.0); - g = hue2rgb(p, q, h); - b = hue2rgb(p, q, h - 1.0/3.0); - } - return vec3(r, g, b); -} - -void main() { - vec4 in_color = texture(in_texture, in_uv); - vec3 color = in_color.rgb; - - color = color * u_brightness; - color += vec3(u_lightness); - color = (color - 0.5) * u_contrast + 0.5; - - vec3 hsl = rgb2hsl(color); - hsl.x = mod(hsl.x + u_hue, 1.0); - hsl.y = clamp(hsl.y * u_saturation, 0.0, 1.0); - - color = hsl2rgb(hsl); - out_color = vec4(color, in_color.a); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts b/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts deleted file mode 100644 index 989ca79a2c..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './colorAdjust.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_colorAdjust = defineImageEffectorFx({ - id: 'colorAdjust', - name: i18n.ts._imageEffector._fxs.colorAdjust, - shader, - uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const, - params: { - lightness: { - label: i18n.ts._imageEffector._fxProps.lightness, - type: 'number', - default: 0, - min: -1, - max: 1, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - contrast: { - label: i18n.ts._imageEffector._fxProps.contrast, - type: 'number', - default: 1, - min: 0, - max: 4, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - hue: { - label: i18n.ts._imageEffector._fxProps.hue, - type: 'number', - default: 0, - min: -1, - max: 1, - step: 0.01, - toViewValue: v => Math.round(v * 180) + '°', - }, - brightness: { - label: i18n.ts._imageEffector._fxProps.brightness, - type: 'number', - default: 1, - min: 0, - max: 4, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - saturation: { - label: i18n.ts._imageEffector._fxProps.saturation, - type: 'number', - default: 1, - min: 0, - max: 4, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.brightness, params.brightness); - gl.uniform1f(u.contrast, params.contrast); - gl.uniform1f(u.hue, params.hue / 2); - gl.uniform1f(u.lightness, params.lightness); - gl.uniform1f(u.saturation, params.saturation); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl b/packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl deleted file mode 100644 index bf37f5ab43..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl +++ /dev/null @@ -1,29 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// colorClamp, colorClampAdvanced共通 -// colorClampではmax, minがすべて同じ値となる - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform float u_rMax; -uniform float u_rMin; -uniform float u_gMax; -uniform float u_gMin; -uniform float u_bMax; -uniform float u_bMin; -out vec4 out_color; - -void main() { - vec4 in_color = texture(in_texture, in_uv); - float r = min(max(in_color.r, u_rMin), u_rMax); - float g = min(max(in_color.g, u_gMin), u_gMax); - float b = min(max(in_color.b, u_bMin), u_bMax); - out_color = vec4(r, g, b, in_color.a); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts b/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts deleted file mode 100644 index f3513011fa..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './colorClamp.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_colorClamp = defineImageEffectorFx({ - id: 'colorClamp', - name: i18n.ts._imageEffector._fxs.colorClamp, - shader, - uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const, - params: { - max: { - label: i18n.ts._imageEffector._fxProps.max, - type: 'number', - default: 1.0, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - min: { - label: i18n.ts._imageEffector._fxProps.min, - type: 'number', - default: -1.0, - min: -1.0, - max: 0.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.rMax, params.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); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts b/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts deleted file mode 100644 index 397e16c1ba..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './colorClamp.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_colorClampAdvanced = defineImageEffectorFx({ - id: 'colorClampAdvanced', - name: i18n.ts._imageEffector._fxs.colorClampAdvanced, - shader, - uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const, - params: { - rMax: { - label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.redComponent})`, - type: 'number', - default: 1.0, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - rMin: { - label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.redComponent})`, - type: 'number', - default: -1.0, - min: -1.0, - max: 0.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - gMax: { - label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.greenComponent})`, - type: 'number', - default: 1.0, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - gMin: { - label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.greenComponent})`, - type: 'number', - default: -1.0, - min: -1.0, - max: 0.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - bMax: { - label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.blueComponent})`, - type: 'number', - default: 1.0, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - bMin: { - label: `${i18n.ts._imageEffector._fxProps.min} (${i18n.ts._imageEffector._fxProps.blueComponent})`, - type: 'number', - default: -1.0, - min: -1.0, - max: 0.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.rMax, params.rMax); - gl.uniform1f(u.rMin, 1.0 + params.rMin); - gl.uniform1f(u.gMax, params.gMax); - gl.uniform1f(u.gMin, 1.0 + params.gMin); - gl.uniform1f(u.bMax, params.bMax); - gl.uniform1f(u.bMin, 1.0 + params.bMin); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.glsl b/packages/frontend/src/utility/image-effector/fxs/distort.glsl deleted file mode 100644 index 7e0d1e3252..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/distort.glsl +++ /dev/null @@ -1,30 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const float PI = 3.141592653589793; -const float TWO_PI = 6.283185307179586; -const float HALF_PI = 1.5707963267948966; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform float u_phase; -uniform float u_frequency; -uniform float u_strength; -uniform int u_direction; // 0: vertical, 1: horizontal -out vec4 out_color; - -void main() { - float v = u_direction == 0 ? - sin((HALF_PI + (u_phase * PI) - (u_frequency / 2.0)) + in_uv.y * u_frequency) * u_strength : - sin((HALF_PI + (u_phase * PI) - (u_frequency / 2.0)) + in_uv.x * u_frequency) * u_strength; - vec4 in_color = u_direction == 0 ? - texture(in_texture, vec2(in_uv.x + v, in_uv.y)) : - texture(in_texture, vec2(in_uv.x, in_uv.y + v)); - out_color = in_color; -} diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.ts b/packages/frontend/src/utility/image-effector/fxs/distort.ts deleted file mode 100644 index 3ea93a0266..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/distort.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './distort.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_distort = defineImageEffectorFx({ - id: 'distort', - name: i18n.ts._imageEffector._fxs.distort, - shader, - uniforms: ['phase', 'frequency', 'strength', 'direction'] as const, - params: { - direction: { - label: i18n.ts._imageEffector._fxProps.direction, - type: 'number:enum', - enum: [ - { value: 0 as const, label: i18n.ts.horizontal }, - { value: 1 as const, label: i18n.ts.vertical }, - ], - default: 1, - }, - phase: { - label: i18n.ts._imageEffector._fxProps.phase, - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - frequency: { - label: i18n.ts._imageEffector._fxProps.frequency, - type: 'number', - default: 30, - min: 0, - max: 100, - step: 0.1, - }, - strength: { - label: i18n.ts._imageEffector._fxProps.strength, - type: 'number', - default: 0.05, - min: 0, - max: 1, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.phase, params.phase); - gl.uniform1f(u.frequency, params.frequency); - gl.uniform1f(u.strength, params.strength); - gl.uniform1i(u.direction, params.direction); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/fill.glsl b/packages/frontend/src/utility/image-effector/fxs/fill.glsl deleted file mode 100644 index f04dc5545a..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/fill.glsl +++ /dev/null @@ -1,50 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const float PI = 3.141592653589793; -const float TWO_PI = 6.283185307179586; -const float HALF_PI = 1.5707963267948966; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform vec2 u_offset; -uniform vec2 u_scale; -uniform bool u_ellipse; -uniform float u_angle; -uniform vec3 u_color; -uniform float u_opacity; -out vec4 out_color; - -void main() { - vec4 in_color = texture(in_texture, in_uv); - //float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); - //float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); - - float angle = -(u_angle * PI); - vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ) + u_offset; - - bool isInside = false; - if (u_ellipse) { - vec2 norm = (rotatedUV - u_offset) / u_scale; - isInside = dot(norm, norm) <= 1.0; - } else { - isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; - } - - out_color = isInside ? vec4( - mix(in_color.r, u_color.r, u_opacity), - mix(in_color.g, u_color.g, u_opacity), - mix(in_color.b, u_color.b, u_opacity), - in_color.a - ) : in_color; -} diff --git a/packages/frontend/src/utility/image-effector/fxs/fill.ts b/packages/frontend/src/utility/image-effector/fxs/fill.ts deleted file mode 100644 index 772cd76cf7..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/fill.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './fill.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_fill = defineImageEffectorFx({ - id: 'fill', - name: i18n.ts._imageEffector._fxs.fill, - shader, - uniforms: ['offset', 'scale', 'ellipse', 'angle', 'color', 'opacity'] as const, - params: { - offsetX: { - label: i18n.ts._imageEffector._fxProps.offset + ' X', - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - offsetY: { - label: i18n.ts._imageEffector._fxProps.offset + ' Y', - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - scaleX: { - label: i18n.ts._imageEffector._fxProps.scale + ' W', - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - scaleY: { - label: i18n.ts._imageEffector._fxProps.scale + ' H', - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - ellipse: { - label: i18n.ts._imageEffector._fxProps.circle, - type: 'boolean', - default: false, - }, - angle: { - label: i18n.ts._imageEffector._fxProps.angle, - type: 'number', - default: 0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 90) + '°', - }, - color: { - label: i18n.ts._imageEffector._fxProps.color, - type: 'color', - default: [1, 1, 1], - }, - opacity: { - label: i18n.ts._imageEffector._fxProps.opacity, - type: 'number', - default: 1.0, - min: 0.0, - max: 1.0, - step: 0.01, - 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); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/grayscale.glsl b/packages/frontend/src/utility/image-effector/fxs/grayscale.glsl deleted file mode 100644 index 54ca719976..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/grayscale.glsl +++ /dev/null @@ -1,22 +0,0 @@ -#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; -out vec4 out_color; - -float getBrightness(vec4 color) { - return (color.r + color.g + color.b) / 3.0; -} - -void main() { - vec4 in_color = texture(in_texture, in_uv); - float brightness = getBrightness(in_color); - out_color = vec4(brightness, brightness, brightness, in_color.a); -} 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-effector/fxs/invert.glsl b/packages/frontend/src/utility/image-effector/fxs/invert.glsl deleted file mode 100644 index a2d1574f5b..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/invert.glsl +++ /dev/null @@ -1,23 +0,0 @@ -#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 bool u_r; -uniform bool u_g; -uniform bool u_b; -out vec4 out_color; - -void main() { - vec4 in_color = texture(in_texture, in_uv); - out_color.r = u_r ? 1.0 - in_color.r : in_color.r; - out_color.g = u_g ? 1.0 - in_color.g : in_color.g; - out_color.b = u_b ? 1.0 - in_color.b : in_color.b; - out_color.a = in_color.a; -} diff --git a/packages/frontend/src/utility/image-effector/fxs/invert.ts b/packages/frontend/src/utility/image-effector/fxs/invert.ts deleted file mode 100644 index 9417047931..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/invert.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './invert.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_invert = defineImageEffectorFx({ - id: 'invert', - name: i18n.ts._imageEffector._fxs.invert, - shader, - uniforms: ['r', 'g', 'b'] as const, - params: { - r: { - label: i18n.ts._imageEffector._fxProps.redComponent, - type: 'boolean', - default: true, - }, - g: { - label: i18n.ts._imageEffector._fxProps.greenComponent, - type: 'boolean', - default: true, - }, - b: { - label: i18n.ts._imageEffector._fxProps.blueComponent, - type: 'boolean', - default: true, - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1i(u.r, params.r ? 1 : 0); - gl.uniform1i(u.g, params.g ? 1 : 0); - gl.uniform1i(u.b, params.b ? 1 : 0); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.glsl b/packages/frontend/src/utility/image-effector/fxs/mirror.glsl deleted file mode 100644 index b27934e9ef..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/mirror.glsl +++ /dev/null @@ -1,26 +0,0 @@ -#version 300 es -precision mediump float; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform int u_h; -uniform int u_v; -out vec4 out_color; - -void main() { - vec2 uv = in_uv; - if (u_h == -1 && in_uv.x > 0.5) { - uv.x = 1.0 - uv.x; - } - if (u_h == 1 && in_uv.x < 0.5) { - uv.x = 1.0 - uv.x; - } - if (u_v == -1 && in_uv.y > 0.5) { - uv.y = 1.0 - uv.y; - } - if (u_v == 1 && in_uv.y < 0.5) { - uv.y = 1.0 - uv.y; - } - out_color = texture(in_texture, uv); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.ts b/packages/frontend/src/utility/image-effector/fxs/mirror.ts deleted file mode 100644 index 6515454ead..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/mirror.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './mirror.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_mirror = defineImageEffectorFx({ - id: 'mirror', - name: i18n.ts._imageEffector._fxs.mirror, - shader, - uniforms: ['h', 'v'] as const, - params: { - h: { - label: i18n.ts.horizontal, - type: 'number:enum', - 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' } - ], - default: -1, - }, - v: { - label: i18n.ts.vertical, - type: 'number:enum', - 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' } - ], - default: 0, - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1i(u.h, params.h); - gl.uniform1i(u.v, params.v); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/pixelate.glsl b/packages/frontend/src/utility/image-effector/fxs/pixelate.glsl deleted file mode 100644 index 4de3f27397..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/pixelate.glsl +++ /dev/null @@ -1,68 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const float PI = 3.141592653589793; -const float TWO_PI = 6.283185307179586; -const float HALF_PI = 1.5707963267948966; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform vec2 u_offset; -uniform vec2 u_scale; -uniform bool u_ellipse; -uniform float u_angle; -uniform int u_samples; -uniform float u_strength; -out vec4 out_color; - -// TODO: pixelateの中心を画像中心ではなく範囲の中心にする -// TODO: 画像のアスペクト比に関わらず各画素は正方形にする - -void main() { - if (u_strength <= 0.0) { - out_color = texture(in_texture, in_uv); - return; - } - - float angle = -(u_angle * PI); - vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ) + u_offset; - - bool isInside = false; - if (u_ellipse) { - vec2 norm = (rotatedUV - u_offset) / u_scale; - isInside = dot(norm, norm) <= 1.0; - } else { - isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y; - } - - if (!isInside) { - out_color = texture(in_texture, in_uv); - return; - } - - float dx = u_strength / 1.0; - float dy = u_strength / 1.0; - vec2 new_uv = vec2( - (dx * (floor((in_uv.x - 0.5 - (dx / 2.0)) / dx) + 0.5)), - (dy * (floor((in_uv.y - 0.5 - (dy / 2.0)) / dy) + 0.5)) - ) + vec2(0.5 + (dx / 2.0), 0.5 + (dy / 2.0)); - - vec4 result = vec4(0.0); - float totalSamples = 0.0; - - // TODO: より多くのサンプリング - result += texture(in_texture, new_uv); - totalSamples += 1.0; - - out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/pixelate.ts b/packages/frontend/src/utility/image-effector/fxs/pixelate.ts deleted file mode 100644 index e3eef49b23..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/pixelate.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './pixelate.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_pixelate = defineImageEffectorFx({ - id: 'pixelate', - name: i18n.ts._imageEffector._fxs.pixelate, - shader, - uniforms: ['offset', 'scale', 'ellipse', 'angle', 'strength', 'samples'] as const, - params: { - offsetX: { - label: i18n.ts._imageEffector._fxProps.offset + ' X', - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - offsetY: { - label: i18n.ts._imageEffector._fxProps.offset + ' Y', - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - scaleX: { - label: i18n.ts._imageEffector._fxProps.scale + ' W', - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - scaleY: { - label: i18n.ts._imageEffector._fxProps.scale + ' H', - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - ellipse: { - label: i18n.ts._imageEffector._fxProps.circle, - type: 'boolean', - default: false, - }, - angle: { - label: i18n.ts._imageEffector._fxProps.angle, - type: 'number', - default: 0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 90) + '°', - }, - strength: { - label: i18n.ts._imageEffector._fxProps.strength, - type: 'number', - default: 0.2, - min: 0.0, - max: 0.5, - step: 0.01, - }, - }, - main: ({ gl, u, params }) => { - gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2); - gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2); - gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0); - gl.uniform1f(u.angle, params.angle / 2); - gl.uniform1f(u.strength, params.strength * params.strength); - gl.uniform1i(u.samples, 256); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.glsl b/packages/frontend/src/utility/image-effector/fxs/polkadot.glsl deleted file mode 100644 index 39ecad34b5..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/polkadot.glsl +++ /dev/null @@ -1,75 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const float PI = 3.141592653589793; -const float TWO_PI = 6.283185307179586; -const float HALF_PI = 1.5707963267948966; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform float u_angle; -uniform float u_scale; -uniform float u_major_radius; -uniform float u_major_opacity; -uniform float u_minor_divisions; -uniform float u_minor_radius; -uniform float u_minor_opacity; -uniform vec3 u_color; -out vec4 out_color; - -void main() { - vec4 in_color = texture(in_texture, in_uv); - float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); - float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); - - float angle = -(u_angle * PI); - vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ); - - float major_modX = mod(rotatedUV.x, (1.0 / u_scale)); - float major_modY = mod(rotatedUV.y, (1.0 / u_scale)); - float major_threshold = ((u_major_radius / 2.0) / u_scale); - if ( - length(vec2(major_modX, major_modY)) < major_threshold || - length(vec2((1.0 / u_scale) - major_modX, major_modY)) < major_threshold || - length(vec2(major_modX, (1.0 / u_scale) - major_modY)) < major_threshold || - length(vec2((1.0 / u_scale) - major_modX, (1.0 / u_scale) - major_modY)) < major_threshold - ) { - out_color = vec4( - mix(in_color.r, u_color.r, u_major_opacity), - mix(in_color.g, u_color.g, u_major_opacity), - mix(in_color.b, u_color.b, u_major_opacity), - in_color.a - ); - return; - } - - float minor_modX = mod(rotatedUV.x, (1.0 / u_scale / u_minor_divisions)); - float minor_modY = mod(rotatedUV.y, (1.0 / u_scale / u_minor_divisions)); - float minor_threshold = ((u_minor_radius / 2.0) / (u_minor_divisions * u_scale)); - if ( - length(vec2(minor_modX, minor_modY)) < minor_threshold || - length(vec2((1.0 / u_scale / u_minor_divisions) - minor_modX, minor_modY)) < minor_threshold || - length(vec2(minor_modX, (1.0 / u_scale / u_minor_divisions) - minor_modY)) < minor_threshold || - length(vec2((1.0 / u_scale / u_minor_divisions) - minor_modX, (1.0 / u_scale / u_minor_divisions) - minor_modY)) < minor_threshold - ) { - out_color = vec4( - mix(in_color.r, u_color.r, u_minor_opacity), - mix(in_color.g, u_color.g, u_minor_opacity), - mix(in_color.b, u_color.b, u_minor_opacity), - in_color.a - ); - return; - } - - out_color = in_color; -} diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts b/packages/frontend/src/utility/image-effector/fxs/polkadot.ts deleted file mode 100644 index 521e08cc7b..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './polkadot.glsl'; -import { i18n } from '@/i18n.js'; - -// Primarily used for watermark -export const FX_polkadot = defineImageEffectorFx({ - id: 'polkadot', - name: i18n.ts._imageEffector._fxs.polkadot, - shader, - uniforms: ['angle', 'scale', 'major_radius', 'major_opacity', 'minor_divisions', 'minor_radius', 'minor_opacity', 'color'] as const, - params: { - angle: { - label: i18n.ts._imageEffector._fxProps.angle, - type: 'number', - default: 0, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 90) + '°', - }, - scale: { - label: i18n.ts._imageEffector._fxProps.scale, - type: 'number', - default: 3.0, - min: 1.0, - max: 10.0, - step: 0.1, - }, - majorRadius: { - label: i18n.ts._watermarkEditor.polkadotMainDotRadius, - type: 'number', - default: 0.1, - min: 0.0, - max: 1.0, - step: 0.01, - }, - majorOpacity: { - label: i18n.ts._watermarkEditor.polkadotMainDotOpacity, - type: 'number', - default: 0.75, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - minorDivisions: { - label: i18n.ts._watermarkEditor.polkadotSubDotDivisions, - type: 'number', - default: 4, - min: 0, - max: 16, - step: 1, - }, - minorRadius: { - label: i18n.ts._watermarkEditor.polkadotSubDotRadius, - type: 'number', - default: 0.25, - min: 0.0, - max: 1.0, - step: 0.01, - }, - minorOpacity: { - label: i18n.ts._watermarkEditor.polkadotSubDotOpacity, - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - color: { - label: i18n.ts._imageEffector._fxProps.color, - type: 'color', - default: [1, 1, 1], - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.angle, params.angle / 2); - gl.uniform1f(u.scale, params.scale * params.scale); - gl.uniform1f(u.major_radius, params.majorRadius); - gl.uniform1f(u.major_opacity, params.majorOpacity); - gl.uniform1f(u.minor_divisions, params.minorDivisions); - gl.uniform1f(u.minor_radius, params.minorRadius); - gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]); - gl.uniform1f(u.minor_opacity, params.minorOpacity); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.glsl b/packages/frontend/src/utility/image-effector/fxs/stripe.glsl deleted file mode 100644 index bb18d8fcb8..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/stripe.glsl +++ /dev/null @@ -1,45 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const float PI = 3.141592653589793; -const float TWO_PI = 6.283185307179586; -const float HALF_PI = 1.5707963267948966; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform float u_angle; -uniform float u_frequency; -uniform float u_phase; -uniform float u_threshold; -uniform vec3 u_color; -uniform float u_opacity; -out vec4 out_color; - -void main() { - vec4 in_color = texture(in_texture, in_uv); - float x_ratio = max(in_resolution.x / in_resolution.y, 1.0); - float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); - - float angle = -(u_angle * PI); - vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio); - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ); - - float phase = u_phase * TWO_PI; - float value = (1.0 + sin((rotatedUV.x * u_frequency - HALF_PI) + phase)) / 2.0; - value = value < u_threshold ? 1.0 : 0.0; - out_color = vec4( - mix(in_color.r, u_color.r, value * u_opacity), - mix(in_color.g, u_color.g, value * u_opacity), - mix(in_color.b, u_color.b, value * u_opacity), - in_color.a - ); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.ts b/packages/frontend/src/utility/image-effector/fxs/stripe.ts deleted file mode 100644 index 3a6ecf970c..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/stripe.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './stripe.glsl'; -import { i18n } from '@/i18n.js'; - -// Primarily used for watermark -export const FX_stripe = defineImageEffectorFx({ - id: 'stripe', - name: i18n.ts._imageEffector._fxs.stripe, - shader, - uniforms: ['angle', 'frequency', 'phase', 'threshold', 'color', 'opacity'] as const, - params: { - angle: { - label: i18n.ts._imageEffector._fxProps.angle, - type: 'number', - default: 0.5, - min: -1.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 90) + '°', - }, - frequency: { - label: i18n.ts._watermarkEditor.stripeFrequency, - type: 'number', - default: 10.0, - min: 1.0, - max: 30.0, - step: 0.1, - }, - threshold: { - label: i18n.ts._watermarkEditor.stripeWidth, - type: 'number', - default: 0.1, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - color: { - label: i18n.ts._imageEffector._fxProps.color, - type: 'color', - default: [1, 1, 1], - }, - opacity: { - label: i18n.ts._imageEffector._fxProps.opacity, - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.angle, params.angle / 2); - gl.uniform1f(u.frequency, params.frequency * params.frequency); - gl.uniform1f(u.phase, 0.0); - gl.uniform1f(u.threshold, params.threshold); - gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]); - gl.uniform1f(u.opacity, params.opacity); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/tearing.glsl b/packages/frontend/src/utility/image-effector/fxs/tearing.glsl deleted file mode 100644 index 3fb2fc2cad..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/tearing.glsl +++ /dev/null @@ -1,33 +0,0 @@ -#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 int u_amount; -uniform float u_shiftStrengths[128]; -uniform float u_shiftOrigins[128]; -uniform float u_shiftHeights[128]; -uniform float u_channelShift; -out vec4 out_color; - -void main() { - float v = 0.0; - - for (int i = 0; i < u_amount; i++) { - if (in_uv.y > (u_shiftOrigins[i] - u_shiftHeights[i]) && in_uv.y < (u_shiftOrigins[i] + u_shiftHeights[i])) { - v += u_shiftStrengths[i]; - } - } - - float r = texture(in_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r; - float g = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).g; - float b = texture(in_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b; - float a = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).a; - out_color = vec4(r, g, b, a); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/tearing.ts b/packages/frontend/src/utility/image-effector/fxs/tearing.ts deleted file mode 100644 index 453b16bb19..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/tearing.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import seedrandom from 'seedrandom'; -import shader from './tearing.glsl'; -import { defineImageEffectorFx } from '../ImageEffector.js'; -import { i18n } from '@/i18n.js'; - -export const FX_tearing = defineImageEffectorFx({ - id: 'tearing', - name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing, - shader, - uniforms: ['amount', 'channelShift'] as const, - params: { - amount: { - label: i18n.ts._imageEffector._fxProps.amount, - type: 'number', - default: 3, - min: 1, - max: 100, - step: 1, - }, - strength: { - label: i18n.ts._imageEffector._fxProps.strength, - type: 'number', - default: 0.05, - min: -1, - max: 1, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - size: { - label: i18n.ts._imageEffector._fxProps.size, - type: 'number', - default: 0.2, - min: 0, - max: 1, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - channelShift: { - label: i18n.ts._imageEffector._fxProps.glitchChannelShift, - type: 'number', - default: 0.5, - min: 0, - max: 10, - step: 0.01, - toViewValue: v => Math.round(v * 100) + '%', - }, - seed: { - label: i18n.ts._imageEffector._fxProps.seed, - type: 'seed', - default: 100, - }, - }, - main: ({ gl, program, u, params }) => { - gl.uniform1i(u.amount, params.amount); - gl.uniform1f(u.channelShift, params.channelShift); - - const rnd = seedrandom(params.seed.toString()); - - for (let i = 0; i < params.amount; i++) { - const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`); - gl.uniform1f(o, rnd()); - - const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`); - gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength); - - const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`); - gl.uniform1f(h, rnd() * params.size); - } - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.glsl b/packages/frontend/src/utility/image-effector/fxs/threshold.glsl deleted file mode 100644 index 5ca8c46c39..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/threshold.glsl +++ /dev/null @@ -1,23 +0,0 @@ -#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 float u_r; -uniform float u_g; -uniform float u_b; -out vec4 out_color; - -void main() { - vec4 in_color = texture(in_texture, in_uv); - float r = in_color.r < u_r ? 0.0 : 1.0; - float g = in_color.g < u_g ? 0.0 : 1.0; - float b = in_color.b < u_b ? 0.0 : 1.0; - out_color = vec4(r, g, b, in_color.a); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.ts b/packages/frontend/src/utility/image-effector/fxs/threshold.ts deleted file mode 100644 index d0bb8305ae..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/threshold.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './threshold.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_threshold = defineImageEffectorFx({ - id: 'threshold', - name: i18n.ts._imageEffector._fxs.threshold, - shader, - uniforms: ['r', 'g', 'b'] as const, - params: { - r: { - label: i18n.ts._imageEffector._fxProps.redComponent, - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - }, - g: { - label: i18n.ts._imageEffector._fxProps.greenComponent, - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - }, - b: { - label: i18n.ts._imageEffector._fxProps.blueComponent, - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - }, - }, - main: ({ gl, u, params }) => { - gl.uniform1f(u.r, params.r); - gl.uniform1f(u.g, params.g); - gl.uniform1f(u.b, params.b); - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl deleted file mode 100644 index d6a1ef1820..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl +++ /dev/null @@ -1,147 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -const float PI = 3.141592653589793; - -in vec2 in_uv; // 0..1 -uniform sampler2D in_texture; // 背景 -uniform vec2 in_resolution; // 出力解像度(px) - -uniform sampler2D u_watermark; // ウォーターマーク -uniform vec2 u_wmResolution; // ウォーターマーク元解像度(px) - -uniform float u_opacity; // 0..1 -uniform float u_scale; // watermarkのスケール -uniform float u_angle; // -1..1 (PI倍) -uniform bool u_cover; // cover基準 or fit基準 -uniform bool u_repeat; // タイル敷き詰め -uniform int u_alignX; // 0:left 1:center 2:right -uniform int u_alignY; // 0:top 1:center 2:bottom -uniform float u_margin; // 余白(比率) -uniform float u_repeatMargin; // 敷き詰め時の余白(比率) -uniform bool u_noBBoxExpansion; // 回転時のBounding Box拡張を抑止 -uniform bool u_wmEnabled; // watermark有効 - -out vec4 out_color; - -mat2 rot(float a) { - float c = cos(a), s = sin(a); - return mat2(c, -s, s, c); -} - -// cover/fitとscaleから、最終的なサイズ(px)を計算 -vec2 computeWmSize(vec2 outSize, vec2 wmSize, bool cover, float scale) { - float wmAspect = wmSize.x / wmSize.y; - float outAspect = outSize.x / outSize.y; - vec2 size; - if (cover) { - if (wmAspect >= outAspect) { - size.y = outSize.y * scale; - size.x = size.y * wmAspect; - } else { - size.x = outSize.x * scale; - size.y = size.x / wmAspect; - } - } else { - if (wmAspect >= outAspect) { - size.x = outSize.x * scale; - size.y = size.x / wmAspect; - } else { - size.y = outSize.y * scale; - size.x = size.y * wmAspect; - } - } - return size; -} - -void main() { - vec2 outSize = in_resolution; - vec2 p = in_uv * outSize; // 出力のピクセル座標 - vec4 base = texture(in_texture, in_uv); - - if (!u_wmEnabled) { - out_color = base; - return; - } - - float theta = u_angle * PI; // ラジアン - vec2 wmSize = computeWmSize(outSize, u_wmResolution, u_cover, u_scale); - vec2 margin = u_repeat ? wmSize * u_repeatMargin : outSize * u_margin; - - // アライメントに基づく回転中心を計算 - float rotateX = 0.0; - float rotateY = 0.0; - if (abs(theta) > 1e-6 && !u_noBBoxExpansion) { - rotateX = abs(abs(wmSize.x * cos(theta)) + abs(wmSize.y * sin(theta)) - wmSize.x) * 0.5; - rotateY = abs(abs(wmSize.x * sin(theta)) + abs(wmSize.y * cos(theta)) - wmSize.y) * 0.5; - } - - float x; - if (u_alignX == 1) { - x = (outSize.x - wmSize.x) * 0.5; - } else if (u_alignX == 0) { - x = rotateX + margin.x; - } else { - x = outSize.x - wmSize.x - margin.x - rotateX; - } - - float y; - if (u_alignY == 1) { - y = (outSize.y - wmSize.y) * 0.5; - } else if (u_alignY == 0) { - y = rotateY + margin.y; - } else { - y = outSize.y - wmSize.y - margin.y - rotateY; - } - - vec2 rectMin = vec2(x, y); - vec2 rectMax = rectMin + wmSize; - vec2 rectCenter = (rectMin + rectMax) * 0.5; - - vec4 wmCol = vec4(0.0); - - if (u_repeat) { - // アライメントに基づく中心で回転 - vec2 q = rectCenter + rot(theta) * (p - rectCenter); - - // タイルグリッドの原点をrectMin(アライメント位置)に設定 - vec2 gridOrigin = rectMin - margin; - vec2 qFromOrigin = q - gridOrigin; - - // タイルサイズ(ウォーターマーク + マージン)で正規化 - vec2 tile = wmSize + margin * 2.0; - vec2 tileUv = qFromOrigin / tile; - - // タイル内のローカル座標(0..1)を取得 - vec2 localUv = fract(tileUv); - - // ローカル座標をピクセル単位に変換 - vec2 localPos = localUv * tile; - - // マージン領域内かチェック - bool inMargin = any(lessThan(localPos, margin)) || any(greaterThanEqual(localPos, margin + wmSize)); - - if (!inMargin) { - // ウォーターマーク領域内: UV座標を計算 - vec2 uvWm = (localPos - margin) / wmSize; - wmCol = texture(u_watermark, uvWm); - } - // マージン領域の場合は透明(wmCol = vec4(0.0))のまま - } else { - // アライメントと回転に従い一枚だけ描画 - vec2 q = rectCenter + rot(theta) * (p - rectCenter); - bool inside = all(greaterThanEqual(q, rectMin)) && all(lessThan(q, rectMax)); - if (inside) { - vec2 uvWm = (q - rectMin) / wmSize; - wmCol = texture(u_watermark, uvWm); - } - } - - float a = clamp(wmCol.a * u_opacity, 0.0, 1.0); - out_color = mix(base, vec4(wmCol.rgb, 1.0), a); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts deleted file mode 100644 index bb51ed796b..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './watermarkPlacement.glsl'; - -export const FX_watermarkPlacement = defineImageEffectorFx({ - id: 'watermarkPlacement', - name: '(internal)', - 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); - gl.uniform1f(u.scale, params.scale ?? 0.3); - gl.uniform1f(u.angle, params.angle ?? 0.0); - gl.uniform1i(u.cover, params.cover ? 1 : 0); - gl.uniform1i(u.repeat, params.repeat ? 1 : 0); - const ax = params.align?.x === 'left' ? 0 : params.align?.x === 'center' ? 1 : 2; - const ay = params.align?.y === 'top' ? 0 : params.align?.y === 'center' ? 1 : 2; - gl.uniform1i(u.alignX, ax); - gl.uniform1i(u.alignY, ay); - gl.uniform1f(u.margin, (params.align?.margin ?? 0)); - gl.uniform1f(u.repeatMargin, (params.align?.margin ?? 0)); - gl.uniform1i(u.noBBoxExpansion, params.noBoundingBoxExpansion ? 1 : 0); - - // ウォーターマークテクスチャ - const wm = textures.watermark; - if (wm) { - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, wm.texture); - - // リピートモードに応じてWRAP属性を設定 - if (params.repeat) { - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); - } else { - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - } - - gl.uniform1i(u.watermark, 1); - gl.uniform2f(u.wmResolution, wm.width, wm.height); - gl.uniform1i(u.wmEnabled, 1); - } else { - gl.uniform1i(u.wmEnabled, 0); - } - }, -}); diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl b/packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl deleted file mode 100644 index a0f11fcb5b..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl +++ /dev/null @@ -1,48 +0,0 @@ -#version 300 es -precision mediump float; - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// エイリアスを解決してくれないので、プロジェクトルートからの絶対パスにする必要がある -#include /src/shaders/snoise; - -in vec2 in_uv; -uniform sampler2D in_texture; -uniform vec2 in_resolution; -uniform vec2 u_pos; -uniform float u_frequency; -uniform bool u_thresholdEnabled; -uniform float u_threshold; -uniform float u_maskSize; -uniform bool u_black; -out vec4 out_color; - -void main() { - vec4 in_color = texture(in_texture, in_uv); - vec2 centeredUv = (in_uv - vec2(0.5, 0.5)); - vec2 uv = centeredUv; - - float seed = 1.0; - float time = 0.0; - - vec2 noiseUV = (uv - u_pos) / distance((uv - u_pos), vec2(0.0)); - float noiseX = (noiseUV.x + seed) * u_frequency; - float noiseY = (noiseUV.y + seed) * u_frequency; - float noise = (1.0 + snoise(vec3(noiseX, noiseY, time))) / 2.0; - - float t = noise; - if (u_thresholdEnabled) t = t < u_threshold ? 1.0 : 0.0; - - // TODO: マスクの形自体も揺らぎを与える - float d = distance(uv * vec2(2.0, 2.0), u_pos * vec2(2.0, 2.0)); - float mask = d < u_maskSize ? 0.0 : ((d - u_maskSize) * (1.0 + (u_maskSize * 2.0))); - out_color = vec4( - mix(in_color.r, u_black ? 0.0 : 1.0, t * mask), - mix(in_color.g, u_black ? 0.0 : 1.0, t * mask), - mix(in_color.b, u_black ? 0.0 : 1.0, t * mask), - in_color.a - ); -} diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts b/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts deleted file mode 100644 index 8c0956d24e..0000000000 --- a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineImageEffectorFx } from '../ImageEffector.js'; -import shader from './zoomLines.glsl'; -import { i18n } from '@/i18n.js'; - -export const FX_zoomLines = defineImageEffectorFx({ - id: 'zoomLines', - name: i18n.ts._imageEffector._fxs.zoomLines, - shader, - uniforms: ['pos', 'frequency', 'thresholdEnabled', 'threshold', 'maskSize', 'black'] as const, - params: { - x: { - label: i18n.ts._imageEffector._fxProps.centerX, - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - }, - y: { - label: i18n.ts._imageEffector._fxProps.centerY, - type: 'number', - default: 0.0, - min: -1.0, - max: 1.0, - step: 0.01, - }, - frequency: { - label: i18n.ts._imageEffector._fxProps.frequency, - type: 'number', - default: 5.0, - min: 0.0, - max: 15.0, - step: 0.1, - }, - smoothing: { - label: i18n.ts._imageEffector._fxProps.zoomLinesSmoothing, - caption: i18n.ts._imageEffector._fxProps.zoomLinesSmoothingDescription, - type: 'boolean', - default: false, - }, - threshold: { - label: i18n.ts._imageEffector._fxProps.zoomLinesThreshold, - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - }, - maskSize: { - label: i18n.ts._imageEffector._fxProps.zoomLinesMaskSize, - type: 'number', - default: 0.5, - min: 0.0, - max: 1.0, - step: 0.01, - }, - black: { - label: i18n.ts._imageEffector._fxProps.zoomLinesBlack, - type: 'boolean', - 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); - }, -}); 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 { + 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[]; - -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; - 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[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((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/watermark/watermark.glsl b/packages/frontend/src/utility/watermark/watermark.glsl new file mode 100644 index 0000000000..d6a1ef1820 --- /dev/null +++ b/packages/frontend/src/utility/watermark/watermark.glsl @@ -0,0 +1,147 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const float PI = 3.141592653589793; + +in vec2 in_uv; // 0..1 +uniform sampler2D in_texture; // 背景 +uniform vec2 in_resolution; // 出力解像度(px) + +uniform sampler2D u_watermark; // ウォーターマーク +uniform vec2 u_wmResolution; // ウォーターマーク元解像度(px) + +uniform float u_opacity; // 0..1 +uniform float u_scale; // watermarkのスケール +uniform float u_angle; // -1..1 (PI倍) +uniform bool u_cover; // cover基準 or fit基準 +uniform bool u_repeat; // タイル敷き詰め +uniform int u_alignX; // 0:left 1:center 2:right +uniform int u_alignY; // 0:top 1:center 2:bottom +uniform float u_margin; // 余白(比率) +uniform float u_repeatMargin; // 敷き詰め時の余白(比率) +uniform bool u_noBBoxExpansion; // 回転時のBounding Box拡張を抑止 +uniform bool u_wmEnabled; // watermark有効 + +out vec4 out_color; + +mat2 rot(float a) { + float c = cos(a), s = sin(a); + return mat2(c, -s, s, c); +} + +// cover/fitとscaleから、最終的なサイズ(px)を計算 +vec2 computeWmSize(vec2 outSize, vec2 wmSize, bool cover, float scale) { + float wmAspect = wmSize.x / wmSize.y; + float outAspect = outSize.x / outSize.y; + vec2 size; + if (cover) { + if (wmAspect >= outAspect) { + size.y = outSize.y * scale; + size.x = size.y * wmAspect; + } else { + size.x = outSize.x * scale; + size.y = size.x / wmAspect; + } + } else { + if (wmAspect >= outAspect) { + size.x = outSize.x * scale; + size.y = size.x / wmAspect; + } else { + size.y = outSize.y * scale; + size.x = size.y * wmAspect; + } + } + return size; +} + +void main() { + vec2 outSize = in_resolution; + vec2 p = in_uv * outSize; // 出力のピクセル座標 + vec4 base = texture(in_texture, in_uv); + + if (!u_wmEnabled) { + out_color = base; + return; + } + + float theta = u_angle * PI; // ラジアン + vec2 wmSize = computeWmSize(outSize, u_wmResolution, u_cover, u_scale); + vec2 margin = u_repeat ? wmSize * u_repeatMargin : outSize * u_margin; + + // アライメントに基づく回転中心を計算 + float rotateX = 0.0; + float rotateY = 0.0; + if (abs(theta) > 1e-6 && !u_noBBoxExpansion) { + rotateX = abs(abs(wmSize.x * cos(theta)) + abs(wmSize.y * sin(theta)) - wmSize.x) * 0.5; + rotateY = abs(abs(wmSize.x * sin(theta)) + abs(wmSize.y * cos(theta)) - wmSize.y) * 0.5; + } + + float x; + if (u_alignX == 1) { + x = (outSize.x - wmSize.x) * 0.5; + } else if (u_alignX == 0) { + x = rotateX + margin.x; + } else { + x = outSize.x - wmSize.x - margin.x - rotateX; + } + + float y; + if (u_alignY == 1) { + y = (outSize.y - wmSize.y) * 0.5; + } else if (u_alignY == 0) { + y = rotateY + margin.y; + } else { + y = outSize.y - wmSize.y - margin.y - rotateY; + } + + vec2 rectMin = vec2(x, y); + vec2 rectMax = rectMin + wmSize; + vec2 rectCenter = (rectMin + rectMax) * 0.5; + + vec4 wmCol = vec4(0.0); + + if (u_repeat) { + // アライメントに基づく中心で回転 + vec2 q = rectCenter + rot(theta) * (p - rectCenter); + + // タイルグリッドの原点をrectMin(アライメント位置)に設定 + vec2 gridOrigin = rectMin - margin; + vec2 qFromOrigin = q - gridOrigin; + + // タイルサイズ(ウォーターマーク + マージン)で正規化 + vec2 tile = wmSize + margin * 2.0; + vec2 tileUv = qFromOrigin / tile; + + // タイル内のローカル座標(0..1)を取得 + vec2 localUv = fract(tileUv); + + // ローカル座標をピクセル単位に変換 + vec2 localPos = localUv * tile; + + // マージン領域内かチェック + bool inMargin = any(lessThan(localPos, margin)) || any(greaterThanEqual(localPos, margin + wmSize)); + + if (!inMargin) { + // ウォーターマーク領域内: UV座標を計算 + vec2 uvWm = (localPos - margin) / wmSize; + wmCol = texture(u_watermark, uvWm); + } + // マージン領域の場合は透明(wmCol = vec4(0.0))のまま + } else { + // アライメントと回転に従い一枚だけ描画 + vec2 q = rectCenter + rot(theta) * (p - rectCenter); + bool inside = all(greaterThanEqual(q, rectMin)) && all(lessThan(q, rectMax)); + if (inside) { + vec2 uvWm = (q - rectMin) / wmSize; + wmCol = texture(u_watermark, uvWm); + } + } + + float a = clamp(wmCol.a * u_opacity, 0.0, 1.0); + out_color = mix(base, vec4(wmCol.rgb, 1.0), a); +} diff --git a/packages/frontend/src/utility/watermark/watermark.ts b/packages/frontend/src/utility/watermark/watermark.ts new file mode 100644 index 0000000000..62efcd12b6 --- /dev/null +++ b/packages/frontend/src/utility/watermark/watermark.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import shader from './watermark.glsl'; +import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js'; + +export const fn = defineImageCompositorFunction>({ + shader, + main: ({ gl, u, params, textures }) => { + // 基本パラメータ + gl.uniform1f(u.opacity, params.opacity ?? 1.0); + gl.uniform1f(u.scale, params.scale ?? 0.3); + gl.uniform1f(u.angle, params.angle ?? 0.0); + gl.uniform1i(u.cover, params.cover ? 1 : 0); + gl.uniform1i(u.repeat, params.repeat ? 1 : 0); + const ax = params.align?.x === 'left' ? 0 : params.align?.x === 'center' ? 1 : 2; + const ay = params.align?.y === 'top' ? 0 : params.align?.y === 'center' ? 1 : 2; + gl.uniform1i(u.alignX, ax); + gl.uniform1i(u.alignY, ay); + gl.uniform1f(u.margin, (params.align?.margin ?? 0)); + gl.uniform1f(u.repeatMargin, (params.align?.margin ?? 0)); + gl.uniform1i(u.noBBoxExpansion, params.noBoundingBoxExpansion ? 1 : 0); + + // ウォーターマークテクスチャ + const wm = textures.get(params.watermark); + if (wm) { + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, wm.texture); + + // リピートモードに応じてWRAP属性を設定 + if (params.repeat) { + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); + } else { + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + } + + gl.uniform1i(u.watermark, 1); + gl.uniform2f(u.wmResolution, wm.width, wm.height); + gl.uniform1i(u.wmEnabled, 1); + } else { + gl.uniform1i(u.wmEnabled, 0); + } + }, +}); 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; +} -- cgit v1.2.3-freya