Compare commits

...

14 Commits

Author SHA1 Message Date
LyAhn 23e9850c7a Merge feat/tagging-ux: tagging model choice, related tags, tag manager, and UX polish
github/actions/ci GitHub Actions CI finished: failure
- JoyTag added as a second selectable tagger alongside WD; model switches on demand, tags attributed per-model

- Related tags atlas in Explore: clicking a tag shows co-occurring tags with animated connection lines and image counts

- Persist worker pauses across restarts via a Settings toggle

- Tag manager gains live filter, sort (most-used / least-used / A–Z / Z–A), and virtualisation for large libraries

- Tooltip portal with anchorToCursor mode for precise hover positioning

- Noisy AI tags (e.g. generic background descriptors) filtered at store time; existing tags cleaned on migration

- Fix: tag cloud hover glow and atlas gradient now adapt to Subtle Light theme

- Fix: AI Workspace 'Selected Folders' scope no longer pre-selects the first folder

- Fix: changelog version lookup strips build suffixes so What's New works in UI Lab
2026-06-29 18:36:01 +01:00
LyAhn c111032d99 fix(changelog): strip build suffixes before version lookup
UI-lab builds append a suffix to the version string (e.g.
"0.1.1-ui") which caused getChangelogForVersion to find no match and
fall back to the "not available in-app" message. A regex now strips any
hyphen-and-letter suffix before the lookup so the What's New modal
renders correctly in all build modes.
2026-06-29 18:06:45 +01:00
LyAhn c13f78c68b docs(changelog): add unreleased entries for feat/tagging-ux
Adds the user-facing changes introduced on this branch that were missing
from the [Unreleased] section:

Added:
- Related tags in Explore (tag atlas connection lines + co-occurrence counts)
- Persist worker pauses across restarts (Settings toggle)

Changed:
- Tag manager search/sort/virtualisation

Fixed:
- Noisy AI tags filtered automatically (removal list applied at store time)
- Explore Tag Cloud hover glow and atlas gradient in Subtle Light theme
- AI Workspace "Selected Folders" scope no longer pre-selects a folder
2026-06-29 17:23:20 +01:00
LyAhn f2939d70ab fix: atlas glow and AI Workspace scope pre-selection in light theme
Two targeted fixes:

- Tag Cloud atlas SVG radial gradient now switches its inner stop from white
  to a warm dark tone (rgba 60 50 30) when the Subtle Light theme is active,
  matching the explore-tag hover glow style used elsewhere in light mode.
- Switching the AI Workspace tagging queue scope to "Selected Folders" no
  longer auto-selects the first folder in the list; the selection now starts
  empty so the user can choose exactly which folders to target.
2026-06-29 17:23:03 +01:00
LyAhn 8dbabc2d9e fix: match tag cloud hover glow to dark mode in light theme
Replace the flat background hover override with a dark warm radial
gradient on the ::before pseudo-element, mirroring the same elliptical
glow effect used in dark mode.
2026-06-29 17:06:50 +01:00
LyAhn 5bc397af01 chore: add Playwright test scaffolding
Adds @playwright/test and @types/node as dev dependencies, playwright.config.ts
with a localhost:1420 base URL targeting the Vite dev server, an example
spec, and the standard Playwright output directories to .gitignore.
2026-06-29 16:30:05 +01:00
LyAhn af3c8418ee feat: virtualized tag manager with filter, sort, and light-theme support
Replaces the flat TagManageRow list with a virtualized grid of
TagManageTile cards using @tanstack/react-virtual and dynamic measured
heights (46px idle, 82px when editing/confirming). Adds a filter input
and a sort dropdown (most-used / least-used / A-Z / Z-A). renameTag and
deleteTag no longer clear exploreTagEntries on invalidation so the
manager keeps its filter/sort state during the background refresh.
Light-theme overrides cover all new tag-manager class names.
2026-06-29 16:29:12 +01:00
LyAhn 9144be2518 feat: Tooltip portal with anchorToCursor mode
Tooltip now portals cursor-anchored variants into document.body via a
`mounted` guard, preventing transformed parents and scroll-container
overflow from distorting coordinates. New `anchorToCursor` prop locks
position at hover entry without tracking; `followCursor` retains the
spring-animated tracking behaviour. Color swatches (ColorFilter),
timeline scrubber dots/labels (Timeline), and toolbar dropdowns (Toolbar)
are updated to use the appropriate cursor mode. Toolbar z-index bumped
z-20→z-40 (dropdowns z-30→z-50) to layer above portaled content; tag
autocomplete result guard added (Array.isArray).
2026-06-29 16:28:35 +01:00
LyAhn d81624573d feat: persist worker-pause state across restarts
Worker pause states can optionally survive app restarts. A new toggle in
Settings saves the current pause map to settings/worker_pauses.json; on
startup lib.rs restores it before workers are spawned. Backend: new
snapshot/replace helpers in indexer.rs, persist functions in commands.rs
(get/set_worker_pauses_persist). Frontend: workerPausesPersist store
field, load/setWorkerPausesPersist actions, toggle in SettingsModal.
2026-06-29 13:21:53 +01:00
LyAhn 949382f28c feat: related-tags atlas in Explore view
Hovering a tag in Explore now loads and displays co-occurring tags as a
weighted cloud. New `get_related_tags` SQL self-join (db.rs/commands.rs),
`loadRelatedTags` store action with per-folder keyed cache, and TagCloud
atlas UI with ResizeObserver-driven layout and RAF animation. Explore tag
limit raised to 180; tag cloud auto-refreshes 700ms after new AI tagging
completes.
2026-06-29 13:21:43 +01:00
LyAhn e4a63c8bb0 fix: filter noisy AI tags
Add an editable AI tag removal list and apply it to WD/JoyTag output before tags are stored. Clean existing generated AI tags during database migration while leaving manual user tags untouched.
2026-06-29 11:24:21 +01:00
LyAhn 1685134116 fix(tagger): model-neutral session log; changelog for JoyTag
create_tagger_session is shared by both models but logged "WD tagger: using
CPU execution provider" even when loading JoyTag. Make the CPU/DirectML
session logs model-neutral. Add the JoyTag tagging-model picker to the
changelog now that it's runtime-verified.
2026-06-29 10:26:51 +01:00
LyAhn 705f8c2e56 feat(tagger): Settings picker to switch tagging model (WD / JoyTag)
Adds a "Tagging model" control to Settings → AI Workspace that calls
get/set_tagger_model, alongside the existing acceleration/threshold/batch
controls. Switching refreshes the model status so the download/ready row and
the model name/description reflect the selected model, and the threshold hint
shows the model-appropriate default (0.4 for JoyTag, 0.35 for WD).

Frontend only; mirrors the existing acceleration toggle pattern. End-to-end
JoyTag tagging still needs the model files on disk to verify.
2026-06-29 09:51:30 +01:00
LyAhn 52d54d2404 feat(tagger): add JoyTag provider behind a tagger-model abstraction
Introduces a selectable tagging model so Phokus isn't locked to the
anime-leaning WD tagger. JoyTag uses the Danbooru schema but generalizes to
photographic content and is strong on NSFW concepts — a better fit for a photo
manager while keeping the explicitness range.

- `TaggerModel` enum (wd | joytag) persisted as a `tagger_model` setting;
  `model_dir`, status, and download now follow the active model (per-model
  repo/dir/file list). WD stays the default.
- `Tagger` trait implemented by `WdTagger` and the new `JoyTagger`;
  `create_active_tagger` builds the selected one. The worker holds
  `Box<dyn Tagger>` and rebuilds on model change (TAGGER_SESSION_DIRTY).
- Shared `assemble_batch` skeleton (pack -> one forward pass -> per-image
  fallback, results in input order); both providers and the shared
  decode/pad/resize are de-duped onto common helpers.
- JoyTag specifics: NCHW + RGB + CLIP-normalized input (vs WD's NHWC/BGR/raw),
  flat top_tags.txt labels, logits -> sigmoid -> threshold (default 0.4). It has
  no native rating, so the explicitness rating is derived from its NSFW tags.
- Tags are attributed to the model that produced them (ai_tagger_model),
  via Tagger::model_name, instead of a hardcoded WD constant.
- New get/set_tagger_model commands.

Backend only; the Settings model picker and end-to-end testing against the
JoyTag model files come next.
2026-06-29 09:40:12 +01:00
25 changed files with 2129 additions and 388 deletions
+8 -1
View File
@@ -35,5 +35,12 @@ dist-ssr
*.pyc *.pyc
# Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit # Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit
# locally; ~600 MB, never committed). See RELEASE_PLAN.md "CUDA release variant". # locally; ~600 MB, never committed).
src-tauri/cuda-redist/ src-tauri/cuda-redist/
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+33
View File
@@ -42,9 +42,29 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
changes grouped into collapsible Added / Changed / Fixed sections. changes grouped into collapsible Added / Changed / Fixed sections.
- **Build badge in Settings** — the version line in Settings → Updates now shows - **Build badge in Settings** — the version line in Settings → Updates now shows
whether the running build is the CPU or CUDA (GPU-accelerated) variant. whether the running build is the CPU or CUDA (GPU-accelerated) variant.
- **Choose your tagging model** — Settings → AI Workspace now lets you switch the
AI tagger between the WD tagger (anime-focused) and **JoyTag**, which also
handles photographic content and is stronger on NSFW concepts — a better fit
for real-photo libraries. Each model downloads on demand, and tags are
attributed to the model that produced them. JoyTag has no built-in rating, so
its explicitness rating is derived from its tags.
- **Related tags in Explore** — clicking a tag in the Tag Cloud atlas now reveals
its most co-occurring tags with animated connection lines and per-tag image
counts, so you can quickly see how tags cluster together and jump to a refined
search.
- **Persist worker pauses across restarts** — a new toggle in Settings → General
lets you save per-folder worker pause states so they survive an app restart;
useful when you want a folder permanently excluded from background processing
without having to re-pause it every launch.
### Changed ### Changed
- **Tag manager search and sort** — the Manage mode tag list now has a live
filter input and a sort dropdown (most-used / least-used / AZ / ZA). The
list is virtualised so libraries with thousands of tags scroll without lag,
and renaming or deleting a tag no longer clears the current filter/sort state
mid-edit.
- **Safer deletion** — deleting media now asks for confirmation and spells out - **Safer deletion** — deleting media now asks for confirmation and spells out
that it permanently removes the file(s) from disk. This covers the new gallery that it permanently removes the file(s) from disk. This covers the new gallery
bulk delete and the Duplicate Finder, which previously deleted on a single bulk delete and the Duplicate Finder, which previously deleted on a single
@@ -103,6 +123,19 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
app stays responsive while tagging runs, throughput is steadier (the old wide app stays responsive while tagging runs, throughput is steadier (the old wide
batches caused periodic slowdowns), and the first batch after launch starts batches caused periodic slowdowns), and the first batch after launch starts
faster. faster.
- **Noisy AI tags filtered automatically** — a built-in removal list strips
generic or low-signal tags (e.g. "simple background") from WD and JoyTag
output before they are stored. Previously-generated tags matching the list are
cleaned up at startup. Manual user-added tags are never touched.
- **Explore Tag Cloud hover glow in Subtle Light** — the radial glow that
appears under a tag word on hover was invisible on the light background
(white on cream). It now uses a warm dark tone matching the rest of the
light-theme palette, and the atlas connection-line gradient adapts to the
theme as well.
- **AI Workspace "Selected Folders" no longer pre-selects a folder** — switching
the tagging queue scope to "Selected Folders" previously auto-selected the
first folder in the list. It now starts with nothing selected so you can
choose exactly which folders to target.
## [0.1.1] — 2026-06-23 ## [0.1.1] — 2026-06-23
+2
View File
@@ -37,9 +37,11 @@
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@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/d3-force": "^3.0.10",
"@types/node": "^26.0.1",
"@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",
+79
View File
@@ -0,0 +1,79 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
+68 -14
View File
@@ -48,15 +48,21 @@ importers:
specifier: ^5.0.12 specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.4) version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
devDependencies: devDependencies:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^2 specifier: ^2
version: 2.10.1 version: 2.10.1
'@types/d3-force': '@types/d3-force':
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10 version: 3.0.10
'@types/node':
specifier: ^26.0.1
version: 26.0.1
'@types/react': '@types/react':
specifier: ^19.1.8 specifier: ^19.1.8
version: 19.2.14 version: 19.2.14
@@ -65,7 +71,7 @@ importers:
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
tailwindcss: tailwindcss:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
@@ -74,7 +80,7 @@ importers:
version: 5.8.3 version: 5.8.3
vite: vite:
specifier: ^7.0.4 specifier: ^7.0.4
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
website: website:
dependencies: dependencies:
@@ -96,7 +102,7 @@ importers:
devDependencies: devDependencies:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
'@types/react': '@types/react':
specifier: ^19.1.8 specifier: ^19.1.8
version: 19.2.14 version: 19.2.14
@@ -105,7 +111,7 @@ importers:
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
tailwindcss: tailwindcss:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
@@ -114,10 +120,10 @@ importers:
version: 5.8.3 version: 5.8.3
vite: vite:
specifier: ^7.0.4 specifier: ^7.0.4
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
vite-imagetools: vite-imagetools:
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0(rollup@4.60.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 10.0.0(rollup@4.60.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
packages: packages:
@@ -538,6 +544,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/pluginutils@1.0.0-beta.27': '@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -906,6 +917,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
'@types/react-dom@19.2.3': '@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
@@ -1010,6 +1024,11 @@ packages:
react-dom: react-dom:
optional: true optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1147,6 +1166,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'} engines: {node: '>=12'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.8: postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -1208,6 +1237,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
update-browserslist-db@1.2.3: update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true hasBin: true
@@ -1597,6 +1629,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/pluginutils@5.4.0(rollup@4.60.1)': '@rollup/pluginutils@5.4.0(rollup@4.60.1)':
@@ -1743,12 +1779,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2
'@tailwindcss/vite@4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies: dependencies:
'@tailwindcss/node': 4.2.2 '@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2 '@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2 tailwindcss: 4.2.2
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
@@ -1856,6 +1892,10 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/node@26.0.1':
dependencies:
undici-types: 8.3.0
'@types/react-dom@19.2.3(@types/react@19.2.14)': '@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies: dependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@@ -1864,7 +1904,7 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -1872,7 +1912,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27 '@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.17.0 react-refresh: 0.17.0
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -1963,6 +2003,9 @@ snapshots:
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -2053,6 +2096,14 @@ snapshots:
picomatch@4.0.4: {} picomatch@4.0.4: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.8: postcss@8.5.8:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
@@ -2151,22 +2202,24 @@ snapshots:
typescript@5.8.3: {} typescript@5.8.3: {}
undici-types@8.3.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2): update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies: dependencies:
browserslist: 4.28.2 browserslist: 4.28.2
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
vite-imagetools@10.0.0(rollup@4.60.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)): vite-imagetools@10.0.0(rollup@4.60.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)):
dependencies: dependencies:
'@rollup/pluginutils': 5.4.0(rollup@4.60.1) '@rollup/pluginutils': 5.4.0(rollup@4.60.1)
imagetools-core: 9.1.0 imagetools-core: 9.1.0
sharp: 0.34.5 sharp: 0.34.5
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
transitivePeerDependencies: transitivePeerDependencies:
- rollup - rollup
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0):
dependencies: dependencies:
esbuild: 0.27.7 esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
@@ -2175,6 +2228,7 @@ snapshots:
rollup: 4.60.1 rollup: 4.60.1
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 26.0.1
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.32.0 lightningcss: 1.32.0
+45
View File
@@ -0,0 +1,45 @@
/// AI-generated tags that are too broad/noisy to be useful in this gallery.
/// Edit this list to change what the tagger removes. Manual user tags are not
/// affected.
pub const AI_TAG_REMOVAL_LIST: &[&str] = &["1girl", "1boy", "no humans", "2girls", "2boys"];
fn normalize_tag_for_removal(tag: &str) -> String {
tag.trim()
.chars()
.filter(|c| !matches!(c, ' ' | '_' | '-'))
.flat_map(char::to_lowercase)
.collect()
}
pub fn is_removed_ai_tag(tag: &str) -> bool {
let normalized = normalize_tag_for_removal(tag);
AI_TAG_REMOVAL_LIST
.iter()
.any(|blocked| normalize_tag_for_removal(blocked) == normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removed_ai_tags_match_common_spellings() {
for tag in [
"1girl",
"1 girl",
"1_girl",
"1-girl",
"NO_HUMANS",
"no humans",
] {
assert!(is_removed_ai_tag(tag), "{tag} should be removed");
}
}
#[test]
fn removed_ai_tags_do_not_match_unrelated_tags() {
for tag in ["girl", "boy", "humans", "solo", "landscape"] {
assert!(!is_removed_ai_tag(tag), "{tag} should be kept");
}
}
}
+123 -2
View File
@@ -8,7 +8,7 @@ use crate::db::{
use crate::embedder; use crate::embedder;
use crate::hnsw_index; use crate::hnsw_index;
use crate::indexer::{self, WatcherHandle}; use crate::indexer::{self, WatcherHandle};
use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; use crate::tagger::{self, TaggerAcceleration, TaggerModel, TaggerModelStatus, TaggerRuntimeProbe};
use crate::vector; use crate::vector;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
@@ -180,6 +180,19 @@ pub struct GetExploreTagsParams {
pub limit: Option<usize>, pub limit: Option<usize>,
} }
#[derive(Deserialize)]
pub struct GetRelatedTagsParams {
pub tag: String,
pub folder_id: Option<i64>,
pub limit: Option<usize>,
}
#[derive(Serialize)]
pub struct RelatedTagEntry {
pub tag: String,
pub shared_count: i64,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SearchTagsAutocompleteParams { pub struct SearchTagsAutocompleteParams {
pub query: String, pub query: String,
@@ -1277,7 +1290,28 @@ pub async fn get_explore_tags(
params: GetExploreTagsParams, params: GetExploreTagsParams,
) -> Result<Vec<ExploreTagEntry>, String> { ) -> Result<Vec<ExploreTagEntry>, String> {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)) db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(180))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_related_tags(
db: State<'_, DbState>,
params: GetRelatedTagsParams,
) -> Result<Vec<RelatedTagEntry>, String> {
let tag = params.tag.trim();
if tag.is_empty() {
return Ok(vec![]);
}
let conn = db.get().map_err(|e| e.to_string())?;
db::get_related_tags(&conn, tag, params.folder_id, params.limit.unwrap_or(16))
.map(|entries| {
entries
.into_iter()
.map(|(tag, shared_count)| RelatedTagEntry { tag, shared_count })
.collect()
})
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -1913,6 +1947,7 @@ pub struct FolderWorkerStates {
#[tauri::command] #[tauri::command]
pub async fn set_worker_paused( pub async fn set_worker_paused(
app: AppHandle,
db: State<'_, DbState>, db: State<'_, DbState>,
worker: String, worker: String,
folder_id: i64, folder_id: i64,
@@ -1929,6 +1964,9 @@ pub async fn set_worker_paused(
db::requeue_processing_tagging_jobs_for_folder(&conn, folder_id) db::requeue_processing_tagging_jobs_for_folder(&conn, folder_id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
if let Err(error) = persist_worker_pauses_if_enabled(&app) {
log::warn!("Failed to persist worker pause state: {error}");
}
Ok(()) Ok(())
} }
@@ -1970,6 +2008,11 @@ pub struct SetTaggerAccelerationParams {
pub acceleration: TaggerAcceleration, pub acceleration: TaggerAcceleration,
} }
#[derive(Deserialize)]
pub struct SetTaggerModelParams {
pub model: TaggerModel,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SetTaggerThresholdParams { pub struct SetTaggerThresholdParams {
pub threshold: f32, pub threshold: f32,
@@ -2030,6 +2073,21 @@ pub async fn set_tagger_acceleration(
tagger::set_tagger_acceleration(&app_dir, params.acceleration).map_err(|e| e.to_string()) tagger::set_tagger_acceleration(&app_dir, params.acceleration).map_err(|e| e.to_string())
} }
#[tauri::command]
pub async fn get_tagger_model(app: AppHandle) -> Result<TaggerModel, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
Ok(tagger::tagger_model(&app_dir))
}
#[tauri::command]
pub async fn set_tagger_model(
app: AppHandle,
params: SetTaggerModelParams,
) -> Result<TaggerModel, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
tagger::set_tagger_model(&app_dir, params.model).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub async fn probe_tagger_runtime(app: AppHandle) -> Result<TaggerRuntimeProbe, String> { pub async fn probe_tagger_runtime(app: AppHandle) -> Result<TaggerRuntimeProbe, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
@@ -2428,6 +2486,8 @@ pub async fn bulk_remove_tag(
const TAGGING_QUEUE_SCOPE_FILE: &str = "settings/tagging_queue_scope.txt"; const TAGGING_QUEUE_SCOPE_FILE: &str = "settings/tagging_queue_scope.txt";
const TAGGING_QUEUE_FOLDER_IDS_FILE: &str = "settings/tagging_queue_folder_ids.txt"; const TAGGING_QUEUE_FOLDER_IDS_FILE: &str = "settings/tagging_queue_folder_ids.txt";
const WORKER_PAUSES_PERSIST_FILE: &str = "settings/worker_pauses_persist.txt";
const WORKER_PAUSES_FILE: &str = "settings/worker_pauses.json";
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SetTaggingQueueScopeParams { pub struct SetTaggingQueueScopeParams {
@@ -2499,6 +2559,67 @@ pub async fn set_tagging_queue_folder_ids(
Ok(()) Ok(())
} }
fn worker_pause_persistence_enabled(app_dir: &Path) -> bool {
std::fs::read_to_string(app_dir.join(WORKER_PAUSES_PERSIST_FILE))
.map(|value| value.trim() == "true")
.unwrap_or(false)
}
fn write_worker_pause_snapshot(app_dir: &Path) -> Result<(), String> {
let path = app_dir.join(WORKER_PAUSES_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(&indexer::snapshot_worker_paused_states())
.map_err(|e| e.to_string())?;
std::fs::write(path, json).map_err(|e| e.to_string())
}
fn persist_worker_pauses_if_enabled(app: &AppHandle) -> Result<(), String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
if worker_pause_persistence_enabled(&app_dir) {
write_worker_pause_snapshot(&app_dir)?;
}
Ok(())
}
pub fn restore_persisted_worker_pauses(app_dir: &Path) {
if !worker_pause_persistence_enabled(app_dir) {
return;
}
let path = app_dir.join(WORKER_PAUSES_FILE);
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
match serde_json::from_str::<indexer::PersistedPausedWorkerFolders>(&content) {
Ok(states) => indexer::replace_worker_paused_states(states),
Err(error) => log::warn!("Failed to restore persisted worker pauses: {error}"),
}
}
#[tauri::command]
pub async fn get_worker_pauses_persist(app: AppHandle) -> Result<bool, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
Ok(worker_pause_persistence_enabled(&app_dir))
}
#[tauri::command]
pub async fn set_worker_pauses_persist(app: AppHandle, persist: bool) -> Result<(), String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = app_dir.join(WORKER_PAUSES_PERSIST_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(path, if persist { "true" } else { "false" }).map_err(|e| e.to_string())?;
if persist {
write_worker_pause_snapshot(&app_dir)?;
} else {
let _ = std::fs::remove_file(app_dir.join(WORKER_PAUSES_FILE));
}
Ok(())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// App data folder // App data folder
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+65 -1
View File
@@ -1,4 +1,4 @@
use crate::vector; use crate::{ai_tag_filter, vector};
use anyhow::Result; use anyhow::Result;
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
@@ -378,6 +378,42 @@ pub fn migrate(conn: &Connection) -> Result<()> {
)?; )?;
vector::migrate(conn)?; vector::migrate(conn)?;
remove_filtered_ai_tags(conn)?;
Ok(())
}
fn remove_filtered_ai_tags(conn: &Connection) -> Result<()> {
let mut stmt = conn.prepare("SELECT id, tag FROM image_tags WHERE source = 'ai'")?;
let filtered_ids = stmt
.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?
.filter_map(|row| match row {
Ok((id, tag)) if ai_tag_filter::is_removed_ai_tag(&tag) => Some(Ok(id)),
Ok(_) => None,
Err(error) => Some(Err(error)),
})
.collect::<rusqlite::Result<Vec<_>>>()?;
drop(stmt);
if filtered_ids.is_empty() {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
{
let mut delete_stmt = tx.prepare("DELETE FROM image_tags WHERE id = ?1")?;
for id in &filtered_ids {
delete_stmt.execute([id])?;
}
}
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.commit()?;
log::info!(
"Removed {} filtered AI tag(s) from existing library data",
filtered_ids.len()
);
Ok(()) Ok(())
} }
@@ -2400,6 +2436,34 @@ pub fn get_explore_tags(
Ok(rows) Ok(rows)
} }
pub fn get_related_tags(
conn: &Connection,
tag: &str,
folder_id: Option<i64>,
limit: usize,
) -> Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT other.tag, COUNT(DISTINCT other.image_id) AS shared_count
FROM image_tags base
JOIN image_tags other ON other.image_id = base.image_id
JOIN images i ON i.id = base.image_id
WHERE base.tag = ?1
AND other.tag != base.tag
AND (?2 IS NULL OR i.folder_id = ?2)
GROUP BY other.tag
ORDER BY shared_count DESC, other.tag ASC
LIMIT ?3",
)?;
let rows = stmt
.query_map(params![tag, folder_id, limit as i64], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
type FailedEmbeddingRow = (i64, String, String, Option<String>); type FailedEmbeddingRow = (i64, String, String, Option<String>);
pub fn get_failed_embedding_images( pub fn get_failed_embedding_images(
+53 -6
View File
@@ -3,13 +3,13 @@ use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, Inde
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::tagger::{self, Tagger};
use crate::thumbnail; use crate::thumbnail;
use crate::vector; use crate::vector;
use anyhow::Result; use anyhow::Result;
use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use rayon::prelude::*; use rayon::prelude::*;
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
@@ -41,6 +41,14 @@ struct PausedWorkerFolders {
tagging: HashSet<i64>, tagging: HashSet<i64>,
} }
#[derive(Default, Deserialize, Serialize)]
pub struct PersistedPausedWorkerFolders {
pub thumbnail: Vec<i64>,
pub metadata: Vec<i64>,
pub embedding: Vec<i64>,
pub tagging: Vec<i64>,
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct FolderWorkerPausedState { pub struct FolderWorkerPausedState {
pub thumbnail: bool, pub thumbnail: bool,
@@ -50,6 +58,41 @@ pub struct FolderWorkerPausedState {
pub tagging: bool, pub tagging: bool,
} }
pub fn replace_worker_paused_states(states: PersistedPausedWorkerFolders) {
if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS
.get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
.lock()
{
paused_folders.thumbnail = states.thumbnail.into_iter().collect();
paused_folders.metadata = states.metadata.into_iter().collect();
paused_folders.embedding = states.embedding.into_iter().collect();
paused_folders.caption = HashSet::new();
paused_folders.tagging = states.tagging.into_iter().collect();
}
}
pub fn snapshot_worker_paused_states() -> PersistedPausedWorkerFolders {
let Ok(paused_folders) = PAUSED_WORKER_FOLDERS
.get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
.lock()
else {
return PersistedPausedWorkerFolders::default();
};
let sorted = |set: &HashSet<i64>| {
let mut ids = set.iter().copied().collect::<Vec<_>>();
ids.sort_unstable();
ids
};
PersistedPausedWorkerFolders {
thumbnail: sorted(&paused_folders.thumbnail),
metadata: sorted(&paused_folders.metadata),
embedding: sorted(&paused_folders.embedding),
tagging: sorted(&paused_folders.tagging),
}
}
pub fn set_worker_paused(worker: &str, folder_id: i64, paused: bool) { pub fn set_worker_paused(worker: &str, folder_id: i64, paused: bool) {
if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS
.get_or_init(|| Mutex::new(PausedWorkerFolders::default())) .get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
@@ -330,7 +373,7 @@ pub fn start_caption_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf)
pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) { pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) {
std::thread::spawn(move || { std::thread::spawn(move || {
let mut tagger_instance: Option<WdTagger> = None; let mut tagger_instance: Option<Box<dyn Tagger>> = None;
log::info!("Tagging worker started."); log::info!("Tagging worker started.");
loop { loop {
// If the acceleration setting changed, drop the cached session so // If the acceleration setting changed, drop the cached session so
@@ -1236,7 +1279,7 @@ fn process_tagging_batch(
app: &AppHandle, app: &AppHandle,
pool: &DbPool, pool: &DbPool,
app_data_dir: &Path, app_data_dir: &Path,
tagger_instance: &mut Option<WdTagger>, tagger_instance: &mut Option<Box<dyn Tagger>>,
) -> Result<bool> { ) -> Result<bool> {
if !tagger::tagger_model_status(app_data_dir).ready { if !tagger::tagger_model_status(app_data_dir).ready {
return Ok(false); return Ok(false);
@@ -1264,7 +1307,7 @@ fn process_tagging_batch(
} }
if tagger_instance.is_none() { if tagger_instance.is_none() {
match WdTagger::new(app_data_dir) { match tagger::create_active_tagger(app_data_dir) {
Ok(model) => *tagger_instance = Some(model), Ok(model) => *tagger_instance = Some(model),
Err(error) => { Err(error) => {
with_db_write_lock(|| { with_db_write_lock(|| {
@@ -1323,6 +1366,10 @@ fn process_tagging_batch(
} }
let infer_elapsed = infer_started_at.elapsed(); let infer_elapsed = infer_started_at.elapsed();
// Attribute the tags to the model that actually produced them, not a
// hardcoded one (WD vs JoyTag are both possible).
let tagger_model_name = tagger_ref.model_name();
let tag_results = jobs.iter().cloned().zip(outputs).collect::<Vec<_>>(); let tag_results = jobs.iter().cloned().zip(outputs).collect::<Vec<_>>();
let write_started_at = Instant::now(); let write_started_at = Instant::now();
@@ -1355,7 +1402,7 @@ fn process_tagging_batch(
job.image_id, job.image_id,
&tag_pairs, &tag_pairs,
&output.rating, &output.rating,
tagger::WD_TAGGER_MODEL_NAME, tagger_model_name,
)?; )?;
} }
Err(error) => { Err(error) => {
+7
View File
@@ -1,3 +1,4 @@
mod ai_tag_filter;
mod captioner; mod captioner;
mod color; mod color;
mod commands; mod commands;
@@ -118,6 +119,7 @@ pub fn run() {
let thumb_dir = app_dir.join("thumbnails"); let thumb_dir = app_dir.join("thumbnails");
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir"); std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
commands::restore_persisted_worker_pauses(&app_dir);
// The asset protocol scope is no longer a blanket "**": thumbnails // The asset protocol scope is no longer a blanket "**": thumbnails
// are allowed statically in tauri.conf.json, and each indexed // are allowed statically in tauri.conf.json, and each indexed
@@ -194,14 +196,19 @@ pub fn run() {
commands::suggest_image_tags, commands::suggest_image_tags,
commands::set_worker_paused, commands::set_worker_paused,
commands::get_worker_states, commands::get_worker_states,
commands::get_worker_pauses_persist,
commands::set_worker_pauses_persist,
commands::get_tag_cloud, commands::get_tag_cloud,
commands::get_explore_tags, commands::get_explore_tags,
commands::get_related_tags,
commands::get_images_by_ids, commands::get_images_by_ids,
commands::get_failed_embedding_images, commands::get_failed_embedding_images,
commands::get_failed_tagging_images, commands::get_failed_tagging_images,
commands::get_tagger_model_status, commands::get_tagger_model_status,
commands::get_tagger_acceleration, commands::get_tagger_acceleration,
commands::set_tagger_acceleration, commands::set_tagger_acceleration,
commands::get_tagger_model,
commands::set_tagger_model,
commands::probe_tagger_runtime, commands::probe_tagger_runtime,
commands::get_tagger_threshold, commands::get_tagger_threshold,
commands::set_tagger_threshold, commands::set_tagger_threshold,
+531 -125
View File
@@ -1,3 +1,4 @@
use crate::ai_tag_filter;
use anyhow::Result; use anyhow::Result;
use hf_hub::{api::sync::Api, Repo, RepoType}; use hf_hub::{api::sync::Api, Repo, RepoType};
use image::{imageops::FilterType, DynamicImage, ImageReader}; use image::{imageops::FilterType, DynamicImage, ImageReader};
@@ -16,15 +17,27 @@ pub const WD_TAGGER_MODEL_NAME: &str = "wd-swinv2-tagger-v3";
const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt"; const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt";
const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt"; const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt";
const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt"; const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt";
const TAGGER_MODEL_FILE: &str = "settings/tagger_model.txt";
// Files required on disk before the tagger can run. The ONNX runtime DLLs pub const JOYTAG_MODEL_ID: &str = "fancyfeast/joytag";
// are shared with the captioner and live in the same `onnxruntime/` directory. pub const JOYTAG_MODEL_NAME: &str = "joytag";
const TAGGER_REQUIRED_FILES: &[&str] = &[
// JoyTag preprocessing differs from the WD tagger: it expects RGB (not BGR),
// CLIP-style mean/std normalization on [0,1] values (not raw [0,255]), and an
// NCHW layout (not NHWC). These are the OpenAI CLIP normalization constants.
const JOYTAG_MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73];
const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_58, 0.275_777_11];
// JoyTag's recommended detection threshold (used as the default; the user's
// tagger_threshold setting still overrides it).
const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4;
// Shared ONNX Runtime DLLs (live in the caption model's `onnxruntime/` dir and
// are reused by the tagger). The per-model weight + label files are listed by
// `TaggerModel::download_files`.
const TAGGER_RUNTIME_DLLS: &[&str] = &[
"onnxruntime/onnxruntime.dll", "onnxruntime/onnxruntime.dll",
"onnxruntime/onnxruntime_providers_shared.dll", "onnxruntime/onnxruntime_providers_shared.dll",
"onnxruntime/DirectML.dll", "onnxruntime/DirectML.dll",
"model.onnx",
"selected_tags.csv",
]; ];
// Tags in these Danbooru categories are kept in the output. // Tags in these Danbooru categories are kept in the output.
@@ -78,6 +91,61 @@ impl TaggerAcceleration {
} }
} }
/// Which tagging model is active. Both produce a `TaggerOutput` (tags + an
/// explicitness rating); they differ in vocabulary, preprocessing, and how the
/// rating is derived. WD is Danbooru-trained (anime-leaning); JoyTag uses the
/// Danbooru schema but generalizes to photographic content and is stronger on
/// NSFW concepts.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TaggerModel {
#[default]
Wd,
JoyTag,
}
impl TaggerModel {
fn as_str(self) -> &'static str {
match self {
Self::Wd => "wd",
Self::JoyTag => "joytag",
}
}
/// Hugging Face repo the model files are fetched from.
fn repo_id(self) -> &'static str {
match self {
Self::Wd => WD_TAGGER_MODEL_ID,
Self::JoyTag => JOYTAG_MODEL_ID,
}
}
/// Stable display/identifier name.
fn model_name(self) -> &'static str {
match self {
Self::Wd => WD_TAGGER_MODEL_NAME,
Self::JoyTag => JOYTAG_MODEL_NAME,
}
}
/// Subdirectory under `models/` where this model's files live.
fn dir_name(self) -> &'static str {
match self {
Self::Wd => "wd-swinv2-tagger-v3",
Self::JoyTag => "joytag",
}
}
/// Files fetched from `repo_id` (the shared ONNX Runtime DLLs are handled
/// separately). `model.onnx` is the weights; the second is the label list.
fn download_files(self) -> &'static [&'static str] {
match self {
Self::Wd => &["model.onnx", "selected_tags.csv"],
Self::JoyTag => &["model.onnx", "top_tags.txt"],
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Status / probe types exposed to the frontend // Status / probe types exposed to the frontend
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -152,14 +220,41 @@ struct TagEntry {
// Path helpers // Path helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Directory of the *active* tagger model's files (driven by the
/// `tagger_model` setting), e.g. `…/models/wd-swinv2-tagger-v3`.
pub fn model_dir(app_data_dir: &Path) -> PathBuf { pub fn model_dir(app_data_dir: &Path) -> PathBuf {
app_data_dir.join("models").join("wd-swinv2-tagger-v3") app_data_dir
.join("models")
.join(tagger_model(app_data_dir).dir_name())
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Settings persistence // Settings persistence
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn tagger_model(app_data_dir: &Path) -> TaggerModel {
let path = app_data_dir.join(TAGGER_MODEL_FILE);
let Ok(value) = std::fs::read_to_string(path) else {
return TaggerModel::default();
};
match value.trim().to_ascii_lowercase().as_str() {
"joytag" => TaggerModel::JoyTag,
_ => TaggerModel::Wd,
}
}
pub fn set_tagger_model(app_data_dir: &Path, model: TaggerModel) -> Result<TaggerModel> {
let path = app_data_dir.join(TAGGER_MODEL_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, model.as_str())?;
// Switching models means the cached session is for the wrong model; the
// worker drops and rebuilds it on the next batch (same flag as an EP change).
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
Ok(model)
}
pub fn tagger_acceleration(app_data_dir: &Path) -> TaggerAcceleration { pub fn tagger_acceleration(app_data_dir: &Path) -> TaggerAcceleration {
let path = app_data_dir.join(TAGGER_ACCELERATION_FILE); let path = app_data_dir.join(TAGGER_ACCELERATION_FILE);
let Ok(value) = std::fs::read_to_string(path) else { let Ok(value) = std::fs::read_to_string(path) else {
@@ -231,25 +326,27 @@ pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<u
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub fn tagger_model_status(app_data_dir: &Path) -> TaggerModelStatus { pub fn tagger_model_status(app_data_dir: &Path) -> TaggerModelStatus {
let model = tagger_model(app_data_dir);
let local_dir = model_dir(app_data_dir); let local_dir = model_dir(app_data_dir);
// The ONNX runtime DLLs live in the caption model dir; reuse them. // 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 caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
let missing_files = TAGGER_REQUIRED_FILES
let mut missing_files: Vec<String> = TAGGER_RUNTIME_DLLS
.iter() .iter()
.filter(|file| { .filter(|file| !caption_model_dir.join(file).exists())
let path = if file.starts_with("onnxruntime/") {
caption_model_dir.join(file)
} else {
local_dir.join(file)
};
!path.exists()
})
.map(|file| (*file).to_string()) .map(|file| (*file).to_string())
.collect::<Vec<_>>(); .collect();
missing_files.extend(
model
.download_files()
.iter()
.filter(|file| !local_dir.join(file).exists())
.map(|file| (*file).to_string()),
);
TaggerModelStatus { TaggerModelStatus {
model_id: WD_TAGGER_MODEL_ID, model_id: model.repo_id(),
model_name: WD_TAGGER_MODEL_NAME, model_name: model.model_name(),
local_dir: local_dir.to_string_lossy().to_string(), local_dir: local_dir.to_string_lossy().to_string(),
ready: missing_files.is_empty(), ready: missing_files.is_empty(),
missing_files, missing_files,
@@ -260,19 +357,20 @@ pub fn prepare_tagger_model_with_progress(
app_data_dir: &Path, app_data_dir: &Path,
emit_progress: impl Fn(TaggerModelProgress), emit_progress: impl Fn(TaggerModelProgress),
) -> Result<TaggerModelStatus> { ) -> Result<TaggerModelStatus> {
let model = tagger_model(app_data_dir);
let local_dir = model_dir(app_data_dir); let local_dir = model_dir(app_data_dir);
std::fs::create_dir_all(&local_dir)?; std::fs::create_dir_all(&local_dir)?;
// The tagger shares the ONNX runtime DLLs with the captioner; download // The tagger shares the ONNX runtime DLLs with the captioner; download
// them here so the tagger works even on a clean install where the caption // them here so the tagger works even on a clean install where the caption
// model has never been fetched (ensure_onnx_runtime only initializes). // model has never been fetched (ensure_onnx_runtime only initializes).
const DOWNLOAD_FILES: &[&str] = &["model.onnx", "selected_tags.csv"]; let download_files = model.download_files();
let caption_model_dir = crate::captioner::model_dir(app_data_dir); let caption_model_dir = crate::captioner::model_dir(app_data_dir);
std::fs::create_dir_all(&caption_model_dir)?; std::fs::create_dir_all(&caption_model_dir)?;
// Unified step count across DLLs and tagger files so the bar is coherent. // Unified step count across DLLs and tagger files so the bar is coherent.
let dll_count = crate::captioner::missing_onnx_runtime_count(&caption_model_dir); let dll_count = crate::captioner::missing_onnx_runtime_count(&caption_model_dir);
let model_pending = DOWNLOAD_FILES let model_pending = download_files
.iter() .iter()
.filter(|file| !local_dir.join(file).exists()) .filter(|file| !local_dir.join(file).exists())
.count(); .count();
@@ -318,9 +416,9 @@ pub fn prepare_tagger_model_with_progress(
// (timeout + resume), rather than hf-hub's download_with_progress, whose // (timeout + resume), rather than hf-hub's download_with_progress, whose
// agent has no read timeout and would hang on a stalled connection. // agent has no read timeout and would hang on a stalled connection.
let api = Api::new()?; let api = Api::new()?;
let repo = api.repo(Repo::new(WD_TAGGER_MODEL_ID.to_string(), RepoType::Model)); let repo = api.repo(Repo::new(model.repo_id().to_string(), RepoType::Model));
for file in DOWNLOAD_FILES { for file in download_files {
let destination = local_dir.join(file); let destination = local_dir.join(file);
if destination.exists() { if destination.exists() {
continue; continue;
@@ -424,11 +522,96 @@ pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result<TaggerRuntimeProbe> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Top-level inference entry point // Tagger trait + shared batch skeleton
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A loaded tagging model. Implementations differ in vocabulary, preprocessing,
/// and how the explicitness rating is derived, but all turn a batch of image
/// paths into one `TaggerOutput` per path (in order), with per-image failures
/// reflected in the individual `Result`s. Built for the active model by
/// [`create_active_tagger`].
pub trait Tagger {
fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec<Result<TaggerOutput>>;
/// Stable name of this model, written to the DB as `ai_tagger_model` so
/// tags can be attributed to (and re-tagged across) models.
fn model_name(&self) -> &'static str;
}
/// Build the tagger for the currently-selected model.
pub fn create_active_tagger(app_data_dir: &Path) -> Result<Box<dyn Tagger>> {
match tagger_model(app_data_dir) {
TaggerModel::Wd => Ok(Box::new(WdTagger::new(app_data_dir)?)),
TaggerModel::JoyTag => Ok(Box::new(JoyTagger::new(app_data_dir)?)),
}
}
/// Shared batch skeleton: pack the successfully-preprocessed images into one
/// contiguous buffer, run a single batched forward pass via `infer`, and fall
/// back to per-image inference if the batch fails (e.g. a model pinned to
/// batch=1). Returns one result per input slot, in order — a decode failure
/// stays attached to its own slot. `infer(pixels, count)` runs `count`
/// contiguous images (`count * stride` floats) and returns `count` outputs.
fn assemble_batch(
preprocessed: Vec<Result<Vec<f32>>>,
stride: usize,
model_label: &str,
mut infer: impl FnMut(&[f32], usize) -> Result<Vec<TaggerOutput>>,
) -> Vec<Result<TaggerOutput>> {
let mut batch_slots: Vec<usize> = Vec::new();
let mut batch_pixels: Vec<f32> = Vec::with_capacity(preprocessed.len() * stride);
for (i, result) in preprocessed.iter().enumerate() {
if let Ok(pixels) = result {
batch_slots.push(i);
batch_pixels.extend_from_slice(pixels);
}
}
// Seed each slot with its decode error; inference overwrites decoded slots.
let mut results: Vec<Result<TaggerOutput>> = preprocessed
.into_iter()
.map(|r| match r {
Ok(_) => Err(anyhow::anyhow!(
"tagging inference did not run for this image"
)),
Err(error) => Err(error),
})
.collect();
if batch_slots.is_empty() {
return results; // nothing decoded
}
match infer(&batch_pixels, batch_slots.len()) {
Ok(outputs) => {
// `infer` must return exactly one output per packed image; the zip
// below would otherwise silently leave trailing slots as errors.
debug_assert_eq!(outputs.len(), batch_slots.len());
for (&slot, output) in batch_slots.iter().zip(outputs) {
results[slot] = Ok(output);
}
}
Err(batch_error) => {
log::warn!(
"{model_label} batch inference failed for {} images, falling back to per-image: {batch_error}",
batch_slots.len()
);
for (k, &slot) in batch_slots.iter().enumerate() {
let one = &batch_pixels[k * stride..(k + 1) * stride];
results[slot] = infer(one, 1).and_then(|mut out| {
out.drain(..)
.next()
.ok_or_else(|| anyhow::anyhow!("tagger produced no output for image"))
});
}
}
}
results
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tagger implementation // WD tagger implementation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub struct WdTagger { pub struct WdTagger {
@@ -509,85 +692,6 @@ impl WdTagger {
}) })
} }
/// Tag a batch of images in a single forward pass. Returns one result per
/// input path, in the same order. Per-image decode failures — and, if the
/// batched inference itself fails (e.g. a model pinned to batch=1), a
/// per-image inference fallback — are reflected in the individual `Result`s.
pub fn run_batch(
&mut self,
image_paths: &[PathBuf],
max_tags: usize,
) -> Vec<Result<TaggerOutput>> {
if image_paths.is_empty() {
return Vec::new();
}
let started_at = Instant::now();
let input_size = self.input_size;
let stride = input_size * input_size * 3;
// Decode + preprocess every image in parallel, keeping results aligned to
// the inputs so a failure stays attached to its own path.
let preprocessed: Vec<Result<Vec<f32>>> = image_paths
.par_iter()
.map(|path| preprocess_image(path, input_size))
.collect();
// Pack the successfully-decoded images into one contiguous buffer for a
// single batched inference, remembering which input slot each came from.
let mut batch_slots: Vec<usize> = Vec::new();
let mut batch_pixels: Vec<f32> = Vec::with_capacity(image_paths.len() * stride);
for (i, result) in preprocessed.iter().enumerate() {
if let Ok(pixels) = result {
batch_slots.push(i);
batch_pixels.extend_from_slice(pixels);
}
}
// Seed each slot with its decode error; inference overwrites the ones
// that decoded. The placeholder error never survives a decoded slot.
let mut results: Vec<Result<TaggerOutput>> = preprocessed
.into_iter()
.map(|r| match r {
Ok(_) => Err(anyhow::anyhow!(
"tagging inference did not run for this image"
)),
Err(error) => Err(error),
})
.collect();
if batch_slots.is_empty() {
return results; // nothing decoded
}
match self.infer_batch(&batch_pixels, batch_slots.len(), max_tags) {
Ok(outputs) => {
for (&slot, output) in batch_slots.iter().zip(outputs) {
results[slot] = Ok(output);
}
}
Err(batch_error) => {
log::warn!(
"WD tagger batch inference failed for {} images, falling back to per-image: {batch_error}",
batch_slots.len()
);
for (k, &slot) in batch_slots.iter().enumerate() {
let pixels = batch_pixels[k * stride..(k + 1) * stride].to_vec();
results[slot] = self.infer_one(pixels, max_tags);
}
}
}
log::debug!(
"WD tagger batch: {} images ({} decoded) in {:?}",
image_paths.len(),
batch_slots.len(),
started_at.elapsed(),
);
results
}
/// Run `count` already-preprocessed images (contiguous `[count, H, W, 3]` /// Run `count` already-preprocessed images (contiguous `[count, H, W, 3]`
/// pixels) through the model in one forward pass. /// pixels) through the model in one forward pass.
fn infer_batch( fn infer_batch(
@@ -632,14 +736,6 @@ impl WdTagger {
.collect()) .collect())
} }
/// Run a single preprocessed image through the model.
fn infer_one(&mut self, image_array: Vec<f32>, max_tags: usize) -> Result<TaggerOutput> {
self.infer_batch(&image_array, 1, max_tags)?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("tagger produced no output for image"))
}
/// Convert one image's class probabilities into its rating + sorted tags. /// Convert one image's class probabilities into its rating + sorted tags.
/// Associated (not `&self`) so callers can hold a disjoint session borrow. /// Associated (not `&self`) so callers can hold a disjoint session borrow.
fn tags_from_probs( fn tags_from_probs(
@@ -664,6 +760,7 @@ impl WdTagger {
.filter(|(entry, prob)| { .filter(|(entry, prob)| {
(entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY) (entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY)
&& **prob >= threshold && **prob >= threshold
&& !ai_tag_filter::is_removed_ai_tag(&entry.name)
}) })
.map(|(entry, prob)| TagResult { .map(|(entry, prob)| TagResult {
tag: entry.name.clone(), tag: entry.name.clone(),
@@ -678,6 +775,177 @@ impl WdTagger {
} }
} }
impl Tagger for WdTagger {
fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec<Result<TaggerOutput>> {
if image_paths.is_empty() {
return Vec::new();
}
let input_size = self.input_size;
let preprocessed: Vec<Result<Vec<f32>>> = image_paths
.par_iter()
.map(|path| preprocess_image(path, input_size))
.collect();
assemble_batch(
preprocessed,
input_size * input_size * 3,
"WD tagger",
|pixels, count| self.infer_batch(pixels, count, max_tags),
)
}
fn model_name(&self) -> &'static str {
WD_TAGGER_MODEL_NAME
}
}
// ---------------------------------------------------------------------------
// JoyTag implementation
// JoyTag uses the Danbooru tag schema but generalizes to photographic content
// and is strong on NSFW concepts. It has no rating output, so the explicitness
// rating is derived from its NSFW tags (see `joytag_rating`). Input is NCHW,
// RGB, CLIP-normalized — see `preprocess_joytag`.
// ---------------------------------------------------------------------------
pub struct JoyTagger {
session: Session,
labels: Vec<String>,
threshold: f32,
input_size: usize,
}
impl JoyTagger {
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!(
"JoyTag model is missing {} required file(s): {}",
status.missing_files.len(),
status.missing_files.join(", ")
);
}
let local_dir = model_dir(app_data_dir);
// Shared ONNX runtime DLLs (see WdTagger::new).
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 = joytag_threshold(app_data_dir);
let model_path = local_dir.join("model.onnx");
let labels_path = local_dir.join("top_tags.txt");
let session = create_tagger_session(&model_path, acceleration)?;
// JoyTag uses NCHW (N, 3, H, W); the spatial size lives at axis 2.
let (input_size, batch_axis) = {
let inputs = session.inputs();
inputs
.first()
.and_then(|inp| {
if let ort::value::ValueType::Tensor { shape, .. } = inp.dtype() {
let size = shape.get(2).copied().filter(|&d| d > 0).map(|d| d as usize);
Some((size.unwrap_or(448), shape.first().copied()))
} else {
None
}
})
.unwrap_or((448, None))
};
let labels = load_joytag_labels(&labels_path)?;
log::info!(
"JoyTag loaded in {:?} ({} tags, input {}x{}, batch axis {:?}, {:?} acceleration)",
started_at.elapsed(),
labels.len(),
input_size,
input_size,
batch_axis,
acceleration,
);
Ok(Self {
session,
labels,
threshold,
input_size,
})
}
/// Run `count` already-preprocessed images (contiguous `[count, 3, H, W]`
/// pixels) through the model in one forward pass.
fn infer_batch(
&mut self,
pixels: &[f32],
count: usize,
max_tags: usize,
) -> Result<Vec<TaggerOutput>> {
let input = Tensor::from_array((
[count, 3usize, self.input_size, self.input_size],
pixels.to_vec().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 (_, logits) = outputs[0]
.try_extract_tensor::<f32>()
.map_err(|error| anyhow::anyhow!("{error}"))?;
let n_labels = self.labels.len();
if logits.len() != count * n_labels {
anyhow::bail!(
"Model output length {} does not match {} images x {} labels",
logits.len(),
count,
n_labels
);
}
let labels = &self.labels;
let threshold = self.threshold;
Ok(logits
.chunks_exact(n_labels)
.map(|row| joytag_tags_from_logits(labels, threshold, row, max_tags))
.collect())
}
}
impl Tagger for JoyTagger {
fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec<Result<TaggerOutput>> {
if image_paths.is_empty() {
return Vec::new();
}
let input_size = self.input_size;
let preprocessed: Vec<Result<Vec<f32>>> = image_paths
.par_iter()
.map(|path| preprocess_joytag(path, input_size))
.collect();
assemble_batch(
preprocessed,
input_size * input_size * 3,
"JoyTag",
|pixels, count| self.infer_batch(pixels, count, max_tags),
)
}
fn model_name(&self) -> &'static str {
JOYTAG_MODEL_NAME
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Session creation mirrors captioner's `create_session` // Session creation mirrors captioner's `create_session`
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -721,15 +989,13 @@ fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Resul
let mut builder = match acceleration { let mut builder = match acceleration {
TaggerAcceleration::Cpu => { TaggerAcceleration::Cpu => {
log::info!( log::info!("Tagger: using CPU execution provider ({intra_threads} intra-op threads)");
"WD tagger: using CPU execution provider ({intra_threads} intra-op threads)"
);
builder builder
} }
TaggerAcceleration::Auto => builder TaggerAcceleration::Auto => builder
.with_execution_providers([ep::DirectML::default().build().fail_silently()]) .with_execution_providers([ep::DirectML::default().build().fail_silently()])
.unwrap_or_else(|error| { .unwrap_or_else(|error| {
log::info!("WD tagger: DirectML unavailable, falling back to CPU"); log::info!("Tagger: DirectML unavailable, falling back to CPU");
error.recover() error.recover()
}), }),
TaggerAcceleration::Directml => builder TaggerAcceleration::Directml => builder
@@ -771,12 +1037,134 @@ fn load_labels(path: &Path) -> Result<Vec<TagEntry>> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Image preprocessing // JoyTag: label loading, threshold, rating derivation
// 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>> { /// JoyTag labels: one tag per line, index == output position. Underscores are
/// replaced with spaces to match the display style used elsewhere.
fn load_joytag_labels(path: &Path) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path)?;
let labels: Vec<String> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| line.replace('_', " "))
.collect();
if labels.is_empty() {
anyhow::bail!("top_tags.txt is empty or could not be parsed");
}
Ok(labels)
}
/// JoyTag detection threshold. Honours the shared `tagger_threshold` setting if
/// the user has set it, otherwise uses JoyTag's recommended default (0.4).
fn joytag_threshold(app_data_dir: &Path) -> f32 {
match std::fs::read_to_string(app_data_dir.join(TAGGER_THRESHOLD_FILE)) {
Ok(value) => value
.trim()
.parse::<f32>()
.unwrap_or(JOYTAG_DEFAULT_THRESHOLD)
.clamp(0.01, 1.0),
Err(_) => JOYTAG_DEFAULT_THRESHOLD,
}
}
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
// Explicitness buckets for deriving a rating from JoyTag's tags (highest match
// wins). Names use spaces, since underscores are stripped on load. Tunable.
const JOYTAG_EXPLICIT_TAGS: &[&str] = &[
"sex",
"vaginal",
"anal",
"oral",
"fellatio",
"cunnilingus",
"penis",
"pussy",
"cum",
"ejaculation",
"erection",
"handjob",
"paizuri",
"masturbation",
];
const JOYTAG_QUESTIONABLE_TAGS: &[&str] = &[
"nude",
"completely nude",
"nipples",
"topless",
"bottomless",
"pubic hair",
"areola",
"areolae",
];
const JOYTAG_SENSITIVE_TAGS: &[&str] = &[
"lingerie",
"underwear",
"panties",
"swimsuit",
"bikini",
"cleavage",
"bra",
"midriff",
];
/// Derive an explicitness rating from JoyTag's tags. JoyTag has no rating
/// output, so this maps its NSFW-content tags onto the WD-style buckets.
fn joytag_rating(tags: &[TagResult]) -> String {
let has = |bucket: &[&str]| tags.iter().any(|t| bucket.contains(&t.tag.as_str()));
if has(JOYTAG_EXPLICIT_TAGS) {
"explicit".to_string()
} else if has(JOYTAG_QUESTIONABLE_TAGS) {
"questionable".to_string()
} else if has(JOYTAG_SENSITIVE_TAGS) {
"sensitive".to_string()
} else {
"general".to_string()
}
}
/// Convert one image's JoyTag logits into sorted tags + a derived rating.
fn joytag_tags_from_logits(
labels: &[String],
threshold: f32,
logits: &[f32],
max_tags: usize,
) -> TaggerOutput {
let mut tags: Vec<TagResult> = labels
.iter()
.zip(logits.iter())
.filter_map(|(name, logit)| {
let confidence = sigmoid(*logit);
(confidence >= threshold && !ai_tag_filter::is_removed_ai_tag(name)).then(|| {
TagResult {
tag: name.clone(),
confidence,
}
})
})
.collect();
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
// Derive the rating from all kept tags before truncating to max_tags.
let rating = joytag_rating(&tags);
tags.truncate(max_tags);
TaggerOutput { tags, rating }
}
// ---------------------------------------------------------------------------
// Image preprocessing
// Both taggers pad to square with white and resize to the model input size;
// they differ only in channel order, value range, and tensor layout.
// ---------------------------------------------------------------------------
/// Decode an image, composite any alpha onto white, pad to a centered square,
/// and resize to `target_size`. Shared by both taggers.
fn decode_pad_resize(image_path: &Path, target_size: usize) -> Result<image::RgbImage> {
let image = ImageReader::open(image_path)?.decode()?; let image = ImageReader::open(image_path)?.decode()?;
// Composite any alpha channel onto a white background. // Composite any alpha channel onto a white background.
@@ -787,22 +1175,25 @@ fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
image::imageops::overlay(&mut canvas_rgba, &image_rgba, 0, 0); image::imageops::overlay(&mut canvas_rgba, &image_rgba, 0, 0);
let image_rgb = DynamicImage::ImageRgba8(canvas_rgba).to_rgb8(); let image_rgb = DynamicImage::ImageRgba8(canvas_rgba).to_rgb8();
// Pad to square. // Pad to a centered square.
let max_dim = width.max(height); let max_dim = width.max(height);
let pad_left = (max_dim - width) / 2; let pad_left = (max_dim - width) / 2;
let pad_top = (max_dim - height) / 2; let pad_top = (max_dim - height) / 2;
let mut square = image::RgbImage::from_pixel(max_dim, max_dim, image::Rgb([255, 255, 255])); 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); image::imageops::overlay(&mut square, &image_rgb, pad_left as i64, pad_top as i64);
// Resize to model input size. // Resize to model input size (CatmullRom ≈ PIL BICUBIC).
let resized = image::imageops::resize( Ok(image::imageops::resize(
&square, &square,
target_size as u32, target_size as u32,
target_size as u32, target_size as u32,
FilterType::CatmullRom, FilterType::CatmullRom,
); ))
}
// Flatten to (H, W, 3) float32 in BGR order, values in [0, 255]. /// WD tagger input: (N, H, W, 3) float32, raw [0,255] values, BGR order.
fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
let resized = decode_pad_resize(image_path, target_size)?;
let mut pixel_values = vec![0.0f32; target_size * target_size * 3]; let mut pixel_values = vec![0.0f32; target_size * target_size * 3];
for (x, y, pixel) in resized.enumerate_pixels() { for (x, y, pixel) in resized.enumerate_pixels() {
let base = (y as usize * target_size + x as usize) * 3; let base = (y as usize * target_size + x as usize) * 3;
@@ -811,6 +1202,21 @@ fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
pixel_values[base + 1] = f32::from(pixel[1]); // G pixel_values[base + 1] = f32::from(pixel[1]); // G
pixel_values[base + 2] = f32::from(pixel[0]); // R pixel_values[base + 2] = f32::from(pixel[0]); // R
} }
Ok(pixel_values)
}
/// JoyTag input: (N, 3, H, W) float32, RGB, CLIP-normalized ((x/255 mean)/std).
fn preprocess_joytag(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
let resized = decode_pad_resize(image_path, target_size)?;
let plane = target_size * target_size;
let mut pixel_values = vec![0.0f32; 3 * plane];
for (x, y, pixel) in resized.enumerate_pixels() {
let idx = y as usize * target_size + x as usize;
// Channel-major (NCHW): R plane, then G, then B.
for c in 0..3 {
pixel_values[c * plane + idx] =
(f32::from(pixel[c]) / 255.0 - JOYTAG_MEAN[c]) / JOYTAG_STD[c];
}
}
Ok(pixel_values) Ok(pixel_values)
} }
+2
View File
@@ -27,6 +27,7 @@ export default function App() {
const loadAlbums = useGalleryStore((state) => state.loadAlbums); const loadAlbums = useGalleryStore((state) => state.loadAlbums);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion); const loadAppVersion = useGalleryStore((state) => state.loadAppVersion);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
@@ -39,6 +40,7 @@ export default function App() {
void initializeNotifications(); void initializeNotifications();
void loadMutedFolderIds(); void loadMutedFolderIds();
void loadNotificationsPaused(); void loadNotificationsPaused();
void loadWorkerPausesPersist();
void loadFfmpegStatus(); void loadFfmpegStatus();
void loadOnboardingCompleted(); void loadOnboardingCompleted();
// Load the app version first so the What's New toast/modal (which read // Load the app version first so the What's New toast/modal (which read
+3 -1
View File
@@ -105,7 +105,9 @@ const ENTRIES = parseChangelog(changelogRaw);
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null { export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
if (!version) return null; if (!version) return null;
const normalized = version.replace(/^v/, ""); // Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so
// dev/UI-lab builds still resolve to the correct changelog entry.
const normalized = version.replace(/^v/, "").replace(/-[a-z].*/i, "");
// Never surface the in-progress [Unreleased] section to users. // Never surface the in-progress [Unreleased] section to users.
if (normalized.toLowerCase() === "unreleased") return null; if (normalized.toLowerCase() === "unreleased") return null;
return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null; return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null;
+6 -6
View File
@@ -56,7 +56,7 @@ export function ColorFilter() {
}, [open]); }, [open]);
return ( return (
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/[0.06] pl-2"> <div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2">
{/* Trigger — a single palette icon; shows the active color as a dot when a {/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */} filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}> <Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
@@ -99,9 +99,9 @@ export function ColorFilter() {
{SWATCHES.map((swatch) => { {SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb); const active = rgbEquals(colorFilter, swatch.rgb);
return ( return (
<Tooltip label= {swatch.name} followCursor>
<button <button
key={swatch.name} key={swatch.name}
title={swatch.name}
aria-label={`Filter by ${swatch.name}`} aria-label={`Filter by ${swatch.name}`}
className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${ className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110" active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
@@ -109,12 +109,12 @@ export function ColorFilter() {
style={{ backgroundColor: toHex(swatch.rgb) }} style={{ backgroundColor: toHex(swatch.rgb) }}
onClick={() => setColorFilter(active ? null : swatch.rgb)} onClick={() => setColorFilter(active ? null : swatch.rgb)}
/> />
</Tooltip>
); );
})} })}
<Tooltip label= "Custom Colour" followCursor>
{/* Custom color picker — rainbow until a custom color is chosen. */} {/* Custom color picker — rainbow until a custom color is chosen. */}
<label <label
title="Custom color"
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${ className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110" isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`} }`}
@@ -130,11 +130,11 @@ export function ColorFilter() {
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"} value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))} onChange={(event) => setColorFilter(fromHex(event.target.value))}
/> />
</label> </label></Tooltip>
</div> </div>
{isActive || (colorBackfill && colorBackfill.total > 0) ? ( {isActive || (colorBackfill && colorBackfill.total > 0) ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/[0.06] pt-2 light-theme:border-gray-700/40"> <div className="mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2 light-theme:border-gray-700/40">
{colorBackfill && colorBackfill.total > 0 ? ( {colorBackfill && colorBackfill.total > 0 ? (
<span <span
className="text-[10px] text-gray-600" className="text-[10px] text-gray-600"
+93 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { FfmpegStatusRow } from "./onboarding/StepWelcome"; import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown"; import { ThemedDropdown } from "./ThemedDropdown";
import { getChangelogForVersion } from "../changelog"; import { getChangelogForVersion } from "../changelog";
@@ -100,6 +100,44 @@ function ScopeButton({ scope, current, onSelect, children }: {
); );
} }
// Display metadata for each selectable tagging model.
const TAGGER_MODELS: Record<TaggerModel, { name: string; tab: string; description: string }> = {
wd: {
name: "WD SwinV2 Tagger v3",
tab: "WD (anime)",
description:
"Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.",
},
joytag: {
name: "JoyTag",
tab: "JoyTag (general)",
description:
"Booru-schema tagger that also handles photographic content and is strong on NSFW concepts. The explicitness rating is derived from its tags.",
},
};
function TaggerModelButton({ model, current, onSelect, children }: {
model: TaggerModel;
current: TaggerModel;
onSelect: (model: TaggerModel) => void;
children: React.ReactNode;
}) {
const active = model === 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 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => onSelect(model)}
>
{children}
</button>
);
}
function TaggerAccelerationButton({ acceleration, current, onSelect, children }: { function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
acceleration: TaggerAcceleration; acceleration: TaggerAcceleration;
current: TaggerAcceleration; current: TaggerAcceleration;
@@ -129,6 +167,8 @@ export function SettingsModal() {
const [taggerClearing, setTaggerClearing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null); const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false);
const [taggerModelSwitchError, setTaggerModelSwitchError] = useState<string | null>(null);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null); const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null);
@@ -173,6 +213,9 @@ export function SettingsModal() {
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel); const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration); const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration);
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration); const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
const taggerModel = useGalleryStore((state) => state.taggerModel);
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel);
const setTaggerModel = useGalleryStore((state) => state.setTaggerModel);
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold); const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold);
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold); const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize); const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize);
@@ -185,6 +228,8 @@ export function SettingsModal() {
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused); const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist);
const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist);
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex); const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex);
@@ -210,6 +255,7 @@ export function SettingsModal() {
useEffect(() => { useEffect(() => {
if (!settingsOpen) return; if (!settingsOpen) return;
void loadTaggerModelStatus(); void loadTaggerModelStatus();
void loadTaggerModel();
void loadTaggerAcceleration(); void loadTaggerAcceleration();
void loadTaggerThreshold(); void loadTaggerThreshold();
void loadTaggerBatchSize(); void loadTaggerBatchSize();
@@ -221,7 +267,7 @@ export function SettingsModal() {
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]); }, [settingsOpen, loadTaggerModelStatus, loadTaggerModel, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
useEffect(() => { useEffect(() => {
if (!settingsOpen || activeSection !== "general") return; if (!settingsOpen || activeSection !== "general") return;
@@ -365,10 +411,39 @@ export function SettingsModal() {
{activeSection === "workspace" ? ( {activeSection === "workspace" ? (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
<SettingsGroup title="Model"> <SettingsGroup title="Model">
<SettingsItem label="Tagging model" description="Choose which model generates tags. JoyTag suits photos and NSFW; WD is anime-focused. Switching may require a one-time download.">
<div className="flex flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["wd", "joytag"] as const).map((model) => (
<TaggerModelButton
key={model}
model={model}
current={taggerModel}
onSelect={(nextModel) => {
if (nextModel === taggerModel) return;
setTaggerModelSwitching(true);
setTaggerModelSwitchError(null);
void setTaggerModel(nextModel)
.catch((error: unknown) => setTaggerModelSwitchError(String(error)))
.finally(() => setTaggerModelSwitching(false));
}}
>
{TAGGER_MODELS[model].tab}
</TaggerModelButton>
))}
</div>
{taggerModelSwitchError ? (
<p className="text-[11px] text-amber-300">{taggerModelSwitchError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerModelSwitching ? "Switching..." : `Current: ${TAGGER_MODELS[taggerModel].name}`}</p>
)}
</div>
</SettingsItem>
<SettingsItem <SettingsItem
label={ label={
<> <>
WD SwinV2 Tagger v3{" "} {TAGGER_MODELS[taggerModel].name}{" "}
<span className="ml-1.5 align-middle"> <span className="ml-1.5 align-middle">
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}> <StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"} {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
@@ -376,7 +451,7 @@ export function SettingsModal() {
</span> </span>
</> </>
} }
description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds." description={TAGGER_MODELS[taggerModel].description}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{taggerReady ? ( {taggerReady ? (
@@ -469,7 +544,7 @@ export function SettingsModal() {
{taggerThresholdError ? ( {taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p> <p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : ( ) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p> <p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : `Default: ${taggerModel === "joytag" ? "0.4" : "0.35"}`}</p>
)} )}
</div> </div>
</SettingsItem> </SettingsItem>
@@ -781,6 +856,19 @@ export function SettingsModal() {
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} /> <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
</button> </button>
</SettingsItem> </SettingsItem>
<SettingsItem
label="Keep background pauses after restart"
description="When enabled, folders you pause from the sidebar or background bar stay paused the next time Phokus opens."
>
<button
role="switch"
aria-checked={workerPausesPersist}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${workerPausesPersist ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setWorkerPausesPersist(!workerPausesPersist)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${workerPausesPersist ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Maintenance"> <SettingsGroup title="Maintenance">
+553 -93
View File
@@ -1,7 +1,9 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { useVirtualizer } from "@tanstack/react-virtual";
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ThemedDropdown } from "./ThemedDropdown";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { mediaSrc } from "../lib/mediaSrc"; import { mediaSrc } from "../lib/mediaSrc";
@@ -224,60 +226,362 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
); );
} }
// Actual tag cloud — word size driven by log-scaled frequency interface AtlasNode {
function TagWord({
entry,
index,
logMin,
logRange,
onSearch,
}: {
entry: ExploreTagEntry; entry: ExploreTagEntry;
index: number; index: number;
logMin: number; x: number;
logRange: number; y: number;
w: number;
h: number;
fontSize: number;
ratio: number;
accent: string;
driftX: number;
driftY: number;
}
interface TagAnchor {
x: number;
y: number;
}
const TAG_ATLAS_MAX_VISIBLE = 132;
const TAG_ATLAS_DENSE_THRESHOLD = 120;
function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return [];
const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1)));
const logMin = Math.min(...logs);
const logRange = Math.max(...logs) - logMin || 1;
const cx = containerW / 2;
const cy = containerH * 0.48;
const spreadX = containerW * 0.47;
const spreadY = containerH * 0.46;
const accents = isLight ? LIGHT_ACCENTS : ACCENTS;
const nodes = entries.map((entry, index) => {
const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange;
const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1;
const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale;
const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2;
const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26);
const w = Math.min(containerW * maxWidthRatio, textWidth);
const h = fontSize * 1.18 + 14;
const radialRatio = Math.sqrt((index + 0.5) / entries.length);
const angle = index * GOLDEN_ANGLE;
return {
entry,
index,
x: cx + Math.cos(angle) * radialRatio * spreadX,
y: cy + Math.sin(angle) * radialRatio * spreadY,
w,
h,
fontSize,
ratio,
accent: accents[index % accents.length],
driftX: (seeded(index + 101) - 0.5) * 7,
driftY: (seeded(index + 113) - 0.5) * 6,
};
});
const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10;
const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7;
const margin = 28;
for (let iter = 0; iter < 140; 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 + padX - Math.abs(dx);
const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue;
if (overlapX < overlapY) {
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
nb.x += push;
na.x -= push;
} else {
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
nb.y += push;
na.y -= push;
}
}
}
for (const node of nodes) {
node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin);
node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin);
if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) {
node.y += (seeded(node.index + iter + 211) - 0.5) * 2;
}
if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) {
node.x += (seeded(node.index + iter + 223) - 0.5) * 2;
}
}
}
for (let iter = 0; iter < 5; iter++) {
const weighted = nodes.reduce(
(acc, node) => {
const weight = 1 + Math.pow(node.ratio, 1.35) * 9;
return {
x: acc.x + node.x * weight,
y: acc.y + node.y * weight,
weight: acc.weight + weight,
};
},
{ x: 0, y: 0, weight: 0 },
);
const offsetX = containerW * 0.48 - weighted.x / weighted.weight;
const offsetY = containerH * 0.44 - weighted.y / weighted.weight;
if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break;
for (const node of nodes) {
node.x = Math.min(Math.max(node.x + offsetX, node.w / 2 + margin), containerW - node.w / 2 - margin);
node.y = Math.min(Math.max(node.y + offsetY, node.h / 2 + margin), containerH - node.h / 2 - margin);
}
}
return nodes;
}
function TagAtlas({
entries,
onSearch,
loadRelatedTags,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void; onSearch: (tag: string) => void;
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
}) { }) {
const theme = useGalleryStore((state) => state.theme); const theme = useGalleryStore((state) => state.theme);
const isLight = theme === "subtle-light"; const isLight = theme === "subtle-light";
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5; const reducedMotion = useReducedMotion();
const fontSize = 11 + ratio * 28; // 11px 39px const canvasRef = useRef<HTMLDivElement>(null);
const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length]; const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
const tilt = (seeded(index + 5) - 0.5) * 7; const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
// Faint low-frequency words read fine as subtle white-on-dark, but the same low const [activeTag, setActiveTag] = useState<string | null>(null);
// opacity is unreadable on the light theme's cream, so raise the floor there. const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([]);
const minOpacity = isLight ? 0.6 : 0.4; const [anchors, setAnchors] = useState<Record<string, TagAnchor>>({});
const visibleEntries = useMemo(
() => (entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries),
[entries],
);
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();
}, []);
useEffect(() => {
if (!activeTag) {
setRelatedTags([]);
return;
}
let cancelled = false;
void loadRelatedTags(activeTag).then((entries) => {
if (!cancelled) setRelatedTags(entries);
});
return () => {
cancelled = true;
};
}, [activeTag, loadRelatedTags]);
const nodes = useMemo(
() => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight),
[visibleEntries, canvasSize.w, canvasSize.h, isLight],
);
const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes]);
const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined;
const minOpacity = isLight ? 0.62 : 0.42;
const visibleConnections = useMemo(
() =>
activeNode
? relatedTags
.map((related) => {
const node = nodeByTag.get(related.tag);
return node ? { node, related } : null;
})
.filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null)
.slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10)
: [],
[activeNode, nodeByTag, relatedTags, visibleEntries.length],
);
const activeAnchor = activeTag ? anchors[activeTag] : undefined;
const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count));
const connectedByTag = useMemo(
() => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])),
[visibleConnections],
);
const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => {
if (element) {
buttonRefs.current.set(tag, element);
} else {
buttonRefs.current.delete(tag);
}
}, []);
const measureAnchors = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || !activeTag) {
setAnchors({});
return;
}
const canvasRect = canvas.getBoundingClientRect();
const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)];
const nextAnchors: Record<string, TagAnchor> = {};
for (const tag of tagsToMeasure) {
const element = buttonRefs.current.get(tag);
if (!element) continue;
const rect = element.getBoundingClientRect();
nextAnchors[tag] = {
x: rect.left - canvasRect.left + rect.width / 2,
y: rect.top - canvasRect.top + rect.height / 2,
};
}
setAnchors(nextAnchors);
}, [activeTag, visibleConnections]);
useLayoutEffect(() => {
if (!activeTag) {
setAnchors({});
return;
}
let firstFrame = 0;
let secondFrame = 0;
const settleTimer = window.setTimeout(measureAnchors, 180);
firstFrame = window.requestAnimationFrame(() => {
measureAnchors();
secondFrame = window.requestAnimationFrame(measureAnchors);
});
return () => {
window.cancelAnimationFrame(firstFrame);
window.cancelAnimationFrame(secondFrame);
window.clearTimeout(settleTimer);
};
}, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]);
return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden px-8 py-8">
<svg className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<defs>
<radialGradient id="tag-atlas-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={isLight ? "rgba(60,50,30,0.18)" : "rgba(255,255,255,0.2)"} />
<stop offset="100%" stopColor="rgba(0,0,0,0)" />
</radialGradient>
</defs>
{activeNode && activeAnchor ? (
<circle cx={activeAnchor.x} cy={activeAnchor.y} r={Math.max(activeNode.w, activeNode.h) * 0.62} fill="url(#tag-atlas-glow)" opacity="0.24" />
) : null}
{visibleConnections.map(({ node, related }) => {
const from = activeAnchor;
const to = anchors[node.entry.tag];
if (!from || !to) return null;
const strength = related.shared_count / maxShared;
return (
<g key={`${activeTag}:${node.entry.tag}`}>
<motion.line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={node.accent}
strokeWidth={0.35 + strength * 0.55}
strokeOpacity={0.05 + strength * 0.12}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={reducedMotion ? undefined : { pathLength: 0, opacity: 0 }}
animate={reducedMotion ? undefined : { pathLength: 1, opacity: 1 }}
transition={{ duration: 0.32, ease: "easeOut" }}
/>
{!reducedMotion ? (
<motion.line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={node.accent}
strokeWidth={0.65 + strength * 0.5}
strokeOpacity={0.16 + strength * 0.1}
strokeLinecap="round"
pathLength={1}
strokeDasharray="0.08 1"
vectorEffect="non-scaling-stroke"
initial={{ strokeDashoffset: 1.08, opacity: 0 }}
animate={{ strokeDashoffset: [1.08, 0], opacity: [0, 0.65, 0] }}
transition={{
duration: 4.1 + seeded(node.index + 307) * 1.2,
repeat: Infinity,
ease: "easeInOut",
delay: seeded(node.index + 317) * 0.75,
}}
/>
) : null}
</g>
);
})}
</svg>
{nodes.map((node) => {
const isActive = activeTag === node.entry.tag;
const connectedRelated = connectedByTag.get(node.entry.tag);
const isRelated = connectedRelated !== undefined;
const dimmed = activeTag !== null && !isActive && !isRelated;
const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity);
return ( return (
<Tooltip <Tooltip
label={`${entry.tag}${entry.count.toLocaleString()} ${entry.count === 1 ? "image" : "images"}`} key={node.entry.tag}
label={`${node.entry.tag}${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
followCursor followCursor
delay={250} delay={250}
> >
<motion.button <button
initial={{ opacity: 0, scale: 0.6 }} ref={(element) => setButtonRef(node.entry.tag, element)}
animate={{ opacity: minOpacity + ratio * (1 - minOpacity), scale: 1 }} className={`explore-tag-word group absolute inline-flex items-center justify-center rounded-full px-2 py-1 text-center transition-[opacity,transform] duration-300 ease-out before:absolute before:inset-x-[-14px] before:inset-y-[-8px] before:rounded-full before:bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.16),rgba(255,255,255,0.06)_46%,rgba(255,255,255,0)_72%)] before:opacity-0 before:blur-md before:transition-opacity before:duration-300 before:content-[''] hover:before:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/25 ${
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }} isActive ? "before:opacity-100" : ""
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }} }`}
className="explore-tag-word group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]" style={{
style={{ fontSize, rotate: tilt }} left: node.x - node.w / 2,
onClick={() => onSearch(entry.tag)} top: node.y - node.h / 2,
width: node.w,
minHeight: node.h,
fontSize: node.fontSize,
color: node.ratio > 0.55 ? node.accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)",
opacity,
transform: `translate3d(0, 0, 0) scale(${isActive ? 1.045 : isRelated ? 1.015 : 1})`,
zIndex: isActive ? 30 : isRelated ? 20 : Math.round(node.ratio * 10),
}}
onMouseEnter={() => setActiveTag(node.entry.tag)}
onFocus={() => setActiveTag(node.entry.tag)}
onMouseLeave={() => setActiveTag(null)}
onBlur={() => setActiveTag(null)}
onClick={() => onSearch(node.entry.tag)}
> >
<span className="relative z-10 block w-full truncate text-center font-medium leading-none">{node.entry.tag}</span>
<span <span
className="font-medium leading-none" className={`absolute left-1/2 top-full z-10 mt-1 shrink-0 -translate-x-1/2 rounded-full px-1.5 py-0.5 text-[9px] tabular-nums shadow-sm backdrop-blur-sm transition-opacity ${
style={{ color: ratio > 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }} isActive || isRelated ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
style={{ backgroundColor: `${node.accent}1A`, color: node.accent }}
> >
{entry.tag} {connectedRelated ? connectedRelated.shared_count.toLocaleString() : node.entry.count.toLocaleString()}
</span> </span>
<span </button>
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>
</Tooltip> </Tooltip>
); );
})}
</div>
);
} }
function Spinner() { function Spinner() {
@@ -290,6 +594,33 @@ function Spinner() {
); );
} }
function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies";
const subtitle =
mode === "visual"
? "Grouping similar images into browsable clusters."
: "Collecting tags from the current library scope.";
return (
<div className="flex flex-1 items-center justify-center px-8">
<div className="flex max-w-sm flex-col items-center text-center">
<div className="mb-4 flex items-center justify-center gap-3 text-white/65">
<Spinner />
<span className="text-sm font-medium">{title}</span>
</div>
<p className="text-sm leading-relaxed text-white/25">{subtitle}</p>
<div className="mt-6 h-px w-40 overflow-hidden rounded-full bg-white/[0.06]">
<motion.div
className="h-full w-16 rounded-full bg-white/25"
animate={{ x: [-72, 184] }}
transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
</div>
</div>
);
}
// Separate component so its useLayoutEffect fires when the canvas is actually // Separate component so its useLayoutEffect fires when the canvas is actually
// mounted — not at TagCloud mount time when the container may still be hidden // mounted — not at TagCloud mount time when the container may still be hidden
// behind a loading state. // behind a loading state.
@@ -337,9 +668,18 @@ function ClusterCloud({
); );
} }
// A flat, manageable row for a single tag — rename (which doubles as merge when type TagManageSort = "count_desc" | "count_asc" | "az" | "za";
// the new name already exists) and delete across the whole library.
function TagManageRow({ const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [
{ value: "count_desc", label: "Most used" },
{ value: "count_asc", label: "Least used" },
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
];
// Compact management tile for a single tag. Rename doubles as merge when the new
// name already exists, and delete applies across the scoped tag set.
function TagManageTile({
entry, entry,
onSearch, onSearch,
onRename, onRename,
@@ -379,12 +719,17 @@ function TagManageRow({
}; };
return ( return (
<div className="group flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-white/[0.04]"> <div
<div className="min-w-0 flex-1"> data-tag-manager-tile
className={`tag-manager-tile group relative min-w-0 rounded-lg border border-white/[0.06] bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:border-white/[0.14] focus-within:bg-white/[0.045] hover:border-white/[0.12] hover:bg-white/[0.04] ${
editing || confirming ? "min-h-[82px]" : "min-h-[46px]"
}`}
>
<div className="flex min-w-0 items-start gap-2">
{editing ? ( {editing ? (
<input <input
ref={inputRef} ref={inputRef}
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/40" className="tag-manager-edit-input min-w-0 flex-1 rounded-md border border-blue-400/35 bg-black/25 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/30"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -394,29 +739,31 @@ function TagManageRow({
disabled={busy} disabled={busy}
/> />
) : ( ) : (
<Tooltip label="Search this tag" delay={500} anchorToCursor className="min-w-0 flex-1">
<button <button
className="truncate text-left text-sm text-white/85 transition-colors hover:text-white" className="tag-manager-name block w-full truncate pr-36 text-left text-sm font-medium text-white/85 transition-colors hover:text-white"
onClick={() => onSearch(entry.tag)} onClick={() => onSearch(entry.tag)}
title="Search this tag"
> >
{entry.tag} {entry.tag}
</button> </button>
</Tooltip>
)} )}
<span className="tag-manager-count absolute right-2.5 top-2 shrink-0 rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38">
{entry.count.toLocaleString()}
</span>
</div> </div>
<span className="shrink-0 text-xs tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
{editing ? ( {editing ? (
<div className="flex shrink-0 items-center gap-1"> <div className="mt-2 flex items-center gap-1">
<button <button
className="rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50" className="tag-manager-save rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
onClick={() => void commitRename()} onClick={() => void commitRename()}
disabled={busy || !value.trim()} disabled={busy || !value.trim()}
title="Rename (merges into the target if it already exists)"
> >
Save Save
</button> </button>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80" className="tag-manager-secondary rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setEditing(false)} onClick={() => setEditing(false)}
disabled={busy} disabled={busy}
> >
@@ -424,16 +771,16 @@ function TagManageRow({
</button> </button>
</div> </div>
) : confirming ? ( ) : confirming ? (
<div className="flex shrink-0 items-center gap-1"> <div className="mt-2 flex items-center gap-1">
<button <button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50" className="tag-manager-danger rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }} onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }}
disabled={busy} disabled={busy}
> >
Delete Delete
</button> </button>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80" className="tag-manager-secondary rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setConfirming(false)} onClick={() => setConfirming(false)}
disabled={busy} disabled={busy}
> >
@@ -441,16 +788,17 @@ function TagManageRow({
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"> <div className="pointer-events-none absolute right-12 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
<Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80" className="tag-manager-action rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
title="Rename or merge into another tag"
> >
Rename Rename
</button> </button>
</Tooltip>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80" className="tag-manager-action tag-manager-action-danger rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
> >
Delete Delete
@@ -472,15 +820,136 @@ function TagManageList({
onRename: (from: string, to: string) => Promise<void>; onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>; onDelete: (tag: string) => Promise<void>;
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [sort, setSort] = useState<TagManageSort>("count_desc");
const [columns, setColumns] = useState(3);
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const update = () => {
const width = el.getBoundingClientRect().width;
setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const filteredEntries = useMemo(() => {
const needle = query.trim().toLowerCase();
const filtered = needle
? entries.filter((entry) => entry.tag.toLowerCase().includes(needle))
: entries;
return [...filtered].sort((left, right) => {
switch (sort) {
case "count_asc":
return left.count - right.count || left.tag.localeCompare(right.tag);
case "az":
return left.tag.localeCompare(right.tag);
case "za":
return right.tag.localeCompare(left.tag);
case "count_desc":
default:
return right.count - left.count || left.tag.localeCompare(right.tag);
}
});
}, [entries, query, sort]);
const rowCount = Math.ceil(filteredEntries.length / columns);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => 54,
overscan: 7,
});
const visibleItems = rowVirtualizer.getVirtualItems();
const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]);
return ( return (
<div className="mx-auto w-full max-w-2xl overflow-y-auto px-6 py-6"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<p className="mb-3 px-3 text-[11px] leading-relaxed text-white/30"> <div className="tag-manager-header shrink-0 border-b border-white/[0.05] bg-black/[0.08] px-6 py-4">
Rename a tag to clean it up, or rename it to an existing tag's name to merge them. Delete <div className="flex flex-wrap items-end justify-between gap-3">
removes a tag from every image. These changes apply across your whole library. <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/32">
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">{entries.length.toLocaleString()} tags</span>
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">{totalUses.toLocaleString()} uses</span>
{query.trim() ? (
<span className="tag-manager-match rounded-full bg-blue-500/10 px-2 py-1 text-blue-200/70 tabular-nums">
{filteredEntries.length.toLocaleString()} matches
</span>
) : null}
</div>
<p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28">
Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.
</p> </p>
<div className="divide-y divide-white/[0.05]"> </div>
{entries.map((entry) => (
<TagManageRow <div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
<input
className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 outline-none transition-colors placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter tags"
/>
{query ? (
<span className="absolute right-2 top-2 z-10">
<Tooltip label="Clear filter" delay={400} anchorToCursor>
<button
className="tag-manager-clear inline-flex h-5 w-5 items-center justify-center rounded-md text-white/35 transition-colors hover:bg-white/8 hover:text-white/75 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/70"
onClick={() => setQuery("")}
aria-label="Clear tag filter"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</Tooltip>
</span>
) : null}
</div>
<ThemedDropdown
value={sort}
onChange={(value) => setSort(value as TagManageSort)}
options={TAG_MANAGE_SORTS}
ariaLabel="Sort managed tags"
align="right"
/>
</div>
</div>
</div>
<div ref={scrollRef} data-tag-manager-scroll className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div ref={measureRef} className="mx-auto w-full max-w-7xl">
{filteredEntries.length === 0 ? (
<div className="tag-manager-empty flex h-48 items-center justify-center rounded-lg border border-white/[0.06] bg-white/[0.02] text-sm text-white/30">
No tags match that filter.
</div>
) : (
<div
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{visibleItems.map((virtualRow) => {
const start = virtualRow.index * columns;
const rowEntries = filteredEntries.slice(start, start + columns);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute left-0 top-0 grid w-full gap-x-2 pb-2"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowEntries.map((entry) => (
<TagManageTile
key={entry.tag} key={entry.tag}
entry={entry} entry={entry}
onSearch={onSearch} onSearch={onSearch}
@@ -489,6 +958,12 @@ function TagManageList({
/> />
))} ))}
</div> </div>
);
})}
</div>
)}
</div>
</div>
</div> </div>
); );
} }
@@ -502,6 +977,7 @@ export function TagCloud() {
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries); const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading); const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags);
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
const searchForTag = useGalleryStore((state) => state.searchForTag); const searchForTag = useGalleryStore((state) => state.searchForTag);
const renameTag = useGalleryStore((state) => state.renameTag); const renameTag = useGalleryStore((state) => state.renameTag);
@@ -515,17 +991,10 @@ export function TagCloud() {
else void loadExploreTags(); else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]); }, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]);
const { logMin, logRange } = useMemo(() => {
if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 };
const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 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 loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading;
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0; const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);
return ( return (
<div className="explore-view 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]"> <div className="explore-view 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]">
@@ -541,6 +1010,10 @@ export function TagCloud() {
: hasEntries : hasEntries
? exploreMode === "visual" ? exploreMode === "visual"
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
: manageTags
? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage`
: visibleTagCount < entryCount
? `${visibleTagCount} of ${entryCount} tags shown — click any to search`
: `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
: exploreMode === "visual" : exploreMode === "visual"
? "No clusters — images need embeddings first" ? "No clusters — images need embeddings first"
@@ -583,11 +1056,8 @@ export function TagCloud() {
</div> </div>
</div> </div>
{loading ? ( {loading && !hasEntries ? (
<div className="explore-empty flex flex-1 items-center justify-center gap-3 text-white/25"> <ExploreLoadingPanel mode={exploreMode} />
<Spinner />
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
</div>
) : !hasEntries ? ( ) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8"> <div className="flex flex-1 items-center justify-center px-8">
<p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25"> <p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
@@ -597,30 +1067,20 @@ export function TagCloud() {
</p> </p>
</div> </div>
) : exploreMode === "visual" ? ( ) : exploreMode === "visual" ? (
<div className="relative flex min-h-0 flex-1 flex-col">
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} /> <ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
</div>
) : manageTags ? ( ) : manageTags ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<TagManageList <TagManageList
entries={exploreTagEntries} entries={exploreTagEntries}
onSearch={searchForTag} onSearch={searchForTag}
onRename={renameTag} onRename={renameTag}
onDelete={handleDeleteTag} onDelete={handleDeleteTag}
/> />
</div>
) : ( ) : (
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */ <TagAtlas entries={exploreTagEntries} onSearch={searchForTag} loadRelatedTags={loadRelatedTags} />
<div className="overflow-y-auto px-8 py-8">
<div className="flex flex-wrap items-center justify-center gap-x-0.5 gap-y-1 leading-none">
{exploreTagEntries.map((entry, index) => (
<TagWord
key={entry.tag}
entry={entry}
index={index}
logMin={logMin}
logRange={logRange}
onSearch={searchForTag}
/>
))}
</div>
</div>
)} )}
</div> </div>
); );
+5 -4
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ContextMenu, ImageTile } from "./Gallery"; import { ContextMenu, ImageTile } from "./Gallery";
import { Tooltip } from "./Tooltip";
const GAP = 6; const GAP = 6;
const HEADER_HEIGHT = 52; const HEADER_HEIGHT = 52;
@@ -374,16 +375,16 @@ function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: Scrubber
return ( return (
<div className="w-full flex flex-col items-center"> <div className="w-full flex flex-col items-center">
<Tooltip label={yearEntry.year} anchorToCursor>
<button <button
className={`w-full text-center py-0.5 text-[10px] font-semibold tracking-wide transition-colors ${ className={`w-full text-center py-0.5 text-[10px] font-semibold tracking-wide transition-colors ${
isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55" isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55"
}`} }`}
onClick={() => onScrollTo(yearEntry.firstGroupIndex)} onClick={() => onScrollTo(yearEntry.firstGroupIndex)}
title={yearEntry.year}
> >
{yearEntry.year} {yearEntry.year}
</button> </button>
</Tooltip>
<div <div
className="grid gap-[3px] pb-1.5" className="grid gap-[3px] pb-1.5"
style={{ gridTemplateColumns: "repeat(3, 10px)" }} style={{ gridTemplateColumns: "repeat(3, 10px)" }}
@@ -396,14 +397,14 @@ function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: Scrubber
return <span key={monthNum} className="h-[10px] w-[10px]" />; return <span key={monthNum} className="h-[10px] w-[10px]" />;
} }
return ( return (
<Tooltip key={monthNum} label={`${monthEntry.label} ${yearEntry.year}`} anchorToCursor>
<button <button
key={monthNum}
title={`${monthEntry.label} ${yearEntry.year}`}
onClick={() => onScrollTo(monthEntry.groupIndex)} onClick={() => onScrollTo(monthEntry.groupIndex)}
className={`h-[10px] w-[10px] rounded-full transition-colors ${ className={`h-[10px] w-[10px] rounded-full transition-colors ${
isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40" isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40"
}`} }`}
/> />
</Tooltip>
); );
})} })}
</div> </div>
+11 -10
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter"; import { ColorFilter } from "./ColorFilter";
import { Tooltip } from "./Tooltip";
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" },
@@ -230,7 +231,7 @@ export function Toolbar() {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", { const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 }, params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
}); });
setTagSuggestions(results); setTagSuggestions(Array.isArray(results) ? results : []);
} catch { } catch {
setTagSuggestions([]); setTagSuggestions([]);
} }
@@ -266,7 +267,7 @@ export function Toolbar() {
const showCommandHints = !searchCommand && 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-40 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
{/* Primary row */} {/* Primary row */}
<div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5"> <div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
{/* Title + count */} {/* Title + count */}
@@ -357,7 +358,7 @@ export function Toolbar() {
{/* Tag autocomplete suggestions */} {/* Tag autocomplete suggestions */}
{showTagSuggestions && tagSuggestions.length > 0 ? ( {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"> <div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
{tagSuggestions.map((entry) => ( {tagSuggestions.map((entry) => (
<button <button
key={entry.tag} key={entry.tag}
@@ -381,25 +382,25 @@ export function Toolbar() {
{/* Tag mode with no suggestions yet — show a brief hint */} {/* Tag mode with no suggestions yet — show a brief hint */}
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? ( {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"> <div className="absolute left-0 top-full z-50 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> <p className="text-xs text-white/25">No matching tags</p>
</div> </div>
) : null} ) : null}
{/* Semantic mode hint */} {/* Semantic mode hint */}
{searchCommand === "semantic" && searchPanelOpen ? ( {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"> <div className="absolute left-0 top-full z-50 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> <p className="text-xs text-white/40">Search by meaning and visual concepts</p>
</div> </div>
) : null} ) : null}
{/* Command hints — only shown when no command is active */} {/* Command hints — only shown when no command is active */}
{showCommandHints ? ( {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"> <div className="absolute left-0 top-full z-50 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: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search user and AI tags" },
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" }, { command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning, object, mood" },
] as const ] as const
).map((option) => ( ).map((option) => (
<button <button
@@ -434,8 +435,8 @@ export function Toolbar() {
{/* Zoom */} {/* Zoom */}
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40"> <div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40">
{(["compact", "comfortable", "detail"] as const).map((preset, i) => ( {(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<Tooltip key={preset} label={`${tileSize}px tiles`} followCursor>
<button <button
key={preset}
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${ className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
i > 0 ? "border-l border-white/8" : "" i > 0 ? "border-l border-white/8" : ""
} ${ } ${
@@ -443,11 +444,11 @@ export function Toolbar() {
? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white" ? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" : "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
title={`${tileSize}px tiles`}
onClick={() => setZoomPreset(preset)} onClick={() => setZoomPreset(preset)}
> >
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"} {preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
</button> </button>
</Tooltip>
))} ))}
</div> </div>
</div> </div>
+33 -22
View File
@@ -1,4 +1,5 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react"; import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
type TooltipSide = "top" | "bottom" | "left" | "right"; type TooltipSide = "top" | "bottom" | "left" | "right";
@@ -20,8 +21,8 @@ const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
/** /**
* Lightweight custom tooltip — fades in (faster than the native one) after a * Lightweight custom tooltip — fades in (faster than the native one) after a
* tunable hover `delay`, and hides instantly on leave or click. By default it * tunable hover `delay`, and hides instantly on leave or click. By default it
* anchors to a `side` of the wrapper; with `followCursor` it tracks the pointer * anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's
* (fixed-positioned, so it escapes scroll-container clipping). * entry position; `followCursor` keeps tracking the pointer.
*/ */
export function Tooltip({ export function Tooltip({
label, label,
@@ -29,6 +30,7 @@ export function Tooltip({
side = "bottom", side = "bottom",
align = "center", align = "center",
block = false, block = false,
anchorToCursor = false,
followCursor = false, followCursor = false,
className = "", className = "",
children, children,
@@ -41,6 +43,8 @@ export function Tooltip({
align?: TooltipAlign; align?: TooltipAlign;
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */ /** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean; block?: boolean;
/** Position at the cursor when shown, without following subsequent movement. */
anchorToCursor?: boolean;
/** Track the cursor (fixed position) instead of anchoring to a side. */ /** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean; followCursor?: boolean;
/** Extra classes for the wrapper (e.g. layout). */ /** Extra classes for the wrapper (e.g. layout). */
@@ -49,6 +53,7 @@ export function Tooltip({
}) { }) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 }); const [coords, setCoords] = useState({ x: 0, y: 0 });
const [mounted, setMounted] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined); const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null); const tooltipRef = useRef<HTMLSpanElement>(null);
@@ -79,7 +84,7 @@ export function Tooltip({
}; };
const show = (event: MouseEvent<HTMLElement>) => { const show = (event: MouseEvent<HTMLElement>) => {
clear(); clear();
if (followCursor) place(event.clientX, event.clientY); if (anchorToCursor || followCursor) place(event.clientX, event.clientY);
if (delay <= 0) { if (delay <= 0) {
setVisible(true); setVisible(true);
return; return;
@@ -100,9 +105,31 @@ export function Tooltip({
}); });
}; };
useEffect(() => clear, []); useEffect(() => {
setMounted(true);
return clear;
}, []);
const Wrapper = block ? "div" : "span"; const Wrapper = block ? "div" : "span";
const cursorTooltip = (
<motion.span
ref={tooltipRef}
role="tooltip"
aria-hidden={!visible}
// Portaled + fixed so transformed parents and scroll containers cannot
// distort cursor-anchored tooltip coordinates.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: followCursor ? { type: "spring", stiffness: 700, damping: 45, mass: 0.35 } : { duration: 0 },
top: followCursor ? { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } : { duration: 0 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
);
return ( return (
<Wrapper <Wrapper
@@ -113,24 +140,8 @@ export function Tooltip({
onPointerDown={hide} onPointerDown={hide}
> >
{children} {children}
{followCursor ? ( {anchorToCursor || followCursor ? (
<motion.span mounted ? createPortal(cursorTooltip, document.body) : null
ref={tooltipRef}
role="tooltip"
aria-hidden={!visible}
// Fixed (so the scroll container's overflow doesn't clip it) and
// positioned by `place()` near the cursor with viewport-edge flipping.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: { type: "spring", stiffness: 700, damping: 45, mass: 0.35 },
top: { type: "spring", stiffness: 520, damping: 34, mass: 0.45 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
) : ( ) : (
<span <span
role="tooltip" role="tooltip"
+34 -3
View File
@@ -1,5 +1,5 @@
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import type { Album, Folder, ImageRecord, ImageTag, SortOrder } from "../store"; import type { Album, ExploreTagEntry, Folder, ImageRecord, ImageTag, SortOrder } from "../store";
import { compareImages, createMockDb, mockExif } from "./mockFixtures"; import { compareImages, createMockDb, mockExif } from "./mockFixtures";
import { getMockScenario } from "./mockScenarios"; import { getMockScenario } from "./mockScenarios";
@@ -58,12 +58,22 @@ function searchTags(payload: unknown) {
.filter(([, tags]) => tags.some((tag) => tag.tag.toLowerCase().includes(query))) .filter(([, tags]) => tags.some((tag) => tag.tag.toLowerCase().includes(query)))
.map(([imageId]) => Number(imageId)), .map(([imageId]) => Number(imageId)),
); );
const images = filterImages(db.images, payload) const images = filterImages(db.images, { params: { ...p, query: "", search: "" } })
.filter((image) => matchingIds.has(image.id)) .filter((image) => matchingIds.has(image.id))
.sort(compareImages((p.sort ?? "date_desc") as SortOrder)); .sort(compareImages((p.sort ?? "date_desc") as SortOrder));
return page(images, Number(p.offset ?? 0), Number(p.limit ?? 200)); return page(images, Number(p.offset ?? 0), Number(p.limit ?? 200));
} }
function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] {
const p = params(payload);
const query = String(p.query ?? "").trim().toLowerCase();
const limit = Number(p.limit ?? 10);
return db.exploreTags
.filter((entry) => !query || entry.tag.toLowerCase().includes(query))
.sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag))
.slice(0, limit);
}
function semanticSearch(payload: unknown): ImageRecord[] { function semanticSearch(payload: unknown): ImageRecord[] {
const p = params(payload); const p = params(payload);
const query = String(p.query ?? "").toLowerCase(); const query = String(p.query ?? "").toLowerCase();
@@ -233,6 +243,8 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
return semanticSearch(payload); return semanticSearch(payload);
case "search_images_by_tag": case "search_images_by_tag":
return searchTags(payload); return searchTags(payload);
case "search_tags_autocomplete":
return searchTagsAutocomplete(payload);
case "get_images_by_ids": case "get_images_by_ids":
return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean); return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean);
case "find_similar_images": case "find_similar_images":
@@ -241,7 +253,21 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
case "get_tag_cloud": case "get_tag_cloud":
return db.scenario === "empty" ? [] : db.tagCloud; return db.scenario === "empty" ? [] : db.tagCloud;
case "get_explore_tags": case "get_explore_tags":
return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 48)); return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180));
case "get_related_tags": {
const relatedCounts = new Map<string, number>();
for (const imageTags of Object.values(db.tagsByImageId)) {
if (!imageTags.some((tag) => tag.tag === p.tag)) continue;
for (const tag of imageTags) {
if (tag.tag === p.tag) continue;
relatedCounts.set(tag.tag, (relatedCounts.get(tag.tag) ?? 0) + 1);
}
}
return Array.from(relatedCounts.entries())
.map(([tag, shared_count]) => ({ tag, shared_count }))
.sort((left, right) => right.shared_count - left.shared_count || left.tag.localeCompare(right.tag))
.slice(0, Number(p.limit ?? 18));
}
case "suggest_image_tags": case "suggest_image_tags":
return ["select", "warm light"]; return ["select", "warm light"];
case "rename_tag": case "rename_tag":
@@ -344,6 +370,9 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
return 12; return 12;
case "get_tagger_model_status": case "get_tagger_model_status":
return { model_id: "SmilingWolf/wd-vit-tagger-v3", model_name: "WD ViT Tagger", local_dir: "mock://models/tagger", ready: true, missing_files: [] }; return { model_id: "SmilingWolf/wd-vit-tagger-v3", model_name: "WD ViT Tagger", local_dir: "mock://models/tagger", ready: true, missing_files: [] };
case "get_tagger_model":
case "set_tagger_model":
return p.model ?? "wd";
case "get_tagger_acceleration": case "get_tagger_acceleration":
case "set_tagger_acceleration": case "set_tagger_acceleration":
return p.acceleration ?? "auto"; return p.acceleration ?? "auto";
@@ -364,9 +393,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
case "set_tagging_queue_folder_ids": case "set_tagging_queue_folder_ids":
case "set_muted_folder_ids": case "set_muted_folder_ids":
case "set_notifications_paused": case "set_notifications_paused":
case "set_worker_pauses_persist":
case "set_worker_paused": case "set_worker_paused":
return null; return null;
case "get_notifications_paused": case "get_notifications_paused":
case "get_worker_pauses_persist":
case "get_onboarding_completed": case "get_onboarding_completed":
return db.scenario !== "empty"; return db.scenario !== "empty";
case "set_onboarding_completed": case "set_onboarding_completed":
+80 -10
View File
@@ -57,6 +57,68 @@ const tags = [
"blue hour", "blue hour",
"select", "select",
]; ];
const hugeTagThemes = [
"alpine",
"archive",
"botanical",
"cinematic",
"coastal",
"editorial",
"industrial",
"interior",
"macro",
"night",
"portrait",
"street",
"travel",
"urban",
"wildlife",
"workspace",
];
const hugeTagSubjects = [
"glass",
"steel",
"water",
"stone",
"mist",
"foliage",
"market",
"signage",
"neon",
"shadow",
"reflection",
"texture",
"silhouette",
"motion",
"symmetry",
"detail",
"warm light",
"blue hour",
"gold accent",
"soft focus",
"hard light",
"negative space",
"foreground",
"background",
"wide angle",
"close crop",
"environment",
"still life",
"natural light",
"available light",
];
const hugeTags = [
...tags,
...Array.from({ length: hugeTagThemes.length * hugeTagSubjects.length }, (_, index) => {
const theme = hugeTagThemes[index % hugeTagThemes.length];
const subject = hugeTagSubjects[Math.floor(index / hugeTagThemes.length) % hugeTagSubjects.length];
return `${theme} ${subject}`;
}),
...Array.from({ length: 320 }, (_, index) => `fixture concept ${String(index + 1).padStart(3, "0")}`),
];
const aiRatings: AiRating[] = ["general", "sensitive", "questionable"]; const aiRatings: AiRating[] = ["general", "sensitive", "questionable"];
function daysAgo(days: number): string { function daysAgo(days: number): string {
@@ -136,17 +198,23 @@ function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
return images; return images;
} }
function makeTags(images: ImageRecord[]): Record<number, ImageTag[]> { function tagVocabularyForScenario(scenario: MockScenario): string[] {
return scenario === "huge" ? hugeTags : tags;
}
function makeTags(images: ImageRecord[], scenario: MockScenario): Record<number, ImageTag[]> {
const vocabulary = tagVocabularyForScenario(scenario);
const tagCount = scenario === "huge" ? 9 : 3;
return Object.fromEntries( return Object.fromEntries(
images.map((image, index) => [ images.map((image, index) => [
image.id, image.id,
[0, 1, 2].map((offset) => ({ Array.from({ length: tagCount }, (_, offset) => ({
id: image.id * 10 + offset, id: image.id * 10 + offset,
image_id: image.id, image_id: image.id,
tag: tags[(index + offset) % tags.length], tag: vocabulary[(index * (offset + 3) + offset * 29) % vocabulary.length],
source: offset === 0 ? "user" : "ai", source: offset === 0 ? "user" : "ai",
ai_model: offset === 0 ? null : "wd-vit-tagger-v3", ai_model: offset === 0 ? null : "wd-vit-tagger-v3",
confidence: offset === 0 ? null : 0.72 + offset * 0.07, confidence: offset === 0 ? null : Math.min(0.99, 0.72 + offset * 0.03),
created_at: daysAgo(index + offset), created_at: daysAgo(index + offset),
})), })),
]), ]),
@@ -197,16 +265,18 @@ function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] {
}); });
} }
function makeExploreTags(images: ImageRecord[]): ExploreTagEntry[] { function makeExploreTags(images: ImageRecord[], scenario: MockScenario): ExploreTagEntry[] {
return tags.map((tag, index) => { const vocabulary = tagVocabularyForScenario(scenario);
return vocabulary.map((tag, index) => {
const representative = images[index % Math.max(images.length, 1)]; const representative = images[index % Math.max(images.length, 1)];
const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3;
return { return {
tag, tag,
count: Math.max(3, Math.round(images.length / (index + 3))), count: Math.max(1, Math.round(images.length / divisor)),
representative_image_id: representative?.id ?? index + 1, representative_image_id: representative?.id ?? index + 1,
thumbnail_path: representative?.thumbnail_path ?? null, thumbnail_path: representative?.thumbnail_path ?? null,
}; };
}); }).sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag));
} }
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] { function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
@@ -234,9 +304,9 @@ export function createMockDb(scenario: MockScenario): MockDb {
images, images,
albums, albums,
albumImageIds, albumImageIds,
tagsByImageId: makeTags(images), tagsByImageId: makeTags(images, scenario),
tagCloud: makeTagCloud(images), tagCloud: makeTagCloud(images),
exploreTags: makeExploreTags(images), exploreTags: makeExploreTags(images, scenario),
backgroundJobs: makeProgress(folders, scenario), backgroundJobs: makeProgress(folders, scenario),
duplicateGroups: makeDuplicateGroups(images, scenario), duplicateGroups: makeDuplicateGroups(images, scenario),
duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null, duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null,
+108 -2
View File
@@ -209,8 +209,13 @@ html[data-theme="subtle-light"] .explore-cluster-count {
color: #ffffff !important; color: #ffffff !important;
} }
html[data-theme="subtle-light"] .explore-tag-word:hover { html[data-theme="subtle-light"] .explore-tag-word::before {
background: #e8e2d6 !important; background: radial-gradient(
ellipse at center,
rgba(60, 50, 30, 0.13),
rgba(60, 50, 30, 0.05) 46%,
rgba(60, 50, 30, 0) 72%
) !important;
} }
html[data-theme="subtle-light"] .explore-spinner { html[data-theme="subtle-light"] .explore-spinner {
@@ -218,6 +223,107 @@ html[data-theme="subtle-light"] .explore-spinner {
border-top-color: rgb(17 24 39 / 0.55) !important; border-top-color: rgb(17 24 39 / 0.55) !important;
} }
html[data-theme="subtle-light"] .tag-manager-header {
background: rgb(244 242 234 / 0.72) !important;
border-color: #d8d2c7 !important;
}
html[data-theme="subtle-light"] .tag-manager-stat {
background: rgb(31 41 55 / 0.06) !important;
color: #5f5a52 !important;
}
html[data-theme="subtle-light"] .tag-manager-match {
background: rgb(37 99 235 / 0.12) !important;
color: #1d4ed8 !important;
}
html[data-theme="subtle-light"] .tag-manager-help,
html[data-theme="subtle-light"] .tag-manager-empty {
color: #6f6a61 !important;
}
html[data-theme="subtle-light"] .tag-manager-filter {
background: #fbfaf6 !important;
border-color: #cfc7b8 !important;
color: #111827 !important;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.64) !important;
}
html[data-theme="subtle-light"] .tag-manager-filter::placeholder {
color: #8a8378 !important;
}
html[data-theme="subtle-light"] .tag-manager-filter:focus {
background: #fffdfa !important;
border-color: #7aa2e3 !important;
}
html[data-theme="subtle-light"] .tag-manager-clear {
color: #6b645a !important;
}
html[data-theme="subtle-light"] .tag-manager-clear:hover {
background: rgb(31 41 55 / 0.08) !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .tag-manager-tile {
background: rgb(251 250 246 / 0.5) !important;
border-color: rgb(151 141 126 / 0.22) !important;
}
html[data-theme="subtle-light"] .tag-manager-tile:hover,
html[data-theme="subtle-light"] .tag-manager-tile:focus-within {
background: rgb(255 253 250 / 0.74) !important;
border-color: rgb(151 141 126 / 0.36) !important;
}
html[data-theme="subtle-light"] .tag-manager-name {
color: #1f2937 !important;
}
html[data-theme="subtle-light"] .tag-manager-name:hover {
color: #111827 !important;
}
html[data-theme="subtle-light"] .tag-manager-count {
background: rgb(31 41 55 / 0.07) !important;
color: #6b645a !important;
}
html[data-theme="subtle-light"] .tag-manager-edit-input {
background: #fffdfa !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .tag-manager-secondary,
html[data-theme="subtle-light"] .tag-manager-action {
background: rgb(255 253 250 / 0.92) !important;
border-color: rgb(31 41 55 / 0.12) !important;
color: #5f5a52 !important;
}
html[data-theme="subtle-light"] .tag-manager-secondary:hover,
html[data-theme="subtle-light"] .tag-manager-action:hover {
background: rgb(31 41 55 / 0.06) !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .tag-manager-save {
color: #1d4ed8 !important;
}
html[data-theme="subtle-light"] .tag-manager-danger,
html[data-theme="subtle-light"] .tag-manager-action-danger:hover {
color: #b91c1c !important;
}
html[data-theme="subtle-light"] .tag-manager-empty {
background: rgb(251 250 246 / 0.42) !important;
border-color: #d8d2c7 !important;
}
html[data-theme="subtle-light"] .feature-scope-trigger { html[data-theme="subtle-light"] .feature-scope-trigger {
background: #f8f6ef !important; background: #f8f6ef !important;
border-color: #d0c8ba !important; border-color: #d0c8ba !important;
+106 -20
View File
@@ -51,6 +51,7 @@ export type SearchCommand = "filename" | "semantic" | "tag";
export type CaptionAcceleration = "auto" | "cpu" | "directml"; export type CaptionAcceleration = "auto" | "cpu" | "directml";
export type CaptionDetail = "short" | "detailed" | "paragraph"; export type CaptionDetail = "short" | "detailed" | "paragraph";
export type TaggerAcceleration = "auto" | "cpu" | "directml"; export type TaggerAcceleration = "auto" | "cpu" | "directml";
export type TaggerModel = "wd" | "joytag";
export type AiRating = "general" | "sensitive" | "questionable" | "explicit"; export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
export type TaggingQueueScope = "all" | "selected"; export type TaggingQueueScope = "all" | "selected";
export type SimilarScope = "all_media" | "current_folder" | "current_album"; export type SimilarScope = "all_media" | "current_folder" | "current_album";
@@ -216,6 +217,11 @@ export interface ExploreTagEntry {
thumbnail_path: string | null; thumbnail_path: string | null;
} }
export interface RelatedTagEntry {
tag: string;
shared_count: number;
}
export interface DuplicateGroup { export interface DuplicateGroup {
file_hash: string; file_hash: string;
file_size: number; file_size: number;
@@ -375,6 +381,7 @@ interface GalleryState {
exploreTagEntries: ExploreTagEntry[]; exploreTagEntries: ExploreTagEntry[];
exploreTagLoading: boolean; exploreTagLoading: boolean;
exploreTagsFolderId: number | null | undefined; exploreTagsFolderId: number | null | undefined;
relatedTagsByKey: Record<string, RelatedTagEntry[]>;
indexingProgress: Record<number, IndexProgress>; indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>; mediaJobProgress: Record<number, FolderJobProgress>;
cacheDir: string; cacheDir: string;
@@ -393,6 +400,7 @@ interface GalleryState {
taggingQueueFolderIds: number[]; taggingQueueFolderIds: number[];
mutedFolderIds: number[]; mutedFolderIds: number[];
notificationsPaused: boolean; notificationsPaused: boolean;
workerPausesPersist: boolean;
theme: AppTheme; theme: AppTheme;
lightboxAutoplay: boolean; lightboxAutoplay: boolean;
lightboxAutoMute: boolean; lightboxAutoMute: boolean;
@@ -424,6 +432,7 @@ interface GalleryState {
taggerModelPreparing: boolean; taggerModelPreparing: boolean;
taggerModelError: string | null; taggerModelError: string | null;
taggerModelProgress: TaggerModelProgress | null; taggerModelProgress: TaggerModelProgress | null;
taggerModel: TaggerModel;
taggerAcceleration: TaggerAcceleration; taggerAcceleration: TaggerAcceleration;
taggerThreshold: number; taggerThreshold: number;
taggerBatchSize: number; taggerBatchSize: number;
@@ -478,8 +487,9 @@ interface GalleryState {
closeImage: () => void; closeImage: () => void;
setView: (view: ActiveView) => void; setView: (view: ActiveView) => void;
setExploreMode: (mode: ExploreMode) => void; setExploreMode: (mode: ExploreMode) => void;
loadTagCloud: () => Promise<void>; loadTagCloud: (options?: { force?: boolean }) => Promise<void>;
loadExploreTags: () => Promise<void>; loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
showVisualCluster: (imageIds: number[]) => Promise<void>; showVisualCluster: (imageIds: number[]) => Promise<void>;
searchForTag: (tag: string) => void; searchForTag: (tag: string) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>; loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
@@ -515,6 +525,8 @@ interface GalleryState {
toggleMutedFolder: (folderId: number) => void; toggleMutedFolder: (folderId: number) => void;
loadNotificationsPaused: () => Promise<void>; loadNotificationsPaused: () => Promise<void>;
setNotificationsPaused: (paused: boolean) => void; setNotificationsPaused: (paused: boolean) => void;
loadWorkerPausesPersist: () => Promise<void>;
setWorkerPausesPersist: (persist: boolean) => void;
setTheme: (theme: AppTheme) => void; setTheme: (theme: AppTheme) => void;
setLightboxAutoplay: (enabled: boolean) => void; setLightboxAutoplay: (enabled: boolean) => void;
setLightboxAutoMute: (enabled: boolean) => void; setLightboxAutoMute: (enabled: boolean) => void;
@@ -551,6 +563,8 @@ interface GalleryState {
deleteTaggerModel: () => Promise<void>; deleteTaggerModel: () => Promise<void>;
loadTaggerAcceleration: () => Promise<void>; loadTaggerAcceleration: () => Promise<void>;
setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise<void>; setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise<void>;
loadTaggerModel: () => Promise<void>;
setTaggerModel: (model: TaggerModel) => Promise<void>;
loadTaggerThreshold: () => Promise<void>; loadTaggerThreshold: () => Promise<void>;
setTaggerThreshold: (threshold: number) => Promise<void>; setTaggerThreshold: (threshold: number) => Promise<void>;
loadTaggerBatchSize: () => Promise<void>; loadTaggerBatchSize: () => Promise<void>;
@@ -611,6 +625,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24;
let galleryRequestToken = 0; let galleryRequestToken = 0;
let tagCloudRequestToken = 0; let tagCloudRequestToken = 0;
let exploreTagRequestToken = 0; let exploreTagRequestToken = 0;
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
function initialAiCaptionsEnabled(): boolean { function initialAiCaptionsEnabled(): boolean {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -858,6 +873,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
exploreTagEntries: [], exploreTagEntries: [],
exploreTagLoading: false, exploreTagLoading: false,
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
relatedTagsByKey: {},
indexingProgress: {}, indexingProgress: {},
mediaJobProgress: {}, mediaJobProgress: {},
cacheDir: "", cacheDir: "",
@@ -876,6 +892,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
taggingQueueFolderIds: [], taggingQueueFolderIds: [],
mutedFolderIds: [], mutedFolderIds: [],
notificationsPaused: false, notificationsPaused: false,
workerPausesPersist: false,
theme: initialTheme(), theme: initialTheme(),
lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true), lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true),
lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false), lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false),
@@ -902,6 +919,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
taggerModelPreparing: false, taggerModelPreparing: false,
taggerModelError: null, taggerModelError: null,
taggerModelProgress: null, taggerModelProgress: null,
taggerModel: "wd",
taggerAcceleration: "auto", taggerAcceleration: "auto",
taggerThreshold: 0.35, taggerThreshold: 0.35,
taggerBatchSize: 8, taggerBatchSize: 8,
@@ -932,12 +950,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId)); const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId));
return { return {
folders, folders,
taggingQueueFolderIds: taggingQueueFolderIds: nextSelected,
nextSelected.length > 0
? nextSelected
: state.taggingQueueScope === "selected" && folders.length > 0
? [folders[0].id]
: nextSelected,
}; };
}); });
}, },
@@ -1383,10 +1396,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setExploreMode: (exploreMode) => set({ exploreMode }), setExploreMode: (exploreMode) => set({ exploreMode }),
loadTagCloud: async () => { loadTagCloud: async (options) => {
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get(); const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
const force = options?.force ?? false;
// Skip if already loaded for this folder and not currently loading // Skip if already loaded for this folder and not currently loading
if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) { if (!force && !tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
return; return;
} }
const requestToken = ++tagCloudRequestToken; const requestToken = ++tagCloudRequestToken;
@@ -1404,19 +1418,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
} }
}, },
loadExploreTags: async () => { loadExploreTags: async (options) => {
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { const force = options?.force ?? false;
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
return; return;
} }
const requestToken = ++exploreTagRequestToken; const requestToken = ++exploreTagRequestToken;
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId }); set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId });
try { try {
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", { const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
params: { folder_id: selectedFolderId, limit: 48 }, params: { folder_id: selectedFolderId, limit: 180 },
}); });
if (requestToken !== exploreTagRequestToken) return; if (requestToken !== exploreTagRequestToken) return;
set({ exploreTagEntries: entries, exploreTagLoading: false }); set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} });
} catch (error) { } catch (error) {
if (requestToken !== exploreTagRequestToken) return; if (requestToken !== exploreTagRequestToken) return;
console.error("Failed to load explore tags:", error); console.error("Failed to load explore tags:", error);
@@ -1818,10 +1833,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setTaggingQueueScope: (taggingQueueScope) => { setTaggingQueueScope: (taggingQueueScope) => {
set((state) => ({ set((state) => ({
taggingQueueScope, taggingQueueScope,
taggingQueueFolderIds: taggingQueueFolderIds: state.taggingQueueFolderIds,
taggingQueueScope === "selected" && state.taggingQueueFolderIds.length === 0 && state.folders.length > 0
? [state.folders[0].id]
: state.taggingQueueFolderIds,
})); }));
void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {}); void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {});
}, },
@@ -1883,6 +1895,42 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void invoke("set_notifications_paused", { paused }).catch(() => {}); void invoke("set_notifications_paused", { paused }).catch(() => {});
}, },
loadRelatedTags: async (tag) => {
const trimmed = tag.trim();
if (!trimmed) return [];
const { selectedFolderId, relatedTagsByKey } = get();
const key = `${selectedFolderId ?? "all"}:${trimmed}`;
if (relatedTagsByKey[key]) {
return relatedTagsByKey[key];
}
const entries = await invoke<RelatedTagEntry[]>("get_related_tags", {
params: { tag: trimmed, folder_id: selectedFolderId, limit: 18 },
});
set((state) => ({
relatedTagsByKey: {
...state.relatedTagsByKey,
[key]: entries,
},
}));
return entries;
},
loadWorkerPausesPersist: async () => {
try {
const persist = await invoke<boolean>("get_worker_pauses_persist");
set({ workerPausesPersist: persist });
} catch {
// fall back to in-memory default
}
},
setWorkerPausesPersist: (persist) => {
set({ workerPausesPersist: persist });
void invoke("set_worker_pauses_persist", { persist }).catch(() => {});
},
setTheme: (theme) => { setTheme: (theme) => {
window.localStorage.setItem(THEME_KEY, theme); window.localStorage.setItem(THEME_KEY, theme);
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
@@ -2158,6 +2206,30 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
set({ taggerAcceleration, taggerRuntimeProbe: null }); set({ taggerAcceleration, taggerRuntimeProbe: null });
}, },
loadTaggerModel: async () => {
try {
const taggerModel = await invoke<TaggerModel>("get_tagger_model");
// Never clobber the valid default with a missing/blank backend response.
if (taggerModel) set({ taggerModel });
} catch (error) {
set({ taggerModelError: String(error) });
}
},
setTaggerModel: async (model) => {
const taggerModel = await invoke<TaggerModel>("set_tagger_model", {
params: { model },
});
// Switching models changes which files are required on disk, so refresh the
// status too — the download/ready UI must reflect the newly-selected model.
try {
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status");
set({ taggerModel, taggerModelStatus, taggerModelError: null, taggerRuntimeProbe: null });
} catch (error) {
set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) });
}
},
loadTaggerThreshold: async () => { loadTaggerThreshold: async () => {
try { try {
const taggerThreshold = await invoke<number>("get_tagger_threshold"); const taggerThreshold = await invoke<number>("get_tagger_threshold");
@@ -2290,9 +2362,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
renameTag: async (from, to) => { renameTag: async (from, to) => {
await invoke("rename_tag", { params: { from, to } }); await invoke("rename_tag", { params: { from, to } });
// Tag content changed — invalidate the explore-tags and tag-cloud caches. // Tag content changed — invalidate the explore-tags and tag-cloud caches.
// Keep the current tag list visible while the refresh runs so manager UI
// state such as filtering and sorting is not lost to a loading remount.
set({ set({
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
exploreTagEntries: [],
tagCloudFolderId: undefined, tagCloudFolderId: undefined,
tagCloudEntries: [], tagCloudEntries: [],
}); });
@@ -2308,9 +2381,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
deleteTag: async (tag) => { deleteTag: async (tag) => {
const removed = await invoke<number>("delete_tag", { params: { tag } }); const removed = await invoke<number>("delete_tag", { params: { tag } });
// Keep the current tag list visible while the refresh runs so manager UI
// state such as filtering and sorting is not lost to a loading remount.
set({ set({
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
exploreTagEntries: [],
tagCloudFolderId: undefined, tagCloudFolderId: undefined,
tagCloudEntries: [], tagCloudEntries: [],
}); });
@@ -2858,6 +2932,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => { const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
const batch = event.payload; const batch = event.payload;
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
if (taggingUpdated) {
set({ exploreTagsFolderId: undefined });
if (get().activeView === "explore") {
if (!exploreTagRefreshTimer) {
exploreTagRefreshTimer = setTimeout(() => {
exploreTagRefreshTimer = null;
void get().loadExploreTags({ force: true });
}, 700);
}
}
}
set((state) => { set((state) => {
const selectedImageUpdate = const selectedImageUpdate =
+18
View File
@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
+1 -1
View File
@@ -4,8 +4,8 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"dev": "vite",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },