wip
This commit is contained in:
parent
fad3aed79e
commit
31c4237748
|
@ -12066,6 +12066,12 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"image": string;
|
||||
};
|
||||
"_imageEffector": {
|
||||
/**
|
||||
* エフェクト
|
||||
*/
|
||||
"title": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
@ -3231,3 +3231,6 @@ _watermarkEditor:
|
|||
position: "位置"
|
||||
type: "タイプ"
|
||||
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>
|
||||
|
||||
<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 { v4 as uuid } from 'uuid';
|
||||
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 * as os from '@/os.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();
|
||||
|
||||
|
@ -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) {
|
||||
function changeWatermarkPreset(presetId: string | null) {
|
||||
item.watermarkPresetId = presetId;
|
||||
|
@ -490,7 +508,7 @@ async function preprocess(item: (typeof items)['value'][number]): Promise<void>
|
|||
canvas: canvas,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
layers: preset.layers,
|
||||
layers: makeImageEffectorLayers(preset.layers),
|
||||
originalImage: img,
|
||||
});
|
||||
|
||||
|
|
|
@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<FormSlot>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.position }}</template>
|
||||
<MkPositionSelector
|
||||
v-model:x="layer.alignX"
|
||||
v-model:y="layer.alignY"
|
||||
v-model:x="layer.align.x"
|
||||
v-model:y="layer.align.y"
|
||||
></MkPositionSelector>
|
||||
</FormSlot>
|
||||
|
||||
|
@ -50,8 +50,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<FormSlot>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.position }}</template>
|
||||
<MkPositionSelector
|
||||
v-model:x="layer.alignX"
|
||||
v-model:y="layer.alignY"
|
||||
v-model:x="layer.align.x"
|
||||
v-model:y="layer.align.y"
|
||||
></MkPositionSelector>
|
||||
</FormSlot>
|
||||
|
||||
|
@ -91,9 +91,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
|
||||
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 { ImageEffector } from '@/utility/ImageEffector.js';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.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 { prefer } from '@/preferences.js';
|
||||
|
||||
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
|
||||
const layer = defineModel<WatermarkPreset['layers'][number]>('layer', { required: true });
|
||||
|
||||
const driveFile = ref();
|
||||
const driveFileError = ref(false);
|
||||
|
|
|
@ -45,9 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
|
||||
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 { ImageEffector } from '@/utility/ImageEffector.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';
|
||||
|
@ -70,8 +71,7 @@ const preset = reactive(deepClone(props.preset) ?? {
|
|||
id: uuid(),
|
||||
type: 'text',
|
||||
text: `(c) @${$i.username}`,
|
||||
alignX: 'right',
|
||||
alignY: 'bottom',
|
||||
align: { x: 'right', y: 'bottom' },
|
||||
scale: 0.3,
|
||||
opacity: 0.75,
|
||||
repeat: false,
|
||||
|
@ -96,10 +96,9 @@ watch(type, () => {
|
|||
if (type.value === 'text') {
|
||||
preset.layers = [{
|
||||
id: uuid(),
|
||||
type: type.value,
|
||||
type: 'text',
|
||||
text: `(c) @${$i.username}`,
|
||||
alignX: 'right',
|
||||
alignY: 'bottom',
|
||||
align: { x: 'right', y: 'bottom' },
|
||||
scale: 0.3,
|
||||
opacity: 0.75,
|
||||
repeat: false,
|
||||
|
@ -107,11 +106,10 @@ watch(type, () => {
|
|||
} else if (type.value === 'image') {
|
||||
preset.layers = [{
|
||||
id: uuid(),
|
||||
type: type.value,
|
||||
type: 'image',
|
||||
imageId: null,
|
||||
imageUrl: null,
|
||||
alignX: 'right',
|
||||
alignY: 'bottom',
|
||||
align: { x: 'right', y: 'bottom' },
|
||||
scale: 0.3,
|
||||
opacity: 0.75,
|
||||
repeat: false,
|
||||
|
@ -121,7 +119,7 @@ watch(type, () => {
|
|||
|
||||
watch(preset, async (newValue, oldValue) => {
|
||||
if (renderer != null) {
|
||||
renderer.updateLayers(preset.layers);
|
||||
renderer.updateLayers(makeImageEffectorLayers(preset.layers));
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
|
@ -158,7 +156,7 @@ async function initRenderer() {
|
|||
canvas: canvasEl.value,
|
||||
width: 1500,
|
||||
height: 1000,
|
||||
layers: preset.layers,
|
||||
layers: makeImageEffectorLayers(preset.layers),
|
||||
originalImage: sampleImage_3_2,
|
||||
});
|
||||
} else if (sampleImageType.value === '2_3') {
|
||||
|
@ -166,7 +164,7 @@ async function initRenderer() {
|
|||
canvas: canvasEl.value,
|
||||
width: 1000,
|
||||
height: 1500,
|
||||
layers: preset.layers,
|
||||
layers: makeImageEffectorLayers(preset.layers),
|
||||
originalImage: sampleImage_2_3,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -22,13 +22,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
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 * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import { ImageEffector } from '@/utility/ImageEffector.js';
|
||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||
|
||||
const props = defineProps<{
|
||||
preset: WatermarkPreset;
|
||||
|
@ -75,7 +76,7 @@ onMounted(() => {
|
|||
canvas: canvasEl.value,
|
||||
width: 1500,
|
||||
height: 1000,
|
||||
layers: props.preset.layers,
|
||||
layers: makeImageEffectorLayers(props.preset.layers),
|
||||
originalImage: sampleImage,
|
||||
});
|
||||
|
||||
|
@ -95,7 +96,7 @@ onUnmounted(() => {
|
|||
|
||||
watch(() => props.preset, async () => {
|
||||
if (renderer != null) {
|
||||
renderer.updateLayers(props.preset.layers);
|
||||
renderer.updateLayers(makeImageEffectorLayers(props.preset.layers));
|
||||
await renderer.bakeTextures();
|
||||
renderer.render();
|
||||
}
|
||||
|
|
|
@ -149,7 +149,7 @@ import { computed, defineAsyncComponent, ref } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import XWatermarkItem from './drive.WatermarkItem.vue';
|
||||
import type { WatermarkPreset } from '@/preferences/def.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.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 { DeckProfile } from '@/deck.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';
|
||||
|
||||
/** サウンド設定 */
|
||||
|
@ -30,12 +30,6 @@ export type SoundStore = {
|
|||
volume: number;
|
||||
};
|
||||
|
||||
export type WatermarkPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
layers: ImageEffectorLayer[];
|
||||
};
|
||||
|
||||
// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる)
|
||||
|
||||
export const PREF_DEF = {
|
||||
|
|
|
@ -3,94 +3,67 @@
|
|||
* 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
|
||||
precision highp float;
|
||||
type ParamTypeToPrimitive = {
|
||||
'number': number;
|
||||
'boolean': boolean;
|
||||
'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
|
||||
};
|
||||
|
||||
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;
|
||||
type ImageEffectorFxParamDefs = Record<string, {
|
||||
type: keyof ParamTypeToPrimitive;
|
||||
default: any;
|
||||
}>;
|
||||
|
||||
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 function defineImageEffectorFx<ID extends string, P extends ImageEffectorFxParamDefs>(fx: ImageEffectorFx<ID, P>) {
|
||||
return fx;
|
||||
}
|
||||
`;
|
||||
|
||||
type ImageEffectorTextLayer = {
|
||||
id: string;
|
||||
type: 'text';
|
||||
text: string;
|
||||
repeat: boolean;
|
||||
scale: number;
|
||||
alignX: 'left' | 'center' | 'right';
|
||||
alignY: 'top' | 'center' | 'bottom';
|
||||
opacity: number;
|
||||
export type ImageEffectorFx<ID extends string, P extends ImageEffectorFxParamDefs> = {
|
||||
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;
|
||||
};
|
||||
|
||||
type 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;
|
||||
type: 'image';
|
||||
imageUrl: string | null;
|
||||
imageId: string | null;
|
||||
cover: boolean;
|
||||
repeat: boolean;
|
||||
scale: number;
|
||||
alignX: 'left' | 'center' | 'right';
|
||||
alignY: 'top' | 'center' | 'bottom';
|
||||
opacity: number;
|
||||
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 = ImageEffectorTextLayer | ImageEffectorImageLayer;
|
||||
export type ImageEffectorLayer = ImageEffectorLayerOf<(typeof FXS)[number]['id'], Extract<(typeof FXS)[number], { id: (typeof FXS)[number]['id'] }>>;
|
||||
|
||||
export class ImageEffector {
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
|
@ -104,8 +77,9 @@ export class ImageEffector {
|
|||
private originalImageTexture: WebGLTexture;
|
||||
private resultTexture: WebGLTexture;
|
||||
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 shaderCache: Map<string, WebGLProgram> = new Map();
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement;
|
||||
|
@ -191,9 +165,9 @@ export class ImageEffector {
|
|||
|
||||
private calcTexturesKey() {
|
||||
return this.layers.map(layer => {
|
||||
if (layer.type === 'image') {
|
||||
return layer.imageId;
|
||||
} else if (layer.type === 'text') {
|
||||
if (layer.fxId === 'watermarkPlacement' && layer.imageUrl != null) {
|
||||
return layer.imageUrl;
|
||||
} else if (layer.fxId === 'watermarkPlacement' && layer.text != null) {
|
||||
return layer.text;
|
||||
}
|
||||
return '';
|
||||
|
@ -218,10 +192,10 @@ export class ImageEffector {
|
|||
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);
|
||||
}
|
||||
this.bakedTextures.clear();
|
||||
this.bakedTexturesForWatermarkFx.clear();
|
||||
}
|
||||
|
||||
public async bakeTextures() {
|
||||
|
@ -235,7 +209,7 @@ export class ImageEffector {
|
|||
this.disposeBakedTextures();
|
||||
|
||||
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 img = new Image();
|
||||
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.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
this.bakedTextures.set(layer.id, {
|
||||
this.bakedTexturesForWatermarkFx.set(layer.id, {
|
||||
texture: texture,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
} else if (layer.type === 'text') {
|
||||
} else if (layer.fxId === 'watermarkPlacement' && layer.text != null) {
|
||||
const measureCtx = window.document.createElement('canvas').getContext('2d')!;
|
||||
measureCtx.canvas.width = this.renderWidth;
|
||||
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.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
this.bakedTextures.set(layer.id, {
|
||||
this.bakedTexturesForWatermarkFx.set(layer.id, {
|
||||
texture: texture,
|
||||
width: textCtx.canvas.width,
|
||||
height: textCtx.canvas.height,
|
||||
|
@ -342,18 +316,19 @@ export class ImageEffector {
|
|||
return shaderProgram;
|
||||
}
|
||||
|
||||
private renderTextOrImageLayer(layer: ImageEffectorTextLayer | ImageEffectorImageLayer) {
|
||||
private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture) {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
throw new Error('gl is not initialized');
|
||||
}
|
||||
|
||||
const watermarkTexture = this.bakedTextures.get(layer.id);
|
||||
if (watermarkTexture == null) {
|
||||
return;
|
||||
}
|
||||
const fx = FXS.find(fx => fx.id === layer.fxId);
|
||||
if (fx == null) 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;
|
||||
out vec2 in_uv;
|
||||
|
||||
|
@ -361,58 +336,30 @@ export class ImageEffector {
|
|||
in_uv = (position + 1.0) / 2.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.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||
const u_texture_src = gl.getUniformLocation(shaderProgram, 'u_texture_src');
|
||||
gl.uniform1i(u_texture_src, 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, watermarkTexture.texture);
|
||||
const u_texture_watermark = gl.getUniformLocation(shaderProgram, 'u_texture_watermark');
|
||||
gl.uniform1i(u_texture_watermark, 1);
|
||||
|
||||
const u_resolution_src = gl.getUniformLocation(shaderProgram, 'u_resolution_src');
|
||||
gl.uniform2fv(u_resolution_src, [this.renderWidth, this.renderHeight]);
|
||||
|
||||
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);
|
||||
fx.main({
|
||||
gl: gl,
|
||||
program: shaderProgram,
|
||||
params: Object.fromEntries(
|
||||
Object.entries(fx.params).map(([key, param]) => {
|
||||
return [key, layer.params[key] ?? param.default];
|
||||
}),
|
||||
) as any,
|
||||
preTexture: preTexture,
|
||||
width: this.renderWidth,
|
||||
height: this.renderHeight,
|
||||
watermark: watermark,
|
||||
});
|
||||
|
||||
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() {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
|
@ -449,7 +396,7 @@ export class ImageEffector {
|
|||
// --------------------
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
for (const shader of this.shaderCache.values()) {
|
||||
gl.deleteProgram(shader);
|
||||
}
|
||||
|
||||
this.disposeBakedTextures();
|
||||
gl.deleteProgram(this.renderTextureProgram);
|
||||
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