Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions packages/web/lib/components/vton/VtonModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,20 @@ export function VtonModal() {
});
if (!isCurrentRequest()) return;

// 브라우저가 실제로 디코드할 수 있는지 확인하고 나서 에디터를 연다.
// compressImage 의 catch 는 실패 시 **원본을 그대로 반환**하므로,
// 디코드 불가 포맷(예: 크롬 + HEIC)이 여기까지 조용히 흘러들어와
// 에디터가 에러 없이 새까맣게 뜬다. accept 로 피커를 좁혔지만
// 드래그앤드롭·OS 변환 실패는 그걸 우회하므로 이 가드가 최종 방어선.
await new Promise<void>((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;
Expand Down
16 changes: 13 additions & 3 deletions packages/web/lib/components/vton/VtonPhotoArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ interface VtonPhotoAreaProps {
}

type PhotoInputIntent = "camera" | "gallery" | "upload";

/** 피커에 보일 포맷.
*
* 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<VtonSharePlatform, "native">;
label: string;
Expand Down Expand Up @@ -254,22 +264,22 @@ export function VtonPhotoArea({
<input
ref={cameraInputRef}
type="file"
accept="image/*"
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
className="hidden"
onChange={handleInputChange}
/>
<input
ref={galleryInputRef}
type="file"
accept="image/*"
accept={ACCEPTED_IMAGE_TYPES}
className="hidden"
onChange={handleInputChange}
/>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
accept={ACCEPTED_IMAGE_TYPES}
className="hidden"
onChange={handleInputChange}
/>
Expand Down
79 changes: 66 additions & 13 deletions packages/web/lib/utils/imageCompression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,73 @@ export interface CompressionResult {
}

/**
* 이미지 압축
* - 2MB 이상인 경우 압축
* - 1920px 이상인 경우 리사이즈
* HEIC/HEIF 판별. iPhone 기본 포맷인데 브라우저(크롬/파이어폭스)와 서버 Rust
* `image` 크레이트 둘 다 디코드하지 못한다 → 어느 canvas/`<img>` 경로도 못 읽고,
* 서버의 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<File> {
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<CompressionResult> {
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,
};
}

Expand All @@ -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,
Expand All @@ -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,
};
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading