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
+35 -17
View File
@@ -75,8 +75,9 @@ export interface ThumbnailBatch {
export type ActiveView = "gallery" | "explore";
export interface TagCloudEntry {
label: string;
count: number;
representative_image_id: number;
thumbnail_path: string | null;
}
export type SortOrder =
@@ -101,12 +102,14 @@ interface GalleryState {
sort: SortOrder;
mediaFilter: MediaFilter;
favoritesOnly: boolean;
failedEmbeddingsOnly: boolean;
zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null;
collectionTitle: string | null;
activeView: ActiveView;
tagCloudEntries: TagCloudEntry[];
tagCloudLoading: boolean;
tagCloudFolderId: number | null | undefined; // undefined = never loaded
indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>;
cacheDir: string;
@@ -126,12 +129,13 @@ interface GalleryState {
setSort: (sort: SortOrder) => void;
setMediaFilter: (filter: MediaFilter) => void;
setFavoritesOnly: (favoritesOnly: boolean) => void;
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void;
closeImage: () => void;
setView: (view: ActiveView) => void;
loadTagCloud: () => Promise<void>;
searchByTag: (tag: string) => void;
searchByTag: (imageId: number) => void;
loadSimilarImages: (imageId: number) => Promise<void>;
retryFailedEmbeddings: (folderId: number) => Promise<void>;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
@@ -161,12 +165,14 @@ function matchesFilters(
selectedFolderId: number | null,
mediaFilter: MediaFilter,
favoritesOnly: boolean,
failedEmbeddingsOnly: boolean,
search: string,
): boolean {
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter;
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 {
@@ -265,12 +271,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
sort: "date_desc",
mediaFilter: "all",
favoritesOnly: false,
failedEmbeddingsOnly: false,
zoomPreset: "comfortable",
selectedImage: null,
collectionTitle: null,
activeView: "gallery",
tagCloudEntries: [],
tagCloudLoading: false,
tagCloudFolderId: undefined,
indexingProgress: {},
mediaJobProgress: {},
cacheDir: "",
@@ -301,6 +309,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get();
await loadFolders();
await loadBackgroundJobProgress();
// Invalidate tag cloud cache since library content changed
set({ tagCloudFolderId: undefined, tagCloudEntries: [] });
if (selectedFolderId === folderId) {
set({ selectedFolderId: null });
await loadImages(true);
@@ -311,16 +321,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const { loadFolders, loadBackgroundJobProgress } = get();
await invoke("reindex_folder", { folderId });
await loadFolders();
// Invalidate tag cloud cache since embeddings will be regenerated
set({ tagCloudFolderId: undefined, tagCloudEntries: [] });
await loadBackgroundJobProgress();
},
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);
},
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 });
try {
@@ -357,6 +369,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
search: search || null,
media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly,
embedding_failed_only: failedEmbeddingsOnly,
sort,
offset,
limit: PAGE_SIZE,
@@ -417,6 +430,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true);
},
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null });
void get().loadImages(true);
},
setZoomPreset: (zoomPreset) => set({ zoomPreset }),
openImage: (image) => set({ selectedImage: image }),
@@ -425,8 +443,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setView: (activeView) => set({ activeView }),
loadTagCloud: async () => {
const { selectedFolderId } = get();
set({ tagCloudLoading: true });
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
// Skip if already loaded for this folder and not currently loading
if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
return;
}
set({ tagCloudLoading: true, tagCloudFolderId: selectedFolderId });
try {
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
folderId: selectedFolderId,
@@ -438,19 +460,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
},
searchByTag: (tag) => {
set({
activeView: "gallery",
search: tag,
searchMode: "semantic",
images: [],
loadedCount: 0,
collectionTitle: `Exploring: ${tag}`,
});
void get().loadImages(true);
searchByTag: (imageId) => {
set({ activeView: "gallery", images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images" });
void get().loadSimilarImages(imageId);
},
loadSimilarImages: async (imageId) => {
set({ images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images" });
const images = await invoke<ImageRecord[]>("find_similar_images", {
params: { image_id: imageId, limit: PAGE_SIZE },
});
@@ -530,6 +546,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
state.selectedFolderId,
state.mediaFilter,
state.favoritesOnly,
state.failedEmbeddingsOnly,
state.search,
),
);
@@ -559,6 +576,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
state.selectedFolderId,
state.mediaFilter,
state.favoritesOnly,
state.failedEmbeddingsOnly,
state.search,
),
);