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