| 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,6 +34,7 @@ |
| 34 | "class-variance-authority": "^0.7.1", | 34 | "class-variance-authority": "^0.7.1", |
| 35 | "culori": "^4.0.2", | 35 | "culori": "^4.0.2", |
| 36 | "effect": "catalog:", | 36 | "effect": "catalog:", |
| 37 | "heic-to": "^1.5.2", | ||
| 37 | "jose": "catalog:", | 38 | "jose": "catalog:", |
| 38 | "jsonc-parser": "3.3.1", | 39 | "jsonc-parser": "3.3.1", |
| 39 | "jszip": "3.10.1", | 40 | "jszip": "3.10.1", |
apps/web/src/components/chat/ChatComposer.tsx+16-6| ... | @@ -73,7 +73,11 @@ import { | ... | @@ -73,7 +73,11 @@ import { |
| 73 | type ComposerTaskStep, | 73 | type ComposerTaskStep, |
| 74 | type ComposerTasksProgress, | 74 | type ComposerTasksProgress, |
| 75 | } from "./ComposerTasksBadge"; | 75 | } from "./ComposerTasksBadge"; |
| 76 | import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; | 76 | import { |
| 77 | compressImageForStash, | ||
| 78 | isHeicImageFile, | ||
| 79 | prepareImageForAttachment, | ||
| 80 | } from "../../lib/imageCompression"; | ||
| 77 | import { | 81 | import { |
| 78 | releaseAttachmentUpload, | 82 | releaseAttachmentUpload, |
| 79 | retryAttachmentUpload, | 83 | retryAttachmentUpload, |
| ... | @@ -2537,12 +2541,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2537,12 +2541,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2537 | const acceptedFiles: File[] = []; | 2541 | const acceptedFiles: File[] = []; |
| 2538 | let error: string | null = null; | 2542 | let error: string | null = null; |
| 2539 | for (const file of files) { | 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 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; | 2546 | error = `Unsupported file type for '${file.name}'. Please attach image files only.`; |
| 2542 | continue; | 2547 | continue; |
| 2543 | } | 2548 | } |
| 2544 | if (!isProviderSendTurnSupportedImageMimeType(file.type)) { | 2549 | if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) { |
| 2545 | error = `'${file.name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; | 2550 | error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`; |
| 2546 | continue; | 2551 | continue; |
| 2547 | } | 2552 | } |
| 2548 | if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { | 2553 | if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { |
| ... | @@ -2562,7 +2567,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2562,7 +2567,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2562 | for (const file of acceptedFiles) { | 2567 | for (const file of acceptedFiles) { |
| 2563 | // Images over the wire cap are downscaled to fit rather than | 2568 | // Images over the wire cap are downscaled to fit rather than |
| 2564 | // refused; files already within it pass through byte-for-byte. | 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 | if (!compressed.ok) { | 2574 | if (!compressed.ok) { |
| 2567 | compressionError = | 2575 | compressionError = |
| 2568 | compressed.reason === "unreadable" | 2576 | compressed.reason === "unreadable" |
| ... | @@ -2615,7 +2623,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2615,7 +2623,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2615 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { | 2623 | const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => { |
| 2616 | const files = Array.from(event.clipboardData.files); | 2624 | const files = Array.from(event.clipboardData.files); |
| 2617 | if (files.length === 0) return; | 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 | if (imageFiles.length === 0) return; | 2629 | if (imageFiles.length === 0) return; |
| 2620 | event.preventDefault(); | 2630 | event.preventDefault(); |
| 2621 | void addComposerImages(imageFiles); | 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,10 +3,20 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 4 | compressImageForStash, | 4 | compressImageForStash, |
| 5 | compressImageToByteLimit, | 5 | compressImageToByteLimit, |
| 6 | isHeicImageFile, | ||
| 6 | MAX_COMPRESSIBLE_SOURCE_BYTES, | 7 | MAX_COMPRESSIBLE_SOURCE_BYTES, |
| 7 | MAX_STASH_IMAGE_DATA_URL_CHARS, | 8 | MAX_STASH_IMAGE_DATA_URL_CHARS, |
| 9 | prepareImageForAttachment, | ||
| 8 | } from "./imageCompression"; | 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 | * jsdom has no real canvas/codec, so the re-encode path is exercised with | 21 | * jsdom has no real canvas/codec, so the re-encode path is exercised with |
| 12 | * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a | 22 | * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a |
| ... | @@ -22,6 +32,45 @@ function makeFile(sizeBytes: number, type = "image/png"): File { | ... | @@ -22,6 +32,45 @@ function makeFile(sizeBytes: number, type = "image/png"): File { |
| 22 | return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type }); | 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 | * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. | 75 | * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. |
| 27 | * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed | 76 | * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed |
| ... | @@ -63,6 +112,7 @@ function stubCanvasPipeline( | ... | @@ -63,6 +112,7 @@ function stubCanvasPipeline( |
| 63 | } | 112 | } |
| 64 | 113 | ||
| 65 | afterEach(() => { | 114 | afterEach(() => { |
| 115 | mocks.heicTo.mockReset(); | ||
| 66 | vi.unstubAllGlobals(); | 116 | vi.unstubAllGlobals(); |
| 67 | globalThis.createImageBitmap = originalCreateImageBitmap; | 117 | globalThis.createImageBitmap = originalCreateImageBitmap; |
| 68 | globalThis.OffscreenCanvas = originalOffscreenCanvas; | 118 | globalThis.OffscreenCanvas = originalOffscreenCanvas; |
| ... | @@ -251,3 +301,148 @@ describe("compressImageForStash", () => { | ... | @@ -251,3 +301,148 @@ describe("compressImageForStash", () => { |
| 251 | expect(smallestRequested).toBeLessThan(800); | 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,7 +8,8 @@ |
| 8 | * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit | 8 | * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit |
| 9 | * via `compressImageToByteLimit` instead of rejecting the paste. | 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,6 +25,8 @@ export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000; |
| 24 | * ImageBitmap can OOM the tab — beyond this we refuse rather than risk it. | 25 | * ImageBitmap can OOM the tab — beyond this we refuse rather than risk it. |
| 25 | */ | 26 | */ |
| 26 | export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024; | 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 | * Quality ladder tried in order until the encoded image fits the budget. | 31 | * 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; | 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,6 +35,8 @@ export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024; |
| 32 | const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; | 35 | const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; |
| 33 | /** Extra downscale passes applied when even the lowest quality overflows. */ | 36 | /** Extra downscale passes applied when even the lowest quality overflows. */ |
| 34 | const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; | 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 | export interface CompressedStashImage { | 41 | export interface CompressedStashImage { |
| 37 | dataUrl: string; | 42 | dataUrl: string; |
| ... | @@ -55,6 +60,86 @@ export type CompressImageFileResult = | ... | @@ -55,6 +60,86 @@ export type CompressImageFileResult = |
| 55 | | { ok: true; file: File; recompressed: boolean } | 60 | | { ok: true; file: File; recompressed: boolean } |
| 56 | | { ok: false; reason: ImageCompressionFailureReason }; | 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 | /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ | 143 | /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ |
| 59 | const BASE64_CHUNK_SIZE = 0x8000; | 144 | const BASE64_CHUNK_SIZE = 0x8000; |
| 60 | 145 | ||
| ... | @@ -167,6 +252,7 @@ async function encodeWithinBudget( | ... | @@ -167,6 +252,7 @@ async function encodeWithinBudget( |
| 167 | bitmap: ImageBitmap, | 252 | bitmap: ImageBitmap, |
| 168 | maxDimension: number, | 253 | maxDimension: number, |
| 169 | budgetChars: number, | 254 | budgetChars: number, |
| 255 | preferredMimeType?: "image/jpeg", | ||
| 170 | ): Promise<{ dataUrl: string; mimeType: string } | null> { | 256 | ): Promise<{ dataUrl: string; mimeType: string } | null> { |
| 171 | const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); | 257 | const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); |
| 172 | const width = Math.max(1, Math.round(bitmap.width * scale)); | 258 | const width = Math.max(1, Math.round(bitmap.width * scale)); |
| ... | @@ -176,8 +262,11 @@ async function encodeWithinBudget( | ... | @@ -176,8 +262,11 @@ async function encodeWithinBudget( |
| 176 | 262 | ||
| 177 | // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to | 263 | // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to |
| 178 | // happen before drawing and depends on which codec we end up using. | 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); | 265 | const mimeType = |
| 180 | const mimeType = probe ? "image/webp" : "image/jpeg"; | 266 | preferredMimeType ?? |
| 267 | ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0)) | ||
| 268 | ? "image/webp" | ||
| 269 | : "image/jpeg"); | ||
| 181 | 270 | ||
| 182 | if (mimeType === "image/jpeg") { | 271 | if (mimeType === "image/jpeg") { |
| 183 | target.context.fillStyle = "#ffffff"; | 272 | target.context.fillStyle = "#ffffff"; |
| ... | @@ -203,7 +292,11 @@ type ReencodeResult = | ... | @@ -203,7 +292,11 @@ type ReencodeResult = |
| 203 | * Shared re-encode loop: decodes `file`, then walks the quality ladder and | 292 | * Shared re-encode loop: decodes `file`, then walks the quality ladder and |
| 204 | * fallback downscale passes until an encoding fits `budgetChars`. | 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 | if (!canRecompress()) { | 300 | if (!canRecompress()) { |
| 208 | return { ok: false, reason: "too-large" }; | 301 | return { ok: false, reason: "too-large" }; |
| 209 | } | 302 | } |
| ... | @@ -229,7 +322,7 @@ async function reencodeWithinBudget(file: File, budgetChars: number): Promise<Re | ... | @@ -229,7 +322,7 @@ async function reencodeWithinBudget(file: File, budgetChars: number): Promise<Re |
| 229 | const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); | 322 | const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); |
| 230 | let encoded: { dataUrl: string; mimeType: string } | null; | 323 | let encoded: { dataUrl: string; mimeType: string } | null; |
| 231 | try { | 324 | try { |
| 232 | encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); | 325 | encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars, preferredMimeType); |
| 233 | } catch { | 326 | } catch { |
| 234 | // Canvas allocation, drawing, or the codec itself can throw — often | 327 | // Canvas allocation, drawing, or the codec itself can throw — often |
| 235 | // precisely *because* the target is too big (OOM on a large bitmap). | 328 | // precisely *because* the target is too big (OOM on a large bitmap). |
| ... | @@ -300,23 +393,26 @@ export async function compressImageForStash( | ... | @@ -300,23 +393,26 @@ export async function compressImageForStash( |
| 300 | * `File` (WebP or JPEG). Files already within the limit pass through | 393 | * `File` (WebP or JPEG). Files already within the limit pass through |
| 301 | * untouched, preserving their exact bytes and format. Sources above | 394 | * untouched, preserving their exact bytes and format. Sources above |
| 302 | * `MAX_COMPRESSIBLE_SOURCE_BYTES` are refused outright — decoding them is | 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 | export async function compressImageToByteLimit( | 400 | export async function compressImageToByteLimit( |
| 306 | file: File, | 401 | file: File, |
| 307 | maxBytes: number, | 402 | maxBytes: number, |
| 403 | options?: { preferredMimeType?: "image/jpeg"; sourceSizeBytes?: number }, | ||
| 308 | ): Promise<CompressImageFileResult> { | 404 | ): Promise<CompressImageFileResult> { |
| 309 | if (file.size <= maxBytes) { | 405 | if (file.size <= maxBytes) { |
| 310 | return { ok: true, file, recompressed: false }; | 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 | return { ok: false, reason: "too-large" }; | 409 | return { ok: false, reason: "too-large" }; |
| 314 | } | 410 | } |
| 315 | // The re-encode loop budgets in data-URL characters. Base64 turns 3 bytes | 411 | // The re-encode loop budgets in data-URL characters. Base64 turns 3 bytes |
| 316 | // into 4 chars; flooring keeps the budget a hair conservative instead of | 412 | // into 4 chars; flooring keeps the budget a hair conservative instead of |
| 317 | // admitting an encoding right at the byte cap. | 413 | // admitting an encoding right at the byte cap. |
| 318 | const budgetChars = Math.floor(maxBytes / 3) * 4; | 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 | if (!reencoded.ok) { | 416 | if (!reencoded.ok) { |
| 321 | return reencoded; | 417 | return reencoded; |
| 322 | } | 418 | } |
| ... | @@ -330,3 +426,43 @@ export async function compressImageToByteLimit( | ... | @@ -330,3 +426,43 @@ export async function compressImageToByteLimit( |
| 330 | recompressed: true, | 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,6 +7,9 @@ multiple messages, then send again in the same thread. |
| 7 | On servers that support direct uploads, images upload as soon as you add them. The send button | 7 | On servers that support direct uploads, images upload as soon as you add them. The send button |
| 8 | becomes available after every upload finishes. Failed uploads can be retried or removed. | 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 | ## Commands and skills | 13 | ## Commands and skills |
| 11 | 14 | ||
| 12 | Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, | 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,6 +590,9 @@ importers: |
| 590 | effect: | 590 | effect: |
| 591 | specifier: 4.0.0-beta.103 | 591 | specifier: 4.0.0-beta.103 |
| 592 | version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) | 592 | version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) |
| 593 | heic-to: | ||
| 594 | specifier: ^1.5.2 | ||
| 595 | version: 1.5.2 | ||
| 593 | jose: | 596 | jose: |
| 594 | specifier: 'catalog:' | 597 | specifier: 'catalog:' |
| 595 | version: 6.2.2 | 598 | version: 6.2.2 |
| ... | @@ -7159,6 +7162,9 @@ packages: | ... | @@ -7159,6 +7162,9 @@ packages: |
| 7159 | headers-polyfill@4.0.3: | 7162 | headers-polyfill@4.0.3: |
| 7160 | resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} | 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 | hermes-compiler@250829098.0.10: | 7168 | hermes-compiler@250829098.0.10: |
| 7163 | resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} | 7169 | resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} |
| 7164 | 7170 | ||
| ... | @@ -17839,6 +17845,8 @@ snapshots: | ... | @@ -17839,6 +17845,8 @@ snapshots: |
| 17839 | 17845 | ||
| 17840 | headers-polyfill@4.0.3: {} | 17846 | headers-polyfill@4.0.3: {} |
| 17841 | 17847 | ||
| 17848 | heic-to@1.5.2: {} | ||
| 17849 | |||
| 17842 | hermes-compiler@250829098.0.10: {} | 17850 | hermes-compiler@250829098.0.10: {} |
| 17843 | 17851 | ||
| 17844 | hermes-estree@0.33.3: {} | 17852 | hermes-estree@0.33.3: {} |