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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user