diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 655fbd4..2871f9e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1369,6 +1369,9 @@ name = "esaxx-rs" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] [[package]] name = "event-listener" @@ -3962,6 +3965,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", + "tokenizers", "tokio", "uuid", "walkdir", @@ -5944,6 +5948,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", + "indicatif", "itertools", "log", "macro_rules_attribute", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c16a3ce..3618749 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,3 +39,4 @@ candle-core = { version = "0.10.2", features = ["cuda"] } candle-nn = { version = "0.10.2", features = ["cuda"] } candle-transformers = { version = "0.10.2", features = ["cuda"] } hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] } +tokenizers = "0.22.1" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index eb5af3e..cc57607 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,5 @@ use crate::db::{self, DbPool, Folder, FolderJobProgress, ImageRecord}; +use crate::embedder::ClipImageEmbedder; use crate::indexer; use crate::vector; use serde::{Deserialize, Serialize}; @@ -44,6 +45,15 @@ pub struct RetryFailedEmbeddingsParams { pub folder_id: i64, } +#[derive(Deserialize)] +pub struct SemanticSearchParams { + pub query: String, + pub folder_id: Option, + pub media_kind: Option, + pub favorites_only: Option, + pub limit: Option, +} + #[tauri::command] pub async fn add_folder( app: AppHandle, @@ -188,3 +198,52 @@ pub async fn retry_failed_embeddings( let conn = db.get().map_err(|e| e.to_string())?; db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string()) } + +#[tauri::command] +pub async fn semantic_search_images( + db: State<'_, DbState>, + params: SemanticSearchParams, +) -> Result, String> { + let embedder = ClipImageEmbedder::new().map_err(|e| e.to_string())?; + let embedding = embedder.embed_text(¶ms.query).map_err(|e| e.to_string())?; + + let conn = db.get().map_err(|e| e.to_string())?; + let limit = params.limit.unwrap_or(64); + let ids = vector::search_image_ids_by_embedding(&conn, &embedding, limit).map_err(|e| e.to_string())?; + let mut images = db::get_images_by_ids(&conn, &ids).map_err(|e| e.to_string())?; + + if let Some(folder_id) = params.folder_id { + images.retain(|image| image.folder_id == folder_id); + } + if let Some(media_kind) = params.media_kind.as_deref() { + images.retain(|image| image.media_kind == media_kind); + } + if params.favorites_only.unwrap_or(false) { + images.retain(|image| image.favorite); + } + + Ok(images) +} + +#[derive(Serialize)] +pub struct WorkerStates { + pub thumbnail_paused: bool, + pub metadata_paused: bool, + pub embedding_paused: bool, +} + +#[tauri::command] +pub async fn set_worker_paused(worker: String, paused: bool) -> Result<(), String> { + indexer::set_worker_paused(&worker, paused); + Ok(()) +} + +#[tauri::command] +pub async fn get_worker_states() -> Result { + let states = indexer::get_worker_paused_states(); + Ok(WorkerStates { + thumbnail_paused: states[0], + metadata_paused: states[1], + embedding_paused: states[2], + }) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index fd38fcd..3c44d2d 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -813,6 +813,8 @@ pub fn get_images( "date_desc" => "modified_at DESC NULLS LAST", "size_asc" => "file_size ASC", "size_desc" => "file_size DESC", + "duration_asc" => "duration_ms ASC NULLS LAST", + "duration_desc" => "duration_ms DESC NULLS LAST", _ => "modified_at DESC NULLS LAST", }; diff --git a/src-tauri/src/embedder.rs b/src-tauri/src/embedder.rs index 148632a..f074863 100644 --- a/src-tauri/src/embedder.rs +++ b/src-tauri/src/embedder.rs @@ -4,9 +4,11 @@ use candle_nn::VarBuilder; use candle_transformers::models::clip::{self, ClipModel}; use hf_hub::{api::sync::Api, Repo, RepoType}; use std::path::{Path, PathBuf}; +use tokenizers::Tokenizer; pub struct ClipImageEmbedder { model: ClipModel, + tokenizer: Tokenizer, device: Device, image_size: usize, } @@ -21,6 +23,11 @@ impl ClipImageEmbedder { )); println!("Resolving CLIP model weights from Hugging Face cache..."); let model_path = repo.get("model.safetensors")?; + let tokenizer_repo = api.repo(Repo::new( + "openai/clip-vit-base-patch32".to_string(), + RepoType::Model, + )); + let tokenizer_path = tokenizer_repo.get("tokenizer.json")?; let config = clip::ClipConfig::vit_base_patch32(); let device = resolve_device()?; @@ -32,10 +39,12 @@ impl ClipImageEmbedder { )? }; let model = ClipModel::new(vb, &config)?; + let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?; println!("CLIP image embedder ready."); Ok(Self { model, + tokenizer, device, image_size: config.image_size, }) @@ -56,6 +65,22 @@ impl ClipImageEmbedder { } Ok(embeddings) } + + pub fn embed_text(&self, query: &str) -> Result> { + let encoding = self + .tokenizer + .encode(query, true) + .map_err(anyhow::Error::msg)?; + let token_ids = encoding + .get_ids() + .iter() + .map(|token| *token as u32) + .collect::>(); + let input_ids = Tensor::new(vec![token_ids], &self.device)?; + let features = self.model.get_text_features(&input_ids)?; + let normalized = clip::div_l2_norm(&features)?; + Ok(normalized.flatten_all()?.to_vec1::()?) + } } fn resolve_device() -> Result { diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 581f3d6..34ed185 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -9,6 +9,7 @@ use rayon::prelude::*; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter}; @@ -24,6 +25,27 @@ const JOB_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(750); static LAST_JOB_PROGRESS_EMIT: OnceLock>> = OnceLock::new(); static ACTIVE_INDEXING_FOLDERS: OnceLock>> = OnceLock::new(); + +static THUMBNAIL_WORKER_PAUSED: AtomicBool = AtomicBool::new(false); +static METADATA_WORKER_PAUSED: AtomicBool = AtomicBool::new(false); +static EMBEDDING_WORKER_PAUSED: AtomicBool = AtomicBool::new(false); + +pub fn set_worker_paused(worker: &str, paused: bool) { + match worker { + "thumbnail" => THUMBNAIL_WORKER_PAUSED.store(paused, Ordering::Relaxed), + "metadata" => METADATA_WORKER_PAUSED.store(paused, Ordering::Relaxed), + "embedding" => EMBEDDING_WORKER_PAUSED.store(paused, Ordering::Relaxed), + _ => {} + } +} + +pub fn get_worker_paused_states() -> [bool; 3] { + [ + THUMBNAIL_WORKER_PAUSED.load(Ordering::Relaxed), + METADATA_WORKER_PAUSED.load(Ordering::Relaxed), + EMBEDDING_WORKER_PAUSED.load(Ordering::Relaxed), + ] +} static FOLDER_STORAGE_PROFILES: OnceLock>> = OnceLock::new(); static DB_WRITE_LOCK: OnceLock> = OnceLock::new(); @@ -73,20 +95,26 @@ pub fn start_thumbnail_worker( cache_dir: PathBuf, ) { std::thread::spawn(move || loop { + if THUMBNAIL_WORKER_PAUSED.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(500)); + continue; + } if let Err(error) = process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) { eprintln!("Thumbnail worker error: {}", error); } - std::thread::sleep(std::time::Duration::from_millis(250)); }); } pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaTools) { std::thread::spawn(move || loop { + if METADATA_WORKER_PAUSED.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(500)); + continue; + } if let Err(error) = process_metadata_batch(&app, &pool, &media_tools) { eprintln!("Metadata worker error: {}", error); } - std::thread::sleep(std::time::Duration::from_millis(250)); }); } @@ -96,10 +124,13 @@ pub fn start_embedding_worker(app: AppHandle, pool: DbPool) { let mut embedder: Option = None; println!("Embedding worker started."); loop { + if EMBEDDING_WORKER_PAUSED.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(500)); + continue; + } if let Err(error) = process_embedding_batch(&app, &pool, &mut embedder) { eprintln!("Embedding worker error: {}", error); } - std::thread::sleep(std::time::Duration::from_millis(500)); } }); @@ -319,7 +350,7 @@ fn process_thumbnail_batch( return Ok(()); } - println!("Embedding batch claimed: {} items", jobs.len()); + println!("Thumbnail batch claimed: {} items", jobs.len()); let (image_jobs, video_jobs): (Vec<_>, Vec<_>) = jobs.into_iter().partition(|job| job.media_kind == "image"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d5513a8..a755288 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,6 +74,9 @@ pub fn run() { commands::update_image_details, commands::find_similar_images, commands::retry_failed_embeddings, + commands::semantic_search_images, + commands::set_worker_paused, + commands::get_worker_states, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index e630be0..d5a4340 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -6,6 +6,13 @@ use serde::Deserialize; use std::path::{Path, PathBuf}; use std::process::Command; +// On Windows, GUI apps spawn subprocesses with a visible console window by default. +// CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation. +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + #[derive(Debug, Clone)] pub struct MediaTools { ffmpeg_path: PathBuf, @@ -30,6 +37,10 @@ impl MediaTools { } pub fn ensure_installed() -> Result<()> { + // Skip download entirely if both binaries are already present. + if ffmpeg_path().exists() && ffprobe_path().exists() { + return Ok(()); + } auto_download_with_progress(|event| match event { FfmpegDownloadProgressEvent::Starting => { println!("Downloading bundled FFmpeg..."); @@ -53,11 +64,17 @@ impl MediaTools { } pub fn ffmpeg_command(&self) -> Command { - Command::new(&self.ffmpeg_path) + let mut cmd = Command::new(&self.ffmpeg_path); + #[cfg(target_os = "windows")] + cmd.creation_flags(CREATE_NO_WINDOW); + cmd } pub fn ffprobe_command(&self) -> Command { - Command::new(&self.ffprobe_path) + let mut cmd = Command::new(&self.ffprobe_path); + #[cfg(target_os = "windows")] + cmd.creation_flags(CREATE_NO_WINDOW); + cmd } } diff --git a/src-tauri/src/vector.rs b/src-tauri/src/vector.rs index ed88b7e..5679c83 100644 --- a/src-tauri/src/vector.rs +++ b/src-tauri/src/vector.rs @@ -78,6 +78,38 @@ pub fn find_similar_image_ids(conn: &Connection, image_id: i64, limit: usize) -> Ok(ids) } +pub fn search_image_ids_by_embedding( + conn: &Connection, + embedding: &[f32], + limit: usize, +) -> Result> { + if embedding.len() != CLIP_VECTOR_DIM { + return Err(anyhow!( + "expected {}-dimensional embedding, got {}", + CLIP_VECTOR_DIM, + embedding.len() + )); + } + + let packed = pack_f32(embedding); + let mut stmt = conn.prepare( + "SELECT image_id + FROM image_vec + WHERE embedding MATCH vec_f32(?1) + AND k = ?2", + )?; + let rows = stmt.query_map((&packed, limit as i64), |row| row.get::<_, i64>(0))?; + + let mut ids = Vec::new(); + for row in rows { + ids.push(row?); + if ids.len() >= limit { + break; + } + } + Ok(ids) +} + #[allow(dead_code)] fn pack_f32(values: &[f32]) -> Vec { let mut out = Vec::with_capacity(values.len() * std::mem::size_of::()); diff --git a/src/App.tsx b/src/App.tsx index f5bd3af..de6eb4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import { useEffect } from "react"; import { useGalleryStore } from "./store"; import { Sidebar } from "./components/Sidebar"; import { BackgroundTasks } from "./components/BackgroundTasks"; -import { MenuBar } from "./components/MenuBar"; import { Toolbar } from "./components/Toolbar"; import { Gallery } from "./components/Gallery"; import { Lightbox } from "./components/Lightbox"; @@ -31,7 +30,6 @@ export default function App() {
- diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index c8d8c66..eb8232e 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -1,15 +1,32 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { useGalleryStore } from "../store"; -function ProgressBar({ value }: { value: number }) { - return ( -
-
-
- ); +type WorkerKey = "thumbnail" | "metadata" | "embedding"; + +const WORKER_FOR_STAGE: Record = { + Thumbnails: "thumbnail", + Metadata: "metadata", + Embeddings: "embedding", +}; + +interface TaskStage { + label: string; + detail: string; + progress: number | null; // 0–100, or null for indeterminate + failed: boolean; +} + +interface Task { + id: number; + name: string; + stages: TaskStage[]; + hasFailedEmbeddings: boolean; + pendingMediaWork: number; + embeddingProcessed: number; + embeddingTotal: number; + currentFile: string | null; + snapshot: string; } export function BackgroundTasks() { @@ -17,114 +34,351 @@ export function BackgroundTasks() { const indexingProgress = useGalleryStore((state) => state.indexingProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); + const [expanded, setExpanded] = useState(false); + const [dismissed, setDismissed] = useState>({}); + const [paused, setPaused] = useState>({ + thumbnail: false, + metadata: false, + embedding: false, + }); - const tasks = useMemo(() => { + useEffect(() => { + invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>( + "get_worker_states", + ).then((states) => { + setPaused({ + thumbnail: states.thumbnail_paused, + metadata: states.metadata_paused, + embedding: states.embedding_paused, + }); + }); + }, []); + + const toggleWorker = (worker: WorkerKey) => { + const next = !paused[worker]; + setPaused((prev) => ({ ...prev, [worker]: next })); + void invoke("set_worker_paused", { worker, paused: next }); + }; + + const dismissTask = (id: number, snapshot: string) => { + setDismissed((prev) => ({ ...prev, [id]: snapshot })); + setExpanded(false); + }; + + const tasks = useMemo(() => { return folders - .map((folder) => { + .map((folder): Task | null => { const index = indexingProgress[folder.id]; const jobs = mediaJobProgress[folder.id]; - const pendingMediaWork = - (jobs?.thumbnail_pending ?? 0) + - (jobs?.metadata_pending ?? 0) + - (jobs?.embedding_pending ?? 0); - const embeddingProcessed = (jobs?.embedding_ready ?? 0) + (jobs?.embedding_failed ?? 0); - const embeddingTotal = embeddingProcessed + (jobs?.embedding_pending ?? 0); - const hasFailedEmbeddings = (jobs?.embedding_failed ?? 0) > 0; - if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) { - return null; + const thumbnailPending = jobs?.thumbnail_pending ?? 0; + const metadataPending = jobs?.metadata_pending ?? 0; + const embeddingPending = jobs?.embedding_pending ?? 0; + const embeddingReady = jobs?.embedding_ready ?? 0; + const embeddingFailed = jobs?.embedding_failed ?? 0; + + const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending; + const embeddingProcessed = embeddingReady + embeddingFailed; + const embeddingTotal = embeddingProcessed + embeddingPending; + const hasFailedEmbeddings = embeddingFailed > 0; + + if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) return null; + + const stages: TaskStage[] = []; + + if (index && !index.done) { + const pct = index.total > 0 ? (index.indexed / index.total) * 100 : 0; + stages.push({ + label: "Scanning", + detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`, + progress: pct, + failed: false, + }); } - const indexPercent = index && index.total > 0 ? (index.indexed / index.total) * 100 : 0; - const embeddingPercent = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0; + if (thumbnailPending > 0) { + stages.push({ + label: "Thumbnails", + detail: thumbnailPending.toLocaleString(), + progress: null, + failed: false, + }); + } + + if (metadataPending > 0) { + stages.push({ + label: "Metadata", + detail: metadataPending.toLocaleString(), + progress: null, + failed: false, + }); + } + + if (embeddingPending > 0) { + const pct = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0; + stages.push({ + label: "Embeddings", + detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`, + progress: pct, + failed: false, + }); + } + + if (hasFailedEmbeddings && pendingMediaWork === 0) { + stages.push({ + label: "Failed", + detail: `${embeddingFailed.toLocaleString()} embeddings`, + progress: null, + failed: true, + }); + } + + const snapshot = `${pendingMediaWork}:${embeddingFailed}`; + return { id: folder.id, name: folder.name, - index, - jobs, + stages, + hasFailedEmbeddings, pendingMediaWork, - indexPercent, embeddingProcessed, embeddingTotal, - embeddingPercent, - hasFailedEmbeddings, + currentFile: index && !index.done ? (index.current_file || null) : null, + snapshot, }; }) - .filter((task) => task !== null); - }, [folders, indexingProgress, mediaJobProgress]); + .filter((t): t is Task => t !== null) + .filter((t) => dismissed[t.id] !== t.snapshot); + }, [folders, indexingProgress, mediaJobProgress, dismissed]); - if (tasks.length === 0) { - return null; - } + if (tasks.length === 0) return null; + + const primary = tasks[0]; + const extraCount = tasks.length - 1; + const hasFailed = tasks.some((t) => t.hasFailedEmbeddings && t.pendingMediaWork === 0); + + // Best progress bar value: use embedding progress if available (most informative), + // otherwise fall back to scanning progress, otherwise indeterminate. + const embeddingStage = primary.stages.find((s) => s.label === "Embeddings"); + const scanningStage = primary.stages.find((s) => s.label === "Scanning"); + const barProgress = embeddingStage?.progress ?? scanningStage?.progress ?? null; return ( -
-
-

Background Tasks

- {tasks.length} active -
-
- {tasks.map((task) => ( -
-
-
-

{task.name}

-

- {task.index && !task.index.done - ? `${task.index.indexed.toLocaleString()} of ${task.index.total.toLocaleString()} scanned` - : task.hasFailedEmbeddings && task.pendingMediaWork === 0 - ? `Embedding failures require attention` - : `${task.pendingMediaWork.toLocaleString()} media jobs remaining`} -

-
-
- {task.jobs?.thumbnail_pending ?
{task.jobs.thumbnail_pending.toLocaleString()} thumbnails
: null} - {task.jobs?.metadata_pending ?
{task.jobs.metadata_pending.toLocaleString()} metadata
: null} - {task.embeddingTotal > 0 ? ( -
- {task.embeddingProcessed.toLocaleString()} / {task.embeddingTotal.toLocaleString()} embeddings -
- ) : null} - {task.jobs?.embedding_failed ?
{task.jobs.embedding_failed.toLocaleString()} failed
: null} -
-
+
+ {/* Slim bar */} +
setExpanded((v) => !v)} + > + {/* Pulse dot */} +
+
+
+
- {task.index && !task.index.done ? ( -
- -

{task.index.current_file || "Scanning..."}

-
- ) : task.embeddingTotal > 0 && (task.jobs?.embedding_pending ?? 0) > 0 ? ( -
- -

- {task.embeddingProcessed.toLocaleString()} completed, {task.jobs?.embedding_pending?.toLocaleString() ?? 0} remaining -

-
- ) : task.hasFailedEmbeddings ? ( -
- -
-

- {task.jobs?.embedding_failed?.toLocaleString() ?? 0} embedding failures need attention -

+ {/* Folder name */} + {primary.name} + + {/* Stage tags — all active stages visible simultaneously */} +
+ {primary.stages.map((stage) => { + const workerKey = WORKER_FOR_STAGE[stage.label]; + const isPaused = workerKey ? paused[workerKey] : false; + return ( + + {stage.label} + + {stage.detail} + + {workerKey && ( + )} + + ); + })} +
+ + {/* Progress bar — embedding or scanning progress, pulsing if indeterminate */} +
+
+
+ + {/* Extra folders badge */} + {extraCount > 0 && ( + + +{extraCount} + + )} + + {/* Retry (failed embeddings only) */} + {primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && ( + + )} + + {/* Expand chevron (only when multiple folders) */} + {tasks.length > 1 && ( + + + + )} + + {/* Dismiss */} + +
+ + {/* Expanded panel — one row per folder */} + {expanded && ( +
+ {tasks.map((task) => { + const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings"); + const taskScanningStage = task.stages.find((s) => s.label === "Scanning"); + const taskBarProgress = taskEmbeddingStage?.progress ?? taskScanningStage?.progress ?? null; + const taskHasFailed = task.hasFailedEmbeddings && task.pendingMediaWork === 0; + + return ( +
+
+ {task.name} + +
+ {task.stages.map((stage) => { + const workerKey = WORKER_FOR_STAGE[stage.label]; + const isPaused = workerKey ? paused[workerKey] : false; + return ( + + {isPaused && ( + + + + )} + {stage.label} + + {stage.detail} + + {workerKey && ( + + )} + + ); + })} +
+ +
+
+
+ + {taskHasFailed && ( + + )} + +
+ + {task.currentFile && ( +

+ {task.currentFile} +

+ )}
- ) : task.pendingMediaWork > 0 ? ( -
- -

Processing thumbnails, metadata, and embeddings

-
- ) : null} -
- ))} -
+ ); + })} +
+ )}
); } diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index d4d6336..a7265a8 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -2,27 +2,7 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { convertFileSrc } from "@tauri-apps/api/core"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; -const GAP = 8; - -function RatingStars({ rating }: { rating: number }) { - return ( -
- {Array.from({ length: 5 }, (_, index) => { - const filled = index < rating; - return ( - - - - ); - })} -
- ); -} +const GAP = 6; function formatDuration(durationMs: number | null): string | null { if (!durationMs || durationMs <= 0) return null; @@ -30,27 +10,12 @@ function formatDuration(durationMs: number | null): string | null { const seconds = totalSeconds % 60; const minutes = Math.floor(totalSeconds / 60) % 60; const hours = Math.floor(totalSeconds / 3600); - if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } - return `${minutes}:${seconds.toString().padStart(2, "0")}`; } -function embeddingLabel(image: ImageRecord): string { - if (image.embedding_status === "ready") { - return image.embedding_model ? `Embeddings ready` : "Embeddings ready"; - } - if (image.embedding_status === "failed") { - return "Embeddings failed"; - } - if (image.embedding_status === "processing") { - return "Embedding..."; - } - return "Embedding queued"; -} - function ContextMenu({ x, y, @@ -65,60 +30,57 @@ function ContextMenu({ const openImage = useGalleryStore((state) => state.openImage); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); + const canFindSimilar = image.embedding_status === "ready"; return (
event.stopPropagation()} > -
-
Rating
-
+
+
Rating
+
{Array.from({ length: 5 }, (_, index) => { const rating = index + 1; return ( @@ -156,6 +115,7 @@ function ImageTile({ const [loaded, setLoaded] = useState(false); const [errored, setErrored] = useState(false); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); + const canFindSimilar = image.embedding_status === "ready"; const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) @@ -165,94 +125,102 @@ function ImageTile({ return ( - Right-click for more
@@ -288,15 +256,11 @@ export function Gallery() { useEffect(() => { const close = (event: PointerEvent) => { - if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) { - return; - } + if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; setContextMenu(null); }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setContextMenu(null); - } + if (event.key === "Escape") setContextMenu(null); }; window.addEventListener("pointerdown", close); window.addEventListener("keydown", handleKeyDown); @@ -308,23 +272,21 @@ export function Gallery() { if (images.length === 0 && !loadingImages) { return ( -
- - - -

No media found

-

Try another filter, or add a folder from the library menu.

+
+
+ + + +

No media found

+

Try adjusting your filters or add a new folder

+
); } return ( -
+
{loadingImages ? ( -
-
+
+
) : null} {contextMenu ? ( - setContextMenu(null)} /> + setContextMenu(null)} + /> ) : null}
); diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 3ee3b26..1edfc37 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -56,6 +56,7 @@ export function Lightbox() { const imageViewportRef = useRef(null); const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; + const canFindSimilar = selectedImage?.embedding_status === "ready"; const goPrev = useCallback(() => { if (currentIndex > 0) openImage(images[currentIndex - 1]); @@ -199,10 +200,18 @@ export function Lightbox() {
- {/* All photos link */} -
+ {/* Nav */} +
selectFolder(null)} > - + - All Media - {selectedFolderId === null && ( - {totalImages.toLocaleString()} - )} + + All Media +
+ {/* Section label */} + {folders.length > 0 && ( +
+ Libraries +
+ )} + {/* Folder list */} -
+
{folders.length === 0 ? ( -

- No folders added yet +

+ Add a folder to get started

) : ( folders.map((folder) => ( @@ -161,19 +145,6 @@ export function Sidebar() { )) )}
- - {/* Add folder button */} -
- -
); } diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index e7caecb..a34e272 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,16 +1,93 @@ import { useEffect, useRef, useState } from "react"; -import { tileSizeForZoom, useGalleryStore, SortOrder } from "../store"; +import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store"; -const SORT_OPTIONS: { value: SortOrder; label: string }[] = [ +const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, { value: "date_asc", label: "Oldest first" }, - { value: "name_asc", label: "Name A-Z" }, - { value: "name_desc", label: "Name Z-A" }, + { value: "name_asc", label: "Name A–Z" }, + { value: "name_desc", label: "Name Z–A" }, { value: "size_desc", label: "Largest first" }, { value: "size_asc", label: "Smallest first" }, ]; -function FilterChip({ +const VIDEO_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ + { value: "duration_desc", label: "Longest first" }, + { value: "duration_asc", label: "Shortest first" }, +]; + +function getSortOptions(mediaFilter: MediaFilter) { + if (mediaFilter === "video") { + return [...BASE_SORT_OPTIONS, ...VIDEO_SORT_OPTIONS]; + } + return BASE_SORT_OPTIONS; +} + +function SortDropdown({ + value, + onChange, + options, +}: { + value: SortOrder; + onChange: (v: SortOrder) => void; + options: { value: SortOrder; label: string }[]; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const current = options.find((o) => o.value === value) ?? BASE_SORT_OPTIONS[0]; + + useEffect(() => { + const close = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + window.addEventListener("pointerdown", close); + return () => window.removeEventListener("pointerdown", close); + }, []); + + return ( +
+ + {open ? ( +
+ {options.map((option) => ( + + ))} +
+ ) : null} +
+ ); +} + +function FilterPill({ label, active, onClick, @@ -21,10 +98,10 @@ function FilterChip({ }) { return ( + ))} +
+
+ + + + setSearchValue(event.target.value)} + placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."} + className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors" /> - - setSearchValue(event.target.value)} - placeholder="Search filenames, scenes, references..." - className="w-72 rounded-xl border border-white/10 bg-white/5 py-2 pl-9 pr-4 text-sm text-white placeholder:text-gray-500 focus:border-blue-500 focus:outline-none" - /> + {searchValue.trim().length > 0 && ( + + )} +
- +
-
- setMediaFilter("all")} /> - setMediaFilter("image")} /> - setMediaFilter("video")} /> - setFavoritesOnly(!favoritesOnly)} /> - -
- Tile {tileSize}px - - - -
+ {/* Filter row */} +
+ { setMediaFilter("all"); setFavoritesOnly(false); }} /> + { setMediaFilter("image"); setFavoritesOnly(false); }} /> + { setMediaFilter("video"); setFavoritesOnly(false); }} /> + setFavoritesOnly(!favoritesOnly)} />
); diff --git a/src/store.ts b/src/store.ts index edf1e7d..610790c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -14,6 +14,7 @@ export interface Folder { export type MediaKind = "image" | "video"; export type MediaFilter = "all" | MediaKind; export type ZoomPreset = "compact" | "comfortable" | "detail"; +export type SearchMode = "filename" | "semantic"; export interface ImageRecord { id: number; @@ -77,7 +78,9 @@ export type SortOrder = | "name_asc" | "name_desc" | "size_desc" - | "size_asc"; + | "size_asc" + | "duration_desc" + | "duration_asc"; interface GalleryState { folders: Folder[]; @@ -87,6 +90,7 @@ interface GalleryState { loadedCount: number; loadingImages: boolean; search: string; + searchMode: SearchMode; sort: SortOrder; mediaFilter: MediaFilter; favoritesOnly: boolean; @@ -106,6 +110,9 @@ interface GalleryState { loadImages: (reset?: boolean) => Promise; loadMoreImages: () => Promise; setSearch: (search: string) => void; + clearSearch: () => void; + resetSearch: () => void; + setSearchMode: (mode: SearchMode) => void; setSort: (sort: SortOrder) => void; setMediaFilter: (filter: MediaFilter) => void; setFavoritesOnly: (favoritesOnly: boolean) => void; @@ -171,6 +178,10 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number return compareNullableNumber(a.file_size, b.file_size); case "size_desc": return compareNullableNumber(b.file_size, a.file_size); + case "duration_asc": + return compareNullableNumber(a.duration_ms, b.duration_ms); + case "duration_desc": + return compareNullableNumber(b.duration_ms, a.duration_ms); default: return compareNullableDate(b.modified_at, a.modified_at); } @@ -237,6 +248,7 @@ export const useGalleryStore = create((set, get) => ({ loadedCount: 0, loadingImages: false, search: "", + searchMode: "filename", sort: "date_desc", mediaFilter: "all", favoritesOnly: false, @@ -292,10 +304,31 @@ export const useGalleryStore = create((set, get) => ({ }, loadImages: async (reset = false) => { - const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly } = get(); + const { selectedFolderId, search, searchMode, sort, loadedCount, mediaFilter, favoritesOnly } = get(); set({ loadingImages: true }); try { + if (searchMode === "semantic" && search.trim()) { + const images = await invoke("semantic_search_images", { + params: { + query: search, + folder_id: selectedFolderId, + media_kind: mediaFilter === "all" ? null : mediaFilter, + favorites_only: favoritesOnly, + limit: PAGE_SIZE, + }, + }); + + set({ + images, + totalImages: images.length, + loadedCount: images.length, + loadingImages: false, + collectionTitle: `Semantic search: ${search}`, + }); + return; + } + const offset = reset ? 0 : loadedCount; const result = await invoke<{ images: ImageRecord[]; @@ -338,6 +371,21 @@ export const useGalleryStore = create((set, get) => ({ void get().loadImages(true); }, + clearSearch: () => { + set({ search: "", images: [], loadedCount: 0, collectionTitle: null }); + void get().loadImages(true); + }, + + resetSearch: () => { + set({ search: "", searchMode: "filename", images: [], loadedCount: 0, collectionTitle: null }); + void get().loadImages(true); + }, + + setSearchMode: (searchMode) => { + set({ searchMode, images: [], loadedCount: 0, collectionTitle: null }); + void get().loadImages(true); + }, + setSort: (sort) => { set({ sort, images: [], loadedCount: 0, collectionTitle: null }); void get().loadImages(true); @@ -369,6 +417,7 @@ export const useGalleryStore = create((set, get) => ({ loadingImages: false, collectionTitle: "Similar Images", selectedFolderId: null, + selectedImage: null, }); },