feat(upload): convert HEIC/HEIF to JPEG on the client before preview/upload

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>
This commit is contained in:
Arda Samadi 2026-06-23 16:43:30 +03:30
parent 641abf711c
commit bac93568f5
5 changed files with 91 additions and 6 deletions

View File

@ -9,6 +9,7 @@ import AppSelectBox from "../AppSelectBox";
import { Button } from "../ui/button";
import { FormField } from "../ui/FormField";
import { ImageUpload } from "./ImageUpload";
import { convertHeicToJpeg } from "~/utils/convert-heic";
import { ImageUploadDrawer } from "./ImageUploadDrawer";
import { Textarea } from "../ui/textarea";
import { useToast } from "../../hooks/use-toast";
@ -294,9 +295,13 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
!validationErrors.instagramId;
// Handle image selection
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const handleImageSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const picked = event.target.files?.[0];
if (picked) {
// iPhone logos are often HEIC, which most browsers can't preview; convert.
const file = await convertHeicToJpeg(picked);
if (imagePreviewUrl) {
URL.revokeObjectURL(imagePreviewUrl);
}

View File

@ -3,6 +3,7 @@ import { X, Loader } from "lucide-react";
import HeaderWithSupport from "../components/HeaderWithSupport";
import { useSearchParams } from "react-router-dom";
import { safeGoBack } from "~/utils/helpers";
import { convertHeicFiles } from "~/utils/convert-heic";
import { useRef, useState, useEffect } from "react";
import { Button } from "../components/ui/button";
import { ImageUploadDrawer } from "~/components/store/ImageUploadDrawer";
@ -709,11 +710,16 @@ export default function AddProduct() {
};
// Handle image selection
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleImageSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const files = event.target.files;
if (files && files.length > 0) {
// Convert FileList to Array and process all files
const newImages = Array.from(files).map((file) => ({
// iPhone photos are HEIC, which most browsers can't preview/crop.
// Convert to JPEG up front so preview, the crop editor, and upload work.
const converted = await convertHeicFiles(Array.from(files));
const newImages = converted.map((file) => ({
file,
url: URL.createObjectURL(file),
isEdited: false,

66
app/utils/convert-heic.ts Normal file
View File

@ -0,0 +1,66 @@
// 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)));
}

7
package-lock.json generated
View File

@ -28,6 +28,7 @@
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.5.2",
"framer-motion": "^12.4.10",
"heic2any": "^0.0.4",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-http-backend": "^3.0.2",
@ -8839,6 +8840,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",

View File

@ -37,6 +37,7 @@
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.5.2",
"framer-motion": "^12.4.10",
"heic2any": "^0.0.4",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-http-backend": "^3.0.2",