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 <noreply@anthropic.com>
This commit is contained in:
21
migrations/009_add_media_variants.sql
Normal file
21
migrations/009_add_media_variants.sql
Normal file
@@ -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);
|
||||||
@@ -34,6 +34,14 @@ MIN_QUALITY = 70 # Minimum JPEG quality before rejection (v1.4.
|
|||||||
MIN_DIMENSION = 640 # Minimum dimension before rejection (v1.4.0)
|
MIN_DIMENSION = 640 # Minimum dimension before rejection (v1.4.0)
|
||||||
MAX_IMAGES_PER_NOTE = 4
|
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]:
|
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:
|
def save_media(file_data: bytes, filename: str) -> Dict:
|
||||||
"""
|
"""
|
||||||
Save uploaded media file
|
Save uploaded media file
|
||||||
@@ -283,6 +439,20 @@ def save_media(file_data: bytes, filename: str) -> Dict:
|
|||||||
db.commit()
|
db.commit()
|
||||||
media_id = cursor.lastrowid
|
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 {
|
return {
|
||||||
'id': media_id,
|
'id': media_id,
|
||||||
'filename': filename,
|
'filename': filename,
|
||||||
@@ -291,7 +461,8 @@ def save_media(file_data: bytes, filename: str) -> Dict:
|
|||||||
'mime_type': mime_type,
|
'mime_type': mime_type,
|
||||||
'size': actual_size,
|
'size': actual_size,
|
||||||
'width': width,
|
'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]:
|
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
|
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
|
note_id: Note ID to get media for
|
||||||
|
|
||||||
Returns:
|
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
|
from starpunk.database import get_db
|
||||||
|
|
||||||
@@ -369,8 +540,9 @@ def get_note_media(note_id: int) -> List[Dict]:
|
|||||||
(note_id,)
|
(note_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
return [
|
media_list = []
|
||||||
{
|
for row in rows:
|
||||||
|
media_dict = {
|
||||||
'id': row[0],
|
'id': row[0],
|
||||||
'filename': row[1],
|
'filename': row[1],
|
||||||
'stored_filename': row[2],
|
'stored_filename': row[2],
|
||||||
@@ -382,8 +554,42 @@ def get_note_media(note_id: int) -> List[Dict]:
|
|||||||
'caption': row[8],
|
'caption': row[8],
|
||||||
'display_order': row[9]
|
'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:
|
def delete_media(media_id: int) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user