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

@@ -21,6 +21,7 @@ from starpunk.media import (
MAX_DIMENSION,
RESIZE_DIMENSION,
MAX_IMAGES_PER_NOTE,
HEIC_SUPPORTED,
)
@@ -45,44 +46,75 @@ def create_test_image(width=800, height=600, format='PNG'):
return buffer.getvalue()
def create_test_heic(width=800, height=600):
"""
Generate test HEIC image using pillow-heif
Args:
width: Image width in pixels
height: Image height in pixels
Returns:
Bytes of HEIC image data
"""
if not HEIC_SUPPORTED:
pytest.skip("pillow-heif not available")
import pillow_heif
# Create a simple RGB image
img = Image.new('RGB', (width, height), color='blue')
# Convert to HEIC
buffer = io.BytesIO()
heif_file = pillow_heif.from_pillow(img)
heif_file.save(buffer, format='HEIF')
buffer.seek(0)
return buffer.getvalue()
class TestImageValidation:
"""Test validate_image function"""
def test_valid_jpeg(self):
"""Test validation of valid JPEG image"""
image_data = create_test_image(800, 600, 'JPEG')
mime_type, width, height = validate_image(image_data, 'test.jpg')
file_data, mime_type, width, height = validate_image(image_data, 'test.jpg')
assert mime_type == 'image/jpeg'
assert width == 800
assert height == 600
assert file_data == image_data # No conversion
def test_valid_png(self):
"""Test validation of valid PNG image"""
image_data = create_test_image(800, 600, 'PNG')
mime_type, width, height = validate_image(image_data, 'test.png')
file_data, mime_type, width, height = validate_image(image_data, 'test.png')
assert mime_type == 'image/png'
assert width == 800
assert height == 600
assert file_data == image_data # No conversion
def test_valid_gif(self):
"""Test validation of valid GIF image"""
image_data = create_test_image(800, 600, 'GIF')
mime_type, width, height = validate_image(image_data, 'test.gif')
file_data, mime_type, width, height = validate_image(image_data, 'test.gif')
assert mime_type == 'image/gif'
assert width == 800
assert height == 600
assert file_data == image_data # No conversion
def test_valid_webp(self):
"""Test validation of valid WebP image"""
image_data = create_test_image(800, 600, 'WEBP')
mime_type, width, height = validate_image(image_data, 'test.webp')
file_data, mime_type, width, height = validate_image(image_data, 'test.webp')
assert mime_type == 'image/webp'
assert width == 800
assert height == 600
assert file_data == image_data # No conversion
def test_file_too_large(self):
"""Test rejection of >10MB file (per Q6)"""
@@ -113,6 +145,96 @@ class TestImageValidation:
assert "Invalid or corrupted" in str(exc_info.value)
class TestHEICSupport:
"""Test HEIC/HEIF image format support (v1.4.2)"""
def test_heic_detection_and_conversion(self):
"""Test HEIC file is detected and converted to JPEG"""
heic_data = create_test_heic(800, 600)
file_data, mime_type, width, height = validate_image(heic_data, 'test.heic')
# Should be converted to JPEG
assert mime_type == 'image/jpeg'
assert width == 800
assert height == 600
# file_data should be different (converted to JPEG)
assert file_data != heic_data
# Verify it's actually JPEG by opening it
img = Image.open(io.BytesIO(file_data))
assert img.format == 'JPEG'
def test_heic_with_rgba_mode(self):
"""Test HEIC with alpha channel is converted to RGB JPEG"""
if not HEIC_SUPPORTED:
pytest.skip("pillow-heif not available")
import pillow_heif
# Create image with alpha channel
img = Image.new('RGBA', (800, 600), color=(255, 0, 0, 128))
buffer = io.BytesIO()
heif_file = pillow_heif.from_pillow(img)
heif_file.save(buffer, format='HEIF')
buffer.seek(0)
heic_data = buffer.getvalue()
file_data, mime_type, width, height = validate_image(heic_data, 'test.heic')
# Should be converted to JPEG (no alpha)
assert mime_type == 'image/jpeg'
# Verify it's RGB (no alpha)
img = Image.open(io.BytesIO(file_data))
assert img.mode == 'RGB'
def test_heic_dimensions_preserved(self):
"""Test HEIC conversion preserves dimensions"""
heic_data = create_test_heic(1024, 768)
file_data, mime_type, width, height = validate_image(heic_data, 'photo.heic')
assert width == 1024
assert height == 768
assert mime_type == 'image/jpeg'
def test_heic_error_without_library(self, monkeypatch):
"""Test appropriate error when HEIC uploaded but pillow-heif not available"""
# Mock HEIC_SUPPORTED to False
from starpunk import media
monkeypatch.setattr(media, 'HEIC_SUPPORTED', False)
# Create a mock HEIC file (just needs to be recognized as HEIC by Pillow)
# We'll create a real HEIC if library is available, otherwise skip
if not HEIC_SUPPORTED:
pytest.skip("pillow-heif not available to create test HEIC")
heic_data = create_test_heic(800, 600)
with pytest.raises(ValueError) as exc_info:
validate_image(heic_data, 'test.heic')
assert "HEIC/HEIF images require pillow-heif library" in str(exc_info.value)
assert "convert to JPEG" in str(exc_info.value)
def test_heic_full_upload_flow(self, app):
"""Test complete HEIC upload through save_media"""
heic_data = create_test_heic(800, 600)
with app.app_context():
media_info = save_media(heic_data, 'iphone_photo.heic')
# Should be saved as JPEG
assert media_info['mime_type'] == 'image/jpeg'
assert media_info['width'] == 800
assert media_info['height'] == 600
# Verify file was saved
media_path = Path(app.config['DATA_PATH']) / 'media' / media_info['path']
assert media_path.exists()
# Verify it's actually a JPEG file
saved_img = Image.open(media_path)
assert saved_img.format == 'JPEG'
class TestImageOptimization:
"""Test optimize_image function"""