This commit is contained in:
syuilo 2025-10-30 17:54:45 +09:00
parent a892bbcce5
commit 98774208a2
15 changed files with 878 additions and 45 deletions

30
locales/index.d.ts vendored
View File

@ -5605,6 +5605,36 @@ export interface Locale extends ILocale {
* *
*/ */
"deviceInfoDescription": string; "deviceInfoDescription": string;
"_imageLabelEditor": {
/**
*
*/
"title": string;
/**
*
*/
"frameThickness": string;
/**
*
*/
"centered": string;
/**
* ()
*/
"captionMain": string;
/**
* ()
*/
"captionSub": string;
/**
*
*/
"availableVariables": string;
/**
*
*/
"withQrCode": string;
};
"_compression": { "_compression": {
"_quality": { "_quality": {
/** /**

View File

@ -1397,6 +1397,15 @@ widgets: "ウィジェット"
deviceInfo: "デバイス情報" deviceInfo: "デバイス情報"
deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。" deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。"
_imageLabelEditor:
title: "ラベルの編集"
frameThickness: "フレーム"
centered: "中央揃え"
captionMain: "キャプション(大)"
captionSub: "キャプション(小)"
availableVariables: "利用可能な変数"
withQrCode: "二次元コード"
_compression: _compression:
_quality: _quality:
high: "高品質" high: "高品質"

View File

@ -48,6 +48,7 @@
"estree-walker": "3.0.3", "estree-walker": "3.0.3",
"eventemitter3": "5.0.1", "eventemitter3": "5.0.1",
"execa": "9.6.0", "execa": "9.6.0",
"exifreader": "4.32.0",
"frontend-shared": "workspace:*", "frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*", "icons-subsetter": "workspace:*",
"idb-keyval": "6.2.2", "idb-keyval": "6.2.2",

View File

@ -314,10 +314,16 @@ onUnmounted(() => {
.embedCodeGenPreviewRoot { .embedCodeGenPreviewRoot {
position: relative; position: relative;
background-color: var(--MI_THEME-bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
cursor: not-allowed; cursor: not-allowed;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
} }
.embedCodeGenPreviewWrapper { .embedCodeGenPreviewWrapper {

View File

@ -155,8 +155,8 @@ onMounted(async () => {
if (w > MAX_W || h > MAX_H) { if (w > MAX_W || h > MAX_H) {
const scale = Math.min(MAX_W / w, MAX_H / h); const scale = Math.min(MAX_W / w, MAX_H / h);
w *= scale; w = Math.floor(w * scale);
h *= scale; h = Math.floor(h * scale);
} }
renderer = new ImageEffector({ renderer = new ImageEffector({
@ -373,8 +373,14 @@ function onImagePointerdown(ev: PointerEvent) {
.preview { .preview {
position: relative; position: relative;
background-color: var(--MI_THEME-bg); background-color: var(--MI_THEME-bg);
background-size: auto auto; background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); background-size: 20px 20px;
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
} }
.previewContainer { .previewContainer {

View File

@ -0,0 +1,330 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="1000"
:height="600"
:scroll="false"
:withOkButton="true"
@close="cancel()"
@ok="save()"
@closed="emit('closed')"
>
<template #header><i class="ti ti-photo"></i> {{ i18n.ts._imageLabelEditor.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="$style.preview">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
</div>
</div>
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<MkRange v-model="frame.frameThickness" :min="0" :max="0.1" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageLabelEditor.frameThickness }}</template>
</MkRange>
<MkSwitch v-model="frame.centered">
<template #label>{{ i18n.ts._imageLabelEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="frame.title">
<template #label>{{ i18n.ts._imageLabelEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="frame.text">
<template #label>{{ i18n.ts._imageLabelEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="frame.withQrCode">
<template #label>{{ i18n.ts._imageLabelEditor.withQrCode }}</template>
</MkSwitch>
<MkInfo>
<div>{{ i18n.ts._imageLabelEditor.availableVariables }}:</div>
<div><code class="_selectableAtomic">{date}</code> - 撮影日時</div>
<div><code class="_selectableAtomic">{model}</code> - カメラモデル</div>
<div><code class="_selectableAtomic">{lensModel}</code> - レンズモデル</div>
<div><code class="_selectableAtomic">{mm}</code> - 焦点距離 (: 50)</div>
<div><code class="_selectableAtomic">{f}</code> - 絞り値 (: 1.8)</div>
<div><code class="_selectableAtomic">{s}</code> - シャッタースピード (: 1/125)</div>
<div><code class="_selectableAtomic">{iso}</code> - ISO感度 (: 100)</div>
</MkInfo>
</div>
</div>
</div>
</div>
</MkModalWindow>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
import ExifReader from 'exifreader';
import type { ImageLabelParams } from '@/utility/image-label-renderer.js';
import { ImageLabelRenderer } from '@/utility/image-label-renderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue';
import XLayer from '@/components/MkWatermarkEditorDialog.Layer.vue';
import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { ensureSignin } from '@/i.js';
import { genId } from '@/utility/id.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
const $i = ensureSignin();
const EXIF_MOCK = {
DateTimeOriginal: { description: '2025:01:01 12:00:00' },
Model: { description: 'Example camera' },
LensModel: { description: 'Example lens 123mm f/1.23' },
FocalLength: { description: '123mm' },
ExposureTime: { description: '1/234' },
FNumber: { description: '1.23' },
ISOSpeedRatings: { description: '123' },
} satisfies ExifReader.Tags;
const props = defineProps<{
frame?: ImageLabelParams | null;
image?: File | null;
}>();
const frame = reactive<ImageLabelParams>(deepClone(props.frame) ?? {
style: 'frame',
frameThickness: 0.05,
title: 'Untitled by @syuilo',
text: '{mm}mm f/{f} {s}s ISO{iso}',
centered: false,
withQrCode: true,
});
const emit = defineEmits<{
(ev: 'ok', frame: ImageLabelParams): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
async function cancel() {
dialog.value?.close();
}
watch(frame, async (newValue, oldValue) => {
if (renderer != null) {
renderer.update(frame);
}
}, { deep: true });
const canvasEl = useTemplateRef('canvasEl');
const sampleImage_3_2 = new Image();
sampleImage_3_2.src = '/client-assets/sample/3-2.jpg';
const sampleImage_3_2_loading = new Promise<void>(resolve => {
sampleImage_3_2.onload = () => resolve();
});
const sampleImage_2_3 = new Image();
sampleImage_2_3.src = '/client-assets/sample/2-3.jpg';
const sampleImage_2_3_loading = new Promise<void>(resolve => {
sampleImage_2_3.onload = () => resolve();
});
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
watch(sampleImageType, async () => {
if (renderer != null) {
renderer.destroy(false);
renderer = null;
initRenderer();
}
});
let renderer: ImageLabelRenderer | null = null;
let imageBitmap: ImageBitmap | null = null;
async function initRenderer() {
if (canvasEl.value == null) return;
if (sampleImageType.value === '3_2') {
renderer = new ImageLabelRenderer({
canvas: canvasEl.value,
image: sampleImage_3_2,
exif: EXIF_MOCK,
renderAsPreview: true,
});
} else if (sampleImageType.value === '2_3') {
renderer = new ImageLabelRenderer({
canvas: canvasEl.value,
image: sampleImage_2_3,
exif: EXIF_MOCK,
renderAsPreview: true,
});
} else if (props.image != null) {
imageBitmap = await window.createImageBitmap(props.image);
renderer = new ImageLabelRenderer({
canvas: canvasEl.value,
image: imageBitmap,
exif: EXIF_MOCK,
renderAsPreview: true,
});
}
await renderer!.update(frame);
renderer!.render();
}
onMounted(async () => {
const closeWaiting = os.waiting();
await nextTick(); // waiting
await sampleImage_3_2_loading;
await sampleImage_2_3_loading;
await initRenderer();
closeWaiting();
});
onUnmounted(() => {
if (renderer != null) {
renderer.destroy();
renderer = null;
}
if (imageBitmap != null) {
imageBitmap.close();
imageBitmap = null;
}
});
async function save() {
const { canceled, result: name } = await os.inputText({
title: i18n.ts.name,
default: preset.name,
});
if (canceled) return;
preset.name = name || '';
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
}
emit('ok', preset);
}
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {
display: flex;
flex-direction: column;
height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.previewTitle {
position: absolute;
z-index: 100;
top: 8px;
left: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 85%;
}
.previewControls {
position: absolute;
z-index: 100;
bottom: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
}
.previewControlsButton {
&.active {
color: var(--MI_THEME-accent);
}
}
.previewSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.previewCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
object-fit: contain;
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -249,8 +249,8 @@ async function initRenderer() {
if (w > MAX_W || h > MAX_H) { if (w > MAX_W || h > MAX_H) {
const scale = Math.min(MAX_W / w, MAX_H / h); const scale = Math.min(MAX_W / w, MAX_H / h);
w *= scale; w = Math.floor(w * scale);
h *= scale; h = Math.floor(h * scale);
} }
renderer = new WatermarkRenderer({ renderer = new WatermarkRenderer({
@ -380,8 +380,14 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
.preview { .preview {
position: relative; position: relative;
background-color: var(--MI_THEME-bg); background-color: var(--MI_THEME-bg);
background-size: auto auto; background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); background-size: 20px 20px;
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
} }
.previewContainer { .previewContainer {

View File

@ -8,6 +8,7 @@ import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
import isAnimated from 'is-file-animated'; import isAnimated from 'is-file-animated';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue'; import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue';
import ExifReader from 'exifreader';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import { genId } from '@/utility/id.js'; import { genId } from '@/utility/id.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -17,6 +18,7 @@ import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
import { WatermarkRenderer } from '@/utility/watermark.js'; import { WatermarkRenderer } from '@/utility/watermark.js';
import { ImageLabelRenderer } from '@/utility/image-label-renderer.js';
export type UploaderFeatures = { export type UploaderFeatures = {
imageEditing?: boolean; imageEditing?: boolean;
@ -571,6 +573,36 @@ export function useUploader(options: {
}); });
} }
const canvas = window.document.createElement('canvas');
const exif = await ExifReader.load(await item.file.arrayBuffer());
const labelRenderer = new ImageLabelRenderer({
canvas: canvas,
image: await window.createImageBitmap(preprocessedFile),
exif,
});
//await labelRenderer.update({
// title: `${meta_model} + ${meta_lensModel}`,
// text: `${date} ${meta_mm}mm f/${meta_f} ${meta_s}s ISO${meta_iso}`,
//});
await labelRenderer.update({
title: 'aaaaaaaaaaaaa',
text: 'bbbbbbbbbbbbbbbbbbbb',
});
labelRenderer.render();
preprocessedFile = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob == null) {
throw new Error('Failed to convert canvas to blob');
}
resolve(blob);
labelRenderer.destroy();
}, 'image/png');
});
const compressionSettings = getCompressionSettings(item.compressionLevel); const compressionSettings = getCompressionSettings(item.compressionLevel);
const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile)); const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile));

View File

@ -124,6 +124,49 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder> </MkFolder>
</SearchMarker> </SearchMarker>
<SearchMarker :keywords="['label', 'frame', 'credit', 'metadata']">
<MkFolder>
<template #icon><i class="ti ti-photo"></i></template>
<template #label><SearchLabel>{{ i18n.ts.label }}</SearchLabel></template>
<div class="_gaps">
<div class="_gaps_s">
<!--
<XWatermarkItem
v-for="(preset, i) in prefer.r.watermarkPresets.value"
:key="preset.id"
:preset="preset"
@updatePreset="onUpdateWatermarkPreset(preset.id, $event)"
@del="onDeleteWatermarkPreset(preset.id)"
/>
-->
<MkButton iconOnly rounded style="margin: 0 auto;" @click="addImageLabelPreset"><i class="ti ti-plus"></i></MkButton>
<!--
<SearchMarker :keywords="['sync', 'watermark', 'preset', 'devices']">
<MkSwitch :modelValue="watermarkPresetsSyncEnabled" @update:modelValue="changeWatermarkPresetsSyncEnabled">
<template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts.syncBetweenDevices }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
-->
</div>
<hr>
<!--
<SearchMarker :keywords="['default', 'label', 'preset']">
<MkPreferenceContainer k="defaultWatermarkPresetId">
<MkSelect v-model="defaultWatermarkPresetId" :items="[{ label: i18n.ts.none, value: null }, ...prefer.r.watermarkPresets.value.map(p => ({ label: p.name || i18n.ts.noName, value: p.id }))]">
<template #label><SearchLabel>{{ i18n.ts.defaultPreset }}</SearchLabel></template>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
-->
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['default', 'image', 'compression']"> <SearchMarker :keywords="['default', 'image', 'compression']">
<MkPreferenceContainer k="defaultImageCompressionLevel"> <MkPreferenceContainer k="defaultImageCompressionLevel">
<MkSelect <MkSelect
@ -299,6 +342,16 @@ function onDeleteWatermarkPreset(id: string) {
} }
} }
async function addImageLabelPreset() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageLabelEditorDialog.vue').then(x => x.default), {
}, {
ok: (preset: any) => {
//prefer.commit('imageLabelPresets', [...prefer.s.imageLabelPresets, preset]);
},
closed: () => dispose(),
});
}
function saveProfile() { function saveProfile() {
misskeyApi('i/update', { misskeyApi('i/update', {
alwaysMarkNsfw: !!alwaysMarkNsfw.value, alwaysMarkNsfw: !!alwaysMarkNsfw.value,

View File

@ -6,7 +6,7 @@
import QRCodeStyling from 'qr-code-styling'; import QRCodeStyling from 'qr-code-styling';
import { url, host } from '@@/js/config.js'; import { url, host } from '@@/js/config.js';
import { getProxiedImageUrl } from '../media-proxy.js'; import { getProxiedImageUrl } from '../media-proxy.js';
import { initShaderProgram } from '../webgl.js'; import { createTexture, initShaderProgram } from '../webgl.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
export type ImageEffectorRGB = [r: number, g: number, b: number]; export type ImageEffectorRGB = [r: number, g: number, b: number];
@ -71,12 +71,17 @@ interface TextureParamDef extends CommonParamDef {
} | null; } | null;
}; };
interface TextureRefParamDef extends CommonParamDef {
type: 'textureRef';
default: string;
};
interface ColorParamDef extends CommonParamDef { interface ColorParamDef extends CommonParamDef {
type: 'color'; type: 'color';
default: ImageEffectorRGB; default: ImageEffectorRGB;
}; };
type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | TextureParamDef | ColorParamDef; type ImageEffectorFxParamDef = NumberParamDef | NumberEnumParamDef | BooleanParamDef | AlignParamDef | SeedParamDef | TextureParamDef | TextureRefParamDef | ColorParamDef;
export type ImageEffectorFxParamDefs = Record<string, ImageEffectorFxParamDef>; export type ImageEffectorFxParamDefs = Record<string, ImageEffectorFxParamDef>;
@ -129,27 +134,26 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
private canvas: HTMLCanvasElement | null = null; private canvas: HTMLCanvasElement | null = null;
private renderWidth: number; private renderWidth: number;
private renderHeight: number; private renderHeight: number;
private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
private layers: ImageEffectorLayer[] = []; private layers: ImageEffectorLayer[] = [];
private originalImageTexture: WebGLTexture; private baseTexture: WebGLTexture;
private shaderCache: Map<string, WebGLProgram> = new Map(); private shaderCache: Map<string, WebGLProgram> = new Map();
private perLayerResultTextures: Map<string, WebGLTexture> = new Map(); private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map(); private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
private nopProgram: WebGLProgram; private nopProgram: WebGLProgram;
private fxs: [...IEX]; private fxs: [...IEX];
private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map(); private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
private registeredTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
constructor(options: { constructor(options: {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
renderWidth: number; renderWidth: number;
renderHeight: number; renderHeight: number;
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement; image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null;
fxs: [...IEX]; fxs: [...IEX];
}) { }) {
this.canvas = options.canvas; this.canvas = options.canvas;
this.renderWidth = options.renderWidth; this.renderWidth = options.renderWidth;
this.renderHeight = options.renderHeight; this.renderHeight = options.renderHeight;
this.originalImage = options.image;
this.fxs = options.fxs; this.fxs = options.fxs;
this.canvas.width = this.renderWidth; this.canvas.width = this.renderWidth;
@ -161,9 +165,7 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
premultipliedAlpha: false, premultipliedAlpha: false,
}); });
if (gl == null) { if (gl == null) throw new Error('Failed to initialize WebGL2 context');
throw new Error('Failed to initialize WebGL2 context');
}
this.gl = gl; this.gl = gl;
@ -174,11 +176,16 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
this.originalImageTexture = createTexture(gl); if (options.image != null) {
gl.activeTexture(gl.TEXTURE0); this.baseTexture = createTexture(gl);
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture); gl.activeTexture(gl.TEXTURE0);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.originalImage.width, this.originalImage.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage); gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
gl.bindTexture(gl.TEXTURE_2D, null); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, options.image.width, options.image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, options.image);
gl.bindTexture(gl.TEXTURE_2D, null);
} else {
this.baseTexture = createTexture(gl);
gl.activeTexture(gl.TEXTURE0);
}
this.nopProgram = initShaderProgram(this.gl, `#version 300 es this.nopProgram = initShaderProgram(this.gl, `#version 300 es
in vec2 position; in vec2 position;
@ -254,11 +261,19 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
height: this.renderHeight, height: this.renderHeight,
textures: Object.fromEntries( textures: Object.fromEntries(
Object.entries(fx.params as ImageEffectorFxParamDefs).map(([k, v]) => { Object.entries(fx.params as ImageEffectorFxParamDefs).map(([k, v]) => {
if (v.type !== 'texture') return [k, null]; if (v.type === 'textureRef') {
const param = getValue<typeof v.type>(layer.params, k); const param = getValue<typeof v.type>(layer.params, k);
if (param == null) return [k, null]; if (param == null) return [k, null];
const texture = this.paramTextures.get(this.getTextureKeyForParam(param)) ?? null; const texture = this.registeredTextures.get(param) ?? null;
return [k, texture]; return [k, texture];
} else if (v.type === 'texture') {
const param = getValue<typeof v.type>(layer.params, k);
if (param == null) return [k, null];
const texture = this.paramTextures.get(this.getTextureKeyForParam(param)) ?? null;
return [k, texture];
} else {
return [k, null];
}
})), })),
}); });
@ -271,7 +286,7 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
// 入力をそのまま出力 // 入力をそのまま出力
if (this.layers.length === 0) { if (this.layers.length === 0) {
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture); gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
gl.useProgram(this.nopProgram); gl.useProgram(this.nopProgram);
gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0); gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0);
@ -280,7 +295,7 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
return; return;
} }
let preTexture = this.originalImageTexture; let preTexture = this.baseTexture;
for (const layer of this.layers) { for (const layer of this.layers) {
const isLast = layer === this.layers.at(-1); const isLast = layer === this.layers.at(-1);
@ -322,7 +337,7 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
if (fx == null) continue; if (fx == null) continue;
for (const k of Object.keys(layer.params)) { for (const k of Object.keys(layer.params)) {
const paramDef = fx.params[k]; const paramDef = (fx.params as ImageEffectorFxParamDefs)[k];
if (paramDef == null) continue; if (paramDef == null) continue;
if (paramDef.type !== 'texture') continue; if (paramDef.type !== 'texture') continue;
const v = getValue<typeof paramDef.type>(layer.params, k); const v = getValue<typeof paramDef.type>(layer.params, k);
@ -354,6 +369,28 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
this.render(); this.render();
} }
public registerTexture(key: string, image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement) {
const gl = this.gl;
if (this.registeredTextures.has(key)) {
const existing = this.registeredTextures.get(key)!;
gl.deleteTexture(existing.texture);
this.registeredTextures.delete(key);
}
const texture = createTexture(gl);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindTexture(gl.TEXTURE_2D, null);
this.registeredTextures.set(key, {
texture: texture,
width: image.width,
height: image.height,
});
}
public changeResolution(width: number, height: number) { public changeResolution(width: number, height: number) {
this.renderWidth = width; this.renderWidth = width;
this.renderHeight = height; this.renderHeight = height;
@ -400,7 +437,12 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
} }
this.paramTextures.clear(); this.paramTextures.clear();
this.gl.deleteTexture(this.originalImageTexture); for (const texture of this.registeredTextures.values()) {
this.gl.deleteTexture(texture.texture);
}
this.registeredTextures.clear();
this.gl.deleteTexture(this.baseTexture);
if (disposeCanvas) { if (disposeCanvas) {
const loseContextExt = this.gl.getExtension('WEBGL_lose_context'); const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
@ -409,17 +451,6 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
} }
} }
function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.bindTexture(gl.TEXTURE_2D, null);
return texture;
}
async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string | null): Promise<{ texture: WebGLTexture, width: number, height: number } | null> { async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string | null): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
if (imageUrl == null || imageUrl.trim() === '') return null; if (imageUrl == null || imageUrl.trim() === '') return null;

View File

@ -0,0 +1,33 @@
#version 300 es
precision mediump float;
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
in vec2 in_uv;
uniform sampler2D in_texture;
uniform vec2 in_resolution;
uniform sampler2D u_image;
uniform sampler2D u_label;
uniform vec2 u_labelResolution;
uniform bool u_labelEnabled;
uniform float u_imageMarginX;
uniform float u_imageMarginY;
out vec4 out_color;
void main() {
float labelRatio = u_labelEnabled ? (u_labelResolution.y / in_resolution.y) : 0.0;
vec4 image_color = texture(u_image, (in_uv / vec2(1.0, 1.0 - labelRatio) / vec2(1.0 - u_imageMarginX - u_imageMarginX, 1.0 - u_imageMarginY)) - vec2(u_imageMarginX, u_imageMarginY));
vec4 label_color = texture(u_label, (in_uv - vec2(0.0, 1.0 - labelRatio)) / vec2(1.0, labelRatio));
if (in_uv.y > (1.0 - labelRatio)) {
out_color = label_color;
} else {
if (in_uv.x < u_imageMarginX || in_uv.x > (1.0 - u_imageMarginX) || in_uv.y < u_imageMarginY) {
out_color = vec4(1.0, 1.0, 1.0, 1.0);
} else {
out_color = image_color;
}
}
}

View File

@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import shader from './label.glsl';
export const FX_label = defineImageEffectorFx({
id: 'label',
name: '(internal)',
shader,
uniforms: ['image', 'label', 'labelResolution', 'labelEnabled', 'imageMarginX', 'imageMarginY'] as const,
params: {
image: {
type: 'textureRef',
default: null,
},
label: {
type: 'textureRef',
default: null,
},
imageMarginX: {
type: 'number',
default: 0.05,
max: 1,
min: 0,
},
imageMarginY: {
type: 'number',
default: 0.05,
max: 1,
min: 0,
},
},
main: ({ gl, u, params, textures }) => {
const image = textures.image;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, image.texture);
gl.uniform1i(u.image, 1);
gl.uniform1f(u.imageMarginX, params.imageMarginX);
gl.uniform1f(u.imageMarginY, params.imageMarginY);
const label = textures.label;
if (label) {
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, label.texture);
gl.uniform1i(u.label, 2);
gl.uniform2f(u.labelResolution, label.width, label.height);
gl.uniform1i(u.labelEnabled, 1);
} else {
gl.uniform1i(u.labelEnabled, 0);
}
},
});

View File

@ -0,0 +1,210 @@
/*
* 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_label } from './image-effector/fxs/label.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 FXS = [
FX_label,
] as const satisfies ImageEffectorFx<string, any>[];
export type ImageLabelParams = {
style: 'frame' | 'frameLess';
frameThickness: number;
title: string;
text: string;
centered: boolean;
withQrCode: boolean;
};
export class ImageLabelRenderer {
private effector: ImageEffector<typeof FXS>;
private renderWidth: number;
private renderHeight: number;
private image: HTMLImageElement | ImageBitmap;
private paddingBottom = 0;
private exif: ExifReader.Tags;
constructor(options: {
canvas: HTMLCanvasElement,
image: HTMLImageElement | ImageBitmap,
exif: ExifReader.Tags,
renderAsPreview?: boolean,
}) {
this.image = options.image;
this.exif = options.exif;
console.log(this.exif);
let w = this.image.width;
let h = this.image.height;
if (options.renderAsPreview) {
const MAX_W = 1000;
const MAX_H = 1000;
if (w > MAX_W || h > MAX_H) {
const scale = Math.min(MAX_W / w, MAX_H / h);
w = Math.floor(w * scale);
h = Math.floor(h * scale);
}
}
this.paddingBottom = Math.floor(h * 0.2);
this.renderWidth = w;
this.renderHeight = h + this.paddingBottom;
this.effector = new ImageEffector({
canvas: options.canvas,
renderWidth: this.renderWidth,
renderHeight: this.renderHeight,
image: null,
fxs: FXS,
});
this.effector.registerTexture('image', this.image);
}
private interpolateText(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 'model': return this.exif.Model ? this.exif.Model.description : '-';
case 'lensModel': return this.exif.LensModel ? this.exif.LensModel.description : '-';
case 'mm': return this.exif.FocalLength ? this.exif.FocalLength.description.replace(' mm', '').replace('mm', '') : '-';
case 'f': return this.exif.FNumber ? this.exif.FNumber.description.replace('f/', '') : '-';
case 's': return this.exif.ExposureTime ? this.exif.ExposureTime.description : '-';
case 'iso': return this.exif.ISOSpeedRatings ? this.exif.ISOSpeedRatings.description : '-';
default: return '-';
}
});
}
public async update(params: ImageLabelParams): Promise<void> {
const aspectRatio = this.renderWidth / this.renderHeight;
const ctx = window.document.createElement('canvas').getContext('2d')!;
ctx.canvas.width = this.renderWidth;
ctx.canvas.height = this.paddingBottom;
const fontSize = ctx.canvas.height / 6;
const marginX = Math.max(fontSize * 2, (ctx.canvas.width * params.frameThickness) / aspectRatio);
const withQrCode = params.withQrCode;
const qrSize = ctx.canvas.height * 0.6;
const qrMarginX = Math.max((ctx.canvas.height - qrSize) / 2, (ctx.canvas.width * params.frameThickness) / aspectRatio);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = '#000000';
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textBaseline = 'middle';
const titleY = params.text === '' ? (ctx.canvas.height / 2) : (ctx.canvas.height / 2) - (fontSize * 0.9);
if (params.centered) {
ctx.textAlign = 'center';
ctx.fillText(this.interpolateText(params.title), ctx.canvas.width / 2, titleY, ctx.canvas.width - marginX - marginX);
} else {
ctx.textAlign = 'left';
ctx.fillText(this.interpolateText(params.title), marginX, titleY, ctx.canvas.width - marginX - (withQrCode ? (qrSize + qrMarginX + (fontSize * 1)) : 0));
}
ctx.fillStyle = '#00000088';
ctx.font = `${fontSize * 0.85}px sans-serif`;
ctx.textBaseline = 'middle';
const textY = params.title === '' ? (ctx.canvas.height / 2) : (ctx.canvas.height / 2) + (fontSize * 0.9);
if (params.centered) {
ctx.textAlign = 'center';
ctx.fillText(this.interpolateText(params.text), ctx.canvas.width / 2, textY, ctx.canvas.width - marginX - marginX);
} else {
ctx.textAlign = 'left';
ctx.fillText(this.interpolateText(params.text), marginX, textY, ctx.canvas.width - marginX - (withQrCode ? (qrSize + qrMarginX + (fontSize * 1)) : 0));
}
const $i = ensureSignin();
if (withQrCode) {
const qrCodeInstance = new QRCodeStyling({
width: ctx.canvas.height,
height: ctx.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);
ctx.drawImage(
qrImageBitmap,
ctx.canvas.width - qrSize - qrMarginX,
(ctx.canvas.height - qrSize) / 2,
qrSize,
qrSize,
);
qrImageBitmap.close();
}
const data = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
const padding = params.frameThickness;
const paddingX = padding / aspectRatio;
const paddingY = padding;
await this.effector.registerTexture('label', data);
await this.effector.setLayers([{
fxId: 'label',
id: 'a',
params: {
image: 'image',
label: 'label',
imageMarginX: paddingX,
imageMarginY: paddingY,
},
}]);
this.render();
}
public render(): void {
this.effector.render();
}
/*
* disposeCanvas = true loseContextを呼ぶためcanvasも再利用不可になるので注意
*/
public destroy(disposeCanvas = true): void {
this.effector.destroy(disposeCanvas);
}
}

View File

@ -38,3 +38,14 @@ export function initShaderProgram(gl: WebGL2RenderingContext, vsSource: string,
return shaderProgram; return shaderProgram;
} }
export function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.bindTexture(gl.TEXTURE_2D, null);
return texture;
}

View File

@ -797,6 +797,9 @@ importers:
execa: execa:
specifier: 9.6.0 specifier: 9.6.0
version: 9.6.0 version: 9.6.0
exifreader:
specifier: 4.32.0
version: 4.32.0
frontend-shared: frontend-shared:
specifier: workspace:* specifier: workspace:*
version: link:../frontend-shared version: link:../frontend-shared
@ -4973,6 +4976,10 @@ packages:
resolution: {integrity: sha512-siPY6BD5dQ2SZPl3I0OZBHL27ZqZvLEosObsZRQ1NUB8qcxegwt0T9eKtV96JMFQpIz1elhkzqOg4c/Ri6Dp9A==} resolution: {integrity: sha512-siPY6BD5dQ2SZPl3I0OZBHL27ZqZvLEosObsZRQ1NUB8qcxegwt0T9eKtV96JMFQpIz1elhkzqOg4c/Ri6Dp9A==}
engines: {node: ^14.14.0 || >=16.0.0} engines: {node: ^14.14.0 || >=16.0.0}
'@xmldom/xmldom@0.9.8':
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
engines: {node: '>=14.6'}
abbrev@1.1.1: abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
@ -6505,6 +6512,9 @@ packages:
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
engines: {node: '>=4'} engines: {node: '>=4'}
exifreader@4.32.0:
resolution: {integrity: sha512-sj1PzjpaPwSE/2MeUqoAYcfc2u7AZOGSby0FzmAkB4jjeCXgDryxzVgMwV+tJKGIkGdWkkWiUWoLSJoPHJ6V5Q==}
exit@0.1.2: exit@0.1.2:
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -7212,6 +7222,7 @@ packages:
intersection-observer@0.12.2: intersection-observer@0.12.2:
resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==}
deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
ioredis@5.8.1: ioredis@5.8.1:
resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==} resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==}
@ -15621,6 +15632,9 @@ snapshots:
dependencies: dependencies:
arch: 3.0.0 arch: 3.0.0
'@xmldom/xmldom@0.9.8':
optional: true
abbrev@1.1.1: {} abbrev@1.1.1: {}
abbrev@3.0.1: {} abbrev@3.0.1: {}
@ -17465,6 +17479,10 @@ snapshots:
dependencies: dependencies:
pify: 2.3.0 pify: 2.3.0
exifreader@4.32.0:
optionalDependencies:
'@xmldom/xmldom': 0.9.8
exit@0.1.2: {} exit@0.1.2: {}
expand-template@2.0.3: expand-template@2.0.3: