Most browsers (everything but Safari/WebKit) can't decode HEIC in <img> or canvas, so picking an iPhone HEIC photo broke the preview and the crop editor, and could reach the backend in a format it couldn't process. Convert HEIC/HEIF to JPEG right after selection via heic2any (dynamically imported, browser-only). - new util app/utils/convert-heic.ts: isHeic() detection (MIME or extension when iOS reports no type) + convertHeicToJpeg()/convertHeicFiles(); never throws — falls back to the original file on failure. - wired into the product image picker (store.add.manual) and the store logo picker (StoreForm). - adds heic2any dependency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
67 lines
2.2 KiB
TypeScript
67 lines
2.2 KiB
TypeScript
// Client-side HEIC/HEIF -> JPEG conversion.
|
|
//
|
|
// iPhones store photos as HEIC. Most browsers (everything except Safari/WebKit)
|
|
// can't decode HEIC in <img>/canvas, so previewing and cropping a freshly
|
|
// picked HEIC file fails. We convert to JPEG right after selection so the rest
|
|
// of the upload flow (preview, ImageEditor crop, upload) works everywhere.
|
|
//
|
|
// heic2any is browser-only (uses a wasm libheif build), so it must be imported
|
|
// dynamically inside the handler — never at module top level (SSR would break).
|
|
|
|
const HEIC_EXT_RE = /\.(heic|heif)$/i;
|
|
|
|
/** True if the file looks like HEIC/HEIF by MIME type or, when the browser
|
|
* reports no/!generic type (common on iOS), by file extension. */
|
|
export function isHeic(file: File): boolean {
|
|
const type = (file.type || "").toLowerCase();
|
|
if (
|
|
type === "image/heic" ||
|
|
type === "image/heif" ||
|
|
type === "image/heic-sequence" ||
|
|
type === "image/heif-sequence"
|
|
) {
|
|
return true;
|
|
}
|
|
if (type === "" || type === "application/octet-stream") {
|
|
return HEIC_EXT_RE.test(file.name);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns a JPEG File when given a HEIC/HEIF file; otherwise returns the
|
|
* original file unchanged. Never throws — on conversion failure it falls back
|
|
* to the original file (the backend's pillow-heif may still handle it).
|
|
*/
|
|
export async function convertHeicToJpeg(
|
|
file: File,
|
|
quality = 0.92
|
|
): Promise<File> {
|
|
if (typeof window === "undefined" || !isHeic(file)) return file;
|
|
|
|
try {
|
|
const heic2any = (await import("heic2any")).default;
|
|
const result = (await heic2any({
|
|
blob: file,
|
|
toType: "image/jpeg",
|
|
quality,
|
|
})) as Blob | Blob[];
|
|
|
|
const blob = Array.isArray(result) ? result[0] : result;
|
|
const baseName = file.name.replace(HEIC_EXT_RE, "") || "image";
|
|
|
|
return new File([blob], `${baseName}.jpg`, {
|
|
type: "image/jpeg",
|
|
lastModified: Date.now(),
|
|
});
|
|
} catch (e) {
|
|
console.error("HEIC->JPEG conversion failed; using original file", e);
|
|
return file;
|
|
}
|
|
}
|
|
|
|
/** Convert any HEIC/HEIF files in a list, leaving others untouched. */
|
|
export async function convertHeicFiles(files: File[]): Promise<File[]> {
|
|
return Promise.all(files.map((f) => convertHeicToJpeg(f)));
|
|
}
|