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
|
import {
computeResizedDimensions,
JPEG_QUALITY,
shouldConvertImage,
useConvertedImage,
} from "../logic/image-compress";
/** A successfully converted image: a JPEG blob at `JPEG_QUALITY`. */
export interface ConvertedJpeg {
readonly blob: Blob;
readonly mimeType: "image/jpeg";
}
/**
* The injected browser edge for attach-time compression: a `File` in, a
* (maybe) smaller JPEG out. Returns `null` when the source is not converted
* (non-convertible format) or the conversion is skipped/failed (decode or
* encode error, smaller-wins rule) — the caller then sends the original
* bytes. Compression must never block or break sending, so every failure is
* swallowed into `null`.
*/
export type ImageJpegEncoder = (file: File) => Promise<ConvertedJpeg | null>;
/**
* Canvas-based JPEG re-encode: decode the source, rescale to cap the longest
* edge at `MAX_IMAGE_EDGE_PX` (never upscale, high-quality smoothing), flatten
* any transparency onto a white background (JPEG has no alpha), and encode at
* `JPEG_QUALITY`. Applied only to convertible (lossless) sources; the result
* is used only under the smaller-wins rule.
*
* Thin by design — every decision comes from `../logic/image-compress`.
*/
export const convertToJpeg: ImageJpegEncoder = async (file: File) => {
try {
if (!shouldConvertImage(file.type)) return null;
const bitmap = await createImageBitmap(file);
const target = computeResizedDimensions(bitmap.width, bitmap.height);
const canvas = document.createElement("canvas");
canvas.width = target.width;
canvas.height = target.height;
const ctx = canvas.getContext("2d");
if (ctx === null) {
bitmap.close();
return null;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
// Flatten transparency: JPEG has no alpha channel.
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, target.width, target.height);
ctx.drawImage(bitmap, 0, 0, target.width, target.height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY);
});
if (blob === null) return null;
// Smaller-wins: keep the original when the re-encode didn't actually help.
if (!useConvertedImage(file.size, blob.size)) return null;
return { blob, mimeType: "image/jpeg" };
} catch {
// Decode/encode failure → the original image is sent untouched.
return null;
}
};
|