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:
90
specs/003-upload-thumbnails/contracts/api.md
Normal file
90
specs/003-upload-thumbnails/contracts/api.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# API Contract: Upload Thumbnails
|
||||
|
||||
**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## New endpoint
|
||||
|
||||
### `GET /api/v1/images/{image_id}/thumbnail`
|
||||
|
||||
Returns the thumbnail content for the given image. If no thumbnail was generated
|
||||
(image pre-dates the feature or generation failed), falls back to the full-size
|
||||
original.
|
||||
|
||||
**Path parameters**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `image_id` | UUID | Unique identifier of the image |
|
||||
|
||||
**Responses**
|
||||
|
||||
#### `200 OK` — Thumbnail (or original fallback) content
|
||||
|
||||
| Header | Value | Notes |
|
||||
|--------|-------|-------|
|
||||
| `Content-Type` | `image/webp` | Always WebP when a thumbnail exists; original `mime_type` when falling back to the original |
|
||||
| `ETag` | `"{sha256-hex}"` | Same hash as the original image — content is immutable |
|
||||
| `Cache-Control` | `public, max-age=31536000, immutable` | Safe: thumbnail content never changes |
|
||||
|
||||
Body: raw image bytes (WebP thumbnail, or original bytes as fallback).
|
||||
|
||||
#### `404 Not Found` — Image not found
|
||||
|
||||
```json
|
||||
{ "detail": "Image not found", "code": "image_not_found" }
|
||||
```
|
||||
|
||||
#### `500 Internal Server Error` — Storage retrieval failure
|
||||
|
||||
```json
|
||||
{ "detail": "Failed to retrieve image content", "code": "storage_error" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changed endpoint: `POST /api/v1/images`
|
||||
|
||||
The upload response body gains one new field:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `thumbnail_key` | `string \| null` | S3 key of the generated thumbnail. `null` if generation failed. |
|
||||
|
||||
All existing fields are unchanged.
|
||||
|
||||
**Example response** (new field only shown):
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"thumbnail_key": "abc123…-thumb",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changed endpoint: `GET /api/v1/images` and `GET /api/v1/images/{id}`
|
||||
|
||||
Both metadata responses gain the same `thumbnail_key` field (`string | null`).
|
||||
|
||||
---
|
||||
|
||||
## UI contract
|
||||
|
||||
The Angular `ImageService` gains one new method:
|
||||
|
||||
```
|
||||
getThumbnailUrl(id: string): string
|
||||
→ '/api/v1/images/{id}/thumbnail'
|
||||
```
|
||||
|
||||
The `ImageRecord` interface gains:
|
||||
|
||||
```
|
||||
thumbnail_key: string | null;
|
||||
```
|
||||
|
||||
The library grid component uses `getThumbnailUrl(image.id)` as the `src` for
|
||||
every grid cell. The detail component continues using `getFileUrl(image.id)`.
|
||||
Reference in New Issue
Block a user