authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-06 07:08:10+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:08:10-07:00
logff2fda7d6d59a05b9077b8fc48a5e0b67054b8d5
tree2107a31ccc7567a8ce85cf89bffa6e309ad32d92
parent2103bfaa77c431e0923ceb53b52418c0eef174da
signature Signed by PGP key B5690EEEBB952194

fix(web): avoid blocking image preparation conversions (#13342)


3 files changed, 139 insertions(+), 93 deletions(-)

apps/web/src/components/ChatView.logic.ts+1-16
......@@ -810,22 +810,7 @@ export interface PullRequestDialogState {
810810 key: number;
811811}
812812
813export function readFileAsDataUrl(file: File): Promise<string> {
814 return new Promise((resolve, reject) => {
815 const reader = new FileReader();
816 reader.addEventListener("load", () => {
817 if (typeof reader.result === "string") {
818 resolve(reader.result);
819 return;
820 }
821 reject(new Error("Could not read image data."));
822 });
823 reader.addEventListener("error", () => {
824 reject(reader.error ?? new Error("Failed to read image."));
825 });
826 reader.readAsDataURL(file);
827 });
828}
813export { readFileAsDataUrl } from "../lib/imageCompression";
829814
830815export function resolveSendEnvMode(input: {
831816 requestedEnvMode: DraftThreadEnvMode;
apps/web/src/lib/imageCompression.test.ts+78-2
......@@ -1,4 +1,4 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
33import {
44 createComposerImageThumbnail,
......@@ -34,6 +34,26 @@ vi.mock("heic-to/csp", () => ({
3434const originalCreateImageBitmap = globalThis.createImageBitmap;
3535const originalOffscreenCanvas = globalThis.OffscreenCanvas;
3636
37beforeEach(() => {
38 vi.stubGlobal(
39 "FileReader",
40 class extends EventTarget {
41 result: string | null = null;
42 error: unknown = null;
43 async readAsDataURL(blob: Blob) {
44 try {
45 const bytes = await blob.arrayBuffer();
46 this.result = `data:${blob.type || "application/octet-stream"};base64,${Buffer.from(bytes).toString("base64")}`;
47 this.dispatchEvent(new Event("load"));
48 } catch (error) {
49 this.error = error;
50 this.dispatchEvent(new Event("error"));
51 }
52 }
53 },
54 );
55});
56
3757function makeFile(sizeBytes: number, type = "image/png"): File {
3858 return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });
3959}
......@@ -85,7 +105,7 @@ function makeHeicFile(options?: {
85105 */
86106function stubCanvasPipeline(
87107 sizeForQuality: (quality: number) => number,
88 options?: { supportsWebp?: boolean },
108 options?: { supportsWebp?: boolean; htmlCanvas?: boolean },
89109) {
90110 const supportsWebp = options?.supportsWebp ?? true;
91111 const close = vi.fn();
......@@ -114,6 +134,17 @@ function stubCanvasPipeline(
114134 }
115135 },
116136 );
137 if (options?.htmlCanvas) {
138 const Canvas = globalThis.OffscreenCanvas;
139 class HtmlCanvas extends Canvas {
140 toBlob(callback: BlobCallback, type: string, quality: number) {
141 void this.convertToBlob({ type, quality }).then(callback);
142 }
143 }
144 vi.stubGlobal("OffscreenCanvas", undefined);
145 vi.stubGlobal("HTMLCanvasElement", HtmlCanvas);
146 vi.stubGlobal("document", { createElement: () => new HtmlCanvas(0, 0) });
147 }
117148 return { close, fillRect };
118149}
119150
......@@ -194,6 +225,51 @@ describe("dataUrlToFile", () => {
194225});
195226
196227describe("compressImageForStash", () => {
228 it.each([true, false])(
229 "encodes with HTML canvas when WebP support is %s",
230 async (supportsWebp) => {
231 const { close } = stubCanvasPipeline(() => 120_000, { htmlCanvas: true, supportsWebp });
232
233 const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);
234
235 expect(result.ok && result.file.type).toBe(supportsWebp ? "image/webp" : "image/jpeg");
236 expect(result.ok && result.file.size).toBe(120_000);
237 expect(close).toHaveBeenCalledOnce();
238 },
239 );
240
241 it("keeps the exact data URL budget boundary and original bytes", async () => {
242 const original = makeFile(3);
243 const dataUrl = "data:image/png;base64,BwcH";
244
245 const result = await compressImageForStash(original, dataUrl.length);
246
247 expect(result.ok && result.image.dataUrl).toBe(dataUrl);
248 expect(result.ok && result.image.recompressed).toBe(false);
249 expect(await compressImageForStash(original, dataUrl.length - 1)).toEqual({
250 ok: false,
251 reason: "too-large",
252 });
253 });
254
255 it("reports unreadable if reading a small image fails", async () => {
256 const original = makeFile(3);
257 vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("read failed"));
258
259 expect(await compressImageForStash(original)).toEqual({ ok: false, reason: "unreadable" });
260 });
261
262 it("compresses an oversized source before reading it as a data URL", async () => {
263 stubCanvasPipeline(() => 120_000);
264 const original = makeFile(4_000_000);
265 const read = vi.spyOn(original, "arrayBuffer").mockRejectedValue(new Error("unneeded read"));
266
267 const result = await compressImageForStash(original);
268
269 expect(result.ok && result.image.recompressed).toBe(true);
270 expect(read).not.toHaveBeenCalled();
271 });
272
197273 it("stores a small image verbatim without re-encoding", async () => {
198274 const bitmapSpy = vi.fn();
199275 vi.stubGlobal("createImageBitmap", bitmapSpy);
apps/web/src/lib/imageCompression.ts+60-75
......@@ -143,33 +143,27 @@ async function validateHeicImageDimensions(
143143 return foundImageDimensions ? null : "unreadable";
144144}
145145
146/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */
147const BASE64_CHUNK_SIZE = 0x8000;
148
149function bytesToBase64(bytes: Uint8Array): string {
150 let binary = "";
151 for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
152 binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
153 }
154 return btoa(binary);
155}
156
157/**
158 * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so
159 * the module works anywhere `Blob` does (including non-DOM test runners).
160 */
161async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise<string> {
162 const buffer = await blob.arrayBuffer();
163 const mimeType = mimeTypeOverride || blob.type || "application/octet-stream";
164 return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`;
146export function readFileAsDataUrl(file: Blob): Promise<string> {
147 return new Promise((resolve, reject) => {
148 const reader = new FileReader();
149 reader.addEventListener("load", () => {
150 if (typeof reader.result === "string") {
151 resolve(reader.result);
152 return;
153 }
154 reject(new Error("Could not read image data."));
155 });
156 reader.addEventListener("error", () => {
157 reject(reader.error ?? new Error("Failed to read image."));
158 });
159 reader.readAsDataURL(file);
160 });
165161}
166162
167/** Approximate decoded byte count for a base64 data URL. */
168function dataUrlByteLength(dataUrl: string): number {
169 const commaIndex = dataUrl.indexOf(",");
170 const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
171 const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
172 return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
163function dataUrlLength(blob: Blob): number {
164 return (
165 `data:${blob.type || "application/octet-stream"};base64,`.length + 4 * Math.ceil(blob.size / 3)
166 );
173167}
174168
175169/** Base64 payload of a data URL decoded back into a `File`. */
......@@ -232,19 +226,14 @@ async function encodeCanvas(
232226 canvas: OffscreenCanvas | HTMLCanvasElement,
233227 quality: number,
234228 mimeType: string,
235 budgetChars: number,
236): Promise<{ dataUrl: string | null; mimeType: string } | null> {
229): Promise<Blob | null> {
230 let blob: Blob | null;
237231 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
238 const dataUrl = canvas.toDataURL(mimeType, quality);
239 // toDataURL silently returns a PNG when the requested type is unsupported.
240 if (!dataUrl.startsWith(`data:${mimeType}`)) return null;
241 return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType };
232 blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mimeType, quality));
233 } else {
234 blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
242235 }
243 const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
244 if (blob.type && blob.type !== mimeType) return null;
245 const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3);
246 if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType };
247 return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
236 return blob?.type === mimeType ? blob : null;
248237}
249238
250239const composerThumbnails = new WeakMap<File, Promise<string | null>>();
......@@ -274,10 +263,8 @@ export function createComposerImageThumbnail(file: File): Promise<string | null>
274263 dimension,
275264 dimension,
276265 );
277 return (
278 (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ??
279 null
280 );
266 const blob = await encodeCanvas(surface.canvas, 1, "image/png");
267 return blob ? await readFileAsDataUrl(blob) : null;
281268 } catch {
282269 return null;
283270 } finally {
......@@ -297,7 +284,7 @@ async function encodeWithinBudget(
297284 maxDimension: number,
298285 budgetChars: number,
299286 preferredMimeType?: "image/jpeg",
300): Promise<{ dataUrl: string; mimeType: string; imageSize: ImageSize } | null> {
287): Promise<{ blob: Blob; mimeType: string; imageSize: ImageSize } | null> {
301288 const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
302289 const width = Math.max(1, Math.round(bitmap.width * scale));
303290 const height = Math.max(1, Math.round(bitmap.height * scale));
......@@ -308,7 +295,7 @@ async function encodeWithinBudget(
308295 // happen before drawing and depends on which codec we end up using.
309296 const mimeType =
310297 preferredMimeType ??
311 ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0))
298 ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp"))
312299 ? "image/webp"
313300 : "image/jpeg");
314301
......@@ -319,17 +306,17 @@ async function encodeWithinBudget(
319306 target.context.drawImage(bitmap, 0, 0, width, height);
320307
321308 for (const quality of QUALITY_STEPS) {
322 const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars);
309 const encoded = await encodeCanvas(target.canvas, quality, mimeType);
323310 if (!encoded) break;
324 if (encoded.dataUrl !== null) {
325 return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType, imageSize: { width, height } };
311 if (dataUrlLength(encoded) <= budgetChars) {
312 return { blob: encoded, mimeType, imageSize: { width, height } };
326313 }
327314 }
328315 return null;
329316}
330317
331318type ReencodeResult =
332 | { ok: true; dataUrl: string; mimeType: string; imageSize: ImageSize }
319 | { ok: true; blob: Blob; mimeType: string; imageSize: ImageSize }
333320 | { ok: false; reason: ImageCompressionFailureReason };
334321
335322/**
......@@ -378,10 +365,10 @@ async function reencodeWithinBudget(
378365 continue;
379366 }
380367 encodeFailed = false;
381 if (encoded && encoded.dataUrl.length <= budgetChars) {
368 if (encoded) {
382369 return {
383370 ok: true,
384 dataUrl: encoded.dataUrl,
371 blob: encoded.blob,
385372 mimeType: encoded.mimeType,
386373 imageSize: encoded.imageSize,
387374 };
......@@ -405,37 +392,35 @@ export async function compressImageForStash(
405392 file: File,
406393 budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS,
407394): Promise<CompressStashImageResult> {
408 let originalDataUrl: string;
409395 try {
410 originalDataUrl = await blobToDataUrl(file);
411 } catch {
412 return { ok: false, reason: "unreadable" };
413 }
414 if (originalDataUrl.length <= budgetChars) {
396 if (dataUrlLength(file) <= budgetChars) {
397 return {
398 ok: true,
399 image: {
400 dataUrl: await readFileAsDataUrl(file),
401 mimeType: file.type,
402 sizeBytes: file.size,
403 recompressed: false,
404 },
405 };
406 }
407 const reencoded = await reencodeWithinBudget(file, budgetChars);
408 if (!reencoded.ok) {
409 return reencoded;
410 }
415411 return {
416412 ok: true,
417413 image: {
418 dataUrl: originalDataUrl,
419 mimeType: file.type,
420 sizeBytes: file.size,
421 recompressed: false,
414 dataUrl: await readFileAsDataUrl(reencoded.blob),
415 mimeType: reencoded.mimeType,
416 sizeBytes: reencoded.blob.size,
417 recompressed: true,
418 imageSize: reencoded.imageSize,
422419 },
423420 };
421 } catch {
422 return { ok: false, reason: "unreadable" };
424423 }
425 const reencoded = await reencodeWithinBudget(file, budgetChars);
426 if (!reencoded.ok) {
427 return reencoded;
428 }
429 return {
430 ok: true,
431 image: {
432 dataUrl: reencoded.dataUrl,
433 mimeType: reencoded.mimeType,
434 sizeBytes: dataUrlByteLength(reencoded.dataUrl),
435 recompressed: true,
436 imageSize: reencoded.imageSize,
437 },
438 };
439424}
440425
441426/**
......@@ -468,10 +453,10 @@ export async function compressImageToByteLimit(
468453 }
469454 return {
470455 ok: true,
471 file: dataUrlToFile(
472 reencoded.dataUrl,
456 file: new File(
457 [reencoded.blob],
473458 fileNameForMimeType(file.name || "image", reencoded.mimeType),
474 reencoded.mimeType,
459 { type: reencoded.mimeType },
475460 ),
476461 recompressed: true,
477462 imageSize: reencoded.imageSize,