This commit is contained in:
syuilo 2025-05-28 08:31:36 +09:00
parent ed3a844f5d
commit 2a8920f8c3
10 changed files with 1038 additions and 59 deletions

42
locales/index.d.ts vendored
View File

@ -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;

View File

@ -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

View File

@ -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>

View File

@ -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);
});
}

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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,

View File

@ -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);
}
}