Compare commits

...

5 Commits

Author SHA1 Message Date
syuilo 1ebd75ac10 wip 2025-06-02 20:38:04 +09:00
syuilo cf96ef461d wip 2025-06-02 20:20:13 +09:00
syuilo 6adc9c910a wip 2025-06-02 19:46:18 +09:00
syuilo 74dbee9e15 Update MkUploaderDialog.vue 2025-06-02 15:55:04 +09:00
syuilo 2226aedb69 wip 2025-06-02 15:09:14 +09:00
9 changed files with 163 additions and 64 deletions

View File

@ -4,7 +4,7 @@
- -
### Client ### Client
- - Feat: 画像にウォーターマークを付与できるようになりました
### Server ### Server
- -

4
locales/index.d.ts vendored
View File

@ -12081,6 +12081,10 @@ export interface Locale extends ILocale {
* *
*/ */
"image": string; "image": string;
/**
*
*/
"advanced": string;
}; };
"_imageEffector": { "_imageEffector": {
/** /**

View File

@ -3235,6 +3235,7 @@ _watermarkEditor:
position: "位置" position: "位置"
type: "タイプ" type: "タイプ"
image: "画像" image: "画像"
advanced: "高度"
_imageEffector: _imageEffector:
title: "エフェクト" title: "エフェクト"

View File

@ -7,7 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder :defaultOpen="true" :canPage="false"> <MkFolder :defaultOpen="true" :canPage="false">
<template #label>{{ fx.name }}</template> <template #label>{{ fx.name }}</template>
<template #footer> <template #footer>
<MkButton @click="emit('del')">{{ i18n.ts.remove }}</MkButton> <div class="_buttons">
<MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
<MkButton iconOnly @click="emit('swapUp')"><i class="ti ti-arrow-up"></i></MkButton>
<MkButton iconOnly @click="emit('swapDown')"><i class="ti ti-arrow-down"></i></MkButton>
</div>
</template> </template>
<div :class="$style.root" class="_gaps"> <div :class="$style.root" class="_gaps">
@ -60,6 +64,8 @@ if (fx == null) {
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'del'): void; (e: 'del'): void;
(e: 'swapUp'): void;
(e: 'swapDown'): void;
}>(); }>();
</script> </script>

View File

@ -35,6 +35,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="layer.id" :key="layer.id"
v-model:layer="layers[i]" v-model:layer="layers[i]"
@del="onLayerDelete(layer)" @del="onLayerDelete(layer)"
@swapUp="onLayerSwapUp(layer)"
@swapDown="onLayerSwapDown(layer)"
></XLayer> ></XLayer>
<MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton> <MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton>
@ -105,6 +107,22 @@ function addEffect(ev: MouseEvent) {
})), ev.currentTarget ?? ev.target); })), ev.currentTarget ?? ev.target);
} }
function onLayerSwapUp(layer: ImageEffectorLayer) {
const index = layers.indexOf(layer);
if (index > 0) {
layers.splice(index, 1);
layers.splice(index - 1, 0, layer);
}
}
function onLayerSwapDown(layer: ImageEffectorLayer) {
const index = layers.indexOf(layer);
if (index < layers.length - 1) {
layers.splice(index, 1);
layers.splice(index + 1, 0, layer);
}
}
function onLayerDelete(layer: ImageEffectorLayer) { function onLayerDelete(layer: ImageEffectorLayer) {
const index = layers.indexOf(layer); const index = layers.indexOf(layer);
if (index !== -1) { if (index !== -1) {

View File

@ -295,7 +295,7 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
if (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) { if (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
menu.push({ menu.push({
icon: 'ti ti-sparkles', icon: 'ti ti-sparkles',
text: i18n.ts._imageEffector.title, text: i18n.ts._imageEffector.title + ' (BETA)',
action: async () => { action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
image: item.file, image: item.file,

View File

@ -30,14 +30,39 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div :class="$style.controls"> <div :class="$style.controls">
<div class="_spacer _gaps"> <div class="_spacer _gaps">
<MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }]"></MkSelect> <MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]">
<template #label>{{ i18n.ts._watermarkEditor.type }}</template>
</MkSelect>
<div v-if="type === 'text' || type === 'image'">
<XLayer <XLayer
v-for="(layer, i) in preset.layers" v-for="(layer, i) in preset.layers"
:key="layer.id" :key="layer.id"
v-model:layer="preset.layers[i]" v-model:layer="preset.layers[i]"
></XLayer> ></XLayer>
</div> </div>
<div v-else-if="type === 'advanced'" class="_gaps_s">
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
<template #label>
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
</template>
<template #footer>
<div class="_buttons">
<MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton>
<MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton>
<MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton>
</div>
</template>
<XLayer
v-model:layer="preset.layers[i]"
></XLayer>
</MkFolder>
<MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -53,7 +78,7 @@ import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkFolder from '@/components/MkFolder.vue';
import XLayer from '@/components/MkWatermarkEditorDialog.Layer.vue'; import XLayer from '@/components/MkWatermarkEditorDialog.Layer.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
@ -61,15 +86,8 @@ import { ensureSignin } from '@/i.js';
const $i = ensureSignin(); const $i = ensureSignin();
const props = defineProps<{ function createTextLayer(): WatermarkPreset['layers'][number] {
preset?: WatermarkPreset | null; return {
image?: File | null;
}>();
const preset = reactive(deepClone(props.preset) ?? {
id: uuid(),
name: '',
layers: [{
id: uuid(), id: uuid(),
type: 'text', type: 'text',
text: `(c) @${$i.username}`, text: `(c) @${$i.username}`,
@ -77,8 +95,33 @@ const preset = reactive(deepClone(props.preset) ?? {
scale: 0.3, scale: 0.3,
opacity: 0.75, opacity: 0.75,
repeat: false, repeat: false,
}], };
} satisfies WatermarkPreset); }
function createImageLayer(): WatermarkPreset['layers'][number] {
return {
id: uuid(),
type: 'image',
imageId: null,
imageUrl: null,
align: { x: 'right', y: 'bottom' },
scale: 0.3,
opacity: 0.75,
repeat: false,
cover: false,
};
}
const props = defineProps<{
preset?: WatermarkPreset | null;
image?: File | null;
}>();
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
id: uuid(),
name: '',
layers: [createTextLayer()],
});
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'ok', preset: WatermarkPreset): void; (ev: 'ok', preset: WatermarkPreset): void;
@ -98,29 +141,14 @@ async function cancel() {
dialog.value?.close(); dialog.value?.close();
} }
const type = ref(preset.layers[0].type); const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type);
watch(type, () => { watch(type, () => {
if (type.value === 'text') { if (type.value === 'text') {
preset.layers = [{ preset.layers = [createTextLayer()];
id: uuid(),
type: 'text',
text: `(c) @${$i.username}`,
align: { x: 'right', y: 'bottom' },
scale: 0.3,
opacity: 0.75,
repeat: false,
}];
} else if (type.value === 'image') { } else if (type.value === 'image') {
preset.layers = [{ preset.layers = [createImageLayer()];
id: uuid(), } else if (type.value === 'advanced') {
type: 'image', // nop
imageId: null,
imageUrl: null,
align: { x: 'right', y: 'bottom' },
scale: 0.3,
opacity: 0.75,
repeat: false,
}];
} }
}); });
@ -147,7 +175,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
const sampleImageType = ref(props.image != null ? 'provided' : '3_2'); const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
watch(sampleImageType, async () => { watch(sampleImageType, async () => {
if (renderer != null) { if (renderer != null) {
renderer.destroy(); renderer.destroy(false);
renderer = null; renderer = null;
initRenderer(); initRenderer();
} }
@ -241,6 +269,42 @@ async function save() {
emit('ok', preset); emit('ok', preset);
} }
function addLayer(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts._watermarkEditor.text,
action: () => {
preset.layers.push(createTextLayer());
},
}, {
text: i18n.ts._watermarkEditor.image,
action: () => {
preset.layers.push(createImageLayer());
},
}], ev.currentTarget ?? ev.target);
}
function swapUpLayer(layer: WatermarkPreset['layers'][number]) {
const index = preset.layers.findIndex(l => l.id === layer.id);
if (index > 0) {
const tmp = preset.layers[index - 1];
preset.layers[index - 1] = preset.layers[index];
preset.layers[index] = tmp;
}
}
function swapDownLayer(layer: WatermarkPreset['layers'][number]) {
const index = preset.layers.findIndex(l => l.id === layer.id);
if (index < preset.layers.length - 1) {
const tmp = preset.layers[index + 1];
preset.layers[index + 1] = preset.layers[index];
preset.layers[index] = tmp;
}
}
function removeLayer(layer: WatermarkPreset['layers'][number]) {
preset.layers = preset.layers.filter(l => l.id !== layer.id);
}
</script> </script>
<style module> <style module>

View File

@ -131,7 +131,7 @@ export class ImageEffector {
void main() { void main() {
out_color = texture(u_texture, in_uv); out_color = texture(u_texture, in_uv);
} }
`)!; `);
this.renderInvertedTextureProgram = this.initShaderProgram(`#version 300 es this.renderInvertedTextureProgram = this.initShaderProgram(`#version 300 es
in vec2 position; in vec2 position;
@ -152,45 +152,43 @@ export class ImageEffector {
void main() { void main() {
out_color = texture(u_texture, in_uv); out_color = texture(u_texture, in_uv);
} }
`)!; `);
} }
public loadShader(type, source) { public loadShader(type: GLenum, source: string): WebGLShader {
const gl = this.gl; const gl = this.gl;
const shader = gl.createShader(type)!; const shader = gl.createShader(type);
if (shader == null) {
throw new Error('falied to create shader');
}
gl.shaderSource(shader, source); gl.shaderSource(shader, source);
gl.compileShader(shader); gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert( console.error(`falied to compile shader: ${gl.getShaderInfoLog(shader)}`);
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
);
gl.deleteShader(shader); gl.deleteShader(shader);
return null; throw new Error(`falied to compile shader: ${gl.getShaderInfoLog(shader)}`);
} }
return shader; return shader;
} }
public initShaderProgram(vsSource, fsSource): WebGLProgram { public initShaderProgram(vsSource: string, fsSource: string): WebGLProgram {
const gl = this.gl; const gl = this.gl;
const vertexShader = this.loadShader(gl.VERTEX_SHADER, vsSource)!; const vertexShader = this.loadShader(gl.VERTEX_SHADER, vsSource);
const fragmentShader = this.loadShader(gl.FRAGMENT_SHADER, fsSource)!; const fragmentShader = this.loadShader(gl.FRAGMENT_SHADER, fsSource);
const shaderProgram = gl.createProgram();
const shaderProgram = gl.createProgram()!;
gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader); gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram); gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
alert( console.error(`failed to init shader: ${gl.getProgramInfoLog(shaderProgram)}`);
`failed to init shader: ${gl.getProgramInfoLog(
shaderProgram,
)}`,
);
throw new Error('failed to init shader'); throw new Error('failed to init shader');
} }
@ -363,7 +361,10 @@ export class ImageEffector {
return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : ''; return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : '';
} }
public destroy() { /*
* disposeCanvas = true loseContextを呼ぶためcanvasも再利用不可になるので注意
*/
public destroy(disposeCanvas = true) {
for (const shader of this.shaderCache.values()) { for (const shader of this.shaderCache.values()) {
this.gl.deleteProgram(shader); this.gl.deleteProgram(shader);
} }
@ -388,10 +389,12 @@ export class ImageEffector {
this.gl.deleteProgram(this.renderInvertedTextureProgram); this.gl.deleteProgram(this.renderInvertedTextureProgram);
this.gl.deleteTexture(this.originalImageTexture); this.gl.deleteTexture(this.originalImageTexture);
if (disposeCanvas) {
const loseContextExt = this.gl.getExtension('WEBGL_lose_context'); const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
if (loseContextExt) loseContextExt.loseContext(); if (loseContextExt) loseContextExt.loseContext();
} }
} }
}
function createTexture(gl: WebGL2RenderingContext): WebGLTexture { function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
const texture = gl.createTexture(); const texture = gl.createTexture();

View File

@ -98,7 +98,10 @@ export class WatermarkRenderer {
this.effector.render(); this.effector.render();
} }
public destroy(): void { /*
this.effector.destroy(); * disposeCanvas = true loseContextを呼ぶためcanvasも再利用不可になるので注意
*/
public destroy(disposeCanvas = true): void {
this.effector.destroy(disposeCanvas);
} }
} }