authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-24 22:00:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 22:00:28-07:00
logbd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c
tree09aa77c7ab4733860ab904e424123861a87f33c2
parent3c75eb1132bb5d67cfa95ac6271ef68959f986c1
signature Signed by PGP key B5690EEEBB952194

feat(web): safely attach HEIC photos as JPEG images (#8161)

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";
76import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";76import {
77 compressImageForStash,
78 isHeicImageFile,
79 prepareImageForAttachment,
80} from "../../lib/imageCompression";
77import {81import {
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 than2568 // 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";
3import {3import {
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";
911
12const mocks = vi.hoisted(() => ({
13 heicTo: vi.fn(),
14}));
15
16vi.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 with21 * jsdom has no real canvas/codec, so the re-encode path is exercised with
12 * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a22 * 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}
2434
35function 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-typed76 * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed
...@@ -63,6 +112,7 @@ function stubCanvasPipeline(...@@ -63,6 +112,7 @@ function stubCanvasPipeline(
63}112}
64113
65afterEach(() => {114afterEach(() => {
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
305describe("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 fit8 * `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 */
1314
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 */
26export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024;27export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024;
28const MAX_HEIC_DECODE_PIXELS = 64_000_000;
29const 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;
32const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const;35const 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. */
34const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const;37const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const;
38const HEIC_IMAGE_MIME_TYPE = /^image\/hei(?:c|f)$/i;
39const HEIC_IMAGE_EXTENSION = /\.(?:heic|heif)$/i;
3540
36export interface CompressedStashImage {41export 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 };
5762
63/** Finder and some browsers omit the MIME type when dragging HEIC photos. */
64export 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
74interface HeicMetadataBox {
75 payloadOffset: number;
76 endOffset: number;
77}
78
79function 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. */
110async 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`. */
59const BASE64_CHUNK_SIZE = 0x8000;144const BASE64_CHUNK_SIZE = 0x8000;
60145
...@@ -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(
176262
177 // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to263 // 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");
181270
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 and292 * 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 */
206async function reencodeWithinBudget(file: File, budgetChars: number): Promise<ReencodeResult> {295async 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 — often327 // 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 through393 * `File` (WebP or JPEG). Files already within the limit pass through
301 * untouched, preserving their exact bytes and format. Sources above394 * untouched, preserving their exact bytes and format. Sources above
302 * `MAX_COMPRESSIBLE_SOURCE_BYTES` are refused outright — decoding them is395 * `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 */
305export async function compressImageToByteLimit(400export 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 bytes411 // 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 of412 // 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 */
434export 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.
7On servers that support direct uploads, images upload as soon as you add them. The send button7On servers that support direct uploads, images upload as soon as you add them. The send button
8becomes available after every upload finishes. Failed uploads can be retried or removed.8becomes available after every upload finishes. Failed uploads can be retried or removed.
99
10On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
11the composer or paste them into a message.
12
10## Commands and skills13## Commands and skills
1114
12Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source,15Type `/` 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.103591 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.2598 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==}
71617164
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==}
71647170
...@@ -17839,6 +17845,8 @@ snapshots:...@@ -17839,6 +17845,8 @@ snapshots:
1783917845
17840 headers-polyfill@4.0.3: {}17846 headers-polyfill@4.0.3: {}
1784117847
17848 heic-to@1.5.2: {}
17849
17842 hermes-compiler@250829098.0.10: {}17850 hermes-compiler@250829098.0.10: {}
1784317851
17844 hermes-estree@0.33.3: {}17852 hermes-estree@0.33.3: {}