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
specs/003-upload-thumbnails/data-model.md
Normal file
79
specs/003-upload-thumbnails/data-model.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Data Model: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
|
||||
|
||||
## Schema change: `images` table
|
||||
|
||||
One nullable column is added to the existing `images` table.
|
||||
|
||||
| Column | Type | Nullable | Default | Notes |
|
||||
|--------|------|----------|---------|-------|
|
||||
| `thumbnail_key` | `VARCHAR(70)` | YES | `NULL` | S3 object key for the WebP thumbnail. `NULL` = no thumbnail available (generation failed or pre-dates this feature). Derived value: `{image.hash}-thumb`. |
|
||||
|
||||
No other tables change. No new tables are added.
|
||||
|
||||
### Migration
|
||||
|
||||
**File**: `api/alembic/versions/002_add_thumbnail_key.py`
|
||||
|
||||
```
|
||||
upgrade: ALTER TABLE images ADD COLUMN thumbnail_key VARCHAR(70);
|
||||
downgrade: ALTER TABLE images DROP COLUMN thumbnail_key;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ORM model change: `Image`
|
||||
|
||||
`api/app/models.py` — `Image` class gains one field:
|
||||
|
||||
```
|
||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New module: `api/app/thumbnail.py`
|
||||
|
||||
Contains the thumbnail generation logic. Not a model, but documented here because
|
||||
it defines the thumbnail's shape:
|
||||
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Output format | WebP |
|
||||
| Max dimension (longest side) | 400 px |
|
||||
| Aspect ratio | Preserved (never upscaled) |
|
||||
| Source formats supported | JPEG, PNG, GIF (frame 0), WebP |
|
||||
| Key signature | `async def generate_thumbnail(data: bytes, mime_type: str) -> bytes` |
|
||||
|
||||
---
|
||||
|
||||
## API response shape change
|
||||
|
||||
`_image_to_dict()` in `api/app/routers/images.py` adds `"thumbnail_key"` to its
|
||||
output so the UI can determine whether a thumbnail is available:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"hash": "...",
|
||||
"thumbnail_key": "abc123...-thumb", ← new (null if no thumbnail)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The UI uses the presence of `thumbnail_key` to decide whether to call
|
||||
`/api/v1/images/{id}/thumbnail` (with thumbnail) or fall back to
|
||||
`/api/v1/images/{id}/file` (without). In practice the endpoint itself
|
||||
handles the fallback, so the UI can always call `/thumbnail`.
|
||||
|
||||
---
|
||||
|
||||
## Storage objects per image (after this feature)
|
||||
|
||||
| Object | Key | Format | Created at |
|
||||
|--------|-----|--------|-----------|
|
||||
| Original | `{sha256_hash}` | Original mime_type | Upload |
|
||||
| Thumbnail | `{sha256_hash}-thumb` | `image/webp` | Upload (same request) |
|
||||
|
||||
Thumbnail object is deleted alongside original on image deletion.
|
||||
Reference in New Issue
Block a user