Compare commits
5 Commits
74a0ef7053
...
1ebd75ac10
Author | SHA1 | Date |
---|---|---|
|
1ebd75ac10 | |
|
cf96ef461d | |
|
6adc9c910a | |
|
74dbee9e15 | |
|
2226aedb69 |
|
@ -4,7 +4,7 @@
|
||||||
-
|
-
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
-
|
- Feat: 画像にウォーターマークを付与できるようになりました
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
-
|
-
|
||||||
|
|
|
@ -12081,6 +12081,10 @@ export interface Locale extends ILocale {
|
||||||
* 画像
|
* 画像
|
||||||
*/
|
*/
|
||||||
"image": string;
|
"image": string;
|
||||||
|
/**
|
||||||
|
* 高度
|
||||||
|
*/
|
||||||
|
"advanced": string;
|
||||||
};
|
};
|
||||||
"_imageEffector": {
|
"_imageEffector": {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -3235,6 +3235,7 @@ _watermarkEditor:
|
||||||
position: "位置"
|
position: "位置"
|
||||||
type: "タイプ"
|
type: "タイプ"
|
||||||
image: "画像"
|
image: "画像"
|
||||||
|
advanced: "高度"
|
||||||
|
|
||||||
_imageEffector:
|
_imageEffector:
|
||||||
title: "エフェクト"
|
title: "エフェクト"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -30,13 +30,38 @@ 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>
|
||||||
|
|
||||||
<XLayer
|
<div v-if="type === 'text' || type === 'image'">
|
||||||
v-for="(layer, i) in preset.layers"
|
<XLayer
|
||||||
:key="layer.id"
|
v-for="(layer, i) in preset.layers"
|
||||||
v-model:layer="preset.layers[i]"
|
:key="layer.id"
|
||||||
></XLayer>
|
v-model:layer="preset.layers[i]"
|
||||||
|
></XLayer>
|
||||||
|
</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>
|
||||||
|
@ -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>
|
||||||
|
|
|
@ -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,8 +389,10 @@ export class ImageEffector {
|
||||||
this.gl.deleteProgram(this.renderInvertedTextureProgram);
|
this.gl.deleteProgram(this.renderInvertedTextureProgram);
|
||||||
this.gl.deleteTexture(this.originalImageTexture);
|
this.gl.deleteTexture(this.originalImageTexture);
|
||||||
|
|
||||||
const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
|
if (disposeCanvas) {
|
||||||
if (loseContextExt) loseContextExt.loseContext();
|
const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
|
||||||
|
if (loseContextExt) loseContextExt.loseContext();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue