From 5c44e5ba59d8054c3c6e5dfd223621ebc624cc7e Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Tue, 13 May 2025 11:19:06 +0900 Subject: [PATCH] wip --- .../src/components/MkUploadDialog.vue | 206 ++++++++---------- .../utility/{upload => }/isWebpSupported.ts | 0 packages/frontend/src/utility/upload.ts | 97 +++++++++ .../src/utility/upload/compress-config.ts | 29 --- 4 files changed, 190 insertions(+), 142 deletions(-) rename packages/frontend/src/utility/{upload => }/isWebpSupported.ts (100%) create mode 100644 packages/frontend/src/utility/upload.ts delete mode 100644 packages/frontend/src/utility/upload/compress-config.ts diff --git a/packages/frontend/src/components/MkUploadDialog.vue b/packages/frontend/src/components/MkUploadDialog.vue index fe3ce3c00f..96a380d89f 100644 --- a/packages/frontend/src/components/MkUploadDialog.vue +++ b/packages/frontend/src/components/MkUploadDialog.vue @@ -20,13 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
{{ ctx.name }}
-
+
{{ bytes(ctx.file.size) }} + ({{ bytes(ctx.compressedSize) }})
@@ -34,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -55,8 +57,9 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -68,7 +71,8 @@ import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; import { apiUrl } from '@@/js/config.js'; -import { getCompressionConfig } from '@/utility/upload/compress-config.js'; +import isAnimated from 'is-file-animated'; +import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; @@ -78,9 +82,18 @@ import MkButton from '@/components/MkButton.vue'; import bytes from '@/filters/bytes.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; +import { isWebpSupported } from '@/utility/isWebpSupported.js'; +import { uploadFile } from '@/utility/upload.js'; const $i = ensureSignin(); +const compressionSupportedTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/svg+xml', +] as const; + const mimeTypeMap = { 'image/webp': 'webp', 'image/jpeg': 'jpg', @@ -108,29 +121,34 @@ const items = ref([] as { waiting: boolean; uploading: boolean; uploaded: Misskey.entities.DriveFile | null; + uploadFailed: boolean; + compressedSize?: number | null; + compressedImage?: Blob | null; file: File; }[]); const dialog = useTemplateRef('dialog'); -const uploadStarted = ref(false); -const compressionLevel = ref<0 | 1 | 2 | 3>(2); +const firstUploadAttempted = ref(false); +const isUploading = computed(() => items.value.some(item => item.uploading)); +const canRetry = computed(() => firstUploadAttempted.value && !isUploading.value && items.value.some(item => item.uploaded == null)); +const compressionLevel = ref<0 | 1 | 2 | 3>(2); const compressionSettings = computed(() => { if (compressionLevel.value === 1) { return { - maxWidth: 1024 + 512, - maxHeight: 1024 + 512, + maxWidth: 2000, + maxHeight: 2000, }; } else if (compressionLevel.value === 2) { return { - maxWidth: 1024 + 256, - maxHeight: 1024 + 256, + maxWidth: 2000 * 0.75, // =1500 + maxHeight: 2000 * 0.75, // =1500 }; } else if (compressionLevel.value === 3) { return { - maxWidth: 1024, - maxHeight: 1024, + maxWidth: 2000 * 0.75 * 0.75, // =1125 + maxHeight: 2000 * 0.75 * 0.75, // =1125 }; } else { return null; @@ -138,7 +156,12 @@ const compressionSettings = computed(() => { }); watch(items, () => { - if (uploadStarted.value && items.value.every(item => item.uploaded)) { + if (items.value.length === 0) { + dialog.value?.close(); + return; + } + + if (items.value.every(item => item.uploaded)) { emit('done', items.value.map(item => item.uploaded!)); dialog.value?.close(); } @@ -149,112 +172,60 @@ function cancel() { dialog.value?.close(); } -function upload() { - uploadStarted.value = true; +function showMenu(ev: MouseEvent, item: typeof items.value[0]) { - for (const item of items.value) { - if ((item.file.size > instance.maxFileSize) || (item.file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, - }); - continue; - } +} +async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる + firstUploadAttempted.value = true; + + for (const item of items.value.filter(item => item.uploaded == null)) { item.waiting = true; - const reader = new FileReader(); - reader.onload = async (): Promise => { - const config = compressionLevel.value !== 0 ? await getCompressionConfig(item.file, compressionSettings.value) : undefined; - let resizedImage: Blob | undefined; - if (config) { - try { - const resized = await readAndCompressImage(item.file, config); - if (resized.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) - resizedImage = resized; - } - if (_DEV_) { - const saved = ((1 - resized.size / item.file.size) * 100).toFixed(2); - console.log(`Image compression: before ${item.file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); - } + const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && compressionSupportedTypes.includes(item.file.type) && !(await isAnimated(item.file)); - item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; - } catch (err) { - console.error('Failed to resize image', err); - } - } - - const formData = new FormData(); - formData.append('i', $i.token); - formData.append('force', 'true'); - formData.append('file', resizedImage ?? item.file); - formData.append('name', item.name); - if (props.folderId) formData.append('folderId', props.folderId); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = ((ev: ProgressEvent) => { - item.uploading = false; - - if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { - if (xhr.status === 413) { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, - }); - } else if (ev.target?.response) { - const res = JSON.parse(ev.target.response); - if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseInappropriate, - }); - } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseNoFreeSpace, - }); - } else { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, - }); - } - } else { - alert({ - type: 'error', - title: 'Failed to upload', - text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, - }); - } - - reject(); - return; - } - - const driveFile = JSON.parse(ev.target.response); - item.uploaded = driveFile; - }) as (ev: ProgressEvent) => any; - - xhr.upload.onprogress = ev => { - if (ev.lengthComputable) { - item.waiting = false; - item.progressMax = ev.total; - item.progressValue = ev.loaded; - } + if (shouldCompress) { + const config = { + mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', + maxWidth: compressionSettings.value.maxWidth, + maxHeight: compressionSettings.value.maxHeight, + quality: isWebpSupported() ? 0.85 : 0.8, }; - xhr.send(formData); - item.uploading = true; - }; - reader.readAsArrayBuffer(item.file); + 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 driveFile = await uploadFile(item.compressedImage ?? item.file, { + name: item.name, + folderId: props.folderId, + onProgress: (progress) => { + item.waiting = false; + item.progressMax = progress.total; + item.progressValue = progress.loaded; + }, + }).catch(err => { + item.uploadFailed = true; + item.progressMax = null; + item.progressValue = null; + throw err; + }).finally(() => { + item.uploading = false; + }); + + item.uploaded = driveFile; } } @@ -272,6 +243,7 @@ onMounted(() => { waiting: false, uploading: false, uploaded: null, + uploadFailed: false, file: markRaw(file), }); } @@ -348,4 +320,12 @@ onMounted(() => { flex: 1; min-width: 0; } + +.itemInfo { + opacity: 0.7; + margin-top: 4px; + font-size: 90%; + display: flex; + gap: 8px; +} diff --git a/packages/frontend/src/utility/upload/isWebpSupported.ts b/packages/frontend/src/utility/isWebpSupported.ts similarity index 100% rename from packages/frontend/src/utility/upload/isWebpSupported.ts rename to packages/frontend/src/utility/isWebpSupported.ts diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts new file mode 100644 index 0000000000..548f879226 --- /dev/null +++ b/packages/frontend/src/utility/upload.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { apiUrl } from '@@/js/config.js'; +import { $i } from '@/i.js'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +export function uploadFile(file: File, options: { + name?: string; + folderId?: string | null; + onProgress?: (ctx: { total: number; loaded: number; }) => void; +} = {}): Promise { + return new Promise((resolve, reject) => { + if ($i == null) return reject(); + + if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + return reject(); + } + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = ((ev: ProgressEvent) => { + if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { + if (xhr.status === 413) { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + } else if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, + }); + } + } else { + os.alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } + + reject(); + return; + } + + const driveFile = JSON.parse(ev.target.response); + resolve(driveFile); + }) as (ev: ProgressEvent) => any; + + if (options.onProgress) { + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) { + options.onProgress({ + total: ev.total, + loaded: ev.loaded, + }); + } + }; + } + + const formData = new FormData(); + formData.append('i', $i.token); + formData.append('force', 'true'); + formData.append('file', file); + formData.append('name', options.name ?? file.name); + if (options.folderId) formData.append('folderId', options.folderId); + + xhr.send(formData); + }); +} diff --git a/packages/frontend/src/utility/upload/compress-config.ts b/packages/frontend/src/utility/upload/compress-config.ts deleted file mode 100644 index 1868d262d2..0000000000 --- a/packages/frontend/src/utility/upload/compress-config.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import isAnimated from 'is-file-animated'; -import { isWebpSupported } from './isWebpSupported.js'; -import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer'; - -const supportedTypes = [ - 'image/jpeg', - 'image/png', - 'image/webp', - 'image/svg+xml', -] as const; - -export async function getCompressionConfig(file: File, options: Partial<{ maxWidth: number; maxHeight: number; }> = {}): Promise { - if (!supportedTypes.includes(file.type) || await isAnimated(file)) { - return; - } - - return { - mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', - maxWidth: options.maxWidth ?? 2048, - maxHeight: options.maxHeight ?? 2048, - quality: isWebpSupported() ? 0.9 : 0.85, - debug: true, - }; -}