Files
mule-image/photovault-app-prompt.md
2026-04-06 23:30:19 +02:00

30 KiB
Raw Blame History

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:

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)

-- 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:

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

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:

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
15 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.

  • 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

/* 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