The thing that clicked today: Loupe has been a personal one-off, a photo culling tool wired to exactly my setup, and today I decided to aim it at other people who want to self-host it, then spent the rest of the day paying down everything that assumption had let me get away with. Six threads, one throughline: make it portable, make it shareable, make it look like a product.
Built / shipped
A portability audit, then a portable spine. First a strictly read-only audit to find out how tied to my machine the thing really was. The finding that mattered most: the enrichment database (aesthetic scores, people, scene labels) is 100% derived from a macOS photo library, and there was no saved script that builds it, just loose input files sitting around. That's the single biggest blocker for a Mac-less second user. So every hardcoded path now goes through two environment variables (one for originals, one for generated data), across ten files, defaulting to my exact current layout so nothing breaks unset. Worker counts are set the same way with defaults based on the machine, so a beefier or weaker machine tunes itself instead of inheriting my conservative 4-core values. The storage-mount safety check is optional and off by default, since a new user won't have my mount. And the enrichment builder is recovered and committed as something reproducible: I'd lost the original throwaway script, so it was reconstructed from the session transcript plus the live database's own records of where its data came from, packaged properly, and gated on a hard test, rebuilding from the raw inputs has to be bit-for-bit identical to the live database. All four tables matched. Wrote an onboarding runbook to go with it.
A finished brand and a redesigned header. I locked the wordmark: the "o" is an isometric loupe lens on a glass stand sitting over a film strip, with the frame beneath the lens lit amber, like the loupe is examining the chosen frame. Then redrew the Overview header into a compact two-row band: logo left, breadcrumb stacked above a full-width stats line, buttons right.
A pre-deletion review surface. The cut and kept tallies in the stat strip are now clickable: each opens a filtered grid of everything currently pending-cut or pending-keep. That turns a passive counter into the place you eyeball the whole set before anything becomes irreversible, which is the whole ethos of this project. The counts read from the same state the stats line uses, so the grid can never silently disagree with the number above it.
loupeculling.com. Every loupe.* domain was taken, so I ran a real availability sweep over a curated candidate list. I had to hit each registry's own lookup endpoint directly because the public aggregator rate-limited me into the ground. The "Loupe Culling" family was wide open. The marketing site is a separate static site, fully decoupled from the app, with faithful app mockups rebuilt in HTML from royalty-free stock photos only, never a real photo from anyone's library.
By end of day the repo work needed to go public was substantially done too: I found secrets committed in history (a home address and GPS coordinates, an old log capturing my home network's internal layout, a stray internal IP), untracked them, and rewrote both repos' histories to purge those along with every runtime database, personal config, model weights, and cached photo previews. The first scrub pass missed the biggest files (the model weights and the preview cache), so the repo barely shrank until a second pass. I also de-personalized the source: real names, home locations, owner identity all moved into gitignored config, with neutral placeholders shipping in the source.
Problems & fixes
The photobook kept showing near-identical frames and the same selection every load. Root cause wasn't randomness. It was the opposite. The old code returned the score-ranked top-8 per month, which is both stable (same frames every time) and clustered (a burst's frames all score alike, so they sit adjacent). The fix needs both a fresh per-load shuffle and a diversity guard: accept a frame only if it is visually far enough from every already-picked one and far enough apart in time. In practice the randomization does most of the spreading and the guard is a safety net for genuine bursts.
A garbled tagline on the marketing site's mobile hero. An inline SVG logo with no width set fell back to a 300px default box, which centered the wordmark and shoved the tagline off the edge. Mobile got its own headline-first layout rather than a shrunk desktop.
A bumped logo grew the whole header. I asked for a bigger logo expecting a few pixels of row growth; it grew the full amount because the logo is the tallest element in the row, so its height change is a row-height change, not a cosmetic one. I'd also misremembered the current size: verifying against the live file beat trusting my memory.
The elaborate logo turned to mud when shrunk for the header. So the brand is now two tiers: the clean wordmark in the working header, the detailed mark reserved for hero and report headers that have room.
Decisions
- Reframe Loupe as something other people could run, with a friend as test customer number one (Mac plus iCloud, his own storage). Build features that generalize instead of ones hardcoded to me, and narrow the initial target to Mac plus iCloud so I can lean into the Apple-photo-library extraction rather than rebuild that intelligence from scratch. It's the highest-value, hardest-to-replace layer.
- Defer a fancier "setup console" idea explicitly: build it only after the pipeline is proven end-to-end for a second person, not before.
- Every irreversible history rewrite got the same discipline: a full backup bundle first, verified complete, then the rewrite, then a re-scan to prove it worked.
Learned
- A git merge will delete an untracked file from your working folder if the incoming history records a deletion at that path. I nearly lost a secrets file that way; restore from the stash before restarting anything. I did not know git would do that.
- After a history scrub, re-scan the largest files. Don't trust the first pass.
- Caches that get rebuilt at startup legitimately differ after a restart: prove data integrity by row-count against an inventory taken beforehand, not by a remembered total that drifts as you cull live.
Still open / next
Fully scrubbing old commit history (not just the current working tree) is the last step left before the repos are publish-safe. Credential rotation is on the pre-publish checklist; going public makes it the right call regardless. And onboarding the test user on his own Mac and storage, which I can't reach from here.