-
-
Notifications
You must be signed in to change notification settings - Fork 748
/
thumbnail.ts
238 lines (219 loc) · 10.1 KB
/
thumbnail.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
import log from "@/base/log";
import { type Electron } from "@/base/types/ipc";
import { FileType, type FileTypeInfo } from "@/media/file-type";
import { heicToJPEG } from "@/media/heic-convert";
import { scaledImageDimensions } from "@/media/image";
import * as ffmpeg from "@/new/photos/services/ffmpeg";
import {
toDataOrPathOrZipEntry,
type DesktopUploadItem,
} from "@/new/photos/services/upload/types";
import { ensure } from "@/utils/ensure";
import { withTimeout } from "@/utils/promise";
/** Maximum width or height of the generated thumbnail */
const maxThumbnailDimension = 720;
/** Maximum size (in bytes) of the generated thumbnail */
const maxThumbnailSize = 100 * 1024; // 100 KB
/**
* Timeout (ms) to wait before giving up on canvas thumbnail generation.
*
* [Note: Rendering arbitrary file types to the canvas needs a timeout]
*
* When generating thumbnails on the web (or as a fallback on the desktop app),
* we use an HTML canvas. We take the file's content, a blob, and load it on the
* canvas by creating an image URL for this blob (using `createObjectURL`).
*
* In case when the browser knows how to render images of this type, this works
* great. Later we can read off the thumbnail from the (resized) canvas.
*
* However, if this in not a file format that the browser can understand, then
* this process just hangs. There isn't a trivial way of knowing beforehand
* which browser will support which file type, so we need to add a timeout.
*/
const canvasThumbnailGenerationTimeout = 30 * 1000;
/**
* Generate a JPEG thumbnail for the given image or video blob.
*
* The thumbnail has a smaller file size so that is quick to load. But more
* importantly, it uses a universal file format (JPEG in our case) so that the
* thumbnail itself can be opened in all clients, even those like the web client
* itself that might not yet have support for more exotic formats.
*
* @param blob The image or video blob whose thumbnail we want to generate.
*
* @param fileTypeInfo The type information for the file this blob came from.
*
* @return The JPEG data of the generated thumbnail.
*/
export const generateThumbnailWeb = async (
blob: Blob,
fileTypeInfo: FileTypeInfo,
): Promise<Uint8Array> =>
fileTypeInfo.fileType === FileType.image
? await generateImageThumbnailWeb(blob, fileTypeInfo)
: await generateVideoThumbnailWeb(blob);
const generateImageThumbnailWeb = async (
blob: Blob,
{ extension }: FileTypeInfo,
) => {
if (extension == "heic" || extension == "heif") {
log.debug(() => `Pre-converting HEIC to JPEG for thumbnail generation`);
blob = await heicToJPEG(blob);
}
return generateImageThumbnailUsingCanvas(blob);
};
const generateImageThumbnailUsingCanvas = async (blob: Blob) => {
const canvas = document.createElement("canvas");
const canvasCtx = ensure(canvas.getContext("2d"));
const imageURL = URL.createObjectURL(blob);
await withTimeout(
new Promise((resolve, reject) => {
const image = new Image();
image.setAttribute("src", imageURL);
image.onload = () => {
try {
URL.revokeObjectURL(imageURL);
const { width, height } = scaledImageDimensions(
image.width,
image.height,
maxThumbnailDimension,
);
canvas.width = width;
canvas.height = height;
canvasCtx.drawImage(image, 0, 0, width, height);
resolve(undefined);
} catch (e: unknown) {
reject(e);
}
};
}),
canvasThumbnailGenerationTimeout,
);
return await compressedJPEGData(canvas);
};
const compressedJPEGData = async (canvas: HTMLCanvasElement) => {
let blob: Blob | undefined | null;
let prevSize = Number.MAX_SAFE_INTEGER;
let quality = 0.7;
do {
if (blob) prevSize = blob.size;
blob = await new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), "image/jpeg", quality);
});
quality -= 0.1;
} while (
quality >= 0.5 &&
blob &&
blob.size > maxThumbnailSize &&
percentageSizeDiff(blob.size, prevSize) >= 10
);
return new Uint8Array(await ensure(blob).arrayBuffer());
};
const percentageSizeDiff = (
newThumbnailSize: number,
oldThumbnailSize: number,
) => ((oldThumbnailSize - newThumbnailSize) * 100) / oldThumbnailSize;
const generateVideoThumbnailWeb = async (blob: Blob) => {
try {
return await ffmpeg.generateVideoThumbnailWeb(blob);
} catch (e) {
log.error(
`Failed to generate video thumbnail using the wasm FFmpeg web worker, will fallback to canvas`,
e,
);
return generateVideoThumbnailUsingCanvas(blob);
}
};
export const generateVideoThumbnailUsingCanvas = async (blob: Blob) => {
const canvas = document.createElement("canvas");
const canvasCtx = ensure(canvas.getContext("2d"));
const videoURL = URL.createObjectURL(blob);
await withTimeout(
new Promise((resolve, reject) => {
const video = document.createElement("video");
video.preload = "metadata";
video.src = videoURL;
video.addEventListener("loadeddata", () => {
try {
URL.revokeObjectURL(videoURL);
const { width, height } = scaledImageDimensions(
video.videoWidth,
video.videoHeight,
maxThumbnailDimension,
);
canvas.width = width;
canvas.height = height;
canvasCtx.drawImage(video, 0, 0, width, height);
resolve(undefined);
} catch (e) {
reject(e);
}
});
}),
canvasThumbnailGenerationTimeout,
);
return await compressedJPEGData(canvas);
};
/**
* Generate a JPEG thumbnail for the given file or path using native tools.
*
* This function only works when we're running in the context of our desktop
* app, and this dependency is enforced by the need to pass the {@link electron}
* object which we use to perform IPC with the Node.js side of our desktop app.
*
* @param dataOrPath Contents of an image or video file, or the path to the
* image or video file on the user's local file system, whose thumbnail we want
* to generate.
*
* @param fileTypeInfo The type information for {@link dataOrPath}.
*
* @return The JPEG data of the generated thumbnail.
*
* See also {@link generateThumbnailWeb}.
*/
export const generateThumbnailNative = async (
electron: Electron,
desktopUploadItem: DesktopUploadItem,
fileTypeInfo: FileTypeInfo,
): Promise<Uint8Array> =>
fileTypeInfo.fileType === FileType.image
? await electron.generateImageThumbnail(
toDataOrPathOrZipEntry(desktopUploadItem),
maxThumbnailDimension,
maxThumbnailSize,
)
: ffmpeg.generateVideoThumbnailNative(electron, desktopUploadItem);
/**
* A fallback, black, thumbnail for use in cases where thumbnail generation
* fails.
*/
export const fallbackThumbnail = () =>
Uint8Array.from(atob(blackThumbnailB64), (c) => c.charCodeAt(0));
const blackThumbnailB64 =
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB" +
"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ" +
"EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARC" +
"ACWASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF" +
"BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk" +
"6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztL" +
"W2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAA" +
"AAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVY" +
"nLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImK" +
"kpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oAD" +
"AMBAAIRAxEAPwD/AD/6ACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA" +
"CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg" +
"AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKAC" +
"gAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo" +
"AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg" +
"AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg" +
"AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA" +
"CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA" +
"CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA" +
"KACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACg" +
"AoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo" +
"AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKA" +
"CgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAK" +
"ACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoA" +
"KACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo" +
"AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAo" +
"AKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD/9k=";