Fast yes/no photo triage for event dumps. Local-only, browser-based. Windows + Linux + macOS.
  • JavaScript 46.6%
  • Python 20.9%
  • CSS 11.1%
  • HTML 7%
  • PowerShell 6.7%
  • Other 7.7%
Find a file
2026-06-04 21:56:12 +00:00
.gitignore v0.7 - server-side thumbnails, drag-to-pan, undecided pile 2026-06-04 19:30:59 +00:00
app.js v0.8.6 — double-click image to toggle zoom + selection state in history 2026-06-04 21:33:33 +00:00
index.html v0.8.6 — double-click image to toggle zoom + selection state in history 2026-06-04 21:33:33 +00:00
install.bat Fix install.ps1 parser errors: strip non-ASCII chars 2026-06-04 14:13:50 +00:00
install.ps1 v0.7 - server-side thumbnails, drag-to-pan, undecided pile 2026-06-04 19:30:59 +00:00
install.sh v0.7 - server-side thumbnails, drag-to-pan, undecided pile 2026-06-04 19:30:59 +00:00
LICENSE v0.7.7: yes-only export, runbook, CC BY-NC-SA 4.0 license 2026-06-04 20:31:59 +00:00
README.md README: catch up to v0.8.6 (selection mode, double-click zoom, Backspace, M, Enter semantics) 2026-06-04 21:56:12 +00:00
run.bat v0.2: Python backend, works in Firefox/any browser 2026-06-04 13:50:53 +00:00
run.sh v0.2: Python backend, works in Firefox/any browser 2026-06-04 13:50:53 +00:00
server.py v0.8.5 — Backspace = back to previous view 2026-06-04 21:24:59 +00:00
style.css v0.8.3 — selection mode fixes + topbar layout + mode-toggle DOM sync 2026-06-04 21:17:42 +00:00

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

  1. Point it at a folder of photos (JPEG, PNG, WebP, HEIC, AVIF supported).
  2. Speed pass — one photo at a time, hit for yes / for no / Enter to skip.
  3. Review in grid mode — flip any decision, filter by yes / no / undecided.
  4. 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.12 automatically
  • 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: tkinter is 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 EnterBackspace 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.

  1. Check the console window (Windows) or terminal (Linux/macOS). The server prints what's wrong on the way down.
  2. Port 8765 in use? Another instance might still be running.
    • Windows: netstat -ano | findstr 8765 then taskkill /PID <pid> /F
    • Linux/macOS: lsof -i :8765 then kill <pid>
  3. Python missing? Re-run the installer (install.bat or ./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.

  1. 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 --upgrade in the same Python env the server runs under, then restart run.bat / ./run.sh.
  2. 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:

  1. Hard refresh the browser tab (Ctrl+F5 / Cmd+Shift+R) — old cached app.js may have the v0.7.3-or-earlier bug.
  2. Verify app.js shows Photo Sorter v0.7.x (where x ≥ 4) at the top.
  3. If still broken, git pull and 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. alongside foo.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 EnterBackspace round-trips: opening a selected thumb in loupe and pressing Backspace restores the grid with the white outline back on the original thumb, so arrow voting picks up exactly where you left off.
  • v0.8.5Backspace now 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 .active while state was actually grid or filmstrip, 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). Edit app.js, refresh the browser — the new content is served immediately without needing a hard refresh. Versioned URLs are tagged Cache-Control: immutable; unversioned requests still get no-store as a safety net.
  • v0.8 — Two big UX additions for keyboard-first workflows:
    • Selection mode (M or ⊟ 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.
  • 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 -001 suffixes; 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 than yes or no, 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 .zoomed class was pre-stretching the IMG before scale() 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 with Z or 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 --folder option.
  • 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.