754 lines
30 KiB
Markdown
754 lines
30 KiB
Markdown
# PhotoVault — Full Application Spec Prompt
|
||
|
||
> A self-hosted, Docker-deployed photo management application inspired by Lightroom's workflow.
|
||
> Use this document as the complete specification to build the app from scratch.
|
||
|
||
---
|
||
|
||
## 1. Project Overview
|
||
|
||
Build **PhotoVault**, a self-hosted photo & video management web application optimized for a single-user homelab deployment. The user mounts one or more host folders containing photos/videos; the app indexes them, generates thumbnails, and provides a fast keyboard-driven interface to browse, organize, tag, and manage the library. The architecture must be forward-compatible with AI photo recognition features (face detection, scene classification, CLIP embeddings) to be added in a later phase.
|
||
|
||
---
|
||
|
||
## 2. Stack & Deployment
|
||
|
||
### 2.1 Docker Compose (single `docker-compose.yml`)
|
||
|
||
```
|
||
services:
|
||
frontend — React SPA (Nginx)
|
||
backend — Python FastAPI
|
||
db — SQLite (file-based, volume-mounted)
|
||
worker — Celery + Redis for background thumbnail/indexing tasks
|
||
redis — Redis (Celery broker)
|
||
```
|
||
|
||
All services declared in one `docker-compose.yml`. Use named volumes for:
|
||
- `/data/thumbs` — generated thumbnails (persistent)
|
||
- `/data/db` — SQLite database file
|
||
- `/data/trash` — files moved to trash
|
||
|
||
Photo source folders are mounted as **read-write** bind mounts via an environment variable:
|
||
|
||
```yaml
|
||
volumes:
|
||
- ${PHOTO_DIRS}:/photos:rw
|
||
```
|
||
|
||
`PHOTO_DIRS` supports multiple paths via a config file (`photovault.yml`) described in §4.
|
||
|
||
### 2.2 Frontend
|
||
|
||
- **React 18** + **Vite**
|
||
- **Tailwind CSS v4**
|
||
- **shadcn/ui** component library
|
||
- **TanStack Query** (React Query) for data fetching & cache
|
||
- **TanStack Virtual** for virtualized scrolling (critical for performance with thousands of photos)
|
||
- **Zustand** for global UI state (selection, active photo, heap, filters)
|
||
- **Framer Motion** for transitions
|
||
|
||
### 2.3 Backend
|
||
|
||
- **Python 3.12 + FastAPI**
|
||
- **SQLite** via **SQLAlchemy 2.0** (async) + **Alembic** for migrations
|
||
- **Celery + Redis** for background tasks (thumbnail generation, folder scanning, metadata extraction)
|
||
- **pyvips** (libvips) for fast thumbnail generation — preferred over Pillow for speed at scale
|
||
- **rawpy** for RAW format decoding (CR2, CR3, NEF, ARW, RAF, DNG, ORF, RW2, etc.)
|
||
- **pillow-heif** for HEIC/HEIF (iPhone photos)
|
||
- **ffmpeg** (via `ffmpeg-python`) for video thumbnail extraction and metadata
|
||
- **pyexiftool** (wraps ExifTool binary) for deep metadata extraction from all formats
|
||
- **Watchfiles** for inotify-based folder watching (auto-detect new/deleted files)
|
||
|
||
> **AI-readiness note**: The backend worker architecture is designed to add a `clip_embed` task later (using `open-clip-torch`) that stores 512-dim CLIP embeddings per photo in the DB. Reserve a `embeddings` table with a `photo_id` FK and a `BLOB` column for the vector. No AI code yet — just the schema placeholder.
|
||
|
||
---
|
||
|
||
## 3. Data Model (SQLite via SQLAlchemy)
|
||
|
||
```sql
|
||
-- Core tables
|
||
|
||
photos (
|
||
id TEXT PRIMARY KEY, -- UUID
|
||
filepath TEXT UNIQUE NOT NULL,
|
||
filename TEXT NOT NULL,
|
||
folder_id TEXT REFERENCES folders(id),
|
||
media_type TEXT NOT NULL, -- 'photo' | 'video' | 'raw' | 'heic'
|
||
original_format TEXT, -- 'CR3', 'NEF', 'HEIC', 'MP4', 'JPEG', etc.
|
||
width INTEGER,
|
||
height INTEGER,
|
||
file_size INTEGER,
|
||
taken_at DATETIME, -- from EXIF DateTimeOriginal, fallback to file mtime
|
||
taken_at_source TEXT, -- 'exif' | 'filesystem' | 'manual'
|
||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME,
|
||
is_trashed BOOLEAN DEFAULT 0,
|
||
trashed_at DATETIME,
|
||
thumb_small TEXT, -- path to 240px thumb
|
||
thumb_medium TEXT, -- path to 640px thumb
|
||
thumb_large TEXT, -- path to 1280px thumb
|
||
exif_json TEXT, -- full EXIF/XMP blob as JSON
|
||
user_title TEXT, -- user-edited title
|
||
user_notes TEXT,
|
||
rating INTEGER DEFAULT 0, -- 0-5 stars
|
||
color_label TEXT, -- 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
|
||
is_picked BOOLEAN DEFAULT 0,
|
||
is_rejected BOOLEAN DEFAULT 0
|
||
)
|
||
|
||
folders (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
path TEXT UNIQUE NOT NULL,
|
||
parent_id TEXT REFERENCES folders(id),
|
||
source_root_id TEXT REFERENCES source_roots(id),
|
||
photo_count INTEGER DEFAULT 0,
|
||
last_scanned DATETIME
|
||
)
|
||
|
||
source_roots (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
path TEXT UNIQUE NOT NULL,
|
||
is_active BOOLEAN DEFAULT 1,
|
||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
|
||
tags (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT UNIQUE NOT NULL,
|
||
color TEXT
|
||
)
|
||
|
||
photo_tags (
|
||
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
|
||
tag_id TEXT REFERENCES tags(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (photo_id, tag_id)
|
||
)
|
||
|
||
heaps (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME
|
||
)
|
||
|
||
heap_photos (
|
||
heap_id TEXT REFERENCES heaps(id) ON DELETE CASCADE,
|
||
photo_id TEXT REFERENCES photos(id) ON DELETE CASCADE,
|
||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
sort_order INTEGER DEFAULT 0,
|
||
PRIMARY KEY (heap_id, photo_id)
|
||
)
|
||
|
||
-- AI-readiness placeholder (no implementation yet)
|
||
embeddings (
|
||
photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
|
||
model TEXT, -- e.g. 'clip-vit-b32'
|
||
vector BLOB -- raw float32 bytes
|
||
)
|
||
```
|
||
|
||
**Indexes**: Create indexes on `photos.taken_at`, `photos.folder_id`, `photos.is_trashed`, `photos.rating`, `photos.color_label`, `photo_tags.tag_id`.
|
||
|
||
---
|
||
|
||
## 4. Configuration
|
||
|
||
App is configured via a `photovault.yml` file mounted into the backend container:
|
||
|
||
```yaml
|
||
source_roots:
|
||
- name: "Main Library"
|
||
path: /photos/main
|
||
- name: "iPhone Imports"
|
||
path: /photos/iphone
|
||
|
||
thumbnails:
|
||
small: 240 # px, longest edge
|
||
medium: 640
|
||
large: 1280
|
||
quality: 85 # JPEG quality
|
||
format: webp # output format for thumbs
|
||
|
||
scanner:
|
||
watch: true # use watchfiles inotify
|
||
initial_scan_on_start: true
|
||
|
||
trash:
|
||
path: /data/trash
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Backend API (FastAPI)
|
||
|
||
All routes under `/api/v1/`. Authentication: none (single-user, homelab). Use async SQLAlchemy sessions.
|
||
|
||
### 5.1 Photos
|
||
|
||
```
|
||
GET /photos List photos (pagination + filters — see §5.5)
|
||
GET /photos/{id} Get single photo with full EXIF + tags
|
||
GET /photos/{id}/thumb/{size} Serve thumbnail (small|medium|large) — use X-Accel-Redirect for Nginx
|
||
GET /photos/{id}/original Serve original file (download)
|
||
PATCH /photos/{id} Update: user_title, user_notes, rating, color_label, is_picked, is_rejected, taken_at (manual override)
|
||
DELETE /photos/{id} Move to trash (sets is_trashed=1, moves file to /data/trash)
|
||
POST /photos/bulk Bulk actions: { ids: [], action: 'trash'|'restore'|'delete_permanent'|'move'|'copy'|'add_tag'|'remove_tag'|'set_rating'|'set_color'|'pick'|'reject' }
|
||
POST /photos/bulk/move Move files to a target folder_id
|
||
POST /photos/bulk/copy Copy files to a target folder_id
|
||
```
|
||
|
||
### 5.2 Folders
|
||
|
||
```
|
||
GET /folders Folder tree (nested, with photo_count)
|
||
GET /folders/{id}/photos Photos in folder (supports same filters as /photos)
|
||
POST /folders Create folder (creates directory on disk)
|
||
PATCH /folders/{id} Rename folder (renames directory on disk)
|
||
DELETE /folders/{id} Delete folder — requires folder to be empty
|
||
POST /folders/{id}/scan Trigger manual re-scan of folder
|
||
```
|
||
|
||
### 5.3 Heaps
|
||
|
||
```
|
||
GET /heaps List all heaps
|
||
POST /heaps Create heap { name }
|
||
GET /heaps/{id} Get heap with photos
|
||
PATCH /heaps/{id} Rename heap
|
||
DELETE /heaps/{id} Delete heap (does NOT delete photos)
|
||
POST /heaps/{id}/photos Add photos { photo_ids: [] }
|
||
DELETE /heaps/{id}/photos Remove photos { photo_ids: [] }
|
||
POST /heaps/{id}/convert Convert heap to folder on disk: { target_path, move: bool }
|
||
```
|
||
|
||
### 5.4 Tags
|
||
|
||
```
|
||
GET /tags List all tags with usage counts
|
||
POST /tags Create tag
|
||
PATCH /tags/{id} Rename / recolor tag
|
||
DELETE /tags/{id} Delete tag (removes from all photos)
|
||
GET /tags/{id}/photos Photos with this tag
|
||
```
|
||
|
||
### 5.5 Filters & Search
|
||
|
||
All list endpoints support these query parameters:
|
||
|
||
```
|
||
q Full-text search (filename, user_title, user_notes, EXIF JSON)
|
||
date_from ISO8601 datetime
|
||
date_to ISO8601 datetime
|
||
folder_id Filter by folder (recursive if include_subfolders=true)
|
||
tag_ids Comma-separated tag IDs (AND logic by default; mode=or for OR)
|
||
media_type photo|video|raw|heic (comma-separated for multiple)
|
||
rating_min 0-5
|
||
rating_max 0-5
|
||
color_label red|orange|yellow|green|blue|purple|none
|
||
is_picked true|false
|
||
is_rejected true|false
|
||
is_trashed true|false (default false)
|
||
heap_id Filter to photos in a specific heap
|
||
sort taken_at|added_at|filename|file_size|rating (default taken_at)
|
||
order asc|desc (default desc)
|
||
page integer (default 1)
|
||
per_page integer (default 100, max 500)
|
||
```
|
||
|
||
Full-text search uses SQLite FTS5. Create a virtual FTS table:
|
||
```sql
|
||
CREATE VIRTUAL TABLE photos_fts USING fts5(
|
||
photo_id UNINDEXED,
|
||
filename,
|
||
user_title,
|
||
user_notes,
|
||
exif_text -- denormalized key EXIF fields as plain text (camera make/model, GPS, lens, etc.)
|
||
);
|
||
```
|
||
|
||
### 5.6 Trash
|
||
|
||
```
|
||
GET /trash List trashed photos (same filters)
|
||
POST /trash/restore Restore { photo_ids: [] } — moves files back to original folder
|
||
DELETE /trash/empty Permanently delete all trashed photos + files
|
||
DELETE /trash/{id} Permanently delete single photo + file
|
||
```
|
||
|
||
### 5.7 Library Stats & Scanning
|
||
|
||
```
|
||
GET /library/stats { total_photos, total_videos, total_size, last_scan }
|
||
POST /library/scan Trigger full re-scan (Celery task)
|
||
GET /library/scan/status { status, progress, current_folder, queued, done }
|
||
```
|
||
|
||
### 5.8 Background Tasks (Celery)
|
||
|
||
- `scan_folder(folder_path)` — Walk directory tree, insert/update photos, detect deletions
|
||
- `generate_thumbs(photo_id)` — Generate small/medium/large WebP thumbnails via pyvips/rawpy/ffmpeg
|
||
- `extract_metadata(photo_id)` — Run ExifTool, parse EXIF/XMP/IPTC, update DB
|
||
- `watch_folders()` — Long-running Watchfiles task, dispatches scan_folder on changes
|
||
- `embed_photo(photo_id)` *(placeholder, no-op)* — Reserved for CLIP embeddings
|
||
|
||
**Priority queues**: Thumbnail generation for visible photos should be on a `high` queue; full library scans on a `low` queue.
|
||
|
||
---
|
||
|
||
## 6. Frontend Architecture
|
||
|
||
### 6.1 Layout
|
||
|
||
Three-pane layout (similar to Lightroom Library module):
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ TOP BAR [Logo] [Search] [Filters bar] [View mode] [Heap] │
|
||
├──────────┬──────────────────────────────────┬───────────────┤
|
||
│ │ │ │
|
||
│ LEFT │ MAIN TIMELINE │ RIGHT │
|
||
│ SIDEBAR │ (continuous scroll, │ SIDEBAR │
|
||
│ │ sticky date headers, │ (metadata │
|
||
│ Folder │ virtualized thumbnail │ panel for │
|
||
│ tree │ grid) │ selected │
|
||
│ │ │ photo) │
|
||
│ Heaps │ │ │
|
||
│ list │ │ │
|
||
│ │ │ │
|
||
│ Tags │ │ │
|
||
└──────────┴──────────────────────────────────┴───────────────┘
|
||
```
|
||
|
||
- Left sidebar: resizable, collapsible (shortcut: `Tab`)
|
||
- Right sidebar: collapsible (shortcut: `I`), shows when ≥1 photo selected
|
||
- Main area: full virtualized scroll, single scroll region
|
||
|
||
### 6.2 Views
|
||
|
||
| View | Shortcut | Description |
|
||
|------|----------|-------------|
|
||
| Grid (Library) | `G` | Default timeline thumbnail grid |
|
||
| Loupe (Fullscreen) | `E` | Single photo full-viewport view |
|
||
| Compare | `C` | Side-by-side compare of 2 selected photos |
|
||
|
||
### 6.3 Timeline View (Grid)
|
||
|
||
- **Continuous vertical scroll** with **sticky date headers** that label each date group (Year / Month / Day — configurable via a "Group by" dropdown: Year, Month, Day, Week, Folder)
|
||
- Thumbnails rendered via **TanStack Virtual** — only DOM nodes in/near viewport are rendered
|
||
- Thumbnail grid is **responsive** — uses CSS grid with `auto-fill` and configurable thumbnail size (slider or `+/-` keys)
|
||
- Thumbnails show: image, hover overlay with filename, EXIF date, optional rating stars
|
||
- **Lazy thumbnail loading**: request `thumb_small` initially; upgrade to `thumb_medium` on hover/selection
|
||
- On initial scan, show a shimmer skeleton for photos without thumbnails yet; poll backend for thumb completion
|
||
|
||
### 6.4 Keyboard Shortcuts (Lightroom-style)
|
||
|
||
#### Navigation (Grid mode)
|
||
| Key | Action |
|
||
|-----|--------|
|
||
| `←` `→` `↑` `↓` | Move cursor one photo in direction |
|
||
| `Shift+←/→/↑/↓` | Extend selection |
|
||
| `Cmd/Ctrl+A` | Select all |
|
||
| `Cmd/Ctrl+Shift+A` | Deselect all |
|
||
| `Space` | Quick preview (fullscreen loupe, hold) |
|
||
| `Enter` | Open loupe view |
|
||
| `Esc` | Deselect / close loupe |
|
||
| `Home` / `End` | Jump to first / last photo |
|
||
| `Page Up/Down` | Scroll by screen height |
|
||
|
||
#### Navigation (Loupe mode)
|
||
| Key | Action |
|
||
|-----|--------|
|
||
| `←` `→` | Previous / next photo |
|
||
| `Esc` | Return to grid |
|
||
| `Z` | Toggle zoom (fit ↔ 100%) |
|
||
| `+` / `-` | Zoom in / out |
|
||
|
||
#### Flagging & Rating
|
||
| Key | Action |
|
||
|-----|--------|
|
||
| `P` | Pick (flag) |
|
||
| `X` | Reject |
|
||
| `U` | Unflag |
|
||
| `1–5` | Set star rating |
|
||
| `0` | Remove star rating |
|
||
| `6` | Red label |
|
||
| `7` | Orange label |
|
||
| `8` | Yellow label |
|
||
| `9` | Green label |
|
||
|
||
#### Actions
|
||
| Key | Action |
|
||
|-----|--------|
|
||
| `G` | Go to grid view |
|
||
| `E` | Go to loupe view |
|
||
| `C` | Compare view (2 selected) |
|
||
| `Tab` | Toggle left sidebar |
|
||
| `I` | Toggle right metadata sidebar |
|
||
| `\` | Toggle filter bar |
|
||
| `F` | Toggle fullscreen |
|
||
| `Delete` | Move selected to trash |
|
||
| `Shift+Delete` | Permanently delete (if in trash view) |
|
||
| `Cmd/Ctrl+Z` | Undo last action |
|
||
| `Cmd/Ctrl+Shift+Z` | Redo |
|
||
| `Cmd/Ctrl+C` | Copy selected to clipboard (for move/copy target) |
|
||
| `Cmd/Ctrl+X` | Cut selected (for move) |
|
||
| `Cmd/Ctrl+V` | Paste into current folder |
|
||
| `T` | Add/remove from active heap |
|
||
| `Cmd/Ctrl+F` | Focus search bar |
|
||
| `/` | Focus search bar |
|
||
| `?` | Show keyboard shortcut reference overlay |
|
||
|
||
All shortcuts must work without modifier unless noted. Shortcuts must be suppressed when focus is inside an input/textarea.
|
||
|
||
### 6.5 Bulk Selection
|
||
|
||
- **Click** — select single photo (deselects others)
|
||
- **Shift+Click** — range select from last selected to clicked
|
||
- **Cmd/Ctrl+Click** — toggle individual photo in selection
|
||
- **Cmd/Ctrl+A** — select all visible
|
||
- A **selection bar** appears at the top of the main area when ≥2 photos selected, showing count and bulk action buttons: Rate, Color Label, Tag, Add to Heap, Move, Copy, Trash, Export
|
||
- Bulk actions call `POST /api/v1/photos/bulk`
|
||
|
||
### 6.6 Metadata Sidebar (Right Panel)
|
||
|
||
When a photo is selected, the right sidebar shows:
|
||
|
||
**Section: Preview**
|
||
- Large thumbnail (clicking opens loupe)
|
||
- Filename (editable inline, renames file on disk)
|
||
- User title (editable)
|
||
- User notes (textarea)
|
||
- Rating (5-star widget, keyboard-interactive)
|
||
- Color label (color dot picker)
|
||
- Flags: Picked / Rejected toggles
|
||
|
||
**Section: Tags**
|
||
- Tag chips with remove button
|
||
- "Add tag" autocomplete input
|
||
- Create new tag inline
|
||
|
||
**Section: EXIF / Metadata**
|
||
Collapsible groups:
|
||
- *Camera*: Make, Model, Serial, Lens, Firmware
|
||
- *Capture*: Date Taken (editable override), Shutter Speed, Aperture, ISO, Focal Length, EV, Flash, White Balance, Metering Mode
|
||
- *File*: Format, Dimensions, File Size, Color Space, Bit Depth
|
||
- *Location*: GPS lat/lon shown on a small Leaflet.js map tile if available; altitude, country, city (reverse-geocoded via nominatim.openstreetmap.org on demand)
|
||
- *IPTC/XMP*: Copyright, Creator, Description, Keywords
|
||
|
||
**Section: Histogram** (stretch goal)
|
||
- Live RGB+Luminosity histogram rendered from a downsampled version of the photo
|
||
|
||
### 6.7 Filter Bar
|
||
|
||
A collapsible horizontal bar below the top bar (shortcut `\`). Contains:
|
||
|
||
| Control | Type |
|
||
|---------|------|
|
||
| Date range | Date range picker (from/to) |
|
||
| Media type | Multi-select chips: Photo / Video / RAW / HEIC |
|
||
| Rating | Min/max star slider |
|
||
| Color label | Color dot multi-select |
|
||
| Flags | Picked / Rejected / Unflagged toggle buttons |
|
||
| Tags | Multi-select tag dropdown (AND/OR mode toggle) |
|
||
| Camera make | Dropdown (populated from DB) |
|
||
| Lens | Dropdown (populated from DB) |
|
||
|
||
Active filters shown as removable chips in the filter bar. "Clear all" button. Filter state persists in URL query params for shareability/bookmarks.
|
||
|
||
### 6.8 Search
|
||
|
||
- Magnifier icon in top bar, shortcut `/` or `Cmd+F`
|
||
- Full-text search via FTS5 backend
|
||
- Search covers: filename, user title, user notes, camera make/model, lens, GPS place names, tags
|
||
- Results appear inline in the current view (no separate search results page)
|
||
- Search combined with active filters (additive)
|
||
|
||
### 6.9 Folder Tree (Left Sidebar)
|
||
|
||
- Hierarchical tree view of all source roots and their subfolder structure
|
||
- Each folder shows photo count badge
|
||
- Right-click context menu: New Subfolder, Rename, Move Photos Here, Scan Now, Copy Path
|
||
- Drag-and-drop folders to rearrange (moves directory on disk with confirmation)
|
||
- "All Photos" virtual root node at top
|
||
- "Trash" virtual node at bottom with count badge
|
||
|
||
### 6.10 Heaps Panel (Left Sidebar)
|
||
|
||
- List of named heaps below folder tree
|
||
- "+ New Heap" button (creates unnamed heap, prompts for name)
|
||
- Each heap shows photo count
|
||
- Click heap → main area shows heap contents in grid
|
||
- Right-click context menu: Rename, Convert to Folder (prompts for target path + move/copy choice), Delete Heap, Clear Heap
|
||
- **Active Heap indicator**: One heap can be set as "active" (bold + icon). Pressing `T` adds/removes the selected photo(s) from the active heap.
|
||
- A persistent "current heap" pill shown in the top bar when a heap is active
|
||
|
||
### 6.11 Loupe View
|
||
|
||
- Single photo, full-viewport
|
||
- Original-quality image (served from backend, format-agnostic — backend transcodes RAW/HEIC to JPEG/WebP on the fly for web display)
|
||
- Zoom: fit-to-window ↔ 100% (toggle `Z`), scroll wheel to zoom, drag to pan at 100%+
|
||
- Filmstrip at bottom: horizontally scrollable strip of thumbnails (current context — same folder or heap), keyboard navigable
|
||
- Left panel collapse, right metadata panel still accessible
|
||
- For videos: HTML5 `<video>` player with controls, muted autoplay of preview, unmute toggle
|
||
|
||
### 6.12 Trash View
|
||
|
||
- Accessible via "Trash" node in sidebar
|
||
- Same grid layout, same filters, same shortcuts
|
||
- Extra actions in bulk selection bar: Restore, Permanently Delete
|
||
- "Empty Trash" button at top with confirmation dialog showing count + total size
|
||
|
||
### 6.13 Library Scan Progress
|
||
|
||
- On first launch or manual scan trigger: a non-blocking progress bar in the top bar
|
||
- Shows: `Scanning… 1,234 / 12,456 photos indexed`
|
||
- Photos appear in the timeline as they are indexed (optimistic streaming via polling `GET /library/scan/status` every 2s)
|
||
|
||
---
|
||
|
||
## 7. Media Handling
|
||
|
||
### 7.1 Supported Formats
|
||
|
||
| Category | Formats |
|
||
|----------|---------|
|
||
| JPEG | `.jpg`, `.jpeg` |
|
||
| PNG | `.png` |
|
||
| TIFF | `.tif`, `.tiff` |
|
||
| WebP | `.webp` |
|
||
| HEIC/HEIF | `.heic`, `.heif` (via pillow-heif) |
|
||
| RAW — Canon | `.cr2`, `.cr3` |
|
||
| RAW — Nikon | `.nef`, `.nrw` |
|
||
| RAW — Sony | `.arw`, `.srf` |
|
||
| RAW — Fuji | `.raf` |
|
||
| RAW — Panasonic | `.rw2` |
|
||
| RAW — Olympus | `.orf` |
|
||
| RAW — Samsung | `.srw` |
|
||
| RAW — Pentax | `.pef` |
|
||
| RAW — Leica | `.rwl`, `.dng` |
|
||
| RAW — DNG (universal) | `.dng` |
|
||
| RAW — Others | via rawpy (libraw) fallback |
|
||
| Video | `.mp4`, `.mov`, `.avi`, `.mkv`, `.mts`, `.m2ts`, `.3gp` |
|
||
| Live Photos | `.heic` + `.mov` pair (detect by matching base filename) |
|
||
|
||
### 7.2 Thumbnail Generation Pipeline
|
||
|
||
For each photo during indexing:
|
||
1. Detect format by extension + magic bytes
|
||
2. Decode to in-memory RGB image:
|
||
- JPEG/PNG/TIFF/WebP → pyvips native
|
||
- HEIC/HEIF → pillow-heif → pyvips
|
||
- RAW → rawpy (half-size decode for speed) → numpy → pyvips
|
||
- Video → ffmpeg extract frame at 10% duration → pyvips
|
||
3. Auto-rotate by EXIF orientation
|
||
4. Generate 3 sizes: 240px, 640px, 1280px (longest edge, maintain AR)
|
||
5. Save as WebP (quality 85) to `/data/thumbs/{photo_id}/{size}.webp`
|
||
6. Update `thumb_small`, `thumb_medium`, `thumb_large` columns in DB
|
||
|
||
For web display of original RAW/HEIC in loupe view: generate a full-res WebP proxy on demand (cached). Serve via `GET /photos/{id}/proxy`.
|
||
|
||
### 7.3 Metadata Extraction
|
||
|
||
Run ExifTool (subprocess) on every file during indexing. Parse output JSON. Store:
|
||
- `taken_at` — prefer `DateTimeOriginal`, fallback: `CreateDate`, `MediaCreateDate`, file mtime
|
||
- GPS coordinates if present
|
||
- All EXIF/IPTC/XMP fields stored as JSON in `exif_json`
|
||
- Denormalize key fields to FTS table for search
|
||
|
||
For Live Photos: link the `.mov` sidecar to the `.heic` via a `live_photo_video_id` FK on the photos table.
|
||
|
||
---
|
||
|
||
## 8. File Operations
|
||
|
||
All file operations that touch disk must:
|
||
1. Validate target path is within a known source_root (prevent path traversal)
|
||
2. Execute atomically where possible (temp file + rename)
|
||
3. Update DB after successful disk operation (never before)
|
||
4. Emit a WebSocket event (or SSE) so the frontend can update optimistically
|
||
5. Be undoable via Undo stack (store reverse operation in memory, max 50 ops)
|
||
|
||
### Operations
|
||
|
||
| Operation | Disk action | DB action |
|
||
|-----------|-------------|-----------|
|
||
| Move photos | `shutil.move` | Update `filepath`, `folder_id` |
|
||
| Copy photos | `shutil.copy2` | Insert new photo record |
|
||
| Rename file | `os.rename` | Update `filepath`, `filename` |
|
||
| Rename folder | `os.rename` | Update folder `path` recursively |
|
||
| Create folder | `os.makedirs` | Insert folder record |
|
||
| Trash photo | Move to `/data/trash/{id}/original.{ext}` | Set `is_trashed=1`, `trashed_at` |
|
||
| Restore from trash | Move back to original path (or new path if original gone) | Clear `is_trashed` |
|
||
| Permanent delete | `os.unlink` | Delete photo record (cascade to tags, heaps) |
|
||
| Convert heap to folder | `os.makedirs(target)` + move/copy each photo | Insert folder, update photo folder_id |
|
||
|
||
---
|
||
|
||
## 9. Performance Requirements
|
||
|
||
- **Initial page load**: < 2s (LCP)
|
||
- **Timeline scroll** (10,000+ photos): 60 fps — enforced by TanStack Virtual (only ~20-30 DOM nodes rendered at any time)
|
||
- **Thumbnail serve**: < 50ms via Nginx X-Accel-Redirect (backend sets header, Nginx serves file directly)
|
||
- **Search**: < 200ms for FTS5 query on 100k photos
|
||
- **Thumbnail generation**: ≥ 10 photos/sec on typical homelab CPU (pyvips is ~10x faster than Pillow)
|
||
- **Scan throughput**: ≥ 500 files/sec metadata scan (ExifTool batch mode processes files in bulk)
|
||
- **Celery workers**: 4 concurrent workers by default (`CELERYD_CONCURRENCY=4` env var)
|
||
- Images not yet thumbnailed show a shimmer skeleton; thumbnails stream into view as they complete
|
||
|
||
---
|
||
|
||
## 10. UI Design System
|
||
|
||
### 10.1 Aesthetic
|
||
|
||
Dark-first application (photography tools are dark-themed to preserve color perception). Light mode available via toggle.
|
||
|
||
- **Dark mode primary surface**: Near-black warm dark `#111110`, not cold gray
|
||
- **Accent**: Desaturated teal — does not compete with photo colors
|
||
- **Typography**: `Geist` (body, UI chrome) + `Geist Mono` (metadata values, EXIF numbers)
|
||
- Dense UI — this is a power tool, not a consumer app. Compact spacing.
|
||
- Inspired by: Lightroom Classic, Linear, Darkroom (iOS)
|
||
|
||
### 10.2 Key UI Components (shadcn/ui)
|
||
|
||
Use these shadcn/ui primitives: `Button`, `ContextMenu`, `Dialog`, `DropdownMenu`, `Input`, `Label`, `Popover`, `ScrollArea`, `Separator`, `Sheet` (for mobile sidebar), `Skeleton`, `Slider`, `Switch`, `Tabs`, `Textarea`, `Toast`, `Tooltip`
|
||
|
||
Build custom components:
|
||
- `<PhotoThumbnail>` — thumbnail with selection state, pick/reject badges, rating overlay on hover
|
||
- `<TimelineGroup>` — sticky date header + grid of thumbnails
|
||
- `<VirtualTimeline>` — TanStack Virtual wrapper over TimelineGroups
|
||
- `<FilmStrip>` — horizontal scrollable strip for loupe view
|
||
- `<StarRating>` — interactive 0-5 stars
|
||
- `<ColorLabel>` — 7-state color dot picker
|
||
- `<MetadataRow>` — label + value pair with edit-in-place for editable fields
|
||
- `<FolderTreeNode>` — recursive folder tree item with context menu
|
||
- `<HeapItem>` — heap list item with active indicator
|
||
- `<FilterChip>` — removable active filter chip
|
||
- `<ProgressBar>` — scan progress in top bar
|
||
- `<ShortcutReference>` — `?` overlay showing all shortcuts in a modal
|
||
|
||
### 10.3 Color Scheme Variables
|
||
|
||
```css
|
||
/* Dark mode (default for photo apps) */
|
||
--color-bg: #111110;
|
||
--color-surface: #161615;
|
||
--color-surface-2: #1c1c1a;
|
||
--color-surface-offset: #222220;
|
||
--color-border: rgba(255,255,255,0.08);
|
||
--color-text: #e8e6e0;
|
||
--color-text-muted: #878580;
|
||
--color-text-faint: #4a4845;
|
||
--color-primary: #4f98a3; /* desaturated teal */
|
||
--color-pick: #4f9e5c; /* green for picked */
|
||
--color-reject: #c25a5a; /* red for rejected */
|
||
--color-star: #d4a340; /* amber for stars */
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Error States & Edge Cases
|
||
|
||
- **File not found on disk** (moved externally): Show "missing file" badge on thumbnail. Offer "Locate File" action.
|
||
- **Corrupt/unreadable file**: Log error, show broken-image placeholder, never crash the scan worker.
|
||
- **Duplicate detection**: On scan, if a file with the same SHA-256 hash already exists in DB, mark as `is_duplicate=true` — do not create a second record. Show duplicate indicator in thumbnail.
|
||
- **Scan in progress + user navigates**: Show partial results immediately as photos are indexed.
|
||
- **Disk full**: Catch `OSError` on thumbnail write, log, continue scan.
|
||
- **RAW decode failure**: Fall back to extracting the embedded JPEG preview from the RAW file (ExifTool can extract it).
|
||
|
||
---
|
||
|
||
## 12. Stretch Goals (Phase 2 — Not in Initial Build)
|
||
|
||
These must not be built now but the architecture must not block them:
|
||
|
||
1. **AI Scene Classification** — CLIP embeddings per photo, semantic search ("find photos with mountains")
|
||
2. **Face Detection & Clustering** — face_recognition lib or InsightFace, cluster by identity
|
||
3. **Smart Albums** — saved filter presets that auto-populate (e.g., "5-star Canon shots from 2024")
|
||
4. **Duplicate Finder** — perceptual hash (pHash) across library
|
||
5. **Export Presets** — resize + watermark + format conversion on export
|
||
6. **Multi-user** — add FastAPI auth (JWT), per-user libraries
|
||
7. **Mobile PWA** — service worker, offline thumbnail caching
|
||
|
||
---
|
||
|
||
## 13. Docker Compose File Structure
|
||
|
||
```
|
||
photovault/
|
||
├── docker-compose.yml
|
||
├── photovault.yml ← user config
|
||
├── .env ← PHOTO_DIRS, REDIS_URL, etc.
|
||
├── frontend/
|
||
│ ├── Dockerfile
|
||
│ ├── package.json
|
||
│ ├── vite.config.ts
|
||
│ └── src/
|
||
│ ├── main.tsx
|
||
│ ├── App.tsx
|
||
│ ├── store/ ← Zustand stores
|
||
│ ├── components/
|
||
│ │ ├── layout/
|
||
│ │ ├── timeline/
|
||
│ │ ├── loupe/
|
||
│ │ ├── sidebar/
|
||
│ │ ├── metadata/
|
||
│ │ └── shared/
|
||
│ ├── hooks/
|
||
│ ├── api/ ← TanStack Query hooks + axios client
|
||
│ └── lib/
|
||
│ └── shortcuts.ts ← global keyboard shortcut registry
|
||
└── backend/
|
||
├── Dockerfile
|
||
├── requirements.txt
|
||
├── alembic/
|
||
├── app/
|
||
│ ├── main.py ← FastAPI app
|
||
│ ├── config.py ← pydantic settings
|
||
│ ├── database.py ← SQLAlchemy async engine
|
||
│ ├── models/ ← SQLAlchemy ORM models
|
||
│ ├── schemas/ ← Pydantic request/response schemas
|
||
│ ├── routers/ ← FastAPI routers per domain
|
||
│ │ ├── photos.py
|
||
│ │ ├── folders.py
|
||
│ │ ├── heaps.py
|
||
│ │ ├── tags.py
|
||
│ │ ├── trash.py
|
||
│ │ └── library.py
|
||
│ ├── services/ ← Business logic
|
||
│ │ ├── scanner.py
|
||
│ │ ├── thumbnailer.py
|
||
│ │ ├── metadata.py
|
||
│ │ └── file_ops.py
|
||
│ └── tasks/ ← Celery tasks
|
||
│ ├── celery.py
|
||
│ ├── scan.py
|
||
│ └── thumbs.py
|
||
└── nginx.conf ← X-Accel-Redirect for thumb serving
|
||
```
|
||
|
||
---
|
||
|
||
## 14. Implementation Priorities
|
||
|
||
Build in this order to get a working MVP as fast as possible:
|
||
|
||
1. **Docker Compose skeleton** — all services up, health checks passing
|
||
2. **DB schema + Alembic migration**
|
||
3. **Folder scanner + thumbnail generator** (Celery tasks) — the core engine
|
||
4. **`GET /photos` + `GET /photos/{id}/thumb/{size}`** — minimum API to display photos
|
||
5. **Frontend: VirtualTimeline + PhotoThumbnail** — display the library
|
||
6. **Frontend: keyboard navigation + selection**
|
||
7. **Frontend: left sidebar (folder tree + heaps)**
|
||
8. **Frontend: right sidebar (metadata panel) + EXIF display**
|
||
9. **Filter bar + search**
|
||
10. **Loupe view with filmstrip**
|
||
11. **File operations: move, copy, rename, trash, restore**
|
||
12. **Metadata editing: title, notes, rating, color label, tags**
|
||
13. **Heaps: create, populate, convert to folder**
|
||
14. **Trash view + permanent delete**
|
||
15. **Polish: undo/redo, bulk actions, duplicate detection, live scan progress**
|