summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/adapter
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-18 18:39:00 +0900
committerAdam Malczewski <[email protected]>2026-09-18 18:39:00 +0900
commit72663945ef0a0053fbfd83b168e90f12169a5c3a (patch)
treed4a7c0071c2a9baeba657316d59b7212031363e0 /src/features/chat/adapter
parent9c201c60a727106f1e3bf8c2af5f86aa7427098f (diff)
downloaddispatch-web-72663945ef0a0053fbfd83b168e90f12169a5c3a.tar.gz
dispatch-web-72663945ef0a0053fbfd83b168e90f12169a5c3a.zip
feat(chat): compress images at attach time (JPEG re-encode + rescale) with a per-image toggle
Staged images now default to a compressed variant: LOSSLESS sources (PNG/BMP) are re-encoded to JPEG at q0.85, rescaled to cap the longest edge at 2048px (never upscaled, high-quality smoothing, alpha flattened onto white), and used only under a smaller-wins rule. JPEG/WebP/GIF are sent verbatim. Each staged thumbnail gets a Compress checkmark (default ON, reversible until send, cached so flipping never re-encodes); OFF sends the original untouched bytes. Conversion runs async at staging and any failure silently falls back to the original — sending is never blocked or broken. Pure decision logic lives in features/chat/logic/image-compress.ts (format selection, dimension math, smaller-wins); the canvas encode is a thin injected edge (features/chat/adapter/image-encode.ts, Composer prop encodeJpeg). No wire-contract, send-path, or backend changes; complements the backend's body-budget eviction safety net by shrinking payloads at the source. Verification: typecheck 0 errors, vitest 1720/1720 (twice), biome clean, build succeeds. New tests: 12 pure-logic, 2 encoder-edge, 6 component.
Diffstat (limited to 'src/features/chat/adapter')
-rw-r--r--src/features/chat/adapter/image-encode.test.ts20
-rw-r--r--src/features/chat/adapter/image-encode.ts67
2 files changed, 87 insertions, 0 deletions
diff --git a/src/features/chat/adapter/image-encode.test.ts b/src/features/chat/adapter/image-encode.test.ts
new file mode 100644
index 0000000..dbaa8ae
--- /dev/null
+++ b/src/features/chat/adapter/image-encode.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { convertToJpeg } from "./image-encode";
+
+describe("convertToJpeg (canvas edge — real environment, no mocks)", () => {
+ it("skips non-convertible formats without touching the canvas", async () => {
+ const jpeg = new File([new Uint8Array([0xff, 0xd8])], "photo.jpg", { type: "image/jpeg" });
+ await expect(convertToJpeg(jpeg)).resolves.toBe(null);
+ const gif = new File([new Uint8Array([0x47])], "anim.gif", { type: "image/gif" });
+ await expect(convertToJpeg(gif)).resolves.toBe(null);
+ const unnamed = new File([new Uint8Array([0x00])], "blob", { type: "" });
+ await expect(convertToJpeg(unnamed)).resolves.toBe(null);
+ });
+
+ it("falls back to null on decode failure (no real canvas in this environment)", async () => {
+ const png = new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" });
+ // jsdom has no `createImageBitmap` → the decode throws → the failure path
+ // must resolve to `null` (never reject), so sending is never blocked.
+ await expect(convertToJpeg(png)).resolves.toBe(null);
+ });
+});
diff --git a/src/features/chat/adapter/image-encode.ts b/src/features/chat/adapter/image-encode.ts
new file mode 100644
index 0000000..4570a5a
--- /dev/null
+++ b/src/features/chat/adapter/image-encode.ts
@@ -0,0 +1,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;
+ }
+};