Compare commits
10 Commits
7f273fdfe3
...
f60f66d6e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
f60f66d6e2
|
|||
|
0fcb94e0b8
|
|||
|
409c8fd86b
|
|||
|
f5fa5f4f6b
|
|||
|
23c3836510
|
|||
|
166e682880
|
|||
|
cc0a59995a
|
|||
|
2c3192fea9
|
|||
|
caf60c0ff0
|
|||
|
81a9044870
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,7 +17,6 @@
|
||||
/dist/
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
.zed/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
295
Cargo.lock
generated
295
Cargo.lock
generated
@@ -430,12 +430,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -1061,10 +1055,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1074,11 +1066,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1282,24 +1272,6 @@ dependencies = [
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1308,21 +1280,13 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1504,22 +1468,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -1637,12 +1585,6 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.15.7"
|
||||
@@ -2236,61 +2178,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -2480,6 +2367,7 @@ dependencies = [
|
||||
name = "rbv-cluster"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
"rbv-entity",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
@@ -2560,11 +2448,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"rbv-entity",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2627,46 +2511,6 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2701,12 +2545,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
@@ -2760,7 +2598,6 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3277,9 +3114,6 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -3550,14 +3384,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3650,12 +3482,6 @@ dependencies = [
|
||||
"tracing-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -3818,15 +3644,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -3870,20 +3687,6 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.114"
|
||||
@@ -3950,26 +3753,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
@@ -4112,15 +3895,6 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4154,30 +3928,13 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -4190,12 +3947,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -4208,12 +3959,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -4226,24 +3971,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -4256,12 +3989,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -4274,12 +4001,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -4292,12 +4013,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -4310,12 +4025,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -27,9 +27,6 @@ serde_json = "1"
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# HTTP client (for ML API)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["multipart", "json", "rustls-tls"] }
|
||||
|
||||
# Web framework
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tower = "0.5"
|
||||
@@ -64,6 +61,7 @@ anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
glob = "0.3"
|
||||
rand = "0.8"
|
||||
rayon = "1"
|
||||
hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto", "http1", "http2"] }
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
|
||||
|
||||
15
README.md
15
README.md
@@ -1,10 +1,10 @@
|
||||
# rbv
|
||||
|
||||
Personal photo library indexer with facial recognition and semantic search.
|
||||
Extracts CLIP embeddings and face detections from image galleries via an
|
||||
[immich-ml](https://github.com/immich-app/immich) compatible ML API, clusters
|
||||
faces into person identities, and serves results through a mTLS-authenticated
|
||||
HTTPS API with a React web UI.
|
||||
Extracts CLIP embeddings and face detections from image galleries using local
|
||||
ONNX inference (ViT-B-32 CLIP + SCRFD/ArcFace), clusters faces into person
|
||||
identities, and serves results through a mTLS-authenticated HTTPS API with a
|
||||
React web UI.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
@@ -15,7 +15,8 @@ crates/
|
||||
rbv-entity shared type definitions (no logic)
|
||||
rbv-hash BLAKE3 content-addressed ID generation
|
||||
rbv-data database access layer (sqlx + pgvector)
|
||||
rbv-ml ML API client (immich-ml wire format)
|
||||
rbv-ml ML backend trait and shared types
|
||||
rbv-infer local ONNX inference (CLIP, SCRFD, ArcFace)
|
||||
rbv-cluster face embedding clustering (DBSCAN / union-find)
|
||||
rbv-ingest gallery discovery and ingest pipeline
|
||||
rbv-auth mTLS validation, argon2 passwords, sessions
|
||||
@@ -43,7 +44,7 @@ rbv-api → rbv-entity, rbv-data, rbv-ml, rbv-auth, rbv-search
|
||||
## Prerequisites
|
||||
|
||||
- PostgreSQL with the `pgvector` extension
|
||||
- An immich-ml compatible ML API (e.g. the immich `machine-learning` container)
|
||||
- ONNX model files (ViT-B-32 CLIP + buffalo_l face models)
|
||||
- Rust toolchain (stable)
|
||||
- Node.js + npm (for the UI)
|
||||
|
||||
@@ -82,7 +83,7 @@ rbv-api \
|
||||
--server-cert /etc/rbv/server.pem \
|
||||
--server-key /etc/rbv/server.key \
|
||||
--database "$DATABASE_URL" \
|
||||
--ml-uri http://127.0.0.1:3003 \
|
||||
--model-dir /path/to/models \
|
||||
--ui-dir /srv/rbv/ui \
|
||||
--face-cache /srv/rbv/cache/faces \
|
||||
[--listen 0.0.0.0:8443] \
|
||||
|
||||
@@ -3,7 +3,7 @@ server {
|
||||
server_name rbv.internal;
|
||||
http2 on;
|
||||
|
||||
ssl_certificate /etc/nginx/tls/rbv/chain.pem;
|
||||
ssl_certificate /etc/nginx/tls/rbv/rbv.pem;
|
||||
ssl_certificate_key /etc/nginx/tls/rbv/key.pem;
|
||||
|
||||
root /usr/share/nginx/rbv;
|
||||
|
||||
@@ -8,7 +8,7 @@ ExecStart=/usr/local/bin/rbv-api \
|
||||
--server-cert /etc/nginx/tls/rbv/rbv.pem \
|
||||
--server-key /etc/nginx/tls/rbv/key.pem \
|
||||
--database postgres://rbv:password@localhost:4432/rbv \
|
||||
--ml-uri http://127.0.0.1:3003
|
||||
--model-dir /tank/containers/immich/ml-cache
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -7,7 +7,9 @@ ConditionFileIsExecutable=/usr/local/bin/rbv
|
||||
Type=oneshot
|
||||
Environment=RUST_LOG=info,ort=off,sqlx::query=off
|
||||
ExecStart=/usr/local/bin/rbv cluster \
|
||||
--database postgres://rbv:password@localhost:4432/rbv
|
||||
--database postgres://rbv:password@localhost:4432/rbv \
|
||||
--concurrency 32 \
|
||||
--threshold 0.55
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -8,9 +8,10 @@ OnSuccess=rbv-cluster.service
|
||||
Environment=RUST_LOG=info,ort=off,sqlx::query=off
|
||||
ExecStart=/usr/local/bin/rbv index \
|
||||
--target /tank/data/rbv/%i \
|
||||
--concurrency 24 \
|
||||
--concurrency 32 \
|
||||
--database postgres://rbv:password@localhost:4432/rbv \
|
||||
--model-dir /tank/containers/immich/ml-cache
|
||||
--model-dir /tank/containers/immich/ml-cache \
|
||||
--face-score-thresh 0.7
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
|
||||
12
asset/systemd/step@.timer
Normal file
12
asset/systemd/step@.timer
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=step cert renew
|
||||
Documentation=https://hackmd.io/@rob-tn/rJvy9YYKWg
|
||||
|
||||
[Timer]
|
||||
Persistent=true
|
||||
OnCalendar=*:1/15
|
||||
AccuracySec=1us
|
||||
RandomizedDelaySec=5m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -24,13 +24,13 @@ pub struct ApiArgs {
|
||||
#[arg(long)]
|
||||
pub database: String,
|
||||
|
||||
/// Base URL of the machine learning API (mutually exclusive with --model-dir)
|
||||
#[arg(long, conflicts_with = "model_dir")]
|
||||
pub ml_uri: Option<String>,
|
||||
/// Path to ONNX model directory for local inference
|
||||
#[arg(long)]
|
||||
pub model_dir: PathBuf,
|
||||
|
||||
/// Path to ONNX model directory for local inference (mutually exclusive with --ml-uri)
|
||||
#[arg(long, conflicts_with = "ml_uri")]
|
||||
pub model_dir: Option<PathBuf>,
|
||||
/// Minimum face detection confidence score (0.0–1.0, default 0.7)
|
||||
#[arg(long)]
|
||||
pub face_score_thresh: Option<f32>,
|
||||
|
||||
/// Address to listen on
|
||||
#[arg(long, default_value = "0.0.0.0:8443")]
|
||||
|
||||
@@ -51,10 +51,19 @@ async fn serve_face_crop(
|
||||
let img = image::load_from_memory(&raw)
|
||||
.map_err(|e| ApiError::internal(format!("image decode: {e}")))?;
|
||||
|
||||
// Bounding box coordinates are relative to the resized image (max 1280px)
|
||||
// that was sent to the ML backend. Resize to match before cropping.
|
||||
const MAX_DIM: u32 = 1280;
|
||||
let img = if img.width() > MAX_DIM || img.height() > MAX_DIM {
|
||||
img.resize(MAX_DIM, MAX_DIM, image::imageops::FilterType::Lanczos3)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let x = x1.max(0) as u32;
|
||||
let y = y1.max(0) as u32;
|
||||
let w = (x2 - x1).max(1) as u32;
|
||||
let h = (y2 - y1).max(1) as u32;
|
||||
let w = ((x2 - x1).max(1) as u32).min(img.width().saturating_sub(x));
|
||||
let h = ((y2 - y1).max(1) as u32).min(img.height().saturating_sub(y));
|
||||
|
||||
let cropped = img.crop_imm(x, y, w, h);
|
||||
let resized = cropped.resize(256, 256, image::imageops::FilterType::Lanczos3);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{extract::{Path, Query, State}, routing::get, Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use rbv_entity::Gallery;
|
||||
use crate::{error::ApiResult, state::AppState};
|
||||
use crate::{error::ApiResult, routes::person::PersonResponse, state::AppState};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -9,6 +9,7 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/random", get(random_galleries))
|
||||
.route("/{id}", get(get_gallery))
|
||||
.route("/{id}/images", get(get_gallery_images))
|
||||
.route("/{id}/persons", get(get_gallery_persons))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -101,3 +102,18 @@ async fn get_gallery_images(
|
||||
})).collect();
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn get_gallery_persons(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<Vec<PersonResponse>>> {
|
||||
let bytes = rbv_hash::from_hex(&id)
|
||||
.map_err(|_| crate::error::ApiError::bad_request("invalid gallery id"))?;
|
||||
let gid = rbv_entity::GalleryId(bytes);
|
||||
let persons = rbv_data::person::get_persons_for_gallery(&state.pool, &gid).await?;
|
||||
let mut out = Vec::with_capacity(persons.len());
|
||||
for p in &persons {
|
||||
out.push(super::person::person_response(&state.pool, p, 0).await?);
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ pub struct PersonResponse {
|
||||
pub primary_name: Option<String>,
|
||||
pub names: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub image_count: i64,
|
||||
}
|
||||
|
||||
async fn person_response(pool: &sqlx::PgPool, person: &Person) -> ApiResult<PersonResponse> {
|
||||
pub async fn person_response(pool: &sqlx::PgPool, person: &Person, image_count: i64) -> ApiResult<PersonResponse> {
|
||||
let name_rows = rbv_data::person::get_person_names(pool, &person.id).await?;
|
||||
let primary = name_rows.iter().find(|n| n.is_primary).map(|n| n.name.clone());
|
||||
let names: Vec<_> = name_rows.into_iter().map(|n| n.name).collect();
|
||||
@@ -39,6 +40,7 @@ async fn person_response(pool: &sqlx::PgPool, person: &Person) -> ApiResult<Pers
|
||||
primary_name: primary,
|
||||
names,
|
||||
created_at: person.created_at.to_rfc3339(),
|
||||
image_count,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ async fn list_persons(
|
||||
let persons = rbv_data::person::get_all_persons_paged(&state.pool, q.page, q.per_page).await?;
|
||||
let mut out = Vec::with_capacity(persons.len());
|
||||
for p in &persons {
|
||||
out.push(person_response(&state.pool, p).await?);
|
||||
out.push(person_response(&state.pool, &p.person, p.image_count).await?);
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
@@ -62,7 +64,8 @@ async fn get_person(
|
||||
let person = rbv_data::person::get_person(&state.pool, &pid)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("person not found"))?;
|
||||
Ok(Json(person_response(&state.pool, &person).await?))
|
||||
let image_count = rbv_data::person::count_images_for_person(&state.pool, &pid).await.unwrap_or(0);
|
||||
Ok(Json(person_response(&state.pool, &person, image_count).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -3,25 +3,16 @@ use anyhow::Result;
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing::{info, warn};
|
||||
use anyhow::bail;
|
||||
use rbv_ml::{MlBackend, MlClient};
|
||||
use crate::{args::ApiArgs, middleware, routes, state::AppState, tls::build_rustls_config};
|
||||
|
||||
pub async fn run(args: ApiArgs) -> Result<()> {
|
||||
let pool = rbv_data::connect(&args.database, 10).await?;
|
||||
rbv_data::run_migrations(&pool).await?;
|
||||
|
||||
let ml: Arc<dyn MlBackend> = match (&args.model_dir, &args.ml_uri) {
|
||||
(Some(model_dir), _) => {
|
||||
info!("Using local ONNX inference from {}", model_dir.display());
|
||||
Arc::new(rbv_infer::OnnxBackend::load(model_dir)?)
|
||||
}
|
||||
(_, Some(uri)) => {
|
||||
info!("Using remote ML API at {uri}");
|
||||
Arc::new(MlClient::new(uri))
|
||||
}
|
||||
(None, None) => bail!("Either --ml-uri or --model-dir must be provided"),
|
||||
};
|
||||
info!("Using local ONNX inference from {}", args.model_dir.display());
|
||||
let ml: Arc<dyn rbv_ml::MlBackend> = Arc::new(
|
||||
rbv_infer::OnnxBackend::load_with_options(&args.model_dir, args.face_score_thresh)?
|
||||
);
|
||||
let allowed_cns = args.client_cn.clone();
|
||||
|
||||
// Ensure face cache directory exists if configured.
|
||||
|
||||
@@ -25,7 +25,7 @@ them in the database.
|
||||
rbv index \
|
||||
--target <PATH>... \
|
||||
--database <CONNSTR> \
|
||||
--ml-uri <URL> \
|
||||
--model-dir <PATH> \
|
||||
[--concurrency <N>] # default 4
|
||||
[--include <GLOB>...]
|
||||
[--exclude <GLOB>...]
|
||||
@@ -39,8 +39,7 @@ rbv index \
|
||||
- Any arbitrary directory — galleries are discovered recursively
|
||||
|
||||
Images already present in the database are skipped, so re-running against
|
||||
the same target is safe and cheap. Failed images (e.g. due to a transient ML
|
||||
API error) are not written to the database and will be retried on the next
|
||||
the same target is safe and cheap. Failed images are not written to the database and will be retried on the next
|
||||
run.
|
||||
|
||||
**Quality note:** Indexing one gallery or the whole tree produces identical
|
||||
@@ -76,13 +75,13 @@ rbv cluster \
|
||||
rbv migrate --database "$DATABASE_URL"
|
||||
|
||||
# 2. Index all galleries (incremental — safe to re-run)
|
||||
rbv index --target /mnt/galleries --database "$DATABASE_URL" --ml-uri http://ml:3003
|
||||
rbv index --target /mnt/galleries --database "$DATABASE_URL" --model-dir /path/to/models
|
||||
|
||||
# 3. Cluster faces into persons
|
||||
rbv cluster --database "$DATABASE_URL"
|
||||
|
||||
# 4. As new galleries are added, repeat steps 2–3
|
||||
rbv index --target /mnt/galleries/new-chunk --database "$DATABASE_URL" --ml-uri http://ml:3003
|
||||
rbv index --target /mnt/galleries/new-chunk --database "$DATABASE_URL" --model-dir /path/to/models
|
||||
rbv cluster --database "$DATABASE_URL"
|
||||
```
|
||||
|
||||
|
||||
@@ -54,13 +54,9 @@ pub struct IndexArgs {
|
||||
#[arg(long)]
|
||||
pub database: String,
|
||||
|
||||
/// Base URL of the machine learning API (mutually exclusive with --model-dir)
|
||||
#[arg(long, conflicts_with = "model_dir")]
|
||||
pub ml_uri: Option<String>,
|
||||
|
||||
/// Path to ONNX model directory for local inference (mutually exclusive with --ml-uri)
|
||||
#[arg(long, conflicts_with = "ml_uri")]
|
||||
pub model_dir: Option<PathBuf>,
|
||||
/// Path to ONNX model directory for local inference
|
||||
#[arg(long)]
|
||||
pub model_dir: PathBuf,
|
||||
|
||||
/// Number of images to process concurrently (parallelises I/O and
|
||||
/// preprocessing; ONNX inference itself is serialised per model)
|
||||
@@ -72,6 +68,16 @@ pub struct IndexArgs {
|
||||
/// are more likely to be reached early in a run.
|
||||
#[arg(long)]
|
||||
pub sort_galleries: bool,
|
||||
|
||||
/// Minimum face detection confidence score (0.0–1.0, default 0.7).
|
||||
/// Higher values reject low-quality faces (blurry, partial, background).
|
||||
#[arg(long)]
|
||||
pub face_score_thresh: Option<f32>,
|
||||
|
||||
/// Purge all ML-derived data (embeddings, face detections, persons)
|
||||
/// before indexing, forcing a full re-index from scratch.
|
||||
#[arg(long)]
|
||||
pub ml_purge: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -80,7 +86,12 @@ pub struct ClusterArgs {
|
||||
#[arg(long)]
|
||||
pub database: String,
|
||||
|
||||
/// Cosine similarity threshold for grouping faces (0.0–1.0)
|
||||
#[arg(long, default_value = "0.65")]
|
||||
/// Cosine similarity threshold for grouping faces (0.0–1.0).
|
||||
/// Lower values group more aggressively (fewer persons, more merges).
|
||||
#[arg(long, default_value = "0.55")]
|
||||
pub threshold: f32,
|
||||
|
||||
/// Number of threads for parallel similarity computation
|
||||
#[arg(long, default_value = "4")]
|
||||
pub concurrency: usize,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use rbv_cluster::{cluster_faces, ClusterConfig};
|
||||
use crate::args::ClusterArgs;
|
||||
|
||||
pub async fn run(args: ClusterArgs) -> Result<()> {
|
||||
let pool = rbv_data::connect(&args.database, 4).await?;
|
||||
let pool = rbv_data::connect(&args.database, 2).await?;
|
||||
|
||||
info!("Loading unassigned face embeddings...");
|
||||
let faces = rbv_data::face::unassigned_face_embeddings(&pool).await?;
|
||||
@@ -18,27 +18,37 @@ pub async fn run(args: ClusterArgs) -> Result<()> {
|
||||
let config = ClusterConfig {
|
||||
similarity_threshold: args.threshold,
|
||||
min_cluster_size: 1,
|
||||
concurrency: args.concurrency,
|
||||
};
|
||||
|
||||
info!("Clustering with threshold {}...", args.threshold);
|
||||
let clusters = cluster_faces(&faces, &config);
|
||||
info!("Formed {} clusters.", clusters.len());
|
||||
|
||||
let mut persons_created = 0u64;
|
||||
let mut faces_assigned = 0u64;
|
||||
|
||||
for cluster in clusters {
|
||||
// Generate all person IDs upfront then write in two bulk queries.
|
||||
let mut person_ids = Vec::with_capacity(clusters.len());
|
||||
let mut assignments = Vec::new();
|
||||
for cluster in &clusters {
|
||||
if cluster.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let person_id = rbv_data::person::create_person(&pool).await?;
|
||||
persons_created += 1;
|
||||
for face_id in &cluster {
|
||||
rbv_data::face::assign_face_to_person(&pool, face_id, &person_id).await?;
|
||||
faces_assigned += 1;
|
||||
let person_id = rbv_entity::PersonId::new();
|
||||
for face_id in cluster {
|
||||
assignments.push((face_id.clone(), person_id.clone()));
|
||||
}
|
||||
person_ids.push(person_id);
|
||||
}
|
||||
|
||||
info!("Created {} persons, assigned {} faces.", persons_created, faces_assigned);
|
||||
let persons_created = person_ids.len();
|
||||
let faces_assigned = assignments.len();
|
||||
|
||||
info!("Writing {persons_created} persons and {faces_assigned} face assignments...");
|
||||
rbv_data::person::create_persons_batch(&pool, &person_ids).await?;
|
||||
rbv_data::face::assign_faces_batch(&pool, &assignments).await?;
|
||||
|
||||
info!("Refreshing person image counts...");
|
||||
rbv_data::person::refresh_image_counts(&pool, &person_ids).await?;
|
||||
|
||||
info!("Created {persons_created} persons, assigned {faces_assigned} faces.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use std::sync::Arc;
|
||||
use rbv_ingest::{IngestConfig, ingest_galleries, discover_galleries, FilterConfig};
|
||||
use rbv_ml::{MlBackend, MlClient};
|
||||
use crate::args::IndexArgs;
|
||||
|
||||
pub async fn run(args: IndexArgs) -> Result<()> {
|
||||
let pool = rbv_data::connect(&args.database, args.concurrency as u32 + 4).await?;
|
||||
|
||||
let ml: Arc<dyn MlBackend> = match (&args.model_dir, &args.ml_uri) {
|
||||
(Some(model_dir), _) => {
|
||||
info!("Using local ONNX inference from {}", model_dir.display());
|
||||
Arc::new(rbv_infer::OnnxBackend::load(model_dir)?)
|
||||
}
|
||||
(_, Some(uri)) => {
|
||||
info!("Using remote ML API at {uri}");
|
||||
Arc::new(MlClient::new(uri))
|
||||
}
|
||||
(None, None) => bail!("Either --ml-uri or --model-dir must be provided"),
|
||||
};
|
||||
if args.ml_purge {
|
||||
info!("Purging all ML-derived data (embeddings, faces, persons)...");
|
||||
rbv_data::image::purge_ml_data(&pool).await?;
|
||||
info!("Purge complete.");
|
||||
}
|
||||
|
||||
info!("Using local ONNX inference from {}", args.model_dir.display());
|
||||
let ml: Arc<dyn rbv_ml::MlBackend> = Arc::new(
|
||||
rbv_infer::OnnxBackend::load_with_options(&args.model_dir, args.face_score_thresh)?
|
||||
);
|
||||
|
||||
let filter = FilterConfig::new(args.include, args.exclude);
|
||||
let config = IngestConfig {
|
||||
|
||||
@@ -8,3 +8,4 @@ license.workspace = true
|
||||
rbv-entity = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use rbv_entity::FaceId;
|
||||
use rayon::prelude::*;
|
||||
use tracing::info;
|
||||
|
||||
pub struct ClusterConfig {
|
||||
@@ -6,6 +7,8 @@ pub struct ClusterConfig {
|
||||
pub similarity_threshold: f32,
|
||||
/// Minimum number of faces to form a cluster (1 = all faces get a person ID).
|
||||
pub min_cluster_size: usize,
|
||||
/// Number of threads for parallel similarity computation.
|
||||
pub concurrency: usize,
|
||||
}
|
||||
|
||||
impl Default for ClusterConfig {
|
||||
@@ -13,6 +16,7 @@ impl Default for ClusterConfig {
|
||||
Self {
|
||||
similarity_threshold: 0.65,
|
||||
min_cluster_size: 1,
|
||||
concurrency: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,19 +37,28 @@ pub fn cluster_faces(faces: &[(FaceId, Vec<f32>)], config: &ClusterConfig) -> Ve
|
||||
// Normalise embeddings to unit length for cosine similarity via dot product.
|
||||
let normalised: Vec<Vec<f32>> = faces.iter().map(|(_, e)| normalise(e)).collect();
|
||||
|
||||
// Union-Find
|
||||
// Union-Find — dot products are parallelised per row, union-find stays sequential.
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
let mut unions = 0usize;
|
||||
let log_every = (n / 100).max(1);
|
||||
let started = std::time::Instant::now();
|
||||
let threshold = config.similarity_threshold;
|
||||
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(config.concurrency)
|
||||
.build()
|
||||
.expect("failed to build rayon thread pool");
|
||||
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
let sim = dot(&normalised[i], &normalised[j]);
|
||||
if sim >= config.similarity_threshold {
|
||||
if union(&mut parent, i, j) {
|
||||
unions += 1;
|
||||
}
|
||||
let matches: Vec<usize> = pool.install(|| {
|
||||
(i + 1..n)
|
||||
.into_par_iter()
|
||||
.filter(|&j| dot(&normalised[i], &normalised[j]) >= threshold)
|
||||
.collect()
|
||||
});
|
||||
for j in matches {
|
||||
if union(&mut parent, i, j) {
|
||||
unions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,32 @@ pub async fn assign_face_to_person(pool: &PgPool, face_id: &FaceId, person_id: &
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn assign_faces_batch(pool: &PgPool, assignments: &[(FaceId, PersonId)]) -> Result<()> {
|
||||
if assignments.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Disable the per-row image_count trigger during bulk assignment;
|
||||
// caller should use person::refresh_all_image_counts() afterwards.
|
||||
sqlx::query("ALTER TABLE face_detections DISABLE TRIGGER trg_fd_person_image_count")
|
||||
.execute(pool).await?;
|
||||
const CHUNK: usize = 16_000;
|
||||
for chunk in assignments.chunks(CHUNK) {
|
||||
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
|
||||
"UPDATE face_detections SET person_id = v.person_id \
|
||||
FROM (VALUES ",
|
||||
);
|
||||
qb.push_values(chunk, |mut b, (face_id, person_id)| {
|
||||
b.push_bind(face_id.as_bytes().to_vec())
|
||||
.push_bind(person_id.as_uuid());
|
||||
});
|
||||
qb.push(") AS v(face_id, person_id) WHERE face_detections.id = v.face_id");
|
||||
qb.build().execute(pool).await?;
|
||||
}
|
||||
sqlx::query("ALTER TABLE face_detections ENABLE TRIGGER trg_fd_person_image_count")
|
||||
.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn all_face_embeddings(pool: &PgPool) -> Result<Vec<(FaceId, Vec<f32>)>> {
|
||||
let rows = sqlx::query("SELECT id, embedding FROM face_detections")
|
||||
.fetch_all(pool)
|
||||
|
||||
@@ -2,6 +2,22 @@ use anyhow::Result;
|
||||
use sqlx::{PgPool, Postgres, Row};
|
||||
use rbv_entity::{GalleryId, GalleryImage, Image, ImageId};
|
||||
|
||||
/// Purge all ML-derived data: persons, face detections, CLIP embeddings,
|
||||
/// and image records. Gallery metadata is preserved. Cascading FKs handle
|
||||
/// the dependent tables (person_names, gallery_images, etc).
|
||||
pub async fn purge_ml_data(pool: &PgPool) -> Result<()> {
|
||||
// Disable the image_count trigger to avoid per-row overhead during truncate.
|
||||
sqlx::query("ALTER TABLE face_detections DISABLE TRIGGER trg_fd_person_image_count")
|
||||
.execute(pool).await?;
|
||||
sqlx::query("TRUNCATE persons CASCADE").execute(pool).await?;
|
||||
sqlx::query("TRUNCATE face_detections CASCADE").execute(pool).await?;
|
||||
sqlx::query("TRUNCATE clip_embeddings").execute(pool).await?;
|
||||
sqlx::query("TRUNCATE gallery_images, images CASCADE").execute(pool).await?;
|
||||
sqlx::query("ALTER TABLE face_detections ENABLE TRIGGER trg_fd_person_image_count")
|
||||
.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn image_exists(pool: &PgPool, id: &ImageId) -> Result<bool> {
|
||||
let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM images WHERE id = $1)")
|
||||
.bind(id.as_bytes())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::{PgPool, Row};
|
||||
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
|
||||
use rbv_entity::{GalleryId, Person, PersonId, PersonName};
|
||||
|
||||
pub async fn create_person(pool: &PgPool) -> Result<PersonId> {
|
||||
@@ -11,6 +11,21 @@ pub async fn create_person(pool: &PgPool) -> Result<PersonId> {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn create_persons_batch(pool: &PgPool, person_ids: &[PersonId]) -> Result<()> {
|
||||
if person_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
const CHUNK: usize = 32_000;
|
||||
for chunk in person_ids.chunks(CHUNK) {
|
||||
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("INSERT INTO persons (id) ");
|
||||
qb.push_values(chunk, |mut b, id| {
|
||||
b.push_bind(id.as_uuid());
|
||||
});
|
||||
qb.build().execute(pool).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_person(pool: &PgPool, id: &PersonId) -> Result<Option<Person>> {
|
||||
let row = sqlx::query("SELECT id, created_at FROM persons WHERE id = $1")
|
||||
.bind(id.as_uuid())
|
||||
@@ -18,21 +33,29 @@ pub async fn get_person(pool: &PgPool, id: &PersonId) -> Result<Option<Person>>
|
||||
.await?;
|
||||
Ok(row.map(|r| Person {
|
||||
id: PersonId(r.get("id")),
|
||||
created_at: r.get::<chrono::NaiveDateTime, _>("created_at").and_utc(),
|
||||
created_at: r.get("created_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_all_persons_paged(pool: &PgPool, page: i64, per_page: i64) -> Result<Vec<Person>> {
|
||||
pub struct PersonWithImageCount {
|
||||
pub person: Person,
|
||||
pub image_count: i64,
|
||||
}
|
||||
|
||||
pub async fn get_all_persons_paged(pool: &PgPool, page: i64, per_page: i64) -> Result<Vec<PersonWithImageCount>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, created_at FROM persons ORDER BY created_at LIMIT $1 OFFSET $2",
|
||||
"SELECT id, created_at, image_count FROM persons ORDER BY image_count DESC, created_at LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.iter().map(|r| Person {
|
||||
id: PersonId(r.get("id")),
|
||||
created_at: r.get::<chrono::NaiveDateTime, _>("created_at").and_utc(),
|
||||
Ok(rows.iter().map(|r| PersonWithImageCount {
|
||||
person: Person {
|
||||
id: PersonId(r.get("id")),
|
||||
created_at: r.get("created_at"),
|
||||
},
|
||||
image_count: r.get("image_count"),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
@@ -92,7 +115,7 @@ pub async fn find_persons_by_name(pool: &PgPool, name: &str) -> Result<Vec<(Pers
|
||||
let id: uuid::Uuid = r.get("id");
|
||||
let person = Person {
|
||||
id: PersonId(id),
|
||||
created_at: r.get::<chrono::NaiveDateTime, _>("created_at").and_utc(),
|
||||
created_at: r.get("created_at"),
|
||||
};
|
||||
let pname = PersonName {
|
||||
person_id: PersonId(id),
|
||||
@@ -103,6 +126,47 @@ pub async fn find_persons_by_name(pool: &PgPool, name: &str) -> Result<Vec<(Pers
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn count_images_for_person(pool: &PgPool, person_id: &PersonId) -> Result<i64> {
|
||||
let row = sqlx::query("SELECT image_count FROM persons WHERE id = $1")
|
||||
.bind(person_id.as_uuid())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.get("image_count"))
|
||||
}
|
||||
|
||||
/// Recompute image_count for a specific set of persons.
|
||||
/// Much faster than a full-table scan when you know which persons changed.
|
||||
pub async fn refresh_image_counts(pool: &PgPool, person_ids: &[PersonId]) -> Result<()> {
|
||||
if person_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
const CHUNK: usize = 32_000;
|
||||
for chunk in person_ids.chunks(CHUNK) {
|
||||
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
|
||||
r#"
|
||||
UPDATE persons p
|
||||
SET image_count = COALESCE(fc.cnt, 0)
|
||||
FROM (
|
||||
SELECT fd.person_id, COUNT(DISTINCT fd.image_id) AS cnt
|
||||
FROM face_detections fd
|
||||
WHERE fd.person_id IN (
|
||||
"#,
|
||||
);
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in chunk {
|
||||
sep.push_bind(id.as_uuid());
|
||||
}
|
||||
qb.push(") GROUP BY fd.person_id) fc WHERE p.id = fc.person_id AND p.id IN (");
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in chunk {
|
||||
sep.push_bind(id.as_uuid());
|
||||
}
|
||||
qb.push(")");
|
||||
qb.build().execute(pool).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn count_persons(pool: &PgPool) -> Result<i64> {
|
||||
let row = sqlx::query("SELECT COUNT(*) AS count FROM persons")
|
||||
.fetch_one(pool)
|
||||
@@ -125,6 +189,6 @@ pub async fn get_persons_for_gallery(pool: &PgPool, gallery_id: &GalleryId) -> R
|
||||
.await?;
|
||||
Ok(rows.iter().map(|r| Person {
|
||||
id: PersonId(r.get("id")),
|
||||
created_at: r.get::<chrono::NaiveDateTime, _>("created_at").and_utc(),
|
||||
created_at: r.get("created_at"),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct OnnxBackend {
|
||||
}
|
||||
|
||||
impl OnnxBackend {
|
||||
/// Load from a model directory with immich-compatible layout:
|
||||
/// Load from a model directory with the following layout:
|
||||
/// ```text
|
||||
/// <model_dir>/
|
||||
/// clip/ViT-B-32__openai/visual/model.onnx
|
||||
@@ -48,6 +48,10 @@ impl OnnxBackend {
|
||||
/// facial-recognition/buffalo_l/recognition/model.onnx
|
||||
/// ```
|
||||
pub fn load(model_dir: &Path) -> Result<Self, InferError> {
|
||||
Self::load_with_options(model_dir, None)
|
||||
}
|
||||
|
||||
pub fn load_with_options(model_dir: &Path, face_score_thresh: Option<f32>) -> Result<Self, InferError> {
|
||||
let clip_base = model_dir.join("clip").join("ViT-B-32__openai");
|
||||
let face_base = model_dir.join("facial-recognition").join("buffalo_l");
|
||||
|
||||
@@ -69,8 +73,13 @@ impl OnnxBackend {
|
||||
tracing::info!("Loading CLIP textual model from {}", textual_model.display());
|
||||
let clip_textual = clip_textual::ClipTextual::load(&textual_model, &tokenizer_json)?;
|
||||
|
||||
tracing::info!("Loading SCRFD detection model from {}", detect_model.display());
|
||||
let face_detect = scrfd::Scrfd::load(&detect_model)?;
|
||||
let face_detect = if let Some(thresh) = face_score_thresh {
|
||||
tracing::info!("Loading SCRFD detection model from {} (score_thresh={thresh})", detect_model.display());
|
||||
scrfd::Scrfd::load_with_threshold(&detect_model, thresh)?
|
||||
} else {
|
||||
tracing::info!("Loading SCRFD detection model from {}", detect_model.display());
|
||||
scrfd::Scrfd::load(&detect_model)?
|
||||
};
|
||||
|
||||
tracing::info!("Loading ArcFace recognition model from {}", recognize_model.display());
|
||||
let face_recognize = arcface::ArcFace::load(&recognize_model)?;
|
||||
|
||||
@@ -5,7 +5,7 @@ use ort::value::Tensor;
|
||||
use crate::{InferError, preprocess};
|
||||
|
||||
const CANVAS: u32 = 640;
|
||||
const SCORE_THRESH: f32 = 0.5;
|
||||
const DEFAULT_SCORE_THRESH: f32 = 0.7;
|
||||
const NMS_THRESH: f32 = 0.4;
|
||||
/// FPN strides and anchors per cell for the det_10g model.
|
||||
const STRIDES: [u32; 3] = [8, 16, 32];
|
||||
@@ -23,12 +23,17 @@ pub struct Detection {
|
||||
|
||||
pub struct Scrfd {
|
||||
session: Mutex<Session>,
|
||||
score_thresh: f32,
|
||||
}
|
||||
|
||||
impl Scrfd {
|
||||
pub fn load(model_path: &Path) -> Result<Self, InferError> {
|
||||
Self::load_with_threshold(model_path, DEFAULT_SCORE_THRESH)
|
||||
}
|
||||
|
||||
pub fn load_with_threshold(model_path: &Path, score_thresh: f32) -> Result<Self, InferError> {
|
||||
let session = Session::builder()?.commit_from_file(model_path)?;
|
||||
Ok(Self { session: Mutex::new(session) })
|
||||
Ok(Self { session: Mutex::new(session), score_thresh })
|
||||
}
|
||||
|
||||
pub fn detect(&self, image_bytes: &[u8]) -> Result<Vec<Detection>, InferError> {
|
||||
@@ -65,7 +70,7 @@ impl Scrfd {
|
||||
for a in 0..num_anchors {
|
||||
// scores_raw shape: [1, num_anchors, 1] or [num_anchors] — iterate flat
|
||||
let score = scores_raw[a];
|
||||
if score < SCORE_THRESH {
|
||||
if score < self.score_thresh {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,8 @@ pub async fn ingest_galleries(
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire_owned().await.unwrap();
|
||||
process_image(&pool, ml.as_ref(), &gid, &image_path, ordering as i32).await
|
||||
let result = process_image(&pool, ml.as_ref(), &gid, &image_path, ordering as i32).await;
|
||||
(image_path, result)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ pub async fn ingest_galleries(
|
||||
|
||||
for task in tasks {
|
||||
match task.await {
|
||||
Ok(Ok((skipped, faces))) => {
|
||||
Ok((_, Ok((skipped, faces)))) => {
|
||||
if skipped {
|
||||
report.images_skipped += 1;
|
||||
g_skipped += 1;
|
||||
@@ -115,9 +116,9 @@ pub async fn ingest_galleries(
|
||||
g_faces += faces;
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!("Image error: {e:#}");
|
||||
report.errors.push((gallery_path.clone(), e));
|
||||
Ok((image_path, Err(e))) => {
|
||||
warn!("{} {} {e:#}", gid.to_hex(), image_path.display());
|
||||
report.errors.push((image_path, e));
|
||||
g_errors += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -128,9 +129,9 @@ pub async fn ingest_galleries(
|
||||
}
|
||||
|
||||
let label = if !gallery.source_name.is_empty() {
|
||||
gallery.source_name.clone()
|
||||
format!("{} {}", gid.to_hex(), gallery.source_name)
|
||||
} else {
|
||||
gallery_path.display().to_string()
|
||||
format!("{} {}", gid.to_hex(), gallery_path.display())
|
||||
};
|
||||
info!(
|
||||
images = g_processed,
|
||||
|
||||
@@ -4,15 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["http"]
|
||||
http = ["dep:reqwest"]
|
||||
|
||||
[dependencies]
|
||||
rbv-entity = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
use thiserror::Error;
|
||||
use tracing::debug;
|
||||
use crate::MlBackend;
|
||||
use crate::response::{AnalysisResult, ParseError};
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
use crate::request::{image_entries, text_entries};
|
||||
#[cfg(feature = "http")]
|
||||
use crate::response::{RawPredictResponse, RawTextResponse};
|
||||
|
||||
const DEFAULT_CLIP_MODEL: &str = "ViT-B-32__openai";
|
||||
const DEFAULT_FACE_MODEL: &str = "buffalo_l";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MlError {
|
||||
#[cfg(feature = "http")]
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[cfg(feature = "http")]
|
||||
#[error("HTTP {status}: {body}")]
|
||||
HttpStatus { status: u16, body: String },
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(#[from] ParseError),
|
||||
#[error("Missing embedding in response")]
|
||||
MissingEmbedding,
|
||||
#[error("Inference error: {0}")]
|
||||
Inference(String),
|
||||
}
|
||||
@@ -30,119 +9,6 @@ pub enum MlError {
|
||||
impl MlError {
|
||||
/// Returns true if the error is transient and worth retrying.
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
#[cfg(feature = "http")]
|
||||
MlError::Http(e) => e.is_connect() || e.is_timeout() || e.is_request(),
|
||||
#[cfg(feature = "http")]
|
||||
MlError::HttpStatus { status, .. } => *status >= 500,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
#[derive(Clone)]
|
||||
pub struct MlClient {
|
||||
http: reqwest::Client,
|
||||
predict_url: String,
|
||||
clip_model: String,
|
||||
face_model: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
impl MlClient {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
predict_url: format!("{base}/predict"),
|
||||
clip_model: DEFAULT_CLIP_MODEL.to_string(),
|
||||
face_model: DEFAULT_FACE_MODEL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_models(mut self, clip_model: &str, face_model: &str) -> Self {
|
||||
self.clip_model = clip_model.to_string();
|
||||
self.face_model = face_model.to_string();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
#[async_trait::async_trait]
|
||||
impl MlBackend for MlClient {
|
||||
/// Submit an image for CLIP visual embedding + face detection/recognition.
|
||||
async fn analyze_image(&self, image_bytes: &[u8]) -> Result<AnalysisResult, MlError> {
|
||||
let entries = image_entries(&self.clip_model, &self.face_model);
|
||||
|
||||
let image_part = reqwest::multipart::Part::bytes(image_bytes.to_vec())
|
||||
.file_name("image.jpg")
|
||||
.mime_str("image/jpeg")
|
||||
.unwrap();
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("image", image_part)
|
||||
.text("entries", entries);
|
||||
|
||||
let is_jpeg = image_bytes.starts_with(&[0xFF, 0xD8, 0xFF]);
|
||||
debug!(
|
||||
url = %self.predict_url,
|
||||
bytes = image_bytes.len(),
|
||||
is_jpeg,
|
||||
"Submitting image to ML API",
|
||||
);
|
||||
if !is_jpeg {
|
||||
tracing::warn!(
|
||||
"Image does not have JPEG magic bytes; first 4: {:02X?}",
|
||||
&image_bytes[..4.min(image_bytes.len())]
|
||||
);
|
||||
}
|
||||
|
||||
let resp = self.http
|
||||
.post(&self.predict_url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(MlError::HttpStatus { status, body });
|
||||
}
|
||||
|
||||
let response = resp.json::<RawPredictResponse>().await?;
|
||||
|
||||
Ok(response.into_analysis_result()?)
|
||||
}
|
||||
|
||||
/// Submit a text query for CLIP text embedding.
|
||||
async fn embed_text(&self, text: &str) -> Result<Vec<f32>, MlError> {
|
||||
let entries = text_entries(&self.clip_model);
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("text", text.to_string())
|
||||
.text("entries", entries);
|
||||
|
||||
debug!(url = %self.predict_url, text, "Submitting text to ML API");
|
||||
|
||||
let resp = self.http
|
||||
.post(&self.predict_url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(MlError::HttpStatus { status, body });
|
||||
}
|
||||
|
||||
let response = resp.json::<RawTextResponse>().await?;
|
||||
|
||||
let clip_str = response.clip.ok_or(MlError::MissingEmbedding)?;
|
||||
let embedding: Vec<f32> = serde_json::from_str(&clip_str)
|
||||
.map_err(|e| MlError::Parse(ParseError::InvalidEmbedding(e)))?;
|
||||
|
||||
Ok(embedding)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
|
||||
pub use client::{MlClient, MlError};
|
||||
pub use client::MlError;
|
||||
pub use response::{AnalysisResult, DetectedFace};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Build the `entries` JSON string for a combined CLIP visual + facial recognition request.
|
||||
pub(crate) fn image_entries(clip_model: &str, face_model: &str) -> String {
|
||||
let entries: Value = json!({
|
||||
"clip": {
|
||||
"visual": {
|
||||
"modelName": clip_model,
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
"facial-recognition": {
|
||||
"detection": {
|
||||
"modelName": face_model,
|
||||
"options": {}
|
||||
},
|
||||
"recognition": {
|
||||
"modelName": face_model,
|
||||
"options": {}
|
||||
}
|
||||
}
|
||||
});
|
||||
entries.to_string()
|
||||
}
|
||||
|
||||
/// Build the `entries` JSON string for a text CLIP embedding request.
|
||||
pub(crate) fn text_entries(clip_model: &str) -> String {
|
||||
let entries: Value = json!({
|
||||
"clip": {
|
||||
"textual": {
|
||||
"modelName": clip_model,
|
||||
"options": {}
|
||||
}
|
||||
}
|
||||
});
|
||||
entries.to_string()
|
||||
}
|
||||
@@ -1,16 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
use rbv_entity::BoundingBox;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseError {
|
||||
#[error("missing field: {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("invalid embedding: {0}")]
|
||||
InvalidEmbedding(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Public result type returned to callers of MlClient.
|
||||
/// Result type returned by MlBackend implementations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnalysisResult {
|
||||
pub clip_embedding: Vec<f32>,
|
||||
@@ -25,71 +15,3 @@ pub struct DetectedFace {
|
||||
pub embedding: Vec<f32>,
|
||||
pub detection_score: f32,
|
||||
}
|
||||
|
||||
// ---------- wire format (private) ----------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawPredictResponse {
|
||||
/// CLIP embedding encoded as a JSON array string, e.g. "[0.1, -0.2, ...]"
|
||||
pub clip: Option<String>,
|
||||
#[serde(rename = "facial-recognition")]
|
||||
pub facial_recognition: Option<Vec<RawFaceDetection>>,
|
||||
#[serde(rename = "imageHeight")]
|
||||
pub image_height: Option<i32>,
|
||||
#[serde(rename = "imageWidth")]
|
||||
pub image_width: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawFaceDetection {
|
||||
#[serde(rename = "boundingBox")]
|
||||
pub bounding_box: RawBoundingBox,
|
||||
/// Face embedding encoded as a JSON array string
|
||||
pub embedding: String,
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawBoundingBox {
|
||||
pub x1: f32,
|
||||
pub y1: f32,
|
||||
pub x2: f32,
|
||||
pub y2: f32,
|
||||
}
|
||||
|
||||
impl RawPredictResponse {
|
||||
pub(crate) fn into_analysis_result(self) -> Result<AnalysisResult, ParseError> {
|
||||
let clip_str = self.clip.ok_or(ParseError::MissingField("clip"))?;
|
||||
let clip_embedding: Vec<f32> = serde_json::from_str(&clip_str)?;
|
||||
|
||||
let faces = self.facial_recognition.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let embedding: Vec<f32> = serde_json::from_str(&f.embedding)?;
|
||||
Ok(DetectedFace {
|
||||
bounding_box: BoundingBox {
|
||||
x1: f.bounding_box.x1 as i32,
|
||||
y1: f.bounding_box.y1 as i32,
|
||||
x2: f.bounding_box.x2 as i32,
|
||||
y2: f.bounding_box.y2 as i32,
|
||||
},
|
||||
embedding,
|
||||
detection_score: f.score,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
|
||||
Ok(AnalysisResult {
|
||||
clip_embedding,
|
||||
faces,
|
||||
image_width: self.image_width.unwrap_or(0),
|
||||
image_height: self.image_height.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire response for a text-only CLIP embedding request.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawTextResponse {
|
||||
pub clip: Option<String>,
|
||||
}
|
||||
|
||||
294
doc/db-cluster.md
Normal file
294
doc/db-cluster.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# PostgreSQL Cluster
|
||||
|
||||
Three-node Patroni cluster with pgvector, consolidating the rbv and immich
|
||||
PostgreSQL containers previously running on gramathea.
|
||||
|
||||
All connections (client and replication) use mutual TLS via the internal
|
||||
step CA. No password authentication is used anywhere.
|
||||
|
||||
Certificate convention on all infra hosts:
|
||||
- CA: `/etc/pki/ca-trust/source/anchors/root-internal.pem`
|
||||
- Cert: `/etc/pki/tls/misc/$(hostname -f).pem`
|
||||
- Key: `/etc/pki/tls/private/$(hostname -f).pem`
|
||||
|
||||
Certs are provisioned as both client and server, so the same PEMs serve
|
||||
for PostgreSQL SSL, client certificate authentication, and Patroni
|
||||
replication.
|
||||
|
||||
## Nodes
|
||||
|
||||
| Hostname | Site | Role |
|
||||
|---|---|---|
|
||||
| frankie.hanzalova.internal | primary | primary site, node 1 |
|
||||
| _(TBD)_ | primary | primary site, node 2 |
|
||||
| _(TBD)_ | secondary | secondary site, node 3 |
|
||||
|
||||
Hardware: ASRock E3C236D4M-4L, E3-1230 v6, 16 GB RAM, 2×1 TB SSD.
|
||||
|
||||
Replication topology (target):
|
||||
- Primary → sync standby: within primary site (no WireGuard on critical write path)
|
||||
- Primary → async standby: secondary site node (DR copy)
|
||||
|
||||
## Phase 1 — Standalone on frankie
|
||||
|
||||
### Install PostgreSQL 17 and pgvector
|
||||
|
||||
```bash
|
||||
# Add PGDG repository
|
||||
sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/F-$(rpm -E %fedora)-x86_64/pgdg-fedora-repo-latest.noarch.rpm
|
||||
|
||||
# Disable the Fedora-packaged postgres to avoid conflicts
|
||||
sudo dnf -qy module disable postgresql
|
||||
|
||||
# Install server and pgvector
|
||||
sudo dnf install -y postgresql17-server pgvector_17
|
||||
|
||||
# Initialise the data directory
|
||||
sudo /usr/pgsql-17/bin/postgresql-17-setup initdb
|
||||
|
||||
# Enable and start
|
||||
sudo systemctl enable --now postgresql-17.service
|
||||
```
|
||||
|
||||
### Make certificates readable by postgres
|
||||
|
||||
Grant the postgres user read access via ACL, leaving ownership as root.
|
||||
This way cert renewals take effect automatically without re-copying.
|
||||
|
||||
```bash
|
||||
sudo setfacl -m u:postgres:r /etc/pki/tls/private/$(hostname).pem
|
||||
```
|
||||
|
||||
### Configure postgresql.conf
|
||||
|
||||
```bash
|
||||
sudo -u postgres mkdir -p /var/lib/pgsql/17/data/postgresql.conf.d
|
||||
if ! sudo -u postgres grep 'postgresql.conf.d' /var/lib/pgsql/17/data/postgresql.conf &> /dev/null; then
|
||||
echo 'include_dir = postgresql.conf.d' | sudo -u postgres tee --append /var/lib/pgsql/17/data/postgresql.conf
|
||||
fi
|
||||
echo "listen_addresses = '*'" | sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/listen.conf
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/ssl.conf <<'EOF'
|
||||
ssl = on
|
||||
ssl_cert_file = '/etc/pki/tls/misc/frankie.hanzalova.internal.pem'
|
||||
ssl_key_file = '/etc/pki/tls/private/frankie.hanzalova.internal.pem'
|
||||
ssl_ca_file = '/etc/pki/ca-trust/source/anchors/root-internal.pem'
|
||||
EOF
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/memory.conf <<'EOF'
|
||||
shared_buffers = 4GB
|
||||
work_mem = 64MB
|
||||
maintenance_work_mem = 512MB
|
||||
EOF
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/wal.conf <<'EOF'
|
||||
wal_level = replica
|
||||
max_wal_senders = 5
|
||||
wal_keep_size = 1GB
|
||||
EOF
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/checkpoint.conf <<'EOF'
|
||||
checkpoint_completion_target = 0.9
|
||||
EOF
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/vchord.conf <<'EOF'
|
||||
shared_preload_libraries = 'vchord'
|
||||
EOF
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/postgresql.conf.d/logging.conf <<'EOF'
|
||||
log_destination = 'stderr'
|
||||
logging_collector = off
|
||||
EOF
|
||||
|
||||
sudo systemctl reload postgresql-17.service
|
||||
```
|
||||
|
||||
### Configure pg_hba.conf
|
||||
|
||||
Update the default rules with certificate-only authentication for lan connections.
|
||||
Local unix-socket access retains `peer` for admin use.
|
||||
|
||||
```bash
|
||||
sudo -u postgres mkdir -p /var/lib/pgsql/17/data/pg_hba.conf.d
|
||||
if ! sudo -u postgres grep 'pg_hba.conf.d' /var/lib/pgsql/17/data/pg_hba.conf &> /dev/null; then
|
||||
echo 'include_dir = pg_hba.conf.d' | sudo -u postgres tee --append /var/lib/pgsql/17/data/pg_hba.conf
|
||||
fi
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/pg_hba.conf.d/network-connections.conf <<'EOF'
|
||||
hostnossl all all 0.0.0.0/0 reject
|
||||
hostssl all all 10.3.0.0/16 cert map=cert_cn
|
||||
hostssl all all 10.6.0.0/16 cert map=cert_cn
|
||||
hostssl replication replicator 10.0.0.0/8 cert clientcert=verify-full map=cn
|
||||
EOF
|
||||
|
||||
sudo systemctl reload postgresql-17.service
|
||||
```
|
||||
|
||||
### Configure pg_ident.conf
|
||||
|
||||
Maps the CN of each client certificate to the appropriate database user.
|
||||
Add a line for each application host.
|
||||
|
||||
```bash
|
||||
sudo -u postgres mkdir -p /var/lib/pgsql/17/data/pg_ident.conf.d
|
||||
if ! sudo -u postgres grep 'pg_ident.conf.d' /var/lib/pgsql/17/data/pg_ident.conf &> /dev/null; then
|
||||
echo 'include_dir = pg_ident.conf.d' | sudo -u postgres tee --append /var/lib/pgsql/17/data/pg_ident.conf
|
||||
fi
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/pg_ident.conf.d/immich.conf <<'EOF'
|
||||
cn gramathea.kosherinata.internal immich
|
||||
EOF
|
||||
sudo -u postgres tee /var/lib/pgsql/17/data/pg_ident.conf.d/rbv.conf <<'EOF'
|
||||
cn gramathea.kosherinata.internal rbv
|
||||
EOF
|
||||
|
||||
sudo systemctl reload postgresql-17.service
|
||||
```
|
||||
|
||||
### Create roles and databases
|
||||
|
||||
No passwords — authentication is via certificate only.
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql <<'EOF'
|
||||
CREATE USER rbv;
|
||||
CREATE DATABASE rbv OWNER rbv;
|
||||
|
||||
CREATE USER immich;
|
||||
CREATE DATABASE immich OWNER immich;
|
||||
|
||||
CREATE USER replicator REPLICATION;
|
||||
EOF
|
||||
```
|
||||
|
||||
### Install VectorChord
|
||||
|
||||
VectorChord is not in PGDG — install from the GitHub release zip.
|
||||
Check https://github.com/tensorchord/VectorChord/releases for the current version.
|
||||
|
||||
```bash
|
||||
curl \
|
||||
--fail \
|
||||
--show-error \
|
||||
--location \
|
||||
--silent \
|
||||
--output /tmp/postgresql-17-vchord_1.1.1_x86_64-linux-gnu.zip \
|
||||
--url https://github.com/tensorchord/VectorChord/releases/download/1.1.1/postgresql-17-vchord_1.1.1_x86_64-linux-gnu.zip
|
||||
unzip \
|
||||
-d /tmp/vchord \
|
||||
/tmp/postgresql-17-vchord_1.1.1_x86_64-linux-gnu.zip
|
||||
|
||||
sudo install \
|
||||
--owner root \
|
||||
--group root \
|
||||
/tmp/vchord/pkglibdir/vchord.so \
|
||||
/usr/pgsql-17/lib/
|
||||
sudo install \
|
||||
--owner root \
|
||||
--group root \
|
||||
--mode 644 \
|
||||
/tmp/vchord/sharedir/extension/vchord* \
|
||||
/usr/pgsql-17/share/extension/
|
||||
|
||||
rm -rf /tmp/vchord /tmp/postgresql-17-vchord_1.1.1_x86_64-linux-gnu.zip
|
||||
```
|
||||
|
||||
VectorChord requires preloading (needs a restart, not just reload):
|
||||
|
||||
> [!CAUTION]
|
||||
> deprecated in favour of `/var/lib/pgsql/17/data/postgresql.conf.d/vchord.conf` above.
|
||||
```bash
|
||||
sudo tee -a /var/lib/pgsql/17/data/postgresql.conf <<'EOF'
|
||||
|
||||
# VectorChord
|
||||
shared_preload_libraries = 'vchord'
|
||||
EOF
|
||||
|
||||
sudo systemctl restart postgresql-17
|
||||
```
|
||||
|
||||
### Enable pgvector and VectorChord
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql -d rbv <<'EOF'
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS vchord;
|
||||
EOF
|
||||
|
||||
sudo -u postgres psql -d immich <<'EOF'
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS vchord;
|
||||
EOF
|
||||
```
|
||||
|
||||
### Open firewall port
|
||||
|
||||
```bash
|
||||
sudo firewall-cmd --zone=$(firewall-cmd --get-default-zone) --add-service postgresql --permanent
|
||||
sudo firewall-cmd --reload
|
||||
sudo firewall-cmd --list-services
|
||||
```
|
||||
|
||||
### Migrate data from gramathea
|
||||
|
||||
Run on gramathea. The dump uses password auth against the existing
|
||||
containers; the restore connects to frankie using the host certificate.
|
||||
|
||||
```bash
|
||||
# Dump from the running quadlet containers (password auth, local)
|
||||
pg_dump -h localhost -p 4432 -U rbv rbv > rbv.sql
|
||||
pg_dump -h localhost -p 5432 -U postgres immich > immich.sql # adjust port/user for immich container
|
||||
|
||||
# Restore on frankie using cert auth
|
||||
psql "host=frankie.hanzalova.internal \
|
||||
user=rbv dbname=rbv \
|
||||
sslmode=verify-full \
|
||||
sslcert=/etc/pki/tls/misc/gramathea.kosherinata.internal.pem \
|
||||
sslkey=/etc/pki/tls/private/gramathea.kosherinata.internal.pem \
|
||||
sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem" \
|
||||
< rbv.sql
|
||||
|
||||
psql "host=frankie.hanzalova.internal \
|
||||
user=immich dbname=immich \
|
||||
sslmode=verify-full \
|
||||
sslcert=/etc/pki/tls/misc/gramathea.kosherinata.internal.pem \
|
||||
sslkey=/etc/pki/tls/private/gramathea.kosherinata.internal.pem \
|
||||
sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem" \
|
||||
< immich.sql
|
||||
```
|
||||
|
||||
### Update application connection strings
|
||||
|
||||
rbv services use the connection string format accepted by libpq/sqlx.
|
||||
SSL parameters can be passed inline or via environment variables; inline
|
||||
is shown here for clarity. Update `/etc/systemd/system/rbv-*.service`:
|
||||
|
||||
```
|
||||
postgres://rbv@frankie.hanzalova.internal/rbv\
|
||||
?sslmode=verify-full\
|
||||
&sslcert=/etc/pki/tls/misc/gramathea.kosherinata.internal.pem\
|
||||
&sslkey=/etc/pki/tls/private/gramathea.kosherinata.internal.pem\
|
||||
&sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem
|
||||
```
|
||||
|
||||
The `sed -i` password substitution in `script/deploy.sh` can be removed
|
||||
once the services are updated to cert-based connection strings.
|
||||
|
||||
For immich, update `DB_HOSTNAME`, `DB_USERNAME`, and set:
|
||||
```
|
||||
DB_SSL_MODE=verify-full
|
||||
DB_SSL_CERT=/etc/pki/tls/misc/gramathea.kosherinata.internal.pem
|
||||
DB_SSL_KEY=/etc/pki/tls/private/gramathea.kosherinata.internal.pem
|
||||
DB_SSL_ROOT_CERT=/etc/pki/ca-trust/source/anchors/root-internal.pem
|
||||
```
|
||||
|
||||
Once both applications are confirmed working against frankie, stop and
|
||||
disable the postgres quadlets on gramathea:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now rbv-postgres.service
|
||||
# and the immich postgres equivalent
|
||||
```
|
||||
|
||||
## Phase 2 — Patroni HA (when second node is ready)
|
||||
|
||||
_To be documented once node 2 hardware is provisioned._
|
||||
|
||||
Key steps will be:
|
||||
1. Install etcd on all three nodes
|
||||
2. Install Patroni on all three nodes
|
||||
3. Bootstrap Patroni on node 1 (adopts existing data directory — no re-initdb)
|
||||
4. Stream base backup to node 2, add as sync standby (cert auth for replication)
|
||||
5. Add node 3 as async standby
|
||||
42
migrations/0009_person_image_count.sql
Normal file
42
migrations/0009_person_image_count.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Denormalized image_count on persons for fast sort-by-popularity.
|
||||
-- Maintained by a trigger on face_detections.
|
||||
|
||||
ALTER TABLE persons ADD COLUMN IF NOT EXISTS image_count BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persons_image_count ON persons (image_count DESC, created_at);
|
||||
|
||||
-- Trigger function: recompute image_count for affected person(s).
|
||||
CREATE OR REPLACE FUNCTION fn_update_person_image_count() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
|
||||
IF NEW.person_id IS NOT NULL THEN
|
||||
UPDATE persons
|
||||
SET image_count = (
|
||||
SELECT COUNT(DISTINCT image_id)
|
||||
FROM face_detections
|
||||
WHERE person_id = NEW.person_id
|
||||
)
|
||||
WHERE id = NEW.person_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' OR TG_OP = 'UPDATE' THEN
|
||||
IF OLD.person_id IS NOT NULL AND (TG_OP = 'DELETE' OR OLD.person_id IS DISTINCT FROM NEW.person_id) THEN
|
||||
UPDATE persons
|
||||
SET image_count = (
|
||||
SELECT COUNT(DISTINCT image_id)
|
||||
FROM face_detections
|
||||
WHERE person_id = OLD.person_id
|
||||
)
|
||||
WHERE id = OLD.person_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_fd_person_image_count
|
||||
AFTER INSERT OR UPDATE OF person_id OR DELETE
|
||||
ON face_detections
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_update_person_image_count();
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
postgres_host=gramathea.kosherinata.internal
|
||||
api_host=gramathea.kosherinata.internal
|
||||
@@ -111,6 +112,7 @@ deploy_ui() {
|
||||
${ui_host}:/tmp/provisioner \
|
||||
&& ssh ${ui_host} sudo mkdir -p /etc/nginx/tls/rbv \
|
||||
&& ssh ${ui_host} sudo step ca certificate \
|
||||
--force \
|
||||
--provisioner lair \
|
||||
--provisioner-password-file /tmp/provisioner \
|
||||
--ca-url https://ca.internal \
|
||||
@@ -126,18 +128,37 @@ deploy_ui() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if rsync \
|
||||
--archive \
|
||||
--compress \
|
||||
--rsync-path 'sudo rsync' \
|
||||
--chown root:root \
|
||||
asset/systemd/step@.service \
|
||||
${ui_host}:/etc/systemd/system/step@.service; then
|
||||
echo 'step cert renewal service deployed successfully'
|
||||
else
|
||||
echo 'failed to deploy step cert renewal service'
|
||||
exit 1
|
||||
fi
|
||||
for unit in step@.{service,timer}; do
|
||||
if rsync \
|
||||
--archive \
|
||||
--compress \
|
||||
--rsync-path 'sudo rsync' \
|
||||
--chown root:root \
|
||||
asset/systemd/${unit} \
|
||||
${ui_host}:/etc/systemd/system/${unit}; then
|
||||
echo "${unit} deployed successfully"
|
||||
else
|
||||
echo "failed to deploy ${unit}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
ssh ${ui_host} "
|
||||
sudo systemctl daemon-reload
|
||||
if ! systemctl is-enabled --quiet step@rbv.timer; then
|
||||
if sudo systemctl enable step@rbv.timer; then
|
||||
echo 'step@rbv.timer enabled'
|
||||
else
|
||||
echo 'failed to enable step@rbv.timer'
|
||||
fi
|
||||
fi
|
||||
if ! systemctl is-active --quiet step@rbv.timer; then
|
||||
if sudo systemctl start step@rbv.timer; then
|
||||
echo 'step@rbv.timer started'
|
||||
else
|
||||
echo 'failed to start step@rbv.timer'
|
||||
fi
|
||||
fi
|
||||
"
|
||||
|
||||
(cd ui && npm run build)
|
||||
if ssh ${ui_host} sudo mkdir -p /usr/share/nginx/rbv \
|
||||
|
||||
@@ -64,6 +64,8 @@ export const getGallery = (id: string) => request<Gallery>(`/galleries/${id}`)
|
||||
|
||||
export const getGalleryImages = (id: string) => request<GalleryImage[]>(`/galleries/${id}/images`)
|
||||
|
||||
export const getGalleryPersons = (id: string) => request<Person[]>(`/galleries/${id}/persons`)
|
||||
|
||||
// ── Images ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ImageMeta {
|
||||
@@ -85,6 +87,7 @@ export interface Person {
|
||||
primary_name: string | null
|
||||
names: string[]
|
||||
created_at: string
|
||||
image_count: number
|
||||
}
|
||||
|
||||
export interface FaceRef {
|
||||
|
||||
@@ -20,9 +20,24 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gallery-faces {
|
||||
.gallery-persons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gallery-person-link {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: var(--colour-surface, #f0f0f0);
|
||||
color: var(--colour-link, #0969da);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gallery-person-link:hover {
|
||||
background: var(--colour-link, #0969da);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.gallery-viewer {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getGallery, getGalleryImages, faceCropUrl,
|
||||
getGallery, getGalleryImages, getGalleryPersons,
|
||||
imageFileUrl, thumbnailUrl,
|
||||
} from '../api/client'
|
||||
import type { Gallery as GalleryType, GalleryImage, FaceRef } from '../api/client'
|
||||
import type { Gallery as GalleryType, GalleryImage, Person } from '../api/client'
|
||||
import './Gallery.css'
|
||||
|
||||
export function Gallery() {
|
||||
@@ -14,7 +14,7 @@ export function Gallery() {
|
||||
const [gallery, setGallery] = useState<GalleryType | null>(null)
|
||||
const [images, setImages] = useState<GalleryImage[]>([])
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [faces, setFaces] = useState<FaceRef[]>([])
|
||||
const [persons, setPersons] = useState<Person[]>([])
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
@@ -40,10 +40,9 @@ export function Gallery() {
|
||||
strip.scrollTo({ left: target, behavior: 'smooth' })
|
||||
}, [current])
|
||||
|
||||
// Load faces appearing in this gallery's images (first 10 distinct persons)
|
||||
useEffect(() => {
|
||||
// Placeholder — face-per-gallery query not in API; skip for now
|
||||
setFaces([])
|
||||
if (!id) return
|
||||
getGalleryPersons(id).then(setPersons).catch(() => setPersons([]))
|
||||
}, [id])
|
||||
|
||||
const prev = useCallback(() => setCurrent(c => (c - 1 + images.length) % images.length), [images.length])
|
||||
@@ -115,11 +114,15 @@ export function Gallery() {
|
||||
<div className="gallery-page">
|
||||
<div className="gallery-header">
|
||||
<div className="gallery-title">{gallery.source_name || gallery.collection}</div>
|
||||
<div className="gallery-faces">
|
||||
{faces.map(f => (
|
||||
<img key={f.id} className="face-avatar" src={faceCropUrl(f.id)} alt="" loading="lazy" />
|
||||
))}
|
||||
</div>
|
||||
{persons.length > 0 && (
|
||||
<div className="gallery-persons">
|
||||
{persons.map(p => (
|
||||
<Link key={p.id} to={`/people/${p.id}`} className="gallery-person-link">
|
||||
{p.primary_name ?? 'Unnamed'}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user