Skip to content
Source
Sign inSign up

imageCompression.ts

557 lines18.2 KB

1import {
2 downscalableImageMimeType,
3 fitImageAttachmentSize,
4 IMAGE_ATTACHMENT_MAX_EDGE,
5} from "@t3tools/client-runtime/image-attachment-size";
6import {
7 IMAGE_DIMENSIONS_HEADER_BYTES,
8 readImageDimensions,
9} from "@t3tools/shared/imageDimensions";
10​
11const MAX_DIMENSION = IMAGE_ATTACHMENT_MAX_EDGE;
12export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000;
13/** Bound source decoding: enormous ImageBitmaps can OOM the tab before resizing. */
14export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024;
15const MAX_HEIC_DECODE_PIXELS = 64_000_000;
16const MAX_HEIC_METADATA_BYTES = 1024 * 1024;
17const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const;
18const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const;
19const HEIC_IMAGE_MIME_TYPE = /^image\/hei(?:c|f)$/i;
20const HEIC_IMAGE_EXTENSION = /\.(?:heic|heif)$/i;
21​
22type ImageSize = { width: number; height: number };
23​
24export interface CompressedStashImage {
25 imageSize?: ImageSize;
26 dataUrl: string;
27 mimeType: string;
28 sizeBytes: number;
29 recompressed: boolean;
31​
32export type ImageCompressionFailureReason = "too-large" | "unreadable";
33​
34export type CompressStashImageResult =
35 | { ok: true; image: CompressedStashImage }
36 | { ok: false; reason: ImageCompressionFailureReason };
37​
38export type CompressImageFileResult =
39 | { ok: true; file: File; recompressed: boolean; imageSize?: ImageSize }
40 | { ok: false; reason: ImageCompressionFailureReason };
41​
42/** Finder and some browsers omit the MIME type when dragging HEIC photos. */
43export function isHeicImageFile(file: Pick<File, "name" | "type">): boolean {
44 if (HEIC_IMAGE_MIME_TYPE.test(file.type)) {
45 return true;
46 }
47 return (
48 (file.type === "" || file.type.toLowerCase() === "application/octet-stream") &&
49 HEIC_IMAGE_EXTENSION.test(file.name)
50 );
52​
53interface HeicMetadataBox {
54 payloadOffset: number;
55 endOffset: number;
57​
58function findHeicMetadataBox(
59 view: DataView,
60 startOffset: number,
61 endOffset: number,
62 type: number,
63): HeicMetadataBox | null {
64 let offset = startOffset;
65 while (offset + 8 <= endOffset) {
66 let size = view.getUint32(offset);
67 let headerSize = 8;
68 if (size === 1) {
69 if (offset + 16 > endOffset) return null;
70 const extendedSize = view.getBigUint64(offset + 8);
71 if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return null;
72 size = Number(extendedSize);
73 headerSize = 16;
74 } else if (size === 0) {
75 size = endOffset - offset;
76 }
77 if (size < headerSize || size > endOffset - offset) return null;
78​
79 const nextOffset = offset + size;
80 if (view.getUint32(offset + 4) === type) {
81 return { payloadOffset: offset + headerSize, endOffset: nextOffset };
82 }
83 offset = nextOffset;
84 }
85 return null;
87​
88async function validateHeicImageDimensions(
89 file: File,
90): Promise<ImageCompressionFailureReason | null> {
91 const metadata = await file.slice(0, MAX_HEIC_METADATA_BYTES).arrayBuffer();
92 const view = new DataView(metadata);
93 const meta = findHeicMetadataBox(view, 0, view.byteLength, 0x6d657461);
94 if (!meta || meta.payloadOffset + 4 > meta.endOffset) return "unreadable";
95 const properties = findHeicMetadataBox(view, meta.payloadOffset + 4, meta.endOffset, 0x69707270);
96 if (!properties) return "unreadable";
97 const containers = findHeicMetadataBox(
98 view,
99 properties.payloadOffset,
100 properties.endOffset,
101 0x6970636f,
102 );
103 if (!containers) return "unreadable";
104​
105 let offset = containers.payloadOffset;
106 let foundImageDimensions = false;
107 while (offset < containers.endOffset) {
108 const image = findHeicMetadataBox(view, offset, containers.endOffset, 0x69737065);
109 if (!image) break;
110 if (image.payloadOffset + 12 > image.endOffset) return "unreadable";
111 const width = view.getUint32(image.payloadOffset + 4);
112 const height = view.getUint32(image.payloadOffset + 8);
113 if (width === 0 || height === 0) return "unreadable";
114 if (width > MAX_HEIC_DECODE_PIXELS / height) return "too-large";
115 foundImageDimensions = true;
116 offset = image.endOffset;
117 }
118 return foundImageDimensions ? null : "unreadable";
120​
121export function readFileAsDataUrl(file: Blob): Promise<string> {
122 return new Promise((resolve, reject) => {
123 const reader = new FileReader();
124 reader.addEventListener("load", () => {
125 if (typeof reader.result === "string") {
126 resolve(reader.result);
127 return;
128 }
129 reject(new Error("Could not read image data."));
130 });
131 reader.addEventListener("error", () => {
132 reject(reader.error ?? new Error("Failed to read image."));
133 });
134 reader.readAsDataURL(file);
135 });
137​
138function dataUrlLength(blob: Blob): number {
139 return (
140 `data:${blob.type || "application/octet-stream"};base64,`.length + 4 * Math.ceil(blob.size / 3)
141 );
143​
144export function dataUrlToFile(dataUrl: string, name: string, mimeType: string): File {
145 const payload = dataUrl.slice(dataUrl.indexOf(",") + 1);
146 const binary = atob(payload);
147 const bytes = new Uint8Array(binary.length);
148 for (let index = 0; index < binary.length; index += 1) {
149 bytes[index] = binary.charCodeAt(index);
150 }
151 return new File([bytes], name, { type: mimeType });
153​
154function fileNameForMimeType(name: string, mimeType: string): string {
155 const extension = mimeType === "image/webp" ? ".webp" : ".jpg";
156 const dotIndex = name.lastIndexOf(".");
157 const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
158 return `${base}${extension}`;
160​
161function canRecompress(): boolean {
162 return (
163 typeof createImageBitmap === "function" &&
164 (typeof OffscreenCanvas === "function" || typeof document !== "undefined")
165 );
167​
168interface Canvas2D {
169 canvas: OffscreenCanvas | HTMLCanvasElement;
170 context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
172​
173function createCanvas(width: number, height: number): Canvas2D | null {
174 if (typeof OffscreenCanvas === "function") {
175 const canvas = new OffscreenCanvas(width, height);
176 const context = canvas.getContext("2d");
177 if (!context) return null;
178 return { canvas, context };
179 }
180 if (typeof document === "undefined") return null;
181 const canvas = document.createElement("canvas");
182 canvas.width = width;
183 canvas.height = height;
184 const context = canvas.getContext("2d");
185 if (!context) return null;
186 return { canvas, context };
188​
189async function encodeCanvas(
190 canvas: OffscreenCanvas | HTMLCanvasElement,
191 quality: number,
192 mimeType: string,
193): Promise<Blob | null> {
194 let blob: Blob | null;
195 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
196 blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mimeType, quality));
197 } else {
198 blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
199 }
200 return blob?.type === mimeType ? blob : null;
202​
203const composerThumbnails = new WeakMap<File, Promise<string | null>>();
204const settledComposerThumbnails = new WeakMap<File, string | null>();
205​
206function cacheComposerImageThumbnail(
207 file: File,
208 thumbnail: Promise<string | null>,
209): Promise<string | null> {
210 composerThumbnails.set(file, thumbnail);
211 void thumbnail.then((src) => settledComposerThumbnails.set(file, src));
212 return thumbnail;
214​
215/** `undefined` until the thumbnail settles, so a tile can paint a known thumbnail immediately. */
216export function readComposerImageThumbnail(file: File): string | null | undefined {
217 return settledComposerThumbnails.get(file);
219​
220export function createComposerImageThumbnail(file: File): Promise<string | null> {
221 return (
222 composerThumbnails.get(file) ??
223 cacheComposerImageThumbnail(file, renderComposerImageThumbnail(file))
224 );
226​
227/**
228 * Pasted images are re-encoded after their placeholder already shows the source's thumbnail.
229 * Reusing it keeps the tile from flashing back to its fallback while the new file is decoded.
230 * Resolves once the target's thumbnail is readable, so a tile swapped to it paints at once even
231 * when compression finished before the source's thumbnail did.
232 */
233export async function inheritComposerImageThumbnail(source: File, target: File): Promise<void> {
234 const sourceThumbnail = composerThumbnails.get(source);
235 if (source === target || !sourceThumbnail || composerThumbnails.has(target)) return;
236 cacheComposerImageThumbnail(
237 target,
238 sourceThumbnail.then((src) => src ?? renderComposerImageThumbnail(target)),
239 );
240 const src = await sourceThumbnail;
241 if (src) settledComposerThumbnails.set(target, src);
243​
244async function renderComposerImageThumbnail(file: File): Promise<string | null> {
245 if (isHeicImageFile(file)) return prepareHeicImage(file).thumbnail;
246 // Placeholders thumbnail the uncompressed source, which can be big enough to OOM the decode.
247 if (!canRecompress() || file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) return null;
248 let bitmap: ImageBitmap | undefined;
249 try {
250 bitmap = await createImageBitmap(file);
251 return await renderComposerImageThumbnailFromBitmap(bitmap);
252 } catch {
253 return null;
254 } finally {
255 bitmap?.close();
256 }
258​
259async function renderComposerImageThumbnailFromBitmap(bitmap: ImageBitmap): Promise<string | null> {
260 try {
261 const side = Math.min(bitmap.width, bitmap.height);
262 if (side <= 0) return null;
263 const dimension = Math.min(256, side);
264 const surface = createCanvas(dimension, dimension);
265 if (!surface) return null;
266 surface.context.drawImage(
267 bitmap,
268 (bitmap.width - side) / 2,
269 (bitmap.height - side) / 2,
270 side,
271 side,
272 0,
273 0,
274 dimension,
275 dimension,
276 );
277 const blob = await encodeCanvas(surface.canvas, 1, "image/png");
278 return blob ? await readFileAsDataUrl(blob) : null;
279 } catch {
280 return null;
281 }
283​
284async function encodeWithinBudget(
285 bitmap: ImageBitmap,
286 maxDimension: number,
287 budgetChars: number,
288 preferredMimeType?: "image/jpeg",
289): Promise<{ blob: Blob; mimeType: string; imageSize: ImageSize } | null> {
290 const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
291 const width = Math.max(1, Math.round(bitmap.width * scale));
292 const height = Math.max(1, Math.round(bitmap.height * scale));
293 const target = createCanvas(width, height);
294 if (!target) return null;
295​
296 // Probe the codec before drawing; JPEG needs a white matte because it has no alpha.
297 const mimeType =
298 preferredMimeType ??
299 ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp"))
300 ? "image/webp"
301 : "image/jpeg");
302​
303 if (mimeType === "image/jpeg") {
304 target.context.fillStyle = "#ffffff";
305 target.context.fillRect(0, 0, width, height);
306 }
307 target.context.drawImage(bitmap, 0, 0, width, height);
308​
309 for (const quality of QUALITY_STEPS) {
310 const encoded = await encodeCanvas(target.canvas, quality, mimeType);
311 if (!encoded) break;
312 if (dataUrlLength(encoded) <= budgetChars) {
313 return { blob: encoded, mimeType, imageSize: { width, height } };
314 }
315 }
316 return null;
318​
319type ReencodeResult =
320 | { ok: true; blob: Blob; mimeType: string; imageSize: ImageSize }
321 | { ok: false; reason: ImageCompressionFailureReason };
322​
323async function reencodeWithinBudget(
324 file: File,
325 budgetChars: number,
326 preferredMimeType?: "image/jpeg",
327): Promise<ReencodeResult> {
328 if (!canRecompress()) {
329 return { ok: false, reason: "too-large" };
330 }
331​
332 let bitmap: ImageBitmap;
333 try {
334 bitmap = await createImageBitmap(file);
335 } catch {
336 return { ok: false, reason: "unreadable" };
337 }
338​
339 try {
340 // Shrink the previous target; using MAX_DIMENSION leaves smaller images unchanged.
341 const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height));
342 let encodeFailed = false;
343 for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) {
344 const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale));
345 let encoded: Awaited<ReturnType<typeof encodeWithinBudget>>;
346 try {
347 encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars, preferredMimeType);
348 } catch {
349 encodeFailed = true;
350 continue;
351 }
352 encodeFailed = false;
353 if (encoded) {
354 return {
355 ok: true,
356 blob: encoded.blob,
357 mimeType: encoded.mimeType,
358 imageSize: encoded.imageSize,
359 };
360 }
361 }
362 return { ok: false, reason: encodeFailed ? "unreadable" : "too-large" };
363 } finally {
364 bitmap.close();
365 }
367​
368export async function compressImageForStash(
369 file: File,
370 budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS,
371): Promise<CompressStashImageResult> {
372 try {
373 if (dataUrlLength(file) <= budgetChars) {
374 return {
375 ok: true,
376 image: {
377 dataUrl: await readFileAsDataUrl(file),
378 mimeType: file.type,
379 sizeBytes: file.size,
380 recompressed: false,
381 },
382 };
383 }
384 const reencoded = await reencodeWithinBudget(file, budgetChars);
385 if (!reencoded.ok) {
386 return reencoded;
387 }
388 return {
389 ok: true,
390 image: {
391 dataUrl: await readFileAsDataUrl(reencoded.blob),
392 mimeType: reencoded.mimeType,
393 sizeBytes: reencoded.blob.size,
394 recompressed: true,
395 imageSize: reencoded.imageSize,
396 },
397 };
398 } catch {
399 return { ok: false, reason: "unreadable" };
400 }
402​
403async function fitImageToMaxEdge(file: File): Promise<{ file: File; imageSize: ImageSize } | null> {
404 const mimeType = downscalableImageMimeType(file.type);
405 if (!mimeType || !canRecompress() || file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) return null;
406 let bitmap: ImageBitmap | undefined;
407 try {
408 const header = readImageDimensions(
409 new Uint8Array(await file.slice(0, IMAGE_DIMENSIONS_HEADER_BYTES).arrayBuffer()),
410 );
411 if (!header || !fitImageAttachmentSize(header)) return null;
412 bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
413 const imageSize = fitImageAttachmentSize(bitmap);
414 if (!imageSize) return null;
415 const target = createCanvas(imageSize.width, imageSize.height);
416 if (!target) return null;
417 target.context.imageSmoothingQuality = "high";
418 target.context.drawImage(bitmap, 0, 0, imageSize.width, imageSize.height);
419 const blob = await encodeCanvas(target.canvas, QUALITY_STEPS[0], mimeType);
420 if (!blob) return null;
421 return {
422 file: new File([blob], file.name, { type: mimeType, lastModified: file.lastModified }),
423 imageSize,
424 };
425 } catch {
426 return null;
427 } finally {
428 bitmap?.close();
429 }
431​
432export async function compressImageToByteLimit(
433 file: File,
434 maxBytes: number,
435 options?: { preferredMimeType?: "image/jpeg"; sourceSizeBytes?: number },
436): Promise<CompressImageFileResult> {
437 const fitted = await fitImageToMaxEdge(file);
438 if (fitted && fitted.file.size <= maxBytes) {
439 return { ok: true, file: fitted.file, recompressed: true, imageSize: fitted.imageSize };
440 }
441 if (!fitted && file.size <= maxBytes) {
442 return { ok: true, file, recompressed: false };
443 }
444 if ((options?.sourceSizeBytes ?? file.size) > MAX_COMPRESSIBLE_SOURCE_BYTES) {
445 return { ok: false, reason: "too-large" };
446 }
447 const budgetChars = Math.floor(maxBytes / 3) * 4;
448 const reencoded = await reencodeWithinBudget(
449 fitted?.file ?? file,
450 budgetChars,
451 options?.preferredMimeType,
452 );
453 if (!reencoded.ok) {
454 return reencoded;
455 }
456 return {
457 ok: true,
458 file: new File(
459 [reencoded.blob],
460 fileNameForMimeType(file.name || "image", reencoded.mimeType),
461 { type: reencoded.mimeType },
462 ),
463 recompressed: true,
464 imageSize: reencoded.imageSize,
465 };
467​
468const heicPreparations = new WeakMap<
469 File,
470 { image: Promise<CompressImageFileResult>; thumbnail: Promise<string | null> }
471>();
472// Preparing tiles share full-size decodes, so keep only one HEIC bitmap alive at a time.
473let heicPreparationQueue = Promise.resolve();
474​
475function prepareHeicImage(file: File) {
476 const cached = heicPreparations.get(file);
477 if (cached) return cached;
478 let resolveThumbnail!: (src: string | null) => void;
479 const thumbnail = new Promise<string | null>((resolve) => {
480 resolveThumbnail = resolve;
481 });
482 const image = heicPreparationQueue
483 .then(() => convertHeicImage(file, resolveThumbnail))
484 .finally(() => resolveThumbnail(null));
485 heicPreparationQueue = image.then(
486 () => undefined,
487 () => undefined,
488 );
489 const preparation = { image, thumbnail };
490 heicPreparations.set(file, preparation);
491 return preparation;
493​
494async function convertHeicImage(
495 file: File,
496 onThumbnail: (src: string | null) => void,
497): Promise<CompressImageFileResult> {
498 if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) {
499 return { ok: false, reason: "too-large" };
500 }
501​
502 let bitmap: ImageBitmap | undefined;
503 try {
504 const dimensionError = await validateHeicImageDimensions(file);
505 if (dimensionError) {
506 return { ok: false, reason: dimensionError };
507 }
508 bitmap =
509 typeof createImageBitmap === "function"
510 ? await createImageBitmap(file).catch(() => undefined)
511 : undefined;
512 if (!bitmap) {
513 const { heicTo } = await import("heic-to/csp");
514 bitmap = await heicTo({ blob: file, type: "bitmap" });
515 }
516 const imageSize = fitImageAttachmentSize(bitmap) ?? {
517 width: bitmap.width,
518 height: bitmap.height,
519 };
520 const surface = createCanvas(imageSize.width, imageSize.height);
521 if (!surface) return { ok: false, reason: "unreadable" };
522 surface.context.drawImage(bitmap, 0, 0, imageSize.width, imageSize.height);
523 const [converted] = await Promise.all([
524 encodeCanvas(surface.canvas, QUALITY_STEPS[0], "image/jpeg"),
525 renderComposerImageThumbnailFromBitmap(bitmap).then(onThumbnail),
526 ]);
527 if (!converted) return { ok: false, reason: "unreadable" };
528 return {
529 ok: true,
530 file: new File([converted], fileNameForMimeType(file.name || "image", "image/jpeg"), {
531 type: "image/jpeg",
532 lastModified: file.lastModified,
533 }),
534 recompressed: true,
535 imageSize,
536 };
537 } catch {
538 return { ok: false, reason: "unreadable" };
539 } finally {
540 bitmap?.close();
541 }
543​
544export async function prepareImageForAttachment(
545 file: File,
546 maxBytes: number,
547): Promise<CompressImageFileResult> {
548 if (!isHeicImageFile(file)) return compressImageToByteLimit(file, maxBytes);
549 const prepared = await prepareHeicImage(file).image;
550 if (!prepared.ok || prepared.file.size <= maxBytes) return prepared;
551 const result = await compressImageToByteLimit(prepared.file, maxBytes, {
552 preferredMimeType: "image/jpeg",
553 sourceSizeBytes: file.size,
554 });
555​
556 return result.ok ? { ...result, recompressed: true } : result;