# Implementation Plan: Upload Thumbnails **Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from `specs/003-upload-thumbnails/spec.md` ## Summary When an image is uploaded, generate a WebP thumbnail (longest side ≤ 400 px, aspect ratio preserved) and store it in S3 alongside the original. Add a `GET /api/v1/images/{id}/thumbnail` endpoint that serves the thumbnail (or falls back to the original for images that pre-date the feature). The Angular library grid switches from `/file` to `/thumbnail`. The detail page is unchanged. Changes span: a new Pillow dependency, a new `thumbnail.py` utility module, one Alembic migration, the upload and delete routes, a new thumbnail serve route, and the Angular image service and library component. ## Technical Context **Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI) **Primary Dependencies**: FastAPI, Pillow (new — thumbnail generation), aiobotocore, SQLAlchemy 2.x async, Alembic, Angular **Storage**: S3-compatible object storage via `StorageBackend.put()` and `.get()`; thumbnails stored at key `{sha256_hash}-thumb` in the same bucket **Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI) **Target Platform**: Linux server (containerised); modern evergreen desktop browsers **Project Type**: Web application — FastAPI API + Angular SPA **Performance Goals**: 20-image grid transfers ≥ 80% less data than full-size; first page of 1,000-image library loads in under 2 s **Constraints**: Thumbnail generation is synchronous within the upload request; thumbnail failure must not block upload success; no backfill of existing images in v1 **Scale/Scope**: Single-user personal application; upload frequency low ## Constitution Check *GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.* | Principle | Check | Status | |-----------|-------|--------| | §2.1 Separation of concerns | `thumbnail.py` owns resize logic; router owns orchestration; UI knows nothing about S3 keys | ✅ | | §2.2 Dependency direction | UI → API → Storage; thumbnail stored via `StorageBackend`; no upward imports | ✅ | | §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend.put()` and `.get()`; no raw S3 SDK calls in routes or `thumbnail.py` | ✅ | | §2.4 Auth abstraction | No change to auth flow | ✅ | | §2.5 DB abstraction | New `thumbnail_key` column accessed only through `ImageRepository`; migration added | ✅ | | §2.6 No speculative abstraction | `thumbnail.py` is a concrete module-level function; no interface added because one implementation exists | ✅ | | §3.1 API versioning | New route at `/api/v1/images/{id}/thumbnail` | ✅ | | §3.3 Error shape | `image_not_found` and `storage_error` codes used consistently | ✅ | | §4.2 Images immutable after upload | Thumbnail is generated at upload time only; never mutated | ✅ | | §4.3 Dedup by hash | Duplicate upload returns existing record including existing `thumbnail_key`; no re-generation | ✅ | | §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ | | §5.2 Test pyramid | Unit test for `thumbnail.py`; integration tests for new route + upload + delete | ✅ | | §5.3 Test colocation | API tests in `api/tests/`; Angular spec files colocated with components | ✅ | | §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ | | §7.1 One-command start | No change to `docker-compose.yml` required | ✅ | | §7.2 Env configuration | No new env vars; Pillow is a build dependency, not a runtime config | ✅ | | §8 Scope boundaries | Backfill of existing images, multiple thumbnail sizes, animated WebP — all deferred | ✅ | **Post-design re-check**: All gates still pass after Phase 1 design. ## Project Structure ### Documentation (this feature) ```text specs/003-upload-thumbnails/ ├── plan.md # This file ├── spec.md # Feature specification ├── research.md # Phase 0 decisions ├── data-model.md # Schema and module changes ├── contracts/ │ └── api.md # New endpoint + changed response shapes ├── checklists/ │ └── requirements.md # Spec quality checklist └── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) ``` ### Files changed or created ```text api/ ├── pyproject.toml # Add Pillow dependency ├── app/ │ ├── thumbnail.py # NEW — generate_thumbnail() │ ├── models.py # Add thumbnail_key column to Image │ ├── repositories/ │ │ └── image_repo.py # Pass thumbnail_key on create │ └── routers/ │ └── images.py # Upload: generate+store thumbnail │ # Delete: remove thumbnail │ # New route: GET /images/{id}/thumbnail │ # _image_to_dict: add thumbnail_key field └── alembic/ └── versions/ └── 002_add_thumbnail_key.py # NEW migration ui/ └── src/ └── app/ ├── services/ │ └── image.service.ts # Add getThumbnailUrl(); add thumbnail_key to ImageRecord └── library/ └── library.component.ts # Use getThumbnailUrl() for grid image src ``` ## Milestones > **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write > the failing test(s) first, confirm they fail, then implement until they pass. --- ### M1 — Thumbnail generation utility **Goal**: A tested, self-contained function that produces a WebP thumbnail from raw image bytes. **Deliverables**: - Add `pillow>=10.0` to `[project.dependencies]` in `api/pyproject.toml` - Create `api/app/thumbnail.py` with `generate_thumbnail(data: bytes, mime_type: str) -> bytes`: - Open image bytes with Pillow - Seek to frame 0 (handles animated GIFs) - Convert mode as needed for WebP output - Resize to fit within 400×400 using LANCZOS resampling (never upscale) - Encode as WebP quality 80 and return bytes - Unit tests in `api/tests/unit/test_thumbnail.py`: - `test_thumbnail_is_webp` — output starts with WebP magic bytes - `test_thumbnail_fits_within_400px` — both dimensions ≤ 400 - `test_thumbnail_preserves_aspect_ratio` — ratio within 1% of original - `test_thumbnail_handles_gif_first_frame` — GIF input produces static WebP - `test_thumbnail_handles_png_with_alpha` — RGBA PNG produces valid WebP - `test_thumbnail_does_not_upscale` — 100×100 image stays ≤ 100×100 **Done criterion**: All unit tests pass; `ruff check api/` passes. --- ### M2 — Database migration **Goal**: The `images` table has a nullable `thumbnail_key` column; the ORM model and repository reflect it. **Deliverables**: - `api/alembic/versions/002_add_thumbnail_key.py` — `upgrade` adds `VARCHAR(70) NULLABLE` column; `downgrade` drops it - `api/app/models.py`: add `thumbnail_key: Mapped[str | None]` mapped to `String(70)`, `nullable=True`, `default=None` - `api/app/repositories/image_repo.py`: add `thumbnail_key: str | None = None` parameter to `create()`; persist it on the new `Image` instance **Done criterion**: `alembic upgrade head` runs cleanly inside Docker; all existing 46 integration tests still pass (new column is nullable, no existing test breaks). --- ### M3 — Upload route: generate and store thumbnail **Goal**: Every new upload generates a thumbnail; duplicates reuse the existing record; failures are tolerated without blocking the upload. **TDD first** — new tests in `api/tests/integration/test_upload.py`: - `test_upload_returns_thumbnail_key` — upload response includes non-null `thumbnail_key` ending in `-thumb` - `test_duplicate_upload_reuses_thumbnail_key` — second upload of the same file returns the same `thumbnail_key` as the first - `test_upload_succeeds_when_thumbnail_fails` — patch `generate_thumbnail` to raise, upload returns 200/201 with `thumbnail_key: null` **Implementation** in `api/app/routers/images.py` `upload_image()`: 1. After `await storage.put(hash_hex, data, mime_type)`, attempt thumbnail generation and storage in a try/except; catch any exception and leave `thumbnail_key` as `None` 2. Call `thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)` — `generate_thumbnail` is CPU-bound (Pillow); `asyncio.to_thread` runs it in the default thread pool executor so it does not block the async event loop 3. Pass `thumbnail_key` to `image_repo.create()` 4. Add `"thumbnail_key": image.thumbnail_key` to `_image_to_dict()` **Done criterion**: New tests pass; all 46 existing tests pass. --- ### M4 — New `GET /api/v1/images/{id}/thumbnail` endpoint **Goal**: Clients fetch thumbnail content; falls back to original if no thumbnail exists. **TDD first** — new tests in `api/tests/integration/test_serving.py`: - `test_thumbnail_returns_webp` — upload image, call `/thumbnail`, assert 200, `content-type: image/webp`, ETag, `Cache-Control` with immutable - `test_thumbnail_fallback_returns_original` — set `thumbnail_key=None` on a record, call `/thumbnail`, assert 200 with original mime_type - `test_thumbnail_unknown_id_returns_404` — unknown UUID → 404 `image_not_found` **Implementation**: new route `GET /images/{image_id}/thumbnail` in `api/app/routers/images.py` using `image.thumbnail_key or image.storage_key` to select the key, and `"image/webp" if image.thumbnail_key else image.mime_type` for the content type. Same `ETag` + `Cache-Control` headers as `/file`. **Done criterion**: All new tests pass; all existing tests pass. --- ### M5 — Delete route: remove thumbnail from storage **Goal**: Deleting an image also removes its thumbnail; no orphaned objects left. **TDD first** — new test in `api/tests/integration/test_delete.py`: - `test_delete_removes_thumbnail` — upload image, delete it, then verify `GET /images/{id}/thumbnail` returns 404 **Implementation** in `api/app/routers/images.py` `delete_image()`: after deleting the DB record and the original object, call `await storage.delete(image.thumbnail_key)` if `image.thumbnail_key` is not None. **Done criterion**: New test passes; all existing delete tests pass. --- ### M6 — UI: library grid uses thumbnail endpoint **Goal**: Library grid fetches thumbnails instead of full-size originals; detail page is unchanged. **Deliverables**: - `ui/src/app/services/image.service.ts`: - Add `thumbnail_key: string | null` to `ImageRecord` interface - Add `getThumbnailUrl(id: string): string` returning `/api/v1/images/${id}/thumbnail` - `ui/src/app/library/library.component.ts` + template: replace `getFileUrl(image.id)` with `getThumbnailUrl(image.id)` for grid `` - Update Angular spec files: add `thumbnail_key: null` to all `ImageRecord` mock objects - Verify `ng test` passes and `ng build` succeeds **Done criterion**: Angular build clean; all Angular tests pass; library grid `` elements reference `/thumbnail` not `/file`. ## Post-design Constitution Re-check | Principle | Verdict | |-----------|---------| | §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend`; `thumbnail.py` never touches S3 directly | ✅ | | §2.5 DB abstraction | `thumbnail_key` persisted only through `ImageRepository.create()` | ✅ | | §2.6 No speculative abstraction | One concrete function; no interface | ✅ | | §4.2 Immutability | Thumbnail written once at upload; never mutated | ✅ | | §5.1 TDD | Failing tests written before each milestone's implementation | ✅ | All gates pass. Feature is ready for `/speckit-tasks`.