feat: expand media exploration and tagging controls

This commit is contained in:
2026-04-12 12:18:47 +01:00
parent b2826d1143
commit ff4a568b57
23 changed files with 3206 additions and 798 deletions
+12
View File
@@ -0,0 +1,12 @@
[target.x86_64-pc-windows-msvc]
rustflags = [
# Disable MSVC incremental linking — avoids .ilk file corruption
# and removes one source of link failure on restart.
"-C", "link-arg=/INCREMENTAL:NO",
# Skip PDB generation entirely in dev builds.
# The .pdb file is the most common reason the linker fails after
# an unclean shutdown: the previous phokus.exe process holds the
# file open, so link.exe cannot write a new one → LNK error.
# Rust backtraces still work without MSVC PDBs.
"-C", "link-arg=/DEBUG:NONE",
]
+308
View File
@@ -61,6 +61,73 @@ dependencies = [
"libc",
]
[[package]]
name = "anndists"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8396b473aa0bceed68fb32462505387ea39fa47c7029417e0a49f10592b036"
dependencies = [
"anyhow",
"cfg-if",
"cpu-time",
"env_logger",
"lazy_static",
"log",
"num-traits",
"num_cpus",
"rayon",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -266,6 +333,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -595,6 +671,12 @@ 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 = "chacha20"
version = "0.10.0"
@@ -626,6 +708,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
@@ -757,6 +845,16 @@ dependencies = [
"libc",
]
[[package]]
name = "cpu-time"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1352,12 +1450,35 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "env_logger"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -2340,6 +2461,8 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
]
@@ -2416,6 +2539,31 @@ version = "1.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
[[package]]
name = "hnsw_rs"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a5258f079b97bf2e8311ff9579e903c899dcbac0d9a138d62e9a066778bd07"
dependencies = [
"anndists",
"anyhow",
"bincode",
"cfg-if",
"cpu-time",
"env_logger",
"hashbrown 0.15.5",
"indexmap 2.13.1",
"lazy_static",
"log",
"mmap-rs",
"num-traits",
"num_cpus",
"parking_lot",
"rand 0.9.2",
"rayon",
"serde",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -2819,6 +2967,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2857,6 +3011,30 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2958,6 +3136,12 @@ dependencies = [
"selectors 0.24.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -3109,6 +3293,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "macro_rules_attribute"
version = "0.2.2"
@@ -3235,6 +3428,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mmap-rs"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ecce9d566cb9234ae3db9e249c8b55665feaaf32b0859ff1e27e310d2beb3d8"
dependencies = [
"bitflags 2.11.0",
"combine",
"libc",
"mach2",
"nix",
"sysctl",
"thiserror 2.0.18",
"widestring",
"windows 0.48.0",
]
[[package]]
name = "monostate"
version = "0.1.18"
@@ -3356,6 +3566,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -3634,6 +3856,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "onig"
version = "6.5.1"
@@ -4034,8 +4262,10 @@ dependencies = [
"fast_image_resize",
"ffmpeg-sidecar",
"hf-hub",
"hnsw_rs",
"image",
"log",
"memmap2",
"ort",
"r2d2",
"r2d2_sqlite",
@@ -6575,6 +6805,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.0"
@@ -6938,6 +7174,12 @@ dependencies = [
"winsafe",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6984,6 +7226,15 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -7236,6 +7487,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -7302,6 +7568,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -7320,6 +7592,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -7338,6 +7616,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -7368,6 +7652,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -7386,6 +7676,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -7404,6 +7700,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -7422,6 +7724,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
+50
View File
@@ -27,6 +27,7 @@ rusqlite = { version = "0.32", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.25"
sqlite-vec = "=0.1.9"
hnsw_rs = "0.3.4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] }
fast_image_resize = { version = "6.0.0", features = ["image"] }
walkdir = "2"
@@ -38,6 +39,7 @@ anyhow = "1"
log = "0.4"
ffmpeg-sidecar = "2.5.0"
xxhash-rust = { version = "0.8", features = ["xxh3"] }
memmap2 = "0.9"
sysinfo = "0.38.4"
candle-core = { version = "0.10.2", features = ["cuda"] }
candle-nn = { version = "0.10.2", features = ["cuda"] }
@@ -48,3 +50,51 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n
ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
csv = "1"
# ── Dev-mode performance ────────────────────────────────────────────────────
# opt-level=1 on the main crate keeps incremental compile short.
# Only the packages that are genuine hot-path bottlenecks in dev get opt-level=3
# (using "*" caused cargo to recheck all dependency fingerprints too aggressively,
# which caused spurious full rebuilds and linker conflicts).
[profile.dev]
opt-level = 1
# ML inference — without opt these run 20-50× slower than release
[profile.dev.package.candle-core]
opt-level = 3
[profile.dev.package.candle-nn]
opt-level = 3
[profile.dev.package.candle-transformers]
opt-level = 3
# ONNX runtime (WD tagger)
[profile.dev.package.ort]
opt-level = 3
[profile.dev.package.ort-sys]
opt-level = 3
# Image decode/resize workers
[profile.dev.package.image]
opt-level = 3
[profile.dev.package.fast_image_resize]
opt-level = 3
# Parallel work scheduler
[profile.dev.package.rayon]
opt-level = 3
[profile.dev.package.rayon-core]
opt-level = 3
# Tokenisation (embedding model pre-processing)
[profile.dev.package.tokenizers]
opt-level = 3
# Hashing (duplicate finder)
[profile.dev.package.xxhash-rust]
opt-level = 3
# SQLite (frequent db calls in workers)
[profile.dev.package.rusqlite]
opt-level = 3
[profile.dev.package.libsqlite3-sys]
opt-level = 3
+399 -14
View File
@@ -2,8 +2,9 @@ use crate::captioner::{
self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe,
CaptionVisionProbe,
};
use crate::db::{self, DbPool, Folder, FolderJobProgress, ImageRecord, ImageTag};
use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag};
use crate::embedder;
use crate::hnsw_index;
use crate::indexer;
use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe};
use crate::vector;
@@ -21,12 +22,21 @@ pub struct ImagesPage {
pub limit: i64,
}
#[derive(Serialize)]
pub struct SimilarImagesPage {
pub images: Vec<ImageRecord>,
pub offset: usize,
pub limit: usize,
pub has_more: bool,
}
#[derive(Deserialize)]
pub struct GetImagesParams {
pub folder_id: Option<i64>,
pub search: Option<String>,
pub media_kind: Option<String>,
pub favorites_only: Option<bool>,
pub rating_min: Option<i64>,
pub embedding_failed_only: Option<bool>,
pub sort: Option<String>,
pub offset: Option<i64>,
@@ -44,7 +54,9 @@ pub struct UpdateImageDetailsParams {
pub struct FindSimilarImagesParams {
pub image_id: i64,
pub folder_id: Option<i64>,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub threshold: Option<f32>,
}
#[derive(Deserialize)]
@@ -114,9 +126,56 @@ pub struct SemanticSearchParams {
pub folder_id: Option<i64>,
pub media_kind: Option<String>,
pub favorites_only: Option<bool>,
pub rating_min: Option<i64>,
pub limit: Option<usize>,
}
#[derive(Deserialize)]
pub struct TagSearchParams {
pub query: String,
pub folder_id: Option<i64>,
pub media_kind: Option<String>,
pub favorites_only: Option<bool>,
pub rating_min: Option<i64>,
pub limit: Option<usize>,
}
#[derive(Deserialize)]
pub struct GetExploreTagsParams {
pub folder_id: Option<i64>,
pub limit: Option<usize>,
}
#[derive(Deserialize)]
pub struct SearchTagsAutocompleteParams {
pub query: String,
pub folder_id: Option<i64>,
pub limit: Option<usize>,
}
#[derive(Serialize, Deserialize)]
pub struct DuplicateGroup {
pub file_hash: String,
pub file_size: u64,
pub images: Vec<ImageRecord>,
}
#[derive(Serialize)]
pub struct DuplicateScanCache {
pub groups: Vec<DuplicateGroup>,
pub scanned_at: i64,
}
#[derive(Deserialize)]
pub struct DeleteImagesFromDiskParams {
pub image_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct GetImagesByIdsParams {
pub image_ids: Vec<i64>,
}
#[tauri::command]
pub async fn add_folder(
app: AppHandle,
@@ -187,6 +246,7 @@ pub async fn get_images(
let search = params.search.as_deref();
let media_kind = params.media_kind.as_deref();
let favorites_only = params.favorites_only.unwrap_or(false);
let rating_min = params.rating_min.unwrap_or(0);
let embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
let total = db::count_images(
@@ -195,6 +255,7 @@ pub async fn get_images(
search,
media_kind,
favorites_only,
rating_min,
embedding_failed_only,
)
.map_err(|e| e.to_string())?;
@@ -205,6 +266,7 @@ pub async fn get_images(
search,
media_kind,
favorites_only,
rating_min,
embedding_failed_only,
sort,
offset,
@@ -254,16 +316,42 @@ pub async fn reindex_folder(
pub async fn find_similar_images(
db: State<'_, DbState>,
params: FindSimilarImagesParams,
) -> Result<Vec<ImageRecord>, String> {
) -> Result<SimilarImagesPage, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let limit = params.limit.unwrap_or(32);
let offset = params.offset.unwrap_or(0);
let threshold = params.threshold.unwrap_or(0.24);
if !vector::has_image_vector(&conn, params.image_id).map_err(|e| e.to_string())? {
db::repair_embedding_consistency(&conn).map_err(|e| e.to_string())?;
return Ok(Vec::new());
return Ok(SimilarImagesPage {
images: Vec::new(),
offset,
limit,
has_more: false,
});
}
let image_ids = vector::find_similar_image_ids(&conn, params.image_id, limit, params.folder_id)
let matches = hnsw_index::find_similar_image_matches(
&conn,
params.image_id,
params.folder_id,
threshold,
offset,
limit + 1,
)
.map_err(|e| e.to_string())?;
db::get_images_by_ids(&conn, &image_ids).map_err(|e| e.to_string())
let has_more = matches.len() > limit;
let image_ids = matches
.into_iter()
.take(limit)
.map(|(image_id, _)| image_id)
.collect::<Vec<_>>();
let images = db::get_images_by_ids(&conn, &image_ids).map_err(|e| e.to_string())?;
Ok(SimilarImagesPage {
images,
offset,
limit,
has_more,
})
}
#[derive(Serialize)]
@@ -328,10 +416,31 @@ pub async fn semantic_search_images(
if params.favorites_only.unwrap_or(false) {
images.retain(|image| image.favorite);
}
if let Some(rating_min) = params.rating_min {
images.retain(|image| image.rating >= rating_min);
}
Ok(images)
}
#[tauri::command]
pub async fn search_images_by_tag(
db: State<'_, DbState>,
params: TagSearchParams,
) -> Result<Vec<ImageRecord>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::search_images_by_tag(
&conn,
&params.query,
params.folder_id,
params.media_kind.as_deref(),
params.favorites_only.unwrap_or(false),
params.rating_min.unwrap_or(0),
params.limit.unwrap_or(64),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_generated_caption(
db: State<'_, DbState>,
@@ -521,6 +630,8 @@ pub struct TagCloudEntry {
pub count: usize,
pub representative_image_id: i64,
pub thumbnail_path: Option<String>,
#[serde(default)]
pub image_ids: Vec<i64>,
}
fn fnv_hash_ids(ids: &[i64]) -> u64 {
@@ -569,7 +680,11 @@ pub async fn get_tag_cloud(
.map_err(|e| e.to_string())?
{
if let Ok(entries) = serde_json::from_str::<Vec<TagCloudEntry>>(&json) {
return Ok(entries);
// Reject cache entries written before image_ids were tracked — they all
// have empty image_ids which causes "No media found" when a cluster is opened.
if entries.iter().all(|e| !e.image_ids.is_empty()) {
return Ok(entries);
}
}
}
}
@@ -597,6 +712,13 @@ pub async fn get_tag_cloud(
}
let centroid = &centroids[ci];
let cluster_ids = points
.iter()
.enumerate()
.filter(|(i, _)| assignments[*i] == ci)
.map(|(i, _)| ids[i])
.collect::<Vec<_>>();
let best_id = points
.iter()
.enumerate()
@@ -614,6 +736,7 @@ pub async fn get_tag_cloud(
count,
representative_image_id: best_id,
thumbnail_path,
image_ids: cluster_ids,
});
}
@@ -625,6 +748,224 @@ pub async fn get_tag_cloud(
Ok(entries)
}
#[tauri::command]
pub async fn get_explore_tags(
db: State<'_, DbState>,
params: GetExploreTagsParams,
) -> Result<Vec<ExploreTagEntry>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn search_tags_autocomplete(
db: State<'_, DbState>,
params: SearchTagsAutocompleteParams,
) -> Result<Vec<ExploreTagEntry>, String> {
if params.query.trim().is_empty() {
return Ok(vec![]);
}
let conn = db.get().map_err(|e| e.to_string())?;
db::search_tags_autocomplete(&conn, &params.query, params.folder_id, params.limit.unwrap_or(10))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn find_duplicates(
app: AppHandle,
db: State<'_, DbState>,
folder_id: Option<i64>,
) -> Result<Vec<DuplicateGroup>, String> {
let records = {
let conn = db.get().map_err(|e| e.to_string())?;
db::get_all_image_paths(&conn, folder_id).map_err(|e| e.to_string())?
};
let total = records.len();
let _ = app.emit("duplicate_scan_progress", (0usize, total));
// Two-phase detection. No full-file read at any point.
//
// Phase 1 — stat:
// Read only file metadata (size). Files with a unique size cannot be
// duplicates; discard them immediately. Zero file content read.
//
// Phase 2 — sample hash:
// For each size-matched candidate, read four evenly-spaced 16 KB windows
// (head, 33%, 66%, tail) and hash them together. Total I/O per file is
// capped at 64 KB regardless of how large the file is.
//
// For small files (≤ 64 KB) the windows cover the whole file, so the
// hash is exact. For large files, matching all four windows at different
// offsets is effectively impossible for natural photo/video content unless
// the files are genuinely identical — eliminating the need for a full read.
let app_hash = app.clone();
let pairs: Vec<(u64, u64, i64)> = tokio::task::spawn_blocking(move || {
use memmap2::Mmap;
use rayon::prelude::*;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use xxhash_rust::xxh3::{xxh3_64, Xxh3};
const WINDOW: usize = 16 * 1024; // 16 KB per sample window
const N: usize = 4; // windows at 0%, 33%, 66%, 100%
const COVERED: usize = WINDOW * N; // 64 KB total; also full-coverage threshold
// Hash four evenly-spaced 16 KB windows. For files ≤ 64 KB this is
// equivalent to hashing the entire file.
fn sample(mmap: &[u8]) -> u64 {
if mmap.len() <= COVERED {
return xxh3_64(mmap);
}
let mut h = Xxh3::new();
for i in 0..N {
let pos = (mmap.len() - WINDOW) * i / (N - 1);
h.update(&mmap[pos..pos + WINDOW]);
}
h.digest()
}
// ── Phase 1: stat ─────────────────────────────────────────────────
let sized: Vec<(i64, String, u64)> = records
.par_iter()
.filter_map(|r| {
let size = std::fs::metadata(&r.path).ok()?.len();
if size == 0 { return None; }
Some((r.id, r.path.clone(), size))
})
.collect();
let mut by_size: HashMap<u64, Vec<usize>> = HashMap::new();
for (i, (_, _, size)) in sized.iter().enumerate() {
by_size.entry(*size).or_default().push(i);
}
let candidates: Vec<usize> = by_size
.into_values()
.filter(|g| g.len() > 1)
.flatten()
.collect();
if candidates.is_empty() {
return vec![];
}
// ── Phase 2: sample hash ──────────────────────────────────────────
let c_total = candidates.len();
let _ = app_hash.emit("duplicate_scan_progress", (0usize, c_total));
let counter = AtomicUsize::new(0);
candidates
.par_iter()
.filter_map(|&idx| {
let (id, path, size) = &sized[idx];
let file = std::fs::File::open(path).ok()?;
// SAFETY: read-only; no external truncation expected during scan.
let mmap = unsafe { Mmap::map(&file).ok()? };
let hash = sample(&mmap);
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
if done % 100 == 0 || done == c_total {
let _ = app_hash.emit("duplicate_scan_progress", (done, c_total));
}
Some((hash, *size, *id))
})
.collect()
})
.await
.map_err(|e| e.to_string())?;
let mut size_hash_map: std::collections::HashMap<(u64, u64), Vec<i64>> = std::collections::HashMap::new();
for (hash, file_size, id) in pairs {
size_hash_map.entry((hash, file_size)).or_default().push(id);
}
// Resolve image records for each duplicate group
let conn = db.get().map_err(|e| e.to_string())?;
let mut groups: Vec<DuplicateGroup> = size_hash_map
.into_iter()
.filter(|(_, ids)| ids.len() > 1)
.filter_map(|((hash, file_size), ids)| {
let images = db::get_images_by_ids(&conn, &ids).ok()?;
Some(DuplicateGroup {
file_hash: format!("{:016x}", hash),
file_size,
images,
})
})
.collect();
// Largest duplicates first — wastes the most space
groups.sort_by(|a, b| b.file_size.cmp(&a.file_size));
// Persist results so they survive restart — best-effort, ignore errors.
let folder_scope = match folder_id {
Some(id) => format!("folder:{}", id),
None => "all".to_string(),
};
if let Ok(json) = serde_json::to_string(&groups) {
let _ = db::set_duplicate_scan_cache(&conn, &folder_scope, &json);
}
Ok(groups)
}
#[tauri::command]
pub async fn load_duplicate_scan_cache(
db: State<'_, DbState>,
folder_id: Option<i64>,
) -> Result<Option<DuplicateScanCache>, String> {
let folder_scope = match folder_id {
Some(id) => format!("folder:{}", id),
None => "all".to_string(),
};
let conn = db.get().map_err(|e| e.to_string())?;
match db::get_duplicate_scan_cache(&conn, &folder_scope).map_err(|e| e.to_string())? {
Some((json, scanned_at)) => {
let groups: Vec<DuplicateGroup> =
serde_json::from_str(&json).map_err(|e| e.to_string())?;
Ok(Some(DuplicateScanCache { groups, scanned_at }))
}
None => Ok(None),
}
}
#[tauri::command]
pub async fn delete_images_from_disk(
db: State<'_, DbState>,
params: DeleteImagesFromDiskParams,
) -> Result<usize, String> {
if params.image_ids.is_empty() {
return Ok(0);
}
let conn = db.get().map_err(|e| e.to_string())?;
// Collect paths before deleting DB rows
let records = db::get_all_image_paths(&conn, None).map_err(|e| e.to_string())?;
let id_set: std::collections::HashSet<i64> = params.image_ids.iter().copied().collect();
let paths: Vec<String> = records
.into_iter()
.filter(|r| id_set.contains(&r.id))
.map(|r| r.path)
.collect();
db::delete_images_by_ids(&conn, &params.image_ids).map_err(|e| e.to_string())?;
let mut deleted = 0usize;
for path in &paths {
if std::fs::remove_file(path).is_ok() {
deleted += 1;
}
}
Ok(deleted)
}
#[tauri::command]
pub async fn get_images_by_ids(
db: State<'_, DbState>,
params: GetImagesByIdsParams,
) -> Result<Vec<ImageRecord>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::get_images_by_ids(&conn, &params.image_ids).map_err(|e| e.to_string())
}
// ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ──
fn dot(a: &[f32], b: &[f32]) -> f32 {
@@ -811,15 +1152,22 @@ pub struct SetTaggerThresholdParams {
pub threshold: f32,
}
#[derive(Deserialize)]
pub struct SetTaggerBatchSizeParams {
pub batch_size: usize,
}
#[derive(Deserialize)]
pub struct QueueTaggingJobsParams {
pub folder_id: Option<i64>,
pub folder_ids: Option<Vec<i64>>,
pub image_id: Option<i64>,
}
#[derive(Deserialize)]
pub struct ClearTaggingJobsParams {
pub folder_id: Option<i64>,
pub folder_ids: Option<Vec<i64>>,
}
#[derive(Deserialize)]
@@ -883,6 +1231,21 @@ pub async fn set_tagger_threshold(
tagger::set_tagger_threshold(&app_dir, params.threshold).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_tagger_batch_size(app: AppHandle) -> Result<usize, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
Ok(tagger::tagger_batch_size(&app_dir))
}
#[tauri::command]
pub async fn set_tagger_batch_size(
app: AppHandle,
params: SetTaggerBatchSizeParams,
) -> Result<usize, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
tagger::set_tagger_batch_size(&app_dir, params.batch_size).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn prepare_tagger_model(app: AppHandle) -> Result<TaggerModelStatus, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
@@ -913,19 +1276,28 @@ pub async fn queue_tagging_jobs(
params: QueueTaggingJobsParams,
) -> Result<usize, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let (total, folder_ids) = match (params.folder_id, params.image_id) {
(_, Some(image_id)) => {
let requested_folder_ids = params.folder_ids.unwrap_or_default();
let (total, folder_ids) = match (params.folder_id, params.image_id, requested_folder_ids.is_empty()) {
(_, Some(image_id), _) => {
db::enqueue_tagging_job(&conn, image_id).map_err(|e| e.to_string())?;
// Look up just this image's folder_id rather than fetching all folders
let image = db::get_image_by_id(&conn, image_id).map_err(|e| e.to_string())?;
(1usize, vec![image.folder_id])
}
(Some(folder_id), None) => {
(Some(folder_id), None, _) => {
let n = db::enqueue_missing_tagging_jobs_for_folder(&conn, folder_id)
.map_err(|e| e.to_string())?;
(n, vec![folder_id])
}
(None, None) => {
(None, None, false) => {
let mut total = 0usize;
for &folder_id in &requested_folder_ids {
total += db::enqueue_missing_tagging_jobs_for_folder(&conn, folder_id)
.map_err(|e| e.to_string())?;
}
(total, requested_folder_ids)
}
(None, None, true) => {
let folders = db::get_folders(&conn).map_err(|e| e.to_string())?;
let folder_ids: Vec<i64> = folders.iter().map(|f| f.id).collect();
let mut total = 0usize;
@@ -948,14 +1320,27 @@ pub async fn clear_tagging_jobs(
params: ClearTaggingJobsParams,
) -> Result<usize, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let n = db::clear_tagging_jobs(&conn, params.folder_id).map_err(|e| e.to_string())?;
let folder_ids: Vec<i64> = match params.folder_id {
Some(id) => vec![id],
None => db::get_folders(&conn)
let requested_folder_ids = params.folder_ids.unwrap_or_default();
let (n, folder_ids): (usize, Vec<i64>) = match (params.folder_id, requested_folder_ids.is_empty()) {
(Some(id), _) => (
db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?,
vec![id],
),
(None, false) => {
let mut total = 0usize;
for &folder_id in &requested_folder_ids {
total += db::clear_tagging_jobs(&conn, Some(folder_id)).map_err(|e| e.to_string())?;
}
(total, requested_folder_ids)
}
(None, true) => (
db::clear_tagging_jobs(&conn, None).map_err(|e| e.to_string())?,
db::get_folders(&conn)
.map_err(|e| e.to_string())?
.into_iter()
.map(|f| f.id)
.collect(),
),
};
drop(conn);
indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true);
+197 -3
View File
@@ -122,6 +122,14 @@ pub struct ImageTag {
pub created_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExploreTagEntry {
pub tag: String,
pub count: i64,
pub representative_image_id: i64,
pub thumbnail_path: Option<String>,
}
#[derive(Debug, Clone)]
pub struct IndexedMediaEntry {
pub id: i64,
@@ -247,6 +255,12 @@ pub fn migrate(conn: &Connection) -> Result<()> {
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS duplicate_scan_cache (
folder_scope TEXT PRIMARY KEY,
scanned_at INTEGER NOT NULL,
groups_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id);
CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at);
CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status);
@@ -1302,6 +1316,7 @@ pub fn get_images(
search: Option<&str>,
media_kind: Option<&str>,
favorites_only: bool,
rating_min: i64,
embedding_failed_only: bool,
sort: &str,
offset: i64,
@@ -1314,6 +1329,8 @@ pub fn get_images(
"date_desc" => "modified_at DESC NULLS LAST",
"size_asc" => "file_size ASC",
"size_desc" => "file_size DESC",
"rating_asc" => "rating ASC, modified_at DESC NULLS LAST",
"rating_desc" => "rating DESC, modified_at DESC NULLS LAST",
"duration_asc" => "duration_ms ASC NULLS LAST",
"duration_desc" => "duration_ms DESC NULLS LAST",
_ => "modified_at DESC NULLS LAST",
@@ -1333,9 +1350,10 @@ pub fn get_images(
AND (?2 IS NULL OR filename LIKE ?2)
AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1)
AND (?5 = 0 OR embedding_status = 'failed')
AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')
ORDER BY {}
LIMIT ?6 OFFSET ?7",
LIMIT ?7 OFFSET ?8",
order
);
let mut stmt = conn.prepare(&sql)?;
@@ -1345,6 +1363,7 @@ pub fn get_images(
search_pattern,
media_kind,
favorites_flag,
rating_min,
embedding_failed_flag,
limit,
offset
@@ -1360,6 +1379,7 @@ pub fn count_images(
search: Option<&str>,
media_kind: Option<&str>,
favorites_only: bool,
rating_min: i64,
embedding_failed_only: bool,
) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{}%", value));
@@ -1372,12 +1392,14 @@ pub fn count_images(
AND (?2 IS NULL OR filename LIKE ?2)
AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1)
AND (?5 = 0 OR embedding_status = 'failed')",
AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')",
params![
folder_id,
search_pattern,
media_kind,
favorites_flag,
rating_min,
embedding_failed_flag
],
|row| row.get(0),
@@ -1386,6 +1408,141 @@ pub fn count_images(
Ok(count)
}
pub fn search_images_by_tag(
conn: &Connection,
query: &str,
folder_id: Option<i64>,
media_kind: Option<&str>,
favorites_only: bool,
rating_min: i64,
limit: usize,
) -> Result<Vec<ImageRecord>> {
let normalized_query = query.trim().to_ascii_lowercase();
if normalized_query.is_empty() {
return Ok(Vec::new());
}
let favorites_flag = i64::from(favorites_only);
let mut stmt = conn.prepare(
"SELECT DISTINCT i.id
FROM images i
JOIN image_tags t ON t.image_id = i.id
WHERE (?1 IS NULL OR i.folder_id = ?1)
AND (?2 IS NULL OR i.media_kind = ?2)
AND (?3 = 0 OR i.favorite = 1)
AND i.rating >= ?4
AND LOWER(TRIM(t.tag)) = ?5
ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC
LIMIT ?6",
)?;
let image_ids = stmt
.query_map(
params![
folder_id,
media_kind,
favorites_flag,
rating_min,
normalized_query,
limit as i64
],
|row| row.get::<_, i64>(0),
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
get_images_by_ids(conn, &image_ids)
}
pub fn search_tags_autocomplete(
conn: &Connection,
query: &str,
folder_id: Option<i64>,
limit: usize,
) -> Result<Vec<ExploreTagEntry>> {
let pattern = format!("%{}%", query.to_lowercase());
let mut stmt = conn.prepare(
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id
FROM image_tags t
JOIN images i ON i.id = t.image_id
WHERE (?1 IS NULL OR i.folder_id = ?1)
AND LOWER(t.tag) LIKE ?2
GROUP BY t.tag
ORDER BY tag_count DESC, t.tag ASC
LIMIT ?3",
)?;
let rows = stmt
.query_map(params![folder_id, pattern, limit as i64], |row| {
Ok(ExploreTagEntry {
tag: row.get(0)?,
count: row.get(1)?,
representative_image_id: row.get(2)?,
thumbnail_path: None, // skip per-suggestion thumbnail for speed
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub struct ImagePathRecord {
pub id: i64,
pub path: String,
pub thumbnail_path: Option<String>,
}
pub fn get_all_image_paths(
conn: &Connection,
folder_id: Option<i64>,
) -> Result<Vec<ImagePathRecord>> {
let mut stmt = conn.prepare(
"SELECT id, path, thumbnail_path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id",
)?;
let rows = stmt
.query_map(params![folder_id], |row| {
Ok(ImagePathRecord {
id: row.get(0)?,
path: row.get(1)?,
thumbnail_path: row.get(2)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn get_explore_tags(
conn: &Connection,
folder_id: Option<i64>,
limit: usize,
) -> Result<Vec<ExploreTagEntry>> {
let mut stmt = conn.prepare(
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id
FROM image_tags t
JOIN images i ON i.id = t.image_id
WHERE (?1 IS NULL OR i.folder_id = ?1)
GROUP BY t.tag
HAVING COUNT(DISTINCT t.image_id) >= 2
ORDER BY tag_count DESC, t.tag ASC
LIMIT ?2",
)?;
let rows = stmt
.query_map(params![folder_id, limit as i64], |row| {
let representative_image_id = row.get::<_, i64>(2)?;
let thumbnail_path = get_image_by_id(conn, representative_image_id)
.ok()
.and_then(|image| image.thumbnail_path);
Ok(ExploreTagEntry {
tag: row.get(0)?,
count: row.get(1)?,
representative_image_id,
thumbnail_path,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn get_failed_embedding_images(
conn: &Connection,
folder_id: i64,
@@ -1871,6 +2028,43 @@ pub fn set_tag_cloud_cache(
Ok(())
}
/// Returns (groups_json, scanned_at_unix) for the given folder scope, if present.
pub fn get_duplicate_scan_cache(
conn: &Connection,
folder_scope: &str,
) -> Result<Option<(String, i64)>> {
match conn.query_row(
"SELECT groups_json, scanned_at FROM duplicate_scan_cache WHERE folder_scope = ?1",
params![folder_scope],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
) {
Ok(row) => Ok(Some(row)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Upserts the duplicate scan cache for the given scope.
pub fn set_duplicate_scan_cache(
conn: &Connection,
folder_scope: &str,
groups_json: &str,
) -> Result<()> {
let scanned_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
conn.execute(
"INSERT INTO duplicate_scan_cache (folder_scope, scanned_at, groups_json)
VALUES (?1, ?2, ?3)
ON CONFLICT(folder_scope) DO UPDATE SET
scanned_at = excluded.scanned_at,
groups_json = excluded.groups_json",
params![folder_scope, scanned_at, groups_json],
)?;
Ok(())
}
fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?;
let mut rows = stmt.query([])?;
+129
View File
@@ -0,0 +1,129 @@
use crate::vector;
use anyhow::Result;
use hnsw_rs::prelude::{DistCosine, Hnsw, Neighbour};
use rusqlite::Connection;
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
const HNSW_MAX_CONNECTIONS: usize = 24;
const HNSW_EF_CONSTRUCTION: usize = 300;
const HNSW_EF_SEARCH: usize = 96;
struct CachedHnswIndex {
revision: String,
image_ids_by_external: Vec<i64>,
external_by_image_id: HashMap<i64, usize>,
hnsw: Hnsw<'static, f32, DistCosine>,
}
static IMAGE_HNSW_INDEX: OnceLock<RwLock<Option<CachedHnswIndex>>> = OnceLock::new();
fn cache() -> &'static RwLock<Option<CachedHnswIndex>> {
IMAGE_HNSW_INDEX.get_or_init(|| RwLock::new(None))
}
fn build_index(conn: &Connection) -> Result<CachedHnswIndex> {
let embeddings = vector::get_all_image_embeddings_with_ids(conn, None)?;
let max_elements = embeddings.len().max(1);
let max_layer = 16.min((max_elements as f32).ln().trunc() as usize).max(1);
let mut hnsw = Hnsw::<f32, DistCosine>::new(
HNSW_MAX_CONNECTIONS,
max_elements,
max_layer,
HNSW_EF_CONSTRUCTION,
DistCosine {},
);
let image_ids_by_external = embeddings
.iter()
.map(|(image_id, _)| *image_id)
.collect::<Vec<_>>();
let external_by_image_id = image_ids_by_external
.iter()
.enumerate()
.map(|(external_id, image_id)| (*image_id, external_id))
.collect::<HashMap<_, _>>();
let data_with_id = embeddings
.iter()
.enumerate()
.map(|(external_id, (_, embedding))| (embedding, external_id))
.collect::<Vec<_>>();
hnsw.parallel_insert(&data_with_id);
hnsw.set_searching_mode(true);
Ok(CachedHnswIndex {
revision: vector::get_embedding_revision(conn)?,
image_ids_by_external,
external_by_image_id,
hnsw,
})
}
fn ensure_index(conn: &Connection) -> Result<()> {
let revision = vector::get_embedding_revision(conn)?;
{
let guard = cache().read().expect("hnsw cache poisoned");
if guard.as_ref().map(|cached| cached.revision.as_str()) == Some(revision.as_str()) {
return Ok(());
}
}
let next = build_index(conn)?;
let mut guard = cache().write().expect("hnsw cache poisoned");
*guard = Some(next);
Ok(())
}
pub fn find_similar_image_matches(
conn: &Connection,
image_id: i64,
folder_id: Option<i64>,
threshold: f32,
offset: usize,
limit: usize,
) -> Result<Vec<(i64, f32)>> {
ensure_index(conn)?;
let query_embedding = match vector::get_image_embedding(conn, image_id)? {
Some(embedding) => embedding,
None => return Ok(Vec::new()),
};
let guard = cache().read().expect("hnsw cache poisoned");
let Some(cached) = guard.as_ref() else {
return Ok(Vec::new());
};
let knbn = (offset + limit).max(limit).saturating_add(32);
let neighbours: Vec<Neighbour> = if let Some(folder_id) = folder_id {
let mut allowed_ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter()
.filter_map(|(allowed_image_id, _)| {
cached.external_by_image_id.get(&allowed_image_id).copied()
})
.collect::<Vec<_>>();
allowed_ids.sort_unstable();
cached
.hnsw
.search_filter(&query_embedding, knbn, HNSW_EF_SEARCH, Some(&allowed_ids))
} else {
cached.hnsw.search(&query_embedding, knbn, HNSW_EF_SEARCH)
};
let matches = neighbours
.into_iter()
.filter_map(|neighbour| {
let image_id_match = cached.image_ids_by_external.get(neighbour.d_id).copied()?;
if image_id_match == image_id || neighbour.distance > threshold {
return None;
}
Some((image_id_match, neighbour.distance))
})
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
Ok(matches)
}
+3 -3
View File
@@ -331,6 +331,7 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
if !missing_ids.is_empty() {
db::delete_images_by_ids(&conn, &missing_ids)?;
}
let _ = db::backfill_embedding_jobs(&conn)?;
db::update_folder_count(&conn, folder_id)?;
}
@@ -864,8 +865,6 @@ fn process_caption_batch(
Ok(())
}
const TAGGING_BATCH_SIZE: usize = 1;
fn process_tagging_batch(
app: &AppHandle,
pool: &DbPool,
@@ -877,9 +876,10 @@ fn process_tagging_batch(
}
let paused_folders = paused_folder_ids("tagging");
let batch_size = crate::tagger::tagger_batch_size(app_data_dir);
let jobs = with_db_write_lock(|| {
let mut conn = pool.get()?;
db::claim_tagging_jobs(&mut conn, &paused_folders, TAGGING_BATCH_SIZE)
db::claim_tagging_jobs(&mut conn, &paused_folders, batch_size)
})?;
if jobs.is_empty() {
+10
View File
@@ -2,6 +2,7 @@ mod captioner;
mod commands;
mod db;
mod embedder;
mod hnsw_index;
mod indexer;
mod media;
mod storage;
@@ -89,6 +90,7 @@ pub fn run() {
commands::debug_similar_images,
commands::retry_failed_embeddings,
commands::semantic_search_images,
commands::search_images_by_tag,
commands::get_caption_model_status,
commands::get_caption_acceleration,
commands::set_caption_acceleration,
@@ -107,6 +109,8 @@ pub fn run() {
commands::set_worker_paused,
commands::get_worker_states,
commands::get_tag_cloud,
commands::get_explore_tags,
commands::get_images_by_ids,
commands::get_failed_embedding_images,
commands::get_tagger_model_status,
commands::get_tagger_acceleration,
@@ -114,6 +118,8 @@ pub fn run() {
commands::probe_tagger_runtime,
commands::get_tagger_threshold,
commands::set_tagger_threshold,
commands::get_tagger_batch_size,
commands::set_tagger_batch_size,
commands::prepare_tagger_model,
commands::delete_tagger_model,
commands::queue_tagging_jobs,
@@ -121,6 +127,10 @@ pub fn run() {
commands::get_image_tags,
commands::add_user_tag,
commands::remove_tag,
commands::search_tags_autocomplete,
commands::find_duplicates,
commands::load_duplicate_scan_cache,
commands::delete_images_from_disk,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+23
View File
@@ -14,6 +14,7 @@ pub const WD_TAGGER_MODEL_NAME: &str = "wd-swinv2-tagger-v3";
const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt";
const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt";
const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt";
// Files required on disk before the tagger can run. The ONNX runtime DLLs
// are shared with the captioner and live in the same `onnxruntime/` directory.
@@ -193,6 +194,28 @@ pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result<f32>
Ok(clamped)
}
pub fn tagger_batch_size(app_data_dir: &Path) -> usize {
let path = app_data_dir.join(TAGGER_BATCH_SIZE_FILE);
let Ok(value) = std::fs::read_to_string(path) else {
return 8;
};
value
.trim()
.parse::<usize>()
.unwrap_or(8)
.clamp(1, 100)
}
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
let clamped = batch_size.clamp(1, 100);
let path = app_data_dir.join(TAGGER_BATCH_SIZE_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, clamped.to_string())?;
Ok(clamped)
}
// ---------------------------------------------------------------------------
// Model status / download
// ---------------------------------------------------------------------------
+114 -25
View File
@@ -96,15 +96,25 @@ pub fn find_similar_image_ids(
Err(error) => return Err(error.into()),
};
let allowed_folder_ids = match folder_id {
Some(folder_id) => Some(image_ids_for_folder(conn, folder_id)?),
None => None,
};
let search_limit = if allowed_folder_ids.is_some() {
count_image_vectors(conn)?.max(1) as usize
} else {
limit + 1
};
if let Some(folder_id) = folder_id {
// Brute-force cosine scan scoped to the folder — avoids the KNN k=4096 limit
// and returns exact nearest neighbours within the folder.
let mut stmt = conn.prepare(
"SELECT v.image_id
FROM image_vec v
JOIN images i ON i.id = v.image_id
WHERE i.folder_id = ?2
AND v.image_id != ?3
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
LIMIT ?4",
)?;
let rows = stmt.query_map((&embedding, folder_id, image_id, limit as i64), |row| {
row.get::<_, i64>(0)
})?;
return Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?);
}
// Global KNN search (no folder filter) — use the ANN index.
let mut stmt = conn.prepare(
"SELECT image_id
FROM image_vec
@@ -112,20 +122,12 @@ pub fn find_similar_image_ids(
AND k = ?2",
)?;
let rows = stmt
.query_map((&embedding, search_limit as i64), |row| {
row.get::<_, i64>(0)
})?
.query_map((&embedding, (limit + 1) as i64), |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut ids = Vec::new();
for row in rows {
if row != image_id {
if allowed_folder_ids
.as_ref()
.is_some_and(|folder_ids| !folder_ids.contains(&row))
{
continue;
}
ids.push(row);
}
if ids.len() >= limit {
@@ -135,15 +137,102 @@ pub fn find_similar_image_ids(
Ok(ids)
}
fn image_ids_for_folder(
conn: &Connection,
folder_id: i64,
) -> Result<std::collections::HashSet<i64>> {
let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?;
let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?;
Ok(rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?)
// pub fn find_similar_image_matches(
// conn: &Connection,
// image_id: i64,
// folder_id: Option<i64>,
// threshold: f32,
// offset: usize,
// limit: usize,
// ) -> Result<Vec<(i64, f32)>> {
// let embedding: Vec<u8> = match conn.query_row(
// "SELECT embedding FROM image_vec WHERE image_id = ?1",
// [image_id],
// |row| row.get(0),
// ) {
// Ok(embedding) => embedding,
// Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()),
// Err(error) => return Err(error.into()),
// };
// let query = match folder_id {
// Some(_) => {
// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance
// FROM image_vec v
// JOIN images i ON i.id = v.image_id
// WHERE i.folder_id = ?2
// AND v.image_id != ?3
// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?4
// ORDER BY distance ASC
// LIMIT ?5 OFFSET ?6"
// }
// None => {
// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance
// FROM image_vec v
// WHERE v.image_id != ?2
// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?3
// ORDER BY distance ASC
// LIMIT ?4 OFFSET ?5"
// }
// };
// let mut stmt = conn.prepare(query)?;
// match folder_id {
// Some(folder_id) => Ok(stmt
// .query_map(
// (
// &embedding,
// folder_id,
// image_id,
// threshold,
// limit as i64,
// offset as i64,
// ),
// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)),
// )?
// .collect::<rusqlite::Result<Vec<_>>>()?),
// None => Ok(stmt
// .query_map(
// (&embedding, image_id, threshold, limit as i64, offset as i64),
// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)),
// )?
// .collect::<rusqlite::Result<Vec<_>>>()?),
// }
// }
pub fn get_image_embedding(conn: &Connection, image_id: i64) -> Result<Option<Vec<f32>>> {
let embedding: Result<Vec<u8>, rusqlite::Error> = conn.query_row(
"SELECT embedding FROM image_vec WHERE image_id = ?1",
[image_id],
|row| row.get(0),
);
match embedding {
Ok(bytes) => Ok(Some(unpack_f32(&bytes))),
Err(SqliteError::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM image_vec", [], |row| row.get(0))?;
let max_updated_at: Option<String> = conn.query_row(
"SELECT MAX(embedding_updated_at) FROM images WHERE embedding_status = 'ready'",
[],
|row| row.get(0),
)?;
Ok(format!("{}:{}", count, max_updated_at.unwrap_or_default()))
}
// fn image_ids_for_folder(
// conn: &Connection,
// folder_id: i64,
// ) -> Result<std::collections::HashSet<i64>> {
// let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?;
// let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?;
// Ok(rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?)
// }
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
/// Each entry is `(image_id, normalized_f32_embedding)`.
pub fn get_all_image_embeddings_with_ids(
+6 -4
View File
@@ -4,9 +4,9 @@
"version": "0.1.0",
"identifier": "wtf.jezz.phokus",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeDevCommand": "pnpm dev:vite",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"beforeBuildCommand": "pnpm build:vite",
"frontendDist": "../dist"
},
"app": {
@@ -25,7 +25,9 @@
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["**"]
"scope": [
"**"
]
}
}
},
@@ -40,4 +42,4 @@
"icons/icon.ico"
]
}
}
}