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:
2026-04-06 16:54:03 +01:00
parent 6c3fd449ce
commit d0b41119c6
11 changed files with 560 additions and 343 deletions
+60 -19
View File
@@ -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::media::{probe_video_metadata, MediaTools};
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 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()
.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) {
Ok(embeddings) => jobs
.into_iter()
.zip(embeddings.into_iter().map(Ok))
.collect::<Vec<_>>(),
Err(batch_error) => {
eprintln!(
"Embedding batch fallback to per-image mode: {}",
batch_error
);
jobs.into_iter()
.zip(source_paths.into_iter())
.map(|(job, source_path)| (job, embedder.embed_image(&source_path)))
.collect::<Vec<_>>()
// Separate jobs with a valid source from those that fail early (e.g. video with no thumbnail).
let mut embeddable_indices: Vec<usize> = Vec::new();
let mut embeddable_paths: Vec<PathBuf> = Vec::new();
// image_id -> early error message for jobs that cannot be embedded yet
let mut pre_failed: HashMap<i64, String> = HashMap::new();
for (i, (job, result)) in jobs.iter().zip(source_results.into_iter()).enumerate() {
match result {
Ok(path) => {
embeddable_indices.push(i);
embeddable_paths.push(path);
}
Err(e) => {
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 write_started_at = Instant::now();
@@ -568,7 +600,16 @@ fn process_embedding_batch(
let tx = conn.transaction()?;
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 {
Ok(embedding) => {
vector::upsert_embedding(&tx, job.image_id, &embedding)?;