Skip to content

Commit

Permalink
Fix image contentType when transcoding
Browse files Browse the repository at this point in the history
Co-authored-by: Josh Perez <60019601+josh-signal@users.noreply.github.com>
  • Loading branch information
automated-signal and josh-signal committed Jul 28, 2021
1 parent 9d09484 commit 5d9901c
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 194 deletions.
31 changes: 0 additions & 31 deletions ts/util/autoOrientImage.ts

This file was deleted.

61 changes: 61 additions & 0 deletions ts/util/handleImageAttachment.ts
@@ -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`,
};
}
3 changes: 0 additions & 3 deletions ts/util/scaleImageToLevel.ts
Expand Up @@ -112,9 +112,6 @@ export async function scaleImageToLevel(
throw new Error('image not a canvas');
}
({ image } = data);
if (!(image instanceof HTMLCanvasElement)) {
throw new Error('image not a canvas');
}
} catch (err) {
const error = new Error('scaleImageToLevel: Failed to process image');
error.originalError = err;
Expand Down
177 changes: 17 additions & 160 deletions ts/views/conversation_view.ts
Expand Up @@ -12,7 +12,7 @@ import {
} from '../types/Attachment';
import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
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 {
GroupV2PendingMemberType,
Expand Down Expand Up @@ -43,15 +43,17 @@ import {
import { getMessagesByConversation } from '../state/selectors/conversations';
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
import { autoOrientImage } from '../util/autoOrientImage';
import { canvasToBlob } from '../util/canvasToBlob';
import {
LinkPreviewImage,
LinkPreviewResult,
LinkPreviewWithDomain,
} from '../types/LinkPreview';
import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf';
import {
autoScale,
handleImageAttachment,
} from '../util/handleImageAttachment';

type AttachmentOptions = {
messageId: string;
Expand Down Expand Up @@ -1858,7 +1860,7 @@ Whisper.ConversationView = Whisper.View.extend({
return toWrite;
},

async maybeAddAttachment(file: any) {
async maybeAddAttachment(file: File): Promise<void> {
if (!file) {
return;
}
Expand Down Expand Up @@ -1892,40 +1894,42 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}

const fileType = file.type as MIMEType;

// 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);
return;
}

let attachment: InMemoryAttachmentDraftType;

try {
if (window.Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) {
attachment = await this.handleImageAttachment(file);
if (window.Signal.Util.GoogleChrome.isImageTypeSupported(fileType)) {
attachment = await handleImageAttachment(file);
} else if (
window.Signal.Util.GoogleChrome.isVideoTypeSupported(file.type)
window.Signal.Util.GoogleChrome.isVideoTypeSupported(fileType)
) {
attachment = await this.handleVideoAttachment(file);
} else {
const data = await this.arrayBufferFromFile(file);
attachment = {
data,
size: data.byteLength,
contentType: file.type,
contentType: fileType,
fileName: file.name,
};
}
} catch (e) {
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
);
const data = await this.arrayBufferFromFile(file);
attachment = {
data,
size: data.byteLength,
contentType: file.type,
contentType: fileType,
fileName: file.name,
};
}
Expand Down Expand Up @@ -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) {
return Promise.all(
unverified.map((contact: any) => {
Expand Down Expand Up @@ -4324,11 +4180,12 @@ Whisper.ConversationView = Whisper.View.extend({

// Ensure that this file is either small enough or is resized to meet our
// requirements for attachments
const withBlob = await this.autoScale({
const withBlob = await autoScale({
contentType: fullSizeImage.contentType,
file: new Blob([fullSizeImage.data], {
type: fullSizeImage.contentType,
}),
fileName: title,
});

const data = await this.arrayBufferFromFile(withBlob.file);
Expand Down

0 comments on commit 5d9901c

Please sign in to comment.