Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 619bd0c9d2 | |||
| 996bb71375 | |||
| 0d9229635b | |||
| cdb8aa20b9 | |||
| d2af84d9e8 |
@@ -70,6 +70,11 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|||||||
is now near-instant. The cluster cache is validated from a lightweight
|
is now near-instant. The cluster cache is validated from a lightweight
|
||||||
image-ID signature instead of re-reading every embedding, so big libraries no
|
image-ID signature instead of re-reading every embedding, so big libraries no
|
||||||
longer stall for several seconds even when the cached result is reused.
|
longer stall for several seconds even when the cached result is reused.
|
||||||
|
- **Faster first-time visual clustering** — computing clusters for a large
|
||||||
|
library is now much quicker: centroids are found on a representative sample and
|
||||||
|
every image is then assigned across all CPU cores, instead of iterating over
|
||||||
|
the whole library single-threaded. Density-aware seeding keeps the groupings
|
||||||
|
balanced, so no single cluster swallows a huge share of images.
|
||||||
- **Tag manager search and sort** — the Manage mode tag list now has a live
|
- **Tag manager search and sort** — the Manage mode tag list now has a live
|
||||||
filter input and a sort dropdown (most-used / least-used / A–Z / Z–A). The
|
filter input and a sort dropdown (most-used / least-used / A–Z / Z–A). The
|
||||||
list is virtualised so libraries with thousands of tags scroll without lag,
|
list is virtualised so libraries with thousands of tags scroll without lag,
|
||||||
|
|||||||
+155
-29
@@ -1161,7 +1161,7 @@ pub async fn reset_generated_captions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct TagCloudEntry {
|
pub struct VisualClusterEntry {
|
||||||
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>,
|
||||||
@@ -1174,10 +1174,10 @@ pub struct TagCloudEntry {
|
|||||||
/// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated
|
/// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated
|
||||||
/// calls (including across app restarts) return instantly when the library hasn't changed.
|
/// calls (including across app restarts) return instantly when the library hasn't changed.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_tag_cloud(
|
pub async fn get_visual_clusters(
|
||||||
db: State<'_, DbState>,
|
db: State<'_, DbState>,
|
||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
) -> Result<Vec<TagCloudEntry>, String> {
|
) -> Result<Vec<VisualClusterEntry>, String> {
|
||||||
let folder_scope = match folder_id {
|
let folder_scope = match folder_id {
|
||||||
Some(id) => format!("folder_{id}"),
|
Some(id) => format!("folder_{id}"),
|
||||||
None => "all".to_string(),
|
None => "all".to_string(),
|
||||||
@@ -1201,6 +1201,7 @@ pub async fn get_tag_cloud(
|
|||||||
let mut hasher = Xxh3::new();
|
let mut hasher = Xxh3::new();
|
||||||
hasher.update(&ids_hash.to_le_bytes());
|
hasher.update(&ids_hash.to_le_bytes());
|
||||||
hasher.update(revision.as_bytes());
|
hasher.update(revision.as_bytes());
|
||||||
|
hasher.update(CLUSTER_CACHE_VERSION.as_bytes());
|
||||||
hasher.digest()
|
hasher.digest()
|
||||||
};
|
};
|
||||||
(count, hash)
|
(count, hash)
|
||||||
@@ -1213,10 +1214,10 @@ pub async fn get_tag_cloud(
|
|||||||
// Try to return a valid SQLite cache before loading any embeddings.
|
// Try to return a valid SQLite cache before loading any embeddings.
|
||||||
{
|
{
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash)
|
if let Some(json) = db::get_visual_cluster_cache(&conn, &folder_scope, current_hash)
|
||||||
.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<VisualClusterEntry>>(&json) {
|
||||||
// Reject cache entries written before image_ids were tracked — they all
|
// 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.
|
// have empty image_ids which causes "No media found" when a cluster is opened.
|
||||||
if entries.iter().all(|e| !e.image_ids.is_empty()) {
|
if entries.iter().all(|e| !e.image_ids.is_empty()) {
|
||||||
@@ -1243,9 +1244,15 @@ pub async fn get_tag_cloud(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let k = (n / 20).clamp(5, 30);
|
let k = (n / 20).clamp(5, 30);
|
||||||
|
let kmeans_start = std::time::Instant::now();
|
||||||
let (centroids, cluster_counts, assignments) = kmeans_cosine(&points, k, 40);
|
let (centroids, cluster_counts, assignments) = kmeans_cosine(&points, k, 40);
|
||||||
|
log::debug!(
|
||||||
|
"visual-cluster clustering: {n} embeddings, k={k}, sampled={} → {:.2?}",
|
||||||
|
n > MAX_KMEANS_SAMPLE,
|
||||||
|
kmeans_start.elapsed(),
|
||||||
|
);
|
||||||
|
|
||||||
let mut entries: Vec<TagCloudEntry> = Vec::new();
|
let mut entries: Vec<VisualClusterEntry> = Vec::new();
|
||||||
let mut order: Vec<usize> = (0..k).collect();
|
let mut order: Vec<usize> = (0..k).collect();
|
||||||
order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a]));
|
order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a]));
|
||||||
|
|
||||||
@@ -1278,7 +1285,7 @@ pub async fn get_tag_cloud(
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|img| img.thumbnail_path);
|
.and_then(|img| img.thumbnail_path);
|
||||||
|
|
||||||
entries.push(TagCloudEntry {
|
entries.push(VisualClusterEntry {
|
||||||
count,
|
count,
|
||||||
representative_image_id: best_id,
|
representative_image_id: best_id,
|
||||||
thumbnail_path,
|
thumbnail_path,
|
||||||
@@ -1289,8 +1296,8 @@ pub async fn get_tag_cloud(
|
|||||||
// Persist to SQLite. The cache is best-effort, but a silent write failure here
|
// Persist to SQLite. The cache is best-effort, but a silent write failure here
|
||||||
// would defeat the whole optimization (every visit would recompute), so log it.
|
// would defeat the whole optimization (every visit would recompute), so log it.
|
||||||
if let Ok(json) = serde_json::to_string(&entries) {
|
if let Ok(json) = serde_json::to_string(&entries) {
|
||||||
if let Err(e) = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json) {
|
if let Err(e) = db::set_visual_cluster_cache(&conn, &folder_scope, current_hash, &json) {
|
||||||
eprintln!("failed to persist tag-cloud cache for {folder_scope}: {e}");
|
log::warn!("failed to persist visual-cluster cache for {folder_scope}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1846,33 +1853,138 @@ fn normalize(v: &mut [f32]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When the library is larger than this, the iterative k-means centroid search
|
||||||
|
/// runs on a deterministic, evenly-strided sample of this many embeddings rather
|
||||||
|
/// than the full set; every image is then assigned to its nearest centroid in a
|
||||||
|
/// single parallel pass. The centroid search is the expensive O(n·k·dim·iters)
|
||||||
|
/// part, so sampling it keeps the (statistically equivalent) clustering fast on
|
||||||
|
/// large libraries while the full assignment still places every image.
|
||||||
|
const MAX_KMEANS_SAMPLE: usize = 3000;
|
||||||
|
|
||||||
|
/// Bumped whenever the clustering algorithm changes so persisted visual-cluster caches
|
||||||
|
/// computed by an older algorithm are invalidated and recomputed on next view.
|
||||||
|
const CLUSTER_CACHE_VERSION: &str = "sampled-v2";
|
||||||
|
|
||||||
|
/// Cosine k-means. For large inputs the iterative centroid search runs on a
|
||||||
|
/// strided sample (see [`MAX_KMEANS_SAMPLE`]); the final assignment of every
|
||||||
|
/// point to its nearest centroid always covers the full input, in parallel.
|
||||||
fn kmeans_cosine(
|
fn kmeans_cosine(
|
||||||
points: &[Vec<f32>],
|
points: &[Vec<f32>],
|
||||||
k: usize,
|
k: usize,
|
||||||
max_iter: usize,
|
max_iter: usize,
|
||||||
) -> (Vec<Vec<f32>>, Vec<usize>, Vec<usize>) {
|
) -> (Vec<Vec<f32>>, Vec<usize>, Vec<usize>) {
|
||||||
let n = points.len();
|
let n = points.len();
|
||||||
let dim = points[0].len();
|
|
||||||
|
|
||||||
// Deterministic k-means++ init: spread centroids as far apart as possible
|
// Deterministic, evenly-distributed sample for the centroid search. For n at
|
||||||
|
// or below the cap this is just the full set (a clone), so behaviour is
|
||||||
|
// unchanged on small/medium libraries.
|
||||||
|
let sample: Vec<Vec<f32>> = if n > MAX_KMEANS_SAMPLE {
|
||||||
|
let step = n / MAX_KMEANS_SAMPLE;
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.step_by(step)
|
||||||
|
.take(MAX_KMEANS_SAMPLE)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
points.to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
let centroids = kmeans_centroids(&sample, k, max_iter);
|
||||||
|
|
||||||
|
// Assign every point — not just the sample — to its nearest centroid.
|
||||||
|
let assignments = assign_to_centroids(points, ¢roids);
|
||||||
|
|
||||||
|
let mut counts = vec![0usize; k];
|
||||||
|
for &a in &assignments {
|
||||||
|
counts[a] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
(centroids, counts, assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small deterministic PRNG (SplitMix64) so k-means++ seeding is reproducible
|
||||||
|
/// across runs without pulling in the `rand` crate.
|
||||||
|
struct DetRng(u64);
|
||||||
|
|
||||||
|
impl DetRng {
|
||||||
|
fn new(seed: u64) -> Self {
|
||||||
|
DetRng(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_u64(&mut self) -> u64 {
|
||||||
|
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = self.0;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform f64 in [0, 1).
|
||||||
|
fn next_f64(&mut self) -> f64 {
|
||||||
|
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic k-means++ seeding: the first centroid is the middle point, then
|
||||||
|
/// each subsequent centroid is drawn from the remaining points with probability
|
||||||
|
/// proportional to its squared distance to the nearest chosen centroid (D²
|
||||||
|
/// weighting). For unit-normalized embeddings the squared Euclidean distance is
|
||||||
|
/// proportional to `1 - cos`, so that is used as the weight. Unlike greedy
|
||||||
|
/// farthest-point seeding this places proportionally more centroids in dense
|
||||||
|
/// regions, preventing a single centroid from absorbing the whole dominant mode.
|
||||||
|
fn kmeans_pp_seed(points: &[Vec<f32>], k: usize) -> Vec<Vec<f32>> {
|
||||||
|
let n = points.len();
|
||||||
|
let mut rng = DetRng::new(0x5EED_1234_ABCD_0001);
|
||||||
let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
|
let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
|
||||||
centroids.push(points[n / 2].clone());
|
centroids.push(points[n / 2].clone());
|
||||||
|
|
||||||
|
// d2[i] = distance from point i to its nearest chosen centroid (∝ squared
|
||||||
|
// Euclidean for unit vectors), kept up to date as centroids are added.
|
||||||
|
let mut d2 = vec![f32::MAX; n];
|
||||||
for _ in 1..k {
|
for _ in 1..k {
|
||||||
let next = points
|
let last = centroids.last().unwrap();
|
||||||
.iter()
|
for (i, p) in points.iter().enumerate() {
|
||||||
.map(|p| {
|
let dist = (1.0 - dot(p, last)).max(0.0);
|
||||||
let best_sim = centroids
|
if dist < d2[i] {
|
||||||
.iter()
|
d2[i] = dist;
|
||||||
.map(|c| dot(p, c))
|
|
||||||
.fold(f32::NEG_INFINITY, f32::max);
|
|
||||||
1.0 - best_sim // distance = 1 - cosine_similarity
|
|
||||||
})
|
|
||||||
.enumerate()
|
|
||||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.unwrap_or(0);
|
|
||||||
centroids.push(points[next].clone());
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: f64 = d2.iter().map(|&w| w as f64).sum();
|
||||||
|
if total <= 0.0 {
|
||||||
|
// Remaining points coincide with chosen centroids; pad deterministically.
|
||||||
|
centroids.push(points[n / 2].clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut target = rng.next_f64() * total;
|
||||||
|
let mut idx = n - 1;
|
||||||
|
for (i, &w) in d2.iter().enumerate() {
|
||||||
|
target -= w as f64;
|
||||||
|
if target <= 0.0 {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
centroids.push(points[idx].clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
centroids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs deterministic k-means++ seeding + Lloyd iterations on `points`, returning
|
||||||
|
/// the final normalized centroids. Operates only on the slice it is given — the
|
||||||
|
/// caller passes a sample for large libraries.
|
||||||
|
fn kmeans_centroids(points: &[Vec<f32>], k: usize, max_iter: usize) -> Vec<Vec<f32>> {
|
||||||
|
let n = points.len();
|
||||||
|
let dim = points[0].len();
|
||||||
|
|
||||||
|
// Density-aware k-means++ seeding (see kmeans_pp_seed). Crucially NOT greedy
|
||||||
|
// farthest-point seeding, which picks outliers and — on a sampled large
|
||||||
|
// library — leaves the dense core under-seeded so one centroid swallows a huge
|
||||||
|
// share of the (visually generic) images.
|
||||||
|
let mut centroids = kmeans_pp_seed(points, k);
|
||||||
|
|
||||||
let mut assignments = vec![0usize; n];
|
let mut assignments = vec![0usize; n];
|
||||||
|
|
||||||
@@ -1914,12 +2026,26 @@ fn kmeans_cosine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut counts = vec![0usize; k];
|
centroids
|
||||||
for &a in &assignments {
|
|
||||||
counts[a] += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(centroids, counts, assignments)
|
/// Assigns each point to its nearest centroid (max cosine similarity) in parallel.
|
||||||
|
/// This is the only step that touches every embedding, so parallelising it is
|
||||||
|
/// where the multi-core speed-up on large libraries comes from.
|
||||||
|
fn assign_to_centroids(points: &[Vec<f32>], centroids: &[Vec<f32>]) -> Vec<usize> {
|
||||||
|
use rayon::prelude::*;
|
||||||
|
points
|
||||||
|
.par_iter()
|
||||||
|
.map(|p| {
|
||||||
|
centroids
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(j, c)| (j, dot(p, c)))
|
||||||
|
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
.map(|(j, _)| j)
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
+15
-11
@@ -267,12 +267,16 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
UNIQUE(image_id, tag)
|
UNIQUE(image_id, tag)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tag_cloud_cache (
|
CREATE TABLE IF NOT EXISTS visual_cluster_cache (
|
||||||
folder_scope TEXT PRIMARY KEY,
|
folder_scope TEXT PRIMARY KEY,
|
||||||
image_ids_hash INTEGER NOT NULL,
|
image_ids_hash INTEGER NOT NULL,
|
||||||
entries_json TEXT NOT NULL,
|
entries_json TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
-- Renamed from the misnamed tag_cloud_cache (it caches visual clusters,
|
||||||
|
-- not tags). The cache is disposable and was already invalidated by a
|
||||||
|
-- cluster-algorithm version bump, so the old table is simply dropped.
|
||||||
|
DROP TABLE IF EXISTS tag_cloud_cache;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS duplicate_scan_cache (
|
CREATE TABLE IF NOT EXISTS duplicate_scan_cache (
|
||||||
folder_scope TEXT PRIMARY KEY,
|
folder_scope TEXT PRIMARY KEY,
|
||||||
@@ -407,7 +411,7 @@ fn remove_filtered_ai_tags(conn: &Connection) -> Result<()> {
|
|||||||
delete_stmt.execute([id])?;
|
delete_stmt.execute([id])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.execute("DELETE FROM tag_cloud_cache", [])?;
|
tx.execute("DELETE FROM visual_cluster_cache", [])?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -2799,9 +2803,9 @@ pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
// …then drop the now-duplicate leftovers still under the old name.
|
// …then drop the now-duplicate leftovers still under the old name.
|
||||||
tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?;
|
tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?;
|
||||||
// The tag-cloud cache keys on image-id hashes, not tag text, so a rename
|
// The visual-cluster cache keys on image-id hashes, not tag text, so a rename
|
||||||
// wouldn't invalidate it automatically — clear it.
|
// wouldn't invalidate it automatically — clear it.
|
||||||
tx.execute("DELETE FROM tag_cloud_cache", [])?;
|
tx.execute("DELETE FROM visual_cluster_cache", [])?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2810,7 +2814,7 @@ pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
|
|||||||
pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
|
pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64;
|
let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64;
|
||||||
tx.execute("DELETE FROM tag_cloud_cache", [])?;
|
tx.execute("DELETE FROM visual_cluster_cache", [])?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
@@ -2983,14 +2987,14 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns cached tag-cloud entries if the stored hash matches `current_hash`.
|
/// Returns cached visual-cluster entries if the stored hash matches `current_hash`.
|
||||||
pub fn get_tag_cloud_cache(
|
pub fn get_visual_cluster_cache(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_scope: &str,
|
folder_scope: &str,
|
||||||
current_hash: u64,
|
current_hash: u64,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>> {
|
||||||
let result: rusqlite::Result<(i64, String)> = conn.query_row(
|
let result: rusqlite::Result<(i64, String)> = conn.query_row(
|
||||||
"SELECT image_ids_hash, entries_json FROM tag_cloud_cache WHERE folder_scope = ?1",
|
"SELECT image_ids_hash, entries_json FROM visual_cluster_cache WHERE folder_scope = ?1",
|
||||||
params![folder_scope],
|
params![folder_scope],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
);
|
);
|
||||||
@@ -3002,15 +3006,15 @@ pub fn get_tag_cloud_cache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upserts the tag-cloud cache for the given scope.
|
/// Upserts the visual-cluster cache for the given scope.
|
||||||
pub fn set_tag_cloud_cache(
|
pub fn set_visual_cluster_cache(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_scope: &str,
|
folder_scope: &str,
|
||||||
image_ids_hash: u64,
|
image_ids_hash: u64,
|
||||||
entries_json: &str,
|
entries_json: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO tag_cloud_cache (folder_scope, image_ids_hash, entries_json, created_at)
|
"INSERT INTO visual_cluster_cache (folder_scope, image_ids_hash, entries_json, created_at)
|
||||||
VALUES (?1, ?2, ?3, datetime('now'))
|
VALUES (?1, ?2, ?3, datetime('now'))
|
||||||
ON CONFLICT(folder_scope) DO UPDATE SET
|
ON CONFLICT(folder_scope) DO UPDATE SET
|
||||||
image_ids_hash = excluded.image_ids_hash,
|
image_ids_hash = excluded.image_ids_hash,
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ pub fn run() {
|
|||||||
commands::get_worker_states,
|
commands::get_worker_states,
|
||||||
commands::get_worker_pauses_persist,
|
commands::get_worker_pauses_persist,
|
||||||
commands::set_worker_pauses_persist,
|
commands::set_worker_pauses_persist,
|
||||||
commands::get_tag_cloud,
|
commands::get_visual_clusters,
|
||||||
commands::get_explore_tags,
|
commands::get_explore_tags,
|
||||||
commands::get_related_tags,
|
commands::get_related_tags,
|
||||||
commands::get_images_by_ids,
|
commands::get_images_by_ids,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub const JOYTAG_MODEL_NAME: &str = "joytag";
|
|||||||
// CLIP-style mean/std normalization on [0,1] values (not raw [0,255]), and an
|
// CLIP-style mean/std normalization on [0,1] values (not raw [0,255]), and an
|
||||||
// NCHW layout (not NHWC). These are the OpenAI CLIP normalization constants.
|
// NCHW layout (not NHWC). These are the OpenAI CLIP normalization constants.
|
||||||
const JOYTAG_MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73];
|
const JOYTAG_MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73];
|
||||||
const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_58, 0.275_777_11];
|
const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_6, 0.275_777_1];
|
||||||
// JoyTag's recommended detection threshold (used as the default; the user's
|
// JoyTag's recommended detection threshold (used as the default; the user's
|
||||||
// tagger_threshold setting still overrides it).
|
// tagger_threshold setting still overrides it).
|
||||||
const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4;
|
const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4;
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
|
|||||||
/// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an
|
/// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an
|
||||||
/// image between folders changes it even when the count happens to stay the same.
|
/// image between folders changes it even when the count happens to stay the same.
|
||||||
/// Used (together with the embedding revision, which catches an image being
|
/// Used (together with the embedding revision, which catches an image being
|
||||||
/// re-embedded in place) as the cheap tag-cloud cache key so a cache hit doesn't
|
/// re-embedded in place) as the cheap visual-cluster cache key so a cache hit doesn't
|
||||||
/// have to read and unpack hundreds of MB of embeddings just to validate freshness.
|
/// have to read and unpack hundreds of MB of embeddings just to validate freshness.
|
||||||
pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> {
|
pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> {
|
||||||
use xxhash_rust::xxh3::Xxh3;
|
use xxhash_rust::xxh3::Xxh3;
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ import { BackgroundTasks } from "./components/BackgroundTasks";
|
|||||||
import { Toolbar } from "./components/Toolbar";
|
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 { ExploreView } from "./components/ExploreView";
|
||||||
import { DuplicateFinder } from "./components/DuplicateFinder";
|
import { DuplicateFinder } from "./components/DuplicateFinder";
|
||||||
import { Timeline } from "./components/Timeline";
|
import { Timeline } from "./components/Timeline";
|
||||||
import { TitleBar } from "./components/TitleBar";
|
import { TitleBar } from "./components/TitleBar";
|
||||||
@@ -88,7 +88,7 @@ export default function App() {
|
|||||||
) : activeView === "explore" ? (
|
) : activeView === "explore" ? (
|
||||||
<>
|
<>
|
||||||
<BackgroundTasks />
|
<BackgroundTasks />
|
||||||
<TagCloud />
|
<ExploreView />
|
||||||
</>
|
</>
|
||||||
) : activeView === "duplicates" ? (
|
) : activeView === "duplicates" ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store";
|
||||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
import { ThemedDropdown } from "./ThemedDropdown";
|
import { ThemedDropdown } from "./ThemedDropdown";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
@@ -43,7 +43,7 @@ function seeded(n: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PlacedNode {
|
interface PlacedNode {
|
||||||
entry: TagCloudEntry;
|
entry: VisualClusterEntry;
|
||||||
index: number;
|
index: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -57,7 +57,7 @@ interface PlacedNode {
|
|||||||
rotateSeed: number;
|
rotateSeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: number): PlacedNode[] {
|
function buildCloud(entries: VisualClusterEntry[], containerW: number, containerH: number): PlacedNode[] {
|
||||||
if (!entries.length || containerW <= 0 || containerH <= 0) return [];
|
if (!entries.length || containerW <= 0 || containerH <= 0) return [];
|
||||||
|
|
||||||
const maxCount = Math.max(...entries.map((e) => e.count));
|
const maxCount = Math.max(...entries.map((e) => e.count));
|
||||||
@@ -622,13 +622,13 @@ function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Separate component so its useLayoutEffect fires when the canvas is actually
|
// Separate component so its useLayoutEffect fires when the canvas is actually
|
||||||
// mounted — not at TagCloud mount time when the container may still be hidden
|
// mounted — not at ExploreView mount time when the container may still be hidden
|
||||||
// behind a loading state.
|
// behind a loading state.
|
||||||
function ClusterCloud({
|
function ClusterCloud({
|
||||||
entries,
|
entries,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
entries: TagCloudEntry[];
|
entries: VisualClusterEntry[];
|
||||||
onOpen: (imageIds: number[]) => void;
|
onOpen: (imageIds: number[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
@@ -968,12 +968,12 @@ function TagManageList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagCloud() {
|
export function ExploreView() {
|
||||||
const exploreMode = useGalleryStore((state) => state.exploreMode);
|
const exploreMode = useGalleryStore((state) => state.exploreMode);
|
||||||
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
|
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
|
||||||
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
|
const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries);
|
||||||
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
|
const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading);
|
||||||
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
|
const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters);
|
||||||
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
|
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
|
||||||
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
|
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
|
||||||
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
|
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
|
||||||
@@ -989,13 +989,13 @@ export function TagCloud() {
|
|||||||
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
|
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exploreMode === "visual") void loadTagCloud();
|
if (exploreMode === "visual") void loadVisualClusters();
|
||||||
else void loadExploreTags();
|
else void loadExploreTags();
|
||||||
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]);
|
}, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]);
|
||||||
|
|
||||||
const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading;
|
const loading = exploreMode === "visual" ? visualClusterLoading : exploreTagLoading;
|
||||||
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
|
const hasEntries = exploreMode === "visual" ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0;
|
||||||
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
|
const entryCount = exploreMode === "visual" ? visualClusterEntries.length : exploreTagEntries.length;
|
||||||
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);
|
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1070,7 +1070,7 @@ export function TagCloud() {
|
|||||||
</div>
|
</div>
|
||||||
) : exploreMode === "visual" ? (
|
) : exploreMode === "visual" ? (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
|
<ClusterCloud entries={visualClusterEntries} onOpen={showVisualCluster} />
|
||||||
</div>
|
</div>
|
||||||
) : manageTags ? (
|
) : manageTags ? (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
@@ -250,8 +250,8 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
case "find_similar_images":
|
case "find_similar_images":
|
||||||
case "find_similar_by_region":
|
case "find_similar_by_region":
|
||||||
return similarImages(payload);
|
return similarImages(payload);
|
||||||
case "get_tag_cloud":
|
case "get_visual_clusters":
|
||||||
return db.scenario === "empty" ? [] : db.tagCloud;
|
return db.scenario === "empty" ? [] : db.visualClusters;
|
||||||
case "get_explore_tags":
|
case "get_explore_tags":
|
||||||
return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180));
|
return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180));
|
||||||
case "get_related_tags": {
|
case "get_related_tags": {
|
||||||
|
|||||||
+17
-7
@@ -9,7 +9,7 @@ import type {
|
|||||||
ImageRecord,
|
ImageRecord,
|
||||||
ImageTag,
|
ImageTag,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
TagCloudEntry,
|
VisualClusterEntry,
|
||||||
} from "../store";
|
} from "../store";
|
||||||
import type { MockScenario } from "./mockScenarios";
|
import type { MockScenario } from "./mockScenarios";
|
||||||
import { fixtureMediaPath } from "./mockMedia";
|
import { fixtureMediaPath } from "./mockMedia";
|
||||||
@@ -21,7 +21,7 @@ export interface MockDb {
|
|||||||
albums: Album[];
|
albums: Album[];
|
||||||
albumImageIds: Record<number, number[]>;
|
albumImageIds: Record<number, number[]>;
|
||||||
tagsByImageId: Record<number, ImageTag[]>;
|
tagsByImageId: Record<number, ImageTag[]>;
|
||||||
tagCloud: TagCloudEntry[];
|
visualClusters: VisualClusterEntry[];
|
||||||
exploreTags: ExploreTagEntry[];
|
exploreTags: ExploreTagEntry[];
|
||||||
backgroundJobs: FolderJobProgress[];
|
backgroundJobs: FolderJobProgress[];
|
||||||
duplicateGroups: DuplicateGroup[];
|
duplicateGroups: DuplicateGroup[];
|
||||||
@@ -252,12 +252,22 @@ function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgr
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] {
|
function makeVisualClusters(images: ImageRecord[], scenario: MockScenario): VisualClusterEntry[] {
|
||||||
return tags.slice(0, 10).map((_, index) => {
|
if (images.length < 5) return [];
|
||||||
|
// Mirror the backend's k = (n / 20).clamp(5, 30). Force the full spread for the
|
||||||
|
// "huge" scenario so Explore shows a realistic field of clusters rather than a
|
||||||
|
// fixed handful, and size the counts off a large virtual library so the badges
|
||||||
|
// read like a big collection.
|
||||||
|
const clusterCount = scenario === "huge" ? 30 : Math.min(30, Math.max(5, Math.round(images.length / 20)));
|
||||||
|
const virtualTotal = scenario === "huge" ? 12_000 : images.length;
|
||||||
|
// Long-tailed sizes (a few large clusters, many smaller), like real k-means output.
|
||||||
|
const weights = Array.from({ length: clusterCount }, (_, i) => 1 / (i + 1.5));
|
||||||
|
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
return weights.map((weight, index) => {
|
||||||
const group = images.filter((image) => image.id % (index + 2) === 0).slice(0, 28);
|
const group = images.filter((image) => image.id % (index + 2) === 0).slice(0, 28);
|
||||||
const representative = group[0] ?? images[index];
|
const representative = group[0] ?? images[index % images.length];
|
||||||
return {
|
return {
|
||||||
count: Math.max(group.length, 1),
|
count: Math.max(1, Math.round((virtualTotal * weight) / weightSum)),
|
||||||
representative_image_id: representative?.id ?? index + 1,
|
representative_image_id: representative?.id ?? index + 1,
|
||||||
thumbnail_path: representative?.thumbnail_path ?? null,
|
thumbnail_path: representative?.thumbnail_path ?? null,
|
||||||
image_ids: group.length ? group.map((image) => image.id) : [representative?.id ?? 1],
|
image_ids: group.length ? group.map((image) => image.id) : [representative?.id ?? 1],
|
||||||
@@ -305,7 +315,7 @@ export function createMockDb(scenario: MockScenario): MockDb {
|
|||||||
albums,
|
albums,
|
||||||
albumImageIds,
|
albumImageIds,
|
||||||
tagsByImageId: makeTags(images, scenario),
|
tagsByImageId: makeTags(images, scenario),
|
||||||
tagCloud: makeTagCloud(images),
|
visualClusters: makeVisualClusters(images, scenario),
|
||||||
exploreTags: makeExploreTags(images, scenario),
|
exploreTags: makeExploreTags(images, scenario),
|
||||||
backgroundJobs: makeProgress(folders, scenario),
|
backgroundJobs: makeProgress(folders, scenario),
|
||||||
duplicateGroups: makeDuplicateGroups(images, scenario),
|
duplicateGroups: makeDuplicateGroups(images, scenario),
|
||||||
|
|||||||
+34
-34
@@ -203,7 +203,7 @@ export interface ImageExif {
|
|||||||
gps_lon: number | null;
|
gps_lon: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagCloudEntry {
|
export interface VisualClusterEntry {
|
||||||
count: number;
|
count: number;
|
||||||
representative_image_id: number;
|
representative_image_id: number;
|
||||||
thumbnail_path: string | null;
|
thumbnail_path: string | null;
|
||||||
@@ -376,9 +376,9 @@ interface GalleryState {
|
|||||||
activeView: ActiveView;
|
activeView: ActiveView;
|
||||||
exploreMode: ExploreMode;
|
exploreMode: ExploreMode;
|
||||||
tagManagerOpen: boolean;
|
tagManagerOpen: boolean;
|
||||||
tagCloudEntries: TagCloudEntry[];
|
visualClusterEntries: VisualClusterEntry[];
|
||||||
tagCloudLoading: boolean;
|
visualClusterLoading: boolean;
|
||||||
tagCloudFolderId: number | null | undefined; // undefined = never loaded
|
visualClusterFolderId: number | null | undefined; // undefined = never loaded
|
||||||
exploreTagEntries: ExploreTagEntry[];
|
exploreTagEntries: ExploreTagEntry[];
|
||||||
exploreTagLoading: boolean;
|
exploreTagLoading: boolean;
|
||||||
// Cache-freshness key: the folder the loaded tags belong to. Set to undefined
|
// Cache-freshness key: the folder the loaded tags belong to. Set to undefined
|
||||||
@@ -497,7 +497,7 @@ interface GalleryState {
|
|||||||
setExploreMode: (mode: ExploreMode) => void;
|
setExploreMode: (mode: ExploreMode) => void;
|
||||||
setTagManagerOpen: (open: boolean) => void;
|
setTagManagerOpen: (open: boolean) => void;
|
||||||
openTagManager: () => void;
|
openTagManager: () => void;
|
||||||
loadTagCloud: (options?: { force?: boolean }) => Promise<void>;
|
loadVisualClusters: (options?: { force?: boolean }) => Promise<void>;
|
||||||
loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
|
loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
|
||||||
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
|
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
|
||||||
showVisualCluster: (imageIds: number[]) => Promise<void>;
|
showVisualCluster: (imageIds: number[]) => Promise<void>;
|
||||||
@@ -633,7 +633,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24;
|
|||||||
// similarity, region search). Any new request increments it so a stale response
|
// similarity, region search). Any new request increments it so a stale response
|
||||||
// from a previous collection type cannot overwrite newer results.
|
// from a previous collection type cannot overwrite newer results.
|
||||||
let galleryRequestToken = 0;
|
let galleryRequestToken = 0;
|
||||||
let tagCloudRequestToken = 0;
|
let visualClusterRequestToken = 0;
|
||||||
let exploreTagRequestToken = 0;
|
let exploreTagRequestToken = 0;
|
||||||
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -878,9 +878,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
activeView: "gallery",
|
activeView: "gallery",
|
||||||
exploreMode: "visual",
|
exploreMode: "visual",
|
||||||
tagManagerOpen: false,
|
tagManagerOpen: false,
|
||||||
tagCloudEntries: [],
|
visualClusterEntries: [],
|
||||||
tagCloudLoading: false,
|
visualClusterLoading: false,
|
||||||
tagCloudFolderId: undefined,
|
visualClusterFolderId: undefined,
|
||||||
exploreTagEntries: [],
|
exploreTagEntries: [],
|
||||||
exploreTagLoading: false,
|
exploreTagLoading: false,
|
||||||
exploreTagsFolderId: undefined,
|
exploreTagsFolderId: undefined,
|
||||||
@@ -1010,7 +1010,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
await loadFolders();
|
await loadFolders();
|
||||||
await loadBackgroundJobProgress();
|
await loadBackgroundJobProgress();
|
||||||
// Invalidate tag cloud and explore-tags cache since library content changed.
|
// Invalidate tag cloud and explore-tags cache since library content changed.
|
||||||
set({ tagCloudFolderId: undefined, tagCloudEntries: [], exploreTagsFolderId: undefined });
|
set({ visualClusterFolderId: undefined, visualClusterEntries: [], exploreTagsFolderId: undefined });
|
||||||
// Always refresh the gallery: the removed folder's images may be on screen
|
// Always refresh the gallery: the removed folder's images may be on screen
|
||||||
// (e.g. in All Media), not only when that folder was the active selection.
|
// (e.g. in All Media), not only when that folder was the active selection.
|
||||||
await loadImages(true);
|
await loadImages(true);
|
||||||
@@ -1021,7 +1021,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
await invoke("reindex_folder", { folderId });
|
await invoke("reindex_folder", { folderId });
|
||||||
await loadFolders();
|
await loadFolders();
|
||||||
// Invalidate tag cloud cache since embeddings will be regenerated
|
// Invalidate tag cloud cache since embeddings will be regenerated
|
||||||
set({ tagCloudFolderId: undefined, tagCloudEntries: [] });
|
set({ visualClusterFolderId: undefined, visualClusterEntries: [] });
|
||||||
await loadBackgroundJobProgress();
|
await loadBackgroundJobProgress();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1085,7 +1085,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explore reloads itself via TagCloud's useEffect on selectedFolderId.
|
// Explore reloads itself via ExploreView's useEffect on selectedFolderId.
|
||||||
if (activeView === "explore") return;
|
if (activeView === "explore") return;
|
||||||
|
|
||||||
void get().loadImages(true);
|
void get().loadImages(true);
|
||||||
@@ -1425,33 +1425,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false });
|
set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
loadTagCloud: async (options) => {
|
loadVisualClusters: async (options) => {
|
||||||
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
|
const { selectedFolderId, visualClusterFolderId, visualClusterLoading } = get();
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
// Skip if already loaded for this folder and not currently loading
|
// Skip if already loaded for this folder and not currently loading
|
||||||
if (!force && !tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
|
if (!force && !visualClusterLoading && visualClusterFolderId !== undefined && visualClusterFolderId === selectedFolderId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestToken = ++tagCloudRequestToken;
|
const requestToken = ++visualClusterRequestToken;
|
||||||
// On a real folder switch, drop the previous folder's clusters so the loading
|
// On a real folder switch, drop the previous folder's clusters so the loading
|
||||||
// panel shows instead of lingering stale results. A same-folder refresh keeps
|
// panel shows instead of lingering stale results. A same-folder refresh keeps
|
||||||
// them to avoid a flicker when the cache returns instantly.
|
// them to avoid a flicker when the cache returns instantly.
|
||||||
const isFolderSwitch = tagCloudFolderId !== selectedFolderId;
|
const isFolderSwitch = visualClusterFolderId !== selectedFolderId;
|
||||||
set({
|
set({
|
||||||
tagCloudLoading: true,
|
visualClusterLoading: true,
|
||||||
tagCloudFolderId: selectedFolderId,
|
visualClusterFolderId: selectedFolderId,
|
||||||
...(isFolderSwitch ? { tagCloudEntries: [] } : {}),
|
...(isFolderSwitch ? { visualClusterEntries: [] } : {}),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
|
const entries = await invoke<VisualClusterEntry[]>("get_visual_clusters", {
|
||||||
folderId: selectedFolderId,
|
folderId: selectedFolderId,
|
||||||
});
|
});
|
||||||
if (requestToken !== tagCloudRequestToken) return;
|
if (requestToken !== visualClusterRequestToken) return;
|
||||||
set({ tagCloudEntries: entries, tagCloudLoading: false });
|
set({ visualClusterEntries: entries, visualClusterLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestToken !== tagCloudRequestToken) return;
|
if (requestToken !== visualClusterRequestToken) return;
|
||||||
console.error("Failed to load tag cloud:", error);
|
console.error("Failed to load tag cloud:", error);
|
||||||
set({ tagCloudLoading: false });
|
set({ visualClusterLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2411,13 +2411,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
|
|
||||||
renameTag: async (from, to) => {
|
renameTag: async (from, to) => {
|
||||||
await invoke("rename_tag", { params: { from, to } });
|
await invoke("rename_tag", { params: { from, to } });
|
||||||
// Tag content changed — invalidate the explore-tags and tag-cloud caches.
|
// Tag content changed — invalidate the explore-tags and visual-cluster caches.
|
||||||
// Keep the current tag list visible while the refresh runs so manager UI
|
// Keep the current tag list visible while the refresh runs so manager UI
|
||||||
// state such as filtering and sorting is not lost to a loading remount.
|
// state such as filtering and sorting is not lost to a loading remount.
|
||||||
set({
|
set({
|
||||||
exploreTagsFolderId: undefined,
|
exploreTagsFolderId: undefined,
|
||||||
tagCloudFolderId: undefined,
|
visualClusterFolderId: undefined,
|
||||||
tagCloudEntries: [],
|
visualClusterEntries: [],
|
||||||
});
|
});
|
||||||
const parsed = parseSearchValue(get().search);
|
const parsed = parseSearchValue(get().search);
|
||||||
if (parsed.mode === "tag" && parsed.query === from) {
|
if (parsed.mode === "tag" && parsed.query === from) {
|
||||||
@@ -2435,8 +2435,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
// state such as filtering and sorting is not lost to a loading remount.
|
// state such as filtering and sorting is not lost to a loading remount.
|
||||||
set({
|
set({
|
||||||
exploreTagsFolderId: undefined,
|
exploreTagsFolderId: undefined,
|
||||||
tagCloudFolderId: undefined,
|
visualClusterFolderId: undefined,
|
||||||
tagCloudEntries: [],
|
visualClusterEntries: [],
|
||||||
});
|
});
|
||||||
const parsed = parseSearchValue(get().search);
|
const parsed = parseSearchValue(get().search);
|
||||||
if (parsed.mode === "tag" && parsed.query === tag) {
|
if (parsed.mode === "tag" && parsed.query === tag) {
|
||||||
@@ -2507,14 +2507,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
if (ids.length === 0 || cleaned.length === 0) return;
|
if (ids.length === 0 || cleaned.length === 0) return;
|
||||||
await invoke<void>("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } });
|
await invoke<void>("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } });
|
||||||
// New tags landed — invalidate Explore tag caches.
|
// New tags landed — invalidate Explore tag caches.
|
||||||
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] });
|
set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
|
||||||
},
|
},
|
||||||
|
|
||||||
bulkRemoveTag: async (tag) => {
|
bulkRemoveTag: async (tag) => {
|
||||||
const ids = Array.from(get().gallerySelectedIds);
|
const ids = Array.from(get().gallerySelectedIds);
|
||||||
if (ids.length === 0 || !tag.trim()) return;
|
if (ids.length === 0 || !tag.trim()) return;
|
||||||
await invoke<void>("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } });
|
await invoke<void>("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } });
|
||||||
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] });
|
set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
|
||||||
},
|
},
|
||||||
|
|
||||||
bulkDeleteSelected: async () => {
|
bulkDeleteSelected: async () => {
|
||||||
@@ -2532,8 +2532,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
totalImages: Math.max(0, state.totalImages - succeededIds.length),
|
totalImages: Math.max(0, state.totalImages - succeededIds.length),
|
||||||
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))),
|
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))),
|
||||||
// Deletion changes tag/duplicate/album aggregates.
|
// Deletion changes tag/duplicate/album aggregates.
|
||||||
tagCloudFolderId: undefined,
|
visualClusterFolderId: undefined,
|
||||||
tagCloudEntries: [],
|
visualClusterEntries: [],
|
||||||
exploreTagsFolderId: undefined,
|
exploreTagsFolderId: undefined,
|
||||||
}));
|
}));
|
||||||
// The DB cascade already removed these from album_images; refresh counts/covers.
|
// The DB cascade already removed these from album_images; refresh counts/covers.
|
||||||
|
|||||||
Reference in New Issue
Block a user