From a47d172d60b1ae712738e0ba5dfbb6b81be3f809 Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 18 Dec 2022 15:40:38 +0900 Subject: [PATCH] enhance(client): Compress non-animated PNG files (#9334) * style: fix TS lint errors about `ev.target` * enhance: compress non-animated PNG * PNG to PNG? * defer jest things (add it later) * Delete jest.config.cjs * check the compressed file size * log compression stats * use ?? * handle if ($i == null) Co-authored-by: tamaina --- packages/client/package.json | 1 + packages/client/src/scripts/upload.ts | 47 +++++++++---------- .../src/scripts/upload/compress-config.ts | 23 +++++++++ yarn.lock | 8 ++++ 4 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 packages/client/src/scripts/upload/compress-config.ts diff --git a/packages/client/package.json b/packages/client/package.json index 9f86a471b..2c9b3cbb2 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -31,6 +31,7 @@ "eventemitter3": "5.0.0", "idb-keyval": "6.2.0", "insert-text-at-cursor": "0.3.0", + "is-file-animated": "1.0.1", "json5": "2.2.1", "katex": "0.15.6", "matter-js": "0.18.0", diff --git a/packages/client/src/scripts/upload.ts b/packages/client/src/scripts/upload.ts index 51f1c1b86..9a39652ef 100644 --- a/packages/client/src/scripts/upload.ts +++ b/packages/client/src/scripts/upload.ts @@ -1,6 +1,7 @@ import { reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { readAndCompressImage } from 'browser-image-resizer'; +import { getCompressionConfig } from './upload/compress-config'; import { defaultStore } from '@/store'; import { apiUrl } from '@/config'; import { $i } from '@/account'; @@ -16,12 +17,6 @@ type Uploading = { }; export const uploads = ref([]); -const compressTypeMap = { - 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, -} as const; - const mimeTypeMap = { 'image/webp': 'webp', 'image/jpeg': 'jpg', @@ -34,16 +29,18 @@ export function uploadFile( name?: string, keepOriginal: boolean = defaultStore.state.keepOriginalUploading, ): Promise { + if ($i == null) throw new Error('Not logged in'); + if (folder && typeof folder === 'object') folder = folder.id; return new Promise((resolve, reject) => { const id = Math.random().toString(); const reader = new FileReader(); - reader.onload = async (ev) => { + reader.onload = async (): Promise => { const ctx = reactive({ id: id, - name: name || file.name || 'untitled', + name: name ?? file.name ?? 'untitled', progressMax: undefined, progressValue: undefined, img: window.URL.createObjectURL(file), @@ -51,20 +48,22 @@ export function uploadFile( uploads.value.push(ctx); - let resizedImage: any; - if (!keepOriginal && file.type in compressTypeMap) { - const imgConfig = compressTypeMap[file.type]; - - const config = { - maxWidth: 2048, - maxHeight: 2048, - debug: true, - ...imgConfig, - }; - + const config = !keepOriginal ? await getCompressionConfig(file) : undefined; + let resizedImage: Blob | undefined; + if (config) { try { - resizedImage = await readAndCompressImage(file, config); - ctx.name = file.type !== imgConfig.mimeType ? `${ctx.name}.${mimeTypeMap[compressTypeMap[file.type].mimeType]}` : ctx.name; + const resized = await readAndCompressImage(file, config); + if (resized.size < file.size || 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 / file.size) * 100).toFixed(2); + console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); + } + + ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; } catch (err) { console.error('Failed to resize image', err); } @@ -73,13 +72,13 @@ export function uploadFile( const formData = new FormData(); formData.append('i', $i.token); formData.append('force', 'true'); - formData.append('file', resizedImage || file); + formData.append('file', resizedImage ?? file); formData.append('name', ctx.name); if (folder) formData.append('folderId', folder); const xhr = new XMLHttpRequest(); xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = (ev) => { + xhr.onload = ((ev: ProgressEvent) => { if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい uploads.value = uploads.value.filter(x => x.id !== id); @@ -122,7 +121,7 @@ export function uploadFile( resolve(driveFile); uploads.value = uploads.value.filter(x => x.id !== id); - }; + }) as (ev: ProgressEvent) => any; xhr.upload.onprogress = ev => { if (ev.lengthComputable) { diff --git a/packages/client/src/scripts/upload/compress-config.ts b/packages/client/src/scripts/upload/compress-config.ts new file mode 100644 index 000000000..793c78ad2 --- /dev/null +++ b/packages/client/src/scripts/upload/compress-config.ts @@ -0,0 +1,23 @@ +import isAnimated from 'is-file-animated'; +import type { BrowserImageResizerConfig } from 'browser-image-resizer'; + +const compressTypeMap = { + 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, + 'image/png': { quality: 1, mimeType: 'image/png' }, + 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, + 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, +} as const; + +export async function getCompressionConfig(file: File): Promise { + const imgConfig = compressTypeMap[file.type]; + if (!imgConfig || await isAnimated(file)) { + return; + } + + return { + maxWidth: 2048, + maxHeight: 2048, + debug: true, + ...imgConfig, + }; +} diff --git a/yarn.lock b/yarn.lock index dc9ac7ccd..96db703b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4975,6 +4975,7 @@ __metadata: eventemitter3: 5.0.0 idb-keyval: 6.2.0 insert-text-at-cursor: 0.3.0 + is-file-animated: 1.0.1 json5: 2.2.1 katex: 0.15.6 matter-js: 0.18.0 @@ -9574,6 +9575,13 @@ __metadata: languageName: node linkType: hard +"is-file-animated@npm:1.0.1": + version: 1.0.1 + resolution: "is-file-animated@npm:1.0.1" + checksum: bcc281e0694e1ba74adfdef75f83f1637ab6470eceecef867d21b4a98e112c32188514b3172348dd137b82cbe8771b6d683de1439d8e1e86011fed77da896c4e + languageName: node + linkType: hard + "is-fullwidth-code-point@npm:^1.0.0": version: 1.0.0 resolution: "is-fullwidth-code-point@npm:1.0.0"