Surface failed embeddings and add filter for affected files
- Fix root cause: embedding_source_path() returns Result<PathBuf>, returning Err for videos without a thumbnail instead of silently falling back to the raw .mp4 path that CLIP cannot decode - indexer: split embedding batch into pre-failed (no source) and embeddable jobs; pre-failed are marked immediately without hitting the CLIP model - db: retry_failed_embedding_jobs skips videos still without a thumbnail so they no longer re-fail immediately on retry - Add get_failed_embedding_images command listing failed files + error per folder - Gallery: amber warning badge on tiles with embedding_status = 'failed' - BackgroundTasks: fetch and show failed filenames/errors in expanded panel - Toolbar: conditional 'Failed Embeddings' amber filter pill shown when any folder has embedding_failed > 0; filters at DB level via new embedding_failed_only param on get_images / count_images - TagCloud: replaced vocabulary/dictionary label system with representative image thumbnails per cluster; results cached in SQLite by image-id hash
This commit is contained in:
+113
-54
@@ -4,7 +4,7 @@ use crate::indexer;
|
|||||||
use crate::vector;
|
use crate::vector;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
pub type DbState = DbPool;
|
pub type DbState = DbPool;
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ pub struct GetImagesParams {
|
|||||||
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 embedding_failed_only: Option<bool>,
|
||||||
pub sort: Option<String>,
|
pub sort: Option<String>,
|
||||||
pub offset: Option<i64>,
|
pub offset: Option<i64>,
|
||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
@@ -124,8 +125,9 @@ 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 embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
|
||||||
|
|
||||||
let total = db::count_images(&conn, params.folder_id, search, media_kind, favorites_only)
|
let total = db::count_images(&conn, params.folder_id, search, media_kind, favorites_only, embedding_failed_only)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let images = db::get_images(
|
let images = db::get_images(
|
||||||
@@ -134,6 +136,7 @@ pub async fn get_images(
|
|||||||
search,
|
search,
|
||||||
media_kind,
|
media_kind,
|
||||||
favorites_only,
|
favorites_only,
|
||||||
|
embedding_failed_only,
|
||||||
sort,
|
sort,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
@@ -224,77 +227,109 @@ pub async fn semantic_search_images(
|
|||||||
Ok(images)
|
Ok(images)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct TagCloudEntry {
|
pub struct TagCloudEntry {
|
||||||
pub label: String,
|
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
|
pub representative_image_id: i64,
|
||||||
|
pub thumbnail_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clusters the library's image embeddings with k-means, then labels each cluster by
|
fn fnv_hash_ids(ids: &[i64]) -> u64 {
|
||||||
/// finding the closest word in the vocabulary. The vocabulary is loaded from
|
let mut h: u64 = 0xcbf29ce484222325;
|
||||||
/// `{app_data_dir}/vocabulary.txt` if present, otherwise from the bundled default.
|
for &id in ids {
|
||||||
/// Vocabulary embeddings are cached to disk — only recomputed when the vocabulary changes.
|
for b in id.to_le_bytes() {
|
||||||
|
h = h.wrapping_mul(0x100000001b3) ^ (b as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clusters the library's image embeddings with k-means and returns one representative
|
||||||
|
/// image per cluster — the member whose embedding is closest to its cluster centroid.
|
||||||
|
/// 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.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_tag_cloud(
|
pub async fn get_tag_cloud(
|
||||||
app: AppHandle,
|
|
||||||
db: State<'_, DbState>,
|
db: State<'_, DbState>,
|
||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
) -> Result<Vec<TagCloudEntry>, String> {
|
) -> Result<Vec<TagCloudEntry>, String> {
|
||||||
let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
let embeddings_with_ids = {
|
||||||
|
|
||||||
let image_embeddings = {
|
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
vector::get_all_image_embeddings(&conn, folder_id).map_err(|e| e.to_string())?
|
vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())?
|
||||||
};
|
};
|
||||||
|
|
||||||
let n = image_embeddings.len();
|
let n = embeddings_with_ids.len();
|
||||||
if n < 5 {
|
if n < 5 {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load vocabulary (custom file > bundled default)
|
// Compute a hash of the current embedded image IDs (sorted for stability)
|
||||||
let vocab = embedder::load_vocabulary(&app_data_dir);
|
let mut sorted_ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect();
|
||||||
|
sorted_ids.sort_unstable();
|
||||||
|
let current_hash = fnv_hash_ids(&sorted_ids);
|
||||||
|
|
||||||
// Embed vocabulary — disk-cached, only recomputes when vocabulary changes
|
let folder_scope = match folder_id {
|
||||||
let vocab_refs: Vec<&str> = vocab.iter().map(|s| s.as_str()).collect();
|
Some(id) => format!("folder_{}", id),
|
||||||
let vocab_embeddings = embedder::embed_vocab_cached(&vocab, &app_data_dir)
|
None => "all".to_string(),
|
||||||
.map_err(|e| e.to_string())?;
|
};
|
||||||
|
|
||||||
// Choose k proportional to library size, capped at 30
|
// Try to return a valid SQLite cache
|
||||||
let k = (n / 20).clamp(5, 30);
|
{
|
||||||
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
// Cluster image embeddings
|
if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash)
|
||||||
let (centroids, cluster_counts) = kmeans_cosine(&image_embeddings, k, 40);
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
// Label each cluster with the nearest vocabulary word
|
if let Ok(entries) = serde_json::from_str::<Vec<TagCloudEntry>>(&json) {
|
||||||
let mut entries: Vec<TagCloudEntry> = Vec::new();
|
return Ok(entries);
|
||||||
let mut used_labels = std::collections::HashSet::new();
|
}
|
||||||
|
|
||||||
let mut order: Vec<usize> = (0..k).collect();
|
|
||||||
order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a]));
|
|
||||||
|
|
||||||
for ci in order {
|
|
||||||
let count = cluster_counts[ci];
|
|
||||||
if count == 0 { continue; }
|
|
||||||
|
|
||||||
let centroid = ¢roids[ci];
|
|
||||||
let label = vocab_refs
|
|
||||||
.iter()
|
|
||||||
.zip(vocab_embeddings.iter())
|
|
||||||
.map(|(&word, emb)| {
|
|
||||||
let sim: f32 = centroid.iter().zip(emb.iter()).map(|(a, b)| a * b).sum();
|
|
||||||
(word, sim)
|
|
||||||
})
|
|
||||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
|
||||||
.map(|(word, _)| word.to_string())
|
|
||||||
.unwrap_or_else(|| "other".to_string());
|
|
||||||
|
|
||||||
if used_labels.insert(label.clone()) {
|
|
||||||
entries.push(TagCloudEntry { label, count });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.sort_unstable_by(|a, b| b.count.cmp(&a.count));
|
// Cache miss — run k-means
|
||||||
|
let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect();
|
||||||
|
let points: Vec<Vec<f32>> = embeddings_with_ids.into_iter().map(|(_, emb)| emb).collect();
|
||||||
|
|
||||||
|
let k = (n / 20).clamp(5, 30);
|
||||||
|
let (centroids, cluster_counts, assignments) = kmeans_cosine(&points, k, 40);
|
||||||
|
|
||||||
|
let mut entries: Vec<TagCloudEntry> = Vec::new();
|
||||||
|
let mut order: Vec<usize> = (0..k).collect();
|
||||||
|
order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a]));
|
||||||
|
|
||||||
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for ci in order {
|
||||||
|
let count = cluster_counts[ci];
|
||||||
|
if count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let centroid = ¢roids[ci];
|
||||||
|
let best_id = points
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(i, _)| assignments[*i] == ci)
|
||||||
|
.map(|(i, p)| (ids[i], dot(centroid, p)))
|
||||||
|
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let thumbnail_path = db::get_image_by_id(&conn, best_id)
|
||||||
|
.ok()
|
||||||
|
.and_then(|img| img.thumbnail_path);
|
||||||
|
|
||||||
|
entries.push(TagCloudEntry {
|
||||||
|
count,
|
||||||
|
representative_image_id: best_id,
|
||||||
|
thumbnail_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to SQLite — ignore write errors (cache is best-effort)
|
||||||
|
if let Ok(json) = serde_json::to_string(&entries) {
|
||||||
|
let _ = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +350,7 @@ 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<Vec<f32>>, Vec<usize>, Vec<usize>) {
|
||||||
let n = points.len();
|
let n = points.len();
|
||||||
let dim = points[0].len();
|
let dim = points[0].len();
|
||||||
|
|
||||||
@@ -375,7 +410,31 @@ fn kmeans_cosine(
|
|||||||
let mut counts = vec![0usize; k];
|
let mut counts = vec![0usize; k];
|
||||||
for &a in &assignments { counts[a] += 1; }
|
for &a in &assignments { counts[a] += 1; }
|
||||||
|
|
||||||
(centroids, counts)
|
(centroids, counts, assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct FailedEmbeddingItem {
|
||||||
|
pub image_id: i64,
|
||||||
|
pub filename: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_failed_embedding_images(
|
||||||
|
db: State<'_, DbState>,
|
||||||
|
folder_id: i64,
|
||||||
|
) -> Result<Vec<FailedEmbeddingItem>, String> {
|
||||||
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
|
let rows = db::get_failed_embedding_images(&conn, folder_id).map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(image_id, filename, error)| FailedEmbeddingItem {
|
||||||
|
image_id,
|
||||||
|
filename,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
+90
-5
@@ -172,6 +172,13 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tag_cloud_cache (
|
||||||
|
folder_scope TEXT PRIMARY KEY,
|
||||||
|
image_ids_hash INTEGER NOT NULL,
|
||||||
|
entries_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
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);
|
||||||
@@ -301,11 +308,16 @@ pub fn backfill_embedding_jobs(conn: &Connection) -> Result<usize> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result<usize> {
|
pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result<usize> {
|
||||||
|
// Only re-queue images that are actually embeddable right now.
|
||||||
|
// Videos without a thumbnail would just fail again immediately, so skip them —
|
||||||
|
// they will be re-queued automatically once their thumbnail is generated.
|
||||||
let updated = conn.execute(
|
let updated = conn.execute(
|
||||||
"INSERT INTO embedding_jobs (image_id, status, attempts, last_error, created_at, updated_at)
|
"INSERT INTO embedding_jobs (image_id, status, attempts, last_error, created_at, updated_at)
|
||||||
SELECT id, 'pending', 0, NULL, datetime('now'), datetime('now')
|
SELECT id, 'pending', 0, NULL, datetime('now'), datetime('now')
|
||||||
FROM images
|
FROM images
|
||||||
WHERE folder_id = ?1 AND embedding_status = 'failed'
|
WHERE folder_id = ?1
|
||||||
|
AND embedding_status = 'failed'
|
||||||
|
AND NOT (media_kind = 'video' AND thumbnail_path IS NULL)
|
||||||
ON CONFLICT(image_id) DO UPDATE SET
|
ON CONFLICT(image_id) DO UPDATE SET
|
||||||
status = 'pending',
|
status = 'pending',
|
||||||
last_error = NULL,
|
last_error = NULL,
|
||||||
@@ -315,7 +327,9 @@ pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result<
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE images
|
"UPDATE images
|
||||||
SET embedding_status = 'pending', embedding_error = NULL
|
SET embedding_status = 'pending', embedding_error = NULL
|
||||||
WHERE folder_id = ?1 AND embedding_status = 'failed'",
|
WHERE folder_id = ?1
|
||||||
|
AND embedding_status = 'failed'
|
||||||
|
AND NOT (media_kind = 'video' AND thumbnail_path IS NULL)",
|
||||||
[folder_id],
|
[folder_id],
|
||||||
)?;
|
)?;
|
||||||
Ok(updated)
|
Ok(updated)
|
||||||
@@ -802,6 +816,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,
|
||||||
|
embedding_failed_only: bool,
|
||||||
sort: &str,
|
sort: &str,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
@@ -820,6 +835,7 @@ pub fn get_images(
|
|||||||
|
|
||||||
let search_pattern = search.map(|value| format!("%{}%", value));
|
let search_pattern = search.map(|value| format!("%{}%", value));
|
||||||
let favorites_flag = i64::from(favorites_only);
|
let favorites_flag = i64::from(favorites_only);
|
||||||
|
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
||||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||||
@@ -829,8 +845,9 @@ 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')
|
||||||
ORDER BY {}
|
ORDER BY {}
|
||||||
LIMIT ?5 OFFSET ?6",
|
LIMIT ?6 OFFSET ?7",
|
||||||
order
|
order
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
@@ -840,6 +857,7 @@ pub fn get_images(
|
|||||||
search_pattern,
|
search_pattern,
|
||||||
media_kind,
|
media_kind,
|
||||||
favorites_flag,
|
favorites_flag,
|
||||||
|
embedding_failed_flag,
|
||||||
limit,
|
limit,
|
||||||
offset
|
offset
|
||||||
],
|
],
|
||||||
@@ -854,23 +872,52 @@ pub fn count_images(
|
|||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
media_kind: Option<&str>,
|
media_kind: Option<&str>,
|
||||||
favorites_only: bool,
|
favorites_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));
|
||||||
|
|
||||||
let favorites_flag = i64::from(favorites_only);
|
let favorites_flag = i64::from(favorites_only);
|
||||||
|
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||||
let count = conn.query_row(
|
let count = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM images
|
"SELECT COUNT(*) FROM images
|
||||||
WHERE (?1 IS NULL OR folder_id = ?1)
|
WHERE (?1 IS NULL OR folder_id = ?1)
|
||||||
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)
|
||||||
params![folder_id, search_pattern, media_kind, favorites_flag],
|
AND (?5 = 0 OR embedding_status = 'failed')",
|
||||||
|
params![
|
||||||
|
folder_id,
|
||||||
|
search_pattern,
|
||||||
|
media_kind,
|
||||||
|
favorites_flag,
|
||||||
|
embedding_failed_flag
|
||||||
|
],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_failed_embedding_images(
|
||||||
|
conn: &Connection,
|
||||||
|
folder_id: i64,
|
||||||
|
) -> Result<Vec<(i64, String, Option<String>)>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, filename, embedding_error
|
||||||
|
FROM images
|
||||||
|
WHERE folder_id = ?1 AND embedding_status = 'failed'
|
||||||
|
ORDER BY filename",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([folder_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_folder(conn: &Connection, folder_id: i64) -> Result<()> {
|
pub fn delete_folder(conn: &Connection, folder_id: i64) -> Result<()> {
|
||||||
conn.execute("DELETE FROM folders WHERE id = ?1", params![folder_id])?;
|
conn.execute("DELETE FROM folders WHERE id = ?1", params![folder_id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -904,6 +951,44 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns cached tag-cloud entries if the stored hash matches `current_hash`.
|
||||||
|
pub fn get_tag_cloud_cache(
|
||||||
|
conn: &Connection,
|
||||||
|
folder_scope: &str,
|
||||||
|
current_hash: u64,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let result: rusqlite::Result<(i64, String)> = conn.query_row(
|
||||||
|
"SELECT image_ids_hash, entries_json FROM tag_cloud_cache WHERE folder_scope = ?1",
|
||||||
|
params![folder_scope],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok((stored_hash, json)) if stored_hash as u64 == current_hash => Ok(Some(json)),
|
||||||
|
Ok(_) => Ok(None),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upserts the tag-cloud cache for the given scope.
|
||||||
|
pub fn set_tag_cloud_cache(
|
||||||
|
conn: &Connection,
|
||||||
|
folder_scope: &str,
|
||||||
|
image_ids_hash: u64,
|
||||||
|
entries_json: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tag_cloud_cache (folder_scope, image_ids_hash, entries_json, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, datetime('now'))
|
||||||
|
ON CONFLICT(folder_scope) DO UPDATE SET
|
||||||
|
image_ids_hash = excluded.image_ids_hash,
|
||||||
|
entries_json = excluded.entries_json,
|
||||||
|
created_at = excluded.created_at",
|
||||||
|
params![folder_scope, image_ids_hash as i64, entries_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([])?;
|
||||||
|
|||||||
+21
-158
@@ -1,173 +1,24 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use candle_core::{DType, Device, Tensor};
|
use candle_core::{DType, Device, Tensor};
|
||||||
use candle_nn::VarBuilder;
|
use candle_nn::VarBuilder;
|
||||||
use candle_transformers::models::clip::{self, ClipModel};
|
use candle_transformers::models::clip::{self, ClipModel};
|
||||||
use hf_hub::{api::sync::Api, Repo, RepoType};
|
use hf_hub::{api::sync::Api, Repo, RepoType};
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use tokenizers::Tokenizer;
|
use tokenizers::Tokenizer;
|
||||||
|
|
||||||
static TEXT_SEARCH_EMBEDDER: OnceLock<Mutex<Option<ClipImageEmbedder>>> = OnceLock::new();
|
static TEXT_SEARCH_EMBEDDER: OnceLock<Mutex<Option<ClipImageEmbedder>>> = OnceLock::new();
|
||||||
/// In-process cache so the disk file is only read/written once per session.
|
|
||||||
static VOCAB_EMBED_CACHE: OnceLock<Mutex<Option<Vec<Vec<f32>>>>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Default vocabulary bundled with the binary.
|
|
||||||
pub const DEFAULT_VOCABULARY: &str = include_str!("../data/vocabulary.txt");
|
|
||||||
|
|
||||||
/// Embed a text query using a lazily-initialized, cached CLIP embedder.
|
/// Embed a text query using a lazily-initialized, cached CLIP embedder.
|
||||||
pub fn embed_text_query(query: &str) -> Result<Vec<f32>> {
|
pub fn embed_text_query(query: &str) -> Result<Vec<f32>> {
|
||||||
with_text_embedder(|e| e.embed_text(query))
|
with_text_embedder(|e| e.embed_text(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse vocabulary text: strip comment lines (starting with `#`) and blank lines.
|
|
||||||
pub fn parse_vocabulary(text: &str) -> Vec<String> {
|
|
||||||
text.lines()
|
|
||||||
.map(|l| l.trim())
|
|
||||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
|
||||||
.map(|l| l.to_string())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the vocabulary from `{cache_dir}/vocabulary.txt` if it exists,
|
|
||||||
/// otherwise fall back to the binary-bundled default.
|
|
||||||
pub fn load_vocabulary(cache_dir: &Path) -> Vec<String> {
|
|
||||||
let custom_path = cache_dir.join("vocabulary.txt");
|
|
||||||
if custom_path.exists() {
|
|
||||||
if let Ok(text) = std::fs::read_to_string(&custom_path) {
|
|
||||||
let words = parse_vocabulary(&text);
|
|
||||||
if !words.is_empty() {
|
|
||||||
println!("Using custom vocabulary from {:?} ({} words)", custom_path, words.len());
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parse_vocabulary(DEFAULT_VOCABULARY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed the vocabulary, using a disk cache so CLIP is only called when the vocabulary
|
|
||||||
/// actually changes. Cache file: `{cache_dir}/vocab_embeddings.bin`.
|
|
||||||
///
|
|
||||||
/// Format: `[u64 hash][u32 n_words][u32 dim][(n_words × dim) × f32 LE]`
|
|
||||||
pub fn embed_vocab_cached(vocab: &[String], cache_dir: &Path) -> Result<Vec<Vec<f32>>> {
|
|
||||||
// In-process cache hit
|
|
||||||
{
|
|
||||||
let guard = VOCAB_EMBED_CACHE
|
|
||||||
.get_or_init(|| Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| anyhow::anyhow!("Vocab cache lock poisoned"))?;
|
|
||||||
if let Some(cached) = guard.as_ref() {
|
|
||||||
return Ok(cached.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let vocab_hash = fnv_hash(vocab);
|
|
||||||
let disk_path = cache_dir.join("vocab_embeddings.bin");
|
|
||||||
|
|
||||||
// Try loading from disk
|
|
||||||
if let Ok(embeddings) = load_disk_cache(&disk_path, vocab_hash) {
|
|
||||||
let mut guard = VOCAB_EMBED_CACHE
|
|
||||||
.get_or_init(|| Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| anyhow::anyhow!("Vocab cache lock poisoned"))?;
|
|
||||||
*guard = Some(embeddings.clone());
|
|
||||||
println!("Vocabulary embeddings loaded from disk cache ({} words).", embeddings.len());
|
|
||||||
return Ok(embeddings);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute embeddings
|
|
||||||
println!("Computing vocabulary embeddings ({} words) — this is cached after the first run.", vocab.len());
|
|
||||||
let prompts: Vec<String> = vocab.iter().map(|w| format!("a photo of {}", w)).collect();
|
|
||||||
let prompt_refs: Vec<&str> = prompts.iter().map(|s| s.as_str()).collect();
|
|
||||||
|
|
||||||
let embeddings = with_text_embedder(|e| {
|
|
||||||
let mut all = Vec::with_capacity(vocab.len());
|
|
||||||
for chunk in prompt_refs.chunks(64) {
|
|
||||||
all.extend(e.embed_texts_batch(chunk)?);
|
|
||||||
}
|
|
||||||
Ok(all)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Save to disk
|
|
||||||
if let Err(e) = save_disk_cache(&disk_path, vocab_hash, &embeddings) {
|
|
||||||
eprintln!("Warning: could not write vocab cache: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut guard = VOCAB_EMBED_CACHE
|
|
||||||
.get_or_init(|| Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| anyhow::anyhow!("Vocab cache lock poisoned"))?;
|
|
||||||
*guard = Some(embeddings.clone());
|
|
||||||
Ok(embeddings)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fnv_hash(words: &[String]) -> u64 {
|
|
||||||
let mut h: u64 = 0xcbf29ce484222325;
|
|
||||||
for w in words {
|
|
||||||
for b in w.bytes() {
|
|
||||||
h = h.wrapping_mul(0x100000001b3) ^ (b as u64);
|
|
||||||
}
|
|
||||||
h = h.wrapping_mul(0x100000001b3) ^ 0x0A; // newline separator
|
|
||||||
}
|
|
||||||
h
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_disk_cache(path: &Path, expected_hash: u64) -> Result<Vec<Vec<f32>>> {
|
|
||||||
let mut f = std::fs::File::open(path).context("no cache file")?;
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
f.read_to_end(&mut buf)?;
|
|
||||||
|
|
||||||
if buf.len() < 16 {
|
|
||||||
anyhow::bail!("cache too short");
|
|
||||||
}
|
|
||||||
|
|
||||||
let hash = u64::from_le_bytes(buf[0..8].try_into()?);
|
|
||||||
if hash != expected_hash {
|
|
||||||
anyhow::bail!("vocab hash mismatch — recomputing");
|
|
||||||
}
|
|
||||||
let n = u32::from_le_bytes(buf[8..12].try_into()?) as usize;
|
|
||||||
let dim = u32::from_le_bytes(buf[12..16].try_into()?) as usize;
|
|
||||||
|
|
||||||
let expected_len = 16 + n * dim * 4;
|
|
||||||
if buf.len() != expected_len {
|
|
||||||
anyhow::bail!("cache size mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut embeddings = Vec::with_capacity(n);
|
|
||||||
let data = &buf[16..];
|
|
||||||
for i in 0..n {
|
|
||||||
let start = i * dim * 4;
|
|
||||||
let emb: Vec<f32> = data[start..start + dim * 4]
|
|
||||||
.chunks_exact(4)
|
|
||||||
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
|
||||||
.collect();
|
|
||||||
embeddings.push(emb);
|
|
||||||
}
|
|
||||||
Ok(embeddings)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_disk_cache(path: &Path, hash: u64, embeddings: &[Vec<f32>]) -> Result<()> {
|
|
||||||
let n = embeddings.len();
|
|
||||||
let dim = embeddings.first().map(|e| e.len()).unwrap_or(0);
|
|
||||||
|
|
||||||
let mut buf = Vec::with_capacity(16 + n * dim * 4);
|
|
||||||
buf.extend_from_slice(&hash.to_le_bytes());
|
|
||||||
buf.extend_from_slice(&(n as u32).to_le_bytes());
|
|
||||||
buf.extend_from_slice(&(dim as u32).to_le_bytes());
|
|
||||||
for emb in embeddings {
|
|
||||||
for &v in emb {
|
|
||||||
buf.extend_from_slice(&v.to_le_bytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::write(path, buf)?;
|
|
||||||
println!("Vocabulary embeddings cached to disk ({n} words, {dim} dims).");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_text_embedder<T>(f: impl FnOnce(&ClipImageEmbedder) -> Result<T>) -> Result<T> {
|
fn with_text_embedder<T>(f: impl FnOnce(&ClipImageEmbedder) -> Result<T>) -> Result<T> {
|
||||||
let lock = TEXT_SEARCH_EMBEDDER.get_or_init(|| Mutex::new(None));
|
let lock = TEXT_SEARCH_EMBEDDER.get_or_init(|| Mutex::new(None));
|
||||||
let mut guard = lock.lock().map_err(|_| anyhow::anyhow!("Text embedder lock poisoned"))?;
|
let mut guard = lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Text embedder lock poisoned"))?;
|
||||||
if guard.is_none() {
|
if guard.is_none() {
|
||||||
println!("Initializing CLIP text embedder...");
|
println!("Initializing CLIP text embedder...");
|
||||||
*guard = Some(ClipImageEmbedder::new()?);
|
*guard = Some(ClipImageEmbedder::new()?);
|
||||||
@@ -319,16 +170,28 @@ fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
|
|||||||
Ok(Tensor::stack(&images, 0)?)
|
Ok(Tensor::stack(&images, 0)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the path that should be fed to the CLIP image embedder for a given media file.
|
||||||
|
///
|
||||||
|
/// For videos the thumbnail image is used (because CLIP only understands still images).
|
||||||
|
/// If a video has no thumbnail yet, an error is returned — the caller should mark the
|
||||||
|
/// embedding job as failed rather than trying to decode the raw video file.
|
||||||
pub fn embedding_source_path(
|
pub fn embedding_source_path(
|
||||||
path: &str,
|
path: &str,
|
||||||
thumbnail_path: Option<&str>,
|
thumbnail_path: Option<&str>,
|
||||||
media_kind: &str,
|
media_kind: &str,
|
||||||
) -> PathBuf {
|
) -> Result<PathBuf> {
|
||||||
if media_kind == "video" {
|
if media_kind == "video" {
|
||||||
thumbnail_path
|
match thumbnail_path {
|
||||||
.map(PathBuf::from)
|
Some(thumb) => Ok(PathBuf::from(thumb)),
|
||||||
.unwrap_or_else(|| PathBuf::from(path))
|
None => Err(anyhow::anyhow!(
|
||||||
|
"No thumbnail available yet for video '{}' — embedding deferred until thumbnail is generated",
|
||||||
|
std::path::Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy())
|
||||||
|
.unwrap_or_default()
|
||||||
|
)),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from(path)
|
Ok(PathBuf::from(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-19
@@ -1,4 +1,4 @@
|
|||||||
use crate::db::{self, DbPool, FolderJobProgress, ImageRecord, IndexedMediaEntry};
|
use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, IndexedMediaEntry};
|
||||||
use crate::embedder::{embedding_source_path, ClipImageEmbedder};
|
use crate::embedder::{embedding_source_path, ClipImageEmbedder};
|
||||||
use crate::media::{probe_video_metadata, MediaTools};
|
use crate::media::{probe_video_metadata, MediaTools};
|
||||||
use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile};
|
use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile};
|
||||||
@@ -539,27 +539,59 @@ fn process_embedding_batch(
|
|||||||
let embedder = embedder.as_ref().expect("embedder should be initialized");
|
let embedder = embedder.as_ref().expect("embedder should be initialized");
|
||||||
|
|
||||||
let infer_started_at = Instant::now();
|
let infer_started_at = Instant::now();
|
||||||
let source_paths = jobs
|
// Resolve the source path for each job. Videos without a thumbnail produce an Err
|
||||||
|
// here — those jobs are marked failed immediately without going to the embedder.
|
||||||
|
let source_results: Vec<Result<PathBuf>> = jobs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind))
|
.map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind))
|
||||||
.collect::<Vec<_>>();
|
.collect();
|
||||||
|
|
||||||
let results = match embedder.embed_images(&source_paths) {
|
// Separate jobs with a valid source from those that fail early (e.g. video with no thumbnail).
|
||||||
Ok(embeddings) => jobs
|
let mut embeddable_indices: Vec<usize> = Vec::new();
|
||||||
.into_iter()
|
let mut embeddable_paths: Vec<PathBuf> = Vec::new();
|
||||||
.zip(embeddings.into_iter().map(Ok))
|
// image_id -> early error message for jobs that cannot be embedded yet
|
||||||
.collect::<Vec<_>>(),
|
let mut pre_failed: HashMap<i64, String> = HashMap::new();
|
||||||
Err(batch_error) => {
|
|
||||||
eprintln!(
|
for (i, (job, result)) in jobs.iter().zip(source_results.into_iter()).enumerate() {
|
||||||
"Embedding batch fallback to per-image mode: {}",
|
match result {
|
||||||
batch_error
|
Ok(path) => {
|
||||||
);
|
embeddable_indices.push(i);
|
||||||
jobs.into_iter()
|
embeddable_paths.push(path);
|
||||||
.zip(source_paths.into_iter())
|
}
|
||||||
.map(|(job, source_path)| (job, embedder.embed_image(&source_path)))
|
Err(e) => {
|
||||||
.collect::<Vec<_>>()
|
pre_failed.insert(job.image_id, e.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
// Run CLIP only on the jobs that have a valid source image.
|
||||||
|
// image_id -> embedding result
|
||||||
|
let mut embed_results: HashMap<i64, Result<Vec<f32>>> = HashMap::new();
|
||||||
|
|
||||||
|
if !embeddable_indices.is_empty() {
|
||||||
|
let embeddable_jobs: Vec<&EmbeddingJob> =
|
||||||
|
embeddable_indices.iter().map(|&i| &jobs[i]).collect();
|
||||||
|
|
||||||
|
match embedder.embed_images(&embeddable_paths) {
|
||||||
|
Ok(embeddings) => {
|
||||||
|
for (job, embedding) in embeddable_jobs.iter().zip(embeddings.into_iter()) {
|
||||||
|
embed_results.insert(job.image_id, Ok(embedding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(batch_error) => {
|
||||||
|
eprintln!(
|
||||||
|
"Embedding batch fallback to per-image mode: {}",
|
||||||
|
batch_error
|
||||||
|
);
|
||||||
|
for (job, source_path) in embeddable_jobs
|
||||||
|
.into_iter()
|
||||||
|
.zip(embeddable_paths.into_iter())
|
||||||
|
{
|
||||||
|
embed_results.insert(job.image_id, embedder.embed_image(&source_path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let infer_elapsed = infer_started_at.elapsed();
|
let infer_elapsed = infer_started_at.elapsed();
|
||||||
|
|
||||||
let write_started_at = Instant::now();
|
let write_started_at = Instant::now();
|
||||||
@@ -568,7 +600,16 @@ fn process_embedding_batch(
|
|||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
let mut updated_images = Vec::new();
|
let mut updated_images = Vec::new();
|
||||||
|
|
||||||
for (job, embedding_result) in results {
|
for job in &jobs {
|
||||||
|
let embedding_result: Result<Vec<f32>> =
|
||||||
|
if let Some(err) = pre_failed.remove(&job.image_id) {
|
||||||
|
Err(anyhow::anyhow!("{}", err))
|
||||||
|
} else if let Some(r) = embed_results.remove(&job.image_id) {
|
||||||
|
r
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("no result for image {}", job.image_id))
|
||||||
|
};
|
||||||
|
|
||||||
match embedding_result {
|
match embedding_result {
|
||||||
Ok(embedding) => {
|
Ok(embedding) => {
|
||||||
vector::upsert_embedding(&tx, job.image_id, &embedding)?;
|
vector::upsert_embedding(&tx, job.image_id, &embedding)?;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ 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_failed_embedding_images,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+21
-11
@@ -78,32 +78,42 @@ pub fn find_similar_image_ids(conn: &Connection, image_id: i64, limit: usize) ->
|
|||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all stored image embeddings, optionally filtered to one folder.
|
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
|
||||||
/// Each embedding is returned as a normalized f32 vector.
|
/// Each entry is `(image_id, normalized_f32_embedding)`.
|
||||||
pub fn get_all_image_embeddings(conn: &Connection, folder_id: Option<i64>) -> Result<Vec<Vec<f32>>> {
|
pub fn get_all_image_embeddings_with_ids(
|
||||||
let packed_rows: Vec<Vec<u8>> = match folder_id {
|
conn: &Connection,
|
||||||
|
folder_id: Option<i64>,
|
||||||
|
) -> Result<Vec<(i64, Vec<f32>)>> {
|
||||||
|
let packed_rows: Vec<(i64, Vec<u8>)> = match folder_id {
|
||||||
Some(fid) => {
|
Some(fid) => {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT embedding FROM image_vec
|
"SELECT image_id, embedding FROM image_vec
|
||||||
WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1)",
|
WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1)",
|
||||||
)?;
|
)?;
|
||||||
let rows: Vec<Vec<u8>> = stmt
|
let rows: Vec<(i64, Vec<u8>)> = stmt
|
||||||
.query_map([fid], |row| row.get::<_, Vec<u8>>(0))?
|
.query_map([fid], |row| {
|
||||||
|
Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
|
||||||
|
})?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.collect();
|
||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let mut stmt = conn.prepare("SELECT embedding FROM image_vec")?;
|
let mut stmt = conn.prepare("SELECT image_id, embedding FROM image_vec")?;
|
||||||
let rows: Vec<Vec<u8>> = stmt
|
let rows: Vec<(i64, Vec<u8>)> = stmt
|
||||||
.query_map([], |row| row.get::<_, Vec<u8>>(0))?
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
|
||||||
|
})?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.collect();
|
||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(packed_rows.iter().map(|b| unpack_f32(b)).collect())
|
Ok(packed_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, b)| (id, unpack_f32(&b)))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unpack_f32(bytes: &[u8]) -> Vec<f32> {
|
fn unpack_f32(bytes: &[u8]) -> Vec<f32> {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ interface Task {
|
|||||||
snapshot: string;
|
snapshot: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FailedEmbeddingItem {
|
||||||
|
image_id: number;
|
||||||
|
filename: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function BackgroundTasks() {
|
export function BackgroundTasks() {
|
||||||
const folders = useGalleryStore((state) => state.folders);
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||||
@@ -41,6 +47,7 @@ export function BackgroundTasks() {
|
|||||||
metadata: false,
|
metadata: false,
|
||||||
embedding: false,
|
embedding: false,
|
||||||
});
|
});
|
||||||
|
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>(
|
invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>(
|
||||||
@@ -54,6 +61,28 @@ export function BackgroundTasks() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
|
||||||
|
const failedCounts = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]),
|
||||||
|
),
|
||||||
|
[mediaJobProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded) return;
|
||||||
|
for (const [folderId, count] of Object.entries(failedCounts)) {
|
||||||
|
if (count > 0) {
|
||||||
|
invoke<FailedEmbeddingItem[]>("get_failed_embedding_images", {
|
||||||
|
folderId: Number(folderId),
|
||||||
|
})
|
||||||
|
.then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items })))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [expanded, failedCounts]);
|
||||||
|
|
||||||
const toggleWorker = (worker: WorkerKey) => {
|
const toggleWorker = (worker: WorkerKey) => {
|
||||||
const next = !paused[worker];
|
const next = !paused[worker];
|
||||||
setPaused((prev) => ({ ...prev, [worker]: next }));
|
setPaused((prev) => ({ ...prev, [worker]: next }));
|
||||||
@@ -374,6 +403,26 @@ export function BackgroundTasks() {
|
|||||||
{task.currentFile}
|
{task.currentFile}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Failed embedding file list */}
|
||||||
|
{taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && (
|
||||||
|
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
|
||||||
|
{failedItems[task.id].map((item) => (
|
||||||
|
<div key={item.image_id} className="flex items-start gap-1.5 min-w-0">
|
||||||
|
<svg className="h-2.5 w-2.5 text-amber-500 shrink-0 mt-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
|
||||||
|
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] text-amber-400/80 truncate font-medium">{item.filename}</p>
|
||||||
|
{item.error && (
|
||||||
|
<p className="text-[9px] text-gray-600 truncate">{item.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -188,6 +188,21 @@ function ImageTile({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Embedding failed badge — top-left */}
|
||||||
|
{image.embedding_status === "failed" && (
|
||||||
|
<div
|
||||||
|
className="absolute top-2 left-2 pointer-events-none"
|
||||||
|
title={image.embedding_error ?? "Embedding failed"}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
|
||||||
|
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
|
||||||
|
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hover overlay — slides up from bottom */}
|
{/* Hover overlay — slides up from bottom */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
||||||
|
|
||||||
|
|||||||
+122
-73
@@ -1,17 +1,18 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore, TagCloudEntry } from "../store";
|
import { useGalleryStore, TagCloudEntry } from "../store";
|
||||||
|
|
||||||
// Accent color pairs: [rest, hover, glow]
|
// Accent glow colours for the hover ring — cycled by index
|
||||||
const ACCENTS: [string, string, string][] = [
|
const GLOWS: string[] = [
|
||||||
["rgba(96,165,250,0.5)", "#93c5fd", "rgba(59,130,246,0.3)"],
|
"rgba(59,130,246,0.5)",
|
||||||
["rgba(192,132,252,0.5)", "#d8b4fe", "rgba(168,85,247,0.3)"],
|
"rgba(168,85,247,0.5)",
|
||||||
["rgba(52,211,153,0.5)", "#6ee7b7", "rgba(16,185,129,0.3)"],
|
"rgba(16,185,129,0.5)",
|
||||||
["rgba(251,191,36,0.5)", "#fcd34d", "rgba(245,158,11,0.3)"],
|
"rgba(245,158,11,0.5)",
|
||||||
["rgba(249,168,212,0.5)", "#fbcfe8", "rgba(236,72,153,0.3)"],
|
"rgba(236,72,153,0.5)",
|
||||||
["rgba(103,232,249,0.5)", "#a5f3fc", "rgba(6,182,212,0.3)"],
|
"rgba(6,182,212,0.5)",
|
||||||
["rgba(253,186,116,0.5)", "#fed7aa", "rgba(249,115,22,0.3)"],
|
"rgba(249,115,22,0.5)",
|
||||||
["rgba(167,243,208,0.5)", "#bbf7d0", "rgba(34,197,94,0.3)"],
|
"rgba(34,197,94,0.5)",
|
||||||
];
|
];
|
||||||
|
|
||||||
function pseudoRandom(seed: number): number {
|
function pseudoRandom(seed: number): number {
|
||||||
@@ -19,24 +20,17 @@ function pseudoRandom(seed: number): number {
|
|||||||
return x - Math.floor(x);
|
return x - Math.floor(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWeight(count: number, maxCount: number): 1 | 2 | 3 | 4 | 5 {
|
// Map cluster size to a tile size bucket (px)
|
||||||
if (maxCount === 0) return 1;
|
function getTileSize(count: number, maxCount: number): number {
|
||||||
|
if (maxCount === 0) return 72;
|
||||||
const ratio = count / maxCount;
|
const ratio = count / maxCount;
|
||||||
if (ratio > 0.75) return 5;
|
if (ratio > 0.75) return 160;
|
||||||
if (ratio > 0.45) return 4;
|
if (ratio > 0.45) return 128;
|
||||||
if (ratio > 0.22) return 3;
|
if (ratio > 0.22) return 104;
|
||||||
if (ratio > 0.08) return 2;
|
if (ratio > 0.08) return 88;
|
||||||
return 1;
|
return 72;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT_SIZE: Record<1 | 2 | 3 | 4 | 5, number> = { 1: 11, 2: 14, 3: 19, 4: 30, 5: 46 };
|
|
||||||
const FONT_WEIGHT: Record<1 | 2 | 3 | 4 | 5, number> = { 1: 400, 2: 400, 3: 500, 4: 700, 5: 800 };
|
|
||||||
const LETTER_SPACING: Record<1 | 2 | 3 | 4 | 5, number> = { 1: 0.8, 2: 0.4, 3: 0, 4: -0.5, 5: -1.5 };
|
|
||||||
const PADDING: Record<1 | 2 | 3 | 4 | 5, string> = {
|
|
||||||
1: "3px 8px", 2: "4px 10px", 3: "5px 13px", 4: "8px 18px", 5: "10px 22px",
|
|
||||||
};
|
|
||||||
const MAX_ROTATION: Record<1 | 2 | 3 | 4 | 5, number> = { 1: 14, 2: 11, 3: 7, 4: 3, 5: 0 };
|
|
||||||
|
|
||||||
function TagButton({
|
function TagButton({
|
||||||
entry,
|
entry,
|
||||||
index,
|
index,
|
||||||
@@ -46,66 +40,108 @@ function TagButton({
|
|||||||
entry: TagCloudEntry;
|
entry: TagCloudEntry;
|
||||||
index: number;
|
index: number;
|
||||||
maxCount: number;
|
maxCount: number;
|
||||||
onSearch: (label: string) => void;
|
onSearch: (imageId: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const weight = getWeight(entry.count, maxCount);
|
const size = getTileSize(entry.count, maxCount);
|
||||||
const accentIndex = (index * 3 + weight) % ACCENTS.length;
|
const glow = GLOWS[index % GLOWS.length];
|
||||||
const [restColor, hoverColor, glowColor] = ACCENTS[accentIndex];
|
|
||||||
const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * MAX_ROTATION[weight];
|
|
||||||
|
|
||||||
const mt = Math.floor(pseudoRandom(index * 3) * 14) + 3;
|
// Small random rotation for organic feel — larger tiles stay flatter
|
||||||
const mr = Math.floor(pseudoRandom(index * 5) * 20) + 6;
|
const maxRot = size >= 128 ? 0 : size >= 104 ? 3 : size >= 88 ? 6 : 10;
|
||||||
const mb = Math.floor(pseudoRandom(index * 11) * 14) + 3;
|
const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * maxRot;
|
||||||
const ml = Math.floor(pseudoRandom(index * 13) * 20) + 6;
|
|
||||||
|
const mt = Math.floor(pseudoRandom(index * 3) * 10) + 4;
|
||||||
|
const mr = Math.floor(pseudoRandom(index * 5) * 12) + 4;
|
||||||
|
const mb = Math.floor(pseudoRandom(index * 11) * 10) + 4;
|
||||||
|
const ml = Math.floor(pseudoRandom(index * 13) * 12) + 4;
|
||||||
|
|
||||||
|
const src = entry.thumbnail_path ? convertFileSrc(entry.thumbnail_path) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
initial={{ opacity: 0, scale: 0.4, rotate: rotation * 2 }}
|
initial={{ opacity: 0, scale: 0.5, rotate: rotation * 2 }}
|
||||||
animate={{ opacity: 1, scale: 1, rotate: rotation }}
|
animate={{ opacity: 1, scale: 1, rotate: rotation }}
|
||||||
transition={{
|
transition={{
|
||||||
delay: index * 0.014,
|
delay: index * 0.025,
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 180,
|
stiffness: 200,
|
||||||
damping: 16,
|
damping: 18,
|
||||||
}}
|
}}
|
||||||
whileHover={{
|
whileHover={{
|
||||||
scale: 1.2,
|
scale: 1.12,
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
transition: { type: "spring", stiffness: 400, damping: 22 },
|
transition: { type: "spring", stiffness: 400, damping: 22 },
|
||||||
}}
|
}}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.92 }}
|
||||||
|
onClick={() => onSearch(entry.representative_image_id)}
|
||||||
|
title={`${entry.count} similar ${entry.count === 1 ? "photo" : "photos"}`}
|
||||||
style={{
|
style={{
|
||||||
fontSize: FONT_SIZE[weight],
|
width: size,
|
||||||
fontWeight: FONT_WEIGHT[weight],
|
height: size,
|
||||||
letterSpacing: LETTER_SPACING[weight],
|
|
||||||
padding: PADDING[weight],
|
|
||||||
margin: `${mt}px ${mr}px ${mb}px ${ml}px`,
|
margin: `${mt}px ${mr}px ${mb}px ${ml}px`,
|
||||||
color: restColor,
|
borderRadius: 12,
|
||||||
borderRadius: 10,
|
border: "2px solid rgba(255,255,255,0.08)",
|
||||||
border: "1px solid transparent",
|
background: "rgba(255,255,255,0.04)",
|
||||||
background: "transparent",
|
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
userSelect: "none",
|
padding: 0,
|
||||||
transition: "color 0.15s, text-shadow 0.15s, background 0.15s, border-color 0.15s",
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: "none",
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
el.style.color = hoverColor;
|
el.style.borderColor = glow;
|
||||||
el.style.textShadow = `0 0 20px ${glowColor}, 0 0 40px ${glowColor}`;
|
el.style.boxShadow = `0 0 16px ${glow}, 0 0 32px ${glow.replace("0.5", "0.25")}`;
|
||||||
el.style.background = glowColor.replace("0.3", "0.1");
|
|
||||||
el.style.borderColor = glowColor.replace("0.3", "0.25");
|
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
el.style.color = restColor;
|
el.style.borderColor = "rgba(255,255,255,0.08)";
|
||||||
el.style.textShadow = "none";
|
el.style.boxShadow = "none";
|
||||||
el.style.background = "transparent";
|
|
||||||
el.style.borderColor = "transparent";
|
|
||||||
}}
|
}}
|
||||||
onClick={() => onSearch(entry.label)}
|
|
||||||
title={`${entry.count} matching ${entry.count === 1 ? "photo" : "photos"}`}
|
|
||||||
>
|
>
|
||||||
{entry.label}
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Fallback placeholder when no thumbnail exists yet
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "rgba(255,255,255,0.06)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Count badge — bottom-right corner */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 5,
|
||||||
|
right: 5,
|
||||||
|
background: "rgba(0,0,0,0.6)",
|
||||||
|
color: "rgba(255,255,255,0.85)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: "3px 6px",
|
||||||
|
borderRadius: 6,
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.count}
|
||||||
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,9 +157,10 @@ export function TagCloud() {
|
|||||||
void loadTagCloud();
|
void loadTagCloud();
|
||||||
}, [selectedFolderId]);
|
}, [selectedFolderId]);
|
||||||
|
|
||||||
const maxCount = tagCloudEntries.length > 0
|
const maxCount =
|
||||||
? Math.max(...tagCloudEntries.map((e) => e.count))
|
tagCloudEntries.length > 0
|
||||||
: 1;
|
? Math.max(...tagCloudEntries.map((e) => e.count))
|
||||||
|
: 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center min-h-0 overflow-y-auto">
|
<div className="flex-1 flex flex-col items-center min-h-0 overflow-y-auto">
|
||||||
@@ -138,7 +175,7 @@ export function TagCloud() {
|
|||||||
Explore your library
|
Explore your library
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[13px] text-white/25">
|
<p className="text-[13px] text-white/25">
|
||||||
Topics found in your photos — sized by how many match
|
Visual clusters from your photos — sized by how many match
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -152,10 +189,22 @@ export function TagCloud() {
|
|||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeOpacity="0.2" />
|
<circle
|
||||||
<path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
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>
|
</motion.svg>
|
||||||
<p className="text-[12px] text-white/20">Analysing your library with CLIP…</p>
|
<p className="text-[12px] text-white/20">Clustering your library…</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -163,18 +212,18 @@ export function TagCloud() {
|
|||||||
{!tagCloudLoading && tagCloudEntries.length === 0 && (
|
{!tagCloudLoading && tagCloudEntries.length === 0 && (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<p className="text-[13px] text-white/25 text-center max-w-xs leading-relaxed">
|
<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,
|
No embeddings yet. Add a folder and wait for the embedding worker to
|
||||||
then come back here.
|
finish, then come back here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cloud */}
|
{/* Cluster grid */}
|
||||||
{!tagCloudLoading && tagCloudEntries.length > 0 && (
|
{!tagCloudLoading && tagCloudEntries.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center px-12 pb-16 max-w-5xl w-full">
|
<div className="flex flex-wrap justify-center px-12 pb-16 max-w-5xl w-full">
|
||||||
{tagCloudEntries.map((entry, index) => (
|
{tagCloudEntries.map((entry, index) => (
|
||||||
<TagButton
|
<TagButton
|
||||||
key={entry.label}
|
key={entry.representative_image_id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
index={index}
|
index={index}
|
||||||
maxCount={maxCount}
|
maxCount={maxCount}
|
||||||
@@ -186,7 +235,7 @@ export function TagCloud() {
|
|||||||
|
|
||||||
{!tagCloudLoading && tagCloudEntries.length > 0 && (
|
{!tagCloudLoading && tagCloudEntries.length > 0 && (
|
||||||
<p className="shrink-0 pb-8 text-[11px] text-white/12 text-center">
|
<p className="shrink-0 pb-8 text-[11px] text-white/12 text-center">
|
||||||
Ranked by visual similarity · CLIP ViT-B/32
|
Grouped by visual similarity · CLIP ViT-B/32
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,16 +91,22 @@ function FilterPill({
|
|||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
|
variant = "default",
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
variant?: "default" | "amber";
|
||||||
}) {
|
}) {
|
||||||
|
const activeClass =
|
||||||
|
variant === "amber"
|
||||||
|
? "bg-amber-500/15 text-amber-300 border border-amber-500/30"
|
||||||
|
: "bg-white/10 text-white";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||||
active
|
active
|
||||||
? "bg-white/10 text-white"
|
? activeClass
|
||||||
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -127,12 +133,21 @@ 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 failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
|
||||||
|
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
|
||||||
|
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 [searchValue, setSearchValue] = useState(search);
|
const [searchValue, setSearchValue] = useState(search);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = 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.
|
||||||
|
// Prevents the debounce effect from dispatching setSearch on initial mount
|
||||||
|
// when searchValue === search (which would wipe a loadSimilarImages result).
|
||||||
|
const userHasTyped = useRef(false);
|
||||||
|
|
||||||
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
||||||
const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
|
const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
|
||||||
@@ -153,6 +168,7 @@ export function Toolbar() {
|
|||||||
}, [mediaFilter, sort, setSort]);
|
}, [mediaFilter, sort, setSort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
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(searchValue); }, 200);
|
||||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
@@ -233,7 +249,10 @@ export function Toolbar() {
|
|||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(event) => setSearchValue(event.target.value)}
|
onChange={(event) => {
|
||||||
|
userHasTyped.current = true;
|
||||||
|
setSearchValue(event.target.value);
|
||||||
|
}}
|
||||||
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."}
|
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -283,10 +302,18 @@ 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} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); }} />
|
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||||
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); }} />
|
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||||
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); }} />
|
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||||
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => setFavoritesOnly(!favoritesOnly)} />
|
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
|
||||||
|
{hasAnyFailedEmbeddings ? (
|
||||||
|
<FilterPill
|
||||||
|
label="Failed Embeddings"
|
||||||
|
active={failedEmbeddingsOnly}
|
||||||
|
variant="amber"
|
||||||
|
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+35
-17
@@ -75,8 +75,9 @@ export interface ThumbnailBatch {
|
|||||||
export type ActiveView = "gallery" | "explore";
|
export type ActiveView = "gallery" | "explore";
|
||||||
|
|
||||||
export interface TagCloudEntry {
|
export interface TagCloudEntry {
|
||||||
label: string;
|
|
||||||
count: number;
|
count: number;
|
||||||
|
representative_image_id: number;
|
||||||
|
thumbnail_path: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortOrder =
|
export type SortOrder =
|
||||||
@@ -101,12 +102,14 @@ interface GalleryState {
|
|||||||
sort: SortOrder;
|
sort: SortOrder;
|
||||||
mediaFilter: MediaFilter;
|
mediaFilter: MediaFilter;
|
||||||
favoritesOnly: boolean;
|
favoritesOnly: boolean;
|
||||||
|
failedEmbeddingsOnly: boolean;
|
||||||
zoomPreset: ZoomPreset;
|
zoomPreset: ZoomPreset;
|
||||||
selectedImage: ImageRecord | null;
|
selectedImage: ImageRecord | null;
|
||||||
collectionTitle: string | null;
|
collectionTitle: string | null;
|
||||||
activeView: ActiveView;
|
activeView: ActiveView;
|
||||||
tagCloudEntries: TagCloudEntry[];
|
tagCloudEntries: TagCloudEntry[];
|
||||||
tagCloudLoading: boolean;
|
tagCloudLoading: boolean;
|
||||||
|
tagCloudFolderId: number | null | undefined; // undefined = never loaded
|
||||||
indexingProgress: Record<number, IndexProgress>;
|
indexingProgress: Record<number, IndexProgress>;
|
||||||
mediaJobProgress: Record<number, FolderJobProgress>;
|
mediaJobProgress: Record<number, FolderJobProgress>;
|
||||||
cacheDir: string;
|
cacheDir: string;
|
||||||
@@ -126,12 +129,13 @@ 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;
|
||||||
|
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;
|
||||||
loadTagCloud: () => Promise<void>;
|
loadTagCloud: () => Promise<void>;
|
||||||
searchByTag: (tag: string) => void;
|
searchByTag: (imageId: number) => void;
|
||||||
loadSimilarImages: (imageId: number) => Promise<void>;
|
loadSimilarImages: (imageId: number) => Promise<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>;
|
||||||
@@ -161,12 +165,14 @@ function matchesFilters(
|
|||||||
selectedFolderId: number | null,
|
selectedFolderId: number | null,
|
||||||
mediaFilter: MediaFilter,
|
mediaFilter: MediaFilter,
|
||||||
favoritesOnly: boolean,
|
favoritesOnly: 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;
|
||||||
return matchesFolder && matchesMedia && matchesFavorite && matchesSearch(image, search);
|
const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed";
|
||||||
|
return matchesFolder && matchesMedia && matchesFavorite && matchesFailedEmbedding && matchesSearch(image, search);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareNullableNumber(a: number | null, b: number | null): number {
|
function compareNullableNumber(a: number | null, b: number | null): number {
|
||||||
@@ -265,12 +271,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
sort: "date_desc",
|
sort: "date_desc",
|
||||||
mediaFilter: "all",
|
mediaFilter: "all",
|
||||||
favoritesOnly: false,
|
favoritesOnly: false,
|
||||||
|
failedEmbeddingsOnly: false,
|
||||||
zoomPreset: "comfortable",
|
zoomPreset: "comfortable",
|
||||||
selectedImage: null,
|
selectedImage: null,
|
||||||
collectionTitle: null,
|
collectionTitle: null,
|
||||||
activeView: "gallery",
|
activeView: "gallery",
|
||||||
tagCloudEntries: [],
|
tagCloudEntries: [],
|
||||||
tagCloudLoading: false,
|
tagCloudLoading: false,
|
||||||
|
tagCloudFolderId: undefined,
|
||||||
indexingProgress: {},
|
indexingProgress: {},
|
||||||
mediaJobProgress: {},
|
mediaJobProgress: {},
|
||||||
cacheDir: "",
|
cacheDir: "",
|
||||||
@@ -301,6 +309,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get();
|
const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get();
|
||||||
await loadFolders();
|
await loadFolders();
|
||||||
await loadBackgroundJobProgress();
|
await loadBackgroundJobProgress();
|
||||||
|
// Invalidate tag cloud cache since library content changed
|
||||||
|
set({ tagCloudFolderId: undefined, tagCloudEntries: [] });
|
||||||
if (selectedFolderId === folderId) {
|
if (selectedFolderId === folderId) {
|
||||||
set({ selectedFolderId: null });
|
set({ selectedFolderId: null });
|
||||||
await loadImages(true);
|
await loadImages(true);
|
||||||
@@ -311,16 +321,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
const { loadFolders, loadBackgroundJobProgress } = get();
|
const { loadFolders, loadBackgroundJobProgress } = get();
|
||||||
await invoke("reindex_folder", { folderId });
|
await invoke("reindex_folder", { folderId });
|
||||||
await loadFolders();
|
await loadFolders();
|
||||||
|
// Invalidate tag cloud cache since embeddings will be regenerated
|
||||||
|
set({ tagCloudFolderId: undefined, tagCloudEntries: [] });
|
||||||
await loadBackgroundJobProgress();
|
await loadBackgroundJobProgress();
|
||||||
},
|
},
|
||||||
|
|
||||||
selectFolder: (folderId) => {
|
selectFolder: (folderId) => {
|
||||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, activeView: "gallery" });
|
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, activeView: "gallery", failedEmbeddingsOnly: false });
|
||||||
void get().loadImages(true);
|
void get().loadImages(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
loadImages: async (reset = false) => {
|
loadImages: async (reset = false) => {
|
||||||
const { selectedFolderId, search, searchMode, sort, loadedCount, mediaFilter, favoritesOnly } = get();
|
const { selectedFolderId, search, searchMode, sort, loadedCount, mediaFilter, favoritesOnly, failedEmbeddingsOnly } = get();
|
||||||
set({ loadingImages: true });
|
set({ loadingImages: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -357,6 +369,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
search: search || null,
|
search: search || null,
|
||||||
media_kind: mediaFilter === "all" ? null : mediaFilter,
|
media_kind: mediaFilter === "all" ? null : mediaFilter,
|
||||||
favorites_only: favoritesOnly,
|
favorites_only: favoritesOnly,
|
||||||
|
embedding_failed_only: failedEmbeddingsOnly,
|
||||||
sort,
|
sort,
|
||||||
offset,
|
offset,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
@@ -417,6 +430,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
void get().loadImages(true);
|
void get().loadImages(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
|
||||||
|
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null });
|
||||||
|
void get().loadImages(true);
|
||||||
|
},
|
||||||
|
|
||||||
setZoomPreset: (zoomPreset) => set({ zoomPreset }),
|
setZoomPreset: (zoomPreset) => set({ zoomPreset }),
|
||||||
|
|
||||||
openImage: (image) => set({ selectedImage: image }),
|
openImage: (image) => set({ selectedImage: image }),
|
||||||
@@ -425,8 +443,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
setView: (activeView) => set({ activeView }),
|
setView: (activeView) => set({ activeView }),
|
||||||
|
|
||||||
loadTagCloud: async () => {
|
loadTagCloud: async () => {
|
||||||
const { selectedFolderId } = get();
|
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
|
||||||
set({ tagCloudLoading: true });
|
// Skip if already loaded for this folder and not currently loading
|
||||||
|
if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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,
|
||||||
@@ -438,19 +460,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
searchByTag: (tag) => {
|
searchByTag: (imageId) => {
|
||||||
set({
|
set({ activeView: "gallery", images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images" });
|
||||||
activeView: "gallery",
|
void get().loadSimilarImages(imageId);
|
||||||
search: tag,
|
|
||||||
searchMode: "semantic",
|
|
||||||
images: [],
|
|
||||||
loadedCount: 0,
|
|
||||||
collectionTitle: `Exploring: ${tag}`,
|
|
||||||
});
|
|
||||||
void get().loadImages(true);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
loadSimilarImages: async (imageId) => {
|
loadSimilarImages: async (imageId) => {
|
||||||
|
set({ images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images" });
|
||||||
const images = await invoke<ImageRecord[]>("find_similar_images", {
|
const images = await invoke<ImageRecord[]>("find_similar_images", {
|
||||||
params: { image_id: imageId, limit: PAGE_SIZE },
|
params: { image_id: imageId, limit: PAGE_SIZE },
|
||||||
});
|
});
|
||||||
@@ -530,6 +546,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
state.selectedFolderId,
|
state.selectedFolderId,
|
||||||
state.mediaFilter,
|
state.mediaFilter,
|
||||||
state.favoritesOnly,
|
state.favoritesOnly,
|
||||||
|
state.failedEmbeddingsOnly,
|
||||||
state.search,
|
state.search,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -559,6 +576,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
state.selectedFolderId,
|
state.selectedFolderId,
|
||||||
state.mediaFilter,
|
state.mediaFilter,
|
||||||
state.favoritesOnly,
|
state.favoritesOnly,
|
||||||
|
state.failedEmbeddingsOnly,
|
||||||
state.search,
|
state.search,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user