- 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>
12 KiB
Implementation Plan: Upload Thumbnails
Branch: 003-upload-thumbnails | Date: 2026-05-03 | Spec: 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)
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
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.0to[project.dependencies]inapi/pyproject.toml - Create
api/app/thumbnail.pywithgenerate_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 bytestest_thumbnail_fits_within_400px— both dimensions ≤ 400test_thumbnail_preserves_aspect_ratio— ratio within 1% of originaltest_thumbnail_handles_gif_first_frame— GIF input produces static WebPtest_thumbnail_handles_png_with_alpha— RGBA PNG produces valid WebPtest_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—upgradeaddsVARCHAR(70) NULLABLEcolumn;downgradedrops itapi/app/models.py: addthumbnail_key: Mapped[str | None]mapped toString(70),nullable=True,default=Noneapi/app/repositories/image_repo.py: addthumbnail_key: str | None = Noneparameter tocreate(); persist it on the newImageinstance
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-nullthumbnail_keyending in-thumbtest_duplicate_upload_reuses_thumbnail_key— second upload of the same file returns the samethumbnail_keyas the firsttest_upload_succeeds_when_thumbnail_fails— patchgenerate_thumbnailto raise, upload returns 200/201 withthumbnail_key: null
Implementation in api/app/routers/images.py upload_image():
- After
await storage.put(hash_hex, data, mime_type), attempt thumbnail generation and storage in a try/except; catch any exception and leavethumbnail_keyasNone - Call
thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)—generate_thumbnailis CPU-bound (Pillow);asyncio.to_threadruns it in the default thread pool executor so it does not block the async event loop - Pass
thumbnail_keytoimage_repo.create() - Add
"thumbnail_key": image.thumbnail_keyto_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-Controlwith immutabletest_thumbnail_fallback_returns_original— setthumbnail_key=Noneon a record, call/thumbnail, assert 200 with original mime_typetest_thumbnail_unknown_id_returns_404— unknown UUID → 404image_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 verifyGET /images/{id}/thumbnailreturns 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 | nulltoImageRecordinterface - Add
getThumbnailUrl(id: string): stringreturning/api/v1/images/${id}/thumbnail
- Add
ui/src/app/library/library.component.ts+ template: replacegetFileUrl(image.id)withgetThumbnailUrl(image.id)for grid<img src>- Update Angular spec files: add
thumbnail_key: nullto allImageRecordmock objects - Verify
ng testpasses andng buildsucceeds
Done criterion: Angular build clean; all Angular tests pass; library grid
<img> 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.