Remove autoOrientJPEG and consolidate downscaling logic

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-03-06 16:31:37 -06:00 committed by GitHub
parent 70858d9063
commit 4b39639c68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 105 additions and 144 deletions

View file

@ -609,12 +609,12 @@ export async function fetchLinkPreviewImage(
const dataBlob = new Blob([data], { const dataBlob = new Blob([data], {
type: contentType, type: contentType,
}); });
const { blob: xcodedDataBlob } = await scaleImageToLevel( const { blob: xcodedDataBlob } = await scaleImageToLevel({
dataBlob, fileOrBlobOrURL: dataBlob,
contentType, contentType,
dataBlob.size, size: dataBlob.size,
false highQuality: false,
); });
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob); const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
data = new Uint8Array(xcodedDataArrayBuffer); data = new Uint8Array(xcodedDataArrayBuffer);

View file

@ -161,6 +161,7 @@ import { deriveProfileKeyVersion } from '../util/zkgroup';
import { incrementMessageCounter } from '../util/incrementMessageCounter'; import { incrementMessageCounter } from '../util/incrementMessageCounter';
import OS from '../util/os/osMain'; import OS from '../util/os/osMain';
import { getMessageAuthorText } from '../util/getMessageAuthorText'; import { getMessageAuthorText } from '../util/getMessageAuthorText';
import { downscaleOutgoingAttachment } from '../util/attachments';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -3818,7 +3819,7 @@ export class ConversationModel extends window.Backbone
// If there are link previews present in the message we shouldn't include // If there are link previews present in the message we shouldn't include
// any attachments as well. // any attachments as well.
const attachmentsToSend = preview && preview.length ? [] : attachments; let attachmentsToSend = preview && preview.length ? [] : attachments;
if (preview && preview.length) { if (preview && preview.length) {
attachments.forEach(attachment => { attachments.forEach(attachment => {
@ -3828,6 +3829,33 @@ export class ConversationModel extends window.Backbone
}); });
} }
/**
* At this point, all attachments have been processed and written to disk as draft
* attachments, via processAttachments. All transcodable images have been re-encoded
* via canvas to remove EXIF data. Images above the high-quality threshold size have
* been scaled to high-quality JPEGs.
*
* If we choose to send images in standard quality, we need to scale them down
* (potentially for the second time). When we do so, we also delete the current
* draft attachment on disk for cleanup.
*
* All draft attachments (with a path or just in-memory) will be written to disk for
* real in `upgradeMessageSchema`.
*/
if (!sendHQImages) {
attachmentsToSend = await Promise.all(
attachmentsToSend.map(async attachment => {
const downscaledAttachment = await downscaleOutgoingAttachment(
attachment
);
if (downscaledAttachment !== attachment && attachment.path) {
drop(deleteAttachmentData(attachment.path));
}
return downscaledAttachment;
})
);
}
// Here we move attachments to disk // Here we move attachments to disk
const attributes = await upgradeMessageSchema({ const attributes = await upgradeMessageSchema({
id: generateGuid(), id: generateGuid(),

View file

@ -351,6 +351,7 @@ async function getPreview(
type: fullSizeImage.contentType, type: fullSizeImage.contentType,
}), }),
fileName: title, fileName: title,
highQuality: true,
}); });
const data = await fileToBytes(withBlob.file); const data = await fileToBytes(withBlob.file);

View file

@ -111,7 +111,7 @@ type MigrationsModuleType = {
}>; }>;
upgradeMessageSchema: ( upgradeMessageSchema: (
attributes: MessageAttributesType, attributes: MessageAttributesType,
options?: { maxVersion?: number; keepOnDisk?: boolean } options?: { maxVersion?: number }
) => Promise<MessageAttributesType>; ) => Promise<MessageAttributesType>;
writeMessageAttachments: ( writeMessageAttachments: (
message: MessageAttributesType message: MessageAttributesType
@ -266,9 +266,9 @@ export function initializeMigrations({
}), }),
upgradeMessageSchema: ( upgradeMessageSchema: (
message: MessageAttributesType, message: MessageAttributesType,
options: { maxVersion?: number; keepOnDisk?: boolean } = {} options: { maxVersion?: number } = {}
) => { ) => {
const { maxVersion, keepOnDisk } = options; const { maxVersion } = options;
return MessageType.upgradeSchema(message, { return MessageType.upgradeSchema(message, {
deleteOnDisk, deleteOnDisk,
@ -283,7 +283,6 @@ export function initializeMigrations({
writeNewAttachmentData, writeNewAttachmentData,
writeNewStickerData, writeNewStickerData,
keepOnDisk,
logger, logger,
maxVersion, maxVersion,
}); });

View file

@ -35,12 +35,12 @@ describe('scaleImageToLevel', () => {
testCases.map( testCases.map(
async ({ path, contentType, expectedWidth, expectedHeight }) => { async ({ path, contentType, expectedWidth, expectedHeight }) => {
const blob = await getBlob(path); const blob = await getBlob(path);
const scaled = await scaleImageToLevel( const scaled = await scaleImageToLevel({
blob, fileOrBlobOrURL: blob,
contentType, contentType,
blob.size, size: blob.size,
true highQuality: true,
); });
const data = await loadImage(scaled.blob, { orientation: true }); const data = await loadImage(scaled.blob, { orientation: true });
const { originalWidth: width, originalHeight: height } = data; const { originalWidth: width, originalHeight: height } = data;
@ -61,7 +61,12 @@ describe('scaleImageToLevel', () => {
'Test setup failure: expected fixture to have EXIF data' 'Test setup failure: expected fixture to have EXIF data'
); );
const scaled = await scaleImageToLevel(original, IMAGE_JPEG, original.size); const scaled = await scaleImageToLevel({
fileOrBlobOrURL: original,
contentType: IMAGE_JPEG,
size: original.size,
highQuality: true,
});
assert.isUndefined( assert.isUndefined(
(await loadImage(scaled.blob, { meta: true, orientation: true })).exif (await loadImage(scaled.blob, { meta: true, orientation: true })).exif
); );

View file

@ -5,7 +5,6 @@ import { isFunction, isObject, isString, omit } from 'lodash';
import * as Contact from './EmbeddedContact'; import * as Contact from './EmbeddedContact';
import type { AttachmentType, AttachmentWithHydratedData } from './Attachment'; import type { AttachmentType, AttachmentWithHydratedData } from './Attachment';
import { autoOrientJPEG } from '../util/attachments';
import { import {
captureDimensionsAndScreenshot, captureDimensionsAndScreenshot,
hasData, hasData,
@ -52,7 +51,6 @@ export type ContextType = {
height: number; height: number;
}>; }>;
getRegionCode: () => string | undefined; getRegionCode: () => string | undefined;
keepOnDisk?: boolean;
logger: LoggerType; logger: LoggerType;
makeImageThumbnail: (params: { makeImageThumbnail: (params: {
size: number; size: number;
@ -369,37 +367,18 @@ export const _mapPreviewAttachments =
return { ...message, preview }; return { ...message, preview };
}; };
const noopUpgrade = async (message: MessageAttributesType) => message;
const toVersion0 = async ( const toVersion0 = async (
message: MessageAttributesType, message: MessageAttributesType,
context: ContextType context: ContextType
) => initializeSchemaVersion({ message, logger: context.logger }); ) => initializeSchemaVersion({ message, logger: context.logger });
const toVersion1 = _withSchemaVersion({ const toVersion1 = _withSchemaVersion({
schemaVersion: 1, schemaVersion: 1,
upgrade: _mapAttachments( // NOOP: We no longer need to run autoOrientJPEG on incoming JPEGs since Chromium
async ( // respects the EXIF orientation for us when displaying the image
attachment: AttachmentType, upgrade: noopUpgrade,
context,
options
): Promise<AttachmentType> => {
const { deleteOnDisk, keepOnDisk } = context;
const rotatedAttachment = await autoOrientJPEG(
attachment,
context,
options
);
if (
!keepOnDisk &&
attachment !== rotatedAttachment &&
rotatedAttachment.data &&
attachment.path
) {
await deleteOnDisk(attachment.path);
}
return rotatedAttachment;
}
),
}); });
const toVersion2 = _withSchemaVersion({ const toVersion2 = _withSchemaVersion({
schemaVersion: 2, schemaVersion: 2,
@ -506,7 +485,6 @@ export const upgradeSchema = async (
makeVideoScreenshot, makeVideoScreenshot,
writeNewStickerData, writeNewStickerData,
deleteOnDisk, deleteOnDisk,
keepOnDisk,
logger, logger,
maxVersion = CURRENT_SCHEMA_VERSION, maxVersion = CURRENT_SCHEMA_VERSION,
}: ContextType }: ContextType
@ -566,7 +544,6 @@ export const upgradeSchema = async (
getImageDimensions, getImageDimensions,
makeImageThumbnail, makeImageThumbnail,
makeVideoScreenshot, makeVideoScreenshot,
keepOnDisk,
logger, logger,
getAbsoluteStickerPath, getAbsoluteStickerPath,
getRegionCode, getRegionCode,
@ -590,7 +567,6 @@ export const processNewAttachment = async (
getImageDimensions, getImageDimensions,
makeImageThumbnail, makeImageThumbnail,
makeVideoScreenshot, makeVideoScreenshot,
deleteOnDisk,
logger, logger,
}: Pick< }: Pick<
ContextType, ContextType,
@ -630,42 +606,16 @@ export const processNewAttachment = async (
throw new TypeError('context.logger is required'); throw new TypeError('context.logger is required');
} }
const rotatedAttachment = await autoOrientJPEG( const finalAttachment = await captureDimensionsAndScreenshot(attachment, {
attachment, writeNewAttachmentData,
{ logger }, getAbsoluteAttachmentPath,
{ makeObjectUrl,
isIncoming: true, revokeObjectUrl,
} getImageDimensions,
); makeImageThumbnail,
makeVideoScreenshot,
let onDiskAttachment = rotatedAttachment; logger,
});
// If we rotated the attachment, then `data` will be the actual bytes of the attachment,
// in memory. We want that updated attachment to go back to disk.
if (rotatedAttachment.data) {
onDiskAttachment = await migrateDataToFileSystem(rotatedAttachment, {
writeNewAttachmentData,
logger,
});
if (rotatedAttachment !== attachment && attachment.path) {
await deleteOnDisk(attachment.path);
}
}
const finalAttachment = await captureDimensionsAndScreenshot(
onDiskAttachment,
{
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
}
);
return finalAttachment; return finalAttachment;
}; };

View file

@ -3,6 +3,7 @@
import { blobToArrayBuffer } from 'blob-util'; import { blobToArrayBuffer } from 'blob-util';
import * as log from '../logging/log';
import { scaleImageToLevel } from './scaleImageToLevel'; import { scaleImageToLevel } from './scaleImageToLevel';
import { dropNull } from './dropNull'; import { dropNull } from './dropNull';
import type { import type {
@ -10,49 +11,23 @@ import type {
UploadedAttachmentType, UploadedAttachmentType,
} from '../types/Attachment'; } from '../types/Attachment';
import { canBeTranscoded } from '../types/Attachment'; import { canBeTranscoded } from '../types/Attachment';
import type { LoggerType } from '../types/Logging';
import * as MIME from '../types/MIME';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
// Upgrade steps NOTE: This step strips all EXIF metadata from JPEG images as part of // All outgoing images go through handleImageAttachment before being sent and thus have
// re-encoding the image: // already been scaled to high-quality level, stripped of exif data, and saved. This
// should be called just before message send to downscale the attachment further if
// When sending an image: // needed.
// 1. During composition, images are passed through handleImageAttachment. If needed, this export const downscaleOutgoingAttachment = async (
// scales them down to high-quality (level 3). attachment: AttachmentType
// 2. Draft images are then written to disk as a draft image (so there is a `path`) ): Promise<AttachmentType> => {
// 3. On send, the message schema is upgraded, triggering this function
export async function autoOrientJPEG(
attachment: AttachmentType,
{ logger }: { logger: LoggerType },
{
sendHQImages = false,
isIncoming = false,
}: {
sendHQImages?: boolean;
isIncoming?: boolean;
} = {}
): Promise<AttachmentType> {
if (isIncoming && !MIME.isJPEG(attachment.contentType)) {
return attachment;
}
if (!canBeTranscoded(attachment)) { if (!canBeTranscoded(attachment)) {
return attachment; return attachment;
} }
// If we haven't downloaded the attachment yet, we won't have the data.
// All images go through handleImageAttachment before being sent and thus have let scaleTarget: string | Blob;
// already been scaled to level, oriented, stripped of exif data, and saved
// in high quality format. If we want to send the image in HQ we can return
// the attachment as-is. Otherwise we'll have to further scale it down.
const { data, path, size } = attachment; const { data, path, size } = attachment;
if (sendHQImages) {
return attachment;
}
let scaleTarget: string | Blob;
if (data) { if (data) {
scaleTarget = new Blob([data], { scaleTarget = new Blob([data], {
type: attachment.contentType, type: attachment.contentType,
@ -65,12 +40,12 @@ export async function autoOrientJPEG(
} }
try { try {
const { blob: xcodedDataBlob } = await scaleImageToLevel( const { blob: xcodedDataBlob } = await scaleImageToLevel({
scaleTarget, fileOrBlobOrURL: scaleTarget,
attachment.contentType, contentType: attachment.contentType,
size, size,
isIncoming highQuality: false,
); });
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob); const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
// IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original // IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original
@ -91,14 +66,14 @@ export async function autoOrientJPEG(
return xcodedAttachment; return xcodedAttachment;
} catch (error: unknown) { } catch (error: unknown) {
const errorString = Errors.toLogFormat(error); const errorString = Errors.toLogFormat(error);
logger.error( log.error(
'autoOrientJPEG: Failed to rotate/scale attachment', 'downscaleOutgoingAttachment: Failed to scale attachment',
errorString errorString
); );
return attachment; return attachment;
} }
} };
export type CdnFieldsType = Pick< export type CdnFieldsType = Pick<
AttachmentType, AttachmentType,

View file

@ -135,10 +135,7 @@ export async function handleEditMessage(
} }
const upgradedEditedMessageData = const upgradedEditedMessageData =
await window.Signal.Migrations.upgradeMessageSchema( await window.Signal.Migrations.upgradeMessageSchema(editAttributes.message);
editAttributes.message,
{ keepOnDisk: true }
);
// Copies over the attachments from the main message if they're the same // Copies over the attachments from the main message if they're the same
// and they have already been downloaded. // and they have already been downloaded.

View file

@ -46,6 +46,8 @@ export async function handleImageAttachment(
: stringToMIMEType(file.type), : stringToMIMEType(file.type),
fileName: file.name, fileName: file.name,
file: processedFile, file: processedFile,
// We always store draft attachments as HQ
highQuality: true,
}); });
const data = await blobToArrayBuffer(resizedBlob); const data = await blobToArrayBuffer(resizedBlob);
@ -66,10 +68,12 @@ export async function autoScale({
contentType, contentType,
file, file,
fileName, fileName,
highQuality,
}: { }: {
contentType: MIMEType; contentType: MIMEType;
file: File | Blob; file: File | Blob;
fileName: string; fileName: string;
highQuality: boolean;
}): Promise<{ }): Promise<{
contentType: MIMEType; contentType: MIMEType;
file: Blob; file: Blob;
@ -79,12 +83,12 @@ export async function autoScale({
return { contentType, file, fileName }; return { contentType, file, fileName };
} }
const { blob, contentType: newContentType } = await scaleImageToLevel( const { blob, contentType: newContentType } = await scaleImageToLevel({
file, fileOrBlobOrURL: file,
contentType, contentType,
file.size, size: file.size,
true highQuality,
); });
if (newContentType !== IMAGE_JPEG) { if (newContentType !== IMAGE_JPEG) {
return { return {

View file

@ -108,12 +108,17 @@ async function getCanvasBlobAsJPEG(
return canvasToBlob(canvas, IMAGE_JPEG, quality); return canvasToBlob(canvas, IMAGE_JPEG, quality);
} }
export async function scaleImageToLevel( export async function scaleImageToLevel({
fileOrBlobOrURL: File | Blob | string, fileOrBlobOrURL,
contentType: MIMEType, contentType,
size: number, size,
sendAsHighQuality?: boolean highQuality,
): Promise<{ }: {
fileOrBlobOrURL: File | Blob | string;
contentType: MIMEType;
size: number;
highQuality: boolean | null;
}): Promise<{
blob: Blob; blob: Blob;
contentType: MIMEType; contentType: MIMEType;
}> { }> {
@ -134,16 +139,13 @@ export async function scaleImageToLevel(
throw error; throw error;
} }
const level = sendAsHighQuality const level = highQuality ? MediaQualityLevels.Three : getMediaQualityLevel();
? MediaQualityLevels.Three
: getMediaQualityLevel();
const { const {
maxDimensions, maxDimensions,
quality, quality,
size: targetSize, size: targetSize,
thresholdSize, thresholdSize,
} = MEDIA_QUALITY_LEVEL_DATA.get(level) || DEFAULT_LEVEL_DATA; } = MEDIA_QUALITY_LEVEL_DATA.get(level) || DEFAULT_LEVEL_DATA;
if (size <= thresholdSize) { if (size <= thresholdSize) {
// Always encode through canvas as a temporary fix for a library bug // Always encode through canvas as a temporary fix for a library bug
const blob: Blob = await canvasToBlob(data.image, contentType); const blob: Blob = await canvasToBlob(data.image, contentType);