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

2.3 KiB

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.pyImage 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:

{
  "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.