feat(media): Add HEIC/HEIF image support - v1.4.2

- Add pillow-heif dependency for iPhone photo support
- Auto-convert HEIC to JPEG (browsers can't display HEIC)
- Graceful error if pillow-heif not installed
- Handles RGBA/P mode conversion to RGB

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-16 17:45:53 -07:00
parent 07f351fef7
commit e4e481d7cf
7 changed files with 569 additions and 13 deletions

View File

@@ -19,6 +19,14 @@ import io
from typing import Optional, List, Dict, Tuple
from flask import current_app
# HEIC/HEIF support - import registers with Pillow automatically
try:
import pillow_heif
pillow_heif.register_heif_opener()
HEIC_SUPPORTED = True
except ImportError:
HEIC_SUPPORTED = False
# Allowed MIME types per Q11
ALLOWED_MIME_TYPES = {
'image/jpeg': ['.jpg', '.jpeg'],
@@ -68,19 +76,21 @@ def get_optimization_params(file_size: int) -> Tuple[int, int]:
return (1280, 85)
def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]:
def validate_image(file_data: bytes, filename: str) -> Tuple[bytes, str, int, int]:
"""
Validate image file
Per Q11: Validate MIME type using Pillow
Per Q6: Reject if >50MB or >4096px (updated v1.4.0)
Per v1.4.2: Convert HEIC to JPEG (browsers cannot display HEIC)
Args:
file_data: Raw file bytes
filename: Original filename
Returns:
Tuple of (mime_type, width, height)
Tuple of (file_data, mime_type, width, height)
Note: file_data may be converted (e.g., HEIC to JPEG)
Raises:
ValueError: If file is invalid
@@ -100,6 +110,25 @@ def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]:
except Exception as e:
raise ValueError(f"Invalid or corrupted image: {e}")
# HEIC/HEIF conversion (v1.4.2)
# HEIC cannot be displayed in browsers, convert to JPEG
if img.format in ('HEIF', 'HEIC'):
if not HEIC_SUPPORTED:
raise ValueError(
"HEIC/HEIF images require pillow-heif library. "
"Please convert to JPEG before uploading."
)
# Convert HEIC to JPEG in memory
output = io.BytesIO()
# Convert to RGB if needed (HEIC may have alpha channel)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
img.save(output, format='JPEG', quality=95)
output.seek(0)
# Re-open as JPEG for further processing
file_data = output.getvalue()
img = Image.open(io.BytesIO(file_data))
# Check format is allowed
if img.format:
format_lower = img.format.lower()
@@ -135,7 +164,7 @@ def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]:
# Not animated, continue normally
pass
return mime_type, width, height
return file_data, mime_type, width, height
def optimize_image(image_data: bytes, original_size: int = None) -> Tuple[Image.Image, int, int, bytes]:
@@ -395,9 +424,9 @@ def save_media(file_data: bytes, filename: str) -> Dict:
file_size = len(file_data)
try:
# Validate image (returns 3-tuple, signature unchanged)
# Validate image (returns 4-tuple with potentially converted bytes)
try:
mime_type, orig_width, orig_height = validate_image(file_data, filename)
file_data, mime_type, orig_width, orig_height = validate_image(file_data, filename)
except ValueError as e:
current_app.logger.warning(
f'Media upload validation failed: filename="{filename}", '