summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/adapter/image-encode.ts
blob: 4570a5aba2a490fbb0d18bfde9cf2ef6b08e818c (plain)
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;
  }
};