feat: Implement v1.4.0 Phase 1 - Large Image Support

Implement tiered resize strategy for large images per v1.4.0 design:

Changes:
- Increase MAX_FILE_SIZE from 10MB to 50MB
- Add MAX_OUTPUT_SIZE constant (10MB target after optimization)
- Add MIN_QUALITY and MIN_DIMENSION constants
- Add get_optimization_params() for tiered strategy:
  - <=10MB: 2048px max, 95% quality
  - 10-25MB: 1600px max, 90% quality
  - 25-50MB: 1280px max, 85% quality
- Update optimize_image() signature to return 4-tuple (img, w, h, bytes)
- Implement iterative quality reduction if output >10MB
- Add animated GIF detection and size check in validate_image()
- Update save_media() to use new optimize_image() return value
- Fix GIF format preservation during optimization
- Update tests to match new optimize_image() signature

All existing tests pass. Ready for Phase 2 (Image Variants).

Following design in:
/home/phil/Projects/starpunk/docs/design/v1.4.0/media-implementation-design.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-10 17:57:44 -07:00
parent 5ea9c8f330
commit 1b51c82656
2 changed files with 136 additions and 53 deletions

View File

@@ -25,19 +25,45 @@ ALLOWED_MIME_TYPES = {
'image/webp': ['.webp'] 'image/webp': ['.webp']
} }
# Limits per Q&A and ADR-058 # Limits per Q&A and ADR-058 (updated in v1.4.0)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB (v1.4.0)
MAX_OUTPUT_SIZE = 10 * 1024 * 1024 # 10MB target after optimization (v1.4.0)
MAX_DIMENSION = 4096 # 4096x4096 max MAX_DIMENSION = 4096 # 4096x4096 max
RESIZE_DIMENSION = 2048 # Auto-resize to 2048px RESIZE_DIMENSION = 2048 # Auto-resize to 2048px (default)
MIN_QUALITY = 70 # Minimum JPEG quality before rejection (v1.4.0)
MIN_DIMENSION = 640 # Minimum dimension before rejection (v1.4.0)
MAX_IMAGES_PER_NOTE = 4 MAX_IMAGES_PER_NOTE = 4
def get_optimization_params(file_size: int) -> Tuple[int, int]:
"""
Determine optimization parameters based on input file size
Per v1.4.0 tiered resize strategy:
- <=10MB: 2048px max, 95% quality
- 10-25MB: 1600px max, 90% quality
- 25-50MB: 1280px max, 85% quality
Args:
file_size: Original file size in bytes
Returns:
Tuple of (max_dimension, quality_percent)
"""
if file_size <= 10 * 1024 * 1024: # <=10MB
return (2048, 95)
elif file_size <= 25 * 1024 * 1024: # 10-25MB
return (1600, 90)
else: # 25-50MB
return (1280, 85)
def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]: def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]:
""" """
Validate image file Validate image file
Per Q11: Validate MIME type using Pillow Per Q11: Validate MIME type using Pillow
Per Q6: Reject if >10MB or >4096px Per Q6: Reject if >50MB or >4096px (updated v1.4.0)
Args: Args:
file_data: Raw file bytes file_data: Raw file bytes
@@ -50,8 +76,9 @@ def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]:
ValueError: If file is invalid ValueError: If file is invalid
""" """
# Check file size first (before loading) # Check file size first (before loading)
if len(file_data) > MAX_FILE_SIZE: file_size = len(file_data)
raise ValueError(f"File too large. Maximum size is 10MB") if file_size > MAX_FILE_SIZE:
raise ValueError("File too large. Maximum size is 50MB")
# Try to open with Pillow (validates integrity) # Try to open with Pillow (validates integrity)
try: try:
@@ -82,48 +109,107 @@ def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]:
if max(width, height) > MAX_DIMENSION: if max(width, height) > MAX_DIMENSION:
raise ValueError(f"Image dimensions too large. Maximum is {MAX_DIMENSION}x{MAX_DIMENSION} pixels") raise ValueError(f"Image dimensions too large. Maximum is {MAX_DIMENSION}x{MAX_DIMENSION} pixels")
# Check for animated GIF (v1.4.0)
# Animated GIFs cannot be resized, so reject if >10MB
if img.format == 'GIF':
try:
img.seek(1) # Try to seek to second frame
# If successful, it's animated
if file_size > MAX_OUTPUT_SIZE:
raise ValueError(
"Animated GIF too large. Maximum size for animated GIFs is 10MB. "
"Consider using a shorter clip or lower resolution."
)
img.seek(0) # Reset to first frame
except EOFError:
# Not animated, continue normally
pass
return mime_type, width, height return mime_type, width, height
def optimize_image(image_data: bytes) -> Tuple[Image.Image, int, int]: def optimize_image(image_data: bytes, original_size: int = None) -> Tuple[Image.Image, int, int, bytes]:
""" """
Optimize image for web display Optimize image for web display with size-aware strategy
Per ADR-058: Per v1.4.0:
- Auto-resize if >2048px (maintaining aspect ratio) - Tiered resize strategy based on input size
- Correct EXIF orientation - Iterative quality reduction if needed
- 95% quality - Target output <=10MB
Per Q12: Preserve GIF animation during resize
Args: Args:
image_data: Raw image bytes image_data: Raw image bytes
original_size: Original file size (for tiered optimization)
Returns: Returns:
Tuple of (optimized_image, width, height) Tuple of (optimized_image, width, height, optimized_bytes)
Raises:
ValueError: If image cannot be optimized to target size
""" """
if original_size is None:
original_size = len(image_data)
# Get initial optimization parameters based on input size
max_dim, quality = get_optimization_params(original_size)
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
# Correct EXIF orientation (per ADR-058) # Save original format before any processing (copy() loses this)
original_format = img.format
# Correct EXIF orientation (per ADR-058), except for GIFs
img = ImageOps.exif_transpose(img) if img.format != 'GIF' else img img = ImageOps.exif_transpose(img) if img.format != 'GIF' else img
# Get original dimensions # For animated GIFs, return as-is (already validated in validate_image)
width, height = img.size if img.format == 'GIF' and getattr(img, 'is_animated', False):
# Already checked size in validate_image, just return original
return img, img.size[0], img.size[1], image_data
# Resize if needed (per ADR-058: >2048px gets resized) # Iterative optimization loop
if max(width, height) > RESIZE_DIMENSION: while True:
# For GIFs, we need special handling to preserve animation # Create copy for this iteration
if img.format == 'GIF' and getattr(img, 'is_animated', False): work_img = img.copy()
# For animated GIFs, just return original
# Per Q12: Preserve GIF animation # Resize if needed
# Note: Resizing animated GIFs is complex, skip for v1.2.0 if max(work_img.size) > max_dim:
return img, width, height work_img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)
# Save to bytes to check size
output = io.BytesIO()
# Use original format (copy() loses the format attribute)
save_format = original_format or 'JPEG'
save_kwargs = {'optimize': True}
if save_format in ['JPEG', 'JPG']:
save_kwargs['quality'] = quality
elif save_format == 'WEBP':
save_kwargs['quality'] = quality
# For GIF and PNG, just use optimize flag
work_img.save(output, format=save_format, **save_kwargs)
output_bytes = output.getvalue()
# Check output size
if len(output_bytes) <= MAX_OUTPUT_SIZE:
width, height = work_img.size
return work_img, width, height, output_bytes
# Need to reduce further
if quality > MIN_QUALITY:
# Reduce quality first
quality -= 5
else: else:
# Calculate new size maintaining aspect ratio # Already at min quality, reduce dimensions
img.thumbnail((RESIZE_DIMENSION, RESIZE_DIMENSION), Image.Resampling.LANCZOS) max_dim = int(max_dim * 0.8)
width, height = img.size quality = 85 # Reset quality for new dimension
return img, width, height # Safety check: minimum dimension
if max_dim < MIN_DIMENSION:
raise ValueError(
"Image cannot be optimized to target size. "
"Please use a smaller or lower-resolution image."
)
def save_media(file_data: bytes, filename: str) -> Dict: def save_media(file_data: bytes, filename: str) -> Dict:
@@ -133,6 +219,7 @@ def save_media(file_data: bytes, filename: str) -> Dict:
Per Q5: UUID-based filename to avoid collisions Per Q5: UUID-based filename to avoid collisions
Per Q2: Date-organized path: /media/YYYY/MM/uuid.ext Per Q2: Date-organized path: /media/YYYY/MM/uuid.ext
Per Q6: Validate, optimize, then save Per Q6: Validate, optimize, then save
Per v1.4.0: Size-aware optimization with iterative quality reduction
Args: Args:
file_data: Raw file bytes file_data: Raw file bytes
@@ -146,11 +233,14 @@ def save_media(file_data: bytes, filename: str) -> Dict:
""" """
from starpunk.database import get_db from starpunk.database import get_db
# Validate image # Validate image (returns 3-tuple, signature unchanged)
mime_type, orig_width, orig_height = validate_image(file_data, filename) mime_type, orig_width, orig_height = validate_image(file_data, filename)
# Optimize image # Compute file size for optimization strategy
optimized_img, width, height = optimize_image(file_data) file_size = len(file_data)
# Optimize image with size-aware strategy (now returns 4-tuple with bytes)
optimized_img, width, height, optimized_bytes = optimize_image(file_data, file_size)
# Generate UUID-based filename (per Q5) # Generate UUID-based filename (per Q5)
file_ext = Path(filename).suffix.lower() file_ext = Path(filename).suffix.lower()
@@ -174,24 +264,12 @@ def save_media(file_data: bytes, filename: str) -> Dict:
full_dir = media_dir / year / month full_dir = media_dir / year / month
full_dir.mkdir(parents=True, exist_ok=True) full_dir.mkdir(parents=True, exist_ok=True)
# Save optimized image # Save optimized image (using bytes from optimize_image to avoid re-encoding)
full_path = full_dir / stored_filename full_path = full_dir / stored_filename
full_path.write_bytes(optimized_bytes)
# Determine save format and quality # Get actual file size (from optimized bytes)
save_format = optimized_img.format or 'PNG' actual_size = len(optimized_bytes)
save_kwargs = {'optimize': True}
if save_format in ['JPEG', 'JPG']:
save_kwargs['quality'] = 95 # Per ADR-058
elif save_format == 'PNG':
save_kwargs['optimize'] = True
elif save_format == 'WEBP':
save_kwargs['quality'] = 95
optimized_img.save(full_path, format=save_format, **save_kwargs)
# Get actual file size after optimization
actual_size = full_path.stat().st_size
# Insert into database # Insert into database
db = get_db(current_app) db = get_db(current_app)

View File

@@ -119,39 +119,44 @@ class TestImageOptimization:
def test_no_resize_needed(self): def test_no_resize_needed(self):
"""Test image within limits is not resized""" """Test image within limits is not resized"""
image_data = create_test_image(1024, 768, 'PNG') image_data = create_test_image(1024, 768, 'PNG')
optimized, width, height = optimize_image(image_data) optimized, width, height, optimized_bytes = optimize_image(image_data)
assert width == 1024 assert width == 1024
assert height == 768 assert height == 768
assert optimized_bytes is not None
assert len(optimized_bytes) > 0
def test_resize_large_image(self): def test_resize_large_image(self):
"""Test auto-resize of >2048px image (per ADR-058)""" """Test auto-resize of >2048px image (per ADR-058)"""
large_image = create_test_image(3000, 2000, 'PNG') large_image = create_test_image(3000, 2000, 'PNG')
optimized, width, height = optimize_image(large_image) optimized, width, height, optimized_bytes = optimize_image(large_image)
# Should be resized to 2048px on longest edge # Should be resized to 2048px on longest edge
assert width == RESIZE_DIMENSION assert width == RESIZE_DIMENSION
# Height should be proportionally scaled # Height should be proportionally scaled
assert height == int(2000 * (RESIZE_DIMENSION / 3000)) assert height == int(2000 * (RESIZE_DIMENSION / 3000))
assert optimized_bytes is not None
def test_aspect_ratio_preserved(self): def test_aspect_ratio_preserved(self):
"""Test aspect ratio is maintained during resize""" """Test aspect ratio is maintained during resize"""
image_data = create_test_image(3000, 1500, 'PNG') image_data = create_test_image(3000, 1500, 'PNG')
optimized, width, height = optimize_image(image_data) optimized, width, height, optimized_bytes = optimize_image(image_data)
# Original aspect ratio: 2:1 # Original aspect ratio: 2:1
# After resize: should still be 2:1 # After resize: should still be 2:1
assert width / height == pytest.approx(2.0, rel=0.01) assert width / height == pytest.approx(2.0, rel=0.01)
assert optimized_bytes is not None
def test_gif_animation_preserved(self): def test_gif_animation_preserved(self):
"""Test GIF animation preservation (per Q12)""" """Test GIF animation preservation (per Q12)"""
# For v1.2.0: Just verify GIF is handled without error # For v1.2.0: Just verify GIF is handled without error
# Full animation preservation is complex # Full animation preservation is complex
gif_data = create_test_image(800, 600, 'GIF') gif_data = create_test_image(800, 600, 'GIF')
optimized, width, height = optimize_image(gif_data) optimized, width, height, optimized_bytes = optimize_image(gif_data)
assert width > 0 assert width > 0
assert height > 0 assert height > 0
assert optimized_bytes is not None
class TestMediaSave: class TestMediaSave: