Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#2411 - Compress large images attached in the chat #2428

Merged
merged 11 commits into from
Dec 2, 2024
22 changes: 17 additions & 5 deletions frontend/controller/actions/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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, needsImageCompression } = attachment
// url here is an instance of URL.createObjectURL(), which needs to be converted to a 'Blob'
const attachmentBlob = await objectURLtoBlob(url)
const attachmentBlob = needsImageCompression
? await compressImage(url)
: await objectURLtoBlob(url)

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]

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', 'needsImageCompression']),
downloadData,
deleteData: { token }
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +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 = 400 * KILOBYTE // 400KB

export const TextObjectType = {
Text: 'TEXT',
Expand Down
97 changes: 97 additions & 0 deletions frontend/utils/image.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'

import sbp from '@sbp/sbp'
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<Blob> {
Expand Down Expand Up @@ -28,3 +29,99 @@ export const imageUpload = async (imageFile: File, params: ?Object): Promise<Obj
const { download } = await sbp('chelonia/fileUpload', imageFile, { type: file.type, cipher: 'aes256gcm' }, params)
return download
}

// Image compression

export function supportsWebP (): Promise<boolean> {
// 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,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA'
const img = new Image()

return new Promise(resolve => {
img.onload = () => { resolve(img.height > 0) }
img.onerror = (e) => { resolve(false) }
img.src = verySmallWebP
})
}

function loadImage (url): any {
const imgEl = new Image()

return new Promise((resolve) => {
imgEl.onload = () => { resolve(imgEl) }
imgEl.src = url
})
}

function generateImageBlobByCanvas ({
sourceImage,
resizingFactor,
quality,
compressToType
}) {
const { naturalWidth, naturalHeight } = sourceImage
const canvasEl = document.createElement('canvas')
const c = canvasEl.getContext('2d')

canvasEl.width = naturalWidth * resizingFactor
canvasEl.height = naturalHeight * resizingFactor

c.drawImage(
sourceImage,
0,
0,
canvasEl.width,
canvasEl.height
)

return new Promise((resolve) => {
canvasEl.toBlob(blob => {
resolve(blob)
}, compressToType, quality)
})
}

function getResizingFactor (sourceImage) {
// If image's physical size is greater than the max dimension, resize the image to the max dimension.
const imageMaxDimension = { width: 2048, height: 1536 }
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<any> {
// 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 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,
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.3) { // Do not sacrifice the image quality too much.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the multiple tests I've done, when the quality factor value passed to canvas.toBlob() becomes <= 0.3, the outcome image starts to lose the quality to an unpleasant level. 0.3 here is a value I chose subjectively, but feel free to try compression test yourself using this codepen I created for this task and let me know if you would like a change.

return blob
} else {
// if the size difference is greater than 100KB, reduce the next compression factors by 10%, otherwise 5%.
const minusFactor = sizeDiff > 100 * KILOBYTE ? 0.1 : 0.05
quality -= minusFactor
}
}
}
9 changes: 7 additions & 2 deletions frontend/views/containers/chatroom/SendArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -689,7 +689,9 @@ export default ({
this.$emit(
'send',
msgToSend,
this.hasAttachments ? cloneDeep(this.ephemeral.attachments) : null,
this.hasAttachments
? cloneDeep(this.ephemeral.attachments)
: null,
this.replyingMessage
) // TODO remove first / last empty lines
this.$refs.textarea.value = ''
Expand Down Expand Up @@ -752,6 +754,9 @@ export default ({
attachment.dimension = { width, height }
}
img.src = fileUrl

// Determine if the image needs lossy-compression before upload.
attachment.needsImageCompression = fileSize > IMAGE_ATTACHMENT_MAX_SIZE
}

list.push(attachment)
Expand Down