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 most of my time went into deciding which shots were even worth editing, not the editing itself. Lightroom can do this, but it wants a catalog and an import step first, and 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

  • 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
  • The loupe view has 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
  • Select up to three near-identical frames to compare them with synchronized zoom and pan; one keystroke keeps the winner and rejects the rest. Select more than three and it turns into a king-of-the-hill playoff until one frame beats all the others
  • A same-named RAW and JPEG collapse into one grid item, so marking, comparing, exporting and deleting always act on the whole pair
  • Export copies or moves 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
  • "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

Getting a browser to feel like a native culler took 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 keeps navigation from ever waiting, and the client-side edit preview mirrors the server's math closely enough that the picture doesn't jump when the real render lands.

Links

The source code is available on GitHub.