Generate compressed thumbnail for images and videos
This commit is contained in:
parent
f152dcccc3
commit
feff619a38
1 changed files with 103 additions and 44 deletions
|
@ -5,11 +5,11 @@ import loadImage from 'blueimp-load-image';
|
||||||
import { blobToArrayBuffer } from 'blob-util';
|
import { blobToArrayBuffer } from 'blob-util';
|
||||||
import { toLogFormat } from './errors';
|
import { toLogFormat } from './errors';
|
||||||
import type { MIMEType } from './MIME';
|
import type { MIMEType } from './MIME';
|
||||||
import { IMAGE_PNG } from './MIME';
|
import { IMAGE_JPEG, IMAGE_PNG } from './MIME';
|
||||||
import type { LoggerType } from './Logging';
|
import type { LoggerType } from './Logging';
|
||||||
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
|
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { canvasToBlob } from '../util/canvasToBlob';
|
import { canvasToBlob } from '../util/canvasToBlob';
|
||||||
|
import { KIBIBYTE } from './AttachmentSize';
|
||||||
|
|
||||||
export { blobToArrayBuffer };
|
export { blobToArrayBuffer };
|
||||||
|
|
||||||
|
@ -101,6 +101,107 @@ export function makeImageThumbnail({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MakeImageThumbnailForBackupOptionsType = Readonly<{
|
||||||
|
maxDimension?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
objectUrl: string;
|
||||||
|
logger: LoggerType;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// 0.7 quality seems to result in a good result in 1 interation for most images
|
||||||
|
const STARTING_JPEG_QUALITY = 0.7;
|
||||||
|
const MINIMUM_JPEG_QUALITY = 0.1;
|
||||||
|
const ADDITIONAL_QUALITY_DECREASE_PER_ITERATION = 0.1;
|
||||||
|
|
||||||
|
export type CreatedThumbnailType = {
|
||||||
|
data: Uint8Array;
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
mimeType: MIMEType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeImageThumbnailForBackup({
|
||||||
|
maxDimension = 256,
|
||||||
|
maxSize = 8 * KIBIBYTE,
|
||||||
|
objectUrl,
|
||||||
|
logger,
|
||||||
|
}: MakeImageThumbnailForBackupOptionsType): Promise<CreatedThumbnailType> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = document.createElement('img');
|
||||||
|
|
||||||
|
image.addEventListener('load', async () => {
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
// Scale image to the right size and draw it on a canvas
|
||||||
|
const canvas = loadImage.scale(image, {
|
||||||
|
canvas: true,
|
||||||
|
maxWidth: maxDimension,
|
||||||
|
maxHeight: maxDimension,
|
||||||
|
});
|
||||||
|
|
||||||
|
strictAssert(
|
||||||
|
canvas instanceof HTMLCanvasElement,
|
||||||
|
'loadImage must produce canvas'
|
||||||
|
);
|
||||||
|
|
||||||
|
let jpegQuality = STARTING_JPEG_QUALITY;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let blob = await canvasToBlob(canvas, IMAGE_JPEG, jpegQuality);
|
||||||
|
let iterations = 1;
|
||||||
|
|
||||||
|
while (
|
||||||
|
blob.size > maxSize &&
|
||||||
|
jpegQuality > MINIMUM_JPEG_QUALITY &&
|
||||||
|
// iterations should be capped by the minimum JPEG quality condition, but for
|
||||||
|
// peace of mind, let's cap them explicitly as well.
|
||||||
|
iterations < 5
|
||||||
|
) {
|
||||||
|
jpegQuality = Math.max(
|
||||||
|
MINIMUM_JPEG_QUALITY,
|
||||||
|
// In testing, the relationship between quality and size in this range is
|
||||||
|
// relatively linear, so we guess at the appropriate quality by scaling by the
|
||||||
|
// size and adding an additional quality decrease as a buffer
|
||||||
|
(maxSize / blob.size) * jpegQuality -
|
||||||
|
ADDITIONAL_QUALITY_DECREASE_PER_ITERATION
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
blob = await canvasToBlob(canvas, IMAGE_JPEG, jpegQuality);
|
||||||
|
iterations += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = (performance.now() - start).toFixed(1);
|
||||||
|
|
||||||
|
const logMethod = blob.size > maxSize ? logger.warn : logger.info;
|
||||||
|
const sizeInKiB = blob.size / KIBIBYTE;
|
||||||
|
logMethod(
|
||||||
|
'makeImageThumbnail: generated thumbnail of dimensions: ' +
|
||||||
|
`${canvas.width} x ${canvas.height}, and size: ${sizeInKiB}(KiB) ` +
|
||||||
|
`at quality: ${jpegQuality}, iterations: ${iterations}, time: ${duration}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = await blobToArrayBuffer(blob);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
data: new Uint8Array(buffer),
|
||||||
|
height: canvas.height,
|
||||||
|
width: canvas.width,
|
||||||
|
mimeType: IMAGE_JPEG,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
image.addEventListener('error', error => {
|
||||||
|
logger.error('makeImageThumbnail error', toLogFormat(error));
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type MakeVideoScreenshotOptionsType = Readonly<{
|
export type MakeVideoScreenshotOptionsType = Readonly<{
|
||||||
objectUrl: string;
|
objectUrl: string;
|
||||||
contentType?: MIMEType;
|
contentType?: MIMEType;
|
||||||
|
@ -150,48 +251,6 @@ export function makeVideoScreenshot({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MakeVideoThumbnailOptionsType = Readonly<{
|
|
||||||
size: number;
|
|
||||||
videoObjectUrl: string;
|
|
||||||
logger: Pick<LoggerType, 'error'>;
|
|
||||||
contentType: MIMEType;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export async function makeVideoThumbnail({
|
|
||||||
size,
|
|
||||||
videoObjectUrl,
|
|
||||||
logger,
|
|
||||||
contentType,
|
|
||||||
}: MakeVideoThumbnailOptionsType): Promise<Blob> {
|
|
||||||
let screenshotObjectUrl: string | undefined;
|
|
||||||
try {
|
|
||||||
const blob = await makeVideoScreenshot({
|
|
||||||
objectUrl: videoObjectUrl,
|
|
||||||
contentType,
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
const data = await blobToArrayBuffer(blob);
|
|
||||||
screenshotObjectUrl = arrayBufferToObjectURL({
|
|
||||||
data,
|
|
||||||
type: contentType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// We need to wait for this, otherwise the finally below will run first
|
|
||||||
const resultBlob = await makeImageThumbnail({
|
|
||||||
size,
|
|
||||||
objectUrl: screenshotObjectUrl,
|
|
||||||
contentType,
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
|
|
||||||
return resultBlob;
|
|
||||||
} finally {
|
|
||||||
if (screenshotObjectUrl !== undefined) {
|
|
||||||
revokeObjectUrl(screenshotObjectUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeObjectUrl(
|
export function makeObjectUrl(
|
||||||
data: Uint8Array | ArrayBuffer,
|
data: Uint8Array | ArrayBuffer,
|
||||||
contentType: MIMEType
|
contentType: MIMEType
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue