Records the real SCRFD+ArcFace analyzer behind the faces-onnx feature, the FacesConfig env vars, and the deviation (unit-tested geometry split from the feature-gated ONNX seam). Notes what stays optional: per-user consent gate, lightbox face boxes, and a periodic re-cluster scheduler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
26 KiB
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:
- Gallery polish — performance + modern UX (timeline virtualization already landed).
- Places — a map of geotagged photos. Most of the data already exists.
- 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/vectorchordPostgres extension, and runtime-downloaded ML models) need explicit sign-off — see §Vendoring & dependencies and §Open decisions. Per repo rules: never hand-editCargo.lock(usecargo 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.pmtilesbasemap read over HTTP Range via the existingServeDir(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
pmtilesRust crate; 1.8 uses MapLibre HTML markers instead of a deck.glIconLayer. Both keep the footprint minimal and need zero new backend code. - Pending: browser smoke-test, and an operator-supplied
static/basemaps/basemap.pmtilesfor the street backdrop (works without it).
Phase 2 — People: complete (detector/embedder shipped, opt-in).
- Migration (
…_faces.sql):facesschema withfaces.persons+faces.faces. Deviation from 2.2: embeddings stored asBYTEA(512×f32little-endian), nopgvector— cosine similarity runs in Rust. This keeps the extension footprint at today'spg_trgm/ltree/citextand 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(singleanalyze(&[u8]) -> Vec<DetectedFace>+is_ready()) andFaceRepository(face_ports.rs). Deviation from 2.3: detector+embedder collapsed into oneFaceAnalyzerPort(the analyzer owns detect→align→embed) instead of splitFaceDetectorPort/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_faces3, immich-style), plus list/photos/rename/hide/merge/delete. "List my own people" needs noauthz.require(user-scoped, likeRecentService/PlacesService). - Indexing:
FaceIndexingServiceimplementsFileLifecycleHook— 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 thefaces-onnxcargo feature) is the real SCRFD+ArcFace pipeline;di::build_face_analyzerpicks 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 inface_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).ortuses load-dynamic solibonnxruntimeis dlopen'd at runtime and the crate builds without it; loading goes throughort::init_from(fallible) not ORT's lazy loader, which wouldpanicunderpanic = "abort". - HTTP:
people_handler.rs+ routes (gated onpeople_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 viaModal.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 inen.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 inexample.env). To run faces: build--features faces-onnx, setOXICLOUD_ENABLE_FACES=true, and point the three model/runtime paths at an operator-supplied ONNX Runtime + SCRFD detector + ArcFace embedder (e.g. InsightFacebuffalo_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 thepmtilesRust 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 (onlypg_trgm/ltree/citextare enabled today). - Gotchas: self-host glyphs+sprites (not the Protomaps CDN); ODbL attribution
"Protomaps © OpenStreetMap" is mandatory; dark-mode via
@protomaps/basemapsflavors.
Faces (self-host, precision, CPU-first):
- Runtime:
ort(ONNX Runtime).candlecan't run SCRFD/RetinaFace (missingResizeop);tractis 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_lweights 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 ≥3to 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:
#[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 pointerat line ~301): whenlatitude/longitudepresent, 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 asyncModal.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
-- 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):
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):
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):
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
pmtilescrate (cargo add pmtiles). - Ship a
.pmtilesbasemap (config: path; default global z0–6 ≈ 60 MB) + self-hosted glyphs and sprites understatic/(frombasemaps-assets). src/interfaces/api/handlers/basemap_handler.rs(new): open the reader once (AsyncPmTilesReader::new_with_path,ArcintoAppState), serveGET /api/basemap/{z}/{x}/{y}.mvt(reader.get_tile(...)). Alt: serve the raw.pmtilesover Range and letpmtiles.jsdo 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 injectscaller_id; handler does no AuthZ — service does).GET /api/photos/geo/cell?bbox=…→ photos in a cell (opens lightbox). Register insrc/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/basemapsstyle 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 thepmtiles://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—GeoClustertypedef.- 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
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):
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 (mirrorthumbnail_service.rs/image_transcode_service.rsto avoid starving Tokio). Pipeline: detect → 5-point similarity align to 112×112 → embed → L2-normalize. ImplementsFaceDetectorPort+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
ortexecution 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) implementsFileLifecycleHook(same pattern asmedia_metadata_service.rs): on image create → decode (reuse decode path) → detect → quality-gate (score ≥0.7, face ≥50–80px, Laplacian blur) → embed → store. Dedup byblob_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 ≥3to 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 indi.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,Facetypedefs.- 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)
- 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. - Basemap extent / hosting: global z0–6 (~60 MB, simplest) vs regional extract vs full planet (~120 GB) — and store path / how shipped.
- Vector store start:
pgvectornow (simplest) vsVectorChordfrom day one (immich's scaled choice). - Map thumbnails: start with count bubbles (MapLibre only) vs deck.gl
IconLayerfrom the start. - HEIC: in scope for Phase 0, or deferred (native
libheifdep)? - 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.