1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-21 21:35:28 +02:00

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
This commit is contained in:
syuilo
2025-11-06 20:25:17 +09:00
committed by GitHub
parent 26c8914a26
commit 4ba18690d7
64 changed files with 2838 additions and 1186 deletions

View File

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

View File

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

View File

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