| author | |
| committer | |
| log | 0dec07d91488e08206ad32f9758a95acd746c424 |
| tree | b1119aedddb866eb29c97346680c0dad6d7287f0 |
| parent | 01e05c15268dedb76da95f442fbf5201cd8e7a44 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 151 insertions(+), 3 deletions(-)
apps/web/src/components/chat/ChatComposer.tsx+21-3| ... | ... | @@ -242,6 +242,7 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; |
| 242 | 242 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; |
| 243 | 243 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; |
| 244 | 244 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| 245 | import { ComposerImageThumbnail } from "./ComposerImageThumbnail"; | |
| 245 | 246 | import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; |
| 246 | 247 | import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; |
| 247 | 248 | import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; |
| ... | ... | @@ -4673,7 +4674,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4673 | 4674 | }} |
| 4674 | 4675 | > |
| 4675 | 4676 | {image.previewUrl ? ( |
| 4676 | <img src={image.previewUrl} alt="" className="size-full object-cover" /> | |
| 4677 | <ComposerImageThumbnail | |
| 4678 | file={image.file} | |
| 4679 | alt="" | |
| 4680 | className="size-full object-cover" | |
| 4681 | fallback={ | |
| 4682 | <PierreEntryIcon | |
| 4683 | pathValue={image.name} | |
| 4684 | kind="file" | |
| 4685 | theme={resolvedTheme} | |
| 4686 | className="m-auto size-3.5" | |
| 4687 | /> | |
| 4688 | } | |
| 4689 | /> | |
| 4677 | 4690 | ) : ( |
| 4678 | 4691 | <PierreEntryIcon |
| 4679 | 4692 | pathValue={image.name} |
| ... | ... | @@ -6287,10 +6300,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6287 | 6300 | onExpandImage(preview); |
| 6288 | 6301 | }} |
| 6289 | 6302 | > |
| 6290 | <img | |
| 6291 | src={image.previewUrl} | |
| 6303 | <ComposerImageThumbnail | |
| 6304 | file={image.file} | |
| 6292 | 6305 | alt={image.name} |
| 6293 | 6306 | className="h-full w-full object-cover" |
| 6307 | fallback={ | |
| 6308 | <span className="flex h-full items-center justify-center px-1 text-[10px] text-secondary-label"> | |
| 6309 | {image.name} | |
| 6310 | </span> | |
| 6311 | } | |
| 6294 | 6312 | /> |
| 6295 | 6313 | </button> |
| 6296 | 6314 | ) : ( |
apps/web/src/components/chat/ComposerImageThumbnail.tsx created+29| ... | ... | @@ -0,0 +1,29 @@ |
| 1 | import { memo, useEffect, useState, type ReactNode } from "react"; | |
| 2 | ||
| 3 | import { createComposerImageThumbnail } from "../../lib/imageCompression"; | |
| 4 | ||
| 5 | /** Keep full-resolution image decoding out of composer rerenders. */ | |
| 6 | export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ | |
| 7 | file, | |
| 8 | alt, | |
| 9 | className, | |
| 10 | fallback, | |
| 11 | }: { | |
| 12 | file: File; | |
| 13 | alt: string; | |
| 14 | className: string; | |
| 15 | fallback: ReactNode; | |
| 16 | }) { | |
| 17 | const [preview, setPreview] = useState<{ file: File; src: string | null } | null>(null); | |
| 18 | useEffect(() => { | |
| 19 | let active = true; | |
| 20 | void createComposerImageThumbnail(file).then((src) => { | |
| 21 | if (active) setPreview({ file, src }); | |
| 22 | }); | |
| 23 | return () => { | |
| 24 | active = false; | |
| 25 | }; | |
| 26 | }, [file]); | |
| 27 | const src = preview?.file === file ? preview.src : null; | |
| 28 | return src ? <img src={src} alt={alt} className={className} /> : fallback; | |
| 29 | }); |
apps/web/src/lib/imageCompression.test.ts+60| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | createComposerImageThumbnail, | |
| 4 | 5 | compressImageForStash, |
| 5 | 6 | compressImageToByteLimit, |
| 6 | 7 | dataUrlToFile, |
| ... | ... | @@ -123,6 +124,65 @@ afterEach(() => { |
| 123 | 124 | globalThis.OffscreenCanvas = originalOffscreenCanvas; |
| 124 | 125 | }); |
| 125 | 126 | |
| 127 | describe("composer image thumbnails", () => { | |
| 128 | it("decodes a tall original once and caches a bounded center crop", async () => { | |
| 129 | const close = vi.fn(); | |
| 130 | const bitmap = { width: 2304, height: 32766, close }; | |
| 131 | const decode = vi.fn(async () => bitmap); | |
| 132 | const drawImage = vi.fn(); | |
| 133 | const dimensions: number[][] = []; | |
| 134 | vi.stubGlobal("createImageBitmap", decode); | |
| 135 | vi.stubGlobal( | |
| 136 | "OffscreenCanvas", | |
| 137 | class { | |
| 138 | constructor(width: number, height: number) { | |
| 139 | dimensions.push([width, height]); | |
| 140 | } | |
| 141 | getContext() { | |
| 142 | return { drawImage }; | |
| 143 | } | |
| 144 | async convertToBlob() { | |
| 145 | return new Blob(["thumbnail"], { type: "image/png" }); | |
| 146 | } | |
| 147 | }, | |
| 148 | ); | |
| 149 | const original = new File(["original bytes"], "tall.png", { type: "image/png" }); | |
| 150 | const [first, second] = await Promise.all([ | |
| 151 | createComposerImageThumbnail(original), | |
| 152 | createComposerImageThumbnail(original), | |
| 153 | ]); | |
| 154 | expect(first).toBe("data:image/png;base64,dGh1bWJuYWls"); | |
| 155 | expect(second).toBe(first); | |
| 156 | expect(await createComposerImageThumbnail(original)).toBe(first); | |
| 157 | expect(decode).toHaveBeenCalledExactlyOnceWith(original); | |
| 158 | expect(dimensions).toEqual([[256, 256]]); | |
| 159 | expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 15231, 2304, 2304, 0, 0, 256, 256); | |
| 160 | expect(close).toHaveBeenCalledOnce(); | |
| 161 | expect(await original.text()).toBe("original bytes"); | |
| 162 | }); | |
| 163 | ||
| 164 | it("releases the decoded image when thumbnail encoding fails", async () => { | |
| 165 | const close = vi.fn(); | |
| 166 | vi.stubGlobal( | |
| 167 | "createImageBitmap", | |
| 168 | vi.fn(async () => ({ width: 500, height: 500, close })), | |
| 169 | ); | |
| 170 | vi.stubGlobal( | |
| 171 | "OffscreenCanvas", | |
| 172 | class { | |
| 173 | getContext() { | |
| 174 | return { drawImage: vi.fn() }; | |
| 175 | } | |
| 176 | async convertToBlob() { | |
| 177 | throw new Error("encoder unavailable"); | |
| 178 | } | |
| 179 | }, | |
| 180 | ); | |
| 181 | expect(await createComposerImageThumbnail(makeFile(5))).toBeNull(); | |
| 182 | expect(close).toHaveBeenCalledOnce(); | |
| 183 | }); | |
| 184 | }); | |
| 185 | ||
| 126 | 186 | describe("dataUrlToFile", () => { |
| 127 | 187 | it("decodes a captured image without a fetch request", async () => { |
| 128 | 188 | const file = dataUrlToFile("data:image/png;base64,AAEC/w==", "window.png", "image/png"); |
apps/web/src/lib/imageCompression.ts+41| ... | ... | @@ -247,6 +247,47 @@ async function encodeCanvas( |
| 247 | 247 | return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; |
| 248 | 248 | } |
| 249 | 249 | |
| 250 | const composerThumbnails = new WeakMap<File, Promise<string | null>>(); | |
| 251 | ||
| 252 | /** Cache a centered square crop for the composer's object-cover image tiles. */ | |
| 253 | export function createComposerImageThumbnail(file: File): Promise<string | null> { | |
| 254 | const cached = composerThumbnails.get(file); | |
| 255 | if (cached) return cached; | |
| 256 | const thumbnail = (async () => { | |
| 257 | if (!canRecompress()) return null; | |
| 258 | let bitmap: ImageBitmap | undefined; | |
| 259 | try { | |
| 260 | bitmap = await createImageBitmap(file); | |
| 261 | const side = Math.min(bitmap.width, bitmap.height); | |
| 262 | if (side <= 0) return null; | |
| 263 | const dimension = Math.min(256, side); | |
| 264 | const surface = createCanvas(dimension, dimension); | |
| 265 | if (!surface) return null; | |
| 266 | surface.context.drawImage( | |
| 267 | bitmap, | |
| 268 | (bitmap.width - side) / 2, | |
| 269 | (bitmap.height - side) / 2, | |
| 270 | side, | |
| 271 | side, | |
| 272 | 0, | |
| 273 | 0, | |
| 274 | dimension, | |
| 275 | dimension, | |
| 276 | ); | |
| 277 | return ( | |
| 278 | (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ?? | |
| 279 | null | |
| 280 | ); | |
| 281 | } catch { | |
| 282 | return null; | |
| 283 | } finally { | |
| 284 | bitmap?.close(); | |
| 285 | } | |
| 286 | })(); | |
| 287 | composerThumbnails.set(file, thumbnail); | |
| 288 | return thumbnail; | |
| 289 | } | |
| 290 | ||
| 250 | 291 | /** |
| 251 | 292 | * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping |
| 252 | 293 | * quality down until the data URL fits `budgetChars`. |