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
+49
View File
@@ -29,6 +29,12 @@ interface Task {
snapshot: string;
}
interface FailedEmbeddingItem {
image_id: number;
filename: string;
error: string | null;
}
export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
@@ -41,6 +47,7 @@ export function BackgroundTasks() {
metadata: false,
embedding: false,
});
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
useEffect(() => {
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 next = !paused[worker];
setPaused((prev) => ({ ...prev, [worker]: next }));
@@ -374,6 +403,26 @@ export function BackgroundTasks() {
{task.currentFile}
</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>
);
})}
+15
View File
@@ -188,6 +188,21 @@ function ImageTile({
)}
</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 */}
<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
View File
@@ -1,17 +1,18 @@
import { useEffect } from "react";
import { motion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, TagCloudEntry } from "../store";
// Accent color pairs: [rest, hover, glow]
const ACCENTS: [string, string, string][] = [
["rgba(96,165,250,0.5)", "#93c5fd", "rgba(59,130,246,0.3)"],
["rgba(192,132,252,0.5)", "#d8b4fe", "rgba(168,85,247,0.3)"],
["rgba(52,211,153,0.5)", "#6ee7b7", "rgba(16,185,129,0.3)"],
["rgba(251,191,36,0.5)", "#fcd34d", "rgba(245,158,11,0.3)"],
["rgba(249,168,212,0.5)", "#fbcfe8", "rgba(236,72,153,0.3)"],
["rgba(103,232,249,0.5)", "#a5f3fc", "rgba(6,182,212,0.3)"],
["rgba(253,186,116,0.5)", "#fed7aa", "rgba(249,115,22,0.3)"],
["rgba(167,243,208,0.5)", "#bbf7d0", "rgba(34,197,94,0.3)"],
// Accent glow colours for the hover ring — cycled by index
const GLOWS: string[] = [
"rgba(59,130,246,0.5)",
"rgba(168,85,247,0.5)",
"rgba(16,185,129,0.5)",
"rgba(245,158,11,0.5)",
"rgba(236,72,153,0.5)",
"rgba(6,182,212,0.5)",
"rgba(249,115,22,0.5)",
"rgba(34,197,94,0.5)",
];
function pseudoRandom(seed: number): number {
@@ -19,24 +20,17 @@ function pseudoRandom(seed: number): number {
return x - Math.floor(x);
}
function getWeight(count: number, maxCount: number): 1 | 2 | 3 | 4 | 5 {
if (maxCount === 0) return 1;
// Map cluster size to a tile size bucket (px)
function getTileSize(count: number, maxCount: number): number {
if (maxCount === 0) return 72;
const ratio = count / maxCount;
if (ratio > 0.75) return 5;
if (ratio > 0.45) return 4;
if (ratio > 0.22) return 3;
if (ratio > 0.08) return 2;
return 1;
if (ratio > 0.75) return 160;
if (ratio > 0.45) return 128;
if (ratio > 0.22) return 104;
if (ratio > 0.08) return 88;
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({
entry,
index,
@@ -46,66 +40,108 @@ function TagButton({
entry: TagCloudEntry;
index: number;
maxCount: number;
onSearch: (label: string) => void;
onSearch: (imageId: number) => void;
}) {
const weight = getWeight(entry.count, maxCount);
const accentIndex = (index * 3 + weight) % ACCENTS.length;
const [restColor, hoverColor, glowColor] = ACCENTS[accentIndex];
const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * MAX_ROTATION[weight];
const size = getTileSize(entry.count, maxCount);
const glow = GLOWS[index % GLOWS.length];
const mt = Math.floor(pseudoRandom(index * 3) * 14) + 3;
const mr = Math.floor(pseudoRandom(index * 5) * 20) + 6;
const mb = Math.floor(pseudoRandom(index * 11) * 14) + 3;
const ml = Math.floor(pseudoRandom(index * 13) * 20) + 6;
// Small random rotation for organic feel — larger tiles stay flatter
const maxRot = size >= 128 ? 0 : size >= 104 ? 3 : size >= 88 ? 6 : 10;
const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * maxRot;
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 (
<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 }}
transition={{
delay: index * 0.014,
delay: index * 0.025,
type: "spring",
stiffness: 180,
damping: 16,
stiffness: 200,
damping: 18,
}}
whileHover={{
scale: 1.2,
scale: 1.12,
rotate: 0,
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={{
fontSize: FONT_SIZE[weight],
fontWeight: FONT_WEIGHT[weight],
letterSpacing: LETTER_SPACING[weight],
padding: PADDING[weight],
width: size,
height: size,
margin: `${mt}px ${mr}px ${mb}px ${ml}px`,
color: restColor,
borderRadius: 10,
border: "1px solid transparent",
background: "transparent",
borderRadius: 12,
border: "2px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.04)",
cursor: "pointer",
userSelect: "none",
transition: "color 0.15s, text-shadow 0.15s, background 0.15s, border-color 0.15s",
padding: 0,
overflow: "hidden",
position: "relative",
flexShrink: 0,
boxShadow: "none",
transition: "border-color 0.15s, box-shadow 0.15s",
}}
onMouseEnter={(e) => {
const el = e.currentTarget;
el.style.color = hoverColor;
el.style.textShadow = `0 0 20px ${glowColor}, 0 0 40px ${glowColor}`;
el.style.background = glowColor.replace("0.3", "0.1");
el.style.borderColor = glowColor.replace("0.3", "0.25");
el.style.borderColor = glow;
el.style.boxShadow = `0 0 16px ${glow}, 0 0 32px ${glow.replace("0.5", "0.25")}`;
}}
onMouseLeave={(e) => {
const el = e.currentTarget;
el.style.color = restColor;
el.style.textShadow = "none";
el.style.background = "transparent";
el.style.borderColor = "transparent";
el.style.borderColor = "rgba(255,255,255,0.08)";
el.style.boxShadow = "none";
}}
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>
);
}
@@ -121,9 +157,10 @@ export function TagCloud() {
void loadTagCloud();
}, [selectedFolderId]);
const maxCount = tagCloudEntries.length > 0
? Math.max(...tagCloudEntries.map((e) => e.count))
: 1;
const maxCount =
tagCloudEntries.length > 0
? Math.max(...tagCloudEntries.map((e) => e.count))
: 1;
return (
<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
</h2>
<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>
</motion.div>
@@ -152,10 +189,22 @@ export function TagCloud() {
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<circle 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" />
<circle
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>
<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>
)}
@@ -163,18 +212,18 @@ export function TagCloud() {
{!tagCloudLoading && tagCloudEntries.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<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,
then come back here.
No embeddings yet. Add a folder and wait for the embedding worker to
finish, then come back here.
</p>
</div>
)}
{/* Cloud */}
{/* Cluster grid */}
{!tagCloudLoading && tagCloudEntries.length > 0 && (
<div className="flex flex-wrap justify-center px-12 pb-16 max-w-5xl w-full">
{tagCloudEntries.map((entry, index) => (
<TagButton
key={entry.label}
key={entry.representative_image_id}
entry={entry}
index={index}
maxCount={maxCount}
@@ -186,7 +235,7 @@ export function TagCloud() {
{!tagCloudLoading && tagCloudEntries.length > 0 && (
<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>
)}
</div>
+33 -6
View File
@@ -91,16 +91,22 @@ function FilterPill({
label,
active,
onClick,
variant = "default",
}: {
label: string;
active: boolean;
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 (
<button
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active
? "bg-white/10 text-white"
? activeClass
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={onClick}
@@ -127,12 +133,21 @@ export function Toolbar() {
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
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 setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
const [searchValue, setSearchValue] = useState(search);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(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 title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
@@ -153,6 +168,7 @@ export function Toolbar() {
}, [mediaFilter, sort, setSort]);
useEffect(() => {
if (!userHasTyped.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
@@ -233,7 +249,10 @@ export function Toolbar() {
ref={searchInputRef}
type="text"
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..."}
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 */}
<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="Images" active={mediaFilter === "image" && !favoritesOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => setFavoritesOnly(!favoritesOnly)} />
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<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>
);