mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-23 01:44:10 +02:00
feat(frontend): 画像編集機能 (#16121)
* wip * wip * wip * wip * Update watermarker.ts * wip * wip * Update watermarker.ts * Update MkUploaderDialog.vue * wip * Update ImageEffector.ts * Update ImageEffector.ts * wip * wip * wip * wip * wip * wip * Update MkRange.vue * Update MkRange.vue * wip * wip * Update MkImageEffectorDialog.vue * Update MkImageEffectorDialog.Layer.vue * wip * Update zoomLines.ts * Update zoomLines.ts * wip * wip * Update ImageEffector.ts * wip * Update ImageEffector.ts * wip * Update ImageEffector.ts * swip * wip * Update ImageEffector.ts * wop * Update MkUploaderDialog.vue * Update ImageEffector.ts * wip * wip * wip * Update def.ts * Update def.ts * test * test * Update manager.ts * Update manager.ts * Update manager.ts * Update manager.ts * Update MkImageEffectorDialog.vue * wip * use WEBGL_lose_context * wip * Update MkUploaderDialog.vue * Update drive.vue * wip * Update MkUploaderDialog.vue * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip
This commit is contained in:
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
v-for="ctx in items"
|
||||
:key="ctx.id"
|
||||
v-panel
|
||||
:class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
|
||||
:class="[$style.item, ctx.preprocessing ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
|
||||
:style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
|
||||
>
|
||||
<div :class="$style.itemInner">
|
||||
@@ -40,8 +40,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div>
|
||||
<div :class="$style.itemInfo">
|
||||
<span>{{ ctx.file.type }}</span>
|
||||
<span>{{ bytes(ctx.file.size) }}</span>
|
||||
<span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span>
|
||||
<span v-else>{{ bytes(ctx.file.size) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
@@ -59,19 +59,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
|
||||
</div>
|
||||
|
||||
<MkSelect
|
||||
v-if="items.length > 0"
|
||||
v-model="compressionLevel"
|
||||
:items="[
|
||||
{ value: 0, label: i18n.ts.none },
|
||||
{ value: 1, label: i18n.ts.low },
|
||||
{ value: 2, label: i18n.ts.middle },
|
||||
{ value: 3, label: i18n.ts.high },
|
||||
]"
|
||||
>
|
||||
<template #label>{{ i18n.ts.compress }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
|
||||
|
||||
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく -->
|
||||
@@ -93,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { computed, defineAsyncComponent, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
|
||||
@@ -109,6 +96,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||
import * as os from '@/os.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
@@ -125,6 +113,14 @@ const CROPPING_SUPPORTED_TYPES = [
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const IMAGE_EDITING_SUPPORTED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
|
||||
|
||||
const mimeTypeMap = {
|
||||
'image/webp': 'webp',
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -148,16 +144,19 @@ const emit = defineEmits<{
|
||||
const items = ref<{
|
||||
id: string;
|
||||
name: string;
|
||||
uploadName?: string;
|
||||
progress: { max: number; value: number } | null;
|
||||
thumbnail: string;
|
||||
waiting: boolean;
|
||||
preprocessing: boolean;
|
||||
uploading: boolean;
|
||||
uploaded: Misskey.entities.DriveFile | null;
|
||||
uploadFailed: boolean;
|
||||
aborted: boolean;
|
||||
compressionLevel: number;
|
||||
compressedSize?: number | null;
|
||||
compressedImage?: Blob | null;
|
||||
preprocessedFile?: Blob | null;
|
||||
file: File;
|
||||
watermarkPresetId: string | null;
|
||||
abort?: (() => void) | null;
|
||||
}[]>([]);
|
||||
|
||||
@@ -165,7 +164,7 @@ const dialog = useTemplateRef('dialog');
|
||||
|
||||
const firstUploadAttempted = ref(false);
|
||||
const isUploading = computed(() => items.value.some(item => item.uploading));
|
||||
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null));
|
||||
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.preprocessing) && items.value.some(item => item.uploaded == null));
|
||||
const canDone = computed(() => items.value.some(item => item.uploaded != null));
|
||||
const overallProgress = computed(() => {
|
||||
const max = items.value.length;
|
||||
@@ -178,19 +177,18 @@ const overallProgress = computed(() => {
|
||||
return Math.round((v / max) * 100);
|
||||
});
|
||||
|
||||
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
|
||||
const compressionSettings = computed(() => {
|
||||
if (compressionLevel.value === 1) {
|
||||
function getCompressionSettings(level: 0 | 1 | 2 | 3) {
|
||||
if (level === 1) {
|
||||
return {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
};
|
||||
} else if (compressionLevel.value === 2) {
|
||||
} else if (level === 2) {
|
||||
return {
|
||||
maxWidth: 2000 * 0.75, // =1500
|
||||
maxHeight: 2000 * 0.75, // =1500
|
||||
};
|
||||
} else if (compressionLevel.value === 3) {
|
||||
} else if (level === 3) {
|
||||
return {
|
||||
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
||||
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
||||
@@ -198,7 +196,7 @@ const compressionSettings = computed(() => {
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(items, () => {
|
||||
if (items.value.length === 0) {
|
||||
@@ -274,31 +272,151 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||
},
|
||||
});
|
||||
|
||||
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) {
|
||||
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
menu.push({
|
||||
icon: 'ti ti-crop',
|
||||
text: i18n.ts.cropImage,
|
||||
action: async () => {
|
||||
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
|
||||
items.value.splice(items.value.indexOf(item), 1, {
|
||||
URL.revokeObjectURL(item.thumbnail);
|
||||
const newItem = {
|
||||
...item,
|
||||
file: markRaw(cropped),
|
||||
thumbnail: window.URL.createObjectURL(cropped),
|
||||
};
|
||||
items.value.splice(items.value.indexOf(item), 1, newItem);
|
||||
preprocess(newItem).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!item.waiting && !item.uploading && !item.uploaded) {
|
||||
if (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
menu.push({
|
||||
icon: 'ti ti-sparkles',
|
||||
text: i18n.ts._imageEffector.title + ' (BETA)',
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
|
||||
image: item.file,
|
||||
}, {
|
||||
ok: (file) => {
|
||||
URL.revokeObjectURL(item.thumbnail);
|
||||
const newItem = {
|
||||
...item,
|
||||
file: markRaw(file),
|
||||
thumbnail: window.URL.createObjectURL(file),
|
||||
};
|
||||
items.value.splice(items.value.indexOf(item), 1, newItem);
|
||||
preprocess(newItem).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
function changeWatermarkPreset(presetId: string | null) {
|
||||
item.watermarkPresetId = presetId;
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
icon: 'ti ti-copyright',
|
||||
text: i18n.ts.watermark,
|
||||
type: 'parent',
|
||||
children: [{
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.none,
|
||||
active: computed(() => item.watermarkPresetId == null),
|
||||
action: () => changeWatermarkPreset(null),
|
||||
}, {
|
||||
type: 'divider',
|
||||
}, ...prefer.s.watermarkPresets.map(preset => ({
|
||||
type: 'radioOption',
|
||||
text: preset.name,
|
||||
active: computed(() => item.watermarkPresetId === preset.id),
|
||||
action: () => changeWatermarkPreset(preset.id),
|
||||
})), {
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'button',
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.add,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
|
||||
image: item.file,
|
||||
}, {
|
||||
ok: (preset) => {
|
||||
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
|
||||
changeWatermarkPreset(preset.id);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
if (COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
function changeCompressionLevel(level: 0 | 1 | 2 | 3) {
|
||||
item.compressionLevel = level;
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
icon: 'ti ti-leaf',
|
||||
text: i18n.ts.compress,
|
||||
type: 'parent',
|
||||
children: [{
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.none,
|
||||
active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null),
|
||||
action: () => changeCompressionLevel(0),
|
||||
}, {
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.low,
|
||||
active: computed(() => item.compressionLevel === 1),
|
||||
action: () => changeCompressionLevel(1),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.medium,
|
||||
active: computed(() => item.compressionLevel === 2),
|
||||
action: () => changeCompressionLevel(2),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.high,
|
||||
active: computed(() => item.compressionLevel === 3),
|
||||
action: () => changeCompressionLevel(3),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (!item.preprocessing && !item.uploading && !item.uploaded) {
|
||||
menu.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
icon: 'ti ti-x',
|
||||
text: i18n.ts.remove,
|
||||
action: () => {
|
||||
URL.revokeObjectURL(item.thumbnail);
|
||||
items.value.splice(items.value.indexOf(item), 1);
|
||||
},
|
||||
});
|
||||
} else if (item.uploading) {
|
||||
menu.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
icon: 'ti ti-cloud-pause',
|
||||
text: i18n.ts.abort,
|
||||
danger: true,
|
||||
@@ -320,7 +438,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||
...item,
|
||||
aborted: false,
|
||||
uploadFailed: false,
|
||||
waiting: false,
|
||||
uploading: false,
|
||||
}));
|
||||
|
||||
@@ -330,40 +447,13 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||
continue;
|
||||
}
|
||||
|
||||
item.waiting = true;
|
||||
item.uploadFailed = false;
|
||||
|
||||
const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file));
|
||||
|
||||
if (shouldCompress) {
|
||||
const config = {
|
||||
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
|
||||
maxWidth: compressionSettings.value.maxWidth,
|
||||
maxHeight: compressionSettings.value.maxHeight,
|
||||
quality: isWebpSupported() ? 0.85 : 0.8,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await readAndCompressImage(item.file, config);
|
||||
if (result.size < item.file.size || item.file.type === 'image/webp') {
|
||||
// The compression may not always reduce the file size
|
||||
// (and WebP is not browser safe yet)
|
||||
item.compressedImage = markRaw(result);
|
||||
item.compressedSize = result.size;
|
||||
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resize image', err);
|
||||
}
|
||||
}
|
||||
|
||||
item.uploading = true;
|
||||
|
||||
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
|
||||
name: item.name,
|
||||
const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
|
||||
name: item.uploadName ?? item.name,
|
||||
folderId: props.folderId,
|
||||
onProgress: (progress) => {
|
||||
item.waiting = false;
|
||||
if (item.progress == null) {
|
||||
item.progress = { max: progress.total, value: progress.loaded };
|
||||
} else {
|
||||
@@ -377,7 +467,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||
item.abort = null;
|
||||
abort();
|
||||
item.uploading = false;
|
||||
item.waiting = false;
|
||||
item.uploadFailed = true;
|
||||
};
|
||||
|
||||
@@ -392,7 +481,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||
}
|
||||
}).finally(() => {
|
||||
item.uploading = false;
|
||||
item.waiting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -419,21 +507,95 @@ async function chooseFile(ev: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
async function preprocess(item: (typeof items)['value'][number]): Promise<void> {
|
||||
item.preprocessing = true;
|
||||
|
||||
let file: Blob | File = item.file;
|
||||
const imageBitmap = await window.createImageBitmap(file);
|
||||
|
||||
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type);
|
||||
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
|
||||
if (needsWatermark && preset != null) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const renderer = new WatermarkRenderer({
|
||||
canvas: canvas,
|
||||
renderWidth: imageBitmap.width,
|
||||
renderHeight: imageBitmap.height,
|
||||
image: imageBitmap,
|
||||
});
|
||||
|
||||
await renderer.setLayers(preset.layers);
|
||||
|
||||
renderer.render();
|
||||
|
||||
file = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob == null) {
|
||||
throw new Error('Failed to convert canvas to blob');
|
||||
}
|
||||
resolve(blob);
|
||||
renderer.destroy();
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
const compressionSettings = getCompressionSettings(item.compressionLevel);
|
||||
const needsCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(file.type) && !(await isAnimated(file));
|
||||
|
||||
if (needsCompress) {
|
||||
const config = {
|
||||
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
|
||||
maxWidth: compressionSettings.maxWidth,
|
||||
maxHeight: compressionSettings.maxHeight,
|
||||
quality: isWebpSupported() ? 0.85 : 0.8,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await readAndCompressImage(file, config);
|
||||
if (result.size < file.size || file.type === 'image/webp') {
|
||||
// The compression may not always reduce the file size
|
||||
// (and WebP is not browser safe yet)
|
||||
file = result;
|
||||
item.compressedSize = result.size;
|
||||
item.uploadName = file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resize image', err);
|
||||
}
|
||||
} else {
|
||||
item.compressedSize = null;
|
||||
item.uploadName = item.name;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(item.thumbnail);
|
||||
item.thumbnail = window.URL.createObjectURL(file);
|
||||
item.preprocessedFile = markRaw(file);
|
||||
item.preprocessing = false;
|
||||
|
||||
imageBitmap.close();
|
||||
}
|
||||
|
||||
function initializeFile(file: File) {
|
||||
const id = genId();
|
||||
const filename = file.name ?? 'untitled';
|
||||
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
||||
items.value.push({
|
||||
const item = {
|
||||
id,
|
||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
||||
progress: null,
|
||||
thumbnail: window.URL.createObjectURL(file),
|
||||
waiting: false,
|
||||
preprocessing: false,
|
||||
uploading: false,
|
||||
aborted: false,
|
||||
uploaded: null,
|
||||
uploadFailed: false,
|
||||
compressionLevel: prefer.s.defaultImageCompressionLevel,
|
||||
watermarkPresetId: prefer.s.defaultWatermarkPresetId,
|
||||
file: markRaw(file),
|
||||
};
|
||||
items.value.push(item);
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,6 +604,12 @@ onMounted(() => {
|
||||
initializeFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
for (const item of items.value) {
|
||||
URL.revokeObjectURL(item.thumbnail);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
Reference in New Issue
Block a user