- JavaScript 46.6%
- Python 20.9%
- CSS 11.1%
- HTML 7%
- PowerShell 6.7%
- Other 7.7%
| .gitignore | ||
| app.js | ||
| index.html | ||
| install.bat | ||
| install.ps1 | ||
| install.sh | ||
| LICENSE | ||
| README.md | ||
| run.bat | ||
| run.sh | ||
| server.py | ||
| style.css | ||
Photo Sorter
Fast yes/no triage for event photos. Built for VRChat / VR event photographers who need to cull a folder of photos (JPEG, PNG, WebP, HEIC, AVIF) down to a "publish" pile, then drop that into Discord.
Works in Firefox, Chrome, Edge, and Safari on Windows, Linux, and macOS. A tiny local Python server does the filesystem work; the browser is just the UI.
What it does
- Point it at a folder of photos (JPEG, PNG, WebP, HEIC, AVIF supported).
- Speed pass — one photo at a time, hit
↑for yes /↓for no /Enterto skip. - Review in grid mode — flip any decision, filter by yes / no / undecided.
- Export — approved photos are copied into
approved-YYYY-MM-DD/inside the source folder. Photos marked "no" stay where they are (decision remembered for next time). Originals are never modified.
Decisions auto-save to your browser as you go, so you can close the tab and resume later from the start screen.
Install & launch
The easy path — use the install script
Both platforms have an installer that handles detection and setup automatically.
Windows — double-click install.bat. It will:
- Check if Python is already installed (and just confirm you're good if so)
- If not, try
winget install Python.Python.3.12automatically - Fall back to step-by-step instructions (Microsoft Store, python.org) if winget fails
- Verify everything works before handing off to
run.bat
Linux / macOS — run ./install.sh. It will:
- Detect your package manager (
apt,dnf,pacman,zypper,apk,xbps,brew,nix-env) - Install the right package names for your distro (see table below)
- Verify the install and report if something needs attention
After the installer succeeds, launch with run.bat / ./run.sh as described below.
Package names by distro (if you prefer to install manually)
| Distro / family | Package manager | Packages |
|---|---|---|
| Debian, Ubuntu, Mint | apt |
python3 python3-tk |
| Fedora, RHEL, Rocky, Alma | dnf |
python3 python3-tkinter |
| Arch, Manjaro, EndeavourOS | pacman |
python tk |
| openSUSE Leap / Tumbleweed | zypper |
python3 python3-tk |
| Alpine | apk |
python3 py3-tkinter |
| Void | xbps-install |
python3 python3-tkinter |
| macOS (Homebrew) | brew |
python3 python-tk |
| macOS (MacPorts) | port |
python313 py313-tkinter |
| Windows | winget / MS Store | Python 3.12 (tkinter bundled) |
Note:
tkinteris only needed for the native folder picker dialog. Without it, the app still works — just paste a folder path into the start screen input field instead.
Windows
After install.bat succeeds, double-click run.bat. A native folder picker
pops up, then your default browser opens to the app.
Linux
./install.sh # run once
./run.sh # run each time
macOS
./install.sh # run once
./run.sh # run each time
Two ways to choose a folder
The start screen has both:
- "Choose Folder…" button — opens your native OS folder picker.
- Path input — paste a folder path, hit Enter. Works in any environment, no GUI dependency, no tkinter required.
You can also pre-select a folder from the command line:
./run.sh --folder /path/to/your/photos
Keyboard shortcuts
| Key | Action |
|---|---|
← → |
Previous / next photo (loupe / filmstrip), or move selection (grid in selection mode) |
↑ / Space / P |
Mark yes (auto-advances in piles view) |
↓ / Delete / X |
Mark no (auto-advances in piles view) |
U |
Mark undecided (clear decision) |
Enter |
Open the selected thumbnail in loupe (sort) or filmstrip (piles) |
Backspace |
Back to previous view (mirrors browser back button) |
Home / End |
First / last photo |
Ctrl+Z |
Undo last decision |
L / F / G |
Loupe / Filmstrip / Grid view |
M |
Toggle selection mode (grid + filmstrip — see below) |
Z |
Toggle wheel mode (advance ↔ zoom) |
B |
Open Piles review (Yes / No / Undecided) |
? |
Help |
Esc |
Close dialog |
Mouse:
- Wheel in advance mode → previous / next photo
- Wheel in zoom mode → zoom in / out toward cursor (1× – 20×)
- Left-drag when zoomed → pan around the image
- Middle-click on photo → toggle wheel mode
- Double-click on the photo → toggle zoom mode and zoom one 1.5× step at the cursor (double-click again to zoom back out)
- Right-click any grid or filmstrip thumbnail → cycle decision (undecided → yes → no → undecided)
Selection mode
By default the grid and filmstrip are "click to open" — clicking a thumb jumps straight to loupe (or filmstrip in piles view). Selection mode flips that: clicking a thumb just selects it (white outline), and you can then vote on or open the selected photo with the keyboard.
Toggle: press M or click the ⊟ Select button in the topbar.
While selection mode is on:
| Key | Action |
|---|---|
← → |
Move selection left / right (wraps to next row at the end) |
↑ / Space / P |
Vote yes on selected photo (auto-advances in piles) |
↓ / Delete / X |
Vote no on selected photo (auto-advances in piles) |
U |
Vote undecided on selected photo |
Enter |
Open the selected photo (loupe in sort context, filmstrip in piles) |
Backspace |
(from loupe after Enter) → return to the grid with the selection restored |
The selection survives Enter → Backspace round-trips, so the keyboard flow
is: click once to anchor, then ↑/↓ to vote without ever leaving the grid.
Runbook
Operational quick-reference for things that occasionally go sideways.
"Server won't start / browser shows ERR_CONNECTION_REFUSED"
The local Python server didn't come up.
- Check the console window (Windows) or terminal (Linux/macOS). The server prints what's wrong on the way down.
- Port 8765 in use? Another instance might still be running.
- Windows:
netstat -ano | findstr 8765thentaskkill /PID <pid> /F - Linux/macOS:
lsof -i :8765thenkill <pid>
- Windows:
- Python missing? Re-run the installer (
install.bator./install.sh). The installer also handles Pillow.
Grid / filmstrip is slow on a big folder
Thumbnails are server-side at 320px. The grid lazy-loads as you scroll. First scroll into a new region pauses briefly while the visible thumbs generate, then subsequent scrolls hit cache.
- Check the server startup log for one of:
Pillow thumbnails enabled (long edge 320px)— good.Pillow NOT installed — grid/filmstrip will be slow— the server is falling back to full-res images for the grid (~10MB each). Fix:pip install Pillow --upgradein the same Python env the server runs under, then restartrun.bat/./run.sh.
- Thumb cache lives in
<app-dir>/thumb-cache/and is wiped on every server start. If startup hangs there, check disk space / permissions on the app directory.
"Undecided pile is empty / Piles view won't open"
Should not happen as of v0.7.4. If it does:
- Hard refresh the browser tab (Ctrl+F5 / Cmd+Shift+R) — old cached
app.jsmay have the v0.7.3-or-earlier bug. - Verify
app.jsshowsPhoto Sorter v0.7.x(where x ≥ 4) at the top. - If still broken,
git pulland restart.
"Exported twice in the same day, folder has duplicates"
Should not happen as of v0.7.6 (re-export is idempotent — existing files are skipped). If you have legacy duplicates from v0.7.5 or older:
- Dupes are named
foo-001.jpg,foo-002.jpg, etc. alongsidefoo.jpg. - Safe to delete the suffixed copies by hand. Originals are untouched.
"Decisions disappeared / app says 'no photos to review'"
Decisions live in IndexedDB, keyed by the exact source folder path. If the path changed (moved/renamed the folder, mounted a drive differently, mapped a network share to a new letter), the app can't find prior decisions.
- Start screen shows recent folders — re-pick the one you were working on.
- If the folder path genuinely changed, decisions for the old path are still there but unreachable from the new path.
"Export finished but the approved-YYYY-MM-DD/ folder is missing"
The folder is only created if there's at least one yes-decision to copy. If your status said "0 approved", no folder was made by design.
Recovery: kill everything and start over
# Windows:
taskkill /IM python.exe /F # stop server
# Linux/macOS:
pkill -f server.py # stop server
Then double-click run.bat / ./run.sh again. Decisions in IndexedDB
survive across restarts (they're per-folder, browser-side).
Where the moving parts live
| Component | Location | Notes |
|---|---|---|
| Source code | repo root (server.py, app.js, index.html, style.css) |
Three files + one stylesheet. No build step. |
| Source photos | wherever you pointed the picker | Opened read-only. Never modified. |
| Approved copies | <source-folder>/approved-YYYY-MM-DD/ |
Created at export time. Idempotent re-exports skip dupes. |
| Decisions | Browser IndexedDB | Keyed by source folder path. Debounced auto-save. |
| Thumbnail cache | <app-dir>/thumb-cache/ |
Wiped on every server start. |
| HTTP server | 127.0.0.1:8765 |
Loopback only — not on your LAN. |
Privacy & safety
- The app runs entirely on your computer. No internet, no accounts, no telemetry.
- The local HTTP server binds only to
127.0.0.1— not reachable from your LAN. - The Python backend opens your photos read-only. The only files it ever creates are copies inside the new dated folders at export time.
- No "delete originals" option exists by design — once a tool can delete your photos, every bug is a potential disaster. Clean up by hand when ready.
- Re-exports are idempotent: if you export, fix a few decisions, and export again the same day, photos already in the dated folders are skipped (not duplicated, not overwritten). Only newly-yes / newly-no photos get copied. The export status reports how many were skipped.
Architecture
your photos (read-only)
│
▼
server.py ← Python 3, stdlib only, binds 127.0.0.1:8765
│
├── /api/folder (current selection)
├── /api/picker (pop tkinter dialog)
├── /api/image/<n> (stream full-res image bytes)
├── /api/thumb/<n> (320px JPEG for grid/filmstrip — needs Pillow)
└── /api/export (copy files into dated folders, idempotent)
▲
│ HTTP
▼
browser ← any modern browser (Firefox, Chrome, Edge, Safari)
│
├── view modes (Loupe / Filmstrip / Grid)
├── keyboard + mouse input
└── IndexedDB (per-folder decisions, auto-save)
No pip install, no Node, no build step. Three source files (server.py,
index.html, app.js) plus style.css.
Versions
- v0.8.6 — Double-click the photo to toggle zoom mode and zoom one 1.5×
step at the cursor in a single gesture (double-click again zooms back out).
Selection state now survives
Enter→Backspaceround-trips: opening a selected thumb in loupe and pressingBackspacerestores the grid with the white outline back on the original thumb, so arrow voting picks up exactly where you left off. - v0.8.5 —
Backspacenow mirrors the browser back button (e.g. loupe → back to grid). Keyboard-only flows no longer need to reach for the mouse to return to the previous view. - v0.8.4 — Selection mode arrow keys simplified.
←/→move the selection one thumb at a time (wrapping to the next row at the end);↑/↓vote yes / no on the selected photo (auto-advancing in piles). Previously↑/↓jumped row-to-row, which conflicted with the loupe-style "arrows = navigate, arrows-perpendicular = vote" muscle memory. - v0.8.3 — Four selection-mode fixes shipped together:
(1) Clicking the ⊟ Select button no longer wipes the active grid filter.
(2) The ⊟ Select button is now visible in piles view (was hidden along
with the rest of the grid-mode controls).
(3) The topbar no longer wraps onto two rows in piles view.
(4) On page reload, mode-toggle buttons sync to the restored mode
correctly — previously the topbar showed Loupe
.activewhile state was actuallygridorfilmstrip, making the buttons appear "dead" until you clicked a different mode and back. - v0.8.2 — Clicking the Home button mid-session now asks for confirmation before bailing back to the start screen. Decisions are always preserved either way — the confirm just protects against an accidental click halfway through a 2000-photo cull.
- v0.8.1 — Per-file content-SHA cache busting. Each asset URL gets a
?v=<sha1>suffix derived from the file's actual bytes (cached by(path, mtime, size)so unchanged files don't get rehashed). Editapp.js, refresh the browser — the new content is served immediately without needing a hard refresh. Versioned URLs are taggedCache-Control: immutable; unversioned requests still getno-storeas a safety net. - v0.8 — Two big UX additions for keyboard-first workflows:
- Selection mode (
Mor ⊟ Select button) flips the grid and filmstrip from "click to open" to "click to select" — pick a thumb, then vote on it or open it with the keyboard without ever leaving the grid. See the Selection mode section above. - Right-click voting in the filmstrip. Right-click on a filmstrip thumbnail now cycles its decision (undecided → yes → no → undecided), matching grid behavior. Previously right-click only worked in the grid.
- Selection mode (
- v0.7.7 — Export now writes the yes pile only. No more
rejected-YYYY-MM-DD/folder cluttering up the source dir. Photos marked "no" are still remembered in IndexedDB (so re-opening the folder shows them as no), but they don't get copied anywhere. README gains a Runbook section for common operational issues. - v0.7.6 — Re-export is idempotent. Exporting twice the same day no
longer duplicates every photo with
-001suffixes; existing files are skipped, only new yes-decisions get copied. Export status reports the skip count. - v0.7.5 — Two click-feel fixes. Buttons no longer nudge down 1px on press (small targets like nav arrows + pile tabs were hard to click reliably). Wheel-to-advance no longer requires hovering inside the photo stage — it works anywhere in the loupe/filmstrip workspace.
- v0.7.4 — You can open Piles view from a fresh folder load. Default pile is now Undecided (always populated) instead of Yes (empty until you mark at least one). If a chosen pile is empty, falls back to Undecided silently.
- v0.7.3 — Fixed Undecided pile button being a silent no-op.
setPile()had a leftover allowlist that rejected anything other thanyesorno, so clicks on the new Undecided tab did nothing. - v0.7.2 — Zoom animates smoothly per wheel tick (160ms ease-out) with a coarser 1.5× step. Each tick visibly glides instead of snapping. Drag-to-pan stays instant (no animation interpolating per mousemove).
- v0.7.1 — Killed the thumbnail prewarm thread that pegged CPU/disk
for minutes after folder load on large corpuses. Undecided pile filter
now defensively catches anything-not-yes-no (was matching the literal
string
'undecided'). - v0.7 — Server-side thumbnails (grid + filmstrip now buttery on 2000+ photo folders). Click-and-drag pan when zoomed (was: cursor-follow zoom origin). Third "Undecided" pile tab so you can re-review skipped photos. Pillow is now an optional dependency — installers grab it for you; if the install fails the app still works, just slower.
- v0.6.4 — Small images no longer jump to full-stage width on the
first zoom tick. Stale
width:100%override on the.zoomedclass was pre-stretching the IMG beforescale()applied. - v0.6.3 — Wheel throttled to one action per physical detent (120ms cooldown). High-res mice and trackpads no longer fire a burst of advances or zoom-to-max on a single tick.
- v0.6.2 — Wheel-advance no longer "sticks" after a tick — the handler now lives at window level, dodging Chrome's per-target wheel coalescing.
- v0.6.1 — Zoom range extended to 20× (was 5×), faster ramp per wheel tick (1.25× was 1.15×).
- v0.6 — Zoom (wheel toward cursor, 1× – 20×), separate Yes / No pile
review (📚 Piles button or
B), lazy-loaded grid for 2000+ photos, browser back/forward navigates between views. Toggle wheel mode withZor middle-click the preview image. - v0.5 — Multi-format (JPEG, PNG, WebP, HEIC, AVIF), Windows installer with UAC self-elevation, PowerShell 5.1 compat, per-launch cache-buster.
- v0.2 — Python backend, works in any browser, native folder dialog +
path input, command-line
--folderoption. - v0.1 — Browser-only (Chromium / Edge), used File System Access API.
Tagged in git for rollback:
git checkout v0.1.
License
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)
In short:
- ✅ Redistribute freely
- ✅ Modify and build on it
- ⚠️ Credit the original project (link back) on any redistribution or fork
- ❌ No commercial use
- 🔁 Derivatives must use the same CC BY-NC-SA 4.0 license
Full text in LICENSE.