Skip to content

Commit

Permalink
Merge branch 'sebin/task/#2411-compress-images-before-uploading' into…
Browse files Browse the repository at this point in the history
… sebin/task/#2429-file-should-show-size
  • Loading branch information
SebinSong committed Dec 2, 2024
2 parents 73d18db + 1985f28 commit 2ae723d
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 7 deletions.
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.
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 @@ -695,7 +695,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 @@ -759,6 +761,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

0 comments on commit 2ae723d

Please sign in to comment.