diff --git a/.gitignore b/.gitignore index cfff0f4..ecec16c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,12 @@ dist-ssr *.pyc # 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/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e36ec2f..0b6380d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - **Build badge in Settings** — the version line in Settings → Updates now shows 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 +- **Tag manager search and sort** — the Manage mode tag list now has a live + filter input and a sort dropdown (most-used / least-used / A–Z / Z–A). 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 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 @@ -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 batches caused periodic slowdowns), and the first batch after launch starts 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 diff --git a/package.json b/package.json index d3d03e3..3697e06 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,11 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2", "@types/d3-force": "^3.0.10", + "@types/node": "^26.0.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6dfc0d9 --- /dev/null +++ b/playwright.config.ts @@ -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, + // }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dd8c70..8762e9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,15 +48,21 @@ importers: specifier: ^5.0.12 version: 5.0.12(@types/react@19.2.14)(react@19.2.4) devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/vite': 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': specifier: ^2 version: 2.10.1 '@types/d3-force': specifier: ^3.0.10 version: 3.0.10 + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 '@types/react': specifier: ^19.1.8 version: 19.2.14 @@ -65,7 +71,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': 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: specifier: ^4.2.2 version: 4.2.2 @@ -74,7 +80,7 @@ importers: version: 5.8.3 vite: 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: dependencies: @@ -96,7 +102,7 @@ importers: devDependencies: '@tailwindcss/vite': 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': specifier: ^19.1.8 version: 19.2.14 @@ -105,7 +111,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': 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: specifier: ^4.2.2 version: 4.2.2 @@ -114,10 +120,10 @@ importers: version: 5.8.3 vite: 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: 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: @@ -538,6 +544,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': 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': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -906,6 +917,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1010,6 +1024,11 @@ packages: react-dom: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1147,6 +1166,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} 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: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -1208,6 +1237,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1597,6 +1629,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@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-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: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 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)': dependencies: @@ -1856,6 +1892,10 @@ snapshots: '@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)': dependencies: '@types/react': 19.2.14 @@ -1864,7 +1904,7 @@ snapshots: dependencies: 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: '@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 '@types/babel__core': 7.20.5 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: - supports-color @@ -1963,6 +2003,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2053,6 +2096,14 @@ snapshots: 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: dependencies: nanoid: 3.3.11 @@ -2151,22 +2202,24 @@ snapshots: typescript@5.8.3: {} + undici-types@8.3.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 escalade: 3.2.0 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: '@rollup/pluginutils': 5.4.0(rollup@4.60.1) imagetools-core: 9.1.0 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: - 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: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -2175,6 +2228,7 @@ snapshots: rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 26.0.1 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 diff --git a/src-tauri/src/ai_tag_filter.rs b/src-tauri/src/ai_tag_filter.rs new file mode 100644 index 0000000..d683235 --- /dev/null +++ b/src-tauri/src/ai_tag_filter.rs @@ -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"); + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b591786..7c0d70d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,7 +8,7 @@ use crate::db::{ use crate::embedder; use crate::hnsw_index; use crate::indexer::{self, WatcherHandle}; -use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; +use crate::tagger::{self, TaggerAcceleration, TaggerModel, TaggerModelStatus, TaggerRuntimeProbe}; use crate::vector; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -180,6 +180,19 @@ pub struct GetExploreTagsParams { pub limit: Option, } +#[derive(Deserialize)] +pub struct GetRelatedTagsParams { + pub tag: String, + pub folder_id: Option, + pub limit: Option, +} + +#[derive(Serialize)] +pub struct RelatedTagEntry { + pub tag: String, + pub shared_count: i64, +} + #[derive(Deserialize)] pub struct SearchTagsAutocompleteParams { pub query: String, @@ -1277,7 +1290,28 @@ pub async fn get_explore_tags( params: GetExploreTagsParams, ) -> Result, 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, 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()) } @@ -1913,6 +1947,7 @@ pub struct FolderWorkerStates { #[tauri::command] pub async fn set_worker_paused( + app: AppHandle, db: State<'_, DbState>, worker: String, folder_id: i64, @@ -1929,6 +1964,9 @@ pub async fn set_worker_paused( db::requeue_processing_tagging_jobs_for_folder(&conn, folder_id) .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(()) } @@ -1970,6 +2008,11 @@ pub struct SetTaggerAccelerationParams { pub acceleration: TaggerAcceleration, } +#[derive(Deserialize)] +pub struct SetTaggerModelParams { + pub model: TaggerModel, +} + #[derive(Deserialize)] pub struct SetTaggerThresholdParams { 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()) } +#[tauri::command] +pub async fn get_tagger_model(app: AppHandle) -> Result { + 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 { + 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] pub async fn probe_tagger_runtime(app: AppHandle) -> Result { 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_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)] pub struct SetTaggingQueueScopeParams { @@ -2499,6 +2559,67 @@ pub async fn set_tagging_queue_folder_ids( 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::(&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 { + 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 // --------------------------------------------------------------------------- diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f6b2bd6..6e51895 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1,4 +1,4 @@ -use crate::vector; +use crate::{ai_tag_filter, vector}; use anyhow::Result; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; @@ -378,6 +378,42 @@ pub fn migrate(conn: &Connection) -> Result<()> { )?; 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::>>()?; + 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(()) } @@ -2400,6 +2436,34 @@ pub fn get_explore_tags( Ok(rows) } +pub fn get_related_tags( + conn: &Connection, + tag: &str, + folder_id: Option, + limit: usize, +) -> Result> { + 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::>>()?; + + Ok(rows) +} + type FailedEmbeddingRow = (i64, String, String, Option); pub fn get_failed_embedding_images( diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index ba09c05..8e3f1be 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -3,13 +3,13 @@ use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, Inde use crate::embedder::{embedding_source_path, ClipImageEmbedder}; use crate::media::{probe_video_metadata, MediaTools}; use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile}; -use crate::tagger::{self, WdTagger}; +use crate::tagger::{self, Tagger}; use crate::thumbnail; use crate::vector; use anyhow::Result; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use rayon::prelude::*; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; @@ -41,6 +41,14 @@ struct PausedWorkerFolders { tagging: HashSet, } +#[derive(Default, Deserialize, Serialize)] +pub struct PersistedPausedWorkerFolders { + pub thumbnail: Vec, + pub metadata: Vec, + pub embedding: Vec, + pub tagging: Vec, +} + #[derive(Clone, Copy)] pub struct FolderWorkerPausedState { pub thumbnail: bool, @@ -50,6 +58,41 @@ pub struct FolderWorkerPausedState { 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| { + let mut ids = set.iter().copied().collect::>(); + 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) { if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS .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) { std::thread::spawn(move || { - let mut tagger_instance: Option = None; + let mut tagger_instance: Option> = None; log::info!("Tagging worker started."); loop { // If the acceleration setting changed, drop the cached session so @@ -1236,7 +1279,7 @@ fn process_tagging_batch( app: &AppHandle, pool: &DbPool, app_data_dir: &Path, - tagger_instance: &mut Option, + tagger_instance: &mut Option>, ) -> Result { if !tagger::tagger_model_status(app_data_dir).ready { return Ok(false); @@ -1264,7 +1307,7 @@ fn process_tagging_batch( } 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), Err(error) => { with_db_write_lock(|| { @@ -1323,6 +1366,10 @@ fn process_tagging_batch( } 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::>(); let write_started_at = Instant::now(); @@ -1355,7 +1402,7 @@ fn process_tagging_batch( job.image_id, &tag_pairs, &output.rating, - tagger::WD_TAGGER_MODEL_NAME, + tagger_model_name, )?; } Err(error) => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c5fc8d..6c51442 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod ai_tag_filter; mod captioner; mod color; mod commands; @@ -118,6 +119,7 @@ pub fn run() { let thumb_dir = app_dir.join("thumbnails"); 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 // are allowed statically in tauri.conf.json, and each indexed @@ -194,14 +196,19 @@ pub fn run() { commands::suggest_image_tags, commands::set_worker_paused, commands::get_worker_states, + commands::get_worker_pauses_persist, + commands::set_worker_pauses_persist, commands::get_tag_cloud, commands::get_explore_tags, + commands::get_related_tags, commands::get_images_by_ids, commands::get_failed_embedding_images, commands::get_failed_tagging_images, commands::get_tagger_model_status, commands::get_tagger_acceleration, commands::set_tagger_acceleration, + commands::get_tagger_model, + commands::set_tagger_model, commands::probe_tagger_runtime, commands::get_tagger_threshold, commands::set_tagger_threshold, diff --git a/src-tauri/src/tagger.rs b/src-tauri/src/tagger.rs index 82518c7..2b9616e 100644 --- a/src-tauri/src/tagger.rs +++ b/src-tauri/src/tagger.rs @@ -1,3 +1,4 @@ +use crate::ai_tag_filter; use anyhow::Result; use hf_hub::{api::sync::Api, Repo, RepoType}; 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_THRESHOLD_FILE: &str = "settings/tagger_threshold.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 -// are shared with the captioner and live in the same `onnxruntime/` directory. -const TAGGER_REQUIRED_FILES: &[&str] = &[ +pub const JOYTAG_MODEL_ID: &str = "fancyfeast/joytag"; +pub const JOYTAG_MODEL_NAME: &str = "joytag"; + +// 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_providers_shared.dll", "onnxruntime/DirectML.dll", - "model.onnx", - "selected_tags.csv", ]; // 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 // --------------------------------------------------------------------------- @@ -152,14 +220,41 @@ struct TagEntry { // 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 { - 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 // --------------------------------------------------------------------------- +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 { + 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 { let path = app_data_dir.join(TAGGER_ACCELERATION_FILE); 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 TaggerModelStatus { + let model = tagger_model(app_data_dir); let local_dir = model_dir(app_data_dir); // The ONNX runtime DLLs live in the caption model dir; reuse them. let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft"); - let missing_files = TAGGER_REQUIRED_FILES + + let mut missing_files: Vec = TAGGER_RUNTIME_DLLS .iter() - .filter(|file| { - let path = if file.starts_with("onnxruntime/") { - caption_model_dir.join(file) - } else { - local_dir.join(file) - }; - !path.exists() - }) + .filter(|file| !caption_model_dir.join(file).exists()) .map(|file| (*file).to_string()) - .collect::>(); + .collect(); + missing_files.extend( + model + .download_files() + .iter() + .filter(|file| !local_dir.join(file).exists()) + .map(|file| (*file).to_string()), + ); TaggerModelStatus { - model_id: WD_TAGGER_MODEL_ID, - model_name: WD_TAGGER_MODEL_NAME, + model_id: model.repo_id(), + model_name: model.model_name(), local_dir: local_dir.to_string_lossy().to_string(), ready: missing_files.is_empty(), missing_files, @@ -260,19 +357,20 @@ pub fn prepare_tagger_model_with_progress( app_data_dir: &Path, emit_progress: impl Fn(TaggerModelProgress), ) -> Result { + let model = tagger_model(app_data_dir); let local_dir = model_dir(app_data_dir); std::fs::create_dir_all(&local_dir)?; // 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 // 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); std::fs::create_dir_all(&caption_model_dir)?; // 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 model_pending = DOWNLOAD_FILES + let model_pending = download_files .iter() .filter(|file| !local_dir.join(file).exists()) .count(); @@ -318,9 +416,9 @@ pub fn prepare_tagger_model_with_progress( // (timeout + resume), rather than hf-hub's download_with_progress, whose // agent has no read timeout and would hang on a stalled connection. 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); if destination.exists() { continue; @@ -424,11 +522,96 @@ pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result { } // --------------------------------------------------------------------------- -// 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>; + + /// 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> { + 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>>, + stride: usize, + model_label: &str, + mut infer: impl FnMut(&[f32], usize) -> Result>, +) -> Vec> { + let mut batch_slots: Vec = Vec::new(); + let mut batch_pixels: Vec = 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> = 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 { @@ -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> { - 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>> = 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 = Vec::new(); - let mut batch_pixels: Vec = 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> = 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]` /// pixels) through the model in one forward pass. fn infer_batch( @@ -632,14 +736,6 @@ impl WdTagger { .collect()) } - /// Run a single preprocessed image through the model. - fn infer_one(&mut self, image_array: Vec, max_tags: usize) -> Result { - 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. /// Associated (not `&self`) so callers can hold a disjoint session borrow. fn tags_from_probs( @@ -664,6 +760,7 @@ impl WdTagger { .filter(|(entry, prob)| { (entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY) && **prob >= threshold + && !ai_tag_filter::is_removed_ai_tag(&entry.name) }) .map(|(entry, prob)| TagResult { 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> { + if image_paths.is_empty() { + return Vec::new(); + } + let input_size = self.input_size; + let preprocessed: Vec>> = 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, + threshold: f32, + input_size: usize, +} + +impl JoyTagger { + pub fn new(app_data_dir: &Path) -> Result { + 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> { + 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::() + .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> { + if image_paths.is_empty() { + return Vec::new(); + } + let input_size = self.input_size; + let preprocessed: Vec>> = 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` // --------------------------------------------------------------------------- @@ -721,15 +989,13 @@ fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Resul let mut builder = match acceleration { TaggerAcceleration::Cpu => { - log::info!( - "WD tagger: using CPU execution provider ({intra_threads} intra-op threads)" - ); + log::info!("Tagger: using CPU execution provider ({intra_threads} intra-op threads)"); builder } TaggerAcceleration::Auto => builder .with_execution_providers([ep::DirectML::default().build().fail_silently()]) .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() }), TaggerAcceleration::Directml => builder @@ -771,12 +1037,134 @@ fn load_labels(path: &Path) -> Result> { } // --------------------------------------------------------------------------- -// Image preprocessing -// WD tagger expects: (1, H, W, 3) float32, raw [0,255] values, BGR channel -// order, padded to square with white (255,255,255). +// JoyTag: label loading, threshold, rating derivation // --------------------------------------------------------------------------- -fn preprocess_image(image_path: &Path, target_size: usize) -> Result> { +/// 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> { + let content = std::fs::read_to_string(path)?; + let labels: Vec = 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::() + .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 = 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 { let image = ImageReader::open(image_path)?.decode()?; // Composite any alpha channel onto a white background. @@ -787,22 +1175,25 @@ fn preprocess_image(image_path: &Path, target_size: usize) -> Result> { image::imageops::overlay(&mut canvas_rgba, &image_rgba, 0, 0); let image_rgb = DynamicImage::ImageRgba8(canvas_rgba).to_rgb8(); - // Pad to square. + // Pad to a centered square. let max_dim = width.max(height); let pad_left = (max_dim - width) / 2; let pad_top = (max_dim - height) / 2; let mut square = image::RgbImage::from_pixel(max_dim, max_dim, image::Rgb([255, 255, 255])); image::imageops::overlay(&mut square, &image_rgb, pad_left as i64, pad_top as i64); - // Resize to model input size. - let resized = image::imageops::resize( + // Resize to model input size (CatmullRom ≈ PIL BICUBIC). + Ok(image::imageops::resize( &square, target_size as u32, target_size as u32, FilterType::CatmullRom, - ); + )) +} - // Flatten to (H, W, 3) float32 in BGR order, values in [0, 255]. +/// WD tagger input: (N, H, W, 3) float32, raw [0,255] values, BGR order. +fn preprocess_image(image_path: &Path, target_size: usize) -> Result> { + let resized = decode_pad_resize(image_path, target_size)?; let mut pixel_values = vec![0.0f32; target_size * target_size * 3]; for (x, y, pixel) in resized.enumerate_pixels() { let base = (y as usize * target_size + x as usize) * 3; @@ -811,6 +1202,21 @@ fn preprocess_image(image_path: &Path, target_size: usize) -> Result> { pixel_values[base + 1] = f32::from(pixel[1]); // G 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> { + 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) } diff --git a/src/App.tsx b/src/App.tsx index 75de078..9a66c3f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ export default function App() { const loadAlbums = useGalleryStore((state) => state.loadAlbums); const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); + const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const loadAppVersion = useGalleryStore((state) => state.loadAppVersion); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); @@ -39,6 +40,7 @@ export default function App() { void initializeNotifications(); void loadMutedFolderIds(); void loadNotificationsPaused(); + void loadWorkerPausesPersist(); void loadFfmpegStatus(); void loadOnboardingCompleted(); // Load the app version first so the What's New toast/modal (which read diff --git a/src/changelog.ts b/src/changelog.ts index 1e3a112..35a76d9 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -105,7 +105,9 @@ const ENTRIES = parseChangelog(changelogRaw); export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | 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. if (normalized.toLowerCase() === "unreleased") return null; return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null; diff --git a/src/components/ColorFilter.tsx b/src/components/ColorFilter.tsx index f4bcbe9..1cca85b 100644 --- a/src/components/ColorFilter.tsx +++ b/src/components/ColorFilter.tsx @@ -56,7 +56,7 @@ export function ColorFilter() { }, [open]); return ( -
+
{/* Trigger — a single palette icon; shows the active color as a dot when a filter is applied so the collapsed state still communicates it. */} @@ -99,9 +99,9 @@ export function ColorFilter() { {SWATCHES.map((swatch) => { const active = rgbEquals(colorFilter, swatch.rgb); return ( +
{isActive || (colorBackfill && colorBackfill.total > 0) ? ( -
+
{colorBackfill && colorBackfill.total > 0 ? ( = { + 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 ( + + ); +} + function TaggerAccelerationButton({ acceleration, current, onSelect, children }: { acceleration: TaggerAcceleration; current: TaggerAcceleration; @@ -129,6 +167,8 @@ export function SettingsModal() { const [taggerClearing, setTaggerClearing] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerAccelerationError, setTaggerAccelerationError] = useState(null); + const [taggerModelSwitching, setTaggerModelSwitching] = useState(false); + const [taggerModelSwitchError, setTaggerModelSwitchError] = useState(null); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState(null); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); const [taggerThresholdError, setTaggerThresholdError] = useState(null); @@ -173,6 +213,9 @@ export function SettingsModal() { const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel); const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration); 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 setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold); const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize); @@ -185,6 +228,8 @@ export function SettingsModal() { const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); const notificationsPaused = useGalleryStore((state) => state.notificationsPaused); 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 vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex); @@ -210,6 +255,7 @@ export function SettingsModal() { useEffect(() => { if (!settingsOpen) return; void loadTaggerModelStatus(); + void loadTaggerModel(); void loadTaggerAcceleration(); void loadTaggerThreshold(); void loadTaggerBatchSize(); @@ -221,7 +267,7 @@ export function SettingsModal() { }; window.addEventListener("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(() => { if (!settingsOpen || activeSection !== "general") return; @@ -365,10 +411,39 @@ export function SettingsModal() { {activeSection === "workspace" ? (
+ +
+
+ {(["wd", "joytag"] as const).map((model) => ( + { + if (nextModel === taggerModel) return; + setTaggerModelSwitching(true); + setTaggerModelSwitchError(null); + void setTaggerModel(nextModel) + .catch((error: unknown) => setTaggerModelSwitchError(String(error))) + .finally(() => setTaggerModelSwitching(false)); + }} + > + {TAGGER_MODELS[model].tab} + + ))} +
+ {taggerModelSwitchError ? ( +

{taggerModelSwitchError}

+ ) : ( +

{taggerModelSwitching ? "Switching..." : `Current: ${TAGGER_MODELS[taggerModel].name}`}

+ )} +
+
+ - WD SwinV2 Tagger v3{" "} + {TAGGER_MODELS[taggerModel].name}{" "} {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"} @@ -376,7 +451,7 @@ export function SettingsModal() { } - description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds." + description={TAGGER_MODELS[taggerModel].description} >
{taggerReady ? ( @@ -469,7 +544,7 @@ export function SettingsModal() { {taggerThresholdError ? (

{taggerThresholdError}

) : ( -

{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}

+

{taggerThresholdSaving ? "Saving..." : `Default: ${taggerModel === "joytag" ? "0.4" : "0.35"}`}

)}
@@ -781,6 +856,19 @@ export function SettingsModal() { + + +
diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index fc586d3..c38026a 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -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 { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; +import { ThemedDropdown } from "./ThemedDropdown"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; @@ -224,59 +226,361 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag ); } -// Actual tag cloud — word size driven by log-scaled frequency -function TagWord({ - entry, - index, - logMin, - logRange, - onSearch, -}: { +interface AtlasNode { entry: ExploreTagEntry; index: number; - logMin: number; - logRange: number; + x: 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; + loadRelatedTags: (tag: string) => Promise; }) { const theme = useGalleryStore((state) => state.theme); const isLight = theme === "subtle-light"; - const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5; - const fontSize = 11 + ratio * 28; // 11px – 39px - const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length]; - const tilt = (seeded(index + 5) - 0.5) * 7; - // Faint low-frequency words read fine as subtle white-on-dark, but the same low - // opacity is unreadable on the light theme's cream, so raise the floor there. - const minOpacity = isLight ? 0.6 : 0.4; + const reducedMotion = useReducedMotion(); + const canvasRef = useRef(null); + const buttonRefs = useRef(new Map()); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + const [activeTag, setActiveTag] = useState(null); + const [relatedTags, setRelatedTags] = useState([]); + const [anchors, setAnchors] = useState>({}); + 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 = {}; + 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 ( - - onSearch(entry.tag)} - > - 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }} - > - {entry.tag} - - - {entry.count.toLocaleString()} - - - +
+ + {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 ( + + + + ); + })} +
); } @@ -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 ( +
+
+
+ + {title} +
+

{subtitle}

+
+ +
+
+
+ ); +} + // Separate component so its useLayoutEffect fires when the canvas is actually // mounted — not at TagCloud mount time when the container may still be hidden // behind a loading state. @@ -337,9 +668,18 @@ function ClusterCloud({ ); } -// A flat, manageable row for a single tag — rename (which doubles as merge when -// the new name already exists) and delete across the whole library. -function TagManageRow({ +type TagManageSort = "count_desc" | "count_asc" | "az" | "za"; + +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, onSearch, onRename, @@ -379,12 +719,17 @@ function TagManageRow({ }; return ( -
-
+
+
{editing ? ( setValue(e.target.value)} onKeyDown={(e) => { @@ -394,29 +739,31 @@ function TagManageRow({ disabled={busy} /> ) : ( - + + + )} + + {entry.count.toLocaleString()} +
- {entry.count.toLocaleString()} {editing ? ( -
+
) : confirming ? ( -
+
) : ( -
+
+ + + - + + + ) : null} +
+ setSort(value as TagManageSort)} + options={TAG_MANAGE_SORTS} + ariaLabel="Sort managed tags" + align="right" + /> +
+
+
+ +
+
+ {filteredEntries.length === 0 ? ( +
+ No tags match that filter. +
+ ) : ( +
+ {visibleItems.map((virtualRow) => { + const start = virtualRow.index * columns; + const rowEntries = filteredEntries.slice(start, start + columns); + return ( +
+ {rowEntries.map((entry) => ( + + ))} +
+ ); + })} +
+ )} +
); @@ -502,6 +977,7 @@ export function TagCloud() { const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries); const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading); const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); + const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags); const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const searchForTag = useGalleryStore((state) => state.searchForTag); const renameTag = useGalleryStore((state) => state.renameTag); @@ -515,17 +991,10 @@ export function TagCloud() { else void 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 hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0; const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; + const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE); return (
@@ -541,7 +1010,11 @@ export function TagCloud() { : hasEntries ? exploreMode === "visual" ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` - : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` + : 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` : exploreMode === "visual" ? "No clusters — images need embeddings first" : "No tags — run the AI tagger or add tags manually"} @@ -583,11 +1056,8 @@ export function TagCloud() {
- {loading ? ( -
- - {exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"} -
+ {loading && !hasEntries ? ( + ) : !hasEntries ? (

@@ -597,30 +1067,20 @@ export function TagCloud() {

) : exploreMode === "visual" ? ( - - ) : manageTags ? ( - - ) : ( - /* Tag cloud — words sized by log-scaled frequency, wrapped freely */ -
-
- {exploreTagEntries.map((entry, index) => ( - - ))} -
+
+
+ ) : manageTags ? ( +
+ +
+ ) : ( + )}
); diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index a2de2eb..b150751 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useVirtualizer } from "@tanstack/react-virtual"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; import { ContextMenu, ImageTile } from "./Gallery"; +import { Tooltip } from "./Tooltip"; const GAP = 6; const HEADER_HEIGHT = 52; @@ -374,16 +375,16 @@ function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: Scrubber return (
- - + + +
; } return ( -
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f2595fa..90ef087 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { ColorFilter } from "./ColorFilter"; +import { Tooltip } from "./Tooltip"; const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, @@ -230,7 +231,7 @@ export function Toolbar() { const results = await invoke("search_tags_autocomplete", { params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 }, }); - setTagSuggestions(results); + setTagSuggestions(Array.isArray(results) ? results : []); } catch { setTagSuggestions([]); } @@ -266,7 +267,7 @@ export function Toolbar() { const showCommandHints = !searchCommand && searchPanelOpen; return ( -
+
{/* Primary row */}
{/* Title + count */} @@ -357,7 +358,7 @@ export function Toolbar() { {/* Tag autocomplete suggestions */} {showTagSuggestions && tagSuggestions.length > 0 ? ( -
+
{tagSuggestions.map((entry) => ( + + + ))}
diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index c345c4a..6aee866 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -1,4 +1,5 @@ import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { motion } from "framer-motion"; 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 * 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 - * (fixed-positioned, so it escapes scroll-container clipping). + * anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's + * entry position; `followCursor` keeps tracking the pointer. */ export function Tooltip({ label, @@ -29,6 +30,7 @@ export function Tooltip({ side = "bottom", align = "center", block = false, + anchorToCursor = false, followCursor = false, className = "", children, @@ -41,6 +43,8 @@ export function Tooltip({ align?: TooltipAlign; /** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */ 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. */ followCursor?: boolean; /** Extra classes for the wrapper (e.g. layout). */ @@ -49,6 +53,7 @@ export function Tooltip({ }) { const [visible, setVisible] = useState(false); const [coords, setCoords] = useState({ x: 0, y: 0 }); + const [mounted, setMounted] = useState(false); const timer = useRef | undefined>(undefined); const frame = useRef(undefined); const tooltipRef = useRef(null); @@ -79,7 +84,7 @@ export function Tooltip({ }; const show = (event: MouseEvent) => { clear(); - if (followCursor) place(event.clientX, event.clientY); + if (anchorToCursor || followCursor) place(event.clientX, event.clientY); if (delay <= 0) { setVisible(true); return; @@ -100,9 +105,31 @@ export function Tooltip({ }); }; - useEffect(() => clear, []); + useEffect(() => { + setMounted(true); + return clear; + }, []); const Wrapper = block ? "div" : "span"; + const cursorTooltip = ( + + {label} + + ); return ( {children} - {followCursor ? ( - - {label} - + {anchorToCursor || followCursor ? ( + mounted ? createPortal(cursorTooltip, document.body) : null ) : ( tags.some((tag) => tag.tag.toLowerCase().includes(query))) .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)) .sort(compareImages((p.sort ?? "date_desc") as SortOrder)); 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[] { const p = params(payload); const query = String(p.query ?? "").toLowerCase(); @@ -233,6 +243,8 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise return semanticSearch(payload); case "search_images_by_tag": return searchTags(payload); + case "search_tags_autocomplete": + return searchTagsAutocomplete(payload); case "get_images_by_ids": return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean); case "find_similar_images": @@ -241,7 +253,21 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case "get_tag_cloud": return db.scenario === "empty" ? [] : db.tagCloud; 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(); + 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": return ["select", "warm light"]; case "rename_tag": @@ -344,6 +370,9 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise return 12; 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: [] }; + case "get_tagger_model": + case "set_tagger_model": + return p.model ?? "wd"; case "get_tagger_acceleration": case "set_tagger_acceleration": 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_muted_folder_ids": case "set_notifications_paused": + case "set_worker_pauses_persist": case "set_worker_paused": return null; case "get_notifications_paused": + case "get_worker_pauses_persist": case "get_onboarding_completed": return db.scenario !== "empty"; case "set_onboarding_completed": diff --git a/src/dev/mockFixtures.ts b/src/dev/mockFixtures.ts index fcb94c1..8cd5e8e 100644 --- a/src/dev/mockFixtures.ts +++ b/src/dev/mockFixtures.ts @@ -57,6 +57,68 @@ const tags = [ "blue hour", "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"]; function daysAgo(days: number): string { @@ -136,17 +198,23 @@ function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] { return images; } -function makeTags(images: ImageRecord[]): Record { +function tagVocabularyForScenario(scenario: MockScenario): string[] { + return scenario === "huge" ? hugeTags : tags; +} + +function makeTags(images: ImageRecord[], scenario: MockScenario): Record { + const vocabulary = tagVocabularyForScenario(scenario); + const tagCount = scenario === "huge" ? 9 : 3; return Object.fromEntries( images.map((image, index) => [ image.id, - [0, 1, 2].map((offset) => ({ + Array.from({ length: tagCount }, (_, offset) => ({ id: image.id * 10 + offset, image_id: image.id, - tag: tags[(index + offset) % tags.length], + tag: vocabulary[(index * (offset + 3) + offset * 29) % vocabulary.length], source: offset === 0 ? "user" : "ai", 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), })), ]), @@ -197,16 +265,18 @@ function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] { }); } -function makeExploreTags(images: ImageRecord[]): ExploreTagEntry[] { - return tags.map((tag, index) => { +function makeExploreTags(images: ImageRecord[], scenario: MockScenario): ExploreTagEntry[] { + const vocabulary = tagVocabularyForScenario(scenario); + return vocabulary.map((tag, index) => { const representative = images[index % Math.max(images.length, 1)]; + const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3; return { 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, 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[] { @@ -234,9 +304,9 @@ export function createMockDb(scenario: MockScenario): MockDb { images, albums, albumImageIds, - tagsByImageId: makeTags(images), + tagsByImageId: makeTags(images, scenario), tagCloud: makeTagCloud(images), - exploreTags: makeExploreTags(images), + exploreTags: makeExploreTags(images, scenario), backgroundJobs: makeProgress(folders, scenario), duplicateGroups: makeDuplicateGroups(images, scenario), duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null, diff --git a/src/index.css b/src/index.css index e573b50..c353603 100644 --- a/src/index.css +++ b/src/index.css @@ -209,8 +209,13 @@ html[data-theme="subtle-light"] .explore-cluster-count { color: #ffffff !important; } -html[data-theme="subtle-light"] .explore-tag-word:hover { - background: #e8e2d6 !important; +html[data-theme="subtle-light"] .explore-tag-word::before { + 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 { @@ -218,6 +223,107 @@ html[data-theme="subtle-light"] .explore-spinner { 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 { background: #f8f6ef !important; border-color: #d0c8ba !important; diff --git a/src/store.ts b/src/store.ts index 2270547..70b3e49 100644 --- a/src/store.ts +++ b/src/store.ts @@ -51,6 +51,7 @@ export type SearchCommand = "filename" | "semantic" | "tag"; export type CaptionAcceleration = "auto" | "cpu" | "directml"; export type CaptionDetail = "short" | "detailed" | "paragraph"; export type TaggerAcceleration = "auto" | "cpu" | "directml"; +export type TaggerModel = "wd" | "joytag"; export type AiRating = "general" | "sensitive" | "questionable" | "explicit"; export type TaggingQueueScope = "all" | "selected"; export type SimilarScope = "all_media" | "current_folder" | "current_album"; @@ -216,6 +217,11 @@ export interface ExploreTagEntry { thumbnail_path: string | null; } +export interface RelatedTagEntry { + tag: string; + shared_count: number; +} + export interface DuplicateGroup { file_hash: string; file_size: number; @@ -375,6 +381,7 @@ interface GalleryState { exploreTagEntries: ExploreTagEntry[]; exploreTagLoading: boolean; exploreTagsFolderId: number | null | undefined; + relatedTagsByKey: Record; indexingProgress: Record; mediaJobProgress: Record; cacheDir: string; @@ -393,6 +400,7 @@ interface GalleryState { taggingQueueFolderIds: number[]; mutedFolderIds: number[]; notificationsPaused: boolean; + workerPausesPersist: boolean; theme: AppTheme; lightboxAutoplay: boolean; lightboxAutoMute: boolean; @@ -424,6 +432,7 @@ interface GalleryState { taggerModelPreparing: boolean; taggerModelError: string | null; taggerModelProgress: TaggerModelProgress | null; + taggerModel: TaggerModel; taggerAcceleration: TaggerAcceleration; taggerThreshold: number; taggerBatchSize: number; @@ -478,8 +487,9 @@ interface GalleryState { closeImage: () => void; setView: (view: ActiveView) => void; setExploreMode: (mode: ExploreMode) => void; - loadTagCloud: () => Promise; - loadExploreTags: () => Promise; + loadTagCloud: (options?: { force?: boolean }) => Promise; + loadExploreTags: (options?: { force?: boolean }) => Promise; + loadRelatedTags: (tag: string) => Promise; showVisualCluster: (imageIds: number[]) => Promise; searchForTag: (tag: string) => void; loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise; @@ -515,6 +525,8 @@ interface GalleryState { toggleMutedFolder: (folderId: number) => void; loadNotificationsPaused: () => Promise; setNotificationsPaused: (paused: boolean) => void; + loadWorkerPausesPersist: () => Promise; + setWorkerPausesPersist: (persist: boolean) => void; setTheme: (theme: AppTheme) => void; setLightboxAutoplay: (enabled: boolean) => void; setLightboxAutoMute: (enabled: boolean) => void; @@ -551,6 +563,8 @@ interface GalleryState { deleteTaggerModel: () => Promise; loadTaggerAcceleration: () => Promise; setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise; + loadTaggerModel: () => Promise; + setTaggerModel: (model: TaggerModel) => Promise; loadTaggerThreshold: () => Promise; setTaggerThreshold: (threshold: number) => Promise; loadTaggerBatchSize: () => Promise; @@ -611,6 +625,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24; let galleryRequestToken = 0; let tagCloudRequestToken = 0; let exploreTagRequestToken = 0; +let exploreTagRefreshTimer: ReturnType | null = null; function initialAiCaptionsEnabled(): boolean { if (typeof window === "undefined") return false; @@ -858,6 +873,7 @@ export const useGalleryStore = create((set, get) => ({ exploreTagEntries: [], exploreTagLoading: false, exploreTagsFolderId: undefined, + relatedTagsByKey: {}, indexingProgress: {}, mediaJobProgress: {}, cacheDir: "", @@ -876,6 +892,7 @@ export const useGalleryStore = create((set, get) => ({ taggingQueueFolderIds: [], mutedFolderIds: [], notificationsPaused: false, + workerPausesPersist: false, theme: initialTheme(), lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true), lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false), @@ -902,6 +919,7 @@ export const useGalleryStore = create((set, get) => ({ taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null, + taggerModel: "wd", taggerAcceleration: "auto", taggerThreshold: 0.35, taggerBatchSize: 8, @@ -932,12 +950,7 @@ export const useGalleryStore = create((set, get) => ({ const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId)); return { folders, - taggingQueueFolderIds: - nextSelected.length > 0 - ? nextSelected - : state.taggingQueueScope === "selected" && folders.length > 0 - ? [folders[0].id] - : nextSelected, + taggingQueueFolderIds: nextSelected, }; }); }, @@ -1383,10 +1396,11 @@ export const useGalleryStore = create((set, get) => ({ setExploreMode: (exploreMode) => set({ exploreMode }), - loadTagCloud: async () => { + loadTagCloud: async (options) => { const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get(); + const force = options?.force ?? false; // 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; } const requestToken = ++tagCloudRequestToken; @@ -1404,19 +1418,20 @@ export const useGalleryStore = create((set, get) => ({ } }, - loadExploreTags: async () => { + loadExploreTags: async (options) => { const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); - if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { + const force = options?.force ?? false; + if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { return; } const requestToken = ++exploreTagRequestToken; set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId }); try { const entries = await invoke("get_explore_tags", { - params: { folder_id: selectedFolderId, limit: 48 }, + params: { folder_id: selectedFolderId, limit: 180 }, }); if (requestToken !== exploreTagRequestToken) return; - set({ exploreTagEntries: entries, exploreTagLoading: false }); + set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} }); } catch (error) { if (requestToken !== exploreTagRequestToken) return; console.error("Failed to load explore tags:", error); @@ -1818,10 +1833,7 @@ export const useGalleryStore = create((set, get) => ({ setTaggingQueueScope: (taggingQueueScope) => { set((state) => ({ taggingQueueScope, - taggingQueueFolderIds: - taggingQueueScope === "selected" && state.taggingQueueFolderIds.length === 0 && state.folders.length > 0 - ? [state.folders[0].id] - : state.taggingQueueFolderIds, + taggingQueueFolderIds: state.taggingQueueFolderIds, })); void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {}); }, @@ -1883,6 +1895,42 @@ export const useGalleryStore = create((set, get) => ({ 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("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("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) => { window.localStorage.setItem(THEME_KEY, theme); document.documentElement.dataset.theme = theme; @@ -2158,6 +2206,30 @@ export const useGalleryStore = create((set, get) => ({ set({ taggerAcceleration, taggerRuntimeProbe: null }); }, + loadTaggerModel: async () => { + try { + const taggerModel = await invoke("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("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("get_tagger_model_status"); + set({ taggerModel, taggerModelStatus, taggerModelError: null, taggerRuntimeProbe: null }); + } catch (error) { + set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) }); + } + }, + loadTaggerThreshold: async () => { try { const taggerThreshold = await invoke("get_tagger_threshold"); @@ -2290,9 +2362,10 @@ export const useGalleryStore = create((set, get) => ({ renameTag: async (from, to) => { await invoke("rename_tag", { params: { from, to } }); // 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({ exploreTagsFolderId: undefined, - exploreTagEntries: [], tagCloudFolderId: undefined, tagCloudEntries: [], }); @@ -2308,9 +2381,10 @@ export const useGalleryStore = create((set, get) => ({ deleteTag: async (tag) => { const removed = await invoke("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({ exploreTagsFolderId: undefined, - exploreTagEntries: [], tagCloudFolderId: undefined, tagCloudEntries: [], }); @@ -2858,6 +2932,18 @@ export const useGalleryStore = create((set, get) => ({ const unlistenThumbnails = await listen("media-updated", (event) => { 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) => { const selectedImageUpdate = diff --git a/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 0000000..54a906a --- /dev/null +++ b/tests/example.spec.ts @@ -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(); +}); diff --git a/website/package.json b/website/package.json index 9d05845..9bc7cf1 100644 --- a/website/package.json +++ b/website/package.json @@ -4,8 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", "build": "tsc && vite build", + "dev": "vite", "preview": "vite preview", "typecheck": "tsc --noEmit" },