This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
PhotoMapAI is a local-first image browser for large photo collections. It uses CLIP embeddings to power semantic text/image search and builds a UMAP "semantic map" that clusters images by content. The backend is FastAPI; the frontend is vanilla ES6 modules (no framework) using Swiper.js and Plotly.js. All processing is local — nothing is sent to external services.
# Install for development (Python 3.10–3.13) — uv is preferred (see below)
uv sync --extra testing --extra development # creates .venv + editable install + locked deps
npm install
# (legacy equivalent: pip install -e .[testing,development])
# Run the server (entry point defined in pyproject.toml)
uv run start_photomap # http://localhost:8050 — runs THIS worktree's .venv, no activation
# (or `start_photomap` directly if the .venv is activated)
# Tests
make test # runs npm test + pytest
pytest tests # backend only
pytest tests/backend/test_search.py::test_text_search # single test
npm test # frontend Jest only
NODE_OPTIONS='--experimental-vm-modules' jest tests/frontend/search.test.js # single JS test
# Linting / formatting (CI enforces both)
make lint # runs backend-lint + frontend-lint
ruff check photomap tests --fix
npm run lint:fix
npm run format # prettier write
npm run format:check # CI check
# Docs
make docs # mkdocs serve on :8000
# Branch + worktree for ANY fix/feature/chore (see "Worktrees are mandatory" below)
git worktree add -b lstein/fix/<what-it-does> ../photomap-worktrees/lstein-fix-<what-it-does>
cd ../photomap-worktrees/lstein-fix-<what-it-does>
uv sync --extra testing --extra development
# Keep a branch current with master — rebase, don't merge (see "Keeping branches current")
git fetch origin && git rebase origin/master && git push --force-with-leaseRuff is configured for line-length 120, target py310, rules E/W/F/I/UP/B (see pyproject.toml). Jest runs in jsdom with experimental ESM (the project is "type": "module").
Reading/investigating in the main checkout (/home/lstein/Projects/PhotoMap,
branch master) is fine. But the first time you are about to create, edit, or
delete a file for a fix/feature/chore, STOP: you must be inside a dedicated
worktree on a branch first. Never edit on master in the main checkout — not for
a "quick" one-line change, and not while "still investigating" a bug.
Self-check before your first edit:
git rev-parse --abbrev-ref HEAD # prints "master" => create the worktree firstCreate AND fully initialize the worktree before editing (branch name:
lstein/{fix,feature,chore}/<what-it-does>):
git worktree add -b lstein/fix/<what-it-does> ../photomap-worktrees/lstein-fix-<what-it-does>
cd ../photomap-worktrees/lstein-fix-<what-it-does>
uv sync --extra testing --extra development # creates .venv + editable install + locked deps
npm install # only if you'll run frontend tests/lintuv sync replaces the old python3 -m venv .venv && source .venv/bin/activate && pip install -e ".[development,testing]" dance: it creates the worktree's .venv,
installs PhotoMap editable, and installs the locked deps from uv.lock in one step.
You do not need to activate it — uv run <cmd> (e.g. uv run start_photomap,
uv run pytest tests) always uses the nearest .venv and auto-syncs first, so it
serves this worktree's code. (Activating with source .venv/bin/activate still
works if you want a bare python/REPL.)
Then alert the author that the worktree is initialized and tell them which
directory to run the server from. The worktree's .venv is an editable install
pointing at the worktree, so uv run start_photomap launched from there serves
that worktree's code — skip this and the author ends up testing the wrong files.
PRs are squash-merged, so master stays linear. Keep feature branches current by
rebasing onto master, never by merging master into them (merge commits inside a
branch are discarded at squash time and just complicate conflict resolution):
git fetch origin
git rebase origin/master # from inside the worktree, on your branch
git push --force-with-lease # only if the branch was already pushed; never plain --forceGlobal git is configured with pull.rebase=true and rebase.autoStash=true, so a
stray git pull rebases (rather than making a merge commit) and rebases don't need a
clean tree. After a rebase that pulls in dependency changes, just uv run … — it
auto-syncs the worktree's .venv. Only rebase branches you own (all lstein/* are
solo); never rebase a branch someone else is building on.
photomap_server.py— FastAPI app entry point. Wires up routers, mounts/staticand Jinja2 templates, and defines the top-level/route.start_photomapfrompyproject.tomlrunsmain()here.routers/— one router per API surface:album,search,umap,index,curation,filetree,upgrade. Routers are included inphotomap_server.py;curation_routeris mounted with an explicit/api/curationprefix while the others set their own prefixes.config.py— YAML-backed album config. Access via theget_config_manager()singleton (lru_cached).Albumis a Pydantic model that expands~in image paths. Config lives in a platformdirs user config directory.embeddings.py— CLIP embedding generation and persistence (.npz).imagetool.py— shared CLI entry point forindex_images,update_images,search_images,search_text,find_duplicate_images(all registered as scripts inpyproject.toml).metadata_extraction.py/metadata_formatting.py— pulls EXIF + generator metadata (InvokeAI) out of images and formats for the UI.
This is the area under active refactor (current branch: lstein/feature/refactor-invoke-metadata). InvokeAI writes several incompatible metadata schemas into PNG tEXt chunks; the parser must auto-detect and upgrade.
invokemetadata.pydefinesGenerationMetadataas a PydanticAnnotated[Union[…], Field(discriminator="metadata_version")]overGenerationMetadata2,GenerationMetadata3, andGenerationMetadata5.GenerationMetadataAdapter.parse()inspects fields likecanvas_v2_metadata,app_version, andmodel_weightsto inject the correctmetadata_versionwhen the source JSON predates the discriminator.invoke/holds the per-version schemas:invoke2metadata.py,invoke3metadata.py,invoke5metadata.py, pluscanvas2metadata.pyandcommon_metadata_elements.pyfor shared types.invoke_metadata_view.pyis the version-agnostic facade consumed byinvoke_formatter.py. When adding support for a new InvokeAI version, add a newinvokeNmetadata.py, extend the Union ininvokemetadata.py, teachparse()how to recognize legacy payloads that lack ametadata_versionfield, and extendInvokeMetadataView'sisinstancedispatch.invoke_formatter.py/exif_formatter.pyrender parsed metadata for the drawer UI;slide_summary.pyproduces the compact slideshow caption.invoke-DELETE/is a holdover from the refactor — leave it alone unless cleaning up.
static/javascript/— one ES6 module per feature. No build step; modules are served directly and imported frommain.js/index.js.state.jsis the centralized application state. Prefer extending it over adding new globals.events.jsowns global keyboard shortcuts; register new ones there rather than scattering listeners.localStorageis used for persisted user preferences,sessionStoragefor per-navigation state.templates/— Jinja2 templates rendered by FastAPI.
tests/backend/— pytest.conftest.py+fixtures.pyset up shared fixtures (test images intests/backend/test_images/). Use the FastAPITestClientfor router tests; seetest_search.py,test_albums.py,test_curation.pyas templates.tests/frontend/— Jest with jsdom.setup.jsprovides DOM fixtures. Seetests/frontend/README.mdfor setup notes.
From .github/copilot-instructions.md — the parts that actually affect how you write code here:
- Python: type hints on public functions,
pathlib.Path(notos.path) for file operations, f-strings, imports ordered stdlib → third-party → local (photomapis first-party to isort). Code must passruff check photomap tests. - Pinned quirk:
setuptools<67is intentional — avoids a deprecation warning from the CLIP dependency. Don't "fix" it. - New API endpoints: add/extend a router under
photomap/backend/routers/, use Pydantic models for request/response, include the router inphotomap_server.py, add atests/backend/test_<name>.py. - New frontend features: create a module in
static/javascript/, wire shared state throughstate.js, register shortcuts inevents.js, add a Jest test. - JavaScript: ES6 modules only,
const/let, must passnpm run lintandnpm run format:check.