summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/logic
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/logic
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/logic')
-rw-r--r--src/features/chat/logic/image-compress.test.ts76
-rw-r--r--src/features/chat/logic/image-compress.ts77
2 files changed, 153 insertions, 0 deletions
diff --git a/src/features/chat/logic/image-compress.test.ts b/src/features/chat/logic/image-compress.test.ts
new file mode 100644
index 0000000..88726bf
--- /dev/null
+++ b/src/features/chat/logic/image-compress.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeResizedDimensions,
+ MAX_IMAGE_EDGE_PX,
+ shouldConvertImage,
+ useConvertedImage,
+} from "./image-compress";
+
+describe("shouldConvertImage", () => {
+ it("converts lossless sources (PNG, BMP)", () => {
+ expect(shouldConvertImage("image/png")).toBe(true);
+ expect(shouldConvertImage("image/bmp")).toBe(true);
+ });
+
+ it("skips already-lossy and animated formats", () => {
+ expect(shouldConvertImage("image/jpeg")).toBe(false);
+ expect(shouldConvertImage("image/webp")).toBe(false);
+ expect(shouldConvertImage("image/gif")).toBe(false);
+ });
+
+ it("leaves unknown or absent MIME types alone", () => {
+ expect(shouldConvertImage(undefined)).toBe(false);
+ expect(shouldConvertImage("")).toBe(false);
+ expect(shouldConvertImage("image/tiff")).toBe(false);
+ expect(shouldConvertImage("image/svg+xml")).toBe(false);
+ });
+});
+
+describe("computeResizedDimensions", () => {
+ it("never upscales a source already within the cap", () => {
+ expect(computeResizedDimensions(100, 50)).toEqual({ width: 100, height: 50 });
+ expect(computeResizedDimensions(2048, 1024)).toEqual({ width: 2048, height: 1024 });
+ expect(computeResizedDimensions(640, 640)).toEqual({ width: 640, height: 640 });
+ });
+
+ it("caps the longest edge at 2048", () => {
+ expect(computeResizedDimensions(4096, 2048)).toEqual({ width: 2048, height: 1024 });
+ expect(computeResizedDimensions(8192, 4096)).toEqual({ width: 2048, height: 1024 });
+ });
+
+ it("caps the longest edge regardless of orientation (portrait)", () => {
+ expect(computeResizedDimensions(2000, 3000)).toEqual({ width: 1365, height: 2048 });
+ });
+
+ it("preserves aspect ratio on the rescale", () => {
+ const { width, height } = computeResizedDimensions(3000, 2000);
+ expect(width).toBe(2048);
+ // 2000 * (2048/3000) = 1365.33… → 1365
+ expect(height).toBe(1365);
+ expect(width / height).toBeCloseTo(3000 / 2000, 2);
+ });
+
+ it("handles extreme aspect ratios without exceeding the cap", () => {
+ expect(computeResizedDimensions(5000, 10)).toEqual({ width: 2048, height: 4 });
+ expect(computeResizedDimensions(10, 5000)).toEqual({ width: 4, height: 2048 });
+ });
+
+ it("honors a custom cap", () => {
+ expect(computeResizedDimensions(1000, 500, 250)).toEqual({ width: 250, height: 125 });
+ });
+
+ it("uses the default 2048px cap constant", () => {
+ expect(MAX_IMAGE_EDGE_PX).toBe(2048);
+ });
+});
+
+describe("useConvertedImage", () => {
+ it("uses the conversion only when it is strictly fewer bytes", () => {
+ expect(useConvertedImage(3_000_000, 300_000)).toBe(true);
+ });
+
+ it("keeps the original when equal or larger", () => {
+ expect(useConvertedImage(300_000, 300_000)).toBe(false);
+ expect(useConvertedImage(100_000, 300_000)).toBe(false);
+ });
+});
diff --git a/src/features/chat/logic/image-compress.ts b/src/features/chat/logic/image-compress.ts
new file mode 100644
index 0000000..a9037b8
--- /dev/null
+++ b/src/features/chat/logic/image-compress.ts
@@ -0,0 +1,77 @@
+/**
+ * Pure decision logic for attach-time image compression (the `Compress`
+ * toggle on the composer's staged-image thumbnails).
+ *
+ * The pipeline (re-encode to JPEG + rescale) runs at the browser edge
+ * (`../adapter/image-encode`); this module owns every DECISION so it stays
+ * pure `input → output` and unit-testable without a DOM:
+ *
+ * - `shouldConvertImage` — which source formats get converted.
+ * - `computeResizedDimensions` — the rescale math (cap the longest edge,
+ * never upscale, preserve aspect).
+ * - `useConvertedImage` — the smaller-wins rule.
+ */
+
+/** JPEG encode quality for the canvas re-encode. */
+export const JPEG_QUALITY = 0.85;
+
+/** Longest-edge cap (px) for the rescale. Never upscale past the source. */
+export const MAX_IMAGE_EDGE_PX = 2048;
+
+/**
+ * Source formats that get converted. Only LOSSLESS sources are re-encoded:
+ * PNG (the screenshot case — multi-MB PNGs are the reason this exists) plus
+ * BMP (trivially canvas-decodable in every browser). Already-lossy formats
+ * (JPEG/WebP) and animated ones (GIF) are sent verbatim — recompressing them
+ * degrades quality and saves nothing, and re-encoding a GIF destroys its
+ * animation. TIFF is NOT listed: browsers can't natively decode it, so the
+ * canvas path would always fall back to the original anyway. An oversized
+ * JPEG stays untouched (the backend's body-budget safety net handles the
+ * extreme case).
+ */
+const CONVERTIBLE_MIME_TYPES = new Set(["image/png", "image/bmp"]);
+
+/**
+ * Whether a source MIME type is converted to JPEG at staging time.
+ * Unknown/absent MIME types are left alone (conservative — never gamble on
+ * bytes we can't classify).
+ */
+export function shouldConvertImage(mimeType: string | undefined): boolean {
+ return mimeType !== undefined && CONVERTIBLE_MIME_TYPES.has(mimeType);
+}
+
+/** Pixel dimensions of a raster image. */
+export interface Dimensions {
+ readonly width: number;
+ readonly height: number;
+}
+
+/**
+ * Target dimensions after the rescale: cap the LONGEST edge at `maxEdge`
+ * (default `MAX_IMAGE_EDGE_PX`), never upscale (a smaller source passes
+ * through unchanged), and preserve aspect ratio.
+ *
+ * Pure math — the canvas draw itself lives at the browser edge.
+ */
+export function computeResizedDimensions(
+ width: number,
+ height: number,
+ maxEdge: number = MAX_IMAGE_EDGE_PX,
+): Dimensions {
+ const longest = Math.max(width, height);
+ if (longest <= maxEdge) return { width, height };
+ const scale = maxEdge / longest;
+ // Rounding can never exceed the cap: each scaled edge is ≤ maxEdge before
+ // rounding, and the only values that round up land exactly ON the cap.
+ return { width: Math.round(width * scale), height: Math.round(height * scale) };
+}
+
+/**
+ * The smaller-wins rule: the converted JPEG is used only when it is actually
+ * FEWER bytes than the original (equal or larger → keep the original).
+ * Compared on raw byte counts (File/Blob `size`); base64 inflation affects
+ * both sides equally so the ratio is preserved.
+ */
+export function useConvertedImage(originalBytes: number, convertedBytes: number): boolean {
+ return convertedBytes < originalBytes;
+}