Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 133 additions & 2 deletions src/features/chat/lib/__tests__/resizeImage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { sniffAcceptedImageMimeType } from "../resizeImage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
dimensionsForProviderHistory,
resizeImage,
sniffAcceptedImageMimeType,
} from "../resizeImage";

const JPEG_HEADER = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46];
const PNG_HEADER = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
Expand All @@ -19,6 +23,133 @@ function bytes(values: number[]): Uint8Array {
return new Uint8Array(values);
}

describe("dimensionsForProviderHistory", () => {
it("keeps images at the many-image request limit unchanged", () => {
expect(dimensionsForProviderHistory(2_000, 1_500)).toEqual({
width: 2_000,
height: 1_500,
});
});

it("scales a 2048px screenshot below the many-image request limit", () => {
expect(dimensionsForProviderHistory(2_048, 1_338)).toEqual({
width: 2_000,
height: 1_307,
});
expect(dimensionsForProviderHistory(1_731, 2_048)).toEqual({
width: 1_690,
height: 2_000,
});
});
});

describe("resizeImage", () => {
const originalImage = globalThis.Image;
const originalCreateElement = document.createElement.bind(document);
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;

let sourceWidth = 0;
let sourceHeight = 0;
let canvas: HTMLCanvasElement;
let drawImage: ReturnType<typeof vi.fn>;
let toDataURL: ReturnType<typeof vi.fn>;

function imageBlob(): Blob {
const source = Uint8Array.from(PNG_HEADER);
const blob = new Blob([source.buffer], { type: "image/png" });
Object.defineProperty(blob, "arrayBuffer", {
value: async () => source.buffer,
});
const originalSlice = blob.slice.bind(blob);
Object.defineProperty(blob, "slice", {
value: (...args: Parameters<Blob["slice"]>) => {
const slice = originalSlice(...args);
const slicedSource = source.slice(args[0] ?? 0, args[1]);
Object.defineProperty(slice, "arrayBuffer", {
value: async () => slicedSource.buffer,
});
return slice;
},
});
return blob;
}

beforeEach(() => {
class MockImage {
width = sourceWidth;
height = sourceHeight;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;

set src(_value: string) {
this.width = sourceWidth;
this.height = sourceHeight;
queueMicrotask(() => this.onload?.());
}
}

drawImage = vi.fn();
toDataURL = vi.fn(() => "data:image/png;base64,cmVzaXplZA==");
canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({ drawImage })),
toDataURL,
} as unknown as HTMLCanvasElement;

globalThis.Image = MockImage as unknown as typeof Image;
URL.createObjectURL = vi.fn(() => "blob:test-image");
URL.revokeObjectURL = vi.fn();
document.createElement = vi.fn(((tagName: string) =>
tagName === "canvas"
? canvas
: originalCreateElement(tagName)) as typeof document.createElement);
});

afterEach(() => {
globalThis.Image = originalImage;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
document.createElement = originalCreateElement;
});

it("re-encodes a 2048px screenshot at the many-image request limit", async () => {
sourceWidth = 2_048;
sourceHeight = 1_338;

const normalized = await resizeImage(imageBlob());

expect(canvas.width).toBe(2_000);
expect(canvas.height).toBe(1_307);
expect(drawImage).toHaveBeenCalledWith(
expect.objectContaining({ width: 2_048, height: 1_338 }),
0,
0,
2_000,
1_307,
);
expect(toDataURL).toHaveBeenCalledWith("image/png", undefined);
expect(normalized).toEqual({
base64: "cmVzaXplZA==",
mimeType: "image/png",
});
});

it("passes through a 2000px image without canvas encoding", async () => {
sourceWidth = 2_000;
sourceHeight = 1_500;
const source = bytes(PNG_HEADER);

const normalized = await resizeImage(imageBlob());

expect(document.createElement).not.toHaveBeenCalledWith("canvas");
expect(toDataURL).not.toHaveBeenCalled();
expect(normalized.mimeType).toBe("image/png");
expect(atob(normalized.base64)).toBe(String.fromCharCode(...source));
});
});

describe("sniffAcceptedImageMimeType", () => {
it("identifies the four accepted formats from magic bytes", () => {
expect(sniffAcceptedImageMimeType(bytes(JPEG_HEADER))).toBe("image/jpeg");
Expand Down
2 changes: 1 addition & 1 deletion src/features/chat/lib/attachmentPayloadBudget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { ChatAttachmentDraft } from "@/shared/types/messages";
* every open chat — when a message overflows its frame limit (16MiB
* tungstenite default, BOT-1463). The budget stays comfortably under that
* so prompt text and JSON envelope overhead can never push a send over the
* edge. Normalized images (2048px cap) are a few hundred KB each, so normal
* edge. Normalized images (2000px cap) are a few hundred KB each, so normal
* use never approaches this.
*/
export const MAX_PROMPT_ATTACHMENT_BYTES = 12 * 1024 * 1024;
Expand Down
22 changes: 17 additions & 5 deletions src/features/chat/lib/resizeImage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const MAX_IMAGE_DIMENSION = 2048;
// Anthropic applies this stricter per-image limit to every historical image
// once a request contains more than 20 image or document blocks. Normalizing
// at ingress keeps an image accepted early in a chat valid as history grows.
const MAX_IMAGE_DIMENSION = 2000;
const JPEG_QUALITY = 0.85;

export interface NormalizedImage {
Expand Down Expand Up @@ -96,14 +99,23 @@ function loadImageElement(blob: Blob): Promise<HTMLImageElement> {
});
}

export function dimensionsForProviderHistory(
sourceWidth: number,
sourceHeight: number,
): { width: number; height: number } {
const maxDimension = Math.max(sourceWidth, sourceHeight);
const scale = Math.min(1, MAX_IMAGE_DIMENSION / maxDimension);
return {
width: Math.max(1, Math.round(sourceWidth * scale)),
height: Math.max(1, Math.round(sourceHeight * scale)),
};
}

function encodeWithCanvas(
img: HTMLImageElement,
sourceMimeType: string,
): NormalizedImage {
const maxDim = Math.max(img.width, img.height);
const scale = Math.min(1, MAX_IMAGE_DIMENSION / maxDim);
const width = Math.max(1, Math.round(img.width * scale));
const height = Math.max(1, Math.round(img.height * scale));
const { width, height } = dimensionsForProviderHistory(img.width, img.height);

const canvas = document.createElement("canvas");
canvas.width = width;
Expand Down