# Photo

4 on-device blocks in the Photo category — each installs and runs on its own, entirely in the browser.

## Smart Gallery

Build a photo library right in your browser that tags every image automatically as you add it. Browse it as a grid or list with filename, category, confidence, and how many similar photos it found, and delete, clear, or cancel at any time. Nothing downloads until you load a model.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/photo/smart-gallery
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/photo/smart-gallery.json

```tsx
'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 a photo library on your device by typing what you are looking for, or by dropping in a reference image. Both search the same photos, with a control for how many results to show and a minimum match threshold. Nothing downloads until you load a model.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/photo/image-search
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/photo/image-search.json

```tsx
'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

Load a photo library on your device, then find and group near-duplicate images. Tune how close a match has to be with quick presets, review each group's average similarity, and bulk-delete the extras while keeping the first of each group. Nothing downloads until you load a model.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/photo/duplicate-finder
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/photo/duplicate-finder.json

```tsx
'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

Add photos to a browser library where each one is sorted into a category as it loads. Edit the label list, re-sort the whole library at once, and filter down to any single category. Nothing downloads until you load a model.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/photo/photo-categorizer
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/photo/photo-categorizer.json

```tsx
'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>
  );
}
```
