From 72663945ef0a0053fbfd83b168e90f12169a5c3a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 18 Sep 2026 18:39:00 +0900 Subject: feat(chat): compress images at attach time (JPEG re-encode + rescale) with a per-image toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/features/chat/adapter/image-encode.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/features/chat/adapter/image-encode.test.ts (limited to 'src/features/chat/adapter/image-encode.test.ts') 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); + }); +}); -- cgit v1.2.3