This commit is contained in:
syuilo 2025-05-13 11:19:06 +09:00
parent 785258da20
commit 5c44e5ba59
4 changed files with 190 additions and 142 deletions

View File

@ -20,13 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-for="ctx in items" :key="ctx.id" v-panel :class="[$style.item, ctx.waiting ? $style.itemWaiting : null]" :style="{ '--p': ctx.progressValue !== null ? `${ctx.progressValue / ctx.progressMax * 100}%` : '0%' }"> <div v-for="ctx in items" :key="ctx.id" v-panel :class="[$style.item, ctx.waiting ? $style.itemWaiting : null]" :style="{ '--p': ctx.progressValue !== null ? `${ctx.progressValue / ctx.progressMax * 100}%` : '0%' }">
<div :class="$style.itemInner"> <div :class="$style.itemInner">
<div> <div>
<MkButton :iconOnly="true" rounded><i class="ti ti-dots"></i></MkButton> <MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton>
</div> </div>
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div> <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div>
<div :class="$style.itemBody"> <div :class="$style.itemBody">
<div>{{ ctx.name }}</div> <div>{{ ctx.name }}</div>
<div style="opacity: 0.7; margin-top: 4px; font-size: 90%;"> <div :class="$style.itemInfo">
<span>{{ bytes(ctx.file.size) }}</span> <span>{{ bytes(ctx.file.size) }}</span>
<span v-if="ctx.compressedSize">({{ bytes(ctx.compressedSize) }})</span>
</div> </div>
<div> <div>
</div> </div>
@ -34,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div> <div>
<MkLoading v-if="ctx.uploading" :em="true"/> <MkLoading v-if="ctx.uploading" :em="true"/>
<MkSystemIcon v-else-if="ctx.uploaded" type="success" style="width: 40px;"/> <MkSystemIcon v-else-if="ctx.uploaded" type="success" style="width: 40px;"/>
<MkSystemIcon v-else-if="ctx.uploadFailed" type="error" style="width: 40px;"/>
</div> </div>
</div> </div>
</div> </div>
@ -55,8 +57,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #footer> <template #footer>
<div class="_buttonsCenter"> <div class="_buttonsCenter">
<MkButton v-if="uploadStarted" rounded @click=""><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> <MkButton v-if="isUploading" rounded @click=""><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
<MkButton v-else primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> <MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
<MkButton v-else-if="canRetry" primary rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
</div> </div>
</template> </template>
</MkModalWindow> </MkModalWindow>
@ -68,7 +71,8 @@ import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
import { apiUrl } from '@@/js/config.js'; 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 MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
@ -78,9 +82,18 @@ import MkButton from '@/components/MkButton.vue';
import bytes from '@/filters/bytes.js'; import bytes from '@/filters/bytes.js';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile } from '@/utility/upload.js';
const $i = ensureSignin(); const $i = ensureSignin();
const compressionSupportedTypes = [
'image/jpeg',
'image/png',
'image/webp',
'image/svg+xml',
] as const;
const mimeTypeMap = { const mimeTypeMap = {
'image/webp': 'webp', 'image/webp': 'webp',
'image/jpeg': 'jpg', 'image/jpeg': 'jpg',
@ -108,29 +121,34 @@ const items = ref([] as {
waiting: boolean; waiting: boolean;
uploading: boolean; uploading: boolean;
uploaded: Misskey.entities.DriveFile | null; uploaded: Misskey.entities.DriveFile | null;
uploadFailed: boolean;
compressedSize?: number | null;
compressedImage?: Blob | null;
file: File; file: File;
}[]); }[]);
const dialog = useTemplateRef('dialog'); const dialog = useTemplateRef('dialog');
const uploadStarted = ref(false); const firstUploadAttempted = ref(false);
const compressionLevel = ref<0 | 1 | 2 | 3>(2); 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(() => { const compressionSettings = computed(() => {
if (compressionLevel.value === 1) { if (compressionLevel.value === 1) {
return { return {
maxWidth: 1024 + 512, maxWidth: 2000,
maxHeight: 1024 + 512, maxHeight: 2000,
}; };
} else if (compressionLevel.value === 2) { } else if (compressionLevel.value === 2) {
return { return {
maxWidth: 1024 + 256, maxWidth: 2000 * 0.75, // =1500
maxHeight: 1024 + 256, maxHeight: 2000 * 0.75, // =1500
}; };
} else if (compressionLevel.value === 3) { } else if (compressionLevel.value === 3) {
return { return {
maxWidth: 1024, maxWidth: 2000 * 0.75 * 0.75, // =1125
maxHeight: 1024, maxHeight: 2000 * 0.75 * 0.75, // =1125
}; };
} else { } else {
return null; return null;
@ -138,7 +156,12 @@ const compressionSettings = computed(() => {
}); });
watch(items, () => { 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!)); emit('done', items.value.map(item => item.uploaded!));
dialog.value?.close(); dialog.value?.close();
} }
@ -149,112 +172,60 @@ function cancel() {
dialog.value?.close(); dialog.value?.close();
} }
function upload() { function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
uploadStarted.value = true;
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; item.waiting = true;
const reader = new FileReader(); const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && compressionSupportedTypes.includes(item.file.type) && !(await isAnimated(item.file));
reader.onload = async (): Promise<void> => {
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}%`);
}
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; if (shouldCompress) {
} catch (err) { const config = {
console.error('Failed to resize image', err); mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
} maxWidth: compressionSettings.value.maxWidth,
} maxHeight: compressionSettings.value.maxHeight,
quality: isWebpSupported() ? 0.85 : 0.8,
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<XMLHttpRequest>) => {
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<EventTarget>) => any;
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) {
item.waiting = false;
item.progressMax = ev.total;
item.progressValue = ev.loaded;
}
}; };
xhr.send(formData); try {
item.uploading = true; const result = await readAndCompressImage(item.file, config);
}; if (result.size < item.file.size || item.file.type === 'image/webp') {
reader.readAsArrayBuffer(item.file); // 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, waiting: false,
uploading: false, uploading: false,
uploaded: null, uploaded: null,
uploadFailed: false,
file: markRaw(file), file: markRaw(file),
}); });
} }
@ -348,4 +320,12 @@ onMounted(() => {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.itemInfo {
opacity: 0.7;
margin-top: 4px;
font-size: 90%;
display: flex;
gap: 8px;
}
</style> </style>

View File

@ -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<Misskey.entities.DriveFile> {
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<XMLHttpRequest>) => {
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<EventTarget>) => 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);
});
}

View File

@ -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<BrowserImageResizerConfigWithConvertedOutput | undefined> {
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,
};
}