wip
This commit is contained in:
parent
ed3a844f5d
commit
2a8920f8c3
|
@ -12004,6 +12004,48 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"tip": string;
|
||||
};
|
||||
/**
|
||||
* ウォーターマーク
|
||||
*/
|
||||
"watermark": string;
|
||||
"_watermarkEditor": {
|
||||
/**
|
||||
* 画像にクレジット情報などのウォーターマークを追加することができます。
|
||||
*/
|
||||
"tip": string;
|
||||
/**
|
||||
* ウォーターマークの編集
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* 敷き詰める
|
||||
*/
|
||||
"repeat": string;
|
||||
/**
|
||||
* 不透明度
|
||||
*/
|
||||
"opacity": string;
|
||||
/**
|
||||
* 大きさ
|
||||
*/
|
||||
"scale": string;
|
||||
/**
|
||||
* テキスト
|
||||
*/
|
||||
"text": string;
|
||||
/**
|
||||
* 位置
|
||||
*/
|
||||
"position": string;
|
||||
/**
|
||||
* タイプ
|
||||
*/
|
||||
"type": string;
|
||||
/**
|
||||
* 画像
|
||||
*/
|
||||
"image": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
@ -3214,3 +3214,15 @@ _clip:
|
|||
|
||||
_userLists:
|
||||
tip: "任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。"
|
||||
|
||||
watermark: "ウォーターマーク"
|
||||
_watermarkEditor:
|
||||
tip: "画像にクレジット情報などのウォーターマークを追加することができます。"
|
||||
title: "ウォーターマークの編集"
|
||||
repeat: "敷き詰める"
|
||||
opacity: "不透明度"
|
||||
scale: "大きさ"
|
||||
text: "テキスト"
|
||||
position: "位置"
|
||||
type: "タイプ"
|
||||
image: "画像"
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 410 KiB |
|
@ -0,0 +1,53 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[$style.root]">
|
||||
<div :class="$style.items">
|
||||
<button class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-align-box-left-top"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-align-box-center-top"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-align-box-right-top"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-align-box-left-middle"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-align-box-center-middle"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-align-box-right-middle"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-align-box-left-bottom"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-align-box-center-bottom"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-align-box-right-bottom"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
|
||||
const x = defineModel<string>('x', { default: 'center' });
|
||||
const y = defineModel<string>('y', { default: 'center' });
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.item {
|
||||
height: 32px;
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: 4px;
|
||||
|
||||
&.active {
|
||||
background: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-for="ctx in items"
|
||||
:key="ctx.id"
|
||||
v-panel
|
||||
:class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
|
||||
:class="[$style.item, ctx.preprocessing ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
|
||||
:style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
|
||||
>
|
||||
<div :class="$style.itemInner">
|
||||
|
@ -59,19 +59,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
|
||||
</div>
|
||||
|
||||
<MkSelect
|
||||
v-if="items.length > 0"
|
||||
v-model="compressionLevel"
|
||||
:items="[
|
||||
{ value: 0, label: i18n.ts.none },
|
||||
{ value: 1, label: i18n.ts.low },
|
||||
{ value: 2, label: i18n.ts.middle },
|
||||
{ value: 3, label: i18n.ts.high },
|
||||
]"
|
||||
>
|
||||
<template #label>{{ i18n.ts.compress }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
|
||||
|
||||
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく -->
|
||||
|
@ -93,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { computed, markRaw, onMounted, 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';
|
||||
|
@ -125,6 +112,14 @@ const CROPPING_SUPPORTED_TYPES = [
|
|||
'image/webp',
|
||||
];
|
||||
|
||||
const IMAGE_EDITING_SUPPORTED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
|
||||
|
||||
const mimeTypeMap = {
|
||||
'image/webp': 'webp',
|
||||
'image/jpeg': 'jpg',
|
||||
|
@ -148,16 +143,19 @@ const emit = defineEmits<{
|
|||
const items = ref<{
|
||||
id: string;
|
||||
name: string;
|
||||
uploadName?: string;
|
||||
progress: { max: number; value: number } | null;
|
||||
thumbnail: string;
|
||||
waiting: boolean;
|
||||
preprocessing: boolean;
|
||||
uploading: boolean;
|
||||
uploaded: Misskey.entities.DriveFile | null;
|
||||
uploadFailed: boolean;
|
||||
aborted: boolean;
|
||||
compressionLevel: 0 | 1 | 2 | 3;
|
||||
compressedSize?: number | null;
|
||||
compressedImage?: Blob | null;
|
||||
file: File;
|
||||
watermarkPresetId: string | null;
|
||||
abort?: (() => void) | null;
|
||||
}[]>([]);
|
||||
|
||||
|
@ -165,7 +163,7 @@ const dialog = useTemplateRef('dialog');
|
|||
|
||||
const firstUploadAttempted = ref(false);
|
||||
const isUploading = computed(() => items.value.some(item => item.uploading));
|
||||
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null));
|
||||
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.preprocessing) && items.value.some(item => item.uploaded == null));
|
||||
const canDone = computed(() => items.value.some(item => item.uploaded != null));
|
||||
const overallProgress = computed(() => {
|
||||
const max = items.value.length;
|
||||
|
@ -178,19 +176,18 @@ const overallProgress = computed(() => {
|
|||
return Math.round((v / max) * 100);
|
||||
});
|
||||
|
||||
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
|
||||
const compressionSettings = computed(() => {
|
||||
if (compressionLevel.value === 1) {
|
||||
function getCompressionSettings(level: 0 | 1 | 2 | 3) {
|
||||
if (level === 1) {
|
||||
return {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
};
|
||||
} else if (compressionLevel.value === 2) {
|
||||
} else if (level === 2) {
|
||||
return {
|
||||
maxWidth: 2000 * 0.75, // =1500
|
||||
maxHeight: 2000 * 0.75, // =1500
|
||||
};
|
||||
} else if (compressionLevel.value === 3) {
|
||||
} else if (level === 3) {
|
||||
return {
|
||||
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
||||
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
||||
|
@ -198,7 +195,7 @@ const compressionSettings = computed(() => {
|
|||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(items, () => {
|
||||
if (items.value.length === 0) {
|
||||
|
@ -274,7 +271,7 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
|||
},
|
||||
});
|
||||
|
||||
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) {
|
||||
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
menu.push({
|
||||
icon: 'ti ti-crop',
|
||||
text: i18n.ts.cropImage,
|
||||
|
@ -289,8 +286,61 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
|||
});
|
||||
}
|
||||
|
||||
if (!item.waiting && !item.uploading && !item.uploaded) {
|
||||
if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
menu.push({
|
||||
icon: 'ti ti-copyright',
|
||||
text: i18n.ts.watermark,
|
||||
type: 'parent',
|
||||
children: [{
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.none,
|
||||
active: computed(() => item.watermarkPresetId == null),
|
||||
action: () => item.watermarkPresetId = null,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
if (COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
function changeCompressionLevel(level: 0 | 1 | 2 | 3) {
|
||||
item.compressionLevel = level;
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
icon: 'ti ti-leaf',
|
||||
text: i18n.ts.compress,
|
||||
type: 'parent',
|
||||
children: [{
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.none,
|
||||
active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null),
|
||||
action: () => changeCompressionLevel(0),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.low,
|
||||
active: computed(() => item.compressionLevel === 1),
|
||||
action: () => changeCompressionLevel(1),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.medium,
|
||||
active: computed(() => item.compressionLevel === 2),
|
||||
action: () => changeCompressionLevel(2),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.high,
|
||||
active: computed(() => item.compressionLevel === 3),
|
||||
action: () => changeCompressionLevel(3),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (!item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
menu.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
icon: 'ti ti-x',
|
||||
text: i18n.ts.remove,
|
||||
action: () => {
|
||||
|
@ -299,6 +349,8 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
|||
});
|
||||
} else if (item.uploading) {
|
||||
menu.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
icon: 'ti ti-cloud-pause',
|
||||
text: i18n.ts.abort,
|
||||
danger: true,
|
||||
|
@ -320,7 +372,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
|||
...item,
|
||||
aborted: false,
|
||||
uploadFailed: false,
|
||||
waiting: false,
|
||||
uploading: false,
|
||||
}));
|
||||
|
||||
|
@ -330,40 +381,13 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
|||
continue;
|
||||
}
|
||||
|
||||
item.waiting = true;
|
||||
item.uploadFailed = false;
|
||||
|
||||
const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file));
|
||||
|
||||
if (shouldCompress) {
|
||||
const config = {
|
||||
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
|
||||
maxWidth: compressionSettings.value.maxWidth,
|
||||
maxHeight: compressionSettings.value.maxHeight,
|
||||
quality: isWebpSupported() ? 0.85 : 0.8,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await readAndCompressImage(item.file, config);
|
||||
if (result.size < item.file.size || item.file.type === 'image/webp') {
|
||||
// The compression may not always reduce the file size
|
||||
// (and WebP is not browser safe yet)
|
||||
item.compressedImage = markRaw(result);
|
||||
item.compressedSize = result.size;
|
||||
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resize image', err);
|
||||
}
|
||||
}
|
||||
|
||||
item.uploading = true;
|
||||
|
||||
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
|
||||
name: item.name,
|
||||
name: item.uploadName ?? item.name,
|
||||
folderId: props.folderId,
|
||||
onProgress: (progress) => {
|
||||
item.waiting = false;
|
||||
if (item.progress == null) {
|
||||
item.progress = { max: progress.total, value: progress.loaded };
|
||||
} else {
|
||||
|
@ -377,7 +401,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
|||
item.abort = null;
|
||||
abort();
|
||||
item.uploading = false;
|
||||
item.waiting = false;
|
||||
item.uploadFailed = true;
|
||||
};
|
||||
|
||||
|
@ -392,7 +415,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
|||
}
|
||||
}).finally(() => {
|
||||
item.uploading = false;
|
||||
item.waiting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -419,21 +441,62 @@ async function chooseFile(ev: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
async function preprocess(item: (typeof items)['value'][number]): Promise<void> {
|
||||
item.preprocessing = true;
|
||||
|
||||
const compressionSettings = getCompressionSettings(item.compressionLevel);
|
||||
const shouldCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file));
|
||||
|
||||
if (shouldCompress) {
|
||||
const config = {
|
||||
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
|
||||
maxWidth: compressionSettings.maxWidth,
|
||||
maxHeight: compressionSettings.maxHeight,
|
||||
quality: isWebpSupported() ? 0.85 : 0.8,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await readAndCompressImage(item.file, config);
|
||||
if (result.size < item.file.size || item.file.type === 'image/webp') {
|
||||
// The compression may not always reduce the file size
|
||||
// (and WebP is not browser safe yet)
|
||||
item.compressedImage = markRaw(result);
|
||||
item.compressedSize = result.size;
|
||||
item.uploadName = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resize image', err);
|
||||
}
|
||||
} else {
|
||||
item.compressedImage = null;
|
||||
item.compressedSize = null;
|
||||
item.uploadName = item.name;
|
||||
}
|
||||
|
||||
item.preprocessing = false;
|
||||
}
|
||||
|
||||
function initializeFile(file: File) {
|
||||
const id = uuid();
|
||||
const filename = file.name ?? 'untitled';
|
||||
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
||||
items.value.push({
|
||||
const item = {
|
||||
id,
|
||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
||||
progress: null,
|
||||
thumbnail: window.URL.createObjectURL(file),
|
||||
waiting: false,
|
||||
preprocessing: false,
|
||||
uploading: false,
|
||||
aborted: false,
|
||||
uploaded: null,
|
||||
uploadFailed: false,
|
||||
compressionLevel: 2 as 0 | 1 | 2 | 3,
|
||||
watermarkPresetId: null,
|
||||
file: markRaw(file),
|
||||
};
|
||||
items.value.push(item);
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" class="_gaps">
|
||||
<div>
|
||||
<MkButton inline rounded primary @click="chooseFile">{{ i18n.ts.selectFile }}</MkButton>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
continuousUpdate
|
||||
>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange
|
||||
v-model="layer.opacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
continuousUpdate
|
||||
>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="layer.repeat">
|
||||
<template #label>{{ i18n.ts._watermarkEditor.repeat }}</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 { WatermarkerLayer, WatermarkPreset } from '@/utility/watermarker.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { Watermarker } from '@/utility/watermarker.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<WatermarkerLayer>('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({
|
||||
anchorElement: ev.currentTarget ?? ev.target,
|
||||
multiple: false,
|
||||
label: i18n.ts.selectFile,
|
||||
features: {
|
||||
watermark: false,
|
||||
},
|
||||
}).then((file) => {
|
||||
if (!file.type.startsWith('image')) {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
title: i18n.ts._watermarkEditor.driveFileTypeWarn,
|
||||
text: i18n.ts._watermarkEditor.driveFileTypeWarnDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
fileId.value = file.id;
|
||||
fileUrl.value = file.url;
|
||||
fileName.value = file.name;
|
||||
driveFileError.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,202 @@
|
|||
<!--
|
||||
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-copyright"></i> {{ i18n.ts._watermarkEditor.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.watermarkEditorPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls" class="_gaps">
|
||||
<MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }]"></MkSelect>
|
||||
|
||||
<XLayer
|
||||
v-for="(layer, i) in preset.layers"
|
||||
:key="layer.id"
|
||||
v-model:layer="preset.layers[i]"
|
||||
></XLayer>
|
||||
</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/watermarker.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { Watermarker } from '@/utility/watermarker.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 MkSwitch from '@/components/MkSwitch.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import XLayer from '@/components/MkWatermarkEditorDialog.Layer.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';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
|
||||
const props = defineProps<{
|
||||
preset: WatermarkPreset | null;
|
||||
}>();
|
||||
|
||||
const preset = reactive(deepClone(props.preset) ?? {
|
||||
id: uuid(),
|
||||
name: '',
|
||||
layers: [{
|
||||
id: uuid(),
|
||||
type: 'text',
|
||||
text: 'sample',
|
||||
alignX: 'right',
|
||||
alignY: 'bottom',
|
||||
scale: 0.5,
|
||||
opacity: 0.5,
|
||||
repeat: false,
|
||||
}],
|
||||
} satisfies WatermarkPreset);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok'): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
const type = ref(preset.layers[0].type);
|
||||
|
||||
watch(preset, async (newValue, oldValue) => {
|
||||
if (renderer != null) {
|
||||
renderer.updatePreset(preset);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const sampleImage = new Image();
|
||||
sampleImage.src = '/client-assets/sample/3-2.jpg';
|
||||
|
||||
let renderer: Watermarker | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
sampleImage.onload = async () => {
|
||||
renderer = new Watermarker({
|
||||
canvas: canvasEl.value,
|
||||
width: 1500,
|
||||
height: 1000,
|
||||
preset: preset,
|
||||
originalImage: sampleImage,
|
||||
});
|
||||
|
||||
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);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.watermarkEditorPreviewTitle {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.watermarkEditorPreviewSpinner {
|
||||
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>
|
|
@ -53,6 +53,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
{{ i18n.ts.drivecleaner }}
|
||||
</FormLink>
|
||||
|
||||
<SearchMarker :keywords="['watermark', 'credit']">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-copyright"></i></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template>
|
||||
|
||||
<MkButton iconOnly @click="addWatermarkPreset"><i class="ti ti-plus"></i></MkButton>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['keep', 'original', 'filename']">
|
||||
<MkPreferenceContainer k="keepOriginalFilename">
|
||||
<MkSwitch v-model="keepOriginalFilename">
|
||||
|
@ -81,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
|
@ -100,6 +109,8 @@ import { prefer } from '@/preferences.js';
|
|||
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
|
||||
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
||||
import { selectDriveFolder } from '@/utility/drive.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
|
@ -152,6 +163,13 @@ function chooseUploadFolder() {
|
|||
});
|
||||
}
|
||||
|
||||
function addWatermarkPreset() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
misskeyApi('i/update', {
|
||||
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
||||
|
|
|
@ -11,6 +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 { WatermarkPreset } from '@/utility/watermarker.js';
|
||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||
|
||||
/** サウンド設定 */
|
||||
|
@ -349,6 +350,9 @@ export const PREF_DEF = {
|
|||
mutingEmojis: {
|
||||
default: [] as string[],
|
||||
},
|
||||
watermarkPresets: {
|
||||
default: [] as WatermarkPreset[],
|
||||
},
|
||||
|
||||
'sound.masterVolume': {
|
||||
default: 0.5,
|
||||
|
|
|
@ -0,0 +1,469 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const A_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
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
vec4 pixel = texture(u_texture_src, in_uv);
|
||||
|
||||
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 = min(x_ratio, y_ratio) / max(x_ratio, y_ratio);
|
||||
float x_scale = x_ratio > y_ratio ? 1.0 * u_scale : aspect_ratio * u_scale;
|
||||
float y_scale = y_ratio > x_ratio ? 1.0 * u_scale : aspect_ratio * 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 type WatermarkPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
layers: WatermarkerLayer[];
|
||||
};
|
||||
|
||||
type WatermarkerTextLayer = {
|
||||
id: string;
|
||||
type: 'text';
|
||||
text: string;
|
||||
repeat: boolean;
|
||||
scale: number;
|
||||
alignX: 'left' | 'center' | 'right';
|
||||
alignY: 'top' | 'center' | 'bottom';
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export type WatermarkerImageLayer = {
|
||||
id: string;
|
||||
type: 'image';
|
||||
imageUrl: string;
|
||||
imageId: string;
|
||||
repeat: boolean;
|
||||
scale: number;
|
||||
alignX: 'left' | 'center' | 'right';
|
||||
alignY: 'top' | 'center' | 'bottom';
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export type WatermarkerLayer = WatermarkerTextLayer | WatermarkerImageLayer;
|
||||
|
||||
export class Watermarker {
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
public gl: WebGL2RenderingContext | null = null;
|
||||
public renderTextureProgram!: WebGLProgram;
|
||||
public renderInvertedTextureProgram!: WebGLProgram;
|
||||
public renderWidth!: number;
|
||||
public renderHeight!: number;
|
||||
public originalImage: HTMLImageElement;
|
||||
private preset: WatermarkPreset;
|
||||
private originalImageTexture: WebGLTexture;
|
||||
private resultTexture: WebGLTexture;
|
||||
private resultFrameBuffer: WebGLFramebuffer;
|
||||
private bakedTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
|
||||
private texturesKey: string;
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement;
|
||||
width: number;
|
||||
height: number;
|
||||
originalImage: HTMLImageElement;
|
||||
preset: WatermarkPreset;
|
||||
}) {
|
||||
this.canvas = options.canvas;
|
||||
this.canvas.width = options.width;
|
||||
this.canvas.height = options.height;
|
||||
this.renderWidth = options.width;
|
||||
this.renderHeight = options.height;
|
||||
this.originalImage = options.originalImage;
|
||||
this.preset = options.preset;
|
||||
this.texturesKey = this.calcTexturesKey();
|
||||
|
||||
this.gl = this.canvas.getContext('webgl2', {
|
||||
preserveDrawingBuffer: false,
|
||||
alpha: true,
|
||||
premultipliedAlpha: false,
|
||||
})!;
|
||||
|
||||
const gl = this.gl;
|
||||
|
||||
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
||||
|
||||
const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
|
||||
const vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
|
||||
this.originalImageTexture = this.createTexture();
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.originalImage.width, this.originalImage.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
this.resultTexture = this.createTexture();
|
||||
this.resultFrameBuffer = gl.createFramebuffer()!;
|
||||
|
||||
this.renderTextureProgram = this.initShaderProgram(`#version 300 es
|
||||
in vec2 position;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`, `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D u_texture;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
out_color = texture(u_texture, in_uv);
|
||||
}
|
||||
`)!;
|
||||
|
||||
this.renderInvertedTextureProgram = this.initShaderProgram(`#version 300 es
|
||||
in vec2 position;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
in_uv.y = 1.0 - in_uv.y;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`, `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D u_texture;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
out_color = texture(u_texture, in_uv);
|
||||
}
|
||||
`)!;
|
||||
}
|
||||
|
||||
private calcTexturesKey() {
|
||||
return this.preset.layers.map(layer => {
|
||||
if (layer.type === 'image') {
|
||||
return layer.imageId;
|
||||
} else if (layer.type === 'text') {
|
||||
return layer.text;
|
||||
}
|
||||
return '';
|
||||
}).join(';');
|
||||
}
|
||||
|
||||
private createTexture(): WebGLTexture {
|
||||
const gl = this.gl!;
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
return texture!;
|
||||
}
|
||||
|
||||
public disposeBakedTextures() {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
throw new Error('gl is not initialized');
|
||||
}
|
||||
|
||||
for (const bakedTexture of this.bakedTextures.values()) {
|
||||
gl.deleteTexture(bakedTexture.texture);
|
||||
}
|
||||
this.bakedTextures.clear();
|
||||
}
|
||||
|
||||
public async bakeTextures() {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
throw new Error('gl is not initialized');
|
||||
}
|
||||
|
||||
console.log('baking textures', this.texturesKey);
|
||||
|
||||
this.disposeBakedTextures();
|
||||
|
||||
for (const layer of this.preset.layers) {
|
||||
if (layer.type === 'image') {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = layer.imageUrl;
|
||||
});
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||
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);
|
||||
} else if (layer.type === 'text') {
|
||||
const measureCtx = window.document.createElement('canvas').getContext('2d')!;
|
||||
measureCtx.canvas.width = this.renderWidth;
|
||||
measureCtx.canvas.height = this.renderHeight;
|
||||
const fontSize = Math.min(this.renderWidth, this.renderHeight) / 20;
|
||||
const margin = Math.min(this.renderWidth, this.renderHeight) / 50;
|
||||
measureCtx.font = `bold ${fontSize}px sans-serif`;
|
||||
const textMetrics = measureCtx.measureText(layer.text);
|
||||
|
||||
const RESOLUTION_FACTOR = 4;
|
||||
|
||||
const textCtx = window.document.createElement('canvas').getContext('2d')!;
|
||||
textCtx.canvas.width = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin) * RESOLUTION_FACTOR;
|
||||
textCtx.canvas.height = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin) * RESOLUTION_FACTOR;
|
||||
|
||||
//textCtx.fillStyle = '#00ff00';
|
||||
//textCtx.fillRect(0, 0, textCtx.canvas.width, textCtx.canvas.height);
|
||||
|
||||
textCtx.shadowColor = '#000000';
|
||||
textCtx.shadowBlur = 10 * RESOLUTION_FACTOR;
|
||||
|
||||
textCtx.fillStyle = '#ffffff';
|
||||
textCtx.font = `bold ${fontSize * RESOLUTION_FACTOR}px sans-serif`;
|
||||
textCtx.textBaseline = 'middle';
|
||||
textCtx.textAlign = 'center';
|
||||
|
||||
textCtx.fillText(layer.text, textCtx.canvas.width / 2, textCtx.canvas.height / 2);
|
||||
|
||||
const texture = this.createTexture();
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
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, {
|
||||
texture: texture,
|
||||
width: textCtx.canvas.width,
|
||||
height: textCtx.canvas.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public loadShader(type, source) {
|
||||
const gl = this.gl!;
|
||||
|
||||
const shader = gl.createShader(type)!;
|
||||
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
alert(
|
||||
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
|
||||
);
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
public initShaderProgram(vsSource, fsSource): WebGLProgram {
|
||||
const gl = this.gl!;
|
||||
|
||||
const vertexShader = this.loadShader(gl.VERTEX_SHADER, vsSource)!;
|
||||
const fragmentShader = this.loadShader(gl.FRAGMENT_SHADER, fsSource)!;
|
||||
|
||||
const shaderProgram = gl.createProgram()!;
|
||||
gl.attachShader(shaderProgram, vertexShader);
|
||||
gl.attachShader(shaderProgram, fragmentShader);
|
||||
gl.linkProgram(shaderProgram);
|
||||
|
||||
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
|
||||
alert(
|
||||
`failed to init shader: ${gl.getProgramInfoLog(
|
||||
shaderProgram,
|
||||
)}`,
|
||||
);
|
||||
throw new Error('failed to init shader');
|
||||
}
|
||||
|
||||
return shaderProgram;
|
||||
}
|
||||
|
||||
private renderTextLayer(layer: WatermarkerTextLayer) {
|
||||
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 shaderProgram = this.initShaderProgram(`#version 300 es
|
||||
in vec2 position;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`, A_SHADER);
|
||||
|
||||
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);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
private renderLayer(layer: WatermarkerLayer) {
|
||||
if (layer.type === 'image') {
|
||||
this.renderImageLayer(layer);
|
||||
} else if (layer.type === 'text') {
|
||||
this.renderTextLayer(layer);
|
||||
}
|
||||
}
|
||||
|
||||
public async render() {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
throw new Error('gl is not initialized');
|
||||
}
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.resultTexture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, this.resultFrameBuffer);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.resultTexture, 0);
|
||||
|
||||
// --------------------
|
||||
|
||||
{
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||
|
||||
gl.useProgram(this.renderTextureProgram);
|
||||
const u_texture = gl.getUniformLocation(this.renderTextureProgram, 'u_texture');
|
||||
gl.uniform1i(u_texture, 0);
|
||||
const u_resolution = gl.getUniformLocation(this.renderTextureProgram, 'u_resolution');
|
||||
gl.uniform2fv(u_resolution, [this.renderWidth, this.renderHeight]);
|
||||
const positionLocation = gl.getAttribLocation(this.renderTextureProgram, 'position');
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
// --------------------
|
||||
|
||||
for (const layer of this.preset.layers) {
|
||||
this.renderLayer(layer);
|
||||
}
|
||||
|
||||
// --------------------
|
||||
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.useProgram(this.renderInvertedTextureProgram);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.resultTexture);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
public async updatePreset(preset: WatermarkPreset) {
|
||||
this.preset = preset;
|
||||
|
||||
const newTexturesKey = this.calcTexturesKey();
|
||||
if (newTexturesKey !== this.texturesKey) {
|
||||
this.texturesKey = newTexturesKey;
|
||||
await this.bakeTextures();
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
throw new Error('gl is not initialized');
|
||||
}
|
||||
|
||||
this.disposeBakedTextures();
|
||||
gl.deleteProgram(this.renderTextureProgram);
|
||||
gl.deleteProgram(this.renderInvertedTextureProgram);
|
||||
gl.deleteTexture(this.originalImageTexture);
|
||||
gl.deleteTexture(this.resultTexture);
|
||||
gl.deleteFramebuffer(this.resultFrameBuffer);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue