diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index 2fc2d048a..36b9001ae 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -1,6 +1,8 @@ // Copyright 2019-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +const webpack = require('webpack'); + module.exports = ({ config }) => { config.entry.unshift( '!!style-loader!css-loader!sanitize.css', @@ -29,5 +31,11 @@ module.exports = ({ config }) => { net: 'net', }; + config.plugins.unshift( + new webpack.IgnorePlugin({ + resourceRegExp: /sharp$/, + }) + ); + return config; }; diff --git a/patches/@types+sharp+0.28.0.patch b/patches/@types+sharp+0.28.0.patch new file mode 100644 index 000000000..d9c3def47 --- /dev/null +++ b/patches/@types+sharp+0.28.0.patch @@ -0,0 +1,27 @@ +diff --git a/node_modules/@types/sharp/index.d.ts b/node_modules/@types/sharp/index.d.ts +index 3210332..4808af0 100755 +--- a/node_modules/@types/sharp/index.d.ts ++++ b/node_modules/@types/sharp/index.d.ts +@@ -23,7 +23,21 @@ import { Duplex } from "stream"; + * @returns A sharp instance that can be used to chain operations + */ + declare function sharp(options?: sharp.SharpOptions): sharp.Sharp; +-declare function sharp(input?: string | Buffer, options?: sharp.SharpOptions): sharp.Sharp; ++declare function sharp( ++ input?: ++ | Buffer ++ | Uint8Array ++ | Uint8ClampedArray ++ | Int8Array ++ | Uint16Array ++ | Int16Array ++ | Uint32Array ++ | Int32Array ++ | Float32Array ++ | Float64Array ++ | string, ++ options?: sharp.SharpOptions ++): sharp.Sharp; + + declare namespace sharp { + /** Object containing nested boolean values representing the available input and output formats/methods. */ diff --git a/preload.js b/preload.js index 2efec5111..994ddd509 100644 --- a/preload.js +++ b/preload.js @@ -378,7 +378,6 @@ try { window.emojiData = require('emoji-datasource'); window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance(); window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat; - window.loadImage = require('blueimp-load-image'); window.getGuid = require('uuid/v4'); const activeWindowService = new ActiveWindowService(); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 5544e8ff3..4bfbaa61a 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -209,7 +209,11 @@ export async function autoOrientJPEG( attachment.data, attachment.contentType ); - const xcodedDataBlob = await scaleImageToLevel(dataBlob, isIncoming); + const { blob: xcodedDataBlob } = await scaleImageToLevel( + dataBlob, + attachment.contentType, + isIncoming + ); const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob); // IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original diff --git a/ts/util/handleImageAttachment.ts b/ts/util/handleImageAttachment.ts index 7a4ccc3ac..b1fab77f9 100644 --- a/ts/util/handleImageAttachment.ts +++ b/ts/util/handleImageAttachment.ts @@ -73,7 +73,19 @@ export async function autoScale({ return { contentType, file, fileName }; } - const blob = await scaleImageToLevel(file, true); + const { blob, contentType: newContentType } = await scaleImageToLevel( + file, + contentType, + true + ); + + if (newContentType !== IMAGE_JPEG) { + return { + contentType, + file: blob, + fileName, + }; + } const { name } = path.parse(fileName); diff --git a/ts/util/scaleImageToLevel.ts b/ts/util/scaleImageToLevel.ts index 02ab09cfe..adcb2788e 100644 --- a/ts/util/scaleImageToLevel.ts +++ b/ts/util/scaleImageToLevel.ts @@ -1,9 +1,10 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import sharp from 'sharp'; import loadImage from 'blueimp-load-image'; -import { IMAGE_JPEG } from '../types/MIME'; +import { MIMEType, IMAGE_JPEG } from '../types/MIME'; import { canvasToBlob } from './canvasToBlob'; import { getValue } from '../RemoteConfig'; @@ -21,6 +22,7 @@ const DEFAULT_LEVEL_DATA = { maxDimensions: 1600, quality: 0.7, size: MiB, + thresholdSize: 0.2 * MiB, }; const MEDIA_QUALITY_LEVEL_DATA = new Map([ @@ -31,6 +33,7 @@ const MEDIA_QUALITY_LEVEL_DATA = new Map([ maxDimensions: 2048, quality: 0.75, size: MiB * 1.5, + thresholdSize: 0.3 * MiB, }, ], [ @@ -39,6 +42,7 @@ const MEDIA_QUALITY_LEVEL_DATA = new Map([ maxDimensions: 4096, quality: 0.75, size: MiB * 3, + thresholdSize: 0.4 * MiB, }, ], ]); @@ -82,7 +86,7 @@ function getMediaQualityLevel(): MediaQualityLevels { return countryValues.get('*') || DEFAULT_LEVEL; } -async function getCanvasBlob( +async function getCanvasBlobAsJPEG( image: HTMLCanvasElement, dimensions: number, quality: number @@ -98,10 +102,20 @@ async function getCanvasBlob( return canvasToBlob(canvas, IMAGE_JPEG, quality); } +async function stripImageFileEXIFData(file: File | Blob): Promise { + const arrayBuffer = await file.arrayBuffer(); + const xArrayBuffer = await sharp(new Uint8Array(arrayBuffer)).toBuffer(); + return new Blob([xArrayBuffer]); +} + export async function scaleImageToLevel( fileOrBlobOrURL: File | Blob, + contentType: MIMEType, sendAsHighQuality?: boolean -): Promise { +): Promise<{ + blob: Blob; + contentType: MIMEType; +}> { let image: HTMLCanvasElement; try { const data = await loadImage(fileOrBlobOrURL, { @@ -121,9 +135,17 @@ export async function scaleImageToLevel( const level = sendAsHighQuality ? MediaQualityLevels.Three : getMediaQualityLevel(); - const { maxDimensions, quality, size } = + const { maxDimensions, quality, size, thresholdSize } = MEDIA_QUALITY_LEVEL_DATA.get(level) || DEFAULT_LEVEL_DATA; + if (fileOrBlobOrURL.size <= thresholdSize) { + const blob = await stripImageFileEXIFData(fileOrBlobOrURL); + return { + blob, + contentType, + }; + } + for (let i = 0; i < SCALABLE_DIMENSIONS.length; i += 1) { const scalableDimensions = SCALABLE_DIMENSIONS[i]; if (maxDimensions < scalableDimensions) { @@ -132,11 +154,18 @@ export async function scaleImageToLevel( // We need these operations to be in serial // eslint-disable-next-line no-await-in-loop - const blob = await getCanvasBlob(image, scalableDimensions, quality); + const blob = await getCanvasBlobAsJPEG(image, scalableDimensions, quality); if (blob.size <= size) { - return blob; + return { + blob, + contentType: IMAGE_JPEG, + }; } } - return getCanvasBlob(image, MIN_DIMENSIONS, quality); + const blob = await getCanvasBlobAsJPEG(image, MIN_DIMENSIONS, quality); + return { + blob, + contentType: IMAGE_JPEG, + }; }