feat: structure
This commit is contained in:
753
photovault-app-prompt.md
Normal file
753
photovault-app-prompt.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# 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**
|
||||
Reference in New Issue
Block a user