diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 528024de4393..8ab8ec4a80ab 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -795,22 +795,7 @@ export interface PullRequestDialogState { key: number; } -export function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - reject(new Error("Could not read image data.")); - }); - reader.addEventListener("error", () => { - reject(reader.error ?? new Error("Failed to read image.")); - }); - reader.readAsDataURL(file); - }); -} +export { readFileAsDataUrl } from "../lib/imageCompression"; export function resolveSendEnvMode(input: { requestedEnvMode: DraftThreadEnvMode; diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index d83bc1ef87c1..e82fc9f89288 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { createComposerImageThumbnail, @@ -34,6 +34,26 @@ vi.mock("heic-to/csp", () => ({ const originalCreateImageBitmap = globalThis.createImageBitmap; const originalOffscreenCanvas = globalThis.OffscreenCanvas; +beforeEach(() => { + vi.stubGlobal( + "FileReader", + class extends EventTarget { + result: string | null = null; + error: unknown = null; + async readAsDataURL(blob: Blob) { + try { + const bytes = await blob.arrayBuffer(); + this.result = `data:${blob.type || "application/octet-stream"};base64,${Buffer.from(bytes).toString("base64")}`; + this.dispatchEvent(new Event("load")); + } catch (error) { + this.error = error; + this.dispatchEvent(new Event("error")); + } + } + }, + ); +}); + function makeFile(sizeBytes: number, type = "image/png"): File { return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type }); } @@ -85,7 +105,7 @@ function makeHeicFile(options?: { */ function stubCanvasPipeline( sizeForQuality: (quality: number) => number, - options?: { supportsWebp?: boolean }, + options?: { supportsWebp?: boolean; htmlCanvas?: boolean }, ) { const supportsWebp = options?.supportsWebp ?? true; const close = vi.fn(); @@ -114,6 +134,17 @@ function stubCanvasPipeline( } }, ); + if (options?.htmlCanvas) { + const Canvas = globalThis.OffscreenCanvas; + class HtmlCanvas extends Canvas { + toBlob(callback: BlobCallback, type: string, quality: number) { + void this.convertToBlob({ type, quality }).then(callback); + } + } + vi.stubGlobal("OffscreenCanvas", undefined); + vi.stubGlobal("HTMLCanvasElement", HtmlCanvas); + vi.stubGlobal("document", { createElement: () => new HtmlCanvas(0, 0) }); + } return { close, fillRect }; } @@ -194,6 +225,51 @@ describe("dataUrlToFile", () => { }); describe("compressImageForStash", () => { + it.each([true, false])( + "encodes with HTML canvas when WebP support is %s", + async (supportsWebp) => { + const { close } = stubCanvasPipeline(() => 120_000, { htmlCanvas: true, supportsWebp }); + + const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000); + + expect(result.ok && result.file.type).toBe(supportsWebp ? "image/webp" : "image/jpeg"); + expect(result.ok && result.file.size).toBe(120_000); + expect(close).toHaveBeenCalledOnce(); + }, + ); + + it("keeps the exact data URL budget boundary and original bytes", async () => { + const original = makeFile(3); + const dataUrl = "data:image/png;base64,BwcH"; + + const result = await compressImageForStash(original, dataUrl.length); + + expect(result.ok && result.image.dataUrl).toBe(dataUrl); + expect(result.ok && result.image.recompressed).toBe(false); + expect(await compressImageForStash(original, dataUrl.length - 1)).toEqual({ + ok: false, + reason: "too-large", + }); + }); + + it("reports unreadable if reading a small image fails", async () => { + const original = makeFile(3); + vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("read failed")); + + expect(await compressImageForStash(original)).toEqual({ ok: false, reason: "unreadable" }); + }); + + it("compresses an oversized source before reading it as a data URL", async () => { + stubCanvasPipeline(() => 120_000); + const original = makeFile(4_000_000); + const read = vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("unneeded read")); + + const result = await compressImageForStash(original); + + expect(result.ok && result.image.recompressed).toBe(true); + expect(read).not.toHaveBeenCalled(); + }); + it("stores a small image verbatim without re-encoding", async () => { const bitmapSpy = vi.fn(); vi.stubGlobal("createImageBitmap", bitmapSpy); diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index 8414f190d5d9..36a05f646d16 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -143,33 +143,27 @@ async function validateHeicImageDimensions( return foundImageDimensions ? null : "unreadable"; } -/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ -const BASE64_CHUNK_SIZE = 0x8000; - -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)); - } - return btoa(binary); -} - -/** - * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so - * the module works anywhere `Blob` does (including non-DOM test runners). - */ -async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise { - const buffer = await blob.arrayBuffer(); - const mimeType = mimeTypeOverride || blob.type || "application/octet-stream"; - return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`; +export function readFileAsDataUrl(file: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("Could not read image data.")); + }); + reader.addEventListener("error", () => { + reject(reader.error ?? new Error("Failed to read image.")); + }); + reader.readAsDataURL(file); + }); } -/** Approximate decoded byte count for a base64 data URL. */ -function dataUrlByteLength(dataUrl: string): number { - const commaIndex = dataUrl.indexOf(","); - const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); - const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; - return Math.max(0, Math.floor((payload.length * 3) / 4) - padding); +function dataUrlLength(blob: Blob): number { + return ( + `data:${blob.type || "application/octet-stream"};base64,`.length + 4 * Math.ceil(blob.size / 3) + ); } /** Base64 payload of a data URL decoded back into a `File`. */ @@ -232,19 +226,14 @@ async function encodeCanvas( canvas: OffscreenCanvas | HTMLCanvasElement, quality: number, mimeType: string, - budgetChars: number, -): Promise<{ dataUrl: string | null; mimeType: string } | null> { +): Promise { + let blob: Blob | null; if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { - const dataUrl = canvas.toDataURL(mimeType, quality); - // toDataURL silently returns a PNG when the requested type is unsupported. - if (!dataUrl.startsWith(`data:${mimeType}`)) return null; - return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType }; + blob = await new Promise((resolve) => canvas.toBlob(resolve, mimeType, quality)); + } else { + blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); } - const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); - if (blob.type && blob.type !== mimeType) return null; - const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3); - if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType }; - return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; + return blob?.type === mimeType ? blob : null; } const composerThumbnails = new WeakMap>(); @@ -274,10 +263,8 @@ export function createComposerImageThumbnail(file: File): Promise dimension, dimension, ); - return ( - (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ?? - null - ); + const blob = await encodeCanvas(surface.canvas, 1, "image/png"); + return blob ? await readFileAsDataUrl(blob) : null; } catch { return null; } finally { @@ -297,7 +284,7 @@ async function encodeWithinBudget( maxDimension: number, budgetChars: number, preferredMimeType?: "image/jpeg", -): Promise<{ dataUrl: string; mimeType: string; imageSize: ImageSize } | null> { +): Promise<{ blob: Blob; mimeType: string; imageSize: ImageSize } | null> { const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); const width = Math.max(1, Math.round(bitmap.width * scale)); const height = Math.max(1, Math.round(bitmap.height * scale)); @@ -308,7 +295,7 @@ async function encodeWithinBudget( // happen before drawing and depends on which codec we end up using. const mimeType = preferredMimeType ?? - ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0)) + ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp")) ? "image/webp" : "image/jpeg"); @@ -319,17 +306,17 @@ async function encodeWithinBudget( target.context.drawImage(bitmap, 0, 0, width, height); for (const quality of QUALITY_STEPS) { - const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars); + const encoded = await encodeCanvas(target.canvas, quality, mimeType); if (!encoded) break; - if (encoded.dataUrl !== null) { - return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType, imageSize: { width, height } }; + if (dataUrlLength(encoded) <= budgetChars) { + return { blob: encoded, mimeType, imageSize: { width, height } }; } } return null; } type ReencodeResult = - | { ok: true; dataUrl: string; mimeType: string; imageSize: ImageSize } + | { ok: true; blob: Blob; mimeType: string; imageSize: ImageSize } | { ok: false; reason: ImageCompressionFailureReason }; /** @@ -378,10 +365,10 @@ async function reencodeWithinBudget( continue; } encodeFailed = false; - if (encoded && encoded.dataUrl.length <= budgetChars) { + if (encoded) { return { ok: true, - dataUrl: encoded.dataUrl, + blob: encoded.blob, mimeType: encoded.mimeType, imageSize: encoded.imageSize, }; @@ -405,37 +392,35 @@ export async function compressImageForStash( file: File, budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, ): Promise { - let originalDataUrl: string; try { - originalDataUrl = await blobToDataUrl(file); - } catch { - return { ok: false, reason: "unreadable" }; - } - if (originalDataUrl.length <= budgetChars) { + if (dataUrlLength(file) <= budgetChars) { + return { + ok: true, + image: { + dataUrl: await readFileAsDataUrl(file), + mimeType: file.type, + sizeBytes: file.size, + recompressed: false, + }, + }; + } + const reencoded = await reencodeWithinBudget(file, budgetChars); + if (!reencoded.ok) { + return reencoded; + } return { ok: true, image: { - dataUrl: originalDataUrl, - mimeType: file.type, - sizeBytes: file.size, - recompressed: false, + dataUrl: await readFileAsDataUrl(reencoded.blob), + mimeType: reencoded.mimeType, + sizeBytes: reencoded.blob.size, + recompressed: true, + imageSize: reencoded.imageSize, }, }; + } catch { + return { ok: false, reason: "unreadable" }; } - const reencoded = await reencodeWithinBudget(file, budgetChars); - if (!reencoded.ok) { - return reencoded; - } - return { - ok: true, - image: { - dataUrl: reencoded.dataUrl, - mimeType: reencoded.mimeType, - sizeBytes: dataUrlByteLength(reencoded.dataUrl), - recompressed: true, - imageSize: reencoded.imageSize, - }, - }; } /** @@ -468,10 +453,10 @@ export async function compressImageToByteLimit( } return { ok: true, - file: dataUrlToFile( - reencoded.dataUrl, + file: new File( + [reencoded.blob], fileNameForMimeType(file.name || "image", reencoded.mimeType), - reencoded.mimeType, + { type: reencoded.mimeType }, ), recompressed: true, imageSize: reencoded.imageSize,