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:
parent
641abf711c
commit
bac93568f5
@ -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);
|
||||
}
|
||||
|
||||
@ -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
66
app/utils/convert-heic.ts
Normal 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
7
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user