feat(faces): People in this gallery — face recognition via an optional ML sidecar (#1074) (#1075)

* feat(ml): optional face-detection sidecar, opt-in and inert by default (#1074)

First of four PRs for "People in this gallery". This one ships only the
sidecar, its wiring and its CI — no schema, no backend code, no UI. Nothing
in PicPeak calls it yet.

picpeak-ml is a single FastAPI + onnxruntime container: three endpoints
(/health, /info, /faces), no database, no volumes, no egress, no model
download at runtime. Clustering, person identity and every privacy decision
stay in the backend where the data already lives.

Models are YuNet (detection) + FaceNet-512 (embedding), both MIT, both
pinned by URL and SHA-256 and verified at build time. The licence analysis
is in ml/LICENSES.md: the more accurate InsightFace weights are
non-commercial-only and PicPeak's users are working photographers, so they
are never baked into an image we publish.

Two things worth review attention:

- Alignment uses a least-squares similarity transform (Umeyama), NOT
  cv2.estimateAffinePartial2D. RANSAC and LMEDS exist to reject outliers
  among many correspondences; given five landmarks and no outliers they fit
  a three-point subset exactly and let the rest drift. Measured on a real
  off-frontal portrait: eyes and nose pinned to 0.11px, mouth corners
  11.8px out on a 160px crop. Umeyama distributes it (max 6.5px, rms 5.1 vs
  7.4). The failure mode is silent — a bad warp still yields 512 confident
  floats — so tests/test_pipeline.py pins it numerically.

- FACENET_ONNX_URL has no default and the build fails loudly without it.
  deepface distributes FaceNet-512 as Keras .h5 only, so the ONNX is
  produced once by tools/convert_facenet.py and published as a release
  asset. Converting inside the build would drag TensorFlow through both
  architecture legs of every build to produce a byte-identical file. The CI
  jobs are gated on the FACENET_ONNX_URL repository variable and skip
  cleanly until it is set.

Off by default, twice over: the sidecar is behind the `faces` compose
profile, and the backend will gate on a `faces` feature flag that defaults
to false. FACE_ML_URL defaults to http://picpeak-ml:8000 so the standard
deployment needs no configuration — nothing dials that host while the flag
is off, which is why a non-resolving default is harmless.

Verified: 27 pytest tests green; YuNet loads and detects against a real
portrait with its landmark order matching the alignment template
index-for-index; both compose files validate and the faces profile is
correctly excluded from a default `up`; workflow YAML parses and the job
graph resolves.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(ml): pin the converter toolchain, verify parity, drop a false reproducibility claim (#1074)

Ran the FaceNet-512 conversion for real and corrected what the previous
commit assumed about it.

The conversion works: 23,497,424 parameters, 89.6 MB ONNX, and the converted
graph matches the Keras original to 2.086e-06 absolute / cosine
1.0000000000. That check is now part of the script rather than something I
did once by hand — a subtly wrong graph still returns 512 plausible floats,
so it refuses to leave the file on disk if parity fails.

Also ran the full pipeline against both real models end to end. The
embedding is L2-normalized to 1.000000, and the same face survives being
re-rendered: half scale 0.973, double scale 0.984, JPEG q40 0.987, rotated
8 degrees 0.984, brightness +40 0.988. Scale invariance in particular is
evidence the alignment warp is doing its job.

Corrected claim: the conversion is NOT byte-reproducible. Two runs with the
same pinned versions on the same machine gave different SHA-256s. The graphs
are functionally identical — same 336 nodes, same 271 initializers, every
weight matching to 0.000e+00 — but a few initializer names differ because
tf2onnx's traced-op naming is not deterministic (Keras layer naming is
deterministic; I checked). The previous commit message and README both
claimed byte-identical output. They were wrong, and it matters: anyone
re-running the conversion gets a different hash, and without this note that
reads like tampering. The build-time SHA-256 pins one published artifact so
its URL cannot start serving different bytes; validating a fresh conversion
is the parity check's job.

requirements-convert.txt now pins the exact set that produced the artifact,
including transitive keras/protobuf/numpy, and documents that the converter
needs Python 3.11 while the image runs 3.12.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): schema, queue, clustering and API for People in this gallery (#1074)

Backend half of the feature. Migration 177, a face-detection queue, the
clustering engine, the gallery and admin APIs, and the privacy wiring. No UI
yet; nothing is reachable until the `faces` feature flag is on, which
defaults to false.

The flag is the gate, not FACE_ML_URL. That variable now has a working
default (the compose service name), so its presence proves nothing about
intent — if it were the gate, every install would poll a hostname that does
not resolve. faceQueue re-checks the flag every tick, so turning it off stops
the workers without a restart.

Visibility scoping is the part worth reviewing closely. Face rows have no
concept of photo visibility, but guests are restricted to
photos.visibility='visible'. A raw count leaks how many hidden photos someone
appears in, and an unscoped cover face renders a crop of a photo the guest
may not open — with the best-scoring face being the likeliest pick, so it
would happen often rather than rarely. facePeopleService recomputes both per
request against the caller's own scope, and event_people.face_count_total is
named to be conspicuous in a guest path. Six tests cover it, including the
case where a person's photos are ALL hidden and they must vanish entirely.

Face data is excluded from backups and .picpeak exports, per the decision in
the thread: it is derived, so a restore re-scans rather than carrying
biometrics between operators. Three separate mechanisms, because the engines
cannot be filtered alike — EXCLUDED_TABLES for export, --exclude-table-data
(not --exclude-table; the CREATE TABLE must survive or restore breaks on the
first query) for Postgres, and DELETE + VACUUM on the temp copy for SQLite,
which has no way to exclude a table from a whole-file .backup. The VACUUM is
not cosmetic: without it the pages stay in the file and the claim is false on
disk.

Archiving now purges face data explicitly. photo_faces cascades off photos,
but archive deletes neither the photo rows nor the event, so without this an
archived gallery kept its biometrics indefinitely.

Other decisions: clustering keeps names across a re-cluster by majority
inheritance (without it, one button click silently discards every name the
photographer typed); consolidation refuses to merge two people who were named
differently; assignment never compares across model_version, since embeddings
from two pipelines are not comparable; low-quality faces are stored but left
unassigned so they show in "this photo contains" without spawning junk people.

Migration is 177, not 174 — 174/175/176 landed on main while this branch was
open.

29 tests green: 7 migration (idempotency, down(), cascade, and that
installing it enqueues NOTHING), 11 clustering, 11 privacy/visibility. Lint
clean; the pre-existing error counts in databaseBackup.js and server.js are
unchanged.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): People strip, face filter and admin controls (#1074)

Frontend half. Renders nothing anywhere unless the `faces` feature flag is
on AND the photographer enabled detection for the gallery — the whole guest
surface hangs off one boolean, event.people_enabled, which the server
computes from the flag, the per-event toggle and the show-to-guests toggle
together.

Guest side: a People strip between the filter bar and the grid, circular
crops from each person's cover face, an active-filter chip row, and a "Show
all" bottom sheet. The face filter composes with category, search, media
type and the liked/saved/rated filters in the same useMemo rather than
replacing them, so "photos of Anna that I liked" works. Two people selected
means AND by default — that is what picking a second face almost always
asks for — with a toggle to OR that appears only once a second person is
picked.

Unnamed people show a photo count and never "Person 7". A number is honest
about what the system knows; an invented name is not. There is a test
asserting we don't do it.

The strip renders nothing below two people, collapses to one line when
dismissed (persisted per slug, so dismissing one gallery says nothing about
the next), and appears mid-backfill with a progress line rather than
blocking the gallery behind a spinner. Avatar crops are computed in ratios
of the source dimensions so they survive whatever rendition the browser
gets; without width/height they fall back to an uncropped thumbnail, since
a wrongly-offset crop is worse than no crop.

No new download endpoint: "download these N" rides the existing photoIds
path, which already enforces access level and per-category permissions
server-side. Adding a person_id selector would have been a second thing to
authorize for no gain.

Guest-facing copy never says "biometric" or "recognition" — those words
describe our implementation, not the guest's experience. The sheet's
footnote answers the first question every guest has (where does this go?)
inline. The admin card, by contrast, is explicit: it states the controller
obligation next to the toggle, and warns that scanning materializes the
preview tier on galleries that never generated one, which is real CPU and
disk an admin should know about before a 2,000-photo backfill.

EN + DE translations. 140 frontend tests green (8 new), tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): measured match threshold, working build defaults, 89MB smaller image (#1074)

Ran the Phase 0 spike that had been outstanding, published the model, and
fixed what both turned up.

THRESHOLD IS NOW MEASURED, NOT GUESSED. LFW's standard 1000-pair protocol
run through this exact pipeline (YuNet -> Umeyama alignment -> FaceNet-512
ONNX), 100% detection on 2000 images:

  same person  cosine 0.6958 +/- 0.1415
  diff person  cosine 0.0849 +/- 0.1674   separation 0.6109
  peak accuracy 96.60% @ 0.405

So the pipeline separates people well — the thing I could not previously
claim, since every earlier number was the same face re-rendered.

Default moves 0.62 -> 0.50. The old value was a placeholder and a bad one:
it gave 0% false merges but 22.4% false splits, i.e. roughly one in four
same-person pairs failing to join, which fragments a gallery badly. 0.50
gives 1.0% false merge / 8.2% false split. Peak accuracy (0.405) is
deliberately NOT chosen: for clustering the two errors do not cost the same.
A false split is a duplicate row the photographer can merge away; a false
merge puts a stranger into someone's "download my photos" — and until the
Phase 2 merge/split UI ships, there is no way to undo one. So this sits on
the conservative side of the optimum.

The spike is committed as ml/tools/benchmark_threshold.py rather than
thrown away, so "why 0.50?" has an answer in six months and a re-tune is one
command.

BUILD DEFAULTS. FACENET_ONNX_URL/_SHA256 now default to the published
ml-models-v1 release asset, so `docker build ml/` and
`docker compose --profile faces up` work with no arguments. Blanking either
still fails loudly — a URL without a checksum is never acceptable, since the
checksum is what makes the URL safe to trust. Found by running compose for
real: it failed exactly as designed, which was correct behaviour and a bad
out-of-box experience now that a canonical artifact exists.

IMAGE SIZE. 389MB -> 300MB single-arch. `chown -R` after COPY rewrote every
copied file into a fresh layer, duplicating the 90MB model for nothing; the
user is now created before the copies and ownership set via COPY --chown.
Also drops pip/setuptools from the runtime image. Measured RSS is 186MiB
idle, and the container answers /faces end-to-end in well under the
80-150ms/photo the issue budgeted.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): threshold 0.50 -> 0.60 from real clustering, theme-aware People strip (#1074)

Both fixes come from running the feature on an actual gallery — 61 photos,
5 real identities — rather than reasoning about it.

THRESHOLD. The LFW pairwise sweep in the previous commit said 0.50, and it
was wrong. On a real gallery at 0.50, three of six visible clusters were
contaminated: two different people merged into one strip entry, which is the
exact failure that puts a stranger into someone's "download my photos".

Pairwise error rates do not predict cluster purity. Greedy assignment
compounds — one wrong face drags the centroid toward the midpoint between two
identities, making the next wrong face likelier. A 1% pairwise false-merge
rate is not a 1% chance of a clean gallery, and no amount of staring at an
ROC curve would have shown that.

Sweep against ground truth (5 identities):

    0.50 -> 6 clusters, 3 contaminated
    0.56 -> 6 clusters, 0 contaminated
    0.60 -> 5 clusters, 0 contaminated   <- exactly right
    0.64 -> 5 clusters, 0 contaminated, fewer faces assigned

0.60 recovers the right number of people with no contamination; higher only
loses coverage. Migration 177 carries the full reasoning so the next person
to touch this knows why the obvious pairwise answer is the wrong one.

THEME. The People strip hardcoded `text-neutral-800` for named people. On a
dark gallery — which the screenshot immediately showed — that renders a
named person's label almost invisibly, while UNNAMED people stayed legible.
Exactly backwards. Labels, headings, the collapsed summary, the scan line
and the filter chip row now read the gallery's own theme tokens
(--color-text / --color-muted-text / --color-accent / --color-surface-border)
like the rest of the gallery surface.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): keep the mobile filter row inside the viewport (#1074)

At 390px the photo count and Clear link were pushed against the right edge
by ml-auto and clipped. Only apply it from the sm breakpoint up, where
there is room; below that they flow after the chips.

Found by screenshotting the real thing on an iPhone-sized viewport.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): complete Phase 1, add People management and auto-categories (#1074)

Closes the two Phase 1 gaps, then builds Phase 2 and Phase 3.

PHASE 1 GAPS. "Download these N" was specified, described as done in an
earlier summary, and never actually built — I had verified the backend needed
no new endpoint and let that stand as if the button existed. It now hands the
filtered photo ids to the same path as a manual selection, so the server
re-applies access level and per-category permissions on the way through.
Photos in a downloads-disabled category are excluded client-side too, so the
number on the button is the number the guest receives. Hidden entirely when
downloads are off for the gallery.

Lightbox person chips ("In this photo: Anna") are the second way into the
face filter — a guest looking at a photo of themselves can act on it without
scrolling back to the strip. Tapping one closes the lightbox and filters the
grid behind it.

PHASE 2. A People management modal over the endpoints that already existed
and were already tested: rename inline, merge (multi-select, first pick is
the target so the name a photographer typed survives), split via a face
picker, hide, ignore. This matters more than it sounds — clustering
deliberately errs toward splitting because a wrong merge puts a stranger into
someone's download, and that trade only works if merging is easy.

PHASE 3. Rule engine over face_count plus face-area ratio: 0 -> Details,
1 large -> Portraits, 2-5 -> Small groups, >5 -> Groups. The area ratio is
what separates "a portrait of someone" from "someone is in this landscape".
Three guarantees, all tested: it only ever fills an EMPTY category (enforced
in the query AND re-checked in the UPDATE, so a photographer setting one
mid-run still wins), everything it touches is marked auto_categorized so undo
is exact, and it is a no-op unless separately enabled. Migration 178 adds the
column — separate from 177, which has already run wherever this branch is
deployed.

Verified on the real gallery: 61 photos -> 48 portraits + 13 small groups,
undo cleared exactly 61 and left the manual ones alone. Merge moved faces and
removed the source. Both confirmed against the database, not just the UI.

TWO BUGS THE BROWSER CAUGHT, both invisible to tsc:

- The lightbox destructure never landed — my patch targeted a line that has a
  default value, matched nothing, and failed silently. `people` resolved to
  something else entirely and the chips would never have rendered. eslint's
  "outer scope value" warning is what surfaced it.

- Admin face thumbnails 403'd because <AuthenticatedImage> attaches whatever
  gallery token is in session storage; an admin who has also opened one of
  their own galleries sends a type:"gallery" bearer to an admin route. Admin
  routes authenticate from the httpOnly cookie, which a plain same-origin
  <img> sends by itself. Worth noting AdminPhotoGrid has the same latent
  shape; not touched here.

Also: the admin card now reports "N people (M shown to guests)" when those
differ, so the settings page and the gallery stop disagreeing without
explanation.

45 backend tests (8 new) and 140 frontend tests green; tsc and eslint clean.
EN + DE for every new string.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* perf(faces): batch migration DDL and drop the face stack from server.js import (#1074)

CI's backend job timed out at 10 minutes on the first run of this branch.
Nothing failed — 132 of 182 suites passed and the wall clock ran out. Main
does the same 182 in 124s, and where main has 12 suites slow enough for jest
to print a duration, this branch had 77.

Two changes, both worth making regardless of how much of the gap they close:

- Migration 177 added its columns one ALTER TABLE at a time (four on photos,
  three on events, plus a separate index statement) and seeded settings with
  a SELECT and an INSERT per key. It now uses one alterTable per table and
  one SELECT plus one bulk INSERT. 178 folds its index into the same
  statement as its column. That chain replays in ~90 suites, so statement
  count there is multiplied by 90.

- server.js required faceQueue at module scope, which pulls in axios and —
  through imageProcessor — sharp. Every supertest suite that imports
  server.js was paying for a module graph it never uses. Now required inside
  the startup block, next to the call that needs it.

Honest about the evidence: locally the migration delta measures at zero
(1.15s vs 1.13s for the same suite, three runs each), so batching alone does
not explain an eight-minute regression. A fast local disk and many cores mask
per-statement and per-import costs that a two-core runner with a shared disk
does not. These are the two real costs this branch added to a path that runs
in almost every suite; whether they are sufficient is a question for CI, not
for another round of local speculation.

37 face tests still green after the change.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* i18n(faces): complete EN and DE coverage for the face feature (#1074)

The admin card and the Features toggle were rendering entirely from inline
English `defaultValue` fallbacks — 22 keys existed in no locale file at all,
so a German admin saw an English consent notice, English toggles and English
buttons. The gallery side was already translated; the admin side was not,
and nothing in the toolchain flags this because a `defaultValue` always
renders something.

Adds the missing `admin.faces.*` (19), `settings.features.faces.*` (2) and
shared `common.clear/saved/saveFailed` in both languages. Existing keys are
left alone (setdefault, not overwrite), so the shared `common` strings other
features rely on are untouched.

Committed the audit as frontend/scripts/i18n-faces-audit.py rather than
throwing it away: it extracts every t() key the face components actually use
and diffs it against each locale, and it also reports German values that are
byte-identical to English, which is the usual shape of an untranslated
copy-paste. Currently: 69 keys in use, EN complete, DE complete, no
identical pairs.

Verified in the browser, not just in the JSON — the German card reads
"61 / 61 Fotos durchsucht · 16 Personen (5 für Gäste sichtbar)" end to end.
Also checked the components for hardcoded user-facing text (JSX nodes,
title/aria-label/placeholder attributes) outside t(); there is none.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): 13 defects from external review — coordinates, counts, erasure, races (#1074)

Codex reviewed the branch against main. Thirteen findings, nine P1. I checked
every one against the code and could not dismiss a single one as a false
positive, so all thirteen are fixed here.

THE WORST ONE: bounding boxes were stored in the wrong coordinate system.
The sidecar reports coordinates in the space of the image it was HANDED —
which is the ≤1920px preview, not the original — while every consumer
compares them against photos.width/height, the original dimensions. A 6000px
photo therefore produced boxes ~3x too small and areas ~9x too small: avatar
crops landed in the wrong place and the Portraits rule could never fire. It
is invisible on any photo already under 1920px, which is exactly why the
demo gallery and every screenshot looked correct. Now scaled once in
faceProcessor so everything downstream can assume original-image coordinates.

ERASURE. The FK cascade on photo_faces is decorative on SQLite: PicPeak never
enables `PRAGMA foreign_keys`, so deleting a photo left its embeddings
behind. I first enabled the pragma globally and reverted it — six unrelated
suites immediately failed on pre-existing dangling references, and switching
it on would start rejecting inserts on every existing install. That is a real
change worth making, but it is its own PR, not a rider on this one. Instead
deletion purges explicitly: purgePhotoFaces in the photo paths (single, bulk,
service) and photo_faces/event_people in deleteEventCascade. Tests assert
this with the pragma explicitly OFF, so they can only pass if the code does
the work.

COUNTS. A re-scan deleted the old face rows without undoing their
contribution to event_people, so counts inflated on every re-scan and ghost
people survived. Now the affected people are recomputed before the
replacements are assigned. My own "must not double its faces" test only
checked photo_faces rows, which is why it passed throughout.

RACES. A worker that finished after an admin purged the event committed its
rows anyway — erasure reported success and the data reappeared. The commit is
now conditional on the row still being 'processing'. And assignFaces is
read-modify-write over an event's people, so two workers lost each other's
updates; it is now serialised per event with an in-process mutex plus a
Postgres advisory lock for the multi-pod case the queue advertises.

METADATA LOSS. Merging discarded the source's name and suppression flags, so
a merge could erase a typed name or un-hide someone. Reclustering remembered
only people with a label, so an unnamed-but-hidden bystander came back
guest-visible after one "Re-group people" — and suppression now propagates to
every descendant cluster, not just the majority one.

Also: export reset face_status so a restored gallery re-scans instead of
claiming to be scanned forever; manual category edits clear auto_categorized
so "undo automatic" cannot delete a photographer's own choice; external
photos are skipped rather than failed (resolvePhotoStorageKey returns null
for them by design); the gallery refetches photo memberships as a scan
progresses so filtering is not stale; a failed VACUUM now fails the backup
rather than publishing one that may retain biometric pages; and the ML
Dockerfile's `|| true` is scoped to the uninstall — as written it was
`(install && uninstall) || true`, so a failed dependency install produced a
green layer and an image with no onnxruntime.

Four new regression tests. Full backend suite failure set verified identical
to origin/main; frontend 140 green; tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): 12 more defects from review round 2 — cross-event purge, leaks, lifecycle (#1074)

Second Codex round on the same diff, now including round 1's fixes. Twelve
findings, seven P1. Again none were false positives.

SECURITY, AND MINE FROM ROUND 1: the bulk-delete face purge iterated the raw
`photoIds` from the request instead of the event-scoped `photos` rows the
handler had already validated. purgePhotoFaces has no event scope of its own,
so an editor could pass another gallery's photo id and delete its face data —
even though the photo deletion right below it was correctly scoped. Fixing
one thing and introducing another is exactly why the second round was worth
running.

ANOTHER VISIBILITY LEAK, same class as the one round 1 fixed: /people returns
scan progress, and getScanStatus counted every photo with a face_status —
including hidden ones. Guests could read the hidden-photo count off the
progress bar while the people list and covers beside it were properly scoped.
Now scoped by the same predicate, with the caller passing its audience.

RECLUSTER, ROUND 1'S FIX WAS INCOMPLETE. I made suppression follow every
descendant but still copied the flags from the majority ANCESTOR. When
reclustering merges a visible named person with a hidden one, the majority
ancestor is often the visible one — republishing the hidden person's photos.
Suppression is now OR-ed across every ancestor contributing faces. The name
also now goes to the genuine largest descendant; the previous code took
whichever cluster came first in map order, which the comment already claimed
it did not.

LIFECYCLE. Face data is excluded from backups and exports, but photos.
face_status came across intact, so a restored install claimed every photo was
scanned while holding no faces — and the worker only claims 'pending', so it
stayed that way forever. Now: the SQLite backup requeues in the dump, restore
requeues after the pool reinit (the Postgres path cannot rewrite rows inside
pg_dump), the portable importer purges LOCAL face tables (they were excluded
from the replace list, so another instance's embeddings survived an import
with FK checks suspended) and requeues, and archiving disables detection so a
restored archive is honestly off rather than enabled-and-empty.

WRITE PATHS. Only processPhoto enqueued. The synchronous upload path
(chunked-upload completion, watch-folder) left photos unscanned, and
replacePhoto kept the OLD image's faces on a row now pointing at a different
picture — stale identities shown on the new photo.

FRONTEND. PeopleSheet and the admin manager rendered centred thumbnails and
ignored the bbox, so on group photos the avatar showed whoever stood in the
middle and two people from one photo were indistinguishable — in the manager
whose entire job is telling faces apart. The crop maths is now one shared
helper (faceCrop.ts) so the three surfaces cannot drift again. Full-page
layouts (gallery-premium, gallery-story) render their own lightbox and never
received the people props.

Backend failure set verified identical to origin/main; frontend 140 green;
tsc and eslint clean.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(faces): round 3 — five of round 2's fixes were wrong or no-ops (#1074)

Third and final Codex round. Eight findings, four P1 — and the important part
is that FIVE of them are defects in round 2's fixes, not in the original code.

- The sync-upload enqueue I added was a silent no-op. It queried through
  `trx` after the transaction had already been committed, which throws
  "Transaction query already complete" straight into the catch I had wrapped
  it in. Chunked uploads and watch-folder imports were still never scanned,
  and the code read as though they were. Uses `db` now.

- The post-restore requeue ran BEFORE the files were restored, in both the
  portable importer and the native restore. The face worker is live during a
  restore, so it could claim those rows and scan the previous instance's
  files, or fail them for originals not yet on disk — with nothing to requeue
  them afterwards. Both now run after file restoration; the native one is
  extracted into requeueFaceScans() and called from the full and
  database-only paths.

- The admin face crop mixed coordinate spaces: an original-pixel bbox scaled
  against the THUMBNAIL's natural size. The API now returns the source
  dimensions alongside the box, so there is one space to reason about.

- Forwarding people props through layoutProps did not make them work — the
  full-page layouts never destructured them. GalleryStoryLayout now threads
  them to its own lightbox.

Genuinely new findings, all in the same class as ones already fixed:

- releaseToPending updated unconditionally, so a photo purged while its
  sidecar request was in flight came back as 'pending' and was rescanned —
  biometric rows reappearing after the purge reported success. Round 2 fixed
  exactly this on the COMMIT path and I did not carry it to the retry path.
  Now guarded on 'processing'.

- purgePhotoFaces left face_status alone, so a worker mid-scan still
  satisfied its commit guard and could write fresh faces into a photo being
  deleted — orphans, since the FK cascade is inert on SQLite. It now clears
  the claim as part of the purge.

- Phase 3 was unreachable: the migration seeds face_auto_categorize_enabled
  false and nothing could ever write it, so the rule engine and its undo
  endpoint returned "disabled" in every real flow. Added GET/PUT and a toggle
  on the admin card, EN + DE.

NOT fixed, deliberately: GalleryPremiumLayout uses yet-another-react-lightbox
rather than the shared PhotoLightbox, so person chips there are a real port
rather than a prop forward. Recorded as open rather than bodged.

Backend failure set identical to origin/main; 41 face tests and 140 frontend
tests green; i18n audit reports EN and DE complete at 71 keys.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(faces): block face recognition on the all-in-one image (#1074, #1042)

The single-container image cannot run this feature, so it is refused there
rather than left to degrade.

WHY, since the reason is not obvious from the code: the AIO image runs the
backend, the frontend, SQLite and every background worker inside one
container aimed at "one photographer plus guests browsing". It has no Redis,
SQLite gives it a single writer, and it contains no ML sidecar to talk to.
Face detection would add a second image-processing pipeline competing with
Sharp for the same CPU and memory. That failure is not loud — the install
just becomes slow and looks broken, which is the worst possible shape for a
deployment whose whole promise is one container and no decisions.

Gated on an explicit PICPEAK_SINGLE_CONTAINER marker, NOT inferred from
SERVE_FRONTEND or a SQLite path: plenty of legitimate multi-container setups
serve the frontend from the backend or run SQLite, and none of them should
lose the feature by accident.

Three layers, because the first is the only one that enforces:

- faceSettings.isFeatureEnabled() returns false before consulting the flag,
  so a database restored from a full deployment with `faces` enabled still
  cannot switch it on here.
- The feature-flag API forces `faces: false` in both directions, so the admin
  UI reflects reality instead of offering a switch that refuses to stay on.
- The Features tab renders the card disabled with a plain-language reason,
  read from a new `single_container` field on /admin/system/version (an
  endpoint the admin UI already calls).

Documented in ml/README.md and .env.example. Three tests pin the behaviour,
including that the marker only accepts explicit truthy values.

NOTE FOR PR #1068: this expects `Dockerfile.aio` to set
`ENV PICPEAK_SINGLE_CONTAINER=true`. That one line lives on that branch and
is not in this commit — until it lands, an AIO build would still offer the
feature. Worth adding alongside the `Limits` section of docs/single-container.md.

44 face tests green; EN + DE complete at 72 keys.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* test(faces): pin the bbox coordinate space with a real scale factor (#1074)

The coordinate-space bug — boxes stored in preview space while every consumer
reads them as original-image pixels — had no test, and could not have been
caught by the ones that existed: every photo in the demo gallery is 750px, so
the scale factor was always exactly 1.0 and the correction never executed.

Verified by hand first, on a real 4000x3000 upload with the face placed
off-centre so a wrong crop would be unmistakable. Before the fix the stored
box was 1493,204 (preview space, face actually at x≈2850-3618); after, 3110,426
— a factor of 2.083, exactly 4000/1920, landing inside the face. The admin
crop then resolved to left=-395px/top=-46px on a 64px window, which is the
face centred.

That verification is now a test rather than a memory. Three cases: a 4000px
photo must scale by 4000/1920, a 1920px photo must NOT change (the case that
hid the bug), and a row with no width must fall back to unscaled rather than
storing NaN.

Note for anyone extending these: jest hoists mock factories above the file,
so anything they close over has to be `mock`-prefixed. Getting that wrong
fails at transform time with a message that does not name the variable.

47 face tests green.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-18 22:37:28 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 6ebdd13dde
commit b69dd134d0
71 changed files with 8287 additions and 24 deletions
@@ -0,0 +1,357 @@
/**
* <FaceRecognitionCard>
*
* Per-event "People in this gallery" controls (#1074). Only rendered when the
* `faces` feature flag is on — see OverviewTab.
*
* Two toggles, deliberately separate:
* - Detect people: the actual processing switch. Off by default.
* - Show to guests: whether the people bar reaches the gallery. On by
* default once detection is on, but a photographer may want clustering
* as a private sorting tool without changing what clients see.
*
* Face embeddings are biometric data (GDPR Art. 9 in the EU, where much of
* this user base operates). The consent paragraph is not boilerplate — we
* provide the switch, the photographer provides the lawful basis — so it
* renders next to the toggle rather than behind a "learn more".
*/
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Users, RefreshCw, Trash2, AlertTriangle, ShieldCheck, SlidersHorizontal } from 'lucide-react';
import { PeopleManagerModal } from './PeopleManagerModal';
import { Button, Card, Loading } from '../common';
import { api } from '../../config/api';
interface FacesPayload {
enabled: boolean;
visible_to_guests: boolean;
last_scan_at: string | null;
status: {
scanned: number;
total: number;
pending: number;
failed: number;
people: number;
// What guests actually see: the minimum-cluster-size floor and the
// hidden/ignored flags applied. Usually lower than `people`.
people_visible_to_guests?: number;
in_progress: boolean;
};
}
interface FaceRecognitionCardProps {
eventId: number;
isArchived?: boolean;
}
export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ eventId, isArchived }) => {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const [managerOpen, setManagerOpen] = useState(false);
const [autoCategories, setAutoCategories] = useState(false);
const { data, isLoading, refetch } = useQuery<FacesPayload>({
queryKey: ['admin-event-faces', eventId],
queryFn: async () => (await api.get(`/admin/events/${eventId}/faces`)).data,
// Poll while a scan is running so the status line advances without a
// manual refresh; stop entirely once it settles.
refetchInterval: (query) => (query.state.data?.status?.in_progress ? 5000 : false),
});
useEffect(() => {
if (!data?.enabled) return;
api.get('/admin/events/faces/auto-categories')
.then((r) => setAutoCategories(!!r.data?.enabled))
.catch(() => { /* pre-migration or no permission — leave off */ });
}, [data?.enabled]);
const patch = async (body: Record<string, boolean>) => {
setSaving(true);
try {
const { data: result } = await api.patch(`/admin/events/${eventId}/faces`, body);
await refetch();
if (result?.queued) {
toast.success(t('admin.faces.queued', {
count: result.queued,
defaultValue: `Scanning ${result.queued} photos for people…`,
}));
} else {
toast.success(t('common.saved', { defaultValue: 'Saved' }));
}
} catch (err: any) {
toast.error(err?.response?.data?.error || t('common.saveFailed', { defaultValue: 'Save failed' }));
} finally {
setSaving(false);
}
};
const action = async (path: string, successKey: string, fallback: string) => {
setSaving(true);
try {
await api.post(`/admin/events/${eventId}/faces/${path}`);
await refetch();
toast.success(t(successKey, { defaultValue: fallback }));
} catch (err: any) {
toast.error(err?.response?.data?.error || fallback);
} finally {
setSaving(false);
}
};
const purge = async () => {
// Irreversible and covers biometric data — a plain confirm is the least
// this deserves.
const ok = window.confirm(t('admin.faces.confirmDelete', {
defaultValue: 'Delete all detected people and face data for this gallery? This cannot be undone. Photos are not affected.',
}));
if (!ok) return;
setSaving(true);
try {
await api.delete(`/admin/events/${eventId}/faces`);
await refetch();
toast.success(t('admin.faces.deleted', { defaultValue: 'Face data deleted' }));
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Delete failed');
} finally {
setSaving(false);
}
};
if (isLoading) {
return <Card><Loading /></Card>;
}
if (!data) return null;
const { status } = data;
return (
<Card>
<div className="flex items-start gap-3 mb-4">
<Users className="text-neutral-400 mt-0.5" size={20} />
<div>
<h3 className="text-lg font-medium text-neutral-900">
{t('admin.faces.title', { defaultValue: 'People in this gallery' })}
</h3>
<p className="text-sm text-neutral-500">
{t('admin.faces.subtitle', {
defaultValue: 'Group photos by the people in them, so guests can find and download their own.',
})}
</p>
</div>
</div>
{/* Consent obligation. Stated plainly and up front, because by the time
someone has switched this on they have already processed the data. */}
<div className="flex gap-2 p-3 mb-4 rounded-lg bg-amber-50 border border-amber-100 text-sm text-amber-900">
<ShieldCheck size={16} className="flex-shrink-0 mt-0.5" />
<p>
{t('admin.faces.consentNotice', {
defaultValue:
'Detected faces are personal data, and in the EU they count as a special category. You are the controller for this gallery: make sure you have a lawful basis for the people in these photos before switching this on. Nothing leaves your server — detection runs in your own container.',
})}
</p>
</div>
<label className="flex items-start gap-3 py-2 cursor-pointer">
<input
type="checkbox"
checked={data.enabled}
disabled={saving || isArchived}
onChange={(e) => patch({ enabled: e.target.checked })}
className="mt-1 rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span>
<span className="block text-sm font-medium text-neutral-800">
{t('admin.faces.enable', { defaultValue: 'Detect people in this gallery' })}
</span>
<span className="block text-xs text-neutral-500">
{t('admin.faces.enableHint', {
defaultValue: 'Existing photos are scanned in the background. Faces and their numeric signatures are stored in your database; they are never included in backups or exports.',
})}
</span>
</span>
</label>
{data.enabled && (
<label className="flex items-start gap-3 py-2 cursor-pointer">
<input
type="checkbox"
checked={data.visible_to_guests}
disabled={saving || isArchived}
onChange={(e) => patch({ visible_to_guests: e.target.checked })}
className="mt-1 rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span>
<span className="block text-sm font-medium text-neutral-800">
{t('admin.faces.visible', { defaultValue: 'Show the people bar to guests' })}
</span>
<span className="block text-xs text-neutral-500">
{t('admin.faces.visibleHint', {
defaultValue: 'Off means you get the grouping as a private tool and guests see an unchanged gallery.',
})}
</span>
</span>
</label>
)}
{/* The preview-tier cost. Scanning generates the lightbox preview for
every photo that lacks one, which is real CPU and real disk — an
admin deserves to know that before starting a 2,000-photo backfill
rather than discovering it in their storage graph. */}
{data.enabled && (
<div className="flex gap-2 p-3 mt-3 rounded-lg bg-neutral-50 text-xs text-neutral-600">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5 text-neutral-400" />
<p>
{t('admin.faces.previewNotice', {
defaultValue: 'Scanning works on the preview-sized copy of each photo. Galleries that have not generated previews yet will create them during the first scan, which uses additional CPU and disk space.',
})}
</p>
</div>
)}
{/* Auto-categories (#1074 phase 3). Global, not per-event, which is why
it sits apart from the two toggles above. Without a control here the
rule engine had no way to be switched on at all. */}
{data.enabled && (
<label className="flex items-start gap-3 py-2 mt-2 pt-3 border-t border-neutral-100 cursor-pointer">
<input
type="checkbox"
checked={autoCategories}
disabled={saving}
onChange={async (e) => {
const next = e.target.checked;
setSaving(true);
try {
await api.put('/admin/events/faces/auto-categories', { enabled: next });
setAutoCategories(next);
toast.success(t('common.saved', { defaultValue: 'Saved' }));
} catch (err: any) {
toast.error(err?.response?.data?.error || t('common.saveFailed', { defaultValue: 'Save failed' }));
} finally {
setSaving(false);
}
}}
className="mt-1 rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<span>
<span className="block text-sm font-medium text-neutral-800">
{t('admin.faces.autoCategories', { defaultValue: 'Sort photos into categories automatically' })}
</span>
<span className="block text-xs text-neutral-500">
{t('admin.faces.autoCategoriesHint', {
defaultValue: 'Uses the number of faces to file photos as Details, Portraits, Small groups or Groups. Applies to every gallery, only ever fills an empty category, and never changes one you set yourself.',
})}
</span>
</span>
</label>
)}
{data.enabled && (
<>
<div className="mt-4 pt-4 border-t border-neutral-100 text-sm text-neutral-600">
{status.in_progress ? (
<p className="flex items-center gap-2">
<RefreshCw size={14} className="animate-spin text-primary-500" />
{t('admin.faces.scanning', {
scanned: status.scanned,
total: status.total,
defaultValue: `Scanning… ${status.scanned} of ${status.total} photos`,
})}
</p>
) : (
<p>
{t('admin.faces.status', {
scanned: status.scanned,
total: status.total,
people: status.people,
defaultValue: `${status.scanned} / ${status.total} photos scanned · ${status.people} people`,
})}
{/* The gallery shows fewer: one-off appearances stay out of
the strip. Without both numbers an admin sees the settings
page and their own gallery disagree, with no explanation. */}
{typeof status.people_visible_to_guests === 'number'
&& status.people_visible_to_guests !== status.people && (
<span className="text-neutral-400">
{' '}
{t('admin.faces.visibleToGuests', {
count: status.people_visible_to_guests,
defaultValue: `(${status.people_visible_to_guests} shown to guests)`,
})}
</span>
)}
{status.failed > 0 && (
<span className="text-amber-600">
{' · '}
{t('admin.faces.failed', {
count: status.failed,
defaultValue: `${status.failed} failed`,
})}
</span>
)}
</p>
)}
</div>
<div className="flex flex-wrap gap-2 mt-4">
<Button
variant="outline"
size="sm"
disabled={saving}
onClick={() => setManagerOpen(true)}
leftIcon={<SlidersHorizontal size={14} />}
>
{t('admin.faces.manage', { defaultValue: 'Manage people' })}
</Button>
<Button
variant="outline"
size="sm"
disabled={saving || isArchived}
onClick={() => action('rescan', 'admin.faces.rescanQueued', 'Re-scan queued')}
leftIcon={<RefreshCw size={14} />}
>
{t('admin.faces.rescan', { defaultValue: 'Re-scan' })}
</Button>
{/* Cheap — re-derives people from data we already have, with no
sidecar call. The button to reach for after changing the
match threshold. */}
<Button
variant="outline"
size="sm"
disabled={saving || isArchived}
onClick={() => action('recluster', 'admin.faces.reclustered', 'People regrouped')}
leftIcon={<Users size={14} />}
>
{t('admin.faces.recluster', { defaultValue: 'Re-group people' })}
</Button>
<Button
variant="outline"
size="sm"
disabled={saving}
onClick={purge}
leftIcon={<Trash2 size={14} />}
className="text-red-600 border-red-200 hover:bg-red-50"
>
{t('admin.faces.delete', { defaultValue: 'Delete all face data' })}
</Button>
</div>
<PeopleManagerModal
eventId={eventId}
open={managerOpen}
onClose={() => setManagerOpen(false)}
onChanged={() => refetch()}
/>
</>
)}
</Card>
);
};
export default FaceRecognitionCard;
@@ -0,0 +1,436 @@
/**
* <PeopleManagerModal> — manage the people detected in one gallery (#1074).
*
* Merge and split are the two operations that make automatic clustering
* survive contact with reality. A well-behaved wedding gallery still produces
* "Anna in daylight" and "Anna at the party" as separate people, and the
* clustering deliberately errs toward splitting rather than merging (a
* duplicate entry is an annoyance; a wrong merge puts a stranger into
* someone's download). That trade only works if merging is easy, which is
* what this screen is for.
*
* Every action here hits an endpoint that already re-checks event ownership
* and that the ids belong to this gallery — the UI is a convenience, never
* the control.
*/
import React, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { X, Check, Merge, Scissors, EyeOff, Ban, Loader2 } from 'lucide-react';
import { Button, Loading } from '../common';
import { api } from '../../config/api';
import { faceCropStyle } from '../gallery/faceCrop';
interface AdminPerson {
id: number;
label: string | null;
face_count: number;
total_face_count?: number;
is_hidden?: boolean;
is_ignored?: boolean;
cover: {
face_id: number;
photo_id: number;
bbox: [number, number, number, number];
photo_width: number | null;
photo_height: number | null;
} | null;
}
interface PersonFace {
id: number;
photo_id: number;
bbox: [number, number, number, number];
photo_width: number | null;
photo_height: number | null;
score: number | null;
blur: number | null;
}
interface PeopleManagerModalProps {
eventId: number;
open: boolean;
onClose: () => void;
onChanged?: () => void;
}
/**
* Square crop of a face, served by the admin thumbnail route (which applies
* its own auth + event-ownership checks).
*
* A plain <img>, deliberately NOT <AuthenticatedImage>. That component
* attaches whatever gallery token it finds in session storage, and an admin
* who has also opened one of their own galleries in the same browser then
* sends a `type: "gallery"` bearer token to an admin route — which rejects
* it as insufficient permissions, so every thumbnail 403s. The admin routes
* authenticate from the httpOnly admin_token cookie, which a same-origin
* <img> sends on its own.
*/
const FaceThumb: React.FC<{
eventId: number;
photoId: number;
bbox?: [number, number, number, number] | null;
photoWidth?: number | null;
photoHeight?: number | null;
size?: number;
dim?: boolean;
}> = ({ eventId, photoId, bbox, photoWidth, photoHeight, size = 64, dim }) => {
// Crop to the face rather than showing the centred thumbnail. On a group
// photo the uncropped version shows whoever stands in the middle, so two
// different people whose cover is the same photo looked IDENTICAL here —
// in the manager whose whole job is telling faces apart.
//
// COORDINATE SPACE: the bbox is in ORIGINAL image pixels, so it must be
// scaled against the ORIGINAL dimensions (now supplied by the API), never
// against the thumbnail's own natural size. Mixing the two renders the
// wrong region entirely — the box ends up a fraction of its true size and
// offset toward the top-left.
const style = faceCropStyle(bbox ? { bbox } : null, photoWidth, photoHeight, size);
return (
<span
className="relative block rounded-full overflow-hidden bg-neutral-100 flex-shrink-0"
style={{ width: size, height: size, opacity: dim ? 0.4 : 1 }}
>
<img
src={`/api/admin/photos/${eventId}/thumbnail/${photoId}`}
alt=""
loading="lazy"
style={style || { width: '100%', height: '100%', objectFit: 'cover' }}
/>
</span>
);
};
export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
eventId, open, onClose, onChanged,
}) => {
const { t } = useTranslation();
const [selected, setSelected] = useState<number[]>([]);
const [renaming, setRenaming] = useState<number | null>(null);
const [draftLabel, setDraftLabel] = useState('');
const [splitting, setSplitting] = useState<AdminPerson | null>(null);
const [splitFaceIds, setSplitFaceIds] = useState<number[]>([]);
const [busy, setBusy] = useState(false);
const { data, isLoading, refetch } = useQuery<{ people: AdminPerson[] }>({
queryKey: ['admin-event-people', eventId],
queryFn: async () => (await api.get(`/admin/events/${eventId}/people`)).data,
enabled: open,
});
const { data: faceData, isLoading: facesLoading } = useQuery<{ faces: PersonFace[] }>({
queryKey: ['admin-person-faces', eventId, splitting?.id],
queryFn: async () =>
(await api.get(`/admin/events/${eventId}/people/${splitting!.id}/faces`)).data,
enabled: !!splitting,
});
const people = useMemo(() => data?.people || [], [data]);
const after = async (message: string) => {
await refetch();
onChanged?.();
setSelected([]);
toast.success(message);
};
const run = async (fn: () => Promise<void>, successMessage: string) => {
setBusy(true);
try {
await fn();
await after(successMessage);
} catch (err: any) {
toast.error(err?.response?.data?.error || t('common.saveFailed', { defaultValue: 'Failed' }));
} finally {
setBusy(false);
}
};
const toggleSelect = (id: number) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const saveLabel = (person: AdminPerson) => {
const label = draftLabel.trim();
setRenaming(null);
if (label === (person.label || '')) return;
run(
async () => {
await api.patch(`/admin/events/${eventId}/people/${person.id}`, { label: label || null });
},
t('admin.people.renamed', { defaultValue: 'Name saved' })
);
};
const doMerge = () => {
// The first selected person is the target — it keeps its name, which is
// almost always the one the photographer already bothered to type.
const [target, ...sources] = selected;
run(
async () => {
await api.post(`/admin/events/${eventId}/people/merge`, {
source_ids: sources, target_id: target,
});
},
t('admin.people.merged', { count: sources.length, defaultValue: 'People merged' })
);
};
const doSplit = () => {
if (!splitting || !splitFaceIds.length) return;
const personId = splitting.id;
const ids = splitFaceIds;
setSplitting(null);
setSplitFaceIds([]);
run(
async () => {
await api.post(`/admin/events/${eventId}/people/${personId}/split`, { face_ids: ids });
},
t('admin.people.split', { defaultValue: 'Split into a new person' })
);
};
const setFlag = (person: AdminPerson, field: 'is_hidden' | 'is_ignored', value: boolean) =>
run(
async () => {
await api.patch(`/admin/events/${eventId}/people/${person.id}`, { [field]: value });
},
t('admin.people.updated', { defaultValue: 'Updated' })
);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
className="relative bg-white text-neutral-900 rounded-xl shadow-xl w-full max-w-4xl max-h-[88vh] flex flex-col"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-100">
<div>
<h2 className="text-lg font-medium text-neutral-900">
{t('admin.people.title', { defaultValue: 'People in this gallery' })}
</h2>
<p className="text-sm text-neutral-500">
{t('admin.people.subtitle', {
defaultValue: 'Rename, merge people who were split apart, or hide someone from guests.',
})}
</p>
</div>
<button type="button" onClick={onClose} className="p-2 -m-2 text-neutral-400 hover:text-neutral-600">
<X size={20} />
</button>
</div>
{/* --- split picker ------------------------------------------------ */}
{splitting ? (
<>
<div className="px-5 py-3 bg-amber-50 border-b border-amber-100 text-sm text-amber-900">
{t('admin.people.splitHelp', {
defaultValue: 'Pick the photos that are NOT this person. They become a new entry, and everything else stays.',
})}
</div>
<div className="flex-1 overflow-y-auto p-5">
{facesLoading ? <Loading /> : (
<div className="grid grid-cols-4 sm:grid-cols-6 gap-3">
{(faceData?.faces || []).map((face) => {
const picked = splitFaceIds.includes(face.id);
return (
<button
key={face.id}
type="button"
onClick={() => setSplitFaceIds((p) =>
p.includes(face.id) ? p.filter((x) => x !== face.id) : [...p, face.id])}
className={`relative rounded-lg overflow-hidden border-2 transition-colors ${
picked ? 'border-primary-600' : 'border-transparent hover:border-neutral-300'
}`}
>
<FaceThumb
eventId={eventId}
photoId={face.photo_id}
bbox={face.bbox}
photoWidth={face.photo_width}
photoHeight={face.photo_height}
size={88}
/>
{picked && (
<span className="absolute top-1 right-1 bg-primary-600 text-white rounded-full p-0.5">
<Check size={12} />
</span>
)}
</button>
);
})}
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 px-5 py-4 border-t border-neutral-100">
<span className="text-sm text-neutral-500">
{t('admin.people.splitSelected', {
count: splitFaceIds.length,
defaultValue: `${splitFaceIds.length} selected`,
})}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setSplitting(null); setSplitFaceIds([]); }}>
{t('common.cancel', { defaultValue: 'Cancel' })}
</Button>
<Button variant="primary" size="sm" disabled={!splitFaceIds.length || busy} onClick={doSplit}>
{t('admin.people.doSplit', { defaultValue: 'Split out' })}
</Button>
</div>
</div>
</>
) : (
<>
{/* --- people grid --------------------------------------------- */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? <Loading /> : people.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-10">
{t('admin.people.empty', { defaultValue: 'No people detected yet.' })}
</p>
) : (
<div className="space-y-1">
{people.map((person) => {
const isSelected = selected.includes(person.id);
return (
<div
key={person.id}
className={`flex items-center gap-3 p-2 rounded-lg border transition-colors ${
isSelected ? 'border-primary-400 bg-primary-50' : 'border-transparent hover:bg-neutral-50'
}`}
>
<button
type="button"
onClick={() => toggleSelect(person.id)}
aria-pressed={isSelected}
aria-label={t('admin.people.select', { defaultValue: 'Select for merging' })}
className="flex-shrink-0"
>
<FaceThumb
eventId={eventId}
photoId={person.cover?.photo_id ?? 0}
bbox={person.cover?.bbox}
photoWidth={person.cover?.photo_width}
photoHeight={person.cover?.photo_height}
dim={person.is_ignored || person.is_hidden}
/>
</button>
<div className="flex-1 min-w-0">
{renaming === person.id ? (
<input
autoFocus
value={draftLabel}
onChange={(e) => setDraftLabel(e.target.value)}
onBlur={() => saveLabel(person)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveLabel(person);
if (e.key === 'Escape') setRenaming(null);
}}
placeholder={t('admin.people.namePlaceholder', { defaultValue: 'Add a name' })}
className="w-full max-w-xs px-2 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
) : (
<button
type="button"
onClick={() => { setRenaming(person.id); setDraftLabel(person.label || ''); }}
className="text-sm text-left text-neutral-900 hover:underline"
>
{person.label || (
<span className="text-neutral-400 italic">
{t('admin.people.unnamed', { defaultValue: 'Add a name' })}
</span>
)}
</button>
)}
<p className="text-xs text-neutral-500">
{t('admin.people.photoCount', {
count: person.total_face_count ?? person.face_count,
defaultValue: `${person.total_face_count ?? person.face_count} photos`,
})}
{person.is_hidden && ` · ${t('admin.people.hidden', { defaultValue: 'hidden from guests' })}`}
{person.is_ignored && ` · ${t('admin.people.ignored', { defaultValue: 'ignored' })}`}
</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
disabled={busy}
title={t('admin.people.splitAction', { defaultValue: 'Split out photos that are someone else' })}
onClick={() => { setSplitting(person); setSplitFaceIds([]); }}
className="p-2 text-neutral-400 hover:text-neutral-700 rounded"
>
<Scissors size={16} />
</button>
<button
type="button"
disabled={busy}
title={t('admin.people.hideAction', { defaultValue: 'Hide from guests' })}
onClick={() => setFlag(person, 'is_hidden', !person.is_hidden)}
className={`p-2 rounded ${person.is_hidden ? 'text-primary-600' : 'text-neutral-400 hover:text-neutral-700'}`}
>
<EyeOff size={16} />
</button>
<button
type="button"
disabled={busy}
title={t('admin.people.ignoreAction', { defaultValue: 'Not a real person — ignore' })}
onClick={() => setFlag(person, 'is_ignored', !person.is_ignored)}
className={`p-2 rounded ${person.is_ignored ? 'text-red-600' : 'text-neutral-400 hover:text-neutral-700'}`}
>
<Ban size={16} />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Merge only becomes available at two, and the wording names the
target explicitly so nobody has to guess which name survives. */}
<div className="flex items-center justify-between gap-3 px-5 py-4 border-t border-neutral-100">
<span className="text-sm text-neutral-500">
{selected.length > 0
? t('admin.people.selectedCount', {
count: selected.length,
defaultValue: `${selected.length} selected`,
})
: t('admin.people.mergeHint', {
defaultValue: 'Tap two or more faces to merge them into one person.',
})}
</span>
<div className="flex gap-2">
{selected.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setSelected([])}>
{t('common.clear', { defaultValue: 'Clear' })}
</Button>
)}
<Button
variant="primary"
size="sm"
disabled={selected.length < 2 || busy}
onClick={doMerge}
leftIcon={busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Merge className="w-4 h-4" />}
>
{t('admin.people.merge', { defaultValue: 'Merge' })}
</Button>
</div>
</div>
</>
)}
</div>
</div>
);
};
export default PeopleManagerModal;
@@ -20,6 +20,8 @@ interface SystemVersion {
node: string;
environment: string;
channel?: 'stable' | 'beta';
// True on the all-in-one image; some features are unavailable there.
single_container?: boolean;
}
interface UpdateInfo {
+243 -4
View File
@@ -17,12 +17,14 @@ import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import { GuestNamePromptModal } from './GuestNamePromptModal';
import { GuestRecoveryModal } from './GuestRecoveryModal';
import { PeopleStrip } from './PeopleStrip';
import { PeopleSheet } from './PeopleSheet';
import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
import type { FilterType, FeedbackFilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api';
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { feedbackService } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
@@ -104,6 +106,41 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Multi-select feedback filters (#889): OR-combined; empty = "All".
// Clicking a filter toggles it, clicking "All" clears the set.
const [activeFilters, setActiveFilters] = useState<FeedbackFilterType[]>([]);
// People filter (#1074). Multi-select, AND by default — see the filter
// block below. `peopleMatchAny` only becomes reachable once a second
// person is picked, since the toggle is meaningless for one.
const [selectedPersonIds, setSelectedPersonIds] = useState<number[]>([]);
const [peopleMatchAny, setPeopleMatchAny] = useState(false);
const [showPeopleSheet, setShowPeopleSheet] = useState(false);
// Dismissal is per gallery: a guest who hides the bar in one gallery has
// said nothing about the next one.
const [peopleCollapsed, setPeopleCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(`picpeak_people_collapsed_${slug}`) === '1';
} catch {
return false;
}
});
const handlePeopleCollapsedChange = (collapsed: boolean) => {
setPeopleCollapsed(collapsed);
try {
localStorage.setItem(`picpeak_people_collapsed_${slug}`, collapsed ? '1' : '0');
} catch {
// Private-mode Safari throws on setItem; the in-memory state still works.
}
};
const togglePerson = (personId: number) => {
setSelectedPersonIds((prev) => {
const next = prev.includes(personId)
? prev.filter((id) => id !== personId)
: [...prev, personId];
// Dropping back below two people makes the any/all toggle meaningless;
// reset it so it doesn't silently persist into the next selection.
if (next.length < 2) setPeopleMatchAny(false);
return next;
});
};
const handleFilterChange = (filter: FilterType) => {
if (filter === 'all') {
setActiveFilters([]);
@@ -263,6 +300,41 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
enabled: !!event.id,
});
// People in this gallery (#1074).
//
// Gated on people_enabled so an install without the feature never fires the
// request at all. Polls only while a backfill is running — a finished
// gallery has a stable people list, and polling it forever would be a
// request per guest per interval for no new information.
// From the /photos payload, not the prop: the prop's event shape comes
// from /info, which does not carry this flag.
const peopleEnabled = data?.event?.people_enabled === true;
const { data: peopleData } = useQuery({
queryKey: ['gallery-people', slug],
queryFn: () => galleryService.getPeople(slug),
enabled: peopleEnabled,
refetchInterval: (query) => (query.state.data?.scan?.in_progress ? 5000 : false),
staleTime: 30_000,
});
const people = peopleData?.people || [];
// The strip comes from /people, but FILTERING uses photo.person_ids, which
// rides on the one-shot /photos response. During a backfill those drift
// apart: new faces appear in the strip while the photo memberships behind
// them are still the set fetched on page load, so tapping a person yields
// zero or a partial result until a manual reload — including after the scan
// has finished.
//
// Refetch the photos whenever the scan's progress changes, and once more on
// the transition to finished.
const scanProgress = peopleData?.scan
? `${peopleData.scan.in_progress}:${peopleData.scan.scanned}`
: null;
useEffect(() => {
if (!peopleEnabled || !scanProgress) return;
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
}, [scanProgress, peopleEnabled, slug, queryClient]);
// Update feedbackEnabled when settings change
useEffect(() => {
if (feedbackSettings) {
@@ -551,7 +623,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
};
photos = photos.filter(photo => activeFilters.some(filter => matchers[filter](photo)));
}
// Apply people filter (#1074). Composes with every filter above rather
// than replacing them, so "photos of Anna that I liked" works.
//
// Two people selected means AND by default ("photos with both Anna and
// Ben") — that is what someone picking a second face is almost always
// asking for. `peopleMatchAny` flips it to OR for the couple-shots case.
if (selectedPersonIds.length > 0) {
photos = photos.filter(photo => {
const ids = photo.person_ids || [];
return peopleMatchAny
? selectedPersonIds.some(id => ids.includes(id))
: selectedPersonIds.every(id => ids.includes(id));
});
}
// Apply sorting
// Each comparator defaults to its natural order (desc for dates/size/rating, asc for name).
// The flip multiplier reverses that when sortDesc differs from the natural order.
@@ -592,7 +679,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds]);
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]);
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
// mode these need to mirror the per-guest filter behaviour above —
@@ -682,6 +769,40 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
setIsSelectionMode(false);
};
// "Download these N" (#1074) — the payoff of the people filter.
//
// Deliberately NO new endpoint or person_id selector: the filtered photo
// ids go through the same path as a manual selection, and the server
// re-applies the access level and per-category permissions on the way
// through. One less thing to authorize.
//
// Photos in a category with downloads disabled (#640) are excluded HERE as
// well as server-side, so the number on the button is the number the guest
// actually receives rather than an optimistic one.
const peopleDownloadableIds = useMemo(() => {
if (selectedPersonIds.length === 0) return [];
return filteredPhotos
.filter((photo) => photo.category_allow_downloads !== false)
.map((photo) => photo.id);
}, [filteredPhotos, selectedPersonIds]);
const handleDownloadPeopleFiltered = async () => {
if (!allowDownloads || peopleDownloadableIds.length === 0) return;
// Same resolution-picker behaviour as every other multi-photo download.
if (downloadChoices.length > 1) {
setResolutionPickerIds(peopleDownloadableIds);
return;
}
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: peopleDownloadableIds.length,
});
await galleryService.downloadSelectedPhotos(slug, peopleDownloadableIds);
};
// Calculate photo counts per category
const photoCounts = useMemo(() => {
if (!data?.photos) return {};
@@ -847,6 +968,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<PhotoGridWithLayouts
photos={filteredPhotos}
slug={slug}
people={peopleEnabled ? people : undefined}
onSelectPerson={togglePerson}
categoryId={selectedCategoryId}
onFeedbackChange={() => {
refetch();
@@ -1118,6 +1241,107 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
</div>
) : null}
{/* People in this gallery (#1074). Sits between the filter bar and
the grid. Renders nothing at all unless the photographer enabled
detection AND left it visible to guests — people_enabled carries
both decisions plus the global feature flag. */}
{peopleEnabled && (
<div className="mt-4">
<PeopleStrip
people={people}
photos={data.photos}
slug={slug}
selectedPersonIds={selectedPersonIds}
onToggle={togglePerson}
onShowAll={() => setShowPeopleSheet(true)}
scan={peopleData?.scan}
collapsed={peopleCollapsed}
onCollapsedChange={handlePeopleCollapsedChange}
/>
{/* Active people filter. The chip row is the single place the
current selection is stated, so "why am I seeing 97 photos"
is always answerable at a glance. */}
{selectedPersonIds.length > 0 && (
<div
className="flex flex-wrap items-center gap-2 py-2 border-t"
style={{ borderColor: 'var(--color-surface-border)' }}
>
{selectedPersonIds.map((id) => {
const person = people.find((p) => p.id === id);
if (!person) return null;
return (
<button
key={id}
type="button"
onClick={() => togglePerson(id)}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-50 text-primary-700 text-sm hover:bg-primary-100"
>
{person.label || t('gallery.people.unnamedCount', {
count: person.face_count,
defaultValue: `${person.face_count} photos`,
})}
<X size={14} />
</button>
);
})}
{/* Only meaningful with two or more people picked. */}
{selectedPersonIds.length > 1 && (
<button
type="button"
onClick={() => setPeopleMatchAny((v) => !v)}
className="px-2.5 py-1 rounded-full border text-xs"
style={{
color: 'var(--color-text)',
borderColor: 'var(--color-surface-border)',
}}
>
{peopleMatchAny
? t('gallery.people.matchAny', { defaultValue: 'Either person' })
: t('gallery.people.matchAll', { defaultValue: 'Both people' })}
</button>
)}
{/* ml-auto only once there's room for it — at 390px the count
and Clear were pushed against the right edge and clipped. */}
<span className="text-sm sm:ml-auto" style={{ color: 'var(--color-muted-text)' }}>
{t('gallery.people.matchCount', {
count: filteredPhotos.length,
total: totalCount,
defaultValue: `${filteredPhotos.length} of ${totalCount} photos`,
})}
</span>
{/* Hidden entirely when downloads are off for the gallery,
rather than shown-and-failing. */}
{allowDownloads && peopleDownloadableIds.length > 0 && (
<Button
variant="primary"
size="sm"
onClick={handleDownloadPeopleFiltered}
leftIcon={<Download className="w-4 h-4" />}
>
{t('gallery.people.downloadThese', {
count: peopleDownloadableIds.length,
defaultValue: `Download these ${peopleDownloadableIds.length}`,
})}
</Button>
)}
<button
type="button"
onClick={() => { setSelectedPersonIds([]); setPeopleMatchAny(false); }}
className="text-sm underline"
style={{ color: 'var(--color-muted-text)' }}
>
{t('gallery.people.clear', { defaultValue: 'Clear' })}
</button>
</div>
)}
</div>
)}
{/* Photo Grid — when the hero header sits directly under the filter
bar, double the wrapper margin (mt-12) so the hero's decorative
`-mt-6` bleed leaves a visible gap instead of gluing the filter
@@ -1125,7 +1349,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<div className={filterBarShown && isHeroHeader ? "mt-12" : "mt-6"}>
<PhotoGridWithLayouts
photos={filteredPhotos}
slug={slug}
slug={slug}
people={peopleEnabled ? people : undefined}
onSelectPerson={togglePerson}
categoryId={selectedCategoryId}
onFeedbackChange={() => {
refetch();
@@ -1201,6 +1427,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}}
/>
)}
{/* "Show all" people (#1074) — a bottom sheet on mobile. */}
{peopleEnabled && (
<PeopleSheet
open={showPeopleSheet}
onClose={() => setShowPeopleSheet(false)}
people={people}
photos={data?.photos || []}
slug={slug}
selectedPersonIds={selectedPersonIds}
onToggle={togglePerson}
/>
)}
</GalleryLayout>
</>
</GuestIdentityProvider>
@@ -0,0 +1,172 @@
import React, { useMemo, useState } from 'react';
import { Search, X, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import type { GalleryPerson, Photo } from '../../types';
import { faceCropStyle } from './faceCrop';
/**
* "Show all" people (#1074).
*
* A bottom sheet on mobile, a centred panel on desktop. The footnote is not
* decoration — "where does this data go?" is the first question every guest
* has, and answering it inline is cheaper than losing their trust.
*/
interface PeopleSheetProps {
open: boolean;
onClose: () => void;
people: GalleryPerson[];
photos: Photo[];
slug: string;
selectedPersonIds: number[];
onToggle: (personId: number) => void;
}
export const PeopleSheet: React.FC<PeopleSheetProps> = ({
open, onClose, people, photos, slug, selectedPersonIds, onToggle,
}) => {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const photoById = useMemo(() => {
const map = new Map<number, Photo>();
for (const photo of photos) map.set(photo.id, photo);
return map;
}, [photos]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return people;
// Only named people are searchable — there is nothing to match an
// unnamed cluster against, and pretending otherwise (matching the count,
// say) would be a puzzle rather than a feature.
return people.filter((p) => p.label?.toLowerCase().includes(q));
}, [people, query]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center sm:justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
aria-label={t('gallery.people.title', { defaultValue: 'People in this gallery' })}
className="relative w-full sm:max-w-2xl bg-white rounded-t-2xl sm:rounded-2xl shadow-xl max-h-[85vh] flex flex-col"
>
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b border-neutral-100">
<h2 className="text-base font-medium text-neutral-900">
{t('gallery.people.title', { defaultValue: 'People in this gallery' })}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t('common.close', { defaultValue: 'Close' })}
className="p-2 -m-2 text-neutral-400 hover:text-neutral-600"
>
<X size={20} />
</button>
</div>
{people.some((p) => p.label) && (
<div className="px-4 pt-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('gallery.people.searchPlaceholder', { defaultValue: 'Find a person' })}
className="w-full pl-9 pr-3 py-2 text-sm border border-neutral-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="grid grid-cols-3 sm:grid-cols-5 gap-4">
{filtered.map((person) => {
const photo = person.cover ? photoById.get(person.cover.photo_id) : undefined;
const selected = selectedPersonIds.includes(person.id);
return (
<button
key={person.id}
type="button"
onClick={() => onToggle(person.id)}
aria-pressed={selected}
className="flex flex-col items-center gap-1.5 group focus:outline-none"
>
<span
className={[
'relative block w-16 h-16 rounded-full overflow-hidden bg-neutral-100 transition-all',
selected
? 'ring-[3px] ring-offset-2 ring-primary-600'
: 'ring-1 ring-neutral-200 group-hover:ring-neutral-400',
].join(' ')}
>
{photo && (
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt=""
isGallery
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
// Crop to the face, exactly as the strip does. Without
// this a group photo shows whoever is centred — often
// not the person being labelled, and identical for two
// people whose cover is the same photo.
style={faceCropStyle(person.cover, photo.width, photo.height, 64)
|| { width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
</span>
<span className="w-full text-center leading-tight">
<span className={`block truncate text-xs ${person.label ? 'font-medium text-neutral-800' : 'text-neutral-500'}`}>
{person.label || t('gallery.people.unnamedCount', {
count: person.face_count,
defaultValue: `${person.face_count} photos`,
})}
</span>
{person.label && (
<span className="block text-[11px] text-neutral-400">{person.face_count}</span>
)}
</span>
</button>
);
})}
</div>
{filtered.length === 0 && (
<p className="text-sm text-neutral-500 text-center py-8">
{t('gallery.people.noMatches', { defaultValue: 'No one matches that name.' })}
</p>
)}
</div>
{/* The answer to the first question every guest has. Deliberately in
plain language — this copy never says "biometric" or
"recognition", because those words describe our implementation,
not the guest's experience. */}
<div className="px-4 py-3 border-t border-neutral-100 flex gap-2 text-xs text-neutral-500">
<Info size={14} className="flex-shrink-0 mt-0.5" />
<p>
{t('gallery.people.privacyNote', {
defaultValue: 'People are detected automatically inside this gallery. Nothing is sent to any external service.',
})}
</p>
</div>
</div>
</div>
);
};
export default PeopleSheet;
@@ -0,0 +1,246 @@
import React, { useMemo, useState } from 'react';
import { ChevronRight, Users, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import type { GalleryPerson, Photo } from '../../types';
import { faceCropStyle } from './faceCrop';
/**
* "People in this gallery" (#1074).
*
* The design goal is that a guest on a phone finds themselves in two taps and
* never learns the words "face recognition". So: circular crops, first names
* where the photographer supplied them, a photo count where they didn't, and
* no vocabulary from the implementation anywhere in the UI.
*
* Unnamed people show ONLY a count. Never "Person 7" — a number is honest
* about what the system knows; an invented name is not.
*/
interface PersonAvatarProps {
person: GalleryPerson;
photo: Photo | undefined;
slug: string;
size: number;
selected: boolean;
onClick: () => void;
}
/**
* A face crop, produced by scaling the source photo inside a round window so
* the stored bbox lands centred.
*
* The bbox is in ORIGINAL image pixels but the thumbnail is served at some
* other size, so everything is done in RATIOS of the source dimensions —
* which survives whatever rendition the browser actually gets. Without the
* photo's width/height we can't compute those ratios, so the component falls
* back to an un-cropped thumbnail rather than rendering a wrongly-offset crop.
*/
const PersonAvatar: React.FC<PersonAvatarProps> = ({
person, photo, slug, size, selected, onClick,
}) => {
const { t } = useTranslation();
const cropStyle = useMemo(
() => faceCropStyle(person.cover, photo?.width, photo?.height, size),
[person.cover, photo?.width, photo?.height, size],
);
const label = person.label
|| t('gallery.people.unnamedCount', { count: person.face_count, defaultValue: `${person.face_count} photos` });
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
aria-label={person.label
? t('gallery.people.filterBy', { name: person.label, defaultValue: `Show photos of ${person.label}` })
: t('gallery.people.filterByUnnamed', { defaultValue: 'Show photos of this person' })}
className="flex flex-col items-center gap-1.5 flex-shrink-0 group focus:outline-none"
style={{ width: `${size + 8}px` }}
>
<span
className={[
'relative block rounded-full overflow-hidden bg-neutral-100 transition-all',
selected
? 'ring-[3px] ring-offset-2 ring-primary-600'
: 'ring-1 ring-neutral-200 group-hover:ring-neutral-400 group-focus-visible:ring-primary-500',
].join(' ')}
style={{ width: `${size}px`, height: `${size}px` }}
>
{photo && person.cover ? (
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt=""
isGallery
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
style={cropStyle || { width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span className="flex items-center justify-center w-full h-full text-neutral-400">
<Users size={size / 2.5} />
</span>
)}
</span>
<span className="w-full text-center leading-tight">
{/* Colours come from the gallery theme tokens, not fixed neutrals:
galleries can be dark, and hardcoded `text-neutral-800` renders
a named person's label almost invisibly against one. */}
<span
className="block truncate text-xs"
style={{
color: selected ? 'var(--color-accent)' : 'var(--color-text)',
fontWeight: person.label ? 500 : 400,
opacity: person.label ? 1 : 0.75,
}}
>
{label}
</span>
{person.label && (
<span className="block text-[11px]" style={{ color: 'var(--color-muted-text)' }}>
{person.face_count}
</span>
)}
</span>
</button>
);
};
interface PeopleStripProps {
people: GalleryPerson[];
photos: Photo[];
slug: string;
selectedPersonIds: number[];
onToggle: (personId: number) => void;
onShowAll: () => void;
scan?: { in_progress: boolean; scanned: number; total: number };
/** Persisted per-slug so a guest who dismisses it stays dismissed. */
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
/** Avatars shown inline; the rest live behind "Show all". */
maxInline?: number;
}
export const PeopleStrip: React.FC<PeopleStripProps> = ({
people, photos, slug, selectedPersonIds, onToggle, onShowAll,
scan, collapsed, onCollapsedChange, maxInline = 12,
}) => {
const { t } = useTranslation();
const [avatarSize] = useState(() => (typeof window !== 'undefined' && window.innerWidth < 640 ? 56 : 64));
const photoById = useMemo(() => {
const map = new Map<number, Photo>();
for (const photo of photos) map.set(photo.id, photo);
return map;
}, [photos]);
// Fewer than two people isn't a "people in this gallery" feature, it's a
// single face taking up a row of the screen. Don't render at all.
if (people.length < 2 && !scan?.in_progress) return null;
if (collapsed) {
return (
<div className="flex items-center justify-between px-1 py-2 text-sm">
<span style={{ color: 'var(--color-muted-text)' }}>
{t('gallery.people.collapsedSummary', {
count: people.length,
defaultValue: `${people.length} people found`,
})}
</span>
<button
type="button"
onClick={() => onCollapsedChange(false)}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{t('gallery.people.show', { defaultValue: 'Show' })}
</button>
</div>
);
}
const inline = people.slice(0, maxInline);
const hasMore = people.length > inline.length;
return (
<div className="py-3">
<div className="flex items-center justify-between mb-2 px-1">
<h3 className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{t('gallery.people.title', { defaultValue: 'People in this gallery' })}
</h3>
<div className="flex items-center gap-3">
{hasMore && (
<button
type="button"
onClick={onShowAll}
className="flex items-center gap-0.5 text-sm text-primary-600 hover:text-primary-700"
>
{t('gallery.people.showAll', {
count: people.length,
defaultValue: `Show all ${people.length}`,
})}
<ChevronRight size={16} />
</button>
)}
<button
type="button"
onClick={() => onCollapsedChange(true)}
aria-label={t('gallery.people.dismiss', { defaultValue: 'Hide the people bar' })}
className="p-1 -m-1 text-neutral-400 hover:text-neutral-600"
>
<X size={16} />
</button>
</div>
</div>
{/* Backfill progress. The strip appears as soon as the first clusters
exist rather than blocking the gallery behind a spinner — a guest
who arrives mid-scan gets a working gallery and a growing strip. */}
{scan?.in_progress && (
<div className="px-1 pb-2">
<div className="flex items-center justify-between text-xs text-neutral-500 mb-1">
<span style={{ color: 'var(--color-muted-text)' }}>
{t('gallery.people.scanning', {
scanned: scan.scanned,
total: scan.total,
defaultValue: `Finding people… ${scan.scanned}/${scan.total} photos`,
})}
</span>
</div>
<div className="h-0.5 bg-neutral-200 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 transition-all duration-500"
style={{ width: `${scan.total ? Math.round((scan.scanned / scan.total) * 100) : 0}%` }}
/>
</div>
</div>
)}
<div
className="flex gap-3 overflow-x-auto pb-1 px-1 snap-x scrollbar-thin"
style={{ scrollbarWidth: 'thin', WebkitOverflowScrolling: 'touch' }}
>
{inline.map((person) => (
<div key={person.id} className="snap-start">
<PersonAvatar
person={person}
photo={person.cover ? photoById.get(person.cover.photo_id) : undefined}
slug={slug}
size={avatarSize}
selected={selectedPersonIds.includes(person.id)}
onClick={() => onToggle(person.id)}
/>
</div>
))}
</div>
</div>
);
};
export default PeopleStrip;
@@ -3,7 +3,7 @@ import { Package } from 'lucide-react';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import type { Photo, DownloadResolutionChoice } from '../../types';
import type { Photo, DownloadResolutionChoice, GalleryPerson } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { PhotoLightbox } from './PhotoLightbox';
import { DownloadResolutionModal } from './DownloadResolutionModal';
@@ -79,6 +79,10 @@ interface PhotoGridWithLayoutsProps {
// Mirror of the admin original-filename toggle (#508). When true, the
// lightbox bottom toolbar surfaces each photo's original camera name.
showOriginalFilename?: boolean;
// People in this gallery (#1074) — forwarded to the lightbox so it can
// show "In this photo: …". Undefined when the feature is off.
people?: GalleryPerson[];
onSelectPerson?: (personId: number) => void;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -117,6 +121,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
isClient = false,
onToggleVisibility,
showOriginalFilename = false,
people,
onSelectPerson,
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -232,6 +238,12 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
const layoutProps = {
photos,
slug,
// Face data (#1074) must reach the full-page layouts too — they render
// their OWN lightbox rather than the one below, so without this the
// "In this photo" chips silently vanish on gallery-premium and
// gallery-story even when the feature is fully enabled.
people,
onSelectPerson,
// Full-page layouts own their bulk-download control, so the resolution
// picker has to reach them too (#858) — otherwise premium/story galleries
// silently skip the choice the admin enabled.
@@ -407,6 +419,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
initialShowFeedback={openFeedbackInitially}
onFeedbackChange={onFeedbackChange}
showOriginalFilename={showOriginalFilename}
people={people}
onSelectPerson={onSelectPerson}
/>
)}
@@ -1,7 +1,8 @@
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, Minimize2, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types';
import type { Photo, GalleryPerson } from '../../types';
import { useSavePhotoToDevice } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback';
@@ -31,6 +32,14 @@ interface PhotoLightboxProps {
// back to source files (#508). Tied to the admin-side toggle that
// also drives original-filename downloads (#493).
showOriginalFilename?: boolean;
// People in this gallery (#1074). Passed only when the feature is on for
// the event AND visible to this viewer — an empty list means "scanned,
// nobody found", which the chips row handles by not rendering.
people?: GalleryPerson[];
// Applying a person filter closes the lightbox and filters the grid
// behind it, so the guest lands on the result rather than paging through
// the old set.
onSelectPerson?: (personId: number) => void;
}
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -48,6 +57,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
disableRightClick = false,
enableDevtoolsProtection = false,
showOriginalFilename = false,
people,
onSelectPerson,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
@@ -153,6 +164,17 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
// leaving currentIndex past the end. The effect below re-syncs the
// index (or closes the lightbox when nothing is left).
const currentPhoto = photos[currentIndex] ?? photos[photos.length - 1];
const { t } = useTranslation();
// People detected in the open photo (#1074). Resolved against the list the
// server returned rather than the raw ids, so a person the photographer
// hid or ignored has no entry to match and simply never appears.
const peopleInPhoto = useMemo<GalleryPerson[]>(() => {
const ids = currentPhoto?.person_ids;
if (!ids?.length || !people?.length) return [];
return people.filter((person) => ids.includes(person.id));
}, [currentPhoto?.person_ids, people]);
// Per-category download permission (#640). AND'd with the event-level
// allowDownloads — disabling at either level hides the download button.
// Defaults true for uncategorised photos and pre-migration-135 categories.
@@ -782,6 +804,37 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{currentPhoto.original_filename || currentPhoto.filename}
</p>
)}
{/* People in this photo (#1074). The second way into the face
filter: a guest looking at a photo of themselves can act on
it without scrolling back to the strip.
Only people the server already returned are shown, so hidden
and ignored ones never appear here either. Unnamed people
show their photo count, never an invented name. */}
{peopleInPhoto.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap mt-1.5">
<span className="text-xs text-white opacity-60">
{t('gallery.people.inThisPhoto', { defaultValue: 'In this photo:' })}
</span>
{peopleInPhoto.map((person) => (
<button
key={person.id}
type="button"
onClick={() => {
onSelectPerson?.(person.id);
onClose();
}}
className="px-2 py-0.5 rounded-full bg-white/15 hover:bg-white/25 text-white text-xs transition-colors"
>
{person.label || t('gallery.people.unnamedCount', {
count: person.face_count,
defaultValue: `${person.face_count} photos`,
})}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-end">
@@ -0,0 +1,134 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { PeopleStrip } from '../PeopleStrip';
import type { GalleryPerson, Photo } from '../../../types';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, opts?: any) => opts?.defaultValue ?? _key,
}),
}));
vi.mock('../../common/AuthenticatedImage', () => ({
AuthenticatedImage: (props: any) => <img alt={props.alt} data-testid="avatar-img" />,
}));
function person(id: number, over: Partial<GalleryPerson> = {}): GalleryPerson {
return {
id,
label: null,
face_count: 10,
cover: { face_id: id, photo_id: id, bbox: [10, 10, 50, 50] },
...over,
};
}
const photos: Photo[] = [1, 2, 3].map((id) => ({
id,
filename: `${id}.jpg`,
url: `/p/${id}`,
thumbnail_url: `/t/${id}`,
type: 'individual',
size: 100,
uploaded_at: '2026-01-01T00:00:00Z',
width: 1000,
height: 800,
})) as Photo[];
const baseProps = {
photos,
slug: 'test-gallery',
selectedPersonIds: [],
onToggle: vi.fn(),
onShowAll: vi.fn(),
collapsed: false,
onCollapsedChange: vi.fn(),
};
describe('PeopleStrip (#1074)', () => {
it('renders nothing for fewer than two people', () => {
// One face taking up a whole row is not a "people in this gallery"
// feature, it's clutter.
const { container } = render(<PeopleStrip {...baseProps} people={[person(1)]} />);
expect(container).toBeEmptyDOMElement();
});
it('still renders during a scan even with too few people yet', () => {
render(
<PeopleStrip
{...baseProps}
people={[person(1)]}
scan={{ in_progress: true, scanned: 40, total: 200 }}
/>
);
expect(screen.getByText(/Finding people/)).toBeInTheDocument();
});
it('shows a photo count for unnamed people, never an invented name', () => {
render(<PeopleStrip {...baseProps} people={[person(1, { face_count: 42 }), person(2)]} />);
expect(screen.getByText('42 photos')).toBeInTheDocument();
// The thing we specifically refuse to do.
expect(screen.queryByText(/Person \d/)).not.toBeInTheDocument();
});
it('shows the name and the count when a person has been named', () => {
render(
<PeopleStrip
{...baseProps}
people={[person(1, { label: 'Anna', face_count: 97 }), person(2)]}
/>
);
expect(screen.getByText('Anna')).toBeInTheDocument();
expect(screen.getByText('97')).toBeInTheDocument();
});
it('marks the selected person as pressed for assistive tech', () => {
render(
<PeopleStrip
{...baseProps}
people={[person(1, { label: 'Anna' }), person(2, { label: 'Ben' })]}
selectedPersonIds={[1]}
/>
);
const buttons = screen.getAllByRole('button', { pressed: true });
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveAttribute('aria-label', expect.stringContaining('Anna'));
});
it('collapses to a one-line summary when dismissed', () => {
render(
<PeopleStrip
{...baseProps}
people={[person(1), person(2), person(3)]}
collapsed
/>
);
expect(screen.getByText('3 people found')).toBeInTheDocument();
expect(screen.queryByTestId('avatar-img')).not.toBeInTheDocument();
});
it('offers "show all" only when people overflow the inline strip', () => {
const many = Array.from({ length: 14 }, (_, i) => person(i + 1));
const { rerender } = render(<PeopleStrip {...baseProps} people={many} maxInline={12} />);
expect(screen.getByText('Show all 14')).toBeInTheDocument();
rerender(<PeopleStrip {...baseProps} people={many.slice(0, 5)} maxInline={12} />);
expect(screen.queryByText(/Show all/)).not.toBeInTheDocument();
});
it('falls back to an uncropped thumbnail when photo dimensions are unknown', () => {
// Without width/height the bbox ratios can't be computed; rendering a
// wrongly-offset crop would be worse than not cropping.
const noDims = [{ ...photos[0], width: undefined, height: undefined }] as Photo[];
render(
<PeopleStrip
{...baseProps}
photos={noDims}
people={[person(1), person(2)]}
/>
);
expect(screen.getAllByTestId('avatar-img').length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,52 @@
/**
* Shared face-crop geometry (#1074).
*
* Extracted because three surfaces need identical maths and two of them
* originally shipped without it: the "Show all" sheet and the admin People
* manager rendered centred full-photo thumbnails, so on any group photo the
* avatar showed whoever happened to be in the middle — often not the person
* it was labelling, and identical for two people from the same photo.
*
* The bbox is in ORIGINAL image pixels; the rendered thumbnail is some other
* size. Everything is therefore done in RATIOS of the source dimensions, which
* survives whatever rendition the browser is actually served.
*/
export interface FaceBox {
bbox: [number, number, number, number];
}
/**
* Style for an <img> inside a square, overflow-hidden, position-relative box
* of `size` px, such that `bbox` lands centred and filling it.
*
* Returns null when the source dimensions are unknown — the caller should then
* fall back to an uncropped thumbnail rather than render a wrongly-offset crop.
*/
export function faceCropStyle(
cover: FaceBox | null | undefined,
photoWidth: number | undefined,
photoHeight: number | undefined,
size: number,
): React.CSSProperties | null {
if (!cover || !photoWidth || !photoHeight) return null;
const [bx, by, bw, bh] = cover.bbox;
if (!bw || !bh) return null;
// Detectors crop tight to the face; a little padding reads as a portrait
// rather than a specimen.
const pad = 0.45;
const cx = bx + bw / 2;
const cy = by + bh / 2;
const side = Math.max(bw, bh) * (1 + pad);
const scale = size / side;
return {
width: `${photoWidth * scale}px`,
height: `${photoHeight * scale}px`,
maxWidth: 'none',
position: 'absolute',
left: `${size / 2 - cx * scale}px`,
top: `${size / 2 - cy * scale}px`,
};
}
@@ -1,8 +1,13 @@
import React from 'react';
import type { Photo, DownloadResolutionChoice } from '../../../types';
import type { Photo, DownloadResolutionChoice, GalleryPerson } from '../../../types';
export interface BaseGalleryLayoutProps {
photos: Photo[];
// People in this gallery (#1074) — forwarded by PhotoGridWithLayouts so
// full-page layouts, which render their OWN lightbox, can still show the
// "In this photo" chips.
people?: GalleryPerson[];
onSelectPerson?: (personId: number) => void;
slug: string;
onPhotoClick: (index: number) => void;
// Optional: open the lightbox with feedback panel visible
@@ -62,6 +62,9 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
welcomeMessage,
onLogout,
showOriginalFilename = false,
people,
onSelectPerson,
}) => {
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
void _onPhotoClick;
@@ -391,6 +394,11 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
useCanvasRendering={useCanvasRendering}
onFeedbackChange={onFeedbackChange}
showOriginalFilename={showOriginalFilename}
// #1074: this layout renders its own lightbox, so the people props
// have to be threaded through explicitly or the "In this photo"
// chips silently disappear on the Story theme.
people={people}
onSelectPerson={onSelectPerson}
/>
)}
@@ -26,8 +26,10 @@ import {
Send,
Workflow,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
import { api } from '../../../config/api';
import { FeatureCard } from '../components/FeatureCard';
import { SidebarPreview } from '../components/SidebarPreview';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
@@ -48,6 +50,17 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
);
export const FeaturesTab: React.FC = () => {
// The all-in-one image cannot run face recognition (#1074) — see
// faceSettings.isSingleContainerImage. Read it from the version endpoint the
// admin UI already calls, so the card can say WHY rather than offering a
// switch that refuses to stay on.
const { data: systemVersion } = useQuery({
queryKey: ['admin-system-version'],
queryFn: async () => (await api.get('/admin/system/version')).data,
staleTime: 5 * 60_000,
});
const isSingleContainer = systemVersion?.single_container === true;
const { t } = useTranslation();
const { staged, setFlag, save, reset, isDirty, isSaving } = useFeatureFlags();
@@ -148,6 +161,33 @@ export const FeaturesTab: React.FC = () => {
enabled={staged.transfers}
onToggle={(next) => setFlag('transfers', next)}
/>
{/* Face recognition (#1074). Requires the optional picpeak-ml
sidecar container — with the flag on but no sidecar running,
photos simply stay queued and nothing breaks. Two deliberate
actions are still needed before any face is processed: this
toggle, and the per-event switch on each gallery. */}
<FeatureCard
icon={Users}
title={t('settings.features.faces.title', 'People in galleries')}
description={t(
'settings.features.faces.description',
'Group each gallery\u2019s photos by the people in them, so guests can find themselves in two taps and download just their own photos. Runs entirely on your own server in a separate, optional container \u2014 nothing is sent anywhere. Detected faces are personal data, so this stays off until you enable it per gallery too.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarHidden
sidebarHiddenLabel={sidebarHiddenLabel}
enabled={staged.faces && !isSingleContainer}
onToggle={(next) => setFlag('faces', next)}
disabled={isSingleContainer}
lockedReason={isSingleContainer
? t(
'settings.features.faces.lockedSingleContainer',
'Not available on the all-in-one image. Face detection needs a separate ML container and competes with image processing for the same CPU and memory, which would slow the whole install down.',
)
: undefined}
/>
</Section>
{/* Automation — the visual workflow engine. Master kill-switch for the
+79 -2
View File
@@ -156,7 +156,10 @@
"show": "Einblenden",
"poweredBy": "Bereitgestellt von",
"on": "an",
"off": "aus"
"off": "aus",
"clear": "Zurücksetzen",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen"
},
"upload": {
"photoCategory": "Fotokategorie",
@@ -1061,7 +1064,27 @@
"downloadReady": "Ihr Download ist bereit",
"downloadNow": "Herunterladen",
"downloadPrepFailed": "Vorbereitung fehlgeschlagen",
"downloadPrepTimeout": "Das dauert länger als erwartet. Bitte erneut versuchen."
"downloadPrepTimeout": "Das dauert länger als erwartet. Bitte erneut versuchen.",
"people": {
"title": "Personen in dieser Galerie",
"showAll": "Alle {{count}} anzeigen",
"show": "Anzeigen",
"dismiss": "Personenleiste ausblenden",
"collapsedSummary": "{{count}} Personen gefunden",
"unnamedCount": "{{count}} Fotos",
"filterBy": "Fotos von {{name}} anzeigen",
"filterByUnnamed": "Fotos dieser Person anzeigen",
"scanning": "Personen werden gesucht… {{scanned}}/{{total}} Fotos",
"matchAll": "Beide Personen",
"matchAny": "Eine der Personen",
"matchCount": "{{count}} von {{total}} Fotos",
"clear": "Zurücksetzen",
"searchPlaceholder": "Person suchen",
"noMatches": "Niemand passt zu diesem Namen.",
"privacyNote": "Personen werden automatisch innerhalb dieser Galerie erkannt. Es werden keine Daten an externe Dienste gesendet.",
"inThisPhoto": "Auf diesem Foto:",
"downloadThese": "Diese {{count}} herunterladen"
}
},
"categories": {
"title": "Fotokategorien",
@@ -2115,6 +2138,11 @@
"title": "Workflows",
"description": "Visuelle Automatisierungen auf einer Canvas erstellen Auslöser, Bedingungen, Verzweigungen, Schleifen und Freigabe-Gates für Admins. Deine Mahnstufen und Buchungsschritte werden zu bearbeitbaren Abläufen. Strikt optional.",
"sidebar": "Workflows"
},
"faces": {
"title": "Personen in Galerien",
"description": "Gruppiert die Fotos jeder Galerie nach den abgebildeten Personen, damit Gäste sich mit zwei Tipps selbst finden und nur ihre eigenen Fotos herunterladen. Läuft vollständig auf Ihrem eigenen Server in einem separaten, optionalen Container — es werden keine Daten übermittelt. Erkannte Gesichter sind personenbezogene Daten; die Funktion bleibt daher aus, bis Sie sie zusätzlich pro Galerie aktivieren.",
"lockedSingleContainer": "Im All-in-One-Image nicht verfügbar. Die Gesichtserkennung benötigt einen separaten ML-Container und konkurriert mit der Bildverarbeitung um dieselbe CPU und denselben Arbeitsspeicher, was die gesamte Installation verlangsamen würde."
}
},
"customerSurface": {
@@ -3085,6 +3113,55 @@
"email_template_created": "E-Mail-Vorlage erstellt: {{template_key}}",
"event_duplicated": "Event dupliziert aus {{source_event_name}}",
"whatsapp_config_updated": "WhatsApp-Konfiguration aktualisiert"
},
"people": {
"title": "Personen in dieser Galerie",
"subtitle": "Umbenennen, getrennte Personen zusammenführen oder jemanden vor Gästen verbergen.",
"select": "Zum Zusammenführen auswählen",
"unnamed": "Namen hinzufügen",
"namePlaceholder": "Namen hinzufügen",
"photoCount": "{{count}} Fotos",
"hidden": "für Gäste verborgen",
"ignored": "ignoriert",
"renamed": "Name gespeichert",
"merged": "Personen zusammengeführt",
"split": "Als neue Person abgetrennt",
"updated": "Aktualisiert",
"merge": "Zusammenführen",
"mergeHint": "Tippen Sie zwei oder mehr Gesichter an, um sie zu einer Person zusammenzuführen.",
"selectedCount": "{{count}} ausgewählt",
"splitAction": "Fotos abtrennen, die jemand anderes zeigen",
"hideAction": "Vor Gästen verbergen",
"ignoreAction": "Keine echte Person — ignorieren",
"splitHelp": "Wählen Sie die Fotos aus, die NICHT diese Person zeigen. Sie werden zu einem neuen Eintrag, alles andere bleibt.",
"splitSelected": "{{count}} ausgewählt",
"doSplit": "Abtrennen",
"empty": "Noch keine Personen erkannt."
},
"faces": {
"manage": "Personen verwalten",
"visibleToGuests": "({{count}} für Gäste sichtbar)",
"title": "Personen in dieser Galerie",
"subtitle": "Fotos nach den abgebildeten Personen gruppieren, damit Gäste ihre eigenen finden und herunterladen können.",
"consentNotice": "Erkannte Gesichter sind personenbezogene Daten und gelten in der EU als besondere Kategorie. Sie sind für diese Galerie verantwortlich: Stellen Sie sicher, dass Sie eine Rechtsgrundlage für die abgebildeten Personen haben, bevor Sie dies aktivieren. Nichts verlässt Ihren Server — die Erkennung läuft in Ihrem eigenen Container.",
"enable": "Personen in dieser Galerie erkennen",
"enableHint": "Vorhandene Fotos werden im Hintergrund durchsucht. Gesichter und ihre numerischen Signaturen werden in Ihrer Datenbank gespeichert; sie sind niemals in Backups oder Exporten enthalten.",
"visible": "Personenleiste für Gäste anzeigen",
"visibleHint": "Ausgeschaltet erhalten nur Sie die Gruppierung als privates Werkzeug, und Gäste sehen eine unveränderte Galerie.",
"previewNotice": "Die Erkennung arbeitet mit der Vorschaugröße jedes Fotos. In Galerien ohne vorhandene Vorschaubilder werden diese beim ersten Durchlauf erzeugt, was zusätzliche CPU-Last und Speicherplatz benötigt.",
"scanning": "Wird durchsucht… {{scanned}} von {{total}} Fotos",
"status": "{{scanned}} / {{total}} Fotos durchsucht · {{people}} Personen",
"failed": "{{count}} fehlgeschlagen",
"queued": "{{count}} Fotos werden nach Personen durchsucht…",
"rescan": "Erneut durchsuchen",
"rescanQueued": "Erneute Suche eingeplant",
"recluster": "Personen neu gruppieren",
"reclustered": "Personen neu gruppiert",
"delete": "Alle Gesichtsdaten löschen",
"deleted": "Gesichtsdaten gelöscht",
"confirmDelete": "Alle erkannten Personen und Gesichtsdaten dieser Galerie löschen? Dies kann nicht rückgängig gemacht werden. Die Fotos selbst bleiben unverändert.",
"autoCategories": "Fotos automatisch in Kategorien einsortieren",
"autoCategoriesHint": "Nutzt die Anzahl der Gesichter, um Fotos als Details, Porträts, Kleine Gruppen oder Gruppen abzulegen. Gilt für alle Galerien, füllt ausschließlich leere Kategorien und ändert niemals eine von Ihnen gesetzte."
}
},
"acceptInvitation": {
+79 -2
View File
@@ -156,7 +156,10 @@
"show": "Show",
"poweredBy": "Powered by",
"on": "on",
"off": "off"
"off": "off",
"clear": "Clear",
"saved": "Saved",
"saveFailed": "Save failed"
},
"upload": {
"photoCategory": "Photo Category",
@@ -602,7 +605,27 @@
"downloadReady": "Your download is ready",
"downloadNow": "Download",
"downloadPrepFailed": "Preparation failed",
"downloadPrepTimeout": "This is taking longer than expected. Please try again."
"downloadPrepTimeout": "This is taking longer than expected. Please try again.",
"people": {
"title": "People in this gallery",
"showAll": "Show all {{count}}",
"show": "Show",
"dismiss": "Hide the people bar",
"collapsedSummary": "{{count}} people found",
"unnamedCount": "{{count}} photos",
"filterBy": "Show photos of {{name}}",
"filterByUnnamed": "Show photos of this person",
"scanning": "Finding people… {{scanned}}/{{total}} photos",
"matchAll": "Both people",
"matchAny": "Either person",
"matchCount": "{{count}} of {{total}} photos",
"clear": "Clear",
"searchPlaceholder": "Find a person",
"noMatches": "No one matches that name.",
"privacyNote": "People are detected automatically inside this gallery. Nothing is sent to any external service.",
"inThisPhoto": "In this photo:",
"downloadThese": "Download these {{count}}"
}
},
"categories": {
"title": "Photo Categories",
@@ -1656,6 +1679,11 @@
"title": "Workflows",
"description": "Build visual automations on a canvas — triggers, conditions, branches, loops and admin approval gates. Your reminder ladder and booking steps become editable flows. Strictly opt-in.",
"sidebar": "Workflows"
},
"faces": {
"title": "People in galleries",
"description": "Group each gallery's photos by the people in them, so guests can find themselves in two taps and download just their own photos. Runs entirely on your own server in a separate, optional container — nothing is sent anywhere. Detected faces are personal data, so this stays off until you enable it per gallery too.",
"lockedSingleContainer": "Not available on the all-in-one image. Face detection needs a separate ML container and competes with image processing for the same CPU and memory, which would slow the whole install down."
}
},
"customerSurface": {
@@ -2657,6 +2685,55 @@
"email_template_created": "Email template created: {{template_key}}",
"event_duplicated": "Event duplicated from {{source_event_name}}",
"whatsapp_config_updated": "WhatsApp configuration updated"
},
"people": {
"title": "People in this gallery",
"subtitle": "Rename, merge people who were split apart, or hide someone from guests.",
"select": "Select for merging",
"unnamed": "Add a name",
"namePlaceholder": "Add a name",
"photoCount": "{{count}} photos",
"hidden": "hidden from guests",
"ignored": "ignored",
"renamed": "Name saved",
"merged": "People merged",
"split": "Split into a new person",
"updated": "Updated",
"merge": "Merge",
"mergeHint": "Tap two or more faces to merge them into one person.",
"selectedCount": "{{count}} selected",
"splitAction": "Split out photos that are someone else",
"hideAction": "Hide from guests",
"ignoreAction": "Not a real person — ignore",
"splitHelp": "Pick the photos that are NOT this person. They become a new entry, and everything else stays.",
"splitSelected": "{{count}} selected",
"doSplit": "Split out",
"empty": "No people detected yet."
},
"faces": {
"manage": "Manage people",
"visibleToGuests": "({{count}} shown to guests)",
"title": "People in this gallery",
"subtitle": "Group photos by the people in them, so guests can find and download their own.",
"consentNotice": "Detected faces are personal data, and in the EU they count as a special category. You are the controller for this gallery: make sure you have a lawful basis for the people in these photos before switching this on. Nothing leaves your server — detection runs in your own container.",
"enable": "Detect people in this gallery",
"enableHint": "Existing photos are scanned in the background. Faces and their numeric signatures are stored in your database; they are never included in backups or exports.",
"visible": "Show the people bar to guests",
"visibleHint": "Off means you get the grouping as a private tool and guests see an unchanged gallery.",
"previewNotice": "Scanning works on the preview-sized copy of each photo. Galleries that have not generated previews yet will create them during the first scan, which uses additional CPU and disk space.",
"scanning": "Scanning… {{scanned}} of {{total}} photos",
"status": "{{scanned}} / {{total}} photos scanned · {{people}} people",
"failed": "{{count}} failed",
"queued": "Scanning {{count}} photos for people…",
"rescan": "Re-scan",
"rescanQueued": "Re-scan queued",
"recluster": "Re-group people",
"reclustered": "People regrouped",
"delete": "Delete all face data",
"deleted": "Face data deleted",
"confirmDelete": "Delete all detected people and face data for this gallery? This cannot be undone. Photos are not affected.",
"autoCategories": "Sort photos into categories automatically",
"autoCategoriesHint": "Uses the number of faces to file photos as Details, Portraits, Small groups or Groups. Applies to every gallery, only ever fills an empty category, and never changes one you set yourself."
}
},
"acceptInvitation": {
@@ -5,6 +5,7 @@ import { PermissionGate } from '../../../components/admin/PermissionGate';
import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard';
import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard';
import { DownloadResolutionCard } from '../../../components/admin/DownloadResolutionCard';
import { FaceRecognitionCard } from '../../../components/admin/FaceRecognitionCard';
import { ShortUrlsCard } from '../../../components/admin/ShortUrlsCard';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import type { AdminPhoto } from '../../../services/photos.service';
@@ -122,6 +123,14 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
other "what the customer receives" controls. */}
<DownloadResolutionCard eventId={event.id} onChanged={() => refetchEvent()} />
{/* People in this gallery (#1074). Gated behind the `faces` feature
flag — which is itself gated on the operator running the optional
picpeak-ml sidecar, so this card is invisible on the vast majority
of installs. */}
{flags.faces && (
<FaceRecognitionCard eventId={event.id} isArchived={event.is_archived} />
)}
{/* Live Slideshow ("Diashow") link + live display settings (migrations 138/139).
Gated behind the `slideshow` feature flag. */}
{flags.slideshow && (
+11 -1
View File
@@ -75,7 +75,17 @@ export type FeatureKey =
// Workflow / automation engine — admin-configurable visual flows (triggers,
// conditions, branches, loops, approval gates) built on a canvas. Strictly
// opt-in; gates the Workflows admin area and the engine runtime.
| 'workflows';
| 'workflows'
// Face recognition — "People in this gallery" (migration 177, #1074).
// Requires the optional picpeak-ml sidecar container. THIS FLAG IS THE
// GATE for the whole feature: the backend's FACE_ML_URL has a working
// default, so the sidecar's presence can't be detected from config alone.
// While this is off, no face UI renders anywhere — no admin panel, no
// people strip, no lightbox chips — and the backend never contacts the
// sidecar. Face embeddings are biometric data (GDPR Art. 9); turning this
// on is only the first of two deliberate actions, since detection is still
// enabled per event.
| 'faces';
export type FeatureFlags = Record<FeatureKey, boolean>;
+13 -1
View File
@@ -1,7 +1,7 @@
import { api } from '../config/api';
import type {
GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier,
DownloadJobStatus, DownloadJobState,
DownloadJobStatus, DownloadJobState, GalleryPeopleResponse,
} from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
import { parseContentDispositionFilename } from '../utils/contentDisposition';
@@ -383,4 +383,16 @@ export const galleryService = {
const response = await api.get<ResolvedGalleryIdentifier>(`/gallery/resolve/${identifier}`);
return response.data;
},
/**
* People detected in this gallery (#1074).
*
* Returns an empty list rather than an error when the feature is off, so a
* guest can't tell "no people here" from "feature disabled". Counts and
* cover faces are scoped server-side to the photos this viewer may see.
*/
async getPeople(slug: string): Promise<GalleryPeopleResponse> {
const response = await api.get<GalleryPeopleResponse>(`/gallery/${slug}/people`);
return response.data;
},
};
+42
View File
@@ -113,6 +113,36 @@ export interface GalleryInfo {
login_logo_visible?: boolean;
}
/**
* A person clustered within one gallery (#1074).
*
* `face_count` is scoped to the photos THIS viewer can see, which is not the
* same as the number of faces stored for them — a guest must never learn how
* many hidden photos someone appears in. `label` is null until the
* photographer names them; the UI shows the count instead of inventing a
* "Person 7", because a number is honest and a fake name is not.
*/
export interface GalleryPerson {
id: number;
label: string | null;
face_count: number;
cover: {
face_id: number;
photo_id: number;
/** [x, y, w, h] in ORIGINAL image pixels — scale by the rendered size. */
bbox: [number, number, number, number];
} | null;
}
export interface GalleryPeopleResponse {
people: GalleryPerson[];
scan: {
in_progress: boolean;
scanned: number;
total: number;
};
}
export interface Photo {
id: number;
filename: string;
@@ -145,6 +175,13 @@ export interface Photo {
// the lightbox download button when this is false (event-level allow_downloads
// also has to be true — they AND together).
category_allow_downloads?: boolean;
// People detected in this photo (#1074). Always present when the feature
// is on for the event — an empty array means "scanned, nobody found",
// which is different from the feature being off (see
// GalleryInfo.event.people_enabled). Hidden and ignored people are
// filtered out server-side, so this never reveals a person the
// photographer suppressed.
person_ids?: number[];
size: number;
uploaded_at: string;
captured_at?: string; // EXIF capture date (if available)
@@ -248,6 +285,11 @@ export interface GalleryData {
// When true, the lightbox surfaces each photo's `original_filename`
// alongside the position counter (#508).
use_original_filenames?: boolean;
// "People in this gallery" (#1074). False whenever the global `faces`
// flag is off, detection is off for this event, or the photographer
// chose to keep the people strip to themselves. The whole face UI hangs
// off this one boolean.
people_enabled?: boolean;
};
categories?: PhotoCategory[];
photos: Photo[];