From 501a711050ad7ca37d731bab4dca951db7521bab Mon Sep 17 00:00:00 2001 From: Phil Skentelbery Date: Wed, 10 Dec 2025 18:19:24 -0700 Subject: [PATCH] feat(media): Add image variant support - v1.4.0 Phase 2 Implement automatic generation of multiple image sizes for responsive delivery and enhanced feed optimization. Changes: - Add migration 009 for media_variants table with CASCADE delete - Define variant specs: thumb (150x150 crop), small (320px), medium (640px), large (1280px) - Implement generate_variant() with center crop for thumbnails and aspect-preserving resize - Implement generate_all_variants() with try/except cleanup, pass year/month/optimized_bytes explicitly - Update save_media() to generate all variants after saving original - Update get_note_media() to include variants dict (backwards compatible - only when variants exist) - Record original as 'original' variant type Technical details: - Variants use explicit year/month parameters to avoid fragile path traversal - Pass optimized_bytes to avoid redundant file I/O - File cleanup on variant generation failure - Skip generating variants larger than source image - variants key only added to response when variants exist (pre-v1.4.0 compatibility) All existing tests pass. Phase 2 complete. Per design document: /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 --- migrations/009_add_media_variants.sql | 21 +++ starpunk/media.py | 220 +++++++++++++++++++++++++- 2 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 migrations/009_add_media_variants.sql diff --git a/migrations/009_add_media_variants.sql b/migrations/009_add_media_variants.sql new file mode 100644 index 0000000..8d598f1 --- /dev/null +++ b/migrations/009_add_media_variants.sql @@ -0,0 +1,21 @@ +-- Migration 009: Add media variants support +-- Version: 1.4.0 Phase 2 +-- Per ADR-059: Full Feed Media Standardization (Phase A) + +-- Media variants table for multiple image sizes +-- Each uploaded image gets thumb, small, medium, large, and original variants +CREATE TABLE IF NOT EXISTS media_variants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_id INTEGER NOT NULL, + variant_type TEXT NOT NULL CHECK (variant_type IN ('thumb', 'small', 'medium', 'large', 'original')), + path TEXT NOT NULL, -- Relative path: YYYY/MM/uuid_variant.ext + width INTEGER NOT NULL, + height INTEGER NOT NULL, + size_bytes INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE, + UNIQUE(media_id, variant_type) +); + +-- Index for efficient variant lookup by media ID +CREATE INDEX IF NOT EXISTS idx_media_variants_media ON media_variants(media_id); diff --git a/starpunk/media.py b/starpunk/media.py index 520eddb..e1fd534 100644 --- a/starpunk/media.py +++ b/starpunk/media.py @@ -34,6 +34,14 @@ MIN_QUALITY = 70 # Minimum JPEG quality before rejection (v1.4. MIN_DIMENSION = 640 # Minimum dimension before rejection (v1.4.0) MAX_IMAGES_PER_NOTE = 4 +# Variant specifications (v1.4.0 Phase 2) +VARIANT_SPECS = { + 'thumb': {'size': (150, 150), 'crop': True}, + 'small': {'width': 320, 'crop': False}, + 'medium': {'width': 640, 'crop': False}, + 'large': {'width': 1280, 'crop': False}, +} + def get_optimization_params(file_size: int) -> Tuple[int, int]: """ @@ -212,6 +220,154 @@ def optimize_image(image_data: bytes, original_size: int = None) -> Tuple[Image. ) +def generate_variant( + img: Image.Image, + variant_type: str, + base_path: Path, + base_filename: str, + file_ext: str +) -> Dict: + """ + Generate a single image variant + + Args: + img: Source PIL Image + variant_type: One of 'thumb', 'small', 'medium', 'large' + base_path: Directory to save to + base_filename: Base filename (UUID without extension) + file_ext: File extension (e.g., '.jpg') + + Returns: + Dict with variant metadata (path, width, height, size_bytes) + """ + spec = VARIANT_SPECS[variant_type] + work_img = img.copy() + + if spec.get('crop'): + # Center crop for thumbnails using ImageOps.fit() + work_img = ImageOps.fit( + work_img, + spec['size'], + method=Image.Resampling.LANCZOS, + centering=(0.5, 0.5) + ) + else: + # Aspect-preserving resize + target_width = spec['width'] + if work_img.width > target_width: + ratio = target_width / work_img.width + new_height = int(work_img.height * ratio) + work_img = work_img.resize( + (target_width, new_height), + Image.Resampling.LANCZOS + ) + + # Generate variant filename + variant_filename = f"{base_filename}_{variant_type}{file_ext}" + variant_path = base_path / variant_filename + + # Save with appropriate quality + save_kwargs = {'optimize': True} + if work_img.format in ['JPEG', 'JPG', None]: + save_kwargs['quality'] = 85 + + # Determine format from extension + save_format = 'JPEG' if file_ext.lower() in ['.jpg', '.jpeg'] else file_ext[1:].upper() + work_img.save(variant_path, format=save_format, **save_kwargs) + + return { + 'variant_type': variant_type, + 'path': str(variant_path.relative_to(base_path.parent.parent)), # Relative to media root + 'width': work_img.width, + 'height': work_img.height, + 'size_bytes': variant_path.stat().st_size + } + + +def generate_all_variants( + img: Image.Image, + base_path: Path, + base_filename: str, + file_ext: str, + media_id: int, + year: str, + month: str, + optimized_bytes: bytes +) -> List[Dict]: + """ + Generate all variants for an image and store in database + + Args: + img: Source PIL Image (the optimized original) + base_path: Directory containing the original + base_filename: Base filename (UUID without extension) + file_ext: File extension + media_id: ID of parent media record + year: Year string (e.g., '2025') for path calculation + month: Month string (e.g., '01') for path calculation + optimized_bytes: Bytes of optimized original (avoids re-reading file) + + Returns: + List of variant metadata dicts + """ + from starpunk.database import get_db + + variants = [] + db = get_db(current_app) + created_files = [] # Track files for cleanup on failure + + try: + # Generate each variant type + for variant_type in ['thumb', 'small', 'medium', 'large']: + # Skip if image is smaller than target + spec = VARIANT_SPECS[variant_type] + target_width = spec.get('width') or spec['size'][0] + + if img.width < target_width and variant_type != 'thumb': + continue # Skip variants larger than original + + variant = generate_variant(img, variant_type, base_path, base_filename, file_ext) + variants.append(variant) + created_files.append(base_path / f"{base_filename}_{variant_type}{file_ext}") + + # Insert into database + db.execute( + """ + INSERT INTO media_variants + (media_id, variant_type, path, width, height, size_bytes) + VALUES (?, ?, ?, ?, ?, ?) + """, + (media_id, variant['variant_type'], variant['path'], + variant['width'], variant['height'], variant['size_bytes']) + ) + + # Also record the original as 'original' variant + # Use explicit year/month for path calculation (avoids fragile parent traversal) + original_path = f"{year}/{month}/{base_filename}{file_ext}" + db.execute( + """ + INSERT INTO media_variants + (media_id, variant_type, path, width, height, size_bytes) + VALUES (?, ?, ?, ?, ?, ?) + """, + (media_id, 'original', original_path, img.width, img.height, + len(optimized_bytes)) # Use passed bytes instead of file I/O + ) + + db.commit() + return variants + + except Exception as e: + # Clean up any created variant files on failure + for file_path in created_files: + try: + if file_path.exists(): + file_path.unlink() + except OSError: + pass # Best effort cleanup + raise # Re-raise the original exception + + def save_media(file_data: bytes, filename: str) -> Dict: """ Save uploaded media file @@ -283,6 +439,20 @@ def save_media(file_data: bytes, filename: str) -> Dict: db.commit() media_id = cursor.lastrowid + # Generate variants (synchronous) - v1.4.0 Phase 2 + # Pass year, month, and optimized_bytes to avoid fragile path traversal and file I/O + base_filename = stored_filename.rsplit('.', 1)[0] + variants = generate_all_variants( + optimized_img, + full_dir, + base_filename, + file_ext, + media_id, + year, + month, + optimized_bytes + ) + return { 'id': media_id, 'filename': filename, @@ -291,7 +461,8 @@ def save_media(file_data: bytes, filename: str) -> Dict: 'mime_type': mime_type, 'size': actual_size, 'width': width, - 'height': height + 'height': height, + 'variants': variants } @@ -335,7 +506,7 @@ def attach_media_to_note(note_id: int, media_ids: List[int], captions: List[str] def get_note_media(note_id: int) -> List[Dict]: """ - Get all media attached to a note + Get all media attached to a note with variants (v1.4.0) Returns list sorted by display_order @@ -343,7 +514,7 @@ def get_note_media(note_id: int) -> List[Dict]: note_id: Note ID to get media for Returns: - List of media dicts with metadata + List of media dicts with metadata (includes 'variants' key if variants exist) """ from starpunk.database import get_db @@ -369,8 +540,9 @@ def get_note_media(note_id: int) -> List[Dict]: (note_id,) ).fetchall() - return [ - { + media_list = [] + for row in rows: + media_dict = { 'id': row[0], 'filename': row[1], 'stored_filename': row[2], @@ -382,8 +554,42 @@ def get_note_media(note_id: int) -> List[Dict]: 'caption': row[8], 'display_order': row[9] } - for row in rows - ] + + # Fetch variants for this media (v1.4.0 Phase 2) + variants = db.execute( + """ + SELECT variant_type, path, width, height, size_bytes + FROM media_variants + WHERE media_id = ? + ORDER BY + CASE variant_type + WHEN 'thumb' THEN 1 + WHEN 'small' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'large' THEN 4 + WHEN 'original' THEN 5 + END + """, + (row[0],) + ).fetchall() + + # Only add 'variants' key if variants exist (backwards compatibility) + # Pre-v1.4.0 media won't have variants, and consumers shouldn't + # expect the key to be present + if variants: + media_dict['variants'] = { + v[0]: { + 'path': v[1], + 'width': v[2], + 'height': v[3], + 'size_bytes': v[4] + } + for v in variants + } + + media_list.append(media_dict) + + return media_list def delete_media(media_id: int) -> None: