This commit is contained in:
syuilo 2025-11-02 16:08:12 +09:00
parent 7bb9d3d0af
commit a54b4f2fd9
6 changed files with 167 additions and 77 deletions

8
locales/index.d.ts vendored
View File

@ -5618,6 +5618,14 @@ export interface Locale extends ILocale {
*
*/
"tip": string;
/**
*
*/
"header": string;
/**
*
*/
"footer": string;
/**
*
*/

View File

@ -1401,6 +1401,8 @@ frame: "フレーム"
_imageFrameEditor:
title: "フレームの編集"
tip: "画像にフレームやメタデータを含んだラベルを追加して装飾できます。"
header: "ヘッダー"
footer: "フッター"
borderThickness: "フチの幅"
labelThickness: "ラベルの幅"
labelScale: "ラベルのスケール"

View File

@ -35,29 +35,65 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
</MkRange>
<MkRange v-model="frame.labelThickness" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
<MkRange v-model="frame.labelScale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<div class="_gaps">
<MkRange v-model="frame.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkSwitch v-model="frame.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkRange v-model="frame.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkInput v-model="frame.title">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkSwitch v-model="frame.labelTop.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkTextarea v-model="frame.text">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkInput v-model="frame.labelTop.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkSwitch v-model="frame.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
<MkTextarea v-model="frame.labelTop.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="frame.labelTop.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
<div class="_gaps">
<MkRange v-model="frame.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="frame.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="frame.labelBottom.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="frame.labelBottom.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="frame.labelBottom.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="frame.labelBottom.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkInfo>
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
@ -118,12 +154,22 @@ const props = defineProps<{
const frame = reactive<ImageFrameParams>(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],
});

View File

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

View File

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

View File

@ -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<string, any>[];
// 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<void> {
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<void> {
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,