Feat: Pre-generate WebP thumbnails on upload for faster library load
- Add Pillow dependency and thumbnail.py with generate_thumbnail() — produces
WebP ≤400px, preserves aspect ratio, never upscales, handles GIF frame 0
- Alembic migration 002 adds nullable thumbnail_key column to images table
- Upload route generates thumbnail via asyncio.to_thread (non-blocking),
stores at {hash}-thumb; failure is tolerated and upload succeeds with null key
- New GET /api/v1/images/{id}/thumbnail endpoint: serves WebP thumbnail or
falls back to original for pre-feature images; ETag + immutable cache headers
- Delete route cleans up thumbnail storage object alongside original
- Library grid switches from /file to /thumbnail for all image src bindings
- 59 tests passing (46 existing + 13 new across unit, upload, serving, delete)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
79
api/tests/unit/test_thumbnail.py
Normal file
79
api/tests/unit/test_thumbnail.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Unit tests for thumbnail generation utility."""
|
||||
import io
|
||||
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.thumbnail import generate_thumbnail
|
||||
|
||||
|
||||
def _make_jpeg(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("RGB", (width, height), color=(128, 64, 32))
|
||||
img.save(buf, format="JPEG", quality=80)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_png_rgba(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("RGBA", (width, height), color=(10, 20, 30, 180))
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_gif(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img = PILImage.new("P", (width, height))
|
||||
img.save(buf, format="GIF")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_thumbnail_is_webp():
|
||||
data = _make_jpeg(600, 400)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
assert result[:4] == b"RIFF"
|
||||
assert result[8:12] == b"WEBP"
|
||||
|
||||
|
||||
def test_thumbnail_fits_within_400px():
|
||||
data = _make_jpeg(800, 600)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
assert w <= 400
|
||||
assert h <= 400
|
||||
|
||||
|
||||
def test_thumbnail_preserves_aspect_ratio():
|
||||
original_w, original_h = 800, 300
|
||||
data = _make_jpeg(original_w, original_h)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
original_ratio = original_w / original_h
|
||||
thumb_ratio = w / h
|
||||
assert abs(original_ratio - thumb_ratio) / original_ratio < 0.01
|
||||
|
||||
|
||||
def test_thumbnail_handles_gif_first_frame():
|
||||
data = _make_gif(500, 500)
|
||||
result = generate_thumbnail(data, "image/gif")
|
||||
assert result[8:12] == b"WEBP"
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
assert not getattr(img, "is_animated", False)
|
||||
|
||||
|
||||
def test_thumbnail_handles_png_with_alpha():
|
||||
data = _make_png_rgba(300, 300)
|
||||
result = generate_thumbnail(data, "image/png")
|
||||
assert result[8:12] == b"WEBP"
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
assert img.format == "WEBP"
|
||||
|
||||
|
||||
def test_thumbnail_does_not_upscale():
|
||||
data = _make_jpeg(100, 100)
|
||||
result = generate_thumbnail(data, "image/jpeg")
|
||||
img = PILImage.open(io.BytesIO(result))
|
||||
w, h = img.size
|
||||
assert w <= 100
|
||||
assert h <= 100
|
||||
Reference in New Issue
Block a user