| 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,7 +64,7 @@ import { |
| 64 | } from "../../promptStashStore"; | 64 | } from "../../promptStashStore"; |
| 65 | import { ComposerStashBadge } from "./ComposerStashBadge"; | 65 | import { ComposerStashBadge } from "./ComposerStashBadge"; |
| 66 | import { ComposerStashMenu } from "./ComposerStashMenu"; | 66 | import { ComposerStashMenu } from "./ComposerStashMenu"; |
| 67 | import { compressImageForStash } from "../../lib/stashImageCompression"; | 67 | import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; |
| 68 | import { isCommandPaletteOpen } from "../../commandPaletteBus"; | 68 | import { isCommandPaletteOpen } from "../../commandPaletteBus"; |
| 69 | import { getTerminalFocusOwner } from "../../lib/terminalFocus"; | 69 | import { getTerminalFocusOwner } from "../../lib/terminalFocus"; |
| 70 | import { resolveShortcutCommand } from "../../keybindings"; | 70 | import { resolveShortcutCommand } from "../../keybindings"; |
| ... | @@ -206,8 +206,6 @@ import { searchProviderSkills } from "../../providerSkillSearch"; | ... | @@ -206,8 +206,6 @@ import { searchProviderSkills } from "../../providerSkillSearch"; |
| 206 | import { useMediaQuery } from "../../hooks/useMediaQuery"; | 206 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 207 | import type { ReviewCommentContext } from "../../reviewCommentContext"; | 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 | const runtimeModeConfig: Record< | 209 | const runtimeModeConfig: Record< |
| 212 | RuntimeMode, | 210 | RuntimeMode, |
| 213 | { label: string; description: string; icon: LucideIcon } | 211 | { label: string; description: string; icon: LucideIcon } |
| ... | @@ -1006,6 +1004,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1006,6 +1004,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1006 | * thread) can still be stashed while an earlier encode is running. | 1004 | * thread) can still be stashed while an earlier encode is running. |
| 1007 | */ | 1005 | */ |
| 1008 | const stashInFlightRef = useRef<Set<string>>(new Set()); | 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 | // Derived: composer send state | 1016 | // Derived: composer send state |
| ... | @@ -1821,12 +1826,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1821,12 +1826,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1821 | event?.preventDefault(); | 1826 | event?.preventDefault(); |
| 1822 | return; | 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 | onSend(event); | 1842 | onSend(event); |
| 1825 | if (shouldBlurMobileComposerOnSubmit()) { | 1843 | if (shouldBlurMobileComposerOnSubmit()) { |
| 1826 | blurMobileComposerAfterSend(); | 1844 | blurMobileComposerAfterSend(); |
| 1827 | } | 1845 | } |
| 1828 | }, | 1846 | }, |
| 1829 | [ | 1847 | [ |
| 1848 | activeThreadId, | ||
| 1830 | blurMobileComposerAfterSend, | 1849 | blurMobileComposerAfterSend, |
| 1831 | isSendDisabled, | 1850 | isSendDisabled, |
| 1832 | noProviderAvailable, | 1851 | noProviderAvailable, |
| ... | @@ -2273,7 +2292,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2273,7 +2292,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2273 | // ------------------------------------------------------------------ | 2292 | // ------------------------------------------------------------------ |
| 2274 | // Callbacks: images | 2293 | // Callbacks: images |
| 2275 | // ------------------------------------------------------------------ | 2294 | // ------------------------------------------------------------------ |
| 2276 | const addComposerImages = (files: File[]) => { | 2295 | const addComposerImages = async (files: File[]) => { |
| 2277 | if (!activeThreadId || files.length === 0) return; | 2296 | if (!activeThreadId || files.length === 0) return; |
| 2278 | if (pendingUserInputs.length > 0) { | 2297 | if (pendingUserInputs.length > 0) { |
| 2279 | toastManager.add({ | 2298 | toastManager.add({ |
| ... | @@ -2282,40 +2301,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2282,40 +2301,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2282 | }); | 2301 | }); |
| 2283 | return; | 2302 | return; |
| 2284 | } | 2303 | } |
| 2285 | const nextImages: ComposerImageAttachment[] = []; | 2304 | // Captured before the awaits below: the user may switch threads while a |
| 2286 | let nextImageCount = composerImagesRef.current.length; | 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 | let error: string | null = null; | 2315 | let error: string | null = null; |
| 2288 | for (const file of files) { | 2316 | for (const file of files) { |
| 2289 | if (!file.type.startsWith("image/")) { | 2317 | if (!file.type.startsWith("image/")) { |
| 2290 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; | 2318 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; |
| 2291 | continue; | 2319 | continue; |
| 2292 | } | 2320 | } |
| 2293 | if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | 2321 | if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { |
| 2294 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; | ||
| 2295 | continue; | ||
| 2296 | } | ||
| 2297 | if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | ||
| 2298 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; | 2322 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; |
| 2299 | break; | 2323 | break; |
| 2300 | } | 2324 | } |
| 2301 | const previewUrl = URL.createObjectURL(file); | 2325 | acceptedFiles.push(file); |
| 2302 | nextImages.push({ | 2326 | reservedCount += 1; |
| 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; | ||
| 2312 | } | 2327 | } |
| 2313 | if (nextImages.length === 1 && nextImages[0]) { | 2328 | setThreadError(threadId, error); |
| 2314 | addComposerImage(nextImages[0]); | 2329 | if (acceptedFiles.length === 0) return; |
| 2315 | } else if (nextImages.length > 1) { | 2330 | |
| 2316 | addComposerImagesToDraft(nextImages); | 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 | const removeComposerImage = (imageId: string) => { | 2381 | const removeComposerImage = (imageId: string) => { |
| ... | @@ -2331,7 +2391,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2331,7 +2391,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2331 | const imageFiles = files.filter((file) => file.type.startsWith("image/")); | 2391 | const imageFiles = files.filter((file) => file.type.startsWith("image/")); |
| 2332 | if (imageFiles.length === 0) return; | 2392 | if (imageFiles.length === 0) return; |
| 2333 | event.preventDefault(); | 2393 | event.preventDefault(); |
| 2334 | addComposerImages(imageFiles); | 2394 | void addComposerImages(imageFiles); |
| 2335 | }; | 2395 | }; |
| 2336 | 2396 | ||
| 2337 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { | 2397 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { |
| ... | @@ -2365,7 +2425,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2365,7 +2425,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2365 | dragDepthRef.current = 0; | 2425 | dragDepthRef.current = 0; |
| 2366 | setIsDragOverComposer(false); | 2426 | setIsDragOverComposer(false); |
| 2367 | const files = Array.from(event.dataTransfer.files); | 2427 | const files = Array.from(event.dataTransfer.files); |
| 2368 | addComposerImages(files); | 2428 | void addComposerImages(files); |
| 2369 | focusComposer(); | 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 | } | ||