/* * 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[]; // 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; 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 { 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); } }