A local web app for culling large photo shoots. It scans a folder, generates cached thumbnails and previews for JPEG, camera RAW and HEIC, and puts every keep/reject/rate decision under the left hand so you never have to leave the keyboard.

Grid view of a photo shoot with keep and reject flags, star ratings and RAW+JPG badges

The grid: lazy-loaded cached thumbnails, flags, ratings, and a live 'N to cull' counter.

Motivation

I came back from a trip with a few thousand photos, and the slow part was never the editing, it was deciding which shots were worth editing at all. Lightroom can do this, but it wants a catalog and an import step first, and it takes a lot of patience per frame. I wanted something that opens a folder instantly and keeps my hand on the home row until the folder is done. Marks live in a SQLite database keyed by file path, so nothing on disk gets touched until an explicit export step, and nothing leaves the machine.

Loupe view with the edit sidebar showing histogram, EXIF info, crop and tone controls

Loupe view: scroll to zoom, drag to pan, and a non-destructive edit sidebar with a live histogram that overlays the original's tone curve.

Features

  • One-handed culling: Shortcuts sit around the ESDF nav cluster so the right hand stays free for the mouse. W keeps, R rejects, 1-5 rates, U undoes the last verdict, and flags auto-advance to the next photo
  • Loupe view with a non-destructive editor: Scroll to zoom, drag to pan, and a sidebar for rotate, straighten, flip, crop, auto-levels, white balance, border and tone. Sliders preview live using CSS/SVG filters that match the server's math, and the real render settles in once you let go. Edits go into a per-photo undo/redo stack, can be copied between photos, and saved as presets
  • Compare and tournament voting: Select up to three near-identical frames and compare them with synchronized zoom and pan; one keystroke keeps the winner and rejects the rest. Select more than three and it becomes a king-of-the-hill playoff until one frame beats all the others
  • RAW + JPEG pairing: A same-named RAW and JPEG collapse into one grid item, so marking, comparing, exporting and deleting always act on the whole pair
  • Export that respects your edits: Copy or move keepers, rejects or rated shots into a folder, optionally preserving the subfolder structure and re-encoding JPEGs at a chosen quality. Edits are baked into the export, unedited files are copied byte-for-byte, and an edited RAW is never overwritten by a move
  • Deliberate deletion: "Delete rejected" opens a review grid of every reject; click any to spare it, then delete only what's still highlighted, to the Recycle Bin where available
Three photos side by side in compare mode with synchronized zoom

Compare mode: up to three frames with synced zoom and pan, zoom one and they all zoom. Press S/D/F to keep one and reject the rest.

Tech Stack

  • Python, FastAPI and Uvicorn on the backend, served on localhost only
  • Pillow for decoding and the edit pipeline, rawpy/LibRaw for camera RAW, pillow-heif for HEIC/HEIF
  • SQLite for marks, ratings and the edit stack, plus a disk cache of rendered thumbnails and previews pruned oldest-first
  • Vanilla JavaScript on the frontend, no framework and no build step: one app.js, one stylesheet, IntersectionObserver for lazy thumbnails, canvas for the live histogram
  • Server-Sent Events for live export progress

Most of the engineering went into making a browser feel like a native culler: a two-tier render cache (a fast draft off a RAM-cached preview while a slider moves, a full-resolution render once it's released), neighbour preloading so navigation never waits, and a client-side edit preview whose math mirrors the server's closely enough that the picture doesn't jump when the real render lands.

Links

The source code is available on GitHub.