다양한 루트로 얻은 소스를 짬뽕한거라 일관성은 없지만 대략 사용방법을 터득하기에는 괜찮을 것 같아서 정리해봄
최종 채택은 2번으로 했지만 나머지도 기록 삼아 남김
1. 클라이언트 - heic2any: webp 바로 전환은 안되서 png 거쳐서 변환. 갤럭시 울트라 200M 사진은 처리불가
'use client'
import { useState } from 'react'
export function FileUpload() {
const [file, setFile] = useState<any>()
// 파일 선택 시 처리
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const isHeic =
file.type === 'image/heic' ||
file.name.toLowerCase().endsWith('.heic') ||
file.name.toLowerCase().endsWith('.heif')
if (isHeic) {
try {
const heic2any = (await import('heic2any')).default
// 1. HEIC/HEIF 파일을 PNG Blob으로 변환
const pngBlob = await heic2any({
blob: file,
toType: 'image/png',
})
// 2. PNG Blob을 File 객체로 변환 및 WebP로 Canvas 변환
const convertedFile = await convertToWebP(pngBlob, file.name)
setFile(convertedFile)
} catch (error) {
console.error('### HEIC 변환 실패:', error)
alert('이미지 변환에 실패했습니다. 다른 이미지를 시도해주세요.')
}
} else {
setFile(file)
}
}
// Blob을 WebP 포맷 File 객체로 변환하는 함수
const convertToWebP = (blob: Blob | Blob[], originalName: string): Promise<File> => {
return new Promise((resolve, reject) => {
const img = new Image()
const url = URL.createObjectURL(Array.isArray(blob) ? blob[0] : blob)
img.onload = () => {
URL.revokeObjectURL(url)
const { width, height } = imageResize(img.width, img.height)
// Canvas 생성
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas context를 생성할 수 없습니다.')
ctx.drawImage(img, 0, 0)
// ctx.drawImage(img, 0, 0, targetWidth, targetHeight)
canvas.toBlob(
(webpBlob) => {
if (!webpBlob) throw new Error('WebP 변환에 실패했습니다.')
const newName = originalName.replace(/\.(heic|heif)$/i, '.webp')
const webpFile = new File([webpBlob], newName, { type: 'image/webp' })
resolve(webpFile)
},
'image/webp',
0.8,
)
}
img.onerror = (err) => reject(err)
img.src = url
})
}
// 폼 전송
const handleSubmit = async (e) => {
e.preventDefault()
if (!file) return alert('파일을 선택해주세요.')
const formData = new FormData()
formData.append('image', file)
}
return (
<form onSubmit={handleSubmit}>
<input type="file" accept="image/*" onChange={handleFileChange} />
<button type="submit">전송</button>
</form>
)
}
2. 클라이언트 - heic-decode: heic2any가 100%라면 60% 정도의 속도로 빠르게 처리하나 빌드 후 라이브러리 용량이 다소 나감. 갤럭시 울트라 200M 사진은 처리불가
'use client'
import { useState } from 'react'
export function FileUpload() {
const [loading, setLoading] = useState(false)
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setLoading(true)
let fileToSend: File | Blob = file
const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif')
if (isHeic) {
try {
const arrayBuffer = await file.arrayBuffer()
// 1. HEIC 내부의 원본 픽셀 데이터(RGBA) 추출
const decode = (await import('heic-decode')).default // 399.78 KB => 910.64 KB
const { width, height, data } = await decode({ buffer: new Uint8Array(arrayBuffer) })
const { width: targetWidth, height: targetHeight } = imageResize(width, height)
// 3. 임시 오프스크린 캔버스 생성하여 리사이징 처리
const canvas = document.createElement('canvas')
canvas.width = targetWidth
canvas.height = targetHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas context를 생성할 수 없습니다.')
// 원본 거대 데이터를 임시 ImageData 객체로 만듦
const originalImageData = new ImageData(new Uint8ClampedArray(data), width, height)
if (width === targetWidth && height === targetHeight) {
// 크기가 동일하면 그대로 그림
ctx.putImageData(originalImageData, 0, 0)
} else {
// 임시 캔버스를 거쳐 브라우저 하드웨어 가속을 이용해 부드럽게 축소(Downscale)합니다.
const tempCanvas = document.createElement('canvas')
tempCanvas.width = width
tempCanvas.height = height
tempCanvas.getContext('2d')?.putImageData(originalImageData, 0, 0)
// 최종 캔버스에 축소해서 그리기
ctx.drawImage(tempCanvas, 0, 0, targetWidth, targetHeight)
}
// 4. 🔥 [수정] 캔버스 내용을 WebP Blob으로 변환
const webpBlob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((blob) => resolve(blob), 'image/webp', 0.8)
})
if (!webpBlob) throw new Error('WebP 변환에 실패했습니다.')
// 5. 🔥 [수정] 서버 전송용 File 객체 확장자를 .webp로 재포장
const newFileName = file.name.replace(/\.(heic|heif)$/i, '.webp')
fileToSend = new File([webpBlob], newFileName, { type: 'image/webp' })
} catch (error) {
console.error('브라우저 내 초고해상도 변환 중 에러 발생:', error)
alert('이미지가 너무 커서 변환에 실패했습니다. 일반 JPG/PNG를 사용해주세요.')
setLoading(false)
return
}
}
// 6. Next.js Route Handler로 FormData 전송
try {
const formData = new FormData()
formData.append('file', fileToSend)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
return (
<>
<div>
<input type="file" accept=".heic,.heif" onChange={handleFileChange} disabled={loading} />
{loading && <p>이미지 최적화 및 변환 중... 잠시만 기다려주세요 (화면을 끄지 마세요)</p>}
</div>
</>
)
}
3. 클라이언트 - @imagemagick/magick-wasm: 갤럭시 울트라 200M 사진도 처리가 가능하나 속도가 압도적으로 느리다.
'use client'
import NextImage from 'next/image'
import { useState } from 'react'
async function initMagick() {
const { initializeImageMagick } = await import('@imagemagick/magick-wasm')
const response = await fetch('/magick.wasm') // public 경로. 해당 파일은 \node_modules\@imagemagick\magick-wasm\dist 안에 있음
const wasmBuffer = await response.arrayBuffer()
await initializeImageMagick(wasmBuffer)
}
initMagick()
export function FileUpload() {
const [outputUrl, setOutputUrl] = useState({ src: '', width: 0, height: 0 })
const handleConvert = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const { ImageMagick } = await import('@imagemagick/magick-wasm')
// 2. Read file as Uint8Array
const arrayBuffer = await file.arrayBuffer()
const data = new Uint8Array(arrayBuffer)
// 3. Perform transformation
ImageMagick.read(data, (image) => {
const { width, height } = imageResize(image.width, image.height)
image.resize(width, height)
image.format = 'WEBP'
// 4. Write back to a Blob for display
image.write((outputData) => {
const url = window.URL.createObjectURL(new Blob([new Uint8Array(outputData).buffer], { type: 'image/webp' }))
setOutputUrl({ src: url, width, height })
})
})
}
return (
<div>
<input type="file" onChange={handleConvert} />
{outputUrl.src && <NextImage {...outputUrl} alt="Converted" />}
</div>
)
}
function imageResize(width: number, height: number) {
const MAX_WIDTH = 4000
const MAX_HEIGHT = 4000
let targetWidth = width
let targetHeight = height
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
if (width > height) {
targetWidth = MAX_WIDTH
targetHeight = Math.round((height * MAX_WIDTH) / width)
} else {
targetHeight = MAX_HEIGHT
targetWidth = Math.round((width * MAX_HEIGHT) / height)
}
}
return { width: targetWidth, height: targetHeight }
}
4. 서버 - heic-convert, sharp: 갤럭시 울트라 200M 사진은 처리불가
import { NextRequest, NextResponse } from 'next/server';
import convert from 'heic-convert'
import sharp from 'sharp'
async function handler(req: NextRequest) {
// ...전략
const formdata = await req.formData();
const newFormData = new FormData()
for (const [key, value] of formdata.entries()) {
if (!value) {
newFormData.append(key, '')
} else if (value instanceof File === false) {
newFormData.append(key, value)
} else {
const file = value
const newFileName = file.name.split('.')
const ext = newFileName.pop()
if (!ext || ['heic', 'heif'].includes(ext.toLowerCase()) === false) {
newFormData.append(key, value)
} else {
const outputBuffer = await convert({
buffer: new Uint8Array(await file.arrayBuffer()) as unknown as ArrayBuffer,
format: 'PNG',
})
// 2. 변환된 버퍼를 sharp로 처리 (리사이징 등)
const webpBuffer = await sharp(outputBuffer).webp({ quality: 80 }).toBuffer()
// 3. Web API의 File 객체 형태로 만들어 FormData에 추가
const finalFile = new File([webpBuffer as any], `${newFileName}.webp`, { type: 'image/webp' })
newFormData.append(key, finalFile)
}
}
}
// ...후략
}
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const OPTIONS = handler;
공통함수
export function imageResize(width: number, height: number) {
const MAX_WIDTH = 4000
const MAX_HEIGHT = 4000
let targetWidth = width
let targetHeight = height
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
if (width > height) {
targetWidth = MAX_WIDTH
targetHeight = Math.round((height * MAX_WIDTH) / width)
} else {
targetHeight = MAX_HEIGHT
targetWidth = Math.round((width * MAX_HEIGHT) / height)
}
}
return { width: targetWidth, height: targetHeight }
}