Network activity
Browse blocks
Photo
Four small photo blocks that search, organize, dedupe, and label your images. Each one runs on your device and installs on its own, and nothing downloads until you load a model.
Smart Gallery
Build a photo library in the browser that tags every image automatically as you add it, shown as a grid or list you can filter, sort, and clear.
Install this block
npx shadcn@latest add @localmode/ui/blocks/photo/smart-galleryidle - load a model to start
Multimodal (CLIP)
CLIP ViT-B/32 (~350 MB) - not loaded. It powers both the image embeddings and categorization. Nothing downloads until you press Load.
Load a model to start
PNG, JPEG or WebP - or click to browse
PNG · JPEG · WebP · up to 10 MB
'use client';/** * @file smart-gallery.tsx * @description Smart Gallery block — an on-device CLIP photo library: multi-file/drag-drop ingest → progressive adaptively-batched `streamEmbedManyImages` embeddings + per-batch zero-shot categorization, rendered as a grid/list gallery with per-photo delete, clear-all, and a cancellable ingest. */import { useState } from 'react';import { Grid3x3, List, Trash2 } from 'lucide-react';import { usePhotoLibrary, type PhotoEntry, type PhotoLibrary } from '@localmode/react';import { transformers, isModelCached } from '@localmode/transformers';import { cn } from '@/lib/utils';import { ModelSelector, type SelectableModel } from '@/components/model-selector';import { ModelDownloader } from '@/components/model-downloader';import { MediaDropzone } from '@/components/media-dropzone';import { ImageResultGallery, type ImageResultCard } from '@/components/image-result-gallery';import { AdaptiveBatchBadge } from '@/components/adaptive-batch-card';interface PhotoSearchModel { id: string; name: string; size: string; dimensions: number;}const DEFAULT_MODEL_ID = 'Xenova/clip-vit-base-patch32';const MODEL_CATALOG: PhotoSearchModel[] = [ { id: 'Xenova/clip-vit-base-patch32', name: 'CLIP ViT-B/32', size: '~350 MB', dimensions: 512 }, { id: 'Xenova/siglip-base-patch16-224', name: 'SigLIP Base', size: '~400 MB', dimensions: 768 },];function getModel(id: string): PhotoSearchModel { return MODEL_CATALOG.find((m) => m.id === id) ?? MODEL_CATALOG[0];}const PHOTO_LABELS = [ 'nature', 'people', 'animals', 'food', 'architecture', 'vehicles', 'art', 'technology', 'sports', 'other',] as const;const PRODUCT_LABELS = [ 'Electronics', 'Clothing', 'Home & Garden', 'Toys', 'Food & Beverage', 'Sports', 'Books', 'Automotive', 'Health', 'Other',] as const;const LABEL_PRESETS: Record<'photo' | 'product', { labels: string[] }> = { photo: { labels: [...PHOTO_LABELS] }, product: { labels: [...PRODUCT_LABELS] },};function formatScore(score: number): string { return `${Math.round(score * 100)}%`;}const SELECTABLE_MODELS: SelectableModel[] = MODEL_CATALOG.map((m) => ({ id: m.id, name: m.name, backend: 'onnx', category: 'Multimodal (CLIP)', size: m.size, vision: true,}));const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];function toCard(photo: PhotoEntry): ImageResultCard { const category = photo.processing ? 'Analyzing…' : photo.category ? `${photo.category} · ${photo.similarCount} similar` : undefined; return { id: photo.id, src: photo.src, label: photo.filename, category, score: photo.processing || photo.confidence === 0 ? undefined : photo.confidence, processing: photo.processing, };}export function SmartGalleryBlock() { const lib: PhotoLibrary = usePhotoLibrary({ modelId: DEFAULT_MODEL_ID, createEmbeddingModel: (id, onProgress) => transformers.multimodalEmbedding(id, { onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]), }), createZeroShotClassifier: (id) => transformers.zeroShotImageClassifier(id), isModelCached: (id) => isModelCached(id), labelPresets: LABEL_PRESETS, getModelDimensions: (id) => getModel(id).dimensions, }); const [view, setView] = useState<'grid' | 'list'>('grid'); const model = getModel(lib.activeModelId); const hasPhotos = lib.photos.length > 0; const disabled = !lib.modelReady || lib.busy || lib.switching; const statusText = lib.switching ? lib.reindexProgress ? `re-indexing ${lib.reindexProgress.completed}/${lib.reindexProgress.total}…` : 're-indexing library…' : lib.modelStatus === 'loading' ? `loading ${model.name}… ${Math.round(lib.modelProgress * 100)}%` : lib.ingestProgress ? `embedding ${lib.ingestProgress.completed}/${lib.ingestProgress.total}…` : lib.error ? 'error' : lib.modelReady ? `ready - ${lib.photos.length} photo${lib.photos.length === 1 ? '' : 's'} indexed` : 'idle - load a model to start'; return ( <div className="mx-auto flex max-w-5xl flex-col gap-4 p-4"> {} <p role="status" aria-live="polite" className="text-xs text-muted-foreground"> {statusText} </p> {lib.error && ( <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" > <span>{lib.error}</span> <button type="button" onClick={lib.clearError} className="rounded px-2 py-0.5 font-medium hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Dismiss </button> </div> )} {} <div data-status={lib.modelStatus} data-model-id={lib.activeModelId} role="group" aria-label="CLIP model status" className="flex flex-col gap-3 rounded-xl border border-border bg-muted/40 p-4 sm:flex-row sm:items-start" > <div className="w-full sm:max-w-sm"> <ModelSelector models={SELECTABLE_MODELS} selectedId={lib.activeModelId} onSelect={(id) => lib.requestModel(id)} /> </div> <div className="flex min-w-0 flex-1 flex-col gap-2"> {lib.modelStatus === 'idle' ? ( <> <p className="text-sm text-muted-foreground"> <span className="font-medium text-foreground">{model.name}</span> ({model.size}) - not loaded. It powers both the image embeddings and categorization. Nothing downloads until you press Load. </p> <button type="button" onClick={() => void lib.loadModel()} className="inline-flex h-9 w-fit items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Load {model.name} </button> </> ) : ( <div> <ModelDownloader name={model.name} size={model.size} category="Multimodal (CLIP)" progress={lib.modelProgressValue} cached={lib.modelCached} ready={lib.modelReady && !lib.switching} className="max-w-sm" /> </div> )} {} {lib.pendingModelId && ( <div className="flex flex-col gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs" > <span className="text-foreground"> Switch to <span className="font-medium">{getModel(lib.pendingModelId).name}</span>? The {getModel(lib.pendingModelId).dimensions}-dim vector space is incompatible - all{' '} {lib.photos.length} photos will be re-embedded and re-categorized. </span> <div className="flex items-center gap-2"> <button type="button" onClick={lib.confirmModelSwitch} className="inline-flex h-7 items-center rounded-md bg-primary px-3 font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Confirm switch </button> <button type="button" onClick={lib.cancelModelSwitch} className="inline-flex h-7 items-center rounded-md border border-border px-3 font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> </div> )} {} {lib.switching && lib.reindexProgress && ( <div data-completed={lib.reindexProgress.completed} data-total={lib.reindexProgress.total} role="status" aria-live="polite" aria-label="Re-index progress" className="text-xs tabular-nums text-muted-foreground" > Re-indexing {lib.reindexProgress.completed}/{lib.reindexProgress.total} photos through{' '} {model.name}… </div> )} </div> </div> {} <div role="group" aria-label="Photo library upload"> <MediaDropzone accept={ACCEPTED} multiple disabled={disabled} addAnother={hasPhotos} processing={lib.ingestProgress != null} processingLabel={ lib.ingestProgress ? `Embedding ${lib.ingestProgress.completed}/${lib.ingestProgress.total}…` : 'Processing…' } title={lib.modelReady ? 'Drop photos here' : 'Load a model to start'} subtitle="PNG, JPEG or WebP - or click to browse" onFiles={(files) => void lib.ingest(files)} onReject={(rejections) => lib.setRejection({ filename: rejections[0].file.name, reason: rejections[0].reason, }) } /> </div> {} {lib.rejection && ( <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive" > <span> Rejected <span className="font-medium">{lib.rejection.filename}</span>:{' '} {lib.rejection.reason} </span> <button type="button" onClick={() => lib.setRejection(null)} className="rounded px-2 py-0.5 text-xs font-medium hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Dismiss </button> </div> )} {} {lib.ingestProgress && ( <div data-completed={lib.ingestProgress.completed} data-total={lib.ingestProgress.total} role="status" aria-live="polite" aria-label="Ingest progress" className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2 text-sm" > <span className="tabular-nums text-muted-foreground"> Embedding {lib.ingestProgress.completed}/{lib.ingestProgress.total}… </span> <button type="button" onClick={lib.cancelIngest} className="rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> )} {} {hasPhotos && ( <div className="flex flex-wrap items-center gap-2"> <span className="text-sm font-medium tabular-nums"> {lib.photos.length} photo{lib.photos.length === 1 ? '' : 's'} </span> <span role="group" aria-label="Adaptive batch info" className="ml-1 inline-flex" > <AdaptiveBatchBadge result={lib.batchInfo} /> </span> <div className="ml-auto flex items-center gap-1"> <div data-view={view} role="group" aria-label="View mode" className="inline-flex items-center rounded-md border border-border bg-muted/40 p-0.5" > {(['grid', 'list'] as const).map((mode) => ( <button key={mode} type="button" aria-pressed={view === mode} aria-label={mode === 'grid' ? 'Grid view' : 'List view'} onClick={() => setView(mode)} className={cn( 'inline-flex h-7 items-center rounded px-2 transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', view === mode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground', )} > {mode === 'grid' ? <Grid3x3 className="size-4" /> : <List className="size-4" />} </button> ))} </div> <button type="button" onClick={lib.clearAll} disabled={lib.busy || lib.switching} className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:border-destructive hover:text-destructive disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <Trash2 className="size-3.5" /> Clear all </button> </div> </div> )} {} {hasPhotos ? ( <div> <ImageResultGallery cards={lib.photos.map(toCard)} layout={view} scoreThresholds={{ high: 0.35, medium: 0.2 }} onDelete={(id) => lib.deletePhoto(id)} /> </div> ) : lib.modelReady ? ( <p className="rounded-lg border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground" > No photos yet. Drop some images above to build your library. </p> ) : null} {} <ul aria-label="Indexed photos" className="sr-only"> {lib.photos.map((photo) => ( <li key={photo.id} data-id={photo.id} data-filename={photo.filename} data-category={photo.category} data-confidence={photo.confidence.toFixed(4)} data-similar={photo.similarCount} data-embedded={photo.embedding !== null} data-processing={photo.processing} > {photo.filename}: {photo.category} ({formatScore(photo.confidence)}), {photo.similarCount}{' '} similar </li> ))} </ul> </div> );}Image Search
Search your photos by typing what you are looking for, or by dropping in a reference image, with a top results count and an adjustable match threshold.
Install this block
npx shadcn@latest add @localmode/ui/blocks/photo/image-searchidle - load a model to start
Multimodal (CLIP)
CLIP ViT-B/32 (~350 MB) - not loaded. It powers both search embeddings and categorization. Nothing downloads until you press Load.
1 · Build a library
Load a model to start
PNG, JPEG or WebP - or click to browse
PNG · JPEG · WebP · up to 10 MB
2 · Search the shared vector space
Ingest photos above first, then search them.
'use client';/** * @file image-search.tsx * @description Self-sufficient CLIP image-search block — ingest a photo library, then run text→image and image→image search over one shared multimodal vector space. */import { useState } from 'react';import { Loader2, Search, Trash2, X } from 'lucide-react';import { usePhotoLibrary, readFileAsDataUrl, type PhotoEntry, type RankedHit, type PhotoLibrary,} from '@localmode/react';import { transformers, isModelCached } from '@localmode/transformers';import { cn } from '@/lib/utils';import { ModelSelector, type SelectableModel } from '@/components/model-selector';import { ModelDownloader } from '@/components/model-downloader';import { SegmentedModePicker } from '@/components/segmented-mode-picker';import { ParameterSlider } from '@/components/parameter-slider';import { MediaDropzone } from '@/components/media-dropzone';import { ImageResultGallery, type ImageResultCard } from '@/components/image-result-gallery';import { ScoredResultBarList } from '@/components/scored-result-bar-list';interface PhotoSearchModel { id: string; name: string; size: string; dimensions: number;}const DEFAULT_MODEL_ID = 'Xenova/clip-vit-base-patch32';const MODEL_CATALOG: PhotoSearchModel[] = [ { id: 'Xenova/clip-vit-base-patch32', name: 'CLIP ViT-B/32', size: '~350 MB', dimensions: 512 }, { id: 'Xenova/siglip-base-patch16-224', name: 'SigLIP Base', size: '~400 MB', dimensions: 768 },];function getModel(id: string): PhotoSearchModel { return MODEL_CATALOG.find((m) => m.id === id) ?? MODEL_CATALOG[0];}const PHOTO_LABELS = [ 'nature', 'people', 'animals', 'food', 'architecture', 'vehicles', 'art', 'technology', 'sports', 'other',];const PRODUCT_LABELS = [ 'Electronics', 'Clothing', 'Home & Garden', 'Toys', 'Food & Beverage', 'Sports', 'Books', 'Automotive', 'Health', 'Other',];const LABEL_PRESETS: Record<string, { labels: string[] }> = { photo: { labels: PHOTO_LABELS }, product: { labels: PRODUCT_LABELS },};const DEFAULT_TOP_K = 20;const MIN_SIMILARITY_FALLBACK = 0.2;function formatScore(score: number): string { return `${Math.round(score * 100)}%`;}function scoreTone(score: number): 'strong' | 'medium' | 'weak' { if (score >= 0.35) return 'strong'; if (score >= 0.2) return 'medium'; return 'weak';}type SearchMode = 'text' | 'image';const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];const SELECTABLE_MODELS: SelectableModel[] = MODEL_CATALOG.map((m) => ({ id: m.id, name: m.name, backend: 'onnx', category: 'Multimodal (CLIP)', size: m.size, vision: true,}));function toLibraryCard(photo: PhotoEntry): ImageResultCard { const category = photo.processing ? 'Analyzing…' : photo.category ? `${photo.category} · ${photo.similarCount} similar` : undefined; return { id: photo.id, src: photo.src, label: photo.filename, category, score: photo.processing || photo.confidence === 0 ? undefined : photo.confidence, processing: photo.processing, };}export function ImageSearchBlock() { const lib: PhotoLibrary = usePhotoLibrary({ modelId: DEFAULT_MODEL_ID, createEmbeddingModel: (id, onProgress) => transformers.multimodalEmbedding(id, { onProgress: (p) => onProgress(p as any) }), createZeroShotClassifier: (id) => transformers.zeroShotImageClassifier(id), isModelCached: (id) => isModelCached(id), labelPresets: { photo: { labels: LABEL_PRESETS.photo.labels }, product: { labels: LABEL_PRESETS.product.labels } }, getModelDimensions: (id) => getModel(id).dimensions, defaultTopK: DEFAULT_TOP_K, minSimilarityFallback: MIN_SIMILARITY_FALLBACK, }); const model = getModel(lib.activeModelId); const statusText = lib.switching ? lib.reindexProgress ? `re-indexing ${lib.reindexProgress.completed}/${lib.reindexProgress.total}…` : 're-indexing library…' : lib.modelStatus === 'loading' ? `loading ${model.name}… ${Math.round(lib.modelProgress * 100)}%` : lib.ingestProgress ? `embedding ${lib.ingestProgress.completed}/${lib.ingestProgress.total}…` : lib.error ? 'error' : lib.modelReady ? `ready - ${lib.photos.length} photo${lib.photos.length === 1 ? '' : 's'} indexed` : 'idle - load a model to start'; const [mode, setMode] = useState<SearchMode>('text'); const [query, setQuery] = useState(''); const [hits, setHits] = useState<RankedHit[] | null>(null); const [refImage, setRefImage] = useState<string | null>(null); const [searching, setSearching] = useState(false); const searchDisabled = !lib.modelReady || lib.switching || lib.photos.length === 0; const ingestDisabled = !lib.modelReady || lib.busy || lib.switching; const hasPhotos = lib.photos.length > 0; const clearSearch = () => { setHits(null); setQuery(''); setRefImage(null); }; const runTextSearch = async () => { const q = query.trim(); if (!q || searchDisabled) return; setSearching(true); try { setHits(await lib.searchByText(q)); } finally { setSearching(false); } }; const runImageSearch = async (file: File) => { if (searchDisabled) return; const dataUrl = await readFileAsDataUrl(file); setRefImage(dataUrl); setSearching(true); try { setHits(await lib.searchByImage(dataUrl)); } finally { setSearching(false); } }; const cards: ImageResultCard[] = []; for (const hit of hits ?? []) { const photo = lib.getPhoto(hit.id); if (!photo) continue; cards.push({ id: photo.id, src: photo.src, label: photo.filename, category: photo.category || undefined, score: hit.score, }); } const scored = cards.map((c) => ({ label: c.label ?? c.id, score: c.score ?? 0 })); const topFilename = cards[0]?.label ?? ''; return ( <div className="mx-auto flex max-w-5xl flex-col gap-4 p-4"> {} <p role="status" aria-live="polite" className="text-xs text-muted-foreground" > {statusText} </p> {lib.error && ( <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" > <span>{lib.error}</span> <button type="button" onClick={lib.clearError} className="rounded px-2 py-0.5 font-medium hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Dismiss </button> </div> )} {} <div data-status={lib.modelStatus} data-model-id={lib.activeModelId} role="group" aria-label="CLIP model status" className="flex flex-col gap-3 rounded-xl border border-border bg-muted/40 p-4 sm:flex-row sm:items-start" > <div className="w-full sm:max-w-sm"> <ModelSelector models={SELECTABLE_MODELS} selectedId={lib.activeModelId} onSelect={(id) => lib.requestModel(id)} /> </div> <div className="flex min-w-0 flex-1 flex-col gap-2"> {lib.modelStatus === 'idle' ? ( <> <p className="text-sm text-muted-foreground"> <span className="font-medium text-foreground">{model.name}</span> ({model.size}) - not loaded. It powers both search embeddings and categorization. Nothing downloads until you press Load. </p> <button type="button" onClick={() => void lib.loadModel()} className="inline-flex h-9 w-fit items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Load {model.name} </button> </> ) : ( <div> <ModelDownloader name={model.name} size={model.size} category="Multimodal (CLIP)" progress={lib.modelProgressValue} cached={lib.modelCached} ready={lib.modelReady && !lib.switching} className="max-w-sm" /> </div> )} {} {lib.pendingModelId && ( <div className="flex flex-col gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs" > <span className="text-foreground"> Switch to <span className="font-medium">{getModel(lib.pendingModelId).name}</span>? The {getModel(lib.pendingModelId).dimensions}-dim vector space is incompatible - all{' '} {lib.photos.length} photos will be re-embedded and re-categorized. </span> <div className="flex items-center gap-2"> <button type="button" onClick={lib.confirmModelSwitch} className="inline-flex h-7 items-center rounded-md bg-primary px-3 font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Confirm switch </button> <button type="button" onClick={lib.cancelModelSwitch} className="inline-flex h-7 items-center rounded-md border border-border px-3 font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> </div> )} {} {lib.switching && lib.reindexProgress && ( <div data-completed={lib.reindexProgress.completed} data-total={lib.reindexProgress.total} role="status" aria-live="polite" aria-label="Re-index progress" className="text-xs tabular-nums text-muted-foreground" > Re-indexing {lib.reindexProgress.completed}/{lib.reindexProgress.total} photos through{' '} {model.name}… </div> )} </div> </div> {} <section className="flex flex-col gap-3"> <h2 className="text-sm font-semibold text-foreground">1 · Build a library</h2> <div role="group" aria-label="Photo library upload"> <MediaDropzone accept={ACCEPTED} multiple disabled={ingestDisabled} addAnother={hasPhotos} processing={lib.ingestProgress != null} processingLabel={ lib.ingestProgress ? `Embedding ${lib.ingestProgress.completed}/${lib.ingestProgress.total}…` : 'Processing…' } title={lib.modelReady ? 'Drop photos here' : 'Load a model to start'} subtitle="PNG, JPEG or WebP - or click to browse" onFiles={(files) => void lib.ingest(files)} onReject={(rejections) => lib.setRejection({ filename: rejections[0].file.name, reason: rejections[0].reason, }) } /> </div> {} {lib.rejection && ( <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive" > <span> Rejected <span className="font-medium">{lib.rejection.filename}</span>:{' '} {lib.rejection.reason} </span> <button type="button" onClick={() => lib.setRejection(null)} className="rounded px-2 py-0.5 text-xs font-medium hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Dismiss </button> </div> )} {} {lib.ingestProgress && ( <div data-completed={lib.ingestProgress.completed} data-total={lib.ingestProgress.total} role="status" aria-live="polite" aria-label="Ingest progress" className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2 text-sm" > <span className="tabular-nums text-muted-foreground"> Embedding {lib.ingestProgress.completed}/{lib.ingestProgress.total}… </span> <button type="button" onClick={lib.cancelIngest} className="rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> )} {} {hasPhotos && ( <div className="flex flex-wrap items-center gap-2"> <span className="text-sm font-medium tabular-nums"> {lib.photos.length} photo{lib.photos.length === 1 ? '' : 's'} indexed </span> <button type="button" onClick={lib.clearAll} disabled={lib.busy || lib.switching} className="ml-auto inline-flex h-7 items-center gap-1 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:border-destructive hover:text-destructive disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <Trash2 className="size-3.5" /> Clear all </button> </div> )} {} {hasPhotos ? ( <div> <ImageResultGallery cards={lib.photos.map(toLibraryCard)} layout="grid" onDelete={(id) => lib.deletePhoto(id)} scoreThresholds={{ high: 0.35, medium: 0.2 }} /> </div> ) : lib.modelReady ? ( <p className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground" > No photos yet. Drop some images above to build a searchable library. </p> ) : null} {} <ul aria-label="Indexed photos" className="sr-only"> {lib.photos.map((photo) => ( <li key={photo.id} data-id={photo.id} data-filename={photo.filename} data-category={photo.category} data-confidence={photo.confidence.toFixed(4)} data-embedded={photo.embedding !== null} data-processing={photo.processing} > {photo.filename}: {photo.category} ({formatScore(photo.confidence)}) </li> ))} </ul> </section> {} <section className="flex flex-col gap-4 border-t border-border pt-4"> <h2 className="text-sm font-semibold text-foreground"> 2 · Search the shared vector space </h2> {} <div className="flex flex-wrap items-center gap-3"> <div data-mode={mode}> <SegmentedModePicker<SearchMode> items={[ { id: 'text', label: 'Text' }, { id: 'image', label: 'Image' }, ]} selectedId={mode} onSelect={(m) => { setMode(m); clearSearch(); }} aria-label="Search mode" /> </div> <span data-threshold={lib.minSimilarity} className="ml-auto text-xs text-muted-foreground" > Minimum similarity: {formatScore(lib.minSimilarity)} </span> </div> {} <div data-value={lib.topK} className="max-w-xs"> <ParameterSlider label="Results (top-K)" value={lib.topK} onChange={lib.setTopK} min={1} max={50} step={1} /> </div> {} {mode === 'text' && ( <div className="flex flex-wrap items-center gap-2"> <div className="relative flex-1"> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <input type="text" aria-label="Search query" value={query} disabled={searchDisabled} placeholder="Describe a photo, e.g. “a photo of a dog”" onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void runTextSearch(); }} className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-3 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50" /> </div> <button type="button" onClick={() => void runTextSearch()} disabled={searchDisabled || !query.trim() || searching} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > {searching ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Search </button> {hits && ( <button type="button" onClick={clearSearch} className="inline-flex h-9 items-center gap-1 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <X className="size-3.5" /> Clear </button> )} </div> )} {} {mode === 'image' && ( <div className="flex flex-col gap-3 sm:flex-row sm:items-start"> <div className="w-full sm:max-w-xs" role="group" aria-label="Reference image upload" > <MediaDropzone accept={ACCEPTED} multiple={false} disabled={searchDisabled} processing={searching} processingLabel="Searching…" title="Drop a reference image" subtitle="Find visually similar photos" onFiles={(files) => void runImageSearch(files[0])} /> </div> {refImage && ( <div className="flex items-center gap-2"> {} <img src={refImage} alt="Reference" className="size-20 rounded-md object-cover" /> <button type="button" onClick={clearSearch} className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <X className="size-3.5" /> Clear </button> </div> )} </div> )} {} {hits !== null && (cards.length > 0 ? ( <div className="flex flex-col gap-4"> <div data-count={cards.length} data-top={topFilename} role="region" aria-label="Search results" > <ImageResultGallery cards={cards} layout="grid" scoreThresholds={{ high: 0.35, medium: 0.2 }} /> </div> <ScoredResultBarList results={scored} sort={false} limit={10} /> {} <ol aria-label="Search results ranking" className="sr-only"> {cards.map((c, i) => ( <li key={c.id} data-rank={i} data-id={c.id} data-filename={c.label} data-score={(c.score ?? 0).toFixed(4)} data-tone={scoreTone(c.score ?? 0)} > {c.label}: {formatScore(c.score ?? 0)} </li> ))} </ol> </div> ) : ( <p className="rounded-lg border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground" > No photos at or above the {formatScore(lib.minSimilarity)} similarity threshold. </p> ))} {searchDisabled && lib.photos.length === 0 && ( <p className="text-sm text-muted-foreground">Ingest photos above first, then search them.</p> )} </section> </div> );}Duplicate Finder
Find near-duplicate photos and group them together, with a match strength you can tune, quick presets, and one-click bulk delete that keeps the first of each group.
Install this block
npx shadcn@latest add @localmode/ui/blocks/photo/duplicate-finderidle - load a model to start
Multimodal (CLIP)
CLIP ViT-B/32 (~350 MB) - not loaded. It embeds every photo so duplicates can be found by cosine similarity. Nothing downloads until you press Load.
Load a model to start
PNG, JPEG or WebP - or click to browse
PNG · JPEG · WebP · up to 10 MB
Photos above this cosine similarity group as duplicates. Changing it re-groups instantly - no re-embedding.
Ingest at least two photos above to scan for duplicates.
'use client';/** * @file duplicate-finder.tsx * @description Duplicate Finder block — a self-sufficient CLIP workbench that ingests its own photos, then union-find groups near-duplicates over the cached image embeddings (no re-embedding) with a tunable threshold + Strict/Balanced/Relaxed presets, per-group average similarity, keep-first select + bulk delete, and a cancellable RE-GROUP scan. */import { useEffect, useRef, useState } from 'react';import { Grid3x3, List, Loader2, Trash2 } from 'lucide-react';import { usePhotoLibrary, groupDuplicates, duplicateIds, selectAllDuplicateIds, type DuplicateGroup, type PhotoEntry, type PhotoLibrary,} from '@localmode/react';import { transformers, isModelCached } from '@localmode/transformers';import { cn } from '@/lib/utils';import { ModelSelector, type SelectableModel } from '@/components/model-selector';import { ModelDownloader } from '@/components/model-downloader';import { MediaDropzone } from '@/components/media-dropzone';import { ParameterSlider } from '@/components/parameter-slider';import { CosineSimilarityMeter } from '@/components/cosine-similarity-meter';import { ImageResultGallery, type ImageResultCard } from '@/components/image-result-gallery';interface PhotoSearchModel { id: string; name: string; size: string; dimensions: number;}const DEFAULT_MODEL_ID = 'Xenova/clip-vit-base-patch32';const MODEL_CATALOG: PhotoSearchModel[] = [ { id: 'Xenova/clip-vit-base-patch32', name: 'CLIP ViT-B/32', size: '~350 MB', dimensions: 512 }, { id: 'Xenova/siglip-base-patch16-224', name: 'SigLIP Base', size: '~400 MB', dimensions: 768 },];function getModel(id: string): PhotoSearchModel { return MODEL_CATALOG.find((m) => m.id === id) ?? MODEL_CATALOG[0];}const PHOTO_LABELS = [ 'nature', 'people', 'animals', 'food', 'architecture', 'vehicles', 'art', 'technology', 'sports', 'other',];const PRODUCT_LABELS = [ 'Electronics', 'Clothing', 'Home & Garden', 'Toys', 'Food & Beverage', 'Sports', 'Books', 'Automotive', 'Health', 'Other',];const LABEL_PRESETS: Record<string, { label: string; labels: string[] }> = { photo: { label: 'Photo', labels: [...PHOTO_LABELS] }, product: { label: 'Product', labels: [...PRODUCT_LABELS] },};const DUPLICATE_PRESETS = [ { id: 'strict', label: 'Strict', value: 0.95 }, { id: 'balanced', label: 'Balanced', value: 0.9 }, { id: 'relaxed', label: 'Relaxed', value: 0.85 },] as const;const DEFAULT_DUPLICATE_THRESHOLD = 0.9;function formatScore(score: number): string { return `${Math.round(score * 100)}%`;}const SELECTABLE_MODELS: SelectableModel[] = MODEL_CATALOG.map((m) => ({ id: m.id, name: m.name, backend: 'onnx', category: 'Multimodal (CLIP)', size: m.size, vision: true,}));const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];const toCard = (photo: PhotoEntry): ImageResultCard => ({ id: photo.id, src: photo.src, label: photo.filename,});export function DuplicateFinderBlock() { const lib: PhotoLibrary = usePhotoLibrary({ modelId: DEFAULT_MODEL_ID, createEmbeddingModel: (id, onProgress) => transformers.multimodalEmbedding(id, { onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]), }), createZeroShotClassifier: (id) => transformers.zeroShotImageClassifier(id), isModelCached: (id) => isModelCached(id), labelPresets: LABEL_PRESETS, getModelDimensions: (id) => getModel(id).dimensions, }); const [view, setView] = useState<'grid' | 'list'>('grid'); const [threshold, setThreshold] = useState(DEFAULT_DUPLICATE_THRESHOLD); const [groups, setGroups] = useState<DuplicateGroup[] | null>(null); const [selected, setSelected] = useState<Set<string>>(new Set()); const [scanning, setScanning] = useState(false); const cancelRef = useRef(false); const model = getModel(lib.activeModelId); const hasPhotos = lib.photos.length > 0; const disabled = !lib.modelReady || lib.busy || lib.switching; const embeddedCount = lib.photos.filter((p) => p.embedding !== null).length; const canScan = lib.modelReady && !lib.switching && embeddedCount >= 2; const dupIds = groups ? duplicateIds(groups) : new Set<string>(); const uniquePhotos = lib.photos.filter((p) => p.embedding !== null && !dupIds.has(p.id)); const hasDuplicates = (groups?.length ?? 0) > 0; const statusText = lib.switching ? lib.reindexProgress ? `re-indexing ${lib.reindexProgress.completed}/${lib.reindexProgress.total}…` : 're-indexing library…' : lib.modelStatus === 'loading' ? `loading ${model.name}… ${Math.round(lib.modelProgress * 100)}%` : lib.ingestProgress ? `embedding ${lib.ingestProgress.completed}/${lib.ingestProgress.total}…` : scanning ? 'scanning for duplicates…' : lib.error ? 'error' : groups !== null ? `${groups.length} duplicate group${groups.length === 1 ? '' : 's'} · ${dupIds.size} duplicate${dupIds.size === 1 ? '' : 's'}` : lib.modelReady ? `ready - ${lib.photos.length} photo${lib.photos.length === 1 ? '' : 's'} indexed` : 'idle - load a model to start'; const recompute = (th: number) => setGroups(groupDuplicates(lib.photos, th)); const scan = () => { if (!canScan) return; cancelRef.current = false; setScanning(true); setSelected(new Set()); setTimeout(() => { if (cancelRef.current) { setScanning(false); return; } setGroups(groupDuplicates(lib.photos, threshold)); setScanning(false); }, 0); }; const cancelScan = () => { cancelRef.current = true; }; useEffect(() => { if (groups === null) return; setGroups(groupDuplicates(lib.photos, threshold)); const present = new Set(lib.photos.map((p) => p.id)); setSelected((prev) => { const filtered = new Set([...prev].filter((id) => present.has(id))); return filtered.size === prev.size ? prev : filtered; }); }, [lib.photos]); const applyThreshold = (th: number) => { setThreshold(th); if (groups !== null) recompute(th); }; const toggleSelect = (id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const bulkDelete = () => { if (selected.size === 0) return; lib.deletePhotos(new Set(selected)); setSelected(new Set()); }; return ( <div className="mx-auto flex max-w-5xl flex-col gap-4 p-4"> {} <p role="status" aria-live="polite" className="text-xs text-muted-foreground"> {statusText} </p> {lib.error && ( <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" > <span>{lib.error}</span> <button type="button" onClick={lib.clearError} className="rounded px-2 py-0.5 font-medium hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Dismiss </button> </div> )} {} <div data-status={lib.modelStatus} data-model-id={lib.activeModelId} role="group" aria-label="CLIP model status" className="flex flex-col gap-3 rounded-xl border border-border bg-muted/40 p-4 sm:flex-row sm:items-start" > <div className="w-full sm:max-w-sm"> <ModelSelector models={SELECTABLE_MODELS} selectedId={lib.activeModelId} onSelect={(id) => lib.requestModel(id)} /> </div> <div className="flex min-w-0 flex-1 flex-col gap-2"> {lib.modelStatus === 'idle' ? ( <> <p className="text-sm text-muted-foreground"> <span className="font-medium text-foreground">{model.name}</span> ({model.size}) - not loaded. It embeds every photo so duplicates can be found by cosine similarity. Nothing downloads until you press Load. </p> <button type="button" onClick={() => void lib.loadModel()} className="inline-flex h-9 w-fit items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Load {model.name} </button> </> ) : ( <div> <ModelDownloader name={model.name} size={model.size} category="Multimodal (CLIP)" progress={lib.modelProgressValue} cached={lib.modelCached} ready={lib.modelReady && !lib.switching} className="max-w-sm" /> </div> )} {} {lib.pendingModelId && ( <div className="flex flex-col gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs" > <span className="text-foreground"> Switch to <span className="font-medium">{getModel(lib.pendingModelId).name}</span>? The {getModel(lib.pendingModelId).dimensions}-dim vector space is incompatible - all{' '} {lib.photos.length} photos will be re-embedded. </span> <div className="flex items-center gap-2"> <button type="button" onClick={lib.confirmModelSwitch} className="inline-flex h-7 items-center rounded-md bg-primary px-3 font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Confirm switch </button> <button type="button" onClick={lib.cancelModelSwitch} className="inline-flex h-7 items-center rounded-md border border-border px-3 font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> </div> )} {} {lib.switching && lib.reindexProgress && ( <div data-completed={lib.reindexProgress.completed} data-total={lib.reindexProgress.total} role="status" aria-live="polite" aria-label="Re-index progress" className="text-xs tabular-nums text-muted-foreground" > Re-indexing {lib.reindexProgress.completed}/{lib.reindexProgress.total} photos through{' '} {model.name}… </div> )} </div> </div> {} <div role="group" aria-label="Photo library upload"> <MediaDropzone accept={ACCEPTED} multiple disabled={disabled} addAnother={hasPhotos} processing={lib.ingestProgress != null} processingLabel={ lib.ingestProgress ? `Embedding ${lib.ingestProgress.completed}/${lib.ingestProgress.total}…` : 'Processing…' } title={lib.modelReady ? 'Drop photos here' : 'Load a model to start'} subtitle="PNG, JPEG or WebP - or click to browse" onFiles={(files) => void lib.ingest(files)} onReject={(rejections) => lib.setRejection({ filename: rejections[0].file.name, reason: rejections[0].reason, }) } /> </div> {} {lib.rejection && ( <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive" > <span> Rejected <span className="font-medium">{lib.rejection.filename}</span>:{' '} {lib.rejection.reason} </span> <button type="button" onClick={() => lib.setRejection(null)} className="rounded px-2 py-0.5 text-xs font-medium hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Dismiss </button> </div> )} {} {lib.ingestProgress && ( <div data-completed={lib.ingestProgress.completed} data-total={lib.ingestProgress.total} role="status" aria-live="polite" aria-label="Ingest progress" className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2 text-sm" > <span className="tabular-nums text-muted-foreground"> Embedding {lib.ingestProgress.completed}/{lib.ingestProgress.total}… </span> <button type="button" onClick={lib.cancelIngest} className="rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> )} {} {hasPhotos && ( <div className="flex flex-wrap items-center gap-2"> <span className="text-sm font-medium tabular-nums"> {lib.photos.length} photo{lib.photos.length === 1 ? '' : 's'} <span className="ml-1 font-normal text-muted-foreground"> ({embeddedCount} embedded) </span> </span> <div className="ml-auto flex items-center gap-1"> <div data-view={view} role="group" aria-label="View mode" className="inline-flex items-center rounded-md border border-border bg-muted/40 p-0.5" > {(['grid', 'list'] as const).map((mode) => ( <button key={mode} type="button" aria-pressed={view === mode} aria-label={mode === 'grid' ? 'Grid view' : 'List view'} onClick={() => setView(mode)} className={cn( 'inline-flex h-7 items-center rounded px-2 transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', view === mode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground', )} > {mode === 'grid' ? <Grid3x3 className="size-4" /> : <List className="size-4" />} </button> ))} </div> <button type="button" onClick={lib.clearAll} disabled={lib.busy || lib.switching} className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:border-destructive hover:text-destructive disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <Trash2 className="size-3.5" /> Clear all </button> </div> </div> )} {} <div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4"> <div data-value={threshold}> <ParameterSlider label="Similarity threshold" value={Math.round(threshold * 100)} onChange={(v) => applyThreshold(v / 100)} min={50} max={100} step={1} precision={0} unit="%" description="Photos above this cosine similarity group as duplicates. Changing it re-groups instantly - no re-embedding." /> </div> <div className="flex flex-wrap items-center gap-2"> <div role="group" aria-label="Duplicate threshold presets" className="flex items-center gap-1"> {DUPLICATE_PRESETS.map((preset) => ( <button key={preset.id} type="button" data-preset={preset.id} aria-pressed={threshold === preset.value} onClick={() => applyThreshold(preset.value)} className={cn( 'whitespace-nowrap rounded-md border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', threshold === preset.value ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-foreground hover:bg-accent', )} > {preset.label} · {formatScore(preset.value)} </button> ))} </div> <button type="button" onClick={scan} disabled={!canScan || scanning} className="ml-auto inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > {scanning && <Loader2 className="size-4 animate-spin" />} {groups === null ? 'Scan for duplicates' : 'Re-scan'} </button> {scanning && ( <button type="button" onClick={cancelScan} className="inline-flex h-8 items-center rounded-md border border-border px-2.5 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> )} </div> </div> {!canScan && ( <p className="text-sm text-muted-foreground"> Ingest at least two photos above to scan for duplicates. </p> )} {} {groups !== null && ( <div className="flex flex-wrap items-center gap-2"> <span data-total={embeddedCount} data-duplicates={dupIds.size} data-groups={groups.length} data-threshold={threshold} role="group" aria-label="Duplicate scan stats" className="text-sm tabular-nums text-muted-foreground" > {embeddedCount} photos ·{' '} {hasDuplicates ? ( <span className="font-medium text-foreground">{dupIds.size} duplicates</span> ) : ( <span className="font-medium text-emerald-500">no duplicates</span> )}{' '} · threshold {formatScore(threshold)} </span> {hasDuplicates && ( <div className="ml-auto flex items-center gap-1.5"> {selected.size > 0 ? ( <button type="button" onClick={() => setSelected(new Set())} className="inline-flex h-8 items-center rounded-md border border-border px-2.5 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Deselect all </button> ) : ( <button type="button" onClick={() => setSelected(selectAllDuplicateIds(groups))} className="inline-flex h-8 items-center rounded-md border border-border px-2.5 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Select duplicates </button> )} {selected.size > 0 && ( <button type="button" data-count={selected.size} onClick={bulkDelete} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-destructive px-3 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <Trash2 className="size-3.5" /> Delete {selected.size} selected </button> )} </div> )} </div> )} {} {hasDuplicates && ( <div className="flex flex-col gap-4"> <h2 className="text-sm font-semibold">Duplicate groups ({groups!.length})</h2> {groups!.map((group, i) => ( <div key={group.photos[0].id} data-size={group.photos.length} data-similarity={group.similarity.toFixed(4)} data-members={group.photos.map((p) => p.filename).join(',')} role="group" aria-label="Duplicate group" className="flex flex-col gap-3 rounded-lg border border-amber-500/30 bg-card p-3 sm:flex-row sm:items-center" > <CosineSimilarityMeter similarity={group.similarity} caption={`Group ${i + 1} · ${group.photos.length} photos`} className="w-full sm:w-auto shrink-0" /> <div className="min-w-0 flex-1"> <ImageResultGallery cards={group.photos.map(toCard)} layout="grid" selectedIds={[...selected]} onSelect={(id) => toggleSelect(id)} scoreThresholds={{ high: 0.35, medium: 0.2 }} /> </div> </div> ))} </div> )} {} {groups !== null && uniquePhotos.length > 0 && ( <div className="flex flex-col gap-2"> <h2 className="text-sm font-semibold">Unique photos ({uniquePhotos.length})</h2> <ImageResultGallery cards={uniquePhotos.map(toCard)} layout={view} scoreThresholds={{ high: 0.35, medium: 0.2 }} /> </div> )} {} {groups !== null && !hasDuplicates && ( <p role="status" aria-live="polite" aria-label="Duplicate scan result" className="rounded-lg border border-dashed border-emerald-500/40 bg-emerald-500/5 px-4 py-8 text-center text-sm text-emerald-600 dark:text-emerald-400" > No duplicates found at {formatScore(threshold)} - all {embeddedCount} photos are unique. </p> )} {} {hasPhotos && groups === null && ( <div> <ImageResultGallery cards={lib.photos.map(toCard)} layout={view} onDelete={(id) => lib.deletePhoto(id)} scoreThresholds={{ high: 0.35, medium: 0.2 }} /> </div> )} {} <ul aria-label="Indexed photos" className="sr-only"> {lib.photos.map((photo) => ( <li key={photo.id} data-id={photo.id} data-filename={photo.filename} data-embedded={photo.embedding !== null} data-processing={photo.processing} > {photo.filename} {photo.embedding !== null ? ' (embedded)' : ' (embedding…)'} </li> ))} </ul> </div> );}Photo Categorizer
Sort photos into your own set of categories, edit the label list, re-sort the whole library at once, and filter by any single category.
Install this block
npx shadcn@latest add @localmode/ui/blocks/photo/photo-categorizeridle - load a model to start
Multimodal (CLIP)
CLIP ViT-B/32 (~350 MB) - not loaded. It powers both the image embeddings and the zero-shot categorization. Nothing downloads until you press Load.
Load a model to start
PNG, JPEG or WebP - categorized as they embed
PNG · JPEG · WebP · up to 10 MB
'use client';/** * @file photo-categorizer.tsx * @description Self-sufficient CLIP zero-shot photo categorizer — ingest photos (categorized on embed), edit the Photo/Product label set, re-categorize the whole library, and facet-filter by category. */import { useState } from 'react';import { Loader2, RefreshCw, Trash2 } from 'lucide-react';import { cn } from '@/lib/utils';import { ModelSelector, type SelectableModel } from '@/components/model-selector';import { ModelDownloader } from '@/components/model-downloader';import { MediaDropzone } from '@/components/media-dropzone';import { ImageResultGallery, type ImageResultCard } from '@/components/image-result-gallery';import { CategoryFacetList } from '@/components/category-facet-list';import { EditableLabelSet } from '@/components/editable-label-set';import { usePhotoLibrary, type PhotoEntry, type PhotoLibrary } from '@localmode/react';import { transformers, isModelCached } from '@localmode/transformers';interface PhotoModel { id: string; name: string; size: string; dimensions: number;}const DEFAULT_MODEL_ID = 'Xenova/clip-vit-base-patch32';const MODEL_CATALOG: PhotoModel[] = [ { id: 'Xenova/clip-vit-base-patch32', name: 'CLIP ViT-B/32', size: '~350 MB', dimensions: 512 }, { id: 'Xenova/siglip-base-patch16-224', name: 'SigLIP Base', size: '~400 MB', dimensions: 768 },];function getModel(id: string): PhotoModel { return MODEL_CATALOG.find((m) => m.id === id) ?? MODEL_CATALOG[0];}const PHOTO_LABELS = [ 'nature', 'people', 'animals', 'food', 'architecture', 'vehicles', 'art', 'technology', 'sports', 'other',];const PRODUCT_LABELS = [ 'Electronics', 'Clothing', 'Home & Garden', 'Toys', 'Food & Beverage', 'Sports', 'Books', 'Automotive', 'Health', 'Other',];const LABEL_PRESETS: Record<string, { label: string; labels: string[] }> = { photo: { label: 'Photo', labels: PHOTO_LABELS }, product: { label: 'Product', labels: PRODUCT_LABELS },};function categoryCounts(entries: PhotoEntry[]): Record<string, number> { const counts: Record<string, number> = {}; for (const entry of entries) { if (entry.processing || !entry.category) continue; counts[entry.category] = (counts[entry.category] ?? 0) + 1; } return counts;}function formatScore(score: number): string { return `${Math.round(score * 100)}%`;}const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];const SELECTABLE_MODELS: SelectableModel[] = MODEL_CATALOG.map((m) => ({ id: m.id, name: m.name, backend: 'onnx', category: 'Multimodal (CLIP)', size: m.size, vision: true,}));const toCard = (photo: PhotoEntry): ImageResultCard => ({ id: photo.id, src: photo.src, label: photo.filename, category: photo.processing ? 'Analyzing…' : photo.category ? photo.category : undefined, score: photo.processing || photo.confidence === 0 ? undefined : photo.confidence,});export function PhotoCategorizerBlock() { const lib: PhotoLibrary = usePhotoLibrary({ modelId: DEFAULT_MODEL_ID, createEmbeddingModel: (id, onProgress) => transformers.multimodalEmbedding(id, { onProgress: (p) => onProgress(p as never) }), createZeroShotClassifier: (id) => transformers.zeroShotImageClassifier(id), isModelCached: (id) => isModelCached(id), labelPresets: LABEL_PRESETS, getModelDimensions: (id) => getModel(id).dimensions, }); const [selected, setSelected] = useState<string | null>(null); const model = getModel(lib.activeModelId); const hasPhotos = lib.photos.length > 0; const disabled = !lib.modelReady || lib.busy || lib.switching; const counts = categoryCounts(lib.photos); const facetCategories = Array.from(new Set([...lib.labels, ...Object.keys(counts)])); const embeddedCount = lib.photos.filter((p) => p.embedding !== null).length; const filtered = selected ? lib.photos.filter((p) => p.category === selected) : lib.photos; const recategorizing = lib.recategorizeProgress != null; const statusText = lib.switching ? lib.reindexProgress ? `re-indexing ${lib.reindexProgress.completed}/${lib.reindexProgress.total}…` : 're-indexing library…' : lib.modelStatus === 'loading' ? `loading ${model.name}… ${Math.round(lib.modelProgress * 100)}%` : lib.ingestProgress ? `categorizing ${lib.ingestProgress.completed}/${lib.ingestProgress.total}…` : recategorizing && lib.recategorizeProgress ? `re-categorizing ${lib.recategorizeProgress.completed}/${lib.recategorizeProgress.total}…` : lib.error ? 'error' : lib.modelReady ? `ready - ${lib.photos.length} photo${lib.photos.length === 1 ? '' : 's'} categorized` : 'idle - load a model to start'; return ( <div className="mx-auto flex max-w-5xl flex-col gap-4 p-4"> {} <p role="status" aria-live="polite" className="text-xs text-muted-foreground" > {statusText} </p> {lib.error && ( <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" > <span>{lib.error}</span> <button type="button" onClick={lib.clearError} className="rounded px-2 py-0.5 font-medium hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Dismiss </button> </div> )} {} <div data-status={lib.modelStatus} data-model-id={lib.activeModelId} role="group" aria-label="CLIP model status" className="flex flex-col gap-3 rounded-xl border border-border bg-muted/40 p-4 sm:flex-row sm:items-start" > <div className="w-full sm:max-w-sm"> <ModelSelector models={SELECTABLE_MODELS} selectedId={lib.activeModelId} onSelect={(id) => lib.requestModel(id)} /> </div> <div className="flex min-w-0 flex-1 flex-col gap-2"> {lib.modelStatus === 'idle' ? ( <> <p className="text-sm text-muted-foreground"> <span className="font-medium text-foreground">{model.name}</span> ({model.size}) - not loaded. It powers both the image embeddings and the zero-shot categorization. Nothing downloads until you press Load. </p> <button type="button" onClick={() => void lib.loadModel()} className="inline-flex h-9 w-fit items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Load {model.name} </button> </> ) : ( <div> <ModelDownloader name={model.name} size={model.size} category="Multimodal (CLIP)" progress={lib.modelProgressValue} cached={lib.modelCached} ready={lib.modelReady && !lib.switching} className="max-w-sm" /> </div> )} {} {lib.pendingModelId && ( <div className="flex flex-col gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs" > <span className="text-foreground"> Switch to <span className="font-medium">{getModel(lib.pendingModelId).name}</span>? The {getModel(lib.pendingModelId).dimensions}-dim vector space is incompatible - all{' '} {lib.photos.length} photos will be re-embedded and re-categorized. </span> <div className="flex items-center gap-2"> <button type="button" onClick={lib.confirmModelSwitch} className="inline-flex h-7 items-center rounded-md bg-primary px-3 font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Confirm switch </button> <button type="button" onClick={lib.cancelModelSwitch} className="inline-flex h-7 items-center rounded-md border border-border px-3 font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> </div> )} {} {lib.switching && lib.reindexProgress && ( <div data-completed={lib.reindexProgress.completed} data-total={lib.reindexProgress.total} role="status" aria-live="polite" aria-label="Re-index progress" className="text-xs tabular-nums text-muted-foreground" > Re-indexing {lib.reindexProgress.completed}/{lib.reindexProgress.total} photos through{' '} {model.name}… </div> )} </div> </div> {} <div role="group" aria-label="Photo library upload"> <MediaDropzone accept={ACCEPTED} multiple disabled={disabled} addAnother={hasPhotos} processing={lib.ingestProgress != null} processingLabel={ lib.ingestProgress ? `Categorizing ${lib.ingestProgress.completed}/${lib.ingestProgress.total}…` : 'Processing…' } title={lib.modelReady ? 'Drop photos here' : 'Load a model to start'} subtitle="PNG, JPEG or WebP - categorized as they embed" onFiles={(files) => void lib.ingest(files)} onReject={(rejections) => lib.setRejection({ filename: rejections[0].file.name, reason: rejections[0].reason, }) } /> </div> {} {lib.rejection && ( <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive" > <span> Rejected <span className="font-medium">{lib.rejection.filename}</span>:{' '} {lib.rejection.reason} </span> <button type="button" onClick={() => lib.setRejection(null)} className="rounded px-2 py-0.5 text-xs font-medium hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Dismiss </button> </div> )} {} {lib.ingestProgress && ( <div data-completed={lib.ingestProgress.completed} data-total={lib.ingestProgress.total} role="status" aria-live="polite" aria-label="Categorization progress" className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2 text-sm" > <span className="tabular-nums text-muted-foreground"> Categorizing {lib.ingestProgress.completed}/{lib.ingestProgress.total}… </span> <button type="button" onClick={lib.cancelIngest} className="rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> )} {} <div className="flex flex-col gap-4 lg:flex-row"> {} <aside className="order-last w-full shrink-0 lg:order-none lg:w-56"> <h2 className="mb-2 text-sm font-semibold">Categories</h2> <div data-selected={selected ?? ''} role="group" aria-label="Category facets" > <CategoryFacetList categories={facetCategories} counts={counts} selected={selected} onSelect={setSelected} /> </div> </aside> {} <div className="flex min-w-0 flex-1 flex-col gap-4"> {} <div className="flex flex-wrap items-center gap-2"> <span className="text-sm font-medium">Label set:</span> <div role="group" aria-label="Label set presets" className="flex items-center gap-1" > {(Object.keys(LABEL_PRESETS) as Array<keyof typeof LABEL_PRESETS>).map((id) => ( <button key={id} type="button" data-preset={id} aria-pressed={lib.activePreset === id} onClick={() => lib.applyPreset(id)} className={cn( 'rounded-md border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', lib.activePreset === id ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-foreground hover:bg-accent', )} > {LABEL_PRESETS[id].label} </button> ))} {lib.activePreset === 'custom' && ( <span className="rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground"> Custom </span> )} </div> </div> {} <div> <EditableLabelSet labels={lib.labels} onAdd={(label) => lib.addLabel(label)} onRemove={(_, index) => lib.removeLabel(index)} placeholder="Add a category label…" /> </div> {} <div className="flex flex-wrap items-center gap-2"> <button type="button" onClick={() => void lib.recategorize()} disabled={!lib.modelReady || embeddedCount === 0 || lib.busy || lib.switching} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {recategorizing ? ( <Loader2 className="size-4 animate-spin" /> ) : ( <RefreshCw className="size-4" /> )} Re-categorize library </button> {recategorizing && lib.recategorizeProgress && ( <div data-completed={lib.recategorizeProgress.completed} data-total={lib.recategorizeProgress.total} role="status" aria-live="polite" aria-label="Re-categorization progress" className="flex items-center gap-2 text-xs tabular-nums text-muted-foreground" > Re-categorizing {lib.recategorizeProgress.completed}/ {lib.recategorizeProgress.total}… <button type="button" onClick={lib.cancelRecategorize} className="rounded border border-border px-2 py-0.5 font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> </div> )} <span className="ml-auto text-xs text-muted-foreground" > {embeddedCount} categorized photo{embeddedCount === 1 ? '' : 's'} </span> {hasPhotos && ( <button type="button" onClick={lib.clearAll} disabled={lib.busy || lib.switching} className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:border-destructive hover:text-destructive disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <Trash2 className="size-3.5" /> Clear all </button> )} </div> {} {filtered.length > 0 ? ( <div data-count={filtered.length} role="region" aria-label="Categorized photos" > <ImageResultGallery cards={filtered.map(toCard)} layout="grid" scoreThresholds={{ high: 0.35, medium: 0.2 }} onDelete={(id) => lib.deletePhoto(id)} /> </div> ) : lib.photos.length === 0 && !lib.modelReady ? null : ( <p className="rounded-lg border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground" > {lib.photos.length === 0 ? 'No photos yet. Drop images above - they are categorized as they embed.' : `No photos in “${selected}”.`} </p> )} </div> </div> {} <ul aria-label="Indexed photos" className="sr-only"> {lib.photos.map((photo) => ( <li key={photo.id} data-id={photo.id} data-filename={photo.filename} data-category={photo.category} data-confidence={photo.confidence.toFixed(4)} data-embedded={photo.embedding !== null} data-processing={photo.processing} > {photo.filename}: {photo.category} ({formatScore(photo.confidence)}) </li> ))} </ul> </div> );}