Paul Nothaft bbce3cd2a2 feat(faces): let the photographer choose which photo represents a person (#1119)
Phase 1 of #1096.

Clustering picks the cover, and its idea of a good one and a human's do not
always agree. A cluster whose avatar is turned away or softer than the rest
stays that way in the guest-facing people strip too, and nothing in the UI
could change it.

A picker reachable from each person row, reusing the face list the split
dialog already loads — same query, same grid, different action on a click.

Making the choice actually stick took four changes
---------------------------------------------------------------------------
event_people.cover_face_id has existed since migration 177 and the PATCH
already accepted it, so the first version of this was frontend-only. It was
also a no-op:

- facePeopleService.listPeople SELECTED cover_face_id and then discarded it,
  recomputing the cover as the best-scoring VISIBLE face on every read. The
  picker saved, said so, and the avatar reverted immediately. It now prefers
  the stored pick whenever this audience can see it, and falls back to the
  score-ordered choice otherwise — so visibility scoping still wins, and a
  guest is never handed a crop of a photo they cannot open.
- recomputeCentroid overwrote cover_face_id unconditionally. It runs on
  rescan and on photo replacement, so any reprocessing silently undid a
  deliberate choice. It now keeps the chosen face while it is still a member
  of the cluster.
- The face list is cached per person, and split/merge move faces between
  people. Until now the only reader closed itself after acting, so nobody saw
  the stale copy; the picker is a second reader of the same key.
- cover_face_id meant two things. assignFaces seeded it with whichever face
  opened the cluster and recomputeCentroid overwrote it with the highest
  scoring one, so an automatic guess was indistinguishable from a deliberate
  choice — and honouring it would have pinned every UNCURATED person to that
  guess, which is worse than the fallback it replaced (the fallback is
  computed per audience and skips photos a guest cannot open). Both writers
  are gone, migration 179 clears the stored guesses, and the column now means
  one thing. That also removes the need to defend the choice against rescans:
  nothing overwrites it, and a dangling id self-heals to the derived cover.

Clearing existing values is safe rather than destructive: no install has ever
been able to SET a cover, so every stored value is an automatic guess by
construction.

Also fixes a PostgreSQL-only 500
---------------------------------------------------------------------------
GET /admin/events/:id/people/:personId/faces joined `photos` but did not
table-qualify its WHERE, and photo_faces and photos BOTH have an event_id:

  column reference "event_id" is ambiguous

Postgres refuses it, so the endpoint 500s and the Split dialog — its only
consumer until now — has been broken on every PostgreSQL install since the
join was added. SQLite resolves the ambiguity silently, which is why the suite
stayed green. Reproduced against a real Postgres before and after.

The query is now a named builder the route calls and the test imports, rather
than a copy: an earlier version of that test re-declared the query, so the
route could regress to the bare form while the assertions kept passing.

Merge and recluster preserve the choice as well. Both already carried labels
and privacy flags across; the chosen cover is human state of the same kind, so
it now rides along — through a merge when the target has none, and through a
recluster by following its FACE into whichever cluster ends up holding it,
rather than the majority-descendant rule the label uses.

The picker and the endpoint disagree past 500 faces, so the picker now says
when it is showing a capped list rather than presenting it as exhaustive.

Frontend suite 178 passing, backend 23 across the touched suites, build clean,
no new type errors. Mutation-checked twice: dropping the cover preference fails
the new listPeople test while the visibility-scoping test still passes, and
restoring the auto-seed in assignFaces fails it too.
2026-08-21 19:26:52 +02:00

PicPeak Logo

📸 PicPeak

Open-source, self-hosted photo sharing for events.

License: MIT Docker Buy Me A Coffee

Homepage · Live Demo · Documentation · Support


PicPeak is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.

PicPeak Gallery Preview

Important

PicPeak has moved to its own GitHub organization. Docker images are now at ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml} (and on Docker Hub as picpeak/{backend,frontend,aio,ml}) and active development is on main. The old ghcr.io/the-luap/... path still responds but its tags are frozen at 2026-05-27 — if updates never arrive, check your image path first. See docs/migration-to-org.md for the one-line docker-compose.yml edit.

Contents

🎮 Live Demo

Try PicPeak without installing anything — demo.picpeak.app · admin panel

Email Password
demo@picpeak.app Demo2026!

The demo resets periodically. Uploaded content may be removed without notice.

🚀 Quick Start

Get PicPeak running in under 5 minutes:

# Clone the repository
git clone https://github.com/PicPeak/picpeak.git
cd picpeak

# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env

# Start with Docker Compose
docker compose up -d

# Access at http://localhost:3000

On first start, open http://localhost:3000/admin and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in First-run setup.

Updating / release channels: set PICPEAK_CHANNEL (stable default, or beta) in .env, then docker compose pull && docker compose up -d. See RELEASING.md for the promotion cadence.

Or: one container, no compose file

For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:

docker run -d --name picpeak -p 3000:3000 \
  -v picpeak:/data \
  -e JWT_SECRET="$(openssl rand -base64 48)" \
  ghcr.io/picpeak/picpeak/aio:main

Then open http://localhost:3000/admin and read the setup token with docker exec picpeak cat /data/db/SETUP_TOKEN.

:main is the active-development tag, and today it is the only one the all-in-one image has — Dockerfile.aio landed after the current stable release, so :stable and :latest first appear for this image once the aio build reaches the stable branch. Switch to :stable then, or pin a version tag (3.107.4-beta.0) if you would rather not track main.

The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a .picpeak backup and restore it into the full stack. See Single-container install for the volume layout, the external-Postgres variant, TLS, and the limits.

Docker images

GHCR Docker Hub
Backend ghcr.io/picpeak/picpeak/backend picpeak/backend
Frontend ghcr.io/picpeak/picpeak/frontend picpeak/frontend
All-in-one ghcr.io/picpeak/picpeak/aio picpeak/aio
ML sidecar (optional) ghcr.io/picpeak/picpeak/ml picpeak/ml

Both registries get the same digests and the same tags — stable/latest, a pinned x.y.z, and beta/main for the active development channel — for linux/amd64 and linux/arm64. Keep every image in one install on the same tag.

🌟 Why PicPeak?

Unlike expensive SaaS solutions, PicPeak gives you:

  • 💰 No Monthly Fees — one-time setup, unlimited galleries
  • 🔒 Complete Data Control — your photos stay on your server
  • 🎨 White-Label Ready — full branding customization
  • 📱 Mobile-First Design — beautiful on all devices
  • 🌍 Multi-Language — built-in i18n (EN, DE)

Features

For photographers — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a Live Slideshow projector view that auto-picks-up new uploads during live events.

For clients — clean mobile-optimized galleries, one-click bulk downloads, smart search, People in this gallery face grouping (opt-in per gallery, needs the optional ML sidecar), optional guest uploads, and download protection (watermarking + right-click prevention).

Technical — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible storage backends, webhooks, and security-first defaults (JWT, rate limiting, CORS).

🧾 For studios — CRM & Accounting (Beta, off by default)
  • 📝 Quotes → Contracts → Invoices — one deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
  • ⏱️ Hours Logging & Calendar — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
  • 🧾 Inbound Supplier Invoices & Expenses — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
  • 📊 Tax Report & Accountant Export — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
  • 🌍 VAT & Multi-currency — single VAT-code registry snapshotted onto each document

Warning

CRM & Accounting — examples only, verify locally. Feature-flagged off by default. Seeded contract blocks are written by the maintainer, not a lawyer; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are jurisdiction-specific guidance only. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read the CRM disclaimers first.

📖 Documentation

Full documentation lives at docs.picpeak.app — deployment, admin settings, API, branding, and more.

Topic Link
🚀 Deployment (Docker, env, reverse proxy, SSL) docs.picpeak.app/deployment
📦 Single-container install (one docker run, SQLite) docs.picpeak.app/deployment/single-container
⚙️ Admin settings reference docs.picpeak.app/guides/admin-settings
🎯 Creating events docs.picpeak.app/guides/creating-events
📽️ Live Slideshow docs.picpeak.app/features/live-slideshow
💾 Backup & Restore docs.picpeak.app/guides/backup-restore
🔌 API reference docs.picpeak.app/api
🪝 Webhooks docs.picpeak.app/features/webhooks
💾 Storage backends (local / S3) docs.picpeak.app/features/storage-backends
💻 System requirements & tuning docs.picpeak.app/deployment/system-requirements
🧾 CRM & Accounting docs.picpeak.app/features/crm · disclaimers
🗺️ Roadmap GitHub Issues

Project meta: Contributing · License · Security · Code of Conduct

📊 Comparison with Alternatives

Feature PicPeak PicDrop Scrapbook.de Pixieset
Self-Hosted
Custom Branding Full Limited Limited (paid)
Monthly Cost $0* $29-199 €19-99 ~$60
Storage Limit Unlimited** 50-500GB 100-1000GB 3GBUnlimited***
Client Uploads Limited
API Access Paid
Open Source
Customer Accounts
Quotes / Contracts / Invoices 🧪 Beta
Incoming Invoices & Accounting 🧪 Beta

*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.

🏗️ Tech Stack

  • Backend: Node.js, Express, SQLite/PostgreSQL
  • Frontend: React, Tailwind CSS, Framer Motion
  • Storage: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see Storage Backends
  • Email: SMTP with customizable templates
  • Analytics: Privacy-focused with Umami integration
  • External media: point PicPeak at EXTERNAL_MEDIA_ROOT to reference existing originals read-only, index quickly, and generate thumbnails on demand

📸 Screenshots

Click to see the admin dashboard, analytics, and event management

🎛️ Admin Dashboard

PicPeak Admin Dashboard

📊 Analytics & Insights

PicPeak Analytics Dashboard

📁 Event Management

PicPeak Events Management

🤝 Contributing

We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the Contributing Guide to get started.

Found a security issue? Please open a security issue. See SECURITY.md for the policy.

Support the Project

PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also star the repo, share it, file good bug reports, or open a PR.

🙏 Acknowledgments

PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.

👥 Contributors

A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:

@the-luap — creator and lead maintainer

  • Gallery foundation (events, uploads, sharing, download protection, templates)
  • Backup & restore, analytics, branding/theming
  • The architecture every later feature builds on

@Luca-Timo

  • Native Apple Silicon multi-arch images
  • CRM & accounting suite (quotes/contracts/invoices)
  • Hours logging & Treuhänder/Banana tax export
  • Gallery header/banner decoupling

@Rekoo-PS — bug reports & product feedback

  • Login-loop fix, mobile-lightbox overhaul, bulk-delete workflow
  • Also a BuyMeACoffee supporter

If you've contributed and aren't listed here, please open a PR — this list is meant to grow.

📄 License

PicPeak is released under the MIT License. Use it freely for personal or commercial projects.


Made with ❤️ by photographers, for photographers
Homepage · Live Demo · Documentation · Support

S
Description
Secure photo sharing platform for weddings and events with automatic expiration and email notifications
Readme MIT 157 MiB
2025-07-24 15:24:20 +02:00
Languages
JavaScript 59.8%
TypeScript 38.5%
Shell 0.7%
Python 0.5%
CSS 0.4%
Other 0.1%