diff --git a/starpunk/media.py b/starpunk/media.py index 2dc01bc..520eddb 100644 --- a/starpunk/media.py +++ b/starpunk/media.py @@ -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 + # For animated GIFs, return as-is (already validated in validate_image) + 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) - if max(width, height) > RESIZE_DIMENSION: - # For GIFs, we need special handling to preserve animation - 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 + # 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: - # Calculate new size maintaining aspect ratio - img.thumbnail((RESIZE_DIMENSION, RESIZE_DIMENSION), Image.Resampling.LANCZOS) - width, height = img.size + # Already at min quality, reduce dimensions + max_dim = int(max_dim * 0.8) + 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: @@ -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) diff --git a/tests/test_media_upload.py b/tests/test_media_upload.py index f86922c..bc45ff9 100644 --- a/tests/test_media_upload.py +++ b/tests/test_media_upload.py @@ -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: