wip
This commit is contained in:
parent
a892bbcce5
commit
98774208a2
|
|
@ -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": {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1397,6 +1397,15 @@ widgets: "ウィジェット"
|
||||||
deviceInfo: "デバイス情報"
|
deviceInfo: "デバイス情報"
|
||||||
deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。"
|
deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。"
|
||||||
|
|
||||||
|
_imageLabelEditor:
|
||||||
|
title: "ラベルの編集"
|
||||||
|
frameThickness: "フレーム"
|
||||||
|
centered: "中央揃え"
|
||||||
|
captionMain: "キャプション(大)"
|
||||||
|
captionSub: "キャプション(小)"
|
||||||
|
availableVariables: "利用可能な変数"
|
||||||
|
withQrCode: "二次元コード"
|
||||||
|
|
||||||
_compression:
|
_compression:
|
||||||
_quality:
|
_quality:
|
||||||
high: "高品質"
|
high: "高品質"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue