This commit is contained in:
syuilo 2025-11-03 20:31:37 +09:00
parent e4b6853f0a
commit 6becc489dc
5 changed files with 278 additions and 120 deletions

View File

@ -31,15 +31,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<MkRange v-model="frame.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
</MkRange>
<MkInput :modelValue="getHex(frame.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) frame.bgColor = c; }">
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
</MkInput>
<MkInput :modelValue="getHex(frame.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) frame.fgColor = c; }">
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
</MkInput>
@ -47,31 +47,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
<div class="_gaps">
<MkSwitch v-model="frame.labelTop.enabled">
<MkSwitch v-model="params.labelTop.enabled">
<template #label>{{ i18n.ts.show }}</template>
</MkSwitch>
<MkRange v-model="frame.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="frame.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="frame.labelTop.centered">
<MkSwitch v-model="params.labelTop.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="frame.labelTop.textBig">
<MkInput v-model="params.labelTop.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="frame.labelTop.textSmall">
<MkTextarea v-model="params.labelTop.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="frame.labelTop.withQrCode">
<MkSwitch v-model="params.labelTop.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
@ -81,31 +81,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
<div class="_gaps">
<MkSwitch v-model="frame.labelBottom.enabled">
<MkSwitch v-model="params.labelBottom.enabled">
<template #label>{{ i18n.ts.show }}</template>
</MkSwitch>
<MkRange v-model="frame.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
</MkRange>
<MkRange v-model="frame.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
</MkRange>
<MkSwitch v-model="frame.labelBottom.centered">
<MkSwitch v-model="params.labelBottom.centered">
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
</MkSwitch>
<MkInput v-model="frame.labelBottom.textBig">
<MkInput v-model="params.labelBottom.textBig">
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
</MkInput>
<MkTextarea v-model="frame.labelBottom.textSmall">
<MkTextarea v-model="params.labelBottom.textSmall">
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
</MkTextarea>
<MkSwitch v-model="frame.labelBottom.withQrCode">
<MkSwitch v-model="params.labelBottom.withQrCode">
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
</MkSwitch>
</div>
@ -132,7 +132,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
import ExifReader from 'exifreader';
import { throttle } from 'throttle-debounce';
import type { ImageFrameParams } from '@/utility/image-frame-renderer/image-frame-renderer.js';
import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/image-frame-renderer.js';
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/image-frame-renderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
@ -153,22 +153,19 @@ import { useMkSelect } from '@/composables/use-mkselect.js';
const $i = ensureSignin();
const EXIF_MOCK = {
DateTimeOriginal: { description: '2012:03:04 5:06:07' },
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?: ImageFrameParams | null;
presetEditMode?: boolean;
preset?: ImageFramePreset | null;
params?: ImageFrameParams | null;
image?: File | null;
}>();
const frame = reactive<ImageFrameParams>(deepClone(props.frame) ?? {
const preset = deepClone(props.preset) ?? {
id: genId(),
name: '',
};
const params = reactive<ImageFrameParams>(deepClone(props.params) ?? {
borderThickness: 0.05,
labelTop: {
enabled: false,
@ -194,6 +191,7 @@ const frame = reactive<ImageFrameParams>(deepClone(props.frame) ?? {
const emit = defineEmits<{
(ev: 'ok', frame: ImageFrameParams): void;
(ev: 'presetOk', preset: ImageFramePreset): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
@ -206,11 +204,11 @@ async function cancel() {
const updateThrottled = throttle(50, () => {
if (renderer != null) {
renderer.render(frame);
renderer.render(params);
}
});
watch(frame, async (newValue, oldValue) => {
watch(params, async (newValue, oldValue) => {
updateThrottled();
}, { deep: true });
@ -262,14 +260,14 @@ async function initRenderer() {
renderer = new ImageFrameRenderer({
canvas: canvasEl.value,
image: sampleImage_3_2,
exif: EXIF_MOCK,
exif: null,
renderAsPreview: true,
});
} else if (sampleImageType.value === '2_3') {
renderer = new ImageFrameRenderer({
canvas: canvasEl.value,
image: sampleImage_2_3,
exif: EXIF_MOCK,
exif: null,
renderAsPreview: true,
});
} else if (imageFile != null) {
@ -285,7 +283,7 @@ async function initRenderer() {
});
}
await renderer!.render(frame);
await renderer!.render(params);
}
onMounted(async () => {
@ -313,21 +311,34 @@ onUnmounted(() => {
});
async function save() {
const { canceled, result: name } = await os.inputText({
title: i18n.ts.name,
default: preset.name,
});
if (canceled) return;
if (props.presetEditMode) {
const { canceled, result: name } = await os.inputText({
title: i18n.ts.name,
default: preset.name,
});
if (canceled) return;
preset.name = name || '';
preset.name = name || '';
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
}
emit('presetOk', {
...preset,
params: deepClone(params),
});
} else {
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
}
emit('ok', params);
}
emit('ok', preset);
}
function getHex(c: [number, number, number]) {

View File

@ -343,8 +343,8 @@ export function useUploader(options: {
!item.uploading &&
!item.uploaded
) {
function changePreset(preset: ImageFramePreset | null) {
item.imageFramePreset = preset;
function change(params: ImageFrameParams | null) {
item.imageFrameParams = params;
preprocess(item).then(() => {
triggerRef(items);
});
@ -355,35 +355,41 @@ export function useUploader(options: {
text: i18n.ts.frame,
type: 'parent',
children: [{
type: 'radioOption',
text: i18n.ts.none,
active: computed(() => item.imageFrameParams == null),
action: () => changePreset(null),
}, {
type: 'divider',
}, ...prefer.s.imageFramePresets.map(preset => ({
type: 'radioOption' as const,
text: preset.name,
active: computed(() => item.imageFramePreset?.id === preset.id),
action: () => changePreset(preset),
})), ...(prefer.s.imageFramePresets.length > 0 ? [{
type: 'divider' as const,
}] : []), {
type: 'button',
icon: 'ti ti-plus',
text: i18n.ts.add,
icon: 'ti ti-pencil',
text: i18n.ts.edit,
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
params: item.imageFrameParams,
image: item.file,
}, {
ok: (preset) => {
prefer.commit('imageFramePresets', [...prefer.s.imageFramePresets, preset]);
changePreset(preset.id);
ok: (params) => {
change(params);
},
closed: () => dispose(),
});
},
}],
}, {
type: 'button',
text: i18n.ts.remove,
action: () => change(null),
}, {
type: 'divider',
}, ...prefer.s.imageFramePresets.map(preset => ({
type: 'button' as const,
text: preset.name,
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
params: preset.params,
image: item.file,
}, {
ok: (params) => {
change(params);
},
closed: () => dispose(),
});
},
}))],
});
}
@ -625,33 +631,28 @@ export function useUploader(options: {
});
}
const canvas = window.document.createElement('canvas');
const needsImageFrame = item.imageFrameParams != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type);
if (needsImageFrame && item.imageFrameParams != null) {
const canvas = window.document.createElement('canvas');
const exif = await ExifReader.load(await item.file.arrayBuffer());
const frameRenderer = new ImageFrameRenderer({
canvas: canvas,
image: await window.createImageBitmap(preprocessedFile),
exif,
});
const exif = await ExifReader.load(await item.file.arrayBuffer());
await frameRenderer.render(item.imageFrameParams);
const frameRenderer = new ImageFrameRenderer({
canvas: canvas,
image: await window.createImageBitmap(preprocessedFile),
exif,
});
//await frameRenderer.update({
// title: `${meta_model} + ${meta_lensModel}`,
// text: `${date} ${meta_mm}mm f/${meta_f} ${meta_s}s ISO${meta_iso}`,
//});
await frameRenderer.render({
title: 'aaaaaaaaaaaaa',
text: 'bbbbbbbbbbbbbbbbbbbb',
});
preprocessedFile = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob == null) {
throw new Error('Failed to convert canvas to blob');
}
resolve(blob);
frameRenderer.destroy();
}, 'image/png');
});
preprocessedFile = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob == null) {
throw new Error('Failed to convert canvas to blob');
}
resolve(blob);
frameRenderer.destroy();
}, 'image/png');
});
}
const compressionSettings = getCompressionSettings(item.compressionLevel);
const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile));

View File

@ -0,0 +1,111 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkFolder :defaultOpen="false" :canPage="false">
<template #icon><i class="ti ti-pencil"></i></template>
<template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template>
<template #footer>
<div class="_buttons">
<MkButton @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton>
<MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton>
</div>
</template>
<div>
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import type { ImageFramePreset } from '@/utility/image-frame-renderer/image-frame-renderer.js';
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/image-frame-renderer.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { deepClone } from '@/utility/clone.js';
import MkFolder from '@/components/MkFolder.vue';
const props = defineProps<{
preset: ImageFramePreset;
}>();
const emit = defineEmits<{
(ev: 'updatePreset', preset: ImageFramePreset): void,
(ev: 'del'): void,
}>();
async function edit() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageFrameEditorDialog.vue')), {
presetEditMode: true,
preset: deepClone(props.preset),
params: deepClone(props.preset.params),
}, {
presetOk: (preset) => {
emit('updatePreset', preset);
},
closed: () => dispose(),
});
}
function del(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.delete,
action: () => {
emit('del');
},
}], ev.currentTarget ?? ev.target);
}
const canvasEl = useTemplateRef('canvasEl');
const sampleImage = new Image();
sampleImage.src = '/client-assets/sample/3-2.jpg';
let renderer: ImageFrameRenderer | null = null;
onMounted(() => {
sampleImage.onload = async () => {
watch(canvasEl, async () => {
if (canvasEl.value == null) return;
renderer = new ImageFrameRenderer({
canvas: canvasEl.value,
image: sampleImage,
exif: null,
renderAsPreview: true,
});
await renderer.render(props.preset.params);
}, { immediate: true });
};
});
onUnmounted(() => {
if (renderer != null) {
renderer.destroy();
renderer = null;
}
});
watch(() => props.preset, async () => {
if (renderer != null) {
await renderer.render(props.preset.params);
}
}, { deep: true });
</script>
<style lang="scss" module>
.previewCanvas {
display: block;
width: 100%;
height: 100%;
max-height: 200px;
box-sizing: border-box;
object-fit: contain;
}
</style>

View File

@ -132,38 +132,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<div class="_gaps_s">
<!--
<XWatermarkItem
v-for="(preset, i) in prefer.r.watermarkPresets.value"
<XImageFrameItem
v-for="(preset, i) in prefer.r.imageFramePresets.value"
:key="preset.id"
:preset="preset"
@updatePreset="onUpdateWatermarkPreset(preset.id, $event)"
@del="onDeleteWatermarkPreset(preset.id)"
@updatePreset="onUpdateImageFramePreset(preset.id, $event)"
@del="onDeleteImageFramePreset(preset.id)"
/>
-->
<MkButton iconOnly rounded style="margin: 0 auto;" @click="addImageFramePreset"><i class="ti ti-plus"></i></MkButton>
<!--
<SearchMarker :keywords="['sync', 'watermark', 'preset', 'devices']">
<MkSwitch :modelValue="watermarkPresetsSyncEnabled" @update:modelValue="changeWatermarkPresetsSyncEnabled">
<SearchMarker :keywords="['sync', 'frame', 'label', 'preset', 'devices']">
<MkSwitch :modelValue="imageFramePresetsSyncEnabled" @update:modelValue="changeImageFramePresetsSyncEnabled">
<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>
@ -219,7 +203,9 @@ import { computed, defineAsyncComponent, ref } from 'vue';
import * as Misskey from 'misskey-js';
import tinycolor from 'tinycolor2';
import XWatermarkItem from './drive.WatermarkItem.vue';
import XImageFrameItem from './drive.ImageFrameItem.vue';
import type { WatermarkPreset } from '@/utility/watermark.js';
import type { ImageFramePreset } from '@/utility/image-frame-renderer/image-frame-renderer.js';
import FormLink from '@/components/form/link.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
@ -239,6 +225,7 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { selectDriveFolder } from '@/utility/drive.js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import { genId } from '@/utility/id.js';
const $i = ensureSignin();
@ -280,6 +267,20 @@ function changeWatermarkPresetsSyncEnabled(value: boolean) {
}
}
const imageFramePresetsSyncEnabled = ref(prefer.isSyncEnabled('imageFramePresets'));
function changeImageFramePresetsSyncEnabled(value: boolean) {
if (value) {
prefer.enableSync('imageFramePresets').then((res) => {
if (res == null) return;
if (res.enabled) imageFramePresetsSyncEnabled.value = true;
});
} else {
prefer.disableSync('imageFramePresets');
imageFramePresetsSyncEnabled.value = false;
}
}
misskeyApi('drive').then(info => {
capacity.value = info.capacity;
usage.value = info.usage;
@ -343,11 +344,35 @@ function onDeleteWatermarkPreset(id: string) {
}
}
function onUpdateImageFramePreset(id: string, preset: ImageFramePreset) {
const index = prefer.s.imageFramePresets.findIndex(p => p.id === id);
if (index !== -1) {
prefer.commit('imageFramePresets', [
...prefer.s.imageFramePresets.slice(0, index),
preset,
...prefer.s.imageFramePresets.slice(index + 1),
]);
}
}
function onDeleteImageFramePreset(id: string) {
const index = prefer.s.imageFramePresets.findIndex(p => p.id === id);
if (index !== -1) {
prefer.commit('imageFramePresets', [
...prefer.s.imageFramePresets.slice(0, index),
...prefer.s.imageFramePresets.slice(index + 1),
]);
}
}
async function addImageFramePreset() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
presetEditMode: true,
preset: null,
params: null,
}, {
ok: (preset: any) => {
//prefer.commit('imageFramePresets', [...prefer.s.imageFramePresets, preset]);
presetOk: (preset) => {
prefer.commit('imageFramePresets', [...prefer.s.imageFramePresets, preset]);
},
closed: () => dispose(),
});

View File

@ -38,6 +38,16 @@ export type ImageFramePreset = {
params: ImageFrameParams;
};
const EXIF_MOCK = {
DateTimeOriginal: { description: '2012:03:04 5:06:07' },
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;
export class ImageFrameRenderer {
private compositor: ImageCompositor;
private image: HTMLImageElement | ImageBitmap;
@ -47,11 +57,11 @@ export class ImageFrameRenderer {
constructor(options: {
canvas: HTMLCanvasElement,
image: HTMLImageElement | ImageBitmap,
exif: ExifReader.Tags,
exif: ExifReader.Tags | null,
renderAsPreview?: boolean,
}) {
this.image = options.image;
this.exif = options.exif;
this.exif = options.exif ?? EXIF_MOCK;
this.renderAsPreview = options.renderAsPreview ?? false;
console.log(this.exif);