summaryrefslogtreecommitdiff
path: root/packages/frontend/src/utility/watermark
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-11-06 20:25:17 +0900
committerGitHub <noreply@github.com>2025-11-06 20:25:17 +0900
commit4ba18690d7abd7eea086bb59e6cbcc8ead9e121a (patch)
tree7d25ec47d8711d945b08e3903642f2e982f40048 /packages/frontend/src/utility/watermark
parentfix(frontend): improve startViewTransition handling (diff)
downloadmisskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.gz
misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.bz2
misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.zip
feat(frontend): EXIFフレーム機能 (#16725)
* wip * wip * Update ImageEffector.ts * Update image-label-renderer.ts * Update image-label-renderer.ts * wip * Update image-label-renderer.ts * wip * wip * wip * wip * wip * wip * wip * Update use-uploader.ts * Update watermark.ts * wip * wu * wip * Update image-frame-renderer.ts * wip * wip * Update image-frame-renderer.ts * Create ImageCompositor.ts * Update ImageCompositor.ts * wip * wip * Update ImageEffector.ts * wip * Update use-uploader.ts * wip * wip * wip * wip * Update fxs.ts * wip * wip * wip * Update CHANGELOG.md * wip * wip * Update MkImageEffectorDialog.vue * Update MkImageEffectorDialog.vue * Update MkImageFrameEditorDialog.vue * Update use-uploader.ts * improve error handling * Update use-uploader.ts * 🎨 * wip * wip * lazy load * lazy load * wip * wip * wip
Diffstat (limited to 'packages/frontend/src/utility/watermark')
-rw-r--r--packages/frontend/src/utility/watermark/WatermarkRenderer.ts332
-rw-r--r--packages/frontend/src/utility/watermark/watermark.glsl147
-rw-r--r--packages/frontend/src/utility/watermark/watermark.ts57
3 files changed, 536 insertions, 0 deletions
diff --git a/packages/frontend/src/utility/watermark/WatermarkRenderer.ts b/packages/frontend/src/utility/watermark/WatermarkRenderer.ts
new file mode 100644
index 0000000000..766d45148a
--- /dev/null
+++ b/packages/frontend/src/utility/watermark/WatermarkRenderer.ts
@@ -0,0 +1,332 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import QRCodeStyling from 'qr-code-styling';
+import { url, host } from '@@/js/config.js';
+import { getProxiedImageUrl } from '../media-proxy.js';
+import { fn as fn_watermark } from './watermark.js';
+import { fn as fn_stripe } from '@/utility/image-compositor-functions/stripe.js';
+import { fn as fn_poladot } from '@/utility/image-compositor-functions/polkadot.js';
+import { fn as fn_checker } from '@/utility/image-compositor-functions/checker.js';
+import { ImageCompositor } from '@/lib/ImageCompositor.js';
+import { ensureSignin } from '@/i.js';
+
+type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
+
+export type WatermarkLayers = ({
+ id: string;
+ type: 'text';
+ text: string;
+ repeat: boolean;
+ noBoundingBoxExpansion: boolean;
+ scale: number;
+ angle: number;
+ align: Align;
+ opacity: number;
+} | {
+ id: string;
+ type: 'image';
+ imageUrl: string | null;
+ imageId: string | null;
+ cover: boolean;
+ repeat: boolean;
+ noBoundingBoxExpansion: boolean;
+ scale: number;
+ angle: number;
+ align: Align;
+ opacity: number;
+} | {
+ id: string;
+ type: 'qr';
+ data: string;
+ scale: number;
+ align: Align;
+ opacity: number;
+} | {
+ id: string;
+ type: 'stripe';
+ angle: number;
+ frequency: number;
+ threshold: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+} | {
+ id: string;
+ type: 'polkadot';
+ angle: number;
+ scale: number;
+ majorRadius: number;
+ majorOpacity: number;
+ minorDivisions: number;
+ minorRadius: number;
+ minorOpacity: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+} | {
+ id: string;
+ type: 'checker';
+ angle: number;
+ scale: number;
+ color: [r: number, g: number, b: number];
+ opacity: number;
+})[];
+
+export type WatermarkPreset = {
+ id: string;
+ name: string;
+ layers: WatermarkLayers;
+};
+
+type WatermarkRendererImageCompositor = ImageCompositor<{
+ watermark: typeof fn_watermark;
+ stripe: typeof fn_stripe;
+ polkadot: typeof fn_poladot;
+ checker: typeof fn_checker;
+}>;
+
+export class WatermarkRenderer {
+ private compositor: WatermarkRendererImageCompositor;
+
+ constructor(options: {
+ canvas: HTMLCanvasElement,
+ renderWidth: number,
+ renderHeight: number,
+ image: HTMLImageElement | ImageBitmap,
+ }) {
+ this.compositor = new ImageCompositor({
+ canvas: options.canvas,
+ renderWidth: options.renderWidth,
+ renderHeight: options.renderHeight,
+ image: options.image,
+ functions: {
+ watermark: fn_watermark,
+ stripe: fn_stripe,
+ polkadot: fn_poladot,
+ checker: fn_checker,
+ },
+ });
+ }
+
+ public async render(layers: WatermarkLayers) {
+ const compositorLayers: Parameters<WatermarkRendererImageCompositor['render']>[0] = [];
+
+ const unused = new Set(this.compositor.getKeysOfRegisteredTextures());
+
+ for (const layer of layers) {
+ if (layer.type === 'text') {
+ const textureKey = `text:${layer.text}`;
+ unused.delete(textureKey);
+ if (!this.compositor.hasTexture(textureKey)) {
+ if (_DEV_) console.log(`Baking text texture of <${textureKey}>...`);
+ const image = await createTextureFromText(layer.text);
+ if (image != null) this.compositor.registerTexture(textureKey, image);
+ }
+
+ compositorLayers.push({
+ functionId: 'watermark',
+ id: layer.id,
+ params: {
+ repeat: layer.repeat,
+ noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
+ scale: layer.scale,
+ align: layer.align,
+ angle: layer.angle,
+ opacity: layer.opacity,
+ cover: false,
+ watermark: textureKey,
+ },
+ });
+ } else if (layer.type === 'image') {
+ const textureKey = `url:${layer.imageUrl}`;
+ unused.delete(textureKey);
+ if (!this.compositor.hasTexture(textureKey)) {
+ if (_DEV_) console.log(`Baking url image texture of <${textureKey}>...`);
+ const image = await createTextureFromUrl(layer.imageUrl);
+ if (image != null) this.compositor.registerTexture(textureKey, image);
+ }
+
+ compositorLayers.push({
+ functionId: 'watermark',
+ id: layer.id,
+ params: {
+ repeat: layer.repeat,
+ noBoundingBoxExpansion: layer.noBoundingBoxExpansion,
+ scale: layer.scale,
+ align: layer.align,
+ angle: layer.angle,
+ opacity: layer.opacity,
+ cover: layer.cover,
+ watermark: textureKey,
+ },
+ });
+ } else if (layer.type === 'qr') {
+ const textureKey = `qr:${layer.data}`;
+ unused.delete(textureKey);
+ if (!this.compositor.hasTexture(textureKey)) {
+ if (_DEV_) console.log(`Baking qr texture of <${textureKey}>...`);
+ const image = await createTextureFromQr({ data: layer.data });
+ if (image != null) this.compositor.registerTexture(textureKey, image);
+ }
+
+ compositorLayers.push({
+ functionId: 'watermark',
+ id: layer.id,
+ params: {
+ repeat: false,
+ scale: layer.scale,
+ align: layer.align,
+ angle: 0,
+ opacity: layer.opacity,
+ cover: false,
+ watermark: textureKey,
+ },
+ });
+ } else if (layer.type === 'stripe') {
+ compositorLayers.push({
+ functionId: 'stripe',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ frequency: layer.frequency,
+ threshold: layer.threshold,
+ color: layer.color,
+ opacity: layer.opacity,
+ },
+ });
+ } else if (layer.type === 'polkadot') {
+ compositorLayers.push({
+ functionId: 'polkadot',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ scale: layer.scale,
+ majorRadius: layer.majorRadius,
+ majorOpacity: layer.majorOpacity,
+ minorDivisions: layer.minorDivisions,
+ minorRadius: layer.minorRadius,
+ minorOpacity: layer.minorOpacity,
+ color: layer.color,
+ },
+ });
+ } else if (layer.type === 'checker') {
+ compositorLayers.push({
+ functionId: 'checker',
+ id: layer.id,
+ params: {
+ angle: layer.angle,
+ scale: layer.scale,
+ color: layer.color,
+ opacity: layer.opacity,
+ },
+ });
+ } else {
+ throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
+ }
+ }
+
+ for (const k of unused) {
+ if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
+ this.compositor.unregisterTexture(k);
+ }
+
+ this.compositor.render(compositorLayers);
+ }
+
+ public changeResolution(width: number, height: number) {
+ this.compositor.changeResolution(width, height);
+ }
+
+ /*
+ * disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
+ */
+ public destroy(disposeCanvas = true): void {
+ this.compositor.destroy(disposeCanvas);
+ }
+}
+
+async function createTextureFromUrl(imageUrl: string | 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;
+
+ return image;
+}
+
+async function createTextureFromText(text: string | null, resolution = 2048) {
+ 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), cropWidth, cropHeight);
+
+ ctx.canvas.remove();
+
+ return data;
+}
+
+async function createTextureFromQr(options: { data: string | null }, resolution = 512) {
+ const $i = ensureSignin();
+
+ const qrCodeInstance = new QRCodeStyling({
+ width: resolution,
+ height: resolution,
+ margin: 42,
+ type: 'canvas',
+ data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
+ image: $i.avatarUrl,
+ qrOptions: {
+ typeNumber: 0,
+ mode: 'Byte',
+ errorCorrectionLevel: 'H',
+ },
+ imageOptions: {
+ hideBackgroundDots: true,
+ imageSize: 0.3,
+ margin: 16,
+ crossOrigin: 'anonymous',
+ },
+ dotsOptions: {
+ type: 'dots',
+ },
+ cornersDotOptions: {
+ type: 'dot',
+ },
+ cornersSquareOptions: {
+ type: 'extra-rounded',
+ },
+ });
+
+ const blob = await qrCodeInstance.getRawData('png') as Blob | null;
+ if (blob == null) return null;
+
+ const image = await window.createImageBitmap(blob);
+
+ return image;
+}
diff --git a/packages/frontend/src/utility/watermark/watermark.glsl b/packages/frontend/src/utility/watermark/watermark.glsl
new file mode 100644
index 0000000000..d6a1ef1820
--- /dev/null
+++ b/packages/frontend/src/utility/watermark/watermark.glsl
@@ -0,0 +1,147 @@
+#version 300 es
+precision mediump float;
+
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const float PI = 3.141592653589793;
+
+in vec2 in_uv; // 0..1
+uniform sampler2D in_texture; // 背景
+uniform vec2 in_resolution; // 出力解像度(px)
+
+uniform sampler2D u_watermark; // ウォーターマーク
+uniform vec2 u_wmResolution; // ウォーターマーク元解像度(px)
+
+uniform float u_opacity; // 0..1
+uniform float u_scale; // watermarkのスケール
+uniform float u_angle; // -1..1 (PI倍)
+uniform bool u_cover; // cover基準 or fit基準
+uniform bool u_repeat; // タイル敷き詰め
+uniform int u_alignX; // 0:left 1:center 2:right
+uniform int u_alignY; // 0:top 1:center 2:bottom
+uniform float u_margin; // 余白(比率)
+uniform float u_repeatMargin; // 敷き詰め時の余白(比率)
+uniform bool u_noBBoxExpansion; // 回転時のBounding Box拡張を抑止
+uniform bool u_wmEnabled; // watermark有効
+
+out vec4 out_color;
+
+mat2 rot(float a) {
+ float c = cos(a), s = sin(a);
+ return mat2(c, -s, s, c);
+}
+
+// cover/fitとscaleから、最終的なサイズ(px)を計算
+vec2 computeWmSize(vec2 outSize, vec2 wmSize, bool cover, float scale) {
+ float wmAspect = wmSize.x / wmSize.y;
+ float outAspect = outSize.x / outSize.y;
+ vec2 size;
+ if (cover) {
+ if (wmAspect >= outAspect) {
+ size.y = outSize.y * scale;
+ size.x = size.y * wmAspect;
+ } else {
+ size.x = outSize.x * scale;
+ size.y = size.x / wmAspect;
+ }
+ } else {
+ if (wmAspect >= outAspect) {
+ size.x = outSize.x * scale;
+ size.y = size.x / wmAspect;
+ } else {
+ size.y = outSize.y * scale;
+ size.x = size.y * wmAspect;
+ }
+ }
+ return size;
+}
+
+void main() {
+ vec2 outSize = in_resolution;
+ vec2 p = in_uv * outSize; // 出力のピクセル座標
+ vec4 base = texture(in_texture, in_uv);
+
+ if (!u_wmEnabled) {
+ out_color = base;
+ return;
+ }
+
+ float theta = u_angle * PI; // ラジアン
+ vec2 wmSize = computeWmSize(outSize, u_wmResolution, u_cover, u_scale);
+ vec2 margin = u_repeat ? wmSize * u_repeatMargin : outSize * u_margin;
+
+ // アライメントに基づく回転中心を計算
+ float rotateX = 0.0;
+ float rotateY = 0.0;
+ if (abs(theta) > 1e-6 && !u_noBBoxExpansion) {
+ rotateX = abs(abs(wmSize.x * cos(theta)) + abs(wmSize.y * sin(theta)) - wmSize.x) * 0.5;
+ rotateY = abs(abs(wmSize.x * sin(theta)) + abs(wmSize.y * cos(theta)) - wmSize.y) * 0.5;
+ }
+
+ float x;
+ if (u_alignX == 1) {
+ x = (outSize.x - wmSize.x) * 0.5;
+ } else if (u_alignX == 0) {
+ x = rotateX + margin.x;
+ } else {
+ x = outSize.x - wmSize.x - margin.x - rotateX;
+ }
+
+ float y;
+ if (u_alignY == 1) {
+ y = (outSize.y - wmSize.y) * 0.5;
+ } else if (u_alignY == 0) {
+ y = rotateY + margin.y;
+ } else {
+ y = outSize.y - wmSize.y - margin.y - rotateY;
+ }
+
+ vec2 rectMin = vec2(x, y);
+ vec2 rectMax = rectMin + wmSize;
+ vec2 rectCenter = (rectMin + rectMax) * 0.5;
+
+ vec4 wmCol = vec4(0.0);
+
+ if (u_repeat) {
+ // アライメントに基づく中心で回転
+ vec2 q = rectCenter + rot(theta) * (p - rectCenter);
+
+ // タイルグリッドの原点をrectMin(アライメント位置)に設定
+ vec2 gridOrigin = rectMin - margin;
+ vec2 qFromOrigin = q - gridOrigin;
+
+ // タイルサイズ(ウォーターマーク + マージン)で正規化
+ vec2 tile = wmSize + margin * 2.0;
+ vec2 tileUv = qFromOrigin / tile;
+
+ // タイル内のローカル座標(0..1)を取得
+ vec2 localUv = fract(tileUv);
+
+ // ローカル座標をピクセル単位に変換
+ vec2 localPos = localUv * tile;
+
+ // マージン領域内かチェック
+ bool inMargin = any(lessThan(localPos, margin)) || any(greaterThanEqual(localPos, margin + wmSize));
+
+ if (!inMargin) {
+ // ウォーターマーク領域内: UV座標を計算
+ vec2 uvWm = (localPos - margin) / wmSize;
+ wmCol = texture(u_watermark, uvWm);
+ }
+ // マージン領域の場合は透明(wmCol = vec4(0.0))のまま
+ } else {
+ // アライメントと回転に従い一枚だけ描画
+ vec2 q = rectCenter + rot(theta) * (p - rectCenter);
+ bool inside = all(greaterThanEqual(q, rectMin)) && all(lessThan(q, rectMax));
+ if (inside) {
+ vec2 uvWm = (q - rectMin) / wmSize;
+ wmCol = texture(u_watermark, uvWm);
+ }
+ }
+
+ float a = clamp(wmCol.a * u_opacity, 0.0, 1.0);
+ out_color = mix(base, vec4(wmCol.rgb, 1.0), a);
+}
diff --git a/packages/frontend/src/utility/watermark/watermark.ts b/packages/frontend/src/utility/watermark/watermark.ts
new file mode 100644
index 0000000000..62efcd12b6
--- /dev/null
+++ b/packages/frontend/src/utility/watermark/watermark.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import shader from './watermark.glsl';
+import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
+
+export const fn = defineImageCompositorFunction<Partial<{
+ cover: boolean;
+ repeat: boolean;
+ scale: number;
+ angle: number;
+ align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
+ opacity: number;
+ noBoundingBoxExpansion: boolean;
+ watermark: string | null;
+}>>({
+ shader,
+ main: ({ gl, u, params, textures }) => {
+ // 基本パラメータ
+ gl.uniform1f(u.opacity, params.opacity ?? 1.0);
+ gl.uniform1f(u.scale, params.scale ?? 0.3);
+ gl.uniform1f(u.angle, params.angle ?? 0.0);
+ gl.uniform1i(u.cover, params.cover ? 1 : 0);
+ gl.uniform1i(u.repeat, params.repeat ? 1 : 0);
+ const ax = params.align?.x === 'left' ? 0 : params.align?.x === 'center' ? 1 : 2;
+ const ay = params.align?.y === 'top' ? 0 : params.align?.y === 'center' ? 1 : 2;
+ gl.uniform1i(u.alignX, ax);
+ gl.uniform1i(u.alignY, ay);
+ gl.uniform1f(u.margin, (params.align?.margin ?? 0));
+ gl.uniform1f(u.repeatMargin, (params.align?.margin ?? 0));
+ gl.uniform1i(u.noBBoxExpansion, params.noBoundingBoxExpansion ? 1 : 0);
+
+ // ウォーターマークテクスチャ
+ const wm = textures.get(params.watermark);
+ if (wm) {
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, wm.texture);
+
+ // リピートモードに応じてWRAP属性を設定
+ if (params.repeat) {
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
+ } else {
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ }
+
+ gl.uniform1i(u.watermark, 1);
+ gl.uniform2f(u.wmResolution, wm.width, wm.height);
+ gl.uniform1i(u.wmEnabled, 1);
+ } else {
+ gl.uniform1i(u.wmEnabled, 0);
+ }
+ },
+});