| author | |
| committer | |
| log | cbe8052020aa0c02c63fbe090e7f1f25603622d1 |
| tree | 3bd60cb01aeccc79bc8e582d13cee280928d7257 |
| parent | 69a18ce912ac3ae93cf498cfe0cee40beb49fb9f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 64 | 64 | } from "../../promptStashStore"; |
| 65 | 65 | import { ComposerStashBadge } from "./ComposerStashBadge"; |
| 66 | 66 | import { ComposerStashMenu } from "./ComposerStashMenu"; |
| 67 | import { compressImageForStash } from "../../lib/stashImageCompression"; | |
| 67 | import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; | |
| 68 | 68 | import { isCommandPaletteOpen } from "../../commandPaletteBus"; |
| 69 | 69 | import { getTerminalFocusOwner } from "../../lib/terminalFocus"; |
| 70 | 70 | import { resolveShortcutCommand } from "../../keybindings"; |
| ... | ... | @@ -206,8 +206,6 @@ import { searchProviderSkills } from "../../providerSkillSearch"; |
| 206 | 206 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 207 | 207 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 208 | 208 | |
| 209 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | |
| 210 | ||
| 211 | 209 | const runtimeModeConfig: Record< |
| 212 | 210 | RuntimeMode, |
| 213 | 211 | { label: string; description: string; icon: LucideIcon } |
| ... | ... | @@ -1006,6 +1004,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1006 | 1004 | * thread) can still be stashed while an earlier encode is running. |
| 1007 | 1005 | */ |
| 1008 | 1006 | 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()); | |
| 1009 | 1014 | |
| 1010 | 1015 | // ------------------------------------------------------------------ |
| 1011 | 1016 | // Derived: composer send state |
| ... | ... | @@ -1821,12 +1826,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1821 | 1826 | event?.preventDefault(); |
| 1822 | 1827 | return; |
| 1823 | 1828 | } |
| 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 | } | |
| 1824 | 1842 | onSend(event); |
| 1825 | 1843 | if (shouldBlurMobileComposerOnSubmit()) { |
| 1826 | 1844 | blurMobileComposerAfterSend(); |
| 1827 | 1845 | } |
| 1828 | 1846 | }, |
| 1829 | 1847 | [ |
| 1848 | activeThreadId, | |
| 1830 | 1849 | blurMobileComposerAfterSend, |
| 1831 | 1850 | isSendDisabled, |
| 1832 | 1851 | noProviderAvailable, |
| ... | ... | @@ -2273,7 +2292,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2273 | 2292 | // ------------------------------------------------------------------ |
| 2274 | 2293 | // Callbacks: images |
| 2275 | 2294 | // ------------------------------------------------------------------ |
| 2276 | const addComposerImages = (files: File[]) => { | |
| 2295 | const addComposerImages = async (files: File[]) => { | |
| 2277 | 2296 | if (!activeThreadId || files.length === 0) return; |
| 2278 | 2297 | if (pendingUserInputs.length > 0) { |
| 2279 | 2298 | toastManager.add({ |
| ... | ... | @@ -2282,40 +2301,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2282 | 2301 | }); |
| 2283 | 2302 | return; |
| 2284 | 2303 | } |
| 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[] = []; | |
| 2287 | 2315 | let error: string | null = null; |
| 2288 | 2316 | for (const file of files) { |
| 2289 | 2317 | if (!file.type.startsWith("image/")) { |
| 2290 | 2318 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; |
| 2291 | 2319 | continue; |
| 2292 | 2320 | } |
| 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) { | |
| 2298 | 2322 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; |
| 2299 | 2323 | break; |
| 2300 | 2324 | } |
| 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; | |
| 2312 | 2327 | } |
| 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 | } | |
| 2317 | 2378 | } |
| 2318 | setThreadError(activeThreadId, error); | |
| 2319 | 2379 | }; |
| 2320 | 2380 | |
| 2321 | 2381 | const removeComposerImage = (imageId: string) => { |
| ... | ... | @@ -2331,7 +2391,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2331 | 2391 | const imageFiles = files.filter((file) => file.type.startsWith("image/")); |
| 2332 | 2392 | if (imageFiles.length === 0) return; |
| 2333 | 2393 | event.preventDefault(); |
| 2334 | addComposerImages(imageFiles); | |
| 2394 | void addComposerImages(imageFiles); | |
| 2335 | 2395 | }; |
| 2336 | 2396 | |
| 2337 | 2397 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { |
| ... | ... | @@ -2365,7 +2425,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2365 | 2425 | dragDepthRef.current = 0; |
| 2366 | 2426 | setIsDragOverComposer(false); |
| 2367 | 2427 | const files = Array.from(event.dataTransfer.files); |
| 2368 | addComposerImages(files); | |
| 2428 | void addComposerImages(files); | |
| 2369 | 2429 | focusComposer(); |
| 2370 | 2430 | }; |
| 2371 | 2431 |
apps/web/src/lib/imageCompression.test.ts created+253| ... | ... | @@ -0,0 +1,253 @@ |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 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 | ||
| 18 | const originalCreateImageBitmap = globalThis.createImageBitmap; | |
| 19 | const originalOffscreenCanvas = globalThis.OffscreenCanvas; | |
| 20 | ||
| 21 | function 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 | */ | |
| 31 | function 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 | ||
| 65 | afterEach(() => { | |
| 66 | vi.unstubAllGlobals(); | |
| 67 | globalThis.createImageBitmap = originalCreateImageBitmap; | |
| 68 | globalThis.OffscreenCanvas = originalOffscreenCanvas; | |
| 69 | }); | |
| 70 | ||
| 71 | describe("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 | */ | |
| 18 | const MAX_DIMENSION = 2048; | |
| 19 | /** Base64 budget for a single stashed image (~975KB of binary). */ | |
| 20 | export 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 | */ | |
| 26 | export 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 | */ | |
| 32 | const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; | |
| 33 | /** Extra downscale passes applied when even the lowest quality overflows. */ | |
| 34 | const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; | |
| 35 | ||
| 36 | export 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 | */ | |
| 48 | export type ImageCompressionFailureReason = "too-large" | "unreadable"; | |
| 49 | ||
| 50 | export type CompressStashImageResult = | |
| 51 | | { ok: true; image: CompressedStashImage } | |
| 52 | | { ok: false; reason: ImageCompressionFailureReason }; | |
| 53 | ||
| 54 | export 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`. */ | |
| 59 | const BASE64_CHUNK_SIZE = 0x8000; | |
| 60 | ||
| 61 | function 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 | */ | |
| 73 | async 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. */ | |
| 80 | function 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`. */ | |
| 88 | function 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 | */ | |
| 102 | function 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 | ||
| 109 | function canRecompress(): boolean { | |
| 110 | return ( | |
| 111 | typeof createImageBitmap === "function" && | |
| 112 | (typeof OffscreenCanvas === "function" || typeof document !== "undefined") | |
| 113 | ); | |
| 114 | } | |
| 115 | ||
| 116 | interface Canvas2D { | |
| 117 | canvas: OffscreenCanvas | HTMLCanvasElement; | |
| 118 | context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; | |
| 119 | } | |
| 120 | ||
| 121 | function 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 | */ | |
| 143 | async 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 | */ | |
| 165 | async 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 | ||
| 201 | type 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 | */ | |
| 209 | async 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 | */ | |
| 265 | export 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 | */ | |
| 308 | export 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 @@ |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { 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 | ||
| 13 | const originalCreateImageBitmap = globalThis.createImageBitmap; | |
| 14 | const originalOffscreenCanvas = globalThis.OffscreenCanvas; | |
| 15 | ||
| 16 | function 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 | */ | |
| 26 | function 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 | ||
| 60 | afterEach(() => { | |
| 61 | vi.unstubAllGlobals(); | |
| 62 | globalThis.createImageBitmap = originalCreateImageBitmap; | |
| 63 | globalThis.OffscreenCanvas = originalOffscreenCanvas; | |
| 64 | }); | |
| 65 | ||
| 66 | describe("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 | */ | |
| 17 | const MAX_DIMENSION = 2048; | |
| 18 | /** Base64 budget for a single stashed image (~975KB of binary). */ | |
| 19 | export 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 | */ | |
| 25 | const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; | |
| 26 | /** Extra downscale passes applied when even the lowest quality overflows. */ | |
| 27 | const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; | |
| 28 | ||
| 29 | export 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 | */ | |
| 41 | export type StashImageFailureReason = "too-large" | "unreadable"; | |
| 42 | ||
| 43 | export 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`. */ | |
| 48 | const BASE64_CHUNK_SIZE = 0x8000; | |
| 49 | ||
| 50 | function 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 | */ | |
| 62 | async 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. */ | |
| 69 | function 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 | ||
| 76 | function canRecompress(): boolean { | |
| 77 | return ( | |
| 78 | typeof createImageBitmap === "function" && | |
| 79 | (typeof OffscreenCanvas === "function" || typeof document !== "undefined") | |
| 80 | ); | |
| 81 | } | |
| 82 | ||
| 83 | interface Canvas2D { | |
| 84 | canvas: OffscreenCanvas | HTMLCanvasElement; | |
| 85 | context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; | |
| 86 | } | |
| 87 | ||
| 88 | function 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 | */ | |
| 110 | async 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 | */ | |
| 132 | async 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 | */ | |
| 176 | export 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 | } |