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']
}
# Limits per Q&A and ADR-058
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
# Limits per Q&A and ADR-058 (updated in v1.4.0)
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
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
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]:
"""
Validate image file
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:
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
"""
# Check file size first (before loading)
if len(file_data) > MAX_FILE_SIZE:
raise ValueError(f"File too large. Maximum size is 10MB")
file_size = len(file_data)
if file_size > MAX_FILE_SIZE:
raise ValueError("File too large. Maximum size is 50MB")
# Try to open with Pillow (validates integrity)
try:
@@ -82,48 +109,107 @@ def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]:
if max(width, height) > MAX_DIMENSION:
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
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:
- Auto-resize if >2048px (maintaining aspect ratio)
- Correct EXIF orientation
- 95% quality
Per Q12: Preserve GIF animation during resize
Per v1.4.0:
- Tiered resize strategy based on input size
- Iterative quality reduction if needed
- Target output <=10MB
Args:
image_data: Raw image bytes
original_size: Original file size (for tiered optimization)
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))
# 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
# Get original dimensions
width, height = img.size
# Resize if needed (per ADR-058: >2048px gets resized)
if max(width, height) > RESIZE_DIMENSION:
# For GIFs, we need special handling to preserve animation
# For animated GIFs, return as-is (already validated in validate_image)
if img.format == 'GIF' and getattr(img, 'is_animated', False):
# For animated GIFs, just return original
# Per Q12: Preserve GIF animation
# Note: Resizing animated GIFs is complex, skip for v1.2.0
return img, width, height
else:
# Calculate new size maintaining aspect ratio
img.thumbnail((RESIZE_DIMENSION, RESIZE_DIMENSION), Image.Resampling.LANCZOS)
width, height = img.size
# Already checked size in validate_image, just return original
return img, img.size[0], img.size[1], image_data
return img, width, height
# Iterative optimization loop
while True:
# Create copy for this iteration
work_img = img.copy()
# Resize if needed
if max(work_img.size) > max_dim:
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:
# Already at min quality, reduce dimensions
max_dim = int(max_dim * 0.8)
quality = 85 # Reset quality for new dimension
# 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:
@@ -133,6 +219,7 @@ def save_media(file_data: bytes, filename: str) -> Dict:
Per Q5: UUID-based filename to avoid collisions
Per Q2: Date-organized path: /media/YYYY/MM/uuid.ext
Per Q6: Validate, optimize, then save
Per v1.4.0: Size-aware optimization with iterative quality reduction
Args:
file_data: Raw file bytes
@@ -146,11 +233,14 @@ def save_media(file_data: bytes, filename: str) -> Dict:
"""
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)
# Optimize image
optimized_img, width, height = optimize_image(file_data)
# Compute file size for optimization strategy
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)
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.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.write_bytes(optimized_bytes)
# Determine save format and quality
save_format = optimized_img.format or 'PNG'
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
# Get actual file size (from optimized bytes)
actual_size = len(optimized_bytes)
# Insert into database
db = get_db(current_app)

View File

@@ -119,39 +119,44 @@ class TestImageOptimization:
def test_no_resize_needed(self):
"""Test image within limits is not resized"""
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 height == 768
assert optimized_bytes is not None
assert len(optimized_bytes) > 0
def test_resize_large_image(self):
"""Test auto-resize of >2048px image (per ADR-058)"""
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
assert width == RESIZE_DIMENSION
# Height should be proportionally scaled
assert height == int(2000 * (RESIZE_DIMENSION / 3000))
assert optimized_bytes is not None
def test_aspect_ratio_preserved(self):
"""Test aspect ratio is maintained during resize"""
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
# After resize: should still be 2:1
assert width / height == pytest.approx(2.0, rel=0.01)
assert optimized_bytes is not None
def test_gif_animation_preserved(self):
"""Test GIF animation preservation (per Q12)"""
# For v1.2.0: Just verify GIF is handled without error
# Full animation preservation is complex
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 height > 0
assert optimized_bytes is not None
class TestMediaSave: