wip
This commit is contained in:
parent
a892bbcce5
commit
98774208a2
|
|
@ -5605,6 +5605,36 @@ export interface Locale extends ILocale {
|
|||
* 技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。
|
||||
*/
|
||||
"deviceInfoDescription": string;
|
||||
"_imageLabelEditor": {
|
||||
/**
|
||||
* ラベルの編集
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* フレーム
|
||||
*/
|
||||
"frameThickness": string;
|
||||
/**
|
||||
* 中央揃え
|
||||
*/
|
||||
"centered": string;
|
||||
/**
|
||||
* キャプション(大)
|
||||
*/
|
||||
"captionMain": string;
|
||||
/**
|
||||
* キャプション(小)
|
||||
*/
|
||||
"captionSub": string;
|
||||
/**
|
||||
* 利用可能な変数
|
||||
*/
|
||||
"availableVariables": string;
|
||||
/**
|
||||
* 二次元コード
|
||||
*/
|
||||
"withQrCode": string;
|
||||
};
|
||||
"_compression": {
|
||||
"_quality": {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,15 @@ widgets: "ウィジェット"
|
|||
deviceInfo: "デバイス情報"
|
||||
deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。"
|
||||
|
||||
_imageLabelEditor:
|
||||
title: "ラベルの編集"
|
||||
frameThickness: "フレーム"
|
||||
centered: "中央揃え"
|
||||
captionMain: "キャプション(大)"
|
||||
captionSub: "キャプション(小)"
|
||||
availableVariables: "利用可能な変数"
|
||||
withQrCode: "二次元コード"
|
||||
|
||||
_compression:
|
||||
_quality:
|
||||
high: "高品質"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"execa": "9.6.0",
|
||||
"exifreader": "4.32.0",
|
||||
"frontend-shared": "workspace:*",
|
||||
"icons-subsetter": "workspace:*",
|
||||
"idb-keyval": "6.2.2",
|
||||
|
|
|
|||
|
|
@ -314,10 +314,16 @@ onUnmounted(() => {
|
|||
|
||||
.embedCodeGenPreviewRoot {
|
||||
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;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -155,8 +155,8 @@ onMounted(async () => {
|
|||
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
w = Math.floor(w * scale);
|
||||
h = Math.floor(h * scale);
|
||||
}
|
||||
|
||||
renderer = new ImageEffector({
|
||||
|
|
@ -373,8 +373,14 @@ function onImagePointerdown(ev: PointerEvent) {
|
|||
.preview {
|
||||
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);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
w = Math.floor(w * scale);
|
||||
h = Math.floor(h * scale);
|
||||
}
|
||||
|
||||
renderer = new WatermarkRenderer({
|
||||
|
|
@ -380,8 +380,14 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
|||
.preview {
|
||||
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);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
|
|||
import isAnimated from 'is-file-animated';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue';
|
||||
import ExifReader from 'exifreader';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
@ -17,6 +18,7 @@ import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
|||
import * as os from '@/os.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
import { ImageLabelRenderer } from '@/utility/image-label-renderer.js';
|
||||
|
||||
export type UploaderFeatures = {
|
||||
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 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>
|
||||
</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']">
|
||||
<MkPreferenceContainer k="defaultImageCompressionLevel">
|
||||
<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() {
|
||||
misskeyApi('i/update', {
|
||||
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import QRCodeStyling from 'qr-code-styling';
|
||||
import { url, host } from '@@/js/config.js';
|
||||
import { getProxiedImageUrl } from '../media-proxy.js';
|
||||
import { initShaderProgram } from '../webgl.js';
|
||||
import { createTexture, initShaderProgram } from '../webgl.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
export type ImageEffectorRGB = [r: number, g: number, b: number];
|
||||
|
|
@ -71,12 +71,17 @@ interface TextureParamDef extends CommonParamDef {
|
|||
} | null;
|
||||
};
|
||||
|
||||
interface TextureRefParamDef extends CommonParamDef {
|
||||
type: 'textureRef';
|
||||
default: string;
|
||||
};
|
||||
|
||||
interface ColorParamDef extends CommonParamDef {
|
||||
type: 'color';
|
||||
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>;
|
||||
|
||||
|
|
@ -129,27 +134,26 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
|
|||
private canvas: HTMLCanvasElement | null = null;
|
||||
private renderWidth: number;
|
||||
private renderHeight: number;
|
||||
private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||
private layers: ImageEffectorLayer[] = [];
|
||||
private originalImageTexture: WebGLTexture;
|
||||
private baseTexture: WebGLTexture;
|
||||
private shaderCache: Map<string, WebGLProgram> = new Map();
|
||||
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
|
||||
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
|
||||
private nopProgram: WebGLProgram;
|
||||
private fxs: [...IEX];
|
||||
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: {
|
||||
canvas: HTMLCanvasElement;
|
||||
renderWidth: number;
|
||||
renderHeight: number;
|
||||
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null;
|
||||
fxs: [...IEX];
|
||||
}) {
|
||||
this.canvas = options.canvas;
|
||||
this.renderWidth = options.renderWidth;
|
||||
this.renderHeight = options.renderHeight;
|
||||
this.originalImage = options.image;
|
||||
this.fxs = options.fxs;
|
||||
|
||||
this.canvas.width = this.renderWidth;
|
||||
|
|
@ -161,9 +165,7 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
|
|||
premultipliedAlpha: false,
|
||||
});
|
||||
|
||||
if (gl == null) {
|
||||
throw new Error('Failed to initialize WebGL2 context');
|
||||
}
|
||||
if (gl == null) throw new Error('Failed to initialize WebGL2 context');
|
||||
|
||||
this.gl = gl;
|
||||
|
||||
|
|
@ -174,11 +176,16 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
|
|||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
|
||||
this.originalImageTexture = createTexture(gl);
|
||||
if (options.image != null) {
|
||||
this.baseTexture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||
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.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
|
||||
in vec2 position;
|
||||
|
|
@ -254,11 +261,19 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
|
|||
height: this.renderHeight,
|
||||
textures: Object.fromEntries(
|
||||
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);
|
||||
if (param == null) return [k, null];
|
||||
const texture = this.registeredTextures.get(param) ?? null;
|
||||
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) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
|
||||
|
||||
gl.useProgram(this.nopProgram);
|
||||
gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0);
|
||||
|
|
@ -280,7 +295,7 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
|
|||
return;
|
||||
}
|
||||
|
||||
let preTexture = this.originalImageTexture;
|
||||
let preTexture = this.baseTexture;
|
||||
|
||||
for (const layer of this.layers) {
|
||||
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;
|
||||
|
||||
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.type !== 'texture') continue;
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
this.renderWidth = width;
|
||||
this.renderHeight = height;
|
||||
|
|
@ -400,7 +437,12 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
|
|||
}
|
||||
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) {
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
|
||||
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:
|
||||
specifier: 9.6.0
|
||||
version: 9.6.0
|
||||
exifreader:
|
||||
specifier: 4.32.0
|
||||
version: 4.32.0
|
||||
frontend-shared:
|
||||
specifier: workspace:*
|
||||
version: link:../frontend-shared
|
||||
|
|
@ -4973,6 +4976,10 @@ packages:
|
|||
resolution: {integrity: sha512-siPY6BD5dQ2SZPl3I0OZBHL27ZqZvLEosObsZRQ1NUB8qcxegwt0T9eKtV96JMFQpIz1elhkzqOg4c/Ri6Dp9A==}
|
||||
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:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
|
||||
|
|
@ -6505,6 +6512,9 @@ packages:
|
|||
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
exifreader@4.32.0:
|
||||
resolution: {integrity: sha512-sj1PzjpaPwSE/2MeUqoAYcfc2u7AZOGSby0FzmAkB4jjeCXgDryxzVgMwV+tJKGIkGdWkkWiUWoLSJoPHJ6V5Q==}
|
||||
|
||||
exit@0.1.2:
|
||||
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -7212,6 +7222,7 @@ packages:
|
|||
|
||||
intersection-observer@0.12.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==}
|
||||
|
|
@ -15621,6 +15632,9 @@ snapshots:
|
|||
dependencies:
|
||||
arch: 3.0.0
|
||||
|
||||
'@xmldom/xmldom@0.9.8':
|
||||
optional: true
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
abbrev@3.0.1: {}
|
||||
|
|
@ -17465,6 +17479,10 @@ snapshots:
|
|||
dependencies:
|
||||
pify: 2.3.0
|
||||
|
||||
exifreader@4.32.0:
|
||||
optionalDependencies:
|
||||
'@xmldom/xmldom': 0.9.8
|
||||
|
||||
exit@0.1.2: {}
|
||||
|
||||
expand-template@2.0.3:
|
||||
|
|
|
|||
Loading…
Reference in New Issue