summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/logic
diff options
context:
space:
mode:
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;
+}