Files
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

181 lines
8.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.