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;
|
||||
/**
|
||||
* 低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。
|
||||
*/
|
||||
"defaultImageCompressionLevel_description": string;
|
||||
"_chat": {
|
||||
/**
|
||||
* まだメッセージはありません
|
||||
|
@ -12079,6 +12083,10 @@ export interface Locale extends ILocale {
|
|||
* エフェクトを追加
|
||||
*/
|
||||
"addEffect": string;
|
||||
/**
|
||||
* 変更を破棄して終了しますか?
|
||||
*/
|
||||
"discardChangesConfirm": string;
|
||||
"_fxs": {
|
||||
/**
|
||||
* 色収差
|
||||
|
|
|
@ -1365,6 +1365,7 @@ tip: "ヒントとコツ"
|
|||
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
|
||||
hideAllTips: "全ての「ヒントとコツ」を非表示"
|
||||
defaultImageCompressionLevel: "デフォルトの画像圧縮度"
|
||||
defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。"
|
||||
|
||||
_chat:
|
||||
noMessagesYet: "まだメッセージはありません"
|
||||
|
@ -3236,6 +3237,7 @@ _watermarkEditor:
|
|||
_imageEffector:
|
||||
title: "エフェクト"
|
||||
addEffect: "エフェクトを追加"
|
||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||
|
||||
_fxs:
|
||||
chromaticAberration: "色収差"
|
||||
|
|
|
@ -48,7 +48,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
||||
import { i18n } from '@/i18n.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';
|
||||
|
||||
const props = defineProps<{
|
||||
image: HTMLImageElement;
|
||||
image: File;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -73,7 +72,14 @@ const emit = defineEmits<{
|
|||
|
||||
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');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
@ -82,7 +88,7 @@ const layers = reactive<ImageEffectorLayer[]>([]);
|
|||
|
||||
watch(layers, async () => {
|
||||
if (renderer != null) {
|
||||
renderer.updateLayers(layers);
|
||||
renderer.setLayers(layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
|
@ -109,20 +115,39 @@ function onLayerDelete(layer: ImageEffectorLayer) {
|
|||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
let renderer: ImageEffector | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
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({
|
||||
canvas: canvasEl.value,
|
||||
width: props.image.width,
|
||||
height: props.image.height,
|
||||
layers: layers,
|
||||
originalImage: props.image,
|
||||
renderWidth: w,
|
||||
renderHeight: h,
|
||||
image: imageBitmap,
|
||||
fxs: FXS,
|
||||
});
|
||||
|
||||
await renderer!.bakeTextures();
|
||||
await renderer.setLayers(layers);
|
||||
|
||||
renderer!.render();
|
||||
renderer.render();
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
@ -130,18 +155,26 @@ onUnmounted(() => {
|
|||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
if (imageBitmap != null) {
|
||||
imageBitmap.close();
|
||||
imageBitmap = null;
|
||||
}
|
||||
});
|
||||
|
||||
function save() {
|
||||
if (layers.length === 0) {
|
||||
if (layers.length === 0 || renderer == null || imageBitmap == null || canvasEl.value == null) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
renderer!.render(); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||
canvasEl.value!.toBlob((blob) => {
|
||||
const closeWaiting = os.waiting();
|
||||
|
||||
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' }));
|
||||
dialog.value?.close();
|
||||
closeWaiting();
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
|
@ -149,9 +182,9 @@ const enabled = ref(true);
|
|||
watch(enabled, () => {
|
||||
if (renderer != null) {
|
||||
if (enabled.value) {
|
||||
renderer.updateLayers(layers);
|
||||
renderer.setLayers(layers);
|
||||
} else {
|
||||
renderer.updateLayers([]);
|
||||
renderer.setLayers([]);
|
||||
}
|
||||
renderer.render();
|
||||
}
|
||||
|
|
|
@ -96,9 +96,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
|||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||
import * as os from '@/os.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||
import { makeImageEffectorLayers } from '@/utility/watermark.js';
|
||||
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
|
@ -299,9 +297,8 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
|||
icon: 'ti ti-sparkles',
|
||||
text: i18n.ts._imageEffector.title,
|
||||
action: async () => {
|
||||
const img = await getImageElement(item.file);
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageEffectorDialog.vue')), {
|
||||
image: img,
|
||||
image: item.file,
|
||||
}, {
|
||||
ok: (file) => {
|
||||
URL.revokeObjectURL(item.thumbnail);
|
||||
|
@ -315,10 +312,7 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
|||
triggerRef(items);
|
||||
});
|
||||
},
|
||||
closed: () => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
dispose();
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -348,7 +342,24 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
|||
text: preset.name,
|
||||
active: computed(() => item.watermarkPresetId === 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> {
|
||||
item.preprocessing = true;
|
||||
|
||||
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 preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
|
||||
if (needsWatermark && preset != null) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const renderer = new ImageEffector({
|
||||
const renderer = new WatermarkRenderer({
|
||||
canvas: canvas,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
layers: makeImageEffectorLayers(preset.layers),
|
||||
originalImage: img,
|
||||
fxs: [FX_watermarkPlacement],
|
||||
renderWidth: imageBitmap.width,
|
||||
renderHeight: imageBitmap.height,
|
||||
image: imageBitmap,
|
||||
});
|
||||
|
||||
await renderer.bakeTextures();
|
||||
await renderer.setLayers(preset.layers);
|
||||
|
||||
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');
|
||||
}
|
||||
resolve(blob);
|
||||
renderer.destroy();
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
@ -578,7 +572,7 @@ async function preprocess(item: (typeof items)['value'][number]): Promise<void>
|
|||
item.preprocessedFile = markRaw(file);
|
||||
item.preprocessing = false;
|
||||
|
||||
URL.revokeObjectURL(img.src);
|
||||
imageBitmap.close();
|
||||
}
|
||||
|
||||
function initializeFile(file: File) {
|
||||
|
|
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<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 === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
</div>
|
||||
|
@ -48,9 +48,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
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 { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkSelect from '@/components/MkSelect.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 { deepClone } from '@/utility/clone.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const props = defineProps<{
|
||||
preset?: WatermarkPreset | null;
|
||||
image?: File | null;
|
||||
}>();
|
||||
|
||||
const preset = reactive(deepClone(props.preset) ?? {
|
||||
|
@ -122,7 +121,7 @@ watch(type, () => {
|
|||
|
||||
watch(preset, async (newValue, oldValue) => {
|
||||
if (renderer != null) {
|
||||
renderer.updateLayers(makeImageEffectorLayers(preset.layers));
|
||||
renderer.setLayers(preset.layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
|
@ -140,7 +139,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
|
|||
sampleImage_2_3.onload = () => resolve();
|
||||
});
|
||||
|
||||
const sampleImageType = ref('3_2');
|
||||
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
|
||||
watch(sampleImageType, async () => {
|
||||
if (renderer != null) {
|
||||
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() {
|
||||
if (canvasEl.value == null) return;
|
||||
|
||||
if (sampleImageType.value === '3_2') {
|
||||
renderer = new ImageEffector({
|
||||
renderer = new WatermarkRenderer({
|
||||
canvas: canvasEl.value,
|
||||
width: 1500,
|
||||
height: 1000,
|
||||
layers: makeImageEffectorLayers(preset.layers),
|
||||
originalImage: sampleImage_3_2,
|
||||
fxs: [FX_watermarkPlacement],
|
||||
renderWidth: 1500,
|
||||
renderHeight: 1000,
|
||||
image: sampleImage_3_2,
|
||||
});
|
||||
} else if (sampleImageType.value === '2_3') {
|
||||
renderer = new ImageEffector({
|
||||
renderer = new WatermarkRenderer({
|
||||
canvas: canvasEl.value,
|
||||
width: 1000,
|
||||
height: 1500,
|
||||
layers: makeImageEffectorLayers(preset.layers),
|
||||
originalImage: sampleImage_2_3,
|
||||
fxs: [FX_watermarkPlacement],
|
||||
renderWidth: 1000,
|
||||
renderHeight: 1500,
|
||||
image: sampleImage_2_3,
|
||||
});
|
||||
} 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();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const closeWaiting = os.waiting();
|
||||
|
||||
await sampleImage_3_2_loading;
|
||||
await sampleImage_2_3_loading;
|
||||
|
||||
initRenderer();
|
||||
await initRenderer();
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
@ -191,6 +211,10 @@ onUnmounted(() => {
|
|||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
if (imageBitmap != null) {
|
||||
imageBitmap.close();
|
||||
imageBitmap = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
|
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkFolder :defaultOpen="false">
|
||||
<MkFolder :defaultOpen="false" :canPage="false">
|
||||
<template #icon><i class="ti ti-pencil"></i></template>
|
||||
<template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template>
|
||||
<template #footer>
|
||||
|
@ -23,14 +23,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
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 * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
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<{
|
||||
preset: WatermarkPreset;
|
||||
|
@ -66,23 +64,21 @@ const canvasEl = useTemplateRef('canvasEl');
|
|||
const sampleImage = new Image();
|
||||
sampleImage.src = '/client-assets/sample/3-2.jpg';
|
||||
|
||||
let renderer: ImageEffector | null = null;
|
||||
let renderer: WatermarkRenderer | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
sampleImage.onload = async () => {
|
||||
watch(canvasEl, async () => {
|
||||
if (canvasEl.value == null) return;
|
||||
|
||||
renderer = new ImageEffector({
|
||||
renderer = new WatermarkRenderer({
|
||||
canvas: canvasEl.value,
|
||||
width: 1500,
|
||||
height: 1000,
|
||||
layers: makeImageEffectorLayers(props.preset.layers),
|
||||
originalImage: sampleImage,
|
||||
fxs: [FX_watermarkPlacement],
|
||||
renderWidth: 1500,
|
||||
renderHeight: 1000,
|
||||
image: sampleImage,
|
||||
});
|
||||
|
||||
await renderer.bakeTextures();
|
||||
await renderer.setLayers(props.preset.layers);
|
||||
|
||||
renderer.render();
|
||||
}, { immediate: true });
|
||||
|
@ -98,8 +94,7 @@ onUnmounted(() => {
|
|||
|
||||
watch(() => props.preset, async () => {
|
||||
if (renderer != null) {
|
||||
renderer.updateLayers(makeImageEffectorLayers(props.preset.layers));
|
||||
await renderer.bakeTextures();
|
||||
await renderer.setLayers(props.preset.layers);
|
||||
renderer.render();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
|
|
@ -90,6 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkFolder>
|
||||
<template #icon><i class="ti ti-copyright"></i></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template>
|
||||
<template #caption>{{ i18n.ts._watermarkEditor.tip }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<div class="_gaps_s">
|
||||
|
@ -134,6 +135,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
]"
|
||||
>
|
||||
<template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template>
|
||||
<template #caption><div v-html="i18n.ts.defaultImageCompressionLevel_description"></div></template>
|
||||
</MkSelect>
|
||||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
|
|
|
@ -11,6 +11,7 @@ type ParamTypeToPrimitive = {
|
|||
'boolean': boolean;
|
||||
'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
|
||||
'seed': number;
|
||||
'texture': { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
|
||||
};
|
||||
|
||||
type ImageEffectorFxParamDefs = Record<string, {
|
||||
|
@ -18,30 +19,30 @@ type ImageEffectorFxParamDefs = Record<string, {
|
|||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
name: string;
|
||||
shader: string;
|
||||
uniforms: U;
|
||||
params: P,
|
||||
uniforms: US;
|
||||
params: PS,
|
||||
main: (ctx: {
|
||||
gl: WebGL2RenderingContext;
|
||||
program: WebGLProgram;
|
||||
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;
|
||||
height: number;
|
||||
watermark?: {
|
||||
textures: Record<string, {
|
||||
texture: WebGLTexture;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
} | null>;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
|
@ -49,54 +50,55 @@ export type ImageEffectorLayer = {
|
|||
id: string;
|
||||
fxId: string;
|
||||
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 {
|
||||
private gl: WebGL2RenderingContext;
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private gl: WebGL2RenderingContext | null = null;
|
||||
private renderTextureProgram!: WebGLProgram;
|
||||
private renderInvertedTextureProgram!: WebGLProgram;
|
||||
private renderWidth!: number;
|
||||
private renderHeight!: number;
|
||||
private renderTextureProgram: WebGLProgram;
|
||||
private renderInvertedTextureProgram: WebGLProgram;
|
||||
private renderWidth: number;
|
||||
private renderHeight: number;
|
||||
private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||
private layers: ImageEffectorLayer[];
|
||||
private layers: ImageEffectorLayer[] = [];
|
||||
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 perLayerResultTextures: Map<string, WebGLTexture> = new Map();
|
||||
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
|
||||
private fxs: ImageEffectorFx[];
|
||||
private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement;
|
||||
width: number;
|
||||
height: number;
|
||||
originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||
layers: ImageEffectorLayer[];
|
||||
renderWidth: number;
|
||||
renderHeight: number;
|
||||
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||
fxs: ImageEffectorFx[];
|
||||
}) {
|
||||
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.layers = options.layers;
|
||||
this.renderWidth = options.renderWidth;
|
||||
this.renderHeight = options.renderHeight;
|
||||
this.originalImage = options.image;
|
||||
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,
|
||||
alpha: true,
|
||||
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);
|
||||
|
||||
|
@ -105,10 +107,10 @@ export class ImageEffector {
|
|||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
|
||||
this.originalImageTexture = this.createTexture();
|
||||
this.originalImageTexture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
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);
|
||||
|
||||
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) {
|
||||
const gl = this.gl!;
|
||||
const gl = this.gl;
|
||||
|
||||
const shader = gl.createShader(type)!;
|
||||
|
||||
|
@ -284,7 +175,7 @@ export class ImageEffector {
|
|||
}
|
||||
|
||||
public initShaderProgram(vsSource, fsSource): WebGLProgram {
|
||||
const gl = this.gl!;
|
||||
const gl = this.gl;
|
||||
|
||||
const vertexShader = this.loadShader(gl.VERTEX_SHADER, vsSource)!;
|
||||
const fragmentShader = this.loadShader(gl.FRAGMENT_SHADER, fsSource)!;
|
||||
|
@ -308,15 +199,10 @@ export class ImageEffector {
|
|||
|
||||
private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture) {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
throw new Error('gl is not initialized');
|
||||
}
|
||||
|
||||
const fx = this.fxs.find(fx => fx.id === layer.fxId);
|
||||
if (fx == null) return;
|
||||
|
||||
const watermark = layer.fxId === 'watermarkPlacement' ? this.bakedTexturesForWatermarkFx.get(layer.id) : undefined;
|
||||
|
||||
const cachedShader = this.shaderCache.get(fx.id);
|
||||
const shaderProgram = cachedShader ?? this.initShaderProgram(`#version 300 es
|
||||
in vec2 position;
|
||||
|
@ -348,11 +234,18 @@ export class ImageEffector {
|
|||
Object.entries(fx.params).map(([key, param]) => {
|
||||
return [key, layer.params[key] ?? param.default];
|
||||
}),
|
||||
) as any,
|
||||
),
|
||||
u: Object.fromEntries(fx.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
|
||||
width: this.renderWidth,
|
||||
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);
|
||||
|
@ -360,9 +253,6 @@ export class ImageEffector {
|
|||
|
||||
public render() {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
throw new Error('gl is not initialized');
|
||||
}
|
||||
|
||||
{
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
|
@ -386,7 +276,7 @@ export class ImageEffector {
|
|||
|
||||
for (const layer of this.layers) {
|
||||
const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
|
||||
const resultTexture = cachedResultTexture ?? this.createTexture();
|
||||
const resultTexture = cachedResultTexture ?? createTexture(gl);
|
||||
if (cachedResultTexture == null) {
|
||||
this.perLayerResultTextures.set(layer.id, resultTexture);
|
||||
}
|
||||
|
@ -420,42 +310,160 @@ export class ImageEffector {
|
|||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
public async updateLayers(layers: ImageEffectorLayer[]) {
|
||||
public async setLayers(layers: ImageEffectorLayer[]) {
|
||||
this.layers = layers;
|
||||
|
||||
const newTexturesKey = this.calcTexturesKey();
|
||||
if (newTexturesKey !== this.texturesKey) {
|
||||
this.texturesKey = newTexturesKey;
|
||||
await this.bakeTextures();
|
||||
const unused = new Set(this.paramTextures.keys());
|
||||
|
||||
for (const layer of layers) {
|
||||
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();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
const gl = this.gl;
|
||||
if (gl == null) {
|
||||
throw new Error('gl is not initialized');
|
||||
public changeResolution(width: number, height: number) {
|
||||
this.renderWidth = width;
|
||||
this.renderHeight = height;
|
||||
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()) {
|
||||
gl.deleteProgram(shader);
|
||||
this.gl.deleteProgram(shader);
|
||||
}
|
||||
this.shaderCache.clear();
|
||||
|
||||
for (const texture of this.perLayerResultTextures.values()) {
|
||||
gl.deleteTexture(texture);
|
||||
this.gl.deleteTexture(texture);
|
||||
}
|
||||
this.perLayerResultTextures.clear();
|
||||
|
||||
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
|
||||
gl.deleteFramebuffer(framebuffer);
|
||||
this.gl.deleteFramebuffer(framebuffer);
|
||||
}
|
||||
this.perLayerResultFrameBuffers.clear();
|
||||
|
||||
this.disposeBakedTextures();
|
||||
gl.deleteProgram(this.renderTextureProgram);
|
||||
gl.deleteProgram(this.renderInvertedTextureProgram);
|
||||
gl.deleteTexture(this.originalImageTexture);
|
||||
for (const texture of this.paramTextures.values()) {
|
||||
this.gl.deleteTexture(texture.texture);
|
||||
}
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
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.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.opacity, params.opacity);
|
||||
gl.uniform1f(u.angle, 0.0);
|
||||
|
|
|
@ -69,7 +69,7 @@ export const FX_zoomLines = defineImageEffectorFx({
|
|||
},
|
||||
threshold: {
|
||||
type: 'number' as const,
|
||||
default: 0.5,
|
||||
default: 0.8,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
|
|
|
@ -3,7 +3,9 @@
|
|||
* 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 { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||
|
||||
export type WatermarkPreset = {
|
||||
id: string;
|
||||
|
@ -29,36 +31,74 @@ export type WatermarkPreset = {
|
|||
})[];
|
||||
};
|
||||
|
||||
export function makeImageEffectorLayers(layers: WatermarkPreset['layers']): ImageEffectorLayer[] {
|
||||
return layers.map(layer => {
|
||||
if (layer.type === 'text') {
|
||||
return {
|
||||
fxId: 'watermarkPlacement',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
opacity: layer.opacity,
|
||||
cover: false,
|
||||
},
|
||||
text: layer.text,
|
||||
imageUrl: null,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
fxId: 'watermarkPlacement',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
opacity: layer.opacity,
|
||||
cover: layer.cover,
|
||||
},
|
||||
text: null,
|
||||
imageUrl: layer.imageUrl ?? null,
|
||||
};
|
||||
}
|
||||
});
|
||||
export class WatermarkRenderer {
|
||||
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') {
|
||||
return {
|
||||
fxId: 'watermarkPlacement',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
opacity: layer.opacity,
|
||||
cover: false,
|
||||
watermark: {
|
||||
type: 'text',
|
||||
text: layer.text,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
fxId: 'watermarkPlacement',
|
||||
id: layer.id,
|
||||
params: {
|
||||
repeat: layer.repeat,
|
||||
scale: layer.scale,
|
||||
align: layer.align,
|
||||
opacity: layer.opacity,
|
||||
cover: layer.cover,
|
||||
watermark: {
|
||||
type: 'url',
|
||||
url: layer.imageUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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