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; "tip": string;
/**
*
*/
"header": string;
/**
*
*/
"footer": string;
/** /**
* *
*/ */

View File

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

View File

@ -35,29 +35,65 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template> <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
</MkRange> </MkRange>
<MkRange v-model="frame.labelThickness" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> <template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
</MkRange>
<MkRange v-model="frame.labelScale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> <div class="_gaps">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> <MkRange v-model="frame.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
</MkRange> <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkSwitch v-model="frame.centered"> <MkRange v-model="frame.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkSwitch> </MkRange>
<MkInput v-model="frame.title"> <MkSwitch v-model="frame.labelTop.centered">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkInput> </MkSwitch>
<MkTextarea v-model="frame.text"> <MkInput v-model="frame.labelTop.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkTextarea> </MkInput>
<MkSwitch v-model="frame.withQrCode"> <MkTextarea v-model="frame.labelTop.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkSwitch> </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> <MkInfo>
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div> <div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
@ -118,12 +154,22 @@ const props = defineProps<{
const frame = reactive<ImageFrameParams>(deepClone(props.frame) ?? { const frame = reactive<ImageFrameParams>(deepClone(props.frame) ?? {
borderThickness: 0.05, borderThickness: 0.05,
labelThickness: 0.2, labelTop: {
labelScale: 1.0, scale: 1.0,
title: '{year}/{0month}/{0day}', padding: 0.2,
text: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}', textBig: '',
centered: false, textSmall: '',
withQrCode: true, 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], bgColor: [255, 255, 255],
fgColor: [0, 0, 0], fgColor: [0, 0, 0],
}); });

View File

@ -10,7 +10,8 @@ in vec2 in_uv;
uniform sampler2D in_texture; uniform sampler2D in_texture;
uniform vec2 in_resolution; uniform vec2 in_resolution;
uniform sampler2D u_image; uniform sampler2D u_image;
uniform sampler2D u_label; uniform sampler2D u_topLabel;
uniform sampler2D u_bottomLabel;
uniform float u_paddingTop; uniform float u_paddingTop;
uniform float u_paddingBottom; uniform float u_paddingBottom;
uniform float u_paddingLeft; uniform float u_paddingLeft;
@ -27,13 +28,20 @@ void main() {
remap(in_uv.y, u_paddingTop, 1.0 - u_paddingBottom, 0.0, 1.0) 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, in_uv.x,
remap(in_uv.y, 1.0 - u_paddingBottom, 1.0, 0.0, 1.0) remap(in_uv.y, 1.0 - u_paddingBottom, 1.0, 0.0, 1.0)
)); ));
if (in_uv.y > (1.0 - u_paddingBottom)) { if (in_uv.y < u_paddingTop) {
out_color = label_color; out_color = topLabel_color;
} else if (in_uv.y > (1.0 - u_paddingBottom)) {
out_color = bottomLabel_color;
} else { } else {
if (in_uv.y > u_paddingTop && in_uv.x > u_paddingLeft && in_uv.x < (1.0 - u_paddingRight)) { if (in_uv.y > u_paddingTop && in_uv.x > u_paddingLeft && in_uv.x < (1.0 - u_paddingRight)) {
out_color = image_color; out_color = image_color;

View File

@ -10,13 +10,17 @@ export const FX_frame = defineImageEffectorFx({
id: 'frame', id: 'frame',
name: '(internal)', name: '(internal)',
shader, shader,
uniforms: ['image', 'label', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'] as const, uniforms: ['image', 'topLabel', 'bottomLabel', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'] as const,
params: { params: {
image: { image: {
type: 'textureRef', type: 'textureRef',
default: null, default: null,
}, },
label: { topLabel: {
type: 'textureRef',
default: null,
},
bottomLabel: {
type: 'textureRef', type: 'textureRef',
default: null, default: null,
}, },
@ -47,6 +51,8 @@ export const FX_frame = defineImageEffectorFx({
}, },
main: ({ gl, u, params, textures }) => { main: ({ gl, u, params, textures }) => {
const image = textures.image; const image = textures.image;
if (image == null) return;
gl.activeTexture(gl.TEXTURE1); gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, image.texture); gl.bindTexture(gl.TEXTURE_2D, image.texture);
gl.uniform1i(u.image, 1); gl.uniform1i(u.image, 1);
@ -56,11 +62,18 @@ export const FX_frame = defineImageEffectorFx({
gl.uniform1f(u.paddingLeft, params.paddingLeft); gl.uniform1f(u.paddingLeft, params.paddingLeft);
gl.uniform1f(u.paddingRight, params.paddingRight); gl.uniform1f(u.paddingRight, params.paddingRight);
const label = textures.label; const topLabel = textures.topLabel;
if (label) { if (topLabel) {
gl.activeTexture(gl.TEXTURE2); gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, label.texture); gl.bindTexture(gl.TEXTURE_2D, topLabel.texture);
gl.uniform1i(u.label, 2); 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 { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const FXS = [ const FXS = [
FX_frame, FX_frame,
] as const satisfies ImageEffectorFx<string, any>[]; ] as const satisfies ImageEffectorFx<string, any>[];
// TODO: 上部にもラベルを配置できるようにする // TODO: 上部にもラベルを配置できるようにする
export type ImageFrameParams = { type LabelParams = {
borderThickness: number; scale: number;
labelThickness: number; padding: number;
labelScale: number; textBig: string;
title: string; textSmall: string;
text: string;
centered: boolean; centered: boolean;
withQrCode: boolean; withQrCode: boolean;
};
export type ImageFrameParams = {
borderThickness: number;
labelTop: LabelParams;
labelBottom: LabelParams;
bgColor: [r: number, g: number, b: number]; bgColor: [r: number, g: number, b: number];
fgColor: [r: number, g: number, b: number]; fgColor: [r: number, g: number, b: number];
borderRadius: number; // TODO borderRadius: number; // TODO
@ -64,7 +71,7 @@ export class ImageFrameRenderer {
this.effector.registerTexture('image', this.image); this.effector.registerTexture('image', this.image);
} }
private interpolateText(text: string) { private interpolateTemplateText(text: string) {
return text.replaceAll(/\{(\w+)\}/g, (_: string, key: string) => { return text.replaceAll(/\{(\w+)\}/g, (_: string, key: string) => {
const meta_date = this.exif.DateTimeOriginal ? this.exif.DateTimeOriginal.description : '-'; const meta_date = this.exif.DateTimeOriginal ? this.exif.DateTimeOriginal.description : '-';
const date = meta_date.split(' ')[0].replaceAll(':', '/'); const date = meta_date.split(' ')[0].replaceAll(':', '/');
@ -92,33 +99,11 @@ export class ImageFrameRenderer {
}); });
} }
public async updateAndRender(params: ImageFrameParams): Promise<void> { private async renderLabel(renderWidth: number, renderHeight: number, paddingLeft: number, paddingRight: number, imageAreaH: number, params: LabelParams) {
let imageAreaW = this.image.width; const scaleBase = imageAreaH * params.scale;
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;
const labelCanvasCtx = window.document.createElement('canvas').getContext('2d')!; const labelCanvasCtx = window.document.createElement('canvas').getContext('2d')!;
labelCanvasCtx.canvas.width = renderWidth; labelCanvasCtx.canvas.width = renderWidth;
labelCanvasCtx.canvas.height = paddingBottom; labelCanvasCtx.canvas.height = renderHeight;
const scaleBase = imageAreaH * params.labelScale;
const fontSize = scaleBase / 30; const fontSize = scaleBase / 30;
const textsMarginLeft = Math.max(fontSize * 2, paddingLeft); const textsMarginLeft = Math.max(fontSize * 2, paddingLeft);
const textsMarginRight = textsMarginLeft; const textsMarginRight = textsMarginLeft;
@ -133,30 +118,28 @@ export class ImageFrameRenderer {
labelCanvasCtx.font = `bold ${fontSize}px sans-serif`; labelCanvasCtx.font = `bold ${fontSize}px sans-serif`;
labelCanvasCtx.textBaseline = 'middle'; 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) { if (params.centered) {
labelCanvasCtx.textAlign = 'center'; 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 { } else {
labelCanvasCtx.textAlign = 'left'; 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.fillStyle = '#00000088';
labelCanvasCtx.font = `${fontSize * 0.85}px sans-serif`; labelCanvasCtx.font = `${fontSize * 0.85}px sans-serif`;
labelCanvasCtx.textBaseline = 'middle'; 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) { if (params.centered) {
labelCanvasCtx.textAlign = 'center'; 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 { } else {
labelCanvasCtx.textAlign = 'left'; 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) { if (withQrCode) {
try { try {
const qrCodeInstance = new QRCodeStyling({ 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); this.effector.changeResolution(renderWidth, renderHeight);
@ -218,7 +230,8 @@ export class ImageFrameRenderer {
id: 'a', id: 'a',
params: { params: {
image: 'image', image: 'image',
label: 'label', topLabel: 'topLabel',
bottomLabel: 'bottomLabel',
paddingLeft: paddingLeft / renderWidth, paddingLeft: paddingLeft / renderWidth,
paddingRight: paddingRight / renderWidth, paddingRight: paddingRight / renderWidth,
paddingTop: paddingTop / renderHeight, paddingTop: paddingTop / renderHeight,