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:
2026-05-03 17:26:16 +00:00
parent cd89ba5dea
commit f953c88984
24 changed files with 1270 additions and 5 deletions

View 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