Files
reactbin/specs/003-upload-thumbnails/data-model.md
agatha f953c88984 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>
2026-05-03 17:26:16 +00:00

80 lines
2.3 KiB
Markdown

# 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.