| author | |
| committer | |
| log | bd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c |
| tree | 09aa77c7ab4733860ab904e424123861a87f33c2 |
| parent | 3c75eb1132bb5d67cfa95ac6271ef68959f986c1 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: mweinbach <maxweinbach5@gmail.com>6 files changed, 367 insertions(+), 14 deletions(-)
apps/web/package.json+1| ... | ... | @@ -34,6 +34,7 @@ |
| 34 | 34 | "class-variance-authority": "^0.7.1", |
| 35 | 35 | "culori": "^4.0.2", |
| 36 | 36 | "effect": "catalog:", |
| 37 | "heic-to": "^1.5.2", | |
| 37 | 38 | "jose": "catalog:", |
| 38 | 39 | "jsonc-parser": "3.3.1", |
| 39 | 40 | "jszip": "3.10.1", |
apps/web/src/components/chat/ChatComposer.tsx+16-6| ... | ... | @@ -73,7 +73,11 @@ import { |
| 73 | 73 | type ComposerTaskStep, |
| 74 | 74 | type ComposerTasksProgress, |
| 75 | 75 | } from "./ComposerTasksBadge"; |
| 76 | import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; | |
| 76 | import { | |
| 77 | compressImageForStash, | |
| 78 | isHeicImageFile, | |
| 79 | prepareImageForAttachment, | |
| 80 | } from "../../lib/imageCompression"; | |
| 77 | 81 | import { |
| 78 | 82 | releaseAttachmentUpload, |
| 79 | 83 | retryAttachmentUpload, |
| ... | ... | @@ -2537,12 +2541,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2537 | 2541 | const acceptedFiles: File[] = []; |
| 2538 | 2542 | let error: string | null = null; |
| 2539 | 2543 | for (const file of files) { |
| 2540 | if (!file.type.startsWith("image/")) { | |
| 2544 | const isHeicImage = isHeicImageFile(file); | |
| 2545 | if (!file.type.startsWith("image/") && !isHeicImage) { | |
| 2541 | 2546 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; |
| 2542 | 2547 | continue; |
| 2543 | 2548 | } |
| 2544 | if (!isProviderSendTurnSupportedImageMimeType(file.type)) { | |
| 2545 | error = `'${file.name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; | |
| 2549 | if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) { | |
| 2550 | error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`; | |
| 2546 | 2551 | continue; |
| 2547 | 2552 | } |
| 2548 | 2553 | if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { |
| ... | ... | @@ -2562,7 +2567,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2562 | 2567 | for (const file of acceptedFiles) { |
| 2563 | 2568 | // Images over the wire cap are downscaled to fit rather than |
| 2564 | 2569 | // refused; files already within it pass through byte-for-byte. |
| 2565 | const compressed = await compressImageToByteLimit(file, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES); | |
| 2570 | const compressed = await prepareImageForAttachment( | |
| 2571 | file, | |
| 2572 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | |
| 2573 | ); | |
| 2566 | 2574 | if (!compressed.ok) { |
| 2567 | 2575 | compressionError = |
| 2568 | 2576 | compressed.reason === "unreadable" |
| ... | ... | @@ -2615,7 +2623,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2615 | 2623 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { |
| 2616 | 2624 | const files = Array.from(event.clipboardData.files); |
| 2617 | 2625 | if (files.length === 0) return; |
| 2618 | const imageFiles = files.filter((file) => file.type.startsWith("image/")); | |
| 2626 | const imageFiles = files.filter( | |
| 2627 | (file) => file.type.startsWith("image/") || isHeicImageFile(file), | |
| 2628 | ); | |
| 2619 | 2629 | if (imageFiles.length === 0) return; |
| 2620 | 2630 | event.preventDefault(); |
| 2621 | 2631 | void addComposerImages(imageFiles); |
apps/web/src/lib/imageCompression.test.ts+195| ... | ... | @@ -3,10 +3,20 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | compressImageForStash, |
| 5 | 5 | compressImageToByteLimit, |
| 6 | isHeicImageFile, | |
| 6 | 7 | MAX_COMPRESSIBLE_SOURCE_BYTES, |
| 7 | 8 | MAX_STASH_IMAGE_DATA_URL_CHARS, |
| 9 | prepareImageForAttachment, | |
| 8 | 10 | } from "./imageCompression"; |
| 9 | 11 | |
| 12 | const mocks = vi.hoisted(() => ({ | |
| 13 | heicTo: vi.fn(), | |
| 14 | })); | |
| 15 | ||
| 16 | vi.mock("heic-to/csp", () => ({ | |
| 17 | heicTo: mocks.heicTo, | |
| 18 | })); | |
| 19 | ||
| 10 | 20 | /** |
| 11 | 21 | * jsdom has no real canvas/codec, so the re-encode path is exercised with |
| 12 | 22 | * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a |
| ... | ... | @@ -22,6 +32,45 @@ function makeFile(sizeBytes: number, type = "image/png"): File { |
| 22 | 32 | return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type }); |
| 23 | 33 | } |
| 24 | 34 | |
| 35 | function makeHeicFile(options?: { | |
| 36 | name?: string; | |
| 37 | type?: string; | |
| 38 | width?: number; | |
| 39 | height?: number; | |
| 40 | lastModified?: number; | |
| 41 | }): File { | |
| 42 | const encoder = new TextEncoder(); | |
| 43 | const makeBox = (name: string, ...contents: Uint8Array[]) => { | |
| 44 | const bytes = new Uint8Array(8 + contents.reduce((size, content) => size + content.length, 0)); | |
| 45 | new DataView(bytes.buffer).setUint32(0, bytes.length); | |
| 46 | bytes.set(encoder.encode(name), 4); | |
| 47 | let offset = 8; | |
| 48 | for (const content of contents) { | |
| 49 | bytes.set(content, offset); | |
| 50 | offset += content.length; | |
| 51 | } | |
| 52 | return bytes; | |
| 53 | }; | |
| 54 | ||
| 55 | const dimensions = new Uint8Array(12); | |
| 56 | const view = new DataView(dimensions.buffer); | |
| 57 | view.setUint32(4, options?.width ?? 4000); | |
| 58 | view.setUint32(8, options?.height ?? 3000); | |
| 59 | const properties = makeBox("iprp", makeBox("ipco", makeBox("ispe", dimensions))); | |
| 60 | ||
| 61 | return new File( | |
| 62 | [ | |
| 63 | makeBox("ftyp", encoder.encode("heic"), new Uint8Array(4)), | |
| 64 | makeBox("meta", new Uint8Array(4), properties), | |
| 65 | ], | |
| 66 | options?.name ?? "photo.heic", | |
| 67 | { | |
| 68 | type: options?.type ?? "image/heic", | |
| 69 | ...(options?.lastModified !== undefined ? { lastModified: options.lastModified } : {}), | |
| 70 | }, | |
| 71 | ); | |
| 72 | } | |
| 73 | ||
| 25 | 74 | /** |
| 26 | 75 | * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. |
| 27 | 76 | * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed |
| ... | ... | @@ -63,6 +112,7 @@ function stubCanvasPipeline( |
| 63 | 112 | } |
| 64 | 113 | |
| 65 | 114 | afterEach(() => { |
| 115 | mocks.heicTo.mockReset(); | |
| 66 | 116 | vi.unstubAllGlobals(); |
| 67 | 117 | globalThis.createImageBitmap = originalCreateImageBitmap; |
| 68 | 118 | globalThis.OffscreenCanvas = originalOffscreenCanvas; |
| ... | ... | @@ -251,3 +301,148 @@ describe("compressImageForStash", () => { |
| 251 | 301 | expect(smallestRequested).toBeLessThan(800); |
| 252 | 302 | }); |
| 253 | 303 | }); |
| 304 | ||
| 305 | describe("HEIC attachment preparation", () => { | |
| 306 | it("recognizes HEIC and HEIF MIME types and case-insensitive file extensions", () => { | |
| 307 | expect(isHeicImageFile({ name: "photo.bin", type: "image/heic" })).toBe(true); | |
| 308 | expect(isHeicImageFile({ name: "photo.bin", type: "image/heif" })).toBe(true); | |
| 309 | expect(isHeicImageFile({ name: "photo.heic", type: "image/heic-sequence" })).toBe(false); | |
| 310 | expect(isHeicImageFile({ name: "photo.heif", type: "image/heif-sequence" })).toBe(false); | |
| 311 | expect(isHeicImageFile({ name: "IMG_1234.HEIC", type: "" })).toBe(true); | |
| 312 | expect(isHeicImageFile({ name: "photo.heif", type: "application/octet-stream" })).toBe(true); | |
| 313 | expect(isHeicImageFile({ name: "photo.png", type: "image/png" })).toBe(false); | |
| 314 | expect(isHeicImageFile({ name: "photo.heic", type: "image/png" })).toBe(false); | |
| 315 | expect(isHeicImageFile({ name: "photo.heif", type: "image/jpeg" })).toBe(false); | |
| 316 | }); | |
| 317 | ||
| 318 | it("converts a HEIC photo with a missing MIME type into a named JPEG", async () => { | |
| 319 | const original = makeHeicFile({ | |
| 320 | name: "IMG_1234.HEIC", | |
| 321 | type: "", | |
| 322 | lastModified: 123, | |
| 323 | }); | |
| 324 | mocks.heicTo.mockResolvedValueOnce( | |
| 325 | new Blob([new Uint8Array([4, 5, 6, 7])], { type: "image/jpeg" }), | |
| 326 | ); | |
| 327 | ||
| 328 | const result = await prepareImageForAttachment(original, 1024); | |
| 329 | ||
| 330 | expect(mocks.heicTo).toHaveBeenCalledWith({ | |
| 331 | blob: original, | |
| 332 | type: "image/jpeg", | |
| 333 | quality: 0.92, | |
| 334 | }); | |
| 335 | expect(result.ok && result.file.name).toBe("IMG_1234.jpg"); | |
| 336 | expect(result.ok && result.file.type).toBe("image/jpeg"); | |
| 337 | expect(result.ok && result.file.size).toBe(4); | |
| 338 | expect(result.ok && result.file.lastModified).toBe(123); | |
| 339 | expect(result.ok && result.recompressed).toBe(true); | |
| 340 | }); | |
| 341 | ||
| 342 | it("keeps oversized converted photos in JPEG format while shrinking them", async () => { | |
| 343 | const original = makeHeicFile({ | |
| 344 | name: "photo.heif", | |
| 345 | type: "image/heif", | |
| 346 | }); | |
| 347 | mocks.heicTo.mockResolvedValueOnce( | |
| 348 | new Blob([new Uint8Array(2_000_000)], { type: "image/jpeg" }), | |
| 349 | ); | |
| 350 | const { fillRect } = stubCanvasPipeline(() => 200_000); | |
| 351 | ||
| 352 | const result = await prepareImageForAttachment(original, 1_000_000); | |
| 353 | ||
| 354 | expect(result.ok && result.file.name).toBe("photo.jpg"); | |
| 355 | expect(result.ok && result.file.type).toBe("image/jpeg"); | |
| 356 | expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000); | |
| 357 | expect(fillRect).toHaveBeenCalled(); | |
| 358 | }); | |
| 359 | ||
| 360 | it("compresses JPEG intermediates above the source safety ceiling", async () => { | |
| 361 | const original = makeHeicFile({ | |
| 362 | name: "large.heic", | |
| 363 | type: "image/heic", | |
| 364 | }); | |
| 365 | mocks.heicTo.mockResolvedValueOnce( | |
| 366 | new Blob([new Uint8Array(MAX_COMPRESSIBLE_SOURCE_BYTES + 1)], { | |
| 367 | type: "image/jpeg", | |
| 368 | }), | |
| 369 | ); | |
| 370 | const { close } = stubCanvasPipeline(() => 200_000); | |
| 371 | ||
| 372 | const result = await prepareImageForAttachment(original, 1_000_000); | |
| 373 | ||
| 374 | expect(result.ok && result.file.name).toBe("large.jpg"); | |
| 375 | expect(result.ok && result.file.type).toBe("image/jpeg"); | |
| 376 | expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000); | |
| 377 | expect(close).toHaveBeenCalled(); | |
| 378 | }); | |
| 379 | ||
| 380 | it.each([ | |
| 381 | { label: "24 MP", width: 5712, height: 4284 }, | |
| 382 | { label: "48 MP", width: 8064, height: 6048 }, | |
| 383 | ])("accepts $label HEIC photos", async ({ width, height }) => { | |
| 384 | const original = makeHeicFile({ width, height }); | |
| 385 | mocks.heicTo.mockResolvedValueOnce(new Blob(["jpeg"], { type: "image/jpeg" })); | |
| 386 | ||
| 387 | const result = await prepareImageForAttachment(original, 1024); | |
| 388 | ||
| 389 | expect(result.ok && result.file.type).toBe("image/jpeg"); | |
| 390 | expect(mocks.heicTo).toHaveBeenCalledOnce(); | |
| 391 | }); | |
| 392 | ||
| 393 | it("rejects oversized HEIC dimensions before loading the decoder", async () => { | |
| 394 | const original = makeHeicFile({ width: 16_000, height: 4001 }); | |
| 395 | ||
| 396 | expect(await prepareImageForAttachment(original, 1024)).toEqual({ | |
| 397 | ok: false, | |
| 398 | reason: "too-large", | |
| 399 | }); | |
| 400 | expect(mocks.heicTo).not.toHaveBeenCalled(); | |
| 401 | }); | |
| 402 | ||
| 403 | it("rejects invalid HEIC metadata before loading the decoder", async () => { | |
| 404 | const original = new File([new Uint8Array([1, 2, 3])], "broken.heic", { | |
| 405 | type: "image/heic", | |
| 406 | }); | |
| 407 | ||
| 408 | expect(await prepareImageForAttachment(original, 1024)).toEqual({ | |
| 409 | ok: false, | |
| 410 | reason: "unreadable", | |
| 411 | }); | |
| 412 | expect(mocks.heicTo).not.toHaveBeenCalled(); | |
| 413 | }); | |
| 414 | ||
| 415 | it("reports unreadable when HEIC decoding fails", async () => { | |
| 416 | const original = makeHeicFile({ | |
| 417 | name: "broken.heic", | |
| 418 | type: "image/heic", | |
| 419 | }); | |
| 420 | mocks.heicTo.mockRejectedValueOnce(new Error("Invalid HEIC image")); | |
| 421 | ||
| 422 | expect(await prepareImageForAttachment(original, 1024)).toEqual({ | |
| 423 | ok: false, | |
| 424 | reason: "unreadable", | |
| 425 | }); | |
| 426 | }); | |
| 427 | ||
| 428 | it("rejects unsafe HEIC sources before loading the decoder", async () => { | |
| 429 | const original = new File(["photo"], "large.heic", { type: "image/heic" }); | |
| 430 | Object.defineProperty(original, "size", { value: MAX_COMPRESSIBLE_SOURCE_BYTES + 1 }); | |
| 431 | ||
| 432 | expect(await prepareImageForAttachment(original, 1024)).toEqual({ | |
| 433 | ok: false, | |
| 434 | reason: "too-large", | |
| 435 | }); | |
| 436 | expect(mocks.heicTo).not.toHaveBeenCalled(); | |
| 437 | }); | |
| 438 | ||
| 439 | it("leaves supported images untouched without loading the HEIC decoder", async () => { | |
| 440 | const original = makeFile(1024); | |
| 441 | ||
| 442 | const result = await prepareImageForAttachment(original, 2048); | |
| 443 | ||
| 444 | expect(result.ok && result.file).toBe(original); | |
| 445 | expect(result.ok && result.recompressed).toBe(false); | |
| 446 | expect(mocks.heicTo).not.toHaveBeenCalled(); | |
| 447 | }); | |
| 448 | }); |
apps/web/src/lib/imageCompression.ts+144-8| ... | ... | @@ -8,7 +8,8 @@ |
| 8 | 8 | * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit |
| 9 | 9 | * via `compressImageToByteLimit` instead of rejecting the paste. |
| 10 | 10 | * |
| 11 | * Images already within budget pass through untouched. | |
| 11 | * Supported images already within budget pass through untouched. HEIC/HEIF | |
| 12 | * photos are decoded to JPEG first because providers cannot consume them. | |
| 12 | 13 | */ |
| 13 | 14 | |
| 14 | 15 | /** |
| ... | ... | @@ -24,6 +25,8 @@ export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000; |
| 24 | 25 | * ImageBitmap can OOM the tab — beyond this we refuse rather than risk it. |
| 25 | 26 | */ |
| 26 | 27 | export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024; |
| 28 | const MAX_HEIC_DECODE_PIXELS = 64_000_000; | |
| 29 | const MAX_HEIC_METADATA_BYTES = 1024 * 1024; | |
| 27 | 30 | /** |
| 28 | 31 | * Quality ladder tried in order until the encoded image fits the budget. |
| 29 | 32 | * The floor stays high enough to avoid visible blocking on UI screenshots; |
| ... | ... | @@ -32,6 +35,8 @@ export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024; |
| 32 | 35 | const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; |
| 33 | 36 | /** Extra downscale passes applied when even the lowest quality overflows. */ |
| 34 | 37 | const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; |
| 38 | const HEIC_IMAGE_MIME_TYPE = /^image\/hei(?:c|f)$/i; | |
| 39 | const HEIC_IMAGE_EXTENSION = /\.(?:heic|heif)$/i; | |
| 35 | 40 | |
| 36 | 41 | export interface CompressedStashImage { |
| 37 | 42 | dataUrl: string; |
| ... | ... | @@ -55,6 +60,86 @@ export type CompressImageFileResult = |
| 55 | 60 | | { ok: true; file: File; recompressed: boolean } |
| 56 | 61 | | { ok: false; reason: ImageCompressionFailureReason }; |
| 57 | 62 | |
| 63 | /** Finder and some browsers omit the MIME type when dragging HEIC photos. */ | |
| 64 | export function isHeicImageFile(file: Pick<File, "name" | "type">): boolean { | |
| 65 | if (HEIC_IMAGE_MIME_TYPE.test(file.type)) { | |
| 66 | return true; | |
| 67 | } | |
| 68 | return ( | |
| 69 | (file.type === "" || file.type.toLowerCase() === "application/octet-stream") && | |
| 70 | HEIC_IMAGE_EXTENSION.test(file.name) | |
| 71 | ); | |
| 72 | } | |
| 73 | ||
| 74 | interface HeicMetadataBox { | |
| 75 | payloadOffset: number; | |
| 76 | endOffset: number; | |
| 77 | } | |
| 78 | ||
| 79 | function findHeicMetadataBox( | |
| 80 | view: DataView, | |
| 81 | startOffset: number, | |
| 82 | endOffset: number, | |
| 83 | type: number, | |
| 84 | ): HeicMetadataBox | null { | |
| 85 | let offset = startOffset; | |
| 86 | while (offset + 8 <= endOffset) { | |
| 87 | let size = view.getUint32(offset); | |
| 88 | let headerSize = 8; | |
| 89 | if (size === 1) { | |
| 90 | if (offset + 16 > endOffset) return null; | |
| 91 | const extendedSize = view.getBigUint64(offset + 8); | |
| 92 | if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return null; | |
| 93 | size = Number(extendedSize); | |
| 94 | headerSize = 16; | |
| 95 | } else if (size === 0) { | |
| 96 | size = endOffset - offset; | |
| 97 | } | |
| 98 | if (size < headerSize || size > endOffset - offset) return null; | |
| 99 | ||
| 100 | const nextOffset = offset + size; | |
| 101 | if (view.getUint32(offset + 4) === type) { | |
| 102 | return { payloadOffset: offset + headerSize, endOffset: nextOffset }; | |
| 103 | } | |
| 104 | offset = nextOffset; | |
| 105 | } | |
| 106 | return null; | |
| 107 | } | |
| 108 | ||
| 109 | /** Read HEIC image dimensions before the decoder allocates full RGBA buffers. */ | |
| 110 | async function validateHeicImageDimensions( | |
| 111 | file: File, | |
| 112 | ): Promise<ImageCompressionFailureReason | null> { | |
| 113 | const metadata = await file.slice(0, MAX_HEIC_METADATA_BYTES).arrayBuffer(); | |
| 114 | const view = new DataView(metadata); | |
| 115 | const meta = findHeicMetadataBox(view, 0, view.byteLength, 0x6d657461); | |
| 116 | if (!meta || meta.payloadOffset + 4 > meta.endOffset) return "unreadable"; | |
| 117 | const properties = findHeicMetadataBox(view, meta.payloadOffset + 4, meta.endOffset, 0x69707270); | |
| 118 | if (!properties) return "unreadable"; | |
| 119 | const containers = findHeicMetadataBox( | |
| 120 | view, | |
| 121 | properties.payloadOffset, | |
| 122 | properties.endOffset, | |
| 123 | 0x6970636f, | |
| 124 | ); | |
| 125 | if (!containers) return "unreadable"; | |
| 126 | ||
| 127 | let offset = containers.payloadOffset; | |
| 128 | let foundImageDimensions = false; | |
| 129 | while (offset < containers.endOffset) { | |
| 130 | const image = findHeicMetadataBox(view, offset, containers.endOffset, 0x69737065); | |
| 131 | if (!image) break; | |
| 132 | if (image.payloadOffset + 12 > image.endOffset) return "unreadable"; | |
| 133 | const width = view.getUint32(image.payloadOffset + 4); | |
| 134 | const height = view.getUint32(image.payloadOffset + 8); | |
| 135 | if (width === 0 || height === 0) return "unreadable"; | |
| 136 | if (width > MAX_HEIC_DECODE_PIXELS / height) return "too-large"; | |
| 137 | foundImageDimensions = true; | |
| 138 | offset = image.endOffset; | |
| 139 | } | |
| 140 | return foundImageDimensions ? null : "unreadable"; | |
| 141 | } | |
| 142 | ||
| 58 | 143 | /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ |
| 59 | 144 | const BASE64_CHUNK_SIZE = 0x8000; |
| 60 | 145 | |
| ... | ... | @@ -167,6 +252,7 @@ async function encodeWithinBudget( |
| 167 | 252 | bitmap: ImageBitmap, |
| 168 | 253 | maxDimension: number, |
| 169 | 254 | budgetChars: number, |
| 255 | preferredMimeType?: "image/jpeg", | |
| 170 | 256 | ): Promise<{ dataUrl: string; mimeType: string } | null> { |
| 171 | 257 | const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); |
| 172 | 258 | const width = Math.max(1, Math.round(bitmap.width * scale)); |
| ... | ... | @@ -176,8 +262,11 @@ async function encodeWithinBudget( |
| 176 | 262 | |
| 177 | 263 | // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to |
| 178 | 264 | // happen before drawing and depends on which codec we end up using. |
| 179 | const probe = await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0); | |
| 180 | const mimeType = probe ? "image/webp" : "image/jpeg"; | |
| 265 | const mimeType = | |
| 266 | preferredMimeType ?? | |
| 267 | ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0)) | |
| 268 | ? "image/webp" | |
| 269 | : "image/jpeg"); | |
| 181 | 270 | |
| 182 | 271 | if (mimeType === "image/jpeg") { |
| 183 | 272 | target.context.fillStyle = "#ffffff"; |
| ... | ... | @@ -203,7 +292,11 @@ type ReencodeResult = |
| 203 | 292 | * Shared re-encode loop: decodes `file`, then walks the quality ladder and |
| 204 | 293 | * fallback downscale passes until an encoding fits `budgetChars`. |
| 205 | 294 | */ |
| 206 | async function reencodeWithinBudget(file: File, budgetChars: number): Promise<ReencodeResult> { | |
| 295 | async function reencodeWithinBudget( | |
| 296 | file: File, | |
| 297 | budgetChars: number, | |
| 298 | preferredMimeType?: "image/jpeg", | |
| 299 | ): Promise<ReencodeResult> { | |
| 207 | 300 | if (!canRecompress()) { |
| 208 | 301 | return { ok: false, reason: "too-large" }; |
| 209 | 302 | } |
| ... | ... | @@ -229,7 +322,7 @@ async function reencodeWithinBudget(file: File, budgetChars: number): Promise<Re |
| 229 | 322 | const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); |
| 230 | 323 | let encoded: { dataUrl: string; mimeType: string } | null; |
| 231 | 324 | try { |
| 232 | encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); | |
| 325 | encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars, preferredMimeType); | |
| 233 | 326 | } catch { |
| 234 | 327 | // Canvas allocation, drawing, or the codec itself can throw — often |
| 235 | 328 | // precisely *because* the target is too big (OOM on a large bitmap). |
| ... | ... | @@ -300,23 +393,26 @@ export async function compressImageForStash( |
| 300 | 393 | * `File` (WebP or JPEG). Files already within the limit pass through |
| 301 | 394 | * untouched, preserving their exact bytes and format. Sources above |
| 302 | 395 | * `MAX_COMPRESSIBLE_SOURCE_BYTES` are refused outright — decoding them is |
| 303 | * the risk, so no amount of output budget makes them safe. | |
| 396 | * the risk, so no amount of output budget makes them safe. An internally | |
| 397 | * converted image can provide its original source size when the intermediate | |
| 398 | * format expands beyond that ceiling. | |
| 304 | 399 | */ |
| 305 | 400 | export async function compressImageToByteLimit( |
| 306 | 401 | file: File, |
| 307 | 402 | maxBytes: number, |
| 403 | options?: { preferredMimeType?: "image/jpeg"; sourceSizeBytes?: number }, | |
| 308 | 404 | ): Promise<CompressImageFileResult> { |
| 309 | 405 | if (file.size <= maxBytes) { |
| 310 | 406 | return { ok: true, file, recompressed: false }; |
| 311 | 407 | } |
| 312 | if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) { | |
| 408 | if ((options?.sourceSizeBytes ?? file.size) > MAX_COMPRESSIBLE_SOURCE_BYTES) { | |
| 313 | 409 | return { ok: false, reason: "too-large" }; |
| 314 | 410 | } |
| 315 | 411 | // The re-encode loop budgets in data-URL characters. Base64 turns 3 bytes |
| 316 | 412 | // into 4 chars; flooring keeps the budget a hair conservative instead of |
| 317 | 413 | // admitting an encoding right at the byte cap. |
| 318 | 414 | const budgetChars = Math.floor(maxBytes / 3) * 4; |
| 319 | const reencoded = await reencodeWithinBudget(file, budgetChars); | |
| 415 | const reencoded = await reencodeWithinBudget(file, budgetChars, options?.preferredMimeType); | |
| 320 | 416 | if (!reencoded.ok) { |
| 321 | 417 | return reencoded; |
| 322 | 418 | } |
| ... | ... | @@ -330,3 +426,43 @@ export async function compressImageToByteLimit( |
| 330 | 426 | recompressed: true, |
| 331 | 427 | }; |
| 332 | 428 | } |
| 429 | ||
| 430 | /** | |
| 431 | * Converts HEIC/HEIF photos to provider-compatible JPEG before applying the | |
| 432 | * attachment size limit. The decoder is loaded only when such a photo arrives. | |
| 433 | */ | |
| 434 | export async function prepareImageForAttachment( | |
| 435 | file: File, | |
| 436 | maxBytes: number, | |
| 437 | ): Promise<CompressImageFileResult> { | |
| 438 | if (!isHeicImageFile(file)) { | |
| 439 | return compressImageToByteLimit(file, maxBytes); | |
| 440 | } | |
| 441 | ||
| 442 | if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) { | |
| 443 | return { ok: false, reason: "too-large" }; | |
| 444 | } | |
| 445 | ||
| 446 | let converted: Blob; | |
| 447 | try { | |
| 448 | const dimensionError = await validateHeicImageDimensions(file); | |
| 449 | if (dimensionError) { | |
| 450 | return { ok: false, reason: dimensionError }; | |
| 451 | } | |
| 452 | const { heicTo } = await import("heic-to/csp"); | |
| 453 | converted = await heicTo({ blob: file, type: "image/jpeg", quality: QUALITY_STEPS[0] }); | |
| 454 | } catch { | |
| 455 | return { ok: false, reason: "unreadable" }; | |
| 456 | } | |
| 457 | ||
| 458 | const jpeg = new File([converted], fileNameForMimeType(file.name || "image", "image/jpeg"), { | |
| 459 | type: "image/jpeg", | |
| 460 | lastModified: file.lastModified, | |
| 461 | }); | |
| 462 | const result = await compressImageToByteLimit(jpeg, maxBytes, { | |
| 463 | preferredMimeType: "image/jpeg", | |
| 464 | sourceSizeBytes: file.size, | |
| 465 | }); | |
| 466 | ||
| 467 | return result.ok ? { ...result, recompressed: true } : result; | |
| 468 | } |
docs/user/composer.md+3| ... | ... | @@ -7,6 +7,9 @@ multiple messages, then send again in the same thread. |
| 7 | 7 | On servers that support direct uploads, images upload as soon as you add them. The send button |
| 8 | 8 | becomes available after every upload finishes. Failed uploads can be retried or removed. |
| 9 | 9 | |
| 10 | On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into | |
| 11 | the composer or paste them into a message. | |
| 12 | ||
| 10 | 13 | ## Commands and skills |
| 11 | 14 | |
| 12 | 15 | Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, |
pnpm-lock.yaml+8| ... | ... | @@ -590,6 +590,9 @@ importers: |
| 590 | 590 | effect: |
| 591 | 591 | specifier: 4.0.0-beta.103 |
| 592 | 592 | version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) |
| 593 | heic-to: | |
| 594 | specifier: ^1.5.2 | |
| 595 | version: 1.5.2 | |
| 593 | 596 | jose: |
| 594 | 597 | specifier: 'catalog:' |
| 595 | 598 | version: 6.2.2 |
| ... | ... | @@ -7159,6 +7162,9 @@ packages: |
| 7159 | 7162 | headers-polyfill@4.0.3: |
| 7160 | 7163 | resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} |
| 7161 | 7164 | |
| 7165 | heic-to@1.5.2: | |
| 7166 | resolution: {integrity: sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==} | |
| 7167 | ||
| 7162 | 7168 | hermes-compiler@250829098.0.10: |
| 7163 | 7169 | resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} |
| 7164 | 7170 | |
| ... | ... | @@ -17839,6 +17845,8 @@ snapshots: |
| 17839 | 17845 | |
| 17840 | 17846 | headers-polyfill@4.0.3: {} |
| 17841 | 17847 | |
| 17848 | heic-to@1.5.2: {} | |
| 17849 | ||
| 17842 | 17850 | hermes-compiler@250829098.0.10: {} |
| 17843 | 17851 | |
| 17844 | 17852 | hermes-estree@0.33.3: {} |