# Research: Upload Thumbnails

**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03

## Decision 1: Image processing library

**Decision**: Add `Pillow` as a runtime dependency to the API.

**Rationale**: Pillow is the standard Python image processing library. It supports
reading JPEG, PNG, GIF (frame extraction), and WebP, and can encode output as WebP.
It handles aspect-ratio-preserving resize natively via `Image.thumbnail()`. No
other dependency is needed.

**Alternatives considered**:
- `wand` (ImageMagick binding): More powerful but much heavier; overkill for a
  fixed-size resize operation.
- `opencv-python`: ML-focused, large binary; not justified for simple resize.
- Pure `aiobotocore` + external service: Adds operational complexity with no benefit
  over a local library for a single-user app.

---

## Decision 2: Thumbnail dimensions and format

**Decision**: Longest side ≤ 400 px, WebP output, aspect ratio preserved. This
matches FR-003 and FR-004 exactly and the user's stated preference.

**Rationale**: WebP produces smaller files than JPEG/PNG at equivalent visual quality.
400 px covers a typical grid thumbnail at 1× and 2× display density without being
oversized. Pillow's `Image.thumbnail((400, 400))` implements this constraint directly
(it shrinks to fit within the bounding box, never upscaling).

**Alternatives considered**:
- JPEG thumbnails: Larger file sizes; no alpha channel support.
- Multiple sizes: Out of scope for v1 per spec Assumptions.
- On-demand resize: Rejected by user in favour of pre-generation.

---

## Decision 3: Thumbnail storage key convention

**Decision**: `{sha256_hash}-thumb` (e.g., the 64-char hash hex string + literal
`-thumb`, giving a 70-char key). Stored under the same S3 bucket as originals.

**Rationale**: Deterministic from the image hash — no new random state needed and the
key can always be reconstructed from the `Image.hash` field. The `-thumb` suffix
clearly distinguishes it from the original key. Fits within a `String(70)` column.

**Alternatives considered**:
- Separate bucket for thumbnails: More complex bucket policy management with no benefit
  for a single-user app.
- UUID-based key: Non-deterministic; requires an extra DB round-trip to look up.
- `{hash}/thumb.webp` (path prefix): Works, but adds key parsing complexity for no gain.

---

## Decision 4: Database schema change

**Decision**: Add a nullable `thumbnail_key: String(70)` column to the `images`
table. `NULL` means no thumbnail exists (either generation failed or the image
pre-dates this feature). Add a new Alembic migration `002_add_thumbnail_key.py`.

**Rationale**: Explicitly tracking the thumbnail key in the DB makes the "does a
thumbnail exist?" question a simple `IS NOT NULL` check rather than an S3 head
request. Also allows the delete route to skip the thumbnail delete if the column
is NULL, avoiding a storage error for legacy images.

**Alternatives considered**:
- Derive key at runtime from `image.hash + "-thumb"` without a DB column: Simpler but
  means no way to distinguish "thumbnail was generated" from "thumbnail was never
  attempted", and delete would need a conditional S3 head request.
- Separate `thumbnails` table: Over-engineered; one thumbnail per image with no
  additional attributes doesn't warrant its own table.

---

## Decision 5: Where thumbnail generation lives in the code

**Decision**: A standalone async function `generate_thumbnail(data: bytes, mime_type: str) -> bytes`
in a new module `api/app/thumbnail.py`. Called from the upload route after the original
is stored, before the Image record is created.

**Rationale**: Keeps the thumbnail logic self-contained and independently testable.
The upload route calls it but doesn't own it. Constitution §2.6 allows concrete
functions when no second implementation is needed — no abstract interface is warranted.

**Alternatives considered**:
- Method on `StorageBackend`: Wrong layer; storage knows nothing about image content.
- Inline in the upload route: Makes the route harder to test and read.
- A `ThumbnailService` class: No justification for a class when a module-level function suffices.

---

## Decision 6: Failure handling during upload

**Decision**: If `generate_thumbnail()` raises, log the exception, set `thumbnail_key`
to `NULL` on the Image record, and continue. The upload response succeeds. The
`GET /api/v1/images/{id}/thumbnail` endpoint falls back to the original when
`thumbnail_key` is NULL (FR-009).

**Rationale**: A thumbnail failure should not block the upload — the user still gets
their image in the library. The fallback in the thumbnail endpoint ensures the grid
still renders something.

---

## Decision 7: Thumbnail endpoint response

**Decision**: `GET /api/v1/images/{id}/thumbnail` follows the same pattern as
`GET /api/v1/images/{id}/file`:
- Returns `200` with binary content, `Content-Type: image/webp` (or original
  `mime_type` when falling back to original), `ETag`, and
  `Cache-Control: public, max-age=31536000, immutable`.
- Returns `404` with `{"detail": "...", "code": "image_not_found"}` if the image
  does not exist.
- Falls back to the original when `thumbnail_key IS NULL`.

**Rationale**: Consistent with the existing `/file` endpoint pattern established in
feature 002. The UI only needs to know one URL per image for the grid.

---

## Decision 8: GIF handling

**Decision**: For GIF uploads, `generate_thumbnail()` extracts frame 0 via
`Image.seek(0)` before resizing. The output is always WebP (static, not animated).

**Rationale**: Matches spec assumption: "Animated GIF thumbnails capture only the
first frame; animation is not preserved in the thumbnail." Pillow supports this
with `im.seek(0)`.
