Merge pull request #485 from AtalayaLabs/claude/zealous-faraday-58s1at
feat: Photos evolution — Places (map) & People (faces) + gallery polish
This commit is contained in:
Generated
+80
@@ -3654,6 +3654,16 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
@@ -3866,6 +3876,16 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-owned"
|
||||
version = "0.3.4"
|
||||
@@ -4071,6 +4091,21 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -4157,6 +4192,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -4238,6 +4282,25 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ort"
|
||||
version = "2.0.0-rc.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"ndarray",
|
||||
"ort-sys",
|
||||
"smallvec",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ort-sys"
|
||||
version = "2.0.0-rc.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.1.0"
|
||||
@@ -4676,7 +4739,9 @@ dependencies = [
|
||||
"mockall",
|
||||
"moka",
|
||||
"mp3-duration",
|
||||
"ndarray",
|
||||
"nom-exif",
|
||||
"ort",
|
||||
"oxc_allocator",
|
||||
"oxc_codegen",
|
||||
"oxc_minifier",
|
||||
@@ -5074,6 +5139,15 @@ version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
@@ -5496,6 +5570,12 @@ version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
|
||||
@@ -81,6 +81,8 @@ nom-exif = "3.6.1"
|
||||
extism = { version = "1.30.0", optional = true }
|
||||
toml = { version = "1.1.2", optional = true }
|
||||
file-rotate = { version = "0.7.6", optional = true }
|
||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "tracing", "api-24"], optional = true }
|
||||
ndarray = { version = "0.17.2", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -95,6 +97,12 @@ plugins = ["dep:extism", "dep:toml", "dep:file-rotate"]
|
||||
# this lets one `cargo build` produce both `oxicloud` and `load-seed`
|
||||
# without recompiling oxicloud with mockall in scope.
|
||||
load_seed_bin = []
|
||||
# Real ONNX-backed face analyzer (detector + embedder) for the People feature.
|
||||
# Opt-in: pulls `ort` (ONNX Runtime, load-dynamic — dlopen's libonnxruntime at
|
||||
# runtime) + `ndarray`, a heavy stack most deployments won't use. Activation also
|
||||
# requires OXICLOUD_ENABLE_FACES=true *and* operator-provided ONNX models; without
|
||||
# this feature the People pipeline falls back to the inert NoopFaceAnalyzer.
|
||||
faces-onnx = ["dep:ort", "dep:ndarray"]
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
# Plan: Photos Evolution — Gallery, Places (map) & People (faces)
|
||||
|
||||
## Context
|
||||
|
||||
The Photos view (`static/js/features/library/photos.js` + `photosLightbox.js`) is a
|
||||
date-grouped timeline with infinite scroll, multi-select and a lightbox. The backend
|
||||
already extracts and stores per-photo EXIF — including **GPS latitude/longitude** — in
|
||||
`storage.file_metadata` (`src/infrastructure/services/exif_service.rs`,
|
||||
`media_metadata_service.rs`), and serves the timeline via `GET /api/photos`
|
||||
(`src/interfaces/api/handlers/photos_handler.rs` → `list_media_files` in
|
||||
`src/infrastructure/repositories/pg/file_blob_read_repository.rs`).
|
||||
|
||||
This plan adds, in three phases:
|
||||
|
||||
0. **Gallery polish** — performance + modern UX (timeline virtualization already landed).
|
||||
1. **Places** — a map of geotagged photos. *Most of the data already exists.*
|
||||
2. **People** — face detection, embedding, identity clustering ("like Apple/Google Photos").
|
||||
|
||||
All work follows OxiCloud conventions: hexagonal layering, **AuthZ enforced only in the
|
||||
application service layer** via `*_with_perms(caller_id)` methods calling
|
||||
`AuthorizationEngine::require(...)`, **audit logging on every denial**
|
||||
(`target: "audit"`), feature flags (`OXICLOUD_ENABLE_*`), native `UUID` columns, sqlx
|
||||
migrations, and a **vanilla-JS / vanilla-CSS** frontend (design tokens from
|
||||
`static/css/base/variables.css`, JSDoc-typed, BEM).
|
||||
|
||||
> ⚠️ New dependencies (JS libraries to vendor, Rust crates, the `pgvector`/`vectorchord`
|
||||
> Postgres extension, and runtime-downloaded ML models) need explicit sign-off — see
|
||||
> **§Vendoring & dependencies** and **§Open decisions**. Per repo rules: never hand-edit
|
||||
> `Cargo.lock` (use `cargo add`), and don't introduce JS frameworks.
|
||||
|
||||
---
|
||||
|
||||
## Implementation status (updated)
|
||||
|
||||
**Phase 0 — Gallery polish: essentially complete** (branch `claude/zealous-faraday-58s1at`).
|
||||
|
||||
| Item | Status | Commit / note |
|
||||
|------|--------|---------------|
|
||||
| 0.1 Virtualization | ✅ done | `081b2b6` |
|
||||
| 0.2 width/height on `/api/photos` | ✅ done | `8d09589` — implemented via a flattened `PhotoDto` (`#[serde(flatten)]`) instead of widening `FileDto` + its 6 construction sites; `list_media_files` LEFT JOINs `storage.file_metadata`. `FileItem` gained optional `width`/`height`. |
|
||||
| 0.3 Justified layout | ✅ done | `75ee9b7` |
|
||||
| 0.4 Lightbox (zoom/pan, swipe, info panel, favorite fix) | 🟡 mostly | `ca1a8cb`, `e8520e4` — the map pin shows **coordinates as text**; the embedded mini-map / deep-link into Places is deferred until Places exists. |
|
||||
| 0.5 Shift-select, confirm→Modal, keyboard a11y | 🟡 mostly | `824df4d`, `e8520e4` — optional drag-marquee not done. |
|
||||
| 0.6 HEIC | ⬜ pending | open decision (native `libheif` dep). |
|
||||
| 0.7 Sub-nav tabs | ⬜ pending | deferred to Phase 1 (tabs need the Places/People views). |
|
||||
|
||||
**Phase 1 — Places: complete (Approach A).**
|
||||
- Backend (`f4b431b`): migration `…_places_geo_index.sql`, `FileBlobReadRepository::list_geo_clusters` (plain-SQL grid aggregation, no PostGIS), `PlacesService` (caller_id-scoped), `OXICLOUD_ENABLE_PLACES` (now **default on**, `513b622`), `GET /api/photos/geo`.
|
||||
- Frontend: vendored MapLibre 5.24.0 + pmtiles 4.4.1 (`bb3d739`); `places.js` (`513b622`) renders the server-aggregated clusters as **HTML thumbnail markers** (no glyphs/sprites, no client-side clustering), refetches on pan/zoom, and drills into the lightbox. Optional Protomaps `.pmtiles` basemap read over HTTP **Range via the existing `ServeDir`** (label-light style, light/dark) with graceful fallback to a themed background; ODbL attribution. "Moments | Places" sub-nav.
|
||||
- **Deviations from the original plan:** 1.5 serves the basemap as a *static file* (ServeDir Range) instead of the `pmtiles` Rust crate; 1.8 uses MapLibre HTML markers instead of a deck.gl `IconLayer`. Both keep the footprint minimal and need zero new backend code.
|
||||
- **Pending:** browser smoke-test, and an operator-supplied `static/basemaps/basemap.pmtiles` for the street backdrop (works without it).
|
||||
|
||||
**Phase 2 — People: complete (detector/embedder shipped, opt-in).**
|
||||
- **Migration** (`…_faces.sql`): `faces` schema with `faces.persons` + `faces.faces`.
|
||||
**Deviation from 2.2:** embeddings stored as **`BYTEA`** (512×`f32` little-endian), **no
|
||||
`pgvector`** — cosine similarity runs in Rust. This keeps the extension footprint at
|
||||
today's `pg_trgm`/`ltree`/`citext` and is fine at personal-library scale; the HNSW/ANN
|
||||
path is the documented growth step if it's ever needed.
|
||||
- **Config:** `OXICLOUD_ENABLE_FACES` (`FeaturesConfig::enable_faces`, **default off** —
|
||||
biometric/opt-in). Everything below is inert when off.
|
||||
- **Domain/ports:** `Face`, `Person`, `BoundingBox`, `DetectedFace` (`domain/entities/face.rs`);
|
||||
`FaceAnalyzerPort` (single `analyze(&[u8]) -> Vec<DetectedFace>` + `is_ready()`) and
|
||||
`FaceRepository` (`face_ports.rs`). **Deviation from 2.3:** detector+embedder collapsed
|
||||
into one `FaceAnalyzerPort` (the analyzer owns detect→align→embed) instead of split
|
||||
`FaceDetectorPort`/`FaceEmbedderPort` — simpler seam for a single ONNX session.
|
||||
- **Repository:** `FacePgRepository` (`infrastructure/repositories/pg/`) — bytea
|
||||
encode/decode, person CRUD, `faces_for_*`, `assign_person`, `delete_all_for_user`.
|
||||
- **Service:** `PeopleService` (`application/services/people_service.rs`) — `recluster()`
|
||||
via **union-find connected-components** (cosine ≥ 0.5, `min_faces` 3, immich-style),
|
||||
plus list/photos/rename/hide/merge/delete. "List my own people" needs no `authz.require`
|
||||
(user-scoped, like `RecentService`/`PlacesService`).
|
||||
- **Indexing:** `FaceIndexingService` implements `FileLifecycleHook` — background
|
||||
detect+embed on image create/copy/update, **dedup by blob hash**. Driven by the
|
||||
analyzer port; with the no-op analyzer it does nothing.
|
||||
- **Analyzer:** two implementations behind `FaceAnalyzerPort`. `NoopFaceAnalyzer`
|
||||
(`is_ready()=false`) is the default so the stack compiles/runs **without any ML model**.
|
||||
`OnnxFaceAnalyzer` (`12ede47`, behind the **`faces-onnx`** cargo feature) is the real
|
||||
SCRFD+ArcFace pipeline; `di::build_face_analyzer` picks it when the feature is compiled in
|
||||
and runtime+models are configured, else degrades to the no-op (logged) so startup never
|
||||
fails. **Deviation from 2.4:** the error-prone math (SCRFD anchor decode, NMS, the
|
||||
closed-form similarity alignment, affine warp, normalization) lives in `face_geometry.rs`,
|
||||
compiled in **every** build and covered by 11 unit tests; only the ONNX session calls are
|
||||
feature-gated (and untestable here, no models). `ort` uses **load-dynamic** so
|
||||
`libonnxruntime` is dlopen'd at runtime and the crate builds without it; loading goes
|
||||
through `ort::init_from` (fallible) not ORT's lazy loader, which would `panic` under
|
||||
`panic = "abort"`.
|
||||
- **HTTP:** `people_handler.rs` + routes (gated on `people_service.is_some()`):
|
||||
`GET /api/people`, `/api/people/{id}/photos`, `PATCH /api/people/{id}`,
|
||||
`POST /api/people/merge`, `/api/people/recluster`, `GET /api/people/data`,
|
||||
`GET /api/people/faces/{file_id}`, `POST /api/people/{id}/hide`.
|
||||
- **Frontend (`6314fa6`):** `people.js` + `people.css` — person grid (circular cover,
|
||||
name, count), drill into a person's photos via the existing lightbox, rename via
|
||||
`Modal.prompt` + `PATCH`. Wired into the Photos sub-nav as a third **People** tab that a
|
||||
capability probe (`GET /api/people`) reveals only when faces are on; otherwise hidden.
|
||||
i18n keys in `en.json` (others fall back to English).
|
||||
- **Config (2.4):** `FacesConfig` + `OXICLOUD_FACES_{ORT_DYLIB,DETECTOR_MODEL,
|
||||
EMBEDDER_MODEL,DET_SIZE,DET_THRESHOLD,NMS_THRESHOLD,INTRA_THREADS}` (documented in
|
||||
`example.env`). To run faces: build `--features faces-onnx`, set `OXICLOUD_ENABLE_FACES=true`,
|
||||
and point the three model/runtime paths at an operator-supplied ONNX Runtime +
|
||||
SCRFD detector + ArcFace embedder (e.g. InsightFace `buffalo_l`). Nothing is committed.
|
||||
- **Still open (optional):** per-user opt-in consent gate (2.1), lightbox face-box tagging
|
||||
(2.8), and the periodic full re-cluster job (2.6 has on-demand `recluster`; no scheduler
|
||||
yet). End-to-end smoke-test needs real models + a browser, which only you can run.
|
||||
|
||||
---
|
||||
|
||||
## Research summary (the decisions these phases encode)
|
||||
|
||||
**Map (no third-party APIs, self-host, extreme perf):**
|
||||
- **Engine:** MapLibre GL JS v5 (BSD-3, WebGL2, vendorable UMD, no framework).
|
||||
- **Basemap:** self-hosted **Protomaps `.pmtiles`** (single file) served by Axum via the
|
||||
**`pmtiles`** Rust crate over HTTP Range — OxiCloud serves its own basemap. Global
|
||||
z0–6 ≈ 60 MB; regional extracts on demand; planet ≈ 120 GB.
|
||||
- **Clustering:** client-side **Supercluster** (MapLibre `cluster: true`, in a web worker)
|
||||
up to ~100k points; beyond that, **plain-SQL grid/geohash aggregation** by zoom+bbox —
|
||||
**no PostGIS needed** (only `pg_trgm`/`ltree`/`citext` are enabled today).
|
||||
- **Gotchas:** self-host glyphs+sprites (not the Protomaps CDN); ODbL attribution
|
||||
"Protomaps © OpenStreetMap" is mandatory; dark-mode via `@protomaps/basemaps` flavors.
|
||||
|
||||
**Faces (self-host, precision, CPU-first):**
|
||||
- **Runtime:** **`ort`** (ONNX Runtime). `candle` can't run SCRFD/RetinaFace (missing
|
||||
`Resize` op); `tract` is the pure-Rust fallback for a single static binary.
|
||||
- **Licensing landmine:** no permissive high-accuracy face-recognition checkpoint exists.
|
||||
InsightFace `buffalo_l` (IJB-C ~97.3) and EdgeFace weights are **non-commercial**.
|
||||
- **Recommended (immich/PhotoPrism pattern):** **download** SCRFD + `buffalo_l` weights at
|
||||
runtime (not committed); personal self-hosted use is non-commercial-compliant. Offer a
|
||||
fully-permissive fallback (RetinaFace-MobileNet0.25 **MIT** + a self-retrained
|
||||
EdgeFace/GhostFaceNet embedder, ~94 IJB-C, ~10× smaller).
|
||||
- **Storage/clustering:** embeddings in Postgres via **pgvector** (HNSW, 512-d), growth
|
||||
path to **VectorChord**; **threshold / connected-components incremental clustering**
|
||||
(immich-style), not Approximate Rank-Order; ANN + exact re-rank; quality gating
|
||||
(det-score ≥0.7, face ≥50–80px, blur); `minFaces ≥3` to promote a cluster to a Person.
|
||||
- **Privacy:** biometric data (GDPR Art. 9) → **opt-in, OFF by default, per-user
|
||||
isolation, cascade-delete**, all local.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Gallery polish
|
||||
|
||||
### Execution order
|
||||
|
||||
#### 0.1 Timeline virtualization — ✅ DONE (commit `081b2b6`)
|
||||
Each date-group is a `<section>` whose grid is materialized only near the viewport.
|
||||
**Remaining:** browser smoke-test, then it's closed.
|
||||
|
||||
#### 0.2 Expose image dimensions on the timeline (enables justified layout, kills CLS)
|
||||
**`src/application/dtos/file_dto.rs`** — add to `FileDto`:
|
||||
```rust
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<u32>,
|
||||
```
|
||||
**`src/infrastructure/repositories/pg/file_blob_read_repository.rs`** — in the
|
||||
`list_media_files` query, `LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id` and
|
||||
select `fm.width, fm.height`; map into the new fields.
|
||||
**`static/js/core/types.js`** — add `width?`/`height?` to `FileItem` (already on
|
||||
`FileMetadata`).
|
||||
|
||||
#### 0.3 Justified rows layout (modern, aspect-preserving)
|
||||
**`static/js/features/library/photos.js`** — add a `layoutMode: 'square' | 'justified'`
|
||||
toggle in the toolbar. In justified mode, replace the CSS grid with a row-packing pass
|
||||
(target row height ~180–220px, distribute by aspect ratio = `width/height`, fallback 1:1
|
||||
when dimensions are absent). Keep the existing virtualization: row-pack **within each
|
||||
materialized group**, so it composes with section materialize/dematerialize.
|
||||
**`static/css/views/photos.css`** — `.photos-grid--justified` (flex rows) variant.
|
||||
|
||||
#### 0.4 Lightbox upgrades
|
||||
**`static/js/features/library/photosLightbox.js`**
|
||||
- **Zoom/pan** (wheel + pinch) and **mobile swipe** for prev/next.
|
||||
- **Info panel** (toggle) showing EXIF from `/api/files/{id}/metadata`.
|
||||
- **Map pin** (resolves the existing `//TODO: add geoloc pointer` at line ~301): when
|
||||
`latitude/longitude` present, render a small static MapLibre mini-map / "Show on map"
|
||||
link that deep-links into the Places view.
|
||||
- **Favorite initial state** (bug fix): call `favorites.isFavorite(item.id, 'file')` in
|
||||
`_show()` to set the star correctly (today it always starts empty).
|
||||
|
||||
#### 0.5 UX & a11y
|
||||
**`static/js/features/library/photos.js`**
|
||||
- **Shift-click range select** and optional drag-marquee.
|
||||
- Replace native `confirm()`/`alert()` with the app modal
|
||||
(`static/js/components/modal.js`; add an async `Modal.confirm()` helper).
|
||||
- Tiles become focusable/role-correct; arrow-key navigation across the grid.
|
||||
|
||||
#### 0.6 (Optional) HEIC support
|
||||
`image` crate ships only `jpeg/png/gif/webp` — iPhone HEIC photos currently get **no
|
||||
server thumbnail**. Either add `libheif-rs` decoding in `thumbnail_service.rs` /
|
||||
`media_metadata_service.rs`, or transcode HEIC→JPEG on upload. Flagged as its own task
|
||||
(native dep).
|
||||
|
||||
#### 0.7 Sub-navigation inside Photos
|
||||
**`static/index.html`** + **`static/js/app/navigation.js`** (`switchToPhotosSection`,
|
||||
line ~434) + i18n: add a tab strip **Moments · Places · People** within the Photos view.
|
||||
Places/People tabs are hidden unless their feature flags are on. This is the mount point
|
||||
for Phases 1 & 2.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Places (map)
|
||||
|
||||
Data already exists (`storage.file_metadata.latitude/longitude`, `DOUBLE PRECISION`).
|
||||
No PostGIS.
|
||||
|
||||
### Execution order
|
||||
|
||||
#### 1.1 Migration — index (+ optional geohash)
|
||||
**New file:** `migrations/<ts>_places_geo_index.sql`
|
||||
```sql
|
||||
-- Fast bbox scans over geotagged photos
|
||||
CREATE INDEX IF NOT EXISTS idx_file_metadata_geo
|
||||
ON storage.file_metadata (latitude, longitude)
|
||||
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
|
||||
-- Optional (scale): a geohash/quadkey integer + btree for prefix grouping by zoom.
|
||||
-- ALTER TABLE storage.file_metadata ADD COLUMN geohash BIGINT;
|
||||
```
|
||||
|
||||
#### 1.2 Application port + PG repository (grid aggregation)
|
||||
**`src/application/ports/`** — new `GeoPhotoReadPort` (or extend an existing media port):
|
||||
```rust
|
||||
pub struct GeoCluster { pub lng: f64, pub lat: f64, pub count: i64, pub sample_file_id: Uuid }
|
||||
pub struct GeoBounds { pub w: f64, pub s: f64, pub e: f64, pub n: f64 }
|
||||
|
||||
#[async_trait]
|
||||
pub trait GeoPhotoReadPort: Send + Sync {
|
||||
async fn clusters_in_bounds(&self, user_id: Uuid, b: GeoBounds, cell: f64)
|
||||
-> Result<Vec<GeoCluster>, DomainError>;
|
||||
async fn photos_in_bounds(&self, user_id: Uuid, b: GeoBounds, limit: i64)
|
||||
-> Result<Vec<FileDto>, DomainError>;
|
||||
}
|
||||
```
|
||||
**`src/infrastructure/repositories/pg/`** — PG impl. Grid aggregation (no PostGIS):
|
||||
```sql
|
||||
SELECT round(fm.longitude / $6) * $6 AS gx,
|
||||
round(fm.latitude / $6) * $6 AS gy,
|
||||
count(*) AS n,
|
||||
avg(fm.longitude) AS clng,
|
||||
avg(fm.latitude) AS clat,
|
||||
min(fm.file_id) AS sample_id
|
||||
FROM storage.file_metadata fm
|
||||
JOIN storage.files fi ON fi.id = fm.file_id
|
||||
WHERE fi.user_id = $1::uuid AND NOT fi.is_trashed
|
||||
AND fm.longitude BETWEEN $2 AND $3 -- west .. east
|
||||
AND fm.latitude BETWEEN $4 AND $5 -- south .. north
|
||||
AND fm.latitude IS NOT NULL
|
||||
GROUP BY gx, gy;
|
||||
```
|
||||
`$6` (`cell`) shrinks with zoom. Single indexed scan + hash aggregate; the browser only
|
||||
receives `{count, center, sample_file_id}` per cell.
|
||||
|
||||
#### 1.3 Application service (AuthZ + audit)
|
||||
**`src/application/services/places_service.rs`** (new):
|
||||
```rust
|
||||
pub async fn list_clusters_with_perms(
|
||||
&self, caller_id: Uuid, bounds: GeoBounds, zoom: u8,
|
||||
) -> Result<Vec<GeoCluster>, AppError> {
|
||||
// Scoped to the caller's own library; no cross-user data.
|
||||
self.authz.require(caller_id, /* own photos */).await?; // audit on deny inside require()
|
||||
let cell = cell_for_zoom(zoom);
|
||||
self.geo.clusters_in_bounds(caller_id, bounds, cell).await
|
||||
}
|
||||
```
|
||||
Wire it in **`src/common/di.rs`** (`AppServiceFactory` → `AppState`), `Option<Arc<…>>`
|
||||
gated on the feature flag.
|
||||
|
||||
#### 1.4 Config flag
|
||||
**`src/common/config.rs`** — `FeaturesConfig::enable_places` from `OXICLOUD_ENABLE_PLACES`.
|
||||
|
||||
#### 1.5 Basemap serving (PMTiles via Axum)
|
||||
- Add the **`pmtiles`** crate (`cargo add pmtiles`).
|
||||
- Ship a `.pmtiles` basemap (config: path; default global z0–6 ≈ 60 MB) + self-hosted
|
||||
**glyphs** and **sprites** under `static/` (from `basemaps-assets`).
|
||||
- **`src/interfaces/api/handlers/basemap_handler.rs`** (new): open the reader once
|
||||
(`AsyncPmTilesReader::new_with_path`, `Arc` into `AppState`), serve
|
||||
`GET /api/basemap/{z}/{x}/{y}.mvt` (`reader.get_tile(...)`). *Alt:* serve the raw
|
||||
`.pmtiles` over Range and let `pmtiles.js` do directory math (no tile handler).
|
||||
|
||||
#### 1.6 HTTP endpoints + routes
|
||||
**`src/interfaces/api/handlers/places_handler.rs`** (new):
|
||||
- `GET /api/photos/geo?bbox=w,s,e,n&zoom=Z` → `Vec<GeoCluster>` (auth middleware injects
|
||||
`caller_id`; handler does **no** AuthZ — service does).
|
||||
- `GET /api/photos/geo/cell?bbox=…` → photos in a cell (opens lightbox).
|
||||
Register in **`src/interfaces/api/routes.rs`** (protected routes) + the basemap route
|
||||
(public/cached).
|
||||
|
||||
#### 1.7 Frontend — vendored map + Places module
|
||||
- **Vendor** (needs sign-off): `maplibre-gl` (UMD + CSS), `pmtiles.js`,
|
||||
`@protomaps/basemaps` style JSON → `static/js/vendors/` + `static/css/`.
|
||||
- **`static/js/features/library/places.js`** (+ `static/css/views/places.css`): init
|
||||
MapLibre with the self-hosted style (light/dark flavor by theme), register the
|
||||
`pmtiles://` protocol, add a clustered GeoJSON source fed from `/api/photos/geo`
|
||||
(`cluster: true`) — or, above ~100k, the server-aggregated endpoint. Click cluster →
|
||||
zoom; click point → open lightbox filtered to that cell. Mandatory ODbL attribution
|
||||
control.
|
||||
- **`static/js/core/types.js`** — `GeoCluster` typedef.
|
||||
- Mount under the **Places** sub-nav tab (§0.7).
|
||||
|
||||
#### 1.8 (Optional) thumbnail markers
|
||||
deck.gl `IconLayer` (MIT, no React) atlas for **visible cluster representatives only** —
|
||||
never atlas all points. Start without it (count bubbles), add later.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — People (faces)
|
||||
|
||||
Feature-flagged, opt-in, OFF by default. Biometric data → privacy-first.
|
||||
|
||||
### Execution order
|
||||
|
||||
#### 2.1 Config flag + privacy switch
|
||||
**`src/common/config.rs`** — `OXICLOUD_ENABLE_FACES`. Plus a **per-user opt-in** setting
|
||||
(stored in `auth.users` or a user-settings table) — clustering only runs for users who
|
||||
opted in.
|
||||
|
||||
#### 2.2 Migration — pgvector + schema
|
||||
**New file:** `migrations/<ts>_faces.sql`
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector (or vectorchord)
|
||||
CREATE SCHEMA IF NOT EXISTS faces;
|
||||
|
||||
CREATE TABLE faces.persons (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
display_name TEXT, -- null = unnamed
|
||||
cover_face_id UUID,
|
||||
is_hidden BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE faces.faces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
bbox REAL[4] NOT NULL, -- x,y,w,h (normalized)
|
||||
det_score REAL NOT NULL,
|
||||
quality REAL, -- blur/size gate result
|
||||
embedding vector(512) NOT NULL,
|
||||
person_id UUID REFERENCES faces.persons(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_faces_embedding ON faces.faces
|
||||
USING hnsw (embedding vector_cosine_ops);
|
||||
CREATE INDEX idx_faces_person ON faces.faces (person_id);
|
||||
CREATE INDEX idx_faces_user ON faces.faces (user_id);
|
||||
```
|
||||
Cascade-delete guarantees the **right to erasure**: deleting a file/user removes its
|
||||
faces; deleting a Person unlinks its faces.
|
||||
|
||||
#### 2.3 Domain + ports
|
||||
**`src/domain/entities/`** — `Face`, `Person`.
|
||||
**`src/application/ports/face_ports.rs`** (new):
|
||||
```rust
|
||||
pub struct DetectedFace { pub bbox: [f32;4], pub landmarks: [[f32;2];5], pub score: f32 }
|
||||
|
||||
#[async_trait] pub trait FaceDetectorPort: Send + Sync {
|
||||
async fn detect(&self, image: &DynamicImage) -> Result<Vec<DetectedFace>, DomainError>;
|
||||
}
|
||||
#[async_trait] pub trait FaceEmbedderPort: Send + Sync {
|
||||
async fn embed(&self, aligned_112: &DynamicImage) -> Result<[f32;512], DomainError>;
|
||||
}
|
||||
```
|
||||
**`src/application/ports/`** — `FaceRepository` (CRUD + ANN search via pgvector `<=>`).
|
||||
|
||||
#### 2.4 Infrastructure — ONNX runtime adapter
|
||||
- `cargo add ort ndarray`.
|
||||
- **`src/infrastructure/services/onnx_face_service.rs`** (new): loads detector + embedder
|
||||
ONNX models, runs on a **dedicated thread pool** (mirror `thumbnail_service.rs` /
|
||||
`image_transcode_service.rs` to avoid starving Tokio). Pipeline: detect → 5-point
|
||||
similarity align to 112×112 → embed → L2-normalize. Implements `FaceDetectorPort` +
|
||||
`FaceEmbedderPort`.
|
||||
- **Models** are **downloaded at runtime** to a models dir (NOT committed). Default:
|
||||
SCRFD-2.5G + `buffalo_l/w600k_r50` (immich pattern). Config switch to the
|
||||
permissive fallback (RetinaFace-MobileNet0.25 MIT + bundled-by-you embedder).
|
||||
- GPU optional via `ort` execution providers (`ORT_DYLIB_PATH` / EP Cargo features); same
|
||||
code path falls back to CPU.
|
||||
|
||||
#### 2.5 Indexing pipeline (lifecycle hook + backfill)
|
||||
- **`src/infrastructure/services/face_indexing_service.rs`** (new) implements
|
||||
`FileLifecycleHook` (same pattern as `media_metadata_service.rs`): on image create →
|
||||
decode (reuse decode path) → detect → **quality-gate** (score ≥0.7, face ≥50–80px,
|
||||
Laplacian blur) → embed → store. **Dedup by `blob_hash`**: identical photos reuse faces.
|
||||
- **Backfill**: a throttled background job over the existing library on the **maintenance
|
||||
pool**.
|
||||
|
||||
#### 2.6 Clustering (incremental + periodic) — application service
|
||||
**`src/application/services/people_service.rs`** (new). All methods
|
||||
`*_with_perms(caller_id)` → `authz.require(...)` → audit on deny.
|
||||
- **Online (per import):** ANN candidate via pgvector `<=>` + **exact cosine re-rank**;
|
||||
assign to existing Person if within the *match* threshold (tighter), else leave
|
||||
unassigned. Thresholds: form ≈ cosine-sim 0.75–0.80 (Euclid ≈ 0.5); match tighter (≈0.4
|
||||
Euclid) for precision.
|
||||
- **Periodic full re-cluster:** threshold connected-components over the user's faces;
|
||||
`minFaces ≥3` to promote a cluster to a Person; singletons → "Unknown".
|
||||
|
||||
#### 2.7 HTTP endpoints + routes (AuthZ in service)
|
||||
**`src/interfaces/api/handlers/people_handler.rs`** (new):
|
||||
- `GET /api/people` — persons (cover + count).
|
||||
- `GET /api/people/{id}/photos`.
|
||||
- `PATCH /api/people/{id}` — rename.
|
||||
- `POST /api/people/merge` · `/split` · `POST /api/people/{id}/hide`.
|
||||
- `GET /api/files/{id}/faces` — face boxes for lightbox tagging.
|
||||
- Settings: enable/disable, re-index, **delete all my face data**.
|
||||
Register in `routes.rs`. Wire service in `di.rs` (`Option<Arc<PeopleService>>`).
|
||||
|
||||
#### 2.8 Frontend — People module
|
||||
- **`static/js/features/library/people.js`** (+ `people.css`): grid of person tiles
|
||||
(circular cover face + name), click → that person's photos; rename/merge/hide UI;
|
||||
lightbox face boxes + "tag person".
|
||||
- **`static/js/core/types.js`** — `Person`, `Face` typedefs.
|
||||
- Mount under the **People** sub-nav tab (§0.7); show an explicit **opt-in consent** gate
|
||||
before first indexing.
|
||||
|
||||
#### 2.9 (Optional, later) Semantic search
|
||||
CLIP/SigLIP via the same `ort` stack → natural-language photo search ("beach", "cake").
|
||||
Reuses the embedding-in-Postgres + ANN infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Vendoring & dependencies (need sign-off)
|
||||
|
||||
| Kind | Item | License | Notes |
|
||||
|------|------|---------|-------|
|
||||
| JS (vendor) | `maplibre-gl` (UMD+CSS) | BSD-3 | Map engine; no framework |
|
||||
| JS (vendor) | `pmtiles.js` | BSD-3 | Range-reads `.pmtiles` in browser |
|
||||
| JS (vendor) | `@protomaps/basemaps` style + assets | code BSD-3 / design CC0 | self-host glyphs+sprites |
|
||||
| JS (vendor, opt) | `deck.gl` core+layers | MIT | thumbnail `IconLayer` only |
|
||||
| Rust crate | `pmtiles` | MIT/Apache-2.0 | serve basemap from Axum (`cargo add`) |
|
||||
| Rust crate | `ort` (+`ndarray`) | MIT/Apache-2.0 | ONNX runtime; ships `libonnxruntime.so` |
|
||||
| Rust crate (opt) | `libheif-rs` | LGPL | HEIC decode (native dep) |
|
||||
| PG extension | `pgvector` (→ `vectorchord`) | PostgreSQL / Apache-2.0 | 512-d embeddings + HNSW |
|
||||
| Asset (basemap) | Protomaps `.pmtiles` | data ODbL | self-hosted; attribution required |
|
||||
| ML models (runtime DL, NOT committed) | SCRFD + `buffalo_l` | **non-commercial** | personal self-host OK; commercial = license InsightFace |
|
||||
|
||||
Repo rules respected: no hand-editing `Cargo.lock`; no JS framework; design tokens only
|
||||
for CSS; `target:"audit"` denial logs; AuthZ exclusively in services.
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (need your call)
|
||||
|
||||
1. **Faces embedder strategy:** (a) **immich pattern** — runtime-download `buffalo_l`
|
||||
(~97 IJB-C, non-commercial, recommended default) · (b) fully-permissive bundle —
|
||||
retrain EdgeFace/GhostFaceNet (~94, real ML project) · (c) defer People.
|
||||
2. **Basemap extent / hosting:** global z0–6 (~60 MB, simplest) vs regional extract vs
|
||||
full planet (~120 GB) — and store path / how shipped.
|
||||
3. **Vector store start:** `pgvector` now (simplest) vs `VectorChord` from day one
|
||||
(immich's scaled choice).
|
||||
4. **Map thumbnails:** start with count bubbles (MapLibre only) vs deck.gl `IconLayer`
|
||||
from the start.
|
||||
5. **HEIC:** in scope for Phase 0, or deferred (native `libheif` dep)?
|
||||
6. **MapLibre vendoring approval** (new JS library — per repo rules, needs explicit OK).
|
||||
|
||||
---
|
||||
|
||||
## Suggested sequencing
|
||||
|
||||
| Phase | Risk | Notes |
|
||||
|-------|------|-------|
|
||||
| 0.1 virtualization | done | smoke-test pending |
|
||||
| 0.2–0.5 polish | low | self-contained, verifiable |
|
||||
| 1 Places | low–med | data ready; new vendored map + basemap serving |
|
||||
| 2 People | high | new ML stack, pgvector, privacy, licensing decision |
|
||||
| 0.6 HEIC / 2.9 search | opt | independent, schedule freely |
|
||||
|
||||
Recommended order: finish **Phase 0**, ship **Places**, then tackle **People** once the
|
||||
embedder-licensing decision (#1) is made.
|
||||
+37
@@ -224,6 +224,43 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
|
||||
# Set to false to prevent users from browsing the user directory.
|
||||
#OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
||||
|
||||
# ── People (face recognition) ────────────────────────────────────────────
|
||||
# Biometric data (GDPR Art. 9) — OFF by default, opt-in per deployment.
|
||||
# Detects faces and clusters them into people in the photo library.
|
||||
#
|
||||
# Requires ALL of:
|
||||
# 1. a binary built with the `faces-onnx` cargo feature
|
||||
# (`cargo build --release --features faces-onnx`),
|
||||
# 2. OXICLOUD_ENABLE_FACES=true,
|
||||
# 3. the ONNX Runtime shared library + two operator-provided ONNX models
|
||||
# (a SCRFD/RetinaFace detector with 5-point landmarks, and an ArcFace
|
||||
# 512-d embedder — e.g. InsightFace `buffalo_l`). Models are NOT shipped.
|
||||
# Without all three, the People pipeline stays inert (no-op analyzer) and the
|
||||
# server still boots; the People tab stays hidden in the UI.
|
||||
#OXICLOUD_ENABLE_FACES=false
|
||||
|
||||
# Path to libonnxruntime.{so,dylib,dll}. Falls back to ORT_DYLIB_PATH.
|
||||
# Use the ONNX Runtime build matching this app's `ort` crate (>= 1.24).
|
||||
#OXICLOUD_FACES_ORT_DYLIB=/opt/onnxruntime/lib/libonnxruntime.so
|
||||
|
||||
# Face detector model (SCRFD/RetinaFace, 5-point landmarks).
|
||||
#OXICLOUD_FACES_DETECTOR_MODEL=/var/lib/oxicloud/models/scrfd_10g_bnkps.onnx
|
||||
|
||||
# Face embedder model (ArcFace, 112x112 input -> 512-d output).
|
||||
#OXICLOUD_FACES_EMBEDDER_MODEL=/var/lib/oxicloud/models/w600k_r50.onnx
|
||||
|
||||
# Detector square input size in px (default: 640)
|
||||
#OXICLOUD_FACES_DET_SIZE=640
|
||||
|
||||
# Minimum detector confidence to keep a face, 0..1 (default: 0.5)
|
||||
#OXICLOUD_FACES_DET_THRESHOLD=0.5
|
||||
|
||||
# IoU threshold for non-maximum suppression, 0..1 (default: 0.4)
|
||||
#OXICLOUD_FACES_NMS_THRESHOLD=0.4
|
||||
|
||||
# ONNX Runtime intra-op threads; 0 = let ONNX Runtime decide (default: 0)
|
||||
#OXICLOUD_FACES_INTRA_THREADS=0
|
||||
|
||||
# WASM plugin runtime (Extism). Requires a binary built with the `plugins`
|
||||
# cargo feature (`cargo run --features plugins`); without that feature these
|
||||
# vars are inert. Untrusted plugins run sandboxed: no filesystem, no network,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- Places (photo map): partial index for fast bounding-box scans over the
|
||||
-- caller's geotagged photos. Plain B-tree on (longitude, latitude); no
|
||||
-- PostGIS required. The partial predicate keeps the index small — only rows
|
||||
-- that actually carry GPS coordinates are indexed.
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_file_metadata_geo
|
||||
ON storage.file_metadata (longitude, latitude)
|
||||
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
-- People / Faces: per-user face detections and identity clusters.
|
||||
--
|
||||
-- Embeddings are stored as BYTEA (512 × float32, L2-normalized = 2048 bytes)
|
||||
-- rather than a pgvector column, so the feature adds NO new PostgreSQL
|
||||
-- extension dependency. Similarity is computed in-app (brute-force cosine
|
||||
-- scales comfortably to ~100k faces); pgvector / VectorChord with an HNSW
|
||||
-- index is the documented upgrade path for larger libraries.
|
||||
--
|
||||
-- Biometric data — the feature is OFF by default (OXICLOUD_ENABLE_FACES) and
|
||||
-- opt-in per user. All rows cascade-delete with their owning user, and face
|
||||
-- rows cascade-delete with their source file, satisfying the right to erasure.
|
||||
-- ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS faces;
|
||||
|
||||
-- An identity cluster ("person"). display_name is NULL until the user names it.
|
||||
CREATE TABLE IF NOT EXISTS faces.persons (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
display_name TEXT,
|
||||
cover_face_id UUID, -- representative face (set by the app)
|
||||
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persons_user ON faces.persons (user_id);
|
||||
|
||||
-- A single detected face with its embedding and (optional) person assignment.
|
||||
CREATE TABLE IF NOT EXISTS faces.faces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
person_id UUID REFERENCES faces.persons(id) ON DELETE SET NULL,
|
||||
bbox REAL[] NOT NULL, -- [x, y, w, h], normalized 0..1
|
||||
det_score REAL NOT NULL, -- detector confidence
|
||||
quality REAL, -- blur/size gate score (nullable)
|
||||
embedding BYTEA NOT NULL, -- 512 × float32, L2-normalized
|
||||
blob_hash VARCHAR(64), -- dedup-aware reuse across identical files
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_faces_user ON faces.faces (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_faces_person ON faces.faces (person_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_faces_file ON faces.faces (file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_faces_blob ON faces.faces (blob_hash);
|
||||
@@ -0,0 +1,26 @@
|
||||
//! DTOs for the "Places" (photo map) feature.
|
||||
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// A geographic bounding box in decimal degrees.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct GeoBounds {
|
||||
pub west: f64,
|
||||
pub south: f64,
|
||||
pub east: f64,
|
||||
pub north: f64,
|
||||
}
|
||||
|
||||
/// A clustered group of geotagged photos within one aggregation cell.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct GeoCluster {
|
||||
/// Cluster centroid longitude.
|
||||
pub lng: f64,
|
||||
/// Cluster centroid latitude.
|
||||
pub lat: f64,
|
||||
/// Number of photos in the cluster.
|
||||
pub count: i64,
|
||||
/// A representative photo id, for the cluster thumbnail.
|
||||
pub sample_file_id: String,
|
||||
}
|
||||
@@ -10,9 +10,11 @@ pub mod favorites_dto;
|
||||
pub mod file_dto;
|
||||
pub mod folder_dto;
|
||||
pub mod folder_listing_dto;
|
||||
pub mod geo_dto;
|
||||
pub mod grant_dto;
|
||||
pub mod i18n_dto;
|
||||
pub mod pagination;
|
||||
pub mod people_dto;
|
||||
pub mod playlist_dto;
|
||||
pub mod plugin_dto;
|
||||
pub mod recent_dto;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! DTOs for the People (faces) API.
|
||||
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// A named (or unnamed) identity cluster, with a cover photo for its tile.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct PersonDto {
|
||||
pub id: String,
|
||||
/// `None` until the user names the person.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// File id of the cover face's photo, for the tile thumbnail.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_file_id: Option<String>,
|
||||
pub face_count: i64,
|
||||
pub is_hidden: bool,
|
||||
}
|
||||
|
||||
/// One face box within a photo (for tagging overlays in the lightbox).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct FaceBoxDto {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub person_id: Option<String>,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Ports for the People (faces) feature.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::{DetectedFace, Face, Person};
|
||||
|
||||
/// Detects faces in an image and produces an aligned, L2-normalized embedding
|
||||
/// for each. Takes raw encoded bytes (it decodes internally) so the
|
||||
/// application layer stays decoupled from any image/ML crate.
|
||||
///
|
||||
/// The default implementation ([`NoopFaceAnalyzer`](crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer))
|
||||
/// is a no-op that reports `is_ready() == false`; a real ONNX-backed
|
||||
/// implementation is wired in when the operator provides models at runtime.
|
||||
#[async_trait]
|
||||
pub trait FaceAnalyzerPort: Send + Sync + 'static {
|
||||
/// Whether a usable model is loaded. When false, indexing is skipped.
|
||||
fn is_ready(&self) -> bool;
|
||||
|
||||
/// Detect and embed every face in `image_bytes` (an encoded JPEG/PNG/…).
|
||||
async fn analyze(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError>;
|
||||
}
|
||||
|
||||
/// Persistence for faces and persons. Every method is user-scoped; the
|
||||
/// repository enforces `WHERE user_id = …` so callers only ever touch their
|
||||
/// own biometric data.
|
||||
#[async_trait]
|
||||
pub trait FaceRepository: Send + Sync + 'static {
|
||||
// ── faces ──────────────────────────────────────────────────────
|
||||
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>;
|
||||
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError>;
|
||||
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
|
||||
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError>;
|
||||
/// Faces previously computed for any file sharing this content hash —
|
||||
/// lets indexing reuse results for deduplicated (identical) uploads.
|
||||
async fn faces_for_blob(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
blob_hash: &str,
|
||||
) -> Result<Vec<Face>, DomainError>;
|
||||
async fn assign_person(
|
||||
&self,
|
||||
face_id: Uuid,
|
||||
person_id: Option<Uuid>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
// ── persons ────────────────────────────────────────────────────
|
||||
async fn create_person(&self, person: &Person) -> Result<(), DomainError>;
|
||||
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError>;
|
||||
async fn rename_person(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
person_id: Uuid,
|
||||
name: Option<String>,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn set_person_cover(
|
||||
&self,
|
||||
person_id: Uuid,
|
||||
cover_face_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn set_person_hidden(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
person_id: Uuid,
|
||||
hidden: bool,
|
||||
) -> Result<(), DomainError>;
|
||||
/// File ids that contain a face assigned to this person (most recent first).
|
||||
async fn files_for_person(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
person_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, DomainError>;
|
||||
|
||||
/// Hard-delete every face and person for a user (right to erasure /
|
||||
/// disabling the feature).
|
||||
async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod compression_ports;
|
||||
pub mod content_index_ports;
|
||||
pub mod dedup_ports;
|
||||
pub mod email_sender;
|
||||
pub mod face_ports;
|
||||
pub mod favorites_ports;
|
||||
pub mod file_lifecycle;
|
||||
pub mod file_ports;
|
||||
|
||||
@@ -20,6 +20,8 @@ pub mod magic_link_invite_service;
|
||||
pub mod music_service;
|
||||
pub mod nextcloud_file_id_service;
|
||||
pub mod nextcloud_login_flow_service;
|
||||
pub mod people_service;
|
||||
pub mod places_service;
|
||||
pub mod recent_service;
|
||||
pub mod recipient_notification_service;
|
||||
pub mod search_service;
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! People (faces) use cases: identity clustering + the read/mutation methods
|
||||
//! the HTTP layer calls.
|
||||
//!
|
||||
//! Clustering is a full re-cluster over the user's faces: a union-find groups
|
||||
//! faces whose embeddings are within a cosine threshold (connected
|
||||
//! components), and groups of at least `min_faces` become a "person". This is
|
||||
//! O(n²) in the user's face count — fine for moderate libraries; an ANN index
|
||||
//! (pgvector/VectorChord) is the documented scale-up.
|
||||
//!
|
||||
//! Strictly user-scoped (the repository filters by user), so — like
|
||||
//! `RecentService` / `PlacesService` — no `AuthorizationEngine` check is
|
||||
//! needed: the `caller_id` parameter is the access scope.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::people_dto::{FaceBoxDto, PersonDto};
|
||||
use crate::application::ports::face_ports::FaceRepository;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::Person;
|
||||
use crate::infrastructure::repositories::pg::FacePgRepository;
|
||||
|
||||
/// Cosine similarity of two equal-length vectors. Embeddings are produced
|
||||
/// L2-normalized, so this is ~a dot product; we normalize anyway for safety.
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32);
|
||||
for (&x, &y) in a.iter().zip(b.iter()) {
|
||||
dot += x * y;
|
||||
na += x * x;
|
||||
nb += y * y;
|
||||
}
|
||||
if na == 0.0 || nb == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
dot / (na.sqrt() * nb.sqrt())
|
||||
}
|
||||
|
||||
/// Disjoint-set with path-halving + union by rank.
|
||||
struct UnionFind {
|
||||
parent: Vec<usize>,
|
||||
rank: Vec<usize>,
|
||||
}
|
||||
|
||||
impl UnionFind {
|
||||
fn new(n: usize) -> Self {
|
||||
Self {
|
||||
parent: (0..n).collect(),
|
||||
rank: vec![0; n],
|
||||
}
|
||||
}
|
||||
fn find(&mut self, mut x: usize) -> usize {
|
||||
while self.parent[x] != x {
|
||||
self.parent[x] = self.parent[self.parent[x]];
|
||||
x = self.parent[x];
|
||||
}
|
||||
x
|
||||
}
|
||||
fn union(&mut self, a: usize, b: usize) {
|
||||
let (ra, rb) = (self.find(a), self.find(b));
|
||||
if ra == rb {
|
||||
return;
|
||||
}
|
||||
match self.rank[ra].cmp(&self.rank[rb]) {
|
||||
std::cmp::Ordering::Less => self.parent[ra] = rb,
|
||||
std::cmp::Ordering::Greater => self.parent[rb] = ra,
|
||||
std::cmp::Ordering::Equal => {
|
||||
self.parent[rb] = ra;
|
||||
self.rank[ra] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PeopleService {
|
||||
repo: Arc<FacePgRepository>,
|
||||
/// Min cosine similarity to link two faces into the same identity.
|
||||
cluster_threshold: f32,
|
||||
/// Min faces in a cluster before it becomes a named-able "person".
|
||||
min_faces: usize,
|
||||
}
|
||||
|
||||
impl PeopleService {
|
||||
pub fn new(repo: Arc<FacePgRepository>) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
cluster_threshold: 0.5,
|
||||
min_faces: 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-cluster a user's faces. Returns the number of new persons created.
|
||||
pub async fn recluster(&self, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
let faces = self.repo.faces_for_user(user_id).await?;
|
||||
let n = faces.len();
|
||||
if n == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut uf = UnionFind::new(n);
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if cosine(&faces[i].embedding, &faces[j].embedding) >= self.cluster_threshold {
|
||||
uf.union(i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for i in 0..n {
|
||||
let root = uf.find(i);
|
||||
groups.entry(root).or_default().push(i);
|
||||
}
|
||||
|
||||
let mut created = 0usize;
|
||||
for idxs in groups.into_values() {
|
||||
if idxs.len() < self.min_faces {
|
||||
// Too small to be a person — leave/reset these faces unassigned.
|
||||
for &i in &idxs {
|
||||
if faces[i].person_id.is_some() {
|
||||
self.repo.assign_person(faces[i].id, None).await?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse an existing person on this cluster (preserves a user's name)
|
||||
// or mint a new one.
|
||||
let existing = idxs.iter().find_map(|&i| faces[i].person_id);
|
||||
let person_id = match existing {
|
||||
Some(pid) => pid,
|
||||
None => {
|
||||
let pid = Uuid::new_v4();
|
||||
let person = Person {
|
||||
id: pid,
|
||||
user_id,
|
||||
display_name: None,
|
||||
cover_face_id: Some(faces[idxs[0]].id),
|
||||
is_hidden: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
self.repo.create_person(&person).await?;
|
||||
created += 1;
|
||||
pid
|
||||
}
|
||||
};
|
||||
for &i in &idxs {
|
||||
if faces[i].person_id != Some(person_id) {
|
||||
self.repo
|
||||
.assign_person(faces[i].id, Some(person_id))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
.repo
|
||||
.set_person_cover(person_id, faces[idxs[0]].id)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// People (non-empty clusters), most-photographed first.
|
||||
pub async fn list_people(&self, caller_id: Uuid) -> Result<Vec<PersonDto>, DomainError> {
|
||||
let persons = self.repo.persons_for_user(caller_id).await?;
|
||||
let faces = self.repo.faces_for_user(caller_id).await?;
|
||||
|
||||
let mut count: HashMap<Uuid, i64> = HashMap::new();
|
||||
let mut face_file: HashMap<Uuid, Uuid> = HashMap::new();
|
||||
for f in &faces {
|
||||
if let Some(pid) = f.person_id {
|
||||
*count.entry(pid).or_default() += 1;
|
||||
}
|
||||
face_file.insert(f.id, f.file_id);
|
||||
}
|
||||
|
||||
let mut out: Vec<PersonDto> = persons
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let c = count.get(&p.id).copied().unwrap_or(0);
|
||||
if c == 0 {
|
||||
return None; // hide empty clusters (e.g. after a merge)
|
||||
}
|
||||
let cover_file_id = p
|
||||
.cover_face_id
|
||||
.and_then(|fid| face_file.get(&fid).copied())
|
||||
.map(|u| u.to_string());
|
||||
Some(PersonDto {
|
||||
id: p.id.to_string(),
|
||||
name: p.display_name,
|
||||
cover_file_id,
|
||||
face_count: c,
|
||||
is_hidden: p.is_hidden,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.face_count.cmp(&a.face_count));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// File ids of a person's photos (most recent first).
|
||||
pub async fn person_photos(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
person_id: Uuid,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let files = self.repo.files_for_person(caller_id, person_id).await?;
|
||||
Ok(files.into_iter().map(|u| u.to_string()).collect())
|
||||
}
|
||||
|
||||
/// Face boxes within a photo (for lightbox tagging), caller-scoped.
|
||||
pub async fn faces_for_file(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
file_id: Uuid,
|
||||
) -> Result<Vec<FaceBoxDto>, DomainError> {
|
||||
let faces = self.repo.faces_for_file(file_id).await?;
|
||||
Ok(faces
|
||||
.into_iter()
|
||||
.filter(|f| f.user_id == caller_id)
|
||||
.map(|f| FaceBoxDto {
|
||||
id: f.id.to_string(),
|
||||
person_id: f.person_id.map(|u| u.to_string()),
|
||||
x: f.bbox.x,
|
||||
y: f.bbox.y,
|
||||
w: f.bbox.w,
|
||||
h: f.bbox.h,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn rename_person(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
person_id: Uuid,
|
||||
name: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.repo.rename_person(caller_id, person_id, name).await
|
||||
}
|
||||
|
||||
pub async fn set_hidden(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
person_id: Uuid,
|
||||
hidden: bool,
|
||||
) -> Result<(), DomainError> {
|
||||
self.repo
|
||||
.set_person_hidden(caller_id, person_id, hidden)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Merge `from` into `into` by reassigning all of `from`'s faces. The
|
||||
/// now-empty `from` person is hidden by `list_people`.
|
||||
pub async fn merge(&self, caller_id: Uuid, into: Uuid, from: Uuid) -> Result<(), DomainError> {
|
||||
let faces = self.repo.faces_for_user(caller_id).await?;
|
||||
for f in faces.into_iter().filter(|f| f.person_id == Some(from)) {
|
||||
self.repo.assign_person(f.id, Some(into)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Erase all of the caller's face data (right to erasure / opt-out).
|
||||
pub async fn delete_all(&self, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
self.repo.delete_all_for_user(caller_id).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::infrastructure::repositories::pg::FileBlobReadRepository;
|
||||
|
||||
/// "Places" use case: the caller's geotagged photos aggregated into map
|
||||
/// clusters.
|
||||
///
|
||||
/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so,
|
||||
/// like [`RecentService`](super::recent_service::RecentService) and the photos
|
||||
/// timeline, it needs no `AuthorizationEngine` check: the `caller_id`
|
||||
/// parameter *is* the access scope.
|
||||
pub struct PlacesService {
|
||||
file_read: Arc<FileBlobReadRepository>,
|
||||
}
|
||||
|
||||
impl PlacesService {
|
||||
pub fn new(file_read: Arc<FileBlobReadRepository>) -> Self {
|
||||
Self { file_read }
|
||||
}
|
||||
|
||||
/// Aggregation cell side, in degrees, for a slippy-map zoom level. The
|
||||
/// world (360°) is split into `2^zoom` tiles; we use ~4 cells per tile so
|
||||
/// clusters refine as the user zooms in. Clamped to a sane range.
|
||||
fn cell_for_zoom(zoom: u8) -> f64 {
|
||||
let z = i32::from(zoom.min(20));
|
||||
360.0 / (2_f64.powi(z) * 4.0)
|
||||
}
|
||||
|
||||
/// Clustered geotagged photos for `caller_id` within `bounds`.
|
||||
pub async fn clusters(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
bounds: GeoBounds,
|
||||
zoom: u8,
|
||||
) -> Result<Vec<GeoCluster>, DomainError> {
|
||||
let cell = Self::cell_for_zoom(zoom);
|
||||
self.file_read
|
||||
.list_geo_clusters(caller_id, bounds, cell)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -879,6 +879,11 @@ pub struct FeaturesConfig {
|
||||
pub enable_trash: bool,
|
||||
pub enable_search: bool,
|
||||
pub enable_music: bool,
|
||||
/// Lists the user's geotagged photos on a map (GET /api/photos/geo).
|
||||
pub enable_places: bool,
|
||||
/// Face detection + identity clustering for the photo library ("People").
|
||||
/// Biometric data — OFF by default; opt-in per deployment/user.
|
||||
pub enable_faces: bool,
|
||||
/// Expose other OxiCloud users as a read-only "system" address book
|
||||
/// at GET /api/address-books. Set to false to hide the user directory.
|
||||
pub expose_system_users: bool,
|
||||
@@ -893,11 +898,60 @@ impl Default for FeaturesConfig {
|
||||
enable_trash: true, // Enable trash feature
|
||||
enable_search: true, // Enable search feature
|
||||
enable_music: true, // Enable music feature
|
||||
enable_places: true, // Photo map (GET /api/photos/geo + Places tab)
|
||||
enable_faces: false, // People/faces (biometric) — opt-in, off by default
|
||||
expose_system_users: true, // Expose OxiCloud users as address book by default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Face-recognition (People) model configuration.
|
||||
///
|
||||
/// Only consulted when the `faces-onnx` cargo feature is compiled in *and*
|
||||
/// [`FeaturesConfig::enable_faces`] is true; otherwise the inert
|
||||
/// `NoopFaceAnalyzer` is used regardless of these values. The ONNX Runtime
|
||||
/// dylib and both model files are operator-provided at runtime (never
|
||||
/// committed) — when any is unset or fails to load, the People pipeline
|
||||
/// silently falls back to the no-op analyzer and the server still boots.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FacesConfig {
|
||||
/// `libonnxruntime.{so,dylib,dll}`. Falls back to the `ORT_DYLIB_PATH`
|
||||
/// environment variable when unset. Env: `OXICLOUD_FACES_ORT_DYLIB`.
|
||||
pub ort_dylib: Option<PathBuf>,
|
||||
/// SCRFD/RetinaFace detector model with 5-point landmarks.
|
||||
/// Env: `OXICLOUD_FACES_DETECTOR_MODEL`.
|
||||
pub detector_model: Option<PathBuf>,
|
||||
/// ArcFace embedder model (112×112 → 512-d).
|
||||
/// Env: `OXICLOUD_FACES_EMBEDDER_MODEL`.
|
||||
pub embedder_model: Option<PathBuf>,
|
||||
/// Detector square input size in pixels (default 640).
|
||||
/// Env: `OXICLOUD_FACES_DET_SIZE`.
|
||||
pub det_size: u32,
|
||||
/// Minimum detector confidence to keep a face (default 0.5).
|
||||
/// Env: `OXICLOUD_FACES_DET_THRESHOLD`.
|
||||
pub det_threshold: f32,
|
||||
/// IoU threshold for non-max suppression (default 0.4).
|
||||
/// Env: `OXICLOUD_FACES_NMS_THRESHOLD`.
|
||||
pub nms_threshold: f32,
|
||||
/// ONNX Runtime intra-op threads (0 = let ORT decide).
|
||||
/// Env: `OXICLOUD_FACES_INTRA_THREADS`.
|
||||
pub intra_threads: usize,
|
||||
}
|
||||
|
||||
impl Default for FacesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ort_dylib: None,
|
||||
detector_model: None,
|
||||
embedder_model: None,
|
||||
det_size: 640,
|
||||
det_threshold: 0.5,
|
||||
nms_threshold: 0.4,
|
||||
intra_threads: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-search configuration (embedded Tantivy index over file names and
|
||||
/// extracted file content).
|
||||
///
|
||||
@@ -1063,6 +1117,8 @@ pub struct AppConfig {
|
||||
pub content_search: ContentSearchConfig,
|
||||
/// WASM plugin runtime configuration
|
||||
pub plugins: PluginConfig,
|
||||
/// Face-recognition (People) model configuration
|
||||
pub faces: FacesConfig,
|
||||
}
|
||||
|
||||
/// Server-side i18n knobs.
|
||||
@@ -1116,6 +1172,7 @@ impl Default for AppConfig {
|
||||
i18n: I18nConfig::default(),
|
||||
content_search: ContentSearchConfig::default(),
|
||||
plugins: PluginConfig::default(),
|
||||
faces: FacesConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1378,6 +1435,55 @@ impl AppConfig {
|
||||
config.features.enable_music = val;
|
||||
}
|
||||
|
||||
if let Ok(enable_places) = env::var("OXICLOUD_ENABLE_PLACES").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_places
|
||||
{
|
||||
config.features.enable_places = val;
|
||||
}
|
||||
|
||||
if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_faces
|
||||
{
|
||||
config.features.enable_faces = val;
|
||||
}
|
||||
|
||||
// Faces (People) ONNX runtime + models — operator-provided at runtime.
|
||||
if let Ok(v) = env::var("OXICLOUD_FACES_ORT_DYLIB").or_else(|_| env::var("ORT_DYLIB_PATH"))
|
||||
&& !v.is_empty()
|
||||
{
|
||||
config.faces.ort_dylib = Some(PathBuf::from(v));
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_FACES_DETECTOR_MODEL")
|
||||
&& !v.is_empty()
|
||||
{
|
||||
config.faces.detector_model = Some(PathBuf::from(v));
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_FACES_EMBEDDER_MODEL")
|
||||
&& !v.is_empty()
|
||||
{
|
||||
config.faces.embedder_model = Some(PathBuf::from(v));
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_FACES_DET_SIZE").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.faces.det_size = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_FACES_DET_THRESHOLD").map(|v| v.parse::<f32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.faces.det_threshold = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_FACES_NMS_THRESHOLD").map(|v| v.parse::<f32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.faces.nms_threshold = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_FACES_INTRA_THREADS").map(|v| v.parse::<usize>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.faces.intra_threads = val;
|
||||
}
|
||||
|
||||
// Content search (embedded Tantivy index)
|
||||
if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = v
|
||||
|
||||
@@ -17,6 +17,8 @@ use crate::application::services::folder_service::FolderService;
|
||||
use crate::application::services::i18n_application_service::I18nApplicationService;
|
||||
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||||
use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService;
|
||||
use crate::application::services::people_service::PeopleService;
|
||||
use crate::application::services::places_service::PlacesService;
|
||||
use crate::application::services::recent_service::RecentService;
|
||||
use crate::application::services::search_service::SearchService;
|
||||
use crate::application::services::share_browse_service::ShareBrowseService;
|
||||
@@ -359,6 +361,9 @@ impl AppServiceFactory {
|
||||
fls = fls.with_hook(audio.clone());
|
||||
}
|
||||
fls = fls.with_hook(media_metadata_service.clone());
|
||||
if self.config.features.enable_faces {
|
||||
fls = fls.with_hook(self.create_face_indexing_service(db_pool));
|
||||
}
|
||||
let file_lifecycle = Arc::new(fls);
|
||||
|
||||
Ok(CoreServices {
|
||||
@@ -798,6 +803,95 @@ impl AppServiceFactory {
|
||||
service
|
||||
}
|
||||
|
||||
/// Creates the Places (photo map) service. Reuses the existing file-read
|
||||
/// repository — the data is the caller's own geotagged photos.
|
||||
pub fn create_places_service(
|
||||
&self,
|
||||
file_read: &Arc<FileBlobReadRepository>,
|
||||
) -> Arc<PlacesService> {
|
||||
let service = Arc::new(PlacesService::new(file_read.clone()));
|
||||
tracing::info!("Places service initialized");
|
||||
service
|
||||
}
|
||||
|
||||
/// Creates the face-indexing lifecycle hook (People feature). Picks the
|
||||
/// real ONNX analyzer when the `faces-onnx` feature is compiled in and the
|
||||
/// operator has configured the runtime + models; otherwise the inert no-op
|
||||
/// analyzer (see [`Self::build_face_analyzer`]).
|
||||
pub fn create_face_indexing_service(
|
||||
&self,
|
||||
db_pool: &Arc<PgPool>,
|
||||
) -> Arc<crate::infrastructure::services::face_indexing_service::FaceIndexingService> {
|
||||
let blob_root = self.storage_path.join(".blobs");
|
||||
let analyzer = self.build_face_analyzer();
|
||||
Arc::new(
|
||||
crate::infrastructure::services::face_indexing_service::FaceIndexingService::new(
|
||||
db_pool.clone(),
|
||||
blob_root,
|
||||
analyzer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Selects the face analyzer. With the `faces-onnx` feature and a fully
|
||||
/// configured runtime + models, loads the real ONNX analyzer; any missing
|
||||
/// piece or load failure degrades gracefully to the no-op analyzer (logged)
|
||||
/// so startup never fails on biometric configuration.
|
||||
fn build_face_analyzer(
|
||||
&self,
|
||||
) -> Arc<dyn crate::application::ports::face_ports::FaceAnalyzerPort> {
|
||||
#[cfg(feature = "faces-onnx")]
|
||||
{
|
||||
let f = &self.config.faces;
|
||||
if let (Some(dylib), Some(detector), Some(embedder)) = (
|
||||
f.ort_dylib.as_ref(),
|
||||
f.detector_model.as_ref(),
|
||||
f.embedder_model.as_ref(),
|
||||
) {
|
||||
use crate::infrastructure::services::onnx_face_analyzer::{
|
||||
OnnxFaceAnalyzer, OnnxLoadConfig,
|
||||
};
|
||||
let cfg = OnnxLoadConfig {
|
||||
dylib,
|
||||
detector,
|
||||
embedder,
|
||||
det_size: f.det_size,
|
||||
det_threshold: f.det_threshold,
|
||||
nms_threshold: f.nms_threshold,
|
||||
intra_threads: f.intra_threads,
|
||||
};
|
||||
match OnnxFaceAnalyzer::load(&cfg) {
|
||||
Ok(analyzer) => {
|
||||
tracing::info!("Face analyzer: ONNX models loaded");
|
||||
return Arc::new(analyzer);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Face analyzer: failed to load ONNX models ({e}); \
|
||||
falling back to no-op analyzer"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Face analyzer: faces-onnx compiled but runtime/models not fully \
|
||||
configured; using no-op analyzer"
|
||||
);
|
||||
}
|
||||
}
|
||||
Arc::new(crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer)
|
||||
}
|
||||
|
||||
/// Creates the People (faces) read/clustering service.
|
||||
pub fn create_people_service(&self, db_pool: &Arc<PgPool>) -> Arc<PeopleService> {
|
||||
let repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::FacePgRepository::new(db_pool.clone()),
|
||||
);
|
||||
let service = Arc::new(PeopleService::new(repo));
|
||||
tracing::info!("People service initialized");
|
||||
service
|
||||
}
|
||||
|
||||
/// Preloads translations for every locale in the registry. Build
|
||||
/// the registry at startup via `LocaleRegistry::discover` and pass
|
||||
/// the resulting list here.
|
||||
@@ -1005,6 +1099,8 @@ impl AppServiceFactory {
|
||||
// 6. Database-dependent services (PgPool always available in blob model)
|
||||
let favorites_service: Option<Arc<FavoritesService>>;
|
||||
let recent_service: Option<Arc<RecentService>>;
|
||||
let places_service: Option<Arc<PlacesService>>;
|
||||
let people_service: Option<Arc<PeopleService>>;
|
||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||
@@ -1027,6 +1123,18 @@ impl AppServiceFactory {
|
||||
recent_service = Some(recent.clone());
|
||||
apps.recent_service = Some(recent);
|
||||
|
||||
places_service = if core.config.features.enable_places {
|
||||
Some(self.create_places_service(&repos.file_read_repository))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
people_service = if core.config.features.enable_faces {
|
||||
Some(self.create_people_service(&pool))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
storage_usage_service = Some(storage_usage.clone());
|
||||
|
||||
self.start_tree_etag_flush_job(&maintenance_pool);
|
||||
@@ -1253,6 +1361,8 @@ impl AppServiceFactory {
|
||||
share_browse_service,
|
||||
favorites_service,
|
||||
recent_service,
|
||||
places_service,
|
||||
people_service,
|
||||
storage_usage_service,
|
||||
calendar_service: None,
|
||||
contact_service: None,
|
||||
@@ -1699,6 +1809,8 @@ pub struct AppState {
|
||||
pub share_browse_service: Option<Arc<ShareBrowseService>>,
|
||||
pub favorites_service: Option<Arc<FavoritesService>>,
|
||||
pub recent_service: Option<Arc<RecentService>>,
|
||||
pub places_service: Option<Arc<PlacesService>>,
|
||||
pub people_service: Option<Arc<PeopleService>>,
|
||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
pub calendar_service: Option<Arc<CalendarService>>,
|
||||
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Domain entities for the People (faces) feature.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Length of a face embedding vector (ArcFace-style).
|
||||
pub const EMBEDDING_DIM: usize = 512;
|
||||
|
||||
/// A face bounding box in normalized image coordinates (each component 0..1).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BoundingBox {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl BoundingBox {
|
||||
/// `[x, y, w, h]` — the storage representation (Postgres `REAL[]`).
|
||||
pub fn to_array(self) -> Vec<f32> {
|
||||
vec![self.x, self.y, self.w, self.h]
|
||||
}
|
||||
|
||||
/// Build from a stored `[x, y, w, h]` array; missing components default to 0.
|
||||
pub fn from_slice(a: &[f32]) -> Self {
|
||||
Self {
|
||||
x: a.first().copied().unwrap_or(0.0),
|
||||
y: a.get(1).copied().unwrap_or(0.0),
|
||||
w: a.get(2).copied().unwrap_or(0.0),
|
||||
h: a.get(3).copied().unwrap_or(0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face produced by the analyzer but not yet persisted: where it is, how
|
||||
/// confident the detector was, an optional quality score, and a 512-d,
|
||||
/// L2-normalized embedding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectedFace {
|
||||
pub bbox: BoundingBox,
|
||||
pub det_score: f32,
|
||||
pub quality: Option<f32>,
|
||||
pub embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
/// A persisted face detection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Face {
|
||||
pub id: Uuid,
|
||||
pub file_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// Identity cluster this face belongs to, if any.
|
||||
pub person_id: Option<Uuid>,
|
||||
pub bbox: BoundingBox,
|
||||
pub det_score: f32,
|
||||
pub quality: Option<f32>,
|
||||
pub embedding: Vec<f32>,
|
||||
pub blob_hash: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// An identity cluster ("person"). `display_name` is `None` until the user
|
||||
/// names it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Person {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub display_name: Option<String>,
|
||||
pub cover_face_id: Option<Uuid>,
|
||||
pub is_hidden: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod calendar_event;
|
||||
pub mod contact;
|
||||
pub mod device_code;
|
||||
pub mod entity_errors;
|
||||
pub mod face;
|
||||
pub mod file;
|
||||
pub mod folder;
|
||||
pub mod magic_link_token;
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//! PostgreSQL repository for the People (faces) feature.
|
||||
//!
|
||||
//! Embeddings are stored as `BYTEA` (512 × little-endian `f32`); there is no
|
||||
//! pgvector dependency. Similarity search / clustering is done in-app over the
|
||||
//! decoded vectors (see `PeopleService`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::face_ports::FaceRepository;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::{BoundingBox, Face, Person};
|
||||
|
||||
/// Row shape for `faces.faces` selects (avoids `clippy::type_complexity`).
|
||||
type FaceRow = (
|
||||
Uuid, // id
|
||||
Uuid, // file_id
|
||||
Uuid, // user_id
|
||||
Option<Uuid>, // person_id
|
||||
Vec<f32>, // bbox (REAL[])
|
||||
f32, // det_score
|
||||
Option<f32>, // quality
|
||||
Vec<u8>, // embedding (BYTEA)
|
||||
Option<String>, // blob_hash
|
||||
DateTime<Utc>, // created_at
|
||||
);
|
||||
|
||||
type PersonRow = (
|
||||
Uuid, // id
|
||||
Uuid, // user_id
|
||||
Option<String>, // display_name
|
||||
Option<Uuid>, // cover_face_id
|
||||
bool, // is_hidden
|
||||
DateTime<Utc>, // created_at
|
||||
);
|
||||
|
||||
fn embedding_to_bytes(e: &[f32]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(e.len() * 4);
|
||||
for v in e {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn bytes_to_embedding(b: &[u8]) -> Vec<f32> {
|
||||
b.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn row_to_face(r: FaceRow) -> Face {
|
||||
let (
|
||||
id,
|
||||
file_id,
|
||||
user_id,
|
||||
person_id,
|
||||
bbox,
|
||||
det_score,
|
||||
quality,
|
||||
embedding,
|
||||
blob_hash,
|
||||
created_at,
|
||||
) = r;
|
||||
Face {
|
||||
id,
|
||||
file_id,
|
||||
user_id,
|
||||
person_id,
|
||||
bbox: BoundingBox::from_slice(&bbox),
|
||||
det_score,
|
||||
quality,
|
||||
embedding: bytes_to_embedding(&embedding),
|
||||
blob_hash,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_person(r: PersonRow) -> Person {
|
||||
let (id, user_id, display_name, cover_face_id, is_hidden, created_at) = r;
|
||||
Person {
|
||||
id,
|
||||
user_id,
|
||||
display_name,
|
||||
cover_face_id,
|
||||
is_hidden,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn db_err(ctx: &'static str, e: sqlx::Error) -> DomainError {
|
||||
DomainError::internal_error("FacePg", format!("{ctx}: {e}"))
|
||||
}
|
||||
|
||||
const FACE_COLS: &str =
|
||||
"id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash, created_at";
|
||||
const PERSON_COLS: &str = "id, user_id, display_name, cover_face_id, is_hidden, created_at";
|
||||
|
||||
pub struct FacePgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl FacePgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FaceRepository for FacePgRepository {
|
||||
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError> {
|
||||
if faces.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?;
|
||||
for f in faces {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO faces.faces
|
||||
(id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
"#,
|
||||
)
|
||||
.bind(f.id)
|
||||
.bind(f.file_id)
|
||||
.bind(f.user_id)
|
||||
.bind(f.person_id)
|
||||
.bind(f.bbox.to_array())
|
||||
.bind(f.det_score)
|
||||
.bind(f.quality)
|
||||
.bind(embedding_to_bytes(&f.embedding))
|
||||
.bind(f.blob_hash.as_deref())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_err("save_faces", e))?;
|
||||
}
|
||||
tx.commit().await.map_err(|e| db_err("commit", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError> {
|
||||
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE file_id = $1");
|
||||
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
|
||||
.bind(file_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("faces_for_file", e))?;
|
||||
Ok(rows.into_iter().map(row_to_face).collect())
|
||||
}
|
||||
|
||||
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM faces.faces WHERE file_id = $1")
|
||||
.bind(file_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("delete_faces_for_file", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError> {
|
||||
let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE user_id = $1");
|
||||
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("faces_for_user", e))?;
|
||||
Ok(rows.into_iter().map(row_to_face).collect())
|
||||
}
|
||||
|
||||
async fn faces_for_blob(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
blob_hash: &str,
|
||||
) -> Result<Vec<Face>, DomainError> {
|
||||
let sql =
|
||||
format!("SELECT {FACE_COLS} FROM faces.faces WHERE user_id = $1 AND blob_hash = $2");
|
||||
let rows: Vec<FaceRow> = sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.bind(blob_hash)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("faces_for_blob", e))?;
|
||||
Ok(rows.into_iter().map(row_to_face).collect())
|
||||
}
|
||||
|
||||
async fn assign_person(
|
||||
&self,
|
||||
face_id: Uuid,
|
||||
person_id: Option<Uuid>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE faces.faces SET person_id = $2 WHERE id = $1")
|
||||
.bind(face_id)
|
||||
.bind(person_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("assign_person", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_person(&self, person: &Person) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO faces.persons (id, user_id, display_name, cover_face_id, is_hidden)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
)
|
||||
.bind(person.id)
|
||||
.bind(person.user_id)
|
||||
.bind(person.display_name.as_deref())
|
||||
.bind(person.cover_face_id)
|
||||
.bind(person.is_hidden)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("create_person", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError> {
|
||||
let sql = format!(
|
||||
"SELECT {PERSON_COLS} FROM faces.persons WHERE user_id = $1 ORDER BY created_at"
|
||||
);
|
||||
let rows: Vec<PersonRow> = sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("persons_for_user", e))?;
|
||||
Ok(rows.into_iter().map(row_to_person).collect())
|
||||
}
|
||||
|
||||
async fn rename_person(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
person_id: Uuid,
|
||||
name: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE faces.persons SET display_name = $3, updated_at = now() WHERE id = $2 AND user_id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(person_id)
|
||||
.bind(name)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("rename_person", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_person_cover(
|
||||
&self,
|
||||
person_id: Uuid,
|
||||
cover_face_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE faces.persons SET cover_face_id = $2, updated_at = now() WHERE id = $1",
|
||||
)
|
||||
.bind(person_id)
|
||||
.bind(cover_face_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("set_person_cover", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_person_hidden(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
person_id: Uuid,
|
||||
hidden: bool,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE faces.persons SET is_hidden = $3, updated_at = now() WHERE id = $2 AND user_id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(person_id)
|
||||
.bind(hidden)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("set_person_hidden", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn files_for_person(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
person_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, DomainError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT file_id
|
||||
FROM faces.faces
|
||||
WHERE user_id = $1 AND person_id = $2
|
||||
GROUP BY file_id
|
||||
ORDER BY max(created_at) DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(person_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| db_err("files_for_person", e))?;
|
||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
|
||||
async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?;
|
||||
sqlx::query("DELETE FROM faces.faces WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_err("delete_all_faces", e))?;
|
||||
sqlx::query("DELETE FROM faces.persons WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| db_err("delete_all_persons", e))?;
|
||||
tx.commit().await.map_err(|e| db_err("commit", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type MediaFileRow = (
|
||||
String, // blob_hash
|
||||
Option<Uuid>, // user_id
|
||||
i64, // sort_date
|
||||
Option<i32>, // width
|
||||
Option<i32>, // height
|
||||
);
|
||||
|
||||
use bytes::Bytes;
|
||||
@@ -30,6 +32,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster};
|
||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -416,7 +419,7 @@ impl FileBlobReadRepository {
|
||||
owner_id: Uuid,
|
||||
before: Option<i64>,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<File>, Vec<i64>), DomainError> {
|
||||
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
|
||||
let rows: Vec<MediaFileRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
@@ -425,9 +428,11 @@ impl FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date
|
||||
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
|
||||
fm.width, fm.height
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
|
||||
WHERE fi.user_id = $1
|
||||
AND NOT fi.is_trashed
|
||||
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
||||
@@ -446,15 +451,67 @@ impl FileBlobReadRepository {
|
||||
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
let mut sort_dates = Vec::with_capacity(rows.len());
|
||||
let mut dims = Vec::with_capacity(rows.len());
|
||||
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows {
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows {
|
||||
files.push(Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
|
||||
)?);
|
||||
sort_dates.push(sd);
|
||||
dims.push((w, h));
|
||||
}
|
||||
|
||||
Ok((files, sort_dates))
|
||||
Ok((files, sort_dates, dims))
|
||||
}
|
||||
|
||||
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
|
||||
/// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`.
|
||||
/// Returns one cluster per non-empty cell with its centroid, photo count
|
||||
/// and a representative photo id (for the cluster thumbnail).
|
||||
pub async fn list_geo_clusters(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
bounds: GeoBounds,
|
||||
cell: f64,
|
||||
) -> Result<Vec<GeoCluster>, DomainError> {
|
||||
let rows: Vec<(i64, f64, f64, String)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT count(*) AS n,
|
||||
avg(fm.longitude) AS clng,
|
||||
avg(fm.latitude) AS clat,
|
||||
min(fm.file_id::text) AS sample_id
|
||||
FROM storage.file_metadata fm
|
||||
JOIN storage.files fi ON fi.id = fm.file_id
|
||||
WHERE fi.user_id = $1
|
||||
AND NOT fi.is_trashed
|
||||
AND fm.latitude IS NOT NULL
|
||||
AND fm.longitude IS NOT NULL
|
||||
AND fm.longitude BETWEEN $2 AND $3
|
||||
AND fm.latitude BETWEEN $4 AND $5
|
||||
GROUP BY round(fm.longitude / $6), round(fm.latitude / $6)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(bounds.west)
|
||||
.bind(bounds.east)
|
||||
.bind(bounds.south)
|
||||
.bind(bounds.north)
|
||||
.bind(cell)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("list_geo_clusters: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(n, clng, clat, sample_id)| GeoCluster {
|
||||
lng: clng,
|
||||
lat: clat,
|
||||
count: n,
|
||||
sample_file_id: sample_id,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ mod contact_group_pg_repository;
|
||||
mod contact_persistence_dto;
|
||||
mod contact_pg_repository;
|
||||
mod device_code_pg_repository;
|
||||
mod face_pg_repository;
|
||||
mod favorites_pg_repository;
|
||||
pub mod file_metadata_repository;
|
||||
mod magic_link_token_pg_repository;
|
||||
@@ -33,6 +34,7 @@ pub use contact_group_pg_repository::ContactGroupPgRepository;
|
||||
pub use contact_persistence_dto::*;
|
||||
pub use contact_pg_repository::ContactPgRepository;
|
||||
pub use device_code_pg_repository::DeviceCodePgRepository;
|
||||
pub use face_pg_repository::FacePgRepository;
|
||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Pure geometry + post-processing for the ONNX face pipeline.
|
||||
//!
|
||||
//! Everything here is plain Rust (no `ort`, no `ndarray`) so it compiles in the
|
||||
//! default build and is exercised by `cargo test` — the error-prone numerical
|
||||
//! parts (SCRFD anchor decode, NMS, 5-point similarity alignment, the affine
|
||||
//! warp, normalization) are unit-tested in isolation, while the untestable ONNX
|
||||
//! session calls live behind the `faces-onnx` feature in `onnx_face_analyzer`.
|
||||
//!
|
||||
//! The pipeline mirrors InsightFace's reference implementation:
|
||||
//! SCRFD detector (distance-to-box anchors over strides 8/16/32) → 5-point
|
||||
//! similarity transform onto the canonical 112×112 ArcFace template → ArcFace
|
||||
//! embedder → L2-normalized 512-d vector.
|
||||
|
||||
use image::RgbImage;
|
||||
|
||||
/// One detected face in **detector-input pixel** coordinates (before scaling
|
||||
/// back to the original image): an axis-aligned box `[x1, y1, x2, y2]`, the
|
||||
/// five facial landmarks, and the detector confidence.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Detection {
|
||||
pub bbox: [f32; 4],
|
||||
pub kps: [[f32; 2]; 5],
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
/// A 2×3 affine transform mapping an output/template coordinate to a source
|
||||
/// coordinate: `src = (a·ox + b·oy + tx, c·ox + d·oy + ty)`. Used to sample the
|
||||
/// source image when warping an aligned face crop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Affine {
|
||||
pub a: f32,
|
||||
pub b: f32,
|
||||
pub c: f32,
|
||||
pub d: f32,
|
||||
pub tx: f32,
|
||||
pub ty: f32,
|
||||
}
|
||||
|
||||
/// Canonical ArcFace 5-point template for a 112×112 crop
|
||||
/// (left eye, right eye, nose, left mouth, right mouth).
|
||||
pub const ARCFACE_TEMPLATE: [[f32; 2]; 5] = [
|
||||
[38.2946, 51.6963],
|
||||
[73.5318, 51.5014],
|
||||
[56.0252, 71.7366],
|
||||
[41.5493, 92.3655],
|
||||
[70.7299, 92.2041],
|
||||
];
|
||||
|
||||
/// Aligned-crop side length expected by the ArcFace embedder.
|
||||
pub const ALIGN_SIZE: u32 = 112;
|
||||
|
||||
/// Letterbox geometry for the detector: the largest scale that fits a
|
||||
/// `w0 × h0` image into a `det × det` square without distortion, plus the
|
||||
/// resulting (possibly smaller) dimensions placed at the top-left.
|
||||
///
|
||||
/// Returns `(new_w, new_h, scale)` where `scale = min(det/w0, det/h0)` and
|
||||
/// detector-space coordinates map back to the original by dividing by `scale`.
|
||||
pub fn letterbox(w0: u32, h0: u32, det: u32) -> (u32, u32, f32) {
|
||||
if w0 == 0 || h0 == 0 {
|
||||
return (0, 0, 1.0);
|
||||
}
|
||||
let scale = (det as f32 / w0 as f32).min(det as f32 / h0 as f32);
|
||||
let new_w = ((w0 as f32 * scale).round() as u32).clamp(1, det);
|
||||
let new_h = ((h0 as f32 * scale).round() as u32).clamp(1, det);
|
||||
(new_w, new_h, scale)
|
||||
}
|
||||
|
||||
/// `NCHW`, RGB, float input tensor for an ONNX model: `(px − mean) · scale`,
|
||||
/// channel-major (all R, then all G, then all B). Length is `3 · w · h`.
|
||||
pub fn chw_normalized(img: &RgbImage, mean: f32, scale: f32) -> Vec<f32> {
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
let mut out = vec![0.0f32; 3 * w * h];
|
||||
let plane = w * h;
|
||||
for (i, px) in img.pixels().enumerate() {
|
||||
out[i] = (px[0] as f32 - mean) * scale;
|
||||
out[plane + i] = (px[1] as f32 - mean) * scale;
|
||||
out[2 * plane + i] = (px[2] as f32 - mean) * scale;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode one SCRFD feature-map stride into detections, appending those above
|
||||
/// `threshold` to `out`. All coordinates are in detector-input pixels.
|
||||
///
|
||||
/// `scores` is `[n]`, `bbox` is `[n·4]` (left, top, right, bottom *distances*,
|
||||
/// already multiplied by `stride`), `kps` (when present) is `[n·10]`
|
||||
/// (5 × (dx, dy) distances, already multiplied by `stride`), where
|
||||
/// `n = feat_h · feat_w · num_anchors`. Anchor centers follow InsightFace's
|
||||
/// row-major `mgrid` order with `num_anchors` consecutive duplicates.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn decode_stride(
|
||||
scores: &[f32],
|
||||
bbox: &[f32],
|
||||
kps: Option<&[f32]>,
|
||||
stride: u32,
|
||||
feat_h: u32,
|
||||
feat_w: u32,
|
||||
num_anchors: u32,
|
||||
threshold: f32,
|
||||
out: &mut Vec<Detection>,
|
||||
) {
|
||||
let stride_f = stride as f32;
|
||||
let mut idx = 0usize;
|
||||
for y in 0..feat_h {
|
||||
for x in 0..feat_w {
|
||||
let cx = x as f32 * stride_f;
|
||||
let cy = y as f32 * stride_f;
|
||||
for _ in 0..num_anchors {
|
||||
if idx >= scores.len() {
|
||||
return;
|
||||
}
|
||||
let score = scores[idx];
|
||||
if score >= threshold {
|
||||
let b = idx * 4;
|
||||
if b + 3 < bbox.len() {
|
||||
let det_bbox = [
|
||||
cx - bbox[b],
|
||||
cy - bbox[b + 1],
|
||||
cx + bbox[b + 2],
|
||||
cy + bbox[b + 3],
|
||||
];
|
||||
let mut det_kps = [[0.0f32; 2]; 5];
|
||||
if let Some(kps) = kps {
|
||||
let k = idx * 10;
|
||||
if k + 9 < kps.len() {
|
||||
for (p, slot) in det_kps.iter_mut().enumerate() {
|
||||
*slot = [cx + kps[k + p * 2], cy + kps[k + p * 2 + 1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(Detection {
|
||||
bbox: det_bbox,
|
||||
kps: det_kps,
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersection-over-union of two `[x1, y1, x2, y2]` boxes.
|
||||
pub fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
|
||||
let x1 = a[0].max(b[0]);
|
||||
let y1 = a[1].max(b[1]);
|
||||
let x2 = a[2].min(b[2]);
|
||||
let y2 = a[3].min(b[3]);
|
||||
let iw = (x2 - x1).max(0.0);
|
||||
let ih = (y2 - y1).max(0.0);
|
||||
let inter = iw * ih;
|
||||
let area_a = (a[2] - a[0]).max(0.0) * (a[3] - a[1]).max(0.0);
|
||||
let area_b = (b[2] - b[0]).max(0.0) * (b[3] - b[1]).max(0.0);
|
||||
let union = area_a + area_b - inter;
|
||||
if union <= 0.0 { 0.0 } else { inter / union }
|
||||
}
|
||||
|
||||
/// Greedy non-maximum suppression: keep highest-scoring boxes, drop any whose
|
||||
/// IoU with an already-kept box exceeds `iou_thresh`. Returns the kept
|
||||
/// detections, highest score first.
|
||||
pub fn nms(mut dets: Vec<Detection>, iou_thresh: f32) -> Vec<Detection> {
|
||||
dets.sort_by(|a, b| b.score.total_cmp(&a.score));
|
||||
let mut keep: Vec<Detection> = Vec::with_capacity(dets.len());
|
||||
for d in dets {
|
||||
if keep.iter().all(|k| iou(&k.bbox, &d.bbox) <= iou_thresh) {
|
||||
keep.push(d);
|
||||
}
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
/// Least-squares similarity transform (scale + rotation + translation, no
|
||||
/// shear, no reflection) mapping `src` landmarks onto `dst`, returned as its
|
||||
/// **inverse** affine (output/template coordinate → source coordinate) ready
|
||||
/// for backward-warp sampling.
|
||||
///
|
||||
/// Solved in closed form via the complex-number formulation: with points as
|
||||
/// complex numbers, `w = Σ (b'ᵢ · conj(a'ᵢ)) / Σ |a'ᵢ|²` and `t = mean_b −
|
||||
/// w·mean_a`, which is equivalent to the Umeyama solution InsightFace obtains
|
||||
/// from `skimage.SimilarityTransform`.
|
||||
pub fn similarity_transform_inverse(src: &[[f32; 2]; 5], dst: &[[f32; 2]; 5]) -> Affine {
|
||||
let n = 5.0f32;
|
||||
let (mut max, mut may, mut mbx, mut mby) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
|
||||
for i in 0..5 {
|
||||
max += src[i][0];
|
||||
may += src[i][1];
|
||||
mbx += dst[i][0];
|
||||
mby += dst[i][1];
|
||||
}
|
||||
max /= n;
|
||||
may /= n;
|
||||
mbx /= n;
|
||||
mby /= n;
|
||||
|
||||
// num = Σ b'·conj(a') (complex), den = Σ |a'|² (real)
|
||||
let (mut num_re, mut num_im, mut den) = (0.0f32, 0.0f32, 0.0f32);
|
||||
for i in 0..5 {
|
||||
let ax = src[i][0] - max;
|
||||
let ay = src[i][1] - may;
|
||||
let bx = dst[i][0] - mbx;
|
||||
let by = dst[i][1] - mby;
|
||||
// b' · conj(a') = (bx + i·by)(ax − i·ay)
|
||||
num_re += bx * ax + by * ay;
|
||||
num_im += by * ax - bx * ay;
|
||||
den += ax * ax + ay * ay;
|
||||
}
|
||||
let den = if den.abs() < 1e-12 { 1e-12 } else { den };
|
||||
// w = num/den (forward scale·rotation)
|
||||
let wr = num_re / den;
|
||||
let wi = num_im / den;
|
||||
// t = mean_b − w·mean_a
|
||||
let tr = mbx - (wr * max - wi * may);
|
||||
let ti = mby - (wi * max + wr * may);
|
||||
|
||||
// Inverse of the similarity: src = Ainv·(out − t), Ainv = [[wr,wi],[−wi,wr]]/|w|²
|
||||
let det = wr * wr + wi * wi;
|
||||
let g = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
|
||||
Affine {
|
||||
a: g * wr,
|
||||
b: g * wi,
|
||||
c: -g * wi,
|
||||
d: g * wr,
|
||||
tx: -g * (wr * tr + wi * ti),
|
||||
ty: g * (wi * tr - wr * ti),
|
||||
}
|
||||
}
|
||||
|
||||
/// Warp `img` into an `ALIGN_SIZE × ALIGN_SIZE` aligned face crop using the
|
||||
/// inverse affine from [`similarity_transform_inverse`], sampling bilinearly
|
||||
/// and clamping to the image edge.
|
||||
pub fn warp_to_aligned(img: &RgbImage, inv: &Affine) -> RgbImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let mut out = RgbImage::new(ALIGN_SIZE, ALIGN_SIZE);
|
||||
for oy in 0..ALIGN_SIZE {
|
||||
for ox in 0..ALIGN_SIZE {
|
||||
let sx = inv.a * ox as f32 + inv.b * oy as f32 + inv.tx;
|
||||
let sy = inv.c * ox as f32 + inv.d * oy as f32 + inv.ty;
|
||||
let px = bilinear_sample(img, sx, sy, w, h);
|
||||
out.put_pixel(ox, oy, px);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bilinear RGB sample at floating `(x, y)`, clamping out-of-bounds reads to
|
||||
/// the nearest edge.
|
||||
fn bilinear_sample(img: &RgbImage, x: f32, y: f32, w: u32, h: u32) -> image::Rgb<u8> {
|
||||
let x = x.clamp(0.0, (w - 1) as f32);
|
||||
let y = y.clamp(0.0, (h - 1) as f32);
|
||||
let x0 = x.floor() as u32;
|
||||
let y0 = y.floor() as u32;
|
||||
let x1 = (x0 + 1).min(w - 1);
|
||||
let y1 = (y0 + 1).min(h - 1);
|
||||
let dx = x - x0 as f32;
|
||||
let dy = y - y0 as f32;
|
||||
let p00 = img.get_pixel(x0, y0);
|
||||
let p10 = img.get_pixel(x1, y0);
|
||||
let p01 = img.get_pixel(x0, y1);
|
||||
let p11 = img.get_pixel(x1, y1);
|
||||
let mut out = [0u8; 3];
|
||||
for (ch, slot) in out.iter_mut().enumerate() {
|
||||
let top = p00[ch] as f32 * (1.0 - dx) + p10[ch] as f32 * dx;
|
||||
let bot = p01[ch] as f32 * (1.0 - dx) + p11[ch] as f32 * dx;
|
||||
*slot = (top * (1.0 - dy) + bot * dy).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
image::Rgb(out)
|
||||
}
|
||||
|
||||
/// In-place L2 normalization. A zero vector is left unchanged.
|
||||
pub fn l2_normalize(v: &mut [f32]) {
|
||||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-12 {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Variance of the discrete Laplacian over the luminance of an RGB crop — a
|
||||
/// cheap focus/sharpness proxy (higher = sharper). Used as a face quality
|
||||
/// score for cover selection and gating.
|
||||
pub fn laplacian_variance(img: &RgbImage) -> f32 {
|
||||
let (w, h) = (img.width() as i64, img.height() as i64);
|
||||
if w < 3 || h < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let lum = |x: i64, y: i64| -> f32 {
|
||||
let p = img.get_pixel(x as u32, y as u32);
|
||||
0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
|
||||
};
|
||||
let mut vals = Vec::with_capacity(((w - 2) * (h - 2)) as usize);
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let l = 4.0 * lum(x, y) - lum(x - 1, y) - lum(x + 1, y) - lum(x, y - 1) - lum(x, y + 1);
|
||||
vals.push(l);
|
||||
}
|
||||
}
|
||||
let n = vals.len() as f32;
|
||||
if n == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = vals.iter().sum::<f32>() / n;
|
||||
vals.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn letterbox_fits_and_preserves_aspect() {
|
||||
// Landscape 1000×500 into 640 → width-bound, scale 0.64.
|
||||
let (nw, nh, s) = letterbox(1000, 500, 640);
|
||||
assert_eq!(nw, 640);
|
||||
assert_eq!(nh, 320);
|
||||
assert!((s - 0.64).abs() < 1e-6);
|
||||
// Square fills exactly.
|
||||
let (nw, nh, s) = letterbox(800, 800, 640);
|
||||
assert_eq!((nw, nh), (640, 640));
|
||||
assert!((s - 0.8).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letterbox_degenerate_is_safe() {
|
||||
assert_eq!(letterbox(0, 10, 640), (0, 0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chw_layout_and_normalization() {
|
||||
let mut img = RgbImage::new(2, 1);
|
||||
img.put_pixel(0, 0, image::Rgb([127, 0, 255]));
|
||||
img.put_pixel(1, 0, image::Rgb([128, 255, 0]));
|
||||
let t = chw_normalized(&img, 127.5, 1.0 / 128.0);
|
||||
// Length = 3 channels × 2 px.
|
||||
assert_eq!(t.len(), 6);
|
||||
// R plane first, then G, then B (NCHW).
|
||||
assert!((t[0] - (127.0 - 127.5) / 128.0).abs() < 1e-6);
|
||||
assert!((t[1] - (128.0 - 127.5) / 128.0).abs() < 1e-6);
|
||||
assert!((t[2] - (0.0 - 127.5) / 128.0).abs() < 1e-6); // G of px0
|
||||
assert!((t[4] - (255.0 - 127.5) / 128.0).abs() < 1e-6); // B of px0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_decode_recovers_box_and_kps() {
|
||||
// 1×2 grid, stride 8, 1 anchor → cell centers (0,0) then (8,0).
|
||||
let scores = [0.9f32, 0.9];
|
||||
// distances left/top/right/bottom (already × stride), identical per cell.
|
||||
let bbox = [2.0, 1.0, 3.0, 4.0, 2.0, 1.0, 3.0, 4.0];
|
||||
let kps: Vec<f32> = vec![
|
||||
1.0, 1.0, 2.0, 2.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, // cell 0
|
||||
1.0, 1.0, 2.0, 2.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, // cell 1
|
||||
];
|
||||
let mut out = Vec::new();
|
||||
decode_stride(&scores, &bbox, Some(&kps), 8, 1, 2, 1, 0.5, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
// Cell 0, center (0,0): box = center ± distances, kps = center + offset.
|
||||
assert_eq!(out[0].bbox, [-2.0, -1.0, 3.0, 4.0]);
|
||||
assert_eq!(out[0].kps[0], [1.0, 1.0]);
|
||||
assert_eq!(out[0].kps[1], [2.0, 2.0]);
|
||||
// Cell 1, center (8,0): anchor center advanced by one stride in x.
|
||||
assert_eq!(out[1].bbox, [8.0 - 2.0, -1.0, 8.0 + 3.0, 4.0]);
|
||||
assert_eq!(out[1].kps[0], [9.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_thresholds_out_low_scores() {
|
||||
let scores = [0.2f32, 0.8];
|
||||
let bbox = [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
|
||||
let mut out = Vec::new();
|
||||
// 1×2 grid, 1 anchor → two cells.
|
||||
decode_stride(&scores, &bbox, None, 8, 1, 2, 1, 0.5, &mut out);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!((out[0].score - 0.8).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iou_and_nms() {
|
||||
let a = [0.0, 0.0, 10.0, 10.0];
|
||||
let b = [0.0, 0.0, 10.0, 10.0];
|
||||
assert!((iou(&a, &b) - 1.0).abs() < 1e-6);
|
||||
let c = [100.0, 100.0, 110.0, 110.0];
|
||||
assert_eq!(iou(&a, &c), 0.0);
|
||||
|
||||
let dets = vec![
|
||||
Detection {
|
||||
bbox: a,
|
||||
kps: [[0.0; 2]; 5],
|
||||
score: 0.9,
|
||||
},
|
||||
Detection {
|
||||
bbox: b,
|
||||
kps: [[0.0; 2]; 5],
|
||||
score: 0.8,
|
||||
}, // dup of a
|
||||
Detection {
|
||||
bbox: c,
|
||||
kps: [[0.0; 2]; 5],
|
||||
score: 0.7,
|
||||
}, // separate
|
||||
];
|
||||
let kept = nms(dets, 0.4);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert!((kept[0].score - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_identity() {
|
||||
let inv = similarity_transform_inverse(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE);
|
||||
assert!((inv.a - 1.0).abs() < 1e-4);
|
||||
assert!(inv.b.abs() < 1e-4);
|
||||
assert!(inv.c.abs() < 1e-4);
|
||||
assert!((inv.d - 1.0).abs() < 1e-4);
|
||||
assert!(inv.tx.abs() < 1e-3);
|
||||
assert!(inv.ty.abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_pure_translation() {
|
||||
// src = dst shifted by (+10, +5); inverse must map out→src by the same shift.
|
||||
let mut src = ARCFACE_TEMPLATE;
|
||||
for p in &mut src {
|
||||
p[0] += 10.0;
|
||||
p[1] += 5.0;
|
||||
}
|
||||
let inv = similarity_transform_inverse(&src, &ARCFACE_TEMPLATE);
|
||||
assert!((inv.a - 1.0).abs() < 1e-4);
|
||||
assert!(inv.b.abs() < 1e-4);
|
||||
assert!((inv.tx - 10.0).abs() < 1e-3);
|
||||
assert!((inv.ty - 5.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_identity_preserves_template_region() {
|
||||
// A 112×112 gradient warped by identity returns (close to) itself.
|
||||
let mut img = RgbImage::new(ALIGN_SIZE, ALIGN_SIZE);
|
||||
for y in 0..ALIGN_SIZE {
|
||||
for x in 0..ALIGN_SIZE {
|
||||
img.put_pixel(x, y, image::Rgb([x as u8, y as u8, 128]));
|
||||
}
|
||||
}
|
||||
let inv = similarity_transform_inverse(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE);
|
||||
let out = warp_to_aligned(&img, &inv);
|
||||
let a = out.get_pixel(40, 60);
|
||||
assert!((a[0] as i32 - 40).abs() <= 1);
|
||||
assert!((a[1] as i32 - 60).abs() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l2_normalize_unit_length() {
|
||||
let mut v = vec![3.0f32, 4.0];
|
||||
l2_normalize(&mut v);
|
||||
assert!((v[0] - 0.6).abs() < 1e-6);
|
||||
assert!((v[1] - 0.8).abs() < 1e-6);
|
||||
let mut z = vec![0.0f32, 0.0];
|
||||
l2_normalize(&mut z); // unchanged, no NaN
|
||||
assert_eq!(z, vec![0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laplacian_variance_sharp_vs_flat() {
|
||||
let flat = RgbImage::from_pixel(8, 8, image::Rgb([100, 100, 100]));
|
||||
assert!(laplacian_variance(&flat) < 1e-3);
|
||||
let mut checker = RgbImage::new(8, 8);
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
let v = if (x + y) % 2 == 0 { 0 } else { 255 };
|
||||
checker.put_pixel(x, y, image::Rgb([v, v, v]));
|
||||
}
|
||||
}
|
||||
assert!(laplacian_variance(&checker) > 1000.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Face indexing as a `FileLifecycleHook`.
|
||||
//!
|
||||
//! On image upload it detects + embeds faces (off the request path, in a
|
||||
//! background task) and stores them. It mirrors `MediaMetadataService`: reads
|
||||
//! the blob from the local `.blobs` tree, is dedup-aware (identical uploads
|
||||
//! clone an existing file's faces instead of re-running inference), and is
|
||||
//! completely inert when no model is configured (`FaceAnalyzerPort::is_ready()
|
||||
//! == false`) — so the feature compiles and runs with the default no-op
|
||||
//! analyzer until the operator wires a real ONNX model.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::face_ports::{FaceAnalyzerPort, FaceRepository};
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::Face;
|
||||
use crate::infrastructure::repositories::pg::FacePgRepository;
|
||||
|
||||
/// Minimum detector confidence for a face to be stored.
|
||||
const MIN_DET_SCORE: f32 = 0.6;
|
||||
|
||||
fn is_image(content_type: &str) -> bool {
|
||||
content_type.starts_with("image/")
|
||||
}
|
||||
|
||||
pub struct FaceIndexingService {
|
||||
pool: Arc<PgPool>,
|
||||
repo: Arc<FacePgRepository>,
|
||||
analyzer: Arc<dyn FaceAnalyzerPort>,
|
||||
blob_root: PathBuf,
|
||||
}
|
||||
|
||||
impl FaceIndexingService {
|
||||
pub fn new(pool: Arc<PgPool>, blob_root: PathBuf, analyzer: Arc<dyn FaceAnalyzerPort>) -> Self {
|
||||
let repo = Arc::new(FacePgRepository::new(pool.clone()));
|
||||
Self {
|
||||
pool,
|
||||
repo,
|
||||
analyzer,
|
||||
blob_root,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local path of a blob: `.blobs/{prefix}/{hash}.blob`.
|
||||
fn blob_path(&self, hash: &str) -> PathBuf {
|
||||
let prefix = if hash.len() >= 2 { &hash[0..2] } else { hash };
|
||||
self.blob_root.join(prefix).join(format!("{hash}.blob"))
|
||||
}
|
||||
|
||||
/// Spawn a background indexing task. `reuse_dedup` clones faces from an
|
||||
/// existing file with the same blob hash instead of re-running inference;
|
||||
/// `delete_first` clears prior faces (used on overwrite).
|
||||
fn spawn_index(&self, file_id: Uuid, blob_hash: String, reuse_dedup: bool, delete_first: bool) {
|
||||
let pool = self.pool.clone();
|
||||
let repo = self.repo.clone();
|
||||
let analyzer = self.analyzer.clone();
|
||||
let blob_path = self.blob_path(&blob_hash);
|
||||
tokio::spawn(async move {
|
||||
if delete_first {
|
||||
let _ = repo.delete_faces_for_file(file_id).await;
|
||||
}
|
||||
if let Err(e) = index_file(
|
||||
&pool,
|
||||
&repo,
|
||||
analyzer.as_ref(),
|
||||
file_id,
|
||||
&blob_path,
|
||||
&blob_hash,
|
||||
reuse_dedup,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(target: "oxicloud::faces", "face indexing failed for {file_id}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl FileLifecycleHook for FaceIndexingService {
|
||||
fn on_file_created(
|
||||
&self,
|
||||
file_id: &str,
|
||||
blob_hash: &str,
|
||||
content_type: &str,
|
||||
is_new_blob: bool,
|
||||
) {
|
||||
if !is_image(content_type) || !self.analyzer.is_ready() {
|
||||
return;
|
||||
}
|
||||
if let Ok(fid) = file_id.parse::<Uuid>() {
|
||||
// Dedup hit (blob already existed) → clone an existing file's faces.
|
||||
self.spawn_index(fid, blob_hash.to_string(), !is_new_blob, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_file_copied(
|
||||
&self,
|
||||
file_id: &str,
|
||||
blob_hash: &str,
|
||||
content_type: &str,
|
||||
_source_file_id: &str,
|
||||
) {
|
||||
if !is_image(content_type) || !self.analyzer.is_ready() {
|
||||
return;
|
||||
}
|
||||
if let Ok(fid) = file_id.parse::<Uuid>() {
|
||||
self.spawn_index(fid, blob_hash.to_string(), true, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_file_updated(&self, file_id: &str, blob_hash: &str, content_type: &str) {
|
||||
if !is_image(content_type) || !self.analyzer.is_ready() {
|
||||
return;
|
||||
}
|
||||
if let Ok(fid) = file_id.parse::<Uuid>() {
|
||||
self.spawn_index(fid, blob_hash.to_string(), false, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_file_deleted(&self, _file_id: &str) {
|
||||
// faces.faces.file_id has ON DELETE CASCADE — the DB cleans up.
|
||||
}
|
||||
}
|
||||
|
||||
async fn lookup_user(pool: &PgPool, file_id: Uuid) -> Result<Uuid, DomainError> {
|
||||
let row: (Uuid,) = sqlx::query_as("SELECT user_id FROM storage.files WHERE id = $1")
|
||||
.bind(file_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Faces", format!("lookup user: {e}")))?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
async fn index_file(
|
||||
pool: &PgPool,
|
||||
repo: &FacePgRepository,
|
||||
analyzer: &dyn FaceAnalyzerPort,
|
||||
file_id: Uuid,
|
||||
blob_path: &Path,
|
||||
blob_hash: &str,
|
||||
reuse_dedup: bool,
|
||||
) -> Result<(), DomainError> {
|
||||
let user_id = lookup_user(pool, file_id).await?;
|
||||
|
||||
// Dedup-aware fast path: reuse faces already computed for an identical blob.
|
||||
if reuse_dedup {
|
||||
let peers = repo.faces_for_blob(user_id, blob_hash).await?;
|
||||
let cloned: Vec<Face> = peers
|
||||
.into_iter()
|
||||
.filter(|f| f.file_id != file_id)
|
||||
.map(|f| Face {
|
||||
id: Uuid::new_v4(),
|
||||
file_id,
|
||||
..f
|
||||
})
|
||||
.collect();
|
||||
if !cloned.is_empty() {
|
||||
repo.save_faces(&cloned).await?;
|
||||
return Ok(());
|
||||
}
|
||||
// No peer found — fall through and analyze.
|
||||
}
|
||||
|
||||
let bytes = tokio::fs::read(blob_path)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Faces", format!("read blob: {e}")))?;
|
||||
let detected = analyzer.analyze(&bytes).await?;
|
||||
|
||||
let faces: Vec<Face> = detected
|
||||
.into_iter()
|
||||
.filter(|d| d.det_score >= MIN_DET_SCORE)
|
||||
.map(|d| Face {
|
||||
id: Uuid::new_v4(),
|
||||
file_id,
|
||||
user_id,
|
||||
person_id: None,
|
||||
bbox: d.bbox,
|
||||
det_score: d.det_score,
|
||||
quality: d.quality,
|
||||
embedding: d.embedding,
|
||||
blob_hash: Some(blob_hash.to_string()),
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.collect();
|
||||
repo.save_faces(&faces).await
|
||||
}
|
||||
@@ -6,6 +6,8 @@ pub mod compression_service;
|
||||
pub mod dedup_service;
|
||||
pub mod encrypted_blob_backend;
|
||||
pub mod exif_service;
|
||||
pub mod face_geometry;
|
||||
pub mod face_indexing_service;
|
||||
pub mod file_content_cache;
|
||||
pub mod file_system_i18n_service;
|
||||
pub mod image_transcode_service;
|
||||
@@ -17,7 +19,10 @@ pub mod migration_blob_backend;
|
||||
pub mod migration_job;
|
||||
pub mod mock_email_sender;
|
||||
pub mod nextcloud_chunked_upload_service;
|
||||
pub mod noop_face_analyzer;
|
||||
pub mod oidc_service;
|
||||
#[cfg(feature = "faces-onnx")]
|
||||
pub mod onnx_face_analyzer;
|
||||
pub mod password_hasher;
|
||||
pub mod path_resolver_service;
|
||||
pub mod path_service;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Default no-op face analyzer.
|
||||
//!
|
||||
//! Used when no ML model is configured: it reports `is_ready() == false` and
|
||||
//! returns no faces, so the whole People pipeline compiles and runs inert
|
||||
//! until a real ONNX-backed analyzer (provided by the operator) replaces it.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::application::ports::face_ports::FaceAnalyzerPort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::DetectedFace;
|
||||
|
||||
/// Analyzer that never detects anything.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopFaceAnalyzer;
|
||||
|
||||
#[async_trait]
|
||||
impl FaceAnalyzerPort for NoopFaceAnalyzer {
|
||||
fn is_ready(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn analyze(&self, _image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! ONNX-backed face analyzer (SCRFD detector + ArcFace embedder).
|
||||
//!
|
||||
//! Compiled only with the `faces-onnx` cargo feature. Mirrors the
|
||||
//! immich/InsightFace pipeline: detect faces + 5-point landmarks (SCRFD),
|
||||
//! similarity-align each face to the canonical 112×112 template, then embed
|
||||
//! (ArcFace) into an L2-normalized 512-d vector. All inference runs on a
|
||||
//! blocking thread (`spawn_blocking`) so it never stalls a Tokio worker, and
|
||||
//! each ONNX session is serialized behind a `Mutex` (ORT's `run` needs `&mut`).
|
||||
//!
|
||||
//! The heavy numerical post-processing lives in [`super::face_geometry`] (plain
|
||||
//! Rust, unit-tested); this module only wires it to ONNX Runtime.
|
||||
//!
|
||||
//! **Models are operator-provided at runtime, never committed.** `load` returns
|
||||
//! an error (→ caller falls back to the no-op analyzer) if the ONNX Runtime
|
||||
//! dylib or either model file is missing or incompatible — the server still
|
||||
//! boots. The dylib is loaded via [`ort::init_from`] (a fallible path) rather
|
||||
//! than ORT's lazy loader, which would `panic` on a missing library (fatal
|
||||
//! under `panic = "abort"`).
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use image::RgbImage;
|
||||
use ort::session::Session;
|
||||
use ort::value::Tensor;
|
||||
|
||||
use super::face_geometry as geom;
|
||||
use crate::application::ports::face_ports::FaceAnalyzerPort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::{BoundingBox, DetectedFace, EMBEDDING_DIM};
|
||||
|
||||
/// SCRFD pyramid strides for the 3- and 5-level model variants.
|
||||
const STRIDES_3: [u32; 3] = [8, 16, 32];
|
||||
const STRIDES_5: [u32; 5] = [8, 16, 32, 64, 128];
|
||||
|
||||
/// Discard faces smaller than this (original-image pixels) — embeddings of tiny
|
||||
/// faces are unreliable.
|
||||
const MIN_FACE_PX: f32 = 24.0;
|
||||
/// Hard cap on faces processed per image (bounds work on crowd shots).
|
||||
const MAX_FACES: usize = 64;
|
||||
|
||||
/// Output layout of an InsightFace SCRFD model, inferred from its output count.
|
||||
#[derive(Clone, Copy)]
|
||||
struct ScrfdLayout {
|
||||
/// Feature-map count per output kind (3 for strides 8/16/32, 5 with 64/128).
|
||||
fmc: usize,
|
||||
num_anchors: u32,
|
||||
use_kps: bool,
|
||||
}
|
||||
|
||||
impl ScrfdLayout {
|
||||
fn from_num_outputs(n: usize) -> Option<Self> {
|
||||
match n {
|
||||
6 => Some(Self {
|
||||
fmc: 3,
|
||||
num_anchors: 2,
|
||||
use_kps: false,
|
||||
}),
|
||||
9 => Some(Self {
|
||||
fmc: 3,
|
||||
num_anchors: 2,
|
||||
use_kps: true,
|
||||
}),
|
||||
10 => Some(Self {
|
||||
fmc: 5,
|
||||
num_anchors: 1,
|
||||
use_kps: false,
|
||||
}),
|
||||
15 => Some(Self {
|
||||
fmc: 5,
|
||||
num_anchors: 1,
|
||||
use_kps: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn strides(&self) -> &'static [u32] {
|
||||
if self.fmc == 3 {
|
||||
&STRIDES_3
|
||||
} else {
|
||||
&STRIDES_5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to find the runtime + models, plus detector knobs. Borrowed paths;
|
||||
/// nothing is retained after [`OnnxFaceAnalyzer::load`].
|
||||
pub struct OnnxLoadConfig<'a> {
|
||||
/// Path to `libonnxruntime.{so,dylib,dll}`.
|
||||
pub dylib: &'a Path,
|
||||
/// SCRFD detector `.onnx`.
|
||||
pub detector: &'a Path,
|
||||
/// ArcFace embedder `.onnx`.
|
||||
pub embedder: &'a Path,
|
||||
pub det_size: u32,
|
||||
pub det_threshold: f32,
|
||||
pub nms_threshold: f32,
|
||||
/// ORT intra-op threads (0 = let ONNX Runtime decide).
|
||||
pub intra_threads: usize,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
detector: Mutex<Session>,
|
||||
embedder: Mutex<Session>,
|
||||
layout: ScrfdLayout,
|
||||
det_size: u32,
|
||||
det_threshold: f32,
|
||||
nms_threshold: f32,
|
||||
}
|
||||
|
||||
/// Real face analyzer. Cheap to clone (`Arc` inside).
|
||||
#[derive(Clone)]
|
||||
pub struct OnnxFaceAnalyzer {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
fn dom(e: impl std::fmt::Display) -> DomainError {
|
||||
DomainError::internal_error("Faces", e.to_string())
|
||||
}
|
||||
|
||||
fn build_session(path: &Path, intra_threads: usize) -> Result<Session, DomainError> {
|
||||
let mut builder = Session::builder().map_err(dom)?;
|
||||
if intra_threads > 0 {
|
||||
builder = builder.with_intra_threads(intra_threads).map_err(dom)?;
|
||||
}
|
||||
builder.commit_from_file(path).map_err(dom)
|
||||
}
|
||||
|
||||
impl OnnxFaceAnalyzer {
|
||||
/// Load the ONNX Runtime dylib and both models. Returns an error (caller
|
||||
/// falls back to the no-op analyzer) on any missing/incompatible artifact.
|
||||
pub fn load(cfg: &OnnxLoadConfig<'_>) -> Result<Self, DomainError> {
|
||||
// Fallible dylib load — populates ORT's global handle so later calls
|
||||
// never hit the panicking lazy loader.
|
||||
ort::init_from(cfg.dylib)
|
||||
.map_err(|e| dom(format!("ONNX Runtime dylib: {e}")))?
|
||||
.commit();
|
||||
|
||||
let detector = build_session(cfg.detector, cfg.intra_threads)?;
|
||||
let embedder = build_session(cfg.embedder, cfg.intra_threads)?;
|
||||
|
||||
let n_out = detector.outputs().len();
|
||||
let layout = ScrfdLayout::from_num_outputs(n_out).ok_or_else(|| {
|
||||
dom(format!(
|
||||
"detector has {n_out} outputs; expected an SCRFD model (6/9/10/15)"
|
||||
))
|
||||
})?;
|
||||
if !layout.use_kps {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::faces",
|
||||
"SCRFD model has no landmark outputs; face alignment will be approximate"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "oxicloud::faces",
|
||||
"ONNX face analyzer ready (detector {} outputs, embedder loaded, det_size={})",
|
||||
n_out, cfg.det_size
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(Inner {
|
||||
detector: Mutex::new(detector),
|
||||
embedder: Mutex::new(embedder),
|
||||
layout,
|
||||
det_size: cfg.det_size,
|
||||
det_threshold: cfg.det_threshold,
|
||||
nms_threshold: cfg.nms_threshold,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
/// Full synchronous pipeline for one encoded image.
|
||||
fn analyze_blocking(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
|
||||
let orig = image::load_from_memory(image_bytes)
|
||||
.map_err(|e| dom(format!("decode image: {e}")))?
|
||||
.to_rgb8();
|
||||
let (w0, h0) = (orig.width(), orig.height());
|
||||
if w0 == 0 || h0 == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let dets = self.detect(&orig)?;
|
||||
|
||||
let mut faces = Vec::new();
|
||||
for det in dets.into_iter().take(MAX_FACES) {
|
||||
let fw = det.bbox[2] - det.bbox[0];
|
||||
let fh = det.bbox[3] - det.bbox[1];
|
||||
if fw < MIN_FACE_PX || fh < MIN_FACE_PX {
|
||||
continue;
|
||||
}
|
||||
let Some(embedding) = self.embed(&orig, &det)? else {
|
||||
continue;
|
||||
};
|
||||
let aligned_quality = {
|
||||
let inv = geom::similarity_transform_inverse(&det.kps, &geom::ARCFACE_TEMPLATE);
|
||||
let aligned = geom::warp_to_aligned(&orig, &inv);
|
||||
geom::laplacian_variance(&aligned)
|
||||
};
|
||||
let x = (det.bbox[0] / w0 as f32).clamp(0.0, 1.0);
|
||||
let y = (det.bbox[1] / h0 as f32).clamp(0.0, 1.0);
|
||||
let bw = (fw / w0 as f32).clamp(0.0, 1.0);
|
||||
let bh = (fh / h0 as f32).clamp(0.0, 1.0);
|
||||
faces.push(DetectedFace {
|
||||
bbox: BoundingBox { x, y, w: bw, h: bh },
|
||||
det_score: det.score,
|
||||
quality: Some(aligned_quality),
|
||||
embedding,
|
||||
});
|
||||
}
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
/// Run SCRFD and return detections in **original-image pixels**.
|
||||
fn detect(&self, orig: &RgbImage) -> Result<Vec<geom::Detection>, DomainError> {
|
||||
let det = self.det_size;
|
||||
let (nw, nh, scale) = geom::letterbox(orig.width(), orig.height(), det);
|
||||
let resized = image::imageops::resize(orig, nw, nh, image::imageops::FilterType::Triangle);
|
||||
let mut canvas = RgbImage::new(det, det);
|
||||
image::imageops::overlay(&mut canvas, &resized, 0, 0);
|
||||
let input = geom::chw_normalized(&canvas, 127.5, 1.0 / 128.0);
|
||||
let tensor =
|
||||
Tensor::from_array(([1_i64, 3, det as i64, det as i64], input)).map_err(dom)?;
|
||||
|
||||
let layout = self.layout;
|
||||
let total = layout.fmc * if layout.use_kps { 3 } else { 2 };
|
||||
let raw: Vec<Vec<f32>> = {
|
||||
let mut sess = self
|
||||
.detector
|
||||
.lock()
|
||||
.map_err(|_| dom("detector mutex poisoned"))?;
|
||||
let outputs = sess.run(ort::inputs![tensor]).map_err(dom)?;
|
||||
(0..total)
|
||||
.map(|i| {
|
||||
outputs[i]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map(|(_, data)| data.to_vec())
|
||||
.map_err(dom)
|
||||
})
|
||||
.collect::<Result<_, _>>()?
|
||||
};
|
||||
|
||||
let mut dets = Vec::new();
|
||||
for (si, &stride) in layout.strides().iter().enumerate() {
|
||||
let scores = &raw[si];
|
||||
let bbox: Vec<f32> = raw[layout.fmc + si]
|
||||
.iter()
|
||||
.map(|v| v * stride as f32)
|
||||
.collect();
|
||||
let kps: Option<Vec<f32>> = if layout.use_kps {
|
||||
Some(
|
||||
raw[2 * layout.fmc + si]
|
||||
.iter()
|
||||
.map(|v| v * stride as f32)
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let feat = det / stride;
|
||||
geom::decode_stride(
|
||||
scores,
|
||||
&bbox,
|
||||
kps.as_deref(),
|
||||
stride,
|
||||
feat,
|
||||
feat,
|
||||
layout.num_anchors,
|
||||
self.det_threshold,
|
||||
&mut dets,
|
||||
);
|
||||
}
|
||||
|
||||
// Scale detector-space coordinates back to the original image.
|
||||
let inv_scale = if scale.abs() < 1e-9 { 1.0 } else { 1.0 / scale };
|
||||
for d in &mut dets {
|
||||
for v in &mut d.bbox {
|
||||
*v *= inv_scale;
|
||||
}
|
||||
for k in &mut d.kps {
|
||||
k[0] *= inv_scale;
|
||||
k[1] *= inv_scale;
|
||||
}
|
||||
}
|
||||
Ok(geom::nms(dets, self.nms_threshold))
|
||||
}
|
||||
|
||||
/// Align one detection and run the ArcFace embedder. Returns `None` if the
|
||||
/// embedder produces an unexpected output length.
|
||||
fn embed(
|
||||
&self,
|
||||
orig: &RgbImage,
|
||||
det: &geom::Detection,
|
||||
) -> Result<Option<Vec<f32>>, DomainError> {
|
||||
let inv = geom::similarity_transform_inverse(&det.kps, &geom::ARCFACE_TEMPLATE);
|
||||
let aligned = geom::warp_to_aligned(orig, &inv);
|
||||
let input = geom::chw_normalized(&aligned, 127.5, 1.0 / 127.5);
|
||||
let size = geom::ALIGN_SIZE as i64;
|
||||
let tensor = Tensor::from_array(([1_i64, 3, size, size], input)).map_err(dom)?;
|
||||
|
||||
let mut embedding: Vec<f32> = {
|
||||
let mut sess = self
|
||||
.embedder
|
||||
.lock()
|
||||
.map_err(|_| dom("embedder mutex poisoned"))?;
|
||||
let outputs = sess.run(ort::inputs![tensor]).map_err(dom)?;
|
||||
let (_, data) = outputs[0].try_extract_tensor::<f32>().map_err(dom)?;
|
||||
data.to_vec()
|
||||
};
|
||||
if embedding.len() != EMBEDDING_DIM {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::faces",
|
||||
"embedder returned {} dims, expected {EMBEDDING_DIM}; skipping face",
|
||||
embedding.len()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
geom::l2_normalize(&mut embedding);
|
||||
Ok(Some(embedding))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FaceAnalyzerPort for OnnxFaceAnalyzer {
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn analyze(&self, image_bytes: &[u8]) -> Result<Vec<DetectedFace>, DomainError> {
|
||||
let inner = self.inner.clone();
|
||||
let bytes = image_bytes.to_vec();
|
||||
tokio::task::spawn_blocking(move || inner.analyze_blocking(&bytes))
|
||||
.await
|
||||
.map_err(|e| dom(format!("inference task join: {e}")))?
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod grant_handler;
|
||||
pub mod i18n_handler;
|
||||
pub mod magic_link_handler;
|
||||
pub mod music_handler;
|
||||
pub mod people_handler;
|
||||
pub mod photos_handler;
|
||||
pub mod recent_handler;
|
||||
pub mod search_handler;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! HTTP handlers for the People (faces) feature.
|
||||
//!
|
||||
//! Every route is mounted only when `OXICLOUD_ENABLE_FACES` is on (the service
|
||||
//! is present in `AppState`); each handler is also defensive. All work is
|
||||
//! strictly caller-scoped by `PeopleService` (the repository filters by user).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
fn disabled() -> Response {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "People feature is disabled" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn bad_id() -> Response {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "invalid id" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// GET /api/people — identity clusters for the caller.
|
||||
pub async fn list_people(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
match svc.list_people(auth_user.id).await {
|
||||
Ok(people) => Json(people).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/people/{id}/photos — file ids of a person's photos.
|
||||
pub async fn person_photos(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let Ok(person_id) = Uuid::parse_str(&id) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.person_photos(auth_user.id, person_id).await {
|
||||
Ok(files) => Json(files).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RenameBody {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// PATCH /api/people/{id} — name (or clear the name of) a person.
|
||||
pub async fn rename_person(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<RenameBody>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let Ok(person_id) = Uuid::parse_str(&id) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.rename_person(auth_user.id, person_id, body.name).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HideBody {
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// POST /api/people/{id}/hide — hide/unhide a person from the grid.
|
||||
pub async fn hide_person(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<HideBody>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let Ok(person_id) = Uuid::parse_str(&id) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.set_hidden(auth_user.id, person_id, body.hidden).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MergeBody {
|
||||
pub into: String,
|
||||
pub from: String,
|
||||
}
|
||||
|
||||
/// POST /api/people/merge — merge `from` into `into`.
|
||||
pub async fn merge_people(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<MergeBody>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let (Ok(into), Ok(from)) = (Uuid::parse_str(&body.into), Uuid::parse_str(&body.from)) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.merge(auth_user.id, into, from).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/people/recluster — re-run identity clustering for the caller.
|
||||
pub async fn recluster(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
match svc.recluster(auth_user.id).await {
|
||||
Ok(n) => Json(serde_json::json!({ "persons_created": n })).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/people/data — erase all of the caller's face data.
|
||||
pub async fn delete_all(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
match svc.delete_all(auth_user.id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags).
|
||||
pub async fn faces_for_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(file_id): Path<String>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let Ok(fid) = Uuid::parse_str(&file_id) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.faces_for_file(auth_user.id, fid).await {
|
||||
Ok(boxes) => Json(boxes).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::geo_dto::GeoBounds;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
@@ -21,6 +22,20 @@ pub struct PhotosQueryParams {
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// Photos-timeline item: a `FileDto` plus the image's original pixel
|
||||
/// dimensions (from EXIF/metadata), flattened into the same JSON shape so
|
||||
/// the gallery can lay tiles out at their true aspect ratio without a
|
||||
/// second per-file metadata round-trip.
|
||||
#[derive(Serialize)]
|
||||
struct PhotoDto {
|
||||
#[serde(flatten)]
|
||||
file: FileDto,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
height: Option<u32>,
|
||||
}
|
||||
|
||||
/// Lists all image/video files for the authenticated user, sorted by
|
||||
/// capture date (EXIF DateTimeOriginal) falling back to upload date.
|
||||
///
|
||||
@@ -55,17 +70,22 @@ pub async fn list_photos(
|
||||
.list_media_files(user_id, params.before, limit)
|
||||
.await
|
||||
{
|
||||
Ok((files, sort_dates)) => {
|
||||
Ok((files, sort_dates, dims)) => {
|
||||
info!("Photos: returned {} media files for user", files.len());
|
||||
|
||||
// Convert to DTOs with sort_date populated
|
||||
let dtos: Vec<FileDto> = files
|
||||
// Convert to DTOs with sort_date + pixel dimensions populated.
|
||||
let dtos: Vec<PhotoDto> = files
|
||||
.into_iter()
|
||||
.zip(sort_dates.iter())
|
||||
.map(|(file, &sd)| {
|
||||
.zip(dims.iter())
|
||||
.map(|((file, &sd), &(w, h))| {
|
||||
let mut dto = FileDto::from(file);
|
||||
dto.sort_date = Some(sd as u64);
|
||||
dto
|
||||
PhotoDto {
|
||||
file: dto,
|
||||
width: w.map(|v| v.max(0) as u32),
|
||||
height: h.map(|v| v.max(0) as u32),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -91,3 +111,76 @@ pub async fn list_photos(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query parameters for the photos map (clustered) endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub struct GeoQueryParams {
|
||||
/// Bounding box as `west,south,east,north` (decimal degrees).
|
||||
pub bbox: String,
|
||||
/// Slippy-map zoom level (0–20); controls cluster granularity.
|
||||
pub zoom: Option<u8>,
|
||||
}
|
||||
|
||||
/// Lists the caller's geotagged photos aggregated into map clusters within a
|
||||
/// bounding box. Gated on `OXICLOUD_ENABLE_PLACES` (the route is only mounted
|
||||
/// when the Places service is present).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/photos/geo",
|
||||
params(
|
||||
("bbox" = String, Query, description = "Bounding box 'west,south,east,north' (decimal degrees)"),
|
||||
("zoom" = Option<u8>, Query, description = "Map zoom level (0-20), controls cluster size")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Geotagged photos aggregated into map clusters"),
|
||||
(status = 400, description = "Invalid bounding box"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "photos"
|
||||
)]
|
||||
pub async fn list_photos_geo(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Query(params): Query<GeoQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let Some(places) = state.places_service.as_ref() else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "Places feature is disabled" })),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let coords: Vec<f64> = params
|
||||
.bbox
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<f64>().ok())
|
||||
.collect();
|
||||
if coords.len() != 4 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "bbox must be 'west,south,east,north'" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let bounds = GeoBounds {
|
||||
west: coords[0],
|
||||
south: coords[1],
|
||||
east: coords[2],
|
||||
north: coords[3],
|
||||
};
|
||||
let zoom = params.zoom.unwrap_or(3);
|
||||
|
||||
match places.clusters(auth_user.id, bounds, zoom).await {
|
||||
Ok(clusters) => Json(clusters).into_response(),
|
||||
Err(err) => {
|
||||
error!("Error listing photo geo clusters: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("{}", err) })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::recent_handler::clear_recent_items,
|
||||
// Photos handler (free function)
|
||||
handlers::photos_handler::list_photos,
|
||||
handlers::photos_handler::list_photos_geo,
|
||||
// Batch handlers (free functions)
|
||||
handlers::batch_handler::move_files_batch,
|
||||
handlers::batch_handler::copy_files_batch,
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
extract::{DefaultBodyLimit, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json as AxumJson, Response},
|
||||
routing::{any, delete, get, post, put},
|
||||
routing::{any, delete, get, patch, post, put},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
@@ -431,13 +431,33 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
{
|
||||
use crate::interfaces::api::handlers::photos_handler;
|
||||
|
||||
let photos_router = Router::new()
|
||||
.route("/", get(photos_handler::list_photos))
|
||||
.with_state(app_state.clone());
|
||||
let mut photos_router = Router::new().route("/", get(photos_handler::list_photos));
|
||||
if app_state.places_service.is_some() {
|
||||
photos_router = photos_router.route("/geo", get(photos_handler::list_photos_geo));
|
||||
}
|
||||
let photos_router = photos_router.with_state(app_state.clone());
|
||||
|
||||
router = router.nest("/photos", photos_router);
|
||||
}
|
||||
|
||||
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
|
||||
if app_state.people_service.is_some() {
|
||||
use crate::interfaces::api::handlers::people_handler;
|
||||
|
||||
let people_router = Router::new()
|
||||
.route("/", get(people_handler::list_people))
|
||||
.route("/merge", post(people_handler::merge_people))
|
||||
.route("/recluster", post(people_handler::recluster))
|
||||
.route("/data", delete(people_handler::delete_all))
|
||||
.route("/faces/{file_id}", get(people_handler::faces_for_file))
|
||||
.route("/{id}", patch(people_handler::rename_person))
|
||||
.route("/{id}/photos", get(people_handler::person_photos))
|
||||
.route("/{id}/hide", post(people_handler::hide_person))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
router = router.nest("/people", people_router);
|
||||
}
|
||||
|
||||
// Re-enable trash routes to make the trash view work
|
||||
if let Some(_trash_service_ref) = trash_service.clone() {
|
||||
tracing::info!("Setting up trash routes for trash view");
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# The vector basemap is large (tens of MB) and operator-provided — never
|
||||
# commit it to the repo. Drop a Protomaps `.pmtiles` here as `basemap.pmtiles`
|
||||
# and the existing static file server (tower-http ServeDir, Range-capable)
|
||||
# will serve it to the Places map. See README.md.
|
||||
*.pmtiles
|
||||
@@ -0,0 +1,33 @@
|
||||
# Places basemap (optional)
|
||||
|
||||
The **Places** photo map renders your geotagged photos as clusters. It works
|
||||
out of the box **without** a basemap (clusters on a plain background). To get a
|
||||
real street/terrain backdrop, drop a self-hosted vector basemap here — no
|
||||
third-party tile API, fully offline.
|
||||
|
||||
## How it works (Approach "A")
|
||||
|
||||
OxiCloud already serves `static/` through `tower-http`'s `ServeDir`, which
|
||||
honours **HTTP Range** requests. A [PMTiles](https://docs.protomaps.com/pmtiles/)
|
||||
basemap is a *single file* read directly by the browser via Range — so the
|
||||
basemap is just a static file the app already knows how to serve. No extra
|
||||
backend, no tile server, no API keys.
|
||||
|
||||
## Enabling it
|
||||
|
||||
1. Get a Protomaps `.pmtiles` basemap (vector, ODbL OpenStreetMap data):
|
||||
- Whole planet z0–15 (~120 GB) or a smaller global `z0-6` (~60 MB), or
|
||||
- A **regional extract** (recommended — only the area you need, a few MB):
|
||||
```sh
|
||||
# one-time, downloads only your bounding box from the remote planet
|
||||
pmtiles extract https://build.protomaps.com/<DATE>.pmtiles basemap.pmtiles \
|
||||
--bbox=<west>,<south>,<east>,<north>
|
||||
```
|
||||
See https://docs.protomaps.com/basemaps/downloads
|
||||
2. Place it here as **`static/basemaps/basemap.pmtiles`** (this path is
|
||||
git-ignored on purpose — see `.gitignore`).
|
||||
3. Reload the Places view. The map will pick it up automatically.
|
||||
|
||||
The bundled style is **label-light** (water / land / roads / buildings, no
|
||||
text) so it needs no glyph/sprite assets. Attribution “© OpenStreetMap”
|
||||
(ODbL) is shown automatically when a basemap is present.
|
||||
@@ -0,0 +1,112 @@
|
||||
/* People (faces) view */
|
||||
.people-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.people-container.active {
|
||||
display: block;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
/* Grid of person tiles */
|
||||
.people-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.person-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.person-tile:hover {
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.person-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--color-bg-muted);
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.person-name {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.person-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* Single-person header */
|
||||
.people-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.people-toolbar .people-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.people-back,
|
||||
.people-rename {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.people-back:hover,
|
||||
.people-rename:hover {
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
/* Loading / empty states */
|
||||
.people-loading,
|
||||
.people-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-20) var(--space-5);
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.people-empty i {
|
||||
font-size: 48px;
|
||||
color: var(--color-border-medium);
|
||||
}
|
||||
|
||||
.people-loading i {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -8,11 +8,18 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Virtualized timeline: each date-group is a <section>; its grid is
|
||||
materialized (tiles inserted) only while near the viewport — see photos.js. */
|
||||
.photos-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Toolbar with group mode toggle */
|
||||
.photos-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-2) var(--space-1);
|
||||
}
|
||||
|
||||
@@ -48,6 +55,25 @@
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Justified (aspect-preserving) layout — the grid becomes a column of rows;
|
||||
tile sizes are set inline by photos.js (see _justifiedRows). */
|
||||
.photos-layout-justified .photos-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.photos-jrow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.photos-layout-justified .photo-tile {
|
||||
aspect-ratio: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Monthly mode — larger tiles, more breathing room */
|
||||
.photos-group-monthly .photos-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
@@ -107,6 +133,11 @@
|
||||
border-color: var(--color-border-medium);
|
||||
}
|
||||
|
||||
.photo-tile:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.photo-tile:hover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,45 @@
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
/* EXIF info panel */
|
||||
.lightbox-infopanel {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: var(--space-4);
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-lightbox-btn-bg);
|
||||
color: var(--color-lightbox-btn-text);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.lightbox-infopanel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lb-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.lb-info-row i {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Zoomed photo shows a grab cursor for panning */
|
||||
.lightbox-content img.is-zoomed {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.lightbox-nav {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Photos sub-navigation (Moments | Places) */
|
||||
.photos-subnav {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-2) 0;
|
||||
}
|
||||
|
||||
.photos-subnav.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photos-subnav-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-text-faint);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.photos-subnav-tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.photos-subnav-tab.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Map view */
|
||||
.places-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.places-container.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 150px);
|
||||
min-height: 360px;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.places-map {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-2xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.places-loading,
|
||||
.places-error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.places-loading i {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Cluster markers — a circular photo thumbnail with a count badge */
|
||||
.places-cluster {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--color-bg-muted);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-bg-surface);
|
||||
box-shadow: 0 2px 8px var(--color-shadow-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.places-cluster-count {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-danger-text);
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1;
|
||||
padding: var(--space-0-5) var(--space-1-5);
|
||||
border-radius: var(--radius-full);
|
||||
transform: translateY(35%);
|
||||
}
|
||||
@@ -36,6 +36,8 @@
|
||||
<link rel="stylesheet" href="/css/views/trash.css">
|
||||
<link rel="stylesheet" href="/css/views/photos.css">
|
||||
<link rel="stylesheet" href="/css/views/photosLightbox.css">
|
||||
<link rel="stylesheet" href="/css/views/places.css">
|
||||
<link rel="stylesheet" href="/css/views/people.css">
|
||||
<link rel="stylesheet" href="/css/views/music.css">
|
||||
|
||||
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
|
||||
@@ -57,6 +59,8 @@
|
||||
<script defer type="module" src="/js/features/library/favorites.js"></script>
|
||||
<script defer type="module" src="/js/features/library/recent.js"></script>
|
||||
<script defer type="module" src="/js/features/library/photos.js"></script>
|
||||
<script defer type="module" src="/js/features/library/places.js"></script>
|
||||
<script defer type="module" src="/js/features/library/people.js"></script>
|
||||
<script defer type="module" src="/js/features/library/music.js"></script>
|
||||
<script defer type="module" src="/js/features/sharing/fileSharing.js"></script>
|
||||
<script defer type="module" src="/js/model/recentModel.js"></script>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { musicView } from '../features/library/music.js';
|
||||
import { photosView } from '../features/library/photos.js';
|
||||
import { placesView } from '../features/library/places.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { favoritesView } from '../views/favorites/favoritesView.js';
|
||||
import { mySharesView } from '../views/myShares/mySharesView.js';
|
||||
@@ -225,9 +226,10 @@ function setCurrentSection(section) {
|
||||
// Reset owner column — sections that need it re-enable it explicitly below.
|
||||
ui.setOwnerColumnVisible(false);
|
||||
|
||||
// Hide photosView when switching to any other section
|
||||
// Hide photosView (+ the Places sub-view) when switching to any other section
|
||||
if (section !== 'photos' && photosView) {
|
||||
photosView.hide();
|
||||
placesView.unmountTabs();
|
||||
}
|
||||
|
||||
// Hide musicView when switching to any other section
|
||||
@@ -451,6 +453,7 @@ function switchToPhotosSection() {
|
||||
if (photosView) {
|
||||
photosView.show();
|
||||
}
|
||||
placesView.mountTabs();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -436,6 +436,47 @@ const Modal = {
|
||||
requestAnimationFrame(() => {
|
||||
this.overlay.classList.add('active');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirmation dialog (replacement for window.confirm()).
|
||||
* Built on openPanel, so it inherits the overlay, animation, focus-trap,
|
||||
* Escape and click-outside handling.
|
||||
* @param {Object} options
|
||||
* @param {string} options.title
|
||||
* @param {string} options.message
|
||||
* @param {string} [options.confirmText]
|
||||
* @param {string} [options.cancelText]
|
||||
* @param {string} [options.icon] - Font Awesome class, default 'fa-circle-question'
|
||||
* @returns {Promise<boolean>} true if confirmed, false otherwise
|
||||
*/
|
||||
confirmDialog({ title, message, confirmText = null, cancelText = null, icon = 'fa-circle-question' }) {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.overlay) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const content = document.createElement('p');
|
||||
content.className = 'modal-confirm-message';
|
||||
content.textContent = message;
|
||||
|
||||
let settled = false;
|
||||
const done = (/** @type {boolean} */ value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
this.openPanel({
|
||||
title,
|
||||
icon,
|
||||
content,
|
||||
confirmText: confirmText ?? i18n.t('actions.confirm'),
|
||||
cancelText: cancelText ?? i18n.t('actions.cancel'),
|
||||
onConfirm: () => done(true),
|
||||
onCancel: () => done(false)
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
* @property {number} size
|
||||
* @property {string} size_formatted
|
||||
* @property {number} sort_date
|
||||
* @property {number} [width] original pixel width (photos timeline only)
|
||||
* @property {number} [height] original pixel height (photos timeline only)
|
||||
* @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match
|
||||
* @property {string} content_hash raw BLAKE3 content hash, for dedup checks
|
||||
* @property {string} [snippet] plain-text fragment around a content match (search results only)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* OxiCloud - People (faces)
|
||||
*
|
||||
* A grid of identity clusters from GET /api/people; clicking a person shows
|
||||
* their photos (reusing the photos lightbox). Faces are detected + clustered
|
||||
* server-side; this view is read-mostly (list, drill-in, rename).
|
||||
*
|
||||
* The feature is gated on OXICLOUD_ENABLE_FACES — when it is off the API 404s
|
||||
* and the view shows a short "disabled" hint (and the Places/People sub-nav
|
||||
* hides the People tab via a capability probe).
|
||||
*/
|
||||
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { photosLightbox } from './photosLightbox.js';
|
||||
|
||||
/** @import {FileItem} from '../../core/types.js' */
|
||||
/** @typedef {{id: string, name?: string, cover_file_id?: string, face_count: number, is_hidden: boolean}} PersonItem */
|
||||
|
||||
export const peopleView = {
|
||||
/** @type {HTMLElement|null} */
|
||||
_container: null,
|
||||
|
||||
_headers() {
|
||||
return getCsrfHeaders();
|
||||
},
|
||||
|
||||
/** Ensure the container exists (sibling in .content-area). */
|
||||
_mount() {
|
||||
const ca = document.querySelector('.content-area');
|
||||
if (!ca) return;
|
||||
if (!this._container) {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'people-container';
|
||||
el.className = 'people-container';
|
||||
ca.appendChild(el);
|
||||
this._container = el;
|
||||
}
|
||||
},
|
||||
|
||||
async show() {
|
||||
this._mount();
|
||||
if (!this._container) return;
|
||||
this._container.classList.add('active');
|
||||
await this._renderList();
|
||||
},
|
||||
|
||||
hide() {
|
||||
this._container?.classList.remove('active');
|
||||
},
|
||||
|
||||
async _renderList() {
|
||||
if (!this._container) return;
|
||||
this._container.innerHTML = '<div class="people-loading"><i class="fas fa-spinner"></i></div>';
|
||||
try {
|
||||
const res = await fetch('/api/people', { credentials: 'include', headers: this._headers() });
|
||||
if (!res.ok) {
|
||||
this._renderHint(i18n.t('people.disabled'));
|
||||
return;
|
||||
}
|
||||
/** @type {PersonItem[]} */
|
||||
const people = await res.json();
|
||||
if (!people.length) {
|
||||
this._renderHint(i18n.t('people.empty'));
|
||||
return;
|
||||
}
|
||||
let html = '<div class="people-grid">';
|
||||
for (const p of people) {
|
||||
const cover = p.cover_file_id ? `/api/files/${p.cover_file_id}/thumbnail/icon` : '';
|
||||
const name = p.name || i18n.t('people.unnamed');
|
||||
html += `<button class="person-tile" type="button" data-id="${this._escAttr(p.id)}" data-name="${this._escAttr(name)}">`;
|
||||
html += `<span class="person-avatar" style="background-image:url(${cover})"></span>`;
|
||||
html += `<span class="person-name">${this._escHtml(name)}</span>`;
|
||||
html += `<span class="person-count">${p.face_count}</span>`;
|
||||
html += '</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
this._container.innerHTML = html;
|
||||
this._container.querySelectorAll('.person-tile').forEach((t) => {
|
||||
const el = /** @type {HTMLElement} */ (t);
|
||||
el.addEventListener('click', () => this._openPerson(el.dataset.id || '', el.dataset.name || ''));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('People load failed:', err);
|
||||
this._renderHint(i18n.t('people.disabled'));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} personId
|
||||
* @param {string} name
|
||||
*/
|
||||
async _openPerson(personId, name) {
|
||||
if (!this._container) return;
|
||||
this._container.innerHTML =
|
||||
'<div class="people-toolbar">' +
|
||||
`<button class="people-back" type="button" title="${this._escAttr(i18n.t('people.back'))}"><i class="fas fa-arrow-left"></i></button>` +
|
||||
`<h2 class="people-title">${this._escHtml(name)}</h2>` +
|
||||
`<button class="people-rename" type="button" title="${this._escAttr(i18n.t('people.rename_title'))}"><i class="fas fa-pen"></i></button>` +
|
||||
'</div>' +
|
||||
'<div class="photos-grid" id="person-photos"></div>';
|
||||
/** @type {HTMLButtonElement} */ (this._container.querySelector('.people-back')).onclick = () => this._renderList();
|
||||
/** @type {HTMLButtonElement} */ (this._container.querySelector('.people-rename')).onclick = () => this._rename(personId, name);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/people/${personId}/photos`, { credentials: 'include', headers: this._headers() });
|
||||
if (!res.ok) return;
|
||||
/** @type {string[]} */
|
||||
const fileIds = await res.json();
|
||||
// Minimal FileItems so the lightbox can open them by id.
|
||||
const items = fileIds.map(
|
||||
(id) =>
|
||||
/** @type {FileItem} */ (/** @type {any} */ ({ id, name: '', mime_type: 'image/jpeg', created_at: 0, sort_date: 0, size_formatted: '' }))
|
||||
);
|
||||
const grid = this._container.querySelector('#person-photos');
|
||||
if (!grid) return;
|
||||
let html = '';
|
||||
fileIds.forEach((id, i) => {
|
||||
html += `<div class="photo-tile" data-idx="${i}"><img src="/api/files/${this._escAttr(id)}/thumbnail/preview" loading="lazy" decoding="async" alt=""></div>`;
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
grid.querySelectorAll('.photo-tile').forEach((t) => {
|
||||
const el = /** @type {HTMLElement} */ (t);
|
||||
el.addEventListener('click', () => photosLightbox.open(items, Number(el.dataset.idx)));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Person photos failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} personId
|
||||
* @param {string} current
|
||||
*/
|
||||
async _rename(personId, current) {
|
||||
const placeholder = i18n.t('people.unnamed');
|
||||
const value = current === placeholder ? '' : current;
|
||||
const name = await Modal.prompt({
|
||||
title: i18n.t('people.rename_title'),
|
||||
label: i18n.t('people.name_label'),
|
||||
value
|
||||
});
|
||||
if (name === null) return;
|
||||
try {
|
||||
await fetch(`/api/people/${personId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { ...this._headers(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name || null })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Rename failed:', err);
|
||||
}
|
||||
this._openPerson(personId, name || placeholder);
|
||||
},
|
||||
|
||||
/** @param {string} text */
|
||||
_renderHint(text) {
|
||||
if (!this._container) return;
|
||||
this._container.innerHTML = `<div class="people-empty"><i class="fas fa-user-group"></i><p>${this._escHtml(text)}</p></div>`;
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_escAttr(s) {
|
||||
return String(s || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
* Photo grid grouped by day/month/year, with infinite scroll and multi-select.
|
||||
*/
|
||||
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { thumbnail } from '../thumbnail.js';
|
||||
@@ -14,6 +15,14 @@ import { photosLightbox } from './photosLightbox.js';
|
||||
* @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PhotoGroup
|
||||
* @property {string} label
|
||||
* @property {FileItem[]} files
|
||||
* @property {HTMLElement} section
|
||||
* @property {boolean} materialized
|
||||
*/
|
||||
|
||||
const photosView = {
|
||||
/** @type {Array<FileItem>} All loaded photo items */
|
||||
items: [],
|
||||
@@ -25,18 +34,32 @@ const photosView = {
|
||||
exhausted: false,
|
||||
/** @type {Set<string>} Selected item IDs */
|
||||
selected: new Set(),
|
||||
/** @type {IntersectionObserver|null} */
|
||||
_observer: null,
|
||||
/** @type {IntersectionObserver|null} Materializes/dematerializes group tiles by viewport proximity */
|
||||
_materializeObserver: null,
|
||||
/** @type {IntersectionObserver|null} Infinite-scroll trigger on the sentinel */
|
||||
_sentinelObserver: null,
|
||||
/** @type {HTMLElement|null} */
|
||||
_container: null,
|
||||
/** @type {HTMLElement|null} The infinite-scroll sentinel element */
|
||||
_sentinelEl: null,
|
||||
/** @type {boolean} */
|
||||
_initialized: false,
|
||||
/** @type {PhotoModeEnum} */
|
||||
groupMode: 'monthly',
|
||||
/** @type {'square'|'justified'} */
|
||||
layoutMode: 'square',
|
||||
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
|
||||
_videoThumbCache: new Map(),
|
||||
/** @type {number} Items already rendered in the DOM */
|
||||
_renderedCount: 0,
|
||||
/** @type {Map<string, PhotoGroup>} group label → group record (DOM + data) */
|
||||
_groupData: new Map(),
|
||||
/** @type {string[]} Ordered group labels (timeline order) */
|
||||
_groupOrder: [],
|
||||
/** @type {(() => void)|null} Debounced window resize handler */
|
||||
_resizeHandler: null,
|
||||
/** @type {number} */
|
||||
_resizeTimer: 0,
|
||||
/** @type {string|null} Anchor id for shift-range selection */
|
||||
_selectAnchorId: null,
|
||||
|
||||
PAGE_SIZE: 200,
|
||||
|
||||
@@ -60,6 +83,7 @@ const photosView = {
|
||||
}
|
||||
if (!this._initialized) {
|
||||
this.groupMode = /** @type {'daily'|'monthly'|'yearly'} */ (localStorage.getItem('oxicloud-photos-group')) || 'monthly';
|
||||
this.layoutMode = /** @type {'square'|'justified'} */ (localStorage.getItem('oxicloud-photos-layout')) || 'square';
|
||||
this._initialized = true;
|
||||
}
|
||||
},
|
||||
@@ -73,7 +97,9 @@ const photosView = {
|
||||
this.nextCursor = null;
|
||||
this.exhausted = false;
|
||||
this.selected.clear();
|
||||
this._renderedCount = 0;
|
||||
this._groupData = new Map();
|
||||
this._groupOrder = [];
|
||||
this._destroyObserver();
|
||||
this._container.innerHTML = '';
|
||||
this._loadPage();
|
||||
},
|
||||
@@ -84,6 +110,7 @@ const photosView = {
|
||||
this._container.classList.remove('active');
|
||||
}
|
||||
this._destroyObserver();
|
||||
this._unbindResize();
|
||||
this._hideSelectionBar();
|
||||
},
|
||||
|
||||
@@ -97,7 +124,17 @@ const photosView = {
|
||||
if (this.groupMode === mode) return;
|
||||
this.groupMode = mode;
|
||||
localStorage.setItem('oxicloud-photos-group', mode);
|
||||
this._renderedCount = 0;
|
||||
this._renderFull();
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch tile layout (square crop vs justified aspect-preserving rows).
|
||||
* @param {'square'|'justified'} mode
|
||||
*/
|
||||
setLayoutMode(mode) {
|
||||
if (this.layoutMode === mode) return;
|
||||
this.layoutMode = mode;
|
||||
localStorage.setItem('oxicloud-photos-layout', mode);
|
||||
this._renderFull();
|
||||
},
|
||||
|
||||
@@ -149,18 +186,29 @@ const photosView = {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────
|
||||
// Two render paths:
|
||||
// _renderFull() — full DOM rebuild (first load, group-mode change, delete)
|
||||
// _appendBatch(n) — append-only for infinite-scroll pages (O(batch))
|
||||
// ── Virtualized rendering ───────────────────────────────────────
|
||||
// The timeline can hold tens of thousands of items, so we never keep
|
||||
// every tile in the DOM. Each date-group is a <section> with a header
|
||||
// (always present, cheap) and a grid that is *materialized* (tiles in
|
||||
// the DOM) only while near the viewport, and *dematerialized* (emptied,
|
||||
// its height frozen as a spacer) once it scrolls far away. An
|
||||
// IntersectionObserver rooted on the scroll container drives the swap,
|
||||
// so the DOM node count stays bounded by a few screens regardless of
|
||||
// library size.
|
||||
// _renderFull() — rebuild the group skeleton (first load, mode switch, delete)
|
||||
// _appendBatch(n) — append new groups for infinite-scroll pages
|
||||
|
||||
/** Full DOM rebuild — first load, group-mode switch, or after deletions. */
|
||||
/** Rebuild the group skeleton — first load, group-mode switch, or deletions. */
|
||||
_renderFull() {
|
||||
if (!this._container) return;
|
||||
this._destroyObserver();
|
||||
this._groupData = new Map();
|
||||
this._groupOrder = [];
|
||||
|
||||
this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly');
|
||||
this._container.classList.add(`photos-group-${this.groupMode}`);
|
||||
this._container.classList.remove('photos-layout-square', 'photos-layout-justified');
|
||||
this._container.classList.add(`photos-layout-${this.layoutMode}`);
|
||||
|
||||
if (this.items.length === 0 && this.exhausted) {
|
||||
this._renderEmpty();
|
||||
@@ -168,89 +216,257 @@ const photosView = {
|
||||
}
|
||||
if (this.items.length === 0) return;
|
||||
|
||||
const groups = this._groupItems(this.items);
|
||||
let html = this._renderToolbar();
|
||||
// Toolbar via innerHTML, then append group <section>s + sentinel as
|
||||
// real elements so we keep references for the observer.
|
||||
this._container.innerHTML = this._renderToolbar();
|
||||
this._container.onclick = (e) => this._handleClick(e);
|
||||
this._container.onkeydown = (e) => this._handleKeydown(e);
|
||||
|
||||
const groups = this._groupItems(this.items);
|
||||
for (const [label, files] of groups) {
|
||||
html += `<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>`;
|
||||
html += '<div class="photos-grid">';
|
||||
for (const file of files) html += this._renderTile(file);
|
||||
html += '</div>';
|
||||
/** @type {PhotoGroup} */
|
||||
const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false };
|
||||
this._groupData.set(label, rec);
|
||||
this._groupOrder.push(label);
|
||||
this._container.appendChild(rec.section);
|
||||
}
|
||||
|
||||
html += '<div class="photos-sentinel"></div>';
|
||||
this._container.innerHTML = html;
|
||||
this._container.onclick = (e) => this._handleClick(e);
|
||||
this._fadeInTiles();
|
||||
this._renderedCount = this.items.length;
|
||||
this._observeSentinel();
|
||||
this._setupVideoThumbnails();
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.className = 'photos-sentinel';
|
||||
this._container.appendChild(sentinel);
|
||||
this._sentinelEl = sentinel;
|
||||
|
||||
this._setupObservers();
|
||||
this._eagerMaterialize();
|
||||
this._bindResize();
|
||||
},
|
||||
|
||||
/** Append-only render for infinite scroll — inserts only the items
|
||||
* from this.items[startIndex..] without destroying existing DOM.
|
||||
* Complexity: O(batch) instead of O(total_items).
|
||||
/** Append new groups for an infinite-scroll page without rebuilding the
|
||||
* existing skeleton. The first new group may continue the previous tail
|
||||
* label, in which case we merge into it. Complexity: O(new groups).
|
||||
* @param {number} startIndex
|
||||
*/
|
||||
_appendBatch(startIndex) {
|
||||
if (!this._container) return;
|
||||
this._destroyObserver();
|
||||
|
||||
const newItems = this.items.slice(startIndex);
|
||||
if (newItems.length === 0) {
|
||||
this._observeSentinel();
|
||||
return;
|
||||
}
|
||||
|
||||
const newGroups = this._groupItems(newItems);
|
||||
const sentinel = this._container.querySelector('.photos-sentinel');
|
||||
if (!sentinel) {
|
||||
// Fallback: sentinel missing — full rebuild
|
||||
this._renderedCount = 0;
|
||||
if (!this._container || !this._sentinelEl) {
|
||||
this._renderFull();
|
||||
return;
|
||||
}
|
||||
const newItems = this.items.slice(startIndex);
|
||||
if (newItems.length === 0) return;
|
||||
|
||||
const newGroups = this._groupItems(newItems);
|
||||
for (const [label, files] of newGroups) {
|
||||
const existing = this._groupData.get(label);
|
||||
if (existing) {
|
||||
// Continuation of a group already in the timeline.
|
||||
existing.files = existing.files.concat(files);
|
||||
const countEl = existing.section.querySelector('.photos-day-count');
|
||||
if (countEl) countEl.textContent = String(existing.files.length);
|
||||
const grid = /** @type {HTMLElement|null} */ (existing.section.querySelector('.photos-grid'));
|
||||
if (grid) {
|
||||
if (existing.materialized) {
|
||||
if (this.layoutMode === 'justified') {
|
||||
// Justified rows must repack against the whole group.
|
||||
grid.innerHTML = this._renderGroupTiles(existing.files);
|
||||
} else {
|
||||
let tilesHtml = '';
|
||||
for (const file of files) tilesHtml += this._renderTile(file);
|
||||
|
||||
// Does this date-group already exist in the DOM?
|
||||
const existingHeader = this._container.querySelector(`.photos-day-header[data-group="${CSS.escape(label)}"]`);
|
||||
|
||||
if (existingHeader) {
|
||||
// Append tiles to existing grid and update count badge
|
||||
const grid = existingHeader.nextElementSibling;
|
||||
if (grid?.classList.contains('photos-grid')) {
|
||||
grid.insertAdjacentHTML('beforeend', tilesHtml);
|
||||
const countSpan = existingHeader.querySelector('.photos-day-count');
|
||||
if (countSpan) countSpan.textContent = String(grid.children.length);
|
||||
}
|
||||
this._setupVideoThumbnails(grid);
|
||||
this._fadeInTiles(grid);
|
||||
} else {
|
||||
grid.style.minHeight = `${this._estimateHeight(existing.files.length)}px`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// New group — insert header + grid before sentinel
|
||||
const sectionHtml =
|
||||
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
|
||||
`<div class="photos-grid">${tilesHtml}</div>`;
|
||||
sentinel.insertAdjacentHTML('beforebegin', sectionHtml);
|
||||
/** @type {PhotoGroup} */
|
||||
const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false };
|
||||
this._groupData.set(label, rec);
|
||||
this._groupOrder.push(label);
|
||||
this._container.insertBefore(rec.section, this._sentinelEl);
|
||||
this._materializeObserver?.observe(rec.section);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Build a dematerialized group section (header + empty grid spacer).
|
||||
* @param {string} label
|
||||
* @param {FileItem[]} files
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildGroupEl(label, files) {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'photos-group';
|
||||
section.dataset.group = label;
|
||||
section.innerHTML =
|
||||
`<div class="photos-day-header" data-group="${this._escAttr(label)}">${this._escHtml(label)}<span class="photos-day-count">${files.length}</span></div>` +
|
||||
`<div class="photos-grid" style="min-height:${this._estimateHeight(files.length)}px"></div>`;
|
||||
return section;
|
||||
},
|
||||
|
||||
/** Wire the two IntersectionObservers (materialization + infinite scroll). */
|
||||
_setupObservers() {
|
||||
const root = this._container?.parentElement || null;
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
// Degrade gracefully: render every group (legacy behaviour).
|
||||
for (const label of this._groupOrder) {
|
||||
const rec = this._groupData.get(label);
|
||||
if (rec) this._materializeGroup(rec.section);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderedCount = this.items.length;
|
||||
this._observeSentinel();
|
||||
this._setupVideoThumbnails(startIndex);
|
||||
this._fadeInTiles();
|
||||
this._materializeObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const section = /** @type {HTMLElement} */ (entry.target);
|
||||
if (entry.isIntersecting) this._materializeGroup(section);
|
||||
else this._dematerializeGroup(section);
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '1200px 0px' }
|
||||
);
|
||||
for (const label of this._groupOrder) {
|
||||
const rec = this._groupData.get(label);
|
||||
if (rec) this._materializeObserver.observe(rec.section);
|
||||
}
|
||||
|
||||
if (this._sentinelEl) {
|
||||
this._sentinelObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) this._loadPage();
|
||||
},
|
||||
{ root, rootMargin: '600px 0px' }
|
||||
);
|
||||
this._sentinelObserver.observe(this._sentinelEl);
|
||||
}
|
||||
},
|
||||
|
||||
/** Synchronously materialize the first groups within ~1.5 viewports so
|
||||
* the initial paint has tiles before the observer's first callback. */
|
||||
_eagerMaterialize() {
|
||||
const budget = (this._container?.parentElement?.clientHeight || window.innerHeight) * 1.5;
|
||||
let acc = 0;
|
||||
for (const label of this._groupOrder) {
|
||||
const rec = this._groupData.get(label);
|
||||
if (!rec) continue;
|
||||
this._materializeGroup(rec.section);
|
||||
acc += rec.section.offsetHeight;
|
||||
if (acc > budget) break;
|
||||
}
|
||||
},
|
||||
|
||||
/** Fill a group's grid with tiles (idempotent).
|
||||
* @param {HTMLElement} section
|
||||
*/
|
||||
_materializeGroup(section) {
|
||||
const rec = this._groupData.get(section.dataset.group || '');
|
||||
if (!rec || rec.materialized) return;
|
||||
rec.materialized = true;
|
||||
const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid'));
|
||||
if (!grid) return;
|
||||
grid.innerHTML = this._renderGroupTiles(rec.files);
|
||||
grid.style.minHeight = '';
|
||||
this._setupVideoThumbnails(grid);
|
||||
this._fadeInTiles(grid);
|
||||
},
|
||||
|
||||
/** Empty a group's grid, freezing its current height as a spacer.
|
||||
* @param {HTMLElement} section
|
||||
*/
|
||||
_dematerializeGroup(section) {
|
||||
const rec = this._groupData.get(section.dataset.group || '');
|
||||
if (!rec?.materialized) return;
|
||||
rec.materialized = false;
|
||||
const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid'));
|
||||
if (!grid) return;
|
||||
grid.style.minHeight = `${grid.offsetHeight}px`;
|
||||
grid.innerHTML = '';
|
||||
},
|
||||
|
||||
/** Current grid geometry (columns / gap / square tile px) for the active
|
||||
* mode, used to estimate off-screen group heights.
|
||||
* @returns {{cols: number, gap: number, tile: number}}
|
||||
*/
|
||||
_gridMetrics() {
|
||||
const width = this._gridWidth();
|
||||
const mobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
let min;
|
||||
let gap;
|
||||
if (this.groupMode === 'yearly') {
|
||||
min = mobile ? 80 : 120;
|
||||
gap = mobile ? 4 : 10;
|
||||
} else if (this.groupMode === 'monthly') {
|
||||
min = mobile ? 110 : 180;
|
||||
gap = mobile ? 2 : 14;
|
||||
} else {
|
||||
min = mobile ? 100 : 150;
|
||||
gap = mobile ? 2 : 12;
|
||||
}
|
||||
const cols = Math.max(1, Math.floor((width + gap) / (min + gap)));
|
||||
const tile = (width - (cols - 1) * gap) / cols;
|
||||
return { cols, gap, tile };
|
||||
},
|
||||
|
||||
/** Estimated pixel height of a grid holding `count` square tiles.
|
||||
* @param {number} count
|
||||
* @returns {number}
|
||||
*/
|
||||
_estimateHeight(count) {
|
||||
if (this.layoutMode === 'justified') {
|
||||
const width = this._gridWidth();
|
||||
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
|
||||
const perRow = Math.max(1, Math.round(width / (target * 1.4)));
|
||||
const rows = Math.max(1, Math.ceil(count / perRow));
|
||||
return Math.round(rows * target + (rows - 1) * 8);
|
||||
}
|
||||
const { cols, gap, tile } = this._gridMetrics();
|
||||
const rows = Math.max(1, Math.ceil(count / cols));
|
||||
return Math.round(rows * tile + (rows - 1) * gap);
|
||||
},
|
||||
|
||||
/** Re-estimate spacer heights for dematerialized groups after a resize. */
|
||||
_bindResize() {
|
||||
if (this._resizeHandler) return;
|
||||
this._resizeHandler = () => {
|
||||
clearTimeout(this._resizeTimer);
|
||||
this._resizeTimer = window.setTimeout(() => this._onResize(), 150);
|
||||
};
|
||||
window.addEventListener('resize', this._resizeHandler);
|
||||
},
|
||||
|
||||
_onResize() {
|
||||
if (!this._container?.classList.contains('active')) return;
|
||||
for (const label of this._groupOrder) {
|
||||
const rec = this._groupData.get(label);
|
||||
if (!rec || rec.materialized) continue;
|
||||
const grid = /** @type {HTMLElement|null} */ (rec.section.querySelector('.photos-grid'));
|
||||
if (grid) grid.style.minHeight = `${this._estimateHeight(rec.files.length)}px`;
|
||||
}
|
||||
},
|
||||
|
||||
_unbindResize() {
|
||||
if (this._resizeHandler) {
|
||||
window.removeEventListener('resize', this._resizeHandler);
|
||||
this._resizeHandler = null;
|
||||
}
|
||||
clearTimeout(this._resizeTimer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate HTML for a single photo/video tile
|
||||
* @param {FileItem} file
|
||||
* @param {string} [sizeStyle] Inline `width:..;height:..` for justified rows.
|
||||
*/
|
||||
_renderTile(file) {
|
||||
_renderTile(file, sizeStyle) {
|
||||
const isVideo = file.mime_type?.startsWith('video/');
|
||||
const selected = this.selected.has(file.id) ? ' selected' : '';
|
||||
const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null;
|
||||
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
|
||||
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}">`;
|
||||
const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : '';
|
||||
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}" tabindex="0" role="button" aria-label="${this._escAttr(file.name)}"${styleAttr}>`;
|
||||
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
|
||||
const srcset = cachedThumb
|
||||
? ''
|
||||
@@ -261,12 +477,85 @@ const photosView = {
|
||||
return h;
|
||||
},
|
||||
|
||||
/**
|
||||
* Inner HTML for a group's grid in the current layout mode.
|
||||
* @param {FileItem[]} files
|
||||
* @returns {string}
|
||||
*/
|
||||
_renderGroupTiles(files) {
|
||||
if (this.layoutMode !== 'justified') {
|
||||
let html = '';
|
||||
for (const file of files) html += this._renderTile(file);
|
||||
return html;
|
||||
}
|
||||
const rows = this._justifiedRows(files, this._gridWidth());
|
||||
let html = '';
|
||||
for (const row of rows) {
|
||||
html += `<div class="photos-jrow" style="height:${row.height}px">`;
|
||||
for (const t of row.tiles) {
|
||||
html += this._renderTile(t.file, `width:${t.w}px;height:${t.h}px`);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pack files into justified rows (Flickr-style): each full row is scaled so
|
||||
* it fills the container width while preserving every tile's aspect ratio.
|
||||
* Missing dimensions fall back to a 1:1 aspect.
|
||||
* @param {FileItem[]} files
|
||||
* @param {number} width Available content width in px.
|
||||
* @returns {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>}
|
||||
*/
|
||||
_justifiedRows(files, width) {
|
||||
const gap = 8;
|
||||
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
|
||||
/** @type {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} */
|
||||
const rows = [];
|
||||
/** @type {Array<{file: FileItem, aspect: number}>} */
|
||||
let cur = [];
|
||||
let aspectSum = 0;
|
||||
for (const file of files) {
|
||||
let aspect = file.width && file.height ? file.width / file.height : 1;
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
|
||||
aspect = Math.min(Math.max(aspect, 0.4), 3);
|
||||
cur.push({ file, aspect });
|
||||
aspectSum += aspect;
|
||||
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
|
||||
if (rowWidth >= width) {
|
||||
const h = (width - (cur.length - 1) * gap) / aspectSum;
|
||||
rows.push({
|
||||
height: Math.round(h),
|
||||
tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * h)), h: Math.round(h) }))
|
||||
});
|
||||
cur = [];
|
||||
aspectSum = 0;
|
||||
}
|
||||
}
|
||||
if (cur.length) {
|
||||
rows.push({
|
||||
height: target,
|
||||
tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * target)), h: target }))
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
|
||||
/** Current grid content width in px (for layout / height estimates). */
|
||||
_gridWidth() {
|
||||
const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid'));
|
||||
return sample?.clientWidth || (this._container?.clientWidth || 1200) - 16;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fade tiles in as their thumbnails finish loading (kills the pop-in).
|
||||
* Idempotent — only wires images not already marked loaded.
|
||||
* @param {ParentNode} [scope] Limit to a subtree (a group grid); defaults to the whole container.
|
||||
*/
|
||||
_fadeInTiles() {
|
||||
this._container?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => {
|
||||
_fadeInTiles(scope) {
|
||||
const root = scope || this._container;
|
||||
root?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => {
|
||||
const img = /** @type {HTMLImageElement} */ (el);
|
||||
if (img.complete) {
|
||||
img.classList.add('is-loaded');
|
||||
@@ -278,40 +567,25 @@ const photosView = {
|
||||
});
|
||||
},
|
||||
|
||||
/** (Re-)observe the sentinel element for infinite scroll */
|
||||
_observeSentinel() {
|
||||
this._destroyObserver();
|
||||
const sentinel = this._container?.querySelector('.photos-sentinel');
|
||||
if (sentinel && !this.exhausted) {
|
||||
this._observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) this._loadPage();
|
||||
},
|
||||
{ rootMargin: '400px' }
|
||||
);
|
||||
this._observer.observe(sentinel);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Client-side video thumbnail generation ──────────────────────
|
||||
// Uses the browser's native video decoder (<video> + <canvas>) to
|
||||
// extract a frame, show it immediately, and upload to the server
|
||||
// for permanent caching. Zero server-side dependencies (no ffmpeg).
|
||||
|
||||
/** Attach error handlers to video tile images; on failure, extract a
|
||||
* frame from the video using the browser's built-in codec. */
|
||||
/** @param {number} [startIndex=0] When > 0, only process video tiles
|
||||
* for items[startIndex..] — avoids re-scanning the entire DOM. */
|
||||
_setupVideoThumbnails(startIndex = 0) {
|
||||
const tiles = /** @type {NodeListOf<HTMLDivElement>} */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
|
||||
const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null;
|
||||
/** Attach error handlers to video tile images within a freshly
|
||||
* materialized grid; on failure, extract a frame from the video using
|
||||
* the browser's built-in codec.
|
||||
* @param {ParentNode} [scope] Subtree to scan; defaults to the whole container.
|
||||
*/
|
||||
_setupVideoThumbnails(scope) {
|
||||
const root = scope || this._container;
|
||||
const tiles = /** @type {NodeListOf<HTMLDivElement>|undefined} */ (root?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
|
||||
|
||||
if (!tiles) return;
|
||||
|
||||
for (const tile of tiles) {
|
||||
const fileId = tile.dataset.id;
|
||||
if (!fileId) continue;
|
||||
if (newIds && !newIds.has(fileId)) continue;
|
||||
if (this._videoThumbCache.has(fileId)) continue;
|
||||
|
||||
const img = tile.querySelector('img');
|
||||
@@ -356,7 +630,16 @@ const photosView = {
|
||||
['monthly', i18n.t('photos.view_monthly')],
|
||||
['yearly', i18n.t('photos.view_yearly')]
|
||||
];
|
||||
let html = '<div class="photos-toolbar"><div class="view-toggle">';
|
||||
let html = '<div class="photos-toolbar">';
|
||||
|
||||
// Layout toggle (square crop ↔ justified rows)
|
||||
html += '<div class="view-toggle photos-layout-toggle">';
|
||||
html += `<button class="toggle-btn${this.layoutMode === 'square' ? ' active' : ''}" data-layout-mode="square" title="${this._escAttr(i18n.t('photos.layout_square'))}" aria-label="${this._escAttr(i18n.t('photos.layout_square'))}"><i class="fas fa-table-cells"></i></button>`;
|
||||
html += `<button class="toggle-btn${this.layoutMode === 'justified' ? ' active' : ''}" data-layout-mode="justified" title="${this._escAttr(i18n.t('photos.layout_justified'))}" aria-label="${this._escAttr(i18n.t('photos.layout_justified'))}"><i class="fas fa-grip"></i></button>`;
|
||||
html += '</div>';
|
||||
|
||||
// Grouping toggle (day / month / year)
|
||||
html += '<div class="view-toggle">';
|
||||
for (const [mode, label] of modes) {
|
||||
const active = this.groupMode === mode ? ' active' : '';
|
||||
html += `<button class="toggle-btn${active}" data-group-mode="${mode}">${this._escHtml(label)}</button>`;
|
||||
@@ -420,15 +703,28 @@ const photosView = {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-layout-mode]'));
|
||||
if (layoutBtn) {
|
||||
this.setLayoutMode(/** @type {'square'|'justified'} */ (layoutBtn.dataset.layoutMode));
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
|
||||
if (!tile) return;
|
||||
|
||||
const id = tile.dataset.id;
|
||||
const check = target.closest('.photo-check');
|
||||
|
||||
// Shift-click extends the selection from the last anchor.
|
||||
if (id && e.shiftKey && this._selectAnchorId) {
|
||||
this._selectRange(this._selectAnchorId, id);
|
||||
return;
|
||||
}
|
||||
|
||||
// If clicking checkbox or in selection mode, toggle select
|
||||
if (check || this.selected.size > 0) {
|
||||
this._toggleSelect(id, tile);
|
||||
this._selectAnchorId = id || null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -439,6 +735,49 @@ const photosView = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select every item between the anchor and the target (inclusive), in
|
||||
* timeline order. Tracked in the Set so it survives dematerialized
|
||||
* groups; currently-visible tiles get the class applied immediately.
|
||||
* @param {string} anchorId
|
||||
* @param {string} toId
|
||||
*/
|
||||
_selectRange(anchorId, toId) {
|
||||
const a = this.items.findIndex((f) => f.id === anchorId);
|
||||
const b = this.items.findIndex((f) => f.id === toId);
|
||||
if (a < 0 || b < 0) return;
|
||||
const lo = Math.min(a, b);
|
||||
const hi = Math.max(a, b);
|
||||
for (let i = lo; i <= hi; i++) this.selected.add(this.items[i].id);
|
||||
this._container?.querySelectorAll('.photo-tile').forEach((el) => {
|
||||
const t = /** @type {HTMLElement} */ (el);
|
||||
if (t.dataset.id && this.selected.has(t.dataset.id)) t.classList.add('selected');
|
||||
});
|
||||
this._selectAnchorId = toId;
|
||||
this._updateSelectionBar();
|
||||
},
|
||||
|
||||
/**
|
||||
* Keyboard activation for focused tiles: Enter opens the lightbox (or
|
||||
* toggles selection when in selection mode); Space toggles selection.
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
_handleKeydown(e) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const target = /** @type {Element} */ (e.target);
|
||||
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
|
||||
if (!tile) return;
|
||||
e.preventDefault();
|
||||
const id = tile.dataset.id;
|
||||
if (e.key === ' ' || this.selected.size > 0) {
|
||||
this._toggleSelect(id, tile);
|
||||
this._selectAnchorId = id || null;
|
||||
return;
|
||||
}
|
||||
const idx = this.items.findIndex((f) => f.id === id);
|
||||
if (idx >= 0) photosLightbox.open(this.items, idx);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle selection of an item
|
||||
* @param {string} id
|
||||
@@ -493,7 +832,13 @@ const photosView = {
|
||||
const bar_delete = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-delete'));
|
||||
if (bar_delete) {
|
||||
bar_delete.onclick = async () => {
|
||||
if (!confirm('Delete selected items?')) return;
|
||||
const ok = await Modal.confirmDialog({
|
||||
title: i18n.t('photos.delete_title'),
|
||||
message: i18n.t('photos.delete_selected_confirm'),
|
||||
confirmText: i18n.t('actions.delete'),
|
||||
icon: 'fa-trash'
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// One batch request per chunk instead of one DELETE per photo.
|
||||
// The photos view is files-only, so every id is a file id.
|
||||
@@ -526,7 +871,6 @@ const photosView = {
|
||||
if (trashed.size > 0) {
|
||||
this.items = this.items.filter((f) => !trashed.has(f.id));
|
||||
for (const id of trashed) this.selected.delete(id);
|
||||
this._renderedCount = 0;
|
||||
this._renderFull();
|
||||
}
|
||||
// Refresh (or hide) the bar to reflect any items left selected.
|
||||
@@ -571,9 +915,13 @@ const photosView = {
|
||||
},
|
||||
|
||||
_destroyObserver() {
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
this._observer = null;
|
||||
if (this._materializeObserver) {
|
||||
this._materializeObserver.disconnect();
|
||||
this._materializeObserver = null;
|
||||
}
|
||||
if (this._sentinelObserver) {
|
||||
this._sentinelObserver.disconnect();
|
||||
this._sentinelObserver = null;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* original streams in only on demand via the toolbar expand button.
|
||||
*/
|
||||
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { favorites } from '../library/favorites.js';
|
||||
|
||||
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
|
||||
@@ -35,6 +37,23 @@ export const photosLightbox = {
|
||||
*/
|
||||
_showGeneration: 0,
|
||||
|
||||
/** @type {number} Current zoom factor (1 = fit) */
|
||||
_zoom: 1,
|
||||
/** @type {number} */
|
||||
_panX: 0,
|
||||
/** @type {number} */
|
||||
_panY: 0,
|
||||
/** @type {Map<number, {x: number, y: number}>} Active pointers (for pinch) */
|
||||
_pointers: new Map(),
|
||||
/** @type {number} */
|
||||
_pinchStartDist: 0,
|
||||
/** @type {number} */
|
||||
_pinchStartZoom: 1,
|
||||
/** @type {{x: number, y: number, panX: number, panY: number}|null} */
|
||||
_dragStart: null,
|
||||
/** @type {{x: number, y: number, t: number}|null} */
|
||||
_swipeStart: null,
|
||||
|
||||
/**
|
||||
* Register the photosView reference (called from photos.js to avoid circular imports).
|
||||
* @param {any} pv
|
||||
@@ -91,6 +110,7 @@ export const photosLightbox = {
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
this._resetZoom();
|
||||
this._unbindKeys();
|
||||
},
|
||||
|
||||
@@ -127,11 +147,13 @@ export const photosLightbox = {
|
||||
<button class="lightbox-nav lightbox-next"><i class="fas fa-chevron-right"></i></button>
|
||||
<div class="lightbox-toolbar">
|
||||
<button class="lb-fullres hidden" title="Full resolution"><i class="fas fa-expand"></i></button>
|
||||
<button class="lb-info" title="Info"><i class="fas fa-circle-info"></i></button>
|
||||
<button class="lb-download" title="Download"><i class="fas fa-download"></i></button>
|
||||
<button class="lb-favorite" title="Favorite"><i class="far fa-star"></i></button>
|
||||
<button class="lb-delete" title="Delete"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="lightbox-counter"></div>
|
||||
<div class="lightbox-infopanel hidden"></div>
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
this._overlay = el;
|
||||
@@ -149,6 +171,7 @@ export const photosLightbox = {
|
||||
});
|
||||
|
||||
// Toolbar actions (`.lb-fullres` is wired per-item in `_show`)
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-info')).onclick = () => this._toggleInfoPanel();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
|
||||
@@ -172,6 +195,7 @@ export const photosLightbox = {
|
||||
_show() {
|
||||
if (!this._overlay || this.index < 0) return;
|
||||
const generation = ++this._showGeneration;
|
||||
this._resetZoom();
|
||||
|
||||
const item = this.items[this.index];
|
||||
const content = this._overlay.querySelector('.lightbox-content');
|
||||
@@ -182,6 +206,15 @@ export const photosLightbox = {
|
||||
filename.textContent = item.name;
|
||||
counter.textContent = `${this.index + 1} / ${this.items.length}`;
|
||||
|
||||
// Reflect the current favorite state on the toolbar star.
|
||||
const favBtn = this._overlay.querySelector('.lb-favorite');
|
||||
if (favBtn) {
|
||||
const isFav = favorites.isFavorite(item.id, 'file');
|
||||
favBtn.classList.toggle('active', isFav);
|
||||
const favIcon = favBtn.querySelector('i');
|
||||
if (favIcon) favIcon.className = isFav ? 'fas fa-star' : 'far fa-star';
|
||||
}
|
||||
|
||||
// Format date
|
||||
const ts = (item.sort_date || item.created_at) * 1000;
|
||||
const dateStr = new Date(ts).toLocaleDateString(undefined, {
|
||||
@@ -235,6 +268,7 @@ export const photosLightbox = {
|
||||
let showingOriginal = isGif;
|
||||
const img = document.createElement('img');
|
||||
img.alt = item.name;
|
||||
this._wireZoomPan(img);
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
if (generation !== this._showGeneration) return;
|
||||
@@ -297,8 +331,7 @@ export const photosLightbox = {
|
||||
parts.push(`${metadata.width}×${metadata.height}`);
|
||||
}
|
||||
metaEl.textContent = parts.join(' · ');
|
||||
|
||||
//TODO: add geoloc pointer to openstreetmap ?
|
||||
this._fillInfoPanel(metadata, dateStr, sizeStr);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Non-critical, keep existing meta
|
||||
@@ -317,23 +350,25 @@ export const photosLightbox = {
|
||||
a.remove();
|
||||
},
|
||||
|
||||
/** Toggle favorite on current item */
|
||||
/** Toggle favorite on current item (via the favorites module so its
|
||||
* cache stays in sync — the lightbox can then show the right initial
|
||||
* star next time the item is opened). */
|
||||
async _toggleFavorite() {
|
||||
const item = this.items[this.index];
|
||||
if (!item || !favorites) return;
|
||||
if (!item) return;
|
||||
const isFav = favorites.isFavorite(item.id, 'file');
|
||||
try {
|
||||
await fetch(`/api/favorites/file/${item.id}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this._headers()
|
||||
});
|
||||
const btn = this._overlay.querySelector('.lb-favorite');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active');
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = btn.classList.contains('active') ? 'fas fa-star' : 'far fa-star';
|
||||
if (isFav) {
|
||||
await favorites.removeFromFavorites(item.id, 'file', item.name);
|
||||
} else {
|
||||
await favorites.addToFavorites(item.id, item.name, 'file', null);
|
||||
}
|
||||
const btn = this._overlay?.querySelector('.lb-favorite');
|
||||
if (btn) {
|
||||
const nowFav = !isFav;
|
||||
btn.classList.toggle('active', nowFav);
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) icon.className = nowFav ? 'fas fa-star' : 'far fa-star';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Favorite toggle failed:', err);
|
||||
@@ -344,7 +379,13 @@ export const photosLightbox = {
|
||||
async _delete() {
|
||||
const item = this.items[this.index];
|
||||
if (!item) return;
|
||||
if (!confirm(`Delete ${item.name}?`)) return;
|
||||
const ok = await Modal.confirmDialog({
|
||||
title: i18n.t('photos.delete_title'),
|
||||
message: i18n.t('photos.delete_one_confirm', { name: item.name }),
|
||||
confirmText: i18n.t('actions.delete'),
|
||||
icon: 'fa-trash'
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/files/${item.id}`, {
|
||||
@@ -370,6 +411,156 @@ export const photosLightbox = {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Zoom / pan / swipe ──────────────────────────────────────────
|
||||
|
||||
/** @returns {HTMLImageElement|null} The image element currently shown. */
|
||||
_currentImg() {
|
||||
return /** @type {HTMLImageElement|null} */ (this._overlay?.querySelector('.lightbox-content img') || null);
|
||||
},
|
||||
|
||||
/** Reset zoom/pan state (per item and on close). */
|
||||
_resetZoom() {
|
||||
this._zoom = 1;
|
||||
this._panX = 0;
|
||||
this._panY = 0;
|
||||
this._pointers.clear();
|
||||
this._pinchStartDist = 0;
|
||||
this._dragStart = null;
|
||||
this._swipeStart = null;
|
||||
},
|
||||
|
||||
_applyTransform() {
|
||||
const img = this._currentImg();
|
||||
if (img) img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${this._zoom})`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the zoom factor (clamped 1–5), centered. Resets pan at 1.
|
||||
* @param {number} z
|
||||
*/
|
||||
_setZoom(z) {
|
||||
z = Math.min(Math.max(z, 1), 5);
|
||||
if (z === 1) {
|
||||
this._panX = 0;
|
||||
this._panY = 0;
|
||||
}
|
||||
this._zoom = z;
|
||||
this._applyTransform();
|
||||
const img = this._currentImg();
|
||||
if (img) img.classList.toggle('is-zoomed', z > 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Wire wheel-zoom, double-click zoom, drag-pan, pinch-zoom and (when not
|
||||
* zoomed) touch swipe-to-navigate onto a photo element.
|
||||
* @param {HTMLImageElement} img
|
||||
*/
|
||||
_wireZoomPan(img) {
|
||||
img.style.transformOrigin = 'center center';
|
||||
img.style.touchAction = 'none';
|
||||
|
||||
img.addEventListener(
|
||||
'wheel',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
this._setZoom(this._zoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2));
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
img.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
this._setZoom(this._zoom > 1 ? 1 : 2.5);
|
||||
});
|
||||
|
||||
img.addEventListener('pointerdown', (e) => {
|
||||
img.setPointerCapture?.(e.pointerId);
|
||||
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (this._pointers.size === 2) {
|
||||
const pts = [...this._pointers.values()];
|
||||
this._pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
this._pinchStartZoom = this._zoom;
|
||||
} else {
|
||||
this._dragStart = { x: e.clientX, y: e.clientY, panX: this._panX, panY: this._panY };
|
||||
this._swipeStart = { x: e.clientX, y: e.clientY, t: Date.now() };
|
||||
}
|
||||
});
|
||||
|
||||
img.addEventListener('pointermove', (e) => {
|
||||
if (!this._pointers.has(e.pointerId)) return;
|
||||
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (this._pointers.size === 2 && this._pinchStartDist > 0) {
|
||||
const pts = [...this._pointers.values()];
|
||||
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
this._setZoom(this._pinchStartZoom * (dist / this._pinchStartDist));
|
||||
} else if (this._zoom > 1 && this._dragStart) {
|
||||
this._panX = this._dragStart.panX + (e.clientX - this._dragStart.x);
|
||||
this._panY = this._dragStart.panY + (e.clientY - this._dragStart.y);
|
||||
this._applyTransform();
|
||||
}
|
||||
});
|
||||
|
||||
const endPointer = (/** @type {PointerEvent} */ e) => {
|
||||
const wasPinch = this._pointers.size === 2;
|
||||
this._pointers.delete(e.pointerId);
|
||||
if (!wasPinch && this._zoom === 1 && this._swipeStart && e.pointerType === 'touch') {
|
||||
const dx = e.clientX - this._swipeStart.x;
|
||||
const dy = e.clientY - this._swipeStart.y;
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||
if (dx > 0) this.prev();
|
||||
else this.next();
|
||||
}
|
||||
}
|
||||
if (wasPinch) this._pinchStartDist = 0;
|
||||
this._dragStart = null;
|
||||
this._swipeStart = null;
|
||||
};
|
||||
img.addEventListener('pointerup', endPointer);
|
||||
img.addEventListener('pointercancel', endPointer);
|
||||
},
|
||||
|
||||
// ── Info panel ──────────────────────────────────────────────────
|
||||
|
||||
/** Toggle the EXIF info panel. */
|
||||
_toggleInfoPanel() {
|
||||
this._overlay?.querySelector('.lightbox-infopanel')?.classList.toggle('hidden');
|
||||
},
|
||||
|
||||
/**
|
||||
* Populate the info panel from fetched EXIF metadata.
|
||||
* @param {FileMetadata} metadata
|
||||
* @param {string} dateStr
|
||||
* @param {string} sizeStr
|
||||
*/
|
||||
_fillInfoPanel(metadata, dateStr, sizeStr) {
|
||||
const panel = this._overlay?.querySelector('.lightbox-infopanel');
|
||||
if (!panel) return;
|
||||
const item = this.items[this.index];
|
||||
const rows = [this._infoRow('fa-image', item?.name || ''), this._infoRow('fa-calendar', dateStr)];
|
||||
if (sizeStr) rows.push(this._infoRow('fa-hard-drive', sizeStr));
|
||||
if (metadata.width && metadata.height) {
|
||||
rows.push(this._infoRow('fa-ruler-combined', `${metadata.width} × ${metadata.height}`));
|
||||
}
|
||||
if (metadata.camera_make || metadata.camera_model) {
|
||||
rows.push(this._infoRow('fa-camera', [metadata.camera_make, metadata.camera_model].filter(Boolean).join(' ')));
|
||||
}
|
||||
if (metadata.latitude != null && metadata.longitude != null) {
|
||||
rows.push(this._infoRow('fa-location-dot', `${metadata.latitude.toFixed(5)}, ${metadata.longitude.toFixed(5)}`));
|
||||
}
|
||||
panel.innerHTML = rows.join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} icon FontAwesome class
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
_infoRow(icon, text) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = text;
|
||||
return `<div class="lb-info-row"><i class="fas ${icon}"></i><span>${d.innerHTML}</span></div>`;
|
||||
},
|
||||
|
||||
/** Keyboard navigation */
|
||||
_bindKeys() {
|
||||
this._keyHandler = (e) => {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* OxiCloud - Places (photo map)
|
||||
*
|
||||
* Renders the user's geotagged photos on a self-hosted MapLibre GL map.
|
||||
* Photos are clustered *server-side* (GET /api/photos/geo, grid aggregation),
|
||||
* so we draw one lightweight HTML marker per cluster — no glyph/sprite assets
|
||||
* and no client-side clustering needed. The vector basemap is optional: if a
|
||||
* `static/basemaps/basemap.pmtiles` is present it is read directly by the
|
||||
* browser over HTTP Range (pmtiles.js); otherwise the map falls back to a
|
||||
* plain themed background and still shows the photo clusters.
|
||||
*
|
||||
* MapLibre + pmtiles.js are heavy, so they are vendored and lazy-loaded only
|
||||
* when the Places tab is first opened.
|
||||
*/
|
||||
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { peopleView } from './people.js';
|
||||
import { photosView } from './photos.js';
|
||||
import { photosLightbox } from './photosLightbox.js';
|
||||
|
||||
/** @import {FileItem} from '../../core/types.js' */
|
||||
/** @typedef {{lng: number, lat: number, count: number, sample_file_id: string}} GeoClusterItem */
|
||||
|
||||
const BASEMAP_URL = '/basemaps/basemap.pmtiles';
|
||||
|
||||
export const placesView = {
|
||||
/** @type {HTMLElement|null} */
|
||||
_container: null,
|
||||
/** @type {HTMLElement|null} */
|
||||
_subnav: null,
|
||||
/** @type {any} MapLibre Map instance */
|
||||
_map: null,
|
||||
/** @type {any[]} current cluster markers */
|
||||
_markers: [],
|
||||
/** @type {{maplibregl: any, pmtiles: any}|null} */
|
||||
_libs: null,
|
||||
/** @type {number} debounce timer for moveend refresh */
|
||||
_moveTimer: 0,
|
||||
/** @type {'moments'|'places'|'people'} */
|
||||
_activeTab: 'moments',
|
||||
/** @type {boolean|null} cached basemap availability */
|
||||
_hasBasemap: null,
|
||||
|
||||
/** Auth headers (HttpOnly cookies + CSRF) */
|
||||
_headers() {
|
||||
return getCsrfHeaders();
|
||||
},
|
||||
|
||||
// ── Sub-navigation (Moments | Places) ───────────────────────────
|
||||
// Lives at the top of `.content-area`; mounted while the Photos section
|
||||
// is active and torn down (hidden) when the user leaves it.
|
||||
|
||||
/** Create/show the Moments|Places tab bar and the map container. */
|
||||
mountTabs() {
|
||||
const contentArea = document.querySelector('.content-area');
|
||||
if (!contentArea) return;
|
||||
|
||||
if (!this._subnav) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'photos-subnav';
|
||||
bar.innerHTML =
|
||||
`<button class="photos-subnav-tab active" type="button" data-ptab="moments">${this._esc(i18n.t('photos.tab_moments'))}</button>` +
|
||||
`<button class="photos-subnav-tab" type="button" data-ptab="places">${this._esc(i18n.t('photos.tab_places'))}</button>` +
|
||||
`<button class="photos-subnav-tab hidden" type="button" data-ptab="people">${this._esc(i18n.t('photos.tab_people'))}</button>`;
|
||||
bar.addEventListener('click', (e) => {
|
||||
const btn = /** @type {HTMLElement} */ (e.target).closest('[data-ptab]');
|
||||
if (btn) this._switchTab(/** @type {'moments'|'places'|'people'} */ (btn.getAttribute('data-ptab')));
|
||||
});
|
||||
contentArea.insertBefore(bar, contentArea.firstChild);
|
||||
this._subnav = bar;
|
||||
this._probePeople();
|
||||
}
|
||||
this._subnav.classList.remove('hidden');
|
||||
|
||||
if (!this._container) {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'places-container';
|
||||
el.className = 'places-container';
|
||||
contentArea.appendChild(el);
|
||||
this._container = el;
|
||||
}
|
||||
|
||||
// Always (re)enter the Photos section on the Moments tab.
|
||||
this._activeTab = 'moments';
|
||||
this._setActiveTab('moments');
|
||||
this.hide();
|
||||
peopleView.hide();
|
||||
},
|
||||
|
||||
/** Reveal the People tab only if GET /api/people is available (faces on). */
|
||||
async _probePeople() {
|
||||
try {
|
||||
const res = await fetch('/api/people', { credentials: 'include', headers: getCsrfHeaders() });
|
||||
if (res.ok) {
|
||||
this._subnav?.querySelector('[data-ptab="people"]')?.classList.remove('hidden');
|
||||
}
|
||||
} catch {
|
||||
/* leave the People tab hidden */
|
||||
}
|
||||
},
|
||||
|
||||
/** Hide the tab bar and the map (called when leaving the Photos section). */
|
||||
unmountTabs() {
|
||||
this._subnav?.classList.add('hidden');
|
||||
this.hide();
|
||||
peopleView.hide();
|
||||
},
|
||||
|
||||
/** Hide the map container (without destroying the map). */
|
||||
hide() {
|
||||
this._container?.classList.remove('active');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {'moments'|'places'|'people'} tab
|
||||
*/
|
||||
_switchTab(tab) {
|
||||
if (tab === this._activeTab) return;
|
||||
this._activeTab = tab;
|
||||
this._setActiveTab(tab);
|
||||
// Hide all three views, then show the selected one.
|
||||
photosView.hide();
|
||||
this.hide();
|
||||
peopleView.hide();
|
||||
if (tab === 'places') this._showMap();
|
||||
else if (tab === 'people') peopleView.show();
|
||||
else photosView.show();
|
||||
},
|
||||
|
||||
/** @param {string} tab */
|
||||
_setActiveTab(tab) {
|
||||
this._subnav?.querySelectorAll('[data-ptab]').forEach((b) => {
|
||||
b.classList.toggle('active', b.getAttribute('data-ptab') === tab);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Map ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Reveal the map container and (lazily) build the map. */
|
||||
async _showMap() {
|
||||
if (!this._container) return;
|
||||
this._container.classList.add('active');
|
||||
|
||||
if (this._map) {
|
||||
this._map.resize();
|
||||
this._refreshClusters(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this._container.innerHTML = '<div class="places-map" id="places-map"></div>' + '<div class="places-loading"><i class="fas fa-spinner"></i></div>';
|
||||
try {
|
||||
const libs = await this._loadLibs();
|
||||
await this._initMap(libs);
|
||||
} catch (err) {
|
||||
console.error('Places map failed to load:', err);
|
||||
if (this._container) {
|
||||
this._container.innerHTML = `<div class="places-error">${this._esc(i18n.t('photos.map_error'))}</div>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Inject a vendored script once, resolving when it has loaded.
|
||||
* @param {string} src
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[data-vendor="${src}"]`)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.dataset.vendor = src;
|
||||
s.addEventListener('load', () => resolve());
|
||||
s.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
},
|
||||
|
||||
/** Lazy-load MapLibre GL + pmtiles.js (+ MapLibre CSS) and read their globals. */
|
||||
async _loadLibs() {
|
||||
if (this._libs) return this._libs;
|
||||
if (!document.querySelector('link[data-vendor="maplibre-css"]')) {
|
||||
const l = document.createElement('link');
|
||||
l.rel = 'stylesheet';
|
||||
l.href = '/js/vendors/maplibre-gl.css';
|
||||
l.dataset.vendor = 'maplibre-css';
|
||||
document.head.appendChild(l);
|
||||
}
|
||||
await this._loadScript('/js/vendors/maplibre-gl.js');
|
||||
await this._loadScript('/js/vendors/pmtiles.js');
|
||||
const w = /** @type {any} */ (window);
|
||||
this._libs = { maplibregl: w.maplibregl, pmtiles: w.pmtiles };
|
||||
return this._libs;
|
||||
},
|
||||
|
||||
/** Whether a basemap .pmtiles is available (cached after first probe). */
|
||||
async _checkBasemap() {
|
||||
if (this._hasBasemap !== null) return this._hasBasemap;
|
||||
try {
|
||||
const res = await fetch(BASEMAP_URL, { headers: { Range: 'bytes=0-0' } });
|
||||
this._hasBasemap = res.ok; // 200/206 = present, 404 = absent
|
||||
} catch {
|
||||
this._hasBasemap = false;
|
||||
}
|
||||
return this._hasBasemap;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{maplibregl: any, pmtiles: any}} libs
|
||||
*/
|
||||
async _initMap({ maplibregl, pmtiles }) {
|
||||
const hasBasemap = await this._checkBasemap();
|
||||
if (hasBasemap) {
|
||||
try {
|
||||
const protocol = new pmtiles.Protocol();
|
||||
maplibregl.addProtocol('pmtiles', protocol.tile);
|
||||
} catch (e) {
|
||||
console.error('pmtiles protocol registration failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this._map = new maplibregl.Map({
|
||||
container: 'places-map',
|
||||
style: hasBasemap ? this._basemapStyle() : this._blankStyle(),
|
||||
center: [0, 25],
|
||||
zoom: 1.3,
|
||||
attributionControl: false
|
||||
});
|
||||
this._map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
||||
if (hasBasemap) {
|
||||
this._map.addControl(
|
||||
new maplibregl.AttributionControl({
|
||||
customAttribution: 'Protomaps © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this._map.on('load', () => {
|
||||
this._removeLoading();
|
||||
this._refreshClusters(true);
|
||||
});
|
||||
this._map.on('moveend', () => {
|
||||
clearTimeout(this._moveTimer);
|
||||
this._moveTimer = window.setTimeout(() => this._refreshClusters(false), 250);
|
||||
});
|
||||
},
|
||||
|
||||
_removeLoading() {
|
||||
this._container?.querySelector('.places-loading')?.remove();
|
||||
},
|
||||
|
||||
/** Fetch clusters for the current viewport and render them.
|
||||
* @param {boolean} fit Fit the map to the returned clusters (first load).
|
||||
*/
|
||||
async _refreshClusters(fit) {
|
||||
if (!this._map) return;
|
||||
const b = this._map.getBounds();
|
||||
const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`;
|
||||
const zoom = Math.round(this._map.getZoom());
|
||||
try {
|
||||
const res = await fetch(`/api/photos/geo?bbox=${bbox}&zoom=${zoom}`, {
|
||||
credentials: 'include',
|
||||
headers: this._headers()
|
||||
});
|
||||
if (!res.ok) return;
|
||||
/** @type {GeoClusterItem[]} */
|
||||
const clusters = await res.json();
|
||||
this._renderMarkers(clusters);
|
||||
if (fit && clusters.length) this._fitTo(clusters);
|
||||
} catch (err) {
|
||||
console.error('Places geo fetch failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {GeoClusterItem[]} clusters */
|
||||
_renderMarkers(clusters) {
|
||||
for (const m of this._markers) m.remove();
|
||||
this._markers = [];
|
||||
if (!this._libs) return;
|
||||
const { maplibregl } = this._libs;
|
||||
|
||||
for (const c of clusters) {
|
||||
const size = Math.round(Math.min(64, 30 + Math.log2(c.count + 1) * 6));
|
||||
const el = document.createElement('div');
|
||||
el.className = 'places-cluster';
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
el.style.backgroundImage = `url(/api/files/${c.sample_file_id}/thumbnail/icon)`;
|
||||
if (c.count > 1) {
|
||||
el.innerHTML = `<span class="places-cluster-count">${c.count}</span>`;
|
||||
}
|
||||
el.addEventListener('click', () => this._onClusterClick(c));
|
||||
const marker = new maplibregl.Marker({ element: el }).setLngLat([c.lng, c.lat]).addTo(this._map);
|
||||
this._markers.push(marker);
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {GeoClusterItem} c */
|
||||
_onClusterClick(c) {
|
||||
const zoom = this._map.getZoom();
|
||||
if (c.count === 1 || zoom >= 16) {
|
||||
// Drill down to the representative photo. We only know its id, so
|
||||
// build a minimal item and let the lightbox load the rest.
|
||||
const item = /** @type {FileItem} */ (
|
||||
/** @type {any} */ ({
|
||||
id: c.sample_file_id,
|
||||
name: '',
|
||||
mime_type: 'image/jpeg',
|
||||
created_at: 0,
|
||||
sort_date: 0,
|
||||
size_formatted: ''
|
||||
})
|
||||
);
|
||||
photosLightbox.open([item], 0);
|
||||
} else {
|
||||
this._map.easeTo({ center: [c.lng, c.lat], zoom: Math.min(zoom + 2.5, 17) });
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {GeoClusterItem[]} clusters */
|
||||
_fitTo(clusters) {
|
||||
if (!this._libs) return;
|
||||
const { maplibregl } = this._libs;
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
for (const c of clusters) bounds.extend([c.lng, c.lat]);
|
||||
if (!bounds.isEmpty()) {
|
||||
this._map.fitBounds(bounds, { padding: 64, maxZoom: 14, duration: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
/** @returns {boolean} */
|
||||
_isDark() {
|
||||
return document.documentElement.getAttribute('data-color-scheme') === 'dark';
|
||||
},
|
||||
|
||||
/** Minimal MapLibre style: themed background only (no basemap). */
|
||||
_blankStyle() {
|
||||
return {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [
|
||||
{
|
||||
id: 'bg',
|
||||
type: 'background',
|
||||
paint: { 'background-color': this._isDark() ? '#0f172a' : '#e8eef3' }
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
/** Label-light Protomaps vector style (no glyphs/sprites required). */
|
||||
_basemapStyle() {
|
||||
const dark = this._isDark();
|
||||
const c = dark
|
||||
? { earth: '#1b2433', land: '#222d3d', water: '#0d1b2a', roads: '#3a4860', buildings: '#2a3547', boundary: '#475569' }
|
||||
: { earth: '#f3efe9', land: '#e9e4da', water: '#a8c8e8', roads: '#ffffff', buildings: '#e0dccf', boundary: '#c9c2b6' };
|
||||
return {
|
||||
version: 8,
|
||||
sources: {
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
url: `pmtiles://${BASEMAP_URL}`,
|
||||
attribution: 'Protomaps © OpenStreetMap'
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{ id: 'bg', type: 'background', paint: { 'background-color': c.earth } },
|
||||
{ id: 'earth', type: 'fill', source: 'protomaps', 'source-layer': 'earth', paint: { 'fill-color': c.earth } },
|
||||
{ id: 'landuse', type: 'fill', source: 'protomaps', 'source-layer': 'landuse', paint: { 'fill-color': c.land, 'fill-opacity': 0.6 } },
|
||||
{ id: 'water', type: 'fill', source: 'protomaps', 'source-layer': 'water', paint: { 'fill-color': c.water } },
|
||||
{ id: 'roads', type: 'line', source: 'protomaps', 'source-layer': 'roads', minzoom: 7, paint: { 'line-color': c.roads, 'line-width': 0.8 } },
|
||||
{ id: 'buildings', type: 'fill', source: 'protomaps', 'source-layer': 'buildings', minzoom: 13, paint: { 'fill-color': c.buildings } },
|
||||
{
|
||||
id: 'boundaries',
|
||||
type: 'line',
|
||||
source: 'protomaps',
|
||||
'source-layer': 'boundaries',
|
||||
paint: { 'line-color': c.boundary, 'line-width': 0.6, 'line-dasharray': [2, 2] }
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
};
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
maplibre-gl 5.24.0
|
||||
pmtiles 4.4.1
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+59
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
+19
-1
@@ -68,7 +68,25 @@
|
||||
"items_selected": "selected",
|
||||
"view_daily": "Day",
|
||||
"view_monthly": "Month",
|
||||
"view_yearly": "Year"
|
||||
"view_yearly": "Year",
|
||||
"delete_title": "Move to Trash",
|
||||
"delete_selected_confirm": "Move the selected items to Trash?",
|
||||
"delete_one_confirm": "Move \"{{name}}\" to Trash?",
|
||||
"layout_square": "Grid",
|
||||
"layout_justified": "Justified",
|
||||
"tab_moments": "Moments",
|
||||
"tab_places": "Places",
|
||||
"tab_people": "People",
|
||||
"map_loading": "Loading map…",
|
||||
"map_error": "Could not load the map"
|
||||
},
|
||||
"people": {
|
||||
"unnamed": "Unnamed",
|
||||
"empty": "No people yet",
|
||||
"disabled": "Face recognition is disabled",
|
||||
"rename_title": "Name this person",
|
||||
"name_label": "Name",
|
||||
"back": "Back"
|
||||
},
|
||||
"music": {
|
||||
"create_playlist": "Create Playlist",
|
||||
|
||||
Reference in New Issue
Block a user