diff --git a/README.md b/README.md index 784cc6b..6ee6ac6 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,30 @@ A local-first desktop media library for browsing, filtering, and curating image ## Features +### Library - Add and remove media folders; background indexing with live progress +- **Live file tracking** — a filesystem watcher keeps the library in sync as files are added, changed, or removed; renames and moves are handled in place, preserving thumbnails and embeddings - Browse all media or filter by folder, type (image/video), favorites, or star rating -- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`) -- **Similar image search** — find visually similar media by image or selected region -- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold control -- **Explore view** — visual cluster map and tag cloud for browsing by theme -- **Duplicate finder** — scan for exact duplicates by file hash with bulk delete -- Lightbox preview with keyboard navigation, inline tag editing, and rating controls -- Sort by date, name, size, rating, or duration +- Sort by date added, date taken (EXIF), name, size, rating, or duration - Grid density controls (compact / comfortable / detail) +- Desktop notifications, batched per folder, with per-folder mute and a global pause + +### Search & discovery +- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`) +- **Similar image search** — find visually similar media by image or a selected region +- **Explore view** — visual cluster map and tag cloud for browsing by theme +- **Timeline view** — media grouped chronologically by capture date (EXIF) +- **Duplicate finder** — three-phase exact-duplicate scan (size → sample hash → full hash) with live progress and bulk delete +- Explore, Timeline, and Duplicates are folder-scopable directly from their headers — no need to bounce through the sidebar + +### Viewing & curation +- Lightbox with keyboard navigation, zoom, inline tag editing, and rating controls +- **Custom video player** — immersive edge-to-edge playback with scrubbing, volume, playback speed, loop, and fullscreen; auto-hiding controls and full keyboard support +- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold, batch size, and per-folder queue targeting + +### Maintenance +- Database compaction and orphaned-thumbnail cleanup from Settings, with live size/reclaimable stats +- Per-folder pausing of background work (thumbnails, metadata, embeddings, tagging) ## Supported formats @@ -26,9 +40,10 @@ A local-first desktop media library for browsing, filtering, and curating image - Tauri 2 + Rust backend - React 19 + TypeScript + Zustand -- SQLite + `sqlite-vec` (vector search) +- SQLite + `sqlite-vec` (vector search) + HNSW index - ONNX Runtime (`ort`) for AI tagging -- Candle (Rust ML) for visual embeddings +- Candle (Rust ML) for CLIP visual embeddings +- mozjpeg for fast scaled JPEG decoding - FFmpeg sidecar for video thumbnails and metadata - Vite + Tailwind CSS v4 @@ -47,12 +62,16 @@ pnpm dev:vite # Production build pnpm build:app + +# Type-check the frontend +pnpm build:vite ``` ## How it works 1. Add a folder from the sidebar — the Rust indexer walks it recursively. -2. Supported files are written to SQLite with metadata (path, dimensions, media type, etc.). -3. Background workers generate thumbnails, compute visual embeddings, and run AI tagging. +2. Supported files are written to SQLite with metadata (path, dimensions, media type, EXIF capture date, etc.). +3. Background workers process the queue as a strict priority pipeline — thumbnails first, then video metadata, then visual embeddings, then AI tags — so each stage runs at full speed instead of competing for CPU, disk, and the database. 4. Progress events stream back to the UI while the gallery updates incrementally. -5. Embeddings power semantic search and the similar images feature via an HNSW index. +5. A filesystem watcher picks up later changes (new files, edits, deletions, renames) and keeps the index current without rescans. +6. Embeddings power semantic search and the similar-images feature via an HNSW index. diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index 67b361b..ce9c344 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { convertFileSrc } from "@tauri-apps/api/core"; import { DuplicateGroup, useGalleryStore } from "../store"; +import { FolderScopeDropdown } from "./FolderScopeDropdown"; function formatBytes(bytes: number): string { if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; @@ -188,6 +189,7 @@ export function DuplicateFinder() { )}
+ {/* Batch select — only shown when there are groups and nothing is selected yet */} {hasResults && selectedCount === 0 && !deleting && ( + {open ? ( +
+ + {folders.map((folder) => { + const active = selectedFolderId === folder.id; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index dadec21..4921002 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { convertFileSrc } from "@tauri-apps/api/core"; import { useGalleryStore, ImageTag, AiRating } from "../store"; +import { VideoPlayer } from "./VideoPlayer"; function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -223,8 +224,9 @@ export function Lightbox() { } } if (regionSelectMode) return; // block nav keys during selection - if (event.key === "ArrowLeft") goPrev(); - if (event.key === "ArrowRight") goNext(); + // Shift+arrows are reserved for video seeking (handled by VideoPlayer) + if (event.key === "ArrowLeft" && !event.shiftKey) goPrev(); + if (event.key === "ArrowRight" && !event.shiftKey) goNext(); if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25)); if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25)); }; @@ -365,19 +367,18 @@ export function Lightbox() { {selectedImage.media_kind === "video" ? ( -