From c7b9596f7e2ef6bf057dfcec96ed006cbdfa2fdd Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Fri, 17 Jul 2026 23:47:56 +0900 Subject: [PATCH 1/2] fix(vton): reject undecodable photos loudly instead of a black editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker said accept="image/*" while the server takes only jpeg/png/webp, so an iPhone HEIC could be selected. Chrome can't decode HEIC, and compressImage's catch silently returns the *original* file on failure — so the HEIC reached the editor, the failed, and the user got a black canvas with no error at all. Reproduced with a real .heic. Two fixes, because either alone leaves a hole: - accept now lists exactly what the server accepts, matching what DecodeUploader already does. Desktop pickers hide .heic, and iOS Safari transcodes HEIC->JPEG when accept doesn't mention it. - a decode probe before opening the editor, since drag-and-drop and a failed OS transcode both bypass accept. Failure routes to the existing photoPrepareError instead of rendering nothing. Same silent-fallback shape as the R2 CORS bug earlier in this work: a catch that hides the failure and hands back something broken. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/lib/components/vton/VtonModal.tsx | 14 ++++++++++++++ .../web/lib/components/vton/VtonPhotoArea.tsx | 15 ++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/web/lib/components/vton/VtonModal.tsx b/packages/web/lib/components/vton/VtonModal.tsx index 72daf875..ba2c32d0 100644 --- a/packages/web/lib/components/vton/VtonModal.tsx +++ b/packages/web/lib/components/vton/VtonModal.tsx @@ -326,6 +326,20 @@ export function VtonModal() { }); if (!isCurrentRequest()) return; + // 브라우저가 실제로 디코드할 수 있는지 확인하고 나서 에디터를 연다. + // compressImage 의 catch 는 실패 시 **원본을 그대로 반환**하므로, + // 디코드 불가 포맷(예: 크롬 + HEIC)이 여기까지 조용히 흘러들어와 + // 에디터가 에러 없이 새까맣게 뜬다. accept 로 피커를 좁혔지만 + // 드래그앤드롭·OS 변환 실패는 그걸 우회하므로 이 가드가 최종 방어선. + await new Promise((resolve, reject) => { + const probe = new Image(); + probe.onload = () => resolve(); + probe.onerror = () => + reject(new Error(`undecodable image: ${file.type || "unknown"}`)); + probe.src = dataUrl; + }); + if (!isCurrentRequest()) return; + setPendingPhotoEdit(dataUrl); } catch { if (!isCurrentRequest()) return; diff --git a/packages/web/lib/components/vton/VtonPhotoArea.tsx b/packages/web/lib/components/vton/VtonPhotoArea.tsx index 7e4ddc8a..cbd86bda 100644 --- a/packages/web/lib/components/vton/VtonPhotoArea.tsx +++ b/packages/web/lib/components/vton/VtonPhotoArea.tsx @@ -46,6 +46,15 @@ interface VtonPhotoAreaProps { } type PhotoInputIntent = "camera" | "gallery" | "upload"; + +/** 서버 `decode_data_url_image`(api-server) 가 받는 것과 **정확히 같은 목록**. + * + * 예전엔 `image/*` 라 HEIC(아이폰 기본 포맷)를 고를 수 있었는데, 크롬은 HEIC 를 + * 디코드하지 못해서 에디터가 **에러 없이 새까맣게** 떴다(compressImage 의 catch + * 가 실패를 삼키고 원본 HEIC 를 그대로 돌려준다). 목록을 좁히면 데스크탑 + * 피커에서 아예 안 보이고, iOS Safari 는 accept 에 heic 가 없으면 업로드 시 + * JPEG 로 변환해준다. DecodeUploader 가 쓰는 것과 같은 목록. */ +const ACCEPTED_IMAGE_TYPES = "image/jpeg,image/png,image/webp"; const PLATFORM_SHARE_OPTIONS: Array<{ platform: Exclude; label: string; @@ -254,7 +263,7 @@ export function VtonPhotoArea({ From e6790e0874bc72b2abfc5ba7f03d3913b7b5c3c4 Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Sat, 18 Jul 2026 00:15:47 +0900 Subject: [PATCH 2/2] feat(vton): convert HEIC to JPEG on upload so iPhone photos just work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the reject-loudly fix in this branch. Rejecting HEIC is correct as a floor, but iPhone shoots HEIC by default — telling users their camera photos are unsupported is a bad answer when the conversion is cheap. compressImage now transcodes HEIC/HEIF to JPEG before anything else, so all six of its call sites benefit, not just VTON. The conversion is a dynamic import, so the ~libheif payload only downloads for users who actually upload a HEIC — normal jpeg/png uploads are unaffected. Two things a real .heic caught that a mock wouldn't have: - The conversion must run BEFORE the 2MB size gate. Sub-2MB HEICs skip compression entirely, so converting inside that branch would let small HEICs through untouched — exactly the black-editor bug, just for smaller files. - heic2any (the obvious pick) is abandoned at 0.0.4 and died with "ERR_LIBHEIF format not supported" on a real iOS 18 photo — that file is tmap/gain-map HEVC HEIC, which its stale bundled libheif can't read. Switched to heic-to (actively maintained, current libheif), which decodes it. Verified end to end in a browser: a real 2.5MB iPhone HEIC lands in the crop editor as a decoded image. Also revived COMPRESSION_CONFIG.fileType, dead since the monorepo flatten — compression output is now normalized to JPEG as the config always intended. accept lists HEIC again since the client converts it; the decode probe from the previous commit still backstops any conversion that fails. Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 3 + .../web/lib/components/vton/VtonPhotoArea.tsx | 15 ++-- packages/web/lib/utils/imageCompression.ts | 79 ++++++++++++++++--- packages/web/package.json | 1 + 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/bun.lock b/bun.lock index 4cd692b8..2f2aad72 100644 --- a/bun.lock +++ b/bun.lock @@ -94,6 +94,7 @@ "clsx": "^2.1.1", "google-auth-library": "^10.6.2", "gsap": "3.13.0", + "heic-to": "^1.5.2", "lenis": "^1.3.15", "lucide-react": "^0.577.0", "motion": "^12.23.12", @@ -2095,6 +2096,8 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], + "heic-to": ["heic-to@1.5.2", "", {}, "sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], diff --git a/packages/web/lib/components/vton/VtonPhotoArea.tsx b/packages/web/lib/components/vton/VtonPhotoArea.tsx index cbd86bda..ba656fbc 100644 --- a/packages/web/lib/components/vton/VtonPhotoArea.tsx +++ b/packages/web/lib/components/vton/VtonPhotoArea.tsx @@ -47,14 +47,15 @@ interface VtonPhotoAreaProps { type PhotoInputIntent = "camera" | "gallery" | "upload"; -/** 서버 `decode_data_url_image`(api-server) 가 받는 것과 **정확히 같은 목록**. +/** 피커에 보일 포맷. * - * 예전엔 `image/*` 라 HEIC(아이폰 기본 포맷)를 고를 수 있었는데, 크롬은 HEIC 를 - * 디코드하지 못해서 에디터가 **에러 없이 새까맣게** 떴다(compressImage 의 catch - * 가 실패를 삼키고 원본 HEIC 를 그대로 돌려준다). 목록을 좁히면 데스크탑 - * 피커에서 아예 안 보이고, iOS Safari 는 accept 에 heic 가 없으면 업로드 시 - * JPEG 로 변환해준다. DecodeUploader 가 쓰는 것과 같은 목록. */ -const ACCEPTED_IMAGE_TYPES = "image/jpeg,image/png,image/webp"; + * jpeg/png/webp 는 서버가 바로 받고, **HEIC(아이폰 기본)는 compressImage 가 + * 업로드 시점에 JPEG 로 변환**한다(heic2any, 동적 import). 그래서 HEIC 를 피커에 + * 다시 허용한다 — 아이폰 유저가 갤러리 사진을 그냥 고를 수 있어야 한다. 변환에 + * 실패하면 VtonModal 의 디코드 프로브가 잡아서 "준비 못함" 에러로 떨어진다 + * (에디터가 새까맣게 뜨지 않는다). */ +const ACCEPTED_IMAGE_TYPES = + "image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"; const PLATFORM_SHARE_OPTIONS: Array<{ platform: Exclude; label: string; diff --git a/packages/web/lib/utils/imageCompression.ts b/packages/web/lib/utils/imageCompression.ts index a198a538..b07044bd 100644 --- a/packages/web/lib/utils/imageCompression.ts +++ b/packages/web/lib/utils/imageCompression.ts @@ -21,25 +21,73 @@ export interface CompressionResult { } /** - * 이미지 압축 - * - 2MB 이상인 경우 압축 - * - 1920px 이상인 경우 리사이즈 + * HEIC/HEIF 판별. iPhone 기본 포맷인데 브라우저(크롬/파이어폭스)와 서버 Rust + * `image` 크레이트 둘 다 디코드하지 못한다 → 어느 canvas/`` 경로도 못 읽고, + * 서버의 webp 재인코딩(디코드가 전제)도 실패한다. 그래서 업로드 시점에 JPEG 로 + * 바꿔야 파이프라인 전체가 성립한다. + * + * 확장자 대신 MIME + 확장자를 함께 본다 — iOS 는 종종 `image/heic` 을 주지만 + * 데스크탑 드래그앤드롭은 빈 type 에 `.heic` 확장자만 오기도 한다. + */ +function isHeic(file: File): boolean { + const type = file.type.toLowerCase(); + if (type === "image/heic" || type === "image/heif") return true; + return /\.(heic|heif)$/i.test(file.name); +} + +/** + * HEIC → JPEG. **동적 import** 라 HEIC 를 실제로 올린 유저만 내려받는다 — 일반 + * 업로드 번들엔 영향 0. + * + * `heic-to` 를 쓴다(구 `heic2any@0.0.4` 아님). 후자는 수년간 방치돼 번들 libheif + * 가 낡아서, iOS 18+ 가 만드는 `tmap`(톤매핑/게인맵) HEVC HEIC 를 실제 + * 파일로 검증했을 때 `ERR_LIBHEIF format not supported` 로 죽었다. `heic-to` 는 + * 최신 libheif 빌드라 그 변형을 디코드한다. + * + * 실패하면 던진다(호출부가 "준비 못함"으로 처리) — 원본 HEIC 를 그대로 흘리면 + * 뒤에서 새까맣게 깨진다. + */ +async function convertHeicToJpeg(file: File): Promise { + const { heicTo } = await import("heic-to"); + const jpeg = await heicTo({ + blob: file, + type: "image/jpeg", + quality: 0.9, + }); + const name = file.name.replace(/\.(heic|heif)$/i, ".jpg"); + return new File([jpeg], name.endsWith(".jpg") ? name : `${name}.jpg`, { + type: "image/jpeg", + lastModified: file.lastModified, + }); +} + +/** + * 이미지 압축 (필요 시). HEIC 는 먼저 JPEG 로 변환, 2MB 초과면 리사이즈/압축. */ export async function compressImage( file: File, onProgress?: (progress: number) => void ): Promise { const originalSize = file.size; + + // HEIC 변환은 크기 검사 **앞**에 있어야 한다. 2MB 미만 HEIC 는 압축 분기를 + // 건너뛰므로, 여기서 안 바꾸면 변환 없이 그대로 통과해 뒤에서 깨진다. + let working = file; + if (isHeic(file)) { + working = await convertHeicToJpeg(file); + } + const needsCompression = - file.size > COMPRESSION_CONFIG.maxSizeMB * 1024 * 1024; + working.size > COMPRESSION_CONFIG.maxSizeMB * 1024 * 1024; if (!needsCompression) { onProgress?.(100); return { - file, + file: working, originalSize, - compressedSize: originalSize, - wasCompressed: false, + compressedSize: working.size, + // HEIC→JPEG 변환도 "처리됨"으로 친다 — 원본과 파일이 달라졌다. + wasCompressed: working !== file, }; } @@ -48,13 +96,17 @@ export async function compressImage( maxWidthOrHeight: COMPRESSION_CONFIG.maxWidthOrHeight, useWebWorker: COMPRESSION_CONFIG.useWebWorker, initialQuality: COMPRESSION_CONFIG.initialQuality, + // 죽어있던 설정을 살린다 — 압축 출력을 JPEG 로 통일(PNG 도 JPEG 로). + // 서버가 IMAGE_WEBP_ENABLED 로 최종 webp 재인코딩을 하지만, 그 전 단계 + // 입력을 일관된 포맷으로 두는 편이 예측 가능하다. + fileType: COMPRESSION_CONFIG.fileType, onProgress: (percent: number) => { onProgress?.(percent); }, }; try { - const compressedFile = await imageCompression(file, options); + const compressedFile = await imageCompression(working, options); onProgress?.(100); return { file: compressedFile, @@ -63,14 +115,15 @@ export async function compressImage( wasCompressed: true, }; } catch { - // 압축 실패 시 원본 반환 - console.warn("이미지 압축 실패, 원본 파일 사용:", file.name); + // 압축 실패 시 (변환된) 파일 반환. HEIC 변환은 이미 끝났으므로 여기로 와도 + // `working` 은 디코드 가능한 JPEG 다 — 원본 HEIC 로 되돌리지 않는다. + console.warn("이미지 압축 실패, 처리된 파일 사용:", working.name); onProgress?.(100); return { - file, + file: working, originalSize, - compressedSize: originalSize, - wasCompressed: false, + compressedSize: working.size, + wasCompressed: working !== file, }; } } diff --git a/packages/web/package.json b/packages/web/package.json index d8063a58..f4e5183e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -41,6 +41,7 @@ "clsx": "^2.1.1", "google-auth-library": "^10.6.2", "gsap": "3.13.0", + "heic-to": "^1.5.2", "lenis": "^1.3.15", "lucide-react": "^0.577.0", "motion": "^12.23.12",