증상: 사용자가 iPhone HEIC 사진을 관리함에서 업로드하면 400 Bad Request. 로그: ⚠️ pillow_heif 직접 처리 실패: Metadata not correctly assigned to image ❌ HEIF 처리도 실패: cannot identify image file 원인: pillow_heif 가 특정 iPhone 이 생성한 HEIC 의 메타데이터를 처리 못함. libheif 를 직접 사용하는 ImageMagick 이 더 범용적이라 system2-report/imageUploadService.js 와 동일한 패턴으로 fallback 추가. 변경: - Dockerfile: imagemagick + libheif1 apt-get 추가 + HEIC policy.xml 해제 - file_service.py: pillow_heif/PIL 실패 시 subprocess 로 magick/convert 호출해서 임시 파일로 JPEG 변환 후 다시 PIL 로 open Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
179 lines
8.0 KiB
Python
179 lines
8.0 KiB
Python
import os
|
|
import base64
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
import uuid
|
|
from PIL import Image
|
|
import io
|
|
|
|
# HEIF/HEIC 지원을 위한 라이브러리
|
|
try:
|
|
from pillow_heif import register_heif_opener
|
|
register_heif_opener()
|
|
HEIF_SUPPORTED = True
|
|
except ImportError:
|
|
HEIF_SUPPORTED = False
|
|
|
|
UPLOAD_DIR = "/app/uploads"
|
|
|
|
def ensure_upload_dir():
|
|
"""업로드 디렉토리 생성"""
|
|
if not os.path.exists(UPLOAD_DIR):
|
|
os.makedirs(UPLOAD_DIR)
|
|
|
|
def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str]:
|
|
"""Base64 이미지를 파일로 저장하고 경로 반환"""
|
|
try:
|
|
ensure_upload_dir()
|
|
|
|
# Base64 헤더 제거 및 정리
|
|
if "," in base64_string:
|
|
base64_string = base64_string.split(",")[1]
|
|
|
|
# Base64 문자열 정리 (공백, 개행 제거)
|
|
base64_string = base64_string.strip().replace('\n', '').replace('\r', '').replace(' ', '')
|
|
|
|
print(f"🔍 정리된 Base64 길이: {len(base64_string)}")
|
|
print(f"🔍 Base64 시작 20자: {base64_string[:20]}")
|
|
|
|
# 디코딩
|
|
try:
|
|
image_data = base64.b64decode(base64_string)
|
|
print(f"🔍 디코딩된 데이터 길이: {len(image_data)}")
|
|
print(f"🔍 바이너리 시작 20바이트: {image_data[:20]}")
|
|
except Exception as decode_error:
|
|
print(f"❌ Base64 디코딩 실패: {decode_error}")
|
|
raise decode_error
|
|
|
|
# 파일 시그니처 확인
|
|
file_signature = image_data[:20]
|
|
print(f"🔍 파일 시그니처 (hex): {file_signature.hex()}")
|
|
|
|
# HEIC 파일 시그니처 확인
|
|
is_heic = b'ftyp' in image_data[:20] and (b'heic' in image_data[:50] or b'mif1' in image_data[:50])
|
|
print(f"🔍 HEIC 파일 여부: {is_heic}")
|
|
|
|
# 이미지 검증 및 형식 확인
|
|
image = None
|
|
try:
|
|
# HEIC 파일인 경우 pillow_heif를 직접 사용하여 처리
|
|
if is_heic and HEIF_SUPPORTED:
|
|
print("🔄 HEIC 파일 감지, pillow_heif로 직접 처리...")
|
|
try:
|
|
import pillow_heif
|
|
heif_file = pillow_heif.open_heif(io.BytesIO(image_data), convert_hdr_to_8bit=False)
|
|
image = heif_file.to_pillow()
|
|
print(f"✅ HEIC -> PIL 변환 성공: 모드: {image.mode}, 크기: {image.size}")
|
|
except Exception as heic_error:
|
|
print(f"⚠️ pillow_heif 직접 처리 실패: {heic_error}")
|
|
# PIL Image.open()으로 재시도
|
|
image = Image.open(io.BytesIO(image_data))
|
|
print(f"✅ PIL Image.open() 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}")
|
|
else:
|
|
# 일반 이미지 처리
|
|
image = Image.open(io.BytesIO(image_data))
|
|
print(f"🔍 이미지 형식: {image.format}, 모드: {image.mode}, 크기: {image.size}")
|
|
except Exception as e:
|
|
print(f"❌ 이미지 열기 실패: {e}")
|
|
|
|
# HEIF 재시도
|
|
if HEIF_SUPPORTED:
|
|
print("🔄 HEIF 형식으로 재시도...")
|
|
try:
|
|
image = Image.open(io.BytesIO(image_data))
|
|
print(f"✅ HEIF 재시도 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}")
|
|
except Exception as heif_e:
|
|
print(f"❌ HEIF 처리도 실패: {heif_e}")
|
|
image = None # ImageMagick fallback 시도
|
|
else:
|
|
print("❌ HEIF 지원 라이브러리가 설치되지 않거나 처리 불가")
|
|
|
|
# ImageMagick fallback (pillow_heif 가 특정 iPhone HEIC 메타데이터를 처리 못하는 경우)
|
|
if image is None:
|
|
print("🔄 ImageMagick 으로 fallback 시도...")
|
|
try:
|
|
import subprocess
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile(suffix='.bin', delete=False, dir='/tmp') as tmp_in:
|
|
tmp_in.write(image_data)
|
|
tmp_in_path = tmp_in.name
|
|
tmp_out_path = tmp_in_path + '.jpg'
|
|
# convert 는 ImageMagick 6, magick 은 ImageMagick 7
|
|
cmd = None
|
|
for binary in ('magick', 'convert'):
|
|
try:
|
|
subprocess.run([binary, '-version'], capture_output=True, timeout=5, check=True)
|
|
cmd = binary
|
|
break
|
|
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
continue
|
|
if not cmd:
|
|
raise Exception("ImageMagick not installed")
|
|
result = subprocess.run(
|
|
[cmd, tmp_in_path, tmp_out_path],
|
|
capture_output=True, timeout=30, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
raise Exception(f"ImageMagick 변환 실패: {result.stderr[:200]}")
|
|
image = Image.open(tmp_out_path)
|
|
image.load() # 파일 삭제 전 강제 로드
|
|
print(f"✅ ImageMagick fallback 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}")
|
|
try:
|
|
os.unlink(tmp_in_path)
|
|
os.unlink(tmp_out_path)
|
|
except OSError:
|
|
pass
|
|
except Exception as magick_e:
|
|
print(f"❌ ImageMagick fallback 실패: {magick_e}")
|
|
raise e # 원래 PIL 에러를 올림
|
|
|
|
# 이미지가 성공적으로 로드되지 않은 경우
|
|
if image is None:
|
|
raise Exception("이미지 로드 실패")
|
|
|
|
# iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환
|
|
# RGB 모드로 변환 (RGBA, P 모드 등을 처리)
|
|
if image.mode in ('RGBA', 'LA', 'P'):
|
|
# 투명도가 있는 이미지는 흰 배경과 합성
|
|
background = Image.new('RGB', image.size, (255, 255, 255))
|
|
if image.mode == 'P':
|
|
image = image.convert('RGBA')
|
|
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
|
|
image = background
|
|
elif image.mode != 'RGB':
|
|
image = image.convert('RGB')
|
|
|
|
# 파일명 생성 (prefix 포함)
|
|
filename = f"{prefix}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.jpg"
|
|
filepath = os.path.join(UPLOAD_DIR, filename)
|
|
|
|
# 이미지 저장 (최대 크기 제한)
|
|
max_size = (1920, 1920)
|
|
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
|
|
# 항상 JPEG로 저장
|
|
image.save(filepath, 'JPEG', quality=85, optimize=True)
|
|
|
|
# 웹 경로 반환
|
|
return f"/uploads/{filename}"
|
|
|
|
except Exception as e:
|
|
print(f"이미지 저장 실패: {e}")
|
|
# ⚠ silent failure 금지 — 저장 실패는 반드시 상위로 전파해서
|
|
# system2/클라이언트가 "성공"으로 오인하지 않도록 한다.
|
|
# (2026-04-09: named volume 권한 이슈로 사진 유실 사고 재발 방지)
|
|
raise RuntimeError(f"Image save failed: {e}") from e
|
|
|
|
def delete_file(filepath: str):
|
|
"""파일 삭제"""
|
|
try:
|
|
if filepath and filepath.startswith("/uploads/"):
|
|
filename = filepath.replace("/uploads/", "")
|
|
full_path = os.path.normpath(os.path.join(UPLOAD_DIR, filename))
|
|
if not full_path.startswith(os.path.normpath(UPLOAD_DIR)):
|
|
raise ValueError("잘못된 파일 경로")
|
|
if os.path.exists(full_path):
|
|
os.remove(full_path)
|
|
except Exception as e:
|
|
print(f"파일 삭제 실패: {e}")
|