feat: expand media exploration and tagging controls
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Generated
+308
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
¶ms.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 = ¢roids[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, ¶ms.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, ¶ms.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, ¶ms.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
@@ -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([])?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user