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 { Button } from "../ui/button";
|
||||||
import { FormField } from "../ui/FormField";
|
import { FormField } from "../ui/FormField";
|
||||||
import { ImageUpload } from "./ImageUpload";
|
import { ImageUpload } from "./ImageUpload";
|
||||||
|
import { convertHeicToJpeg } from "~/utils/convert-heic";
|
||||||
import { ImageUploadDrawer } from "./ImageUploadDrawer";
|
import { ImageUploadDrawer } from "./ImageUploadDrawer";
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from "../ui/textarea";
|
||||||
import { useToast } from "../../hooks/use-toast";
|
import { useToast } from "../../hooks/use-toast";
|
||||||
@ -294,9 +295,13 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
|||||||
!validationErrors.instagramId;
|
!validationErrors.instagramId;
|
||||||
|
|
||||||
// Handle image selection
|
// Handle image selection
|
||||||
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageSelect = async (
|
||||||
const file = event.target.files?.[0];
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
if (file) {
|
) => {
|
||||||
|
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) {
|
if (imagePreviewUrl) {
|
||||||
URL.revokeObjectURL(imagePreviewUrl);
|
URL.revokeObjectURL(imagePreviewUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { X, Loader } from "lucide-react";
|
|||||||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { safeGoBack } from "~/utils/helpers";
|
import { safeGoBack } from "~/utils/helpers";
|
||||||
|
import { convertHeicFiles } from "~/utils/convert-heic";
|
||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect } from "react";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { ImageUploadDrawer } from "~/components/store/ImageUploadDrawer";
|
import { ImageUploadDrawer } from "~/components/store/ImageUploadDrawer";
|
||||||
@ -709,11 +710,16 @@ export default function AddProduct() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle image selection
|
// Handle image selection
|
||||||
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageSelect = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
// Convert FileList to Array and process all files
|
// iPhone photos are HEIC, which most browsers can't preview/crop.
|
||||||
const newImages = Array.from(files).map((file) => ({
|
// 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,
|
file,
|
||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
isEdited: false,
|
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",
|
"cmdk": "^1.1.1",
|
||||||
"embla-carousel-react": "^8.5.2",
|
"embla-carousel-react": "^8.5.2",
|
||||||
"framer-motion": "^12.4.10",
|
"framer-motion": "^12.4.10",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
@ -8839,6 +8840,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/hoist-non-react-statics": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
"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",
|
"cmdk": "^1.1.1",
|
||||||
"embla-carousel-react": "^8.5.2",
|
"embla-carousel-react": "^8.5.2",
|
||||||
"framer-motion": "^12.4.10",
|
"framer-motion": "^12.4.10",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user