diff options
Diffstat (limited to 'src/features/chat/logic')
| -rw-r--r-- | src/features/chat/logic/image-compress.test.ts | 76 | ||||
| -rw-r--r-- | src/features/chat/logic/image-compress.ts | 77 |
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; +} |
