259 lines
9.3 KiB
TypeScript
259 lines
9.3 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import QRCodeStyling from 'qr-code-styling';
|
|
import { url } from '@@/js/config.js';
|
|
import ExifReader from 'exifreader';
|
|
import { FX_frame } from './image-effector/fxs/frame.js';
|
|
import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
|
|
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: 上部にもラベルを配置できるようにする
|
|
|
|
type LabelParams = {
|
|
enabled: boolean;
|
|
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
|
|
};
|
|
|
|
export type ImageFramePreset = {
|
|
id: string;
|
|
name: string;
|
|
params: ImageFrameParams;
|
|
};
|
|
|
|
export class ImageFrameRenderer {
|
|
private effector: ImageEffector<typeof FXS>;
|
|
private image: HTMLImageElement | ImageBitmap;
|
|
private exif: ExifReader.Tags;
|
|
private renderAsPreview = false;
|
|
|
|
constructor(options: {
|
|
canvas: HTMLCanvasElement,
|
|
image: HTMLImageElement | ImageBitmap,
|
|
exif: ExifReader.Tags,
|
|
renderAsPreview?: boolean,
|
|
}) {
|
|
this.image = options.image;
|
|
this.exif = options.exif;
|
|
this.renderAsPreview = options.renderAsPreview ?? false;
|
|
console.log(this.exif);
|
|
|
|
this.effector = new ImageEffector({
|
|
canvas: options.canvas,
|
|
renderWidth: 1,
|
|
renderHeight: 1,
|
|
image: null,
|
|
fxs: FXS,
|
|
});
|
|
|
|
this.effector.registerTexture('image', this.image);
|
|
}
|
|
|
|
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(':', '/');
|
|
switch (key) {
|
|
case 'date': return date;
|
|
case 'year': return date.split('/')[0];
|
|
case 'month': return date.split('/')[1].replace(/^0/, '');
|
|
case 'day': return date.split('/')[2].replace(/^0/, '');
|
|
case 'hour': return meta_date.split(' ')[1].split(':')[0].replace(/^0/, '');
|
|
case 'minute': return meta_date.split(' ')[1].split(':')[1].replace(/^0/, '');
|
|
case 'second': return meta_date.split(' ')[1].split(':')[2].replace(/^0/, '');
|
|
case '0month': return date.split('/')[1];
|
|
case '0day': return date.split('/')[2];
|
|
case '0hour': return meta_date.split(' ')[1].split(':')[0];
|
|
case '0minute': return meta_date.split(' ')[1].split(':')[1];
|
|
case '0second': return meta_date.split(' ')[1].split(':')[2];
|
|
case 'camera_model': return this.exif.Model ? this.exif.Model.description : '-';
|
|
case 'camera_lens_model': return this.exif.LensModel ? this.exif.LensModel.description : '-';
|
|
case 'camera_mm': return this.exif.FocalLength ? this.exif.FocalLength.description.replace(' mm', '').replace('mm', '') : '-';
|
|
case 'camera_f': return this.exif.FNumber ? this.exif.FNumber.description.replace('f/', '') : '-';
|
|
case 'camera_s': return this.exif.ExposureTime ? this.exif.ExposureTime.description : '-';
|
|
case 'camera_iso': return this.exif.ISOSpeedRatings ? this.exif.ISOSpeedRatings.description : '-';
|
|
default: return '-';
|
|
}
|
|
});
|
|
}
|
|
|
|
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 = renderHeight;
|
|
const fontSize = scaleBase / 30;
|
|
const textsMarginLeft = Math.max(fontSize * 2, paddingLeft);
|
|
const textsMarginRight = textsMarginLeft;
|
|
const withQrCode = params.withQrCode;
|
|
const qrSize = scaleBase * 0.1;
|
|
const qrMarginRight = Math.max((labelCanvasCtx.canvas.height - qrSize) / 2, paddingRight);
|
|
|
|
labelCanvasCtx.fillStyle = '#ffffff';
|
|
labelCanvasCtx.fillRect(0, 0, labelCanvasCtx.canvas.width, labelCanvasCtx.canvas.height);
|
|
|
|
labelCanvasCtx.fillStyle = '#000000';
|
|
labelCanvasCtx.font = `bold ${fontSize}px sans-serif`;
|
|
labelCanvasCtx.textBaseline = 'middle';
|
|
|
|
const titleY = params.textSmall === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) - (fontSize * 0.9);
|
|
if (params.centered) {
|
|
labelCanvasCtx.textAlign = 'center';
|
|
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textBig), labelCanvasCtx.canvas.width / 2, titleY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
|
|
} else {
|
|
labelCanvasCtx.textAlign = 'left';
|
|
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.textBig === '' ? (labelCanvasCtx.canvas.height / 2) : (labelCanvasCtx.canvas.height / 2) + (fontSize * 0.9);
|
|
if (params.centered) {
|
|
labelCanvasCtx.textAlign = 'center';
|
|
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), labelCanvasCtx.canvas.width / 2, textY, labelCanvasCtx.canvas.width - textsMarginLeft - textsMarginRight);
|
|
} else {
|
|
labelCanvasCtx.textAlign = 'left';
|
|
labelCanvasCtx.fillText(this.interpolateTemplateText(params.textSmall), textsMarginLeft, textY, labelCanvasCtx.canvas.width - textsMarginLeft - (withQrCode ? (qrSize + qrMarginRight + (fontSize * 1)) : textsMarginRight));
|
|
}
|
|
|
|
if (withQrCode) {
|
|
try {
|
|
const qrCodeInstance = new QRCodeStyling({
|
|
width: labelCanvasCtx.canvas.height,
|
|
height: labelCanvasCtx.canvas.height,
|
|
margin: 0,
|
|
type: 'canvas',
|
|
data: `${url}/users/${$i.id}`,
|
|
//image: $i.avatarUrl,
|
|
qrOptions: {
|
|
typeNumber: 0,
|
|
mode: 'Byte',
|
|
errorCorrectionLevel: 'H',
|
|
},
|
|
imageOptions: {
|
|
hideBackgroundDots: true,
|
|
imageSize: 0.3,
|
|
margin: 16,
|
|
crossOrigin: 'anonymous',
|
|
},
|
|
dotsOptions: {
|
|
type: 'dots',
|
|
roundSize: false,
|
|
},
|
|
cornersDotOptions: {
|
|
type: 'dot',
|
|
},
|
|
cornersSquareOptions: {
|
|
type: 'extra-rounded',
|
|
},
|
|
});
|
|
|
|
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
|
|
if (blob == null) throw new Error('Failed to generate QR code');
|
|
|
|
const qrImageBitmap = await window.createImageBitmap(blob);
|
|
|
|
labelCanvasCtx.drawImage(
|
|
qrImageBitmap,
|
|
labelCanvasCtx.canvas.width - qrSize - qrMarginRight,
|
|
(labelCanvasCtx.canvas.height - qrSize) / 2,
|
|
qrSize,
|
|
qrSize,
|
|
);
|
|
qrImageBitmap.close();
|
|
} catch (err) {
|
|
// nop
|
|
}
|
|
}
|
|
|
|
return labelCanvasCtx.getImageData(0, 0, labelCanvasCtx.canvas.width, labelCanvasCtx.canvas.height); ;
|
|
}
|
|
|
|
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 = params.labelTop.enabled ? Math.floor(imageAreaH * params.labelTop.padding) : Math.floor(imageAreaH * params.borderThickness);
|
|
const paddingBottom = params.labelBottom.enabled ? Math.floor(imageAreaH * params.labelBottom.padding) : Math.floor(imageAreaH * params.borderThickness);
|
|
const renderWidth = imageAreaW + paddingLeft + paddingRight;
|
|
const renderHeight = imageAreaH + paddingTop + paddingBottom;
|
|
|
|
if (params.labelTop.enabled) {
|
|
const topLabelImage = await this.renderLabel(renderWidth, paddingTop, paddingLeft, paddingRight, imageAreaH, params.labelTop);
|
|
this.effector.registerTexture('topLabel', topLabelImage);
|
|
}
|
|
|
|
if (params.labelBottom.enabled) {
|
|
const bottomLabelImage = await this.renderLabel(renderWidth, paddingBottom, paddingLeft, paddingRight, imageAreaH, params.labelBottom);
|
|
this.effector.registerTexture('bottomLabel', bottomLabelImage);
|
|
}
|
|
|
|
this.effector.changeResolution(renderWidth, renderHeight);
|
|
|
|
await this.effector.setLayersAndRender([{
|
|
fxId: 'frame',
|
|
id: 'a',
|
|
params: {
|
|
image: 'image',
|
|
topLabel: 'topLabel',
|
|
bottomLabel: 'bottomLabel',
|
|
topLabelEnabled: params.labelTop.enabled,
|
|
bottomLabelEnabled: params.labelBottom.enabled,
|
|
paddingLeft: paddingLeft / renderWidth,
|
|
paddingRight: paddingRight / renderWidth,
|
|
paddingTop: paddingTop / renderHeight,
|
|
paddingBottom: paddingBottom / renderHeight,
|
|
},
|
|
}]);
|
|
}
|
|
|
|
public render(): void {
|
|
this.effector.render();
|
|
}
|
|
|
|
/*
|
|
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
|
*/
|
|
public destroy(disposeCanvas = true): void {
|
|
this.effector.destroy(disposeCanvas);
|
|
}
|
|
}
|