// Browser-side HEIC / HEIF → JPEG conversion for the pre-send
// attachment preview (`ChatAttachmentPreview.vue`). Chrome refuses
// to render `image/heic` / `image/heif` in `
`, so a user who
// pastes / drops an iPhone photo sees a broken-image icon before
// they send the message.
//
// The server-side upload path is deliberately NOT touched — the
// original HEIC bytes still travel to `/api/attachments` verbatim,
// where a Node-side heic-convert / sharp roundtrip produces the
// JPEG the LLM reads. This module runs entirely in-browser and only
// affects what the user sees in the preview chip.
//
// `heic2any` bundles WASM libheif (~1.5 MB). We dynamic-import it
// on first use so users who never paste HEIC don't pay the
// download / parse cost. Failures are swallowed — the caller then
// leaves `previewDataUrl` unset and the preview component drops
// back to a file-icon chip.
// `image/heic-sequence` / `image/heif-sequence` are the multi-frame
// container MIMEs iOS emits for burst mode and Live Photos. Codex
// review on #2000: without these, a Live Photo would silently miss
// the preview conversion and land in the file-icon fallback.
const HEIF_LIKE_MIMES: ReadonlySet = new Set(["image/heic", "image/heif", "image/heic-sequence", "image/heif-sequence"]);
/** True when the given MIME identifies a HEIC / HEIF variant Chrome
* can't render natively (including the `-sequence` container
* variants for Live Photos / burst mode). Kept narrow — TIFF / BMP
* are technically in-band for the server-side conversion path but
* Chrome renders BMP natively today and heic2any doesn't decode
* TIFF at all. */
export function needsBrowserPreviewConversion(mime: string): boolean {
return HEIF_LIKE_MIMES.has(mime);
}
async function blobToDataUrl(blob: Blob): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => (typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("FileReader returned non-string")));
reader.onerror = () => reject(new Error("FileReader failed"));
reader.readAsDataURL(blob);
});
}
/** Decode a HEIC / HEIF File into a JPEG data URL suitable for
* `
`. Returns null on any decode failure (unsupported
* variant, malformed bytes, WASM load failure) — the caller uses
* this to leave the preview chip in the file-icon state instead
* of a broken image. */
export async function buildHeicPreviewDataUrl(file: File): Promise {
try {
// heic2any's default export is a function; dynamic import keeps
// the ~1.5 MB WASM libheif bundle out of the app's initial
// payload for users who never paste HEIC.
const heic2any = (await import("heic2any")).default;
const converted = await heic2any({ blob: file, toType: "image/jpeg", quality: 0.85 });
// heic2any returns `Blob | Blob[]` — a multi-frame HEIC / HEIF
// (burst mode, live photo) returns an array. Take the first
// frame for the preview; the full sequence still ships as-is to
// the upload endpoint via the untouched `dataUrl`.
const jpegBlob = Array.isArray(converted) ? converted[0] : converted;
// A frameless result has no preview to show; the original file still
// uploads via the untouched `dataUrl`.
if (!jpegBlob) return null;
return await blobToDataUrl(jpegBlob);
} catch {
return null;
}
}