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

@@ -5,7 +5,9 @@ T067 — DELETE of unknown ID → 404 image_not_found
"""
import io
import uuid
import pytest
from PIL import Image as PILImage
def _minimal_jpeg_v2() -> bytes:
@@ -58,3 +60,25 @@ async def test_delete_unknown_id_returns_404(client):
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"
@pytest.mark.asyncio
async def test_delete_removes_thumbnail(client):
buf = io.BytesIO()
PILImage.new("RGB", (200, 150), color=(60, 90, 120)).save(buf, format="JPEG")
data = buf.getvalue()
upload = await client.post(
"/api/v1/images",
files={"file": ("thumb-del.jpg", io.BytesIO(data), "image/jpeg")},
)
assert upload.status_code == 201
image_id = upload.json()["id"]
assert upload.json()["thumbnail_key"] is not None
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
assert delete_resp.status_code == 204
thumb_resp = await client.get(f"/api/v1/images/{image_id}/thumbnail")
assert thumb_resp.status_code == 404
assert thumb_resp.json()["code"] == "image_not_found"

View File

@@ -7,6 +7,16 @@ import io
import uuid
import pytest
from PIL import Image as PILImage
from sqlalchemy import update
from app.models import Image
def _real_jpeg() -> bytes:
buf = io.BytesIO()
PILImage.new("RGB", (200, 150), color=(120, 80, 200)).save(buf, format="JPEG")
return buf.getvalue()
def _minimal_webp() -> bytes:
@@ -62,3 +72,53 @@ async def test_file_response_exposes_no_storage_details(client):
assert "minio" not in response.text.lower()
assert "s3://" not in response.text.lower()
assert "amazonaws.com" not in response.text.lower()
@pytest.mark.asyncio
async def test_thumbnail_returns_webp(client):
data = _real_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("t.jpg", io.BytesIO(data), "image/jpeg")},
)
assert upload.status_code == 201
body = upload.json()
image_id = body["id"]
image_hash = body["hash"]
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
assert response.status_code == 200
assert response.headers["content-type"] == "image/webp"
assert response.headers["etag"] == f'"{image_hash}"'
assert "immutable" in response.headers["cache-control"]
assert len(response.content) > 0
@pytest.mark.asyncio
async def test_thumbnail_fallback_returns_original(client, db_session):
data = _real_jpeg()
upload = await client.post(
"/api/v1/images",
files={"file": ("fallback.jpg", io.BytesIO(data), "image/jpeg")},
)
assert upload.status_code == 201
image_id = upload.json()["id"]
await db_session.execute(
update(Image).where(Image.id == uuid.UUID(image_id)).values(thumbnail_key=None)
)
await db_session.flush()
db_session.expire_all()
response = await client.get(f"/api/v1/images/{image_id}/thumbnail")
assert response.status_code == 200
assert "image/jpeg" in response.headers["content-type"]
assert len(response.content) > 0
@pytest.mark.asyncio
async def test_thumbnail_unknown_id_returns_404(client):
response = await client.get(f"/api/v1/images/{uuid.uuid4()}/thumbnail")
assert response.status_code == 404
body = response.json()
assert body["code"] == "image_not_found"

View File

@@ -6,7 +6,16 @@ T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
T079 — GET /api/v1/images/{id} 404 → error envelope shape
"""
import io
from unittest.mock import patch
import pytest
from PIL import Image as PILImage
def _real_jpeg(color: tuple = (100, 150, 200), size: tuple = (200, 150)) -> bytes:
buf = io.BytesIO()
PILImage.new("RGB", size, color=color).save(buf, format="JPEG")
return buf.getvalue()
def _minimal_jpeg() -> bytes:
@@ -96,3 +105,51 @@ async def test_get_unknown_image_returns_404_with_envelope(client):
body = response.json()
assert body["code"] == "image_not_found"
assert "detail" in body
@pytest.mark.asyncio
async def test_upload_returns_thumbnail_key(client):
data = _real_jpeg(color=(100, 150, 200))
response = await client.post(
"/api/v1/images",
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
)
assert response.status_code == 201
body = response.json()
assert "thumbnail_key" in body
assert body["thumbnail_key"] is not None
assert body["thumbnail_key"].endswith("-thumb")
@pytest.mark.asyncio
async def test_duplicate_upload_reuses_thumbnail_key(client):
data = _real_jpeg(color=(200, 100, 50))
r1 = await client.post(
"/api/v1/images",
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
)
assert r1.status_code in (200, 201)
r2 = await client.post(
"/api/v1/images",
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
)
assert r2.status_code == 200
tk1 = r1.json()["thumbnail_key"]
tk2 = r2.json()["thumbnail_key"]
assert tk1 is not None
assert tk1 == tk2
@pytest.mark.asyncio
async def test_upload_succeeds_when_thumbnail_fails(client):
data = _real_jpeg(color=(50, 200, 150))
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
response = await client.post(
"/api/v1/images",
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
)
assert response.status_code in (200, 201)
body = response.json()
assert body["thumbnail_key"] is None

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