wip
This commit is contained in:
parent
ed3a844f5d
commit
2a8920f8c3
|
@ -12004,6 +12004,48 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"tip": string;
|
"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: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -3214,3 +3214,15 @@ _clip:
|
||||||
|
|
||||||
_userLists:
|
_userLists:
|
||||||
tip: "任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。"
|
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"
|
v-for="ctx in items"
|
||||||
:key="ctx.id"
|
:key="ctx.id"
|
||||||
v-panel
|
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%' }"
|
:style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
|
||||||
>
|
>
|
||||||
<div :class="$style.itemInner">
|
<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>
|
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
|
||||||
</div>
|
</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>
|
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
|
||||||
|
|
||||||
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく -->
|
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく -->
|
||||||
|
@ -93,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 * 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';
|
||||||
|
@ -125,6 +112,14 @@ const CROPPING_SUPPORTED_TYPES = [
|
||||||
'image/webp',
|
'image/webp',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const IMAGE_EDITING_SUPPORTED_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
|
||||||
|
|
||||||
const mimeTypeMap = {
|
const mimeTypeMap = {
|
||||||
'image/webp': 'webp',
|
'image/webp': 'webp',
|
||||||
'image/jpeg': 'jpg',
|
'image/jpeg': 'jpg',
|
||||||
|
@ -148,16 +143,19 @@ const emit = defineEmits<{
|
||||||
const items = ref<{
|
const items = ref<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
uploadName?: string;
|
||||||
progress: { max: number; value: number } | null;
|
progress: { max: number; value: number } | null;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
waiting: boolean;
|
preprocessing: boolean;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
uploaded: Misskey.entities.DriveFile | null;
|
uploaded: Misskey.entities.DriveFile | null;
|
||||||
uploadFailed: boolean;
|
uploadFailed: boolean;
|
||||||
aborted: boolean;
|
aborted: boolean;
|
||||||
|
compressionLevel: 0 | 1 | 2 | 3;
|
||||||
compressedSize?: number | null;
|
compressedSize?: number | null;
|
||||||
compressedImage?: Blob | null;
|
compressedImage?: Blob | null;
|
||||||
file: File;
|
file: File;
|
||||||
|
watermarkPresetId: string | null;
|
||||||
abort?: (() => void) | null;
|
abort?: (() => void) | null;
|
||||||
}[]>([]);
|
}[]>([]);
|
||||||
|
|
||||||
|
@ -165,7 +163,7 @@ const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
const firstUploadAttempted = ref(false);
|
const firstUploadAttempted = ref(false);
|
||||||
const isUploading = computed(() => items.value.some(item => item.uploading));
|
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 canDone = computed(() => items.value.some(item => item.uploaded != null));
|
||||||
const overallProgress = computed(() => {
|
const overallProgress = computed(() => {
|
||||||
const max = items.value.length;
|
const max = items.value.length;
|
||||||
|
@ -178,19 +176,18 @@ const overallProgress = computed(() => {
|
||||||
return Math.round((v / max) * 100);
|
return Math.round((v / max) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
|
function getCompressionSettings(level: 0 | 1 | 2 | 3) {
|
||||||
const compressionSettings = computed(() => {
|
if (level === 1) {
|
||||||
if (compressionLevel.value === 1) {
|
|
||||||
return {
|
return {
|
||||||
maxWidth: 2000,
|
maxWidth: 2000,
|
||||||
maxHeight: 2000,
|
maxHeight: 2000,
|
||||||
};
|
};
|
||||||
} else if (compressionLevel.value === 2) {
|
} else if (level === 2) {
|
||||||
return {
|
return {
|
||||||
maxWidth: 2000 * 0.75, // =1500
|
maxWidth: 2000 * 0.75, // =1500
|
||||||
maxHeight: 2000 * 0.75, // =1500
|
maxHeight: 2000 * 0.75, // =1500
|
||||||
};
|
};
|
||||||
} else if (compressionLevel.value === 3) {
|
} else if (level === 3) {
|
||||||
return {
|
return {
|
||||||
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
||||||
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
||||||
|
@ -198,7 +195,7 @@ const compressionSettings = computed(() => {
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
watch(items, () => {
|
watch(items, () => {
|
||||||
if (items.value.length === 0) {
|
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({
|
menu.push({
|
||||||
icon: 'ti ti-crop',
|
icon: 'ti ti-crop',
|
||||||
text: i18n.ts.cropImage,
|
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({
|
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',
|
icon: 'ti ti-x',
|
||||||
text: i18n.ts.remove,
|
text: i18n.ts.remove,
|
||||||
action: () => {
|
action: () => {
|
||||||
|
@ -299,6 +349,8 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||||
});
|
});
|
||||||
} else if (item.uploading) {
|
} else if (item.uploading) {
|
||||||
menu.push({
|
menu.push({
|
||||||
|
type: 'divider',
|
||||||
|
}, {
|
||||||
icon: 'ti ti-cloud-pause',
|
icon: 'ti ti-cloud-pause',
|
||||||
text: i18n.ts.abort,
|
text: i18n.ts.abort,
|
||||||
danger: true,
|
danger: true,
|
||||||
|
@ -320,7 +372,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
...item,
|
...item,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
waiting: false,
|
|
||||||
uploading: false,
|
uploading: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -330,40 +381,13 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.waiting = true;
|
|
||||||
item.uploadFailed = false;
|
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;
|
item.uploading = true;
|
||||||
|
|
||||||
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
|
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
|
||||||
name: item.name,
|
name: item.uploadName ?? item.name,
|
||||||
folderId: props.folderId,
|
folderId: props.folderId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
item.waiting = false;
|
|
||||||
if (item.progress == null) {
|
if (item.progress == null) {
|
||||||
item.progress = { max: progress.total, value: progress.loaded };
|
item.progress = { max: progress.total, value: progress.loaded };
|
||||||
} else {
|
} else {
|
||||||
|
@ -377,7 +401,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
item.abort = null;
|
item.abort = null;
|
||||||
abort();
|
abort();
|
||||||
item.uploading = false;
|
item.uploading = false;
|
||||||
item.waiting = false;
|
|
||||||
item.uploadFailed = true;
|
item.uploadFailed = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -392,7 +415,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
}
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
item.uploading = false;
|
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) {
|
function initializeFile(file: File) {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
const filename = file.name ?? 'untitled';
|
const filename = file.name ?? 'untitled';
|
||||||
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
||||||
items.value.push({
|
const item = {
|
||||||
id,
|
id,
|
||||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
||||||
progress: null,
|
progress: null,
|
||||||
thumbnail: window.URL.createObjectURL(file),
|
thumbnail: window.URL.createObjectURL(file),
|
||||||
waiting: false,
|
preprocessing: false,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
uploaded: null,
|
uploaded: null,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
|
compressionLevel: 2 as 0 | 1 | 2 | 3,
|
||||||
|
watermarkPresetId: null,
|
||||||
file: markRaw(file),
|
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 }}
|
{{ i18n.ts.drivecleaner }}
|
||||||
</FormLink>
|
</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']">
|
<SearchMarker :keywords="['keep', 'original', 'filename']">
|
||||||
<MkPreferenceContainer k="keepOriginalFilename">
|
<MkPreferenceContainer k="keepOriginalFilename">
|
||||||
<MkSwitch v-model="keepOriginalFilename">
|
<MkSwitch v-model="keepOriginalFilename">
|
||||||
|
@ -81,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue';
|
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 FormLink from '@/components/form/link.vue';
|
import FormLink from '@/components/form/link.vue';
|
||||||
|
@ -100,6 +109,8 @@ import { prefer } from '@/preferences.js';
|
||||||
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
|
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
|
||||||
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
||||||
import { selectDriveFolder } from '@/utility/drive.js';
|
import { selectDriveFolder } from '@/utility/drive.js';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
@ -152,6 +163,13 @@ function chooseUploadFolder() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addWatermarkPreset() {
|
||||||
|
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {
|
||||||
|
}, {
|
||||||
|
closed: () => dispose(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function saveProfile() {
|
function saveProfile() {
|
||||||
misskeyApi('i/update', {
|
misskeyApi('i/update', {
|
||||||
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
||||||
|
|
|
@ -11,6 +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 { WatermarkPreset } from '@/utility/watermarker.js';
|
||||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||||
|
|
||||||
/** サウンド設定 */
|
/** サウンド設定 */
|
||||||
|
@ -349,6 +350,9 @@ export const PREF_DEF = {
|
||||||
mutingEmojis: {
|
mutingEmojis: {
|
||||||
default: [] as string[],
|
default: [] as string[],
|
||||||
},
|
},
|
||||||
|
watermarkPresets: {
|
||||||
|
default: [] as WatermarkPreset[],
|
||||||
|
},
|
||||||
|
|
||||||
'sound.masterVolume': {
|
'sound.masterVolume': {
|
||||||
default: 0.5,
|
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