This commit is contained in:
syuilo 2025-05-28 16:43:15 +09:00
parent fad3aed79e
commit 31c4237748
14 changed files with 790 additions and 175 deletions

6
locales/index.d.ts vendored
View File

@ -12066,6 +12066,12 @@ export interface Locale extends ILocale {
*/ */
"image": string; "image": string;
}; };
"_imageEffector": {
/**
*
*/
"title": string;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View File

@ -3231,3 +3231,6 @@ _watermarkEditor:
position: "位置" position: "位置"
type: "タイプ" type: "タイプ"
image: "画像" image: "画像"
_imageEffector:
title: "エフェクト"

View File

@ -0,0 +1,147 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_gaps">
<template v-if="layer.type === 'text'">
<MkInput v-model="layer.text">
<template #label>{{ i18n.ts._watermarkEditor.text }}</template>
</MkInput>
<FormSlot>
<template #label>{{ i18n.ts._watermarkEditor.position }}</template>
<MkPositionSelector
v-model:x="layer.alignX"
v-model:y="layer.alignY"
></MkPositionSelector>
</FormSlot>
<MkRange
v-model="layer.scale"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
</MkRange>
<MkRange
v-model="layer.opacity"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
</MkRange>
<MkSwitch v-model="layer.repeat">
<template #label>{{ i18n.ts._watermarkEditor.repeat }}</template>
</MkSwitch>
</template>
<template v-else-if="layer.type === 'image'">
<MkButton inline rounded primary @click="chooseFile">{{ i18n.ts.selectFile }}</MkButton>
<FormSlot>
<template #label>{{ i18n.ts._watermarkEditor.position }}</template>
<MkPositionSelector
v-model:x="layer.alignX"
v-model:y="layer.alignY"
></MkPositionSelector>
</FormSlot>
<MkRange
v-model="layer.scale"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
</MkRange>
<MkRange
v-model="layer.opacity"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
</MkRange>
<MkSwitch v-model="layer.repeat">
<template #label>{{ i18n.ts._watermarkEditor.repeat }}</template>
</MkSwitch>
<MkSwitch v-model="layer.cover">
<template #label>{{ i18n.ts._watermarkEditor.cover }}</template>
</MkSwitch>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
import { v4 as uuid } from 'uuid';
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { i18n } from '@/i18n.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import FormSlot from '@/components/form/slot.vue';
import MkPositionSelector from '@/components/MkPositionSelector.vue';
import * as os from '@/os.js';
import { selectFile } from '@/utility/drive.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js';
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
const driveFile = ref();
const driveFileError = ref(false);
onMounted(async () => {
if (layer.value.type === 'image' && layer.value.imageId != null) {
await misskeyApi('drive/files/show', {
fileId: layer.value.imageId,
}).then((res) => {
driveFile.value = res;
}).catch((err) => {
driveFileError.value = true;
});
}
});
function chooseFile(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then((file) => {
if (!file.type.startsWith('image')) {
os.alert({
type: 'warning',
title: i18n.ts._watermarkEditor.driveFileTypeWarn,
text: i18n.ts._watermarkEditor.driveFileTypeWarnDescription,
});
return;
}
layer.value.imageId = file.id;
layer.value.imageUrl = file.url;
driveFileError.value = false;
});
}
</script>
<style module>
.root {
}
</style>

View File

@ -0,0 +1,200 @@
<!--
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-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="$style.preview">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div class="_acrylic" :class="$style.previewControls">
</div>
</div>
</div>
<div :class="$style.controls" class="_gaps">
<XLayer
v-for="(layer, i) in layers"
:key="layer.id"
v-model:layer="layers[i]"
></XLayer>
<MkButton rounded primary @click="addEffect"><i class="ti ti-plus"></i></MkButton>
</div>
</div>
</div>
</MkModalWindow>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
import { v4 as uuid } from 'uuid';
import type { WatermarkPreset } from '@/utility/watermark.js';
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { i18n } from '@/i18n.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import XLayer from '@/components/MkWatermarkEditorDialog.Layer.vue';
import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
const props = defineProps<{
image: HTMLImageElement;
}>();
const emit = defineEmits<{
(ev: 'ok', preset: WatermarkPreset): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
function cancel() {
emit('cancel');
dialog.value?.close();
}
const layers = reactive<ImageEffectorLayer[]>([]);
watch(layers, async () => {
if (renderer != null) {
renderer.updateLayers(layers);
}
}, { deep: true });
function addEffect(ev: MouseEvent) {
}
const canvasEl = useTemplateRef('canvasEl');
let renderer: ImageEffector | null = null;
onMounted(async () => {
renderer = new ImageEffector({
canvas: canvasEl.value,
width: props.image.width,
height: props.image.height,
layers: layers,
originalImage: props.image,
});
await renderer!.bakeTextures();
renderer!.render();
});
onUnmounted(() => {
if (renderer != null) {
renderer.destroy();
renderer = null;
}
});
</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-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
}
.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 {
padding: 24px;
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -80,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue'; import { computed, defineAsyncComponent, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
@ -96,7 +96,8 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
import { ImageEffector } from '@/utility/ImageEffector.js'; import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import { makeImageEffectorLayers } from '@/utility/watermark.js';
const $i = ensureSignin(); const $i = ensureSignin();
@ -288,6 +289,23 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
}); });
} }
if (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
menu.push({
icon: 'ti ti-sparkles',
text: i18n.ts._imageEffector.title,
action: async () => {
const img = await getImageElement(item.file);
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageEffectorDialog.vue')), {
image: img,
}, {
ok: () => {
},
closed: () => dispose(),
});
},
});
}
if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) { if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
function changeWatermarkPreset(presetId: string | null) { function changeWatermarkPreset(presetId: string | null) {
item.watermarkPresetId = presetId; item.watermarkPresetId = presetId;
@ -490,7 +508,7 @@ async function preprocess(item: (typeof items)['value'][number]): Promise<void>
canvas: canvas, canvas: canvas,
width: img.width, width: img.width,
height: img.height, height: img.height,
layers: preset.layers, layers: makeImageEffectorLayers(preset.layers),
originalImage: img, originalImage: img,
}); });

View File

@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSlot> <FormSlot>
<template #label>{{ i18n.ts._watermarkEditor.position }}</template> <template #label>{{ i18n.ts._watermarkEditor.position }}</template>
<MkPositionSelector <MkPositionSelector
v-model:x="layer.alignX" v-model:x="layer.align.x"
v-model:y="layer.alignY" v-model:y="layer.align.y"
></MkPositionSelector> ></MkPositionSelector>
</FormSlot> </FormSlot>
@ -50,8 +50,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSlot> <FormSlot>
<template #label>{{ i18n.ts._watermarkEditor.position }}</template> <template #label>{{ i18n.ts._watermarkEditor.position }}</template>
<MkPositionSelector <MkPositionSelector
v-model:x="layer.alignX" v-model:x="layer.align.x"
v-model:y="layer.alignY" v-model:y="layer.align.y"
></MkPositionSelector> ></MkPositionSelector>
</FormSlot> </FormSlot>
@ -91,9 +91,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts"> <script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue'; import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { ImageEffectorLayer } from '@/utility/ImageEffector.js'; import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import type { WatermarkPreset } from '@/utility/watermark.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { ImageEffector } from '@/utility/ImageEffector.js';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
@ -106,7 +106,7 @@ import { selectFile } from '@/utility/drive.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
const layer = defineModel<ImageEffectorLayer>('layer', { required: true }); const layer = defineModel<WatermarkPreset['layers'][number]>('layer', { required: true });
const driveFile = ref(); const driveFile = ref();
const driveFileError = ref(false); const driveFileError = ref(false);

View File

@ -45,9 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts"> <script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue'; import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { WatermarkPreset } from '@/preferences/def.js'; import type { WatermarkPreset } from '@/utility/watermark.js';
import { makeImageEffectorLayers } from '@/utility/watermark.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { ImageEffector } from '@/utility/ImageEffector.js'; import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
@ -70,8 +71,7 @@ const preset = reactive(deepClone(props.preset) ?? {
id: uuid(), id: uuid(),
type: 'text', type: 'text',
text: `(c) @${$i.username}`, text: `(c) @${$i.username}`,
alignX: 'right', align: { x: 'right', y: 'bottom' },
alignY: 'bottom',
scale: 0.3, scale: 0.3,
opacity: 0.75, opacity: 0.75,
repeat: false, repeat: false,
@ -96,10 +96,9 @@ watch(type, () => {
if (type.value === 'text') { if (type.value === 'text') {
preset.layers = [{ preset.layers = [{
id: uuid(), id: uuid(),
type: type.value, type: 'text',
text: `(c) @${$i.username}`, text: `(c) @${$i.username}`,
alignX: 'right', align: { x: 'right', y: 'bottom' },
alignY: 'bottom',
scale: 0.3, scale: 0.3,
opacity: 0.75, opacity: 0.75,
repeat: false, repeat: false,
@ -107,11 +106,10 @@ watch(type, () => {
} else if (type.value === 'image') { } else if (type.value === 'image') {
preset.layers = [{ preset.layers = [{
id: uuid(), id: uuid(),
type: type.value, type: 'image',
imageId: null, imageId: null,
imageUrl: null, imageUrl: null,
alignX: 'right', align: { x: 'right', y: 'bottom' },
alignY: 'bottom',
scale: 0.3, scale: 0.3,
opacity: 0.75, opacity: 0.75,
repeat: false, repeat: false,
@ -121,7 +119,7 @@ watch(type, () => {
watch(preset, async (newValue, oldValue) => { watch(preset, async (newValue, oldValue) => {
if (renderer != null) { if (renderer != null) {
renderer.updateLayers(preset.layers); renderer.updateLayers(makeImageEffectorLayers(preset.layers));
} }
}, { deep: true }); }, { deep: true });
@ -158,7 +156,7 @@ async function initRenderer() {
canvas: canvasEl.value, canvas: canvasEl.value,
width: 1500, width: 1500,
height: 1000, height: 1000,
layers: preset.layers, layers: makeImageEffectorLayers(preset.layers),
originalImage: sampleImage_3_2, originalImage: sampleImage_3_2,
}); });
} else if (sampleImageType.value === '2_3') { } else if (sampleImageType.value === '2_3') {
@ -166,7 +164,7 @@ async function initRenderer() {
canvas: canvasEl.value, canvas: canvasEl.value,
width: 1000, width: 1000,
height: 1500, height: 1500,
layers: preset.layers, layers: makeImageEffectorLayers(preset.layers),
originalImage: sampleImage_2_3, originalImage: sampleImage_2_3,
}); });
} }

View File

@ -22,13 +22,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import type { WatermarkPreset } from '@/preferences/def.js'; import type { WatermarkPreset } from '@/utility/watermark.js';
import { makeImageEffectorLayers } from '@/utility/watermark.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import { ImageEffector } from '@/utility/ImageEffector.js'; import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
const props = defineProps<{ const props = defineProps<{
preset: WatermarkPreset; preset: WatermarkPreset;
@ -75,7 +76,7 @@ onMounted(() => {
canvas: canvasEl.value, canvas: canvasEl.value,
width: 1500, width: 1500,
height: 1000, height: 1000,
layers: props.preset.layers, layers: makeImageEffectorLayers(props.preset.layers),
originalImage: sampleImage, originalImage: sampleImage,
}); });
@ -95,7 +96,7 @@ onUnmounted(() => {
watch(() => props.preset, async () => { watch(() => props.preset, async () => {
if (renderer != null) { if (renderer != null) {
renderer.updateLayers(props.preset.layers); renderer.updateLayers(makeImageEffectorLayers(props.preset.layers));
await renderer.bakeTextures(); await renderer.bakeTextures();
renderer.render(); renderer.render();
} }

View File

@ -149,7 +149,7 @@ import { computed, defineAsyncComponent, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import XWatermarkItem from './drive.WatermarkItem.vue'; import XWatermarkItem from './drive.WatermarkItem.vue';
import type { WatermarkPreset } from '@/preferences/def.js'; import type { WatermarkPreset } from '@/utility/watermark.js';
import FormLink from '@/components/form/link.vue'; import FormLink from '@/components/form/link.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';

View File

@ -11,7 +11,7 @@ import type { Plugin } from '@/plugin.js';
import type { DeviceKind } from '@/utility/device-kind.js'; import type { DeviceKind } from '@/utility/device-kind.js';
import type { DeckProfile } from '@/deck.js'; import type { DeckProfile } from '@/deck.js';
import type { PreferencesDefinition } from './manager.js'; import type { PreferencesDefinition } from './manager.js';
import type { ImageEffectorLayer } from '@/utility/ImageEffector.js'; import type { WatermarkPreset } from '@/utility/watermark.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
/** サウンド設定 */ /** サウンド設定 */
@ -30,12 +30,6 @@ export type SoundStore = {
volume: number; volume: number;
}; };
export type WatermarkPreset = {
id: string;
name: string;
layers: ImageEffectorLayer[];
};
// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる) // NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる)
export const PREF_DEF = { export const PREF_DEF = {

View File

@ -3,94 +3,67 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { getProxiedImageUrl } from './media-proxy.js'; import { getProxiedImageUrl } from '../media-proxy.js';
import { FX_chromaticAberration } from './fxs/chromaticAberration.js';
import { FX_watermarkPlacement } from './fxs/watermarkPlacement.js';
const WATERMARK_PLACEMENT_SHADER = `#version 300 es type ParamTypeToPrimitive = {
precision highp float; 'number': number;
'boolean': boolean;
in vec2 in_uv; 'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
uniform sampler2D u_texture_src;
uniform sampler2D u_texture_watermark;
uniform vec2 u_resolution_src;
uniform vec2 u_resolution_watermark;
uniform float u_scale;
uniform float u_angle;
uniform float u_opacity;
uniform bool u_repeat;
uniform int u_alignX; // 0: left, 1: center, 2: right
uniform int u_alignY; // 0: top, 1: center, 2: bottom
uniform int u_fitMode; // 0: contain, 1: cover
out vec4 out_color;
void main() {
vec4 pixel = texture(u_texture_src, in_uv);
bool contain = u_fitMode == 0;
float x_ratio = u_resolution_watermark.x / u_resolution_src.x;
float y_ratio = u_resolution_watermark.y / u_resolution_src.y;
float aspect_ratio = contain ?
(min(x_ratio, y_ratio) / max(x_ratio, y_ratio)) :
(max(x_ratio, y_ratio) / min(x_ratio, y_ratio));
float x_scale = contain ?
(x_ratio > y_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) :
(x_ratio > y_ratio ? aspect_ratio * u_scale : 1.0 * u_scale);
float y_scale = contain ?
(y_ratio > x_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) :
(y_ratio > x_ratio ? aspect_ratio * u_scale : 1.0 * u_scale);
float x_offset = u_alignX == 0 ? x_scale / 2.0 : u_alignX == 2 ? 1.0 - (x_scale / 2.0) : 0.5;
float y_offset = u_alignY == 0 ? y_scale / 2.0 : u_alignY == 2 ? 1.0 - (y_scale / 2.0) : 0.5;
if (!u_repeat) {
bool isInside = in_uv.x > x_offset - (x_scale / 2.0) && in_uv.x < x_offset + (x_scale / 2.0) &&
in_uv.y > y_offset - (y_scale / 2.0) && in_uv.y < y_offset + (y_scale / 2.0);
if (!isInside) {
out_color = pixel;
return;
}
}
vec4 watermarkPixel = texture(u_texture_watermark, vec2(
(in_uv.x - (x_offset - (x_scale / 2.0))) / x_scale,
(in_uv.y - (y_offset - (y_scale / 2.0))) / y_scale
));
out_color.r = mix(pixel.r, watermarkPixel.r, u_opacity * watermarkPixel.a);
out_color.g = mix(pixel.g, watermarkPixel.g, u_opacity * watermarkPixel.a);
out_color.b = mix(pixel.b, watermarkPixel.b, u_opacity * watermarkPixel.a);
out_color.a = pixel.a * (1.0 - u_opacity * watermarkPixel.a) + watermarkPixel.a * u_opacity;
}
`;
type ImageEffectorTextLayer = {
id: string;
type: 'text';
text: string;
repeat: boolean;
scale: number;
alignX: 'left' | 'center' | 'right';
alignY: 'top' | 'center' | 'bottom';
opacity: number;
}; };
type ImageEffectorImageLayer = { type ImageEffectorFxParamDefs = Record<string, {
id: string; type: keyof ParamTypeToPrimitive;
type: 'image'; default: any;
imageUrl: string | null; }>;
imageId: string | null;
cover: boolean; export function defineImageEffectorFx<ID extends string, P extends ImageEffectorFxParamDefs>(fx: ImageEffectorFx<ID, P>) {
repeat: boolean; return fx;
scale: number; }
alignX: 'left' | 'center' | 'right';
alignY: 'top' | 'center' | 'bottom'; export type ImageEffectorFx<ID extends string, P extends ImageEffectorFxParamDefs> = {
opacity: number; id: ID;
shader: string;
params: P,
main: (ctx: {
gl: WebGL2RenderingContext;
program: WebGLProgram;
params: {
[key in keyof P]: ParamTypeToPrimitive[P[key]['type']];
};
preTexture: WebGLTexture;
width: number;
height: number;
watermark?: {
texture: WebGLTexture;
width: number;
height: number;
};
}) => void;
}; };
export type ImageEffectorLayer = ImageEffectorTextLayer | ImageEffectorImageLayer; const FXS = [
FX_watermarkPlacement,
FX_chromaticAberration,
] as const satisfies ImageEffectorFx<string, any>[];
export type ImageEffectorLayerOf<
FXID extends (typeof FXS)[number]['id'],
FX extends { params: ImageEffectorFxParamDefs } = Extract<(typeof FXS)[number], { id: FXID }>,
> = {
id: string;
fxId: FXID;
params: {
[key in keyof FX['params']]: ParamTypeToPrimitive[FX['params'][key]['type']];
};
// for watermarkPlacement fx
imageUrl?: string | null;
text?: string | null;
};
export type ImageEffectorLayer = ImageEffectorLayerOf<(typeof FXS)[number]['id'], Extract<(typeof FXS)[number], { id: (typeof FXS)[number]['id'] }>>;
export class ImageEffector { export class ImageEffector {
private canvas: HTMLCanvasElement | null = null; private canvas: HTMLCanvasElement | null = null;
@ -104,8 +77,9 @@ export class ImageEffector {
private originalImageTexture: WebGLTexture; private originalImageTexture: WebGLTexture;
private resultTexture: WebGLTexture; private resultTexture: WebGLTexture;
private resultFrameBuffer: WebGLFramebuffer; private resultFrameBuffer: WebGLFramebuffer;
private bakedTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map(); private bakedTexturesForWatermarkFx: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
private texturesKey: string; private texturesKey: string;
private shaderCache: Map<string, WebGLProgram> = new Map();
constructor(options: { constructor(options: {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@ -191,9 +165,9 @@ export class ImageEffector {
private calcTexturesKey() { private calcTexturesKey() {
return this.layers.map(layer => { return this.layers.map(layer => {
if (layer.type === 'image') { if (layer.fxId === 'watermarkPlacement' && layer.imageUrl != null) {
return layer.imageId; return layer.imageUrl;
} else if (layer.type === 'text') { } else if (layer.fxId === 'watermarkPlacement' && layer.text != null) {
return layer.text; return layer.text;
} }
return ''; return '';
@ -218,10 +192,10 @@ export class ImageEffector {
throw new Error('gl is not initialized'); throw new Error('gl is not initialized');
} }
for (const bakedTexture of this.bakedTextures.values()) { for (const bakedTexture of this.bakedTexturesForWatermarkFx.values()) {
gl.deleteTexture(bakedTexture.texture); gl.deleteTexture(bakedTexture.texture);
} }
this.bakedTextures.clear(); this.bakedTexturesForWatermarkFx.clear();
} }
public async bakeTextures() { public async bakeTextures() {
@ -235,7 +209,7 @@ export class ImageEffector {
this.disposeBakedTextures(); this.disposeBakedTextures();
for (const layer of this.layers) { for (const layer of this.layers) {
if (layer.type === 'image') { if (layer.fxId === 'watermarkPlacement' && layer.imageUrl != null) {
const image = await new Promise<HTMLImageElement>((resolve, reject) => { const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
@ -249,12 +223,12 @@ export class ImageEffector {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); 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); gl.bindTexture(gl.TEXTURE_2D, null);
this.bakedTextures.set(layer.id, { this.bakedTexturesForWatermarkFx.set(layer.id, {
texture: texture, texture: texture,
width: image.width, width: image.width,
height: image.height, height: image.height,
}); });
} else if (layer.type === 'text') { } else if (layer.fxId === 'watermarkPlacement' && layer.text != null) {
const measureCtx = window.document.createElement('canvas').getContext('2d')!; const measureCtx = window.document.createElement('canvas').getContext('2d')!;
measureCtx.canvas.width = this.renderWidth; measureCtx.canvas.width = this.renderWidth;
measureCtx.canvas.height = this.renderHeight; measureCtx.canvas.height = this.renderHeight;
@ -289,7 +263,7 @@ export class ImageEffector {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, textCtx.canvas.width, textCtx.canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, textCtx.canvas); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, textCtx.canvas.width, textCtx.canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, textCtx.canvas);
gl.bindTexture(gl.TEXTURE_2D, null); gl.bindTexture(gl.TEXTURE_2D, null);
this.bakedTextures.set(layer.id, { this.bakedTexturesForWatermarkFx.set(layer.id, {
texture: texture, texture: texture,
width: textCtx.canvas.width, width: textCtx.canvas.width,
height: textCtx.canvas.height, height: textCtx.canvas.height,
@ -342,18 +316,19 @@ export class ImageEffector {
return shaderProgram; return shaderProgram;
} }
private renderTextOrImageLayer(layer: ImageEffectorTextLayer | ImageEffectorImageLayer) { private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture) {
const gl = this.gl; const gl = this.gl;
if (gl == null) { if (gl == null) {
throw new Error('gl is not initialized'); throw new Error('gl is not initialized');
} }
const watermarkTexture = this.bakedTextures.get(layer.id); const fx = FXS.find(fx => fx.id === layer.fxId);
if (watermarkTexture == null) { if (fx == null) return;
return;
}
const shaderProgram = this.initShaderProgram(`#version 300 es const watermark = layer.fxId === 'watermarkPlacement' ? this.bakedTexturesForWatermarkFx.get(layer.id) : undefined;
const cachedShader = this.shaderCache.get(fx.id);
const shaderProgram = cachedShader ?? this.initShaderProgram(`#version 300 es
in vec2 position; in vec2 position;
out vec2 in_uv; out vec2 in_uv;
@ -361,58 +336,30 @@ export class ImageEffector {
in_uv = (position + 1.0) / 2.0; in_uv = (position + 1.0) / 2.0;
gl_Position = vec4(position, 0.0, 1.0); gl_Position = vec4(position, 0.0, 1.0);
} }
`, WATERMARK_PLACEMENT_SHADER); `, fx.shader);
if (cachedShader == null) {
this.shaderCache.set(fx.id, shaderProgram);
}
gl.useProgram(shaderProgram); gl.useProgram(shaderProgram);
gl.activeTexture(gl.TEXTURE0); fx.main({
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture); gl: gl,
const u_texture_src = gl.getUniformLocation(shaderProgram, 'u_texture_src'); program: shaderProgram,
gl.uniform1i(u_texture_src, 0); params: Object.fromEntries(
Object.entries(fx.params).map(([key, param]) => {
gl.activeTexture(gl.TEXTURE1); return [key, layer.params[key] ?? param.default];
gl.bindTexture(gl.TEXTURE_2D, watermarkTexture.texture); }),
const u_texture_watermark = gl.getUniformLocation(shaderProgram, 'u_texture_watermark'); ) as any,
gl.uniform1i(u_texture_watermark, 1); preTexture: preTexture,
width: this.renderWidth,
const u_resolution_src = gl.getUniformLocation(shaderProgram, 'u_resolution_src'); height: this.renderHeight,
gl.uniform2fv(u_resolution_src, [this.renderWidth, this.renderHeight]); watermark: watermark,
});
const u_resolution_watermark = gl.getUniformLocation(shaderProgram, 'u_resolution_watermark');
gl.uniform2fv(u_resolution_watermark, [watermarkTexture.width, watermarkTexture.height]);
const u_scale = gl.getUniformLocation(shaderProgram, 'u_scale');
gl.uniform1f(u_scale, layer.scale);
const u_opacity = gl.getUniformLocation(shaderProgram, 'u_opacity');
gl.uniform1f(u_opacity, layer.opacity);
const u_angle = gl.getUniformLocation(shaderProgram, 'u_angle');
gl.uniform1f(u_angle, 0.0);
const u_repeat = gl.getUniformLocation(shaderProgram, 'u_repeat');
gl.uniform1i(u_repeat, layer.repeat ? 1 : 0);
const u_alignX = gl.getUniformLocation(shaderProgram, 'u_alignX');
gl.uniform1i(u_alignX, layer.alignX === 'left' ? 0 : layer.alignX === 'right' ? 2 : 1);
const u_alignY = gl.getUniformLocation(shaderProgram, 'u_alignY');
gl.uniform1i(u_alignY, layer.alignY === 'top' ? 0 : layer.alignY === 'bottom' ? 2 : 1);
const u_fitMode = gl.getUniformLocation(shaderProgram, 'u_fitMode');
gl.uniform1i(u_fitMode, layer.cover ? 1 : 0);
gl.drawArrays(gl.TRIANGLES, 0, 6); gl.drawArrays(gl.TRIANGLES, 0, 6);
} }
private renderLayer(layer: ImageEffectorLayer) {
if (layer.type === 'image') {
this.renderTextOrImageLayer(layer);
} else if (layer.type === 'text') {
this.renderTextOrImageLayer(layer);
}
}
public render() { public render() {
const gl = this.gl; const gl = this.gl;
if (gl == null) { if (gl == null) {
@ -449,7 +396,7 @@ export class ImageEffector {
// -------------------- // --------------------
for (const layer of this.layers) { for (const layer of this.layers) {
this.renderLayer(layer); this.renderLayer(layer, this.originalImageTexture);
} }
// -------------------- // --------------------
@ -481,6 +428,10 @@ export class ImageEffector {
throw new Error('gl is not initialized'); throw new Error('gl is not initialized');
} }
for (const shader of this.shaderCache.values()) {
gl.deleteProgram(shader);
}
this.disposeBakedTextures(); this.disposeBakedTextures();
gl.deleteProgram(this.renderTextureProgram); gl.deleteProgram(this.renderTextureProgram);
gl.deleteProgram(this.renderInvertedTextureProgram); gl.deleteProgram(this.renderInvertedTextureProgram);

View File

@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
const shader = `#version 300 es
precision highp float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
out vec4 out_color;
uniform float u_amount;
uniform float u_start;
uniform bool u_normalize;
void main() {
int samples = 64;
float r_strength = 1.0;
float g_strength = 1.5;
float b_strength = 2.0;
vec2 size = vec2(u_resolution.x, u_resolution.y);
vec4 accumulator = vec4(0.0);
float normalisedValue = length((in_uv - 0.5) * 2.0);
float strength = clamp((normalisedValue - u_start) * (1.0 / (1.0 - u_start)), 0.0, 1.0);
//vec2 vector = normalize((in_uv - (size / 2.0)) / size);
//vec2 vector = in_uv;
vec2 vector = (u_normalize ? normalize(in_uv - vec2(0.5)) : in_uv - vec2(0.5));
vec2 velocity = vector * strength * u_amount;
//vec2 rOffset = -vector * strength * (u_amount * 1.0);
//vec2 gOffset = -vector * strength * (u_amount * 1.5);
//vec2 bOffset = -vector * strength * (u_amount * 2.0);
//vec2 rOffset = -vector * strength * (u_amount * 0.5);
//vec2 gOffset = -vector * strength * (u_amount * 1.0);
//vec2 bOffset = -vector * strength * (u_amount * 2.0);
vec2 rOffset = -vector * strength * (u_amount * r_strength);
vec2 gOffset = -vector * strength * (u_amount * g_strength);
vec2 bOffset = -vector * strength * (u_amount * b_strength);
for (int i=0; i < samples; i++) {
accumulator.r += texture(u_texture, in_uv + rOffset).r;
rOffset -= velocity / float(samples);
accumulator.g += texture(u_texture, in_uv + gOffset).g;
gOffset -= velocity / float(samples);
accumulator.b += texture(u_texture, in_uv + bOffset).b;
bOffset -= velocity / float(samples);
}
out_color = vec4(vec3(accumulator / float(samples)), 1.0);
}
`;
export const FX_chromaticAberration = defineImageEffectorFx({
id: 'chromaticAberration' as const,
shader,
params: {
normalize: {
type: 'boolean' as const,
default: false,
},
amount: {
type: 'number' as const,
default: 0.1,
min: 0.0,
max: 1.0,
step: 0.01,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_amount = gl.getUniformLocation(program, 'u_amount');
gl.uniform1f(u_amount, params.amount);
const u_normalize = gl.getUniformLocation(program, 'u_normalize');
gl.uniform1i(u_normalize, params.normalize ? 1 : 0);
},
});

View File

@ -0,0 +1,142 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
const shader = `#version 300 es
precision highp float;
in vec2 in_uv;
uniform sampler2D u_texture_src;
uniform sampler2D u_texture_watermark;
uniform vec2 u_resolution_src;
uniform vec2 u_resolution_watermark;
uniform float u_scale;
uniform float u_angle;
uniform float u_opacity;
uniform bool u_repeat;
uniform int u_alignX; // 0: left, 1: center, 2: right
uniform int u_alignY; // 0: top, 1: center, 2: bottom
uniform int u_fitMode; // 0: contain, 1: cover
out vec4 out_color;
void main() {
vec4 pixel = texture(u_texture_src, in_uv);
bool contain = u_fitMode == 0;
float x_ratio = u_resolution_watermark.x / u_resolution_src.x;
float y_ratio = u_resolution_watermark.y / u_resolution_src.y;
float aspect_ratio = contain ?
(min(x_ratio, y_ratio) / max(x_ratio, y_ratio)) :
(max(x_ratio, y_ratio) / min(x_ratio, y_ratio));
float x_scale = contain ?
(x_ratio > y_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) :
(x_ratio > y_ratio ? aspect_ratio * u_scale : 1.0 * u_scale);
float y_scale = contain ?
(y_ratio > x_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) :
(y_ratio > x_ratio ? aspect_ratio * u_scale : 1.0 * u_scale);
float x_offset = u_alignX == 0 ? x_scale / 2.0 : u_alignX == 2 ? 1.0 - (x_scale / 2.0) : 0.5;
float y_offset = u_alignY == 0 ? y_scale / 2.0 : u_alignY == 2 ? 1.0 - (y_scale / 2.0) : 0.5;
if (!u_repeat) {
bool isInside = in_uv.x > x_offset - (x_scale / 2.0) && in_uv.x < x_offset + (x_scale / 2.0) &&
in_uv.y > y_offset - (y_scale / 2.0) && in_uv.y < y_offset + (y_scale / 2.0);
if (!isInside) {
out_color = pixel;
return;
}
}
vec4 watermarkPixel = texture(u_texture_watermark, vec2(
(in_uv.x - (x_offset - (x_scale / 2.0))) / x_scale,
(in_uv.y - (y_offset - (y_scale / 2.0))) / y_scale
));
out_color.r = mix(pixel.r, watermarkPixel.r, u_opacity * watermarkPixel.a);
out_color.g = mix(pixel.g, watermarkPixel.g, u_opacity * watermarkPixel.a);
out_color.b = mix(pixel.b, watermarkPixel.b, u_opacity * watermarkPixel.a);
out_color.a = pixel.a * (1.0 - u_opacity * watermarkPixel.a) + watermarkPixel.a * u_opacity;
}
`;
export const FX_watermarkPlacement = defineImageEffectorFx({
id: 'watermarkPlacement' as const,
shader,
params: {
cover: {
type: 'boolean' as const,
default: false,
},
repeat: {
type: 'boolean' as const,
default: false,
},
scale: {
type: 'number' as const,
default: 0.3,
min: 0.0,
max: 1.0,
step: 0.01,
},
align: {
type: 'align' as const,
default: { x: 'right', y: 'bottom' },
},
opacity: {
type: 'number' as const,
default: 0.75,
min: 0.0,
max: 1.0,
step: 0.01,
},
},
main: ({ gl, program, params, preTexture, width, height, watermark }) => {
if (watermark == null) {
return;
}
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture_src = gl.getUniformLocation(program, 'u_texture_src');
gl.uniform1i(u_texture_src, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, watermark.texture);
const u_texture_watermark = gl.getUniformLocation(program, 'u_texture_watermark');
gl.uniform1i(u_texture_watermark, 1);
const u_resolution_src = gl.getUniformLocation(program, 'u_resolution_src');
gl.uniform2fv(u_resolution_src, [width, height]);
const u_resolution_watermark = gl.getUniformLocation(program, 'u_resolution_watermark');
gl.uniform2fv(u_resolution_watermark, [watermark.width, watermark.height]);
const u_scale = gl.getUniformLocation(program, 'u_scale');
gl.uniform1f(u_scale, params.scale);
const u_opacity = gl.getUniformLocation(program, 'u_opacity');
gl.uniform1f(u_opacity, params.opacity);
const u_angle = gl.getUniformLocation(program, 'u_angle');
gl.uniform1f(u_angle, 0.0);
const u_repeat = gl.getUniformLocation(program, 'u_repeat');
gl.uniform1i(u_repeat, params.repeat ? 1 : 0);
const u_alignX = gl.getUniformLocation(program, 'u_alignX');
gl.uniform1i(u_alignX, params.align.x === 'left' ? 0 : params.align.x === 'right' ? 2 : 1);
const u_alignY = gl.getUniformLocation(program, 'u_alignY');
gl.uniform1i(u_alignY, params.align.y === 'top' ? 0 : params.align.y === 'bottom' ? 2 : 1);
const u_fitMode = gl.getUniformLocation(program, 'u_fitMode');
gl.uniform1i(u_fitMode, params.cover ? 1 : 0);
},
});

View File

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
export type WatermarkPreset = {
id: string;
name: string;
layers: ({
id: string;
type: 'text';
text: string;
repeat: boolean;
scale: number;
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
opacity: number;
} | {
id: string;
type: 'image';
imageUrl: string | null;
imageId: string | null;
cover: boolean;
repeat: boolean;
scale: number;
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
opacity: number;
})[];
};
export function makeImageEffectorLayers(layers: WatermarkPreset['layers']): ImageEffectorLayer[] {
return layers.map(layer => {
if (layer.type === 'text') {
return {
fxId: 'watermarkPlacement',
id: layer.id,
params: {
repeat: layer.repeat,
scale: layer.scale,
align: layer.align,
opacity: layer.opacity,
cover: false,
},
text: layer.text,
imageUrl: null,
};
} else {
return {
fxId: 'watermarkPlacement',
id: layer.id,
params: {
repeat: layer.repeat,
scale: layer.scale,
align: layer.align,
opacity: layer.opacity,
cover: layer.cover,
},
text: null,
imageUrl: layer.imageUrl ?? null,
};
}
});
}