# v1.4.0 "Media" Release - Implementation Design **Version**: 1.4.0 **Status**: Design Complete **Author**: StarPunk Architecture **Date**: 2025-12-10 --- ## Revision History | Date | Version | Changes | |------|---------|---------| | 2025-12-10 | 1.0 | Initial design document | | 2025-12-10 | 1.1 | Post-Q&A corrections: validate_image() signature unchanged; file_size computed in save_media(); animated GIF handling; simplified variant path calculation; optimized_bytes passed to variants; file cleanup on variant failure; backwards-compatible variants key; make_response import; photo truncation warning; isDefault fallback logic; configurable about URL; test image noise generation; SITE_URL normalization pattern | ## Executive Summary This document provides the complete implementation design for v1.4.0 "Media", which adds three major features: 1. **Micropub Media Endpoint** - W3C-compliant media upload via `/micropub/media` 2. **Large Image Support** - Accept and resize images up to 50MB 3. **Enhanced Feed Media** - Multiple image sizes with complete Media RSS implementation **Total Estimated Effort**: 28-40 hours --- ## Table of Contents 1. [Confirmed Decisions](#confirmed-decisions) 2. [Phase 1: Large Image Support](#phase-1-large-image-support) 3. [Phase 2: Image Variants](#phase-2-image-variants) 4. [Phase 3: Micropub Media Endpoint](#phase-3-micropub-media-endpoint) 5. [Phase 4: Enhanced Feed Media](#phase-4-enhanced-feed-media) 6. [Phase 5: Testing & Documentation](#phase-5-testing--documentation) 7. [Database Schema](#database-schema) 8. [API Specifications](#api-specifications) 9. [File Modifications Summary](#file-modifications-summary) 10. [Test Requirements](#test-requirements) 11. [Developer Q&A](#developer-qa) --- ## Confirmed Decisions The following decisions have been confirmed during the design phase: | Decision | Outcome | |----------|---------| | Scope flexibility | NOT locked - defer to large image support only if necessary | | Token scope | No new scope - existing `create` tokens work for media uploads | | Unused upload retention | Delete unused media after 24 hours | | Photo property URLs | Accept URL values directly without downloading | | Quality edge case | Reject if still >10MB after optimization | | EXIF dimensions | Nice to have (not required) | | Variant timing | Synchronous/eager generation on upload | | Existing media | Only new uploads get variants | | Thumbnail cropping | Center crop using `Pillow.ImageOps.fit()` | | `media:group` usage | Use for size variants of same image only | | JSON Feed extension | `_starpunk` namespace with `about` URL | --- ## Phase 1: Large Image Support **Estimated Effort**: 4-6 hours ### Overview Remove the 10MB file size rejection and implement tiered resize strategy for large images. ### Current Behavior (v1.2.0) - Files >10MB rejected with error - Files <=10MB accepted and resized if >2048px ### New Behavior (v1.4.0) - Files up to 50MB accepted - Tiered resize strategy based on input size - Final output always <=10MB after optimization - Reject if optimization cannot achieve target ### Tiered Resize Strategy | Input Size | Max Dimension | Quality | Target Output | |------------|---------------|---------|---------------| | <=10MB | 2048px | 95% | <=5MB typical | | 10-25MB | 1600px | 90% | <=5MB target | | 25-50MB | 1280px | 85% | <=5MB target | | >50MB | Rejected | - | Error message | ### Iterative Quality Reduction If first pass produces >10MB output: 1. Reduce max dimension by 20% 2. Reduce quality by 5% 3. Repeat until <=10MB or min quality (70%) reached 4. If still >10MB at 70% quality, reject with error ### File Modifications #### `/home/phil/Projects/starpunk/starpunk/media.py` **Constants to modify:** ```python # OLD MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB # NEW MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB MAX_OUTPUT_SIZE = 10 * 1024 * 1024 # 10MB (target after optimization) MIN_QUALITY = 70 # Minimum JPEG quality before rejection ``` **New function: `get_optimization_params()`** ```python def get_optimization_params(file_size: int) -> Tuple[int, int]: """ Determine optimization parameters based on input file size 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) ``` **Modified function: `validate_image()`** Changes: - Update MAX_FILE_SIZE check to 50MB - Add animated GIF detection and specific error message - **NOTE**: Return signature remains unchanged (3-tuple). File size is computed in `save_media()`. ```python def validate_image(file_data: bytes, filename: str) -> Tuple[str, int, int]: """ Validate image file Returns: Tuple of (mime_type, width, height) """ file_size = len(file_data) # Check file size (new 50MB limit) if file_size > MAX_FILE_SIZE: raise ValueError("File too large. Maximum size is 50MB") # ... existing validation code ... # Check for animated GIF (reject if >10MB since we can't resize) if img.format == 'GIF': try: img.seek(1) # 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) except EOFError: # Not animated, continue normally pass return mime_type, width, height ``` **Modified function: `optimize_image()`** Changes: - Accept `original_size` parameter - Implement tiered resize strategy - Add iterative quality reduction loop ```python def optimize_image(image_data: bytes, original_size: int = None) -> Tuple[Image.Image, int, int, bytes]: """ Optimize image for web display with size-aware strategy Args: image_data: Raw image bytes original_size: Original file size (for tiered optimization) Returns: 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 max_dim, quality = get_optimization_params(original_size) img = Image.open(io.BytesIO(image_data)) img = ImageOps.exif_transpose(img) if img.format != 'GIF' else img # 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() save_format = work_img.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 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 < 640: raise ValueError( "Image cannot be optimized to target size. " "Please use a smaller or lower-resolution image." ) ``` **Modified function: `save_media()`** Changes: - Compute `file_size = len(file_data)` after validation (signature unchanged) - Pass original size to `optimize_image()` - Use returned bytes directly instead of re-saving ```python def save_media(file_data: bytes, filename: str) -> Dict: """Save uploaded media file with size-aware optimization""" # Validate image (returns 3-tuple, unchanged signature) mime_type, orig_width, orig_height = validate_image(file_data, filename) # Compute file size for optimization strategy file_size = len(file_data) # Optimize image with size-aware strategy optimized_img, width, height, optimized_bytes = optimize_image(file_data, file_size) # ... generate filename and path ... # Write optimized bytes directly (already saved during optimization) full_path.write_bytes(optimized_bytes) # ... database insert and return ... ``` ### Error Messages - "File too large. Maximum size is 50MB" - "Image cannot be optimized to target size. Please use a smaller or lower-resolution image." - "Animated GIF too large. Maximum size for animated GIFs is 10MB. Consider using a shorter clip or lower resolution." --- ## Phase 2: Image Variants **Estimated Effort**: 8-12 hours ### Overview Generate multiple renditions on upload for responsive image delivery and feed optimization. ### Variant Specifications | Variant | Dimensions | Method | Use Case | |---------|------------|--------|----------| | `thumb` | 150x150 (square) | Center crop | Thumbnails, previews | | `small` | 320px width | Aspect preserve | Mobile, low bandwidth | | `medium` | 640px width | Aspect preserve | Standard display | | `large` | 1280px width | Aspect preserve | High-res display | | `original` | As uploaded (<=2048px) | From optimization | Full quality | ### Storage Structure ``` /data/media/2025/01/ abc123.jpg # Original/large (from optimization) abc123_medium.jpg # 640px width abc123_small.jpg # 320px width abc123_thumb.jpg # 150x150 center crop ``` ### Database Schema **New table: `media_variants`** ```sql CREATE TABLE media_variants ( id INTEGER PRIMARY KEY AUTOINCREMENT, media_id INTEGER NOT NULL, variant_type TEXT NOT NULL, -- 'thumb', 'small', 'medium', 'large', 'original' path TEXT NOT NULL, 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) ); CREATE INDEX idx_media_variants_media ON media_variants(media_id); ``` ### File Modifications #### `/home/phil/Projects/starpunk/starpunk/media.py` **New constants:** ```python # Variant specifications VARIANT_SPECS = { 'thumb': {'size': (150, 150), 'crop': True}, 'small': {'width': 320, 'crop': False}, 'medium': {'width': 640, 'crop': False}, 'large': {'width': 1280, 'crop': False}, } ``` **New function: `generate_variant()`** ```python 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.parent)), # Relative to media root 'width': work_img.width, 'height': work_img.height, 'size_bytes': variant_path.stat().st_size } ``` **New function: `generate_all_variants()`** ```python 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 ``` **Modified function: `save_media()`** Add variant generation after saving original: ```python def save_media(file_data: bytes, filename: str) -> Dict: """Save uploaded media file with variants""" # ... existing validation and optimization ... # (optimized_bytes is returned from optimize_image()) # Generate path components (year/month already computed for file path) year = now.strftime('%Y') month = now.strftime('%m') # Save optimized original full_path.write_bytes(optimized_bytes) # ... database insert for media table ... # Generate variants (synchronous) # 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, # ... existing fields ... 'variants': variants } ``` **Modified function: `get_note_media()`** Include variants in response (only when they exist for backwards compatibility): ```python def get_note_media(note_id: int) -> List[Dict]: """Get all media attached to a note with variants""" # ... existing query ... media_list = [] for row in rows: media_dict = { # ... existing fields ... } # Fetch variants for this media 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 ``` ### Migration File **`/home/phil/Projects/starpunk/migrations/009_add_media_variants.sql`** ```sql -- 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 CREATE TABLE IF NOT EXISTS media_variants ( id INTEGER PRIMARY KEY AUTOINCREMENT, media_id INTEGER NOT NULL, variant_type TEXT NOT NULL, -- 'thumb', 'small', 'medium', 'large', 'original' path TEXT NOT NULL, 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 CREATE INDEX IF NOT EXISTS idx_media_variants_media ON media_variants(media_id); ``` --- ## Phase 3: Micropub Media Endpoint **Estimated Effort**: 6-8 hours ### Overview Implement W3C Micropub media endpoint for external client uploads. ### Endpoint Specification **Endpoint**: `POST /micropub/media` **Request**: - Content-Type: `multipart/form-data` - Single file part named `file` - Authorization: Bearer token with `create` scope (no new scope needed) **Response**: - Success: `201 Created` with `Location` header - Error: JSON with `error` and `error_description` ### File Modifications #### `/home/phil/Projects/starpunk/starpunk/routes/micropub.py` **New route: `/micropub/media`** ```python @bp.route('/media', methods=['POST']) def media_endpoint(): """ Micropub media endpoint for file uploads W3C Micropub Specification compliant media upload. Accepts multipart/form-data with single file part named 'file'. Returns: 201 Created with Location header on success 4xx/5xx error responses per OAuth 2.0 format """ from starpunk.media import save_media, ALLOWED_MIME_TYPES # Extract and verify token token = extract_bearer_token(request) if not token: return error_response("unauthorized", "No access token provided", 401) token_info = verify_external_token(token) if not token_info: return error_response("unauthorized", "Invalid or expired access token", 401) # Check scope (create scope allows media upload) if not check_scope("create", token_info.get("scope", "")): return error_response( "insufficient_scope", "Token lacks create scope", 403 ) # Validate content type content_type = request.headers.get("Content-Type", "") if "multipart/form-data" not in content_type: return error_response( "invalid_request", "Content-Type must be multipart/form-data", 400 ) # Extract file if 'file' not in request.files: return error_response( "invalid_request", "No file provided. Use 'file' as the form field name.", 400 ) uploaded_file = request.files['file'] if not uploaded_file.filename: return error_response( "invalid_request", "No filename provided", 400 ) try: # Read file data file_data = uploaded_file.read() # Save media (validates, optimizes, generates variants) media = save_media(file_data, uploaded_file.filename) # Build media URL site_url = current_app.config.get("SITE_URL", "http://localhost:5000") media_url = f"{site_url}/media/{media['path']}" # Return 201 with Location header (per W3C Micropub spec) response = make_response("", 201) response.headers["Location"] = media_url return response except ValueError as e: # Validation errors (file too large, invalid format, etc.) return error_response("invalid_request", str(e), 400) except Exception as e: current_app.logger.error(f"Media upload failed: {e}") return error_response("server_error", "Failed to process upload", 500) ``` **Import additions at top of file:** ```python from flask import Blueprint, current_app, request, make_response # NOTE: Add make_response to existing imports from starpunk.auth_external import verify_external_token, check_scope ``` #### `/home/phil/Projects/starpunk/starpunk/micropub.py` **Modified function: `handle_query()` - Update q=config response** ```python def handle_query(args: dict, token_info: dict): """Handle Micropub query endpoints""" q = args.get("q") if q == "config": # Return server configuration with media endpoint site_url = current_app.config.get("SITE_URL", "http://localhost:5000") config = { "media-endpoint": f"{site_url}/micropub/media", # NEW: Advertise media endpoint "syndicate-to": [], "post-types": [ {"type": "note", "name": "Note", "properties": ["content"]}, {"type": "photo", "name": "Photo", "properties": ["photo"]} # NEW ], } return jsonify(config), 200 # ... rest of handle_query unchanged ... ``` **New function: `extract_photos()`** ```python def extract_photos(properties: dict) -> List[Dict[str, str]]: """ Extract photo URLs and alt text from Micropub properties Handles both simple URL strings and structured photo objects with alt text. Args: properties: Normalized Micropub properties dict Returns: List of dicts with 'url' and optional 'alt' keys Examples: >>> # Simple URL >>> extract_photos({'photo': ['https://example.com/photo.jpg']}) [{'url': 'https://example.com/photo.jpg', 'alt': ''}] >>> # With alt text >>> extract_photos({'photo': [{'value': 'https://example.com/photo.jpg', 'alt': 'Sunset'}]}) [{'url': 'https://example.com/photo.jpg', 'alt': 'Sunset'}] """ photos = properties.get("photo", []) result = [] for photo in photos: if isinstance(photo, str): # Simple URL string result.append({'url': photo, 'alt': ''}) elif isinstance(photo, dict): # Structured object with value and alt url = photo.get('value') or photo.get('url', '') alt = photo.get('alt', '') if url: result.append({'url': url, 'alt': alt}) return result ``` **Modified function: `handle_create()`** Add photo property handling: ```python def handle_create(data: dict, token_info: dict): """Handle Micropub create action""" # ... existing scope check and property extraction ... # Extract photos (NEW) photos = extract_photos(properties) # Create note try: note = create_note( content=content, published=True, created_at=published_date, custom_slug=custom_slug, tags=tags if tags else None ) # Attach photos if present (NEW) if photos: _attach_photos_to_note(note.id, photos) # ... rest unchanged ... ``` **New function: `_attach_photos_to_note()`** ```python def _attach_photos_to_note(note_id: int, photos: List[Dict[str, str]]) -> None: """ Attach photos to a note by URL Photos must already exist on this server (uploaded via media endpoint). External URLs are accepted but stored as-is (no download). Args: note_id: ID of the note to attach to photos: List of dicts with 'url' and 'alt' keys """ from starpunk.database import get_db from starpunk.media import attach_media_to_note from urllib.parse import urlparse # Normalize SITE_URL by stripping trailing slash for consistent comparison site_url = current_app.config.get("SITE_URL", "http://localhost:5000").rstrip('/') db = get_db(current_app) media_ids = [] captions = [] # Log warning if photos are being truncated if len(photos) > 4: current_app.logger.warning( f"Micropub create received {len(photos)} photos, truncating to 4 per ADR-057" ) for photo in photos[:4]: # Max 4 photos per ADR-057 url = photo['url'] alt = photo.get('alt', '') # Check if URL is on our server if url.startswith(site_url) or url.startswith('/media/'): # Extract path from URL if url.startswith(site_url): path = url[len(site_url):] else: path = url # Remove leading /media/ if present if path.startswith('/media/'): path = path[7:] # Look up media by path row = db.execute( "SELECT id FROM media WHERE path = ?", (path,) ).fetchone() if row: media_ids.append(row[0]) captions.append(alt) else: current_app.logger.warning(f"Photo URL not found in media: {url}") else: # External URL - log but don't fail current_app.logger.info(f"External photo URL ignored: {url}") if media_ids: attach_media_to_note(note_id, media_ids, captions) ``` --- ## Phase 4: Enhanced Feed Media **Estimated Effort**: 6-8 hours ### Overview Implement complete Media RSS specification with multiple image sizes and enhance JSON Feed with variant information. ### RSS Feed Enhancements #### `/home/phil/Projects/starpunk/starpunk/feeds/rss.py` **Changes to `generate_rss_streaming()`**: ```python def generate_rss_streaming( site_url: str, site_name: str, site_description: str, notes: list[Note], limit: int = 50, ): """Generate RSS 2.0 with full Media RSS support""" # ... existing header generation ... for note in notes[:limit]: # ... existing item generation ... # Enhanced media handling with variants if hasattr(note, 'media') and note.media: for media_item in note.media: variants = media_item.get('variants', {}) # Use media:group for multiple sizes of same image if variants: item_xml += '\n ' # Determine which variant is the default (largest available) # Fallback order: large -> medium -> small default_variant = None for fallback in ['large', 'medium', 'small']: if fallback in variants: default_variant = fallback break # Add each variant as media:content for variant_type in ['large', 'medium', 'small']: if variant_type in variants: v = variants[variant_type] media_url = f"{site_url}/media/{v['path']}" is_default = 'true' if variant_type == default_variant else 'false' item_xml += f''' ''' item_xml += '\n ' # Add media:thumbnail if 'thumb' in variants: thumb = variants['thumb'] thumb_url = f"{site_url}/media/{thumb['path']}" item_xml += f''' ''' # Add media:title for caption if media_item.get('caption'): item_xml += f''' {_escape_xml(media_item['caption'])}''' else: # Fallback for media without variants (legacy) media_url = f"{site_url}/media/{media_item['path']}" item_xml += f''' ''' # ... rest of item generation ... ``` ### JSON Feed Enhancements #### `/home/phil/Projects/starpunk/starpunk/feeds/json_feed.py` **Changes to `_build_item_object()`**: ```python def _build_item_object(site_url: str, note: Note) -> Dict[str, Any]: """Build JSON Feed item with enhanced media support""" # ... existing item construction ... # Enhanced _starpunk extension with variants # about URL is configurable via STARPUNK_ABOUT_URL config, with sensible default about_url = current_app.config.get( "STARPUNK_ABOUT_URL", "https://github.com/yourusername/starpunk" ) starpunk_ext = { "permalink_path": note.permalink, "word_count": len(note.content.split()), "about": about_url # Extension info URL (configurable) } # Add media variants if present if hasattr(note, 'media') and note.media: media_variants = [] for media_item in note.media: variants = media_item.get('variants', {}) if variants: media_info = { "caption": media_item.get('caption', ''), "variants": {} } for variant_type, variant_data in variants.items(): media_info["variants"][variant_type] = { "url": f"{site_url}/media/{variant_data['path']}", "width": variant_data['width'], "height": variant_data['height'], "size_in_bytes": variant_data['size_bytes'] } media_variants.append(media_info) if media_variants: starpunk_ext["media_variants"] = media_variants item["_starpunk"] = starpunk_ext return item ``` ### ATOM Feed Enhancements #### `/home/phil/Projects/starpunk/starpunk/feeds/atom.py` **Changes to `generate_atom_streaming()`**: Add enhanced enclosure links with proper attributes: ```python def generate_atom_streaming(...): # ... existing generation ... for note in notes[:limit]: # ... existing entry generation ... # Enhanced media enclosures with title attribute if hasattr(note, 'media') and note.media: for item in note.media: media_url = f"{site_url}/media/{item['path']}" mime_type = item.get('mime_type', 'image/jpeg') size = item.get('size', 0) caption = item.get('caption', '') # Include title attribute for caption title_attr = f' title="{_escape_xml(caption)}"' if caption else '' yield f' \n' ``` --- ## Phase 5: Testing & Documentation **Estimated Effort**: 4-6 hours ### Test Requirements #### Unit Tests **`/home/phil/Projects/starpunk/tests/test_media_v140.py`** ```python """Tests for v1.4.0 media features""" import pytest from io import BytesIO from PIL import Image class TestLargeImageSupport: """Tests for large image (>10MB) handling""" def test_accept_file_up_to_50mb(self, app, client): """Files up to 50MB should be accepted""" pass def test_reject_file_over_50mb(self, app, client): """Files over 50MB should be rejected""" pass def test_tiered_resize_10_to_25mb(self, app, client): """Files 10-25MB should resize to 1600px max""" pass def test_tiered_resize_25_to_50mb(self, app, client): """Files 25-50MB should resize to 1280px max""" pass def test_iterative_quality_reduction(self, app, client): """Quality should reduce iteratively if output >10MB""" pass def test_reject_if_cannot_optimize(self, app, client): """Reject if optimization cannot achieve target size""" pass class TestImageVariants: """Tests for image variant generation""" def test_generate_all_variants(self, app, client): """All four variants should be generated""" pass def test_thumb_is_square_crop(self, app, client): """Thumbnail should be 150x150 center crop""" pass def test_small_preserves_aspect(self, app, client): """Small variant should preserve aspect ratio""" pass def test_variants_stored_in_database(self, app, client): """Variants should be recorded in media_variants table""" pass def test_get_note_media_includes_variants(self, app, client): """get_note_media() should include variant data""" pass def test_skip_variant_larger_than_original(self, app, client): """Skip generating variants larger than source""" pass class TestMicropubMediaEndpoint: """Tests for /micropub/media endpoint""" def test_upload_success_returns_201(self, app, client): """Successful upload returns 201 with Location header""" pass def test_upload_requires_auth(self, app, client): """Upload without token returns 401""" pass def test_upload_requires_create_scope(self, app, client): """Upload without create scope returns 403""" pass def test_upload_validates_content_type(self, app, client): """Non-multipart requests return 400""" pass def test_upload_requires_file_field(self, app, client): """Missing 'file' field returns 400""" pass def test_config_query_includes_media_endpoint(self, app, client): """q=config should include media-endpoint URL""" pass class TestPhotoProperty: """Tests for Micropub photo property handling""" def test_photo_url_string(self, app, client): """Simple URL string in photo property""" pass def test_photo_with_alt_text(self, app, client): """Photo object with value and alt""" pass def test_multiple_photos(self, app, client): """Multiple photos in photo array""" pass def test_max_four_photos(self, app, client): """Only first 4 photos should be attached""" pass def test_external_url_logged_not_failed(self, app, client): """External URLs should log but not fail""" pass class TestFeedMediaEnhancements: """Tests for enhanced feed media support""" def test_rss_media_group(self, app, client): """RSS should use media:group for variants""" pass def test_rss_media_thumbnail(self, app, client): """RSS should include media:thumbnail""" pass def test_rss_media_title_for_caption(self, app, client): """RSS should include media:title for captions""" pass def test_json_feed_starpunk_variants(self, app, client): """JSON Feed should include variants in _starpunk""" pass def test_json_feed_about_url(self, app, client): """JSON Feed _starpunk should include about URL""" pass def test_atom_enclosure_title(self, app, client): """ATOM enclosures should have title attribute""" pass ``` #### Integration Tests **`/home/phil/Projects/starpunk/tests/integration/test_media_workflow.py`** ```python """Integration tests for complete media workflow""" class TestMediaWorkflow: """End-to-end media upload and display""" def test_upload_via_micropub_display_in_feed(self, app, client): """Upload via /micropub/media, create note with photo, verify feed""" pass def test_large_image_complete_workflow(self, app, client): """Upload large image, verify resize, verify variants, verify feed""" pass ``` ### Documentation Updates 1. **Update `/home/phil/Projects/starpunk/docs/architecture/syndication-architecture.md`** - Add Media RSS variant support - Document `_starpunk` extension format 2. **Update `/home/phil/Projects/starpunk/CHANGELOG.md`** - Add v1.4.0 section with all features 3. **Update `/home/phil/Projects/starpunk/docs/standards/testing-checklist.md`** - Add media upload validation steps - Add feed validation for Media RSS --- ## Database Schema ### Complete Migration SQL **File**: `/home/phil/Projects/starpunk/migrations/009_add_media_variants.sql` ```sql -- 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); ``` ### Schema Diagram ``` +----------------+ +---------------+ +------------------+ | notes | | note_media | | media | +----------------+ +---------------+ +------------------+ | id (PK) |<------| note_id (FK) | | id (PK) | | slug | | media_id (FK) |------>| filename | | file_path | | display_order | | stored_filename | | published | | caption | | path | | created_at | +---------------+ | mime_type | | updated_at | | size | | deleted_at | | width | | content_hash | | height | +----------------+ | uploaded_at | +------------------+ | | 1:N v +------------------+ | media_variants | +------------------+ | id (PK) | | media_id (FK) | | variant_type | | path | | width | | height | | size_bytes | | created_at | +------------------+ ``` --- ## API Specifications ### Micropub Media Endpoint **Endpoint**: `POST /micropub/media` **Request**: ```http POST /micropub/media HTTP/1.1 Host: example.com Authorization: Bearer {token} Content-Type: multipart/form-data; boundary=----WebKitFormBoundary ------WebKitFormBoundary Content-Disposition: form-data; name="file"; filename="photo.jpg" Content-Type: image/jpeg [binary data] ------WebKitFormBoundary-- ``` **Success Response**: ```http HTTP/1.1 201 Created Location: https://example.com/media/2025/01/abc123-def456.jpg ``` **Error Responses**: | Status | Error | Description | |--------|-------|-------------| | 400 | invalid_request | Missing file, invalid format, file too large | | 401 | unauthorized | No token or invalid token | | 403 | insufficient_scope | Token lacks create scope | | 500 | server_error | Processing failure | ### Micropub Config Query **Request**: ```http GET /micropub?q=config HTTP/1.1 Authorization: Bearer {token} ``` **Response**: ```json { "media-endpoint": "https://example.com/micropub/media", "syndicate-to": [], "post-types": [ {"type": "note", "name": "Note", "properties": ["content"]}, {"type": "photo", "name": "Photo", "properties": ["photo"]} ] } ``` ### Photo Property in Micropub Create **Simple URL**: ```http POST /micropub HTTP/1.1 Content-Type: application/x-www-form-urlencoded h=entry&content=My+photo&photo=https://example.com/media/2025/01/abc.jpg ``` **JSON with Alt Text**: ```json { "type": ["h-entry"], "properties": { "content": ["My photo post"], "photo": [{ "value": "https://example.com/media/2025/01/abc.jpg", "alt": "A beautiful sunset over the ocean" }] } } ``` --- ## File Modifications Summary ### Files to Create | File | Purpose | |------|---------| | `/home/phil/Projects/starpunk/migrations/009_add_media_variants.sql` | Database migration for variants table | | `/home/phil/Projects/starpunk/tests/test_media_v140.py` | Unit tests for new features | ### Files to Modify | File | Changes | |------|---------| | `/home/phil/Projects/starpunk/starpunk/media.py` | Large image support, variant generation | | `/home/phil/Projects/starpunk/starpunk/micropub.py` | Photo property extraction, config update | | `/home/phil/Projects/starpunk/starpunk/routes/micropub.py` | New media endpoint route | | `/home/phil/Projects/starpunk/starpunk/feeds/rss.py` | Media RSS enhancements | | `/home/phil/Projects/starpunk/starpunk/feeds/json_feed.py` | Variant info in _starpunk | | `/home/phil/Projects/starpunk/starpunk/feeds/atom.py` | Enclosure title attribute | | `/home/phil/Projects/starpunk/starpunk/migrations.py` | Add migration 009 detection (if needed) | --- ## Developer Q&A ### General Questions **Q1: What happens to existing media files when upgrading to v1.4.0?** A: Existing media files continue to work unchanged. Variants are only generated for **new uploads** after upgrading. Existing media will show in feeds without variant information - feeds gracefully handle both cases. **Q2: Can I retroactively generate variants for existing media?** A: Not automatically. A management command could be added post-release if needed, but it's not in scope for v1.4.0. **Q3: How much additional storage do variants use?** A: Approximately 4x per image: - Original: 100% - Large (1280px): ~50% - Medium (640px): ~25% - Small (320px): ~12% - Thumb (150x150): ~3% For a typical 500KB optimized image, expect ~900KB total with variants. ### Large Image Support **Q4: What if a user uploads a 45MB image that still can't fit in 10MB after optimization?** A: The iterative optimization will: 1. Try resize to 1280px at 85% quality 2. Reduce quality to 80%, 75%, 70% 3. If still >10MB, reduce dimensions to 1024px at 85% 4. Continue until success or minimum (640px at 70%) 5. If 640px at 70% still >10MB, reject with error This handles extreme edge cases like uncompressed TIFFs converted to JPEG. **Q5: Is the 50MB limit configurable?** A: In v1.4.0, it's a constant. Configuration could be added later if needed. ### Micropub Media Endpoint **Q6: Do I need a new token scope for media uploads?** A: No. The existing `create` scope is sufficient. Per the confirmed decisions, tokens with `create` scope can upload media. **Q7: What happens if a Micropub client sends a photo URL that doesn't exist on my server?** A: The URL is logged and ignored. The note is still created, but without that photo attached. This prevents failures when clients reference external URLs. **Q8: Can I upload multiple files in one request?** A: No. The W3C Micropub spec defines a single file per request. Upload multiple files with multiple requests, then reference all URLs in the create request's photo property. **Q9: What's the maximum number of photos per note?** A: 4 photos, per ADR-057. This matches Twitter/Mastodon limits. ### Feed Enhancements **Q10: How do feed readers handle the media:group element?** A: Most modern feed readers (Feedly, Inoreader, NewsBlur) understand Media RSS and will: - Use the `isDefault="true"` variant for display - Allow users to view other sizes - Show thumbnails in list views Older readers ignore the namespace and fall back to the HTML in description. **Q11: What's the `_starpunk.about` URL for?** A: Per JSON Feed extension best practices, custom namespaces should include an `about` URL that documents the extension. This helps consumers understand the data format. **Q12: Will Media RSS validation pass after these changes?** A: Yes. The implementation follows the Media RSS specification at https://www.rssboard.org/media-rss. Run the W3C Feed Validator to confirm. ### Implementation Order **Q13: Can phases be implemented out of order?** A: Phases 1 and 2 should be done together (variants depend on large image support changes to `save_media()`). Phase 3 (Micropub) can be done independently. Phase 4 (feeds) requires Phase 2 completion. **Q14: What's the minimum viable v1.4.0?** A: If time is constrained, Phase 1 (large image support) alone provides significant user value and can ship independently. Other phases can be moved to v1.4.1. ### Testing **Q15: How do I test with large images without storing them in the repo?** A: Generate test images programmatically: ```python from PIL import Image import io import numpy as np def create_test_image(width, height, target_size_mb): """Create a test image of approximate target size""" # Create image with random noise to prevent JPEG compression from # shrinking it too much. Solid colors compress extremely well. noise = np.random.randint(0, 256, (height, width, 3), dtype=np.uint8) img = Image.fromarray(noise, 'RGB') # Iteratively adjust quality to hit target size quality = 95 while quality > 50: output = io.BytesIO() img.save(output, 'JPEG', quality=quality) size_mb = len(output.getvalue()) / (1024 * 1024) if size_mb >= target_size_mb * 0.9: # Within 10% of target return output.getvalue() # Need larger file, but can't increase noise, so use higher resolution break output = io.BytesIO() img.save(output, 'JPEG', quality=95) return output.getvalue() # Example usage in tests: # large_image = create_test_image(4000, 3000, 15) # ~15MB test image ``` **Q16: How do I validate Media RSS output?** A: Use the W3C Feed Validator (https://validator.w3.org/feed/) and verify: - No errors for `media:` namespace elements - Proper attribute validation - Valid XML structure --- ## Acceptance Criteria ### Phase 1: Large Image Support - [ ] Files up to 50MB accepted - [ ] Files >50MB rejected with clear error - [ ] Tiered resize strategy applied based on input size - [ ] Iterative quality reduction works for edge cases - [ ] Final output always <=10MB - [ ] All existing tests pass ### Phase 2: Image Variants - [ ] Migration 009 creates media_variants table - [ ] All four variants generated on upload - [ ] Thumbnail is center-cropped square - [ ] Variants smaller than source not generated - [ ] get_note_media() returns variant data - [ ] Variants cascade-deleted with parent media ### Phase 3: Micropub Media Endpoint - [ ] POST /micropub/media accepts uploads - [ ] Returns 201 with Location header on success - [ ] Requires valid bearer token with create scope - [ ] q=config includes media-endpoint URL - [ ] Photo property attaches images to notes - [ ] Alt text preserved as caption ### Phase 4: Enhanced Feed Media - [ ] RSS uses media:group for variants - [ ] RSS includes media:thumbnail - [ ] RSS includes media:title for captions - [ ] JSON Feed _starpunk includes variants - [ ] JSON Feed _starpunk includes about URL - [ ] ATOM enclosures have title attribute - [ ] All feeds validate without errors ### Phase 5: Testing & Documentation - [ ] All new tests pass - [ ] Test coverage maintained >80% - [ ] CHANGELOG updated - [ ] Architecture docs updated - [ ] Version bumped to 1.4.0 --- ## Implementation Notes ### SITE_URL Normalization Throughout this implementation, `SITE_URL` should be normalized by stripping trailing slashes before use. This ensures consistent URL construction: ```python # Standard pattern for SITE_URL normalization site_url = current_app.config.get("SITE_URL", "http://localhost:5000").rstrip('/') media_url = f"{site_url}/media/{path}" ``` This pattern is used in: - `_attach_photos_to_note()` for URL comparison - Media endpoint for Location header - Feed generation for media URLs ### Configuration Options | Config Key | Default | Description | |------------|---------|-------------| | `SITE_URL` | `http://localhost:5000` | Base URL for the site | | `STARPUNK_ABOUT_URL` | `https://github.com/yourusername/starpunk` | URL documenting the `_starpunk` JSON Feed extension | --- ## References - [W3C Micropub Specification](https://www.w3.org/TR/micropub/) - [Media RSS Specification](https://www.rssboard.org/media-rss) - [JSON Feed 1.1 Specification](https://jsonfeed.org/version/1.1) - [ADR-057: Media Attachment Model](/home/phil/Projects/starpunk/docs/decisions/ADR-057-media-attachment-model.md) - [ADR-058: Image Optimization Strategy](/home/phil/Projects/starpunk/docs/decisions/ADR-058-image-optimization-strategy.md) - [ADR-059: Full Feed Media Standardization](/home/phil/Projects/starpunk/docs/decisions/ADR-059-full-feed-media-standardization.md) - [v1.4.0 Release Definition](/home/phil/Projects/starpunk/docs/projectplan/v1.4.0/RELEASE.md)