Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ab9357d6f | |||
| fe65bc6f38 | |||
| 68932b55c5 | |||
| f1116c6c26 | |||
| b92b850d02 | |||
| bf04df7484 | |||
| d29a779c13 | |||
| b7e82dbf91 |
@@ -63,6 +63,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
so the info panel shows more at a glance with less scrolling.
|
||||
- **Faster Explore revisits** — returning to a folder's visual clusters should
|
||||
feel much faster now, even in big libraries.
|
||||
- **Calmer Tag Cloud during AI tagging** — Explore no longer keeps hammering the
|
||||
tag list while a folder is actively being tagged, so tagging stays smoother and
|
||||
the cloud catches up once the work settles.
|
||||
- **Faster first-time clustering** — large libraries build their first visual
|
||||
clusters much more quickly, while still keeping the groups nicely balanced.
|
||||
- **Better tag browsing** — the Tag manager now has live search, sorting
|
||||
|
||||
@@ -2159,6 +2159,7 @@ pub struct SetTaggerModelParams {
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetTaggerThresholdParams {
|
||||
pub threshold: f32,
|
||||
pub model: Option<TaggerModel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -2258,7 +2259,11 @@ pub async fn set_tagger_threshold(
|
||||
params: SetTaggerThresholdParams,
|
||||
) -> Result<f32, String> {
|
||||
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
tagger::set_tagger_threshold(&app_dir, params.threshold).map_err(|e| e.to_string())
|
||||
match params.model {
|
||||
Some(model) => tagger::set_tagger_threshold_for_model(&app_dir, model, params.threshold),
|
||||
None => tagger::set_tagger_threshold(&app_dir, params.threshold),
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+27
-16
@@ -303,6 +303,7 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_image_id ON image_tags(tag, image_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -2446,32 +2447,42 @@ pub fn get_explore_tags(
|
||||
limit: usize,
|
||||
) -> Result<Vec<ExploreTagEntry>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id,
|
||||
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
|
||||
MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
|
||||
FROM image_tags t
|
||||
JOIN images i ON i.id = t.image_id
|
||||
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
||||
GROUP BY t.tag
|
||||
HAVING COUNT(DISTINCT t.image_id) >= 1
|
||||
ORDER BY tag_count DESC, t.tag ASC
|
||||
LIMIT ?2",
|
||||
"WITH tag_counts AS (
|
||||
SELECT t.tag,
|
||||
COUNT(DISTINCT t.image_id) AS tag_count,
|
||||
MIN(t.image_id) AS representative_image_id,
|
||||
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
|
||||
MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
|
||||
FROM image_tags t
|
||||
JOIN images i ON i.id = t.image_id
|
||||
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
||||
GROUP BY t.tag
|
||||
HAVING COUNT(DISTINCT t.image_id) >= 1
|
||||
ORDER BY tag_count DESC, t.tag ASC
|
||||
LIMIT ?2
|
||||
)
|
||||
SELECT c.tag,
|
||||
c.tag_count,
|
||||
c.representative_image_id,
|
||||
i.thumbnail_path,
|
||||
c.has_ai_source,
|
||||
c.has_user_source
|
||||
FROM tag_counts c
|
||||
JOIN images i ON i.id = c.representative_image_id
|
||||
ORDER BY c.tag_count DESC, c.tag ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![folder_id, limit as i64], |row| {
|
||||
let representative_image_id = row.get::<_, i64>(2)?;
|
||||
let thumbnail_path = get_image_by_id(conn, representative_image_id)
|
||||
.ok()
|
||||
.and_then(|image| image.thumbnail_path);
|
||||
|
||||
Ok(ExploreTagEntry {
|
||||
tag: row.get(0)?,
|
||||
count: row.get(1)?,
|
||||
representative_image_id,
|
||||
thumbnail_path,
|
||||
has_ai_source: row.get::<_, i64>(3)? != 0,
|
||||
has_user_source: row.get::<_, i64>(4)? != 0,
|
||||
thumbnail_path: row.get(3)?,
|
||||
has_ai_source: row.get::<_, i64>(4)? != 0,
|
||||
has_user_source: row.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
+40
-20
@@ -16,6 +16,7 @@ pub const WD_TAGGER_MODEL_NAME: &str = "wd-swinv2-tagger-v3";
|
||||
|
||||
const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt";
|
||||
const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt";
|
||||
const JOYTAG_THRESHOLD_FILE: &str = "settings/joytag_threshold.txt";
|
||||
const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt";
|
||||
const TAGGER_MODEL_FILE: &str = "settings/tagger_model.txt";
|
||||
|
||||
@@ -27,8 +28,7 @@ pub const JOYTAG_MODEL_NAME: &str = "joytag";
|
||||
// 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_6, 0.275_777_1];
|
||||
// JoyTag's recommended detection threshold (used as the default; the user's
|
||||
// tagger_threshold setting still overrides it).
|
||||
// JoyTag's recommended detection threshold.
|
||||
const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4;
|
||||
|
||||
// Shared ONNX Runtime DLLs (live in the caption model's `onnxruntime/` dir and
|
||||
@@ -64,8 +64,8 @@ pub const TAGGER_INFER_CHUNK: usize = 4;
|
||||
/// claim the GPU/CPU between dispatches. Negligible against per-chunk inference.
|
||||
pub const TAGGER_INFER_YIELD_MS: u64 = 40;
|
||||
|
||||
/// Set to `true` by `set_tagger_acceleration` so the tagging worker loop
|
||||
/// knows to drop its cached `WdTagger` and reload with the new EP.
|
||||
/// Set to `true` by tagger setting changes so the tagging worker loop knows to
|
||||
/// drop its cached model session and reload with the current settings.
|
||||
pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -112,6 +112,20 @@ impl TaggerModel {
|
||||
}
|
||||
}
|
||||
|
||||
fn threshold_file(self) -> &'static str {
|
||||
match self {
|
||||
Self::Wd => TAGGER_THRESHOLD_FILE,
|
||||
Self::JoyTag => JOYTAG_THRESHOLD_FILE,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_threshold(self) -> f32 {
|
||||
match self {
|
||||
Self::Wd => DEFAULT_THRESHOLD,
|
||||
Self::JoyTag => JOYTAG_DEFAULT_THRESHOLD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hugging Face repo the model files are fetched from.
|
||||
fn repo_id(self) -> &'static str {
|
||||
match self {
|
||||
@@ -280,21 +294,33 @@ pub fn set_tagger_acceleration(
|
||||
Ok(acceleration)
|
||||
}
|
||||
|
||||
pub fn tagger_threshold(app_data_dir: &Path) -> f32 {
|
||||
let path = app_data_dir.join(TAGGER_THRESHOLD_FILE);
|
||||
fn tagger_threshold_for_model(app_data_dir: &Path, model: TaggerModel) -> f32 {
|
||||
let path = app_data_dir.join(model.threshold_file());
|
||||
let Ok(value) = std::fs::read_to_string(path) else {
|
||||
return DEFAULT_THRESHOLD;
|
||||
return model.default_threshold();
|
||||
};
|
||||
value
|
||||
.trim()
|
||||
.parse::<f32>()
|
||||
.unwrap_or(DEFAULT_THRESHOLD)
|
||||
.unwrap_or_else(|_| model.default_threshold())
|
||||
.clamp(0.01, 1.0)
|
||||
}
|
||||
|
||||
pub fn tagger_threshold(app_data_dir: &Path) -> f32 {
|
||||
tagger_threshold_for_model(app_data_dir, tagger_model(app_data_dir))
|
||||
}
|
||||
|
||||
pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result<f32> {
|
||||
set_tagger_threshold_for_model(app_data_dir, tagger_model(app_data_dir), threshold)
|
||||
}
|
||||
|
||||
pub fn set_tagger_threshold_for_model(
|
||||
app_data_dir: &Path,
|
||||
model: TaggerModel,
|
||||
threshold: f32,
|
||||
) -> Result<f32> {
|
||||
let clamped = threshold.clamp(0.01, 1.0);
|
||||
let path = app_data_dir.join(TAGGER_THRESHOLD_FILE);
|
||||
let path = app_data_dir.join(model.threshold_file());
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -466,7 +492,8 @@ pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result<TaggerRuntimeProbe> {
|
||||
let status = tagger_model_status(app_data_dir);
|
||||
if !status.ready {
|
||||
anyhow::bail!(
|
||||
"WD Tagger model is missing {} required file(s): {}",
|
||||
"{} is missing {} required file(s): {}",
|
||||
status.model_name,
|
||||
status.missing_files.len(),
|
||||
status.missing_files.join(", ")
|
||||
);
|
||||
@@ -1056,17 +1083,10 @@ fn load_joytag_labels(path: &Path) -> Result<Vec<String>> {
|
||||
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).
|
||||
/// JoyTag detection threshold. Uses JoyTag's own setting so WD tuning does not
|
||||
/// leak into the general/photo-friendly model.
|
||||
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,
|
||||
}
|
||||
tagger_threshold_for_model(app_data_dir, TaggerModel::JoyTag)
|
||||
}
|
||||
|
||||
fn sigmoid(x: f32) -> f32 {
|
||||
|
||||
@@ -23,6 +23,8 @@ export default function App() {
|
||||
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
|
||||
const loadImages = useGalleryStore((state) => state.loadImages);
|
||||
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
|
||||
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
|
||||
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel);
|
||||
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
|
||||
const loadAlbums = useGalleryStore((state) => state.loadAlbums);
|
||||
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
|
||||
@@ -53,6 +55,8 @@ export default function App() {
|
||||
loadFolders().then(async () => {
|
||||
void loadBackgroundJobProgress();
|
||||
void loadCaptionModelStatus();
|
||||
void loadTaggerModel();
|
||||
void loadTaggerModelStatus();
|
||||
void loadDuplicateScanCache();
|
||||
await loadAlbums();
|
||||
await loadImages(true);
|
||||
|
||||
@@ -156,6 +156,7 @@ export function Lightbox() {
|
||||
const addUserTag = useGalleryStore((state) => state.addUserTag);
|
||||
const removeTag = useGalleryStore((state) => state.removeTag);
|
||||
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
|
||||
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
|
||||
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
|
||||
const albums = useGalleryStore((state) => state.albums);
|
||||
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
|
||||
@@ -254,6 +255,13 @@ export function Lightbox() {
|
||||
const canStartSlideshow = slideshowImages.length > 0;
|
||||
const canFindSimilar = selectedImage?.embedding_status === "ready";
|
||||
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
|
||||
const taggerReady = taggerModelStatus?.ready ?? false;
|
||||
const taggerStatusKnown = taggerModelStatus !== null;
|
||||
const taggerButtonTooltip = !taggerStatusKnown
|
||||
? "Checking AI tagger model..."
|
||||
: taggerReady
|
||||
? "Queue AI tagging for this image"
|
||||
: "AI tagger model not installed";
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (currentIndex > 0) openImage(images[currentIndex - 1]);
|
||||
@@ -421,6 +429,11 @@ export function Lightbox() {
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedImage?.id, selectedImage?.media_kind, getImageExif]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedImage?.media_kind !== "image" || taggerStatusKnown) return;
|
||||
void loadTaggerModelStatus();
|
||||
}, [loadTaggerModelStatus, selectedImage?.media_kind, taggerStatusKnown]);
|
||||
|
||||
// Reset the queued state once the worker finishes so the button is usable again
|
||||
useEffect(() => {
|
||||
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
|
||||
@@ -1156,10 +1169,10 @@ export function Lightbox() {
|
||||
</span>
|
||||
) : null}
|
||||
{selectedImage.media_kind === "image" ? (
|
||||
<Tooltip label={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"} followCursor>
|
||||
<Tooltip label={taggerButtonTooltip} followCursor>
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!(taggerModelStatus?.ready ?? false) || taggingQueued}
|
||||
disabled={!taggerReady || taggingQueued}
|
||||
onClick={() => {
|
||||
setTaggingQueued(true);
|
||||
void queueTaggingForImage(selectedImage.id).catch(() => undefined);
|
||||
|
||||
@@ -4,11 +4,15 @@ import { FfmpegStatusRow } from "./onboarding/StepWelcome";
|
||||
import { ThemedDropdown } from "./ThemedDropdown";
|
||||
import { getChangelogForVersion } from "../changelog";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { TAGGER_MODELS } from "../taggerModels";
|
||||
|
||||
type SettingsSection = "workspace" | "general";
|
||||
type SettingsSection = "general" | "media" | "updates" | "storage" | "workspace";
|
||||
|
||||
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
||||
{ id: "general", label: "General", detail: "App data and diagnostics" },
|
||||
{ id: "general", label: "General", detail: "Theme and notifications" },
|
||||
{ id: "media", label: "Media", detail: "Playback and slideshow" },
|
||||
{ id: "updates", label: "Updates & Setup", detail: "Versions, setup, and tour" },
|
||||
{ id: "storage", label: "Storage", detail: "App data and maintenance" },
|
||||
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
|
||||
];
|
||||
|
||||
@@ -101,22 +105,6 @@ 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;
|
||||
@@ -282,7 +270,7 @@ export function SettingsModal() {
|
||||
}, [settingsOpen, loadTaggerModelStatus, loadTaggerModel, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsOpen || activeSection !== "general") return;
|
||||
if (!settingsOpen || activeSection !== "storage") return;
|
||||
setVacuumResult(null);
|
||||
setThumbnailCleanupResult(null);
|
||||
void getDatabaseInfo().then(setDbInfo).catch(() => {});
|
||||
@@ -304,6 +292,7 @@ export function SettingsModal() {
|
||||
|
||||
if (!settingsOpen) return null;
|
||||
|
||||
const activeSectionMeta = SECTIONS.find((section) => section.id === activeSection) ?? SECTIONS[0];
|
||||
const taggerReady = taggerModelStatus?.ready ?? false;
|
||||
const queueScopeLabel =
|
||||
taggingQueueScope === "all"
|
||||
@@ -322,7 +311,7 @@ export function SettingsModal() {
|
||||
: taggerModelProgress
|
||||
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
|
||||
: taggerModelPreparing
|
||||
? "Preparing WD Tagger..."
|
||||
? "Preparing AI tagger..."
|
||||
: taggerReady
|
||||
? "Installed"
|
||||
: "Install model";
|
||||
@@ -445,21 +434,23 @@ export function SettingsModal() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Tooltip label="Close settings" anchorToCursor className="absolute right-4 top-4 z-10">
|
||||
<button
|
||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="absolute right-4 top-4 z-10">
|
||||
<Tooltip label="Close settings" anchorToCursor>
|
||||
<button
|
||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="px-10 py-8">
|
||||
<h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3>
|
||||
<p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
|
||||
<h3 className="text-lg font-semibold text-white">{activeSectionMeta.label}</h3>
|
||||
<p className="mt-1 text-xs text-gray-600">{activeSectionMeta.detail}</p>
|
||||
|
||||
{activeSection === "workspace" ? (
|
||||
<div className="mt-8 space-y-9">
|
||||
@@ -474,6 +465,9 @@ export function SettingsModal() {
|
||||
current={taggerModel}
|
||||
onSelect={(nextModel) => {
|
||||
if (nextModel === taggerModel) return;
|
||||
setTaggerThresholdDraft(null);
|
||||
setTaggerThresholdError(null);
|
||||
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
|
||||
setTaggerModelSwitching(true);
|
||||
setTaggerModelSwitchError(null);
|
||||
void setTaggerModel(nextModel)
|
||||
@@ -506,34 +500,45 @@ export function SettingsModal() {
|
||||
}
|
||||
description={TAGGER_MODELS[taggerModel].description}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{taggerReady ? (
|
||||
<>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{taggerReady ? (
|
||||
<>
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||
onClick={() => void probeTaggerRuntime()}
|
||||
disabled={taggerRuntimeChecking}
|
||||
>
|
||||
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
|
||||
onClick={() => void deleteTaggerModel()}
|
||||
disabled={taggerModelPreparing}
|
||||
>
|
||||
Delete model files
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||
onClick={() => void probeTaggerRuntime()}
|
||||
disabled={taggerRuntimeChecking}
|
||||
>
|
||||
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
|
||||
onClick={() => void deleteTaggerModel()}
|
||||
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||
onClick={() => void prepareTaggerModel()}
|
||||
disabled={taggerModelPreparing}
|
||||
>
|
||||
Delete model files
|
||||
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
|
||||
<span className="relative">{taggerDownloadLabel}</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||
onClick={() => void prepareTaggerModel()}
|
||||
disabled={taggerModelPreparing}
|
||||
>
|
||||
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
|
||||
<span className="relative">{taggerDownloadLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{taggerRuntimeProbe ? (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-400">
|
||||
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
|
||||
{" "}<span className="ml-2 text-gray-600">· acceleration: {taggerRuntimeProbe.acceleration}</span>
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
@@ -575,12 +580,12 @@ export function SettingsModal() {
|
||||
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
|
||||
value={thresholdDisplay}
|
||||
onChange={(event) => setTaggerThresholdDraft(event.target.value)}
|
||||
onBlur={() => {
|
||||
const value = parseFloat(thresholdDisplay);
|
||||
onBlur={(event) => {
|
||||
const value = parseFloat(event.currentTarget.value);
|
||||
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
|
||||
setTaggerThresholdError(null);
|
||||
setTaggerThresholdSaving(true);
|
||||
void setTaggerThreshold(value)
|
||||
void setTaggerThreshold(value, taggerModel)
|
||||
.catch((error: unknown) => setTaggerThresholdError(String(error)))
|
||||
.finally(() => {
|
||||
setTaggerThresholdDraft(null);
|
||||
@@ -597,7 +602,7 @@ export function SettingsModal() {
|
||||
{taggerThresholdError ? (
|
||||
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : `Default: ${taggerModel === "joytag" ? "0.4" : "0.35"}`}</p>
|
||||
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : `Default: ${TAGGER_MODELS[taggerModel].defaultThreshold}`}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
@@ -646,15 +651,6 @@ export function SettingsModal() {
|
||||
</p>
|
||||
{taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
|
||||
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
|
||||
{taggerRuntimeProbe ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
|
||||
<span className="ml-2 text-gray-600">acceleration: {taggerRuntimeProbe.acceleration}</span>
|
||||
</p>
|
||||
<p className="mt-1.5 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
@@ -776,6 +772,8 @@ export function SettingsModal() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 space-y-9">
|
||||
{activeSection === "general" ? (
|
||||
<>
|
||||
<SettingsGroup title="Appearance">
|
||||
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background.">
|
||||
<ThemedDropdown
|
||||
@@ -791,6 +789,39 @@ export function SettingsModal() {
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Notifications">
|
||||
<SettingsItem
|
||||
label="Pause all notifications"
|
||||
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
|
||||
>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={notificationsPaused}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
|
||||
onClick={() => setNotificationsPaused(!notificationsPaused)}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
</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>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeSection === "media" ? (
|
||||
<>
|
||||
<SettingsGroup title="Video playback">
|
||||
<SettingsItem
|
||||
label="Autoplay in lightbox"
|
||||
@@ -869,6 +900,11 @@ export function SettingsModal() {
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeSection === "updates" ? (
|
||||
<>
|
||||
<SettingsGroup title="Updates">
|
||||
<SettingsItem
|
||||
label={
|
||||
@@ -963,7 +999,12 @@ export function SettingsModal() {
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Storage & notifications">
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeSection === "storage" ? (
|
||||
<>
|
||||
<SettingsGroup title="App data">
|
||||
<SettingsItem
|
||||
label="App data folder"
|
||||
description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
|
||||
@@ -979,32 +1020,6 @@ export function SettingsModal() {
|
||||
{openingDataFolder ? "Opening..." : "Open data folder"}
|
||||
</button>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
label="Pause all notifications"
|
||||
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
|
||||
>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={notificationsPaused}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
|
||||
onClick={() => setNotificationsPaused(!notificationsPaused)}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
</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 title="Maintenance">
|
||||
@@ -1161,6 +1176,8 @@ export function SettingsModal() {
|
||||
</button>
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -147,6 +147,7 @@ export function TitleBar() {
|
||||
<Tooltip label="Settings (right-click to switch theme)" anchorToCursor>
|
||||
<button
|
||||
ref={settingsBtnRef}
|
||||
aria-label="Settings"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
onContextMenu={handleSettingsContextMenu}
|
||||
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
|
||||
|
||||
@@ -326,7 +326,7 @@ export function Toolbar() {
|
||||
className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`}
|
||||
/>
|
||||
{searchCommand !== null ? (
|
||||
<div className="absolute left-8 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-8 top-1/2 flex -translate-y-1/2">
|
||||
<Tooltip label="Remove search command" anchorToCursor>
|
||||
<button
|
||||
type="button"
|
||||
@@ -339,7 +339,7 @@ export function Toolbar() {
|
||||
</div>
|
||||
) : null}
|
||||
{searchQuery.trim().length > 0 || searchCommand !== null ? (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2">
|
||||
<Tooltip label="Clear search" anchorToCursor>
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { TaggerModelProgress, useGalleryStore } from "../../store";
|
||||
import { TaggerModel, TaggerModelProgress, useGalleryStore } from "../../store";
|
||||
import { TAGGER_MODELS } from "../../taggerModels";
|
||||
import { FakeProgressBar, formatBytes } from "./fakes";
|
||||
|
||||
const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"];
|
||||
@@ -15,19 +16,47 @@ function taggerDownloadFraction(progress: TaggerModelProgress | null): number |
|
||||
return null;
|
||||
}
|
||||
|
||||
function TaggerModelChoice({ model, current, disabled, onSelect }: {
|
||||
model: TaggerModel;
|
||||
current: TaggerModel;
|
||||
disabled: boolean;
|
||||
onSelect: (model: TaggerModel) => void;
|
||||
}) {
|
||||
const active = model === current;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
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"
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(model)}
|
||||
>
|
||||
{TAGGER_MODELS[model].tab}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepAiFeatures() {
|
||||
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus);
|
||||
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing);
|
||||
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress);
|
||||
const taggerModelError = useGalleryStore((s) => s.taggerModelError);
|
||||
const taggerModel = useGalleryStore((s) => s.taggerModel);
|
||||
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel);
|
||||
const loadTaggerModel = useGalleryStore((s) => s.loadTaggerModel);
|
||||
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus);
|
||||
const setTaggerModel = useGalleryStore((s) => s.setTaggerModel);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTaggerModel();
|
||||
void loadTaggerModelStatus();
|
||||
}, [loadTaggerModelStatus]);
|
||||
}, [loadTaggerModel, loadTaggerModelStatus]);
|
||||
|
||||
const taggerReady = taggerModelStatus?.ready ?? false;
|
||||
const taggerStatusLabel = taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed";
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -43,9 +72,29 @@ export function StepAiFeatures() {
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white">Automatic tags for every image</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-500">
|
||||
The WD tagger model (~1.3 GB download) labels images so you can search with{" "}
|
||||
The AI tagger model labels images so you can search with{" "}
|
||||
<code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> — tags look like:
|
||||
</p>
|
||||
<div className="mt-3 inline-flex max-w-full flex-wrap items-center gap-1 rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
|
||||
{(["wd", "joytag"] as const).map((model) => (
|
||||
<TaggerModelChoice
|
||||
key={model}
|
||||
model={model}
|
||||
current={taggerModel}
|
||||
disabled={taggerModelPreparing}
|
||||
onSelect={(nextModel) => {
|
||||
if (nextModel === taggerModel) return;
|
||||
void setTaggerModel(nextModel);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-gray-600">
|
||||
Current: {TAGGER_MODELS[taggerModel].name} · {taggerStatusLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-500">
|
||||
{TAGGER_MODELS[taggerModel].description}
|
||||
</p>
|
||||
<span className="mt-2 flex flex-wrap gap-1.5">
|
||||
{FAKE_TAGS.map((tag) => (
|
||||
<span key={tag} className="rounded-md border border-white/10 bg-gray-900/50 px-2 py-0.5 text-[11px] text-gray-400 light-theme:border-gray-300/70 light-theme:bg-gray-900 light-theme:text-gray-600">
|
||||
|
||||
+75
-5
@@ -1,5 +1,15 @@
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import type { Album, ExploreTagEntry, Folder, ImageRecord, ImageTag, SortOrder } from "../store";
|
||||
import type {
|
||||
Album,
|
||||
ExploreTagEntry,
|
||||
Folder,
|
||||
ImageRecord,
|
||||
ImageTag,
|
||||
SortOrder,
|
||||
TaggerModel,
|
||||
TaggerModelStatus,
|
||||
TaggerRuntimeProbe,
|
||||
} from "../store";
|
||||
import { compareImages, createMockDb, mockExif } from "./mockFixtures";
|
||||
import { getMockScenario } from "./mockScenarios";
|
||||
|
||||
@@ -7,6 +17,12 @@ const db = createMockDb(getMockScenario());
|
||||
let nextFolderId = 100;
|
||||
let nextAlbumId = 100;
|
||||
let nextTagId = 10_000;
|
||||
let mockTaggerModel: TaggerModel = db.scenario === "joytag-unready" ? "joytag" : "wd";
|
||||
let mockTaggerReady = db.scenario !== "unready" && db.scenario !== "joytag-unready";
|
||||
const mockTaggerThresholds: Record<TaggerModel, number> = {
|
||||
wd: 0.35,
|
||||
joytag: 0.4,
|
||||
};
|
||||
|
||||
type AnyPayload = Record<string, any> | undefined;
|
||||
type Rgb = [number, number, number];
|
||||
@@ -30,6 +46,10 @@ function isRgb(value: unknown): value is Rgb {
|
||||
return Array.isArray(value) && value.length === 3 && value.every((entry) => typeof entry === "number");
|
||||
}
|
||||
|
||||
function isTaggerModel(value: unknown): value is TaggerModel {
|
||||
return value === "wd" || value === "joytag";
|
||||
}
|
||||
|
||||
function colorDistanceSq(left: Rgb, right: Rgb): number {
|
||||
return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 + (left[2] - right[2]) ** 2;
|
||||
}
|
||||
@@ -55,6 +75,42 @@ function page<T>(items: T[], offset = 0, limit = 200) {
|
||||
};
|
||||
}
|
||||
|
||||
function mockTaggerStatus(): TaggerModelStatus {
|
||||
const modelInfo =
|
||||
mockTaggerModel === "joytag"
|
||||
? {
|
||||
model_id: "fancyfeast/joytag",
|
||||
model_name: "JoyTag",
|
||||
local_dir: "mock://models/joytag",
|
||||
label_file: "top_tags.txt",
|
||||
}
|
||||
: {
|
||||
model_id: "SmilingWolf/wd-swinv2-tagger-v3",
|
||||
model_name: "wd-swinv2-tagger-v3",
|
||||
local_dir: "mock://models/wd-swinv2-tagger-v3",
|
||||
label_file: "selected_tags.csv",
|
||||
};
|
||||
|
||||
return {
|
||||
model_id: modelInfo.model_id,
|
||||
model_name: modelInfo.model_name,
|
||||
local_dir: modelInfo.local_dir,
|
||||
ready: mockTaggerReady,
|
||||
missing_files: mockTaggerReady ? [] : ["model.onnx", modelInfo.label_file],
|
||||
};
|
||||
}
|
||||
|
||||
function mockTaggerRuntimeProbe(): TaggerRuntimeProbe {
|
||||
if (!mockTaggerReady) {
|
||||
throw new Error(`${mockTaggerStatus().model_name} is missing required model files`);
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
acceleration: "auto",
|
||||
session: { file: "model.onnx", inputs: ["input"], outputs: ["tags"] },
|
||||
};
|
||||
}
|
||||
|
||||
function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
|
||||
const p = params(payload);
|
||||
const query = String(p.search ?? p.query ?? "").trim().toLowerCase();
|
||||
@@ -463,21 +519,35 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
||||
return reset;
|
||||
}
|
||||
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 mockTaggerStatus();
|
||||
case "get_tagger_model":
|
||||
return mockTaggerModel;
|
||||
case "set_tagger_model":
|
||||
return p.model ?? "wd";
|
||||
mockTaggerModel = p.model ?? "wd";
|
||||
mockTaggerReady = db.scenario !== "unready" && db.scenario !== "joytag-unready";
|
||||
return mockTaggerModel;
|
||||
case "prepare_tagger_model":
|
||||
mockTaggerReady = true;
|
||||
return mockTaggerStatus();
|
||||
case "delete_tagger_model":
|
||||
mockTaggerReady = false;
|
||||
return mockTaggerStatus();
|
||||
case "get_tagger_acceleration":
|
||||
case "set_tagger_acceleration":
|
||||
return p.acceleration ?? "auto";
|
||||
case "get_tagger_threshold":
|
||||
return mockTaggerThresholds[mockTaggerModel];
|
||||
case "set_tagger_threshold":
|
||||
return p.threshold ?? 0.35;
|
||||
{
|
||||
const model = isTaggerModel(p.model) ? p.model : mockTaggerModel;
|
||||
mockTaggerThresholds[model] = p.threshold ?? mockTaggerThresholds[model];
|
||||
return mockTaggerThresholds[model];
|
||||
}
|
||||
case "get_tagger_batch_size":
|
||||
case "set_tagger_batch_size":
|
||||
return p.batch_size ?? 8;
|
||||
case "probe_tagger_runtime":
|
||||
return { ready: true, acceleration: "auto", session: { file: "model.onnx", inputs: ["input"], outputs: ["tags"] } };
|
||||
return mockTaggerRuntimeProbe();
|
||||
case "get_tagging_queue_scope":
|
||||
return "all";
|
||||
case "get_tagging_queue_folder_ids":
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export type MockScenario = "rich" | "empty" | "busy" | "duplicates" | "album" | "errors" | "huge" | "extreme";
|
||||
export type MockScenario =
|
||||
| "rich"
|
||||
| "empty"
|
||||
| "busy"
|
||||
| "duplicates"
|
||||
| "album"
|
||||
| "errors"
|
||||
| "huge"
|
||||
| "extreme"
|
||||
| "unready"
|
||||
| "joytag-unready";
|
||||
|
||||
const SCENARIOS = new Set<MockScenario>([
|
||||
"rich",
|
||||
@@ -9,6 +19,8 @@ const SCENARIOS = new Set<MockScenario>([
|
||||
"errors",
|
||||
"huge",
|
||||
"extreme",
|
||||
"unready",
|
||||
"joytag-unready",
|
||||
]);
|
||||
|
||||
export function getMockScenario(): MockScenario {
|
||||
|
||||
+71
-18
@@ -589,7 +589,7 @@ interface GalleryState {
|
||||
loadTaggerModel: () => Promise<void>;
|
||||
setTaggerModel: (model: TaggerModel) => Promise<void>;
|
||||
loadTaggerThreshold: () => Promise<void>;
|
||||
setTaggerThreshold: (threshold: number) => Promise<void>;
|
||||
setTaggerThreshold: (threshold: number, model?: TaggerModel) => Promise<void>;
|
||||
loadTaggerBatchSize: () => Promise<void>;
|
||||
setTaggerBatchSize: (batchSize: number) => Promise<void>;
|
||||
probeTaggerRuntime: () => Promise<void>;
|
||||
@@ -651,6 +651,34 @@ let galleryRequestToken = 0;
|
||||
let visualClusterRequestToken = 0;
|
||||
let exploreTagRequestToken = 0;
|
||||
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const EXPLORE_TAG_REFRESH_IDLE_MS = 900;
|
||||
const EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS = 4500;
|
||||
|
||||
function scopeHasTaggingPending(
|
||||
progressByFolder: Record<number, FolderJobProgress>,
|
||||
folderId: number | null,
|
||||
): boolean {
|
||||
if (folderId === null) {
|
||||
return Object.values(progressByFolder).some((progress) => progress.tagging_pending > 0);
|
||||
}
|
||||
return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0;
|
||||
}
|
||||
|
||||
function taggingProgressAffectsScope(progressFolderId: number, scopeFolderId: number | null): boolean {
|
||||
return scopeFolderId === null || scopeFolderId === progressFolderId;
|
||||
}
|
||||
|
||||
function imagesAffectScope(images: ImageRecord[], scopeFolderId: number | null): boolean {
|
||||
return scopeFolderId === null || images.some((image) => image.folder_id === scopeFolderId);
|
||||
}
|
||||
|
||||
function scheduleExploreTagRefresh(load: () => void, delayMs: number) {
|
||||
if (exploreTagRefreshTimer) clearTimeout(exploreTagRefreshTimer);
|
||||
exploreTagRefreshTimer = setTimeout(() => {
|
||||
exploreTagRefreshTimer = null;
|
||||
load();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function initialAiCaptionsEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -1498,6 +1526,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
loadExploreTags: async (options) => {
|
||||
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
|
||||
const force = options?.force ?? false;
|
||||
if (!force && exploreTagLoading) {
|
||||
return;
|
||||
}
|
||||
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
|
||||
return;
|
||||
}
|
||||
@@ -2326,11 +2357,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
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.
|
||||
// Switching models changes both readiness and the active threshold setting,
|
||||
// so refresh them together for the selected model.
|
||||
try {
|
||||
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status");
|
||||
set({ taggerModel, taggerModelStatus, taggerModelError: null, taggerRuntimeProbe: null });
|
||||
const [taggerModelStatus, taggerThreshold] = await Promise.all([
|
||||
invoke<TaggerModelStatus>("get_tagger_model_status"),
|
||||
invoke<number>("get_tagger_threshold"),
|
||||
]);
|
||||
set({ taggerModel, taggerModelStatus, taggerThreshold, taggerModelError: null, taggerRuntimeProbe: null });
|
||||
} catch (error) {
|
||||
set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) });
|
||||
}
|
||||
@@ -2345,11 +2379,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setTaggerThreshold: async (threshold) => {
|
||||
setTaggerThreshold: async (threshold, model) => {
|
||||
const taggerThreshold = await invoke<number>("set_tagger_threshold", {
|
||||
params: { threshold },
|
||||
params: { threshold, model },
|
||||
});
|
||||
set({ taggerThreshold });
|
||||
if (!model || get().taggerModel === model) {
|
||||
set({ taggerThreshold });
|
||||
}
|
||||
},
|
||||
|
||||
loadTaggerBatchSize: async () => {
|
||||
@@ -2979,9 +3015,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
notificationTimers.set(taggingKey, setTimeout(() => {
|
||||
notificationTimers.delete(taggingKey);
|
||||
void notifyTaskComplete("AI tagging complete", body);
|
||||
// New tags landed — invalidate Explore tag cache.
|
||||
set({ exploreTagsFolderId: undefined });
|
||||
}, NOTIFICATION_DEBOUNCE_MS));
|
||||
const state = get();
|
||||
if (taggingProgressAffectsScope(progress.folder_id, state.selectedFolderId)) {
|
||||
// New tags landed — refresh the Explore tag cloud after the worker
|
||||
// settles instead of waiting for the user to revisit Explore.
|
||||
set({ exploreTagsFolderId: undefined });
|
||||
if (state.activeView === "explore" && state.exploreMode === "tags") {
|
||||
scheduleExploreTagRefresh(() => {
|
||||
void get().loadExploreTags({ force: true });
|
||||
}, EXPLORE_TAG_REFRESH_IDLE_MS);
|
||||
}
|
||||
}
|
||||
} else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) {
|
||||
clearTimeout(notificationTimers.get(taggingKey));
|
||||
notificationTimers.delete(taggingKey);
|
||||
@@ -3010,6 +3055,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
taggerModelProgress: event.payload.done ? null : event.payload,
|
||||
taggerModelPreparing: !event.payload.done,
|
||||
});
|
||||
if (event.payload.done) {
|
||||
void get().loadTaggerModelStatus();
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
|
||||
@@ -3059,15 +3107,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
|
||||
const batch = event.payload;
|
||||
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
|
||||
if (taggingUpdated) {
|
||||
const state = get();
|
||||
if (taggingUpdated && imagesAffectScope(batch.images, state.selectedFolderId)) {
|
||||
set({ exploreTagsFolderId: undefined });
|
||||
if (get().activeView === "explore") {
|
||||
if (!exploreTagRefreshTimer) {
|
||||
exploreTagRefreshTimer = setTimeout(() => {
|
||||
exploreTagRefreshTimer = null;
|
||||
void get().loadExploreTags({ force: true });
|
||||
}, 700);
|
||||
}
|
||||
if (state.activeView === "explore" && state.exploreMode === "tags") {
|
||||
const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId)
|
||||
? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS
|
||||
: EXPLORE_TAG_REFRESH_IDLE_MS;
|
||||
scheduleExploreTagRefresh(() => {
|
||||
void get().loadExploreTags({ force: true });
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3171,6 +3220,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (exploreTagRefreshTimer) {
|
||||
clearTimeout(exploreTagRefreshTimer);
|
||||
exploreTagRefreshTimer = null;
|
||||
}
|
||||
unlistenProgress();
|
||||
unlistenMediaJobs();
|
||||
unlistenCaptionModelProgress();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TaggerModel } from "./store";
|
||||
|
||||
export const TAGGER_MODELS: Record<TaggerModel, { name: string; tab: string; description: string; defaultThreshold: number }> = {
|
||||
wd: {
|
||||
name: "WD SwinV2 Tagger v3",
|
||||
tab: "WD (anime)",
|
||||
defaultThreshold: 0.35,
|
||||
description:
|
||||
"Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.",
|
||||
},
|
||||
joytag: {
|
||||
name: "JoyTag",
|
||||
tab: "JoyTag (general)",
|
||||
defaultThreshold: 0.4,
|
||||
description:
|
||||
"Booru-schema tagger that also handles photographic content and is strong on NSFW concepts. The explicitness rating is derived from its tags.",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user