wip
This commit is contained in:
parent
fad3aed79e
commit
31c4237748
|
@ -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;
|
||||||
|
|
|
@ -3231,3 +3231,6 @@ _watermarkEditor:
|
||||||
position: "位置"
|
position: "位置"
|
||||||
type: "タイプ"
|
type: "タイプ"
|
||||||
image: "画像"
|
image: "画像"
|
||||||
|
|
||||||
|
_imageEffector:
|
||||||
|
title: "エフェクト"
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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);
|
|
@ -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);
|
||||||
|
},
|
||||||
|
});
|
|
@ -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);
|
||||||
|
},
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue