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 {...@@ -810,22 +810,7 @@ export interface PullRequestDialogState {
810 key: number;810 key: number;
811}811}
812812
813export function readFileAsDataUrl(file: File): Promise<string> {813export { readFileAsDataUrl } from "../lib/imageCompression";
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}
829814
830export function resolveSendEnvMode(input: {815export function resolveSendEnvMode(input: {
831 requestedEnvMode: DraftThreadEnvMode;816 requestedEnvMode: DraftThreadEnvMode;
apps/web/src/lib/imageCompression.test.ts+78-2
...@@ -1,4 +1,4 @@...@@ -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
3import {3import {
4 createComposerImageThumbnail,4 createComposerImageThumbnail,
...@@ -34,6 +34,26 @@ vi.mock("heic-to/csp", () => ({...@@ -34,6 +34,26 @@ vi.mock("heic-to/csp", () => ({
34const originalCreateImageBitmap = globalThis.createImageBitmap;34const originalCreateImageBitmap = globalThis.createImageBitmap;
35const originalOffscreenCanvas = globalThis.OffscreenCanvas;35const 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
37function makeFile(sizeBytes: number, type = "image/png"): File {57function makeFile(sizeBytes: number, type = "image/png"): File {
38 return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });58 return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });
39}59}
...@@ -85,7 +105,7 @@ function makeHeicFile(options?: {...@@ -85,7 +105,7 @@ function makeHeicFile(options?: {
85 */105 */
86function stubCanvasPipeline(106function stubCanvasPipeline(
87 sizeForQuality: (quality: number) => number,107 sizeForQuality: (quality: number) => number,
88 options?: { supportsWebp?: boolean },108 options?: { supportsWebp?: boolean; htmlCanvas?: boolean },
89) {109) {
90 const supportsWebp = options?.supportsWebp ?? true;110 const supportsWebp = options?.supportsWebp ?? true;
91 const close = vi.fn();111 const close = vi.fn();
...@@ -114,6 +134,17 @@ function stubCanvasPipeline(...@@ -114,6 +134,17 @@ function stubCanvasPipeline(
114 }134 }
115 },135 },
116 );136 );
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 }
117 return { close, fillRect };148 return { close, fillRect };
118}149}
119150
...@@ -194,6 +225,51 @@ describe("dataUrlToFile", () => {...@@ -194,6 +225,51 @@ describe("dataUrlToFile", () => {
194});225});
195226
196describe("compressImageForStash", () => {227describe("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
197 it("stores a small image verbatim without re-encoding", async () => {273 it("stores a small image verbatim without re-encoding", async () => {
198 const bitmapSpy = vi.fn();274 const bitmapSpy = vi.fn();
199 vi.stubGlobal("createImageBitmap", bitmapSpy);275 vi.stubGlobal("createImageBitmap", bitmapSpy);
apps/web/src/lib/imageCompression.ts+60-75
...@@ -143,33 +143,27 @@ async function validateHeicImageDimensions(...@@ -143,33 +143,27 @@ async function validateHeicImageDimensions(
143 return foundImageDimensions ? null : "unreadable";143 return foundImageDimensions ? null : "unreadable";
144}144}
145145
146/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */146export function readFileAsDataUrl(file: Blob): Promise<string> {
147const BASE64_CHUNK_SIZE = 0x8000;147 return new Promise((resolve, reject) => {
148148 const reader = new FileReader();
149function bytesToBase64(bytes: Uint8Array): string {149 reader.addEventListener("load", () => {
150 let binary = "";150 if (typeof reader.result === "string") {
151 for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {151 resolve(reader.result);
152 binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));152 return;
153 }153 }
154 return btoa(binary);154 reject(new Error("Could not read image data."));
155}155 });
156156 reader.addEventListener("error", () => {
157/**157 reject(reader.error ?? new Error("Failed to read image."));
158 * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so158 });
159 * the module works anywhere `Blob` does (including non-DOM test runners).159 reader.readAsDataURL(file);
160 */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))}`;
165}161}
166162
167/** Approximate decoded byte count for a base64 data URL. */163function dataUrlLength(blob: Blob): number {
168function dataUrlByteLength(dataUrl: string): number {164 return (
169 const commaIndex = dataUrl.indexOf(",");165 `data:${blob.type || "application/octet-stream"};base64,`.length + 4 * Math.ceil(blob.size / 3)
170 const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);166 );
171 const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
172 return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
173}167}
174168
175/** Base64 payload of a data URL decoded back into a `File`. */169/** Base64 payload of a data URL decoded back into a `File`. */
...@@ -232,19 +226,14 @@ async function encodeCanvas(...@@ -232,19 +226,14 @@ async function encodeCanvas(
232 canvas: OffscreenCanvas | HTMLCanvasElement,226 canvas: OffscreenCanvas | HTMLCanvasElement,
233 quality: number,227 quality: number,
234 mimeType: string,228 mimeType: string,
235 budgetChars: number,229): Promise<Blob | null> {
236): Promise<{ dataUrl: string | null; mimeType: string } | null> {230 let blob: Blob | null;
237 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {231 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
238 const dataUrl = canvas.toDataURL(mimeType, quality);232 blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mimeType, quality));
239 // toDataURL silently returns a PNG when the requested type is unsupported.233 } else {
240 if (!dataUrl.startsWith(`data:${mimeType}`)) return null;234 blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
241 return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType };
242 }235 }
243 const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });236 return blob?.type === mimeType ? blob : null;
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 };
248}237}
249238
250const composerThumbnails = new WeakMap<File, Promise<string | null>>();239const composerThumbnails = new WeakMap<File, Promise<string | null>>();
...@@ -274,10 +263,8 @@ export function createComposerImageThumbnail(file: File): Promise<string | null>...@@ -274,10 +263,8 @@ export function createComposerImageThumbnail(file: File): Promise<string | null>
274 dimension,263 dimension,
275 dimension,264 dimension,
276 );265 );
277 return (266 const blob = await encodeCanvas(surface.canvas, 1, "image/png");
278 (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ??267 return blob ? await readFileAsDataUrl(blob) : null;
279 null
280 );
281 } catch {268 } catch {
282 return null;269 return null;
283 } finally {270 } finally {
...@@ -297,7 +284,7 @@ async function encodeWithinBudget(...@@ -297,7 +284,7 @@ async function encodeWithinBudget(
297 maxDimension: number,284 maxDimension: number,
298 budgetChars: number,285 budgetChars: number,
299 preferredMimeType?: "image/jpeg",286 preferredMimeType?: "image/jpeg",
300): Promise<{ dataUrl: string; mimeType: string; imageSize: ImageSize } | null> {287): Promise<{ blob: Blob; mimeType: string; imageSize: ImageSize } | null> {
301 const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));288 const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
302 const width = Math.max(1, Math.round(bitmap.width * scale));289 const width = Math.max(1, Math.round(bitmap.width * scale));
303 const height = Math.max(1, Math.round(bitmap.height * scale));290 const height = Math.max(1, Math.round(bitmap.height * scale));
...@@ -308,7 +295,7 @@ async function encodeWithinBudget(...@@ -308,7 +295,7 @@ async function encodeWithinBudget(
308 // happen before drawing and depends on which codec we end up using.295 // happen before drawing and depends on which codec we end up using.
309 const mimeType =296 const mimeType =
310 preferredMimeType ??297 preferredMimeType ??
311 ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0))298 ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp"))
312 ? "image/webp"299 ? "image/webp"
313 : "image/jpeg");300 : "image/jpeg");
314301
...@@ -319,17 +306,17 @@ async function encodeWithinBudget(...@@ -319,17 +306,17 @@ async function encodeWithinBudget(
319 target.context.drawImage(bitmap, 0, 0, width, height);306 target.context.drawImage(bitmap, 0, 0, width, height);
320307
321 for (const quality of QUALITY_STEPS) {308 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);
323 if (!encoded) break;310 if (!encoded) break;
324 if (encoded.dataUrl !== null) {311 if (dataUrlLength(encoded) <= budgetChars) {
325 return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType, imageSize: { width, height } };312 return { blob: encoded, mimeType, imageSize: { width, height } };
326 }313 }
327 }314 }
328 return null;315 return null;
329}316}
330317
331type ReencodeResult =318type ReencodeResult =
332 | { ok: true; dataUrl: string; mimeType: string; imageSize: ImageSize }319 | { ok: true; blob: Blob; mimeType: string; imageSize: ImageSize }
333 | { ok: false; reason: ImageCompressionFailureReason };320 | { ok: false; reason: ImageCompressionFailureReason };
334321
335/**322/**
...@@ -378,10 +365,10 @@ async function reencodeWithinBudget(...@@ -378,10 +365,10 @@ async function reencodeWithinBudget(
378 continue;365 continue;
379 }366 }
380 encodeFailed = false;367 encodeFailed = false;
381 if (encoded && encoded.dataUrl.length <= budgetChars) {368 if (encoded) {
382 return {369 return {
383 ok: true,370 ok: true,
384 dataUrl: encoded.dataUrl,371 blob: encoded.blob,
385 mimeType: encoded.mimeType,372 mimeType: encoded.mimeType,
386 imageSize: encoded.imageSize,373 imageSize: encoded.imageSize,
387 };374 };
...@@ -405,37 +392,35 @@ export async function compressImageForStash(...@@ -405,37 +392,35 @@ export async function compressImageForStash(
405 file: File,392 file: File,
406 budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS,393 budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS,
407): Promise<CompressStashImageResult> {394): Promise<CompressStashImageResult> {
408 let originalDataUrl: string;
409 try {395 try {
410 originalDataUrl = await blobToDataUrl(file);396 if (dataUrlLength(file) <= budgetChars) {
411 } catch {397 return {
412 return { ok: false, reason: "unreadable" };398 ok: true,
413 }399 image: {
414 if (originalDataUrl.length <= budgetChars) {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 }
415 return {411 return {
416 ok: true,412 ok: true,
417 image: {413 image: {
418 dataUrl: originalDataUrl,414 dataUrl: await readFileAsDataUrl(reencoded.blob),
419 mimeType: file.type,415 mimeType: reencoded.mimeType,
420 sizeBytes: file.size,416 sizeBytes: reencoded.blob.size,
421 recompressed: false,417 recompressed: true,
418 imageSize: reencoded.imageSize,
422 },419 },
423 };420 };
421 } catch {
422 return { ok: false, reason: "unreadable" };
424 }423 }
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 };
439}424}
440425
441/**426/**
...@@ -468,10 +453,10 @@ export async function compressImageToByteLimit(...@@ -468,10 +453,10 @@ export async function compressImageToByteLimit(
468 }453 }
469 return {454 return {
470 ok: true,455 ok: true,
471 file: dataUrlToFile(456 file: new File(
472 reencoded.dataUrl,457 [reencoded.blob],
473 fileNameForMimeType(file.name || "image", reencoded.mimeType),458 fileNameForMimeType(file.name || "image", reencoded.mimeType),
474 reencoded.mimeType,459 { type: reencoded.mimeType },
475 ),460 ),
476 recompressed: true,461 recompressed: true,
477 imageSize: reencoded.imageSize,462 imageSize: reencoded.imageSize,