- 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>
181 lines
8.6 KiB
Markdown
181 lines
8.6 KiB
Markdown
# Feature Specification: Upload Thumbnails
|
||
|
||
**Feature Branch**: `003-upload-thumbnails`
|
||
**Created**: 2026-05-03
|
||
**Status**: Draft
|
||
**Input**: User description: "When users load the UI, full size images are fetched, which may cause considerable load time when there are a lot of images -- a grid of 20 images could silently pull several hundred megabytes. We will solve this by pre-generating thumbnails on upload: when an image is uploaded, immediately produce one (or a few) fixed-size thumbnail variants and store them alongside the original. The library always fetches the thumbnail key, the detail page fetches the original key. Zero resize cost at serve time. A single fixed-size re-encoded as WebP for smaller bytes will cover the grid view."
|
||
|
||
## User Scenarios & Testing *(mandatory)*
|
||
|
||
### User Story 1 — Fast Library Load (Priority: P1)
|
||
|
||
A user opens the application or runs a tag-filtered search. The image grid
|
||
loads quickly even when the library contains many images, because each grid
|
||
cell fetches a compact thumbnail rather than the full-size original.
|
||
|
||
**Why this priority**: This is the core motivation. A grid of 20 images today
|
||
could pull hundreds of megabytes; thumbnails bring that down to a few
|
||
megabytes, making the library usable on slow or metered connections.
|
||
|
||
**Independent Test**: Upload 20 images of varying sizes (including some near
|
||
the 50 MB limit). Open the library. Measure total bytes transferred while the
|
||
grid loads. Compare against loading the same library before this feature.
|
||
Verify the grid renders fully and that each thumbnail is visually recognisable
|
||
as the correct image.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** a library with multiple images, **When** the user opens the
|
||
library page, **Then** each grid cell displays a thumbnail that is visually
|
||
recognisable as its image, and the total data transferred to render the
|
||
full grid is substantially less than the sum of the original file sizes.
|
||
|
||
2. **Given** a user applies one or more tag filters, **When** the filtered
|
||
results are displayed, **Then** thumbnails are shown for all matching
|
||
images with the same reduced data footprint.
|
||
|
||
3. **Given** a library with images of mixed types (JPEG, PNG, GIF, WebP),
|
||
**When** the grid loads, **Then** thumbnails for all types display
|
||
correctly.
|
||
|
||
---
|
||
|
||
### User Story 2 — Full-Size Image on Detail Page (Priority: P1)
|
||
|
||
A user clicks an image in the library grid to open its detail page. The
|
||
full-size original is displayed, not the thumbnail. The experience is
|
||
unchanged from before this feature.
|
||
|
||
**Why this priority**: The detail page is where the user inspects or copies
|
||
an image; showing the thumbnail there would degrade the product's core value.
|
||
|
||
**Independent Test**: Open any image's detail page. Verify the image
|
||
displayed matches the original resolution and file size, not the thumbnail
|
||
dimensions.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** the user clicks an image thumbnail in the library, **When** the
|
||
detail page loads, **Then** the full-size original image is displayed at
|
||
its native resolution.
|
||
|
||
2. **Given** the user navigates directly to an image's detail URL, **When**
|
||
the page loads, **Then** the full-size original is displayed.
|
||
|
||
---
|
||
|
||
### User Story 3 — Thumbnails Generated Automatically on Upload (Priority: P1)
|
||
|
||
A user uploads a new image. Without any additional action, a thumbnail is
|
||
available immediately. There is no separate step or explicit request to
|
||
generate a thumbnail.
|
||
|
||
**Why this priority**: The value of the feature depends entirely on thumbnails
|
||
being present for every image. Manual generation or lazy generation would
|
||
create inconsistencies in the grid.
|
||
|
||
**Independent Test**: Upload a new image. Immediately open the library. Verify
|
||
the new image's thumbnail appears in the grid without any extra action.
|
||
|
||
**Acceptance Scenarios**:
|
||
|
||
1. **Given** the user uploads a supported image, **When** the upload
|
||
completes, **Then** a thumbnail is available and appears correctly in
|
||
the library grid.
|
||
|
||
2. **Given** the user uploads a duplicate image (already in the library),
|
||
**When** the upload completes, **Then** no redundant thumbnail is
|
||
generated — the existing thumbnail is reused.
|
||
|
||
3. **Given** the user uploads an image at or near the maximum supported
|
||
file size (50 MB), **When** the upload completes, **Then** the thumbnail
|
||
is generated successfully and the upload response time remains acceptable.
|
||
|
||
---
|
||
|
||
### Edge Cases
|
||
|
||
- What happens when thumbnail generation fails during upload? → The upload
|
||
still succeeds and the original image is stored; a fallback to the original
|
||
is shown in the grid, or the item is hidden until the thumbnail is available
|
||
(assumption: fall back to original rather than silently drop the image).
|
||
- What happens when an image is deleted? → Both the original and its thumbnail
|
||
are removed from storage.
|
||
- What happens with existing images that were uploaded before this feature? →
|
||
Those images have no pre-generated thumbnail; the grid falls back to the
|
||
original for those entries until a backfill is performed (backfill is out of
|
||
scope for v1 of this feature).
|
||
- What happens with animated GIFs? → A static thumbnail is generated from the
|
||
first frame.
|
||
|
||
## Requirements *(mandatory)*
|
||
|
||
### Functional Requirements
|
||
|
||
- **FR-001**: The system MUST generate a thumbnail for every newly uploaded
|
||
image as part of the upload operation, before the upload response is
|
||
returned to the caller.
|
||
- **FR-002**: Thumbnails MUST be stored in the same object storage as the
|
||
original, addressable by a distinct key derived from the image.
|
||
- **FR-003**: The thumbnail MUST be encoded as WebP regardless of the original
|
||
image format.
|
||
- **FR-004**: The thumbnail MUST fit within a fixed maximum dimension on its
|
||
longest side, preserving the original aspect ratio; no dimension of the
|
||
thumbnail MAY exceed 400 pixels.
|
||
- **FR-005**: The library grid view MUST fetch and display thumbnails instead
|
||
of original images.
|
||
- **FR-006**: The image detail view MUST continue to fetch and display the
|
||
full-size original.
|
||
- **FR-007**: When a duplicate image is uploaded, the thumbnail MUST NOT be
|
||
regenerated or re-stored; the existing thumbnail is reused.
|
||
- **FR-008**: When an image is deleted, its thumbnail MUST also be deleted
|
||
from storage.
|
||
- **FR-009**: If thumbnail generation fails during upload, the upload MUST
|
||
still succeed; the system MUST fall back to serving the original image in
|
||
the grid for that entry.
|
||
- **FR-010**: The API MUST expose a way for clients to retrieve the thumbnail
|
||
content for a given image, distinct from the full-size content endpoint.
|
||
|
||
### Key Entities *(include if feature involves data)*
|
||
|
||
- **Image**: Gains a new optional attribute indicating whether a thumbnail is
|
||
available and the key under which the thumbnail is stored.
|
||
- **Thumbnail**: A derived, smaller representation of an Image. Key
|
||
attributes: storage key, dimensions (width × height), format (WebP),
|
||
relationship to its source Image.
|
||
|
||
## Success Criteria *(mandatory)*
|
||
|
||
### Measurable Outcomes
|
||
|
||
- **SC-001**: The total data transferred to render a 20-image library grid is
|
||
reduced by at least 80% compared to fetching full-size originals for the
|
||
same images.
|
||
- **SC-002**: The library grid's first page loads in under 2 seconds on a
|
||
local network connection for a library of 1,000 images, with thumbnails
|
||
visible without a second load.
|
||
- **SC-003**: Thumbnails are available immediately after upload completes —
|
||
no polling or manual refresh is required.
|
||
- **SC-004**: The detail page continues to show the full-size original; no
|
||
regression in detail-page image quality is introduced.
|
||
- **SC-005**: Deleting an image removes both the original and its thumbnail;
|
||
no orphaned thumbnail objects remain in storage after deletion.
|
||
|
||
## Assumptions
|
||
|
||
- A single thumbnail size (longest side ≤ 400 px, WebP) is sufficient for
|
||
the library grid view in v1. Additional sizes or formats are out of scope.
|
||
- Thumbnail generation happens synchronously during the upload request. Async
|
||
background processing is not required for v1.
|
||
- Existing images uploaded before this feature are not automatically
|
||
backfilled with thumbnails in v1; the grid falls back to the original for
|
||
those entries.
|
||
- Animated GIF thumbnails capture only the first frame; animation is not
|
||
preserved in the thumbnail.
|
||
- The thumbnail storage key is derived deterministically from the image's
|
||
existing content hash, so no additional database column is strictly required
|
||
to locate it — however the Image record will track thumbnail availability
|
||
for correctness.
|
||
- No change is required to tag management, duplicate detection, or any other
|
||
upload behaviour beyond adding thumbnail generation.
|