Add tag cloud feature with k-means clustering and CUDA support
Introduces an Explore view with a tag cloud that clusters image embeddings using cosine k-means and labels clusters via vocabulary-nearest-neighbour CLIP matching. Vocabulary embeddings are disk-cached (FNV hash-keyed) to avoid redundant inference. Enables CUDA for candle dependencies and adds a build.rs check that surfaces a clear error when the toolkit is missing.
This commit is contained in:
+14
-3
@@ -5,12 +5,14 @@ import { BackgroundTasks } from "./components/BackgroundTasks";
|
||||
import { Toolbar } from "./components/Toolbar";
|
||||
import { Gallery } from "./components/Gallery";
|
||||
import { Lightbox } from "./components/Lightbox";
|
||||
import { TagCloud } from "./components/TagCloud";
|
||||
|
||||
export default function App() {
|
||||
const loadFolders = useGalleryStore((state) => state.loadFolders);
|
||||
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
|
||||
const loadImages = useGalleryStore((state) => state.loadImages);
|
||||
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
||||
const activeView = useGalleryStore((state) => state.activeView);
|
||||
|
||||
useEffect(() => {
|
||||
loadFolders().then(() => {
|
||||
@@ -30,9 +32,18 @@ export default function App() {
|
||||
<div className="flex h-screen bg-gray-950 text-white overflow-hidden select-none">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<Toolbar />
|
||||
<BackgroundTasks />
|
||||
<Gallery />
|
||||
{activeView === "explore" ? (
|
||||
<>
|
||||
<BackgroundTasks />
|
||||
<TagCloud />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Toolbar />
|
||||
<BackgroundTasks />
|
||||
<Gallery />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<Lightbox />
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,8 @@ export function Sidebar() {
|
||||
const addFolder = useGalleryStore((state) => state.addFolder);
|
||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||
const selectFolder = useGalleryStore((state) => state.selectFolder);
|
||||
const activeView = useGalleryStore((state) => state.activeView);
|
||||
const setView = useGalleryStore((state) => state.setView);
|
||||
|
||||
const handleAddFolder = async () => {
|
||||
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
|
||||
@@ -102,10 +104,10 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<div className="px-2 pt-2 pb-1">
|
||||
<div className="px-2 pt-2 pb-1 space-y-px">
|
||||
<div
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||
selectedFolderId === null
|
||||
activeView === "gallery" && selectedFolderId === null
|
||||
? "bg-white/8 text-white"
|
||||
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
||||
}`}
|
||||
@@ -115,10 +117,27 @@ export function Sidebar() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className={`text-[13px] font-medium ${selectedFolderId === null ? "text-white" : ""}`}>
|
||||
<span className={`text-[13px] font-medium ${activeView === "gallery" && selectedFolderId === null ? "text-white" : ""}`}>
|
||||
All Media
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||
activeView === "explore"
|
||||
? "bg-white/8 text-white"
|
||||
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setView("explore")}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
<span className={`text-[13px] font-medium ${activeView === "explore" ? "text-white" : ""}`}>
|
||||
Explore
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section label */}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
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)"],
|
||||
];
|
||||
|
||||
function pseudoRandom(seed: number): number {
|
||||
const x = Math.sin(seed + 1) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function getWeight(count: number, maxCount: number): 1 | 2 | 3 | 4 | 5 {
|
||||
if (maxCount === 0) return 1;
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
maxCount,
|
||||
onSearch,
|
||||
}: {
|
||||
entry: TagCloudEntry;
|
||||
index: number;
|
||||
maxCount: number;
|
||||
onSearch: (label: string) => 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 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;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.4, rotate: rotation * 2 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: rotation }}
|
||||
transition={{
|
||||
delay: index * 0.014,
|
||||
type: "spring",
|
||||
stiffness: 180,
|
||||
damping: 16,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.2,
|
||||
rotate: 0,
|
||||
transition: { type: "spring", stiffness: 400, damping: 22 },
|
||||
}}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
style={{
|
||||
fontSize: FONT_SIZE[weight],
|
||||
fontWeight: FONT_WEIGHT[weight],
|
||||
letterSpacing: LETTER_SPACING[weight],
|
||||
padding: PADDING[weight],
|
||||
margin: `${mt}px ${mr}px ${mb}px ${ml}px`,
|
||||
color: restColor,
|
||||
borderRadius: 10,
|
||||
border: "1px solid transparent",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
transition: "color 0.15s, text-shadow 0.15s, background 0.15s, border-color 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");
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.color = restColor;
|
||||
el.style.textShadow = "none";
|
||||
el.style.background = "transparent";
|
||||
el.style.borderColor = "transparent";
|
||||
}}
|
||||
onClick={() => onSearch(entry.label)}
|
||||
title={`${entry.count} matching ${entry.count === 1 ? "photo" : "photos"}`}
|
||||
>
|
||||
{entry.label}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagCloud() {
|
||||
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
|
||||
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
|
||||
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
|
||||
const searchByTag = useGalleryStore((state) => state.searchByTag);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTagCloud();
|
||||
}, [selectedFolderId]);
|
||||
|
||||
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">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="text-center pt-14 pb-8 shrink-0"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
>
|
||||
<h2 className="text-[22px] font-semibold text-white/70 tracking-tight mb-2">
|
||||
Explore your library
|
||||
</h2>
|
||||
<p className="text-[13px] text-white/25">
|
||||
Topics found in your photos — sized by how many match
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Loading */}
|
||||
{tagCloudLoading && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<motion.svg
|
||||
className="w-8 h-8 text-white/20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
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" />
|
||||
</motion.svg>
|
||||
<p className="text-[12px] text-white/20">Analysing your library with CLIP…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cloud */}
|
||||
{!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}
|
||||
entry={entry}
|
||||
index={index}
|
||||
maxCount={maxCount}
|
||||
onSearch={searchByTag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+45
-1
@@ -72,6 +72,13 @@ export interface ThumbnailBatch {
|
||||
images: ImageRecord[];
|
||||
}
|
||||
|
||||
export type ActiveView = "gallery" | "explore";
|
||||
|
||||
export interface TagCloudEntry {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type SortOrder =
|
||||
| "date_desc"
|
||||
| "date_asc"
|
||||
@@ -97,6 +104,9 @@ interface GalleryState {
|
||||
zoomPreset: ZoomPreset;
|
||||
selectedImage: ImageRecord | null;
|
||||
collectionTitle: string | null;
|
||||
activeView: ActiveView;
|
||||
tagCloudEntries: TagCloudEntry[];
|
||||
tagCloudLoading: boolean;
|
||||
indexingProgress: Record<number, IndexProgress>;
|
||||
mediaJobProgress: Record<number, FolderJobProgress>;
|
||||
cacheDir: string;
|
||||
@@ -119,6 +129,9 @@ interface GalleryState {
|
||||
setZoomPreset: (zoomPreset: ZoomPreset) => void;
|
||||
openImage: (image: ImageRecord) => void;
|
||||
closeImage: () => void;
|
||||
setView: (view: ActiveView) => void;
|
||||
loadTagCloud: () => Promise<void>;
|
||||
searchByTag: (tag: string) => void;
|
||||
loadSimilarImages: (imageId: number) => Promise<void>;
|
||||
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
||||
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
||||
@@ -255,6 +268,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
zoomPreset: "comfortable",
|
||||
selectedImage: null,
|
||||
collectionTitle: null,
|
||||
activeView: "gallery",
|
||||
tagCloudEntries: [],
|
||||
tagCloudLoading: false,
|
||||
indexingProgress: {},
|
||||
mediaJobProgress: {},
|
||||
cacheDir: "",
|
||||
@@ -299,7 +315,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
},
|
||||
|
||||
selectFolder: (folderId) => {
|
||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null });
|
||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, activeView: "gallery" });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
@@ -406,6 +422,34 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
openImage: (image) => set({ selectedImage: image }),
|
||||
closeImage: () => set({ selectedImage: null }),
|
||||
|
||||
setView: (activeView) => set({ activeView }),
|
||||
|
||||
loadTagCloud: async () => {
|
||||
const { selectedFolderId } = get();
|
||||
set({ tagCloudLoading: true });
|
||||
try {
|
||||
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
|
||||
folderId: selectedFolderId,
|
||||
});
|
||||
set({ tagCloudEntries: entries, tagCloudLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Failed to load tag cloud:", error);
|
||||
set({ tagCloudLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
searchByTag: (tag) => {
|
||||
set({
|
||||
activeView: "gallery",
|
||||
search: tag,
|
||||
searchMode: "semantic",
|
||||
images: [],
|
||||
loadedCount: 0,
|
||||
collectionTitle: `Exploring: ${tag}`,
|
||||
});
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
loadSimilarImages: async (imageId) => {
|
||||
const images = await invoke<ImageRecord[]>("find_similar_images", {
|
||||
params: { image_id: imageId, limit: PAGE_SIZE },
|
||||
|
||||
Reference in New Issue
Block a user