This commit is contained in:
syuilo 2025-05-30 13:15:15 +09:00
parent 2d2b9e7a3f
commit 236b8913d2
9 changed files with 283 additions and 263 deletions

View File

@ -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';
@ -82,7 +81,7 @@ const layers = reactive<ImageEffectorLayer[]>([]);
watch(layers, async () => {
if (renderer != null) {
renderer.updateLayers(layers);
renderer.setLayers(layers);
}
}, { deep: true });
@ -111,18 +110,19 @@ const canvasEl = useTemplateRef('canvasEl');
let renderer: ImageEffector | null = null;
onMounted(async () => {
if (canvasEl.value == null) return;
renderer = new ImageEffector({
canvas: canvasEl.value,
width: props.image.width,
height: props.image.height,
layers: layers,
originalImage: props.image,
renderWidth: props.image.width,
renderHeight: props.image.height,
image: props.image,
fxs: FXS,
});
await renderer!.bakeTextures();
await renderer.setLayers(layers);
renderer!.render();
renderer.render();
});
onUnmounted(() => {
@ -149,9 +149,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();
}

View File

@ -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();
@ -522,16 +520,14 @@ async function preprocess(item: (typeof items)['value'][number]): Promise<void>
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: img.width,
renderHeight: img.height,
image: img,
});
await renderer.bakeTextures();
await renderer.setLayers(preset.layers);
renderer.render();
@ -541,6 +537,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');
});
}

View File

@ -47,10 +47,8 @@ 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 { 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,7 +57,6 @@ 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();
@ -122,7 +119,7 @@ watch(type, () => {
watch(preset, async (newValue, oldValue) => {
if (renderer != null) {
renderer.updateLayers(makeImageEffectorLayers(preset.layers));
renderer.setLayers(preset.layers);
}
}, { deep: true });
@ -149,32 +146,28 @@ watch(sampleImageType, async () => {
}
});
let renderer: ImageEffector | null = null;
let renderer: WatermarkRenderer | 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,
});
}
await renderer!.bakeTextures();
await renderer!.setLayers(preset.layers);
renderer!.render();
}

View File

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

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getProxiedImageUrl } from '../media-proxy.js';
import { createTexture } from './utilts.js';
type ParamTypeToPrimitive = {
'number': number;
@ -11,6 +11,7 @@ type ParamTypeToPrimitive = {
'boolean': boolean;
'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
'seed': number;
'texture': string | null;
};
type ImageEffectorFxParamDefs = Record<string, {
@ -22,26 +23,27 @@ export function defineImageEffectorFx<ID extends string, P extends ImageEffector
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 = any, US extends string[] = string[], TS extends string[] = string[]> = {
id: ID;
name: string;
shader: string;
uniforms: U;
params: P,
uniforms: US;
params: PS,
textures?: TS;
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<TS[number], {
texture: WebGLTexture;
width: number;
height: number;
};
} | null>;
}) => void;
};
@ -49,54 +51,54 @@ export type ImageEffectorLayer = {
id: string;
fxId: string;
params: Record<string, any>;
// for watermarkPlacement fx
imageUrl?: string | null;
text?: string | null;
textures?: Record<string, ExternalTextureId | null>;
};
type ExternalTextureId = string;
export class ImageEffector {
public 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 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 externalTextures: Map<ExternalTextureId, { 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;
@ -352,7 +238,13 @@ export class ImageEffector {
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(layer.textures ?? {}).map(([key, textureId]) => {
if (textureId == null) return [key, null];
const externalTexture = this.externalTextures.get(textureId);
if (externalTexture == null) return [key, null];
return [key, externalTexture];
})),
});
gl.drawArrays(gl.TRIANGLES, 0, 6);
@ -360,9 +252,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 +275,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 +309,41 @@ 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();
}
this.render();
}
public destroy() {
const gl = this.gl;
if (gl == null) {
throw new Error('gl is not initialized');
}
public registerExternalTexture(id: string, texture: WebGLTexture, width: number, height: number) {
this.externalTextures.set(id, { texture, width, height });
}
public disposeExternalTextures() {
for (const bakedTexture of this.externalTextures.values()) {
this.gl.deleteTexture(bakedTexture.texture);
}
this.externalTextures.clear();
}
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);
this.disposeExternalTextures();
this.gl.deleteProgram(this.renderTextureProgram);
this.gl.deleteProgram(this.renderInvertedTextureProgram);
this.gl.deleteTexture(this.originalImageTexture);
}
}

View File

@ -99,16 +99,17 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
step: 0.01,
},
},
main: ({ gl, u, params, watermark }) => {
if (watermark == null) {
textures: ['watermark'] as const,
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);

View File

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

View File

@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getProxiedImageUrl } from '../media-proxy.js';
export 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.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;
}
export async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string): Promise<{ texture: WebGLTexture, width: number, height: number }> {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = getProxiedImageUrl(imageUrl); // CORS対策
});
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,
};
}
export async function createTextureFromText(gl: WebGL2RenderingContext, text: string, resolution = 2048): Promise<{ texture: WebGLTexture, width: number, height: number }> {
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;
}

View File

@ -3,7 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { FX_watermarkPlacement } from './image-effector/fxs/watermarkPlacement.js';
import { createTextureFromText, createTextureFromUrl } from './image-effector/utilts.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 +32,101 @@ 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'] = [];
private texturesKey = '';
constructor(options: {
canvas: HTMLCanvasElement,
renderWidth: number,
renderHeight: number,
image: HTMLImageElement,
}) {
this.effector = new ImageEffector({
canvas: options.canvas,
renderWidth: options.renderWidth,
renderHeight: options.renderHeight,
image: options.image,
fxs: [FX_watermarkPlacement],
});
}
private calcTexturesKey() {
return this.layers.map(layer => {
if (layer.type === 'image' && layer.imageUrl != null) {
return layer.imageUrl;
} else if (layer.type === 'text' && layer.text != null) {
return layer.text;
}
return '';
}).join(';');
}
private async bakeTextures(): Promise<void> {
this.effector.disposeExternalTextures();
for (const layer of this.layers) {
if (layer.type === 'text' && layer.text != null) {
const { texture, width, height } = await createTextureFromText(this.effector.gl, layer.text);
this.effector.registerExternalTexture(layer.id, texture, width, height);
} else if (layer.type === 'image' && layer.imageUrl != null) {
const { texture, width, height } = await createTextureFromUrl(this.effector.gl, layer.imageUrl);
this.effector.registerExternalTexture(layer.id, texture, width, height);
}
}
});
}
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,
},
textures: { watermark: layer.id },
};
} else {
return {
fxId: 'watermarkPlacement',
id: layer.id,
params: {
repeat: layer.repeat,
scale: layer.scale,
align: layer.align,
opacity: layer.opacity,
cover: layer.cover,
},
textures: { watermark: layer.id },
};
}
});
}
public async setLayers(layers: WatermarkPreset['layers']) {
this.layers = layers;
const newTexturesKey = this.calcTexturesKey();
if (newTexturesKey !== this.texturesKey) {
this.texturesKey = newTexturesKey;
await this.bakeTextures();
}
this.effector.setLayers(this.makeImageEffectorLayers());
this.render();
}
public render(): void {
this.effector.render();
}
public destroy(): void {
this.effector.destroy();
}
}