diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6380d..91ec441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,10 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Changed +- **Faster Explore on large libraries** — revisiting a folder's visual clusters + 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 + longer stall for several seconds even when the cached result is reused. - **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 list is virtualised so libraries with thousands of tags scroll without lag, @@ -86,6 +90,10 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Fixed +- **Stale Explore results on folder switch** — switching folders (or re-entering + Explore) no longer briefly shows the previous folder's clusters/tags with no + loading indicator; the view now clears and shows a loading state until the new + folder's data arrives. - **Rating no longer scrambles search results** — rating or favoriting an image while viewing similar-image, region, semantic, tag, or album results no longer re-sorts the view back into the default order; the current result ordering is diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7c0d70d..fbd53a8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1178,38 +1178,39 @@ pub async fn get_tag_cloud( db: State<'_, DbState>, folder_id: Option, ) -> Result, String> { - let embeddings_with_ids = { - let conn = db.get().map_err(|e| e.to_string())?; - vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())? - }; - - let n = embeddings_with_ids.len(); - if n < 5 { - return Ok(vec![]); - } - - // Sort by ID for stable ordering; hash both IDs and embedding bytes so that - // replacing a file and regenerating its embedding invalidates the cache even - // when the set of image IDs hasn't changed. - let mut sorted_pairs: Vec<_> = embeddings_with_ids.iter().collect(); - sorted_pairs.sort_unstable_by_key(|(id, _)| *id); - let current_hash = { - use xxhash_rust::xxh3::Xxh3; - let mut hasher = Xxh3::new(); - for (id, embedding) in &sorted_pairs { - hasher.update(&id.to_le_bytes()); - for val in embedding.iter() { - hasher.update(&val.to_le_bytes()); - } - } - hasher.digest() - }; let folder_scope = match folder_id { Some(id) => format!("folder_{id}"), None => "all".to_string(), }; - // Try to return a valid SQLite cache + // Build a cheap cache key from a hash of the embedded image-ID set plus the + // global monotonic embedding revision. The ID-set hash catches membership + // changes (add/remove, or moving an image between folders — even when the + // count is unchanged); the revision catches an image being re-embedded in + // place (same IDs, new vector). Together they validate the cache WITHOUT + // reading and unpacking every embedding blob — which for a large library is + // hundreds of MB and was the real cause of the multi-second Explore stall + // even on a cache hit. + let (count, current_hash) = { + let conn = db.get().map_err(|e| e.to_string())?; + let (count, ids_hash) = + vector::embedding_ids_signature(&conn, folder_id).map_err(|e| e.to_string())?; + let revision = vector::get_embedding_revision(&conn).map_err(|e| e.to_string())?; + let hash = { + use xxhash_rust::xxh3::Xxh3; + let mut hasher = Xxh3::new(); + hasher.update(&ids_hash.to_le_bytes()); + hasher.update(revision.as_bytes()); + hasher.digest() + }; + (count, hash) + }; + + if count < 5 { + return Ok(vec![]); + } + + // Try to return a valid SQLite cache before loading any embeddings. { let conn = db.get().map_err(|e| e.to_string())?; if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash) @@ -1225,8 +1226,17 @@ pub async fn get_tag_cloud( } } - // Cache miss — run k-means + // Cache miss — now pay the cost of loading embeddings and running k-means. + let embeddings_with_ids = { + let conn = db.get().map_err(|e| e.to_string())?; + vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())? + }; + if embeddings_with_ids.len() < 5 { + return Ok(vec![]); + } + let ids: Vec = embeddings_with_ids.iter().map(|(id, _)| *id).collect(); + let n = embeddings_with_ids.len(); let points: Vec> = embeddings_with_ids .into_iter() .map(|(_, emb)| emb) @@ -1276,9 +1286,12 @@ pub async fn get_tag_cloud( }); } - // Persist to SQLite — ignore write errors (cache is best-effort) + // 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. if let Ok(json) = serde_json::to_string(&entries) { - let _ = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json); + if let Err(e) = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json) { + eprintln!("failed to persist tag-cloud cache for {folder_scope}: {e}"); + } } Ok(entries) diff --git a/src-tauri/src/vector.rs b/src-tauri/src/vector.rs index 7a7b842..cc2ea24 100644 --- a/src-tauri/src/vector.rs +++ b/src-tauri/src/vector.rs @@ -263,6 +263,44 @@ pub fn get_embedding_revision(conn: &Connection) -> Result { /// Returns all stored image embeddings with their image IDs, optionally filtered to one folder. /// Each entry is `(image_id, normalized_f32_embedding)`. +/// Returns `(count, hash)` over the stored embedding image IDs for the scope in a +/// single ordered pass, without loading any embedding blobs. The hash covers the +/// 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. +/// 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 +/// have to read and unpack hundreds of MB of embeddings just to validate freshness. +pub fn embedding_ids_signature(conn: &Connection, folder_id: Option) -> Result<(i64, u64)> { + use xxhash_rust::xxh3::Xxh3; + let mut hasher = Xxh3::new(); + let mut count: i64 = 0; + let mut hash_row = |id: i64| { + hasher.update(&id.to_le_bytes()); + count += 1; + }; + match folder_id { + Some(fid) => { + let mut stmt = conn.prepare( + "SELECT image_id FROM image_vec + WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1) + ORDER BY image_id", + )?; + let mut rows = stmt.query([fid])?; + while let Some(row) = rows.next()? { + hash_row(row.get(0)?); + } + } + None => { + let mut stmt = conn.prepare("SELECT image_id FROM image_vec ORDER BY image_id")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + hash_row(row.get(0)?); + } + } + } + Ok((count, hasher.digest())) +} + pub fn get_all_image_embeddings_with_ids( conn: &Connection, folder_id: Option, diff --git a/src/store.ts b/src/store.ts index 70b3e49..f8e0d52 100644 --- a/src/store.ts +++ b/src/store.ts @@ -381,7 +381,14 @@ interface GalleryState { exploreTagEntries: ExploreTagEntry[]; exploreTagLoading: boolean; exploreTagsFolderId: number | null | undefined; + // Cache-freshness key: the folder the loaded tags belong to. Set to undefined + // by content mutations (tag add/remove/rename/delete) to mark the cache dirty + // and force the next load to refetch. relatedTagsByKey: Record; + // The folder whose tags are actually on screen. Kept separate from the dirty + // marker above so a same-folder invalidation isn't mistaken for a folder switch + // (which would wipe the visible list and remount manager UI mid-refresh). + exploreTagsShownFolderId: number | null | undefined; indexingProgress: Record; mediaJobProgress: Record; cacheDir: string; @@ -877,6 +884,7 @@ export const useGalleryStore = create((set, get) => ({ indexingProgress: {}, mediaJobProgress: {}, cacheDir: "", + exploreTagsShownFolderId: undefined, captionModelStatus: null, captionModelPreparing: false, captionModelError: null, @@ -1404,7 +1412,15 @@ export const useGalleryStore = create((set, get) => ({ return; } const requestToken = ++tagCloudRequestToken; - set({ tagCloudLoading: true, tagCloudFolderId: selectedFolderId }); + // 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 + // them to avoid a flicker when the cache returns instantly. + const isFolderSwitch = tagCloudFolderId !== selectedFolderId; + set({ + tagCloudLoading: true, + tagCloudFolderId: selectedFolderId, + ...(isFolderSwitch ? { tagCloudEntries: [] } : {}), + }); try { const entries = await invoke("get_tag_cloud", { folderId: selectedFolderId, @@ -1425,7 +1441,20 @@ export const useGalleryStore = create((set, get) => ({ return; } const requestToken = ++exploreTagRequestToken; - set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId }); + // A real folder switch is decided by what's currently *shown*, not by the + // dirty marker — a same-folder invalidation nulls exploreTagsFolderId but + // leaves exploreTagsShownFolderId pointing at the displayed folder, so the + // visible list (and manager UI state) survives the refresh. On an actual + // switch, drop the previous folder's tags so the loading panel shows. + const { exploreTagsShownFolderId } = get(); + const isFolderSwitch = + exploreTagsShownFolderId !== undefined && exploreTagsShownFolderId !== selectedFolderId; + set({ + exploreTagLoading: true, + exploreTagsFolderId: selectedFolderId, + exploreTagsShownFolderId: selectedFolderId, + ...(isFolderSwitch ? { exploreTagEntries: [], relatedTagsByKey: {} } : {}), + }); try { const entries = await invoke("get_explore_tags", { params: { folder_id: selectedFolderId, limit: 180 },