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
+7 -3
View File
@@ -4,9 +4,11 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "build:app": "tauri build",
"build": "tsc && vite build", "build:vite": "tsc && vite build",
"preview": "vite preview", "dev:app": "tauri dev",
"dev:vite": "vite",
"preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
@@ -15,6 +17,7 @@
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"d3-force": "^3.0.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -23,6 +26,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/d3-force": "^3.0.10",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
+39
View File
@@ -23,6 +23,9 @@ importers:
'@tauri-apps/plugin-opener': '@tauri-apps/plugin-opener':
specifier: ^2 specifier: ^2
version: 2.5.3 version: 2.5.3
d3-force:
specifier: ^3.0.0
version: 3.0.0
framer-motion: framer-motion:
specifier: ^12.38.0 specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -42,6 +45,9 @@ importers:
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^2 specifier: ^2
version: 2.10.1 version: 2.10.1
'@types/d3-force':
specifier: ^3.0.10
version: 3.0.10
'@types/react': '@types/react':
specifier: ^19.1.8 specifier: ^19.1.8
version: 19.2.14 version: 19.2.14
@@ -662,6 +668,9 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -698,6 +707,22 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-force@3.0.0:
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
engines: {node: '>=12'}
d3-quadtree@3.0.1:
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1448,6 +1473,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@types/d3-force@3.0.10': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)': '@types/react-dom@19.2.3(@types/react@19.2.14)':
@@ -1486,6 +1513,18 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-dispatch@3.0.1: {}
d3-force@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-quadtree: 3.0.1
d3-timer: 3.0.1
d3-quadtree@3.0.1: {}
d3-timer@3.0.1: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
+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", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -266,6 +333,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -595,6 +671,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.0" version = "0.10.0"
@@ -626,6 +708,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -757,6 +845,16 @@ dependencies = [
"libc", "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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -1352,12 +1450,35 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "env_home" name = "env_home"
version = "0.1.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" 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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -2340,6 +2461,8 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5", "foldhash 0.1.5",
] ]
@@ -2416,6 +2539,31 @@ version = "1.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" 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]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.29.1" version = "0.29.1"
@@ -2819,6 +2967,12 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@@ -2857,6 +3011,30 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"
@@ -2958,6 +3136,12 @@ dependencies = [
"selectors 0.24.0", "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]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@@ -3109,6 +3293,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "macro_rules_attribute" name = "macro_rules_attribute"
version = "0.2.2" version = "0.2.2"
@@ -3235,6 +3428,23 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "monostate" name = "monostate"
version = "0.1.18" version = "0.1.18"
@@ -3356,6 +3566,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 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]] [[package]]
name = "nodrop" name = "nodrop"
version = "0.1.14" version = "0.1.14"
@@ -3634,6 +3856,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "onig" name = "onig"
version = "6.5.1" version = "6.5.1"
@@ -4034,8 +4262,10 @@ dependencies = [
"fast_image_resize", "fast_image_resize",
"ffmpeg-sidecar", "ffmpeg-sidecar",
"hf-hub", "hf-hub",
"hnsw_rs",
"image", "image",
"log", "log",
"memmap2",
"ort", "ort",
"r2d2", "r2d2",
"r2d2_sqlite", "r2d2_sqlite",
@@ -6575,6 +6805,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.0" version = "1.23.0"
@@ -6938,6 +7174,12 @@ dependencies = [
"winsafe", "winsafe",
] ]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -6984,6 +7226,15 @@ dependencies = [
"windows-version", "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]] [[package]]
name = "windows" name = "windows"
version = "0.61.3" version = "0.61.3"
@@ -7236,6 +7487,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -7302,6 +7568,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -7320,6 +7592,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7338,6 +7616,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -7368,6 +7652,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7386,6 +7676,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -7404,6 +7700,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -7422,6 +7724,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
+50
View File
@@ -27,6 +27,7 @@ rusqlite = { version = "0.32", features = ["bundled"] }
r2d2 = "0.8" r2d2 = "0.8"
r2d2_sqlite = "0.25" r2d2_sqlite = "0.25"
sqlite-vec = "=0.1.9" sqlite-vec = "=0.1.9"
hnsw_rs = "0.3.4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] }
fast_image_resize = { version = "6.0.0", features = ["image"] } fast_image_resize = { version = "6.0.0", features = ["image"] }
walkdir = "2" walkdir = "2"
@@ -38,6 +39,7 @@ anyhow = "1"
log = "0.4" log = "0.4"
ffmpeg-sidecar = "2.5.0" ffmpeg-sidecar = "2.5.0"
xxhash-rust = { version = "0.8", features = ["xxh3"] } xxhash-rust = { version = "0.8", features = ["xxh3"] }
memmap2 = "0.9"
sysinfo = "0.38.4" sysinfo = "0.38.4"
candle-core = { version = "0.10.2", features = ["cuda"] } candle-core = { version = "0.10.2", features = ["cuda"] }
candle-nn = { 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"] } ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
zip = { version = "4.6.1", default-features = false, features = ["deflate"] } zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
csv = "1" 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
+398 -13
View File
@@ -2,8 +2,9 @@ use crate::captioner::{
self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe, self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe,
CaptionVisionProbe, 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::embedder;
use crate::hnsw_index;
use crate::indexer; use crate::indexer;
use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe};
use crate::vector; use crate::vector;
@@ -21,12 +22,21 @@ pub struct ImagesPage {
pub limit: i64, pub limit: i64,
} }
#[derive(Serialize)]
pub struct SimilarImagesPage {
pub images: Vec<ImageRecord>,
pub offset: usize,
pub limit: usize,
pub has_more: bool,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct GetImagesParams { pub struct GetImagesParams {
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
pub search: Option<String>, pub search: Option<String>,
pub media_kind: Option<String>, pub media_kind: Option<String>,
pub favorites_only: Option<bool>, pub favorites_only: Option<bool>,
pub rating_min: Option<i64>,
pub embedding_failed_only: Option<bool>, pub embedding_failed_only: Option<bool>,
pub sort: Option<String>, pub sort: Option<String>,
pub offset: Option<i64>, pub offset: Option<i64>,
@@ -44,7 +54,9 @@ pub struct UpdateImageDetailsParams {
pub struct FindSimilarImagesParams { pub struct FindSimilarImagesParams {
pub image_id: i64, pub image_id: i64,
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
pub offset: Option<usize>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub threshold: Option<f32>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -114,9 +126,56 @@ pub struct SemanticSearchParams {
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
pub media_kind: Option<String>, pub media_kind: Option<String>,
pub favorites_only: Option<bool>, pub favorites_only: Option<bool>,
pub rating_min: Option<i64>,
pub limit: Option<usize>, 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] #[tauri::command]
pub async fn add_folder( pub async fn add_folder(
app: AppHandle, app: AppHandle,
@@ -187,6 +246,7 @@ pub async fn get_images(
let search = params.search.as_deref(); let search = params.search.as_deref();
let media_kind = params.media_kind.as_deref(); let media_kind = params.media_kind.as_deref();
let favorites_only = params.favorites_only.unwrap_or(false); 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 embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
let total = db::count_images( let total = db::count_images(
@@ -195,6 +255,7 @@ pub async fn get_images(
search, search,
media_kind, media_kind,
favorites_only, favorites_only,
rating_min,
embedding_failed_only, embedding_failed_only,
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -205,6 +266,7 @@ pub async fn get_images(
search, search,
media_kind, media_kind,
favorites_only, favorites_only,
rating_min,
embedding_failed_only, embedding_failed_only,
sort, sort,
offset, offset,
@@ -254,16 +316,42 @@ pub async fn reindex_folder(
pub async fn find_similar_images( pub async fn find_similar_images(
db: State<'_, DbState>, db: State<'_, DbState>,
params: FindSimilarImagesParams, params: FindSimilarImagesParams,
) -> Result<Vec<ImageRecord>, String> { ) -> Result<SimilarImagesPage, String> {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
let limit = params.limit.unwrap_or(32); 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())? { 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())?; 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())?; .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)] #[derive(Serialize)]
@@ -328,10 +416,31 @@ pub async fn semantic_search_images(
if params.favorites_only.unwrap_or(false) { if params.favorites_only.unwrap_or(false) {
images.retain(|image| image.favorite); images.retain(|image| image.favorite);
} }
if let Some(rating_min) = params.rating_min {
images.retain(|image| image.rating >= rating_min);
}
Ok(images) 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] #[tauri::command]
pub async fn set_generated_caption( pub async fn set_generated_caption(
db: State<'_, DbState>, db: State<'_, DbState>,
@@ -521,6 +630,8 @@ pub struct TagCloudEntry {
pub count: usize, pub count: usize,
pub representative_image_id: i64, pub representative_image_id: i64,
pub thumbnail_path: Option<String>, pub thumbnail_path: Option<String>,
#[serde(default)]
pub image_ids: Vec<i64>,
} }
fn fnv_hash_ids(ids: &[i64]) -> u64 { fn fnv_hash_ids(ids: &[i64]) -> u64 {
@@ -569,10 +680,14 @@ pub async fn get_tag_cloud(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
{ {
if let Ok(entries) = serde_json::from_str::<Vec<TagCloudEntry>>(&json) { if let Ok(entries) = serde_json::from_str::<Vec<TagCloudEntry>>(&json) {
// 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); return Ok(entries);
} }
} }
} }
}
// Cache miss — run k-means // Cache miss — run k-means
let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect(); let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect();
@@ -597,6 +712,13 @@ pub async fn get_tag_cloud(
} }
let centroid = &centroids[ci]; 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 let best_id = points
.iter() .iter()
.enumerate() .enumerate()
@@ -614,6 +736,7 @@ pub async fn get_tag_cloud(
count, count,
representative_image_id: best_id, representative_image_id: best_id,
thumbnail_path, thumbnail_path,
image_ids: cluster_ids,
}); });
} }
@@ -625,6 +748,224 @@ pub async fn get_tag_cloud(
Ok(entries) 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) ── // ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ──
fn dot(a: &[f32], b: &[f32]) -> f32 { fn dot(a: &[f32], b: &[f32]) -> f32 {
@@ -811,15 +1152,22 @@ pub struct SetTaggerThresholdParams {
pub threshold: f32, pub threshold: f32,
} }
#[derive(Deserialize)]
pub struct SetTaggerBatchSizeParams {
pub batch_size: usize,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct QueueTaggingJobsParams { pub struct QueueTaggingJobsParams {
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
pub folder_ids: Option<Vec<i64>>,
pub image_id: Option<i64>, pub image_id: Option<i64>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ClearTaggingJobsParams { pub struct ClearTaggingJobsParams {
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
pub folder_ids: Option<Vec<i64>>,
} }
#[derive(Deserialize)] #[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()) 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] #[tauri::command]
pub async fn prepare_tagger_model(app: AppHandle) -> Result<TaggerModelStatus, String> { 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())?; 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, params: QueueTaggingJobsParams,
) -> Result<usize, String> { ) -> Result<usize, String> {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
let (total, folder_ids) = match (params.folder_id, params.image_id) { let requested_folder_ids = params.folder_ids.unwrap_or_default();
(_, Some(image_id)) => { 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())?; 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 // 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())?; let image = db::get_image_by_id(&conn, image_id).map_err(|e| e.to_string())?;
(1usize, vec![image.folder_id]) (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) let n = db::enqueue_missing_tagging_jobs_for_folder(&conn, folder_id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
(n, vec![folder_id]) (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 folders = db::get_folders(&conn).map_err(|e| e.to_string())?;
let folder_ids: Vec<i64> = folders.iter().map(|f| f.id).collect(); let folder_ids: Vec<i64> = folders.iter().map(|f| f.id).collect();
let mut total = 0usize; let mut total = 0usize;
@@ -948,14 +1320,27 @@ pub async fn clear_tagging_jobs(
params: ClearTaggingJobsParams, params: ClearTaggingJobsParams,
) -> Result<usize, String> { ) -> Result<usize, String> {
let conn = db.get().map_err(|e| e.to_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 requested_folder_ids = params.folder_ids.unwrap_or_default();
let folder_ids: Vec<i64> = match params.folder_id { let (n, folder_ids): (usize, Vec<i64>) = match (params.folder_id, requested_folder_ids.is_empty()) {
Some(id) => vec![id], (Some(id), _) => (
None => db::get_folders(&conn) 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())? .map_err(|e| e.to_string())?
.into_iter() .into_iter()
.map(|f| f.id) .map(|f| f.id)
.collect(), .collect(),
),
}; };
drop(conn); drop(conn);
indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true); 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, 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)] #[derive(Debug, Clone)]
pub struct IndexedMediaEntry { pub struct IndexedMediaEntry {
pub id: i64, pub id: i64,
@@ -247,6 +255,12 @@ pub fn migrate(conn: &Connection) -> Result<()> {
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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_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_images_modified_at ON images(modified_at);
CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status); CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status);
@@ -1302,6 +1316,7 @@ pub fn get_images(
search: Option<&str>, search: Option<&str>,
media_kind: Option<&str>, media_kind: Option<&str>,
favorites_only: bool, favorites_only: bool,
rating_min: i64,
embedding_failed_only: bool, embedding_failed_only: bool,
sort: &str, sort: &str,
offset: i64, offset: i64,
@@ -1314,6 +1329,8 @@ pub fn get_images(
"date_desc" => "modified_at DESC NULLS LAST", "date_desc" => "modified_at DESC NULLS LAST",
"size_asc" => "file_size ASC", "size_asc" => "file_size ASC",
"size_desc" => "file_size DESC", "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_asc" => "duration_ms ASC NULLS LAST",
"duration_desc" => "duration_ms DESC NULLS LAST", "duration_desc" => "duration_ms DESC NULLS LAST",
_ => "modified_at 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 (?2 IS NULL OR filename LIKE ?2)
AND (?3 IS NULL OR media_kind = ?3) AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1) 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 {} ORDER BY {}
LIMIT ?6 OFFSET ?7", LIMIT ?7 OFFSET ?8",
order order
); );
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
@@ -1345,6 +1363,7 @@ pub fn get_images(
search_pattern, search_pattern,
media_kind, media_kind,
favorites_flag, favorites_flag,
rating_min,
embedding_failed_flag, embedding_failed_flag,
limit, limit,
offset offset
@@ -1360,6 +1379,7 @@ pub fn count_images(
search: Option<&str>, search: Option<&str>,
media_kind: Option<&str>, media_kind: Option<&str>,
favorites_only: bool, favorites_only: bool,
rating_min: i64,
embedding_failed_only: bool, embedding_failed_only: bool,
) -> Result<i64> { ) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{}%", value)); 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 (?2 IS NULL OR filename LIKE ?2)
AND (?3 IS NULL OR media_kind = ?3) AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1) AND (?4 = 0 OR favorite = 1)
AND (?5 = 0 OR embedding_status = 'failed')", AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')",
params![ params![
folder_id, folder_id,
search_pattern, search_pattern,
media_kind, media_kind,
favorites_flag, favorites_flag,
rating_min,
embedding_failed_flag embedding_failed_flag
], ],
|row| row.get(0), |row| row.get(0),
@@ -1386,6 +1408,141 @@ pub fn count_images(
Ok(count) 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( pub fn get_failed_embedding_images(
conn: &Connection, conn: &Connection,
folder_id: i64, folder_id: i64,
@@ -1871,6 +2028,43 @@ pub fn set_tag_cloud_cache(
Ok(()) 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<()> { fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?; let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?;
let mut rows = stmt.query([])?; 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() { if !missing_ids.is_empty() {
db::delete_images_by_ids(&conn, &missing_ids)?; db::delete_images_by_ids(&conn, &missing_ids)?;
} }
let _ = db::backfill_embedding_jobs(&conn)?;
db::update_folder_count(&conn, folder_id)?; db::update_folder_count(&conn, folder_id)?;
} }
@@ -864,8 +865,6 @@ fn process_caption_batch(
Ok(()) Ok(())
} }
const TAGGING_BATCH_SIZE: usize = 1;
fn process_tagging_batch( fn process_tagging_batch(
app: &AppHandle, app: &AppHandle,
pool: &DbPool, pool: &DbPool,
@@ -877,9 +876,10 @@ fn process_tagging_batch(
} }
let paused_folders = paused_folder_ids("tagging"); 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 jobs = with_db_write_lock(|| {
let mut conn = pool.get()?; 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() { if jobs.is_empty() {
+10
View File
@@ -2,6 +2,7 @@ mod captioner;
mod commands; mod commands;
mod db; mod db;
mod embedder; mod embedder;
mod hnsw_index;
mod indexer; mod indexer;
mod media; mod media;
mod storage; mod storage;
@@ -89,6 +90,7 @@ pub fn run() {
commands::debug_similar_images, commands::debug_similar_images,
commands::retry_failed_embeddings, commands::retry_failed_embeddings,
commands::semantic_search_images, commands::semantic_search_images,
commands::search_images_by_tag,
commands::get_caption_model_status, commands::get_caption_model_status,
commands::get_caption_acceleration, commands::get_caption_acceleration,
commands::set_caption_acceleration, commands::set_caption_acceleration,
@@ -107,6 +109,8 @@ pub fn run() {
commands::set_worker_paused, commands::set_worker_paused,
commands::get_worker_states, commands::get_worker_states,
commands::get_tag_cloud, commands::get_tag_cloud,
commands::get_explore_tags,
commands::get_images_by_ids,
commands::get_failed_embedding_images, commands::get_failed_embedding_images,
commands::get_tagger_model_status, commands::get_tagger_model_status,
commands::get_tagger_acceleration, commands::get_tagger_acceleration,
@@ -114,6 +118,8 @@ pub fn run() {
commands::probe_tagger_runtime, commands::probe_tagger_runtime,
commands::get_tagger_threshold, commands::get_tagger_threshold,
commands::set_tagger_threshold, commands::set_tagger_threshold,
commands::get_tagger_batch_size,
commands::set_tagger_batch_size,
commands::prepare_tagger_model, commands::prepare_tagger_model,
commands::delete_tagger_model, commands::delete_tagger_model,
commands::queue_tagging_jobs, commands::queue_tagging_jobs,
@@ -121,6 +127,10 @@ pub fn run() {
commands::get_image_tags, commands::get_image_tags,
commands::add_user_tag, commands::add_user_tag,
commands::remove_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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .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_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt";
const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.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 // 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. // 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) 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 // Model status / download
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+114 -25
View File
@@ -96,15 +96,25 @@ pub fn find_similar_image_ids(
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
}; };
let allowed_folder_ids = match folder_id { if let Some(folder_id) = folder_id {
Some(folder_id) => Some(image_ids_for_folder(conn, folder_id)?), // Brute-force cosine scan scoped to the folder — avoids the KNN k=4096 limit
None => None, // and returns exact nearest neighbours within the folder.
}; let mut stmt = conn.prepare(
let search_limit = if allowed_folder_ids.is_some() { "SELECT v.image_id
count_image_vectors(conn)?.max(1) as usize FROM image_vec v
} else { JOIN images i ON i.id = v.image_id
limit + 1 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( let mut stmt = conn.prepare(
"SELECT image_id "SELECT image_id
FROM image_vec FROM image_vec
@@ -112,20 +122,12 @@ pub fn find_similar_image_ids(
AND k = ?2", AND k = ?2",
)?; )?;
let rows = stmt let rows = stmt
.query_map((&embedding, search_limit as i64), |row| { .query_map((&embedding, (limit + 1) as i64), |row| row.get::<_, i64>(0))?
row.get::<_, i64>(0)
})?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
let mut ids = Vec::new(); let mut ids = Vec::new();
for row in rows { for row in rows {
if row != image_id { if row != image_id {
if allowed_folder_ids
.as_ref()
.is_some_and(|folder_ids| !folder_ids.contains(&row))
{
continue;
}
ids.push(row); ids.push(row);
} }
if ids.len() >= limit { if ids.len() >= limit {
@@ -135,15 +137,102 @@ pub fn find_similar_image_ids(
Ok(ids) Ok(ids)
} }
fn image_ids_for_folder( // pub fn find_similar_image_matches(
conn: &Connection, // conn: &Connection,
folder_id: i64, // image_id: i64,
) -> Result<std::collections::HashSet<i64>> { // folder_id: Option<i64>,
let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?; // threshold: f32,
let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?; // offset: usize,
Ok(rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?) // 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. /// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
/// Each entry is `(image_id, normalized_f32_embedding)`. /// Each entry is `(image_id, normalized_f32_embedding)`.
pub fn get_all_image_embeddings_with_ids( pub fn get_all_image_embeddings_with_ids(
+5 -3
View File
@@ -4,9 +4,9 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "wtf.jezz.phokus", "identifier": "wtf.jezz.phokus",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev:vite",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build", "beforeBuildCommand": "pnpm build:vite",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
@@ -25,7 +25,9 @@
"csp": null, "csp": null,
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**"] "scope": [
"**"
]
} }
} }
}, },
+8
View File
@@ -6,6 +6,7 @@ import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery"; import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox"; import { Lightbox } from "./components/Lightbox";
import { TagCloud } from "./components/TagCloud"; import { TagCloud } from "./components/TagCloud";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from "./components/SettingsModal";
@@ -14,6 +15,7 @@ export default function App() {
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress); const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
const loadImages = useGalleryStore((state) => state.loadImages); const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus); const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
@@ -21,6 +23,7 @@ export default function App() {
loadFolders().then(() => { loadFolders().then(() => {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress();
void loadCaptionModelStatus(); void loadCaptionModelStatus();
void loadDuplicateScanCache();
return loadImages(true); return loadImages(true);
}); });
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
@@ -46,6 +49,11 @@ export default function App() {
<BackgroundTasks /> <BackgroundTasks />
<TagCloud /> <TagCloud />
</> </>
) : activeView === "duplicates" ? (
<>
<BackgroundTasks />
<DuplicateFinder />
</>
) : ( ) : (
<> <>
<Toolbar /> <Toolbar />
+39 -7
View File
@@ -58,6 +58,8 @@ export function BackgroundTasks() {
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({}); const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({}); const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
@@ -127,6 +129,7 @@ export function BackgroundTasks() {
}; };
const dismissTask = (id: number, snapshot: string) => { const dismissTask = (id: number, snapshot: string) => {
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
void clearTaggingJobs(id); void clearTaggingJobs(id);
setDismissed((prev) => ({ ...prev, [id]: snapshot })); setDismissed((prev) => ({ ...prev, [id]: snapshot }));
setExpanded(false); setExpanded(false);
@@ -244,10 +247,35 @@ export function BackgroundTasks() {
.filter((t) => dismissed[t.id] !== t.snapshot); .filter((t) => dismissed[t.id] !== t.snapshot);
}, [folders, indexingProgress, mediaJobProgress, dismissed]); }, [folders, indexingProgress, mediaJobProgress, dismissed]);
if (tasks.length === 0) return null; // Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed
const duplicateScanTask: Task | null = duplicateScanning ? {
id: -1,
name: "Duplicate Scan",
stages: [{
label: "Hashing",
detail: duplicateScanProgress
? `${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
: "Starting…",
progress: duplicateScanProgress && duplicateScanProgress.total > 0
? (duplicateScanProgress.scanned / duplicateScanProgress.total) * 100
: null,
failed: false,
}],
hasFailedEmbeddings: false,
hasFailedTagging: false,
pendingMediaWork: 1,
embeddingProcessed: 0,
embeddingTotal: 0,
currentFile: null,
snapshot: "",
} : null;
const primary = tasks[0]; const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks;
const extraCount = tasks.length - 1;
if (allTasks.length === 0) return null;
const primary = allTasks[0];
const extraCount = allTasks.length - 1;
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging) && t.pendingMediaWork === 0); const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging) && t.pendingMediaWork === 0);
// Best progress bar value: use embedding progress if available (most informative), // Best progress bar value: use embedding progress if available (most informative),
@@ -348,8 +376,8 @@ export function BackgroundTasks() {
</button> </button>
)} )}
{/* Expand chevron (only when multiple folders) */} {/* Expand chevron (only when multiple tasks) */}
{tasks.length > 1 && ( {allTasks.length > 1 && (
<svg <svg
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`} className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke="currentColor"
@@ -358,7 +386,8 @@ export function BackgroundTasks() {
</svg> </svg>
)} )}
{/* Dismiss */} {/* Dismiss — hidden for system tasks like duplicate scan */}
{primary.id >= 0 && (
<button <button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0" className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss" title="Dismiss"
@@ -368,12 +397,13 @@ export function BackgroundTasks() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
)}
</div> </div>
{/* Expanded panel — one row per folder */} {/* Expanded panel — one row per folder */}
{expanded && ( {expanded && (
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3"> <div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
{tasks.map((task) => { {allTasks.map((task) => {
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings"); const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
const taskTaggingStage = task.stages.find((s) => s.label === "Tags"); const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
const taskScanningStage = task.stages.find((s) => s.label === "Scanning"); const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
@@ -453,6 +483,7 @@ export function BackgroundTasks() {
</button> </button>
)} )}
{task.id >= 0 && (
<button <button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0" className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss" title="Dismiss"
@@ -462,6 +493,7 @@ export function BackgroundTasks() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
)}
</div> </div>
{task.currentFile && ( {task.currentFile && (
+274
View File
@@ -0,0 +1,274 @@
import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store";
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected);
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates);
const groupSelectedCount = group.images.filter((img) => selectedIds.has(img.id)).length;
const noneSelected = groupSelectedCount === 0;
// "Keep all but the first" — a common quick action
const handleKeepFirst = () => {
const toDelete = group.images.slice(1).map((img) => img.id);
// Clear any selection for this group first, then add the ones to delete
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
selectAllDuplicates(toDelete);
};
return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4">
{/* Group header */}
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
{group.images.length} copies
</span>
<span className="text-[11px] text-white/30">{formatBytes(group.file_size)} each</span>
<span className="text-[11px] text-white/20">
{formatBytes(group.file_size * (group.images.length - 1))} wasted
</span>
</div>
<div className="flex items-center gap-2">
{noneSelected ? (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={handleKeepFirst}
>
Keep first
</button>
) : (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={() => {
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
}}
>
Deselect all
</button>
)}
</div>
</div>
{/* Image grid */}
<div className="flex flex-wrap gap-3">
{group.images.map((image) => {
const isSelected = selectedIds.has(image.id);
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<button
key={image.id}
className={`group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20"
}`}
style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)}
title={image.path}
>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
) : (
<div className="h-full w-full bg-white/[0.03]" />
)}
{/* Delete overlay */}
{isSelected ? (
<div className="absolute inset-0 flex items-center justify-center bg-red-950/60">
<svg className="h-6 w-6 text-red-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
) : null}
{/* Path tooltip on hover */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
</div>
</button>
);
})}
</div>
</div>
);
}
function formatRelativeTime(unixSecs: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSecs;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
export function DuplicateFinder() {
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates);
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection);
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups);
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const [deleting, setDeleting] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
const selectedCount = duplicateSelectedIds.size;
const hasResults = duplicateGroups.length > 0;
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
const totalWasted = duplicateGroups.reduce(
(sum, g) => sum + g.file_size * (g.images.length - 1),
0,
);
const totalDuplicateImages = duplicateGroups.reduce((sum, g) => sum + g.images.length - 1, 0);
const handleDelete = async () => {
setDeleting(true);
setDeleteResult(null);
try {
const deleted = await deleteSelectedDuplicates();
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`);
} catch (e) {
setDeleteResult(String(e));
} finally {
setDeleting(false);
}
};
const progressPercent =
duplicateScanProgress && duplicateScanProgress.total > 0
? Math.round((duplicateScanProgress.scanned / duplicateScanProgress.total) * 100)
: 0;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]">
{/* Header */}
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-[15px] font-semibold text-white">Duplicate Finder</h2>
<p className="mt-0.5 text-[11px] text-white/30">
{duplicateScanning
? duplicateScanProgress
? `Scanning… ${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
: "Starting scan…"
: hasResults
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
: duplicateLastScanned !== null
? "No duplicates found"
: "Scan your library for identical files"}
</p>
{!duplicateScanning && duplicateLastScanned !== null && (
<p className="mt-0.5 text-[10px] text-white/20">
Last scanned {formatRelativeTime(duplicateLastScanned)}
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && (
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={selectKeepFirstAllGroups}
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
>
Select all duplicates
</button>
)}
{selectedCount > 0 ? (
<>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40"
onClick={clearDuplicateSelection}
disabled={deleting}
>
Deselect all
</button>
<button
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
</>
) : null}
<button
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }}
disabled={duplicateScanning}
>
{duplicateScanning ? "Scanning…" : hasScanned ? "Rescan" : "Scan for duplicates"}
</button>
</div>
</div>
{/* Progress bar */}
{duplicateScanning && duplicateScanProgress ? (
<div className="mt-3 h-px overflow-hidden rounded-full bg-white/[0.07]">
<div
className="h-full rounded-full bg-blue-500/60 transition-[width] duration-200"
style={{ width: `${progressPercent}%` }}
/>
</div>
) : null}
{deleteResult ? (
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
) : null}
</div>
{/* Body */}
{duplicateScanning && !hasResults ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
<span className="text-sm">Hashing files</span>
</div>
) : !hasScanned ? (
<div className="flex flex-1 items-center justify-center px-8">
<div className="max-w-sm text-center">
<svg className="mx-auto mb-4 h-10 w-10 text-white/10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30">
Finds files with identical content regardless of filename or location.
Click <strong className="text-white/50">Scan for duplicates</strong> to begin.
</p>
<p className="mt-2 text-xs text-white/20">
Large libraries may take a minute files are hashed from disk.
</p>
</div>
</div>
) : duplicateGroups.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-white/25">No duplicate files found.</p>
</div>
) : (
<div className="overflow-y-auto px-6 py-5">
<div className="space-y-4">
{duplicateGroups.map((group) => (
<DuplicateGroupCard key={group.file_hash} group={group} />
))}
</div>
</div>
)}
</div>
);
}
+37 -23
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback, useState } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
const GAP = 6; const GAP = 6;
@@ -30,6 +30,7 @@ function ContextMenu({
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === "ready";
return ( return (
@@ -59,7 +60,7 @@ function ContextMenu({
}`} }`}
onClick={async () => { onClick={async () => {
if (!canFindSimilar) return; if (!canFindSimilar) return;
await loadSimilarImages(image.id); await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
onClose(); onClose();
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
@@ -115,6 +116,7 @@ function ImageTile({
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false); const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path const src = image.thumbnail_path
@@ -181,6 +183,15 @@ function ImageTile({
</svg> </svg>
</div> </div>
)} )}
{image.rating > 0 && (
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
{Array.from({ length: image.rating }, (_, index) => (
<svg key={index} className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
)}
{image.media_kind === "video" && image.duration_ms && ( {image.media_kind === "video" && image.duration_ms && (
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm"> <div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
{formatDuration(image.duration_ms)} {formatDuration(image.duration_ms)}
@@ -230,7 +241,7 @@ function ImageTile({
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (!canFindSimilar) return; if (!canFindSimilar) return;
void loadSimilarImages(image.id); void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
> >
@@ -250,11 +261,11 @@ export function Gallery() {
const loadingImages = useGalleryStore((state) => state.loadingImages); const loadingImages = useGalleryStore((state) => state.loadingImages);
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const search = useGalleryStore((state) => state.search); const search = useGalleryStore((state) => state.search);
const searchMode = useGalleryStore((state) => state.searchMode);
const collectionTitle = useGalleryStore((state) => state.collectionTitle); const collectionTitle = useGalleryStore((state) => state.collectionTitle);
const imageLoadError = useGalleryStore((state) => state.imageLoadError); const imageLoadError = useGalleryStore((state) => state.imageLoadError);
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey); const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey);
const isSimilarResults = collectionTitle === "Similar Images"; const isSimilarResults = collectionTitle === "Similar Images";
const parsedSearch = parseSearchValue(search);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
@@ -262,6 +273,7 @@ export function Gallery() {
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const element = parentRef.current; const element = parentRef.current;
if (!element) return; if (!element) return;
if (element.scrollTop < 24) return;
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600; const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) { if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages(); void loadMoreImages();
@@ -295,33 +307,34 @@ export function Gallery() {
}; };
}, []); }, []);
if (images.length === 0 && loadingImages) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8"> <div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72"> <div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" /> <div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<p className="mt-4 text-sm text-white/40 font-medium"> <p className="mt-4 text-sm text-white/40 font-medium">
{isSimilarResults {isSimilarResults
? "Finding similar images" ? "Finding similar images"
: searchMode === "semantic" && search.trim().length > 0 : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? `Searching for matches to "${search}"` ? `Searching for matches to "${parsedSearch.query}"`
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? `Searching tags for "${parsedSearch.query}"`
: "Loading media"} : "Loading media"}
</p> </p>
<p className="text-xs text-white/20 mt-1"> <p className="text-xs text-white/20 mt-1">
{isSimilarResults {isSimilarResults
? "Comparing visual embeddings" ? "Comparing visual embeddings"
: searchMode === "semantic" && search.trim().length > 0 : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Semantic search can take a little longer than filename search" ? "Semantic search can take a little longer than filename search"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Matching against AI and user tags"
: "Fetching results"} : "Fetching results"}
</p> </p>
</div> </div>
</div> </div>
); ) : images.length === 0 && !loadingImages ? (
} <div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
if (images.length === 0 && !loadingImages) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8"> <div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75}
@@ -332,8 +345,10 @@ export function Gallery() {
? "Could not load results" ? "Could not load results"
: isSimilarResults : isSimilarResults
? "No similar images found" ? "No similar images found"
: searchMode === "semantic" && search.trim().length > 0 : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "No semantic matches found" ? "No semantic matches found"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "No tag matches found"
: "No media found"} : "No media found"}
</p> </p>
<p className="text-xs text-white/15 mt-1"> <p className="text-xs text-white/15 mt-1">
@@ -341,17 +356,15 @@ export function Gallery() {
? imageLoadError ? imageLoadError
: isSimilarResults : isSimilarResults
? "This item may be visually isolated, or more embeddings may need to finish processing" ? "This item may be visually isolated, or more embeddings may need to finish processing"
: searchMode === "semantic" && search.trim().length > 0 : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Try a broader phrase, or wait for more embeddings to finish processing" ? "Try a broader phrase, or wait for more embeddings to finish processing"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Try a shorter tag, or wait for more tagging jobs to finish"
: "Try adjusting your filters or add a new folder"} : "Try adjusting your filters or add a new folder"}
</p> </p>
</div> </div>
</div> </div>
); ) : (
}
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
<div <div
className="grid content-start" className="grid content-start"
style={{ style={{
@@ -372,8 +385,9 @@ export function Gallery() {
/> />
))} ))}
</div> </div>
)}
{loadingImages ? ( {images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" /> <div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
</div> </div>
+2 -1
View File
@@ -64,6 +64,7 @@ export function Lightbox() {
const images = useGalleryStore((state) => state.images); const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const getImageTags = useGalleryStore((state) => state.getImageTags); const getImageTags = useGalleryStore((state) => state.getImageTags);
const addUserTag = useGalleryStore((state) => state.addUserTag); const addUserTag = useGalleryStore((state) => state.addUserTag);
@@ -246,7 +247,7 @@ export function Lightbox() {
}`} }`}
onClick={() => { onClick={() => {
if (!canFindSimilar) return; if (!canFindSimilar) return;
void loadSimilarImages(selectedImage.id); void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id);
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
> >
+355 -249
View File
@@ -1,16 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { TaggerAcceleration, useGalleryStore } from "../store"; import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store";
type SettingsSection = "tagging" | "library" | "display" | "storage"; type SettingsSection = "workspace" | "workers";
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "tagging", label: "AI Tagging", detail: "WD tagger model" }, { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
{ id: "library", label: "Library", detail: "Indexing and scanning" }, { id: "workers", label: "Workers", detail: "Queue activity and background processing" },
{ id: "display", label: "Display", detail: "Gallery preferences" },
{ id: "storage", label: "Storage", detail: "Cache and model files" },
]; ];
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) { function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
const className = const className =
tone === "ready" tone === "ready"
@@ -19,19 +16,72 @@ function StatusPill({ children, tone }: { children: React.ReactNode; tone: "read
? "border-sky-400/25 bg-sky-500/10 text-sky-300" ? "border-sky-400/25 bg-sky-500/10 text-sky-300"
: "border-white/10 bg-white/[0.04] text-gray-500"; : "border-white/10 bg-white/[0.04] text-gray-500";
return <span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}>{children}</span>;
}
function SectionShell({ eyebrow, title, description, children }: {
eyebrow: string;
title: string;
description?: string;
children: React.ReactNode;
}) {
return ( return (
<span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}> <section>
{children} <p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
</span> <h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-gray-500">{description}</p> : null}
<div className="mt-5 space-y-4">{children}</div>
</section>
); );
} }
function TaggerAccelerationButton({ function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
acceleration, return (
current, <div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5">
onSelect, <div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4">
children, <p className="text-sm font-medium text-white">{title}</p>
}: { {description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : null}
</div>
<div className="pt-4">{children}</div>
</div>
);
}
function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0 first:pt-0 last:pb-0">
<div className="min-w-0">
<p className="text-sm font-medium text-white">{title}</p>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function ScopeButton({ scope, current, onSelect, children }: {
scope: TaggingQueueScope;
current: TaggingQueueScope;
onSelect: (scope: TaggingQueueScope) => void;
children: React.ReactNode;
}) {
const active = scope === current;
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
}`}
onClick={() => onSelect(scope)}
>
{children}
</button>
);
}
function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
acceleration: TaggerAcceleration; acceleration: TaggerAcceleration;
current: TaggerAcceleration; current: TaggerAcceleration;
onSelect: (acceleration: TaggerAcceleration) => void; onSelect: (acceleration: TaggerAcceleration) => void;
@@ -53,63 +103,32 @@ function TaggerAccelerationButton({
); );
} }
function SettingsRow({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0">
<div className="min-w-0">
<p className="text-sm font-medium text-white">{title}</p>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function SectionShell({
eyebrow,
title,
children,
}: {
eyebrow: string;
title: string;
children: React.ReactNode;
}) {
return (
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
<div className="mt-5 border-t border-white/[0.08]">{children}</div>
</div>
);
}
export function SettingsModal() { export function SettingsModal() {
const [activeSection, setActiveSection] = useState<SettingsSection>("tagging"); const [activeSection, setActiveSection] = useState<SettingsSection>("workspace");
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null); const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null);
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
const settingsOpen = useGalleryStore((state) => state.settingsOpen); const settingsOpen = useGalleryStore((state) => state.settingsOpen);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing); const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress); const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
const taggerModelError = useGalleryStore((state) => state.taggerModelError); const taggerModelError = useGalleryStore((state) => state.taggerModelError);
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration); const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration);
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold); const taggerThreshold = useGalleryStore((state) => state.taggerThreshold);
const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize);
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe); const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe);
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking); const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus); const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
@@ -119,49 +138,123 @@ export function SettingsModal() {
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration); const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold); const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold);
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold); const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize);
const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize);
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime); const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
useEffect(() => { useEffect(() => {
if (!settingsOpen) return; if (!settingsOpen) return;
void loadTaggerModelStatus(); void loadTaggerModelStatus();
void loadTaggerAcceleration(); void loadTaggerAcceleration();
void loadTaggerThreshold(); void loadTaggerThreshold();
void loadTaggerBatchSize();
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsOpen(false); if (event.key === "Escape") setSettingsOpen(false);
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, setSettingsOpen]); }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, setSettingsOpen]);
const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds],
);
const totalQueuedJobs = useMemo(
() => Object.values(mediaJobProgress).reduce((sum, progress) => sum + (progress?.tagging_pending ?? 0), 0),
[mediaJobProgress],
);
if (!settingsOpen) return null; if (!settingsOpen) return null;
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId); const taggerReady = taggerModelStatus?.ready ?? false;
const scopeLabel = selectedFolder ? selectedFolder.name : "all libraries"; const queueScopeLabel =
taggingQueueScope === "all"
? "all media"
: selectedFolders.length > 0
? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? "" : "s"}`
: "no folders selected";
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize);
const taggerDownloadLabel = taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing
? "Preparing WD Tagger..."
: taggerReady
? "Installed"
: "Install model";
const taggerDownloadPercent = taggerModelProgress
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
: 0;
const runQueueAction = (action: "queue" | "clear") => {
const selectedIds = taggingQueueFolderIds;
const perform =
taggingQueueScope === "all"
? action === "queue"
? queueTaggingJobs(null)
: clearTaggingJobs(null)
: selectedIds.length > 0
? action === "queue"
? queueTaggingJobsForFolders(selectedIds)
: clearTaggingJobsForFolders(selectedIds)
: Promise.resolve(0);
if (action === "queue") {
setTaggerQueueing(true);
} else {
setTaggerClearing(true);
}
setTaggerQueueStatus(null);
void perform
.then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before running tagging jobs.");
return;
}
setTaggerQueueStatus(
count === 0
? action === "queue"
? "No missing tags found for the current target."
: "No queued tagging jobs to clear for the current target."
: action === "queue"
? `Queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for tagging.`
: `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? "" : "s"}.`,
);
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
if (action === "queue") {
setTaggerQueueing(false);
} else {
setTaggerClearing(false);
}
});
};
return ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
<div <div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" className="flex h-[min(760px,calc(100vh-56px))] w-full max-w-5xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
onClick={() => setSettingsOpen(false)}
>
<div
className="flex h-[min(680px,calc(100vh-56px))] w-full max-w-4xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<aside className="flex w-56 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]"> <aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
<div className="border-b border-white/[0.07] px-5 py-5"> <div className="border-b border-white/[0.07] px-5 py-5">
<p className="text-base font-semibold text-white">Settings</p> <p className="text-base font-semibold text-white">Settings</p>
<p className="mt-1 text-xs text-gray-600">Phokus preferences</p> <p className="mt-1 text-xs text-gray-600">Operational controls for AI workflows</p>
</div> </div>
<div className="flex-1 space-y-1 overflow-y-auto p-2"> <div className="flex-1 space-y-1 overflow-y-auto p-2">
{SECTIONS.map((section) => ( {SECTIONS.map((section) => (
<button <button
key={section.id} key={section.id}
className={`w-full rounded-md px-3 py-2.5 text-left transition-colors ${ className={`w-full rounded-md px-3 py-2.5 text-left transition-colors ${
activeSection === section.id activeSection === section.id ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/[0.055] hover:text-gray-200"
? "bg-white/10 text-white"
: "text-gray-500 hover:bg-white/[0.055] hover:text-gray-200"
}`} }`}
onClick={() => setActiveSection(section.id)} onClick={() => setActiveSection(section.id)}
> >
@@ -173,7 +266,11 @@ export function SettingsModal() {
</aside> </aside>
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-14 shrink-0 items-center justify-end border-b border-white/[0.07] px-6"> <div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
<div>
<p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "Workers"}</p>
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "Background processing status"}</p>
</div>
<button <button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => setSettingsOpen(false)} onClick={() => setSettingsOpen(false)}
@@ -186,37 +283,33 @@ export function SettingsModal() {
</div> </div>
<div className="flex-1 overflow-y-auto px-7 py-6"> <div className="flex-1 overflow-y-auto px-7 py-6">
{activeSection === "tagging" ? (() => { {activeSection === "workspace" ? (
const taggerReady = taggerModelStatus?.ready ?? false; <div className="space-y-8">
const taggerDownloadLabel = taggerModelProgress <SectionShell
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}` eyebrow="AI Workspace"
: taggerModelPreparing title="Tagging"
? "Preparing WD Tagger..."
: taggerReady
? "Downloaded"
: "Download WD Tagger";
const taggerDownloadPercent = taggerModelProgress
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
: 0;
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
return (
<SectionShell eyebrow="AI Tagging" title="WD SwinV2 Tagger v3">
<SettingsRow
title="WD Tagger model"
description={taggerReady ? "Stored locally and available offline." : "Download the model to enable automatic AI tagging."}
> >
<SettingsCard title="Tagging Models">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white">WD SwinV2 Tagger v3</p>
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
</StatusPill>
</div>
<p className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.
</p>
</div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<button <button
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void prepareTaggerModel()} onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing || taggerReady} disabled={taggerModelPreparing || taggerReady}
> >
{taggerModelProgress ? ( {taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
<span
className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200"
style={{ width: `${taggerDownloadPercent}%` }}
/>
) : null}
<span className="relative">{taggerDownloadLabel}</span> <span className="relative">{taggerDownloadLabel}</span>
</button> </button>
{taggerReady ? ( {taggerReady ? (
@@ -238,63 +331,34 @@ export function SettingsModal() {
</> </>
) : null} ) : null}
</div> </div>
</SettingsRow> </div>
<SettingsRow <div className="mt-4 space-y-4 border-t border-white/[0.07] pt-4">
title="Tagger acceleration" <SettingsRow title="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
description="Use DirectML for GPU-accelerated tagging when available."
>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1"> <div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
<TaggerAccelerationButton <TaggerAccelerationButton
acceleration="auto" key={acceleration}
acceleration={acceleration}
current={taggerAcceleration} current={taggerAcceleration}
onSelect={(acceleration) => { onSelect={(nextAcceleration) => {
setTaggerAccelerationSaving(true); setTaggerAccelerationSaving(true);
void setTaggerAcceleration(acceleration) void setTaggerAcceleration(nextAcceleration)
.catch((error) => setTaggerQueueStatus(String(error))) .catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerAccelerationSaving(false)); .finally(() => setTaggerAccelerationSaving(false));
}} }}
> >
Auto {acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
</TaggerAccelerationButton>
<TaggerAccelerationButton
acceleration="directml"
current={taggerAcceleration}
onSelect={(acceleration) => {
setTaggerAccelerationSaving(true);
void setTaggerAcceleration(acceleration)
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
DirectML
</TaggerAccelerationButton>
<TaggerAccelerationButton
acceleration="cpu"
current={taggerAcceleration}
onSelect={(acceleration) => {
setTaggerAccelerationSaving(true);
void setTaggerAcceleration(acceleration)
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
CPU
</TaggerAccelerationButton> </TaggerAccelerationButton>
))}
</div> </div>
<p className="text-[11px] text-gray-600"> <p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}
</p>
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow title="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
title="Confidence threshold"
description="Tags with confidence below this value are discarded. Lower = more tags, higher = fewer but more accurate."
>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<input <input
type="number" type="number"
min="0.05" min="0.05"
@@ -302,12 +366,12 @@ export function SettingsModal() {
step="0.05" step="0.05"
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none" className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={thresholdDisplay} value={thresholdDisplay}
onChange={(e) => setTaggerThresholdDraft(e.target.value)} onChange={(event) => setTaggerThresholdDraft(event.target.value)}
onBlur={() => { onBlur={() => {
const val = parseFloat(thresholdDisplay); const value = parseFloat(thresholdDisplay);
if (!isNaN(val) && val >= 0.05 && val <= 0.99) { if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdSaving(true); setTaggerThresholdSaving(true);
void setTaggerThreshold(val) void setTaggerThreshold(value)
.catch((error) => setTaggerQueueStatus(String(error))) .catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => { .finally(() => {
setTaggerThresholdDraft(null); setTaggerThresholdDraft(null);
@@ -318,142 +382,184 @@ export function SettingsModal() {
} }
}} }}
/> />
</div> <p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
<p className="text-[11px] text-gray-600">
{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}
</p>
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow title="Tagging batch size" description="Number of images processed concurrently during tag generation.">
title="Tagging queue"
description={`Generate missing AI tags in ${scopeLabel}. Tags update as the background worker finishes each image.`}
>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<button <input
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" type="number"
onClick={() => { min="1"
setTaggerQueueing(true); max="100"
setTaggerQueueStatus(null); step="1"
void queueTaggingJobs(selectedFolderId) className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
.then((queued) => { value={batchSizeDisplay}
setTaggerQueueStatus( onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
queued === 0 onBlur={() => {
? "No missing tags found." const value = parseInt(batchSizeDisplay, 10);
: `Queued ${queued.toLocaleString()} image${queued === 1 ? "" : "s"}.`, if (!isNaN(value) && value >= 1 && value <= 100) {
); setTaggerBatchSizeSaving(true);
}) void setTaggerBatchSize(value)
.catch((error) => setTaggerQueueStatus(String(error))) .catch((error: unknown) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerQueueing(false)); .finally(() => {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeSaving(false);
});
} else {
setTaggerBatchSizeDraft(null);
}
}} }}
disabled={!taggerReady || taggerQueueing || taggerClearing} />
> <p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
{taggerQueueing
? "Queueing..."
: selectedFolder
? "Tag this library"
: "Tag all libraries"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => {
setTaggerClearing(true);
setTaggerQueueStatus(null);
void clearTaggingJobs(selectedFolderId)
.then((cleared) => {
setTaggerQueueStatus(
cleared === 0
? "No queued tagging jobs to clear."
: `Cleared ${cleared.toLocaleString()} queued job${cleared === 1 ? "" : "s"}.`,
);
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerClearing(false));
}}
disabled={taggerQueueing || taggerClearing}
>
{taggerClearing
? "Clearing..."
: selectedFolder
? "Clear this queue"
: "Clear all queued tags"}
</button>
</div> </div>
</SettingsRow> </SettingsRow>
<div className="py-4"> <div>
<p className="text-xs font-medium text-gray-400">Model location</p> <p className="text-xs font-medium text-gray-400">Model location</p>
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600"> <p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"} {taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
</p> </p>
{taggerModelProgress?.current_file ? ( {taggerModelProgress?.current_file ? <p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
<p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> {taggerModelError ? <p className="mt-3 text-xs text-amber-300">{taggerModelError}</p> : null}
) : null}
{taggerModelError ? (
<p className="mt-3 text-xs text-amber-300">{taggerModelError}</p>
) : null}
{taggerQueueStatus ? (
<p className="mt-3 text-xs text-gray-500">{taggerQueueStatus}</p>
) : null}
{taggerRuntimeProbe ? ( {taggerRuntimeProbe ? (
<div className="mt-4 border-t border-white/[0.07] pt-4"> <div className="mt-4 rounded-lg border border-white/[0.07] bg-black/20 px-3 py-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-xs font-medium text-gray-400">Runtime check</p> <p className="text-xs font-medium text-gray-400">Runtime check</p>
<StatusPill tone="ready">Ready</StatusPill> <StatusPill tone="ready">Ready</StatusPill>
</div> </div>
<p className="mt-2 text-xs text-gray-600"> <p className="mt-2 text-xs text-gray-600">Tagger acceleration: {taggerRuntimeProbe.acceleration}</p>
Tagger acceleration: {taggerRuntimeProbe.acceleration} <p className="mt-2 break-all text-xs text-gray-500">{taggerRuntimeProbe.session.file}</p>
</p>
<div className="mt-3 space-y-2">
<div className="rounded-md border border-white/[0.07] bg-black/20 px-3 py-2">
<p className="break-all text-xs text-gray-400">{taggerRuntimeProbe.session.file}</p>
<p className="mt-1 text-[11px] text-gray-600">
{taggerRuntimeProbe.session.inputs.length} input{taggerRuntimeProbe.session.inputs.length === 1 ? "" : "s"} · {taggerRuntimeProbe.session.outputs.join(", ")}
</p>
</div>
</div>
</div> </div>
) : null} ) : null}
</div> </div>
</SectionShell> </div>
); </div>
})() : null} </SettingsCard>
{activeSection === "library" ? ( <SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs.">
<SectionShell eyebrow="Library" title="Indexing and scanning"> <SettingsRow title="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
<SettingsRow title="Background workers" description="Folder-level pause controls remain in the background tasks panel."> <div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
<StatusPill tone="muted">Managed per folder</StatusPill> <ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div>
</SettingsRow> </SettingsRow>
<SettingsRow title="Reindexing" description="Use the library sidebar to rescan a folder when files change.">
<StatusPill tone="muted">Available</StatusPill>
</SettingsRow>
</SectionShell>
) : null}
{activeSection === "display" ? ( <div className="border-b border-white/[0.07] py-4">
<SectionShell eyebrow="Display" title="Gallery preferences"> <div className="flex items-center justify-between gap-4">
<SettingsRow title="Grid density" description="Use the toolbar size control to change thumbnail density."> <div>
<StatusPill tone="muted">Toolbar</StatusPill> <p className="text-sm font-medium text-white">Folder selection</p>
</SettingsRow> <p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
<SettingsRow title="Result view" description="Similar results reset to the top on each new search."> </div>
<StatusPill tone="ready">Enabled</StatusPill> <div className="flex gap-2">
</SettingsRow>
</SectionShell>
) : null}
{activeSection === "storage" ? (
<SectionShell eyebrow="Storage" title="Local files">
<SettingsRow title="WD Tagger" description="Remove the local tagger model without changing the rest of the library.">
<button <button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40"
onClick={() => void deleteTaggerModel()} onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={taggerModelPreparing || !(taggerModelStatus?.ready ?? false)} disabled={folders.length === 0}
> >
Delete model files Select all
</button> </button>
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40"
onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueFolderIds.length === 0}
>
Clear
</button>
</div>
</div>
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
{folders.map((folder) => {
const active = taggingQueueFolderIds.includes(folder.id);
const progress = mediaJobProgress[folder.id];
return (
<button
key={folder.id}
type="button"
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors ${
active
? "border-emerald-400/30 bg-emerald-500/10 text-white"
: "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]"
}`}
onClick={() => toggleTaggingQueueFolder(folder.id)}
>
<div>
<p className="text-sm font-medium">{folder.name}</p>
<p className="mt-1 text-[11px] text-gray-500">{folder.image_count.toLocaleString()} items</p>
</div>
<div className="flex items-center gap-2">
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
</div>
</button>
);
})}
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
</div>
</div>
<SettingsRow title="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
<div className="flex flex-col items-end gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerQueueing ? "Queueing..." : "Queue tagging"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
</button>
</div>
</SettingsRow> </SettingsRow>
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsCard>
<SettingsCard title="Captioning">
<div className="rounded-xl border border-dashed border-white/[0.09] bg-black/20 px-4 py-4">
<p className="text-sm font-medium text-white/50">Coming soon</p>
</div>
</SettingsCard>
</SectionShell> </SectionShell>
) : null} </div>
) : (
<div className="space-y-8">
<SectionShell
eyebrow="Workers"
title="Background processing"
>
<SettingsCard title="Queue summary" description="Live totals across all folder workers.">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Tagging queued</p>
<p className="mt-2 text-2xl font-semibold text-white">{totalQueuedJobs.toLocaleString()}</p>
</div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Selected folders</p>
<p className="mt-2 text-2xl font-semibold text-white">{selectedFolders.length.toLocaleString()}</p>
</div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Library folders</p>
<p className="mt-2 text-2xl font-semibold text-white">{folders.length.toLocaleString()}</p>
</div>
</div>
</SettingsCard>
<SettingsCard title="Worker controls" description="Pause, resume, retry, and per-folder failure details are available in the background tasks panel.">
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-sm text-gray-400">Workers are running in the background.</p>
<StatusPill tone="ready">Live</StatusPill>
</div>
</SettingsCard>
</SectionShell>
</div>
)}
</div> </div>
</main> </main>
</div> </div>
+17
View File
@@ -138,6 +138,23 @@ export function Sidebar() {
Explore Explore
</span> </span>
</div> </div>
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
activeView === "duplicates"
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => setView("duplicates")}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className={`text-[13px] font-medium ${activeView === "duplicates" ? "text-white" : ""}`}>
Duplicates
</span>
</div>
</div> </div>
{/* Section label */} {/* Section label */}
+318 -186
View File
@@ -1,242 +1,374 @@
import { useEffect } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, TagCloudEntry } from "../store"; import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
// Accent glow colours for the hover ring — cycled by index const ACCENTS = [
const GLOWS: string[] = [ "#60a5fa",
"rgba(59,130,246,0.5)", "#c084fc",
"rgba(168,85,247,0.5)", "#4ade80",
"rgba(16,185,129,0.5)", "#fbbf24",
"rgba(245,158,11,0.5)", "#f472b4",
"rgba(236,72,153,0.5)", "#2dd4bf",
"rgba(6,182,212,0.5)", "#fb923c",
"rgba(249,115,22,0.5)", "#a78bfa",
"rgba(34,197,94,0.5)", "#34d399",
"#f87171",
]; ];
function pseudoRandom(seed: number): number { const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const x = Math.sin(seed + 1) * 10000;
function seeded(n: number): number {
const x = Math.sin(n * 9301 + 49297) * 233280;
return x - Math.floor(x); return x - Math.floor(x);
} }
// Map cluster size to a tile size bucket (px) interface PlacedNode {
function getTileSize(count: number, maxCount: number): number {
if (maxCount === 0) return 72;
const ratio = count / maxCount;
if (ratio > 0.75) return 160;
if (ratio > 0.45) return 128;
if (ratio > 0.22) return 104;
if (ratio > 0.08) return 88;
return 72;
}
function TagButton({
entry,
index,
maxCount,
onSearch,
}: {
entry: TagCloudEntry; entry: TagCloudEntry;
index: number; index: number;
maxCount: number; x: number;
onSearch: (imageId: number) => void; y: number;
}) { w: number;
const size = getTileSize(entry.count, maxCount); h: number;
const glow = GLOWS[index % GLOWS.length]; accent: string;
driftX: number;
driftY: number;
driftDuration: number;
rotateSeed: number;
}
// Small random rotation for organic feel — larger tiles stay flatter function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: number): PlacedNode[] {
const maxRot = size >= 128 ? 0 : size >= 104 ? 3 : size >= 88 ? 6 : 10; if (!entries.length || containerW <= 0 || containerH <= 0) return [];
const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * maxRot;
const mt = Math.floor(pseudoRandom(index * 3) * 10) + 4; const maxCount = Math.max(...entries.map((e) => e.count));
const mr = Math.floor(pseudoRandom(index * 5) * 12) + 4; const cx = containerW / 2;
const mb = Math.floor(pseudoRandom(index * 11) * 10) + 4; const cy = containerH / 2;
const ml = Math.floor(pseudoRandom(index * 13) * 12) + 4; // Spread ellipse shrinks slightly to leave room for card half-widths at the edges
const spreadX = containerW * 0.42;
const spreadY = containerH * 0.36;
const n = entries.length;
const src = entry.thumbnail_path ? convertFileSrc(entry.thumbnail_path) : null; // 1. Build initial positions using phyllotaxis spiral
const nodes: PlacedNode[] = entries.map((entry, i) => {
const ratio = Math.max(entry.count / maxCount, 0.08);
// Cards scale from 110px to 230px wide; height is 3/4 of width
const w = 110 + Math.sqrt(ratio) * 120;
const h = w * 0.75;
const radialRatio = Math.sqrt((i + 0.5) / n);
const angle = i * GOLDEN_ANGLE;
return {
entry,
index: i,
x: cx + Math.cos(angle) * radialRatio * spreadX,
y: cy + Math.sin(angle) * radialRatio * spreadY,
w,
h,
accent: ACCENTS[i % ACCENTS.length],
driftX: (seeded(i + 11) - 0.5) * 18,
driftY: (seeded(i + 17) - 0.5) * 14,
driftDuration: 8 + seeded(i + 23) * 7,
rotateSeed: (seeded(i + 31) - 0.5) * 4,
};
});
// 2. Iterative overlap resolution — no physics, just push apart
const PAD = 24;
for (let iter = 0; iter < 80; iter++) {
for (let a = 0; a < nodes.length; a++) {
const na = nodes[a];
for (let b = a + 1; b < nodes.length; b++) {
const nb = nodes[b];
const dx = nb.x - na.x;
const dy = nb.y - na.y;
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue;
// Push along the smaller overlap axis
if (overlapX < overlapY) {
const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1);
nb.x += push;
na.x -= push;
} else {
const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1);
nb.y += push;
na.y -= push;
}
}
// Pull gently back toward anchor to prevent runaway drift
na.x += (cx + Math.cos(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadX - na.x) * 0.05;
na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05;
}
}
// 3. Clamp so cards never poke outside the container
return nodes.map((node) => ({
...node,
x: Math.min(Math.max(node.x, node.w / 2 + 16), containerW - node.w / 2 - 16),
y: Math.min(Math.max(node.y, node.h / 2 + 16), containerH - node.h / 2 - 16),
}));
}
function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) {
const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
const { w, h, accent } = node;
return ( return (
<motion.button <motion.button
initial={{ opacity: 0, scale: 0.5, rotate: rotation * 2 }} className="group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_40px_rgba(0,0,0,0.5)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
animate={{ opacity: 1, scale: 1, rotate: rotation }} style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2 }}
initial={{ opacity: 0, scale: 0.75 }}
animate={{
opacity: 1,
scale: 1,
x: [0, node.driftX, 0],
y: [0, node.driftY, 0],
rotate: [node.rotateSeed, node.rotateSeed + 1.4, node.rotateSeed],
}}
transition={{ transition={{
delay: index * 0.025, opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
type: "spring", scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
stiffness: 200, x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 },
damping: 18, y: { duration: node.driftDuration + 1.6, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 51) * 3 },
}} rotate: { duration: node.driftDuration + 0.9, repeat: Infinity, ease: "easeInOut" },
whileHover={{
scale: 1.12,
rotate: 0,
transition: { type: "spring", stiffness: 400, damping: 22 },
}}
whileTap={{ scale: 0.92 }}
onClick={() => onSearch(entry.representative_image_id)}
title={`${entry.count} similar ${entry.count === 1 ? "photo" : "photos"}`}
style={{
width: size,
height: size,
margin: `${mt}px ${mr}px ${mb}px ${ml}px`,
borderRadius: 12,
border: "2px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.04)",
cursor: "pointer",
padding: 0,
overflow: "hidden",
position: "relative",
flexShrink: 0,
boxShadow: "none",
transition: "border-color 0.15s, box-shadow 0.15s",
}}
onMouseEnter={(e) => {
const el = e.currentTarget;
el.style.borderColor = glow;
el.style.boxShadow = `0 0 16px ${glow}, 0 0 32px ${glow.replace("0.5", "0.25")}`;
}}
onMouseLeave={(e) => {
const el = e.currentTarget;
el.style.borderColor = "rgba(255,255,255,0.08)";
el.style.boxShadow = "none";
}} }}
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} images`}
> >
{src ? ( {src ? (
<img <img
src={src} src={src}
alt="" alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
draggable={false} draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/> />
) : ( ) : (
// Fallback placeholder when no thumbnail exists yet <div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
<div
style={{
width: "100%",
height: "100%",
background: "rgba(255,255,255,0.06)",
}}
/>
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Count badge — bottom-right corner */} {/* Accent glow on hover */}
<div <div
style={{ className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
position: "absolute", style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
bottom: 5, />
right: 5, <div className="absolute inset-x-0 bottom-0 p-3">
background: "rgba(0,0,0,0.6)", <div className="mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
color: "rgba(255,255,255,0.85)", <div className="flex items-end justify-between gap-2">
fontSize: 10, <div>
fontWeight: 600, <p className="text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
lineHeight: 1, <p className="text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
padding: "3px 6px", </div>
borderRadius: 6, <span
backdropFilter: "blur(4px)", className="rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
pointerEvents: "none", style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
}}
> >
{entry.count} Open
</span>
</div>
</div> </div>
</motion.button> </motion.button>
); );
} }
// Actual tag cloud — word size driven by log-scaled frequency
function TagWord({
entry,
index,
logMin,
logRange,
onSearch,
}: {
entry: ExploreTagEntry;
index: number;
logMin: number;
logRange: number;
onSearch: (tag: string) => void;
}) {
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
const fontSize = 11 + ratio * 28; // 11px 39px
const accent = ACCENTS[index % ACCENTS.length];
const tilt = (seeded(index + 5) - 0.5) * 7;
return (
<motion.button
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 0.4 + ratio * 0.6, scale: 1 }}
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
className="group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
style={{ fontSize, rotate: tilt }}
onClick={() => onSearch(entry.tag)}
title={`${entry.tag}${entry.count.toLocaleString()} images`}
>
<span
className="font-medium leading-none"
style={{ color: ratio > 0.55 ? accent : "rgba(255,255,255,0.82)" }}
>
{entry.tag}
</span>
<span
className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: `${accent}22`, color: accent }}
>
{entry.count.toLocaleString()}
</span>
</motion.button>
);
}
function Spinner() {
return (
<motion.div
className="h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
animate={{ rotate: 360 }}
transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}
/>
);
}
// Separate component so its useLayoutEffect fires when the canvas is actually
// mounted — not at TagCloud mount time when the container may still be hidden
// behind a loading state.
function ClusterCloud({
entries,
onOpen,
}: {
entries: TagCloudEntry[];
onOpen: (imageIds: number[]) => void;
}) {
const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
useLayoutEffect(() => {
const el = canvasRef.current;
if (!el) return;
const update = () => {
const r = el.getBoundingClientRect();
setCanvasSize({ w: r.width, h: r.height });
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const nodes = useMemo(
() => buildCloud(entries, canvasSize.w, canvasSize.h),
[entries, canvasSize.w, canvasSize.h],
);
return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden">
<div className="pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
{nodes.map((node) => (
<CloudCard key={`${node.entry.representative_image_id}:${node.index}`} node={node} onOpen={onOpen} />
))}
</div>
);
}
export function TagCloud() { export function TagCloud() {
const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries); const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading); const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud); const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
const searchByTag = useGalleryStore((state) => state.searchByTag); const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
const searchForTag = useGalleryStore((state) => state.searchForTag);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
useEffect(() => { useEffect(() => {
void loadTagCloud(); if (exploreMode === "visual") void loadTagCloud();
}, [selectedFolderId]); else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]);
const maxCount = const { logMin, logRange } = useMemo(() => {
tagCloudEntries.length > 0 if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 };
? Math.max(...tagCloudEntries.map((e) => e.count)) const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1)));
: 1; const lo = Math.min(...logs);
const hi = Math.max(...logs);
return { logMin: lo, logRange: hi - lo || 1 };
}, [exploreTagEntries]);
const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading;
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
return ( return (
<div className="flex-1 flex flex-col items-center min-h-0 overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
{/* Header */} {/* Header */}
<motion.div <div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
className="text-center pt-14 pb-8 shrink-0" <div className="flex items-center justify-between gap-4">
initial={{ opacity: 0, y: -10 }} <div className="min-w-0">
animate={{ opacity: 1, y: 0 }} <h2 className="text-[15px] font-semibold text-white">Explore</h2>
transition={{ duration: 0.35 }} <p className="mt-0.5 truncate text-[11px] text-white/30">
> {loading
<h2 className="text-[22px] font-semibold text-white/70 tracking-tight mb-2"> ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
Explore your library : hasEntries
</h2> ? exploreMode === "visual"
<p className="text-[13px] text-white/25"> ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
Visual clusters from your photos sized by how many match : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
</p> : exploreMode === "visual"
</motion.div> ? "No clusters — images need embeddings first"
: "No tags — run the AI tagger or add tags manually"}
{/* Loading */}
{tagCloudLoading && (
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<motion.svg
className="w-8 h-8 text-white/20"
viewBox="0 0 24 24"
fill="none"
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="2"
strokeOpacity="0.2"
/>
<path
d="M4 12a8 8 0 018-8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</motion.svg>
<p className="text-[12px] text-white/20">Clustering your library</p>
</div>
)}
{/* Empty state */}
{!tagCloudLoading && tagCloudEntries.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<p className="text-[13px] text-white/25 text-center max-w-xs leading-relaxed">
No embeddings yet. Add a folder and wait for the embedding worker to
finish, then come back here.
</p> </p>
</div> </div>
)} <div className="flex shrink-0 rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("visual")}
>
Clusters
</button>
<button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("tags")}
>
Tag Cloud
</button>
</div>
</div>
</div>
{/* Cluster grid */} {loading ? (
{!tagCloudLoading && tagCloudEntries.length > 0 && ( <div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="flex flex-wrap justify-center px-12 pb-16 max-w-5xl w-full"> <Spinner />
{tagCloudEntries.map((entry, index) => ( <span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
<TagButton </div>
key={entry.representative_image_id} ) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8">
<p className="max-w-xs text-center text-sm leading-relaxed text-white/25">
{exploreMode === "visual"
? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar."
: "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}
</p>
</div>
) : exploreMode === "visual" ? (
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
) : (
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */
<div className="overflow-y-auto px-8 py-8">
<div className="flex flex-wrap items-center justify-center gap-x-0.5 gap-y-1 leading-none">
{exploreTagEntries.map((entry, index) => (
<TagWord
key={entry.tag}
entry={entry} entry={entry}
index={index} index={index}
maxCount={maxCount} logMin={logMin}
onSearch={searchByTag} logRange={logRange}
onSearch={searchForTag}
/> />
))} ))}
</div> </div>
)} </div>
{!tagCloudLoading && tagCloudEntries.length > 0 && (
<p className="shrink-0 pb-8 text-[11px] text-white/12 text-center">
Grouped by visual similarity · CLIP ViT-B/32
</p>
)} )}
</div> </div>
); );
+189 -51
View File
@@ -1,11 +1,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store"; import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" }, { value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" }, { value: "date_asc", label: "Oldest first" },
{ value: "name_asc", label: "Name AZ" }, { value: "name_asc", label: "Name AZ" },
{ value: "name_desc", label: "Name ZA" }, { value: "name_desc", label: "Name ZA" },
{ value: "rating_desc", label: "Highest rated" },
{ value: "rating_asc", label: "Lowest rated" },
{ value: "size_desc", label: "Largest first" }, { value: "size_desc", label: "Largest first" },
{ value: "size_asc", label: "Smallest first" }, { value: "size_asc", label: "Smallest first" },
]; ];
@@ -116,12 +119,27 @@ function FilterPill({
); );
} }
function commandPrefix(command: SearchCommand | null): string | null {
switch (command) {
case "semantic":
return "/s";
case "tag":
return "/t";
default:
return null;
}
}
function composeSearchValue(command: SearchCommand | null, query: string): string {
const prefix = commandPrefix(command);
if (!prefix) return query;
return query.length > 0 ? `${prefix} ${query}` : prefix;
}
export function Toolbar() { export function Toolbar() {
const search = useGalleryStore((state) => state.search); const search = useGalleryStore((state) => state.search);
const setSearch = useGalleryStore((state) => state.setSearch); const setSearch = useGalleryStore((state) => state.setSearch);
const clearSearch = useGalleryStore((state) => state.clearSearch); const clearSearch = useGalleryStore((state) => state.clearSearch);
const searchMode = useGalleryStore((state) => state.searchMode);
const setSearchMode = useGalleryStore((state) => state.setSearchMode);
const sort = useGalleryStore((state) => state.sort); const sort = useGalleryStore((state) => state.sort);
const setSort = useGalleryStore((state) => state.setSort); const setSort = useGalleryStore((state) => state.setSort);
const totalImages = useGalleryStore((state) => state.totalImages); const totalImages = useGalleryStore((state) => state.totalImages);
@@ -133,20 +151,26 @@ export function Toolbar() {
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter); const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly); const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly); const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
const minimumRating = useGalleryStore((state) => state.minimumRating);
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly); const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly); const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
const [searchValue, setSearchValue] = useState(search); const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
const [searchQuery, setSearchQuery] = useState(search);
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
const [tagSuggestions, setTagSuggestions] = useState<ExploreTagEntry[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
// Tracks whether the user has typed in the search box at least once. const searchShellRef = useRef<HTMLDivElement>(null);
// Prevents the debounce effect from dispatching setSearch on initial mount
// when searchValue === search (which would wipe a loadSimilarImages result).
const userHasTyped = useRef(false); const userHasTyped = useRef(false);
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId); const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
@@ -154,11 +178,8 @@ export function Toolbar() {
const tileSize = tileSizeForZoom(zoomPreset); const tileSize = tileSizeForZoom(zoomPreset);
const sortOptions = getSortOptions(mediaFilter); const sortOptions = getSortOptions(mediaFilter);
const hasActiveSearch = search.trim().length > 0; const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const searchModes: { value: SearchMode; label: string }[] = [ const isSimilarResults = collectionTitle === "Similar Images";
{ value: "filename", label: "Filename" },
{ value: "semantic", label: "Semantic" },
];
// If current sort is video-only but we switched away from video filter, reset to date_desc // If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => { useEffect(() => {
@@ -170,35 +191,62 @@ export function Toolbar() {
useEffect(() => { useEffect(() => {
if (!userHasTyped.current) return; if (!userHasTyped.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200); debounceRef.current = setTimeout(() => { setSearch(composeSearchValue(searchCommand, searchQuery)); }, 200);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchValue, setSearch]); }, [searchCommand, searchQuery, setSearch]);
useEffect(() => { useEffect(() => {
setSearchValue(search); const parsed = parseSearchValue(search);
setSearchCommand(parsed.prefix && parsed.mode !== "filename" ? parsed.mode : null);
setSearchQuery(parsed.prefix ? parsed.query : search);
}, [search]); }, [search]);
// Fetch tag suggestions when in tag mode
useEffect(() => {
if (searchCommand !== "tag") {
setTagSuggestions([]);
return;
}
if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current);
suggestDebounceRef.current = setTimeout(async () => {
try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
});
setTagSuggestions(results);
} catch {
setTagSuggestions([]);
}
}, 120);
return () => { if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); };
}, [searchCommand, searchQuery, selectedFolderId]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
const isModeToggle = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "s";
if (isModeToggle) {
event.preventDefault();
setSearchMode(searchMode === "semantic" ? "filename" : "semantic");
searchInputRef.current?.focus();
return;
}
const activeElement = document.activeElement; const activeElement = document.activeElement;
const searchFocused = activeElement === searchInputRef.current; const searchFocused = activeElement === searchInputRef.current;
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) { if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
event.preventDefault(); event.preventDefault();
setSearchValue(""); setSearchCommand(null);
setSearchQuery("");
clearSearch(); clearSearch();
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [clearSearch, hasActiveSearch, searchMode, setSearchMode]); }, [clearSearch, hasActiveSearch]);
useEffect(() => {
const close = (event: PointerEvent) => {
if (searchShellRef.current?.contains(event.target as Node)) return;
setSearchPanelOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
const showCommandHints = !searchCommand && searchPanelOpen;
return ( return (
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl"> <div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
@@ -212,9 +260,9 @@ export function Toolbar() {
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}` ? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
: totalImages.toLocaleString()} : totalImages.toLocaleString()}
</span> </span>
{(hasActiveSearch || searchMode === "semantic") && ( {hasActiveSearch && (
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0"> <span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0">
{searchMode === "semantic" ? "Semantic Search" : "Filename Search"} {searchModeLabel(parsedSearch.mode)}
</span> </span>
)} )}
</div> </div>
@@ -222,23 +270,8 @@ export function Toolbar() {
<div className="flex-1" /> <div className="flex-1" />
{/* Search */} {/* Search */}
<div ref={searchShellRef} className="relative">
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden"> <div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
<div className="flex items-center pl-2 pr-1 gap-1 border-r border-white/8">
{searchModes.map((mode) => (
<button
key={mode.value}
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
searchMode === mode.value
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200"
}`}
title={mode.value === "semantic" ? "Toggle with Ctrl/Cmd+Shift+S" : "Toggle with Ctrl/Cmd+Shift+S"}
onClick={() => setSearchMode(mode.value)}
>
{mode.label}
</button>
))}
</div>
<div className="relative"> <div className="relative">
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600" <svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -248,20 +281,50 @@ export function Toolbar() {
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
value={searchValue} value={searchQuery}
onChange={(event) => { onChange={(event) => {
userHasTyped.current = true; userHasTyped.current = true;
setSearchValue(event.target.value); const nextValue = event.target.value;
if (!searchCommand) {
const parsed = parseSearchValue(nextValue);
if (parsed.prefix) {
setSearchCommand(parsed.mode === "filename" ? null : parsed.mode);
setSearchQuery(parsed.query);
return;
}
}
setSearchQuery(nextValue);
}} }}
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."} onKeyDown={(event) => {
className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors" if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) {
event.preventDefault();
setSearchCommand(null);
}
}}
onFocus={() => setSearchPanelOpen(true)}
placeholder="Search files, or use /s /t"
className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`}
/> />
{searchValue.trim().length > 0 && ( {searchCommand !== null ? (
<div className="absolute left-8 top-1/2 -translate-y-1/2">
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white"
onClick={() => { setSearchCommand(null); setTagSuggestions([]); }}
title="Remove search command"
>
{commandPrefix(searchCommand)}
</button>
</div>
) : null}
{searchQuery.trim().length > 0 || searchCommand !== null ? (
<button <button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors" className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
title="Clear search" title="Clear search"
onClick={() => { onClick={() => {
setSearchValue(""); setSearchCommand(null);
setSearchQuery("");
setTagSuggestions([]);
clearSearch(); clearSearch();
}} }}
> >
@@ -269,10 +332,80 @@ export function Toolbar() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
)} ) : null}
</div> </div>
</div> </div>
{/* Tag autocomplete suggestions */}
{showTagSuggestions && tagSuggestions.length > 0 ? (
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
{tagSuggestions.map((entry) => (
<button
key={entry.tag}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
onMouseDown={(e) => {
// mousedown fires before input blur, so we prevent losing focus
e.preventDefault();
userHasTyped.current = true;
setSearchQuery(entry.tag);
setSearch(`/t ${entry.tag}`);
setSearchPanelOpen(false);
searchInputRef.current?.blur();
}}
>
<span className="text-sm text-white/88">{entry.tag}</span>
<span className="shrink-0 text-[11px] tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
</button>
))}
</div>
) : null}
{/* Tag mode with no suggestions yet — show a brief hint */}
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? (
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
<p className="text-xs text-white/25">No matching tags</p>
</div>
) : null}
{/* Semantic mode hint */}
{searchCommand === "semantic" && searchPanelOpen ? (
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
<p className="text-xs text-white/40">Search by meaning and visual concepts</p>
</div>
) : null}
{/* Command hints — only shown when no command is active */}
{showCommandHints ? (
<div className="absolute left-0 top-full z-30 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
{(
[
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search AI and user tags" },
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" },
] as const
).map((option) => (
<button
key={option.prefix}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
onMouseDown={(e) => {
e.preventDefault();
userHasTyped.current = true;
setSearchCommand(option.command);
searchInputRef.current?.focus();
}}
>
<span className="rounded border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-gray-400">
{option.prefix}
</span>
<div>
<p className="text-sm text-gray-200">{option.label}</p>
<p className="text-xs text-gray-500">{option.description}</p>
</div>
</button>
))}
</div>
) : null}
</div>
{/* Sort */} {/* Sort */}
<SortDropdown value={sort} onChange={setSort} options={sortOptions} /> <SortDropdown value={sort} onChange={setSort} options={sortOptions} />
@@ -302,10 +435,14 @@ export function Toolbar() {
{/* Filter row */} {/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5"> <div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? ( {hasAnyFailedEmbeddings ? (
<FilterPill <FilterPill
label="Failed Embeddings" label="Failed Embeddings"
@@ -314,6 +451,7 @@ export function Toolbar() {
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)} onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
/> />
) : null} ) : null}
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div> </div>
</div> </div>
); );
+472 -31
View File
@@ -15,10 +15,14 @@ export type MediaKind = "image" | "video";
export type MediaFilter = "all" | MediaKind; export type MediaFilter = "all" | MediaKind;
export type ZoomPreset = "compact" | "comfortable" | "detail"; export type ZoomPreset = "compact" | "comfortable" | "detail";
export type SearchMode = "filename" | "semantic"; export type SearchMode = "filename" | "semantic";
export type SearchCommand = "filename" | "semantic" | "tag";
export type CaptionAcceleration = "auto" | "cpu" | "directml"; export type CaptionAcceleration = "auto" | "cpu" | "directml";
export type CaptionDetail = "short" | "detailed" | "paragraph"; export type CaptionDetail = "short" | "detailed" | "paragraph";
export type TaggerAcceleration = "auto" | "cpu" | "directml"; export type TaggerAcceleration = "auto" | "cpu" | "directml";
export type AiRating = "general" | "sensitive" | "questionable" | "explicit"; export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
export type TaggingQueueScope = "all" | "selected";
export type SimilarScope = "all_media" | "current_folder";
export type ExploreMode = "visual" | "tags";
export interface ImageRecord { export interface ImageRecord {
id: number; id: number;
@@ -115,12 +119,33 @@ export interface ThumbnailBatch {
images: ImageRecord[]; images: ImageRecord[];
} }
export type ActiveView = "gallery" | "explore"; export type ActiveView = "gallery" | "explore" | "duplicates";
export interface TagCloudEntry { export interface TagCloudEntry {
count: number; count: number;
representative_image_id: number; representative_image_id: number;
thumbnail_path: string | null; thumbnail_path: string | null;
image_ids: number[];
}
export interface ExploreTagEntry {
tag: string;
count: number;
representative_image_id: number;
thumbnail_path: string | null;
}
export interface DuplicateGroup {
file_hash: string;
file_size: number;
images: ImageRecord[];
}
export interface SimilarImagesPage {
images: ImageRecord[];
offset: number;
limit: number;
has_more: boolean;
} }
export interface CaptionModelStatus { export interface CaptionModelStatus {
@@ -171,6 +196,12 @@ export interface TaggerRuntimeProbe {
session: TaggerRuntimeSessionProbe; session: TaggerRuntimeSessionProbe;
} }
export interface ParsedSearch {
mode: SearchCommand;
query: string;
prefix: string | null;
}
export type SortOrder = export type SortOrder =
| "date_desc" | "date_desc"
| "date_asc" | "date_asc"
@@ -178,6 +209,8 @@ export type SortOrder =
| "name_desc" | "name_desc"
| "size_desc" | "size_desc"
| "size_asc" | "size_asc"
| "rating_desc"
| "rating_asc"
| "duration_desc" | "duration_desc"
| "duration_asc"; | "duration_asc";
@@ -194,17 +227,25 @@ interface GalleryState {
sort: SortOrder; sort: SortOrder;
mediaFilter: MediaFilter; mediaFilter: MediaFilter;
favoritesOnly: boolean; favoritesOnly: boolean;
minimumRating: number;
failedEmbeddingsOnly: boolean; failedEmbeddingsOnly: boolean;
zoomPreset: ZoomPreset; zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null; selectedImage: ImageRecord | null;
collectionTitle: string | null; collectionTitle: string | null;
similarSourceImageId: number | null; similarSourceImageId: number | null;
similarSourceFolderId: number | null;
similarHasMore: boolean; similarHasMore: boolean;
similarScope: SimilarScope;
similarFolderId: number | null;
galleryScrollResetKey: number; galleryScrollResetKey: number;
activeView: ActiveView; activeView: ActiveView;
exploreMode: ExploreMode;
tagCloudEntries: TagCloudEntry[]; tagCloudEntries: TagCloudEntry[];
tagCloudLoading: boolean; tagCloudLoading: boolean;
tagCloudFolderId: number | null | undefined; // undefined = never loaded tagCloudFolderId: number | null | undefined; // undefined = never loaded
exploreTagEntries: ExploreTagEntry[];
exploreTagLoading: boolean;
exploreTagsFolderId: number | null | undefined;
indexingProgress: Record<number, IndexProgress>; indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>; mediaJobProgress: Record<number, FolderJobProgress>;
cacheDir: string; cacheDir: string;
@@ -218,6 +259,8 @@ interface GalleryState {
captionDetail: CaptionDetail; captionDetail: CaptionDetail;
aiCaptionsEnabled: boolean; aiCaptionsEnabled: boolean;
settingsOpen: boolean; settingsOpen: boolean;
taggingQueueScope: TaggingQueueScope;
taggingQueueFolderIds: number[];
taggerModelStatus: TaggerModelStatus | null; taggerModelStatus: TaggerModelStatus | null;
taggerModelPreparing: boolean; taggerModelPreparing: boolean;
@@ -225,9 +268,16 @@ interface GalleryState {
taggerModelProgress: TaggerModelProgress | null; taggerModelProgress: TaggerModelProgress | null;
taggerAcceleration: TaggerAcceleration; taggerAcceleration: TaggerAcceleration;
taggerThreshold: number; taggerThreshold: number;
taggerBatchSize: number;
taggerRuntimeProbe: TaggerRuntimeProbe | null; taggerRuntimeProbe: TaggerRuntimeProbe | null;
taggerRuntimeChecking: boolean; taggerRuntimeChecking: boolean;
duplicateGroups: DuplicateGroup[];
duplicateScanning: boolean;
duplicateScanProgress: { scanned: number; total: number } | null;
duplicateSelectedIds: Set<number>;
duplicateLastScanned: number | null; // Unix timestamp (seconds)
loadFolders: () => Promise<void>; loadFolders: () => Promise<void>;
loadBackgroundJobProgress: () => Promise<void>; loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>; addFolder: (path: string) => Promise<void>;
@@ -243,14 +293,19 @@ interface GalleryState {
setSort: (sort: SortOrder) => void; setSort: (sort: SortOrder) => void;
setMediaFilter: (filter: MediaFilter) => void; setMediaFilter: (filter: MediaFilter) => void;
setFavoritesOnly: (favoritesOnly: boolean) => void; setFavoritesOnly: (favoritesOnly: boolean) => void;
setMinimumRating: (minimumRating: number) => void;
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void; setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void; setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void; openImage: (image: ImageRecord) => void;
closeImage: () => void; closeImage: () => void;
setView: (view: ActiveView) => void; setView: (view: ActiveView) => void;
setExploreMode: (mode: ExploreMode) => void;
loadTagCloud: () => Promise<void>; loadTagCloud: () => Promise<void>;
searchByTag: (imageId: number) => void; loadExploreTags: () => Promise<void>;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean) => Promise<void>; showVisualCluster: (imageIds: number[]) => Promise<void>;
searchForTag: (tag: string) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise<void>;
setSimilarScope: (scope: SimilarScope) => void;
suggestImageTags: (imageId: number) => Promise<string[]>; suggestImageTags: (imageId: number) => Promise<string[]>;
loadCaptionModelStatus: () => Promise<void>; loadCaptionModelStatus: () => Promise<void>;
prepareCaptionModel: () => Promise<void>; prepareCaptionModel: () => Promise<void>;
@@ -268,6 +323,9 @@ interface GalleryState {
setCaptionDetail: (detail: CaptionDetail) => Promise<void>; setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
setAiCaptionsEnabled: (enabled: boolean) => void; setAiCaptionsEnabled: (enabled: boolean) => void;
setSettingsOpen: (open: boolean) => void; setSettingsOpen: (open: boolean) => void;
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
toggleTaggingQueueFolder: (folderId: number) => void;
setTaggingQueueFolderIds: (folderIds: number[]) => void;
retryFailedEmbeddings: (folderId: number) => Promise<void>; retryFailedEmbeddings: (folderId: number) => Promise<void>;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>; updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
setCacheDir: (dir: string) => void; setCacheDir: (dir: string) => void;
@@ -280,10 +338,21 @@ interface GalleryState {
setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise<void>; setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise<void>;
loadTaggerThreshold: () => Promise<void>; loadTaggerThreshold: () => Promise<void>;
setTaggerThreshold: (threshold: number) => Promise<void>; setTaggerThreshold: (threshold: number) => Promise<void>;
loadTaggerBatchSize: () => Promise<void>;
setTaggerBatchSize: (batchSize: number) => Promise<void>;
probeTaggerRuntime: () => Promise<void>; probeTaggerRuntime: () => Promise<void>;
queueTaggingJobs: (folderId?: number | null) => Promise<number>; queueTaggingJobs: (folderId?: number | null) => Promise<number>;
queueTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
queueTaggingForImage: (imageId: number) => Promise<number>; queueTaggingForImage: (imageId: number) => Promise<number>;
clearTaggingJobs: (folderId?: number | null) => Promise<number>; clearTaggingJobs: (folderId?: number | null) => Promise<number>;
clearTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
loadDuplicateScanCache: (folderId?: number | null) => Promise<void>;
scanDuplicates: (folderId?: number | null) => Promise<void>;
toggleDuplicateSelected: (imageId: number) => void;
selectAllDuplicates: (imageIds: number[]) => void;
selectKeepFirstAllGroups: () => void;
clearDuplicateSelection: () => void;
deleteSelectedDuplicates: () => Promise<number>;
getImageTags: (imageId: number) => Promise<ImageTag[]>; getImageTags: (imageId: number) => Promise<ImageTag[]>;
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>; addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
removeTag: (tagId: number) => Promise<void>; removeTag: (tagId: number) => Promise<void>;
@@ -291,6 +360,12 @@ interface GalleryState {
const PAGE_SIZE = 200; const PAGE_SIZE = 200;
const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled"; const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled";
const SIMILAR_DISTANCE_THRESHOLD = 0.24;
let galleryRequestToken = 0;
let similarRequestToken = 0;
let tagCloudRequestToken = 0;
let exploreTagRequestToken = 0;
function initialAiCaptionsEnabled(): boolean { function initialAiCaptionsEnabled(): boolean {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -312,19 +387,71 @@ function matchesSearch(image: ImageRecord, search: string): boolean {
return image.filename.toLowerCase().includes(search.toLowerCase()); return image.filename.toLowerCase().includes(search.toLowerCase());
} }
function isDerivedCollectionTitle(collectionTitle: string | null): boolean {
return collectionTitle !== null;
}
export function parseSearchValue(search: string): ParsedSearch {
if (!search.trim()) {
return { mode: "filename", query: "", prefix: null };
}
const slashPrefix = search.match(/^\/([a-z])(?:\s|$)/i);
if (slashPrefix) {
const rawPrefix = slashPrefix[1].toLowerCase();
const query = search.length > 3 ? search.slice(3) : "";
if (rawPrefix === "s") {
return { mode: "semantic", query, prefix: "/s" };
}
if (rawPrefix === "t") {
return { mode: "tag", query, prefix: "/t" };
}
return { mode: "filename", query, prefix: rawPrefix === "f" ? "/f" : null };
}
const trimmed = search.trim();
const match = trimmed.match(/^([a-z]):\s*(.*)$/i);
if (!match) {
return { mode: "filename", query: trimmed, prefix: null };
}
const rawPrefix = match[1].toLowerCase();
const query = match[2].trim();
if (rawPrefix === "s") {
return { mode: "semantic", query, prefix: "s:" };
}
if (rawPrefix === "t") {
return { mode: "tag", query, prefix: "t:" };
}
return { mode: "filename", query, prefix: rawPrefix === "f" ? "f:" : null };
}
export function searchModeLabel(mode: SearchCommand): string {
switch (mode) {
case "semantic":
return "Semantic Search";
case "tag":
return "Tag Search";
default:
return "Filename Search";
}
}
function matchesFilters( function matchesFilters(
image: ImageRecord, image: ImageRecord,
selectedFolderId: number | null, selectedFolderId: number | null,
mediaFilter: MediaFilter, mediaFilter: MediaFilter,
favoritesOnly: boolean, favoritesOnly: boolean,
minimumRating: number,
failedEmbeddingsOnly: boolean, failedEmbeddingsOnly: boolean,
search: string, search: string,
): boolean { ): boolean {
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId; const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter; const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter;
const matchesFavorite = !favoritesOnly || image.favorite; const matchesFavorite = !favoritesOnly || image.favorite;
const matchesRating = image.rating >= minimumRating;
const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed"; const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed";
return matchesFolder && matchesMedia && matchesFavorite && matchesFailedEmbedding && matchesSearch(image, search); return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesSearch(image, search);
} }
function compareNullableNumber(a: number | null, b: number | null): number { function compareNullableNumber(a: number | null, b: number | null): number {
@@ -349,6 +476,10 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number
return compareNullableNumber(a.file_size, b.file_size); return compareNullableNumber(a.file_size, b.file_size);
case "size_desc": case "size_desc":
return compareNullableNumber(b.file_size, a.file_size); return compareNullableNumber(b.file_size, a.file_size);
case "rating_asc":
return compareNullableNumber(a.rating, b.rating);
case "rating_desc":
return compareNullableNumber(b.rating, a.rating);
case "duration_asc": case "duration_asc":
return compareNullableNumber(a.duration_ms, b.duration_ms); return compareNullableNumber(a.duration_ms, b.duration_ms);
case "duration_desc": case "duration_desc":
@@ -424,17 +555,25 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
sort: "date_desc", sort: "date_desc",
mediaFilter: "all", mediaFilter: "all",
favoritesOnly: false, favoritesOnly: false,
minimumRating: 0,
failedEmbeddingsOnly: false, failedEmbeddingsOnly: false,
zoomPreset: "comfortable", zoomPreset: "comfortable",
selectedImage: null, selectedImage: null,
collectionTitle: null, collectionTitle: null,
similarSourceImageId: null, similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false, similarHasMore: false,
similarScope: "all_media",
similarFolderId: null,
galleryScrollResetKey: 0, galleryScrollResetKey: 0,
activeView: "gallery", activeView: "gallery",
exploreMode: "visual",
tagCloudEntries: [], tagCloudEntries: [],
tagCloudLoading: false, tagCloudLoading: false,
tagCloudFolderId: undefined, tagCloudFolderId: undefined,
exploreTagEntries: [],
exploreTagLoading: false,
exploreTagsFolderId: undefined,
indexingProgress: {}, indexingProgress: {},
mediaJobProgress: {}, mediaJobProgress: {},
cacheDir: "", cacheDir: "",
@@ -448,6 +587,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
captionDetail: "paragraph", captionDetail: "paragraph",
aiCaptionsEnabled: initialAiCaptionsEnabled(), aiCaptionsEnabled: initialAiCaptionsEnabled(),
settingsOpen: false, settingsOpen: false,
taggingQueueScope: "all",
taggingQueueFolderIds: [],
taggerModelStatus: null, taggerModelStatus: null,
taggerModelPreparing: false, taggerModelPreparing: false,
@@ -455,14 +596,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
taggerModelProgress: null, taggerModelProgress: null,
taggerAcceleration: "auto", taggerAcceleration: "auto",
taggerThreshold: 0.35, taggerThreshold: 0.35,
taggerBatchSize: 8,
taggerRuntimeProbe: null, taggerRuntimeProbe: null,
taggerRuntimeChecking: false, taggerRuntimeChecking: false,
duplicateGroups: [],
duplicateScanning: false,
duplicateScanProgress: null,
duplicateSelectedIds: new Set(),
duplicateLastScanned: null,
setCacheDir: (cacheDir) => set({ cacheDir }), setCacheDir: (cacheDir) => set({ cacheDir }),
loadFolders: async () => { loadFolders: async () => {
const folders = await invoke<Folder[]>("get_folders"); const folders = await invoke<Folder[]>("get_folders");
set({ folders }); set((state) => {
const folderIds = new Set(folders.map((folder) => folder.id));
const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId));
return {
folders,
taggingQueueFolderIds:
nextSelected.length > 0
? nextSelected
: state.taggingQueueScope === "selected" && folders.length > 0
? [folders[0].id]
: nextSelected,
};
});
}, },
loadBackgroundJobProgress: async () => { loadBackgroundJobProgress: async () => {
@@ -507,29 +667,64 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
loadImages: async (reset = false) => { loadImages: async (reset = false) => {
const { selectedFolderId, search, searchMode, sort, loadedCount, mediaFilter, favoritesOnly, failedEmbeddingsOnly } = get(); const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
const parsedSearch = parseSearchValue(search);
const requestToken = ++galleryRequestToken;
set({ loadingImages: true, imageLoadError: null }); set({ loadingImages: true, imageLoadError: null });
try { try {
if (searchMode === "semantic" && search.trim()) { if (parsedSearch.mode === "semantic" && parsedSearch.query) {
const images = await invoke<ImageRecord[]>("semantic_search_images", { const images = await invoke<ImageRecord[]>("semantic_search_images", {
params: { params: {
query: search, query: parsedSearch.query,
folder_id: selectedFolderId, folder_id: selectedFolderId,
media_kind: mediaFilter === "all" ? null : mediaFilter, media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly, favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}, },
}); });
if (requestToken !== galleryRequestToken) return;
set({ set({
images, images,
totalImages: images.length, totalImages: images.length,
loadedCount: images.length, loadedCount: images.length,
loadingImages: false, loadingImages: false,
collectionTitle: `Semantic search: ${search}`, collectionTitle: `Semantic search: ${parsedSearch.query}`,
selectedFolderId,
similarSourceImageId: null, similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false, similarHasMore: false,
similarFolderId: null,
});
return;
}
if (parsedSearch.mode === "tag" && parsedSearch.query) {
const images = await invoke<ImageRecord[]>("search_images_by_tag", {
params: {
query: parsedSearch.query,
folder_id: selectedFolderId,
media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null,
limit: PAGE_SIZE,
},
});
if (requestToken !== galleryRequestToken) return;
set({
images,
totalImages: images.length,
loadedCount: images.length,
loadingImages: false,
collectionTitle: `Tag search: ${parsedSearch.query}`,
selectedFolderId,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
}); });
return; return;
} }
@@ -543,9 +738,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}>("get_images", { }>("get_images", {
params: { params: {
folder_id: selectedFolderId, folder_id: selectedFolderId,
search: search || null, search: parsedSearch.query || null,
media_kind: mediaFilter === "all" ? null : mediaFilter, media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly, favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null,
embedding_failed_only: failedEmbeddingsOnly, embedding_failed_only: failedEmbeddingsOnly,
sort, sort,
offset, offset,
@@ -553,6 +749,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
}); });
if (requestToken !== galleryRequestToken) return;
set((state) => ({ set((state) => ({
images: reset ? result.images : [...state.images, ...result.images], images: reset ? result.images : [...state.images, ...result.images],
totalImages: result.total, totalImages: result.total,
@@ -560,20 +757,24 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
loadingImages: false, loadingImages: false,
collectionTitle: reset ? null : state.collectionTitle, collectionTitle: reset ? null : state.collectionTitle,
similarSourceImageId: null, similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false, similarHasMore: false,
similarFolderId: null,
})); }));
} catch (error) { } catch (error) {
if (requestToken !== galleryRequestToken) return;
console.error("Failed to load media:", error); console.error("Failed to load media:", error);
set({ loadingImages: false, imageLoadError: String(error) }); set({ loadingImages: false, imageLoadError: String(error) });
} }
}, },
loadMoreImages: async () => { loadMoreImages: async () => {
const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, selectedFolderId } = get(); const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, similarFolderId } = get();
if (loadingImages || loadedCount >= totalImages) return; if (loadingImages || loadedCount >= totalImages) return;
if (collectionTitle === "Explore Cluster") return;
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) { if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
if (!similarHasMore) return; if (!similarHasMore) return;
await get().loadSimilarImages(similarSourceImageId, selectedFolderId, false); await get().loadSimilarImages(similarSourceImageId, similarFolderId, false);
return; return;
} }
await get().loadImages(false); await get().loadImages(false);
@@ -614,6 +815,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true); void get().loadImages(true);
}, },
setMinimumRating: (minimumRating) => {
set({ minimumRating, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
},
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => { setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true); void get().loadImages(true);
@@ -626,59 +832,148 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setView: (activeView) => set({ activeView }), setView: (activeView) => set({ activeView }),
setExploreMode: (exploreMode) => set({ exploreMode }),
loadTagCloud: async () => { loadTagCloud: async () => {
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get(); const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
// Skip if already loaded for this folder and not currently loading // Skip if already loaded for this folder and not currently loading
if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) { if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
return; return;
} }
const requestToken = ++tagCloudRequestToken;
set({ tagCloudLoading: true, tagCloudFolderId: selectedFolderId }); set({ tagCloudLoading: true, tagCloudFolderId: selectedFolderId });
try { try {
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", { const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
folderId: selectedFolderId, folderId: selectedFolderId,
}); });
if (requestToken !== tagCloudRequestToken) return;
set({ tagCloudEntries: entries, tagCloudLoading: false }); set({ tagCloudEntries: entries, tagCloudLoading: false });
} catch (error) { } catch (error) {
if (requestToken !== tagCloudRequestToken) return;
console.error("Failed to load tag cloud:", error); console.error("Failed to load tag cloud:", error);
set({ tagCloudLoading: false }); set({ tagCloudLoading: false });
} }
}, },
searchByTag: (imageId) => { loadExploreTags: async () => {
const { selectedFolderId } = get(); const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
set((state) => ({ activeView: "gallery", images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images", imageLoadError: null, galleryScrollResetKey: state.galleryScrollResetKey + 1 })); if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
void get().loadSimilarImages(imageId, selectedFolderId); return;
}
const requestToken = ++exploreTagRequestToken;
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId });
try {
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
params: { folder_id: selectedFolderId, limit: 48 },
});
if (requestToken !== exploreTagRequestToken) return;
set({ exploreTagEntries: entries, exploreTagLoading: false });
} catch (error) {
if (requestToken !== exploreTagRequestToken) return;
console.error("Failed to load explore tags:", error);
set({ exploreTagLoading: false });
}
}, },
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true) => { showVisualCluster: async (imageIds) => {
const requestedLimit = reset ? PAGE_SIZE : get().loadedCount + PAGE_SIZE; const requestToken = ++similarRequestToken;
set((state) => ({ set((state) => ({
images: reset ? [] : get().images, activeView: "gallery",
loadedCount: reset ? 0 : get().loadedCount, search: "",
images: [],
totalImages: imageIds.length,
loadedCount: 0,
loadingImages: true,
collectionTitle: "Explore Cluster",
imageLoadError: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
galleryScrollResetKey: state.galleryScrollResetKey + 1,
}));
try {
const images = await invoke<ImageRecord[]>("get_images_by_ids", {
params: { image_ids: imageIds },
});
if (requestToken !== similarRequestToken) return;
set({
images,
totalImages: images.length,
loadedCount: images.length,
loadingImages: false,
imageLoadError: null,
collectionTitle: "Explore Cluster",
});
} catch (error) {
if (requestToken !== similarRequestToken) return;
set({
images: [],
totalImages: 0,
loadedCount: 0,
loadingImages: false,
imageLoadError: String(error),
collectionTitle: "Explore Cluster",
});
}
},
searchForTag: (tag) => {
set({ activeView: "gallery", search: `/t ${tag}`, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, similarFolderId: null, imageLoadError: null });
void get().loadImages(true);
},
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null) => {
const requestToken = ++similarRequestToken;
const offset = reset ? 0 : get().loadedCount;
const similarScope = folderId === null ? "all_media" : "current_folder";
set((state) => ({
images: reset ? [] : state.images,
loadedCount: reset ? 0 : state.loadedCount,
loadingImages: true, loadingImages: true,
collectionTitle: "Similar Images", collectionTitle: "Similar Images",
imageLoadError: null, imageLoadError: null,
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarFolderId: folderId ?? null,
similarScope,
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey, galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
})); }));
try { try {
const images = await invoke<ImageRecord[]>("find_similar_images", { const result = await invoke<SimilarImagesPage>("find_similar_images", {
params: { image_id: imageId, folder_id: folderId ?? null, limit: requestedLimit }, params: {
image_id: imageId,
folder_id: folderId ?? null,
offset,
limit: PAGE_SIZE,
threshold: SIMILAR_DISTANCE_THRESHOLD,
},
}); });
const hasMore = images.length >= requestedLimit;
set({ if (requestToken !== similarRequestToken) return;
images,
totalImages: hasMore ? images.length + PAGE_SIZE : images.length, set((state) => {
loadedCount: images.length, const nextImages = reset ? result.images : [...state.images, ...result.images];
const nextLoadedCount = nextImages.length;
return {
images: nextImages,
totalImages: result.has_more ? nextLoadedCount + 1 : nextLoadedCount,
loadedCount: nextLoadedCount,
loadingImages: false, loadingImages: false,
imageLoadError: null, imageLoadError: null,
collectionTitle: "Similar Images", collectionTitle: "Similar Images",
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarHasMore: hasMore, similarSourceFolderId: sourceFolderId,
selectedFolderId: folderId ?? null, similarHasMore: result.has_more,
selectedImage: reset ? null : get().selectedImage, similarFolderId: folderId ?? null,
similarScope,
selectedImage: reset ? null : state.selectedImage,
};
}); });
} catch (error) { } catch (error) {
if (requestToken !== similarRequestToken) return;
console.error("Failed to load similar images:", error); console.error("Failed to load similar images:", error);
set({ set({
images: [], images: [],
@@ -688,13 +983,23 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: String(error), imageLoadError: String(error),
collectionTitle: "Similar Images", collectionTitle: "Similar Images",
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: false, similarHasMore: false,
selectedFolderId: folderId ?? null, similarFolderId: folderId ?? null,
similarScope,
selectedImage: null, selectedImage: null,
}); });
} }
}, },
setSimilarScope: (similarScope) => {
set({ similarScope });
const { similarSourceImageId, similarSourceFolderId, selectedFolderId } = get();
if (similarSourceImageId === null) return;
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId);
},
suggestImageTags: async (imageId) => { suggestImageTags: async (imageId) => {
return invoke<string[]>("suggest_image_tags", { return invoke<string[]>("suggest_image_tags", {
params: { image_id: imageId, limit: 2 }, params: { image_id: imageId, limit: 2 },
@@ -833,6 +1138,27 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setSettingsOpen: (settingsOpen) => set({ settingsOpen }), setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
setTaggingQueueScope: (taggingQueueScope) => {
set((state) => ({
taggingQueueScope,
taggingQueueFolderIds:
taggingQueueScope === "selected" && state.taggingQueueFolderIds.length === 0 && state.folders.length > 0
? [state.folders[0].id]
: state.taggingQueueFolderIds,
}));
},
toggleTaggingQueueFolder: (folderId) => {
set((state) => {
const next = state.taggingQueueFolderIds.includes(folderId)
? state.taggingQueueFolderIds.filter((id) => id !== folderId)
: [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b);
return { taggingQueueFolderIds: next };
});
},
setTaggingQueueFolderIds: (taggingQueueFolderIds) => set({ taggingQueueFolderIds }),
loadTaggerModelStatus: async () => { loadTaggerModelStatus: async () => {
try { try {
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status"); const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status");
@@ -874,6 +1200,22 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
set({ taggerThreshold }); set({ taggerThreshold });
}, },
loadTaggerBatchSize: async () => {
try {
const taggerBatchSize = await invoke<number>("get_tagger_batch_size");
set({ taggerBatchSize });
} catch (error) {
set({ taggerModelError: String(error) });
}
},
setTaggerBatchSize: async (batchSize) => {
const taggerBatchSize = await invoke<number>("set_tagger_batch_size", {
params: { batch_size: batchSize },
});
set({ taggerBatchSize });
},
prepareTaggerModel: async () => { prepareTaggerModel: async () => {
set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null }); set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null });
try { try {
@@ -912,6 +1254,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return queued; return queued;
}, },
queueTaggingJobsForFolders: async (folderIds) => {
const queued = await invoke<number>("queue_tagging_jobs", {
params: { folder_id: null, folder_ids: folderIds, image_id: null },
});
await get().loadBackgroundJobProgress();
return queued;
},
queueTaggingForImage: async (imageId) => { queueTaggingForImage: async (imageId) => {
const queued = await invoke<number>("queue_tagging_jobs", { const queued = await invoke<number>("queue_tagging_jobs", {
params: { folder_id: null, image_id: imageId }, params: { folder_id: null, image_id: imageId },
@@ -928,6 +1278,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return cleared; return cleared;
}, },
clearTaggingJobsForFolders: async (folderIds) => {
const cleared = await invoke<number>("clear_tagging_jobs", {
params: { folder_id: null, folder_ids: folderIds },
});
await get().loadBackgroundJobProgress();
return cleared;
},
getImageTags: async (imageId) => { getImageTags: async (imageId) => {
return invoke<ImageTag[]>("get_image_tags", { return invoke<ImageTag[]>("get_image_tags", {
params: { image_id: imageId }, params: { image_id: imageId },
@@ -946,6 +1304,73 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}); });
}, },
loadDuplicateScanCache: async (folderId = null) => {
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
if (cached) {
set({ duplicateGroups: cached.groups, duplicateLastScanned: cached.scanned_at });
}
},
scanDuplicates: async (folderId = null) => {
const { listen } = await import("@tauri-apps/api/event");
set({ duplicateScanning: true, duplicateGroups: [], duplicateScanProgress: null, duplicateSelectedIds: new Set() });
const unlisten = await listen<[number, number]>("duplicate_scan_progress", (event) => {
const [scanned, total] = event.payload;
set({ duplicateScanProgress: { scanned, total } });
});
try {
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) });
} finally {
unlisten();
set({ duplicateScanning: false });
}
},
toggleDuplicateSelected: (imageId) => {
set((state) => {
const next = new Set(state.duplicateSelectedIds);
if (next.has(imageId)) next.delete(imageId);
else next.add(imageId);
return { duplicateSelectedIds: next };
});
},
selectAllDuplicates: (imageIds) => {
set((state) => {
const next = new Set(state.duplicateSelectedIds);
for (const id of imageIds) next.add(id);
return { duplicateSelectedIds: next };
});
},
selectKeepFirstAllGroups: () => {
const { duplicateGroups } = get();
const toMark = new Set<number>();
for (const group of duplicateGroups) {
for (const img of group.images.slice(1)) toMark.add(img.id);
}
set({ duplicateSelectedIds: toMark });
},
clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }),
deleteSelectedDuplicates: async () => {
const { duplicateSelectedIds } = get();
const ids = Array.from(duplicateSelectedIds);
if (ids.length === 0) return 0;
const deleted = await invoke<number>("delete_images_from_disk", { params: { image_ids: ids } });
// Remove deleted images from groups and drop now-trivial groups
set((state) => ({
duplicateSelectedIds: new Set(),
duplicateGroups: state.duplicateGroups
.map((g) => ({ ...g, images: g.images.filter((img) => !duplicateSelectedIds.has(img.id)) }))
.filter((g) => g.images.length > 1),
}));
return deleted;
},
retryFailedEmbeddings: async (folderId) => { retryFailedEmbeddings: async (folderId) => {
await invoke("retry_failed_embeddings", { params: { folder_id: folderId } }); await invoke("retry_failed_embeddings", { params: { folder_id: folderId } });
await get().loadBackgroundJobProgress(); await get().loadBackgroundJobProgress();
@@ -979,7 +1404,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
if (progress.done) { if (progress.done) {
void get().loadFolders(); void get().loadFolders();
void get().loadBackgroundJobProgress(); void get().loadBackgroundJobProgress();
if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) {
void get().loadImages(true); void get().loadImages(true);
}
setTimeout(() => { setTimeout(() => {
set((state) => { set((state) => {
@@ -1019,12 +1446,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const batch = event.payload; const batch = event.payload;
set((state) => { set((state) => {
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
return state;
}
const visibleImages = batch.images.filter((image) => const visibleImages = batch.images.filter((image) =>
matchesFilters( matchesFilters(
image, image,
state.selectedFolderId, state.selectedFolderId,
state.mediaFilter, state.mediaFilter,
state.favoritesOnly, state.favoritesOnly,
state.minimumRating,
state.failedEmbeddingsOnly, state.failedEmbeddingsOnly,
state.search, state.search,
), ),
@@ -1049,12 +1481,21 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const batch = event.payload; const batch = event.payload;
set((state) => { set((state) => {
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
const selectedImage =
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
: state.selectedImage;
return { selectedImage };
}
const visibleImages = batch.images.filter((image) => const visibleImages = batch.images.filter((image) =>
matchesFilters( matchesFilters(
image, image,
state.selectedFolderId, state.selectedFolderId,
state.mediaFilter, state.mediaFilter,
state.favoritesOnly, state.favoritesOnly,
state.minimumRating,
state.failedEmbeddingsOnly, state.failedEmbeddingsOnly,
state.search, state.search,
), ),