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 @@
3434 "class-variance-authority": "^0.7.1",
3535 "culori": "^4.0.2",
3636 "effect": "catalog:",
37 "heic-to": "^1.5.2",
3738 "jose": "catalog:",
3839 "jsonc-parser": "3.3.1",
3940 "jszip": "3.10.1",
apps/web/src/components/chat/ChatComposer.tsx+16-6
......@@ -73,7 +73,11 @@ import {
7373 type ComposerTaskStep,
7474 type ComposerTasksProgress,
7575} from "./ComposerTasksBadge";
76import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";
76import {
77 compressImageForStash,
78 isHeicImageFile,
79 prepareImageForAttachment,
80} from "../../lib/imageCompression";
7781import {
7882 releaseAttachmentUpload,
7983 retryAttachmentUpload,
......@@ -2537,12 +2541,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25372541 const acceptedFiles: File[] = [];
25382542 let error: string | null = null;
25392543 for (const file of files) {
2540 if (!file.type.startsWith("image/")) {
2544 const isHeicImage = isHeicImageFile(file);
2545 if (!file.type.startsWith("image/") && !isHeicImage) {
25412546 error = `Unsupported file type for '${file.name}'. Please attach image files only.`;
25422547 continue;
25432548 }
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.`;
25462551 continue;
25472552 }
25482553 if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
......@@ -2562,7 +2567,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25622567 for (const file of acceptedFiles) {
25632568 // Images over the wire cap are downscaled to fit rather than
25642569 // 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 );
25662574 if (!compressed.ok) {
25672575 compressionError =
25682576 compressed.reason === "unreadable"
......@@ -2615,7 +2623,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26152623 const onComposerPaste = (event: React.ClipboardEvent<HTMLElement>) => {
26162624 const files = Array.from(event.clipboardData.files);
26172625 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 );
26192629 if (imageFiles.length === 0) return;
26202630 event.preventDefault();
26212631 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";
33import {
44 compressImageForStash,
55 compressImageToByteLimit,
6 isHeicImageFile,
67 MAX_COMPRESSIBLE_SOURCE_BYTES,
78 MAX_STASH_IMAGE_DATA_URL_CHARS,
9 prepareImageForAttachment,
810} from "./imageCompression";
911
12const mocks = vi.hoisted(() => ({
13 heicTo: vi.fn(),
14}));
15
16vi.mock("heic-to/csp", () => ({
17 heicTo: mocks.heicTo,
18}));
19
1020/**
1121 * jsdom has no real canvas/codec, so the re-encode path is exercised with
1222 * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a
......@@ -22,6 +32,45 @@ function makeFile(sizeBytes: number, type = "image/png"): File {
2232 return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type });
2333}
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
2574/**
2675 * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`.
2776 * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed
......@@ -63,6 +112,7 @@ function stubCanvasPipeline(
63112}
64113
65114afterEach(() => {
115 mocks.heicTo.mockReset();
66116 vi.unstubAllGlobals();
67117 globalThis.createImageBitmap = originalCreateImageBitmap;
68118 globalThis.OffscreenCanvas = originalOffscreenCanvas;
......@@ -251,3 +301,148 @@ describe("compressImageForStash", () => {
251301 expect(smallestRequested).toBeLessThan(800);
252302 });
253303});
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 @@
88 * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit
99 * via `compressImageToByteLimit` instead of rejecting the paste.
1010 *
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.
1213 */
1314
1415/**
......@@ -24,6 +25,8 @@ export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000;
2425 * ImageBitmap can OOM the tab — beyond this we refuse rather than risk it.
2526 */
2627export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024;
28const MAX_HEIC_DECODE_PIXELS = 64_000_000;
29const MAX_HEIC_METADATA_BYTES = 1024 * 1024;
2730/**
2831 * Quality ladder tried in order until the encoded image fits the budget.
2932 * 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;
3235const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const;
3336/** Extra downscale passes applied when even the lowest quality overflows. */
3437const 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
3641export interface CompressedStashImage {
3742 dataUrl: string;
......@@ -55,6 +60,86 @@ export type CompressImageFileResult =
5560 | { ok: true; file: File; recompressed: boolean }
5661 | { 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
58143/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */
59144const BASE64_CHUNK_SIZE = 0x8000;
60145
......@@ -167,6 +252,7 @@ async function encodeWithinBudget(
167252 bitmap: ImageBitmap,
168253 maxDimension: number,
169254 budgetChars: number,
255 preferredMimeType?: "image/jpeg",
170256): Promise<{ dataUrl: string; mimeType: string } | null> {
171257 const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
172258 const width = Math.max(1, Math.round(bitmap.width * scale));
......@@ -176,8 +262,11 @@ async function encodeWithinBudget(
176262
177263 // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to
178264 // 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");
181270
182271 if (mimeType === "image/jpeg") {
183272 target.context.fillStyle = "#ffffff";
......@@ -203,7 +292,11 @@ type ReencodeResult =
203292 * Shared re-encode loop: decodes `file`, then walks the quality ladder and
204293 * fallback downscale passes until an encoding fits `budgetChars`.
205294 */
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> {
207300 if (!canRecompress()) {
208301 return { ok: false, reason: "too-large" };
209302 }
......@@ -229,7 +322,7 @@ async function reencodeWithinBudget(file: File, budgetChars: number): Promise<Re
229322 const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale));
230323 let encoded: { dataUrl: string; mimeType: string } | null;
231324 try {
232 encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars);
325 encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars, preferredMimeType);
233326 } catch {
234327 // Canvas allocation, drawing, or the codec itself can throw — often
235328 // precisely *because* the target is too big (OOM on a large bitmap).
......@@ -300,23 +393,26 @@ export async function compressImageForStash(
300393 * `File` (WebP or JPEG). Files already within the limit pass through
301394 * untouched, preserving their exact bytes and format. Sources above
302395 * `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.
304399 */
305400export async function compressImageToByteLimit(
306401 file: File,
307402 maxBytes: number,
403 options?: { preferredMimeType?: "image/jpeg"; sourceSizeBytes?: number },
308404): Promise<CompressImageFileResult> {
309405 if (file.size <= maxBytes) {
310406 return { ok: true, file, recompressed: false };
311407 }
312 if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) {
408 if ((options?.sourceSizeBytes ?? file.size) > MAX_COMPRESSIBLE_SOURCE_BYTES) {
313409 return { ok: false, reason: "too-large" };
314410 }
315411 // The re-encode loop budgets in data-URL characters. Base64 turns 3 bytes
316412 // into 4 chars; flooring keeps the budget a hair conservative instead of
317413 // admitting an encoding right at the byte cap.
318414 const budgetChars = Math.floor(maxBytes / 3) * 4;
319 const reencoded = await reencodeWithinBudget(file, budgetChars);
415 const reencoded = await reencodeWithinBudget(file, budgetChars, options?.preferredMimeType);
320416 if (!reencoded.ok) {
321417 return reencoded;
322418 }
......@@ -330,3 +426,43 @@ export async function compressImageToByteLimit(
330426 recompressed: true,
331427 };
332428}
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.
77On servers that support direct uploads, images upload as soon as you add them. The send button
88becomes 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
1013## Commands and skills
1114
1215Type `/` 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:
590590 effect:
591591 specifier: 4.0.0-beta.103
592592 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
593 heic-to:
594 specifier: ^1.5.2
595 version: 1.5.2
593596 jose:
594597 specifier: 'catalog:'
595598 version: 6.2.2
......@@ -7159,6 +7162,9 @@ packages:
71597162 headers-polyfill@4.0.3:
71607163 resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
71617164
7165 heic-to@1.5.2:
7166 resolution: {integrity: sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==}
7167
71627168 hermes-compiler@250829098.0.10:
71637169 resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==}
71647170
......@@ -17839,6 +17845,8 @@ snapshots:
1783917845
1784017846 headers-polyfill@4.0.3: {}
1784117847
17848 heic-to@1.5.2: {}
17849
1784217850 hermes-compiler@250829098.0.10: {}
1784317851
1784417852 hermes-estree@0.33.3: {}