Compare commits
14 Commits
2d2b9e7a3f
...
ddc8072103
Author | SHA1 | Date |
---|---|---|
|
ddc8072103 | |
|
d1b4456845 | |
|
e3ef2c43cc | |
|
02255efa4f | |
|
12a3000a64 | |
|
c57a8b3b27 | |
|
551c7b0642 | |
|
f3304c7503 | |
|
00a82217e1 | |
|
b59427f421 | |
|
1c985987e8 | |
|
0e37048e1e | |
|
1e9faff0db | |
|
236b8913d2 |
|
@ -5481,6 +5481,10 @@ export interface Locale extends ILocale {
|
||||||
* デフォルトの画像圧縮度
|
* デフォルトの画像圧縮度
|
||||||
*/
|
*/
|
||||||
"defaultImageCompressionLevel": string;
|
"defaultImageCompressionLevel": string;
|
||||||
|
/**
|
||||||
|
* 低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。
|
||||||
|
*/
|
||||||
|
"defaultImageCompressionLevel_description": string;
|
||||||
"_chat": {
|
"_chat": {
|
||||||
/**
|
/**
|
||||||
* まだメッセージはありません
|
* まだメッセージはありません
|
||||||
|
@ -12079,6 +12083,10 @@ export interface Locale extends ILocale {
|
||||||
* エフェクトを追加
|
* エフェクトを追加
|
||||||
*/
|
*/
|
||||||
"addEffect": string;
|
"addEffect": string;
|
||||||
|
/**
|
||||||
|
* 変更を破棄して終了しますか?
|
||||||
|
*/
|
||||||
|
"discardChangesConfirm": string;
|
||||||
"_fxs": {
|
"_fxs": {
|
||||||
/**
|
/**
|
||||||
* 色収差
|
* 色収差
|
||||||
|
|
|
@ -1365,6 +1365,7 @@ tip: "ヒントとコツ"
|
||||||
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
|
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
|
||||||
hideAllTips: "全ての「ヒントとコツ」を非表示"
|
hideAllTips: "全ての「ヒントとコツ」を非表示"
|
||||||
defaultImageCompressionLevel: "デフォルトの画像圧縮度"
|
defaultImageCompressionLevel: "デフォルトの画像圧縮度"
|
||||||
|
defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。"
|
||||||
|
|
||||||
_chat:
|
_chat:
|
||||||
noMessagesYet: "まだメッセージはありません"
|
noMessagesYet: "まだメッセージはありません"
|
||||||
|
@ -3236,6 +3237,7 @@ _watermarkEditor:
|
||||||
_imageEffector:
|
_imageEffector:
|
||||||
title: "エフェクト"
|
title: "エフェクト"
|
||||||
addEffect: "エフェクトを追加"
|
addEffect: "エフェクトを追加"
|
||||||
|
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||||
|
|
||||||
_fxs:
|
_fxs:
|
||||||
chromaticAberration: "色収差"
|
chromaticAberration: "色収差"
|
||||||
|
|
|
@ -48,7 +48,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
|
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
|
||||||
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||||
|
@ -62,7 +61,7 @@ import { deepClone } from '@/utility/clone.js';
|
||||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
image: HTMLImageElement;
|
image: File;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -73,7 +72,14 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const dialog = useTemplateRef('dialog');
|
const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
function cancel() {
|
async function cancel() {
|
||||||
|
if (layers.length > 0) {
|
||||||
|
const { canceled } = await os.confirm({
|
||||||
|
text: i18n.ts._imageEffector.discardChangesConfirm,
|
||||||
|
});
|
||||||
|
if (canceled) return;
|
||||||
|
}
|
||||||
|
|
||||||
emit('cancel');
|
emit('cancel');
|
||||||
dialog.value?.close();
|
dialog.value?.close();
|
||||||
}
|
}
|
||||||
|
@ -82,7 +88,7 @@ const layers = reactive<ImageEffectorLayer[]>([]);
|
||||||
|
|
||||||
watch(layers, async () => {
|
watch(layers, async () => {
|
||||||
if (renderer != null) {
|
if (renderer != null) {
|
||||||
renderer.updateLayers(layers);
|
renderer.setLayers(layers);
|
||||||
}
|
}
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
|
@ -109,20 +115,39 @@ function onLayerDelete(layer: ImageEffectorLayer) {
|
||||||
const canvasEl = useTemplateRef('canvasEl');
|
const canvasEl = useTemplateRef('canvasEl');
|
||||||
|
|
||||||
let renderer: ImageEffector | null = null;
|
let renderer: ImageEffector | null = null;
|
||||||
|
let imageBitmap: ImageBitmap | null = null;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (canvasEl.value == null) return;
|
||||||
|
|
||||||
|
const closeWaiting = os.waiting();
|
||||||
|
|
||||||
|
imageBitmap = await window.createImageBitmap(props.image);
|
||||||
|
|
||||||
|
const MAX_W = 500;
|
||||||
|
const MAX_H = 500;
|
||||||
|
let w = imageBitmap.width;
|
||||||
|
let h = imageBitmap.height;
|
||||||
|
|
||||||
|
if (w > MAX_W || h > MAX_H) {
|
||||||
|
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||||
|
w *= scale;
|
||||||
|
h *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
renderer = new ImageEffector({
|
renderer = new ImageEffector({
|
||||||
canvas: canvasEl.value,
|
canvas: canvasEl.value,
|
||||||
width: props.image.width,
|
renderWidth: w,
|
||||||
height: props.image.height,
|
renderHeight: h,
|
||||||
layers: layers,
|
image: imageBitmap,
|
||||||
originalImage: props.image,
|
|
||||||
fxs: FXS,
|
fxs: FXS,
|
||||||
});
|
});
|
||||||
|
|
||||||
await renderer!.bakeTextures();
|
await renderer.setLayers(layers);
|
||||||
|
|
||||||
renderer!.render();
|
renderer.render();
|
||||||
|
|
||||||
|
closeWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
@ -130,18 +155,26 @@ onUnmounted(() => {
|
||||||
renderer.destroy();
|
renderer.destroy();
|
||||||
renderer = null;
|
renderer = null;
|
||||||
}
|
}
|
||||||
|
if (imageBitmap != null) {
|
||||||
|
imageBitmap.close();
|
||||||
|
imageBitmap = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
if (layers.length === 0) {
|
if (layers.length === 0 || renderer == null || imageBitmap == null || canvasEl.value == null) {
|
||||||
cancel();
|
cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer!.render(); // toBlobの直前にレンダリングしないと何故か壊れる
|
const closeWaiting = os.waiting();
|
||||||
canvasEl.value!.toBlob((blob) => {
|
|
||||||
|
renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
|
||||||
|
renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||||
|
canvasEl.value.toBlob((blob) => {
|
||||||
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
|
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
|
||||||
dialog.value?.close();
|
dialog.value?.close();
|
||||||
|
closeWaiting();
|
||||||
}, 'image/png');
|
}, 'image/png');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,9 +182,9 @@ const enabled = ref(true);
|
||||||
watch(enabled, () => {
|
watch(enabled, () => {
|
||||||
if (renderer != null) {
|
if (renderer != null) {
|
||||||
if (enabled.value) {
|
if (enabled.value) {
|
||||||
renderer.updateLayers(layers);
|
renderer.setLayers(layers);
|
||||||
} else {
|
} else {
|
||||||
renderer.updateLayers([]);
|
renderer.setLayers([]);
|
||||||
}
|
}
|
||||||
renderer.render();
|
renderer.render();
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,9 +96,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { ensureSignin } from '@/i.js';
|
import { ensureSignin } from '@/i.js';
|
||||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||||
import { makeImageEffectorLayers } from '@/utility/watermark.js';
|
|
||||||
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
|
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
@ -299,9 +297,8 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||||
icon: 'ti ti-sparkles',
|
icon: 'ti ti-sparkles',
|
||||||
text: i18n.ts._imageEffector.title,
|
text: i18n.ts._imageEffector.title,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const img = await getImageElement(item.file);
|
|
||||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageEffectorDialog.vue')), {
|
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageEffectorDialog.vue')), {
|
||||||
image: img,
|
image: item.file,
|
||||||
}, {
|
}, {
|
||||||
ok: (file) => {
|
ok: (file) => {
|
||||||
URL.revokeObjectURL(item.thumbnail);
|
URL.revokeObjectURL(item.thumbnail);
|
||||||
|
@ -315,10 +312,7 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||||
triggerRef(items);
|
triggerRef(items);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
closed: () => {
|
closed: () => dispose(),
|
||||||
URL.revokeObjectURL(img.src);
|
|
||||||
dispose();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -348,7 +342,24 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||||
text: preset.name,
|
text: preset.name,
|
||||||
active: computed(() => item.watermarkPresetId === preset.id),
|
active: computed(() => item.watermarkPresetId === preset.id),
|
||||||
action: () => changeWatermarkPreset(preset.id),
|
action: () => changeWatermarkPreset(preset.id),
|
||||||
}))],
|
})), {
|
||||||
|
type: 'divider',
|
||||||
|
}, {
|
||||||
|
type: 'button',
|
||||||
|
icon: 'ti ti-plus',
|
||||||
|
text: i18n.ts.add,
|
||||||
|
action: async () => {
|
||||||
|
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {
|
||||||
|
image: item.file,
|
||||||
|
}, {
|
||||||
|
ok: (preset) => {
|
||||||
|
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
|
||||||
|
changeWatermarkPreset(preset.id);
|
||||||
|
},
|
||||||
|
closed: () => dispose(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -496,42 +507,24 @@ async function chooseFile(ev: MouseEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageElement(file: Blob | File): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
resolve(img);
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = (error) => {
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function preprocess(item: (typeof items)['value'][number]): Promise<void> {
|
async function preprocess(item: (typeof items)['value'][number]): Promise<void> {
|
||||||
item.preprocessing = true;
|
item.preprocessing = true;
|
||||||
|
|
||||||
let file: Blob | File = item.file;
|
let file: Blob | File = item.file;
|
||||||
const img = await getImageElement(file);
|
const imageBitmap = await window.createImageBitmap(file);
|
||||||
|
|
||||||
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type);
|
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type);
|
||||||
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
|
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
|
||||||
if (needsWatermark && preset != null) {
|
if (needsWatermark && preset != null) {
|
||||||
const canvas = window.document.createElement('canvas');
|
const canvas = window.document.createElement('canvas');
|
||||||
const renderer = new ImageEffector({
|
const renderer = new WatermarkRenderer({
|
||||||
canvas: canvas,
|
canvas: canvas,
|
||||||
width: img.width,
|
renderWidth: imageBitmap.width,
|
||||||
height: img.height,
|
renderHeight: imageBitmap.height,
|
||||||
layers: makeImageEffectorLayers(preset.layers),
|
image: imageBitmap,
|
||||||
originalImage: img,
|
|
||||||
fxs: [FX_watermarkPlacement],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await renderer.bakeTextures();
|
await renderer.setLayers(preset.layers);
|
||||||
|
|
||||||
renderer.render();
|
renderer.render();
|
||||||
|
|
||||||
|
@ -541,6 +534,7 @@ async function preprocess(item: (typeof items)['value'][number]): Promise<void>
|
||||||
throw new Error('Failed to convert canvas to blob');
|
throw new Error('Failed to convert canvas to blob');
|
||||||
}
|
}
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
|
renderer.destroy();
|
||||||
}, 'image/png');
|
}, 'image/png');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -578,7 +572,7 @@ async function preprocess(item: (typeof items)['value'][number]): Promise<void>
|
||||||
item.preprocessedFile = markRaw(file);
|
item.preprocessedFile = markRaw(file);
|
||||||
item.preprocessing = false;
|
item.preprocessing = false;
|
||||||
|
|
||||||
URL.revokeObjectURL(img.src);
|
imageBitmap.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeFile(file: File) {
|
function initializeFile(file: File) {
|
||||||
|
|
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||||
<div :class="$style.previewContainer">
|
<div :class="$style.previewContainer">
|
||||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||||
<div class="_acrylic" :class="$style.previewControls">
|
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -48,9 +48,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
|
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||||
import { makeImageEffectorLayers } from '@/utility/watermark.js';
|
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.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';
|
||||||
|
@ -59,12 +58,12 @@ 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';
|
||||||
import { ensureSignin } from '@/i.js';
|
import { ensureSignin } from '@/i.js';
|
||||||
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
|
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
preset?: WatermarkPreset | null;
|
preset?: WatermarkPreset | null;
|
||||||
|
image?: File | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const preset = reactive(deepClone(props.preset) ?? {
|
const preset = reactive(deepClone(props.preset) ?? {
|
||||||
|
@ -122,7 +121,7 @@ watch(type, () => {
|
||||||
|
|
||||||
watch(preset, async (newValue, oldValue) => {
|
watch(preset, async (newValue, oldValue) => {
|
||||||
if (renderer != null) {
|
if (renderer != null) {
|
||||||
renderer.updateLayers(makeImageEffectorLayers(preset.layers));
|
renderer.setLayers(preset.layers);
|
||||||
}
|
}
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
|
@ -140,7 +139,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
|
||||||
sampleImage_2_3.onload = () => resolve();
|
sampleImage_2_3.onload = () => resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
const sampleImageType = ref('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();
|
||||||
|
@ -149,41 +148,62 @@ watch(sampleImageType, async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let renderer: ImageEffector | null = null;
|
let renderer: WatermarkRenderer | null = null;
|
||||||
|
let imageBitmap: ImageBitmap | null = null;
|
||||||
|
|
||||||
async function initRenderer() {
|
async function initRenderer() {
|
||||||
if (canvasEl.value == null) return;
|
if (canvasEl.value == null) return;
|
||||||
|
|
||||||
if (sampleImageType.value === '3_2') {
|
if (sampleImageType.value === '3_2') {
|
||||||
renderer = new ImageEffector({
|
renderer = new WatermarkRenderer({
|
||||||
canvas: canvasEl.value,
|
canvas: canvasEl.value,
|
||||||
width: 1500,
|
renderWidth: 1500,
|
||||||
height: 1000,
|
renderHeight: 1000,
|
||||||
layers: makeImageEffectorLayers(preset.layers),
|
image: sampleImage_3_2,
|
||||||
originalImage: sampleImage_3_2,
|
|
||||||
fxs: [FX_watermarkPlacement],
|
|
||||||
});
|
});
|
||||||
} else if (sampleImageType.value === '2_3') {
|
} else if (sampleImageType.value === '2_3') {
|
||||||
renderer = new ImageEffector({
|
renderer = new WatermarkRenderer({
|
||||||
canvas: canvasEl.value,
|
canvas: canvasEl.value,
|
||||||
width: 1000,
|
renderWidth: 1000,
|
||||||
height: 1500,
|
renderHeight: 1500,
|
||||||
layers: makeImageEffectorLayers(preset.layers),
|
image: sampleImage_2_3,
|
||||||
originalImage: sampleImage_2_3,
|
});
|
||||||
fxs: [FX_watermarkPlacement],
|
} else if (props.image != null) {
|
||||||
|
imageBitmap = await window.createImageBitmap(props.image);
|
||||||
|
|
||||||
|
const MAX_W = 1000;
|
||||||
|
const MAX_H = 1000;
|
||||||
|
let w = imageBitmap.width;
|
||||||
|
let h = imageBitmap.height;
|
||||||
|
|
||||||
|
if (w > MAX_W || h > MAX_H) {
|
||||||
|
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||||
|
w *= scale;
|
||||||
|
h *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer = new WatermarkRenderer({
|
||||||
|
canvas: canvasEl.value,
|
||||||
|
renderWidth: w,
|
||||||
|
renderHeight: h,
|
||||||
|
image: imageBitmap,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await renderer!.bakeTextures();
|
await renderer!.setLayers(preset.layers);
|
||||||
|
|
||||||
renderer!.render();
|
renderer!.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const closeWaiting = os.waiting();
|
||||||
|
|
||||||
await sampleImage_3_2_loading;
|
await sampleImage_3_2_loading;
|
||||||
await sampleImage_2_3_loading;
|
await sampleImage_2_3_loading;
|
||||||
|
|
||||||
initRenderer();
|
await initRenderer();
|
||||||
|
|
||||||
|
closeWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
@ -191,6 +211,10 @@ onUnmounted(() => {
|
||||||
renderer.destroy();
|
renderer.destroy();
|
||||||
renderer = null;
|
renderer = null;
|
||||||
}
|
}
|
||||||
|
if (imageBitmap != null) {
|
||||||
|
imageBitmap.close();
|
||||||
|
imageBitmap = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MkFolder :defaultOpen="false">
|
<MkFolder :defaultOpen="false" :canPage="false">
|
||||||
<template #icon><i class="ti ti-pencil"></i></template>
|
<template #icon><i class="ti ti-pencil"></i></template>
|
||||||
<template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template>
|
<template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
@ -23,14 +23,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||||
import { makeImageEffectorLayers } from '@/utility/watermark.js';
|
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { deepClone } from '@/utility/clone.js';
|
import { deepClone } from '@/utility/clone.js';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
|
||||||
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
preset: WatermarkPreset;
|
preset: WatermarkPreset;
|
||||||
|
@ -66,23 +64,21 @@ const canvasEl = useTemplateRef('canvasEl');
|
||||||
const sampleImage = new Image();
|
const sampleImage = new Image();
|
||||||
sampleImage.src = '/client-assets/sample/3-2.jpg';
|
sampleImage.src = '/client-assets/sample/3-2.jpg';
|
||||||
|
|
||||||
let renderer: ImageEffector | null = null;
|
let renderer: WatermarkRenderer | null = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sampleImage.onload = async () => {
|
sampleImage.onload = async () => {
|
||||||
watch(canvasEl, async () => {
|
watch(canvasEl, async () => {
|
||||||
if (canvasEl.value == null) return;
|
if (canvasEl.value == null) return;
|
||||||
|
|
||||||
renderer = new ImageEffector({
|
renderer = new WatermarkRenderer({
|
||||||
canvas: canvasEl.value,
|
canvas: canvasEl.value,
|
||||||
width: 1500,
|
renderWidth: 1500,
|
||||||
height: 1000,
|
renderHeight: 1000,
|
||||||
layers: makeImageEffectorLayers(props.preset.layers),
|
image: sampleImage,
|
||||||
originalImage: sampleImage,
|
|
||||||
fxs: [FX_watermarkPlacement],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await renderer.bakeTextures();
|
await renderer.setLayers(props.preset.layers);
|
||||||
|
|
||||||
renderer.render();
|
renderer.render();
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
@ -98,8 +94,7 @@ onUnmounted(() => {
|
||||||
|
|
||||||
watch(() => props.preset, async () => {
|
watch(() => props.preset, async () => {
|
||||||
if (renderer != null) {
|
if (renderer != null) {
|
||||||
renderer.updateLayers(makeImageEffectorLayers(props.preset.layers));
|
await renderer.setLayers(props.preset.layers);
|
||||||
await renderer.bakeTextures();
|
|
||||||
renderer.render();
|
renderer.render();
|
||||||
}
|
}
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
|
@ -90,6 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkFolder>
|
<MkFolder>
|
||||||
<template #icon><i class="ti ti-copyright"></i></template>
|
<template #icon><i class="ti ti-copyright"></i></template>
|
||||||
<template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template>
|
<template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template>
|
||||||
|
<template #caption>{{ i18n.ts._watermarkEditor.tip }}</template>
|
||||||
|
|
||||||
<div class="_gaps">
|
<div class="_gaps">
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
|
@ -134,6 +135,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template>
|
<template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template>
|
||||||
|
<template #caption><div v-html="i18n.ts.defaultImageCompressionLevel_description"></div></template>
|
||||||
</MkSelect>
|
</MkSelect>
|
||||||
</MkPreferenceContainer>
|
</MkPreferenceContainer>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
|
|
|
@ -11,6 +11,7 @@ type ParamTypeToPrimitive = {
|
||||||
'boolean': boolean;
|
'boolean': boolean;
|
||||||
'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
|
'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
|
||||||
'seed': number;
|
'seed': number;
|
||||||
|
'texture': { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ImageEffectorFxParamDefs = Record<string, {
|
type ImageEffectorFxParamDefs = Record<string, {
|
||||||
|
@ -18,30 +19,30 @@ type ImageEffectorFxParamDefs = Record<string, {
|
||||||
default: any;
|
default: any;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function defineImageEffectorFx<ID extends string, P extends ImageEffectorFxParamDefs, U extends string[]>(fx: ImageEffectorFx<ID, P, U>) {
|
export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
|
||||||
return fx;
|
return fx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageEffectorFx<ID extends string = string, P extends ImageEffectorFxParamDefs = any, U extends string[] = string[]> = {
|
export type ImageEffectorFx<ID extends string = string, PS extends ImageEffectorFxParamDefs = ImageEffectorFxParamDefs, US extends string[] = string[]> = {
|
||||||
id: ID;
|
id: ID;
|
||||||
name: string;
|
name: string;
|
||||||
shader: string;
|
shader: string;
|
||||||
uniforms: U;
|
uniforms: US;
|
||||||
params: P,
|
params: PS,
|
||||||
main: (ctx: {
|
main: (ctx: {
|
||||||
gl: WebGL2RenderingContext;
|
gl: WebGL2RenderingContext;
|
||||||
program: WebGLProgram;
|
program: WebGLProgram;
|
||||||
params: {
|
params: {
|
||||||
[key in keyof P]: ParamTypeToPrimitive[P[key]['type']];
|
[key in keyof PS]: ParamTypeToPrimitive[PS[key]['type']];
|
||||||
};
|
};
|
||||||
u: Record<U[number], WebGLUniformLocation>;
|
u: Record<US[number], WebGLUniformLocation>;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
watermark?: {
|
textures: Record<string, {
|
||||||
texture: WebGLTexture;
|
texture: WebGLTexture;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
} | null>;
|
||||||
}) => void;
|
}) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -49,54 +50,55 @@ export type ImageEffectorLayer = {
|
||||||
id: string;
|
id: string;
|
||||||
fxId: string;
|
fxId: string;
|
||||||
params: Record<string, any>;
|
params: Record<string, any>;
|
||||||
|
|
||||||
// for watermarkPlacement fx
|
|
||||||
imageUrl?: string | null;
|
|
||||||
text?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, any>, k: string): ParamTypeToPrimitive[T] {
|
||||||
|
return params[k];
|
||||||
|
}
|
||||||
|
|
||||||
export class ImageEffector {
|
export class ImageEffector {
|
||||||
|
private gl: WebGL2RenderingContext;
|
||||||
private canvas: HTMLCanvasElement | null = null;
|
private canvas: HTMLCanvasElement | null = null;
|
||||||
private gl: WebGL2RenderingContext | null = null;
|
private renderTextureProgram: WebGLProgram;
|
||||||
private renderTextureProgram!: WebGLProgram;
|
private renderInvertedTextureProgram: WebGLProgram;
|
||||||
private renderInvertedTextureProgram!: WebGLProgram;
|
private renderWidth: number;
|
||||||
private renderWidth!: number;
|
private renderHeight: number;
|
||||||
private renderHeight!: number;
|
|
||||||
private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||||
private layers: ImageEffectorLayer[];
|
private layers: ImageEffectorLayer[] = [];
|
||||||
private originalImageTexture: WebGLTexture;
|
private originalImageTexture: WebGLTexture;
|
||||||
private bakedTexturesForWatermarkFx: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
|
|
||||||
private texturesKey: string;
|
|
||||||
private shaderCache: Map<string, WebGLProgram> = new Map();
|
private shaderCache: Map<string, WebGLProgram> = new Map();
|
||||||
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
|
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
|
||||||
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
|
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
|
||||||
private fxs: ImageEffectorFx[];
|
private fxs: ImageEffectorFx[];
|
||||||
|
private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
|
||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
width: number;
|
renderWidth: number;
|
||||||
height: number;
|
renderHeight: number;
|
||||||
originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||||
layers: ImageEffectorLayer[];
|
|
||||||
fxs: ImageEffectorFx[];
|
fxs: ImageEffectorFx[];
|
||||||
}) {
|
}) {
|
||||||
this.canvas = options.canvas;
|
this.canvas = options.canvas;
|
||||||
this.canvas.width = options.width;
|
this.renderWidth = options.renderWidth;
|
||||||
this.canvas.height = options.height;
|
this.renderHeight = options.renderHeight;
|
||||||
this.renderWidth = options.width;
|
this.originalImage = options.image;
|
||||||
this.renderHeight = options.height;
|
|
||||||
this.originalImage = options.originalImage;
|
|
||||||
this.layers = options.layers;
|
|
||||||
this.fxs = options.fxs;
|
this.fxs = options.fxs;
|
||||||
this.texturesKey = this.calcTexturesKey();
|
|
||||||
|
|
||||||
this.gl = this.canvas.getContext('webgl2', {
|
this.canvas.width = this.renderWidth;
|
||||||
|
this.canvas.height = this.renderHeight;
|
||||||
|
|
||||||
|
const gl = this.canvas.getContext('webgl2', {
|
||||||
preserveDrawingBuffer: false,
|
preserveDrawingBuffer: false,
|
||||||
alpha: true,
|
alpha: true,
|
||||||
premultipliedAlpha: false,
|
premultipliedAlpha: false,
|
||||||
})!;
|
});
|
||||||
|
|
||||||
const gl = this.gl;
|
if (gl == null) {
|
||||||
|
throw new Error('Failed to initialize WebGL2 context');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gl = gl;
|
||||||
|
|
||||||
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
||||||
|
|
||||||
|
@ -105,10 +107,10 @@ export class ImageEffector {
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||||
|
|
||||||
this.originalImageTexture = this.createTexture();
|
this.originalImageTexture = createTexture(gl);
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, options.width, options.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage);
|
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);
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||||
|
|
||||||
this.renderTextureProgram = this.initShaderProgram(`#version 300 es
|
this.renderTextureProgram = this.initShaderProgram(`#version 300 es
|
||||||
|
@ -153,119 +155,8 @@ export class ImageEffector {
|
||||||
`)!;
|
`)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
private calcTexturesKey() {
|
|
||||||
return this.layers.map(layer => {
|
|
||||||
if (layer.fxId === 'watermarkPlacement' && layer.imageUrl != null) {
|
|
||||||
return layer.imageUrl;
|
|
||||||
} else if (layer.fxId === 'watermarkPlacement' && layer.text != null) {
|
|
||||||
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.bakedTexturesForWatermarkFx.values()) {
|
|
||||||
gl.deleteTexture(bakedTexture.texture);
|
|
||||||
}
|
|
||||||
this.bakedTexturesForWatermarkFx.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.layers) {
|
|
||||||
if (layer.fxId === 'watermarkPlacement' && layer.imageUrl != null) {
|
|
||||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = getProxiedImageUrl(layer.imageUrl); // CORS対策
|
|
||||||
});
|
|
||||||
|
|
||||||
const texture = this.createTexture();
|
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
||||||
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);
|
|
||||||
|
|
||||||
this.bakedTexturesForWatermarkFx.set(layer.id, {
|
|
||||||
texture: texture,
|
|
||||||
width: image.width,
|
|
||||||
height: image.height,
|
|
||||||
});
|
|
||||||
} else if (layer.fxId === 'watermarkPlacement' && layer.text != null) {
|
|
||||||
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);
|
|
||||||
measureCtx.canvas.remove();
|
|
||||||
|
|
||||||
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.bakedTexturesForWatermarkFx.set(layer.id, {
|
|
||||||
texture: texture,
|
|
||||||
width: textCtx.canvas.width,
|
|
||||||
height: textCtx.canvas.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
textCtx.canvas.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public loadShader(type, source) {
|
public loadShader(type, source) {
|
||||||
const gl = this.gl!;
|
const gl = this.gl;
|
||||||
|
|
||||||
const shader = gl.createShader(type)!;
|
const shader = gl.createShader(type)!;
|
||||||
|
|
||||||
|
@ -284,7 +175,7 @@ export class ImageEffector {
|
||||||
}
|
}
|
||||||
|
|
||||||
public initShaderProgram(vsSource, fsSource): WebGLProgram {
|
public initShaderProgram(vsSource, fsSource): 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)!;
|
||||||
|
@ -308,15 +199,10 @@ export class ImageEffector {
|
||||||
|
|
||||||
private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture) {
|
private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture) {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
if (gl == null) {
|
|
||||||
throw new Error('gl is not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fx = this.fxs.find(fx => fx.id === layer.fxId);
|
const fx = this.fxs.find(fx => fx.id === layer.fxId);
|
||||||
if (fx == null) return;
|
if (fx == null) return;
|
||||||
|
|
||||||
const watermark = layer.fxId === 'watermarkPlacement' ? this.bakedTexturesForWatermarkFx.get(layer.id) : undefined;
|
|
||||||
|
|
||||||
const cachedShader = this.shaderCache.get(fx.id);
|
const cachedShader = this.shaderCache.get(fx.id);
|
||||||
const shaderProgram = cachedShader ?? this.initShaderProgram(`#version 300 es
|
const shaderProgram = cachedShader ?? this.initShaderProgram(`#version 300 es
|
||||||
in vec2 position;
|
in vec2 position;
|
||||||
|
@ -348,11 +234,18 @@ export class ImageEffector {
|
||||||
Object.entries(fx.params).map(([key, param]) => {
|
Object.entries(fx.params).map(([key, param]) => {
|
||||||
return [key, layer.params[key] ?? param.default];
|
return [key, layer.params[key] ?? param.default];
|
||||||
}),
|
}),
|
||||||
) as any,
|
),
|
||||||
u: Object.fromEntries(fx.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
|
u: Object.fromEntries(fx.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
|
||||||
width: this.renderWidth,
|
width: this.renderWidth,
|
||||||
height: this.renderHeight,
|
height: this.renderHeight,
|
||||||
watermark: watermark,
|
textures: Object.fromEntries(
|
||||||
|
Object.entries(fx.params).map(([k, v]) => {
|
||||||
|
if (v.type !== 'texture') return [k, null];
|
||||||
|
const param = getValue<typeof v.type>(layer.params, k);
|
||||||
|
if (param == null) return [k, null];
|
||||||
|
const texture = this.paramTextures.get(this.getTextureKeyForParam(param)) ?? null;
|
||||||
|
return [k, texture];
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
@ -360,9 +253,6 @@ export class ImageEffector {
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
if (gl == null) {
|
|
||||||
throw new Error('gl is not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
@ -386,7 +276,7 @@ export class ImageEffector {
|
||||||
|
|
||||||
for (const layer of this.layers) {
|
for (const layer of this.layers) {
|
||||||
const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
|
const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
|
||||||
const resultTexture = cachedResultTexture ?? this.createTexture();
|
const resultTexture = cachedResultTexture ?? createTexture(gl);
|
||||||
if (cachedResultTexture == null) {
|
if (cachedResultTexture == null) {
|
||||||
this.perLayerResultTextures.set(layer.id, resultTexture);
|
this.perLayerResultTextures.set(layer.id, resultTexture);
|
||||||
}
|
}
|
||||||
|
@ -420,42 +310,160 @@ export class ImageEffector {
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateLayers(layers: ImageEffectorLayer[]) {
|
public async setLayers(layers: ImageEffectorLayer[]) {
|
||||||
this.layers = layers;
|
this.layers = layers;
|
||||||
|
|
||||||
const newTexturesKey = this.calcTexturesKey();
|
const unused = new Set(this.paramTextures.keys());
|
||||||
if (newTexturesKey !== this.texturesKey) {
|
|
||||||
this.texturesKey = newTexturesKey;
|
for (const layer of layers) {
|
||||||
await this.bakeTextures();
|
const fx = this.fxs.find(fx => fx.id === layer.fxId);
|
||||||
|
if (fx == null) continue;
|
||||||
|
|
||||||
|
for (const k of Object.keys(layer.params)) {
|
||||||
|
const paramDef = fx.params[k];
|
||||||
|
if (paramDef == null) continue;
|
||||||
|
if (paramDef.type !== 'texture') continue;
|
||||||
|
const v = getValue<typeof paramDef.type>(layer.params, k);
|
||||||
|
if (v == null) continue;
|
||||||
|
|
||||||
|
const textureKey = this.getTextureKeyForParam(v);
|
||||||
|
unused.delete(textureKey);
|
||||||
|
if (this.paramTextures.has(textureKey)) continue;
|
||||||
|
|
||||||
|
console.log(`Baking texture of <${textureKey}>...`);
|
||||||
|
|
||||||
|
const texture = v.type === 'text' ? await createTextureFromText(this.gl, v.text) : v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : null;
|
||||||
|
if (texture == null) continue;
|
||||||
|
|
||||||
|
this.paramTextures.set(textureKey, texture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of unused) {
|
||||||
|
console.log(`Dispose unused texture <${k}>...`);
|
||||||
|
this.gl.deleteTexture(this.paramTextures.get(k)!.texture);
|
||||||
|
this.paramTextures.delete(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public changeResolution(width: number, height: number) {
|
||||||
const gl = this.gl;
|
this.renderWidth = width;
|
||||||
if (gl == null) {
|
this.renderHeight = height;
|
||||||
throw new Error('gl is not initialized');
|
if (this.canvas) {
|
||||||
|
this.canvas.width = this.renderWidth;
|
||||||
|
this.canvas.height = this.renderHeight;
|
||||||
|
}
|
||||||
|
this.gl.viewport(0, 0, this.renderWidth, this.renderHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) {
|
||||||
|
if (v == null) return '';
|
||||||
|
return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
for (const shader of this.shaderCache.values()) {
|
for (const shader of this.shaderCache.values()) {
|
||||||
gl.deleteProgram(shader);
|
this.gl.deleteProgram(shader);
|
||||||
}
|
}
|
||||||
this.shaderCache.clear();
|
this.shaderCache.clear();
|
||||||
|
|
||||||
for (const texture of this.perLayerResultTextures.values()) {
|
for (const texture of this.perLayerResultTextures.values()) {
|
||||||
gl.deleteTexture(texture);
|
this.gl.deleteTexture(texture);
|
||||||
}
|
}
|
||||||
this.perLayerResultTextures.clear();
|
this.perLayerResultTextures.clear();
|
||||||
|
|
||||||
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
|
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
|
||||||
gl.deleteFramebuffer(framebuffer);
|
this.gl.deleteFramebuffer(framebuffer);
|
||||||
}
|
}
|
||||||
this.perLayerResultFrameBuffers.clear();
|
this.perLayerResultFrameBuffers.clear();
|
||||||
|
|
||||||
this.disposeBakedTextures();
|
for (const texture of this.paramTextures.values()) {
|
||||||
gl.deleteProgram(this.renderTextureProgram);
|
this.gl.deleteTexture(texture.texture);
|
||||||
gl.deleteProgram(this.renderInvertedTextureProgram);
|
}
|
||||||
gl.deleteTexture(this.originalImageTexture);
|
this.paramTextures.clear();
|
||||||
|
|
||||||
|
this.gl.deleteProgram(this.renderTextureProgram);
|
||||||
|
this.gl.deleteProgram(this.renderInvertedTextureProgram);
|
||||||
|
this.gl.deleteTexture(this.originalImageTexture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string | null): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
|
||||||
|
if (imageUrl == null || imageUrl.trim() === '') return null;
|
||||||
|
|
||||||
|
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = getProxiedImageUrl(imageUrl); // CORS対策
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (image == null) return null;
|
||||||
|
|
||||||
|
const texture = createTexture(gl);
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
texture,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTextureFromText(gl: WebGL2RenderingContext, text: string | null, resolution = 2048): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
|
||||||
|
if (text == null || text.trim() === '') return null;
|
||||||
|
|
||||||
|
const ctx = window.document.createElement('canvas').getContext('2d')!;
|
||||||
|
ctx.canvas.width = resolution;
|
||||||
|
ctx.canvas.height = resolution / 4;
|
||||||
|
const fontSize = resolution / 32;
|
||||||
|
const margin = fontSize / 2;
|
||||||
|
ctx.shadowColor = '#000000';
|
||||||
|
ctx.shadowBlur = fontSize / 4;
|
||||||
|
|
||||||
|
//ctx.fillStyle = '#00ff00';
|
||||||
|
//ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
ctx.fillText(text, margin, ctx.canvas.height / 2);
|
||||||
|
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin);
|
||||||
|
const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin);
|
||||||
|
const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), ctx.canvas.width, ctx.canvas.height);
|
||||||
|
|
||||||
|
const texture = createTexture(gl);
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cropWidth, cropHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
texture: texture,
|
||||||
|
width: cropWidth,
|
||||||
|
height: cropHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.canvas.remove();
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
|
@ -98,17 +98,21 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
},
|
},
|
||||||
|
watermark: {
|
||||||
|
type: 'texture' as const,
|
||||||
|
default: null,
|
||||||
},
|
},
|
||||||
main: ({ gl, u, params, watermark }) => {
|
},
|
||||||
if (watermark == null) {
|
main: ({ gl, u, params, textures }) => {
|
||||||
|
if (textures.watermark == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
gl.activeTexture(gl.TEXTURE1);
|
gl.activeTexture(gl.TEXTURE1);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, watermark.texture);
|
gl.bindTexture(gl.TEXTURE_2D, textures.watermark.texture);
|
||||||
gl.uniform1i(u.texture_watermark, 1);
|
gl.uniform1i(u.texture_watermark, 1);
|
||||||
|
|
||||||
gl.uniform2fv(u.resolution_watermark, [watermark.width, watermark.height]);
|
gl.uniform2fv(u.resolution_watermark, [textures.watermark.width, textures.watermark.height]);
|
||||||
gl.uniform1f(u.scale, params.scale);
|
gl.uniform1f(u.scale, params.scale);
|
||||||
gl.uniform1f(u.opacity, params.opacity);
|
gl.uniform1f(u.opacity, params.opacity);
|
||||||
gl.uniform1f(u.angle, 0.0);
|
gl.uniform1f(u.angle, 0.0);
|
||||||
|
|
|
@ -69,7 +69,7 @@ export const FX_zoomLines = defineImageEffectorFx({
|
||||||
},
|
},
|
||||||
threshold: {
|
threshold: {
|
||||||
type: 'number' as const,
|
type: 'number' as const,
|
||||||
default: 0.5,
|
default: 0.8,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
|
|
|
@ -3,7 +3,9 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { FX_watermarkPlacement } from './image-effector/fxs/watermarkPlacement.js';
|
||||||
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
||||||
|
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||||
|
|
||||||
export type WatermarkPreset = {
|
export type WatermarkPreset = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -29,8 +31,27 @@ export type WatermarkPreset = {
|
||||||
})[];
|
})[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function makeImageEffectorLayers(layers: WatermarkPreset['layers']): ImageEffectorLayer[] {
|
export class WatermarkRenderer {
|
||||||
return layers.map(layer => {
|
private effector: ImageEffector;
|
||||||
|
private layers: WatermarkPreset['layers'] = [];
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
renderWidth: number,
|
||||||
|
renderHeight: number,
|
||||||
|
image: HTMLImageElement | ImageBitmap,
|
||||||
|
}) {
|
||||||
|
this.effector = new ImageEffector({
|
||||||
|
canvas: options.canvas,
|
||||||
|
renderWidth: options.renderWidth,
|
||||||
|
renderHeight: options.renderHeight,
|
||||||
|
image: options.image,
|
||||||
|
fxs: [FX_watermarkPlacement],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeImageEffectorLayers(): ImageEffectorLayer[] {
|
||||||
|
return this.layers.map(layer => {
|
||||||
if (layer.type === 'text') {
|
if (layer.type === 'text') {
|
||||||
return {
|
return {
|
||||||
fxId: 'watermarkPlacement',
|
fxId: 'watermarkPlacement',
|
||||||
|
@ -41,9 +62,11 @@ export function makeImageEffectorLayers(layers: WatermarkPreset['layers']): Imag
|
||||||
align: layer.align,
|
align: layer.align,
|
||||||
opacity: layer.opacity,
|
opacity: layer.opacity,
|
||||||
cover: false,
|
cover: false,
|
||||||
},
|
watermark: {
|
||||||
|
type: 'text',
|
||||||
text: layer.text,
|
text: layer.text,
|
||||||
imageUrl: null,
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
@ -55,10 +78,27 @@ export function makeImageEffectorLayers(layers: WatermarkPreset['layers']): Imag
|
||||||
align: layer.align,
|
align: layer.align,
|
||||||
opacity: layer.opacity,
|
opacity: layer.opacity,
|
||||||
cover: layer.cover,
|
cover: layer.cover,
|
||||||
|
watermark: {
|
||||||
|
type: 'url',
|
||||||
|
url: layer.imageUrl,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
text: null,
|
|
||||||
imageUrl: layer.imageUrl ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setLayers(layers: WatermarkPreset['layers']) {
|
||||||
|
this.layers = layers;
|
||||||
|
await this.effector.setLayers(this.makeImageEffectorLayers());
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): void {
|
||||||
|
this.effector.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.effector.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue