Fix image contentType when transcoding
This commit is contained in:
parent
b7e5efe0a3
commit
e7a2365905
4 changed files with 78 additions and 194 deletions
|
@ -1,31 +0,0 @@
|
||||||
// Copyright 2018-2021 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import loadImage, { LoadImageOptions } from 'blueimp-load-image';
|
|
||||||
import { IMAGE_JPEG } from '../types/MIME';
|
|
||||||
import { canvasToBlob } from './canvasToBlob';
|
|
||||||
|
|
||||||
const DEFAULT_JPEG_QUALITY = 0.85;
|
|
||||||
|
|
||||||
export async function autoOrientImage(blob: Blob): Promise<Blob> {
|
|
||||||
const options: LoadImageOptions = {
|
|
||||||
canvas: true,
|
|
||||||
orientation: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await loadImage(blob, options);
|
|
||||||
const { image } = data;
|
|
||||||
if (image instanceof HTMLCanvasElement) {
|
|
||||||
// We `return await`, instead of just `return`, so we capture the rejection in this
|
|
||||||
// try/catch block. See [this blog post][0] for more background.
|
|
||||||
// [0]: https://jakearchibald.com/2017/await-vs-return-vs-return-await/
|
|
||||||
return await canvasToBlob(image, IMAGE_JPEG, DEFAULT_JPEG_QUALITY);
|
|
||||||
}
|
|
||||||
throw new Error('image not a canvas');
|
|
||||||
} catch (err) {
|
|
||||||
const error = new Error('autoOrientImage: Failed to process image');
|
|
||||||
error.originalError = err;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
61
ts/util/handleImageAttachment.ts
Normal file
61
ts/util/handleImageAttachment.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { MIMEType, IMAGE_JPEG } from '../types/MIME';
|
||||||
|
import {
|
||||||
|
InMemoryAttachmentDraftType,
|
||||||
|
canBeTranscoded,
|
||||||
|
} from '../types/Attachment';
|
||||||
|
import { imageToBlurHash } from './imageToBlurHash';
|
||||||
|
import { scaleImageToLevel } from './scaleImageToLevel';
|
||||||
|
|
||||||
|
export async function handleImageAttachment(
|
||||||
|
file: File
|
||||||
|
): Promise<InMemoryAttachmentDraftType> {
|
||||||
|
const blurHash = await imageToBlurHash(file);
|
||||||
|
|
||||||
|
const { contentType, file: resizedBlob, fileName } = await autoScale({
|
||||||
|
contentType: file.type as MIMEType,
|
||||||
|
fileName: file.name,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
const data = await window.Signal.Types.VisualAttachment.blobToArrayBuffer(
|
||||||
|
resizedBlob
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
fileName: fileName || file.name,
|
||||||
|
contentType,
|
||||||
|
data,
|
||||||
|
size: data.byteLength,
|
||||||
|
blurHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoScale({
|
||||||
|
contentType,
|
||||||
|
file,
|
||||||
|
fileName,
|
||||||
|
}: {
|
||||||
|
contentType: MIMEType;
|
||||||
|
file: File | Blob;
|
||||||
|
fileName: string;
|
||||||
|
}): Promise<{
|
||||||
|
contentType: MIMEType;
|
||||||
|
file: Blob;
|
||||||
|
fileName: string;
|
||||||
|
}> {
|
||||||
|
if (!canBeTranscoded({ contentType })) {
|
||||||
|
return { contentType, file, fileName };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await scaleImageToLevel(file, true);
|
||||||
|
|
||||||
|
const { name } = path.parse(fileName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType: IMAGE_JPEG,
|
||||||
|
file: blob,
|
||||||
|
fileName: `${name}.jpeg`,
|
||||||
|
};
|
||||||
|
}
|
|
@ -112,9 +112,6 @@ export async function scaleImageToLevel(
|
||||||
throw new Error('image not a canvas');
|
throw new Error('image not a canvas');
|
||||||
}
|
}
|
||||||
({ image } = data);
|
({ image } = data);
|
||||||
if (!(image instanceof HTMLCanvasElement)) {
|
|
||||||
throw new Error('image not a canvas');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = new Error('scaleImageToLevel: Failed to process image');
|
const error = new Error('scaleImageToLevel: Failed to process image');
|
||||||
error.originalError = err;
|
error.originalError = err;
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from '../types/Attachment';
|
} from '../types/Attachment';
|
||||||
import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
|
import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
|
||||||
import * as Stickers from '../types/Stickers';
|
import * as Stickers from '../types/Stickers';
|
||||||
import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
|
import { MIMEType, IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
|
||||||
import { ConversationModel } from '../models/conversations';
|
import { ConversationModel } from '../models/conversations';
|
||||||
import {
|
import {
|
||||||
GroupV2PendingMemberType,
|
GroupV2PendingMemberType,
|
||||||
|
@ -43,8 +43,6 @@ import {
|
||||||
import { getMessagesByConversation } from '../state/selectors/conversations';
|
import { getMessagesByConversation } from '../state/selectors/conversations';
|
||||||
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
|
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
|
||||||
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
|
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
|
||||||
import { autoOrientImage } from '../util/autoOrientImage';
|
|
||||||
import { canvasToBlob } from '../util/canvasToBlob';
|
|
||||||
import {
|
import {
|
||||||
LinkPreviewImage,
|
LinkPreviewImage,
|
||||||
LinkPreviewResult,
|
LinkPreviewResult,
|
||||||
|
@ -52,6 +50,10 @@ import {
|
||||||
} from '../types/LinkPreview';
|
} from '../types/LinkPreview';
|
||||||
import * as LinkPreview from '../types/LinkPreview';
|
import * as LinkPreview from '../types/LinkPreview';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
|
import {
|
||||||
|
autoScale,
|
||||||
|
handleImageAttachment,
|
||||||
|
} from '../util/handleImageAttachment';
|
||||||
|
|
||||||
type AttachmentOptions = {
|
type AttachmentOptions = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
@ -1858,7 +1860,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return toWrite;
|
return toWrite;
|
||||||
},
|
},
|
||||||
|
|
||||||
async maybeAddAttachment(file: any) {
|
async maybeAddAttachment(file: File): Promise<void> {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1892,8 +1894,10 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileType = file.type as MIMEType;
|
||||||
|
|
||||||
// You can't add a non-image attachment if you already have attachments staged
|
// You can't add a non-image attachment if you already have attachments staged
|
||||||
if (!MIME.isImage(file.type) && draftAttachments.length > 0) {
|
if (!MIME.isImage(fileType) && draftAttachments.length > 0) {
|
||||||
this.showToast(Whisper.CannotMixImageAndNonImageAttachmentsToast);
|
this.showToast(Whisper.CannotMixImageAndNonImageAttachmentsToast);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1901,10 +1905,10 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
let attachment: InMemoryAttachmentDraftType;
|
let attachment: InMemoryAttachmentDraftType;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (window.Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) {
|
if (window.Signal.Util.GoogleChrome.isImageTypeSupported(fileType)) {
|
||||||
attachment = await this.handleImageAttachment(file);
|
attachment = await handleImageAttachment(file);
|
||||||
} else if (
|
} else if (
|
||||||
window.Signal.Util.GoogleChrome.isVideoTypeSupported(file.type)
|
window.Signal.Util.GoogleChrome.isVideoTypeSupported(fileType)
|
||||||
) {
|
) {
|
||||||
attachment = await this.handleVideoAttachment(file);
|
attachment = await this.handleVideoAttachment(file);
|
||||||
} else {
|
} else {
|
||||||
|
@ -1912,20 +1916,20 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
attachment = {
|
attachment = {
|
||||||
data,
|
data,
|
||||||
size: data.byteLength,
|
size: data.byteLength,
|
||||||
contentType: file.type,
|
contentType: fileType,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
`Was unable to generate thumbnail for file type ${file.type}`,
|
`Was unable to generate thumbnail for fileType ${fileType}`,
|
||||||
e && e.stack ? e.stack : e
|
e && e.stack ? e.stack : e
|
||||||
);
|
);
|
||||||
const data = await this.arrayBufferFromFile(file);
|
const data = await this.arrayBufferFromFile(file);
|
||||||
attachment = {
|
attachment = {
|
||||||
data,
|
data,
|
||||||
size: data.byteLength,
|
size: data.byteLength,
|
||||||
contentType: file.type,
|
contentType: fileType,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -2009,154 +2013,6 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleImageAttachment(file: any): Promise<InMemoryAttachmentDraftType> {
|
|
||||||
const blurHash = await window.imageToBlurHash(file);
|
|
||||||
if (MIME.isJPEG(file.type)) {
|
|
||||||
const rotatedBlob = await autoOrientImage(file);
|
|
||||||
const { contentType, file: resizedBlob, fileName } = await this.autoScale(
|
|
||||||
{
|
|
||||||
contentType: file.type,
|
|
||||||
fileName: file.name,
|
|
||||||
file: rotatedBlob,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const data = await VisualAttachment.blobToArrayBuffer(resizedBlob);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fileName: fileName || file.name,
|
|
||||||
contentType,
|
|
||||||
data,
|
|
||||||
size: data.byteLength,
|
|
||||||
blurHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { contentType, file: resizedBlob, fileName } = await this.autoScale({
|
|
||||||
contentType: file.type,
|
|
||||||
fileName: file.name,
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
const data = await VisualAttachment.blobToArrayBuffer(resizedBlob);
|
|
||||||
return {
|
|
||||||
fileName: fileName || file.name,
|
|
||||||
contentType,
|
|
||||||
data,
|
|
||||||
size: data.byteLength,
|
|
||||||
blurHash,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
autoScale(attachment: any) {
|
|
||||||
const { contentType, file, fileName } = attachment;
|
|
||||||
if (contentType.split('/')[0] !== 'image' || contentType === 'image/tiff') {
|
|
||||||
// nothing to do
|
|
||||||
return Promise.resolve(attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.onload = async () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
const maxSize = 6000 * 1024;
|
|
||||||
const maxHeight = 4096;
|
|
||||||
const maxWidth = 4096;
|
|
||||||
if (
|
|
||||||
img.naturalWidth <= maxWidth &&
|
|
||||||
img.naturalHeight <= maxHeight &&
|
|
||||||
file.size <= maxSize
|
|
||||||
) {
|
|
||||||
resolve(attachment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gifMaxSize = 25000 * 1024;
|
|
||||||
if (file.type === 'image/gif' && file.size <= gifMaxSize) {
|
|
||||||
resolve(attachment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.type === 'image/gif') {
|
|
||||||
reject(new Error('GIF is too large'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetContentType = IMAGE_JPEG;
|
|
||||||
const canvas = window.loadImage.scale(img, {
|
|
||||||
canvas: true,
|
|
||||||
maxWidth,
|
|
||||||
maxHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
let quality = 0.95;
|
|
||||||
let i = 4;
|
|
||||||
let blob;
|
|
||||||
do {
|
|
||||||
i -= 1;
|
|
||||||
// We want to do these operations in serial.
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
blob = await canvasToBlob(canvas, targetContentType, quality);
|
|
||||||
quality = (quality * maxSize) / blob.size;
|
|
||||||
// NOTE: During testing with a large image, we observed the
|
|
||||||
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
|
|
||||||
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax
|
|
||||||
if (quality < 0.5) {
|
|
||||||
quality = 0.5;
|
|
||||||
}
|
|
||||||
} while (i > 0 && blob.size > maxSize);
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
...attachment,
|
|
||||||
fileName: this.fixExtension(fileName, targetContentType),
|
|
||||||
contentType: targetContentType,
|
|
||||||
file: blob,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
img.onerror = (
|
|
||||||
_event: unknown,
|
|
||||||
_source: unknown,
|
|
||||||
_lineno: unknown,
|
|
||||||
_colno: unknown,
|
|
||||||
error: Error = new Error('Failed to load image for auto-scaling')
|
|
||||||
) => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getFileName(fileName?: string) {
|
|
||||||
if (!fileName) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileName.includes('.')) {
|
|
||||||
return fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileName.split('.').slice(0, -1).join('.');
|
|
||||||
},
|
|
||||||
|
|
||||||
getType(contentType?: string) {
|
|
||||||
if (!contentType) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contentType.includes('/')) {
|
|
||||||
return contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentType.split('/')[1];
|
|
||||||
},
|
|
||||||
|
|
||||||
fixExtension(fileName: string, contentType: string) {
|
|
||||||
const extension = this.getType(contentType);
|
|
||||||
const name = this.getFileName(fileName);
|
|
||||||
return `${name}.${extension}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
markAllAsVerifiedDefault(unverified: any) {
|
markAllAsVerifiedDefault(unverified: any) {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
unverified.map((contact: any) => {
|
unverified.map((contact: any) => {
|
||||||
|
@ -4324,11 +4180,12 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
|
|
||||||
// Ensure that this file is either small enough or is resized to meet our
|
// Ensure that this file is either small enough or is resized to meet our
|
||||||
// requirements for attachments
|
// requirements for attachments
|
||||||
const withBlob = await this.autoScale({
|
const withBlob = await autoScale({
|
||||||
contentType: fullSizeImage.contentType,
|
contentType: fullSizeImage.contentType,
|
||||||
file: new Blob([fullSizeImage.data], {
|
file: new Blob([fullSizeImage.data], {
|
||||||
type: fullSizeImage.contentType,
|
type: fullSizeImage.contentType,
|
||||||
}),
|
}),
|
||||||
|
fileName: title,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await this.arrayBufferFromFile(withBlob.file);
|
const data = await this.arrayBufferFromFile(withBlob.file);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue