feat: expand media discovery and AI workflows #8
@@ -29,3 +29,9 @@ dist-ssr
|
|||||||
|
|
||||||
# Local staging area
|
# Local staging area
|
||||||
/staging
|
/staging
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.py
|
||||||
|
*.json
|
||||||
|
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
**Phokus** — a Tauri v2 desktop image gallery app with a React/TypeScript frontend and a Rust backend. The app indexes local media folders, generates thumbnails via FFmpeg, computes visual embeddings for semantic search and similarity, and AI-tags images using the WD tagger (ONNX via `ort`). AI captioning code exists in the backend but the UI surface has been removed; the worker is commented out in `lib.rs`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (Vite hot-reload + Rust auto-rebuild)
|
||||||
|
pnpm dev:app
|
||||||
|
|
||||||
|
# Frontend only (no Tauri window)
|
||||||
|
pnpm dev:vite
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
pnpm build:app
|
||||||
|
|
||||||
|
# Type-check frontend
|
||||||
|
pnpm build:vite
|
||||||
|
```
|
||||||
|
|
||||||
|
Use **pnpm** — never npm.
|
||||||
|
|
||||||
|
There are no test suites configured.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Frontend (`src/`)
|
||||||
|
|
||||||
|
- **`store.ts`** — single Zustand store (`useGalleryStore`) that owns all app state and all `invoke()` calls to the Tauri backend. Every feature (folders, images, search, similar images, tags, captions, tagger, duplicates) is implemented as store actions here. React components are thin consumers.
|
||||||
|
- **`App.tsx`** — sets up Tauri event listeners (`subscribeToProgress`) and renders the top-level layout (sidebar + active view).
|
||||||
|
- **`src/components/`** — UI components: `Gallery`, `Lightbox`, `Sidebar`, `Toolbar`, `TagCloud`, `DuplicateFinder`, `BackgroundTasks`, `SettingsModal`, `MenuBar`, `TitleBar`.
|
||||||
|
- State management: Zustand v5, no selectors library — components call `useGalleryStore(s => s.field)` directly.
|
||||||
|
- Styling: Tailwind CSS v4 (Vite plugin, no config file).
|
||||||
|
- Virtualized gallery grid: `@tanstack/react-virtual`.
|
||||||
|
- Animation: `framer-motion`.
|
||||||
|
|
||||||
|
### Search modes
|
||||||
|
|
||||||
|
The search bar supports prefix syntax parsed by `parseSearchValue` in `store.ts`:
|
||||||
|
- No prefix / `f:` — filename search (paginated, DB-backed)
|
||||||
|
- `/s <query>` or `s: <query>` — semantic (embedding) search
|
||||||
|
- `/t <tag>` or `t: <tag>` — tag search
|
||||||
|
|
||||||
|
### Backend (`src-tauri/src/`)
|
||||||
|
|
||||||
|
Workers are started in `lib.rs` and run as background threads throughout the app lifetime:
|
||||||
|
- **thumbnail worker** (multiple threads, count from `StorageProfile::Balanced`)
|
||||||
|
- **metadata worker** — FFmpeg probe for video files
|
||||||
|
- **embedding worker** — generates CLIP-style visual embeddings (candle, HuggingFace hub)
|
||||||
|
- **tagging worker** — WD tagger via ONNX Runtime (`ort`), DirectML/CPU acceleration
|
||||||
|
|
||||||
|
Key modules:
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `db.rs` | SQLite pool (r2d2 + rusqlite), schema migrations, all query functions |
|
||||||
|
| `commands.rs` | All `#[tauri::command]` handlers — one-to-one with frontend `invoke()` calls |
|
||||||
|
| `indexer.rs` | Worker thread launchers and job dispatch |
|
||||||
|
| `embedder.rs` | Visual embedding generation (candle + HF hub models) |
|
||||||
|
| `vector.rs` | sqlite-vec integration + HNSW index for ANN search |
|
||||||
|
| `hnsw_index.rs` | In-memory HNSW index wrapper (hnsw_rs) |
|
||||||
|
| `tagger.rs` | WD tagger: ONNX model download, inference, CSV tag loading |
|
||||||
|
| `captioner.rs` | AI captioning (ONNX, disabled in workers but code intact) |
|
||||||
|
| `thumbnail.rs` | Thumbnail generation (image crate + fast_image_resize, FFmpeg for video) |
|
||||||
|
| `media.rs` | FFmpeg sidecar provisioning and probing |
|
||||||
|
| `storage.rs` | `StorageProfile` for tuning worker counts |
|
||||||
|
|
||||||
|
Database: SQLite with WAL mode, stored in the Tauri app data directory as `gallery.db`. Thumbnails stored alongside as `thumbnails/`.
|
||||||
|
|
||||||
|
### Tauri events (backend → frontend)
|
||||||
|
|
||||||
|
| Event | Payload |
|
||||||
|
|-------|---------|
|
||||||
|
| `index-progress` | `IndexProgress` |
|
||||||
|
| `media-job-progress` | `MediaJobProgressEvent` |
|
||||||
|
| `indexed-images` | `IndexedImagesBatch` |
|
||||||
|
| `media-updated` | `ThumbnailBatch` |
|
||||||
|
| `caption-model-progress` | `CaptionModelProgress` |
|
||||||
|
| `tagger-model-progress` | `TaggerModelProgress` |
|
||||||
|
| `duplicate_scan_progress` | `[scanned, total]` |
|
||||||
|
|
||||||
|
### Key types
|
||||||
|
|
||||||
|
`ImageRecord` (mirrored in `store.ts` and `db.rs`) is the central data type. It carries embedding status, tagging status, caption data, and media metadata. The frontend type must stay in sync with the Rust struct serialization.
|
||||||
|
|
||||||
|
## Development notes
|
||||||
|
|
||||||
|
- Hot Reload is active during `dev:app` — do not restart the server for frontend changes. Restart only when adding Rust crates or changing Vite config.
|
||||||
|
- ML inference crates (`candle-*`, `ort`, `image`, `rayon`, `tokenizers`, `xxhash-rust`, `rusqlite`) use `opt-level = 3` in dev profile to keep inference performance acceptable.
|
||||||
|
- The caption worker is intentionally disabled (`lib.rs:73`) — the backend code is intact for future re-enabling.
|
||||||
|
- **Never use `any` type** in TypeScript — look up correct types.
|
||||||
@@ -1,116 +1,58 @@
|
|||||||
# Phokus
|
# Phokus
|
||||||
|
|
||||||
## Overview
|
A local-first desktop media library for browsing, filtering, and curating image and video folders.
|
||||||
|
|
||||||
Phokus is a Tauri desktop app for building a fast, local media library from folders on disk. It indexes images and videos, stores metadata in SQLite, and gives you a dense browsing workflow with filtering, favorites, ratings, and a lightbox preview.
|
## Features
|
||||||
|
|
||||||
The current app is optimized for:
|
- Add and remove media folders; background indexing with live progress
|
||||||
|
- Browse all media or filter by folder, type (image/video), favorites, or star rating
|
||||||
- local folders instead of cloud import flows
|
- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
|
||||||
- large visual libraries
|
- **Similar image search** — find visually similar media by image or selected region
|
||||||
- quick review and curation
|
- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold control
|
||||||
- mixed image and video browsing
|
- **Explore view** — visual cluster map and tag cloud for browsing by theme
|
||||||
|
- **Duplicate finder** — scan for exact duplicates by file hash with bulk delete
|
||||||
## Current features
|
- Lightbox preview with keyboard navigation, inline tag editing, and rating controls
|
||||||
|
- Sort by date, name, size, rating, or duration
|
||||||
- Add and remove media folders
|
- Grid density controls (compact / comfortable / detail)
|
||||||
- Background indexing with progress updates
|
|
||||||
- Browse all media or filter by folder
|
|
||||||
- Search by filename
|
|
||||||
- Filter by images, videos, or favorites
|
|
||||||
- Sort by modified date, name, or file size
|
|
||||||
- Grid density controls
|
|
||||||
- Lightbox preview with keyboard navigation
|
|
||||||
- Favorite and star-rating metadata saved in SQLite
|
|
||||||
- Virtualized/local-first architecture built on Tauri + React
|
|
||||||
|
|
||||||
## Supported formats
|
## Supported formats
|
||||||
|
|
||||||
Images:
|
| Images | Videos |
|
||||||
|
|--------|--------|
|
||||||
- `jpg`
|
| jpg, jpeg, png, gif, bmp | mp4, mov, m4v |
|
||||||
- `jpeg`
|
| tiff, tif, webp, avif, heic, heif | webm |
|
||||||
- `png`
|
|
||||||
- `gif`
|
|
||||||
- `bmp`
|
|
||||||
- `tiff`
|
|
||||||
- `tif`
|
|
||||||
- `webp`
|
|
||||||
- `avif`
|
|
||||||
- `heic`
|
|
||||||
- `heif`
|
|
||||||
|
|
||||||
Videos:
|
|
||||||
|
|
||||||
- `mp4`
|
|
||||||
- `mov`
|
|
||||||
- `m4v`
|
|
||||||
- `webm`
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Tauri 2
|
- Tauri 2 + Rust backend
|
||||||
- React 19
|
- React 19 + TypeScript + Zustand
|
||||||
- TypeScript
|
- SQLite + `sqlite-vec` (vector search)
|
||||||
- Zustand
|
- ONNX Runtime (`ort`) for AI tagging
|
||||||
- Rust
|
- Candle (Rust ML) for visual embeddings
|
||||||
- SQLite + `sqlite-vec`
|
- FFmpeg sidecar for video thumbnails and metadata
|
||||||
- Vite
|
- Vite + Tailwind CSS v4
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
- `src/`: React UI, state, and components
|
|
||||||
- `src-tauri/src/commands.rs`: Tauri command surface
|
|
||||||
- `src-tauri/src/db.rs`: SQLite schema and queries
|
|
||||||
- `src-tauri/src/indexer.rs`: folder crawling and batch indexing
|
|
||||||
- `src-tauri/src/vector.rs`: vector table setup for future semantic workflows
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
**Prerequisites:** Node.js 20+, pnpm, Rust toolchain, Tauri system prerequisites for Windows.
|
||||||
|
|
||||||
- Node.js 20+
|
|
||||||
- `pnpm`
|
|
||||||
- Rust toolchain
|
|
||||||
- Tauri system prerequisites for Windows
|
|
||||||
|
|
||||||
### Install
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
|
||||||
|
|
||||||
### Run in development
|
# Run with hot-reload (frontend + Rust)
|
||||||
|
pnpm dev:app
|
||||||
|
|
||||||
```bash
|
# Frontend only
|
||||||
pnpm tauri dev
|
pnpm dev:vite
|
||||||
```
|
|
||||||
|
|
||||||
### Build
|
# Production build
|
||||||
|
pnpm build:app
|
||||||
```bash
|
|
||||||
pnpm tauri build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. Add a folder from the sidebar or Library menu.
|
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
|
||||||
2. The Rust indexer walks the directory recursively.
|
2. Supported files are written to SQLite with metadata (path, dimensions, media type, etc.).
|
||||||
3. Supported files are written into SQLite with metadata such as path, size, dimensions, media type, rating, and favorite state.
|
3. Background workers generate thumbnails, compute visual embeddings, and run AI tagging.
|
||||||
4. Progress events stream back to the UI while the gallery updates incrementally.
|
4. Progress events stream back to the UI while the gallery updates incrementally.
|
||||||
5. The gallery view loads media in pages and opens items in a lightbox for review.
|
5. Embeddings power semantic search and the similar images feature via an HNSW index.
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This is currently a local desktop library, not a sync product.
|
|
||||||
- Search is filename-based right now.
|
|
||||||
- The vector table and embedding fields exist, but semantic search is not wired into the UI yet.
|
|
||||||
- Some visible UI copy may still use the old working name until the frontend text is updated.
|
|
||||||
|
|
||||||
## Positioning
|
|
||||||
|
|
||||||
The clearest product description today is:
|
|
||||||
|
|
||||||
> A local-first desktop media library for browsing, filtering, and curating image and video folders.
|
|
||||||
|
|
||||||
That description is more accurate than "gallery" alone and gives you a better base for future branding, onboarding copy, and a landing page.
|
|
||||||
|
|||||||
+1
-3
@@ -2,11 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + React + Typescript</title>
|
<title>Phokus</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
|||||||
+7
-2
@@ -4,8 +4,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"build:app": "tauri build",
|
||||||
"build": "tsc && vite build",
|
"build:vite": "tsc && vite build",
|
||||||
|
"dev:app": "tauri dev",
|
||||||
|
"dev:vite": "vite",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
@@ -14,7 +16,9 @@
|
|||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
|||||||
Generated
+49
@@ -20,9 +20,15 @@ importers:
|
|||||||
'@tauri-apps/plugin-fs':
|
'@tauri-apps/plugin-fs':
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.5.0
|
version: 2.5.0
|
||||||
|
'@tauri-apps/plugin-notification':
|
||||||
|
specifier: ^2.3.3
|
||||||
|
version: 2.3.3
|
||||||
'@tauri-apps/plugin-opener':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.5.3
|
version: 2.5.3
|
||||||
|
d3-force:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.38.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -42,6 +48,9 @@ importers:
|
|||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
|
'@types/d3-force':
|
||||||
|
specifier: ^3.0.10
|
||||||
|
version: 3.0.10
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.1.8
|
specifier: ^19.1.8
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
@@ -647,6 +656,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-fs@2.5.0':
|
'@tauri-apps/plugin-fs@2.5.0':
|
||||||
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
|
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-notification@2.3.3':
|
||||||
|
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-opener@2.5.3':
|
'@tauri-apps/plugin-opener@2.5.3':
|
||||||
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
|
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
|
||||||
|
|
||||||
@@ -662,6 +674,9 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
|
'@types/d3-force@3.0.10':
|
||||||
|
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -698,6 +713,22 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-force@3.0.0:
|
||||||
|
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-quadtree@3.0.1:
|
||||||
|
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1423,6 +1454,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-notification@2.3.3':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-opener@2.5.3':
|
'@tauri-apps/plugin-opener@2.5.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.10.1
|
||||||
@@ -1448,6 +1483,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@types/d3-force@3.0.10': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
@@ -1486,6 +1523,18 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
|
d3-force@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-quadtree: 3.0.1
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-quadtree@3.0.1: {}
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
rustflags = [
|
||||||
|
# Disable MSVC incremental linking — avoids .ilk file corruption
|
||||||
|
# and removes one source of link failure on restart.
|
||||||
|
"-C", "link-arg=/INCREMENTAL:NO",
|
||||||
|
# Skip PDB generation entirely in dev builds.
|
||||||
|
# The .pdb file is the most common reason the linker fails after
|
||||||
|
# an unclean shutdown: the previous phokus.exe process holds the
|
||||||
|
# file open, so link.exe cannot write a new one → LNK error.
|
||||||
|
# Rust backtraces still work without MSVC PDBs.
|
||||||
|
"-C", "link-arg=/DEBUG:NONE",
|
||||||
|
]
|
||||||
Generated
+478
-1
@@ -61,6 +61,73 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anndists"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8396b473aa0bceed68fb32462505387ea39fa47c7029417e0a49f10592b036"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"cfg-if",
|
||||||
|
"cpu-time",
|
||||||
|
"env_logger",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"num-traits",
|
||||||
|
"num_cpus",
|
||||||
|
"rayon",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -266,6 +333,15 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -595,6 +671,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -626,6 +708,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -757,6 +845,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpu-time"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -874,6 +972,27 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||||
|
dependencies = [
|
||||||
|
"csv-core",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv-core"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctor"
|
name = "ctor"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -1331,12 +1450,35 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_filter"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_home"
|
name = "env_home"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_logger"
|
||||||
|
version = "0.11.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"env_filter",
|
||||||
|
"jiff",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -2319,6 +2461,8 @@ version = "0.15.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
"foldhash 0.1.5",
|
"foldhash 0.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2389,6 +2533,37 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac-sha256"
|
||||||
|
version = "1.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hnsw_rs"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43a5258f079b97bf2e8311ff9579e903c899dcbac0d9a138d62e9a066778bd07"
|
||||||
|
dependencies = [
|
||||||
|
"anndists",
|
||||||
|
"anyhow",
|
||||||
|
"bincode",
|
||||||
|
"cfg-if",
|
||||||
|
"cpu-time",
|
||||||
|
"env_logger",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap 2.13.1",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"mmap-rs",
|
||||||
|
"num-traits",
|
||||||
|
"num_cpus",
|
||||||
|
"parking_lot",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"rayon",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
@@ -2792,6 +2967,12 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -2830,6 +3011,30 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff"
|
||||||
|
version = "0.2.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||||
|
dependencies = [
|
||||||
|
"jiff-static",
|
||||||
|
"log",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-static"
|
||||||
|
version = "0.2.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2931,6 +3136,12 @@ dependencies = [
|
|||||||
"selectors 0.24.0",
|
"selectors 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3059,6 +3270,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lzma-rust2"
|
||||||
|
version = "0.15.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lzma-sys"
|
name = "lzma-sys"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -3076,6 +3293,27 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac-notification-sys"
|
||||||
|
version = "0.6.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50efa634682b3fc5a1ab6f3dd5b2bce7b848011fc485b53b063dc68f2f74feae"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "macro_rules_attribute"
|
name = "macro_rules_attribute"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3134,6 +3372,16 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matrixmultiply"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"rawpointer",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -3192,6 +3440,23 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mmap-rs"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ecce9d566cb9234ae3db9e249c8b55665feaaf32b0859ff1e27e310d2beb3d8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"combine",
|
||||||
|
"libc",
|
||||||
|
"mach2",
|
||||||
|
"nix",
|
||||||
|
"sysctl",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"widestring",
|
||||||
|
"windows 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "monostate"
|
name = "monostate"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -3262,6 +3527,21 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndarray"
|
||||||
|
version = "0.17.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||||
|
dependencies = [
|
||||||
|
"matrixmultiply",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"rawpointer",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3298,6 +3578,18 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodrop"
|
name = "nodrop"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -3314,6 +3606,20 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-rust"
|
||||||
|
version = "4.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
|
||||||
|
dependencies = [
|
||||||
|
"futures-lite",
|
||||||
|
"log",
|
||||||
|
"mac-notification-sys",
|
||||||
|
"serde",
|
||||||
|
"tauri-winrt-notification",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ntapi"
|
name = "ntapi"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -3576,6 +3882,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "onig"
|
name = "onig"
|
||||||
version = "6.5.1"
|
version = "6.5.1"
|
||||||
@@ -3670,6 +3982,31 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ort"
|
||||||
|
version = "2.0.0-rc.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
|
||||||
|
dependencies = [
|
||||||
|
"libloading 0.9.0",
|
||||||
|
"ndarray",
|
||||||
|
"ort-sys",
|
||||||
|
"smallvec",
|
||||||
|
"tracing",
|
||||||
|
"ureq",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ort-sys"
|
||||||
|
version = "2.0.0-rc.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
|
||||||
|
dependencies = [
|
||||||
|
"hmac-sha256",
|
||||||
|
"lzma-rust2",
|
||||||
|
"ureq",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -3947,11 +4284,15 @@ dependencies = [
|
|||||||
"candle-nn",
|
"candle-nn",
|
||||||
"candle-transformers",
|
"candle-transformers",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"csv",
|
||||||
"fast_image_resize",
|
"fast_image_resize",
|
||||||
"ffmpeg-sidecar",
|
"ffmpeg-sidecar",
|
||||||
"hf-hub",
|
"hf-hub",
|
||||||
|
"hnsw_rs",
|
||||||
"image",
|
"image",
|
||||||
"log",
|
"log",
|
||||||
|
"memmap2",
|
||||||
|
"ort",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"r2d2_sqlite",
|
"r2d2_sqlite",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -3964,12 +4305,15 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tokenizers",
|
"tokenizers",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"ureq",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"xxhash-rust",
|
"xxhash-rust",
|
||||||
|
"zip 4.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4009,7 +4353,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.13.1",
|
"indexmap 2.13.1",
|
||||||
"quick-xml",
|
"quick-xml 0.38.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -4060,6 +4404,15 @@ version = "1.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -4217,6 +4570,15 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.37.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -4421,6 +4783,12 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rawpointer"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -5684,6 +6052,25 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-notification"
|
||||||
|
version = "2.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"notify-rust",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.3"
|
version = "2.5.3"
|
||||||
@@ -5806,6 +6193,18 @@ dependencies = [
|
|||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||||
|
dependencies = [
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"windows 0.61.3",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -6473,6 +6872,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.0"
|
version = "1.23.0"
|
||||||
@@ -6836,6 +7241,12 @@ dependencies = [
|
|||||||
"winsafe",
|
"winsafe",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "widestring"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -6882,6 +7293,15 @@ dependencies = [
|
|||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
@@ -7134,6 +7554,21 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.42.2",
|
"windows_x86_64_msvc 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
|
"windows_aarch64_msvc 0.48.5",
|
||||||
|
"windows_i686_gnu 0.48.5",
|
||||||
|
"windows_i686_msvc 0.48.5",
|
||||||
|
"windows_x86_64_gnu 0.48.5",
|
||||||
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
|
"windows_x86_64_msvc 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7200,6 +7635,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7218,6 +7659,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7236,6 +7683,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7266,6 +7719,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7284,6 +7743,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7302,6 +7767,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7320,6 +7791,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ rusqlite = { version = "0.32", features = ["bundled"] }
|
|||||||
r2d2 = "0.8"
|
r2d2 = "0.8"
|
||||||
r2d2_sqlite = "0.25"
|
r2d2_sqlite = "0.25"
|
||||||
sqlite-vec = "=0.1.9"
|
sqlite-vec = "=0.1.9"
|
||||||
|
hnsw_rs = "0.3.4"
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] }
|
||||||
fast_image_resize = { version = "6.0.0", features = ["image"] }
|
fast_image_resize = { version = "6.0.0", features = ["image"] }
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
@@ -38,9 +39,63 @@ anyhow = "1"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
ffmpeg-sidecar = "2.5.0"
|
ffmpeg-sidecar = "2.5.0"
|
||||||
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
||||||
|
memmap2 = "0.9"
|
||||||
sysinfo = "0.38.4"
|
sysinfo = "0.38.4"
|
||||||
candle-core = { version = "0.10.2", features = ["cuda"] }
|
candle-core = { version = "0.10.2", features = ["cuda"] }
|
||||||
candle-nn = { version = "0.10.2", features = ["cuda"] }
|
candle-nn = { version = "0.10.2", features = ["cuda"] }
|
||||||
candle-transformers = { version = "0.10.2", features = ["cuda"] }
|
candle-transformers = { version = "0.10.2", features = ["cuda"] }
|
||||||
hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] }
|
hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] }
|
||||||
tokenizers = "0.22.1"
|
tokenizers = "0.22.1"
|
||||||
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "download-binaries", "copy-dylibs", "load-dynamic", "directml", "api-24", "tls-native"] }
|
||||||
|
ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
|
||||||
|
zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
|
||||||
|
csv = "1"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
|
# ── Dev-mode performance ────────────────────────────────────────────────────
|
||||||
|
# opt-level=1 on the main crate keeps incremental compile short.
|
||||||
|
# Only the packages that are genuine hot-path bottlenecks in dev get opt-level=3
|
||||||
|
# (using "*" caused cargo to recheck all dependency fingerprints too aggressively,
|
||||||
|
# which caused spurious full rebuilds and linker conflicts).
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
|
# ML inference — without opt these run 20-50× slower than release
|
||||||
|
[profile.dev.package.candle-core]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.candle-nn]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.candle-transformers]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# ONNX runtime (WD tagger)
|
||||||
|
[profile.dev.package.ort]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.ort-sys]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# Image decode/resize workers
|
||||||
|
[profile.dev.package.image]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.fast_image_resize]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# Parallel work scheduler
|
||||||
|
[profile.dev.package.rayon]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.rayon-core]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# Tokenisation (embedding model pre-processing)
|
||||||
|
[profile.dev.package.tokenizers]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# Hashing (duplicate finder)
|
||||||
|
[profile.dev.package.xxhash-rust]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# SQLite (frequent db calls in workers)
|
||||||
|
[profile.dev.package.rusqlite]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.libsqlite3-sys]
|
||||||
|
opt-level = 3
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"fs:allow-read-dir",
|
"fs:allow-read-dir",
|
||||||
"fs:read-files",
|
"fs:read-files",
|
||||||
"fs:read-dirs",
|
"fs:read-dirs",
|
||||||
|
"notification:default",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1184
-48
File diff suppressed because it is too large
Load Diff
+1307
-36
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,51 @@ impl ClipImageEmbedder {
|
|||||||
Ok(self.embed_images(&[path.to_path_buf()])?.remove(0))
|
Ok(self.embed_images(&[path.to_path_buf()])?.remove(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed a cropped region of an image without writing a temp file to disk.
|
||||||
|
/// `crop_x`, `crop_y`, `crop_w`, `crop_h` are normalized 0.0–1.0 coordinates.
|
||||||
|
pub fn embed_image_crop(
|
||||||
|
&self,
|
||||||
|
path: &Path,
|
||||||
|
crop_x: f32,
|
||||||
|
crop_y: f32,
|
||||||
|
crop_w: f32,
|
||||||
|
crop_h: f32,
|
||||||
|
) -> Result<Vec<f32>> {
|
||||||
|
let img = image::ImageReader::open(path)?
|
||||||
|
.with_guessed_format()?
|
||||||
|
.decode()?;
|
||||||
|
|
||||||
|
let img_w = img.width() as f32;
|
||||||
|
let img_h = img.height() as f32;
|
||||||
|
|
||||||
|
let x = ((crop_x * img_w) as u32).min(img.width().saturating_sub(1));
|
||||||
|
let y = ((crop_y * img_h) as u32).min(img.height().saturating_sub(1));
|
||||||
|
let w = ((crop_w * img_w) as u32).max(1).min(img.width() - x);
|
||||||
|
let h = ((crop_h * img_h) as u32).max(1).min(img.height() - y);
|
||||||
|
|
||||||
|
let cropped = img.crop_imm(x, y, w, h);
|
||||||
|
let resized = cropped.resize_to_fill(
|
||||||
|
self.image_size as u32,
|
||||||
|
self.image_size as u32,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw = resized.to_rgb8().into_raw();
|
||||||
|
let tensor = candle_core::Tensor::from_vec(
|
||||||
|
raw,
|
||||||
|
(self.image_size, self.image_size, 3),
|
||||||
|
&candle_core::Device::Cpu,
|
||||||
|
)?
|
||||||
|
.permute((2, 0, 1))?
|
||||||
|
.to_dtype(candle_core::DType::F32)?
|
||||||
|
.affine(2.0 / 255.0, -1.0)?;
|
||||||
|
|
||||||
|
let batch = tensor.unsqueeze(0)?.to_device(&self.device)?;
|
||||||
|
let features = self.model.get_image_features(&batch)?;
|
||||||
|
let normalized = candle_transformers::models::clip::div_l2_norm(&features)?;
|
||||||
|
Ok(normalized.get(0)?.flatten_all()?.to_vec1::<f32>()?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn embed_images(&self, paths: &[PathBuf]) -> Result<Vec<Vec<f32>>> {
|
pub fn embed_images(&self, paths: &[PathBuf]) -> Result<Vec<Vec<f32>>> {
|
||||||
let images = load_images(paths, self.image_size)?.to_device(&self.device)?;
|
let images = load_images(paths, self.image_size)?.to_device(&self.device)?;
|
||||||
let features = self.model.get_image_features(&images)?;
|
let features = self.model.get_image_features(&images)?;
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
use crate::vector;
|
||||||
|
use anyhow::Result;
|
||||||
|
use hnsw_rs::prelude::{DistCosine, Hnsw, Neighbour};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
|
const HNSW_MAX_CONNECTIONS: usize = 24;
|
||||||
|
const HNSW_EF_CONSTRUCTION: usize = 300;
|
||||||
|
const HNSW_EF_SEARCH: usize = 96;
|
||||||
|
|
||||||
|
struct CachedHnswIndex {
|
||||||
|
revision: String,
|
||||||
|
image_ids_by_external: Vec<i64>,
|
||||||
|
external_by_image_id: HashMap<i64, usize>,
|
||||||
|
hnsw: Hnsw<'static, f32, DistCosine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static IMAGE_HNSW_INDEX: OnceLock<RwLock<Option<CachedHnswIndex>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn cache() -> &'static RwLock<Option<CachedHnswIndex>> {
|
||||||
|
IMAGE_HNSW_INDEX.get_or_init(|| RwLock::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_index(conn: &Connection) -> Result<CachedHnswIndex> {
|
||||||
|
// Read the revision *before* fetching embeddings so we can detect any write
|
||||||
|
// that races with the build. If the revision advances while we are building,
|
||||||
|
// the resulting index would be stale — retry until it is stable.
|
||||||
|
loop {
|
||||||
|
let revision_before = vector::get_embedding_revision(conn)?;
|
||||||
|
|
||||||
|
let embeddings = vector::get_all_image_embeddings_with_ids(conn, None)?;
|
||||||
|
let max_elements = embeddings.len().max(1);
|
||||||
|
let max_layer = 16.min((max_elements as f32).ln().trunc() as usize).max(1);
|
||||||
|
let mut hnsw = Hnsw::<f32, DistCosine>::new(
|
||||||
|
HNSW_MAX_CONNECTIONS,
|
||||||
|
max_elements,
|
||||||
|
max_layer,
|
||||||
|
HNSW_EF_CONSTRUCTION,
|
||||||
|
DistCosine {},
|
||||||
|
);
|
||||||
|
|
||||||
|
let image_ids_by_external = embeddings
|
||||||
|
.iter()
|
||||||
|
.map(|(image_id, _)| *image_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let external_by_image_id = image_ids_by_external
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(external_id, image_id)| (*image_id, external_id))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let data_with_id = embeddings
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(external_id, (_, embedding))| (embedding, external_id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
hnsw.parallel_insert(&data_with_id);
|
||||||
|
hnsw.set_searching_mode(true);
|
||||||
|
|
||||||
|
// If the revision is unchanged the index reflects a consistent snapshot.
|
||||||
|
let revision_after = vector::get_embedding_revision(conn)?;
|
||||||
|
if revision_before == revision_after {
|
||||||
|
return Ok(CachedHnswIndex {
|
||||||
|
revision: revision_after,
|
||||||
|
image_ids_by_external,
|
||||||
|
external_by_image_id,
|
||||||
|
hnsw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A concurrent write advanced the revision — discard this build and retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_index(conn: &Connection) -> Result<()> {
|
||||||
|
let revision = vector::get_embedding_revision(conn)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let guard = cache().read().expect("hnsw cache poisoned");
|
||||||
|
if guard.as_ref().map(|cached| cached.revision.as_str()) == Some(revision.as_str()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = build_index(conn)?;
|
||||||
|
let mut guard = cache().write().expect("hnsw cache poisoned");
|
||||||
|
*guard = Some(next);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_similar_image_matches(
|
||||||
|
conn: &Connection,
|
||||||
|
image_id: i64,
|
||||||
|
folder_id: Option<i64>,
|
||||||
|
threshold: f32,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<(i64, f32)>> {
|
||||||
|
ensure_index(conn)?;
|
||||||
|
|
||||||
|
let query_embedding = match vector::get_image_embedding(conn, image_id)? {
|
||||||
|
Some(embedding) => embedding,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch folder image IDs *before* acquiring the read lock so we don't hold
|
||||||
|
// the lock across a potentially slow SQLite query, which would delay any
|
||||||
|
// concurrent ensure_index call waiting for a write lock.
|
||||||
|
let folder_image_ids: Option<Vec<i64>> = if let Some(folder_id) = folder_id {
|
||||||
|
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.collect();
|
||||||
|
Some(ids)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let guard = cache().read().expect("hnsw cache poisoned");
|
||||||
|
let Some(cached) = guard.as_ref() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let knbn = (offset + limit).max(limit).saturating_add(32);
|
||||||
|
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids {
|
||||||
|
let mut allowed_ids = image_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|allowed_image_id| {
|
||||||
|
cached.external_by_image_id.get(&allowed_image_id).copied()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
allowed_ids.sort_unstable();
|
||||||
|
cached
|
||||||
|
.hnsw
|
||||||
|
.search_filter(&query_embedding, knbn, HNSW_EF_SEARCH, Some(&allowed_ids))
|
||||||
|
} else {
|
||||||
|
cached.hnsw.search(&query_embedding, knbn, HNSW_EF_SEARCH)
|
||||||
|
};
|
||||||
|
|
||||||
|
let matches = neighbours
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|neighbour| {
|
||||||
|
let image_id_match = cached.image_ids_by_external.get(neighbour.d_id).copied()?;
|
||||||
|
if image_id_match == image_id || neighbour.distance > threshold {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((image_id_match, neighbour.distance))
|
||||||
|
})
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(matches)
|
||||||
|
}
|
||||||
+477
-51
@@ -1,7 +1,9 @@
|
|||||||
|
use crate::captioner::{self, FlorenceCaptioner};
|
||||||
use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, IndexedMediaEntry};
|
use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, IndexedMediaEntry};
|
||||||
use crate::embedder::{embedding_source_path, ClipImageEmbedder};
|
use crate::embedder::{embedding_source_path, ClipImageEmbedder};
|
||||||
use crate::media::{probe_video_metadata, MediaTools};
|
use crate::media::{probe_video_metadata, MediaTools};
|
||||||
use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile};
|
use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile};
|
||||||
|
use crate::tagger::{self, WdTagger};
|
||||||
use crate::thumbnail;
|
use crate::thumbnail;
|
||||||
use crate::vector;
|
use crate::vector;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -9,7 +11,6 @@ use rayon::prelude::*;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
@@ -22,29 +23,97 @@ const IMAGE_EXTENSIONS: &[&str] = &[
|
|||||||
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];
|
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];
|
||||||
|
|
||||||
const JOB_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(750);
|
const JOB_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(750);
|
||||||
|
const CAPTION_BATCH_SIZE: usize = 1;
|
||||||
|
|
||||||
static LAST_JOB_PROGRESS_EMIT: OnceLock<Mutex<HashMap<i64, Instant>>> = OnceLock::new();
|
static LAST_JOB_PROGRESS_EMIT: OnceLock<Mutex<HashMap<i64, Instant>>> = OnceLock::new();
|
||||||
static ACTIVE_INDEXING_FOLDERS: OnceLock<Mutex<HashSet<i64>>> = OnceLock::new();
|
static ACTIVE_INDEXING_FOLDERS: OnceLock<Mutex<HashSet<i64>>> = OnceLock::new();
|
||||||
|
|
||||||
static THUMBNAIL_WORKER_PAUSED: AtomicBool = AtomicBool::new(false);
|
static PAUSED_WORKER_FOLDERS: OnceLock<Mutex<PausedWorkerFolders>> = OnceLock::new();
|
||||||
static METADATA_WORKER_PAUSED: AtomicBool = AtomicBool::new(false);
|
|
||||||
static EMBEDDING_WORKER_PAUSED: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
pub fn set_worker_paused(worker: &str, paused: bool) {
|
#[derive(Default)]
|
||||||
match worker {
|
struct PausedWorkerFolders {
|
||||||
"thumbnail" => THUMBNAIL_WORKER_PAUSED.store(paused, Ordering::Relaxed),
|
thumbnail: HashSet<i64>,
|
||||||
"metadata" => METADATA_WORKER_PAUSED.store(paused, Ordering::Relaxed),
|
metadata: HashSet<i64>,
|
||||||
"embedding" => EMBEDDING_WORKER_PAUSED.store(paused, Ordering::Relaxed),
|
embedding: HashSet<i64>,
|
||||||
_ => {}
|
caption: HashSet<i64>,
|
||||||
|
tagging: HashSet<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct FolderWorkerPausedState {
|
||||||
|
pub thumbnail: bool,
|
||||||
|
pub metadata: bool,
|
||||||
|
pub embedding: bool,
|
||||||
|
pub caption: bool,
|
||||||
|
pub tagging: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_worker_paused(worker: &str, folder_id: i64, paused: bool) {
|
||||||
|
if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS
|
||||||
|
.get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
|
||||||
|
.lock()
|
||||||
|
{
|
||||||
|
let folder_set = match worker {
|
||||||
|
"thumbnail" => Some(&mut paused_folders.thumbnail),
|
||||||
|
"metadata" => Some(&mut paused_folders.metadata),
|
||||||
|
"embedding" => Some(&mut paused_folders.embedding),
|
||||||
|
"caption" => Some(&mut paused_folders.caption),
|
||||||
|
"tagging" => Some(&mut paused_folders.tagging),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(folder_set) = folder_set {
|
||||||
|
if paused {
|
||||||
|
folder_set.insert(folder_id);
|
||||||
|
} else {
|
||||||
|
folder_set.remove(&folder_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_worker_paused_states() -> [bool; 3] {
|
pub fn get_worker_paused_states(folder_ids: &[i64]) -> HashMap<i64, FolderWorkerPausedState> {
|
||||||
[
|
let Ok(paused_folders) = PAUSED_WORKER_FOLDERS
|
||||||
THUMBNAIL_WORKER_PAUSED.load(Ordering::Relaxed),
|
.get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
|
||||||
METADATA_WORKER_PAUSED.load(Ordering::Relaxed),
|
.lock()
|
||||||
EMBEDDING_WORKER_PAUSED.load(Ordering::Relaxed),
|
else {
|
||||||
]
|
return HashMap::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
folder_ids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|folder_id| {
|
||||||
|
(
|
||||||
|
folder_id,
|
||||||
|
FolderWorkerPausedState {
|
||||||
|
thumbnail: paused_folders.thumbnail.contains(&folder_id),
|
||||||
|
metadata: paused_folders.metadata.contains(&folder_id),
|
||||||
|
embedding: paused_folders.embedding.contains(&folder_id),
|
||||||
|
caption: paused_folders.caption.contains(&folder_id),
|
||||||
|
tagging: paused_folders.tagging.contains(&folder_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paused_folder_ids(worker: &str) -> HashSet<i64> {
|
||||||
|
let Ok(paused_folders) = PAUSED_WORKER_FOLDERS
|
||||||
|
.get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
|
||||||
|
.lock()
|
||||||
|
else {
|
||||||
|
return HashSet::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
match worker {
|
||||||
|
"thumbnail" => paused_folders.thumbnail.clone(),
|
||||||
|
"metadata" => paused_folders.metadata.clone(),
|
||||||
|
"embedding" => paused_folders.embedding.clone(),
|
||||||
|
"caption" => paused_folders.caption.clone(),
|
||||||
|
"tagging" => paused_folders.tagging.clone(),
|
||||||
|
_ => HashSet::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
static FOLDER_STORAGE_PROFILES: OnceLock<Mutex<HashMap<i64, RuntimeAdaptiveProfile>>> =
|
static FOLDER_STORAGE_PROFILES: OnceLock<Mutex<HashMap<i64, RuntimeAdaptiveProfile>>> =
|
||||||
OnceLock::new();
|
OnceLock::new();
|
||||||
@@ -78,11 +147,50 @@ pub struct MediaJobProgressEvent {
|
|||||||
|
|
||||||
pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) {
|
pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
set_folder_indexing_state(folder_id, true);
|
||||||
|
|
||||||
|
// If the folder path no longer exists on disk, record the error and
|
||||||
|
// emit done. Images are intentionally kept in the DB so the user can
|
||||||
|
// choose to relocate the folder or remove it explicitly — they should
|
||||||
|
// not be silently destroyed.
|
||||||
|
if !folder_path.is_dir() {
|
||||||
|
let error_msg = format!("Folder not found: {}", folder_path.display());
|
||||||
|
eprintln!("Indexing error for folder {}: {}", folder_id, error_msg);
|
||||||
|
if let Ok(conn) = pool.get() {
|
||||||
|
let _ = db::set_folder_scan_error(&conn, folder_id, &error_msg);
|
||||||
|
}
|
||||||
|
emit_progress(
|
||||||
|
&app,
|
||||||
|
&IndexProgress {
|
||||||
|
folder_id,
|
||||||
|
total: 0,
|
||||||
|
indexed: 0,
|
||||||
|
current_file: String::new(),
|
||||||
|
done: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
set_folder_indexing_state(folder_id, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let storage_profile = detect_storage_profile(&folder_path);
|
let storage_profile = detect_storage_profile(&folder_path);
|
||||||
set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile));
|
set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile));
|
||||||
set_folder_indexing_state(folder_id, true);
|
if let Err(error) = do_index(app.clone(), &pool, folder_id, folder_path) {
|
||||||
if let Err(error) = do_index(app, pool, folder_id, folder_path) {
|
eprintln!("Indexing error for folder {}: {}", folder_id, error);
|
||||||
eprintln!("Indexing error: {}", error);
|
if let Ok(conn) = pool.get() {
|
||||||
|
let _ = db::set_folder_scan_error(&conn, folder_id, &error.to_string());
|
||||||
|
}
|
||||||
|
// Always emit done so the frontend reloads and recovers from partial state.
|
||||||
|
emit_progress(
|
||||||
|
&app,
|
||||||
|
&IndexProgress {
|
||||||
|
folder_id,
|
||||||
|
total: 0,
|
||||||
|
indexed: 0,
|
||||||
|
current_file: String::new(),
|
||||||
|
done: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
set_folder_indexing_state(folder_id, false);
|
set_folder_indexing_state(folder_id, false);
|
||||||
});
|
});
|
||||||
@@ -95,10 +203,6 @@ pub fn start_thumbnail_worker(
|
|||||||
cache_dir: PathBuf,
|
cache_dir: PathBuf,
|
||||||
) {
|
) {
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || loop {
|
||||||
if THUMBNAIL_WORKER_PAUSED.load(Ordering::Relaxed) {
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Err(error) = process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) {
|
if let Err(error) = process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) {
|
||||||
eprintln!("Thumbnail worker error: {}", error);
|
eprintln!("Thumbnail worker error: {}", error);
|
||||||
}
|
}
|
||||||
@@ -108,10 +212,6 @@ pub fn start_thumbnail_worker(
|
|||||||
|
|
||||||
pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaTools) {
|
pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaTools) {
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || loop {
|
||||||
if METADATA_WORKER_PAUSED.load(Ordering::Relaxed) {
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Err(error) = process_metadata_batch(&app, &pool, &media_tools) {
|
if let Err(error) = process_metadata_batch(&app, &pool, &media_tools) {
|
||||||
eprintln!("Metadata worker error: {}", error);
|
eprintln!("Metadata worker error: {}", error);
|
||||||
}
|
}
|
||||||
@@ -124,10 +224,6 @@ pub fn start_embedding_worker(app: AppHandle, pool: DbPool) {
|
|||||||
let mut embedder: Option<ClipImageEmbedder> = None;
|
let mut embedder: Option<ClipImageEmbedder> = None;
|
||||||
println!("Embedding worker started.");
|
println!("Embedding worker started.");
|
||||||
loop {
|
loop {
|
||||||
if EMBEDDING_WORKER_PAUSED.load(Ordering::Relaxed) {
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Err(error) = process_embedding_batch(&app, &pool, &mut embedder) {
|
if let Err(error) = process_embedding_batch(&app, &pool, &mut embedder) {
|
||||||
eprintln!("Embedding worker error: {}", error);
|
eprintln!("Embedding worker error: {}", error);
|
||||||
}
|
}
|
||||||
@@ -136,7 +232,49 @@ pub fn start_embedding_worker(app: AppHandle, pool: DbPool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
|
pub fn start_caption_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut captioner: Option<FlorenceCaptioner> = None;
|
||||||
|
println!("Caption worker started.");
|
||||||
|
loop {
|
||||||
|
// If the acceleration setting changed, drop the cached session so
|
||||||
|
// the next batch picks it up with the new execution provider.
|
||||||
|
if captioner::CAPTION_SESSION_DIRTY.swap(false, std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
println!("Caption worker: acceleration setting changed — resetting session.");
|
||||||
|
captioner = None;
|
||||||
|
}
|
||||||
|
if let Err(error) = process_caption_batch(&app, &pool, &app_data_dir, &mut captioner) {
|
||||||
|
eprintln!("Caption worker error: {}", error);
|
||||||
|
captioner = None;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(750));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut tagger_instance: Option<WdTagger> = None;
|
||||||
|
println!("Tagging worker started.");
|
||||||
|
loop {
|
||||||
|
// If the acceleration setting changed, drop the cached session so
|
||||||
|
// the next batch picks it up with the new execution provider.
|
||||||
|
if tagger::TAGGER_SESSION_DIRTY.swap(false, std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
println!("Tagging worker: acceleration setting changed — resetting session.");
|
||||||
|
tagger_instance = None;
|
||||||
|
}
|
||||||
|
if let Err(error) =
|
||||||
|
process_tagging_batch(&app, &pool, &app_data_dir, &mut tagger_instance)
|
||||||
|
{
|
||||||
|
eprintln!("Tagging worker error: {}", error);
|
||||||
|
tagger_instance = None;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(750));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
|
||||||
let existing_entries = {
|
let existing_entries = {
|
||||||
let conn = pool.get()?;
|
let conn = pool.get()?;
|
||||||
db::get_folder_media_index(&conn, folder_id)?
|
db::get_folder_media_index(&conn, folder_id)?
|
||||||
@@ -146,12 +284,32 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
|
|||||||
.map(|entry| (entry.path.clone(), entry))
|
.map(|entry| (entry.path.clone(), entry))
|
||||||
.collect::<HashMap<_, _>>();
|
.collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
|
// Collect traversal errors separately. An unreadable subdirectory means we
|
||||||
|
// cannot distinguish absent files from inaccessible ones; tracking errors
|
||||||
|
// lets us skip the deletion step for paths under affected subtrees.
|
||||||
|
let mut unreadable_prefixes: Vec<String> = Vec::new();
|
||||||
let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
|
let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
|
||||||
.follow_links(true)
|
.follow_links(true)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|entry| entry.ok())
|
.filter_map(|entry| match entry {
|
||||||
.filter(|entry| entry.file_type().is_file() && is_supported_media(entry.path()))
|
Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => {
|
||||||
.map(|entry| entry.path().to_path_buf())
|
Some(e.path().to_path_buf())
|
||||||
|
}
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(err) => {
|
||||||
|
// Record the inaccessible path so we can protect its descendants
|
||||||
|
// from the missing-file deletion pass.
|
||||||
|
if let Some(path) = err.path() {
|
||||||
|
unreadable_prefixes.push(path.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"WalkDir error while scanning folder {}: {}",
|
||||||
|
folder_path.display(),
|
||||||
|
err
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let total = media_paths.len();
|
let total = media_paths.len();
|
||||||
@@ -197,7 +355,7 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
|
|||||||
images: committed,
|
images: committed,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
emit_folder_job_progress(&app, &pool, &[folder_id]);
|
emit_folder_job_progress(&app, &pool, &[folder_id], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
processed += path_chunk.len();
|
processed += path_chunk.len();
|
||||||
@@ -224,6 +382,17 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
|
|||||||
let missing_ids = existing_by_path
|
let missing_ids = existing_by_path
|
||||||
.values()
|
.values()
|
||||||
.filter(|entry| !seen_paths.contains(&entry.path))
|
.filter(|entry| !seen_paths.contains(&entry.path))
|
||||||
|
.filter(|entry| {
|
||||||
|
// If this path lives under a subtree that WalkDir couldn't read,
|
||||||
|
// we don't know whether the file is gone or just temporarily
|
||||||
|
// inaccessible — keep the record to avoid false deletions.
|
||||||
|
// Use Path::starts_with (component-aware) so a sibling directory
|
||||||
|
// whose name shares a prefix is not incorrectly protected.
|
||||||
|
let entry_path = std::path::Path::new(&entry.path);
|
||||||
|
unreadable_prefixes
|
||||||
|
.iter()
|
||||||
|
.all(|prefix| !entry_path.starts_with(prefix))
|
||||||
|
})
|
||||||
.map(|entry| entry.id)
|
.map(|entry| entry.id)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -232,7 +401,14 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
|
|||||||
if !missing_ids.is_empty() {
|
if !missing_ids.is_empty() {
|
||||||
db::delete_images_by_ids(&conn, &missing_ids)?;
|
db::delete_images_by_ids(&conn, &missing_ids)?;
|
||||||
}
|
}
|
||||||
|
let _ = db::backfill_embedding_jobs(&conn)?;
|
||||||
db::update_folder_count(&conn, folder_id)?;
|
db::update_folder_count(&conn, folder_id)?;
|
||||||
|
let _ = db::clear_folder_scan_error(&conn, folder_id);
|
||||||
|
// Invalidate duplicate scan cache — any reindex can change file contents
|
||||||
|
// or the set of files, making cached duplicate groups stale.
|
||||||
|
let folder_scope = format!("folder:{}", folder_id);
|
||||||
|
let _ = db::clear_duplicate_scan_cache(&conn, &folder_scope);
|
||||||
|
let _ = db::clear_duplicate_scan_cache(&conn, "all");
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_progress(
|
emit_progress(
|
||||||
@@ -245,7 +421,7 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
|
|||||||
done: true,
|
done: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
emit_folder_job_progress(&app, &pool, &[folder_id]);
|
emit_folder_job_progress(&app, &pool, &[folder_id], true);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +478,14 @@ fn build_record(
|
|||||||
embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()),
|
embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()),
|
||||||
embedding_updated_at: None,
|
embedding_updated_at: None,
|
||||||
embedding_error: None,
|
embedding_error: None,
|
||||||
|
generated_caption: None,
|
||||||
|
caption_model: None,
|
||||||
|
caption_updated_at: None,
|
||||||
|
caption_error: None,
|
||||||
|
ai_rating: None,
|
||||||
|
ai_tagger_model: None,
|
||||||
|
ai_tagged_at: None,
|
||||||
|
ai_tagger_error: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,11 +519,13 @@ fn process_thumbnail_batch(
|
|||||||
with_db_write_lock(|| {
|
with_db_write_lock(|| {
|
||||||
let mut conn = pool.get()?;
|
let mut conn = pool.get()?;
|
||||||
let active_folders = active_indexing_folders();
|
let active_folders = active_indexing_folders();
|
||||||
|
let paused_folders = paused_folder_ids("thumbnail");
|
||||||
let worker_batch_size = max_worker_batch_size(&active_folders);
|
let worker_batch_size = max_worker_batch_size(&active_folders);
|
||||||
let worker_fetch_size = max_worker_fetch_size(&active_folders);
|
let worker_fetch_size = max_worker_fetch_size(&active_folders);
|
||||||
db::claim_thumbnail_jobs(
|
db::claim_thumbnail_jobs(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
&active_folders,
|
&active_folders,
|
||||||
|
&paused_folders,
|
||||||
worker_fetch_size,
|
worker_fetch_size,
|
||||||
worker_batch_size,
|
worker_batch_size,
|
||||||
)
|
)
|
||||||
@@ -428,7 +614,7 @@ fn process_thumbnail_batch(
|
|||||||
images: updated_images,
|
images: updated_images,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>());
|
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -439,11 +625,13 @@ fn process_metadata_batch(app: &AppHandle, pool: &DbPool, media_tools: &MediaToo
|
|||||||
with_db_write_lock(|| {
|
with_db_write_lock(|| {
|
||||||
let mut conn = pool.get()?;
|
let mut conn = pool.get()?;
|
||||||
let active_folders = active_indexing_folders();
|
let active_folders = active_indexing_folders();
|
||||||
|
let paused_folders = paused_folder_ids("metadata");
|
||||||
let worker_batch_size = max_worker_batch_size(&active_folders);
|
let worker_batch_size = max_worker_batch_size(&active_folders);
|
||||||
let worker_fetch_size = max_worker_fetch_size(&active_folders);
|
let worker_fetch_size = max_worker_fetch_size(&active_folders);
|
||||||
db::claim_metadata_jobs(
|
db::claim_metadata_jobs(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
&active_folders,
|
&active_folders,
|
||||||
|
&paused_folders,
|
||||||
worker_fetch_size,
|
worker_fetch_size,
|
||||||
worker_batch_size,
|
worker_batch_size,
|
||||||
)
|
)
|
||||||
@@ -506,7 +694,7 @@ fn process_metadata_batch(app: &AppHandle, pool: &DbPool, media_tools: &MediaToo
|
|||||||
images: updated_images,
|
images: updated_images,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>());
|
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -518,14 +706,11 @@ fn process_embedding_batch(
|
|||||||
embedder: &mut Option<ClipImageEmbedder>,
|
embedder: &mut Option<ClipImageEmbedder>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let batch_started_at = Instant::now();
|
let batch_started_at = Instant::now();
|
||||||
if embedder.is_none() {
|
|
||||||
*embedder = Some(ClipImageEmbedder::new()?);
|
|
||||||
}
|
|
||||||
|
|
||||||
let claim_started_at = Instant::now();
|
let claim_started_at = Instant::now();
|
||||||
|
let paused_folders = paused_folder_ids("embedding");
|
||||||
let jobs = with_db_write_lock(|| {
|
let jobs = with_db_write_lock(|| {
|
||||||
let mut conn = pool.get()?;
|
let mut conn = pool.get()?;
|
||||||
db::claim_embedding_jobs(&mut conn, EMBEDDING_BATCH_SIZE)
|
db::claim_embedding_jobs(&mut conn, &paused_folders, EMBEDDING_BATCH_SIZE)
|
||||||
})?;
|
})?;
|
||||||
let claim_elapsed = claim_started_at.elapsed();
|
let claim_elapsed = claim_started_at.elapsed();
|
||||||
|
|
||||||
@@ -533,9 +718,18 @@ fn process_embedding_batch(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if embedder.is_none() {
|
||||||
|
*embedder = Some(ClipImageEmbedder::new()?);
|
||||||
|
}
|
||||||
|
|
||||||
println!("Embedding batch claimed: {} items", jobs.len());
|
println!("Embedding batch claimed: {} items", jobs.len());
|
||||||
let folder_ids = jobs.iter().map(|job| job.folder_id).collect::<HashSet<_>>();
|
let folder_ids = jobs.iter().map(|job| job.folder_id).collect::<HashSet<_>>();
|
||||||
emit_folder_job_progress(app, pool, &folder_ids.iter().copied().collect::<Vec<_>>());
|
emit_folder_job_progress(
|
||||||
|
app,
|
||||||
|
pool,
|
||||||
|
&folder_ids.iter().copied().collect::<Vec<_>>(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
let embedder = embedder.as_ref().expect("embedder should be initialized");
|
let embedder = embedder.as_ref().expect("embedder should be initialized");
|
||||||
|
|
||||||
let infer_started_at = Instant::now();
|
let infer_started_at = Instant::now();
|
||||||
@@ -639,7 +833,7 @@ fn process_embedding_batch(
|
|||||||
images: updated_images,
|
images: updated_images,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>());
|
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let write_elapsed = write_started_at.elapsed();
|
let write_elapsed = write_started_at.elapsed();
|
||||||
@@ -652,6 +846,237 @@ fn process_embedding_batch(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn process_caption_batch(
|
||||||
|
app: &AppHandle,
|
||||||
|
pool: &DbPool,
|
||||||
|
app_data_dir: &Path,
|
||||||
|
captioner: &mut Option<FlorenceCaptioner>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !captioner::caption_model_status(app_data_dir).ready {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let paused_folders = paused_folder_ids("caption");
|
||||||
|
let jobs = with_db_write_lock(|| {
|
||||||
|
let mut conn = pool.get()?;
|
||||||
|
db::claim_caption_jobs(&mut conn, &paused_folders, CAPTION_BATCH_SIZE)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if jobs.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if captioner.is_none() {
|
||||||
|
match FlorenceCaptioner::new(app_data_dir) {
|
||||||
|
Ok(model) => *captioner = Some(model),
|
||||||
|
Err(error) => {
|
||||||
|
with_db_write_lock(|| {
|
||||||
|
let conn = pool.get()?;
|
||||||
|
db::requeue_caption_jobs(
|
||||||
|
&conn,
|
||||||
|
&jobs.iter().map(|job| job.image_id).collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder_ids = jobs.iter().map(|job| job.folder_id).collect::<HashSet<_>>();
|
||||||
|
emit_folder_job_progress(
|
||||||
|
app,
|
||||||
|
pool,
|
||||||
|
&folder_ids.iter().copied().collect::<Vec<_>>(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let captioner = captioner
|
||||||
|
.as_mut()
|
||||||
|
.expect("captioner should be initialized before caption batch processing");
|
||||||
|
let caption_results = jobs
|
||||||
|
.iter()
|
||||||
|
.map(|job| (job.clone(), captioner.generate(Path::new(&job.path))))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let updated_images = with_db_write_lock(|| {
|
||||||
|
let mut conn = pool.get()?;
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let mut updated_images = Vec::with_capacity(caption_results.len());
|
||||||
|
|
||||||
|
for (job, caption_result) in &caption_results {
|
||||||
|
if db::is_caption_job_cancelled(&tx, job.image_id)? {
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM caption_jobs WHERE image_id = ?1",
|
||||||
|
[job.image_id],
|
||||||
|
)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match caption_result {
|
||||||
|
Ok(caption) => {
|
||||||
|
updated_images.push(db::update_generated_caption(
|
||||||
|
&tx,
|
||||||
|
job.image_id,
|
||||||
|
caption,
|
||||||
|
captioner::FLORENCE_CAPTION_MODEL_NAME,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
db::mark_caption_failed(&tx, job.image_id, &error.to_string())?;
|
||||||
|
updated_images.push(db::get_image_by_id(&tx, job.image_id)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(updated_images)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !updated_images.is_empty() {
|
||||||
|
let folder_ids = updated_images
|
||||||
|
.iter()
|
||||||
|
.map(|image| image.folder_id)
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
emit_media_updates(
|
||||||
|
app,
|
||||||
|
&MediaUpdateBatch {
|
||||||
|
images: updated_images,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_tagging_batch(
|
||||||
|
app: &AppHandle,
|
||||||
|
pool: &DbPool,
|
||||||
|
app_data_dir: &Path,
|
||||||
|
tagger_instance: &mut Option<WdTagger>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !tagger::tagger_model_status(app_data_dir).ready {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let paused_folders = paused_folder_ids("tagging");
|
||||||
|
let batch_size = crate::tagger::tagger_batch_size(app_data_dir);
|
||||||
|
let jobs = with_db_write_lock(|| {
|
||||||
|
let mut conn = pool.get()?;
|
||||||
|
db::claim_tagging_jobs(&mut conn, &paused_folders, batch_size)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if jobs.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagger_instance.is_none() {
|
||||||
|
match WdTagger::new(app_data_dir) {
|
||||||
|
Ok(model) => *tagger_instance = Some(model),
|
||||||
|
Err(error) => {
|
||||||
|
with_db_write_lock(|| {
|
||||||
|
let conn = pool.get()?;
|
||||||
|
db::requeue_tagging_jobs(
|
||||||
|
&conn,
|
||||||
|
&jobs.iter().map(|job| job.image_id).collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder_ids = jobs.iter().map(|job| job.folder_id).collect::<HashSet<_>>();
|
||||||
|
emit_folder_job_progress(
|
||||||
|
app,
|
||||||
|
pool,
|
||||||
|
&folder_ids.iter().copied().collect::<Vec<_>>(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let tagger_ref = tagger_instance
|
||||||
|
.as_mut()
|
||||||
|
.expect("tagger should be initialized before tagging batch processing");
|
||||||
|
|
||||||
|
let tag_results = jobs
|
||||||
|
.iter()
|
||||||
|
.map(|job| {
|
||||||
|
(
|
||||||
|
job.clone(),
|
||||||
|
tagger_ref.run(Path::new(&job.path), tagger::DEFAULT_MAX_TAGS),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let updated_images = with_db_write_lock(|| {
|
||||||
|
let mut conn = pool.get()?;
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let mut updated_images = Vec::with_capacity(tag_results.len());
|
||||||
|
|
||||||
|
for (job, tag_result) in &tag_results {
|
||||||
|
// If the job is no longer in 'processing' state it was either
|
||||||
|
// cancelled or reset to 'pending' (pause) while inference was running.
|
||||||
|
// Discard the result in both cases — for cancelled rows also delete
|
||||||
|
// the row; for paused rows leave it so the job is retried later.
|
||||||
|
if !db::is_tagging_job_processing(&tx, job.image_id)? {
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM tagging_jobs WHERE image_id = ?1 AND status = 'cancelled'",
|
||||||
|
[job.image_id],
|
||||||
|
)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match tag_result {
|
||||||
|
Ok(output) => {
|
||||||
|
let tag_pairs: Vec<(String, f64)> = output
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.map(|t| (t.tag.clone(), t.confidence as f64))
|
||||||
|
.collect();
|
||||||
|
db::update_ai_tags(
|
||||||
|
&tx,
|
||||||
|
job.image_id,
|
||||||
|
&tag_pairs,
|
||||||
|
&output.rating,
|
||||||
|
tagger::WD_TAGGER_MODEL_NAME,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
db::mark_tagging_failed(&tx, job.image_id, &error.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated_images.push(db::get_image_by_id(&tx, job.image_id)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(updated_images)
|
||||||
|
})
|
||||||
|
.or_else(|db_err| {
|
||||||
|
// The DB write failed. Try to requeue the claimed jobs so they aren't
|
||||||
|
// left stuck in 'processing' until the next app restart.
|
||||||
|
let image_ids: Vec<i64> = jobs.iter().map(|job| job.image_id).collect();
|
||||||
|
let _ = with_db_write_lock(|| {
|
||||||
|
let conn = pool.get()?;
|
||||||
|
db::requeue_tagging_jobs(&conn, &image_ids)
|
||||||
|
});
|
||||||
|
Err(db_err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !updated_images.is_empty() {
|
||||||
|
let folder_ids = updated_images
|
||||||
|
.iter()
|
||||||
|
.map(|image| image.folder_id)
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
emit_media_updates(
|
||||||
|
app,
|
||||||
|
&MediaUpdateBatch {
|
||||||
|
images: updated_images,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn active_indexing_folders() -> HashSet<i64> {
|
fn active_indexing_folders() -> HashSet<i64> {
|
||||||
ACTIVE_INDEXING_FOLDERS
|
ACTIVE_INDEXING_FOLDERS
|
||||||
.get_or_init(|| Mutex::new(HashSet::new()))
|
.get_or_init(|| Mutex::new(HashSet::new()))
|
||||||
@@ -737,7 +1162,7 @@ fn emit_media_updates(app: &AppHandle, batch: &MediaUpdateBatch) {
|
|||||||
let _ = app.emit("media-updated", batch);
|
let _ = app.emit("media-updated", batch);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_folder_job_progress(app: &AppHandle, pool: &DbPool, folder_ids: &[i64]) {
|
pub fn emit_folder_job_progress(app: &AppHandle, pool: &DbPool, folder_ids: &[i64], force: bool) {
|
||||||
let mut unique_folder_ids = folder_ids.iter().copied().collect::<Vec<_>>();
|
let mut unique_folder_ids = folder_ids.iter().copied().collect::<Vec<_>>();
|
||||||
unique_folder_ids.sort_unstable();
|
unique_folder_ids.sort_unstable();
|
||||||
unique_folder_ids.dedup();
|
unique_folder_ids.dedup();
|
||||||
@@ -749,10 +1174,11 @@ fn emit_folder_job_progress(app: &AppHandle, pool: &DbPool, folder_ids: &[i64])
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
unique_folder_ids.retain(|folder_id| {
|
unique_folder_ids.retain(|folder_id| {
|
||||||
let should_emit = tracker
|
let should_emit = force
|
||||||
.get(folder_id)
|
|| tracker
|
||||||
.map(|last_emit| now.duration_since(*last_emit) >= JOB_PROGRESS_EMIT_INTERVAL)
|
.get(folder_id)
|
||||||
.unwrap_or(true);
|
.map(|last_emit| now.duration_since(*last_emit) >= JOB_PROGRESS_EMIT_INTERVAL)
|
||||||
|
.unwrap_or(true);
|
||||||
if should_emit {
|
if should_emit {
|
||||||
tracker.insert(*folder_id, now);
|
tracker.insert(*folder_id, now);
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-3
@@ -1,14 +1,17 @@
|
|||||||
|
mod captioner;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
mod embedder;
|
mod embedder;
|
||||||
|
mod hnsw_index;
|
||||||
mod indexer;
|
mod indexer;
|
||||||
mod media;
|
mod media;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
mod tagger;
|
||||||
mod thumbnail;
|
mod thumbnail;
|
||||||
mod vector;
|
mod vector;
|
||||||
|
|
||||||
use tauri::Manager;
|
|
||||||
use crate::storage::StorageProfile;
|
use crate::storage::StorageProfile;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -16,6 +19,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let app_dir = app
|
let app_dir = app
|
||||||
.path()
|
.path()
|
||||||
@@ -34,11 +38,19 @@ pub fn run() {
|
|||||||
let conn = pool.get().expect("Failed to get connection for migration");
|
let conn = pool.get().expect("Failed to get connection for migration");
|
||||||
db::migrate(&conn).expect("Failed to run migrations");
|
db::migrate(&conn).expect("Failed to run migrations");
|
||||||
db::reset_inflight_jobs(&conn).expect("Failed to reset inflight jobs");
|
db::reset_inflight_jobs(&conn).expect("Failed to reset inflight jobs");
|
||||||
let backfilled = db::backfill_embedding_jobs(&conn)
|
let backfilled =
|
||||||
.expect("Failed to backfill embedding jobs");
|
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
|
||||||
if backfilled > 0 {
|
if backfilled > 0 {
|
||||||
println!("Backfilled {} embedding jobs.", backfilled);
|
println!("Backfilled {} embedding jobs.", backfilled);
|
||||||
}
|
}
|
||||||
|
let (orphaned_vectors, missing_vectors) = db::repair_embedding_consistency(&conn)
|
||||||
|
.expect("Failed to repair embedding consistency");
|
||||||
|
if orphaned_vectors > 0 || missing_vectors > 0 {
|
||||||
|
println!(
|
||||||
|
"Repaired embedding consistency: removed {} orphaned vectors, requeued {} missing vectors.",
|
||||||
|
orphaned_vectors, missing_vectors
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let thumb_dir = app_dir.join("thumbnails");
|
let thumb_dir = app_dir.join("thumbnails");
|
||||||
@@ -58,6 +70,9 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
indexer::start_metadata_worker(app.handle().clone(), pool.clone(), media_tools.clone());
|
indexer::start_metadata_worker(app.handle().clone(), pool.clone(), media_tools.clone());
|
||||||
indexer::start_embedding_worker(app.handle().clone(), pool.clone());
|
indexer::start_embedding_worker(app.handle().clone(), pool.clone());
|
||||||
|
// Caption worker disabled — UI removed; keeping backend code intact for future use.
|
||||||
|
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||||
|
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||||
|
|
||||||
app.manage(pool);
|
app.manage(pool);
|
||||||
app.manage(media_tools);
|
app.manage(media_tools);
|
||||||
@@ -73,12 +88,54 @@ pub fn run() {
|
|||||||
commands::reindex_folder,
|
commands::reindex_folder,
|
||||||
commands::update_image_details,
|
commands::update_image_details,
|
||||||
commands::find_similar_images,
|
commands::find_similar_images,
|
||||||
|
commands::find_similar_by_region,
|
||||||
|
commands::debug_similar_images,
|
||||||
commands::retry_failed_embeddings,
|
commands::retry_failed_embeddings,
|
||||||
commands::semantic_search_images,
|
commands::semantic_search_images,
|
||||||
|
commands::search_images_by_tag,
|
||||||
|
commands::get_caption_model_status,
|
||||||
|
commands::get_caption_acceleration,
|
||||||
|
commands::set_caption_acceleration,
|
||||||
|
commands::get_caption_detail,
|
||||||
|
commands::set_caption_detail,
|
||||||
|
commands::prepare_caption_model,
|
||||||
|
commands::delete_caption_model,
|
||||||
|
commands::probe_caption_runtime,
|
||||||
|
commands::probe_caption_image,
|
||||||
|
commands::generate_caption_for_image,
|
||||||
|
commands::queue_caption_jobs,
|
||||||
|
commands::clear_caption_jobs,
|
||||||
|
commands::reset_generated_captions,
|
||||||
|
commands::set_generated_caption,
|
||||||
|
commands::suggest_image_tags,
|
||||||
commands::set_worker_paused,
|
commands::set_worker_paused,
|
||||||
commands::get_worker_states,
|
commands::get_worker_states,
|
||||||
commands::get_tag_cloud,
|
commands::get_tag_cloud,
|
||||||
|
commands::get_explore_tags,
|
||||||
|
commands::get_images_by_ids,
|
||||||
commands::get_failed_embedding_images,
|
commands::get_failed_embedding_images,
|
||||||
|
commands::get_tagger_model_status,
|
||||||
|
commands::get_tagger_acceleration,
|
||||||
|
commands::set_tagger_acceleration,
|
||||||
|
commands::probe_tagger_runtime,
|
||||||
|
commands::get_tagger_threshold,
|
||||||
|
commands::set_tagger_threshold,
|
||||||
|
commands::get_tagger_batch_size,
|
||||||
|
commands::set_tagger_batch_size,
|
||||||
|
commands::prepare_tagger_model,
|
||||||
|
commands::delete_tagger_model,
|
||||||
|
commands::queue_tagging_jobs,
|
||||||
|
commands::clear_tagging_jobs,
|
||||||
|
commands::get_image_tags,
|
||||||
|
commands::add_user_tag,
|
||||||
|
commands::remove_tag,
|
||||||
|
commands::search_tags_autocomplete,
|
||||||
|
commands::find_duplicates,
|
||||||
|
commands::load_duplicate_scan_cache,
|
||||||
|
commands::invalidate_duplicate_scan_cache,
|
||||||
|
commands::delete_images_from_disk,
|
||||||
|
commands::rename_folder,
|
||||||
|
commands::update_folder_path,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -0,0 +1,656 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use hf_hub::{api::sync::Api, Repo, RepoType};
|
||||||
|
use image::{imageops::FilterType, DynamicImage, ImageReader};
|
||||||
|
use ort::ep;
|
||||||
|
use ort::session::{builder::GraphOptimizationLevel, Session};
|
||||||
|
use ort::value::Tensor;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub const WD_TAGGER_MODEL_ID: &str = "SmilingWolf/wd-swinv2-tagger-v3";
|
||||||
|
pub const WD_TAGGER_MODEL_NAME: &str = "wd-swinv2-tagger-v3";
|
||||||
|
|
||||||
|
const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt";
|
||||||
|
const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt";
|
||||||
|
const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt";
|
||||||
|
|
||||||
|
// Files required on disk before the tagger can run. The ONNX runtime DLLs
|
||||||
|
// are shared with the captioner and live in the same `onnxruntime/` directory.
|
||||||
|
const TAGGER_REQUIRED_FILES: &[&str] = &[
|
||||||
|
"onnxruntime/onnxruntime.dll",
|
||||||
|
"onnxruntime/onnxruntime_providers_shared.dll",
|
||||||
|
"onnxruntime/DirectML.dll",
|
||||||
|
"model.onnx",
|
||||||
|
"selected_tags.csv",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tags in these Danbooru categories are kept in the output.
|
||||||
|
// Category 0 = general, category 4 = character.
|
||||||
|
// Category 9 = rating (explicit/questionable/sensitive/general) – used for
|
||||||
|
// `ai_rating` but NOT emitted as individual tags.
|
||||||
|
const GENERAL_CATEGORY: u8 = 0;
|
||||||
|
const CHARACTER_CATEGORY: u8 = 4;
|
||||||
|
const RATING_CATEGORY: u8 = 9;
|
||||||
|
|
||||||
|
pub const DEFAULT_THRESHOLD: f32 = 0.35;
|
||||||
|
pub const DEFAULT_MAX_TAGS: usize = 30;
|
||||||
|
|
||||||
|
/// Set to `true` by `set_tagger_acceleration` so the tagging worker loop
|
||||||
|
/// knows to drop its cached `WdTagger` and reload with the new EP.
|
||||||
|
pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TaggerAcceleration {
|
||||||
|
Auto,
|
||||||
|
Cpu,
|
||||||
|
Directml,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaggerAcceleration {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Auto => "auto",
|
||||||
|
Self::Cpu => "cpu",
|
||||||
|
Self::Directml => "directml",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TaggerAcceleration {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status / probe types exposed to the frontend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TaggerModelStatus {
|
||||||
|
pub model_id: &'static str,
|
||||||
|
pub model_name: &'static str,
|
||||||
|
pub local_dir: String,
|
||||||
|
pub ready: bool,
|
||||||
|
pub missing_files: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct TaggerModelProgress {
|
||||||
|
pub total_files: usize,
|
||||||
|
pub completed_files: usize,
|
||||||
|
pub current_file: Option<String>,
|
||||||
|
pub done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Runtime probe types exposed to the frontend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TaggerRuntimeProbe {
|
||||||
|
pub ready: bool,
|
||||||
|
pub acceleration: TaggerAcceleration,
|
||||||
|
pub session: TaggerRuntimeSessionProbe,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TaggerRuntimeSessionProbe {
|
||||||
|
pub file: &'static str,
|
||||||
|
pub inputs: Vec<String>,
|
||||||
|
pub outputs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tag record returned to callers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TagResult {
|
||||||
|
pub tag: String,
|
||||||
|
pub confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TaggerOutput {
|
||||||
|
pub tags: Vec<TagResult>,
|
||||||
|
/// Highest-scoring rating label: "general" | "sensitive" | "questionable" | "explicit"
|
||||||
|
pub rating: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal label table built from selected_tags.csv
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TagEntry {
|
||||||
|
name: String,
|
||||||
|
category: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn model_dir(app_data_dir: &Path) -> PathBuf {
|
||||||
|
app_data_dir.join("models").join("wd-swinv2-tagger-v3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings persistence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn tagger_acceleration(app_data_dir: &Path) -> TaggerAcceleration {
|
||||||
|
let path = app_data_dir.join(TAGGER_ACCELERATION_FILE);
|
||||||
|
let Ok(value) = std::fs::read_to_string(path) else {
|
||||||
|
return TaggerAcceleration::default();
|
||||||
|
};
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"cpu" => TaggerAcceleration::Cpu,
|
||||||
|
"directml" => TaggerAcceleration::Directml,
|
||||||
|
_ => TaggerAcceleration::Auto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_tagger_acceleration(
|
||||||
|
app_data_dir: &Path,
|
||||||
|
acceleration: TaggerAcceleration,
|
||||||
|
) -> Result<TaggerAcceleration> {
|
||||||
|
let path = app_data_dir.join(TAGGER_ACCELERATION_FILE);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, acceleration.as_str())?;
|
||||||
|
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
|
||||||
|
Ok(acceleration)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tagger_threshold(app_data_dir: &Path) -> f32 {
|
||||||
|
let path = app_data_dir.join(TAGGER_THRESHOLD_FILE);
|
||||||
|
let Ok(value) = std::fs::read_to_string(path) else {
|
||||||
|
return DEFAULT_THRESHOLD;
|
||||||
|
};
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.unwrap_or(DEFAULT_THRESHOLD)
|
||||||
|
.clamp(0.01, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result<f32> {
|
||||||
|
let clamped = threshold.clamp(0.01, 1.0);
|
||||||
|
let path = app_data_dir.join(TAGGER_THRESHOLD_FILE);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, clamped.to_string())?;
|
||||||
|
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
|
||||||
|
Ok(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tagger_batch_size(app_data_dir: &Path) -> usize {
|
||||||
|
let path = app_data_dir.join(TAGGER_BATCH_SIZE_FILE);
|
||||||
|
let Ok(value) = std::fs::read_to_string(path) else {
|
||||||
|
return 8;
|
||||||
|
};
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap_or(8)
|
||||||
|
.clamp(1, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
|
||||||
|
let clamped = batch_size.clamp(1, 100);
|
||||||
|
let path = app_data_dir.join(TAGGER_BATCH_SIZE_FILE);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, clamped.to_string())?;
|
||||||
|
Ok(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Model status / download
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn tagger_model_status(app_data_dir: &Path) -> TaggerModelStatus {
|
||||||
|
let local_dir = model_dir(app_data_dir);
|
||||||
|
// The ONNX runtime DLLs live in the caption model dir; reuse them.
|
||||||
|
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
|
||||||
|
let missing_files = TAGGER_REQUIRED_FILES
|
||||||
|
.iter()
|
||||||
|
.filter(|file| {
|
||||||
|
let path = if file.starts_with("onnxruntime/") {
|
||||||
|
caption_model_dir.join(file)
|
||||||
|
} else {
|
||||||
|
local_dir.join(file)
|
||||||
|
};
|
||||||
|
!path.exists()
|
||||||
|
})
|
||||||
|
.map(|file| (*file).to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
TaggerModelStatus {
|
||||||
|
model_id: WD_TAGGER_MODEL_ID,
|
||||||
|
model_name: WD_TAGGER_MODEL_NAME,
|
||||||
|
local_dir: local_dir.to_string_lossy().to_string(),
|
||||||
|
ready: missing_files.is_empty(),
|
||||||
|
missing_files,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare_tagger_model_with_progress(
|
||||||
|
app_data_dir: &Path,
|
||||||
|
emit_progress: impl Fn(TaggerModelProgress),
|
||||||
|
) -> Result<TaggerModelStatus> {
|
||||||
|
let local_dir = model_dir(app_data_dir);
|
||||||
|
std::fs::create_dir_all(&local_dir)?;
|
||||||
|
|
||||||
|
// Ensure the shared ONNX runtime DLLs are present. The tagger shares
|
||||||
|
// them with the captioner; install them here so the tagger is fully
|
||||||
|
// functional even on a clean install where the caption model has never
|
||||||
|
// been downloaded.
|
||||||
|
let caption_model_dir = crate::captioner::model_dir(app_data_dir);
|
||||||
|
std::fs::create_dir_all(&caption_model_dir)?;
|
||||||
|
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
|
||||||
|
|
||||||
|
// Download the two tagger-specific files.
|
||||||
|
const DOWNLOAD_FILES: &[&str] = &["model.onnx", "selected_tags.csv"];
|
||||||
|
|
||||||
|
let api = Api::new()?;
|
||||||
|
let repo = api.repo(Repo::new(WD_TAGGER_MODEL_ID.to_string(), RepoType::Model));
|
||||||
|
|
||||||
|
let mut completed_files = DOWNLOAD_FILES
|
||||||
|
.iter()
|
||||||
|
.filter(|file| local_dir.join(file).exists())
|
||||||
|
.count();
|
||||||
|
|
||||||
|
emit_progress(TaggerModelProgress {
|
||||||
|
total_files: DOWNLOAD_FILES.len(),
|
||||||
|
completed_files,
|
||||||
|
current_file: None,
|
||||||
|
done: completed_files == DOWNLOAD_FILES.len(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for file in DOWNLOAD_FILES {
|
||||||
|
let destination = local_dir.join(file);
|
||||||
|
if destination.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emit_progress(TaggerModelProgress {
|
||||||
|
total_files: DOWNLOAD_FILES.len(),
|
||||||
|
completed_files,
|
||||||
|
current_file: Some((*file).to_string()),
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
let cached = repo.get(file)?;
|
||||||
|
std::fs::copy(cached, destination)?;
|
||||||
|
completed_files += 1;
|
||||||
|
emit_progress(TaggerModelProgress {
|
||||||
|
total_files: DOWNLOAD_FILES.len(),
|
||||||
|
completed_files,
|
||||||
|
current_file: Some((*file).to_string()),
|
||||||
|
done: completed_files == DOWNLOAD_FILES.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_progress(TaggerModelProgress {
|
||||||
|
total_files: DOWNLOAD_FILES.len(),
|
||||||
|
completed_files,
|
||||||
|
current_file: None,
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(tagger_model_status(app_data_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_tagger_model(app_data_dir: &Path) -> Result<TaggerModelStatus> {
|
||||||
|
let local_dir = model_dir(app_data_dir);
|
||||||
|
if local_dir.exists() {
|
||||||
|
std::fs::remove_dir_all(&local_dir)?;
|
||||||
|
}
|
||||||
|
Ok(tagger_model_status(app_data_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result<TaggerRuntimeProbe> {
|
||||||
|
let status = tagger_model_status(app_data_dir);
|
||||||
|
if !status.ready {
|
||||||
|
anyhow::bail!(
|
||||||
|
"WD Tagger model is missing {} required file(s): {}",
|
||||||
|
status.missing_files.len(),
|
||||||
|
status.missing_files.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_dir = model_dir(app_data_dir);
|
||||||
|
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
|
||||||
|
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
|
||||||
|
|
||||||
|
let acceleration = tagger_acceleration(app_data_dir);
|
||||||
|
let model_path = local_dir.join("model.onnx");
|
||||||
|
|
||||||
|
// Verify that the model file exists and has non-zero size before trying
|
||||||
|
// to create an ORT session (better error message on corruption).
|
||||||
|
let metadata = std::fs::metadata(&model_path)?;
|
||||||
|
if metadata.len() == 0 {
|
||||||
|
anyhow::bail!("model.onnx is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually create a session to verify the EP loads correctly.
|
||||||
|
let loaded_acceleration = match acceleration {
|
||||||
|
TaggerAcceleration::Cpu => {
|
||||||
|
create_tagger_session(&model_path, TaggerAcceleration::Cpu)?;
|
||||||
|
TaggerAcceleration::Cpu
|
||||||
|
}
|
||||||
|
TaggerAcceleration::Auto => {
|
||||||
|
// Try DirectML explicitly; if it fails the real session would have
|
||||||
|
// fallen back to CPU silently — report what would actually run.
|
||||||
|
let directml_ok =
|
||||||
|
create_tagger_session(&model_path, TaggerAcceleration::Directml).is_ok();
|
||||||
|
if directml_ok {
|
||||||
|
TaggerAcceleration::Directml
|
||||||
|
} else {
|
||||||
|
create_tagger_session(&model_path, TaggerAcceleration::Cpu)?;
|
||||||
|
TaggerAcceleration::Cpu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TaggerAcceleration::Directml => {
|
||||||
|
create_tagger_session(&model_path, TaggerAcceleration::Directml)?;
|
||||||
|
TaggerAcceleration::Directml
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(TaggerRuntimeProbe {
|
||||||
|
ready: true,
|
||||||
|
acceleration: loaded_acceleration,
|
||||||
|
session: TaggerRuntimeSessionProbe {
|
||||||
|
file: "model.onnx",
|
||||||
|
inputs: vec!["pixel_values".to_string()],
|
||||||
|
outputs: vec![format!("output [EP: {:?}]", loaded_acceleration)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top-level inference entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tagger implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct WdTagger {
|
||||||
|
session: Session,
|
||||||
|
labels: Vec<TagEntry>,
|
||||||
|
threshold: f32,
|
||||||
|
input_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WdTagger {
|
||||||
|
pub fn new(app_data_dir: &Path) -> Result<Self> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
|
||||||
|
let status = tagger_model_status(app_data_dir);
|
||||||
|
if !status.ready {
|
||||||
|
anyhow::bail!(
|
||||||
|
"WD tagger model is missing {} required file(s): {}",
|
||||||
|
status.missing_files.len(),
|
||||||
|
status.missing_files.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_dir = model_dir(app_data_dir);
|
||||||
|
|
||||||
|
// The ONNX runtime DLLs are shared with the captioner; use the
|
||||||
|
// captioner's shared ORT init lock to avoid double-initialisation.
|
||||||
|
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
|
||||||
|
crate::captioner::ensure_onnx_runtime(&caption_model_dir).map_err(|e| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"ONNX Runtime not initialised — download the Florence-2 caption model first \
|
||||||
|
to get the shared runtime DLLs. Original error: {e}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let acceleration = tagger_acceleration(app_data_dir);
|
||||||
|
let threshold = tagger_threshold(app_data_dir);
|
||||||
|
let model_path = local_dir.join("model.onnx");
|
||||||
|
let labels_path = local_dir.join("selected_tags.csv");
|
||||||
|
|
||||||
|
let session = create_tagger_session(&model_path, acceleration)?;
|
||||||
|
|
||||||
|
// Determine the input spatial size from the ONNX model graph.
|
||||||
|
// WD v3 models use (1, H, W, 3) where H == W, typically 448.
|
||||||
|
let input_size = {
|
||||||
|
let inputs = session.inputs();
|
||||||
|
let dim = inputs
|
||||||
|
.first()
|
||||||
|
.and_then(|inp| {
|
||||||
|
if let ort::value::ValueType::Tensor { shape, .. } = inp.dtype() {
|
||||||
|
shape
|
||||||
|
.get(1)
|
||||||
|
.and_then(|&d| if d > 0 { Some(d as usize) } else { None })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(448);
|
||||||
|
dim
|
||||||
|
};
|
||||||
|
|
||||||
|
let labels = load_labels(&labels_path)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"WD tagger loaded in {:?} ({} labels, input {}x{}, {:?} acceleration)",
|
||||||
|
started_at.elapsed(),
|
||||||
|
labels.len(),
|
||||||
|
input_size,
|
||||||
|
input_size,
|
||||||
|
acceleration,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
session,
|
||||||
|
labels,
|
||||||
|
threshold,
|
||||||
|
input_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&mut self, image_path: &Path, max_tags: usize) -> Result<TaggerOutput> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
|
||||||
|
let image_array = preprocess_image(image_path, self.input_size)?;
|
||||||
|
let batch_size = 1usize;
|
||||||
|
let input = Tensor::from_array((
|
||||||
|
[batch_size, self.input_size, self.input_size, 3usize],
|
||||||
|
image_array.into_boxed_slice(),
|
||||||
|
))
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
|
||||||
|
let input_name: String = self.session.inputs()[0].name().to_string();
|
||||||
|
let outputs = self
|
||||||
|
.session
|
||||||
|
.run(ort::inputs! { input_name.as_str() => input })
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
|
||||||
|
let (_, probabilities) = outputs[0]
|
||||||
|
.try_extract_tensor::<f32>()
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
|
||||||
|
let probs: &[f32] = &probabilities;
|
||||||
|
|
||||||
|
if probs.len() != self.labels.len() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Model output length {} does not match label count {}",
|
||||||
|
probs.len(),
|
||||||
|
self.labels.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect rating scores (category 9) - pick the argmax as the rating.
|
||||||
|
let rating = self
|
||||||
|
.labels
|
||||||
|
.iter()
|
||||||
|
.zip(probs.iter())
|
||||||
|
.filter(|(entry, _)| entry.category == RATING_CATEGORY)
|
||||||
|
.max_by(|(_, a), (_, b)| a.total_cmp(b))
|
||||||
|
.map(|(entry, _)| entry.name.clone())
|
||||||
|
.unwrap_or_else(|| "general".to_string());
|
||||||
|
|
||||||
|
// Collect general + character tags above threshold, sorted by confidence.
|
||||||
|
let mut tags: Vec<TagResult> = self
|
||||||
|
.labels
|
||||||
|
.iter()
|
||||||
|
.zip(probs.iter())
|
||||||
|
.filter(|(entry, prob)| {
|
||||||
|
(entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY)
|
||||||
|
&& **prob >= self.threshold
|
||||||
|
})
|
||||||
|
.map(|(entry, prob)| TagResult {
|
||||||
|
tag: entry.name.clone(),
|
||||||
|
confidence: *prob,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
|
||||||
|
tags.truncate(max_tags);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"WD tagger: {} tags (threshold={}, rating={}) in {:?} for {}",
|
||||||
|
tags.len(),
|
||||||
|
self.threshold,
|
||||||
|
rating,
|
||||||
|
started_at.elapsed(),
|
||||||
|
image_path.display(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(TaggerOutput { tags, rating })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session creation – mirrors captioner's `create_session`
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Result<Session> {
|
||||||
|
let builder = Session::builder().map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
let builder = builder
|
||||||
|
.with_optimization_level(GraphOptimizationLevel::Level3)
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
|
||||||
|
let use_directml = matches!(
|
||||||
|
acceleration,
|
||||||
|
TaggerAcceleration::Auto | TaggerAcceleration::Directml
|
||||||
|
);
|
||||||
|
|
||||||
|
let builder = builder
|
||||||
|
.with_memory_pattern(!use_directml)
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
let builder = builder
|
||||||
|
.with_parallel_execution(false)
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
let builder = builder
|
||||||
|
.with_intra_threads(1)
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
|
||||||
|
let mut builder = match acceleration {
|
||||||
|
TaggerAcceleration::Cpu => {
|
||||||
|
println!("WD tagger: using CPU execution provider");
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
TaggerAcceleration::Auto => builder
|
||||||
|
.with_execution_providers([ep::DirectML::default().build().fail_silently()])
|
||||||
|
.unwrap_or_else(|error| {
|
||||||
|
println!("WD tagger: DirectML unavailable, falling back to CPU");
|
||||||
|
error.recover()
|
||||||
|
}),
|
||||||
|
TaggerAcceleration::Directml => builder
|
||||||
|
.with_execution_providers([ep::DirectML::default().build().error_on_failure()])
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = builder
|
||||||
|
.commit_from_file(path)
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Label loading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn load_labels(path: &Path) -> Result<Vec<TagEntry>> {
|
||||||
|
let mut reader = csv::Reader::from_path(path)?;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for result in reader.records() {
|
||||||
|
let record = result?;
|
||||||
|
// CSV columns: tag_id, name, category, count
|
||||||
|
let name = record
|
||||||
|
.get(1)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing name column in selected_tags.csv"))?
|
||||||
|
.replace('_', " ");
|
||||||
|
let category: u8 = record
|
||||||
|
.get(2)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing category column in selected_tags.csv"))?
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
|
entries.push(TagEntry { name, category });
|
||||||
|
}
|
||||||
|
if entries.is_empty() {
|
||||||
|
anyhow::bail!("selected_tags.csv is empty or could not be parsed");
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Image preprocessing
|
||||||
|
// WD tagger expects: (1, H, W, 3) float32, raw [0,255] values, BGR channel
|
||||||
|
// order, padded to square with white (255,255,255).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
|
||||||
|
let image = ImageReader::open(image_path)?.decode()?;
|
||||||
|
|
||||||
|
// Composite any alpha channel onto a white background.
|
||||||
|
let image_rgba = image.to_rgba8();
|
||||||
|
let (width, height) = image_rgba.dimensions();
|
||||||
|
let mut canvas_rgba =
|
||||||
|
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
|
||||||
|
image::imageops::overlay(&mut canvas_rgba, &image_rgba, 0, 0);
|
||||||
|
let image_rgb = DynamicImage::ImageRgba8(canvas_rgba).to_rgb8();
|
||||||
|
|
||||||
|
// Pad to square.
|
||||||
|
let max_dim = width.max(height);
|
||||||
|
let pad_left = (max_dim - width) / 2;
|
||||||
|
let pad_top = (max_dim - height) / 2;
|
||||||
|
let mut square = image::RgbImage::from_pixel(max_dim, max_dim, image::Rgb([255, 255, 255]));
|
||||||
|
image::imageops::overlay(&mut square, &image_rgb, pad_left as i64, pad_top as i64);
|
||||||
|
|
||||||
|
// Resize to model input size.
|
||||||
|
let resized = image::imageops::resize(
|
||||||
|
&square,
|
||||||
|
target_size as u32,
|
||||||
|
target_size as u32,
|
||||||
|
FilterType::CatmullRom,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flatten to (H, W, 3) float32 in BGR order, values in [0, 255].
|
||||||
|
let mut pixel_values = vec![0.0f32; target_size * target_size * 3];
|
||||||
|
for (x, y, pixel) in resized.enumerate_pixels() {
|
||||||
|
let base = (y as usize * target_size + x as usize) * 3;
|
||||||
|
// BGR order
|
||||||
|
pixel_values[base] = f32::from(pixel[2]); // B
|
||||||
|
pixel_values[base + 1] = f32::from(pixel[1]); // G
|
||||||
|
pixel_values[base + 2] = f32::from(pixel[0]); // R
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pixel_values)
|
||||||
|
}
|
||||||
+317
-9
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use rusqlite::{ffi::sqlite3_auto_extension, Connection};
|
use rusqlite::{ffi::sqlite3_auto_extension, Connection, Error as SqliteError};
|
||||||
use sqlite_vec::sqlite3_vec_init;
|
use sqlite_vec::sqlite3_vec_init;
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
|
|
||||||
@@ -19,8 +19,13 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
"CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0(
|
"CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0(
|
||||||
image_id INTEGER PRIMARY KEY,
|
image_id INTEGER PRIMARY KEY,
|
||||||
embedding FLOAT[{}] distance_metric=cosine
|
embedding FLOAT[{}] distance_metric=cosine
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS caption_vec USING vec0(
|
||||||
|
image_id INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[{}] distance_metric=cosine
|
||||||
);",
|
);",
|
||||||
CLIP_VECTOR_DIM
|
CLIP_VECTOR_DIM, CLIP_VECTOR_DIM
|
||||||
))?;
|
))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -28,6 +33,18 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> {
|
pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> {
|
||||||
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
|
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
|
||||||
|
// Advance the revision so any cached HNSW index is invalidated after deletions.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = value + 1",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn delete_caption_embedding(conn: &Connection, image_id: i64) -> Result<()> {
|
||||||
|
conn.execute("DELETE FROM caption_vec WHERE image_id = ?1", [image_id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,26 +67,74 @@ pub fn upsert_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_similar_image_ids(conn: &Connection, image_id: i64, limit: usize) -> Result<Vec<i64>> {
|
#[allow(dead_code)]
|
||||||
let embedding: Vec<u8> = conn.query_row(
|
pub fn upsert_caption_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) -> Result<()> {
|
||||||
|
if embedding.len() != CLIP_VECTOR_DIM {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"expected {}-dimensional embedding, got {}",
|
||||||
|
CLIP_VECTOR_DIM,
|
||||||
|
embedding.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let packed = pack_f32(embedding);
|
||||||
|
conn.execute("DELETE FROM caption_vec WHERE image_id = ?1", [image_id])?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO caption_vec (image_id, embedding) VALUES (?1, ?2)",
|
||||||
|
(&image_id, &packed),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_similar_image_ids(
|
||||||
|
conn: &Connection,
|
||||||
|
image_id: i64,
|
||||||
|
limit: usize,
|
||||||
|
folder_id: Option<i64>,
|
||||||
|
) -> Result<Vec<i64>> {
|
||||||
|
let embedding: Vec<u8> = match conn.query_row(
|
||||||
"SELECT embedding FROM image_vec WHERE image_id = ?1",
|
"SELECT embedding FROM image_vec WHERE image_id = ?1",
|
||||||
[image_id],
|
[image_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
) {
|
||||||
|
Ok(embedding) => embedding,
|
||||||
|
Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(folder_id) = folder_id {
|
||||||
|
// Brute-force cosine scan scoped to the folder — avoids the KNN k=4096 limit
|
||||||
|
// and returns exact nearest neighbours within the folder.
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT v.image_id
|
||||||
|
FROM image_vec v
|
||||||
|
JOIN images i ON i.id = v.image_id
|
||||||
|
WHERE i.folder_id = ?2
|
||||||
|
AND v.image_id != ?3
|
||||||
|
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
|
||||||
|
LIMIT ?4",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map((&embedding, folder_id, image_id, limit as i64), |row| {
|
||||||
|
row.get::<_, i64>(0)
|
||||||
|
})?;
|
||||||
|
return Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global KNN search (no folder filter) — use the ANN index.
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT image_id
|
"SELECT image_id
|
||||||
FROM image_vec
|
FROM image_vec
|
||||||
WHERE embedding MATCH vec_f32(?1)
|
WHERE embedding MATCH vec_f32(?1)
|
||||||
AND k = ?2",
|
AND k = ?2",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map((&embedding, (limit as i64) + 1), |row| row.get::<_, i64>(0))?;
|
let rows = stmt
|
||||||
|
.query_map((&embedding, (limit + 1) as i64), |row| row.get::<_, i64>(0))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
let mut ids = Vec::new();
|
let mut ids = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let candidate_id = row?;
|
if row != image_id {
|
||||||
if candidate_id != image_id {
|
ids.push(row);
|
||||||
ids.push(candidate_id);
|
|
||||||
}
|
}
|
||||||
if ids.len() >= limit {
|
if ids.len() >= limit {
|
||||||
break;
|
break;
|
||||||
@@ -78,6 +143,106 @@ pub fn find_similar_image_ids(conn: &Connection, image_id: i64, limit: usize) ->
|
|||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pub fn find_similar_image_matches(
|
||||||
|
// conn: &Connection,
|
||||||
|
// image_id: i64,
|
||||||
|
// folder_id: Option<i64>,
|
||||||
|
// threshold: f32,
|
||||||
|
// offset: usize,
|
||||||
|
// limit: usize,
|
||||||
|
// ) -> Result<Vec<(i64, f32)>> {
|
||||||
|
// let embedding: Vec<u8> = match conn.query_row(
|
||||||
|
// "SELECT embedding FROM image_vec WHERE image_id = ?1",
|
||||||
|
// [image_id],
|
||||||
|
// |row| row.get(0),
|
||||||
|
// ) {
|
||||||
|
// Ok(embedding) => embedding,
|
||||||
|
// Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()),
|
||||||
|
// Err(error) => return Err(error.into()),
|
||||||
|
// };
|
||||||
|
|
||||||
|
// let query = match folder_id {
|
||||||
|
// Some(_) => {
|
||||||
|
// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance
|
||||||
|
// FROM image_vec v
|
||||||
|
// JOIN images i ON i.id = v.image_id
|
||||||
|
// WHERE i.folder_id = ?2
|
||||||
|
// AND v.image_id != ?3
|
||||||
|
// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?4
|
||||||
|
// ORDER BY distance ASC
|
||||||
|
// LIMIT ?5 OFFSET ?6"
|
||||||
|
// }
|
||||||
|
// None => {
|
||||||
|
// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance
|
||||||
|
// FROM image_vec v
|
||||||
|
// WHERE v.image_id != ?2
|
||||||
|
// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?3
|
||||||
|
// ORDER BY distance ASC
|
||||||
|
// LIMIT ?4 OFFSET ?5"
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// let mut stmt = conn.prepare(query)?;
|
||||||
|
// match folder_id {
|
||||||
|
// Some(folder_id) => Ok(stmt
|
||||||
|
// .query_map(
|
||||||
|
// (
|
||||||
|
// &embedding,
|
||||||
|
// folder_id,
|
||||||
|
// image_id,
|
||||||
|
// threshold,
|
||||||
|
// limit as i64,
|
||||||
|
// offset as i64,
|
||||||
|
// ),
|
||||||
|
// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)),
|
||||||
|
// )?
|
||||||
|
// .collect::<rusqlite::Result<Vec<_>>>()?),
|
||||||
|
// None => Ok(stmt
|
||||||
|
// .query_map(
|
||||||
|
// (&embedding, image_id, threshold, limit as i64, offset as i64),
|
||||||
|
// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)),
|
||||||
|
// )?
|
||||||
|
// .collect::<rusqlite::Result<Vec<_>>>()?),
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
pub fn get_image_embedding(conn: &Connection, image_id: i64) -> Result<Option<Vec<f32>>> {
|
||||||
|
let embedding: Result<Vec<u8>, rusqlite::Error> = conn.query_row(
|
||||||
|
"SELECT embedding FROM image_vec WHERE image_id = ?1",
|
||||||
|
[image_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
match embedding {
|
||||||
|
Ok(bytes) => Ok(Some(unpack_f32(&bytes))),
|
||||||
|
Err(SqliteError::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
|
||||||
|
// Use the monotonically incremented app_kv counter so that two embeddings
|
||||||
|
// saved within the same clock second still advance the revision, preventing
|
||||||
|
// the HNSW cache from serving stale vectors.
|
||||||
|
let revision: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE((SELECT value FROM app_kv WHERE key = 'embedding_revision'), 0)",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(revision.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// fn image_ids_for_folder(
|
||||||
|
// conn: &Connection,
|
||||||
|
// folder_id: i64,
|
||||||
|
// ) -> Result<std::collections::HashSet<i64>> {
|
||||||
|
// let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?;
|
||||||
|
// let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?;
|
||||||
|
// Ok(rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?)
|
||||||
|
// }
|
||||||
|
|
||||||
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
|
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
|
||||||
/// Each entry is `(image_id, normalized_f32_embedding)`.
|
/// Each entry is `(image_id, normalized_f32_embedding)`.
|
||||||
pub fn get_all_image_embeddings_with_ids(
|
pub fn get_all_image_embeddings_with_ids(
|
||||||
@@ -155,6 +320,149 @@ pub fn search_image_ids_by_embedding(
|
|||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Brute-force cosine search scoped to a single folder, ordered by ascending distance.
|
||||||
|
/// Used for region-based similarity search where we want folder-scoped results.
|
||||||
|
pub fn search_image_ids_by_embedding_in_folder(
|
||||||
|
conn: &Connection,
|
||||||
|
embedding: &[f32],
|
||||||
|
folder_id: i64,
|
||||||
|
exclude_image_id: Option<i64>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<i64>> {
|
||||||
|
if embedding.len() != CLIP_VECTOR_DIM {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"expected {}-dimensional embedding, got {}",
|
||||||
|
CLIP_VECTOR_DIM,
|
||||||
|
embedding.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let packed = pack_f32(embedding);
|
||||||
|
let exclude_id = exclude_image_id.unwrap_or(-1);
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT v.image_id
|
||||||
|
FROM image_vec v
|
||||||
|
JOIN images i ON i.id = v.image_id
|
||||||
|
WHERE i.folder_id = ?2
|
||||||
|
AND v.image_id != ?3
|
||||||
|
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
|
||||||
|
LIMIT ?4",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map((&packed, folder_id, exclude_id, limit as i64), |row| {
|
||||||
|
row.get::<_, i64>(0)
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn search_caption_ids_by_embedding(
|
||||||
|
conn: &Connection,
|
||||||
|
embedding: &[f32],
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<i64>> {
|
||||||
|
if embedding.len() != CLIP_VECTOR_DIM {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"expected {}-dimensional embedding, got {}",
|
||||||
|
CLIP_VECTOR_DIM,
|
||||||
|
embedding.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let packed = pack_f32(embedding);
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT image_id
|
||||||
|
FROM caption_vec
|
||||||
|
WHERE embedding MATCH vec_f32(?1)
|
||||||
|
AND k = ?2",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map((&packed, limit as i64), |row| row.get::<_, i64>(0))?;
|
||||||
|
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
ids.push(row?);
|
||||||
|
if ids.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count_image_vectors(conn: &Connection) -> Result<i64> {
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM image_vec", [], |row| row.get(0))
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn count_caption_vectors(conn: &Connection) -> Result<i64> {
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM caption_vec", [], |row| row.get(0))
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_orphaned_embeddings(conn: &Connection) -> Result<usize> {
|
||||||
|
let image_ids = {
|
||||||
|
let mut stmt = conn.prepare("SELECT id FROM images")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, i64>(0))?
|
||||||
|
.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?;
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
let vector_ids = {
|
||||||
|
let mut stmt = conn.prepare("SELECT image_id FROM image_vec")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, i64>(0))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
let orphaned_ids = vector_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|image_id| !image_ids.contains(image_id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for image_id in &orphaned_ids {
|
||||||
|
delete_embedding(conn, *image_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(orphaned_ids.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn delete_orphaned_caption_embeddings(conn: &Connection) -> Result<usize> {
|
||||||
|
let image_ids = {
|
||||||
|
let mut stmt = conn.prepare("SELECT id FROM images")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, i64>(0))?
|
||||||
|
.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?;
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
let vector_ids = {
|
||||||
|
let mut stmt = conn.prepare("SELECT image_id FROM caption_vec")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, i64>(0))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
let orphaned_ids = vector_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|image_id| !image_ids.contains(image_id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for image_id in &orphaned_ids {
|
||||||
|
delete_caption_embedding(conn, *image_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(orphaned_ids.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_image_vector(conn: &Connection, image_id: i64) -> Result<bool> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM image_vec WHERE image_id = ?1)",
|
||||||
|
[image_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|value| value != 0)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn pack_f32(values: &[f32]) -> Vec<u8> {
|
fn pack_f32(values: &[f32]) -> Vec<u8> {
|
||||||
let mut out = Vec::with_capacity(values.len() * std::mem::size_of::<f32>());
|
let mut out = Vec::with_capacity(values.len() * std::mem::size_of::<f32>());
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "wtf.jezz.phokus",
|
"identifier": "wtf.jezz.phokus",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev:vite",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeBuildCommand": "pnpm build",
|
"beforeBuildCommand": "pnpm build:vite",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -25,7 +25,9 @@
|
|||||||
"csp": null,
|
"csp": null,
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": ["**"]
|
"scope": [
|
||||||
|
"**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+14
@@ -6,18 +6,26 @@ import { Toolbar } from "./components/Toolbar";
|
|||||||
import { Gallery } from "./components/Gallery";
|
import { Gallery } from "./components/Gallery";
|
||||||
import { Lightbox } from "./components/Lightbox";
|
import { Lightbox } from "./components/Lightbox";
|
||||||
import { TagCloud } from "./components/TagCloud";
|
import { TagCloud } from "./components/TagCloud";
|
||||||
|
import { DuplicateFinder } from "./components/DuplicateFinder";
|
||||||
import { TitleBar } from "./components/TitleBar";
|
import { TitleBar } from "./components/TitleBar";
|
||||||
|
import { SettingsModal } from "./components/SettingsModal";
|
||||||
|
import { initializeNotifications } from "./notifications";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const loadFolders = useGalleryStore((state) => state.loadFolders);
|
const loadFolders = useGalleryStore((state) => state.loadFolders);
|
||||||
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
|
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
|
||||||
const loadImages = useGalleryStore((state) => state.loadImages);
|
const loadImages = useGalleryStore((state) => state.loadImages);
|
||||||
|
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
|
||||||
|
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
|
||||||
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
|
||||||
const activeView = useGalleryStore((state) => state.activeView);
|
const activeView = useGalleryStore((state) => state.activeView);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
void initializeNotifications();
|
||||||
loadFolders().then(() => {
|
loadFolders().then(() => {
|
||||||
void loadBackgroundJobProgress();
|
void loadBackgroundJobProgress();
|
||||||
|
void loadCaptionModelStatus();
|
||||||
|
void loadDuplicateScanCache();
|
||||||
return loadImages(true);
|
return loadImages(true);
|
||||||
});
|
});
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
@@ -43,6 +51,11 @@ export default function App() {
|
|||||||
<BackgroundTasks />
|
<BackgroundTasks />
|
||||||
<TagCloud />
|
<TagCloud />
|
||||||
</>
|
</>
|
||||||
|
) : activeView === "duplicates" ? (
|
||||||
|
<>
|
||||||
|
<BackgroundTasks />
|
||||||
|
<DuplicateFinder />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
@@ -54,6 +67,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
|
<SettingsModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore } from "../store";
|
import { useGalleryStore } from "../store";
|
||||||
|
|
||||||
type WorkerKey = "thumbnail" | "metadata" | "embedding";
|
type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging";
|
||||||
|
|
||||||
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
|
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
|
||||||
Thumbnails: "thumbnail",
|
Thumbnails: "thumbnail",
|
||||||
Metadata: "metadata",
|
Metadata: "metadata",
|
||||||
Embeddings: "embedding",
|
Embeddings: "embedding",
|
||||||
|
Tags: "tagging",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TaskStage {
|
interface TaskStage {
|
||||||
@@ -22,6 +23,8 @@ interface Task {
|
|||||||
name: string;
|
name: string;
|
||||||
stages: TaskStage[];
|
stages: TaskStage[];
|
||||||
hasFailedEmbeddings: boolean;
|
hasFailedEmbeddings: boolean;
|
||||||
|
hasFailedTagging: boolean;
|
||||||
|
hasFailedCaptions: boolean;
|
||||||
pendingMediaWork: number;
|
pendingMediaWork: number;
|
||||||
embeddingProcessed: number;
|
embeddingProcessed: number;
|
||||||
embeddingTotal: number;
|
embeddingTotal: number;
|
||||||
@@ -35,31 +38,57 @@ interface FailedEmbeddingItem {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FolderWorkerStates {
|
||||||
|
folder_id: number;
|
||||||
|
thumbnail_paused: boolean;
|
||||||
|
metadata_paused: boolean;
|
||||||
|
embedding_paused: boolean;
|
||||||
|
tagging_paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAUSED_STATE: Record<WorkerKey, boolean> = {
|
||||||
|
thumbnail: false,
|
||||||
|
metadata: false,
|
||||||
|
embedding: false,
|
||||||
|
tagging: false,
|
||||||
|
};
|
||||||
|
|
||||||
export function BackgroundTasks() {
|
export function BackgroundTasks() {
|
||||||
const folders = useGalleryStore((state) => state.folders);
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||||
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
||||||
|
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
|
||||||
|
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
||||||
|
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [dismissed, setDismissed] = useState<Record<number, string>>({});
|
const [dismissed, setDismissed] = useState<Record<number, string>>({});
|
||||||
const [paused, setPaused] = useState<Record<WorkerKey, boolean>>({
|
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
|
||||||
thumbnail: false,
|
|
||||||
metadata: false,
|
|
||||||
embedding: false,
|
|
||||||
});
|
|
||||||
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
|
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>(
|
const folderIds = folders.map((folder) => folder.id);
|
||||||
"get_worker_states",
|
if (folderIds.length === 0) {
|
||||||
).then((states) => {
|
setPaused({});
|
||||||
setPaused({
|
return;
|
||||||
thumbnail: states.thumbnail_paused,
|
}
|
||||||
metadata: states.metadata_paused,
|
|
||||||
embedding: states.embedding_paused,
|
invoke<FolderWorkerStates[]>("get_worker_states", { folderIds }).then((states) => {
|
||||||
});
|
setPaused(
|
||||||
|
Object.fromEntries(
|
||||||
|
states.map((state) => [
|
||||||
|
state.folder_id,
|
||||||
|
{
|
||||||
|
thumbnail: state.thumbnail_paused,
|
||||||
|
metadata: state.metadata_paused,
|
||||||
|
embedding: state.embedding_paused,
|
||||||
|
tagging: state.tagging_paused,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [folders]);
|
||||||
|
|
||||||
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
|
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
|
||||||
const failedCounts = useMemo(
|
const failedCounts = useMemo(
|
||||||
@@ -83,13 +112,25 @@ export function BackgroundTasks() {
|
|||||||
}
|
}
|
||||||
}, [expanded, failedCounts]);
|
}, [expanded, failedCounts]);
|
||||||
|
|
||||||
const toggleWorker = (worker: WorkerKey) => {
|
const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
|
||||||
const next = !paused[worker];
|
return paused[folderId]?.[worker] ?? DEFAULT_PAUSED_STATE[worker];
|
||||||
setPaused((prev) => ({ ...prev, [worker]: next }));
|
};
|
||||||
void invoke("set_worker_paused", { worker, paused: next });
|
|
||||||
|
const toggleWorker = (folderId: number, worker: WorkerKey) => {
|
||||||
|
const next = !isWorkerPaused(folderId, worker);
|
||||||
|
setPaused((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[folderId]: {
|
||||||
|
...DEFAULT_PAUSED_STATE,
|
||||||
|
...prev[folderId],
|
||||||
|
[worker]: next,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
void invoke("set_worker_paused", { worker, folderId, paused: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
const dismissTask = (id: number, snapshot: string) => {
|
const dismissTask = (id: number, snapshot: string) => {
|
||||||
|
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
|
||||||
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
|
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
};
|
};
|
||||||
@@ -105,13 +146,25 @@ export function BackgroundTasks() {
|
|||||||
const embeddingPending = jobs?.embedding_pending ?? 0;
|
const embeddingPending = jobs?.embedding_pending ?? 0;
|
||||||
const embeddingReady = jobs?.embedding_ready ?? 0;
|
const embeddingReady = jobs?.embedding_ready ?? 0;
|
||||||
const embeddingFailed = jobs?.embedding_failed ?? 0;
|
const embeddingFailed = jobs?.embedding_failed ?? 0;
|
||||||
|
const taggingPending = jobs?.tagging_pending ?? 0;
|
||||||
|
const taggingReady = jobs?.tagging_ready ?? 0;
|
||||||
|
const taggingFailed = jobs?.tagging_failed ?? 0;
|
||||||
|
const captionPending = jobs?.caption_pending ?? 0;
|
||||||
|
const captionReady = jobs?.caption_ready ?? 0;
|
||||||
|
const captionFailed = jobs?.caption_failed ?? 0;
|
||||||
|
|
||||||
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending;
|
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending;
|
||||||
const embeddingProcessed = embeddingReady + embeddingFailed;
|
const embeddingProcessed = embeddingReady + embeddingFailed;
|
||||||
const embeddingTotal = embeddingProcessed + embeddingPending;
|
const embeddingTotal = embeddingProcessed + embeddingPending;
|
||||||
|
const taggingProcessed = taggingReady + taggingFailed;
|
||||||
|
const taggingTotal = taggingProcessed + taggingPending;
|
||||||
|
const captionProcessed = captionReady + captionFailed;
|
||||||
|
const captionTotal = captionProcessed + captionPending;
|
||||||
const hasFailedEmbeddings = embeddingFailed > 0;
|
const hasFailedEmbeddings = embeddingFailed > 0;
|
||||||
|
const hasFailedTagging = taggingFailed > 0;
|
||||||
|
const hasFailedCaptions = captionFailed > 0;
|
||||||
|
|
||||||
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) return null;
|
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null;
|
||||||
|
|
||||||
const stages: TaskStage[] = [];
|
const stages: TaskStage[] = [];
|
||||||
|
|
||||||
@@ -153,6 +206,26 @@ export function BackgroundTasks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (taggingPending > 0) {
|
||||||
|
const pct = taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0;
|
||||||
|
stages.push({
|
||||||
|
label: "Tags",
|
||||||
|
detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`,
|
||||||
|
progress: pct,
|
||||||
|
failed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (captionPending > 0) {
|
||||||
|
const pct = captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0;
|
||||||
|
stages.push({
|
||||||
|
label: "Captions",
|
||||||
|
detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`,
|
||||||
|
progress: pct,
|
||||||
|
failed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (hasFailedEmbeddings && pendingMediaWork === 0) {
|
if (hasFailedEmbeddings && pendingMediaWork === 0) {
|
||||||
stages.push({
|
stages.push({
|
||||||
label: "Failed",
|
label: "Failed",
|
||||||
@@ -162,13 +235,33 @@ export function BackgroundTasks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = `${pendingMediaWork}:${embeddingFailed}`;
|
if (hasFailedTagging && pendingMediaWork === 0) {
|
||||||
|
stages.push({
|
||||||
|
label: "Failed",
|
||||||
|
detail: `${taggingFailed.toLocaleString()} tags`,
|
||||||
|
progress: null,
|
||||||
|
failed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFailedCaptions && pendingMediaWork === 0) {
|
||||||
|
stages.push({
|
||||||
|
label: "Failed",
|
||||||
|
detail: `${captionFailed.toLocaleString()} captions`,
|
||||||
|
progress: null,
|
||||||
|
failed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
stages,
|
stages,
|
||||||
hasFailedEmbeddings,
|
hasFailedEmbeddings,
|
||||||
|
hasFailedTagging,
|
||||||
|
hasFailedCaptions,
|
||||||
pendingMediaWork,
|
pendingMediaWork,
|
||||||
embeddingProcessed,
|
embeddingProcessed,
|
||||||
embeddingTotal,
|
embeddingTotal,
|
||||||
@@ -180,17 +273,44 @@ export function BackgroundTasks() {
|
|||||||
.filter((t) => dismissed[t.id] !== t.snapshot);
|
.filter((t) => dismissed[t.id] !== t.snapshot);
|
||||||
}, [folders, indexingProgress, mediaJobProgress, dismissed]);
|
}, [folders, indexingProgress, mediaJobProgress, dismissed]);
|
||||||
|
|
||||||
if (tasks.length === 0) return null;
|
// Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed
|
||||||
|
const duplicateScanTask: Task | null = duplicateScanning ? {
|
||||||
|
id: -1,
|
||||||
|
name: "Duplicate Scan",
|
||||||
|
stages: [{
|
||||||
|
label: "Hashing",
|
||||||
|
detail: duplicateScanProgress
|
||||||
|
? `${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
|
||||||
|
: "Starting…",
|
||||||
|
progress: duplicateScanProgress && duplicateScanProgress.total > 0
|
||||||
|
? (duplicateScanProgress.scanned / duplicateScanProgress.total) * 100
|
||||||
|
: null,
|
||||||
|
failed: false,
|
||||||
|
}],
|
||||||
|
hasFailedEmbeddings: false,
|
||||||
|
hasFailedTagging: false,
|
||||||
|
hasFailedCaptions: false,
|
||||||
|
pendingMediaWork: 1,
|
||||||
|
embeddingProcessed: 0,
|
||||||
|
embeddingTotal: 0,
|
||||||
|
currentFile: null,
|
||||||
|
snapshot: "",
|
||||||
|
} : null;
|
||||||
|
|
||||||
const primary = tasks[0];
|
const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks;
|
||||||
const extraCount = tasks.length - 1;
|
|
||||||
const hasFailed = tasks.some((t) => t.hasFailedEmbeddings && t.pendingMediaWork === 0);
|
if (allTasks.length === 0) return null;
|
||||||
|
|
||||||
|
const primary = allTasks[0];
|
||||||
|
const extraCount = allTasks.length - 1;
|
||||||
|
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging || t.hasFailedCaptions) && t.pendingMediaWork === 0);
|
||||||
|
|
||||||
// Best progress bar value: use embedding progress if available (most informative),
|
// Best progress bar value: use embedding progress if available (most informative),
|
||||||
// otherwise fall back to scanning progress, otherwise indeterminate.
|
// otherwise tagging progress, otherwise fall back to scanning progress, otherwise indeterminate.
|
||||||
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
|
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
|
||||||
|
const taggingStage = primary.stages.find((s) => s.label === "Tags");
|
||||||
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
|
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
|
||||||
const barProgress = embeddingStage?.progress ?? scanningStage?.progress ?? null;
|
const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-b border-white/[0.06]">
|
<div className="shrink-0 border-b border-white/[0.06]">
|
||||||
@@ -214,7 +334,7 @@ export function BackgroundTasks() {
|
|||||||
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
|
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||||
{primary.stages.map((stage) => {
|
{primary.stages.map((stage) => {
|
||||||
const workerKey = WORKER_FOR_STAGE[stage.label];
|
const workerKey = WORKER_FOR_STAGE[stage.label];
|
||||||
const isPaused = workerKey ? paused[workerKey] : false;
|
const isPaused = workerKey ? isWorkerPaused(primary.id, workerKey) : false;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={stage.label}
|
key={stage.label}
|
||||||
@@ -234,7 +354,7 @@ export function BackgroundTasks() {
|
|||||||
<button
|
<button
|
||||||
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
|
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
|
||||||
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
|
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
|
||||||
onClick={(e) => { e.stopPropagation(); toggleWorker(workerKey); }}
|
onClick={(e) => { e.stopPropagation(); toggleWorker(primary.id, workerKey); }}
|
||||||
>
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -283,8 +403,8 @@ export function BackgroundTasks() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expand chevron (only when multiple folders) */}
|
{/* Expand chevron (only when multiple tasks) */}
|
||||||
{tasks.length > 1 && (
|
{allTasks.length > 1 && (
|
||||||
<svg
|
<svg
|
||||||
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
|
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
@@ -293,26 +413,29 @@ export function BackgroundTasks() {
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dismiss */}
|
{/* Dismiss — hidden for system tasks like duplicate scan */}
|
||||||
<button
|
{primary.id >= 0 && (
|
||||||
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
|
<button
|
||||||
title="Dismiss"
|
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
|
||||||
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
|
title="Dismiss"
|
||||||
>
|
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
|
||||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded panel — one row per folder */}
|
{/* Expanded panel — one row per folder */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
|
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
|
||||||
{tasks.map((task) => {
|
{allTasks.map((task) => {
|
||||||
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
|
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
|
||||||
|
const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
|
||||||
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
|
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
|
||||||
const taskBarProgress = taskEmbeddingStage?.progress ?? taskScanningStage?.progress ?? null;
|
const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? null;
|
||||||
const taskHasFailed = task.hasFailedEmbeddings && task.pendingMediaWork === 0;
|
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={task.id}>
|
<div key={task.id}>
|
||||||
@@ -322,7 +445,7 @@ export function BackgroundTasks() {
|
|||||||
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
|
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||||
{task.stages.map((stage) => {
|
{task.stages.map((stage) => {
|
||||||
const workerKey = WORKER_FOR_STAGE[stage.label];
|
const workerKey = WORKER_FOR_STAGE[stage.label];
|
||||||
const isPaused = workerKey ? paused[workerKey] : false;
|
const isPaused = workerKey ? isWorkerPaused(task.id, workerKey) : false;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={stage.label}
|
key={stage.label}
|
||||||
@@ -347,7 +470,7 @@ export function BackgroundTasks() {
|
|||||||
<button
|
<button
|
||||||
className="ml-0.5 text-gray-600 hover:text-white transition-colors"
|
className="ml-0.5 text-gray-600 hover:text-white transition-colors"
|
||||||
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
|
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
|
||||||
onClick={() => toggleWorker(workerKey)}
|
onClick={() => toggleWorker(task.id, workerKey)}
|
||||||
>
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -381,21 +504,26 @@ export function BackgroundTasks() {
|
|||||||
{taskHasFailed && (
|
{taskHasFailed && (
|
||||||
<button
|
<button
|
||||||
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
|
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
|
||||||
onClick={() => void retryFailedEmbeddings(task.id)}
|
onClick={() => {
|
||||||
|
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
|
||||||
|
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{task.id >= 0 && (
|
||||||
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
|
<button
|
||||||
title="Dismiss"
|
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
|
||||||
onClick={() => dismissTask(task.id, task.snapshot)}
|
title="Dismiss"
|
||||||
>
|
onClick={() => dismissTask(task.id, task.snapshot)}
|
||||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.currentFile && (
|
{task.currentFile && (
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
|
import { DuplicateGroup, useGalleryStore } from "../store";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||||
|
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
|
||||||
|
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
|
||||||
|
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected);
|
||||||
|
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates);
|
||||||
|
const groupSelectedCount = group.images.filter((img) => selectedIds.has(img.id)).length;
|
||||||
|
const noneSelected = groupSelectedCount === 0;
|
||||||
|
|
||||||
|
// "Keep all but the first" — a common quick action
|
||||||
|
const handleKeepFirst = () => {
|
||||||
|
const toDelete = group.images.slice(1).map((img) => img.id);
|
||||||
|
// Clear any selection for this group first, then add the ones to delete
|
||||||
|
for (const img of group.images) {
|
||||||
|
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
|
||||||
|
}
|
||||||
|
selectAllDuplicates(toDelete);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4">
|
||||||
|
{/* Group header */}
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
|
||||||
|
{group.images.length} copies
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-white/30">{formatBytes(group.file_size)} each</span>
|
||||||
|
<span className="text-[11px] text-white/20">
|
||||||
|
{formatBytes(group.file_size * (group.images.length - 1))} wasted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{noneSelected ? (
|
||||||
|
<button
|
||||||
|
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
|
||||||
|
onClick={handleKeepFirst}
|
||||||
|
>
|
||||||
|
Keep first
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
|
||||||
|
onClick={() => {
|
||||||
|
for (const img of group.images) {
|
||||||
|
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deselect all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image grid */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{group.images.map((image) => {
|
||||||
|
const isSelected = selectedIds.has(image.id);
|
||||||
|
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={image.id}
|
||||||
|
className={`group relative overflow-hidden rounded-xl border transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "border-red-400/50 ring-1 ring-red-400/30"
|
||||||
|
: "border-white/8 hover:border-white/20"
|
||||||
|
}`}
|
||||||
|
style={{ width: 140, height: 105 }}
|
||||||
|
onClick={() => toggleDuplicateSelected(image.id)}
|
||||||
|
title={image.path}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-white/[0.03]" />
|
||||||
|
)}
|
||||||
|
{/* Delete overlay */}
|
||||||
|
{isSelected ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-red-950/60">
|
||||||
|
<svg className="h-6 w-6 text-red-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* Path tooltip on hover */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(unixSecs: number): string {
|
||||||
|
const diff = Math.floor(Date.now() / 1000) - unixSecs;
|
||||||
|
if (diff < 60) return "just now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DuplicateFinder() {
|
||||||
|
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups);
|
||||||
|
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
||||||
|
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
||||||
|
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError);
|
||||||
|
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
|
||||||
|
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
|
||||||
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||||
|
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates);
|
||||||
|
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection);
|
||||||
|
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups);
|
||||||
|
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
|
||||||
|
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [deleteResult, setDeleteResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedCount = duplicateSelectedIds.size;
|
||||||
|
const hasResults = duplicateGroups.length > 0;
|
||||||
|
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
|
||||||
|
const totalWasted = duplicateGroups.reduce(
|
||||||
|
(sum, g) => sum + g.file_size * (g.images.length - 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalDuplicateImages = duplicateGroups.reduce((sum, g) => sum + g.images.length - 1, 0);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
setDeleteResult(null);
|
||||||
|
try {
|
||||||
|
const deleted = await deleteSelectedDuplicates();
|
||||||
|
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`);
|
||||||
|
} catch (e) {
|
||||||
|
setDeleteResult(String(e));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercent =
|
||||||
|
duplicateScanProgress && duplicateScanProgress.total > 0
|
||||||
|
? Math.round((duplicateScanProgress.scanned / duplicateScanProgress.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[15px] font-semibold text-white">Duplicate Finder</h2>
|
||||||
|
<p className="mt-0.5 text-[11px] text-white/30">
|
||||||
|
{duplicateScanning
|
||||||
|
? duplicateScanProgress
|
||||||
|
? `Scanning… ${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
|
||||||
|
: "Starting scan…"
|
||||||
|
: hasResults
|
||||||
|
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
|
||||||
|
: duplicateLastScanned !== null
|
||||||
|
? "No duplicates found"
|
||||||
|
: "Scan your library for identical files"}
|
||||||
|
</p>
|
||||||
|
{!duplicateScanning && duplicateLastScanned !== null && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-white/20">
|
||||||
|
Last scanned {formatRelativeTime(duplicateLastScanned)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Batch select — only shown when there are groups and nothing is selected yet */}
|
||||||
|
{hasResults && selectedCount === 0 && !deleting && (
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||||
|
onClick={selectKeepFirstAllGroups}
|
||||||
|
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
|
||||||
|
>
|
||||||
|
Select all duplicates
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40"
|
||||||
|
onClick={clearDuplicateSelection}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Deselect all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }}
|
||||||
|
disabled={duplicateScanning}
|
||||||
|
>
|
||||||
|
{duplicateScanning ? "Scanning…" : hasScanned ? "Rescan" : "Scan for duplicates"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{duplicateScanning && duplicateScanProgress ? (
|
||||||
|
<div className="mt-3 h-px overflow-hidden rounded-full bg-white/[0.07]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-blue-500/60 transition-[width] duration-200"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{duplicateScanError ? (
|
||||||
|
<p className="mt-2 text-[11px] text-red-400/80">{duplicateScanError}</p>
|
||||||
|
) : null}
|
||||||
|
{deleteResult ? (
|
||||||
|
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{duplicateScanning && !hasResults ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
|
||||||
|
<span className="text-sm">Hashing files…</span>
|
||||||
|
</div>
|
||||||
|
) : !hasScanned ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center px-8">
|
||||||
|
<div className="max-w-sm text-center">
|
||||||
|
<svg className="mx-auto mb-4 h-10 w-10 text-white/10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-white/30">
|
||||||
|
Finds files with identical content regardless of filename or location.
|
||||||
|
Click <strong className="text-white/50">Scan for duplicates</strong> to begin.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-white/20">
|
||||||
|
Large libraries may take a minute — files are hashed from disk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : duplicateGroups.length === 0 ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<p className="text-sm text-white/25">No duplicate files found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-y-auto px-6 py-5">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{duplicateGroups.map((group) => (
|
||||||
|
<DuplicateGroupCard key={group.file_hash} group={group} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
+102
-68
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
|
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
|
||||||
|
|
||||||
const GAP = 6;
|
const GAP = 6;
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ function ContextMenu({
|
|||||||
const openImage = useGalleryStore((state) => state.openImage);
|
const openImage = useGalleryStore((state) => state.openImage);
|
||||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||||
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
||||||
|
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||||
const canFindSimilar = image.embedding_status === "ready";
|
const canFindSimilar = image.embedding_status === "ready";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +60,7 @@ function ContextMenu({
|
|||||||
}`}
|
}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!canFindSimilar) return;
|
if (!canFindSimilar) return;
|
||||||
await loadSimilarImages(image.id);
|
await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
disabled={!canFindSimilar}
|
disabled={!canFindSimilar}
|
||||||
@@ -115,6 +116,7 @@ function ImageTile({
|
|||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [errored, setErrored] = useState(false);
|
const [errored, setErrored] = useState(false);
|
||||||
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
||||||
|
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||||
const canFindSimilar = image.embedding_status === "ready";
|
const canFindSimilar = image.embedding_status === "ready";
|
||||||
|
|
||||||
const src = image.thumbnail_path
|
const src = image.thumbnail_path
|
||||||
@@ -181,6 +183,15 @@ function ImageTile({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{image.rating > 0 && (
|
||||||
|
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
|
||||||
|
{Array.from({ length: image.rating }, (_, index) => (
|
||||||
|
<svg key={index} className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{image.media_kind === "video" && image.duration_ms && (
|
{image.media_kind === "video" && image.duration_ms && (
|
||||||
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
|
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
|
||||||
{formatDuration(image.duration_ms)}
|
{formatDuration(image.duration_ms)}
|
||||||
@@ -230,7 +241,7 @@ function ImageTile({
|
|||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!canFindSimilar) return;
|
if (!canFindSimilar) return;
|
||||||
void loadSimilarImages(image.id);
|
void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
|
||||||
}}
|
}}
|
||||||
disabled={!canFindSimilar}
|
disabled={!canFindSimilar}
|
||||||
>
|
>
|
||||||
@@ -250,7 +261,11 @@ export function Gallery() {
|
|||||||
const loadingImages = useGalleryStore((state) => state.loadingImages);
|
const loadingImages = useGalleryStore((state) => state.loadingImages);
|
||||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||||
const search = useGalleryStore((state) => state.search);
|
const search = useGalleryStore((state) => state.search);
|
||||||
const searchMode = useGalleryStore((state) => state.searchMode);
|
const collectionTitle = useGalleryStore((state) => state.collectionTitle);
|
||||||
|
const imageLoadError = useGalleryStore((state) => state.imageLoadError);
|
||||||
|
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey);
|
||||||
|
const isSimilarResults = collectionTitle === "Similar Images";
|
||||||
|
const parsedSearch = parseSearchValue(search);
|
||||||
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
|
||||||
@@ -258,6 +273,7 @@ export function Gallery() {
|
|||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const element = parentRef.current;
|
const element = parentRef.current;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
if (element.scrollTop < 24) return;
|
||||||
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
|
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
|
||||||
if (nearBottom && !loadingImages && images.length < totalImages) {
|
if (nearBottom && !loadingImages && images.length < totalImages) {
|
||||||
void loadMoreImages();
|
void loadMoreImages();
|
||||||
@@ -271,6 +287,10 @@ export function Gallery() {
|
|||||||
return () => element.removeEventListener("scroll", handleScroll);
|
return () => element.removeEventListener("scroll", handleScroll);
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
parentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
|
}, [galleryScrollResetKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const close = (event: PointerEvent) => {
|
const close = (event: PointerEvent) => {
|
||||||
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
|
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
|
||||||
@@ -287,73 +307,87 @@ export function Gallery() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (images.length === 0 && loadingImages) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8">
|
|
||||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
|
|
||||||
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
|
||||||
<p className="mt-4 text-sm text-white/40 font-medium">
|
|
||||||
{searchMode === "semantic" && search.trim().length > 0
|
|
||||||
? `Searching for matches to "${search}"`
|
|
||||||
: "Loading media"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-white/20 mt-1">
|
|
||||||
{searchMode === "semantic" && search.trim().length > 0
|
|
||||||
? "Semantic search can take a little longer than filename search"
|
|
||||||
: "Fetching results"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (images.length === 0 && !loadingImages) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8">
|
|
||||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
|
||||||
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75}
|
|
||||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm text-white/30 font-medium">
|
|
||||||
{searchMode === "semantic" && search.trim().length > 0
|
|
||||||
? "No semantic matches found"
|
|
||||||
: "No media found"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-white/15 mt-1">
|
|
||||||
{searchMode === "semantic" && search.trim().length > 0
|
|
||||||
? "Try a broader phrase, or wait for more embeddings to finish processing"
|
|
||||||
: "Try adjusting your filters or add a new folder"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
|
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
|
||||||
<div
|
{images.length === 0 && loadingImages ? (
|
||||||
className="grid content-start"
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
||||||
style={{
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
|
||||||
padding: GAP,
|
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
||||||
gap: GAP,
|
<p className="mt-4 text-sm text-white/40 font-medium">
|
||||||
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`,
|
{isSimilarResults
|
||||||
}}
|
? "Finding similar images"
|
||||||
>
|
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
|
||||||
{images.map((image) => (
|
? `Searching for matches to "${parsedSearch.query}"`
|
||||||
<ImageTile
|
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
|
||||||
key={image.id}
|
? `Searching tags for "${parsedSearch.query}"`
|
||||||
image={image}
|
: "Loading media"}
|
||||||
onClick={() => openImage(image)}
|
</p>
|
||||||
onContextMenu={(event) => {
|
<p className="text-xs text-white/20 mt-1">
|
||||||
event.preventDefault();
|
{isSimilarResults
|
||||||
setContextMenu({ x: event.clientX, y: event.clientY, image });
|
? "Comparing visual embeddings"
|
||||||
}}
|
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
|
||||||
/>
|
? "Semantic search can take a little longer than filename search"
|
||||||
))}
|
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
|
||||||
</div>
|
? "Matching against AI and user tags"
|
||||||
|
: "Fetching results"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 && !loadingImages ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
||||||
|
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-white/30 font-medium">
|
||||||
|
{imageLoadError
|
||||||
|
? "Could not load results"
|
||||||
|
: isSimilarResults
|
||||||
|
? "No similar images found"
|
||||||
|
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
|
||||||
|
? "No semantic matches found"
|
||||||
|
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
|
||||||
|
? "No tag matches found"
|
||||||
|
: "No media found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/15 mt-1">
|
||||||
|
{imageLoadError
|
||||||
|
? imageLoadError
|
||||||
|
: isSimilarResults
|
||||||
|
? "This item may be visually isolated, or more embeddings may need to finish processing"
|
||||||
|
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
|
||||||
|
? "Try a broader phrase, or wait for more embeddings to finish processing"
|
||||||
|
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
|
||||||
|
? "Try a shorter tag, or wait for more tagging jobs to finish"
|
||||||
|
: "Try adjusting your filters or add a new folder"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="grid content-start"
|
||||||
|
style={{
|
||||||
|
padding: GAP,
|
||||||
|
gap: GAP,
|
||||||
|
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{images.map((image) => (
|
||||||
|
<ImageTile
|
||||||
|
key={image.id}
|
||||||
|
image={image}
|
||||||
|
onClick={() => openImage(image)}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setContextMenu({ x: event.clientX, y: event.clientY, image });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loadingImages ? (
|
{images.length > 0 && loadingImages ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+416
-30
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useCallback, useRef, useState } from "react";
|
import { useEffect, useCallback, useRef, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore } from "../store";
|
import { useGalleryStore, ImageTag, AiRating } from "../store";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -45,18 +45,114 @@ function embeddingLabel(status: string, model: string | null): string {
|
|||||||
return "Queued";
|
return "Queued";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ratingPill(rating: AiRating): { label: string; className: string } {
|
||||||
|
switch (rating) {
|
||||||
|
case "general":
|
||||||
|
return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" };
|
||||||
|
case "sensitive":
|
||||||
|
return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" };
|
||||||
|
case "questionable":
|
||||||
|
return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" };
|
||||||
|
case "explicit":
|
||||||
|
return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragRect {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute a CSS-pixel rect (relative to the viewport container) from a DragRect. */
|
||||||
|
function normaliseRect(r: DragRect): { left: number; top: number; width: number; height: number } {
|
||||||
|
return {
|
||||||
|
left: Math.min(r.startX, r.endX),
|
||||||
|
top: Math.min(r.startY, r.endY),
|
||||||
|
width: Math.abs(r.endX - r.startX),
|
||||||
|
height: Math.abs(r.endY - r.startY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a CSS-pixel drag rect (relative to viewport container) to normalised 0–1 crop coords
|
||||||
|
* relative to the actual rendered <img> element bounds. */
|
||||||
|
function rectToNormalisedCrop(
|
||||||
|
rect: DragRect,
|
||||||
|
imgEl: HTMLImageElement,
|
||||||
|
): { x: number; y: number; w: number; h: number } | null {
|
||||||
|
const imgBounds = imgEl.getBoundingClientRect();
|
||||||
|
if (imgBounds.width === 0 || imgBounds.height === 0) return null;
|
||||||
|
|
||||||
|
// rect coords are already in viewport space (client coords)
|
||||||
|
const rawX = Math.min(rect.startX, rect.endX);
|
||||||
|
const rawY = Math.min(rect.startY, rect.endY);
|
||||||
|
const rawW = Math.abs(rect.endX - rect.startX);
|
||||||
|
const rawH = Math.abs(rect.endY - rect.startY);
|
||||||
|
|
||||||
|
// Clamp to image bounds
|
||||||
|
const clampedX = Math.max(rawX, imgBounds.left);
|
||||||
|
const clampedY = Math.max(rawY, imgBounds.top);
|
||||||
|
const clampedRight = Math.min(rawX + rawW, imgBounds.right);
|
||||||
|
const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom);
|
||||||
|
|
||||||
|
const croppedW = clampedRight - clampedX;
|
||||||
|
const croppedH = clampedBottom - clampedY;
|
||||||
|
|
||||||
|
if (croppedW <= 0 || croppedH <= 0) return null;
|
||||||
|
|
||||||
|
// Normalize by the CSS transform scale — getBoundingClientRect already returns
|
||||||
|
// the scaled (on-screen) size, so we normalize directly against that.
|
||||||
|
return {
|
||||||
|
x: (clampedX - imgBounds.left) / imgBounds.width,
|
||||||
|
y: (clampedY - imgBounds.top) / imgBounds.height,
|
||||||
|
w: croppedW / imgBounds.width,
|
||||||
|
h: croppedH / imgBounds.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum selection size as a fraction of the viewport container dimension. */
|
||||||
|
const MIN_SELECTION_FRACTION = 0.02;
|
||||||
|
|
||||||
export function Lightbox() {
|
export function Lightbox() {
|
||||||
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
||||||
const closeImage = useGalleryStore((state) => state.closeImage);
|
const closeImage = useGalleryStore((state) => state.closeImage);
|
||||||
const images = useGalleryStore((state) => state.images);
|
const images = useGalleryStore((state) => state.images);
|
||||||
const openImage = useGalleryStore((state) => state.openImage);
|
const openImage = useGalleryStore((state) => state.openImage);
|
||||||
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
||||||
|
const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion);
|
||||||
|
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||||
|
const getImageTags = useGalleryStore((state) => state.getImageTags);
|
||||||
|
const addUserTag = useGalleryStore((state) => state.addUserTag);
|
||||||
|
const removeTag = useGalleryStore((state) => state.removeTag);
|
||||||
|
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
|
||||||
|
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
|
||||||
|
|
||||||
|
// Tracks the image id that is currently displayed, used to discard async
|
||||||
|
// tag mutations that resolve after the user has navigated to another image.
|
||||||
|
const currentImageIdRef = useRef<number | null>(null);
|
||||||
|
currentImageIdRef.current = selectedImage?.id ?? null;
|
||||||
|
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [tagAdding, setTagAdding] = useState(false);
|
||||||
|
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||||||
|
const [taggingQueued, setTaggingQueued] = useState(false);
|
||||||
|
|
||||||
|
// Region selection state
|
||||||
|
const [regionSelectMode, setRegionSelectMode] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragRect, setDragRect] = useState<DragRect | null>(null);
|
||||||
|
const [regionSearching, setRegionSearching] = useState(false);
|
||||||
|
|
||||||
const imageViewportRef = useRef<HTMLDivElement>(null);
|
const imageViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
||||||
const canFindSimilar = selectedImage?.embedding_status === "ready";
|
const canFindSimilar = selectedImage?.embedding_status === "ready";
|
||||||
|
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
const goPrev = useCallback(() => {
|
||||||
if (currentIndex > 0) openImage(images[currentIndex - 1]);
|
if (currentIndex > 0) openImage(images[currentIndex - 1]);
|
||||||
@@ -66,15 +162,44 @@ export function Lightbox() {
|
|||||||
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]);
|
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]);
|
||||||
}, [currentIndex, images, openImage]);
|
}, [currentIndex, images, openImage]);
|
||||||
|
|
||||||
|
const exitRegionMode = useCallback(() => {
|
||||||
|
setRegionSelectMode(false);
|
||||||
|
setIsDragging(false);
|
||||||
|
setDragRect(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
}, [selectedImage?.id]);
|
setImageTags([]);
|
||||||
|
setTagInput("");
|
||||||
|
setTagsExpanded(false);
|
||||||
|
setTaggingQueued(false);
|
||||||
|
exitRegionMode();
|
||||||
|
setRegionSearching(false);
|
||||||
|
}, [selectedImage?.id, exitRegionMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedImage) return;
|
||||||
|
// Capture the ID so a stale response for image A cannot overwrite B's tags
|
||||||
|
// when the user navigates before the request resolves.
|
||||||
|
let cancelled = false;
|
||||||
|
void getImageTags(selectedImage.id)
|
||||||
|
.then((tags) => { if (!cancelled) setImageTags(tags); })
|
||||||
|
.catch(() => { if (!cancelled) setImageTags([]); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
|
||||||
|
|
||||||
|
// Reset the queued state once the worker finishes so the button is usable again
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
|
||||||
|
}, [selectedImage?.ai_tagged_at]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewport = imageViewportRef.current;
|
const viewport = imageViewportRef.current;
|
||||||
if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return;
|
if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return;
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
if (regionSelectMode) return; // don't zoom during selection
|
||||||
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
|
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setZoom((value) => {
|
setZoom((value) => {
|
||||||
@@ -85,12 +210,19 @@ export function Lightbox() {
|
|||||||
|
|
||||||
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
return () => viewport.removeEventListener("wheel", handleWheel);
|
return () => viewport.removeEventListener("wheel", handleWheel);
|
||||||
}, [selectedImage]);
|
}, [selectedImage, regionSelectMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (event: KeyboardEvent) => {
|
const handler = (event: KeyboardEvent) => {
|
||||||
if (!selectedImage) return;
|
if (!selectedImage) return;
|
||||||
if (event.key === "Escape") closeImage();
|
if (event.key === "Escape") {
|
||||||
|
if (regionSelectMode) {
|
||||||
|
exitRegionMode();
|
||||||
|
} else {
|
||||||
|
closeImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (regionSelectMode) return; // block nav keys during selection
|
||||||
if (event.key === "ArrowLeft") goPrev();
|
if (event.key === "ArrowLeft") goPrev();
|
||||||
if (event.key === "ArrowRight") goNext();
|
if (event.key === "ArrowRight") goNext();
|
||||||
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
|
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
|
||||||
@@ -99,7 +231,75 @@ export function Lightbox() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [selectedImage, closeImage, goPrev, goNext]);
|
}, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode]);
|
||||||
|
|
||||||
|
// ── Region selection pointer handlers ───────────────────────────────────────
|
||||||
|
const handleRegionPointerDown = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!regionSelectMode) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragRect({
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
endX: event.clientX,
|
||||||
|
endY: event.clientY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[regionSelectMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegionPointerMove = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
setDragRect((prev) =>
|
||||||
|
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[isDragging],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegionPointerUp = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
|
||||||
|
setIsDragging(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY };
|
||||||
|
const crop = rectToNormalisedCrop(finalRect, imgRef.current);
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
setDragRect(null);
|
||||||
|
|
||||||
|
// Ignore tiny accidental clicks
|
||||||
|
const containerBounds = imageViewportRef.current?.getBoundingClientRect();
|
||||||
|
const containerSize = containerBounds
|
||||||
|
? Math.min(containerBounds.width, containerBounds.height)
|
||||||
|
: 500;
|
||||||
|
const selW = Math.abs(finalRect.endX - finalRect.startX);
|
||||||
|
const selH = Math.abs(finalRect.endY - finalRect.startY);
|
||||||
|
if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) {
|
||||||
|
exitRegionMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
exitRegionMode();
|
||||||
|
setRegionSearching(true);
|
||||||
|
|
||||||
|
const folderId =
|
||||||
|
similarScope === "current_folder" ? selectedImage.folder_id : null;
|
||||||
|
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
|
||||||
|
.finally(() => setRegionSearching(false));
|
||||||
|
},
|
||||||
|
[isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the CSS rect for the selection overlay (viewport-relative)
|
||||||
|
const selectionOverlay =
|
||||||
|
isDragging && dragRect ? normaliseRect(dragRect) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -111,11 +311,11 @@ export function Lightbox() {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
onClick={closeImage}
|
onClick={regionSelectMode ? undefined : closeImage}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
className="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
||||||
disabled={currentIndex <= 0}
|
disabled={currentIndex <= 0 || regionSelectMode}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
goPrev();
|
goPrev();
|
||||||
@@ -130,8 +330,38 @@ export function Lightbox() {
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={imageViewportRef}
|
ref={imageViewportRef}
|
||||||
className="group relative flex flex-1 items-center justify-center overflow-auto p-10"
|
className={`group relative flex flex-1 items-center justify-center overflow-auto p-10 ${
|
||||||
|
regionSelectMode ? "cursor-crosshair select-none" : ""
|
||||||
|
}`}
|
||||||
|
onPointerDown={handleRegionPointerDown}
|
||||||
|
onPointerMove={handleRegionPointerMove}
|
||||||
|
onPointerUp={handleRegionPointerUp}
|
||||||
>
|
>
|
||||||
|
{/* Region selection mode hint */}
|
||||||
|
{regionSelectMode && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-4 z-20 flex justify-center">
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-xs text-gray-300 backdrop-blur">
|
||||||
|
<svg className="h-3.5 w-3.5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
Draw a region to search — <kbd className="ml-1 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd> to cancel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection rectangle overlay */}
|
||||||
|
{selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed z-30 rounded border-2 border-violet-400 bg-violet-400/15 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
|
||||||
|
style={{
|
||||||
|
left: selectionOverlay.left,
|
||||||
|
top: selectionOverlay.top,
|
||||||
|
width: selectionOverlay.width,
|
||||||
|
height: selectionOverlay.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={selectedImage.id}
|
key={selectedImage.id}
|
||||||
@@ -151,6 +381,7 @@ export function Lightbox() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
|
ref={imgRef}
|
||||||
src={convertFileSrc(selectedImage.path)}
|
src={convertFileSrc(selectedImage.path)}
|
||||||
alt={selectedImage.filename}
|
alt={selectedImage.filename}
|
||||||
className="max-w-full rounded-2xl shadow-2xl"
|
className="max-w-full rounded-2xl shadow-2xl"
|
||||||
@@ -158,33 +389,37 @@ export function Lightbox() {
|
|||||||
maxHeight: "calc(100vh - 10rem)",
|
maxHeight: "calc(100vh - 10rem)",
|
||||||
transform: `scale(${zoom})`,
|
transform: `scale(${zoom})`,
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
|
// Slightly dim the image while in region select mode
|
||||||
|
...(regionSelectMode ? { opacity: 0.85 } : {}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
|
{!regionSelectMode && (
|
||||||
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<button
|
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
|
||||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
<button
|
||||||
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
|
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||||
>
|
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
|
||||||
-
|
>
|
||||||
</button>
|
-
|
||||||
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
</button>
|
||||||
<button
|
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
||||||
className="px-2 text-sm text-gray-300 hover:text-white"
|
<button
|
||||||
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
|
className="px-2 text-sm text-gray-300 hover:text-white"
|
||||||
>
|
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
|
||||||
+
|
>
|
||||||
</button>
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 p-5">
|
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
|
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
|
||||||
<p className="text-xs text-gray-500">Details</p>
|
<p className="text-xs text-gray-500">Details</p>
|
||||||
@@ -207,7 +442,7 @@ export function Lightbox() {
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canFindSimilar) return;
|
if (!canFindSimilar) return;
|
||||||
void loadSimilarImages(selectedImage.id);
|
void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id);
|
||||||
}}
|
}}
|
||||||
disabled={!canFindSimilar}
|
disabled={!canFindSimilar}
|
||||||
>
|
>
|
||||||
@@ -221,7 +456,54 @@ export function Lightbox() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 text-sm">
|
{/* Search region button row */}
|
||||||
|
{canSearchRegion && (
|
||||||
|
<div className="shrink-0 px-5 pb-3">
|
||||||
|
<button
|
||||||
|
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||||
|
regionSelectMode
|
||||||
|
? "border-violet-400/40 bg-violet-500/15 text-violet-300 hover:bg-violet-500/20"
|
||||||
|
: regionSearching
|
||||||
|
? "border-white/5 bg-white/[0.03] text-gray-500 cursor-not-allowed"
|
||||||
|
: "border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (regionSearching) return;
|
||||||
|
setRegionSelectMode((prev) => !prev);
|
||||||
|
setDragRect(null);
|
||||||
|
setIsDragging(false);
|
||||||
|
}}
|
||||||
|
disabled={regionSearching}
|
||||||
|
title={regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content"}
|
||||||
|
>
|
||||||
|
{regionSearching ? (
|
||||||
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
|
<svg className="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
Searching region…
|
||||||
|
</span>
|
||||||
|
) : regionSelectMode ? (
|
||||||
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Cancel selection
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
Search within image
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
|
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -316,13 +598,117 @@ export function Lightbox() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-gray-500">Tags</p>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{selectedImage.ai_rating ? (
|
||||||
|
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${ratingPill(selectedImage.ai_rating).className}`}>
|
||||||
|
{ratingPill(selectedImage.ai_rating).label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{selectedImage.media_kind === "image" ? (
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!(taggerModelStatus?.ready ?? false) || taggingQueued}
|
||||||
|
title={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"}
|
||||||
|
onClick={() => {
|
||||||
|
setTaggingQueued(true);
|
||||||
|
void queueTaggingForImage(selectedImage.id).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{taggingQueued ? "Queued" : "AI tags"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageTags.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => (
|
||||||
|
<span
|
||||||
|
key={t.id}
|
||||||
|
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
|
||||||
|
t.source === "ai"
|
||||||
|
? "border-sky-400/20 bg-sky-500/8 text-sky-300"
|
||||||
|
: "border-white/10 bg-white/5 text-gray-300"
|
||||||
|
}`}
|
||||||
|
title={t.source === "ai" && t.confidence !== null ? `AI confidence: ${(t.confidence * 100).toFixed(0)}%` : undefined}
|
||||||
|
>
|
||||||
|
{t.tag}
|
||||||
|
<button
|
||||||
|
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||||
|
onClick={() => {
|
||||||
|
void removeTag(t.id).then(() =>
|
||||||
|
setImageTags((prev) => prev.filter((x) => x.id !== t.id)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
title="Remove tag"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{imageTags.length > 8 && (
|
||||||
|
<button
|
||||||
|
className="mt-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
onClick={() => setTagsExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{tagsExpanded ? "Show less" : `+${imageTags.length - 8} more`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600">No tags yet</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-2 flex gap-1.5"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const raw = tagInput.trim();
|
||||||
|
if (!raw || tagAdding) return;
|
||||||
|
setTagAdding(true);
|
||||||
|
const taggedImageId = selectedImage.id;
|
||||||
|
void addUserTag(taggedImageId, raw)
|
||||||
|
.then((newTag) => {
|
||||||
|
// Discard if the user navigated away before the request resolved.
|
||||||
|
if (currentImageIdRef.current !== taggedImageId) return;
|
||||||
|
setImageTags((prev) => [...prev, newTag]);
|
||||||
|
setTagInput("");
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setTagAdding(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
|
||||||
|
placeholder="Add tag…"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
disabled={tagAdding}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={tagAdding || !tagInput.trim()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p>
|
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p>
|
||||||
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
|
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto pt-4 text-center text-xs text-gray-600">
|
<div className="shrink-0 mt-auto px-5 pb-4 pt-2 text-center text-xs text-gray-600">
|
||||||
{currentIndex + 1} / {images.length}
|
{currentIndex + 1} / {images.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,7 +717,7 @@ export function Lightbox() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
|
||||||
disabled={currentIndex >= images.length - 1}
|
disabled={currentIndex >= images.length - 1 || regionSelectMode}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
goNext();
|
goNext();
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store";
|
||||||
|
|
||||||
|
type SettingsSection = "workspace" | "workers";
|
||||||
|
|
||||||
|
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
||||||
|
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
|
||||||
|
{ id: "workers", label: "Workers", detail: "Queue activity and background processing" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
|
||||||
|
const className =
|
||||||
|
tone === "ready"
|
||||||
|
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300"
|
||||||
|
: tone === "busy"
|
||||||
|
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
|
||||||
|
: "border-white/10 bg-white/[0.04] text-gray-500";
|
||||||
|
|
||||||
|
return <span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionShell({ eyebrow, title, description, children }: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
|
||||||
|
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-gray-500">{description}</p> : null}
|
||||||
|
<div className="mt-5 space-y-4">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5">
|
||||||
|
<div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4">
|
||||||
|
<p className="text-sm font-medium text-white">{title}</p>
|
||||||
|
{description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0 first:pt-0 last:pb-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white">{title}</p>
|
||||||
|
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeButton({ scope, current, onSelect, children }: {
|
||||||
|
scope: TaggingQueueScope;
|
||||||
|
current: TaggingQueueScope;
|
||||||
|
onSelect: (scope: TaggingQueueScope) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const active = scope === current;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
|
||||||
|
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(scope)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
|
||||||
|
acceleration: TaggerAcceleration;
|
||||||
|
current: TaggerAcceleration;
|
||||||
|
onSelect: (acceleration: TaggerAcceleration) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const active = acceleration === current;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
|
||||||
|
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(acceleration)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal() {
|
||||||
|
const [activeSection, setActiveSection] = useState<SettingsSection>("workspace");
|
||||||
|
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
|
||||||
|
const [taggerQueueing, setTaggerQueueing] = useState(false);
|
||||||
|
const [taggerClearing, setTaggerClearing] = useState(false);
|
||||||
|
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
|
||||||
|
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
|
||||||
|
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
|
||||||
|
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null);
|
||||||
|
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
|
||||||
|
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
|
||||||
|
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
||||||
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
|
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||||
|
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
|
||||||
|
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
|
||||||
|
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
|
||||||
|
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
|
||||||
|
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
|
||||||
|
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
|
||||||
|
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
|
||||||
|
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
|
||||||
|
const taggerModelError = useGalleryStore((state) => state.taggerModelError);
|
||||||
|
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration);
|
||||||
|
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold);
|
||||||
|
const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize);
|
||||||
|
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe);
|
||||||
|
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking);
|
||||||
|
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
|
||||||
|
const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel);
|
||||||
|
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
|
||||||
|
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration);
|
||||||
|
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
|
||||||
|
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold);
|
||||||
|
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
|
||||||
|
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize);
|
||||||
|
const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize);
|
||||||
|
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime);
|
||||||
|
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
|
||||||
|
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
|
||||||
|
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
|
||||||
|
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsOpen) return;
|
||||||
|
void loadTaggerModelStatus();
|
||||||
|
void loadTaggerAcceleration();
|
||||||
|
void loadTaggerThreshold();
|
||||||
|
void loadTaggerBatchSize();
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setSettingsOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, setSettingsOpen]);
|
||||||
|
|
||||||
|
const selectedFolders = useMemo(
|
||||||
|
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
|
||||||
|
[folders, taggingQueueFolderIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalQueuedJobs = useMemo(
|
||||||
|
() => Object.values(mediaJobProgress).reduce((sum, progress) => sum + (progress?.tagging_pending ?? 0), 0),
|
||||||
|
[mediaJobProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!settingsOpen) return null;
|
||||||
|
|
||||||
|
const taggerReady = taggerModelStatus?.ready ?? false;
|
||||||
|
const queueScopeLabel =
|
||||||
|
taggingQueueScope === "all"
|
||||||
|
? "all media"
|
||||||
|
: selectedFolders.length > 0
|
||||||
|
? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? "" : "s"}`
|
||||||
|
: "no folders selected";
|
||||||
|
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
|
||||||
|
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize);
|
||||||
|
const taggerDownloadLabel = taggerModelProgress
|
||||||
|
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
|
||||||
|
: taggerModelPreparing
|
||||||
|
? "Preparing WD Tagger..."
|
||||||
|
: taggerReady
|
||||||
|
? "Installed"
|
||||||
|
: "Install model";
|
||||||
|
const taggerDownloadPercent = taggerModelProgress
|
||||||
|
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const runQueueAction = (action: "queue" | "clear") => {
|
||||||
|
const selectedIds = taggingQueueFolderIds;
|
||||||
|
const perform =
|
||||||
|
taggingQueueScope === "all"
|
||||||
|
? action === "queue"
|
||||||
|
? queueTaggingJobs(null)
|
||||||
|
: clearTaggingJobs(null)
|
||||||
|
: selectedIds.length > 0
|
||||||
|
? action === "queue"
|
||||||
|
? queueTaggingJobsForFolders(selectedIds)
|
||||||
|
: clearTaggingJobsForFolders(selectedIds)
|
||||||
|
: Promise.resolve(0);
|
||||||
|
|
||||||
|
if (action === "queue") {
|
||||||
|
setTaggerQueueing(true);
|
||||||
|
} else {
|
||||||
|
setTaggerClearing(true);
|
||||||
|
}
|
||||||
|
setTaggerQueueStatus(null);
|
||||||
|
|
||||||
|
void perform
|
||||||
|
.then((count) => {
|
||||||
|
if (taggingQueueScope === "selected" && selectedIds.length === 0) {
|
||||||
|
setTaggerQueueStatus("Choose at least one folder before running tagging jobs.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTaggerQueueStatus(
|
||||||
|
count === 0
|
||||||
|
? action === "queue"
|
||||||
|
? "No missing tags found for the current target."
|
||||||
|
: "No queued tagging jobs to clear for the current target."
|
||||||
|
: action === "queue"
|
||||||
|
? `Queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for tagging.`
|
||||||
|
: `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? "" : "s"}.`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => {
|
||||||
|
if (action === "queue") {
|
||||||
|
setTaggerQueueing(false);
|
||||||
|
} else {
|
||||||
|
setTaggerClearing(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="flex h-[min(760px,calc(100vh-56px))] w-full max-w-5xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
|
||||||
|
<div className="border-b border-white/[0.07] px-5 py-5">
|
||||||
|
<p className="text-base font-semibold text-white">Settings</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-600">Operational controls for AI workflows</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
className={`w-full rounded-md px-3 py-2.5 text-left transition-colors ${
|
||||||
|
activeSection === section.id ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/[0.055] hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
>
|
||||||
|
<span className="block text-[13px] font-medium">{section.label}</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-gray-600">{section.detail}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "Workers"}</p>
|
||||||
|
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "Background processing status"}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
title="Close settings"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-7 py-6">
|
||||||
|
{activeSection === "workspace" ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<SectionShell
|
||||||
|
eyebrow="AI Workspace"
|
||||||
|
title="Tagging"
|
||||||
|
>
|
||||||
|
<SettingsCard title="Tagging Models">
|
||||||
|
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-white">WD SwinV2 Tagger v3</p>
|
||||||
|
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
|
||||||
|
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
|
||||||
|
</StatusPill>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">
|
||||||
|
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<button
|
||||||
|
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => void prepareTaggerModel()}
|
||||||
|
disabled={taggerModelPreparing || taggerReady}
|
||||||
|
>
|
||||||
|
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
|
||||||
|
<span className="relative">{taggerDownloadLabel}</span>
|
||||||
|
</button>
|
||||||
|
{taggerReady ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => void probeTaggerRuntime()}
|
||||||
|
disabled={taggerRuntimeChecking}
|
||||||
|
>
|
||||||
|
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => void deleteTaggerModel()}
|
||||||
|
disabled={taggerModelPreparing}
|
||||||
|
>
|
||||||
|
Delete model files
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4 border-t border-white/[0.07] pt-4">
|
||||||
|
<SettingsRow title="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
|
||||||
|
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
|
||||||
|
<TaggerAccelerationButton
|
||||||
|
key={acceleration}
|
||||||
|
acceleration={acceleration}
|
||||||
|
current={taggerAcceleration}
|
||||||
|
onSelect={(nextAcceleration) => {
|
||||||
|
setTaggerAccelerationSaving(true);
|
||||||
|
void setTaggerAcceleration(nextAcceleration)
|
||||||
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => setTaggerAccelerationSaving(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
|
||||||
|
</TaggerAccelerationButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow title="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.05"
|
||||||
|
max="0.99"
|
||||||
|
step="0.05"
|
||||||
|
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
|
||||||
|
value={thresholdDisplay}
|
||||||
|
onChange={(event) => setTaggerThresholdDraft(event.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const value = parseFloat(thresholdDisplay);
|
||||||
|
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
|
||||||
|
setTaggerThresholdSaving(true);
|
||||||
|
void setTaggerThreshold(value)
|
||||||
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => {
|
||||||
|
setTaggerThresholdDraft(null);
|
||||||
|
setTaggerThresholdSaving(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTaggerThresholdDraft(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow title="Tagging batch size" description="Number of images processed concurrently during tag generation.">
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
|
||||||
|
value={batchSizeDisplay}
|
||||||
|
onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const value = parseInt(batchSizeDisplay, 10);
|
||||||
|
if (!isNaN(value) && value >= 1 && value <= 100) {
|
||||||
|
setTaggerBatchSizeSaving(true);
|
||||||
|
void setTaggerBatchSize(value)
|
||||||
|
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => {
|
||||||
|
setTaggerBatchSizeDraft(null);
|
||||||
|
setTaggerBatchSizeSaving(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTaggerBatchSizeDraft(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-400">Model location</p>
|
||||||
|
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
|
||||||
|
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
|
||||||
|
</p>
|
||||||
|
{taggerModelProgress?.current_file ? <p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
|
||||||
|
{taggerModelError ? <p className="mt-3 text-xs text-amber-300">{taggerModelError}</p> : null}
|
||||||
|
{taggerRuntimeProbe ? (
|
||||||
|
<div className="mt-4 rounded-lg border border-white/[0.07] bg-black/20 px-3 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-medium text-gray-400">Runtime check</p>
|
||||||
|
<StatusPill tone="ready">Ready</StatusPill>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-600">Tagger acceleration: {taggerRuntimeProbe.acceleration}</p>
|
||||||
|
<p className="mt-2 break-all text-xs text-gray-500">{taggerRuntimeProbe.session.file}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs.">
|
||||||
|
<SettingsRow title="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
|
||||||
|
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
|
||||||
|
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
|
||||||
|
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<div className="border-b border-white/[0.07] py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Folder selection</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40"
|
||||||
|
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
|
||||||
|
disabled={folders.length === 0}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40"
|
||||||
|
onClick={() => setTaggingQueueFolderIds([])}
|
||||||
|
disabled={taggingQueueFolderIds.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
|
||||||
|
{folders.map((folder) => {
|
||||||
|
const active = taggingQueueFolderIds.includes(folder.id);
|
||||||
|
const progress = mediaJobProgress[folder.id];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={folder.id}
|
||||||
|
type="button"
|
||||||
|
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-emerald-400/30 bg-emerald-500/10 text-white"
|
||||||
|
: "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]"
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleTaggingQueueFolder(folder.id)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{folder.name}</p>
|
||||||
|
<p className="mt-1 text-[11px] text-gray-500">{folder.image_count.toLocaleString()} items</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
|
||||||
|
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsRow title="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => runQueueAction("queue")}
|
||||||
|
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
||||||
|
>
|
||||||
|
{taggerQueueing ? "Queueing..." : "Queue tagging"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => runQueueAction("clear")}
|
||||||
|
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
||||||
|
>
|
||||||
|
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Captioning">
|
||||||
|
<div className="rounded-xl border border-dashed border-white/[0.09] bg-black/20 px-4 py-4">
|
||||||
|
<p className="text-sm font-medium text-white/50">Coming soon</p>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
</SectionShell>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<SectionShell
|
||||||
|
eyebrow="Workers"
|
||||||
|
title="Background processing"
|
||||||
|
>
|
||||||
|
<SettingsCard title="Queue summary" description="Live totals across all folder workers.">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Tagging queued</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">{totalQueuedJobs.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Selected folders</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">{selectedFolders.length.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Library folders</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">{folders.length.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Worker controls" description="Pause, resume, retry, and per-folder failure details are available in the background tasks panel.">
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||||
|
<p className="text-sm text-gray-400">Workers are running in the background.</p>
|
||||||
|
<StatusPill tone="ready">Live</StatusPill>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
</SectionShell>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+258
-51
@@ -1,6 +1,73 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { useGalleryStore, Folder, IndexProgress } from "../store";
|
import { useGalleryStore, Folder, IndexProgress } from "../store";
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
folderId: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderContextMenu({
|
||||||
|
menu,
|
||||||
|
folder,
|
||||||
|
onClose,
|
||||||
|
onRename,
|
||||||
|
onReindex,
|
||||||
|
onLocate,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
menu: ContextMenuState;
|
||||||
|
folder: Folder;
|
||||||
|
onClose: () => void;
|
||||||
|
onRename: () => void;
|
||||||
|
onReindex: () => void;
|
||||||
|
onLocate: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDown = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
document.addEventListener("mousedown", handleDown);
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleDown);
|
||||||
|
document.removeEventListener("keydown", handleKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const item = (label: string, onClick: () => void, danger = false) => (
|
||||||
|
<button
|
||||||
|
className={`w-full text-left px-3 py-1.5 text-[12px] rounded-md transition-colors ${
|
||||||
|
danger
|
||||||
|
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
|
||||||
|
: "text-gray-300 hover:bg-white/8 hover:text-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => { onClick(); onClose(); }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="fixed z-50 min-w-[160px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
|
||||||
|
style={{ left: menu.x, top: menu.y }}
|
||||||
|
>
|
||||||
|
{item("Reindex", onReindex)}
|
||||||
|
{item("Rename", onRename)}
|
||||||
|
{folder.scan_error && item("Locate Folder", onLocate)}
|
||||||
|
<div className="my-1 border-t border-white/[0.06]" />
|
||||||
|
{item("Remove from app", onRemove, true)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FolderItem({
|
function FolderItem({
|
||||||
folder,
|
folder,
|
||||||
selected,
|
selected,
|
||||||
@@ -10,64 +77,187 @@ function FolderItem({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
progress: IndexProgress | undefined;
|
progress: IndexProgress | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
|
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath } = useGalleryStore();
|
||||||
const isIndexing = progress && !progress.done;
|
const isIndexing = progress && !progress.done;
|
||||||
|
const isMissing = !!folder.scan_error && !isIndexing;
|
||||||
|
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState(folder.name);
|
||||||
|
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renaming) {
|
||||||
|
setRenameValue(folder.name);
|
||||||
|
setTimeout(() => renameInputRef.current?.select(), 0);
|
||||||
|
}
|
||||||
|
}, [renaming, folder.name]);
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Keep menu inside viewport
|
||||||
|
const x = Math.min(e.clientX, window.innerWidth - 180);
|
||||||
|
const y = Math.min(e.clientY, window.innerHeight - 160);
|
||||||
|
setContextMenu({ folderId: folder.id, x, y });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocateFolder = async () => {
|
||||||
|
const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` });
|
||||||
|
if (picked && typeof picked === "string") {
|
||||||
|
await updateFolderPath(folder.id, picked);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitRename = async () => {
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
if (trimmed && trimmed !== folder.name) {
|
||||||
|
await renameFolder(folder.id, trimmed);
|
||||||
|
}
|
||||||
|
setRenaming(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
|
||||||
|
if (e.key === "Escape") { setRenaming(false); }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
<div
|
||||||
selected
|
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||||
? "bg-white/8 text-white"
|
selected
|
||||||
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
? "bg-white/8 text-white"
|
||||||
}`}
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
||||||
onClick={() => selectFolder(folder.id)}
|
}`}
|
||||||
>
|
onClick={() => !renaming && selectFolder(folder.id)}
|
||||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
onContextMenu={handleContextMenu}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
>
|
||||||
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
{isMissing ? (
|
||||||
</svg>
|
<span className="shrink-0 text-amber-400">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<div className="flex-1 min-w-0">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
{folder.name}
|
</svg>
|
||||||
</div>
|
</span>
|
||||||
{isIndexing ? (
|
|
||||||
<>
|
|
||||||
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div>
|
|
||||||
<div className="h-px bg-white/10 rounded mt-1.5 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-blue-500 transition-all duration-300"
|
|
||||||
style={{ width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[11px] text-gray-600 mt-0.5">{folder.image_count.toLocaleString()}</div>
|
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
|
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{renaming ? (
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={handleRenameKey}
|
||||||
|
onBlur={() => void commitRename()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
|
||||||
|
{folder.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isIndexing ? (
|
||||||
|
<>
|
||||||
|
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div>
|
||||||
|
<div className="h-px bg-white/10 rounded mt-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all duration-300"
|
||||||
|
style={{ width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-gray-600 mt-0.5">{folder.image_count.toLocaleString()}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover action buttons */}
|
||||||
|
{!renaming && (
|
||||||
|
confirmingRemoval ? (
|
||||||
|
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300 transition-colors"
|
||||||
|
onClick={() => { void removeFolder(folder.id); setConfirmingRemoval(false); }}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-1.5 py-0.5 text-[10px] rounded bg-white/5 text-gray-500 hover:bg-white/10 hover:text-gray-300 transition-colors"
|
||||||
|
onClick={() => setConfirmingRemoval(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
|
||||||
|
title="Reindex"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }}
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
|
title="Remove from app"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
{isMissing && (
|
||||||
<button
|
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||||
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200"
|
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p>
|
||||||
title="Re-index"
|
<p className="text-[10px] text-gray-500 mb-2 leading-snug">
|
||||||
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }}
|
This folder may have been moved or renamed. Locate it to resume, or remove it from the app.
|
||||||
>
|
</p>
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<div className="flex gap-1.5">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
<button
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 hover:text-amber-200 transition-colors"
|
||||||
</svg>
|
onClick={(e) => { e.stopPropagation(); void handleLocateFolder(); }}
|
||||||
</button>
|
>
|
||||||
<button
|
Locate Folder
|
||||||
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400"
|
</button>
|
||||||
title="Remove folder"
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); removeFolder(folder.id); }}
|
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-white/5 text-gray-400 hover:bg-red-500/15 hover:text-red-400 transition-colors"
|
||||||
>
|
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
Remove
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{contextMenu && contextMenu.folderId === folder.id && (
|
||||||
|
<FolderContextMenu
|
||||||
|
menu={contextMenu}
|
||||||
|
folder={folder}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
onRename={() => setRenaming(true)}
|
||||||
|
onReindex={() => void reindexFolder(folder.id)}
|
||||||
|
onLocate={() => void handleLocateFolder()}
|
||||||
|
onRemove={() => setConfirmingRemoval(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +328,23 @@ export function Sidebar() {
|
|||||||
Explore
|
Explore
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||||
|
activeView === "duplicates"
|
||||||
|
? "bg-white/8 text-white"
|
||||||
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
onClick={() => setView("duplicates")}
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className={`text-[13px] font-medium ${activeView === "duplicates" ? "text-white" : ""}`}>
|
||||||
|
Duplicates
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section label */}
|
{/* Section label */}
|
||||||
|
|||||||
+322
-190
@@ -1,243 +1,375 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore, TagCloudEntry } from "../store";
|
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
||||||
|
|
||||||
// Accent glow colours for the hover ring — cycled by index
|
const ACCENTS = [
|
||||||
const GLOWS: string[] = [
|
"#60a5fa",
|
||||||
"rgba(59,130,246,0.5)",
|
"#c084fc",
|
||||||
"rgba(168,85,247,0.5)",
|
"#4ade80",
|
||||||
"rgba(16,185,129,0.5)",
|
"#fbbf24",
|
||||||
"rgba(245,158,11,0.5)",
|
"#f472b4",
|
||||||
"rgba(236,72,153,0.5)",
|
"#2dd4bf",
|
||||||
"rgba(6,182,212,0.5)",
|
"#fb923c",
|
||||||
"rgba(249,115,22,0.5)",
|
"#a78bfa",
|
||||||
"rgba(34,197,94,0.5)",
|
"#34d399",
|
||||||
|
"#f87171",
|
||||||
];
|
];
|
||||||
|
|
||||||
function pseudoRandom(seed: number): number {
|
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||||
const x = Math.sin(seed + 1) * 10000;
|
|
||||||
|
function seeded(n: number): number {
|
||||||
|
const x = Math.sin(n * 9301 + 49297) * 233280;
|
||||||
return x - Math.floor(x);
|
return x - Math.floor(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map cluster size to a tile size bucket (px)
|
interface PlacedNode {
|
||||||
function getTileSize(count: number, maxCount: number): number {
|
|
||||||
if (maxCount === 0) return 72;
|
|
||||||
const ratio = count / maxCount;
|
|
||||||
if (ratio > 0.75) return 160;
|
|
||||||
if (ratio > 0.45) return 128;
|
|
||||||
if (ratio > 0.22) return 104;
|
|
||||||
if (ratio > 0.08) return 88;
|
|
||||||
return 72;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TagButton({
|
|
||||||
entry,
|
|
||||||
index,
|
|
||||||
maxCount,
|
|
||||||
onSearch,
|
|
||||||
}: {
|
|
||||||
entry: TagCloudEntry;
|
entry: TagCloudEntry;
|
||||||
index: number;
|
index: number;
|
||||||
maxCount: number;
|
x: number;
|
||||||
onSearch: (imageId: number) => void;
|
y: number;
|
||||||
}) {
|
w: number;
|
||||||
const size = getTileSize(entry.count, maxCount);
|
h: number;
|
||||||
const glow = GLOWS[index % GLOWS.length];
|
accent: string;
|
||||||
|
driftX: number;
|
||||||
|
driftY: number;
|
||||||
|
driftDuration: number;
|
||||||
|
rotateSeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Small random rotation for organic feel — larger tiles stay flatter
|
function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: number): PlacedNode[] {
|
||||||
const maxRot = size >= 128 ? 0 : size >= 104 ? 3 : size >= 88 ? 6 : 10;
|
if (!entries.length || containerW <= 0 || containerH <= 0) return [];
|
||||||
const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * maxRot;
|
|
||||||
|
|
||||||
const mt = Math.floor(pseudoRandom(index * 3) * 10) + 4;
|
const maxCount = Math.max(...entries.map((e) => e.count));
|
||||||
const mr = Math.floor(pseudoRandom(index * 5) * 12) + 4;
|
const cx = containerW / 2;
|
||||||
const mb = Math.floor(pseudoRandom(index * 11) * 10) + 4;
|
const cy = containerH / 2;
|
||||||
const ml = Math.floor(pseudoRandom(index * 13) * 12) + 4;
|
// Spread ellipse shrinks slightly to leave room for card half-widths at the edges
|
||||||
|
const spreadX = containerW * 0.42;
|
||||||
|
const spreadY = containerH * 0.36;
|
||||||
|
const n = entries.length;
|
||||||
|
|
||||||
const src = entry.thumbnail_path ? convertFileSrc(entry.thumbnail_path) : null;
|
// 1. Build initial positions using phyllotaxis spiral
|
||||||
|
const nodes: PlacedNode[] = entries.map((entry, i) => {
|
||||||
|
const ratio = Math.max(entry.count / maxCount, 0.08);
|
||||||
|
// Cards scale from 110px to 230px wide; height is 3/4 of width
|
||||||
|
const w = 110 + Math.sqrt(ratio) * 120;
|
||||||
|
const h = w * 0.75;
|
||||||
|
const radialRatio = Math.sqrt((i + 0.5) / n);
|
||||||
|
const angle = i * GOLDEN_ANGLE;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
index: i,
|
||||||
|
x: cx + Math.cos(angle) * radialRatio * spreadX,
|
||||||
|
y: cy + Math.sin(angle) * radialRatio * spreadY,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
accent: ACCENTS[i % ACCENTS.length],
|
||||||
|
driftX: (seeded(i + 11) - 0.5) * 18,
|
||||||
|
driftY: (seeded(i + 17) - 0.5) * 14,
|
||||||
|
driftDuration: 8 + seeded(i + 23) * 7,
|
||||||
|
rotateSeed: (seeded(i + 31) - 0.5) * 4,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Iterative overlap resolution — no physics, just push apart
|
||||||
|
const PAD = 24;
|
||||||
|
for (let iter = 0; iter < 80; iter++) {
|
||||||
|
for (let a = 0; a < nodes.length; a++) {
|
||||||
|
const na = nodes[a];
|
||||||
|
for (let b = a + 1; b < nodes.length; b++) {
|
||||||
|
const nb = nodes[b];
|
||||||
|
const dx = nb.x - na.x;
|
||||||
|
const dy = nb.y - na.y;
|
||||||
|
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
|
||||||
|
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
|
||||||
|
if (overlapX <= 0 || overlapY <= 0) continue;
|
||||||
|
// Push along the smaller overlap axis
|
||||||
|
if (overlapX < overlapY) {
|
||||||
|
const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1);
|
||||||
|
nb.x += push;
|
||||||
|
na.x -= push;
|
||||||
|
} else {
|
||||||
|
const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1);
|
||||||
|
nb.y += push;
|
||||||
|
na.y -= push;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pull gently back toward anchor to prevent runaway drift
|
||||||
|
na.x += (cx + Math.cos(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadX - na.x) * 0.05;
|
||||||
|
na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clamp so cards never poke outside the container
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
x: Math.min(Math.max(node.x, node.w / 2 + 16), containerW - node.w / 2 - 16),
|
||||||
|
y: Math.min(Math.max(node.y, node.h / 2 + 16), containerH - node.h / 2 - 16),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) {
|
||||||
|
const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
|
||||||
|
const { w, h, accent } = node;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
initial={{ opacity: 0, scale: 0.5, rotate: rotation * 2 }}
|
className="group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_40px_rgba(0,0,0,0.5)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
|
||||||
animate={{ opacity: 1, scale: 1, rotate: rotation }}
|
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2 }}
|
||||||
|
initial={{ opacity: 0, scale: 0.75 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
x: [0, node.driftX, 0],
|
||||||
|
y: [0, node.driftY, 0],
|
||||||
|
rotate: [node.rotateSeed, node.rotateSeed + 1.4, node.rotateSeed],
|
||||||
|
}}
|
||||||
transition={{
|
transition={{
|
||||||
delay: index * 0.025,
|
opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
|
||||||
type: "spring",
|
scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
|
||||||
stiffness: 200,
|
x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 },
|
||||||
damping: 18,
|
y: { duration: node.driftDuration + 1.6, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 51) * 3 },
|
||||||
}}
|
rotate: { duration: node.driftDuration + 0.9, repeat: Infinity, ease: "easeInOut" },
|
||||||
whileHover={{
|
|
||||||
scale: 1.12,
|
|
||||||
rotate: 0,
|
|
||||||
transition: { type: "spring", stiffness: 400, damping: 22 },
|
|
||||||
}}
|
|
||||||
whileTap={{ scale: 0.92 }}
|
|
||||||
onClick={() => onSearch(entry.representative_image_id)}
|
|
||||||
title={`${entry.count} similar ${entry.count === 1 ? "photo" : "photos"}`}
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
margin: `${mt}px ${mr}px ${mb}px ${ml}px`,
|
|
||||||
borderRadius: 12,
|
|
||||||
border: "2px solid rgba(255,255,255,0.08)",
|
|
||||||
background: "rgba(255,255,255,0.04)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
flexShrink: 0,
|
|
||||||
boxShadow: "none",
|
|
||||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
const el = e.currentTarget;
|
|
||||||
el.style.borderColor = glow;
|
|
||||||
el.style.boxShadow = `0 0 16px ${glow}, 0 0 32px ${glow.replace("0.5", "0.25")}`;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
const el = e.currentTarget;
|
|
||||||
el.style.borderColor = "rgba(255,255,255,0.08)";
|
|
||||||
el.style.boxShadow = "none";
|
|
||||||
}}
|
}}
|
||||||
|
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }}
|
||||||
|
onClick={() => onOpen(node.entry.image_ids)}
|
||||||
|
title={`Open cluster — ${node.entry.count.toLocaleString()} images`}
|
||||||
>
|
>
|
||||||
{src ? (
|
{src ? (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "cover",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Fallback placeholder when no thumbnail exists yet
|
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
background: "rgba(255,255,255,0.06)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
|
||||||
{/* Count badge — bottom-right corner */}
|
{/* Accent glow on hover */}
|
||||||
<div
|
<div
|
||||||
style={{
|
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||||
position: "absolute",
|
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
|
||||||
bottom: 5,
|
/>
|
||||||
right: 5,
|
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||||
background: "rgba(0,0,0,0.6)",
|
<div className="mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
|
||||||
color: "rgba(255,255,255,0.85)",
|
<div className="flex items-end justify-between gap-2">
|
||||||
fontSize: 10,
|
<div>
|
||||||
fontWeight: 600,
|
<p className="text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
|
||||||
lineHeight: 1,
|
<p className="text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
|
||||||
padding: "3px 6px",
|
</div>
|
||||||
borderRadius: 6,
|
<span
|
||||||
backdropFilter: "blur(4px)",
|
className="rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||||
pointerEvents: "none",
|
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
|
||||||
}}
|
>
|
||||||
>
|
Open
|
||||||
{entry.count}
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Actual tag cloud — word size driven by log-scaled frequency
|
||||||
|
function TagWord({
|
||||||
|
entry,
|
||||||
|
index,
|
||||||
|
logMin,
|
||||||
|
logRange,
|
||||||
|
onSearch,
|
||||||
|
}: {
|
||||||
|
entry: ExploreTagEntry;
|
||||||
|
index: number;
|
||||||
|
logMin: number;
|
||||||
|
logRange: number;
|
||||||
|
onSearch: (tag: string) => void;
|
||||||
|
}) {
|
||||||
|
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
|
||||||
|
const fontSize = 11 + ratio * 28; // 11px – 39px
|
||||||
|
const accent = ACCENTS[index % ACCENTS.length];
|
||||||
|
const tilt = (seeded(index + 5) - 0.5) * 7;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, scale: 0.6 }}
|
||||||
|
animate={{ opacity: 0.4 + ratio * 0.6, scale: 1 }}
|
||||||
|
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
|
||||||
|
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
|
||||||
|
className="group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
|
||||||
|
style={{ fontSize, rotate: tilt }}
|
||||||
|
onClick={() => onSearch(entry.tag)}
|
||||||
|
title={`${entry.tag} — ${entry.count.toLocaleString()} images`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-medium leading-none"
|
||||||
|
style={{ color: ratio > 0.55 ? accent : "rgba(255,255,255,0.82)" }}
|
||||||
|
>
|
||||||
|
{entry.tag}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
style={{ backgroundColor: `${accent}22`, color: accent }}
|
||||||
|
>
|
||||||
|
{entry.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate component so its useLayoutEffect fires when the canvas is actually
|
||||||
|
// mounted — not at TagCloud mount time when the container may still be hidden
|
||||||
|
// behind a loading state.
|
||||||
|
function ClusterCloud({
|
||||||
|
entries,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
entries: TagCloudEntry[];
|
||||||
|
onOpen: (imageIds: number[]) => void;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
setCanvasSize({ w: r.width, h: r.height });
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nodes = useMemo(
|
||||||
|
() => buildCloud(entries, canvasSize.w, canvasSize.h),
|
||||||
|
[entries, canvasSize.w, canvasSize.h],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<CloudCard key={`${node.entry.representative_image_id}:${node.index}`} node={node} onOpen={onOpen} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TagCloud() {
|
export function TagCloud() {
|
||||||
|
const exploreMode = useGalleryStore((state) => state.exploreMode);
|
||||||
|
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
|
||||||
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
|
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
|
||||||
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
|
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
|
||||||
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
|
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
|
||||||
const searchByTag = useGalleryStore((state) => state.searchByTag);
|
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
|
||||||
|
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
|
||||||
|
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
|
||||||
|
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
|
||||||
|
const searchForTag = useGalleryStore((state) => state.searchForTag);
|
||||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTagCloud();
|
if (exploreMode === "visual") void loadTagCloud();
|
||||||
}, [selectedFolderId]);
|
else void loadExploreTags();
|
||||||
|
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]);
|
||||||
|
|
||||||
const maxCount =
|
const { logMin, logRange } = useMemo(() => {
|
||||||
tagCloudEntries.length > 0
|
if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 };
|
||||||
? Math.max(...tagCloudEntries.map((e) => e.count))
|
const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1)));
|
||||||
: 1;
|
const lo = Math.min(...logs);
|
||||||
|
const hi = Math.max(...logs);
|
||||||
|
return { logMin: lo, logRange: hi - lo || 1 };
|
||||||
|
}, [exploreTagEntries]);
|
||||||
|
|
||||||
|
const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading;
|
||||||
|
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
|
||||||
|
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center min-h-0 overflow-y-auto">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
|
||||||
className="text-center pt-14 pb-8 shrink-0"
|
<div className="flex items-center justify-between gap-4">
|
||||||
initial={{ opacity: 0, y: -10 }}
|
<div className="min-w-0">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<h2 className="text-[15px] font-semibold text-white">Explore</h2>
|
||||||
transition={{ duration: 0.35 }}
|
<p className="mt-0.5 truncate text-[11px] text-white/30">
|
||||||
>
|
{loading
|
||||||
<h2 className="text-[22px] font-semibold text-white/70 tracking-tight mb-2">
|
? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
|
||||||
Explore your library
|
: hasEntries
|
||||||
</h2>
|
? exploreMode === "visual"
|
||||||
<p className="text-[13px] text-white/25">
|
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
|
||||||
Visual clusters from your photos — sized by how many match
|
: `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
|
||||||
</p>
|
: exploreMode === "visual"
|
||||||
</motion.div>
|
? "No clusters — images need embeddings first"
|
||||||
|
: "No tags — run the AI tagger or add tags manually"}
|
||||||
{/* Loading */}
|
</p>
|
||||||
{tagCloudLoading && (
|
</div>
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
<div className="flex shrink-0 rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
|
||||||
<motion.svg
|
<button
|
||||||
className="w-8 h-8 text-white/20"
|
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||||
viewBox="0 0 24 24"
|
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
fill="none"
|
}`}
|
||||||
animate={{ rotate: 360 }}
|
onClick={() => setExploreMode("visual")}
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
>
|
||||||
>
|
Clusters
|
||||||
<circle
|
</button>
|
||||||
cx="12"
|
<button
|
||||||
cy="12"
|
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||||
r="10"
|
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
stroke="currentColor"
|
}`}
|
||||||
strokeWidth="2"
|
onClick={() => setExploreMode("tags")}
|
||||||
strokeOpacity="0.2"
|
>
|
||||||
/>
|
Tag Cloud
|
||||||
<path
|
</button>
|
||||||
d="M4 12a8 8 0 018-8"
|
</div>
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</motion.svg>
|
|
||||||
<p className="text-[12px] text-white/20">Clustering your library…</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Empty state */}
|
{loading ? (
|
||||||
{!tagCloudLoading && tagCloudEntries.length === 0 && (
|
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<Spinner />
|
||||||
<p className="text-[13px] text-white/25 text-center max-w-xs leading-relaxed">
|
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
|
||||||
No embeddings yet. Add a folder and wait for the embedding worker to
|
</div>
|
||||||
finish, then come back here.
|
) : !hasEntries ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center px-8">
|
||||||
|
<p className="max-w-xs text-center text-sm leading-relaxed text-white/25">
|
||||||
|
{exploreMode === "visual"
|
||||||
|
? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar."
|
||||||
|
: "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : exploreMode === "visual" ? (
|
||||||
|
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
|
||||||
{/* Cluster grid */}
|
) : (
|
||||||
{!tagCloudLoading && tagCloudEntries.length > 0 && (
|
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */
|
||||||
<div className="flex flex-wrap justify-center px-12 pb-16 max-w-5xl w-full">
|
<div className="overflow-y-auto px-8 py-8">
|
||||||
{tagCloudEntries.map((entry, index) => (
|
<div className="flex flex-wrap items-center justify-center gap-x-0.5 gap-y-1 leading-none">
|
||||||
<TagButton
|
{exploreTagEntries.map((entry, index) => (
|
||||||
key={entry.representative_image_id}
|
<TagWord
|
||||||
entry={entry}
|
key={entry.tag}
|
||||||
index={index}
|
entry={entry}
|
||||||
maxCount={maxCount}
|
index={index}
|
||||||
onSearch={searchByTag}
|
logMin={logMin}
|
||||||
/>
|
logRange={logRange}
|
||||||
))}
|
onSearch={searchForTag}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!tagCloudLoading && tagCloudEntries.length > 0 && (
|
|
||||||
<p className="shrink-0 pb-8 text-[11px] text-white/12 text-center">
|
|
||||||
Grouped by visual similarity · CLIP ViT-B/32
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { useGalleryStore } from "../store";
|
||||||
|
|
||||||
// SVG icons for window controls
|
// SVG icons for window controls
|
||||||
function MinimizeIcon() {
|
function MinimizeIcon() {
|
||||||
@@ -37,6 +38,7 @@ function CloseIcon() {
|
|||||||
|
|
||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
const [isMaximized, setIsMaximized] = useState(false);
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
|
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,6 +87,17 @@ export function TitleBar() {
|
|||||||
className="flex items-stretch h-full"
|
className="flex items-stretch h-full"
|
||||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
title="Settings"
|
||||||
|
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.607 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Minimize */}
|
{/* Minimize */}
|
||||||
<button
|
<button
|
||||||
onClick={handleMinimize}
|
onClick={handleMinimize}
|
||||||
|
|||||||
+213
-75
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
||||||
|
|
||||||
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||||
{ value: "date_desc", label: "Newest first" },
|
{ value: "date_desc", label: "Newest first" },
|
||||||
{ value: "date_asc", label: "Oldest first" },
|
{ value: "date_asc", label: "Oldest first" },
|
||||||
{ value: "name_asc", label: "Name A–Z" },
|
{ value: "name_asc", label: "Name A–Z" },
|
||||||
{ value: "name_desc", label: "Name Z–A" },
|
{ value: "name_desc", label: "Name Z–A" },
|
||||||
|
{ value: "rating_desc", label: "Highest rated" },
|
||||||
|
{ value: "rating_asc", label: "Lowest rated" },
|
||||||
{ value: "size_desc", label: "Largest first" },
|
{ value: "size_desc", label: "Largest first" },
|
||||||
{ value: "size_asc", label: "Smallest first" },
|
{ value: "size_asc", label: "Smallest first" },
|
||||||
];
|
];
|
||||||
@@ -116,12 +119,27 @@ function FilterPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commandPrefix(command: SearchCommand | null): string | null {
|
||||||
|
switch (command) {
|
||||||
|
case "semantic":
|
||||||
|
return "/s";
|
||||||
|
case "tag":
|
||||||
|
return "/t";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeSearchValue(command: SearchCommand | null, query: string): string {
|
||||||
|
const prefix = commandPrefix(command);
|
||||||
|
if (!prefix) return query;
|
||||||
|
return query.length > 0 ? `${prefix} ${query}` : prefix;
|
||||||
|
}
|
||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
const search = useGalleryStore((state) => state.search);
|
const search = useGalleryStore((state) => state.search);
|
||||||
const setSearch = useGalleryStore((state) => state.setSearch);
|
const setSearch = useGalleryStore((state) => state.setSearch);
|
||||||
const clearSearch = useGalleryStore((state) => state.clearSearch);
|
const clearSearch = useGalleryStore((state) => state.clearSearch);
|
||||||
const searchMode = useGalleryStore((state) => state.searchMode);
|
|
||||||
const setSearchMode = useGalleryStore((state) => state.setSearchMode);
|
|
||||||
const sort = useGalleryStore((state) => state.sort);
|
const sort = useGalleryStore((state) => state.sort);
|
||||||
const setSort = useGalleryStore((state) => state.setSort);
|
const setSort = useGalleryStore((state) => state.setSort);
|
||||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||||
@@ -133,20 +151,26 @@ export function Toolbar() {
|
|||||||
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
|
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
|
||||||
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
|
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
|
||||||
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
|
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
|
||||||
|
const minimumRating = useGalleryStore((state) => state.minimumRating);
|
||||||
|
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
|
||||||
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
|
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
|
||||||
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
|
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
|
||||||
|
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||||
|
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
|
||||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||||
|
|
||||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
||||||
|
|
||||||
const [searchValue, setSearchValue] = useState(search);
|
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState(search);
|
||||||
|
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
||||||
|
const [tagSuggestions, setTagSuggestions] = useState<ExploreTagEntry[]>([]);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
// Tracks whether the user has typed in the search box at least once.
|
const searchShellRef = useRef<HTMLDivElement>(null);
|
||||||
// Prevents the debounce effect from dispatching setSearch on initial mount
|
|
||||||
// when searchValue === search (which would wipe a loadSimilarImages result).
|
|
||||||
const userHasTyped = useRef(false);
|
const userHasTyped = useRef(false);
|
||||||
|
|
||||||
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
||||||
@@ -154,11 +178,8 @@ export function Toolbar() {
|
|||||||
const tileSize = tileSizeForZoom(zoomPreset);
|
const tileSize = tileSizeForZoom(zoomPreset);
|
||||||
const sortOptions = getSortOptions(mediaFilter);
|
const sortOptions = getSortOptions(mediaFilter);
|
||||||
const hasActiveSearch = search.trim().length > 0;
|
const hasActiveSearch = search.trim().length > 0;
|
||||||
|
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
|
||||||
const searchModes: { value: SearchMode; label: string }[] = [
|
const isSimilarResults = collectionTitle === "Similar Images";
|
||||||
{ value: "filename", label: "Filename" },
|
|
||||||
{ value: "semantic", label: "Semantic" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// If current sort is video-only but we switched away from video filter, reset to date_desc
|
// If current sort is video-only but we switched away from video filter, reset to date_desc
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,35 +191,62 @@ export function Toolbar() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userHasTyped.current) return;
|
if (!userHasTyped.current) return;
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200);
|
debounceRef.current = setTimeout(() => { setSearch(composeSearchValue(searchCommand, searchQuery)); }, 200);
|
||||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
}, [searchValue, setSearch]);
|
}, [searchCommand, searchQuery, setSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchValue(search);
|
const parsed = parseSearchValue(search);
|
||||||
|
setSearchCommand(parsed.prefix && parsed.mode !== "filename" ? parsed.mode : null);
|
||||||
|
setSearchQuery(parsed.prefix ? parsed.query : search);
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
|
// Fetch tag suggestions when in tag mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchCommand !== "tag") {
|
||||||
|
setTagSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current);
|
||||||
|
suggestDebounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
|
||||||
|
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
|
||||||
|
});
|
||||||
|
setTagSuggestions(results);
|
||||||
|
} catch {
|
||||||
|
setTagSuggestions([]);
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
return () => { if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); };
|
||||||
|
}, [searchCommand, searchQuery, selectedFolderId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const isModeToggle = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "s";
|
|
||||||
if (isModeToggle) {
|
|
||||||
event.preventDefault();
|
|
||||||
setSearchMode(searchMode === "semantic" ? "filename" : "semantic");
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeElement = document.activeElement;
|
const activeElement = document.activeElement;
|
||||||
const searchFocused = activeElement === searchInputRef.current;
|
const searchFocused = activeElement === searchInputRef.current;
|
||||||
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
|
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSearchValue("");
|
setSearchCommand(null);
|
||||||
|
setSearchQuery("");
|
||||||
clearSearch();
|
clearSearch();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [clearSearch, hasActiveSearch, searchMode, setSearchMode]);
|
}, [clearSearch, hasActiveSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (event: PointerEvent) => {
|
||||||
|
if (searchShellRef.current?.contains(event.target as Node)) return;
|
||||||
|
setSearchPanelOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", close);
|
||||||
|
return () => window.removeEventListener("pointerdown", close);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
|
||||||
|
const showCommandHints = !searchCommand && searchPanelOpen;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
||||||
@@ -212,9 +260,9 @@ export function Toolbar() {
|
|||||||
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
||||||
: totalImages.toLocaleString()}
|
: totalImages.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
{(hasActiveSearch || searchMode === "semantic") && (
|
{hasActiveSearch && (
|
||||||
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0">
|
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0">
|
||||||
{searchMode === "semantic" ? "Semantic Search" : "Filename Search"}
|
{searchModeLabel(parsedSearch.mode)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,55 +270,140 @@ export function Toolbar() {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
|
<div ref={searchShellRef} className="relative">
|
||||||
<div className="flex items-center pl-2 pr-1 gap-1 border-r border-white/8">
|
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
|
||||||
{searchModes.map((mode) => (
|
<div className="relative">
|
||||||
<button
|
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
|
||||||
key={mode.value}
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
searchMode === mode.value
|
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z" />
|
||||||
? "bg-white/10 text-white"
|
</svg>
|
||||||
: "text-gray-500 hover:text-gray-200"
|
<input
|
||||||
}`}
|
ref={searchInputRef}
|
||||||
title={mode.value === "semantic" ? "Toggle with Ctrl/Cmd+Shift+S" : "Toggle with Ctrl/Cmd+Shift+S"}
|
type="text"
|
||||||
onClick={() => setSearchMode(mode.value)}
|
value={searchQuery}
|
||||||
>
|
onChange={(event) => {
|
||||||
{mode.label}
|
userHasTyped.current = true;
|
||||||
</button>
|
const nextValue = event.target.value;
|
||||||
))}
|
if (!searchCommand) {
|
||||||
</div>
|
const parsed = parseSearchValue(nextValue);
|
||||||
<div className="relative">
|
if (parsed.prefix) {
|
||||||
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
|
setSearchCommand(parsed.mode === "filename" ? null : parsed.mode);
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
setSearchQuery(parsed.query);
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
return;
|
||||||
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z" />
|
}
|
||||||
</svg>
|
}
|
||||||
<input
|
setSearchQuery(nextValue);
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
value={searchValue}
|
|
||||||
onChange={(event) => {
|
|
||||||
userHasTyped.current = true;
|
|
||||||
setSearchValue(event.target.value);
|
|
||||||
}}
|
|
||||||
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."}
|
|
||||||
className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors"
|
|
||||||
/>
|
|
||||||
{searchValue.trim().length > 0 && (
|
|
||||||
<button
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
|
|
||||||
title="Clear search"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchValue("");
|
|
||||||
clearSearch();
|
|
||||||
}}
|
}}
|
||||||
>
|
onKeyDown={(event) => {
|
||||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) {
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
event.preventDefault();
|
||||||
</svg>
|
setSearchCommand(null);
|
||||||
</button>
|
}
|
||||||
)}
|
}}
|
||||||
|
onFocus={() => setSearchPanelOpen(true)}
|
||||||
|
placeholder="Search files, or use /s /t"
|
||||||
|
className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`}
|
||||||
|
/>
|
||||||
|
{searchCommand !== null ? (
|
||||||
|
<div className="absolute left-8 top-1/2 -translate-y-1/2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||||
|
onClick={() => { setSearchCommand(null); setTagSuggestions([]); }}
|
||||||
|
title="Remove search command"
|
||||||
|
>
|
||||||
|
{commandPrefix(searchCommand)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{searchQuery.trim().length > 0 || searchCommand !== null ? (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
|
||||||
|
title="Clear search"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchCommand(null);
|
||||||
|
setSearchQuery("");
|
||||||
|
setTagSuggestions([]);
|
||||||
|
clearSearch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tag autocomplete suggestions */}
|
||||||
|
{showTagSuggestions && tagSuggestions.length > 0 ? (
|
||||||
|
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||||
|
{tagSuggestions.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.tag}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// mousedown fires before input blur, so we prevent losing focus
|
||||||
|
e.preventDefault();
|
||||||
|
userHasTyped.current = true;
|
||||||
|
setSearchQuery(entry.tag);
|
||||||
|
setSearch(`/t ${entry.tag}`);
|
||||||
|
setSearchPanelOpen(false);
|
||||||
|
searchInputRef.current?.blur();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-white/88">{entry.tag}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Tag mode with no suggestions yet — show a brief hint */}
|
||||||
|
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? (
|
||||||
|
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||||
|
<p className="text-xs text-white/25">No matching tags</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Semantic mode hint */}
|
||||||
|
{searchCommand === "semantic" && searchPanelOpen ? (
|
||||||
|
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||||
|
<p className="text-xs text-white/40">Search by meaning and visual concepts</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Command hints — only shown when no command is active */}
|
||||||
|
{showCommandHints ? (
|
||||||
|
<div className="absolute left-0 top-full z-30 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search AI and user tags" },
|
||||||
|
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" },
|
||||||
|
] as const
|
||||||
|
).map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.prefix}
|
||||||
|
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
userHasTyped.current = true;
|
||||||
|
setSearchCommand(option.command);
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="rounded border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-gray-400">
|
||||||
|
{option.prefix}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-200">{option.label}</p>
|
||||||
|
<p className="text-xs text-gray-500">{option.description}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort */}
|
{/* Sort */}
|
||||||
@@ -302,10 +435,14 @@ export function Toolbar() {
|
|||||||
|
|
||||||
{/* Filter row */}
|
{/* Filter row */}
|
||||||
<div className="flex items-center gap-1 px-4 pb-1.5">
|
<div className="flex items-center gap-1 px-4 pb-1.5">
|
||||||
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} />
|
||||||
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||||
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||||
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
|
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
|
||||||
|
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} />
|
||||||
|
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} />
|
||||||
|
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
|
||||||
|
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
|
||||||
{hasAnyFailedEmbeddings ? (
|
{hasAnyFailedEmbeddings ? (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
label="Failed Embeddings"
|
label="Failed Embeddings"
|
||||||
@@ -314,6 +451,7 @@ export function Toolbar() {
|
|||||||
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
|
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
isPermissionGranted,
|
||||||
|
requestPermission,
|
||||||
|
sendNotification,
|
||||||
|
} from "@tauri-apps/plugin-notification";
|
||||||
|
|
||||||
|
let permissionPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
export function initializeNotifications(): Promise<boolean> {
|
||||||
|
permissionPromise ??= (async () => {
|
||||||
|
try {
|
||||||
|
if (await isPermissionGranted()) return true;
|
||||||
|
return (await requestPermission()) === "granted";
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Windows notifications are unavailable:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return permissionPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyTaskComplete(title: string, body: string): Promise<void> {
|
||||||
|
if (!(await initializeNotifications())) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendNotification({ title, body });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not send task completion notification:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1185
-40
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user