authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 04:35:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 04:35:06-07:00
logcbe8052020aa0c02c63fbe090e7f1f25603622d1
tree3bd60cb01aeccc79bc8e582d13cee280928d7257
parent69a18ce912ac3ae93cf498cfe0cee40beb49fb9f
signature Signed by PGP key B5690EEEBB952194

feat(web): pasting a huge screenshot now compresses it instead of erroring (#4967)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 677 insertions(+), 477 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+89-29
......@@ -64,7 +64,7 @@ import {
6464} from "../../promptStashStore";
6565import { ComposerStashBadge } from "./ComposerStashBadge";
6666import { ComposerStashMenu } from "./ComposerStashMenu";
67import { compressImageForStash } from "../../lib/stashImageCompression";
67import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";
6868import { isCommandPaletteOpen } from "../../commandPaletteBus";
6969import { getTerminalFocusOwner } from "../../lib/terminalFocus";
7070import { resolveShortcutCommand } from "../../keybindings";
......@@ -206,8 +206,6 @@ import { searchProviderSkills } from "../../providerSkillSearch";
206206import { useMediaQuery } from "../../hooks/useMediaQuery";
207207import type { ReviewCommentContext } from "../../reviewCommentContext";
208208
209const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
210
211209const runtimeModeConfig: Record<
212210 RuntimeMode,
213211 { label: string; description: string; icon: LucideIcon }
......@@ -1006,6 +1004,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
10061004 * thread) can still be stashed while an earlier encode is running.
10071005 */
10081006 const stashInFlightRef = useRef<Set<string>>(new Set());
1007 /**
1008 * Count of pasted images still being compressed, per thread. Reserved
1009 * against the attachment limit so concurrent pastes can't overshoot it,
1010 * and checked by `submitComposer` so a send can't race an image into the
1011 * next draft.
1012 */
1013 const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());
10091014
10101015 // ------------------------------------------------------------------
10111016 // Derived: composer send state
......@@ -1821,12 +1826,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18211826 event?.preventDefault();
18221827 return;
18231828 }
1829 // A send while a pasted image is still compressing would strand that
1830 // image: the turn snapshot wouldn't include it, and it would surface
1831 // in the *next* draft instead. Only oversized images hit this — small
1832 // files clear the pending counter within a microtask.
1833 if (activeThreadId && (pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {
1834 event?.preventDefault();
1835 toastManager.add({
1836 type: "info",
1837 title: "Still compressing a pasted image.",
1838 description: "Send again once its thumbnail appears.",
1839 });
1840 return;
1841 }
18241842 onSend(event);
18251843 if (shouldBlurMobileComposerOnSubmit()) {
18261844 blurMobileComposerAfterSend();
18271845 }
18281846 },
18291847 [
1848 activeThreadId,
18301849 blurMobileComposerAfterSend,
18311850 isSendDisabled,
18321851 noProviderAvailable,
......@@ -2273,7 +2292,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22732292 // ------------------------------------------------------------------
22742293 // Callbacks: images
22752294 // ------------------------------------------------------------------
2276 const addComposerImages = (files: File[]) => {
2295 const addComposerImages = async (files: File[]) => {
22772296 if (!activeThreadId || files.length === 0) return;
22782297 if (pendingUserInputs.length > 0) {
22792298 toastManager.add({
......@@ -2282,40 +2301,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22822301 });
22832302 return;
22842303 }
2285 const nextImages: ComposerImageAttachment[] = [];
2286 let nextImageCount = composerImagesRef.current.length;
2304 // Captured before the awaits below: the user may switch threads while a
2305 // large image is being compressed, and the attachments and errors belong
2306 // to the thread the paste happened in.
2307 const threadId = activeThreadId;
2308
2309 // Validation happens synchronously so concurrent pastes see each other:
2310 // accepted files reserve their attachment slots (via the pending counter)
2311 // before the first await, keeping the total under the limit.
2312 const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0;
2313 let reservedCount = composerImagesRef.current.length + pendingCount;
2314 const acceptedFiles: File[] = [];
22872315 let error: string | null = null;
22882316 for (const file of files) {
22892317 if (!file.type.startsWith("image/")) {
22902318 error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
22912319 continue;
22922320 }
2293 if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
2294 error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
2295 continue;
2296 }
2297 if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
2321 if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
22982322 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
22992323 break;
23002324 }
2301 const previewUrl = URL.createObjectURL(file);
2302 nextImages.push({
2303 type: "image",
2304 id: randomUUID(),
2305 name: file.name || "image",
2306 mimeType: file.type,
2307 sizeBytes: file.size,
2308 previewUrl,
2309 file,
2310 });
2311 nextImageCount += 1;
2325 acceptedFiles.push(file);
2326 reservedCount += 1;
23122327 }
2313 if (nextImages.length === 1 && nextImages[0]) {
2314 addComposerImage(nextImages[0]);
2315 } else if (nextImages.length > 1) {
2316 addComposerImagesToDraft(nextImages);
2328 setThreadError(threadId, error);
2329 if (acceptedFiles.length === 0) return;
2330
2331 pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length);
2332 try {
2333 const nextImages: ComposerImageAttachment[] = [];
2334 let compressionError: string | null = null;
2335 for (const file of acceptedFiles) {
2336 // Images over the wire cap are downscaled to fit rather than
2337 // refused; files already within it pass through byte-for-byte.
2338 const compressed = await compressImageToByteLimit(file, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES);
2339 if (!compressed.ok) {
2340 compressionError =
2341 compressed.reason === "unreadable"
2342 ? `'${file.name}' could not be read as an image.`
2343 : `'${file.name}' is too large to attach, even after compression.`;
2344 continue;
2345 }
2346 const attachmentFile = compressed.file;
2347 const previewUrl = URL.createObjectURL(attachmentFile);
2348 nextImages.push({
2349 type: "image",
2350 id: randomUUID(),
2351 name: attachmentFile.name || "image",
2352 mimeType: attachmentFile.type,
2353 sizeBytes: attachmentFile.size,
2354 previewUrl,
2355 file: attachmentFile,
2356 });
2357 }
2358 if (nextImages.length === 1 && nextImages[0]) {
2359 addComposerImage(nextImages[0]);
2360 } else if (nextImages.length > 1) {
2361 addComposerImagesToDraft(nextImages);
2362 }
2363 // Only failures are reported here. Success must not pass `null`: by
2364 // now other work (a failed send, an overlapping paste) may have set a
2365 // thread error this call knows nothing about, and clearing it would
2366 // swallow that message.
2367 if (compressionError !== null) {
2368 setThreadError(threadId, compressionError);
2369 }
2370 } finally {
2371 const remaining =
2372 (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length;
2373 if (remaining > 0) {
2374 pendingImageCompressionsRef.current.set(threadId, remaining);
2375 } else {
2376 pendingImageCompressionsRef.current.delete(threadId);
2377 }
23172378 }
2318 setThreadError(activeThreadId, error);
23192379 };
23202380
23212381 const removeComposerImage = (imageId: string) => {
......@@ -2331,7 +2391,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23312391 const imageFiles = files.filter((file) => file.type.startsWith("image/"));
23322392 if (imageFiles.length === 0) return;
23332393 event.preventDefault();
2334 addComposerImages(imageFiles);
2394 void addComposerImages(imageFiles);
23352395 };
23362396
23372397 const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
......@@ -2365,7 +2425,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23652425 dragDepthRef.current = 0;
23662426 setIsDragOverComposer(false);
23672427 const files = Array.from(event.dataTransfer.files);
2368 addComposerImages(files);
2428 void addComposerImages(files);
23692429 focusComposer();
23702430 };
23712431
apps/web/src/lib/imageCompression.test.ts created+253
......@@ -0,0 +1,253 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 compressImageForStash,
5 compressImageToByteLimit,
6 MAX_COMPRESSIBLE_SOURCE_BYTES,
7 MAX_STASH_IMAGE_DATA_URL_CHARS,
8} from "./imageCompression";
9
10/**
11 * jsdom has no real canvas/codec, so the re-encode path is exercised with
12 * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a
13 * payload whose size scales with quality, mirroring how a real JPEG encoder
14 * shrinks as quality drops — enough to verify the ladder logic and budget
15 * enforcement without pulling in a native canvas.
16 */
17
18const originalCreateImageBitmap = globalThis.createImageBitmap;
19const originalOffscreenCanvas = globalThis.OffscreenCanvas;
20
21function makeFile(sizeBytes: number, type = "image/png"): File {
22 return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });
23}
24
25/**
26 * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`.
27 * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed
28 * blob for WebP requests, which is how a real browser signals it cannot
29 * encode that format.
30 */
31function stubCanvasPipeline(
32 sizeForQuality: (quality: number) => number,
33 options?: { supportsWebp?: boolean },
34) {
35 const supportsWebp = options?.supportsWebp ?? true;
36 const close = vi.fn();
37 const fillRect = vi.fn();
38 vi.stubGlobal(
39 "createImageBitmap",
40 vi.fn(async () => ({ width: 4000, height: 3000, close })),
41 );
42 vi.stubGlobal(
43 "OffscreenCanvas",
44 class {
45 constructor(
46 public width: number,
47 public height: number,
48 ) {}
49 getContext() {
50 return {
51 fillStyle: "",
52 fillRect,
53 drawImage: vi.fn(),
54 };
55 }
56 async convertToBlob({ type, quality }: { type: string; quality: number }) {
57 const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type;
58 return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType });
59 }
60 },
61 );
62 return { close, fillRect };
63}
64
65afterEach(() => {
66 vi.unstubAllGlobals();
67 globalThis.createImageBitmap = originalCreateImageBitmap;
68 globalThis.OffscreenCanvas = originalOffscreenCanvas;
69});
70
71describe("compressImageForStash", () => {
72 it("stores a small image verbatim without re-encoding", async () => {
73 const bitmapSpy = vi.fn();
74 vi.stubGlobal("createImageBitmap", bitmapSpy);
75
76 const result = await compressImageForStash(makeFile(1024));
77
78 expect(result.ok).toBe(true);
79 expect(result.ok && result.image.recompressed).toBe(false);
80 expect(result.ok && result.image.mimeType).toBe("image/png");
81 expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true);
82 // Untouched payloads must not pay for a decode.
83 expect(bitmapSpy).not.toHaveBeenCalled();
84 });
85
86 it("re-encodes an oversized image to WebP within the budget", async () => {
87 // Comfortably under budget at the very first quality step.
88 const { close, fillRect } = stubCanvasPipeline(() => 120_000);
89
90 const result = await compressImageForStash(makeFile(4_000_000));
91
92 expect(result.ok).toBe(true);
93 expect(result.ok && result.image.recompressed).toBe(true);
94 expect(result.ok && result.image.mimeType).toBe("image/webp");
95 expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);
96 // sizeBytes should describe the re-encoded payload, not the 4MB original.
97 expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000);
98 // WebP keeps alpha, so no white matte should be painted.
99 expect(fillRect).not.toHaveBeenCalled();
100 expect(close).toHaveBeenCalled();
101 });
102
103 it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => {
104 const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false });
105
106 const result = await compressImageForStash(makeFile(4_000_000));
107
108 expect(result.ok && result.image.recompressed).toBe(true);
109 expect(result.ok && result.image.mimeType).toBe("image/jpeg");
110 // JPEG has no alpha, so transparent regions must be matted white.
111 expect(fillRect).toHaveBeenCalled();
112 });
113
114 it("steps quality down until the encoded image fits", async () => {
115 // Only the lowest quality step (0.68) lands under the budget.
116 const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000));
117
118 const result = await compressImageForStash(makeFile(9_000_000));
119
120 expect(result.ok && result.image.recompressed).toBe(true);
121 expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);
122 expect(close).toHaveBeenCalled();
123 });
124
125 it("reports too-large when even the smallest encoding overflows the budget", async () => {
126 const { close } = stubCanvasPipeline(() => 8_000_000);
127
128 const result = await compressImageForStash(makeFile(9_000_000));
129
130 expect(result).toEqual({ ok: false, reason: "too-large" });
131 // The bitmap must still be released on the give-up path.
132 expect(close).toHaveBeenCalled();
133 });
134
135 it("reports too-large for an oversized image when the browser cannot re-encode", async () => {
136 vi.stubGlobal("createImageBitmap", undefined);
137 vi.stubGlobal("OffscreenCanvas", undefined);
138
139 expect(await compressImageForStash(makeFile(4_000_000))).toEqual({
140 ok: false,
141 reason: "too-large",
142 });
143 });
144
145 it("reports unreadable when the image fails to decode", async () => {
146 vi.stubGlobal(
147 "createImageBitmap",
148 vi.fn(async () => {
149 throw new Error("corrupt image");
150 }),
151 );
152 vi.stubGlobal(
153 "OffscreenCanvas",
154 class {
155 getContext() {
156 return null;
157 }
158 },
159 );
160
161 expect(await compressImageForStash(makeFile(4_000_000))).toEqual({
162 ok: false,
163 reason: "unreadable",
164 });
165 });
166
167 it("compressImageToByteLimit passes small files through byte-for-byte", async () => {
168 const bitmapSpy = vi.fn();
169 vi.stubGlobal("createImageBitmap", bitmapSpy);
170
171 const original = makeFile(1024);
172 const result = await compressImageToByteLimit(original, 10 * 1024 * 1024);
173
174 expect(result.ok).toBe(true);
175 expect(result.ok && result.recompressed).toBe(false);
176 // Pass-through must be the same File object, not a copy.
177 expect(result.ok && result.file).toBe(original);
178 expect(bitmapSpy).not.toHaveBeenCalled();
179 });
180
181 it("compressImageToByteLimit re-encodes an oversized file under the byte cap", async () => {
182 stubCanvasPipeline(() => 200_000);
183
184 const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);
185
186 expect(result.ok).toBe(true);
187 expect(result.ok && result.recompressed).toBe(true);
188 expect(result.ok && result.file.type).toBe("image/webp");
189 // The re-encoded name must match the new container format.
190 expect(result.ok && result.file.name).toBe("shot.webp");
191 expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000);
192 });
193
194 it("compressImageToByteLimit refuses sources above the decode-safety ceiling", async () => {
195 const bitmapSpy = vi.fn();
196 vi.stubGlobal("createImageBitmap", bitmapSpy);
197
198 const result = await compressImageToByteLimit(
199 makeFile(MAX_COMPRESSIBLE_SOURCE_BYTES + 1),
200 10 * 1024 * 1024,
201 );
202
203 expect(result).toEqual({ ok: false, reason: "too-large" });
204 // The whole point of the ceiling is to never decode such a file.
205 expect(bitmapSpy).not.toHaveBeenCalled();
206 });
207
208 it("compressImageToByteLimit reports too-large when no encoding fits", async () => {
209 const { close } = stubCanvasPipeline(() => 3_000_000);
210
211 const result = await compressImageToByteLimit(makeFile(2_000_000), 1_000_000);
212
213 expect(result).toEqual({ ok: false, reason: "too-large" });
214 expect(close).toHaveBeenCalled();
215 });
216
217 it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => {
218 // A small-but-heavy source (e.g. a dense PNG): only a real downscale can
219 // get it under budget, since quality alone is stubbed to never suffice.
220 let smallestRequested = Number.POSITIVE_INFINITY;
221 const close = vi.fn();
222 vi.stubGlobal(
223 "createImageBitmap",
224 vi.fn(async () => ({ width: 800, height: 600, close })),
225 );
226 vi.stubGlobal(
227 "OffscreenCanvas",
228 class {
229 constructor(
230 public width: number,
231 public height: number,
232 ) {
233 smallestRequested = Math.min(smallestRequested, width);
234 }
235 getContext() {
236 return { fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() };
237 }
238 async convertToBlob({ type }: { type: string; quality: number }) {
239 // Only a genuinely downscaled pass fits the budget.
240 const size = smallestRequested < 800 ? 100_000 : 5_000_000;
241 return new Blob([new Uint8Array(size)], { type });
242 }
243 },
244 );
245
246 const result = await compressImageForStash(makeFile(4_000_000));
247
248 expect(result.ok).toBe(true);
249 // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling
250 // that would never go below an 800px source.
251 expect(smallestRequested).toBeLessThan(800);
252 });
253});
apps/web/src/lib/imageCompression.ts created+335
......@@ -0,0 +1,335 @@
1/**
2 * Downscale + re-encode for image attachments that are too big for where
3 * they're headed. Two consumers share the same pipeline:
4 *
5 * - The prompt stash persists images as base64 in localStorage (~5MB origin
6 * quota), so `compressImageForStash` targets a per-image character budget.
7 * - The composer accepts pasted/dropped images larger than the provider's
8 * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit
9 * via `compressImageToByteLimit` instead of rejecting the paste.
10 *
11 * Images already within budget pass through untouched.
12 */
13
14/**
15 * Longest edge kept when an image has to be re-encoded. Sized so a typical
16 * retina screenshot (3024px wide) stays legible rather than being halved.
17 */
18const MAX_DIMENSION = 2048;
19/** Base64 budget for a single stashed image (~975KB of binary). */
20export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000;
21/**
22 * Ceiling on the *source* file handed to the re-encoder. File size is a
23 * proxy for pixel count, and decoding hundreds of megapixels into an
24 * ImageBitmap can OOM the tab — beyond this we refuse rather than risk it.
25 */
26export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024;
27/**
28 * Quality ladder tried in order until the encoded image fits the budget.
29 * The floor stays high enough to avoid visible blocking on UI screenshots;
30 * if even that overflows we drop resolution instead of quality.
31 */
32const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const;
33/** Extra downscale passes applied when even the lowest quality overflows. */
34const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const;
35
36export interface CompressedStashImage {
37 dataUrl: string;
38 mimeType: string;
39 sizeBytes: number;
40 /** True when the payload was re-encoded rather than stored verbatim. */
41 recompressed: boolean;
42}
43
44/**
45 * Why an image could not be compressed. Callers report these differently:
46 * "too large" is a budget outcome, "unreadable" is a decode failure.
47 */
48export type ImageCompressionFailureReason = "too-large" | "unreadable";
49
50export type CompressStashImageResult =
51 | { ok: true; image: CompressedStashImage }
52 | { ok: false; reason: ImageCompressionFailureReason };
53
54export type CompressImageFileResult =
55 | { ok: true; file: File; recompressed: boolean }
56 | { ok: false; reason: ImageCompressionFailureReason };
57
58/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */
59const BASE64_CHUNK_SIZE = 0x8000;
60
61function bytesToBase64(bytes: Uint8Array): string {
62 let binary = "";
63 for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
64 binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
65 }
66 return btoa(binary);
67}
68
69/**
70 * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so
71 * the module works anywhere `Blob` does (including non-DOM test runners).
72 */
73async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise<string> {
74 const buffer = await blob.arrayBuffer();
75 const mimeType = mimeTypeOverride || blob.type || "application/octet-stream";
76 return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`;
77}
78
79/** Approximate decoded byte count for a base64 data URL. */
80function dataUrlByteLength(dataUrl: string): number {
81 const commaIndex = dataUrl.indexOf(",");
82 const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
83 const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
84 return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
85}
86
87/** Base64 payload of a data URL decoded back into a `File`. */
88function dataUrlToFile(dataUrl: string, name: string, mimeType: string): File {
89 const payload = dataUrl.slice(dataUrl.indexOf(",") + 1);
90 const binary = atob(payload);
91 const bytes = new Uint8Array(binary.length);
92 for (let index = 0; index < binary.length; index += 1) {
93 bytes[index] = binary.charCodeAt(index);
94 }
95 return new File([bytes], name, { type: mimeType });
96}
97
98/**
99 * Re-encoding changes the container, so a name like `shot.png` would lie
100 * about its contents. Swap the extension to match the encoded mime type.
101 */
102function fileNameForMimeType(name: string, mimeType: string): string {
103 const extension = mimeType === "image/webp" ? ".webp" : ".jpg";
104 const dotIndex = name.lastIndexOf(".");
105 const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
106 return `${base}${extension}`;
107}
108
109function canRecompress(): boolean {
110 return (
111 typeof createImageBitmap === "function" &&
112 (typeof OffscreenCanvas === "function" || typeof document !== "undefined")
113 );
114}
115
116interface Canvas2D {
117 canvas: OffscreenCanvas | HTMLCanvasElement;
118 context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
119}
120
121function createCanvas(width: number, height: number): Canvas2D | null {
122 if (typeof OffscreenCanvas === "function") {
123 const canvas = new OffscreenCanvas(width, height);
124 const context = canvas.getContext("2d");
125 if (!context) return null;
126 return { canvas, context };
127 }
128 if (typeof document === "undefined") return null;
129 const canvas = document.createElement("canvas");
130 canvas.width = width;
131 canvas.height = height;
132 const context = canvas.getContext("2d");
133 if (!context) return null;
134 return { canvas, context };
135}
136
137/**
138 * WebP is preferred: at matched visual quality it lands roughly 25-35%
139 * smaller than JPEG, so the same budget buys more resolution and detail —
140 * and it keeps alpha, so screenshots with transparency survive intact.
141 * Browsers that can't encode it silently fall back to JPEG.
142 */
143async function encodeToDataUrl(
144 canvas: OffscreenCanvas | HTMLCanvasElement,
145 quality: number,
146 mimeType: string,
147): Promise<{ dataUrl: string; mimeType: string } | null> {
148 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
149 const dataUrl = canvas.toDataURL(mimeType, quality);
150 // toDataURL silently returns a PNG when the requested type is unsupported.
151 if (!dataUrl.startsWith(`data:${mimeType}`)) return null;
152 return { dataUrl, mimeType };
153 }
154 const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
155 if (blob.type && blob.type !== mimeType) return null;
156 return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
157}
158
159/**
160 * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping
161 * quality down until the data URL fits `budgetChars`. Returns the smallest
162 * encoding produced, even if it still exceeds the budget, so the caller can
163 * decide whether to keep or drop it.
164 */
165async function encodeWithinBudget(
166 bitmap: ImageBitmap,
167 maxDimension: number,
168 budgetChars: number,
169): Promise<{ dataUrl: string; mimeType: string } | null> {
170 const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
171 const width = Math.max(1, Math.round(bitmap.width * scale));
172 const height = Math.max(1, Math.round(bitmap.height * scale));
173 const target = createCanvas(width, height);
174 if (!target) return null;
175
176 // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to
177 // happen before drawing and depends on which codec we end up using.
178 const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp");
179 const mimeType = probe ? "image/webp" : "image/jpeg";
180
181 if (mimeType === "image/jpeg") {
182 target.context.fillStyle = "#ffffff";
183 target.context.fillRect(0, 0, width, height);
184 }
185 target.context.drawImage(bitmap, 0, 0, width, height);
186
187 let smallest: { dataUrl: string; mimeType: string } | null = null;
188 for (const quality of QUALITY_STEPS) {
189 const encoded = await encodeToDataUrl(target.canvas, quality, mimeType);
190 if (!encoded) break;
191 if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) {
192 smallest = encoded;
193 }
194 if (encoded.dataUrl.length <= budgetChars) {
195 return encoded;
196 }
197 }
198 return smallest;
199}
200
201type ReencodeResult =
202 | { ok: true; dataUrl: string; mimeType: string }
203 | { ok: false; reason: ImageCompressionFailureReason };
204
205/**
206 * Shared re-encode loop: decodes `file`, then walks the quality ladder and
207 * fallback downscale passes until an encoding fits `budgetChars`.
208 */
209async function reencodeWithinBudget(file: File, budgetChars: number): Promise<ReencodeResult> {
210 if (!canRecompress()) {
211 return { ok: false, reason: "too-large" };
212 }
213
214 let bitmap: ImageBitmap;
215 try {
216 bitmap = await createImageBitmap(file);
217 } catch {
218 return { ok: false, reason: "unreadable" };
219 }
220
221 try {
222 // Each pass shrinks relative to the *previous target*, capped by
223 // MAX_DIMENSION. Scaling a fixed ceiling instead would be a no-op for
224 // images already smaller than that ceiling — the fallback passes would
225 // all resolve to the source size and never actually reduce resolution.
226 const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height));
227 // Tracks whether the *last* attempt threw, so a run of encoder failures
228 // is reported as unreadable while a run of merely-too-big results is
229 // reported as too-large.
230 let encodeFailed = false;
231 for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) {
232 const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale));
233 let encoded: { dataUrl: string; mimeType: string } | null;
234 try {
235 encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars);
236 } catch {
237 // Canvas allocation, drawing, or the codec itself can throw — often
238 // precisely *because* the target is too big (OOM on a large bitmap).
239 // Keep trying the smaller fallback scales rather than giving up: a
240 // reduced pass may well succeed. The exception must never escape,
241 // though, since callers finalize state after this returns and a
242 // throw would strand it (e.g. a stash entry stuck "still saving").
243 encodeFailed = true;
244 continue;
245 }
246 encodeFailed = false;
247 if (encoded && encoded.dataUrl.length <= budgetChars) {
248 return { ok: true, dataUrl: encoded.dataUrl, mimeType: encoded.mimeType };
249 }
250 }
251 return { ok: false, reason: encodeFailed ? "unreadable" : "too-large" };
252 } finally {
253 bitmap.close();
254 }
255}
256
257/**
258 * Produces the payload to persist for a stashed image.
259 *
260 * Small images are stored verbatim (preserving PNG transparency and exact
261 * pixels). Anything over budget is downscaled and re-encoded; if it still
262 * doesn't fit after the fallback passes, reports a failure so the caller
263 * can record it as dropped.
264 */
265export async function compressImageForStash(
266 file: File,
267 budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS,
268): Promise<CompressStashImageResult> {
269 let originalDataUrl: string;
270 try {
271 originalDataUrl = await blobToDataUrl(file);
272 } catch {
273 return { ok: false, reason: "unreadable" };
274 }
275 if (originalDataUrl.length <= budgetChars) {
276 return {
277 ok: true,
278 image: {
279 dataUrl: originalDataUrl,
280 mimeType: file.type,
281 sizeBytes: file.size,
282 recompressed: false,
283 },
284 };
285 }
286 const reencoded = await reencodeWithinBudget(file, budgetChars);
287 if (!reencoded.ok) {
288 return reencoded;
289 }
290 return {
291 ok: true,
292 image: {
293 dataUrl: reencoded.dataUrl,
294 mimeType: reencoded.mimeType,
295 sizeBytes: dataUrlByteLength(reencoded.dataUrl),
296 recompressed: true,
297 },
298 };
299}
300
301/**
302 * Shrinks `file` until its binary size fits `maxBytes`, returning a new
303 * `File` (WebP or JPEG). Files already within the limit pass through
304 * untouched, preserving their exact bytes and format. Sources above
305 * `MAX_COMPRESSIBLE_SOURCE_BYTES` are refused outright — decoding them is
306 * the risk, so no amount of output budget makes them safe.
307 */
308export async function compressImageToByteLimit(
309 file: File,
310 maxBytes: number,
311): Promise<CompressImageFileResult> {
312 if (file.size <= maxBytes) {
313 return { ok: true, file, recompressed: false };
314 }
315 if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) {
316 return { ok: false, reason: "too-large" };
317 }
318 // The re-encode loop budgets in data-URL characters. Base64 turns 3 bytes
319 // into 4 chars; flooring keeps the budget a hair conservative instead of
320 // admitting an encoding right at the byte cap.
321 const budgetChars = Math.floor(maxBytes / 3) * 4;
322 const reencoded = await reencodeWithinBudget(file, budgetChars);
323 if (!reencoded.ok) {
324 return reencoded;
325 }
326 return {
327 ok: true,
328 file: dataUrlToFile(
329 reencoded.dataUrl,
330 fileNameForMimeType(file.name || "image", reencoded.mimeType),
331 reencoded.mimeType,
332 ),
333 recompressed: true,
334 };
335}
apps/web/src/lib/stashImageCompression.test.ts deleted-198
......@@ -1,198 +0,0 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { compressImageForStash, MAX_STASH_IMAGE_DATA_URL_CHARS } from "./stashImageCompression";
4
5/**
6 * jsdom has no real canvas/codec, so the re-encode path is exercised with
7 * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a
8 * payload whose size scales with quality, mirroring how a real JPEG encoder
9 * shrinks as quality drops — enough to verify the ladder logic and budget
10 * enforcement without pulling in a native canvas.
11 */
12
13const originalCreateImageBitmap = globalThis.createImageBitmap;
14const originalOffscreenCanvas = globalThis.OffscreenCanvas;
15
16function makeFile(sizeBytes: number, type = "image/png"): File {
17 return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });
18}
19
20/**
21 * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`.
22 * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed
23 * blob for WebP requests, which is how a real browser signals it cannot
24 * encode that format.
25 */
26function stubCanvasPipeline(
27 sizeForQuality: (quality: number) => number,
28 options?: { supportsWebp?: boolean },
29) {
30 const supportsWebp = options?.supportsWebp ?? true;
31 const close = vi.fn();
32 const fillRect = vi.fn();
33 vi.stubGlobal(
34 "createImageBitmap",
35 vi.fn(async () => ({ width: 4000, height: 3000, close })),
36 );
37 vi.stubGlobal(
38 "OffscreenCanvas",
39 class {
40 constructor(
41 public width: number,
42 public height: number,
43 ) {}
44 getContext() {
45 return {
46 fillStyle: "",
47 fillRect,
48 drawImage: vi.fn(),
49 };
50 }
51 async convertToBlob({ type, quality }: { type: string; quality: number }) {
52 const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type;
53 return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType });
54 }
55 },
56 );
57 return { close, fillRect };
58}
59
60afterEach(() => {
61 vi.unstubAllGlobals();
62 globalThis.createImageBitmap = originalCreateImageBitmap;
63 globalThis.OffscreenCanvas = originalOffscreenCanvas;
64});
65
66describe("compressImageForStash", () => {
67 it("stores a small image verbatim without re-encoding", async () => {
68 const bitmapSpy = vi.fn();
69 vi.stubGlobal("createImageBitmap", bitmapSpy);
70
71 const result = await compressImageForStash(makeFile(1024));
72
73 expect(result.ok).toBe(true);
74 expect(result.ok && result.image.recompressed).toBe(false);
75 expect(result.ok && result.image.mimeType).toBe("image/png");
76 expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true);
77 // Untouched payloads must not pay for a decode.
78 expect(bitmapSpy).not.toHaveBeenCalled();
79 });
80
81 it("re-encodes an oversized image to WebP within the budget", async () => {
82 // Comfortably under budget at the very first quality step.
83 const { close, fillRect } = stubCanvasPipeline(() => 120_000);
84
85 const result = await compressImageForStash(makeFile(4_000_000));
86
87 expect(result.ok).toBe(true);
88 expect(result.ok && result.image.recompressed).toBe(true);
89 expect(result.ok && result.image.mimeType).toBe("image/webp");
90 expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);
91 // sizeBytes should describe the re-encoded payload, not the 4MB original.
92 expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000);
93 // WebP keeps alpha, so no white matte should be painted.
94 expect(fillRect).not.toHaveBeenCalled();
95 expect(close).toHaveBeenCalled();
96 });
97
98 it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => {
99 const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false });
100
101 const result = await compressImageForStash(makeFile(4_000_000));
102
103 expect(result.ok && result.image.recompressed).toBe(true);
104 expect(result.ok && result.image.mimeType).toBe("image/jpeg");
105 // JPEG has no alpha, so transparent regions must be matted white.
106 expect(fillRect).toHaveBeenCalled();
107 });
108
109 it("steps quality down until the encoded image fits", async () => {
110 // Only the lowest quality step (0.68) lands under the budget.
111 const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000));
112
113 const result = await compressImageForStash(makeFile(9_000_000));
114
115 expect(result.ok && result.image.recompressed).toBe(true);
116 expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true);
117 expect(close).toHaveBeenCalled();
118 });
119
120 it("reports too-large when even the smallest encoding overflows the budget", async () => {
121 const { close } = stubCanvasPipeline(() => 8_000_000);
122
123 const result = await compressImageForStash(makeFile(9_000_000));
124
125 expect(result).toEqual({ ok: false, reason: "too-large" });
126 // The bitmap must still be released on the give-up path.
127 expect(close).toHaveBeenCalled();
128 });
129
130 it("reports too-large for an oversized image when the browser cannot re-encode", async () => {
131 vi.stubGlobal("createImageBitmap", undefined);
132 vi.stubGlobal("OffscreenCanvas", undefined);
133
134 expect(await compressImageForStash(makeFile(4_000_000))).toEqual({
135 ok: false,
136 reason: "too-large",
137 });
138 });
139
140 it("reports unreadable when the image fails to decode", async () => {
141 vi.stubGlobal(
142 "createImageBitmap",
143 vi.fn(async () => {
144 throw new Error("corrupt image");
145 }),
146 );
147 vi.stubGlobal(
148 "OffscreenCanvas",
149 class {
150 getContext() {
151 return null;
152 }
153 },
154 );
155
156 expect(await compressImageForStash(makeFile(4_000_000))).toEqual({
157 ok: false,
158 reason: "unreadable",
159 });
160 });
161
162 it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => {
163 // A small-but-heavy source (e.g. a dense PNG): only a real downscale can
164 // get it under budget, since quality alone is stubbed to never suffice.
165 let smallestRequested = Number.POSITIVE_INFINITY;
166 const close = vi.fn();
167 vi.stubGlobal(
168 "createImageBitmap",
169 vi.fn(async () => ({ width: 800, height: 600, close })),
170 );
171 vi.stubGlobal(
172 "OffscreenCanvas",
173 class {
174 constructor(
175 public width: number,
176 public height: number,
177 ) {
178 smallestRequested = Math.min(smallestRequested, width);
179 }
180 getContext() {
181 return { fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() };
182 }
183 async convertToBlob({ type }: { type: string; quality: number }) {
184 // Only a genuinely downscaled pass fits the budget.
185 const size = smallestRequested < 800 ? 100_000 : 5_000_000;
186 return new Blob([new Uint8Array(size)], { type });
187 }
188 },
189 );
190
191 const result = await compressImageForStash(makeFile(4_000_000));
192
193 expect(result.ok).toBe(true);
194 // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling
195 // that would never go below an 800px source.
196 expect(smallestRequested).toBeLessThan(800);
197 });
198});
apps/web/src/lib/stashImageCompression.ts deleted-250
......@@ -1,250 +0,0 @@
1/**
2 * Re-encoding for stashed image attachments.
3 *
4 * The composer accepts images up to `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES`
5 * (10MB), but the stash persists them as base64 in localStorage, where the
6 * whole origin shares a ~5MB quota. Rather than refuse large screenshots,
7 * downscale + re-encode them to JPEG so a stashed prompt keeps its images.
8 *
9 * Only the *stashed copy* is compressed; the live composer attachment is
10 * untouched, so sending without stashing still uploads the original file.
11 */
12
13/**
14 * Longest edge kept when an image has to be re-encoded. Sized so a typical
15 * retina screenshot (3024px wide) stays legible rather than being halved.
16 */
17const MAX_DIMENSION = 2048;
18/** Base64 budget for a single stashed image (~975KB of binary). */
19export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000;
20/**
21 * Quality ladder tried in order until the encoded image fits the budget.
22 * The floor stays high enough to avoid visible blocking on UI screenshots;
23 * if even that overflows we drop resolution instead of quality.
24 */
25const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const;
26/** Extra downscale passes applied when even the lowest quality overflows. */
27const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const;
28
29export interface CompressedStashImage {
30 dataUrl: string;
31 mimeType: string;
32 sizeBytes: number;
33 /** True when the payload was re-encoded rather than stored verbatim. */
34 recompressed: boolean;
35}
36
37/**
38 * Why an image could not be stashed. Callers report these differently:
39 * "too large" is a budget outcome, "unreadable" is a decode failure.
40 */
41export type StashImageFailureReason = "too-large" | "unreadable";
42
43export type CompressStashImageResult =
44 | { ok: true; image: CompressedStashImage }
45 | { ok: false; reason: StashImageFailureReason };
46
47/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */
48const BASE64_CHUNK_SIZE = 0x8000;
49
50function bytesToBase64(bytes: Uint8Array): string {
51 let binary = "";
52 for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
53 binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
54 }
55 return btoa(binary);
56}
57
58/**
59 * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so
60 * the module works anywhere `Blob` does (including non-DOM test runners).
61 */
62async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise<string> {
63 const buffer = await blob.arrayBuffer();
64 const mimeType = mimeTypeOverride || blob.type || "application/octet-stream";
65 return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`;
66}
67
68/** Approximate decoded byte count for a base64 data URL. */
69function dataUrlByteLength(dataUrl: string): number {
70 const commaIndex = dataUrl.indexOf(",");
71 const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
72 const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
73 return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
74}
75
76function canRecompress(): boolean {
77 return (
78 typeof createImageBitmap === "function" &&
79 (typeof OffscreenCanvas === "function" || typeof document !== "undefined")
80 );
81}
82
83interface Canvas2D {
84 canvas: OffscreenCanvas | HTMLCanvasElement;
85 context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
86}
87
88function createCanvas(width: number, height: number): Canvas2D | null {
89 if (typeof OffscreenCanvas === "function") {
90 const canvas = new OffscreenCanvas(width, height);
91 const context = canvas.getContext("2d");
92 if (!context) return null;
93 return { canvas, context };
94 }
95 if (typeof document === "undefined") return null;
96 const canvas = document.createElement("canvas");
97 canvas.width = width;
98 canvas.height = height;
99 const context = canvas.getContext("2d");
100 if (!context) return null;
101 return { canvas, context };
102}
103
104/**
105 * WebP is preferred: at matched visual quality it lands roughly 25-35%
106 * smaller than JPEG, so the same budget buys more resolution and detail —
107 * and it keeps alpha, so screenshots with transparency survive intact.
108 * Browsers that can't encode it silently fall back to JPEG.
109 */
110async function encodeToDataUrl(
111 canvas: OffscreenCanvas | HTMLCanvasElement,
112 quality: number,
113 mimeType: string,
114): Promise<{ dataUrl: string; mimeType: string } | null> {
115 if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
116 const dataUrl = canvas.toDataURL(mimeType, quality);
117 // toDataURL silently returns a PNG when the requested type is unsupported.
118 if (!dataUrl.startsWith(`data:${mimeType}`)) return null;
119 return { dataUrl, mimeType };
120 }
121 const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
122 if (blob.type && blob.type !== mimeType) return null;
123 return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
124}
125
126/**
127 * Draws `bitmap` scaled to fit `maxDimension` and encodes it as JPEG,
128 * stepping quality down until the data URL fits `budgetChars`. Returns the
129 * smallest encoding produced, even if it still exceeds the budget, so the
130 * caller can decide whether to keep or drop it.
131 */
132async function encodeWithinBudget(
133 bitmap: ImageBitmap,
134 maxDimension: number,
135 budgetChars: number,
136): Promise<{ dataUrl: string; mimeType: string } | null> {
137 const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
138 const width = Math.max(1, Math.round(bitmap.width * scale));
139 const height = Math.max(1, Math.round(bitmap.height * scale));
140 const target = createCanvas(width, height);
141 if (!target) return null;
142
143 // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to
144 // happen before drawing and depends on which codec we end up using.
145 const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp");
146 const mimeType = probe ? "image/webp" : "image/jpeg";
147
148 if (mimeType === "image/jpeg") {
149 target.context.fillStyle = "#ffffff";
150 target.context.fillRect(0, 0, width, height);
151 }
152 target.context.drawImage(bitmap, 0, 0, width, height);
153
154 let smallest: { dataUrl: string; mimeType: string } | null = null;
155 for (const quality of QUALITY_STEPS) {
156 const encoded = await encodeToDataUrl(target.canvas, quality, mimeType);
157 if (!encoded) break;
158 if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) {
159 smallest = encoded;
160 }
161 if (encoded.dataUrl.length <= budgetChars) {
162 return encoded;
163 }
164 }
165 return smallest;
166}
167
168/**
169 * Produces the payload to persist for a stashed image.
170 *
171 * Small images are stored verbatim (preserving PNG transparency and exact
172 * pixels). Anything over budget is downscaled and JPEG-encoded; if it still
173 * doesn't fit after the fallback passes, returns `null` so the caller can
174 * record it as dropped.
175 */
176export async function compressImageForStash(
177 file: File,
178 budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS,
179): Promise<CompressStashImageResult> {
180 let originalDataUrl: string;
181 try {
182 originalDataUrl = await blobToDataUrl(file);
183 } catch {
184 return { ok: false, reason: "unreadable" };
185 }
186 if (originalDataUrl.length <= budgetChars) {
187 return {
188 ok: true,
189 image: {
190 dataUrl: originalDataUrl,
191 mimeType: file.type,
192 sizeBytes: file.size,
193 recompressed: false,
194 },
195 };
196 }
197 if (!canRecompress()) {
198 return { ok: false, reason: "too-large" };
199 }
200
201 let bitmap: ImageBitmap;
202 try {
203 bitmap = await createImageBitmap(file);
204 } catch {
205 return { ok: false, reason: "unreadable" };
206 }
207
208 try {
209 // Each pass shrinks relative to the *previous target*, capped by
210 // MAX_DIMENSION. Scaling a fixed ceiling instead would be a no-op for
211 // images already smaller than that ceiling — the fallback passes would
212 // all resolve to the source size and never actually reduce resolution.
213 const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height));
214 // Tracks whether the *last* attempt threw, so a run of encoder failures
215 // is reported as unreadable while a run of merely-too-big results is
216 // reported as too-large.
217 let encodeFailed = false;
218 for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) {
219 const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale));
220 let encoded: { dataUrl: string; mimeType: string } | null;
221 try {
222 encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars);
223 } catch {
224 // Canvas allocation, drawing, or the codec itself can throw — often
225 // precisely *because* the target is too big (OOM on a large bitmap).
226 // Keep trying the smaller fallback scales rather than giving up: a
227 // reduced pass may well succeed. The exception must never escape,
228 // though, since the caller finalizes the entry after this returns and
229 // a throw would strand it as permanently "still saving".
230 encodeFailed = true;
231 continue;
232 }
233 encodeFailed = false;
234 if (encoded && encoded.dataUrl.length <= budgetChars) {
235 return {
236 ok: true,
237 image: {
238 dataUrl: encoded.dataUrl,
239 mimeType: encoded.mimeType,
240 sizeBytes: dataUrlByteLength(encoded.dataUrl),
241 recompressed: true,
242 },
243 };
244 }
245 }
246 return { ok: false, reason: encodeFailed ? "unreadable" : "too-large" };
247 } finally {
248 bitmap.close();
249 }
250}