From a54b4f2fd90d5563f5d65a522a21060da6757407 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 2 Nov 2025 16:08:12 +0900
Subject: [PATCH] wip
---
locales/index.d.ts | 8 ++
locales/ja-JP.yml | 2 +
.../components/MkImageFrameEditorDialog.vue | 94 +++++++++++++-----
.../src/utility/image-effector/fxs/frame.glsl | 16 ++-
.../src/utility/image-effector/fxs/frame.ts | 25 +++--
.../src/utility/image-frame-renderer.ts | 99 +++++++++++--------
6 files changed, 167 insertions(+), 77 deletions(-)
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 7168abd5ca..e753cebeb2 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5618,6 +5618,14 @@ export interface Locale extends ILocale {
* 画像にフレームやメタデータを含んだラベルを追加して装飾できます。
*/
"tip": string;
+ /**
+ * ヘッダー
+ */
+ "header": string;
+ /**
+ * フッター
+ */
+ "footer": string;
/**
* フチの幅
*/
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index c1da09f501..d8536aa4c4 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1401,6 +1401,8 @@ frame: "フレーム"
_imageFrameEditor:
title: "フレームの編集"
tip: "画像にフレームやメタデータを含んだラベルを追加して装飾できます。"
+ header: "ヘッダー"
+ footer: "フッター"
borderThickness: "フチの幅"
labelThickness: "ラベルの幅"
labelScale: "ラベルのスケール"
diff --git a/packages/frontend/src/components/MkImageFrameEditorDialog.vue b/packages/frontend/src/components/MkImageFrameEditorDialog.vue
index 85f53f43e5..dc4944db43 100644
--- a/packages/frontend/src/components/MkImageFrameEditorDialog.vue
+++ b/packages/frontend/src/components/MkImageFrameEditorDialog.vue
@@ -35,29 +35,65 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._imageFrameEditor.borderThickness }}
-
- {{ i18n.ts._imageFrameEditor.labelThickness }}
-
+
+ {{ i18n.ts._imageFrameEditor.header }}
-
- {{ i18n.ts._imageFrameEditor.labelScale }}
-
+
+
+ {{ i18n.ts._imageFrameEditor.labelThickness }}
+
-
- {{ i18n.ts._imageFrameEditor.centered }}
-
+
+ {{ i18n.ts._imageFrameEditor.labelScale }}
+
-
- {{ i18n.ts._imageFrameEditor.captionMain }}
-
+
+ {{ i18n.ts._imageFrameEditor.centered }}
+
-
- {{ i18n.ts._imageFrameEditor.captionSub }}
-
+
+ {{ i18n.ts._imageFrameEditor.captionMain }}
+
-
- {{ i18n.ts._imageFrameEditor.withQrCode }}
-
+
+ {{ i18n.ts._imageFrameEditor.captionSub }}
+
+
+
+ {{ i18n.ts._imageFrameEditor.withQrCode }}
+
+
+
+
+
+ {{ i18n.ts._imageFrameEditor.footer }}
+
+
+
+ {{ i18n.ts._imageFrameEditor.labelThickness }}
+
+
+
+ {{ i18n.ts._imageFrameEditor.labelScale }}
+
+
+
+ {{ i18n.ts._imageFrameEditor.centered }}
+
+
+
+ {{ i18n.ts._imageFrameEditor.captionMain }}
+
+
+
+ {{ i18n.ts._imageFrameEditor.captionSub }}
+
+
+
+ {{ i18n.ts._imageFrameEditor.withQrCode }}
+
+
+
{{ i18n.ts._imageFrameEditor.availableVariables }}:
@@ -118,12 +154,22 @@ const props = defineProps<{
const frame = reactive(deepClone(props.frame) ?? {
borderThickness: 0.05,
- labelThickness: 0.2,
- labelScale: 1.0,
- title: '{year}/{0month}/{0day}',
- text: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}',
- centered: false,
- withQrCode: true,
+ labelTop: {
+ scale: 1.0,
+ padding: 0.2,
+ textBig: '',
+ textSmall: '',
+ centered: false,
+ withQrCode: false,
+ },
+ labelBottom: {
+ scale: 1.0,
+ padding: 0.2,
+ textBig: '{year}/{0month}/{0day}',
+ textSmall: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}',
+ centered: false,
+ withQrCode: true,
+ },
bgColor: [255, 255, 255],
fgColor: [0, 0, 0],
});
diff --git a/packages/frontend/src/utility/image-effector/fxs/frame.glsl b/packages/frontend/src/utility/image-effector/fxs/frame.glsl
index 5a260e4d9f..9030781151 100644
--- a/packages/frontend/src/utility/image-effector/fxs/frame.glsl
+++ b/packages/frontend/src/utility/image-effector/fxs/frame.glsl
@@ -10,7 +10,8 @@ in vec2 in_uv;
uniform sampler2D in_texture;
uniform vec2 in_resolution;
uniform sampler2D u_image;
-uniform sampler2D u_label;
+uniform sampler2D u_topLabel;
+uniform sampler2D u_bottomLabel;
uniform float u_paddingTop;
uniform float u_paddingBottom;
uniform float u_paddingLeft;
@@ -27,13 +28,20 @@ void main() {
remap(in_uv.y, u_paddingTop, 1.0 - u_paddingBottom, 0.0, 1.0)
));
- vec4 label_color = texture(u_label, vec2(
+ vec4 topLabel_color = texture(u_topLabel, vec2(
+ in_uv.x,
+ remap(in_uv.y, 0.0, u_paddingTop, 0.0, 1.0)
+ ));
+
+ vec4 bottomLabel_color = texture(u_bottomLabel, vec2(
in_uv.x,
remap(in_uv.y, 1.0 - u_paddingBottom, 1.0, 0.0, 1.0)
));
- if (in_uv.y > (1.0 - u_paddingBottom)) {
- out_color = label_color;
+ if (in_uv.y < u_paddingTop) {
+ out_color = topLabel_color;
+ } else if (in_uv.y > (1.0 - u_paddingBottom)) {
+ out_color = bottomLabel_color;
} else {
if (in_uv.y > u_paddingTop && in_uv.x > u_paddingLeft && in_uv.x < (1.0 - u_paddingRight)) {
out_color = image_color;
diff --git a/packages/frontend/src/utility/image-effector/fxs/frame.ts b/packages/frontend/src/utility/image-effector/fxs/frame.ts
index 79b7b66b07..89e9631517 100644
--- a/packages/frontend/src/utility/image-effector/fxs/frame.ts
+++ b/packages/frontend/src/utility/image-effector/fxs/frame.ts
@@ -10,13 +10,17 @@ export const FX_frame = defineImageEffectorFx({
id: 'frame',
name: '(internal)',
shader,
- uniforms: ['image', 'label', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'] as const,
+ uniforms: ['image', 'topLabel', 'bottomLabel', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'] as const,
params: {
image: {
type: 'textureRef',
default: null,
},
- label: {
+ topLabel: {
+ type: 'textureRef',
+ default: null,
+ },
+ bottomLabel: {
type: 'textureRef',
default: null,
},
@@ -47,6 +51,8 @@ export const FX_frame = defineImageEffectorFx({
},
main: ({ gl, u, params, textures }) => {
const image = textures.image;
+ if (image == null) return;
+
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, image.texture);
gl.uniform1i(u.image, 1);
@@ -56,11 +62,18 @@ export const FX_frame = defineImageEffectorFx({
gl.uniform1f(u.paddingLeft, params.paddingLeft);
gl.uniform1f(u.paddingRight, params.paddingRight);
- const label = textures.label;
- if (label) {
+ const topLabel = textures.topLabel;
+ if (topLabel) {
gl.activeTexture(gl.TEXTURE2);
- gl.bindTexture(gl.TEXTURE_2D, label.texture);
- gl.uniform1i(u.label, 2);
+ gl.bindTexture(gl.TEXTURE_2D, topLabel.texture);
+ gl.uniform1i(u.topLabel, 2);
+ }
+
+ const bottomLabel = textures.bottomLabel;
+ if (bottomLabel) {
+ gl.activeTexture(gl.TEXTURE3);
+ gl.bindTexture(gl.TEXTURE_2D, bottomLabel.texture);
+ gl.uniform1i(u.bottomLabel, 3);
}
},
});
diff --git a/packages/frontend/src/utility/image-frame-renderer.ts b/packages/frontend/src/utility/image-frame-renderer.ts
index bce3d12eb2..a261c8d58b 100644
--- a/packages/frontend/src/utility/image-frame-renderer.ts
+++ b/packages/frontend/src/utility/image-frame-renderer.ts
@@ -11,20 +11,27 @@ import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effect
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import { ensureSignin } from '@/i.js';
+const $i = ensureSignin();
+
const FXS = [
FX_frame,
] as const satisfies ImageEffectorFx[];
// TODO: 上部にもラベルを配置できるようにする
-export type ImageFrameParams = {
- borderThickness: number;
- labelThickness: number;
- labelScale: number;
- title: string;
- text: string;
+type LabelParams = {
+ scale: number;
+ padding: number;
+ textBig: string;
+ textSmall: string;
centered: boolean;
withQrCode: boolean;
+};
+
+export type ImageFrameParams = {
+ borderThickness: number;
+ labelTop: LabelParams;
+ labelBottom: LabelParams;
bgColor: [r: number, g: number, b: number];
fgColor: [r: number, g: number, b: number];
borderRadius: number; // TODO
@@ -64,7 +71,7 @@ export class ImageFrameRenderer {
this.effector.registerTexture('image', this.image);
}
- private interpolateText(text: string) {
+ private interpolateTemplateText(text: string) {
return text.replaceAll(/\{(\w+)\}/g, (_: string, key: string) => {
const meta_date = this.exif.DateTimeOriginal ? this.exif.DateTimeOriginal.description : '-';
const date = meta_date.split(' ')[0].replaceAll(':', '/');
@@ -92,33 +99,11 @@ export class ImageFrameRenderer {
});
}
- public async updateAndRender(params: ImageFrameParams): Promise {
- let imageAreaW = this.image.width;
- let imageAreaH = this.image.height;
-
- if (this.renderAsPreview) {
- const MAX_W = 1000;
- const MAX_H = 1000;
-
- if (imageAreaW > MAX_W || imageAreaH > MAX_H) {
- const scale = Math.min(MAX_W / imageAreaW, MAX_H / imageAreaH);
- imageAreaW = Math.floor(imageAreaW * scale);
- imageAreaH = Math.floor(imageAreaH * scale);
- }
- }
-
- const paddingTop = Math.floor(imageAreaH * params.borderThickness);
- const paddingLeft = Math.floor(imageAreaH * params.borderThickness);
- const paddingRight = Math.floor(imageAreaH * params.borderThickness);
- const paddingBottom = Math.floor(imageAreaH * params.labelThickness);
- const renderWidth = imageAreaW + paddingLeft + paddingRight;
- const renderHeight = imageAreaH + paddingTop + paddingBottom;
-
- const aspectRatio = renderWidth / renderHeight;
+ private async renderLabel(renderWidth: number, renderHeight: number, paddingLeft: number, paddingRight: number, imageAreaH: number, params: LabelParams) {
+ const scaleBase = imageAreaH * params.scale;
const labelCanvasCtx = window.document.createElement('canvas').getContext('2d')!;
labelCanvasCtx.canvas.width = renderWidth;
- labelCanvasCtx.canvas.height = paddingBottom;
- const scaleBase = imageAreaH * params.labelScale;
+ labelCanvasCtx.canvas.height = renderHeight;
const fontSize = scaleBase / 30;
const textsMarginLeft = Math.max(fontSize * 2, paddingLeft);
const textsMarginRight = textsMarginLeft;
@@ -133,30 +118,28 @@ export class ImageFrameRenderer {
labelCanvasCtx.font = `bold ${fontSize}px sans-serif`;
labelCanvasCtx.textBaseline = 'middle';
- const titleY = params.text === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) - (fontSize * 0.9);
+ const titleY = params.textSmall === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) - (fontSize * 0.9);
if (params.centered) {
labelCanvasCtx.textAlign = 'center';
- labelCanvasCtx.fillText(this.interpolateText(params.title), labelCanvasCtx.canvas.width / 2, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
+ labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), labelCanvasCtx.canvas.width / 2, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
} else {
labelCanvasCtx.textAlign = 'left';
- labelCanvasCtx.fillText(this.interpolateText(params.title), textsMarginLeft, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
+ labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), textsMarginLeft, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
}
labelCanvasCtx.fillStyle = '#00000088';
labelCanvasCtx.font = `${fontSize * 0.85}px sans-serif`;
labelCanvasCtx.textBaseline = 'middle';
- const textY = params.title === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) + (fontSize * 0.9);
+ const textY = params.textBig === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) + (fontSize * 0.9);
if (params.centered) {
labelCanvasCtx.textAlign = 'center';
- labelCanvasCtx.fillText(this.interpolateText(params.text), labelCanvasCtx.canvas.width / 2, textY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
+ labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), labelCanvasCtx.canvas.width / 2, textY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
} else {
labelCanvasCtx.textAlign = 'left';
- labelCanvasCtx.fillText(this.interpolateText(params.text), textsMarginLeft, textY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
+ labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), textsMarginLeft, textY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
}
- const $i = ensureSignin();
-
if (withQrCode) {
try {
const qrCodeInstance = new QRCodeStyling({
@@ -207,9 +190,38 @@ export class ImageFrameRenderer {
}
}
- const data = labelCanvasCtx.getImageData(0, 0, labelCanvasCtx.canvas.width, labelCanvasCtx.canvas.height);
+ return labelCanvasCtx;
+ }
- await this.effector.registerTexture('label', data);
+ public async updateAndRender(params: ImageFrameParams): Promise {
+ let imageAreaW = this.image.width;
+ let imageAreaH = this.image.height;
+
+ if (this.renderAsPreview) {
+ const MAX_W = 1000;
+ const MAX_H = 1000;
+
+ if (imageAreaW > MAX_W || imageAreaH > MAX_H) {
+ const scale = Math.min(MAX_W / imageAreaW, MAX_H / imageAreaH);
+ imageAreaW = Math.floor(imageAreaW * scale);
+ imageAreaH = Math.floor(imageAreaH * scale);
+ }
+ }
+
+ const paddingLeft = Math.floor(imageAreaH * params.borderThickness);
+ const paddingRight = Math.floor(imageAreaH * params.borderThickness);
+ const paddingTop = Math.floor(imageAreaH * params.labelTop.padding);
+ const paddingBottom = Math.floor(imageAreaH * params.labelBottom.padding);
+ const renderWidth = imageAreaW + paddingLeft + paddingRight;
+ const renderHeight = imageAreaH + paddingTop + paddingBottom;
+
+ const topLabelCanvasCtx = await this.renderLabel(renderWidth, paddingBottom, paddingLeft, paddingRight, imageAreaH, params.labelTop);
+ const topLabelImage = topLabelCanvasCtx.getImageData(0, 0, topLabelCanvasCtx.canvas.width, topLabelCanvasCtx.canvas.height);
+ this.effector.registerTexture('topLabel', topLabelImage);
+
+ const bottomLabelCanvasCtx = await this.renderLabel(renderWidth, paddingBottom, paddingLeft, paddingRight, imageAreaH, params.labelBottom);
+ const bottomLabelImage = bottomLabelCanvasCtx.getImageData(0, 0, bottomLabelCanvasCtx.canvas.width, bottomLabelCanvasCtx.canvas.height);
+ this.effector.registerTexture('bottomLabel', bottomLabelImage);
this.effector.changeResolution(renderWidth, renderHeight);
@@ -218,7 +230,8 @@ export class ImageFrameRenderer {
id: 'a',
params: {
image: 'image',
- label: 'label',
+ topLabel: 'topLabel',
+ bottomLabel: 'bottomLabel',
paddingLeft: paddingLeft / renderWidth,
paddingRight: paddingRight / renderWidth,
paddingTop: paddingTop / renderHeight,