From eccdb45a40806c7112c5f302b8309dd86ca5b961 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Sun, 17 Nov 2024 13:31:55 +1300 Subject: [PATCH 01/16] determine if an image needs compression or not --- frontend/utils/constants.js | 1 + frontend/views/containers/chatroom/SendArea.vue | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/utils/constants.js b/frontend/utils/constants.js index 916327b4c5..27e9900cc6 100644 --- a/frontend/utils/constants.js +++ b/frontend/utils/constants.js @@ -20,6 +20,7 @@ export const CHAT_ATTACHMENT_SUPPORTED_EXTENSIONS = [ // TODO: fetch this value from a server API export const MEGABYTE = 1 << 20 export const CHAT_ATTACHMENT_SIZE_LIMIT = 30 * MEGABYTE // in byte. +export const IMAGE_ATTACHMENT_MAX_SIZE = 400000 // 400KB export const TextObjectType = { Text: 'TEXT', diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index e73e21ea07..3fe61dce30 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -279,7 +279,7 @@ import { CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR, CHATROOM_MAX_MESSAGE_LEN } from '@model/contracts/shared/constants.js' -import { CHAT_ATTACHMENT_SIZE_LIMIT } from '~/frontend/utils/constants.js' +import { CHAT_ATTACHMENT_SIZE_LIMIT, IMAGE_ATTACHMENT_MAX_SIZE } from '~/frontend/utils/constants.js' import { OPEN_MODAL, CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING } from '@utils/events.js' import { uniq, throttle, cloneDeep } from '@model/contracts/shared/giLodash.js' import { @@ -689,7 +689,9 @@ export default ({ this.$emit( 'send', msgToSend, - this.hasAttachments ? cloneDeep(this.ephemeral.attachments) : null, + this.hasAttachments + ? cloneDeep(this.ephemeral.attachments).map(this.compressImage) + : null, this.replyingMessage ) // TODO remove first / last empty lines this.$refs.textarea.value = '' @@ -697,6 +699,12 @@ export default ({ this.endMention() if (this.hasAttachments) { this.clearAllAttachments() } }, + compressImage (attachment) { + if (attachment.needsIamgeCompression) { + console.log('TODO: implement image compression logic here') + } + return attachment + }, openCreatePollModal () { const bbox = this.$el.getBoundingClientRect() this.$refs.poll.open({ @@ -752,6 +760,9 @@ export default ({ attachment.dimension = { width, height } } img.src = fileUrl + + // Determine if the image needs lossy-compression before upload. + attachment.needsIamgeCompression = fileSize > IMAGE_ATTACHMENT_MAX_SIZE } list.push(attachment) From 4511cefbfafbdea4e2cd59e58b32572d536632f0 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Mon, 18 Nov 2024 11:46:44 +1300 Subject: [PATCH 02/16] implement compressImage() function --- frontend/utils/image.js | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/frontend/utils/image.js b/frontend/utils/image.js index faf4a1b335..7f59592a1b 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -28,3 +28,48 @@ export const imageUpload = async (imageFile: File, params: ?Object): Promise { + imgEl.onload = () => { resolve(imgEl) } + imgEl.src = url + }) +} +export async function compressImage (imgUrl: string): Promise { + // takes a source image url and generate another objectURL of the compressed image + const resizingFactor = 0.8 + const quality = 0.8 + + const sourceImage = await loadImage(imgUrl) + const { naturalWidth, naturalHeight } = sourceImage + const canvasEl = document.createElement('canvas') + const c = canvasEl.getContext('2d') + + canvasEl.width = naturalWidth * resizingFactor + canvasEl.height = naturalHeight * resizingFactor + + // 1. draw the resized source iamge to canvas + c.drawImage( + sourceImage, + 0, + 0, + canvasEl.width, + canvasEl.height + ) + + // 2. extract the drawn image as a blob + return new Promise((resolve) => { + // reference: canvas API toBlob(): https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob + canvasEl.toBlob(blob => { + if (blob) { + const compressedUrl = URL.createObjectURL(blob) + resolve(compressedUrl) + } else { + resolve('') + } + }, 'image/jpeg', quality) + }) +} From 59e92d97463a29444cda6b06092bd6080358aef5 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Mon, 18 Nov 2024 17:40:38 +1300 Subject: [PATCH 03/16] write a function that checks if image/webp format is supported --- frontend/utils/image.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 7f59592a1b..cda5949d0f 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -30,6 +30,20 @@ export const imageUpload = async (imageFile: File, params: ?Object): Promise { + // Uses a very small webP image to check if the browser supports 'image/webp' format. + // (reference: https://developers.google.com/speed/webp/faq#in_your_own_javascript) + const verySmallWebP = 'data:image/webp;base64,UklGRhIAAABXRUJQVlA4WAoAAAAQAAAAMwAAQUxQSAwAAAAwAQCdASoEAAQAAVAfCWkAQUwAAAABABgAAgAAAAAABAAAAAAAAAA' + const img = new Image() + + return new Promise(resolve => { + img.onload = () => resolve(img.height > 0) + img.onerror = () => resolve(false) + img.src = verySmallWebP + }) +} + function loadImage (url): any { const imgEl = new Image() From 0866b4752b57fd8dcaf868fe125a458907d24cde Mon Sep 17 00:00:00 2001 From: SebinSong Date: Tue, 19 Nov 2024 10:37:53 +1300 Subject: [PATCH 04/16] implement recursive compression logic in compressImage() --- frontend/utils/image.js | 55 ++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/frontend/utils/image.js b/frontend/utils/image.js index cda5949d0f..9137e3fffc 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -1,6 +1,7 @@ 'use strict' import sbp from '@sbp/sbp' +import { IMAGE_ATTACHMENT_MAX_SIZE } from './constants.js' // Copied from https://stackoverflow.com/questions/11876175/how-to-get-a-file-or-blob-from-an-object-url export function objectURLtoBlob (url: string): Promise { @@ -52,12 +53,13 @@ function loadImage (url): any { imgEl.src = url }) } -export async function compressImage (imgUrl: string): Promise { - // takes a source image url and generate another objectURL of the compressed image - const resizingFactor = 0.8 - const quality = 0.8 - const sourceImage = await loadImage(imgUrl) +function generateImageBlobByCanvas ({ + sourceImage, + resizingFactor, + quality, + compressToType +}) { const { naturalWidth, naturalHeight } = sourceImage const canvasEl = document.createElement('canvas') const c = canvasEl.getContext('2d') @@ -65,7 +67,6 @@ export async function compressImage (imgUrl: string): Promise { canvasEl.width = naturalWidth * resizingFactor canvasEl.height = naturalHeight * resizingFactor - // 1. draw the resized source iamge to canvas c.drawImage( sourceImage, 0, @@ -74,16 +75,40 @@ export async function compressImage (imgUrl: string): Promise { canvasEl.height ) - // 2. extract the drawn image as a blob return new Promise((resolve) => { - // reference: canvas API toBlob(): https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob canvasEl.toBlob(blob => { - if (blob) { - const compressedUrl = URL.createObjectURL(blob) - resolve(compressedUrl) - } else { - resolve('') - } - }, 'image/jpeg', quality) + resolve(blob) + }, compressToType, quality) }) } + +export async function compressImage (imgUrl: string): Promise { + // Takes a source image url and generate another objectURL of the compressed image. + + // According to the testing result, 0.8 is a good starting point for both resizingFactor and quality. + let resizingFactor = 0.8 + let quality = 0.8 + // According to the testing result, webP format has a better compression ratio than jpeg. + const compressToType = await supportsWebP() ? 'image/webp' : 'image/jpeg' + const sourceImage = await loadImage(imgUrl) + + while (true) { + const blob = await generateImageBlobByCanvas({ + sourceImage, + resizingFactor, + quality, + compressToType + }) + const sizeDiff = blob.size - IMAGE_ATTACHMENT_MAX_SIZE + + if (sizeDiff <= 0 || // if the compressed image is already smaller than the max size, return the compressed image. + quality <= 0.4) { // Do not sacrifice the image quality too much. + return URL.createObjectURL(blob) + } else { + // if the size difference is greater than 100KB, reduce the next compression factors by 10%, otherwise 5%. + const minusFactor = sizeDiff > 100 * 1000 ? 0.1 : 0.05 + resizingFactor -= minusFactor + quality -= minusFactor + } + } +} From d4712f9cb97574e23e7154589a3fa9ea0cf4cf89 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Tue, 19 Nov 2024 13:10:09 +1300 Subject: [PATCH 05/16] optimise compression factors / some corrections on file details post compression --- frontend/controller/actions/identity.js | 22 ++++++++++++++----- frontend/utils/image.js | 20 +++++++++-------- .../views/containers/chatroom/SendArea.vue | 8 +------ 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 072493e335..d9076e96f0 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -9,7 +9,7 @@ import { import { cloneDeep, has, omit } from '@model/contracts/shared/giLodash.js' import { SETTING_CHELONIA_STATE } from '@model/database.js' import sbp from '@sbp/sbp' -import { imageUpload, objectURLtoBlob } from '@utils/image.js' +import { imageUpload, objectURLtoBlob, compressImage } from '@utils/image.js' import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' import { KV_QUEUE, LOGIN, LOGOUT } from '~/frontend/utils/events.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' @@ -587,15 +587,27 @@ export default (sbp('sbp/selectors/register', { const { identityContractID } = sbp('state/vuex/state').loggedIn try { const attachmentsData = await Promise.all(attachments.map(async (attachment) => { - const { mimeType, url } = attachment + const { url, needsIamgeCompression } = attachment // url here is an instance of URL.createObjectURL(), which needs to be converted to a 'Blob' - const attachmentBlob = await objectURLtoBlob(url) + const attachmentBlob = needsIamgeCompression + ? await compressImage(url) + : await objectURLtoBlob(url) + + if (needsIamgeCompression) { + // Update the attachment details to reflect the compressed image. + const fileNameWithoutExtension = attachment.name.split('.').slice(0, -1).join('.') + const extension = attachmentBlob.type.split('/')[1] + + attachment.mimeType = attachmentBlob.type + attachment.name = `${fileNameWithoutExtension}.${extension}` + } const response = await sbp('chelonia/fileUpload', attachmentBlob, { - type: mimeType, cipher: 'aes256gcm' + type: attachment.mimeType, + cipher: 'aes256gcm' }, { billableContractID }) const { delete: token, download: downloadData } = response return { - attributes: omit(attachment, ['url']), + attributes: omit(attachment, ['url', 'needsIamgeCompression']), downloadData, deleteData: { token } } diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 9137e3fffc..32623e6479 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -35,12 +35,12 @@ export const imageUpload = async (imageFile: File, params: ?Object): Promise { // Uses a very small webP image to check if the browser supports 'image/webp' format. // (reference: https://developers.google.com/speed/webp/faq#in_your_own_javascript) - const verySmallWebP = 'data:image/webp;base64,UklGRhIAAABXRUJQVlA4WAoAAAAQAAAAMwAAQUxQSAwAAAAwAQCdASoEAAQAAVAfCWkAQUwAAAABABgAAgAAAAAABAAAAAAAAAA' + const verySmallWebP = 'data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA' const img = new Image() return new Promise(resolve => { - img.onload = () => resolve(img.height > 0) - img.onerror = () => resolve(false) + img.onload = () => { resolve(img.height > 0) } + img.onerror = (e) => { resolve(false) } img.src = verySmallWebP }) } @@ -82,12 +82,14 @@ function generateImageBlobByCanvas ({ }) } -export async function compressImage (imgUrl: string): Promise { - // Takes a source image url and generate another objectURL of the compressed image. +export async function compressImage (imgUrl: string, sourceMimeType?: string): Promise { + // Takes a source image url and generate a blob of the compressed image. - // According to the testing result, 0.8 is a good starting point for both resizingFactor and quality. - let resizingFactor = 0.8 - let quality = 0.8 + // According to the testing result, 0.8 is a good starting point for both resizingFactor and quality for .jpeg and .webp. + // For other image types, we use 0.9 as the starting point. + const defaultFactor = ['image/jpeg', 'image/webp'].includes(sourceMimeType) ? 0.8 : 0.9 + let resizingFactor = defaultFactor + let quality = defaultFactor // According to the testing result, webP format has a better compression ratio than jpeg. const compressToType = await supportsWebP() ? 'image/webp' : 'image/jpeg' const sourceImage = await loadImage(imgUrl) @@ -103,7 +105,7 @@ export async function compressImage (imgUrl: string): Promise { if (sizeDiff <= 0 || // if the compressed image is already smaller than the max size, return the compressed image. quality <= 0.4) { // Do not sacrifice the image quality too much. - return URL.createObjectURL(blob) + return blob } else { // if the size difference is greater than 100KB, reduce the next compression factors by 10%, otherwise 5%. const minusFactor = sizeDiff > 100 * 1000 ? 0.1 : 0.05 diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index 3fe61dce30..e93b4431f6 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -690,7 +690,7 @@ export default ({ 'send', msgToSend, this.hasAttachments - ? cloneDeep(this.ephemeral.attachments).map(this.compressImage) + ? cloneDeep(this.ephemeral.attachments) : null, this.replyingMessage ) // TODO remove first / last empty lines @@ -699,12 +699,6 @@ export default ({ this.endMention() if (this.hasAttachments) { this.clearAllAttachments() } }, - compressImage (attachment) { - if (attachment.needsIamgeCompression) { - console.log('TODO: implement image compression logic here') - } - return attachment - }, openCreatePollModal () { const bbox = this.$el.getBoundingClientRect() this.$refs.poll.open({ From 342892a9140fb9b8c2f8d9c5e67faac718ee0119 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Tue, 19 Nov 2024 13:37:38 +1300 Subject: [PATCH 06/16] update the minimum quality --- frontend/utils/image.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 32623e6479..9fe47080a0 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -104,7 +104,7 @@ export async function compressImage (imgUrl: string, sourceMimeType?: string): P const sizeDiff = blob.size - IMAGE_ATTACHMENT_MAX_SIZE if (sizeDiff <= 0 || // if the compressed image is already smaller than the max size, return the compressed image. - quality <= 0.4) { // Do not sacrifice the image quality too much. + quality <= 0.3) { // Do not sacrifice the image quality too much. return blob } else { // if the size difference is greater than 100KB, reduce the next compression factors by 10%, otherwise 5%. From 657ff07a329fe1fe9b80ad8f5fdc6b5fe06495c0 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Wed, 20 Nov 2024 09:05:41 +1300 Subject: [PATCH 07/16] fix the typo iamge --- frontend/controller/actions/identity.js | 8 ++++---- frontend/views/containers/chatroom/SendArea.vue | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index d9076e96f0..8ab7634513 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -587,13 +587,13 @@ export default (sbp('sbp/selectors/register', { const { identityContractID } = sbp('state/vuex/state').loggedIn try { const attachmentsData = await Promise.all(attachments.map(async (attachment) => { - const { url, needsIamgeCompression } = attachment + const { url, needsImageCompression } = attachment // url here is an instance of URL.createObjectURL(), which needs to be converted to a 'Blob' - const attachmentBlob = needsIamgeCompression + const attachmentBlob = needsImageCompression ? await compressImage(url) : await objectURLtoBlob(url) - if (needsIamgeCompression) { + if (needsImageCompression) { // Update the attachment details to reflect the compressed image. const fileNameWithoutExtension = attachment.name.split('.').slice(0, -1).join('.') const extension = attachmentBlob.type.split('/')[1] @@ -607,7 +607,7 @@ export default (sbp('sbp/selectors/register', { }, { billableContractID }) const { delete: token, download: downloadData } = response return { - attributes: omit(attachment, ['url', 'needsIamgeCompression']), + attributes: omit(attachment, ['url', 'needsImageCompression']), downloadData, deleteData: { token } } diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index e93b4431f6..0fb986227b 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -756,7 +756,7 @@ export default ({ img.src = fileUrl // Determine if the image needs lossy-compression before upload. - attachment.needsIamgeCompression = fileSize > IMAGE_ATTACHMENT_MAX_SIZE + attachment.needsImageCompression = fileSize > IMAGE_ATTACHMENT_MAX_SIZE } list.push(attachment) From db4564d79b83fd5d1926a472758128d226ba5db7 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Wed, 20 Nov 2024 12:43:40 +1300 Subject: [PATCH 08/16] do not resize the image unless its physical size is too large --- frontend/utils/image.js | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 9fe47080a0..88e62da59b 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -82,18 +82,30 @@ function generateImageBlobByCanvas ({ }) } +function getResizingFactor (sourceImage) { + // If image's physical size is greater than the max dimension, resize the image to the max dimension. + const imageMaxDimension = { width: 1024, height: 768 } + const { naturalWidth, naturalHeight } = sourceImage + + if (naturalWidth > imageMaxDimension.width || naturalHeight > imageMaxDimension.height) { + return Math.min(imageMaxDimension.width / naturalWidth, imageMaxDimension.height / naturalHeight) + } + + return 1 +} + export async function compressImage (imgUrl: string, sourceMimeType?: string): Promise { // Takes a source image url and generate a blob of the compressed image. - // According to the testing result, 0.8 is a good starting point for both resizingFactor and quality for .jpeg and .webp. - // For other image types, we use 0.9 as the starting point. - const defaultFactor = ['image/jpeg', 'image/webp'].includes(sourceMimeType) ? 0.8 : 0.9 - let resizingFactor = defaultFactor - let quality = defaultFactor // According to the testing result, webP format has a better compression ratio than jpeg. const compressToType = await supportsWebP() ? 'image/webp' : 'image/jpeg' const sourceImage = await loadImage(imgUrl) + // According to the testing result, 0.8 is a good starting point for quality for .jpeg and .webp. + // For other image types, use 0.9 as the starting point. + let quality = ['image/jpeg', 'image/webp'].includes(sourceMimeType) ? 0.8 : 0.9 + const resizingFactor = getResizingFactor(sourceImage) + while (true) { const blob = await generateImageBlobByCanvas({ sourceImage, @@ -109,7 +121,6 @@ export async function compressImage (imgUrl: string, sourceMimeType?: string): P } else { // if the size difference is greater than 100KB, reduce the next compression factors by 10%, otherwise 5%. const minusFactor = sizeDiff > 100 * 1000 ? 0.1 : 0.05 - resizingFactor -= minusFactor quality -= minusFactor } } From 6a7cd96e00e30ab7e112dc851b61b56ef339924d Mon Sep 17 00:00:00 2001 From: SebinSong Date: Wed, 27 Nov 2024 14:22:08 +1300 Subject: [PATCH 09/16] attachment data in contracts needs size field --- frontend/model/contracts/shared/types.js | 4 +++- .../views/containers/chatroom/SendArea.vue | 1 + .../file-attachment/ChatAttachmentPreview.vue | 19 ++++++++++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/model/contracts/shared/types.js b/frontend/model/contracts/shared/types.js index 2e9ae671be..40a2ff5e6a 100644 --- a/frontend/model/contracts/shared/types.js +++ b/frontend/model/contracts/shared/types.js @@ -2,7 +2,7 @@ import { objectOf, objectMaybeOf, arrayOf, unionOf, boolean, - object, string, stringMax, optional, number, mapOf, literalOf + object, string, stringMax, optional, number, mapOf, literalOf, numberRange } from '~/frontend/model/contracts/misc/flowTyper.js' import { CHATROOM_TYPES, @@ -19,6 +19,7 @@ import { CHATROOM_NAME_LIMITS_IN_CHARS, CHATROOM_DESCRIPTION_LIMITS_IN_CHARS } from './constants.js' +import { CHAT_ATTACHMENT_SIZE_LIMIT } from '~/frontend/utils/constants.js' // group.js related @@ -67,6 +68,7 @@ export const messageType: any = objectMaybeOf({ attachments: arrayOf(objectOf({ name: string, mimeType: string, + size: optional(numberRange(1, CHAT_ATTACHMENT_SIZE_LIMIT)), dimension: optional(objectOf({ width: number, height: number diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index 8885d02d72..e8ffc99736 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -748,6 +748,7 @@ export default ({ url: fileUrl, name: file.name, mimeType: file.type || '', + size: fileSize, downloadData: null // NOTE: we can tell if the attachment has been uploaded by seeing if this field is non-null. } diff --git a/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue b/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue index 0edef12a61..5735f88037 100644 --- a/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue +++ b/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue @@ -15,7 +15,9 @@ .c-non-image-file-info .c-file-name.has-ellipsis {{ entry.name }} - .c-file-ext {{ fileExt(entry) }} + .c-file-ext-and-size + .c-file-ext {{ fileExt(entry) }} + .c-file-size(v-if='entry.size') {{ fileSize(entry) }} .c-preview-img(v-else) img( @@ -154,6 +156,9 @@ export default { fileExt ({ name }) { return getFileExtension(name, true) }, + fileSize ({ size }) { + return size ? `${size} bytes` : '' + }, fileType ({ mimeType }) { return getFileType(mimeType) }, @@ -267,8 +272,16 @@ export default { &.is-for-download { padding: 0; - .c-preview-non-image .c-non-image-file-info { - width: calc(100% - 4rem); + .c-preview-non-image { + .c-non-image-file-info { + width: calc(100% - 4rem); + } + + .c-file-ext-and-size { + display: flex; + flex-direction: row; + column-gap: 0.25rem; + } } .c-attachment-actions-wrapper { From 2f2855de0f5b86ba883f30b05cb1161711199666 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Wed, 27 Nov 2024 15:33:27 +1300 Subject: [PATCH 10/16] file-size to a human readable string / display it in multiple UIs --- .../file-attachment/ChatAttachmentPreview.vue | 26 ++++++++++++++----- frontend/views/utils/filters.js | 11 ++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue b/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue index 5735f88037..8435bda84d 100644 --- a/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue +++ b/frontend/views/containers/chatroom/file-attachment/ChatAttachmentPreview.vue @@ -17,7 +17,7 @@ .c-file-name.has-ellipsis {{ entry.name }} .c-file-ext-and-size .c-file-ext {{ fileExt(entry) }} - .c-file-size(v-if='entry.size') {{ fileSize(entry) }} + .c-file-size(v-if='entry.size') {{ fileSizeDisplay(entry) }} .c-preview-img(v-else) img( @@ -38,7 +38,7 @@ .c-attachment-actions tooltip( direction='top' - :text='L("Download")' + :text='getDownloadTooltipText(entry)' ) button.is-icon-small( :aria-label='L("Download")' @@ -93,9 +93,10 @@ import sbp from '@sbp/sbp' import Tooltip from '@components/Tooltip.vue' import { MESSAGE_VARIANTS } from '@model/contracts/shared/constants.js' -import { getFileExtension, getFileType } from '@view-utils/filters.js' +import { getFileExtension, getFileType, formatBytesDecimal } from '@view-utils/filters.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' import { OPEN_MODAL } from '@utils/events.js' +import { L } from '@common/common.js' export default { name: 'ChatAttachmentPreview', @@ -156,8 +157,13 @@ export default { fileExt ({ name }) { return getFileExtension(name, true) }, - fileSize ({ size }) { - return size ? `${size} bytes` : '' + fileSizeDisplay ({ size }) { + return size ? formatBytesDecimal(size) : '' + }, + getDownloadTooltipText ({ size }) { + return this.shouldPreviewImages + ? `${L('Download ({size})', { size: formatBytesDecimal(size) })}` + : L('Download') }, fileType ({ mimeType }) { return getFileType(mimeType) @@ -279,8 +285,14 @@ export default { .c-file-ext-and-size { display: flex; + align-items: flex-end; flex-direction: row; - column-gap: 0.25rem; + column-gap: 0.325rem; + } + + .c-file-size { + color: $text_1; + font-size: 0.8em; } } @@ -314,6 +326,8 @@ export default { .is-download-item { &:hover .c-attachment-actions-wrapper { display: flex; + flex-direction: column; + align-items: flex-end; } .c-preview-non-image { diff --git a/frontend/views/utils/filters.js b/frontend/views/utils/filters.js index f2642435d1..4c2d57bb9e 100644 --- a/frontend/views/utils/filters.js +++ b/frontend/views/utils/filters.js @@ -15,6 +15,17 @@ export const getFileType = ( return mimeType.match('image/') ? 'image' : 'non-image' } +export const formatBytesDecimal = (bytes: number, decimals: number = 2): string => { + if (bytes === 0) { return '0 Bytes' } + + const k = 1000 // Decimal base + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + const formattedValue = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + return `${formattedValue} ${sizes[i]}` +} + /** * this function filters `list` by `keyword` * `list` should be an array of objects and strings From 73d18dbd7e6f3eeeedf2c582945254dfc79c80fb Mon Sep 17 00:00:00 2001 From: SebinSong Date: Thu, 28 Nov 2024 11:21:27 +1300 Subject: [PATCH 11/16] update according to feedback --- frontend/model/contracts/shared/types.js | 29 ++++++++++++------------ 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/frontend/model/contracts/shared/types.js b/frontend/model/contracts/shared/types.js index 40a2ff5e6a..c0a7aaefea 100644 --- a/frontend/model/contracts/shared/types.js +++ b/frontend/model/contracts/shared/types.js @@ -19,7 +19,6 @@ import { CHATROOM_NAME_LIMITS_IN_CHARS, CHATROOM_DESCRIPTION_LIMITS_IN_CHARS } from './constants.js' -import { CHAT_ATTACHMENT_SIZE_LIMIT } from '~/frontend/utils/constants.js' // group.js related @@ -65,19 +64,21 @@ export const messageType: any = objectMaybeOf({ type: unionOf(...Object.values(MESSAGE_NOTIFICATIONS).map(v => literalOf(v))), params: mapOf(string, string) // { username } }), - attachments: arrayOf(objectOf({ - name: string, - mimeType: string, - size: optional(numberRange(1, CHAT_ATTACHMENT_SIZE_LIMIT)), - dimension: optional(objectOf({ - width: number, - height: number - })), - downloadData: objectOf({ - manifestCid: string, - downloadParams: optional(object) - }) - })), + attachments: optional( + arrayOf(objectOf({ + name: string, + mimeType: string, + size: optional(numberRange(1, Number.MAX_SAFE_INTEGER)), + dimension: optional(objectOf({ + width: number, + height: number + })), + downloadData: objectOf({ + manifestCid: string, + downloadParams: optional(object) + }) + })) + ), replyingMessage: objectOf({ hash: string, // scroll to the original message and highlight text: string // display text(if too long, truncate) From 1cb398e374e7f5e0c361d9f817da591d2f95de8d Mon Sep 17 00:00:00 2001 From: SebinSong Date: Mon, 2 Dec 2024 14:50:28 +1300 Subject: [PATCH 12/16] update maximum image dimension --- frontend/utils/image.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 88e62da59b..51b050c109 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -84,9 +84,10 @@ function generateImageBlobByCanvas ({ function getResizingFactor (sourceImage) { // If image's physical size is greater than the max dimension, resize the image to the max dimension. - const imageMaxDimension = { width: 1024, height: 768 } + const imageMaxDimension = { width: 2048, height: 1536 } const { naturalWidth, naturalHeight } = sourceImage + console.log(`!@# naturalWidth: ${naturalWidth}, naturalHeight: ${naturalHeight}`) if (naturalWidth > imageMaxDimension.width || naturalHeight > imageMaxDimension.height) { return Math.min(imageMaxDimension.width / naturalWidth, imageMaxDimension.height / naturalHeight) } @@ -98,7 +99,8 @@ export async function compressImage (imgUrl: string, sourceMimeType?: string): P // Takes a source image url and generate a blob of the compressed image. // According to the testing result, webP format has a better compression ratio than jpeg. - const compressToType = await supportsWebP() ? 'image/webp' : 'image/jpeg' + // const compressToType = await supportsWebP() ? 'image/webp' : 'image/jpeg' + const compressToType = 'image/jpeg' const sourceImage = await loadImage(imgUrl) // According to the testing result, 0.8 is a good starting point for quality for .jpeg and .webp. @@ -106,6 +108,7 @@ export async function compressImage (imgUrl: string, sourceMimeType?: string): P let quality = ['image/jpeg', 'image/webp'].includes(sourceMimeType) ? 0.8 : 0.9 const resizingFactor = getResizingFactor(sourceImage) + console.log(`!@# resizingFactor: ${resizingFactor}`) while (true) { const blob = await generateImageBlobByCanvas({ sourceImage, From d4eac0466fc7bd4536917023f65a06d79e925831 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Mon, 2 Dec 2024 14:57:22 +1300 Subject: [PATCH 13/16] remove console.log / revert back webP format as default --- frontend/utils/image.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 51b050c109..abffb5ab0c 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -87,7 +87,6 @@ function getResizingFactor (sourceImage) { const imageMaxDimension = { width: 2048, height: 1536 } const { naturalWidth, naturalHeight } = sourceImage - console.log(`!@# naturalWidth: ${naturalWidth}, naturalHeight: ${naturalHeight}`) if (naturalWidth > imageMaxDimension.width || naturalHeight > imageMaxDimension.height) { return Math.min(imageMaxDimension.width / naturalWidth, imageMaxDimension.height / naturalHeight) } @@ -99,8 +98,7 @@ export async function compressImage (imgUrl: string, sourceMimeType?: string): P // Takes a source image url and generate a blob of the compressed image. // According to the testing result, webP format has a better compression ratio than jpeg. - // const compressToType = await supportsWebP() ? 'image/webp' : 'image/jpeg' - const compressToType = 'image/jpeg' + const compressToType = await supportsWebP() ? 'image/webp' : 'image/jpeg' const sourceImage = await loadImage(imgUrl) // According to the testing result, 0.8 is a good starting point for quality for .jpeg and .webp. @@ -108,7 +106,6 @@ export async function compressImage (imgUrl: string, sourceMimeType?: string): P let quality = ['image/jpeg', 'image/webp'].includes(sourceMimeType) ? 0.8 : 0.9 const resizingFactor = getResizingFactor(sourceImage) - console.log(`!@# resizingFactor: ${resizingFactor}`) while (true) { const blob = await generateImageBlobByCanvas({ sourceImage, From 1985f28feda882fd83e1a646bca81364e2486c7c Mon Sep 17 00:00:00 2001 From: SebinSong Date: Tue, 3 Dec 2024 09:53:18 +1300 Subject: [PATCH 14/16] add a constant for kilo-byte --- frontend/utils/constants.js | 3 ++- frontend/utils/image.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/utils/constants.js b/frontend/utils/constants.js index 27e9900cc6..3017444abf 100644 --- a/frontend/utils/constants.js +++ b/frontend/utils/constants.js @@ -18,9 +18,10 @@ export const CHAT_ATTACHMENT_SUPPORTED_EXTENSIONS = [ // NOTE: Below value was obtained from the '413 Payload Too Large' server error // meaning if this limit is updated on the server-side, an update is required here too. // TODO: fetch this value from a server API +export const KILOBYTE = 1 << 10 export const MEGABYTE = 1 << 20 export const CHAT_ATTACHMENT_SIZE_LIMIT = 30 * MEGABYTE // in byte. -export const IMAGE_ATTACHMENT_MAX_SIZE = 400000 // 400KB +export const IMAGE_ATTACHMENT_MAX_SIZE = 400 * KILOBYTE // 400KB export const TextObjectType = { Text: 'TEXT', diff --git a/frontend/utils/image.js b/frontend/utils/image.js index abffb5ab0c..66fd97bca5 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -1,7 +1,7 @@ 'use strict' import sbp from '@sbp/sbp' -import { IMAGE_ATTACHMENT_MAX_SIZE } from './constants.js' +import { KILOBYTE, IMAGE_ATTACHMENT_MAX_SIZE } from './constants.js' // Copied from https://stackoverflow.com/questions/11876175/how-to-get-a-file-or-blob-from-an-object-url export function objectURLtoBlob (url: string): Promise { @@ -120,7 +120,7 @@ export async function compressImage (imgUrl: string, sourceMimeType?: string): P return blob } else { // if the size difference is greater than 100KB, reduce the next compression factors by 10%, otherwise 5%. - const minusFactor = sizeDiff > 100 * 1000 ? 0.1 : 0.05 + const minusFactor = sizeDiff > 100 * KILOBYTE ? 0.1 : 0.05 quality -= minusFactor } } From 333dd194d47c0db5d33a997d86f1f177180f08b0 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Tue, 3 Dec 2024 14:08:10 +1300 Subject: [PATCH 15/16] updates for PR review --- frontend/model/contracts/shared/types.js | 2 +- frontend/views/utils/filters.js | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/model/contracts/shared/types.js b/frontend/model/contracts/shared/types.js index c0a7aaefea..37c79cc260 100644 --- a/frontend/model/contracts/shared/types.js +++ b/frontend/model/contracts/shared/types.js @@ -68,7 +68,7 @@ export const messageType: any = objectMaybeOf({ arrayOf(objectOf({ name: string, mimeType: string, - size: optional(numberRange(1, Number.MAX_SAFE_INTEGER)), + size: numberRange(1, Number.MAX_SAFE_INTEGER), dimension: optional(objectOf({ width: number, height: number diff --git a/frontend/views/utils/filters.js b/frontend/views/utils/filters.js index 4c2d57bb9e..f5abfd0819 100644 --- a/frontend/views/utils/filters.js +++ b/frontend/views/utils/filters.js @@ -1,3 +1,5 @@ +import { L } from '@common/common.js' + export const toPercent = (decimal: number): number => Math.floor(decimal * 100) export const getFileExtension = ( @@ -16,9 +18,10 @@ export const getFileType = ( } export const formatBytesDecimal = (bytes: number, decimals: number = 2): string => { - if (bytes === 0) { return '0 Bytes' } + if (bytes < 0 || !Number.isFinite(bytes)) return L('Invalid size') + else if (bytes === 0) return L('0 Bytes') - const k = 1000 // Decimal base + const k = 1024 // Decimal base const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) From b638fce3a4832b83eaef9d43cebd123a4d6661b4 Mon Sep 17 00:00:00 2001 From: SebinSong Date: Tue, 3 Dec 2024 14:14:00 +1300 Subject: [PATCH 16/16] file size update after image compression --- frontend/controller/actions/identity.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 8ab7634513..3fcd3a745f 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -600,6 +600,7 @@ export default (sbp('sbp/selectors/register', { attachment.mimeType = attachmentBlob.type attachment.name = `${fileNameWithoutExtension}.${extension}` + attachment.size = attachmentBlob.size } const response = await sbp('chelonia/fileUpload', attachmentBlob, { type: attachment.mimeType,