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