summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-11-06 20:25:17 +0900
committerGitHub <noreply@github.com>2025-11-06 20:25:17 +0900
commit4ba18690d7abd7eea086bb59e6cbcc8ead9e121a (patch)
tree7d25ec47d8711d945b08e3903642f2e982f40048 /packages/frontend/src
parentfix(frontend): improve startViewTransition handling (diff)
downloadmisskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.gz
misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.bz2
misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.zip
feat(frontend): EXIFフレーム機能 (#16725)
* wip * wip * Update ImageEffector.ts * Update image-label-renderer.ts * Update image-label-renderer.ts * wip * Update image-label-renderer.ts * wip * wip * wip * wip * wip * wip * wip * Update use-uploader.ts * Update watermark.ts * wip * wu * wip * Update image-frame-renderer.ts * wip * wip * Update image-frame-renderer.ts * Create ImageCompositor.ts * Update ImageCompositor.ts * wip * wip * Update ImageEffector.ts * wip * Update use-uploader.ts * wip * wip * wip * wip * Update fxs.ts * wip * wip * wip * Update CHANGELOG.md * wip * wip * Update MkImageEffectorDialog.vue * Update MkImageEffectorDialog.vue * Update MkImageFrameEditorDialog.vue * Update use-uploader.ts * improve error handling * Update use-uploader.ts * 🎨 * wip * wip * lazy load * lazy load * wip * wip * wip
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/components/MkEmbedCodeGenDialog.vue23
-rw-r--r--packages/frontend/src/components/MkImageEffectorDialog.Layer.vue12
-rw-r--r--packages/frontend/src/components/MkImageEffectorDialog.vue87
-rw-r--r--packages/frontend/src/components/MkImageFrameEditorDialog.vue509
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue2
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.vue162
-rw-r--r--packages/frontend/src/composables/use-uploader.ts176
-rw-r--r--packages/frontend/src/lib/ImageCompositor.ts313
-rw-r--r--packages/frontend/src/lib/pizzax.ts2
-rw-r--r--packages/frontend/src/pages/settings/drive.ImageFrameItem.vue113
-rw-r--r--packages/frontend/src/pages/settings/drive.WatermarkItem.vue15
-rw-r--r--packages/frontend/src/pages/settings/drive.vue86
-rw-r--r--packages/frontend/src/preferences/def.ts23
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/blockNoise.ts (renamed from packages/frontend/src/utility/image-effector/fxs/blockNoise.ts)59
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/blur.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/blur.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/blur.ts (renamed from packages/frontend/src/utility/image-effector/fxs/blur.ts)38
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/checker.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/checker.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/checker.ts (renamed from packages/frontend/src/utility/image-effector/fxs/checker.ts)31
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts (renamed from packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts)25
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts (renamed from packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts)34
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/colorClamp.ts (renamed from packages/frontend/src/utility/image-effector/fxs/colorClamp.ts)33
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts (renamed from packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts)37
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/distort.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/distort.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/distort.ts (renamed from packages/frontend/src/utility/image-effector/fxs/distort.ts)31
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/fill.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/fill.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/fill.ts (renamed from packages/frontend/src/utility/image-effector/fxs/fill.ts)39
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/grayscale.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/grayscale.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/grayscale.ts21
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/invert.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/invert.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/invert.ts (renamed from packages/frontend/src/utility/image-effector/fxs/invert.ts)28
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/mirror.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/mirror.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/mirror.ts (renamed from packages/frontend/src/utility/image-effector/fxs/mirror.ts)29
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/pixelate.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/pixelate.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/pixelate.ts (renamed from packages/frontend/src/utility/image-effector/fxs/pixelate.ts)38
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/polkadot.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/polkadot.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/polkadot.ts (renamed from packages/frontend/src/utility/image-effector/fxs/polkadot.ts)44
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/stripe.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/stripe.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/stripe.ts (renamed from packages/frontend/src/utility/image-effector/fxs/stripe.ts)37
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/tearing.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/tearing.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/tearing.ts (renamed from packages/frontend/src/utility/image-effector/fxs/tearing.ts)54
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/threshold.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/threshold.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/threshold.ts (renamed from packages/frontend/src/utility/image-effector/fxs/threshold.ts)28
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl)0
-rw-r--r--packages/frontend/src/utility/image-compositor-functions/zoomLines.ts (renamed from packages/frontend/src/utility/image-effector/fxs/zoomLines.ts)40
-rw-r--r--packages/frontend/src/utility/image-effector/ImageEffector.ts482
-rw-r--r--packages/frontend/src/utility/image-effector/fxs.ts82
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/grayscale.ts19
-rw-r--r--packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts270
-rw-r--r--packages/frontend/src/utility/image-frame-renderer/frame.glsl61
-rw-r--r--packages/frontend/src/utility/image-frame-renderer/frame.ts57
-rw-r--r--packages/frontend/src/utility/watermark.ts218
-rw-r--r--packages/frontend/src/utility/watermark/WatermarkRenderer.ts332
-rw-r--r--packages/frontend/src/utility/watermark/watermark.glsl (renamed from packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl)0
-rw-r--r--packages/frontend/src/utility/watermark/watermark.ts (renamed from packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts)63
-rw-r--r--packages/frontend/src/utility/webgl.ts11
59 files changed, 2587 insertions, 1177 deletions
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"
>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
- <div
- :class="$style.embedCodeGenPreviewRoot"
- >
+ <div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<div :class="$style.embedCodeGenPreviewWrapper">
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
@@ -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
<template>
<MkFolder :defaultOpen="true" :canPage="false">
- <template #label>{{ fx.name }}</template>
+ <template #label>{{ fx.uiDefinition.name }}</template>
<template #footer>
<div class="_buttons">
<MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
- <MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" />
+ <MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.uiDefinition.params"/>
</MkFolder>
</template>
@@ -26,14 +26,14 @@ import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue';
import { FXS } from '@/utility/image-effector/fxs.js';
const layer = defineModel<ImageEffectorLayer>('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;
}>();
</script>
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
<div :class="$style.root">
<div :class="$style.container">
- <div :class="$style.preview">
+ <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
@@ -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<ImageEffectorLayer[]>([]);
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<typeof FXS> | 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<ImageEffectorLayer, { fxId: 'fill' } | { fxId: 'blur' } | { fxId: 'pixelate' }>) : 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 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="dialog"
+ :width="1000"
+ :height="600"
+ :scroll="false"
+ :withOkButton="true"
+ @close="cancel()"
+ @ok="save()"
+ @closed="emit('closed')"
+>
+ <template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
+
+ <div :class="$style.root">
+ <div :class="$style.container">
+ <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
+ <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
+ <div :class="$style.previewContainer">
+ <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
+ <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
+ <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
+ <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
+ <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
+ </div>
+ </div>
+ </div>
+ <div :class="$style.controls">
+ <div class="_spacer _gaps">
+ <MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
+ </MkRange>
+
+ <MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
+ <template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
+ </MkInput>
+
+ <MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
+ <template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
+ </MkInput>
+
+ <MkSelect
+ v-model="params.font" :items="[
+ { label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
+ { label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
+ ]"
+ >
+ <template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
+ </MkSelect>
+
+ <MkFolder :defaultOpen="params.labelTop.enabled">
+ <template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
+
+ <div class="_gaps">
+ <MkSwitch v-model="params.labelTop.enabled">
+ <template #label>{{ i18n.ts.show }}</template>
+ </MkSwitch>
+
+ <MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
+ </MkRange>
+
+ <MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
+ </MkRange>
+
+ <MkSwitch v-model="params.labelTop.centered">
+ <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
+ </MkSwitch>
+
+ <MkInput v-model="params.labelTop.textBig">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="params.labelTop.textSmall">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
+ </MkTextarea>
+
+ <MkSwitch v-model="params.labelTop.withQrCode">
+ <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
+
+ <MkFolder :defaultOpen="params.labelBottom.enabled">
+ <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
+
+ <div class="_gaps">
+ <MkSwitch v-model="params.labelBottom.enabled">
+ <template #label>{{ i18n.ts.show }}</template>
+ </MkSwitch>
+
+ <MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
+ </MkRange>
+
+ <MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
+ </MkRange>
+
+ <MkSwitch v-model="params.labelBottom.centered">
+ <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
+ </MkSwitch>
+
+ <MkInput v-model="params.labelBottom.textBig">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="params.labelBottom.textSmall">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
+ </MkTextarea>
+
+ <MkSwitch v-model="params.labelBottom.withQrCode">
+ <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
+
+ <MkInfo>
+ <div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
+ <div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
+ <div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
+ <div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
+ <div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
+ <div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
+ <div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
+ <div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
+ <div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
+ <div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
+ <div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
+ <div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
+ <div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
+ <div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
+ <div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
+ <div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
+ <div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
+ <div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
+ <div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
+ </MkInfo>
+ </div>
+ </div>
+ </div>
+ </div>
+</MkModalWindow>
+</template>
+
+<script setup lang="ts">
+import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
+import ExifReader from 'exifreader';
+import { throttle } from 'throttle-debounce';
+import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
+import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
+import { i18n } from '@/i18n.js';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkRange from '@/components/MkRange.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import * as os from '@/os.js';
+import { deepClone } from '@/utility/clone.js';
+import { ensureSignin } from '@/i.js';
+import { genId } from '@/utility/id.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
+import { prefer } from '@/preferences.js';
+
+const $i = ensureSignin();
+
+const props = defineProps<{
+ presetEditMode?: boolean;
+ preset?: ImageFramePreset | null;
+ params?: ImageFrameParams | null;
+ image?: File | null;
+ imageCaption?: string | null;
+ imageFilename?: string | null;
+}>();
+
+const preset = deepClone(props.preset) ?? {
+ id: genId(),
+ name: '',
+};
+
+const params = reactive<ImageFrameParams>(deepClone(props.params) ?? {
+ borderThickness: 0.05,
+ borderRadius: 0,
+ labelTop: {
+ enabled: false,
+ scale: 1.0,
+ padding: 0.2,
+ textBig: '',
+ textSmall: '',
+ centered: false,
+ withQrCode: false,
+ },
+ labelBottom: {
+ enabled: true,
+ scale: 1.0,
+ padding: 0.2,
+ textBig: '{year}/{0month}/{0day}',
+ textSmall: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}',
+ centered: false,
+ withQrCode: true,
+ },
+ bgColor: [1, 1, 1],
+ fgColor: [0, 0, 0],
+ font: 'sans-serif',
+});
+
+const emit = defineEmits<{
+ (ev: 'ok', frame: ImageFrameParams): void;
+ (ev: 'presetOk', preset: ImageFramePreset): void;
+ (ev: 'cancel'): void;
+ (ev: 'closed'): void;
+}>();
+
+const dialog = useTemplateRef('dialog');
+
+async function cancel() {
+ if (props.presetEditMode) {
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.ts._imageFrameEditor.quitWithoutSaveConfirm,
+ });
+ if (canceled) return;
+ }
+
+ dialog.value?.close();
+}
+
+const updateThrottled = throttle(50, () => {
+ if (renderer != null) {
+ renderer.render(params);
+ }
+});
+
+watch(params, async (newValue, oldValue) => {
+ updateThrottled();
+}, { deep: true });
+
+const canvasEl = useTemplateRef('canvasEl');
+
+const sampleImage_3_2 = new Image();
+sampleImage_3_2.src = '/client-assets/sample/3-2.jpg';
+const sampleImage_3_2_loading = new Promise<void>(resolve => {
+ sampleImage_3_2.onload = () => resolve();
+});
+
+const sampleImage_2_3 = new Image();
+sampleImage_2_3.src = '/client-assets/sample/2-3.jpg';
+const sampleImage_2_3_loading = new Promise<void>(resolve => {
+ sampleImage_2_3.onload = () => resolve();
+});
+
+const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
+watch(sampleImageType, async () => {
+ if (sampleImageType.value === 'provided') return;
+ if (renderer != null) {
+ renderer.destroy(false);
+ renderer = null;
+ initRenderer();
+ }
+});
+
+let imageFile = props.image;
+
+async function choiceImage() {
+ const files = await os.chooseFileFromPc({ multiple: false });
+ if (files.length === 0) return;
+ imageFile = files[0];
+ sampleImageType.value = 'provided';
+ if (renderer != null) {
+ renderer.destroy(false);
+ renderer = null;
+ initRenderer();
+ }
+}
+
+let renderer: ImageFrameRenderer | null = null;
+let imageBitmap: ImageBitmap | null = null;
+
+async function initRenderer() {
+ if (canvasEl.value == null) return;
+
+ if (sampleImageType.value === '3_2') {
+ renderer = new ImageFrameRenderer({
+ canvas: canvasEl.value,
+ image: sampleImage_3_2,
+ exif: null,
+ caption: 'Example caption',
+ filename: 'example_file_name.jpg',
+ renderAsPreview: true,
+ });
+ } else if (sampleImageType.value === '2_3') {
+ renderer = new ImageFrameRenderer({
+ canvas: canvasEl.value,
+ image: sampleImage_2_3,
+ exif: null,
+ caption: 'Example caption',
+ filename: 'example_file_name.jpg',
+ renderAsPreview: true,
+ });
+ } else if (imageFile != null) {
+ imageBitmap = await window.createImageBitmap(imageFile);
+
+ const exif = ExifReader.load(await imageFile.arrayBuffer());
+
+ renderer = new ImageFrameRenderer({
+ canvas: canvasEl.value,
+ image: imageBitmap,
+ exif: exif,
+ caption: props.imageCaption ?? null,
+ filename: props.imageFilename ?? null,
+ renderAsPreview: true,
+ });
+ }
+
+ await renderer!.render(params);
+}
+
+onMounted(async () => {
+ const closeWaiting = os.waiting();
+
+ await nextTick(); // waitingがレンダリングされるまで待つ
+
+ await sampleImage_3_2_loading;
+ await sampleImage_2_3_loading;
+
+ try {
+ await initRenderer();
+ } catch (err) {
+ console.error(err);
+ os.alert({
+ type: 'error',
+ text: i18n.ts._imageFrameEditor.failedToLoadImage,
+ });
+ }
+
+ closeWaiting();
+});
+
+onUnmounted(() => {
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+ if (imageBitmap != null) {
+ imageBitmap.close();
+ imageBitmap = null;
+ }
+});
+
+async function save() {
+ if (props.presetEditMode) {
+ const { canceled, result: name } = await os.inputText({
+ title: i18n.ts.name,
+ default: preset.name,
+ });
+ if (canceled) return;
+
+ preset.name = name || '';
+
+ dialog.value?.close();
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+
+ emit('presetOk', {
+ ...preset,
+ params: deepClone(params),
+ });
+ } else {
+ dialog.value?.close();
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+
+ emit('ok', params);
+ }
+}
+
+function getHex(c: [number, number, number]) {
+ return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
+}
+
+function getRgb(hex: string | number): [number, number, number] | null {
+ if (
+ typeof hex === 'number' ||
+ typeof hex !== 'string' ||
+ !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
+ ) {
+ return null;
+ }
+
+ const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
+ if (m == null) return [0, 0, 0];
+ return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
+}
+</script>
+
+<style module>
+.root {
+ container-type: inline-size;
+ height: 100%;
+}
+
+.container {
+ height: 100%;
+ display: grid;
+ grid-template-columns: 1fr 400px;
+}
+
+.preview {
+ position: relative;
+ 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; }
+}
+
+.previewContainer {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ user-select: none;
+ -webkit-user-drag: none;
+}
+
+.previewTitle {
+ position: absolute;
+ z-index: 100;
+ top: 8px;
+ left: 8px;
+ padding: 6px 10px;
+ border-radius: 6px;
+ font-size: 85%;
+}
+
+.previewControls {
+ position: absolute;
+ z-index: 100;
+ bottom: 8px;
+ right: 8px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 6px;
+}
+
+.previewControlsButton {
+ &.active {
+ color: var(--MI_THEME-accent);
+ }
+}
+
+.previewSpinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ user-select: none;
+ -webkit-user-drag: none;
+}
+
+.previewCanvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ padding: 20px;
+ box-sizing: border-box;
+ object-fit: contain;
+}
+
+.controls {
+ overflow-y: scroll;
+}
+
+@container (max-width: 800px) {
+ .container {
+ grid-template-columns: 1fr;
+ grid-template-rows: 1fr 1fr;
+ }
+}
+</style>
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
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import * as Misskey from 'misskey-js';
-import type { WatermarkPreset } from '@/utility/watermark.js';
+import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
index 3b3f20d8d1..6cd2111598 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
@@ -18,20 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root">
<div :class="$style.container">
- <div :class="$style.preview">
+ <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
+ <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
</div>
</div>
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<div class="_gaps_s">
- <MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
+ <MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
<template #label>
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
@@ -49,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<XLayer
- v-model:layer="preset.layers[i]"
+ v-model:layer="layers[i]"
></XLayer>
</MkFolder>
@@ -64,8 +65,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
-import type { WatermarkPreset } from '@/utility/watermark.js';
-import { WatermarkRenderer } from '@/utility/watermark.js';
+import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
+import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue';
@@ -77,6 +78,7 @@ import { deepClone } from '@/utility/clone.js';
import { ensureSignin } from '@/i.js';
import { genId } from '@/utility/id.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
+import { prefer } from '@/preferences.js';
const $i = ensureSignin();
@@ -161,18 +163,22 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] {
}
const props = defineProps<{
+ presetEditMode?: boolean;
preset?: WatermarkPreset | null;
+ layers?: WatermarkLayers | null;
image?: File | null;
}>();
-const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
+const preset = deepClone(props.preset) ?? {
id: genId(),
name: '',
- layers: [],
-});
+};
+
+const layers = reactive<WatermarkLayers>(props.layers ?? []);
const emit = defineEmits<{
- (ev: 'ok', preset: WatermarkPreset): void;
+ (ev: 'ok', layers: WatermarkLayers): void;
+ (ev: 'presetOk', preset: WatermarkPreset): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
@@ -180,19 +186,21 @@ const emit = defineEmits<{
const dialog = useTemplateRef('dialog');
async function cancel() {
- const { canceled } = await os.confirm({
- type: 'question',
- text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
- });
- if (canceled) return;
+ if (props.presetEditMode) {
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
+ });
+ if (canceled) return;
+ }
emit('cancel');
dialog.value?.close();
}
-watch(preset, async (newValue, oldValue) => {
+watch(layers, async (newValue, oldValue) => {
if (renderer != null) {
- renderer.setLayers(preset.layers);
+ renderer.render(layers);
}
}, { deep: true });
@@ -212,6 +220,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
watch(sampleImageType, async () => {
+ if (sampleImageType.value === 'provided') return;
if (renderer != null) {
renderer.destroy(false);
renderer = null;
@@ -219,6 +228,20 @@ watch(sampleImageType, async () => {
}
});
+let imageFile = props.image;
+
+async function choiceImage() {
+ const files = await os.chooseFileFromPc({ multiple: false });
+ if (files.length === 0) return;
+ imageFile = files[0];
+ sampleImageType.value = 'provided';
+ if (renderer != null) {
+ renderer.destroy(false);
+ renderer = null;
+ initRenderer();
+ }
+}
+
let renderer: WatermarkRenderer | null = null;
let imageBitmap: ImageBitmap | null = null;
@@ -239,8 +262,8 @@ async function initRenderer() {
renderHeight: 1500,
image: sampleImage_2_3,
});
- } else if (props.image != null) {
- imageBitmap = await window.createImageBitmap(props.image);
+ } else if (imageFile != null) {
+ imageBitmap = await window.createImageBitmap(imageFile);
const MAX_W = 1000;
const MAX_H = 1000;
@@ -249,8 +272,8 @@ async function initRenderer() {
if (w > MAX_W || h > MAX_H) {
const scale = Math.min(MAX_W / w, MAX_H / h);
- w *= scale;
- h *= scale;
+ w = Math.floor(w * scale);
+ h = Math.floor(h * scale);
}
renderer = new WatermarkRenderer({
@@ -261,9 +284,7 @@ async function initRenderer() {
});
}
- await renderer!.setLayers(preset.layers);
-
- renderer!.render();
+ await renderer!.render(layers);
}
onMounted(async () => {
@@ -274,7 +295,15 @@ onMounted(async () => {
await sampleImage_3_2_loading;
await sampleImage_2_3_loading;
- await initRenderer();
+ try {
+ await initRenderer();
+ } catch (err) {
+ console.error(err);
+ os.alert({
+ type: 'error',
+ text: i18n.ts._watermarkEditor.failedToLoadImage,
+ });
+ }
closeWaiting();
});
@@ -291,77 +320,93 @@ onUnmounted(() => {
});
async function save() {
- const { canceled, result: name } = await os.inputText({
- title: i18n.ts.name,
- default: preset.name,
- });
- if (canceled) return;
+ if (props.presetEditMode) {
+ const { canceled, result: name } = await os.inputText({
+ title: i18n.ts.name,
+ default: preset.name,
+ });
+ if (canceled) return;
- preset.name = name || '';
+ preset.name = name || '';
- dialog.value?.close();
- if (renderer != null) {
- renderer.destroy();
- renderer = null;
- }
+ dialog.value?.close();
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
- emit('ok', preset);
+ emit('presetOk', {
+ ...preset,
+ layers: deepClone(layers),
+ });
+ } else {
+ dialog.value?.close();
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+
+ emit('ok', layers);
+ }
}
function addLayer(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts._watermarkEditor.text,
action: () => {
- preset.layers.push(createTextLayer());
+ layers.push(createTextLayer());
},
}, {
text: i18n.ts._watermarkEditor.image,
action: () => {
- preset.layers.push(createImageLayer());
+ layers.push(createImageLayer());
},
}, {
text: i18n.ts._watermarkEditor.qr,
action: () => {
- preset.layers.push(createQrLayer());
+ layers.push(createQrLayer());
},
}, {
text: i18n.ts._watermarkEditor.stripe,
action: () => {
- preset.layers.push(createStripeLayer());
+ layers.push(createStripeLayer());
},
}, {
text: i18n.ts._watermarkEditor.polkadot,
action: () => {
- preset.layers.push(createPolkadotLayer());
+ layers.push(createPolkadotLayer());
},
}, {
text: i18n.ts._watermarkEditor.checker,
action: () => {
- preset.layers.push(createCheckerLayer());
+ layers.push(createCheckerLayer());
},
}], ev.currentTarget ?? ev.target);
}
function swapUpLayer(layer: WatermarkPreset['layers'][number]) {
- const index = preset.layers.findIndex(l => l.id === layer.id);
+ const index = layers.findIndex(l => l.id === layer.id);
if (index > 0) {
- const tmp = preset.layers[index - 1];
- preset.layers[index - 1] = preset.layers[index];
- preset.layers[index] = tmp;
+ const tmp = layers[index - 1];
+ layers[index - 1] = layers[index];
+ layers[index] = tmp;
}
}
function swapDownLayer(layer: WatermarkPreset['layers'][number]) {
- const index = preset.layers.findIndex(l => l.id === layer.id);
- if (index < preset.layers.length - 1) {
- const tmp = preset.layers[index + 1];
- preset.layers[index + 1] = preset.layers[index];
- preset.layers[index] = tmp;
+ const index = layers.findIndex(l => l.id === layer.id);
+ if (index < layers.length - 1) {
+ const tmp = layers[index + 1];
+ layers[index + 1] = layers[index];
+ layers[index] = tmp;
}
}
function removeLayer(layer: WatermarkPreset['layers'][number]) {
- preset.layers = preset.layers.filter(l => l.id !== layer.id);
+ const index = layers.findIndex(l => l.id === layer.id);
+ if (index !== -1) {
+ layers.splice(index, 1);
+ }
}
</script>
@@ -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<Blob>((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<Blob>((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<string, any>;
+
+export type ImageCompositorFunction<PS extends ImageCompositorFunctionParams = ImageCompositorFunctionParams> = {
+ shader: string;
+ main: (ctx: {
+ gl: WebGL2RenderingContext;
+ program: WebGLProgram;
+ params: PS;
+ u: Record<string, WebGLUniformLocation>;
+ width: number;
+ height: number;
+ textures: Map<string, { texture: WebGLTexture; width: number; height: number; }>;
+ }) => void;
+};
+
+export type ImageCompositorLayer<FNS extends Record<string, ImageCompositorFunction> = any> = {
+ [K in keyof FNS]: {
+ id: string;
+ functionId: K;
+ params: Parameters<FNS[K]['main']>[0]['params'];
+ };
+}[keyof FNS];
+
+export function defineImageCompositorFunction<PS extends ImageCompositorFunctionParams>(fn: ImageCompositorFunction<PS>) {
+ return fn;
+}
+
+// TODO: per layer cache
+
+export class ImageCompositor<FNS extends Record<string, ImageCompositorFunction<any>>> {
+ private gl: WebGL2RenderingContext;
+ private canvas: HTMLCanvasElement | null = null;
+ private renderWidth: number;
+ private renderHeight: number;
+ private baseTexture: WebGLTexture;
+ private shaderCache: Map<string, WebGLProgram> = new Map();
+ private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
+ private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
+ private nopProgram: WebGLProgram;
+ private registeredTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
+ private registeredFunctions: Map<string, ImageCompositorFunction & { id: string; uniforms: string[] }> = 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<FNS>)[]) {
+ 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 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkFolder :defaultOpen="false" :canPage="false">
+ <template #icon><i class="ti ti-pencil"></i></template>
+ <template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template>
+ <template #footer>
+ <div class="_buttons">
+ <MkButton @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton>
+ <MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton>
+ </div>
+ </template>
+
+ <div>
+ <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
+ </div>
+</MkFolder>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
+import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
+import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { deepClone } from '@/utility/clone.js';
+import MkFolder from '@/components/MkFolder.vue';
+
+const props = defineProps<{
+ preset: ImageFramePreset;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'updatePreset', preset: ImageFramePreset): void,
+ (ev: 'del'): void,
+}>();
+
+async function edit() {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageFrameEditorDialog.vue')), {
+ presetEditMode: true,
+ preset: deepClone(props.preset),
+ params: deepClone(props.preset.params),
+ }, {
+ presetOk: (preset) => {
+ emit('updatePreset', preset);
+ },
+ closed: () => dispose(),
+ });
+}
+
+function del(ev: MouseEvent) {
+ os.popupMenu([{
+ text: i18n.ts.delete,
+ action: () => {
+ emit('del');
+ },
+ }], ev.currentTarget ?? ev.target);
+}
+
+const canvasEl = useTemplateRef('canvasEl');
+
+const sampleImage = new Image();
+sampleImage.src = '/client-assets/sample/3-2.jpg';
+
+let renderer: ImageFrameRenderer | null = null;
+
+onMounted(() => {
+ sampleImage.onload = async () => {
+ watch(canvasEl, async () => {
+ if (canvasEl.value == null) return;
+
+ renderer = new ImageFrameRenderer({
+ canvas: canvasEl.value,
+ image: sampleImage,
+ exif: null,
+ caption: 'Example caption',
+ filename: 'example_file_name.jpg',
+ renderAsPreview: true,
+ });
+
+ await renderer.render(props.preset.params);
+ }, { immediate: true });
+ };
+});
+
+onUnmounted(() => {
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+});
+
+watch(() => props.preset, async () => {
+ if (renderer != null) {
+ await renderer.render(props.preset.params);
+ }
+}, { deep: true });
+</script>
+
+<style lang="scss" module>
+.previewCanvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ max-height: 200px;
+ box-sizing: border-box;
+ object-fit: contain;
+}
+</style>
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
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
-import type { WatermarkPreset } from '@/utility/watermark.js';
-import { WatermarkRenderer } from '@/utility/watermark.js';
+import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
+import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
@@ -41,9 +41,11 @@ const emit = defineEmits<{
async function edit() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {
+ presetEditMode: true,
preset: deepClone(props.preset),
+ layers: deepClone(props.preset.layers),
}, {
- ok: (preset) => {
+ presetOk: (preset) => {
emit('updatePreset', preset);
},
closed: () => dispose(),
@@ -78,9 +80,7 @@ onMounted(() => {
image: sampleImage,
});
- await renderer.setLayers(props.preset.layers);
-
- renderer.render();
+ await renderer.render(props.preset.layers);
}, { immediate: true });
};
});
@@ -94,8 +94,7 @@ onUnmounted(() => {
watch(() => props.preset, async () => {
if (renderer != null) {
- await renderer.setLayers(props.preset.layers);
- renderer.render();
+ await renderer.render(props.preset.layers);
}
}, { deep: true });
</script>
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
</MkFolder>
</SearchMarker>
+ <SearchMarker :keywords="['label', 'frame', 'credit', 'metadata']">
+ <MkFolder>
+ <template #icon><i class="ti ti-device-ipad-horizontal"></i></template>
+ <template #label><SearchLabel>{{ i18n.ts.frame }}</SearchLabel></template>
+ <template #caption>{{ i18n.ts._imageFrameEditor.tip }}</template>
+
+ <div class="_gaps">
+ <div class="_gaps_s">
+ <XImageFrameItem
+ v-for="(preset, i) in prefer.r.imageFramePresets.value"
+ :key="preset.id"
+ :preset="preset"
+ @updatePreset="onUpdateImageFramePreset(preset.id, $event)"
+ @del="onDeleteImageFramePreset(preset.id)"
+ />
+
+ <MkButton iconOnly rounded style="margin: 0 auto;" @click="addImageFramePreset"><i class="ti ti-plus"></i></MkButton>
+
+ <SearchMarker :keywords="['sync', 'frame', 'label', 'preset', 'devices']">
+ <MkSwitch :modelValue="imageFramePresetsSyncEnabled" @update:modelValue="changeImageFramePresetsSyncEnabled">
+ <template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts.syncBetweenDevices }}</SearchLabel></template>
+ </MkSwitch>
+ </SearchMarker>
+ </div>
+ </div>
+ </MkFolder>
+ </SearchMarker>
+
<SearchMarker :keywords="['default', 'image', 'compression']">
<MkPreferenceContainer k="defaultImageCompressionLevel">
<MkSelect
@@ -175,7 +203,9 @@ import { computed, defineAsyncComponent, ref } from 'vue';
import * as Misskey from 'misskey-js';
import tinycolor from 'tinycolor2';
import XWatermarkItem from './drive.WatermarkItem.vue';
-import type { WatermarkPreset } from '@/utility/watermark.js';
+import XImageFrameItem from './drive.ImageFrameItem.vue';
+import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
+import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
import FormLink from '@/components/form/link.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
@@ -195,6 +225,7 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { selectDriveFolder } from '@/utility/drive.js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
+import { genId } from '@/utility/id.js';
const $i = ensureSignin();
@@ -236,6 +267,20 @@ function changeWatermarkPresetsSyncEnabled(value: boolean) {
}
}
+const imageFramePresetsSyncEnabled = ref(prefer.isSyncEnabled('imageFramePresets'));
+
+function changeImageFramePresetsSyncEnabled(value: boolean) {
+ if (value) {
+ prefer.enableSync('imageFramePresets').then((res) => {
+ 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-effector/fxs/blockNoise.glsl b/packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl
index 84c4ecbed4..84c4ecbed4 100644
--- a/packages/frontend/src/utility/image-effector/fxs/blockNoise.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/blockNoise.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts b/packages/frontend/src/utility/image-compositor-functions/blockNoise.ts
index 355ab4536c..8c83ef51a0 100644
--- a/packages/frontend/src/utility/image-effector/fxs/blockNoise.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/blockNoise.ts
@@ -5,14 +5,42 @@
import seedrandom from 'seedrandom';
import shader from './blockNoise.glsl';
-import { defineImageEffectorFx } from '../ImageEffector.js';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_blockNoise = defineImageEffectorFx({
- id: 'blockNoise',
- name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
+export const fn = defineImageCompositorFunction<{
+ amount: number;
+ strength: number;
+ width: number;
+ height: number;
+ channelShift: number;
+ seed: number;
+}>({
shader,
- uniforms: ['amount', 'channelShift'] as const,
+ main: ({ gl, program, u, params }) => {
+ gl.uniform1i(u.amount, params.amount);
+ gl.uniform1f(u.channelShift, params.channelShift);
+
+ const margin = 0;
+
+ const rnd = seedrandom(params.seed.toString());
+
+ for (let i = 0; i < params.amount; i++) {
+ const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
+ gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin);
+
+ const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
+ gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
+
+ const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`);
+ gl.uniform2f(sizes, params.width, params.height);
+ }
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
params: {
amount: {
label: i18n.ts._imageEffector._fxProps.amount,
@@ -64,23 +92,4 @@ export const FX_blockNoise = defineImageEffectorFx({
default: 100,
},
},
- main: ({ gl, program, u, params }) => {
- gl.uniform1i(u.amount, params.amount);
- gl.uniform1f(u.channelShift, params.channelShift);
-
- const margin = 0;
-
- const rnd = seedrandom(params.seed.toString());
-
- for (let i = 0; i < params.amount; i++) {
- const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
- gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin);
-
- const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
- gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
-
- const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`);
- gl.uniform2f(sizes, params.width, params.height);
- }
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/blur.glsl b/packages/frontend/src/utility/image-compositor-functions/blur.glsl
index e591267887..e591267887 100644
--- a/packages/frontend/src/utility/image-effector/fxs/blur.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/blur.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/blur.ts b/packages/frontend/src/utility/image-compositor-functions/blur.ts
index 40f51fa646..1ab8eee6ba 100644
--- a/packages/frontend/src/utility/image-effector/fxs/blur.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/blur.ts
@@ -3,15 +3,33 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './blur.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_blur = defineImageEffectorFx({
- id: 'blur',
- name: i18n.ts._imageEffector._fxs.blur,
+export const fn = defineImageCompositorFunction<{
+ offsetX: number;
+ offsetY: number;
+ scaleX: number;
+ scaleY: number;
+ ellipse: boolean;
+ angle: number;
+ radius: number;
+}>({
shader,
- uniforms: ['offset', 'scale', 'ellipse', 'angle', 'radius', 'samples'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
+ gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
+ gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform1f(u.radius, params.radius);
+ gl.uniform1i(u.samples, 256);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.blur,
params: {
offsetX: {
label: i18n.ts._imageEffector._fxProps.offset + ' X',
@@ -72,12 +90,4 @@ export const FX_blur = defineImageEffectorFx({
step: 0.5,
},
},
- main: ({ gl, u, params }) => {
- gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
- gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
- gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
- gl.uniform1f(u.angle, params.angle / 2);
- gl.uniform1f(u.radius, params.radius);
- gl.uniform1i(u.samples, 256);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.glsl b/packages/frontend/src/utility/image-compositor-functions/checker.glsl
index 09d11c15d2..09d11c15d2 100644
--- a/packages/frontend/src/utility/image-effector/fxs/checker.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/checker.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/checker.ts b/packages/frontend/src/utility/image-compositor-functions/checker.ts
index 7d1938eeb7..e0476bb126 100644
--- a/packages/frontend/src/utility/image-effector/fxs/checker.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/checker.ts
@@ -3,15 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './checker.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_checker = defineImageEffectorFx({
- id: 'checker',
- name: i18n.ts._imageEffector._fxs.checker,
+export const fn = defineImageCompositorFunction<{
+ angle: number;
+ scale: number;
+ color: [number, number, number];
+ opacity: number;
+}>({
shader,
- uniforms: ['angle', 'scale', 'color', 'opacity'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform1f(u.scale, params.scale * params.scale);
+ gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
+ gl.uniform1f(u.opacity, params.opacity);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.checker,
params: {
angle: {
label: i18n.ts._imageEffector._fxProps.angle,
@@ -45,10 +58,4 @@ export const FX_checker = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.angle, params.angle / 2);
- gl.uniform1f(u.scale, params.scale * params.scale);
- gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
- gl.uniform1f(u.opacity, params.opacity);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl
index 60bb4f5318..60bb4f5318 100644
--- a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts
index ed4d134251..5e327dd6ac 100644
--- a/packages/frontend/src/utility/image-effector/fxs/chromaticAberration.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/chromaticAberration.ts
@@ -3,15 +3,24 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './chromaticAberration.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_chromaticAberration = defineImageEffectorFx({
- id: 'chromaticAberration',
- name: i18n.ts._imageEffector._fxs.chromaticAberration,
+export const fn = defineImageCompositorFunction<{
+ normalize: boolean;
+ amount: number;
+}>({
shader,
- uniforms: ['amount', 'start', 'normalize'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.amount, params.amount);
+ gl.uniform1i(u.normalize, params.normalize ? 1 : 0);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.chromaticAberration,
params: {
normalize: {
label: i18n.ts._imageEffector._fxProps.normalize,
@@ -27,8 +36,4 @@ export const FX_chromaticAberration = defineImageEffectorFx({
step: 0.01,
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.amount, params.amount);
- gl.uniform1i(u.normalize, params.normalize ? 1 : 0);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl
index 2d0c87ce95..2d0c87ce95 100644
--- a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts
index 989ca79a2c..33ca05ace7 100644
--- a/packages/frontend/src/utility/image-effector/fxs/colorAdjust.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/colorAdjust.ts
@@ -3,15 +3,30 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './colorAdjust.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_colorAdjust = defineImageEffectorFx({
- id: 'colorAdjust',
- name: i18n.ts._imageEffector._fxs.colorAdjust,
+export const fn = defineImageCompositorFunction<{
+ lightness: number;
+ contrast: number;
+ hue: number;
+ brightness: number;
+ saturation: number;
+}>({
shader,
- uniforms: ['lightness', 'contrast', 'hue', 'brightness', 'saturation'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.brightness, params.brightness);
+ gl.uniform1f(u.contrast, params.contrast);
+ gl.uniform1f(u.hue, params.hue / 2);
+ gl.uniform1f(u.lightness, params.lightness);
+ gl.uniform1f(u.saturation, params.saturation);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.colorAdjust,
params: {
lightness: {
label: i18n.ts._imageEffector._fxProps.lightness,
@@ -59,11 +74,4 @@ export const FX_colorAdjust = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.brightness, params.brightness);
- gl.uniform1f(u.contrast, params.contrast);
- gl.uniform1f(u.hue, params.hue / 2);
- gl.uniform1f(u.lightness, params.lightness);
- gl.uniform1f(u.saturation, params.saturation);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl b/packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl
index bf37f5ab43..bf37f5ab43 100644
--- a/packages/frontend/src/utility/image-effector/fxs/colorClamp.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/colorClamp.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts b/packages/frontend/src/utility/image-compositor-functions/colorClamp.ts
index f3513011fa..d4e7b786d0 100644
--- a/packages/frontend/src/utility/image-effector/fxs/colorClamp.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/colorClamp.ts
@@ -3,15 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './colorClamp.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_colorClamp = defineImageEffectorFx({
- id: 'colorClamp',
- name: i18n.ts._imageEffector._fxs.colorClamp,
+export const fn = defineImageCompositorFunction<{
+ max: number;
+ min: number;
+}>({
shader,
- uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.rMax, params.max);
+ gl.uniform1f(u.rMin, 1.0 + params.min);
+ gl.uniform1f(u.gMax, params.max);
+ gl.uniform1f(u.gMin, 1.0 + params.min);
+ gl.uniform1f(u.bMax, params.max);
+ gl.uniform1f(u.bMin, 1.0 + params.min);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.colorClamp,
params: {
max: {
label: i18n.ts._imageEffector._fxProps.max,
@@ -32,12 +45,4 @@ export const FX_colorClamp = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.rMax, params.max);
- gl.uniform1f(u.rMin, 1.0 + params.min);
- gl.uniform1f(u.gMax, params.max);
- gl.uniform1f(u.gMin, 1.0 + params.min);
- gl.uniform1f(u.bMax, params.max);
- gl.uniform1f(u.bMin, 1.0 + params.min);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts b/packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts
index 397e16c1ba..492524ec06 100644
--- a/packages/frontend/src/utility/image-effector/fxs/colorClampAdvanced.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/colorClampAdvanced.ts
@@ -3,15 +3,32 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './colorClamp.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_colorClampAdvanced = defineImageEffectorFx({
- id: 'colorClampAdvanced',
- name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
+export const fn = defineImageCompositorFunction<{
+ rMax: number;
+ rMin: number;
+ gMax: number;
+ gMin: number;
+ bMax: number;
+ bMin: number;
+}>({
shader,
- uniforms: ['rMax', 'rMin', 'gMax', 'gMin', 'bMax', 'bMin'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.rMax, params.rMax);
+ gl.uniform1f(u.rMin, 1.0 + params.rMin);
+ gl.uniform1f(u.gMax, params.gMax);
+ gl.uniform1f(u.gMin, 1.0 + params.gMin);
+ gl.uniform1f(u.bMax, params.bMax);
+ gl.uniform1f(u.bMin, 1.0 + params.bMin);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
params: {
rMax: {
label: `${i18n.ts._imageEffector._fxProps.max} (${i18n.ts._imageEffector._fxProps.redComponent})`,
@@ -68,12 +85,4 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.rMax, params.rMax);
- gl.uniform1f(u.rMin, 1.0 + params.rMin);
- gl.uniform1f(u.gMax, params.gMax);
- gl.uniform1f(u.gMin, 1.0 + params.gMin);
- gl.uniform1f(u.bMax, params.bMax);
- gl.uniform1f(u.bMin, 1.0 + params.bMin);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.glsl b/packages/frontend/src/utility/image-compositor-functions/distort.glsl
index 7e0d1e3252..7e0d1e3252 100644
--- a/packages/frontend/src/utility/image-effector/fxs/distort.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/distort.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/distort.ts b/packages/frontend/src/utility/image-compositor-functions/distort.ts
index 3ea93a0266..bd0fcdf42f 100644
--- a/packages/frontend/src/utility/image-effector/fxs/distort.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/distort.ts
@@ -3,15 +3,28 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './distort.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_distort = defineImageEffectorFx({
- id: 'distort',
- name: i18n.ts._imageEffector._fxs.distort,
+export const fn = defineImageCompositorFunction<{
+ direction: number;
+ phase: number;
+ frequency: number;
+ strength: number;
+}>({
shader,
- uniforms: ['phase', 'frequency', 'strength', 'direction'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.phase, params.phase);
+ gl.uniform1f(u.frequency, params.frequency);
+ gl.uniform1f(u.strength, params.strength);
+ gl.uniform1i(u.direction, params.direction);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.distort,
params: {
direction: {
label: i18n.ts._imageEffector._fxProps.direction,
@@ -49,10 +62,4 @@ export const FX_distort = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.phase, params.phase);
- gl.uniform1f(u.frequency, params.frequency);
- gl.uniform1f(u.strength, params.strength);
- gl.uniform1i(u.direction, params.direction);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/fill.glsl b/packages/frontend/src/utility/image-compositor-functions/fill.glsl
index f04dc5545a..f04dc5545a 100644
--- a/packages/frontend/src/utility/image-effector/fxs/fill.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/fill.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/fill.ts b/packages/frontend/src/utility/image-compositor-functions/fill.ts
index 772cd76cf7..901bdadfe5 100644
--- a/packages/frontend/src/utility/image-effector/fxs/fill.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/fill.ts
@@ -3,15 +3,34 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './fill.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_fill = defineImageEffectorFx({
- id: 'fill',
- name: i18n.ts._imageEffector._fxs.fill,
+export const fn = defineImageCompositorFunction<{
+ offsetX: number;
+ offsetY: number;
+ scaleX: number;
+ scaleY: number;
+ ellipse: boolean;
+ angle: number;
+ color: [number, number, number];
+ opacity: number;
+}>({
shader,
- uniforms: ['offset', 'scale', 'ellipse', 'angle', 'color', 'opacity'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
+ gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
+ gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
+ gl.uniform1f(u.opacity, params.opacity);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.fill,
params: {
offsetX: {
label: i18n.ts._imageEffector._fxProps.offset + ' X',
@@ -78,12 +97,4 @@ export const FX_fill = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
- main: ({ gl, u, params }) => {
- gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
- gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
- gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
- gl.uniform1f(u.angle, params.angle / 2);
- gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
- gl.uniform1f(u.opacity, params.opacity);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/grayscale.glsl b/packages/frontend/src/utility/image-compositor-functions/grayscale.glsl
index 54ca719976..54ca719976 100644
--- a/packages/frontend/src/utility/image-effector/fxs/grayscale.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/grayscale.glsl
diff --git a/packages/frontend/src/utility/image-compositor-functions/grayscale.ts b/packages/frontend/src/utility/image-compositor-functions/grayscale.ts
new file mode 100644
index 0000000000..b6860de0a2
--- /dev/null
+++ b/packages/frontend/src/utility/image-compositor-functions/grayscale.ts
@@ -0,0 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import shader from './grayscale.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
+import { i18n } from '@/i18n.js';
+
+export const fn = defineImageCompositorFunction({
+ shader,
+ main: ({ gl, u, params }) => {
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.grayscale,
+ params: {
+ },
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/invert.glsl b/packages/frontend/src/utility/image-compositor-functions/invert.glsl
index a2d1574f5b..a2d1574f5b 100644
--- a/packages/frontend/src/utility/image-effector/fxs/invert.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/invert.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/invert.ts b/packages/frontend/src/utility/image-compositor-functions/invert.ts
index 9417047931..f64e68034e 100644
--- a/packages/frontend/src/utility/image-effector/fxs/invert.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/invert.ts
@@ -3,15 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './invert.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_invert = defineImageEffectorFx({
- id: 'invert',
- name: i18n.ts._imageEffector._fxs.invert,
+export const fn = defineImageCompositorFunction<{
+ r: boolean;
+ g: boolean;
+ b: boolean;
+}>({
shader,
- uniforms: ['r', 'g', 'b'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1i(u.r, params.r ? 1 : 0);
+ gl.uniform1i(u.g, params.g ? 1 : 0);
+ gl.uniform1i(u.b, params.b ? 1 : 0);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.invert,
params: {
r: {
label: i18n.ts._imageEffector._fxProps.redComponent,
@@ -29,9 +40,4 @@ export const FX_invert = defineImageEffectorFx({
default: true,
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1i(u.r, params.r ? 1 : 0);
- gl.uniform1i(u.g, params.g ? 1 : 0);
- gl.uniform1i(u.b, params.b ? 1 : 0);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.glsl b/packages/frontend/src/utility/image-compositor-functions/mirror.glsl
index b27934e9ef..b27934e9ef 100644
--- a/packages/frontend/src/utility/image-effector/fxs/mirror.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/mirror.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/mirror.ts b/packages/frontend/src/utility/image-compositor-functions/mirror.ts
index 6515454ead..47d19c0553 100644
--- a/packages/frontend/src/utility/image-effector/fxs/mirror.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/mirror.ts
@@ -3,15 +3,24 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './mirror.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_mirror = defineImageEffectorFx({
- id: 'mirror',
- name: i18n.ts._imageEffector._fxs.mirror,
+export const fn = defineImageCompositorFunction<{
+ h: number;
+ v: number;
+}>({
shader,
- uniforms: ['h', 'v'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1i(u.h, params.h);
+ gl.uniform1i(u.v, params.v);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.mirror,
params: {
h: {
label: i18n.ts.horizontal,
@@ -19,7 +28,7 @@ export const FX_mirror = defineImageEffectorFx({
enum: [
{ value: -1 as const, icon: 'ti ti-arrow-bar-right' },
{ value: 0 as const, icon: 'ti ti-minus-vertical' },
- { value: 1 as const, icon: 'ti ti-arrow-bar-left' }
+ { value: 1 as const, icon: 'ti ti-arrow-bar-left' },
],
default: -1,
},
@@ -29,13 +38,9 @@ export const FX_mirror = defineImageEffectorFx({
enum: [
{ value: -1 as const, icon: 'ti ti-arrow-bar-down' },
{ value: 0 as const, icon: 'ti ti-minus' },
- { value: 1 as const, icon: 'ti ti-arrow-bar-up' }
+ { value: 1 as const, icon: 'ti ti-arrow-bar-up' },
],
default: 0,
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1i(u.h, params.h);
- gl.uniform1i(u.v, params.v);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/pixelate.glsl b/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl
index 4de3f27397..4de3f27397 100644
--- a/packages/frontend/src/utility/image-effector/fxs/pixelate.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/pixelate.ts b/packages/frontend/src/utility/image-compositor-functions/pixelate.ts
index e3eef49b23..249d272e7e 100644
--- a/packages/frontend/src/utility/image-effector/fxs/pixelate.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/pixelate.ts
@@ -3,15 +3,33 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './pixelate.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_pixelate = defineImageEffectorFx({
- id: 'pixelate',
- name: i18n.ts._imageEffector._fxs.pixelate,
+export const fn = defineImageCompositorFunction<{
+ offsetX: number;
+ offsetY: number;
+ scaleX: number;
+ scaleY: number;
+ ellipse: boolean;
+ angle: number;
+ strength: number;
+}>({
shader,
- uniforms: ['offset', 'scale', 'ellipse', 'angle', 'strength', 'samples'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
+ gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
+ gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform1f(u.strength, params.strength * params.strength);
+ gl.uniform1i(u.samples, 256);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.pixelate,
params: {
offsetX: {
label: i18n.ts._imageEffector._fxProps.offset + ' X',
@@ -72,12 +90,4 @@ export const FX_pixelate = defineImageEffectorFx({
step: 0.01,
},
},
- main: ({ gl, u, params }) => {
- gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
- gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
- gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
- gl.uniform1f(u.angle, params.angle / 2);
- gl.uniform1f(u.strength, params.strength * params.strength);
- gl.uniform1i(u.samples, 256);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.glsl b/packages/frontend/src/utility/image-compositor-functions/polkadot.glsl
index 39ecad34b5..39ecad34b5 100644
--- a/packages/frontend/src/utility/image-effector/fxs/polkadot.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/polkadot.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts b/packages/frontend/src/utility/image-compositor-functions/polkadot.ts
index 521e08cc7b..d94d704be3 100644
--- a/packages/frontend/src/utility/image-effector/fxs/polkadot.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/polkadot.ts
@@ -3,16 +3,36 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './polkadot.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-// Primarily used for watermark
-export const FX_polkadot = defineImageEffectorFx({
- id: 'polkadot',
- name: i18n.ts._imageEffector._fxs.polkadot,
+export const fn = defineImageCompositorFunction<{
+ angle: number;
+ scale: number;
+ majorRadius: number;
+ majorOpacity: number;
+ minorDivisions: number;
+ minorRadius: number;
+ minorOpacity: number;
+ color: [number, number, number];
+}>({
shader,
- uniforms: ['angle', 'scale', 'major_radius', 'major_opacity', 'minor_divisions', 'minor_radius', 'minor_opacity', 'color'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform1f(u.scale, params.scale * params.scale);
+ gl.uniform1f(u.major_radius, params.majorRadius);
+ gl.uniform1f(u.major_opacity, params.majorOpacity);
+ gl.uniform1f(u.minor_divisions, params.minorDivisions);
+ gl.uniform1f(u.minor_radius, params.minorRadius);
+ gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
+ gl.uniform1f(u.minor_opacity, params.minorOpacity);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.polkadot,
params: {
angle: {
label: i18n.ts._imageEffector._fxProps.angle,
@@ -79,14 +99,4 @@ export const FX_polkadot = defineImageEffectorFx({
default: [1, 1, 1],
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.angle, params.angle / 2);
- gl.uniform1f(u.scale, params.scale * params.scale);
- gl.uniform1f(u.major_radius, params.majorRadius);
- gl.uniform1f(u.major_opacity, params.majorOpacity);
- gl.uniform1f(u.minor_divisions, params.minorDivisions);
- gl.uniform1f(u.minor_radius, params.minorRadius);
- gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
- gl.uniform1f(u.minor_opacity, params.minorOpacity);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.glsl b/packages/frontend/src/utility/image-compositor-functions/stripe.glsl
index bb18d8fcb8..bb18d8fcb8 100644
--- a/packages/frontend/src/utility/image-effector/fxs/stripe.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/stripe.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/stripe.ts b/packages/frontend/src/utility/image-compositor-functions/stripe.ts
index 3a6ecf970c..d429a124bc 100644
--- a/packages/frontend/src/utility/image-effector/fxs/stripe.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/stripe.ts
@@ -3,16 +3,31 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './stripe.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-// Primarily used for watermark
-export const FX_stripe = defineImageEffectorFx({
- id: 'stripe',
- name: i18n.ts._imageEffector._fxs.stripe,
+export const fn = defineImageCompositorFunction<{
+ angle: number;
+ frequency: number;
+ threshold: number;
+ color: [number, number, number];
+ opacity: number;
+}>({
shader,
- uniforms: ['angle', 'frequency', 'phase', 'threshold', 'color', 'opacity'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.angle, params.angle / 2);
+ gl.uniform1f(u.frequency, params.frequency * params.frequency);
+ gl.uniform1f(u.phase, 0.0);
+ gl.uniform1f(u.threshold, params.threshold);
+ gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
+ gl.uniform1f(u.opacity, params.opacity);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.stripe,
params: {
angle: {
label: i18n.ts._imageEffector._fxProps.angle,
@@ -55,12 +70,4 @@ export const FX_stripe = defineImageEffectorFx({
toViewValue: v => Math.round(v * 100) + '%',
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.angle, params.angle / 2);
- gl.uniform1f(u.frequency, params.frequency * params.frequency);
- gl.uniform1f(u.phase, 0.0);
- gl.uniform1f(u.threshold, params.threshold);
- gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
- gl.uniform1f(u.opacity, params.opacity);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/tearing.glsl b/packages/frontend/src/utility/image-compositor-functions/tearing.glsl
index 3fb2fc2cad..3fb2fc2cad 100644
--- a/packages/frontend/src/utility/image-effector/fxs/tearing.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/tearing.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/tearing.ts b/packages/frontend/src/utility/image-compositor-functions/tearing.ts
index 453b16bb19..66c61b7ca8 100644
--- a/packages/frontend/src/utility/image-effector/fxs/tearing.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/tearing.ts
@@ -5,14 +5,39 @@
import seedrandom from 'seedrandom';
import shader from './tearing.glsl';
-import { defineImageEffectorFx } from '../ImageEffector.js';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_tearing = defineImageEffectorFx({
- id: 'tearing',
- name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing,
+export const fn = defineImageCompositorFunction<{
+ amount: number;
+ strength: number;
+ size: number;
+ channelShift: number;
+ seed: number;
+}>({
shader,
- uniforms: ['amount', 'channelShift'] as const,
+ main: ({ gl, program, u, params }) => {
+ gl.uniform1i(u.amount, params.amount);
+ gl.uniform1f(u.channelShift, params.channelShift);
+
+ const rnd = seedrandom(params.seed.toString());
+
+ for (let i = 0; i < params.amount; i++) {
+ const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
+ gl.uniform1f(o, rnd());
+
+ const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
+ gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
+
+ const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`);
+ gl.uniform1f(h, rnd() * params.size);
+ }
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing,
params: {
amount: {
label: i18n.ts._imageEffector._fxProps.amount,
@@ -55,21 +80,4 @@ export const FX_tearing = defineImageEffectorFx({
default: 100,
},
},
- main: ({ gl, program, u, params }) => {
- gl.uniform1i(u.amount, params.amount);
- gl.uniform1f(u.channelShift, params.channelShift);
-
- const rnd = seedrandom(params.seed.toString());
-
- for (let i = 0; i < params.amount; i++) {
- const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
- gl.uniform1f(o, rnd());
-
- const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
- gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
-
- const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`);
- gl.uniform1f(h, rnd() * params.size);
- }
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.glsl b/packages/frontend/src/utility/image-compositor-functions/threshold.glsl
index 5ca8c46c39..5ca8c46c39 100644
--- a/packages/frontend/src/utility/image-effector/fxs/threshold.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/threshold.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/threshold.ts b/packages/frontend/src/utility/image-compositor-functions/threshold.ts
index d0bb8305ae..83ea788771 100644
--- a/packages/frontend/src/utility/image-effector/fxs/threshold.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/threshold.ts
@@ -3,15 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './threshold.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_threshold = defineImageEffectorFx({
- id: 'threshold',
- name: i18n.ts._imageEffector._fxs.threshold,
+export const fn = defineImageCompositorFunction<{
+ r: number;
+ g: number;
+ b: number;
+}>({
shader,
- uniforms: ['r', 'g', 'b'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform1f(u.r, params.r);
+ gl.uniform1f(u.g, params.g);
+ gl.uniform1f(u.b, params.b);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.threshold,
params: {
r: {
label: i18n.ts._imageEffector._fxProps.redComponent,
@@ -38,9 +49,4 @@ export const FX_threshold = defineImageEffectorFx({
step: 0.01,
},
},
- main: ({ gl, u, params }) => {
- gl.uniform1f(u.r, params.r);
- gl.uniform1f(u.g, params.g);
- gl.uniform1f(u.b, params.b);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl b/packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl
index a0f11fcb5b..a0f11fcb5b 100644
--- a/packages/frontend/src/utility/image-effector/fxs/zoomLines.glsl
+++ b/packages/frontend/src/utility/image-compositor-functions/zoomLines.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts b/packages/frontend/src/utility/image-compositor-functions/zoomLines.ts
index 8c0956d24e..f8768e4ec3 100644
--- a/packages/frontend/src/utility/image-effector/fxs/zoomLines.ts
+++ b/packages/frontend/src/utility/image-compositor-functions/zoomLines.ts
@@ -3,15 +3,34 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './zoomLines.glsl';
+import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
import { i18n } from '@/i18n.js';
-export const FX_zoomLines = defineImageEffectorFx({
- id: 'zoomLines',
- name: i18n.ts._imageEffector._fxs.zoomLines,
+export const fn = defineImageCompositorFunction<{
+ x: number;
+ y: number;
+ frequency: number;
+ smoothing: boolean;
+ threshold: number;
+ maskSize: number;
+ black: boolean;
+}>({
shader,
- uniforms: ['pos', 'frequency', 'thresholdEnabled', 'threshold', 'maskSize', 'black'] as const,
+ main: ({ gl, u, params }) => {
+ gl.uniform2f(u.pos, params.x / 2, params.y / 2);
+ gl.uniform1f(u.frequency, params.frequency * params.frequency);
+ // thresholdの調整が有効な間はsmoothingが利用できない
+ gl.uniform1i(u.thresholdEnabled, params.smoothing ? 0 : 1);
+ gl.uniform1f(u.threshold, params.threshold);
+ gl.uniform1f(u.maskSize, params.maskSize);
+ gl.uniform1i(u.black, params.black ? 1 : 0);
+ },
+});
+
+export const uiDefinition = {
+ name: i18n.ts._imageEffector._fxs.zoomLines,
params: {
x: {
label: i18n.ts._imageEffector._fxProps.centerX,
@@ -65,13 +84,4 @@ export const FX_zoomLines = defineImageEffectorFx({
default: false,
},
},
- main: ({ gl, u, params }) => {
- gl.uniform2f(u.pos, params.x / 2, params.y / 2);
- gl.uniform1f(u.frequency, params.frequency * params.frequency);
- // thresholdの調整が有効な間はsmoothingが利用できない
- gl.uniform1i(u.thresholdEnabled, params.smoothing ? 0 : 1);
- gl.uniform1f(u.threshold, params.threshold);
- gl.uniform1f(u.maskSize, params.maskSize);
- gl.uniform1i(u.black, params.black ? 1 : 0);
- },
-});
+} satisfies ImageEffectorUiDefinition<typeof fn>;
diff --git a/packages/frontend/src/utility/image-effector/ImageEffector.ts b/packages/frontend/src/utility/image-effector/ImageEffector.ts
index 26c74bfae5..b4295c4637 100644
--- a/packages/frontend/src/utility/image-effector/ImageEffector.ts
+++ b/packages/frontend/src/utility/image-effector/ImageEffector.ts
@@ -3,18 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import QRCodeStyling from 'qr-code-styling';
-import { url, host } from '@@/js/config.js';
-import { getProxiedImageUrl } from '../media-proxy.js';
-import { initShaderProgram } from '../webgl.js';
-import { ensureSignin } from '@/i.js';
+import { FXS } from './fxs.js';
+import type { ImageCompositorFunction, ImageCompositorLayer } from '@/lib/ImageCompositor.js';
+import { ImageCompositor } from '@/lib/ImageCompositor.js';
export type ImageEffectorRGB = [r: number, g: number, b: number];
-type ParamTypeToPrimitive = {
- [K in ImageEffectorFxParamDef['type']]: (ImageEffectorFxParamDef & { type: K })['default'];
-};
-
interface CommonParamDef {
type: string;
label?: string;
@@ -60,479 +54,77 @@ interface SeedParamDef extends CommonParamDef {
default: number;
};
-interface TextureParamDef extends CommonParamDef {
- type: 'texture';
- default: {
- type: 'text'; text: string | null;
- } | {
- type: 'url'; url: string | null;
- } | {
- type: 'qr'; data: string | null;
- } | null;
-};
-
interface ColorParamDef extends CommonParamDef {
type: 'color';
default: ImageEffectorRGB;
};
-type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | TextureParamDef | ColorParamDef;
+type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | ColorParamDef;
export type ImageEffectorFxParamDefs = Record<string, ImageEffectorFxParamDef>;
-export type GetParamType<T extends ImageEffectorFxParamDef> =
- T extends NumberEnumParamDef
- ? T['enum'][number]['value']
- : ParamTypeToPrimitive[T['type']];
-
-export type ParamsRecordTypeToDefRecord<PS extends ImageEffectorFxParamDefs> = {
- [K in keyof PS]: GetParamType<PS[K]>;
-};
-
-export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
- return fx;
-}
+export type ImageEffectorLayer = {
+ [K in keyof typeof FXS]: {
+ id: string;
+ fxId: K;
+ params: Parameters<(typeof FXS)[K]['fn']['main']>[0]['params'];
+ };
+}[keyof typeof FXS];
-export type ImageEffectorFx<ID extends string = string, PS extends ImageEffectorFxParamDefs = ImageEffectorFxParamDefs, US extends string[] = string[]> = {
- id: ID;
+export type ImageEffectorUiDefinition<Fn extends ImageCompositorFunction<any> = ImageCompositorFunction> = {
name: string;
- shader: string;
- uniforms: US;
- params: PS,
- main: (ctx: {
- gl: WebGL2RenderingContext;
- program: WebGLProgram;
- params: ParamsRecordTypeToDefRecord<PS>;
- u: Record<US[number], WebGLUniformLocation>;
- width: number;
- height: number;
- textures: Record<string, {
- texture: WebGLTexture;
- width: number;
- height: number;
- } | null>;
- }) => void;
-};
-
-export type ImageEffectorLayer = {
- id: string;
- fxId: string;
- params: Record<string, any>;
+ params: Fn extends ImageCompositorFunction<infer P> ? {
+ [K in keyof P]: ImageEffectorFxParamDef;
+ } : never;
};
-function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, any>, k: string): ParamTypeToPrimitive[T] {
- return params[k];
-}
+type ImageEffectorImageCompositor = ImageCompositor<{
+ [K in keyof typeof FXS]: typeof FXS[K]['fn'];
+}>;
-export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, any>>> {
- private gl: WebGL2RenderingContext;
+export class ImageEffector {
private canvas: HTMLCanvasElement | null = null;
- private renderWidth: number;
- private renderHeight: number;
- private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
- private layers: ImageEffectorLayer[] = [];
- private originalImageTexture: WebGLTexture;
- private shaderCache: Map<string, WebGLProgram> = new Map();
- private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
- private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
- private nopProgram: WebGLProgram;
- private fxs: [...IEX];
- private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
+ private compositor: ImageEffectorImageCompositor;
constructor(options: {
canvas: HTMLCanvasElement;
renderWidth: number;
renderHeight: number;
- image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
- fxs: [...IEX];
+ image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null;
}) {
this.canvas = options.canvas;
- this.renderWidth = options.renderWidth;
- this.renderHeight = options.renderHeight;
- this.originalImage = options.image;
- this.fxs = options.fxs;
-
- this.canvas.width = this.renderWidth;
- this.canvas.height = this.renderHeight;
-
- const gl = this.canvas.getContext('webgl2', {
- preserveDrawingBuffer: false,
- alpha: true,
- premultipliedAlpha: false,
- });
-
- if (gl == null) {
- throw new Error('Failed to initialize WebGL2 context');
- }
-
- this.gl = gl;
-
- gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
-
- const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
- const vertexBuffer = gl.createBuffer();
- gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
- gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
-
- this.originalImageTexture = createTexture(gl);
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.originalImage.width, this.originalImage.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage);
- gl.bindTexture(gl.TEXTURE_2D, null);
-
- this.nopProgram = initShaderProgram(this.gl, `#version 300 es
- in vec2 position;
- out vec2 in_uv;
-
- void main() {
- in_uv = (position + 1.0) / 2.0;
- gl_Position = vec4(position * vec2(1.0, -1.0), 0.0, 1.0);
- }
- `, `#version 300 es
- precision mediump float;
- in vec2 in_uv;
- uniform sampler2D u_texture;
- out vec4 out_color;
-
- void main() {
- out_color = texture(u_texture, in_uv);
- }
- `);
-
- // レジスタ番号はシェーダープログラムに属しているわけではなく、独立の存在なので、とりあえず nopProgram を使って設定する(その後は効果が持続する)
- // ref. https://qiita.com/emadurandal/items/5966c8374f03d4de3266
- const positionLocation = gl.getAttribLocation(this.nopProgram, 'position');
- gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
- gl.enableVertexAttribArray(positionLocation);
- }
-
- private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture, invert = false) {
- const gl = this.gl;
-
- const fx = this.fxs.find(fx => fx.id === layer.fxId);
- if (fx == null) return;
-
- const cachedShader = this.shaderCache.get(fx.id);
- const shaderProgram = cachedShader ?? initShaderProgram(this.gl, `#version 300 es
- in vec2 position;
- uniform bool u_invert;
- out vec2 in_uv;
-
- void main() {
- in_uv = (position + 1.0) / 2.0;
- gl_Position = u_invert ? vec4(position * vec2(1.0, -1.0), 0.0, 1.0) : vec4(position, 0.0, 1.0);
- }
- `, fx.shader);
- if (cachedShader == null) {
- this.shaderCache.set(fx.id, shaderProgram);
- }
-
- gl.useProgram(shaderProgram);
-
- const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
- gl.uniform2fv(in_resolution, [this.renderWidth, this.renderHeight]);
-
- const u_invert = gl.getUniformLocation(shaderProgram, 'u_invert');
- gl.uniform1i(u_invert, invert ? 1 : 0);
-
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, preTexture);
- const in_texture = gl.getUniformLocation(shaderProgram, 'in_texture');
- gl.uniform1i(in_texture, 0);
-
- fx.main({
- gl: gl,
- program: shaderProgram,
- params: Object.fromEntries(
- Object.entries(fx.params as ImageEffectorFxParamDefs).map(([key, param]) => {
- return [key, layer.params[key] ?? param.default];
- }),
- ),
- u: Object.fromEntries(fx.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
- width: this.renderWidth,
- height: this.renderHeight,
- textures: Object.fromEntries(
- Object.entries(fx.params as ImageEffectorFxParamDefs).map(([k, v]) => {
- if (v.type !== 'texture') return [k, null];
- const param = getValue<typeof v.type>(layer.params, k);
- if (param == null) return [k, null];
- const texture = this.paramTextures.get(this.getTextureKeyForParam(param)) ?? null;
- return [k, texture];
- })),
+ this.compositor = new ImageCompositor({
+ canvas: this.canvas,
+ renderWidth: options.renderWidth,
+ renderHeight: options.renderHeight,
+ image: options.image,
+ functions: Object.fromEntries(Object.entries(FXS).map(([fxId, fx]) => [fxId, fx.fn])),
});
-
- gl.drawArrays(gl.TRIANGLES, 0, 6);
- }
-
- public render() {
- const gl = this.gl;
-
- // 入力をそのまま出力
- if (this.layers.length === 0) {
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
-
- gl.useProgram(this.nopProgram);
- gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0);
-
- gl.drawArrays(gl.TRIANGLES, 0, 6);
- return;
- }
-
- let preTexture = this.originalImageTexture;
-
- for (const layer of this.layers) {
- const isLast = layer === this.layers.at(-1);
-
- const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
- const resultTexture = cachedResultTexture ?? createTexture(gl);
- if (cachedResultTexture == null) {
- this.perLayerResultTextures.set(layer.id, resultTexture);
- }
- gl.bindTexture(gl.TEXTURE_2D, resultTexture);
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
- gl.bindTexture(gl.TEXTURE_2D, null);
-
- if (isLast) {
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
- } else {
- const cachedResultFrameBuffer = this.perLayerResultFrameBuffers.get(layer.id);
- const resultFrameBuffer = cachedResultFrameBuffer ?? gl.createFramebuffer()!;
- if (cachedResultFrameBuffer == null) {
- this.perLayerResultFrameBuffers.set(layer.id, resultFrameBuffer);
- }
- gl.bindFramebuffer(gl.FRAMEBUFFER, resultFrameBuffer);
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, resultTexture, 0);
- }
-
- this.renderLayer(layer, preTexture, isLast);
-
- preTexture = resultTexture;
- }
}
- public async setLayers(layers: ImageEffectorLayer[]) {
- this.layers = layers;
-
- const unused = new Set(this.paramTextures.keys());
+ public async render(layers: ImageEffectorLayer[]) {
+ const compositorLayers: Parameters<ImageCompositor<any>['render']>[0] = [];
for (const layer of layers) {
- const fx = this.fxs.find(fx => fx.id === layer.fxId);
- if (fx == null) continue;
-
- for (const k of Object.keys(layer.params)) {
- const paramDef = fx.params[k];
- if (paramDef == null) continue;
- if (paramDef.type !== 'texture') continue;
- const v = getValue<typeof paramDef.type>(layer.params, k);
- if (v == null) continue;
-
- const textureKey = this.getTextureKeyForParam(v);
- unused.delete(textureKey);
- if (this.paramTextures.has(textureKey)) continue;
-
- if (_DEV_) console.log(`Baking texture of <${textureKey}>...`);
-
- const texture =
- v.type === 'text' ? await createTextureFromText(this.gl, v.text) :
- v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) :
- v.type === 'qr' ? await createTextureFromQr(this.gl, { data: v.data }) :
- null;
- if (texture == null) continue;
-
- this.paramTextures.set(textureKey, texture);
- }
+ compositorLayers.push({
+ id: layer.id,
+ functionId: layer.fxId,
+ params: layer.params,
+ });
}
- for (const k of unused) {
- if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
- this.gl.deleteTexture(this.paramTextures.get(k)!.texture);
- this.paramTextures.delete(k);
- }
-
- this.render();
+ this.compositor.render(compositorLayers as Parameters<ImageEffectorImageCompositor['render']>[0]);
}
public changeResolution(width: number, height: number) {
- this.renderWidth = width;
- this.renderHeight = height;
- if (this.canvas) {
- this.canvas.width = this.renderWidth;
- this.canvas.height = this.renderHeight;
- }
- this.gl.viewport(0, 0, this.renderWidth, this.renderHeight);
- }
-
- private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) {
- if (v == null) return '';
- return (
- v.type === 'text' ? `text:${v.text}` :
- v.type === 'url' ? `url:${v.url}` :
- v.type === 'qr' ? `qr:${v.data}` :
- ''
- );
+ this.compositor.changeResolution(width, height);
}
/*
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
*/
public destroy(disposeCanvas = true) {
- this.gl.deleteProgram(this.nopProgram);
-
- for (const shader of this.shaderCache.values()) {
- this.gl.deleteProgram(shader);
- }
- this.shaderCache.clear();
-
- for (const texture of this.perLayerResultTextures.values()) {
- this.gl.deleteTexture(texture);
- }
- this.perLayerResultTextures.clear();
-
- for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
- this.gl.deleteFramebuffer(framebuffer);
- }
- this.perLayerResultFrameBuffers.clear();
-
- for (const texture of this.paramTextures.values()) {
- this.gl.deleteTexture(texture.texture);
- }
- this.paramTextures.clear();
-
- this.gl.deleteTexture(this.originalImageTexture);
-
- if (disposeCanvas) {
- const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
- if (loseContextExt) loseContextExt.loseContext();
- }
+ this.compositor.destroy(disposeCanvas);
}
}
-
-function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
- const texture = gl.createTexture();
- gl.bindTexture(gl.TEXTURE_2D, texture);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
- gl.bindTexture(gl.TEXTURE_2D, null);
- return texture;
-}
-
-async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string | null): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
- if (imageUrl == null || imageUrl.trim() === '') return null;
-
- const image = await new Promise<HTMLImageElement>((resolve, reject) => {
- const img = new Image();
- img.onload = () => resolve(img);
- img.onerror = reject;
- img.src = getProxiedImageUrl(imageUrl); // CORS対策
- }).catch(() => null);
-
- if (image == null) return null;
-
- const texture = createTexture(gl);
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, texture);
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
- gl.bindTexture(gl.TEXTURE_2D, null);
-
- return {
- texture,
- width: image.width,
- height: image.height,
- };
-}
-
-async function createTextureFromText(gl: WebGL2RenderingContext, text: string | null, resolution = 2048): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
- if (text == null || text.trim() === '') return null;
-
- const ctx = window.document.createElement('canvas').getContext('2d')!;
- ctx.canvas.width = resolution;
- ctx.canvas.height = resolution / 4;
- const fontSize = resolution / 32;
- const margin = fontSize / 2;
- ctx.shadowColor = '#000000';
- ctx.shadowBlur = fontSize / 4;
-
- //ctx.fillStyle = '#00ff00';
- //ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-
- ctx.fillStyle = '#ffffff';
- ctx.font = `bold ${fontSize}px sans-serif`;
- ctx.textBaseline = 'middle';
-
- ctx.fillText(text, margin, ctx.canvas.height / 2);
-
- const textMetrics = ctx.measureText(text);
- const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin);
- const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin);
- const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), ctx.canvas.width, ctx.canvas.height);
-
- const texture = createTexture(gl);
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, texture);
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cropWidth, cropHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
- gl.bindTexture(gl.TEXTURE_2D, null);
-
- const info = {
- texture: texture,
- width: cropWidth,
- height: cropHeight,
- };
-
- ctx.canvas.remove();
-
- return info;
-}
-
-async function createTextureFromQr(gl: WebGL2RenderingContext, options: { data: string | null }, resolution = 512): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
- const $i = ensureSignin();
-
- const qrCodeInstance = new QRCodeStyling({
- width: resolution,
- height: resolution,
- margin: 42,
- type: 'canvas',
- data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
- image: $i.avatarUrl,
- qrOptions: {
- typeNumber: 0,
- mode: 'Byte',
- errorCorrectionLevel: 'H',
- },
- imageOptions: {
- hideBackgroundDots: true,
- imageSize: 0.3,
- margin: 16,
- crossOrigin: 'anonymous',
- },
- dotsOptions: {
- type: 'dots',
- },
- cornersDotOptions: {
- type: 'dot',
- },
- cornersSquareOptions: {
- type: 'extra-rounded',
- },
- });
-
- const blob = await qrCodeInstance.getRawData('png') as Blob | null;
- if (blob == null) return null;
-
- const image = await window.createImageBitmap(blob);
-
- const texture = createTexture(gl);
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, texture);
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, resolution, resolution, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
- gl.bindTexture(gl.TEXTURE_2D, null);
-
- return {
- texture,
- width: resolution,
- height: resolution,
- };
-}
diff --git a/packages/frontend/src/utility/image-effector/fxs.ts b/packages/frontend/src/utility/image-effector/fxs.ts
index 2b20cc1f99..1fd0ad6ed7 100644
--- a/packages/frontend/src/utility/image-effector/fxs.ts
+++ b/packages/frontend/src/utility/image-effector/fxs.ts
@@ -3,43 +3,47 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { FX_checker } from './fxs/checker.js';
-import { FX_chromaticAberration } from './fxs/chromaticAberration.js';
-import { FX_colorAdjust } from './fxs/colorAdjust.js';
-import { FX_colorClamp } from './fxs/colorClamp.js';
-import { FX_colorClampAdvanced } from './fxs/colorClampAdvanced.js';
-import { FX_distort } from './fxs/distort.js';
-import { FX_polkadot } from './fxs/polkadot.js';
-import { FX_tearing } from './fxs/tearing.js';
-import { FX_grayscale } from './fxs/grayscale.js';
-import { FX_invert } from './fxs/invert.js';
-import { FX_mirror } from './fxs/mirror.js';
-import { FX_stripe } from './fxs/stripe.js';
-import { FX_threshold } from './fxs/threshold.js';
-import { FX_zoomLines } from './fxs/zoomLines.js';
-import { FX_blockNoise } from './fxs/blockNoise.js';
-import { FX_fill } from './fxs/fill.js';
-import { FX_blur } from './fxs/blur.js';
-import { FX_pixelate } from './fxs/pixelate.js';
-import type { ImageEffectorFx } from './ImageEffector.js';
+import * as checker from '../image-compositor-functions/checker.js';
+import * as chromaticAberration from '../image-compositor-functions/chromaticAberration.js';
+import * as colorAdjust from '../image-compositor-functions/colorAdjust.js';
+import * as colorClamp from '../image-compositor-functions/colorClamp.js';
+import * as colorClampAdvanced from '../image-compositor-functions/colorClampAdvanced.js';
+import * as distort from '../image-compositor-functions/distort.js';
+import * as polkadot from '../image-compositor-functions/polkadot.js';
+import * as tearing from '../image-compositor-functions/tearing.js';
+import * as grayscale from '../image-compositor-functions/grayscale.js';
+import * as invert from '../image-compositor-functions/invert.js';
+import * as mirror from '../image-compositor-functions/mirror.js';
+import * as stripe from '../image-compositor-functions/stripe.js';
+import * as threshold from '../image-compositor-functions/threshold.js';
+import * as zoomLines from '../image-compositor-functions/zoomLines.js';
+import * as blockNoise from '../image-compositor-functions/blockNoise.js';
+import * as fill from '../image-compositor-functions/fill.js';
+import * as blur from '../image-compositor-functions/blur.js';
+import * as pixelate from '../image-compositor-functions/pixelate.js';
+import type { ImageCompositorFunction } from '@/lib/ImageCompositor.js';
+import type { ImageEffectorUiDefinition } from './ImageEffector.js';
-export const FXS = [
- FX_mirror,
- FX_invert,
- FX_grayscale,
- FX_colorAdjust,
- FX_colorClamp,
- FX_colorClampAdvanced,
- FX_distort,
- FX_threshold,
- FX_zoomLines,
- FX_stripe,
- FX_polkadot,
- FX_checker,
- FX_chromaticAberration,
- FX_tearing,
- FX_blockNoise,
- FX_fill,
- FX_blur,
- FX_pixelate,
-] as const satisfies ImageEffectorFx<string, any>[];
+export const FXS = {
+ checker,
+ chromaticAberration,
+ colorAdjust,
+ colorClamp,
+ colorClampAdvanced,
+ distort,
+ polkadot,
+ tearing,
+ grayscale,
+ invert,
+ mirror,
+ stripe,
+ threshold,
+ zoomLines,
+ blockNoise,
+ fill,
+ blur,
+ pixelate,
+} as const satisfies Record<string, {
+ readonly fn: ImageCompositorFunction<any>;
+ readonly uiDefinition: ImageEffectorUiDefinition<any>;
+}>;
diff --git a/packages/frontend/src/utility/image-effector/fxs/grayscale.ts b/packages/frontend/src/utility/image-effector/fxs/grayscale.ts
deleted file mode 100644
index 055e8b4618..0000000000
--- a/packages/frontend/src/utility/image-effector/fxs/grayscale.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { defineImageEffectorFx } from '../ImageEffector.js';
-import shader from './grayscale.glsl';
-import { i18n } from '@/i18n.js';
-
-export const FX_grayscale = defineImageEffectorFx({
- id: 'grayscale',
- name: i18n.ts._imageEffector._fxs.grayscale,
- shader,
- uniforms: [] as const,
- params: {
- },
- main: ({ gl, params }) => {
- },
-});
diff --git a/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts b/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts
new file mode 100644
index 0000000000..9e97728785
--- /dev/null
+++ b/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts
@@ -0,0 +1,270 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import QRCodeStyling from 'qr-code-styling';
+import { url } from '@@/js/config.js';
+import ExifReader from 'exifreader';
+import { FN_frame } from './frame.js';
+import { ImageCompositor } from '@/lib/ImageCompositor.js';
+import { ensureSignin } from '@/i.js';
+
+const $i = ensureSignin();
+
+type LabelParams = {
+ enabled: boolean;
+ scale: number;
+ padding: number;
+ textBig: string;
+ textSmall: string;
+ centered: boolean;
+ withQrCode: boolean;
+};
+
+export type ImageFrameParams = {
+ borderThickness: number;
+ labelTop: LabelParams;
+ labelBottom: LabelParams;
+ bgColor: [r: number, g: number, b: number];
+ fgColor: [r: number, g: number, b: number];
+ font: 'serif' | 'sans-serif';
+ borderRadius: number; // TODO
+};
+
+export type ImageFramePreset = {
+ id: string;
+ name: string;
+ params: ImageFrameParams;
+};
+
+export class ImageFrameRenderer {
+ private compositor: ImageCompositor<{ frame: typeof FN_frame }>;
+ private image: HTMLImageElement | ImageBitmap;
+ private exif: ExifReader.Tags | null;
+ private caption: string | null = null;
+ private filename: string | null = null;
+ private renderAsPreview = false;
+
+ constructor(options: {
+ canvas: HTMLCanvasElement,
+ image: HTMLImageElement | ImageBitmap,
+ exif: ExifReader.Tags | null,
+ filename: string | null,
+ caption: string | null,
+ renderAsPreview?: boolean,
+ }) {
+ this.image = options.image;
+ this.exif = options.exif;
+ this.caption = options.caption ?? null;
+ this.filename = options.filename ?? null;
+ this.renderAsPreview = options.renderAsPreview ?? false;
+
+ this.compositor = new ImageCompositor({
+ canvas: options.canvas,
+ renderWidth: 1,
+ renderHeight: 1,
+ image: null,
+ functions: { frame: FN_frame },
+ });
+
+ this.compositor.registerTexture('image', this.image);
+ }
+
+ private interpolateTemplateText(text: string) {
+ const DateTimeOriginal = this.exif == null ? '2012:03:04 5:06:07' : this.exif.DateTimeOriginal?.description;
+ const Model = this.exif == null ? 'Example camera' : this.exif.Model?.description;
+ const LensModel = this.exif == null ? 'Example lens 123mm f/1.23' : this.exif.LensModel?.description;
+ const FocalLength = this.exif == null ? '123mm' : this.exif.FocalLength?.description;
+ const FocalLengthIn35mmFilm = this.exif == null ? '123mm' : this.exif.FocalLengthIn35mmFilm?.description;
+ const ExposureTime = this.exif == null ? '1/234' : this.exif.ExposureTime?.description;
+ const FNumber = this.exif == null ? '1.23' : this.exif.FNumber?.description;
+ const ISOSpeedRatings = this.exif == null ? '123' : this.exif.ISOSpeedRatings?.description;
+ const GPSLatitude = this.exif == null ? '123.000000000000123' : this.exif.GPSLatitude?.description;
+ const GPSLongitude = this.exif == null ? '456.000000000000123' : this.exif.GPSLongitude?.description;
+ return text.replaceAll(/\{(\w+)\}/g, (_: string, key: string) => {
+ const meta_date = DateTimeOriginal ?? '????:??:?? ??:??:??';
+ const date = meta_date.split(' ')[0].replaceAll(':', '/');
+ switch (key) {
+ case 'caption': return this.caption ?? '?';
+ case 'filename': return this.filename ?? '?';
+ case 'filename_without_ext': return this.filename?.replace(/\.[^/.]+$/, '') ?? '?';
+ case 'year': return date.split('/')[0];
+ case 'month': return date.split('/')[1].replace(/^0/, '');
+ case 'day': return date.split('/')[2].replace(/^0/, '');
+ case 'hour': return meta_date.split(' ')[1].split(':')[0].replace(/^0/, '');
+ case 'minute': return meta_date.split(' ')[1].split(':')[1].replace(/^0/, '');
+ case 'second': return meta_date.split(' ')[1].split(':')[2].replace(/^0/, '');
+ case '0month': return date.split('/')[1];
+ case '0day': return date.split('/')[2];
+ case '0hour': return meta_date.split(' ')[1].split(':')[0];
+ case '0minute': return meta_date.split(' ')[1].split(':')[1];
+ case '0second': return meta_date.split(' ')[1].split(':')[2];
+ case 'camera_model': return Model ?? '?';
+ case 'camera_lens_model': return LensModel ?? '?';
+ case 'camera_mm': return FocalLength?.replace(' mm', '').replace('mm', '') ?? '?';
+ case 'camera_mm_35': return FocalLengthIn35mmFilm?.replace(' mm', '').replace('mm', '') ?? '?';
+ case 'camera_f': return FNumber?.replace('f/', '') ?? '?';
+ case 'camera_s': return ExposureTime ?? '?';
+ case 'camera_iso': return ISOSpeedRatings ?? '?';
+ case 'gps_lat': return GPSLatitude ?? '?';
+ case 'gps_long': return GPSLongitude ?? '?';
+ default: return '?';
+ }
+ });
+ }
+
+ private async renderLabel(renderWidth: number, renderHeight: number, paddingLeft: number, paddingRight: number, imageAreaH: number, fgColor: [number, number, number], font: string, params: LabelParams) {
+ const scaleBase = imageAreaH * params.scale;
+ const labelCanvasCtx = window.document.createElement('canvas').getContext('2d')!;
+ labelCanvasCtx.canvas.width = renderWidth;
+ labelCanvasCtx.canvas.height = renderHeight;
+ const fontSize = scaleBase / 30;
+ const textsMarginLeft = Math.max(fontSize * 2, paddingLeft);
+ const textsMarginRight = textsMarginLeft;
+ const withQrCode = params.withQrCode;
+ const qrSize = scaleBase * 0.1;
+ const qrMarginRight = Math.max((labelCanvasCtx.canvas.height - qrSize) / 2, paddingRight);
+
+ labelCanvasCtx.fillStyle = `rgb(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)})`;
+ labelCanvasCtx.font = `bold ${fontSize}px ${font}`;
+ labelCanvasCtx.textBaseline = 'middle';
+
+ const titleY = params.textSmall === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) - (fontSize * 0.9);
+ if (params.centered) {
+ labelCanvasCtx.textAlign = 'center';
+ labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), labelCanvasCtx.canvas.width / 2, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
+ } else {
+ labelCanvasCtx.textAlign = 'left';
+ labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), textsMarginLeft, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
+ }
+
+ labelCanvasCtx.fillStyle = `rgba(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)}, 0.5)`;
+ labelCanvasCtx.font = `${fontSize * 0.85}px ${font}`;
+ labelCanvasCtx.textBaseline = 'middle';
+
+ const textY = params.textBig === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) + (fontSize * 0.9);
+ if (params.centered) {
+ labelCanvasCtx.textAlign = 'center';
+ labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), labelCanvasCtx.canvas.width / 2, textY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
+ } else {
+ labelCanvasCtx.textAlign = 'left';
+ labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), textsMarginLeft, textY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
+ }
+
+ if (withQrCode) {
+ try {
+ const qrCodeInstance = new QRCodeStyling({
+ width: labelCanvasCtx.canvas.height,
+ height: labelCanvasCtx.canvas.height,
+ margin: 0,
+ type: 'canvas',
+ data: `${url}/users/${$i.id}`,
+ //image: $i.avatarUrl,
+ qrOptions: {
+ typeNumber: 0,
+ mode: 'Byte',
+ errorCorrectionLevel: 'H',
+ },
+ imageOptions: {
+ hideBackgroundDots: true,
+ imageSize: 0.3,
+ margin: 16,
+ crossOrigin: 'anonymous',
+ },
+ dotsOptions: {
+ type: 'dots',
+ roundSize: false,
+ color: `rgb(${Math.floor(fgColor[0] * 255)}, ${Math.floor(fgColor[1] * 255)}, ${Math.floor(fgColor[2] * 255)})`,
+ },
+ backgroundOptions: {
+ color: 'transparent',
+ },
+ cornersDotOptions: {
+ type: 'dot',
+ },
+ cornersSquareOptions: {
+ type: 'extra-rounded',
+ },
+ });
+
+ const blob = await qrCodeInstance.getRawData('png') as Blob | null;
+ if (blob == null) throw new Error('Failed to generate QR code');
+
+ const qrImageBitmap = await window.createImageBitmap(blob);
+
+ labelCanvasCtx.drawImage(
+ qrImageBitmap,
+ labelCanvasCtx.canvas.width - qrSize - qrMarginRight,
+ (labelCanvasCtx.canvas.height - qrSize) / 2,
+ qrSize,
+ qrSize,
+ );
+ qrImageBitmap.close();
+ } catch (err) {
+ // nop
+ }
+ }
+
+ return labelCanvasCtx.getImageData(0, 0, labelCanvasCtx.canvas.width, labelCanvasCtx.canvas.height); ;
+ }
+
+ public async render(params: ImageFrameParams): Promise<void> {
+ let imageAreaW = this.image.width;
+ let imageAreaH = this.image.height;
+
+ if (this.renderAsPreview) {
+ const MAX_W = 1000;
+ const MAX_H = 1000;
+
+ if (imageAreaW > MAX_W || imageAreaH > MAX_H) {
+ const scale = Math.min(MAX_W / imageAreaW, MAX_H / imageAreaH);
+ imageAreaW = Math.floor(imageAreaW * scale);
+ imageAreaH = Math.floor(imageAreaH * scale);
+ }
+ }
+
+ const paddingLeft = Math.floor(imageAreaH * params.borderThickness);
+ const paddingRight = Math.floor(imageAreaH * params.borderThickness);
+ const paddingTop = params.labelTop.enabled ? Math.floor(imageAreaH * params.labelTop.padding) : Math.floor(imageAreaH * params.borderThickness);
+ const paddingBottom = params.labelBottom.enabled ? Math.floor(imageAreaH * params.labelBottom.padding) : Math.floor(imageAreaH * params.borderThickness);
+ const renderWidth = imageAreaW + paddingLeft + paddingRight;
+ const renderHeight = imageAreaH + paddingTop + paddingBottom;
+
+ if (params.labelTop.enabled) {
+ const topLabelImage = await this.renderLabel(renderWidth, paddingTop, paddingLeft, paddingRight, imageAreaH, params.fgColor, params.font, params.labelTop);
+ this.compositor.registerTexture('topLabel', topLabelImage);
+ }
+
+ if (params.labelBottom.enabled) {
+ const bottomLabelImage = await this.renderLabel(renderWidth, paddingBottom, paddingLeft, paddingRight, imageAreaH, params.fgColor, params.font, params.labelBottom);
+ this.compositor.registerTexture('bottomLabel', bottomLabelImage);
+ }
+
+ this.compositor.changeResolution(renderWidth, renderHeight);
+
+ this.compositor.render([{
+ functionId: 'frame',
+ id: 'a',
+ params: {
+ image: 'image',
+ topLabel: 'topLabel',
+ bottomLabel: 'bottomLabel',
+ topLabelEnabled: params.labelTop.enabled,
+ bottomLabelEnabled: params.labelBottom.enabled,
+ paddingLeft: paddingLeft / renderWidth,
+ paddingRight: paddingRight / renderWidth,
+ paddingTop: paddingTop / renderHeight,
+ paddingBottom: paddingBottom / renderHeight,
+ bg: params.bgColor,
+ },
+ }]);
+ }
+
+ /*
+ * disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
+ */
+ public destroy(disposeCanvas = true): void {
+ this.compositor.destroy(disposeCanvas);
+ }
+}
diff --git a/packages/frontend/src/utility/image-frame-renderer/frame.glsl b/packages/frontend/src/utility/image-frame-renderer/frame.glsl
new file mode 100644
index 0000000000..aa9dde5ad8
--- /dev/null
+++ b/packages/frontend/src/utility/image-frame-renderer/frame.glsl
@@ -0,0 +1,61 @@
+#version 300 es
+precision mediump float;
+
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+in vec2 in_uv;
+uniform sampler2D in_texture;
+uniform vec2 in_resolution;
+uniform sampler2D u_image;
+uniform sampler2D u_topLabel;
+uniform sampler2D u_bottomLabel;
+uniform bool u_topLabelEnabled;
+uniform bool u_bottomLabelEnabled;
+uniform float u_paddingTop;
+uniform float u_paddingBottom;
+uniform float u_paddingLeft;
+uniform float u_paddingRight;
+uniform vec3 u_bg;
+out vec4 out_color;
+
+float remap(float value, float inputMin, float inputMax, float outputMin, float outputMax) {
+ return outputMin + (outputMax - outputMin) * ((value - inputMin) / (inputMax - inputMin));
+}
+
+vec3 blendAlpha(vec3 bg, vec4 fg) {
+ return fg.a * fg.rgb + (1.0 - fg.a) * bg;
+}
+
+void main() {
+ vec4 bg = vec4(u_bg, 1.0);
+
+ vec4 image_color = texture(u_image, vec2(
+ remap(in_uv.x, u_paddingLeft, 1.0 - u_paddingRight, 0.0, 1.0),
+ remap(in_uv.y, u_paddingTop, 1.0 - u_paddingBottom, 0.0, 1.0)
+ ));
+
+ vec4 topLabel_color = u_topLabelEnabled ? texture(u_topLabel, vec2(
+ in_uv.x,
+ remap(in_uv.y, 0.0, u_paddingTop, 0.0, 1.0)
+ )) : bg;
+
+ vec4 bottomLabel_color = u_bottomLabelEnabled ? texture(u_bottomLabel, vec2(
+ in_uv.x,
+ remap(in_uv.y, 1.0 - u_paddingBottom, 1.0, 0.0, 1.0)
+ )) : bg;
+
+ if (in_uv.y < u_paddingTop) {
+ out_color = vec4(blendAlpha(bg.rgb, topLabel_color), 1.0);
+ } else if (in_uv.y > (1.0 - u_paddingBottom)) {
+ out_color = vec4(blendAlpha(bg.rgb, bottomLabel_color), 1.0);
+ } else {
+ if (in_uv.y > u_paddingTop && in_uv.x > u_paddingLeft && in_uv.x < (1.0 - u_paddingRight)) {
+ out_color = image_color;
+ } else {
+ out_color = bg;
+ }
+ }
+}
diff --git a/packages/frontend/src/utility/image-frame-renderer/frame.ts b/packages/frontend/src/utility/image-frame-renderer/frame.ts
new file mode 100644
index 0000000000..aeca45c1ec
--- /dev/null
+++ b/packages/frontend/src/utility/image-frame-renderer/frame.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import shader from './frame.glsl';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
+
+export const FN_frame = defineImageCompositorFunction<{
+ image: string | null;
+ topLabel: string | null;
+ bottomLabel: string | null;
+ topLabelEnabled: boolean;
+ bottomLabelEnabled: boolean;
+ paddingTop: number;
+ paddingBottom: number;
+ paddingLeft: number;
+ paddingRight: number;
+ bg: [number, number, number];
+}>({
+ shader,
+ main: ({ gl, u, params, textures }) => {
+ if (params.image == null) return;
+ const image = textures.get(params.image);
+ if (image == null) return;
+
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, image.texture);
+ gl.uniform1i(u.image, 1);
+
+ gl.uniform1i(u.topLabelEnabled, params.topLabelEnabled ? 1 : 0);
+ gl.uniform1i(u.bottomLabelEnabled, params.bottomLabelEnabled ? 1 : 0);
+ gl.uniform1f(u.paddingTop, params.paddingTop);
+ gl.uniform1f(u.paddingBottom, params.paddingBottom);
+ gl.uniform1f(u.paddingLeft, params.paddingLeft);
+ gl.uniform1f(u.paddingRight, params.paddingRight);
+ gl.uniform3f(u.bg, params.bg[0], params.bg[1], params.bg[2]);
+
+ if (params.topLabelEnabled && params.topLabel != null) {
+ const topLabel = textures.get(params.topLabel);
+ if (topLabel) {
+ gl.activeTexture(gl.TEXTURE2);
+ gl.bindTexture(gl.TEXTURE_2D, topLabel.texture);
+ gl.uniform1i(u.topLabel, 2);
+ }
+ }
+
+ if (params.bottomLabelEnabled && params.bottomLabel != null) {
+ const bottomLabel = textures.get(params.bottomLabel);
+ if (bottomLabel) {
+ gl.activeTexture(gl.TEXTURE3);
+ gl.bindTexture(gl.TEXTURE_2D, bottomLabel.texture);
+ gl.uniform1i(u.bottomLabel, 3);
+ }
+ }
+ },
+});
diff --git a/packages/frontend/src/utility/watermark.ts b/packages/frontend/src/utility/watermark.ts
deleted file mode 100644
index 1b46721a2b..0000000000
--- a/packages/frontend/src/utility/watermark.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
-import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
-import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js';
-import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js';
-import { FX_checker } from '@/utility/image-effector/fxs/checker.js';
-import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
-
-const WATERMARK_FXS = [
- FX_watermarkPlacement,
- FX_stripe,
- FX_polkadot,
- FX_checker,
-] as const satisfies ImageEffectorFx<string, any>[];
-
-type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
-
-export type WatermarkPreset = {
- id: string;
- name: string;
- layers: ({
- id: string;
- type: 'text';
- text: string;
- repeat: boolean;
- noBoundingBoxExpansion: boolean;
- scale: number;
- angle: number;
- align: Align;
- opacity: number;
- } | {
- id: string;
- type: 'image';
- imageUrl: string | null;
- imageId: string | null;
- cover: boolean;
- repeat: boolean;
- noBoundingBoxExpansion: boolean;
- scale: number;
- angle: number;
- align: Align;
- opacity: number;
- } | {
- id: string;
- type: 'qr';
- data: string;
- scale: number;
- align: Align;
- opacity: number;
- } | {
- id: string;
- type: 'stripe';
- angle: number;
- frequency: number;
- threshold: number;
- color: [r: number, g: number, b: number];
- opacity: number;
- } | {
- id: string;
- type: 'polkadot';
- angle: number;
- scale: number;
- majorRadius: number;
- majorOpacity: number;
- minorDivisions: number;
- minorRadius: number;
- minorOpacity: number;
- color: [r: number, g: number, b: number];
- opacity: number;
- } | {
- id: string;
- type: 'checker';
- angle: number;
- scale: number;
- color: [r: number, g: number, b: number];
- opacity: number;
- })[];
-};
-
-export class WatermarkRenderer {
- private effector: ImageEffector<typeof WATERMARK_FXS>;
- private layers: WatermarkPreset['layers'] = [];
-
- constructor(options: {
- canvas: HTMLCanvasElement,
- renderWidth: number,
- renderHeight: number,
- image: HTMLImageElement | ImageBitmap,
- }) {
- this.effector = new ImageEffector({
- canvas: options.canvas,
- renderWidth: options.renderWidth,
- renderHeight: options.renderHeight,
- image: options.image,
- fxs: WATERMARK_FXS,
- });
- }
-
- private makeImageEffectorLayers(): ImageEffectorLayer[] {
- return this.layers.map(layer => {
- if (layer.type === 'text') {
- return {
- fxId: 'watermarkPlacement',
- id: layer.id,
- params: {
- repeat: layer.repeat,
- noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
- scale: layer.scale,
- align: layer.align,
- angle: layer.angle,
- opacity: layer.opacity,
- cover: false,
- watermark: {
- type: 'text',
- text: layer.text,
- },
- },
- };
- } else if (layer.type === 'image') {
- return {
- fxId: 'watermarkPlacement',
- id: layer.id,
- params: {
- repeat: layer.repeat,
- noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
- scale: layer.scale,
- align: layer.align,
- angle: layer.angle,
- opacity: layer.opacity,
- cover: layer.cover,
- watermark: {
- type: 'url',
- url: layer.imageUrl,
- },
- },
- };
- } else if (layer.type === 'qr') {
- return {
- fxId: 'watermarkPlacement',
- id: layer.id,
- params: {
- repeat: false,
- scale: layer.scale,
- align: layer.align,
- angle: 0,
- opacity: layer.opacity,
- cover: false,
- watermark: {
- type: 'qr',
- data: layer.data,
- },
- },
- };
- } else if (layer.type === 'stripe') {
- return {
- fxId: 'stripe',
- id: layer.id,
- params: {
- angle: layer.angle,
- frequency: layer.frequency,
- threshold: layer.threshold,
- color: layer.color,
- opacity: layer.opacity,
- },
- };
- } else if (layer.type === 'polkadot') {
- return {
- fxId: 'polkadot',
- id: layer.id,
- params: {
- angle: layer.angle,
- scale: layer.scale,
- majorRadius: layer.majorRadius,
- majorOpacity: layer.majorOpacity,
- minorDivisions: layer.minorDivisions,
- minorRadius: layer.minorRadius,
- minorOpacity: layer.minorOpacity,
- color: layer.color,
- },
- };
- } else if (layer.type === 'checker') {
- return {
- fxId: 'checker',
- id: layer.id,
- params: {
- angle: layer.angle,
- scale: layer.scale,
- color: layer.color,
- opacity: layer.opacity,
- },
- };
- } else {
- throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
- }
- });
- }
-
- public async setLayers(layers: WatermarkPreset['layers']) {
- this.layers = layers;
- await this.effector.setLayers(this.makeImageEffectorLayers());
- this.render();
- }
-
- public render(): void {
- this.effector.render();
- }
-
- /*
- * disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
- */
- public destroy(disposeCanvas = true): void {
- this.effector.destroy(disposeCanvas);
- }
-}
diff --git a/packages/frontend/src/utility/watermark/WatermarkRenderer.ts b/packages/frontend/src/utility/watermark/WatermarkRenderer.ts
new file mode 100644
index 0000000000..766d45148a
--- /dev/null
+++ b/packages/frontend/src/utility/watermark/WatermarkRenderer.ts
@@ -0,0 +1,332 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import QRCodeStyling from 'qr-code-styling';
+import { url, host } from '@@/js/config.js';
+import { getProxiedImageUrl } from '../media-proxy.js';
+import { fn as fn_watermark } from './watermark.js';
+import { fn as fn_stripe } from '@/utility/image-compositor-functions/stripe.js';
+import { fn as fn_poladot } from '@/utility/image-compositor-functions/polkadot.js';
+import { fn as fn_checker } from '@/utility/image-compositor-functions/checker.js';
+import { ImageCompositor } from '@/lib/ImageCompositor.js';
+import { ensureSignin } from '@/i.js';
+
+type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
+
+export type WatermarkLayers = ({
+ id: string;
+ type: 'text';
+ text: string;
+ repeat: boolean;
+ noBoundingBoxExpansion: boolean;
+ scale: number;
+ angle: number;
+ align: Align;
+ opacity: number;
+} | {
+ id: string;
+ type: 'image';
+ imageUrl: string | null;
+ imageId: string | null;
+ cover: boolean;
+ repeat: boolean;
+ noBoundingBoxExpansion: boolean;
+ scale: number;
+ angle: number;
+ align: Align;
+ opacity: number;
+} | {
+ id: string;
+ type: 'qr';
+ data: string;
+ scale: number;
+ align: Align;
+ opacity: number;
+} | {
+ id: string;
+ type: 'stripe';
+ angle: number;
+ frequency: number;
+ threshold: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+} | {
+ id: string;
+ type: 'polkadot';
+ angle: number;
+ scale: number;
+ majorRadius: number;
+ majorOpacity: number;
+ minorDivisions: number;
+ minorRadius: number;
+ minorOpacity: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+} | {
+ id: string;
+ type: 'checker';
+ angle: number;
+ scale: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+})[];
+
+export type WatermarkPreset = {
+ id: string;
+ name: string;
+ layers: WatermarkLayers;
+};
+
+type WatermarkRendererImageCompositor = ImageCompositor<{
+ watermark: typeof fn_watermark;
+ stripe: typeof fn_stripe;
+ polkadot: typeof fn_poladot;
+ checker: typeof fn_checker;
+}>;
+
+export class WatermarkRenderer {
+ private compositor: WatermarkRendererImageCompositor;
+
+ constructor(options: {
+ canvas: HTMLCanvasElement,
+ renderWidth: number,
+ renderHeight: number,
+ image: HTMLImageElement | ImageBitmap,
+ }) {
+ this.compositor = new ImageCompositor({
+ canvas: options.canvas,
+ renderWidth: options.renderWidth,
+ renderHeight: options.renderHeight,
+ image: options.image,
+ functions: {
+ watermark: fn_watermark,
+ stripe: fn_stripe,
+ polkadot: fn_poladot,
+ checker: fn_checker,
+ },
+ });
+ }
+
+ public async render(layers: WatermarkLayers) {
+ const compositorLayers: Parameters<WatermarkRendererImageCompositor['render']>[0] = [];
+
+ const unused = new Set(this.compositor.getKeysOfRegisteredTextures());
+
+ for (const layer of layers) {
+ if (layer.type === 'text') {
+ const textureKey = `text:${layer.text}`;
+ unused.delete(textureKey);
+ if (!this.compositor.hasTexture(textureKey)) {
+ if (_DEV_) console.log(`Baking text texture of <${textureKey}>...`);
+ const image = await createTextureFromText(layer.text);
+ if (image != null) this.compositor.registerTexture(textureKey, image);
+ }
+
+ compositorLayers.push({
+ functionId: 'watermark',
+ id: layer.id,
+ params: {
+ repeat: layer.repeat,
+ noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
+ scale: layer.scale,
+ align: layer.align,
+ angle: layer.angle,
+ opacity: layer.opacity,
+ cover: false,
+ watermark: textureKey,
+ },
+ });
+ } else if (layer.type === 'image') {
+ const textureKey = `url:${layer.imageUrl}`;
+ unused.delete(textureKey);
+ if (!this.compositor.hasTexture(textureKey)) {
+ if (_DEV_) console.log(`Baking url image texture of <${textureKey}>...`);
+ const image = await createTextureFromUrl(layer.imageUrl);
+ if (image != null) this.compositor.registerTexture(textureKey, image);
+ }
+
+ compositorLayers.push({
+ functionId: 'watermark',
+ id: layer.id,
+ params: {
+ repeat: layer.repeat,
+ noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
+ scale: layer.scale,
+ align: layer.align,
+ angle: layer.angle,
+ opacity: layer.opacity,
+ cover: layer.cover,
+ watermark: textureKey,
+ },
+ });
+ } else if (layer.type === 'qr') {
+ const textureKey = `qr:${layer.data}`;
+ unused.delete(textureKey);
+ if (!this.compositor.hasTexture(textureKey)) {
+ if (_DEV_) console.log(`Baking qr texture of <${textureKey}>...`);
+ const image = await createTextureFromQr({ data: layer.data });
+ if (image != null) this.compositor.registerTexture(textureKey, image);
+ }
+
+ compositorLayers.push({
+ functionId: 'watermark',
+ id: layer.id,
+ params: {
+ repeat: false,
+ scale: layer.scale,
+ align: layer.align,
+ angle: 0,
+ opacity: layer.opacity,
+ cover: false,
+ watermark: textureKey,
+ },
+ });
+ } else if (layer.type === 'stripe') {
+ compositorLayers.push({
+ functionId: 'stripe',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ frequency: layer.frequency,
+ threshold: layer.threshold,
+ color: layer.color,
+ opacity: layer.opacity,
+ },
+ });
+ } else if (layer.type === 'polkadot') {
+ compositorLayers.push({
+ functionId: 'polkadot',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ scale: layer.scale,
+ majorRadius: layer.majorRadius,
+ majorOpacity: layer.majorOpacity,
+ minorDivisions: layer.minorDivisions,
+ minorRadius: layer.minorRadius,
+ minorOpacity: layer.minorOpacity,
+ color: layer.color,
+ },
+ });
+ } else if (layer.type === 'checker') {
+ compositorLayers.push({
+ functionId: 'checker',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ scale: layer.scale,
+ color: layer.color,
+ opacity: layer.opacity,
+ },
+ });
+ } else {
+ throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
+ }
+ }
+
+ for (const k of unused) {
+ if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
+ this.compositor.unregisterTexture(k);
+ }
+
+ this.compositor.render(compositorLayers);
+ }
+
+ public changeResolution(width: number, height: number) {
+ this.compositor.changeResolution(width, height);
+ }
+
+ /*
+ * disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
+ */
+ public destroy(disposeCanvas = true): void {
+ this.compositor.destroy(disposeCanvas);
+ }
+}
+
+async function createTextureFromUrl(imageUrl: string | null) {
+ if (imageUrl == null || imageUrl.trim() === '') return null;
+
+ const image = await new Promise<HTMLImageElement>((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = reject;
+ img.src = getProxiedImageUrl(imageUrl); // CORS対策
+ }).catch(() => null);
+
+ if (image == null) return null;
+
+ return image;
+}
+
+async function createTextureFromText(text: string | null, resolution = 2048) {
+ if (text == null || text.trim() === '') return null;
+
+ const ctx = window.document.createElement('canvas').getContext('2d')!;
+ ctx.canvas.width = resolution;
+ ctx.canvas.height = resolution / 4;
+ const fontSize = resolution / 32;
+ const margin = fontSize / 2;
+ ctx.shadowColor = '#000000';
+ ctx.shadowBlur = fontSize / 4;
+
+ //ctx.fillStyle = '#00ff00';
+ //ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+
+ ctx.fillStyle = '#ffffff';
+ ctx.font = `bold ${fontSize}px sans-serif`;
+ ctx.textBaseline = 'middle';
+
+ ctx.fillText(text, margin, ctx.canvas.height / 2);
+
+ const textMetrics = ctx.measureText(text);
+ const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin);
+ const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin);
+ const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), cropWidth, cropHeight);
+
+ ctx.canvas.remove();
+
+ return data;
+}
+
+async function createTextureFromQr(options: { data: string | null }, resolution = 512) {
+ const $i = ensureSignin();
+
+ const qrCodeInstance = new QRCodeStyling({
+ width: resolution,
+ height: resolution,
+ margin: 42,
+ type: 'canvas',
+ data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
+ image: $i.avatarUrl,
+ qrOptions: {
+ typeNumber: 0,
+ mode: 'Byte',
+ errorCorrectionLevel: 'H',
+ },
+ imageOptions: {
+ hideBackgroundDots: true,
+ imageSize: 0.3,
+ margin: 16,
+ crossOrigin: 'anonymous',
+ },
+ dotsOptions: {
+ type: 'dots',
+ },
+ cornersDotOptions: {
+ type: 'dot',
+ },
+ cornersSquareOptions: {
+ type: 'extra-rounded',
+ },
+ });
+
+ const blob = await qrCodeInstance.getRawData('png') as Blob | null;
+ if (blob == null) return null;
+
+ const image = await window.createImageBitmap(blob);
+
+ return image;
+}
diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl b/packages/frontend/src/utility/watermark/watermark.glsl
index d6a1ef1820..d6a1ef1820 100644
--- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.glsl
+++ b/packages/frontend/src/utility/watermark/watermark.glsl
diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts b/packages/frontend/src/utility/watermark/watermark.ts
index bb51ed796b..62efcd12b6 100644
--- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts
+++ b/packages/frontend/src/utility/watermark/watermark.ts
@@ -3,57 +3,20 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineImageEffectorFx } from '../ImageEffector.js';
-import shader from './watermarkPlacement.glsl';
+import shader from './watermark.glsl';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
-export const FX_watermarkPlacement = defineImageEffectorFx({
- id: 'watermarkPlacement',
- name: '(internal)',
+export const fn = defineImageCompositorFunction<Partial<{
+ cover: boolean;
+ repeat: boolean;
+ scale: number;
+ angle: number;
+ align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
+ opacity: number;
+ noBoundingBoxExpansion: boolean;
+ watermark: string | null;
+}>>({
shader,
- uniforms: ['opacity', 'scale', 'angle', 'cover', 'repeat', 'alignX', 'alignY', 'margin', 'repeatMargin', 'noBBoxExpansion', 'wmResolution', 'wmEnabled', 'watermark'] as const,
- params: {
- cover: {
- type: 'boolean',
- default: false,
- },
- repeat: {
- type: 'boolean',
- default: false,
- },
- scale: {
- type: 'number',
- default: 0.3,
- min: 0.0,
- max: 1.0,
- step: 0.01,
- },
- angle: {
- type: 'number',
- default: 0,
- min: -1.0,
- max: 1.0,
- step: 0.01,
- },
- align: {
- type: 'align',
- default: { x: 'right', y: 'bottom', margin: 0 },
- },
- opacity: {
- type: 'number',
- default: 0.75,
- min: 0.0,
- max: 1.0,
- step: 0.01,
- },
- noBoundingBoxExpansion: {
- type: 'boolean',
- default: false,
- },
- watermark: {
- type: 'texture',
- default: null,
- },
- },
main: ({ gl, u, params, textures }) => {
// 基本パラメータ
gl.uniform1f(u.opacity, params.opacity ?? 1.0);
@@ -70,7 +33,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
gl.uniform1i(u.noBBoxExpansion, params.noBoundingBoxExpansion ? 1 : 0);
// ウォーターマークテクスチャ
- const wm = textures.watermark;
+ const wm = textures.get(params.watermark);
if (wm) {
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, wm.texture);
diff --git a/packages/frontend/src/utility/webgl.ts b/packages/frontend/src/utility/webgl.ts
index ae595b605c..334663b1a1 100644
--- a/packages/frontend/src/utility/webgl.ts
+++ b/packages/frontend/src/utility/webgl.ts
@@ -38,3 +38,14 @@ export function initShaderProgram(gl: WebGL2RenderingContext, vsSource: string,
return shaderProgram;
}
+
+export function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
+ const texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.bindTexture(gl.TEXTURE_2D, null);
+ return texture;
+}