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

View File

@ -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();
@ -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); 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: img.width,
height: img.height, renderHeight: img.height,
layers: makeImageEffectorLayers(preset.layers), image: img,
originalImage: img,
fxs: [FX_watermarkPlacement],
}); });
await renderer.bakeTextures(); await renderer.setLayers(preset.layers);
renderer.render(); 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'); throw new Error('Failed to convert canvas to blob');
} }
resolve(blob); resolve(blob);
renderer.destroy();
}, 'image/png'); }, 'image/png');
}); });
} }

View File

@ -47,10 +47,8 @@ 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 { WatermarkRenderer } from '@/utility/watermark.js';
import { makeImageEffectorLayers } 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,7 +57,6 @@ 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();
@ -122,7 +119,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 });
@ -149,32 +146,28 @@ watch(sampleImageType, async () => {
} }
}); });
let renderer: ImageEffector | null = null; let renderer: WatermarkRenderer | 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],
}); });
} }
await renderer!.bakeTextures(); await renderer!.setLayers(preset.layers);
renderer!.render(); renderer!.render();
} }

View File

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

View File

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

View File

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

View File

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

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 * 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 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,36 +32,101 @@ export type WatermarkPreset = {
})[]; })[];
}; };
export function makeImageEffectorLayers(layers: WatermarkPreset['layers']): ImageEffectorLayer[] { export class WatermarkRenderer {
return layers.map(layer => { private effector: ImageEffector;
if (layer.type === 'text') { private layers: WatermarkPreset['layers'] = [];
return { private texturesKey = '';
fxId: 'watermarkPlacement',
id: layer.id, constructor(options: {
params: { canvas: HTMLCanvasElement,
repeat: layer.repeat, renderWidth: number,
scale: layer.scale, renderHeight: number,
align: layer.align, image: HTMLImageElement,
opacity: layer.opacity, }) {
cover: false, this.effector = new ImageEffector({
}, canvas: options.canvas,
text: layer.text, renderWidth: options.renderWidth,
imageUrl: null, renderHeight: options.renderHeight,
}; image: options.image,
} else { fxs: [FX_watermarkPlacement],
return { });
fxId: 'watermarkPlacement', }
id: layer.id,
params: { private calcTexturesKey() {
repeat: layer.repeat, return this.layers.map(layer => {
scale: layer.scale, if (layer.type === 'image' && layer.imageUrl != null) {
align: layer.align, return layer.imageUrl;
opacity: layer.opacity, } else if (layer.type === 'text' && layer.text != null) {
cover: layer.cover, return layer.text;
}, }
text: null, return '';
imageUrl: layer.imageUrl ?? null, }).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();
}
} }