diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f9847b..1fd72b14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,11 @@ name: CI on: workflow_dispatch: + inputs: + release_marker: + description: 'Type [RELEASE] to run the release CI' + required: true + type: string concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -9,6 +14,7 @@ concurrency: jobs: frontend: + if: github.actor == 'ExtraBinoss' && inputs.release_marker == '[RELEASE]' name: Vue / TypeScript runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -27,6 +33,7 @@ jobs: run: npm run build rust: + if: github.actor == 'ExtraBinoss' && inputs.release_marker == '[RELEASE]' name: Rust (${{ matrix.os }}) strategy: fail-fast: false diff --git a/README.md b/README.md index 0f832118..d1e6b1c8 100644 --- a/README.md +++ b/README.md @@ -18,23 +18,17 @@
- - - - -
- Banner placeholder
- Replace this block with docs/assets/imagyx-banner.webp
- Expected size: 1600 × 800 px · 2:1 ratio · WebP or PNG
- Keep important content inside the centered 1200 × 600 px safe area. -
+

+ Imagyx banner +

+ +## Demo + +See how Imagyx lets you search your local image library instantly using natural language. -
diff --git a/docs/assets/IMAGYX-demo.webm b/docs/assets/IMAGYX-demo.webm new file mode 100644 index 00000000..b26afa96 Binary files /dev/null and b/docs/assets/IMAGYX-demo.webm differ diff --git a/docs/assets/imagyx-banner.png b/docs/assets/imagyx-banner.png new file mode 100644 index 00000000..4eb896e1 Binary files /dev/null and b/docs/assets/imagyx-banner.png differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c3290c8a..f8c32fc1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1874,6 +1874,7 @@ dependencies = [ "png 0.18.1", "ravif", "rayon", + "rgb", "tiff", "zune-core", "zune-jpeg", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9e6cc482..b4fd52fa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,7 +25,7 @@ dirs = "6" walkdir = "2.5" notify = "8" rayon = "1.11" -image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff", "ico", "rayon"] } +image = { version = "0.25.10", default-features = false, features = ["avif", "jpeg", "png", "webp", "gif", "bmp", "tiff", "ico", "rayon"] } arboard = "3" parking_lot = "0.12" chrono = { version = "0.4", features = ["serde"] } diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 720ff161..d1d6f865 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -1,14 +1,66 @@ use std::{ borrow::Cow, + fs::{self, File}, + io::{BufWriter, Write}, path::{Path, PathBuf}, process::Command, sync::Arc, }; use arboard::{Clipboard, ImageData}; +use image::{DynamicImage, ImageFormat, Rgba, RgbaImage, codecs::jpeg::JpegEncoder, imageops}; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager, State}; -use crate::state::AppState; +use crate::{indexer, models::ImageAsset, state::AppState}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageConversionFormat { + Avif, + Webp, + Png, + Jpg, + Ico, +} + +impl ImageConversionFormat { + fn extension(self) -> &'static str { + match self { + Self::Avif => "avif", + Self::Webp => "webp", + Self::Png => "png", + Self::Jpg => "jpg", + Self::Ico => "ico", + } + } + + fn image_format(self) -> ImageFormat { + match self { + Self::Avif => ImageFormat::Avif, + Self::Webp => ImageFormat::WebP, + Self::Png => ImageFormat::Png, + Self::Jpg => ImageFormat::Jpeg, + Self::Ico => ImageFormat::Ico, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageConversionResult { + image: ImageAsset, + inherited_semantic_index: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ImageConversionProgress { + source_image_id: String, + target_format: ImageConversionFormat, + progress: f32, + stage: &'static str, +} #[tauri::command(rename_all = "camelCase")] pub fn open_in_file_manager( @@ -19,11 +71,12 @@ pub fn open_in_file_manager( let canonical = managed_path(&state, &path)?; #[cfg(target_os = "windows")] { - let mut command = Command::new("explorer"); + let explorer_path = windows_explorer_path(&canonical); + let mut command = Command::new("explorer.exe"); if reveal && canonical.is_file() { - command.arg(format!("/select,{}", canonical.display())); + command.arg("/select,").arg(&explorer_path); } else { - command.arg(&canonical); + command.arg(&explorer_path); } command .spawn() @@ -55,6 +108,16 @@ pub fn open_in_file_manager( Ok(()) } +#[cfg(target_os = "windows")] +fn windows_explorer_path(path: &Path) -> PathBuf { + let path = path.to_string_lossy(); + if let Some(unc) = path.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{unc}")); + } + let normalized = path.strip_prefix(r"\\?\").unwrap_or(path.as_ref()); + PathBuf::from(normalized) +} + #[tauri::command(rename_all = "camelCase")] pub async fn copy_image_to_clipboard( path: String, @@ -84,26 +147,41 @@ pub async fn copy_image_to_clipboard( .map_err(|error| error.to_string())? } +#[tauri::command(rename_all = "camelCase")] +pub async fn convert_image( + image_id: String, + target_format: ImageConversionFormat, + app: AppHandle, + state: State<'_, Arc>, +) -> Result { + let state = Arc::clone(state.inner()); + tauri::async_runtime::spawn_blocking(move || { + convert_image_blocking(&app, &state, &image_id, target_format) + }) + .await + .map_err(|error| error.to_string())? +} + #[tauri::command(rename_all = "camelCase")] pub fn open_in_imagyx( image_id: String, app: AppHandle, state: State<'_, Arc>, ) -> Result<(), String> { - if !state + let image = state .database - .image_exists(&image_id) + .images_by_ids(&[image_id]) .map_err(|error| error.to_string())? - { - return Err("Image inconnue".to_owned()); - } + .into_iter() + .next() + .ok_or_else(|| "Image inconnue".to_owned())?; let main = app .get_webview_window("main") .ok_or_else(|| "Fenêtre principale indisponible".to_owned())?; main.show().map_err(|error| error.to_string())?; let _ = main.unminimize(); main.set_focus().map_err(|error| error.to_string())?; - main.emit("open-image-requested", image_id) + main.emit("open-image-requested", image) .map_err(|error| error.to_string())?; if let Some(spotlight) = app.get_webview_window("spotlight") { let _ = spotlight.emit("spotlight-will-hide", ()); @@ -112,6 +190,205 @@ pub fn open_in_imagyx( Ok(()) } +fn convert_image_blocking( + app: &AppHandle, + state: &AppState, + image_id: &str, + target_format: ImageConversionFormat, +) -> Result { + let _index_guard = state.lock_indexer(); + let source = state + .database + .images_by_ids(&[image_id.to_owned()]) + .map_err(|error| error.to_string())? + .into_iter() + .next() + .ok_or_else(|| "Image inconnue".to_owned())?; + let source_path = managed_path(state, &source.path)?; + if normalized_extension(&source.extension) == target_format.extension() { + return Err("L’image est déjà dans ce format".to_owned()); + } + + emit_conversion_progress(app, image_id, target_format, 0.08, "decoding"); + let decoded = image::open(&source_path) + .map_err(|error| format!("Impossible de décoder l’image: {error}"))?; + emit_conversion_progress(app, image_id, target_format, 0.34, "encoding"); + + let destination = unique_destination(&source_path, target_format)?; + let temporary = temporary_path(&destination); + if let Err(error) = encode_image(&decoded, target_format, &temporary) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + fs::rename(&temporary, &destination) + .map_err(|error| format!("Impossible de finaliser l’image convertie: {error}"))?; + emit_conversion_progress(app, image_id, target_format, 0.82, "indexing"); + + let folder = state + .database + .folders() + .map_err(|error| error.to_string())? + .into_iter() + .find(|folder| folder.id == source.folder_id) + .ok_or_else(|| "Le dossier source n’est plus suivi".to_owned())?; + let asset = match indexer::prepare_asset(&folder, &destination) { + Ok(asset) => asset, + Err(error) => { + let _ = fs::remove_file(&destination); + return Err(error.to_string()); + } + }; + let inherited_embedding = state + .database + .embedding_vector(&source.id) + .map_err(|error| error.to_string())?; + let embeddings = inherited_embedding + .as_ref() + .map(|vector| vec![(asset.id.clone(), vector.clone())]) + .unwrap_or_default(); + + if let Err(error) = state + .database + .save_assets(std::slice::from_ref(&asset), &embeddings) + { + let _ = fs::remove_file(&destination); + return Err(error.to_string()); + } + state + .database + .link_derivative(&asset.id, &source.id, target_format.extension()) + .map_err(|error| error.to_string())?; + if let Some(vector) = inherited_embedding { + state + .vectors + .write() + .upsert([(asset.id.clone(), asset.folder_id.clone(), vector)]); + } + + emit_conversion_progress(app, image_id, target_format, 1.0, "complete"); + let _ = app.emit("library-updated", ()); + Ok(ImageConversionResult { + image: asset, + inherited_semantic_index: !embeddings.is_empty(), + }) +} + +fn emit_conversion_progress( + app: &AppHandle, + source_image_id: &str, + target_format: ImageConversionFormat, + progress: f32, + stage: &'static str, +) { + let _ = app.emit( + "image-conversion-progress", + ImageConversionProgress { + source_image_id: source_image_id.to_owned(), + target_format, + progress, + stage, + }, + ); +} + +fn encode_image( + image: &DynamicImage, + target_format: ImageConversionFormat, + path: &Path, +) -> Result<(), String> { + let file = File::create(path) + .map_err(|error| format!("Impossible de créer l’image convertie: {error}"))?; + let mut writer = BufWriter::new(file); + let result = match target_format { + ImageConversionFormat::Jpg => { + let flattened = flatten_for_jpeg(image); + JpegEncoder::new_with_quality(&mut writer, 92) + .encode_image(&DynamicImage::ImageRgb8(flattened)) + } + ImageConversionFormat::Ico => { + prepare_icon(image).write_to(&mut writer, target_format.image_format()) + } + _ => image.write_to(&mut writer, target_format.image_format()), + }; + result.map_err(|error| format!("Impossible d’encoder l’image: {error}"))?; + writer + .flush() + .map_err(|error| format!("Impossible d’écrire l’image convertie: {error}"))?; + Ok(()) +} + +fn flatten_for_jpeg(image: &DynamicImage) -> image::RgbImage { + let foreground = image.to_rgba8(); + let (width, height) = foreground.dimensions(); + let mut background = RgbaImage::from_pixel(width, height, Rgba([255, 255, 255, 255])); + imageops::overlay(&mut background, &foreground, 0, 0); + DynamicImage::ImageRgba8(background).into_rgb8() +} + +fn prepare_icon(image: &DynamicImage) -> DynamicImage { + const ICON_SIZES: [u32; 6] = [16, 32, 48, 64, 128, 256]; + let resized = image.thumbnail(256, 256).to_rgba8(); + let (width, height) = resized.dimensions(); + let longest_side = width.max(height); + let side = ICON_SIZES + .into_iter() + .find(|candidate| *candidate >= longest_side) + .unwrap_or(256); + let mut canvas = RgbaImage::from_pixel(side, side, Rgba([0, 0, 0, 0])); + imageops::overlay( + &mut canvas, + &resized, + i64::from((side - width) / 2), + i64::from((side - height) / 2), + ); + DynamicImage::ImageRgba8(canvas) +} + +fn unique_destination( + source: &Path, + target_format: ImageConversionFormat, +) -> Result { + let parent = source + .parent() + .ok_or_else(|| "Le dossier de destination est introuvable".to_owned())?; + let stem = source + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| "Le nom du fichier source est invalide".to_owned())?; + let extension = target_format.extension(); + let direct = parent.join(format!("{stem}.{extension}")); + if !direct.exists() { + return Ok(direct); + } + let converted = parent.join(format!("{stem}-converted.{extension}")); + if !converted.exists() { + return Ok(converted); + } + for suffix in 2..=9_999 { + let candidate = parent.join(format!("{stem}-converted-{suffix}.{extension}")); + if !candidate.exists() { + return Ok(candidate); + } + } + Err("Impossible de trouver un nom libre pour l’image convertie".to_owned()) +} + +fn temporary_path(destination: &Path) -> PathBuf { + let file_name = destination + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("converted-image"); + destination.with_file_name(format!(".{file_name}.imagyx-{}", uuid::Uuid::new_v4())) +} + +fn normalized_extension(extension: &str) -> &str { + if extension.eq_ignore_ascii_case("jpeg") { + "jpg" + } else { + extension + } +} + fn managed_path(state: &AppState, path: &str) -> Result { let canonical = PathBuf::from(path) .canonicalize() @@ -128,3 +405,43 @@ fn managed_path(state: &AppState, path: &str) -> Result { } Ok(canonical) } + +#[cfg(test)] +mod tests { + use std::fs; + + use image::{DynamicImage, GenericImageView, Rgba, RgbaImage}; + use tempfile::tempdir; + + use super::{ImageConversionFormat, flatten_for_jpeg, prepare_icon, unique_destination}; + + #[test] + fn destination_never_overwrites_an_existing_conversion() { + let temp = tempdir().expect("temp directory"); + let source = temp.path().join("photo.png"); + fs::write(&source, []).expect("source"); + fs::write(temp.path().join("photo.webp"), []).expect("direct conversion"); + fs::write(temp.path().join("photo-converted.webp"), []).expect("named conversion"); + + let destination = + unique_destination(&source, ImageConversionFormat::Webp).expect("unique destination"); + assert!(destination.ends_with("photo-converted-2.webp")); + } + + #[test] + fn icon_output_is_square_and_never_larger_than_256() { + let source = + DynamicImage::ImageRgba8(RgbaImage::from_pixel(800, 320, Rgba([20, 40, 60, 255]))); + let icon = prepare_icon(&source); + let (width, height) = icon.dimensions(); + assert_eq!(width, height); + assert!(width <= 256); + } + + #[test] + fn jpeg_conversion_flattens_transparency_on_white() { + let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel(1, 1, Rgba([0, 0, 0, 0]))); + let flattened = flatten_for_jpeg(&source); + assert_eq!(flattened.get_pixel(0, 0).0, [255, 255, 255]); + } +} diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index c25f8740..cdeef451 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -1,12 +1,17 @@ use std::{path::PathBuf, sync::Arc}; +#[cfg(debug_assertions)] +use std::time::Instant; + use tauri::State; use crate::{ indexer, - models::{ImageAsset, SearchRequest}, + models::{ImageAsset, SearchPage, SearchRequest}, state::AppState, }; +#[cfg(debug_assertions)] +use crate::tracing; #[tauri::command(rename_all = "camelCase")] pub async fn get_thumbnail( @@ -42,23 +47,96 @@ pub async fn get_thumbnail( .map_err(|error| error.to_string())? } -#[tauri::command] -pub async fn search_images( +async fn execute_search_page( request: SearchRequest, - state: State<'_, Arc>, -) -> Result, String> { - let state = Arc::clone(state.inner()); + diagnostic_id: Option, + state: Arc, +) -> Result { + #[cfg(debug_assertions)] + let received_at = Instant::now(); + tauri::async_runtime::spawn_blocking(move || { - indexer::search( + #[cfg(debug_assertions)] + let diagnostic_label = diagnostic_id.as_deref().unwrap_or("-"); + #[cfg(debug_assertions)] + let queue_wait_ms = received_at.elapsed().as_secs_f64() * 1_000.0; + #[cfg(debug_assertions)] + let execution_started_at = Instant::now(); + #[cfg(debug_assertions)] + let mode = request.mode.as_deref().unwrap_or_else(|| { + if request.query.trim().is_empty() { + "browse" + } else if request.query_vector.is_some() { + "hybrid" + } else { + "lexical" + } + }); + + let limit = request.limit.unwrap_or(2_000).min(50_000); + let offset = request.offset.unwrap_or(0).min(50_000); + + #[cfg(debug_assertions)] + tracing::event( + "search.command.start", + format!( + "id={diagnostic_label} mode={mode} query={:?} folder_id={:?} exclude_image_id={:?} limit={limit} offset={offset} vector_dimensions={} spawn_blocking_queue_ms={queue_wait_ms:.2}", + request.query, + request.folder_id, + request.exclude_image_id, + request.query_vector.as_ref().map_or(0, Vec::len), + ), + ); + + let result = indexer::search_page_with_diagnostics( &state, &request.query, request.query_vector.as_deref(), request.folder_id.as_deref(), - request.limit.unwrap_or(2_000).min(50_000), - request.offset.unwrap_or(0), - ) + request.mode.as_deref(), + request.exclude_image_id.as_deref(), + limit, + offset, + diagnostic_id.as_deref(), + ); + + #[cfg(debug_assertions)] + tracing::event( + "search.command.complete", + format!( + "id={diagnostic_label} mode={mode} query={:?} results={} total_available={} success={} rust_execution_ms={:.2} command_total_ms={:.2}", + request.query, + result.as_ref().map_or(0, |page| page.items.len()), + result.as_ref().map_or(0, |page| page.total), + result.is_ok(), + execution_started_at.elapsed().as_secs_f64() * 1_000.0, + received_at.elapsed().as_secs_f64() * 1_000.0, + ), + ); + + result }) .await .map_err(|error| error.to_string())? .map_err(|error| error.to_string()) } + +#[tauri::command(rename_all = "camelCase")] +pub async fn search_images( + request: SearchRequest, + diagnostic_id: Option, + state: State<'_, Arc>, +) -> Result, String> { + execute_search_page(request, diagnostic_id, Arc::clone(state.inner())) + .await + .map(|page| page.items) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn search_image_page( + request: SearchRequest, + diagnostic_id: Option, + state: State<'_, Arc>, +) -> Result { + execute_search_page(request, diagnostic_id, Arc::clone(state.inner())).await +} diff --git a/src-tauri/src/commands/semantic.rs b/src-tauri/src/commands/semantic.rs index 230ccb0f..44fbd89d 100644 --- a/src-tauri/src/commands/semantic.rs +++ b/src-tauri/src/commands/semantic.rs @@ -170,6 +170,45 @@ pub async fn prepare_ai_images( Ok(Response::new(packed)) } +#[tauri::command(rename_all = "camelCase")] +pub async fn prepare_visual_query_image(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let image = image::open(Path::new(&path)).map_err(|error| error.to_string())?; + let pixels = image + .resize_to_fill( + AI_IMAGE_EDGE, + AI_IMAGE_EDGE, + image::imageops::FilterType::Triangle, + ) + .to_rgb8() + .into_raw(); + Ok(Response::new(pixels)) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command(rename_all = "camelCase")] +pub fn get_image_embedding( + image_id: String, + state: State<'_, Arc>, +) -> Result, String> { + if let Some(vector) = state + .vectors + .read() + .vector(&image_id) + .map(ToOwned::to_owned) + { + return Ok(vector); + } + + state + .database + .embedding_vector(&image_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| "Cette image n’a pas encore d’index visuel".to_owned()) +} + #[tauri::command] pub fn save_embeddings( embeddings: Vec, diff --git a/src-tauri/src/database/derivatives.rs b/src-tauri/src/database/derivatives.rs new file mode 100644 index 00000000..13e96d7a --- /dev/null +++ b/src-tauri/src/database/derivatives.rs @@ -0,0 +1,110 @@ +use chrono::Utc; +use rusqlite::params; + +use crate::AppError; + +use super::Database; + +impl Database { + pub fn link_derivative( + &self, + image_id: &str, + source_image_id: &str, + conversion_format: &str, + ) -> Result<(), AppError> { + let connection = self.connect()?; + connection.execute( + "INSERT INTO image_derivatives ( + image_id, source_image_id, conversion_format, created_at + ) VALUES ( + ?1, + COALESCE( + (SELECT source_image_id FROM image_derivatives WHERE image_id = ?2), + ?2 + ), + ?3, + ?4 + ) + ON CONFLICT(image_id) DO UPDATE SET + source_image_id = excluded.source_image_id, + conversion_format = excluded.conversion_format, + created_at = excluded.created_at", + params![ + image_id, + source_image_id, + conversion_format, + Utc::now().timestamp_millis() + ], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use crate::{database::Database, models::ImageAsset}; + + #[test] + fn keeps_converted_images_in_the_same_derivative_family() { + let temp = tempdir().expect("temp directory"); + let database = Database::new(temp.path().join("imagyx.sqlite3")).expect("database"); + let folder_path = temp.path().join("images"); + fs::create_dir_all(&folder_path).expect("images directory"); + let connection = database.connect().expect("connection"); + connection + .execute( + "INSERT INTO folders (id, name, path, created_at) VALUES (?1, ?2, ?3, 1)", + rusqlite::params!["folder", "Images", folder_path.to_string_lossy()], + ) + .expect("folder"); + drop(connection); + + let asset = |id: &str, extension: &str| ImageAsset { + id: id.to_owned(), + folder_id: "folder".to_owned(), + path: folder_path + .join(format!("{id}.{extension}")) + .to_string_lossy() + .into_owned(), + name: format!("{id}.{extension}"), + extension: extension.to_owned(), + width: 1, + height: 1, + size_bytes: 1, + modified_at: 1, + thumbnail_path: String::new(), + semantic_score: None, + }; + database + .save_assets( + &[ + asset("source", "png"), + asset("webp", "webp"), + asset("avif", "avif"), + ], + &[], + ) + .expect("assets"); + database + .link_derivative("webp", "source", "webp") + .expect("first derivative"); + database + .link_derivative("avif", "webp", "avif") + .expect("second derivative"); + + let root: String = database + .connect() + .expect("connection") + .query_row( + "SELECT source_image_id FROM image_derivatives WHERE image_id = 'avif'", + [], + |row| row.get(0), + ) + .expect("derivative root"); + assert_eq!(root, "source"); + } +} diff --git a/src-tauri/src/database/embeddings.rs b/src-tauri/src/database/embeddings.rs index dfab669d..9530472d 100644 --- a/src-tauri/src/database/embeddings.rs +++ b/src-tauri/src/database/embeddings.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use rusqlite::params; +use rusqlite::{OptionalExtension, params}; use crate::{ AppError, @@ -46,6 +46,18 @@ impl Database { Ok(()) } + pub fn embedding_vector(&self, image_id: &str) -> Result>, AppError> { + let connection = self.connect()?; + let blob = connection + .query_row( + "SELECT vector FROM embeddings WHERE image_id = ?1 AND model = ?2", + params![image_id, MODEL_ID], + |row| row.get::<_, Vec>(0), + ) + .optional()?; + Ok(blob.map(|value| decode_vector(&value))) + } + pub fn vectors(&self) -> Result, AppError> { let connection = self.connect()?; let mut statement = connection.prepare( diff --git a/src-tauri/src/database/migrations.rs b/src-tauri/src/database/migrations.rs index d96bacfc..30eeb2bf 100644 --- a/src-tauri/src/database/migrations.rs +++ b/src-tauri/src/database/migrations.rs @@ -41,6 +41,14 @@ pub(super) fn migrate(database: &Database) -> Result<(), AppError> { updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_embeddings_model ON embeddings(model); + CREATE TABLE IF NOT EXISTS image_derivatives ( + image_id TEXT PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE, + source_image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE, + conversion_format TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_image_derivatives_source + ON image_derivatives(source_image_id, created_at DESC); CREATE VIRTUAL TABLE IF NOT EXISTS images_fts USING fts5( name, path, @@ -81,11 +89,8 @@ pub(super) fn migrate(database: &Database) -> Result<(), AppError> { params![MODEL_ID], )?; - let schema_version: i64 = connection.query_row( - "PRAGMA user_version", - [], - |row| row.get::<_, i64>(0), - )?; + let schema_version: i64 = + connection.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))?; if schema_version < FTS_SCHEMA_VERSION { connection.execute("INSERT INTO images_fts(images_fts) VALUES('rebuild')", [])?; connection.pragma_update(None, "user_version", FTS_SCHEMA_VERSION)?; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 862a18ae..5f23d58b 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -1,5 +1,6 @@ mod assets; mod codec; +mod derivatives; mod embeddings; mod folders; mod migrations; diff --git a/src-tauri/src/database/search.rs b/src-tauri/src/database/search.rs index 5ed1132d..55d8c8a0 100644 --- a/src-tauri/src/database/search.rs +++ b/src-tauri/src/database/search.rs @@ -8,6 +8,20 @@ use super::{ }; impl Database { + pub fn image_count(&self, folder_id: Option<&str>) -> Result { + let connection = self.connect()?; + let count = if let Some(folder_id) = folder_id { + connection.query_row( + "SELECT COUNT(*) FROM images WHERE folder_id = ?1", + params![folder_id], + |row| row.get::<_, i64>(0), + )? + } else { + connection.query_row("SELECT COUNT(*) FROM images", [], |row| row.get::<_, i64>(0))? + }; + Ok(count_to_usize(count)) + } + pub fn recent_images( &self, folder_id: Option<&str>, @@ -104,6 +118,73 @@ impl Database { rows.collect::, _>>().map_err(AppError::from) } + pub fn lexical_search_count( + &self, + fts_query: &str, + folder_id: Option<&str>, + ) -> Result { + if fts_query.is_empty() { + return Ok(0); + } + let connection = self.connect()?; + let count = if let Some(folder_id) = folder_id { + connection.query_row( + "SELECT COUNT(*) + FROM images_fts JOIN images i ON i.rowid = images_fts.rowid + WHERE images_fts MATCH ?1 AND i.folder_id = ?2", + params![fts_query, folder_id], + |row| row.get::<_, i64>(0), + )? + } else { + connection.query_row( + "SELECT COUNT(*) FROM images_fts WHERE images_fts MATCH ?1", + params![fts_query], + |row| row.get::<_, i64>(0), + )? + }; + Ok(count_to_usize(count)) + } + + pub fn hybrid_search_count( + &self, + fts_query: Option<&str>, + folder_id: Option<&str>, + ) -> Result { + let connection = self.connect()?; + let count = match (fts_query, folder_id) { + (Some(fts_query), Some(folder_id)) => connection.query_row( + "SELECT COUNT(*) FROM images i + WHERE i.folder_id = ?2 AND ( + EXISTS (SELECT 1 FROM embeddings e WHERE e.image_id = i.id) + OR i.rowid IN (SELECT rowid FROM images_fts WHERE images_fts MATCH ?1) + )", + params![fts_query, folder_id], + |row| row.get::<_, i64>(0), + )?, + (Some(fts_query), None) => connection.query_row( + "SELECT COUNT(*) FROM images i + WHERE EXISTS (SELECT 1 FROM embeddings e WHERE e.image_id = i.id) + OR i.rowid IN (SELECT rowid FROM images_fts WHERE images_fts MATCH ?1)", + params![fts_query], + |row| row.get::<_, i64>(0), + )?, + (None, Some(folder_id)) => connection.query_row( + "SELECT COUNT(*) FROM images i + WHERE i.folder_id = ?1 + AND EXISTS (SELECT 1 FROM embeddings e WHERE e.image_id = i.id)", + params![folder_id], + |row| row.get::<_, i64>(0), + )?, + (None, None) => connection.query_row( + "SELECT COUNT(*) FROM images i + WHERE EXISTS (SELECT 1 FROM embeddings e WHERE e.image_id = i.id)", + [], + |row| row.get::<_, i64>(0), + )?, + }; + Ok(count_to_usize(count)) + } + pub fn image_exists(&self, image_id: &str) -> Result { self.connect()? .query_row( @@ -129,5 +210,9 @@ fn sqlite_limit(limit: usize) -> i64 { i64::try_from(limit).unwrap_or(i64::MAX) } +fn count_to_usize(count: i64) -> usize { + usize::try_from(count.max(0)).unwrap_or(usize::MAX) +} + #[cfg(test)] mod tests; diff --git a/src-tauri/src/indexer/files.rs b/src-tauri/src/indexer/files.rs index ebd60474..6e41bf3d 100644 --- a/src-tauri/src/indexer/files.rs +++ b/src-tauri/src/indexer/files.rs @@ -32,10 +32,7 @@ pub(super) fn is_changed(path: &Path, existing: &HashMap) -> .is_none_or(|existing| *existing != fingerprint) } -pub(super) fn prepare_asset( - folder: &FollowedFolder, - path: &Path, -) -> Result { +pub(crate) fn prepare_asset(folder: &FollowedFolder, path: &Path) -> Result { let metadata = path.metadata()?; let file_name = path .file_name() @@ -81,7 +78,7 @@ pub fn is_supported_image(path: &Path) -> bool { .is_some_and(|extension| { matches!( extension.as_str(), - "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "tif" | "tiff" | "ico" + "avif" | "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "tif" | "tiff" | "ico" ) }) } @@ -96,6 +93,7 @@ mod tests { #[test] fn recognises_supported_extensions_case_insensitively() { + assert!(is_supported_image(Path::new("photo.AVIF"))); assert!(is_supported_image(Path::new("photo.JPEG"))); assert!(is_supported_image(Path::new("icon.ico"))); assert!(!is_supported_image(Path::new("notes.txt"))); diff --git a/src-tauri/src/indexer/mod.rs b/src-tauri/src/indexer/mod.rs index 871daa02..6999a877 100644 --- a/src-tauri/src/indexer/mod.rs +++ b/src-tauri/src/indexer/mod.rs @@ -4,5 +4,6 @@ mod scan; mod search; pub use crate::vector_store::cosine_similarity; +pub(crate) use files::prepare_asset; pub use scan::{index_folder, pending_assets}; -pub use search::search; +pub use search::search_page_with_diagnostics; diff --git a/src-tauri/src/indexer/search.rs b/src-tauri/src/indexer/search.rs index 67a7261e..20732387 100644 --- a/src-tauri/src/indexer/search.rs +++ b/src-tauri/src/indexer/search.rs @@ -1,54 +1,236 @@ use std::collections::{HashMap, HashSet}; +#[cfg(debug_assertions)] +use std::time::Instant; + use crate::{ AppError, fuzzy::{exact_name_bonus, fts_query, normalize_query, reciprocal_rank}, - models::ImageAsset, + models::SearchPage, state::AppState, tracing, }; -const MAX_SEARCH_RESULTS: usize = 60; -const MIN_LEXICAL_CANDIDATES: usize = 200; -const MAX_LEXICAL_CANDIDATES: usize = 500; -const SEMANTIC_CANDIDATES: usize = 200; +const MAX_QUERY_PAGE_SIZE: usize = 200; +const MAX_SEARCH_WINDOW: usize = 50_000; +const MIN_SEARCH_CANDIDATES: usize = 200; +const CANDIDATE_OVERSAMPLE: usize = 4; -pub fn search( +pub fn search_page_with_diagnostics( state: &AppState, query: &str, query_vector: Option<&[f32]>, folder_id: Option<&str>, + requested_mode: Option<&str>, + exclude_image_id: Option<&str>, requested_limit: usize, requested_offset: usize, -) -> Result, AppError> { + _diagnostic_id: Option<&str>, +) -> Result { + #[cfg(debug_assertions)] + let diagnostic_id = _diagnostic_id; + #[cfg(debug_assertions)] + let total_started_at = Instant::now(); + + let visual_mode = requested_mode == Some("visual") + || (query.trim().is_empty() && query_vector.is_some()); + if visual_mode { + let limit = requested_limit.clamp(1, MAX_QUERY_PAGE_SIZE); + let offset = requested_offset.min(MAX_SEARCH_WINDOW); + let window_end = offset.saturating_add(limit).min(MAX_SEARCH_WINDOW); + let Some(query_vector) = query_vector else { + return Ok(SearchPage { + items: Vec::new(), + total: 0, + }); + }; + + #[cfg(debug_assertions)] + let vector_lock_started_at = Instant::now(); + let vectors = state.vectors.read(); + #[cfg(debug_assertions)] + let vector_lock_wait_ms = vector_lock_started_at.elapsed().as_secs_f64() * 1_000.0; + let excluded_in_scope = exclude_image_id + .is_some_and(|image_id| vectors.contains_in_scope(image_id, folder_id)); + let excluded_count = if excluded_in_scope { 1 } else { 0 }; + let total = vectors.count(folder_id).saturating_sub(excluded_count); + let exclusion_overscan = if exclude_image_id.is_some() { 1 } else { 0 }; + let candidate_limit = window_end + .saturating_add(exclusion_overscan) + .min(MAX_SEARCH_WINDOW); + + #[cfg(debug_assertions)] + let vector_scan_started_at = Instant::now(); + let matches = vectors + .top_k_with_diagnostics(query_vector, folder_id, candidate_limit, _diagnostic_id) + .into_iter() + .filter(|item| exclude_image_id != Some(item.image_id.as_str())) + .skip(offset) + .take(limit) + .collect::>(); + #[cfg(debug_assertions)] + let vector_scan_ms = vector_scan_started_at.elapsed().as_secs_f64() * 1_000.0; + drop(vectors); + + let ordered_ids = matches + .iter() + .map(|item| item.image_id.clone()) + .collect::>(); + #[cfg(debug_assertions)] + let database_started_at = Instant::now(); + let by_id = state + .database + .images_by_ids(&ordered_ids)? + .into_iter() + .map(|image| (image.id.clone(), image)) + .collect::>(); + let items = matches + .into_iter() + .filter_map(|item| { + let mut image = by_id.get(&item.image_id)?.clone(); + image.semantic_score = Some(item.score); + Some(image) + }) + .collect::>(); + #[cfg(debug_assertions)] + let database_ms = database_started_at.elapsed().as_secs_f64() * 1_000.0; + + #[cfg(debug_assertions)] + tracing::event( + "search.pipeline", + format!( + "id={} mode=visual folder_id={folder_id:?} exclude_image_id={exclude_image_id:?} requested_limit={requested_limit} effective_limit={limit} offset={offset} window_end={window_end} total_available={total} query_vector_dimensions={} vector_lock_wait_ms={vector_lock_wait_ms:.2} vector_scan_ms={vector_scan_ms:.2} database_ms={database_ms:.2} results={} total_ms={:.2}", + diagnostic_id.unwrap_or("-"), + query_vector.len(), + items.len(), + total_started_at.elapsed().as_secs_f64() * 1_000.0, + ), + ); + return Ok(SearchPage { items, total }); + } + + #[cfg(debug_assertions)] + let normalize_started_at = Instant::now(); let tokens = normalize_query(query); + #[cfg(debug_assertions)] + let normalize_ms = normalize_started_at.elapsed().as_secs_f64() * 1_000.0; + if tokens.is_empty() { let _trace = tracing::span("search.browse"); - return state - .database - .recent_images(folder_id, requested_limit, requested_offset); + let limit = requested_limit.max(1).min(MAX_SEARCH_WINDOW); + let offset = requested_offset.min(MAX_SEARCH_WINDOW); + #[cfg(debug_assertions)] + let count_started_at = Instant::now(); + let total = state.database.image_count(folder_id)?; + #[cfg(debug_assertions)] + let count_ms = count_started_at.elapsed().as_secs_f64() * 1_000.0; + #[cfg(debug_assertions)] + let database_started_at = Instant::now(); + let items = state.database.recent_images(folder_id, limit, offset)?; + + #[cfg(debug_assertions)] + tracing::event( + "search.pipeline", + format!( + "id={} mode=browse query={query:?} folder_id={folder_id:?} requested_limit={requested_limit} effective_limit={limit} offset={offset} normalize_ms={normalize_ms:.2} count_ms={count_ms:.2} database_ms={:.2} results={} total_available={total} total_ms={:.2}", + diagnostic_id.unwrap_or("-"), + database_started_at.elapsed().as_secs_f64() * 1_000.0, + items.len(), + total_started_at.elapsed().as_secs_f64() * 1_000.0, + ), + ); + return Ok(SearchPage { items, total }); } + #[cfg(debug_assertions)] + let mode = if query_vector.is_some() { + "hybrid" + } else { + "lexical" + }; let _trace = if query_vector.is_some() { tracing::span("search.hybrid") } else { tracing::span("search.lexical") }; - let limit = requested_limit.clamp(1, MAX_SEARCH_RESULTS); - let lexical_limit = (limit * 4) - .max(MIN_LEXICAL_CANDIDATES) - .min(MAX_LEXICAL_CANDIDATES); - let lexical = match fts_query(query) { - Some(query) => state.database.lexical_search(&query, folder_id, lexical_limit)?, + + let limit = requested_limit.clamp(1, MAX_QUERY_PAGE_SIZE); + let offset = requested_offset.min(MAX_SEARCH_WINDOW); + let window_end = offset.saturating_add(limit).min(MAX_SEARCH_WINDOW); + let candidate_limit = window_end + .saturating_mul(CANDIDATE_OVERSAMPLE) + .max(MIN_SEARCH_CANDIDATES) + .min(MAX_SEARCH_WINDOW); + + #[cfg(debug_assertions)] + let fts_started_at = Instant::now(); + let prepared_fts_query = fts_query(query); + #[cfg(debug_assertions)] + let fts_prepare_ms = fts_started_at.elapsed().as_secs_f64() * 1_000.0; + + #[cfg(debug_assertions)] + let count_started_at = Instant::now(); + let total = if query_vector.is_some() { + state + .database + .hybrid_search_count(prepared_fts_query.as_deref(), folder_id)? + } else { + match prepared_fts_query.as_deref() { + Some(prepared_query) => state.database.lexical_search_count(prepared_query, folder_id)?, + None => 0, + } + }; + #[cfg(debug_assertions)] + let count_ms = count_started_at.elapsed().as_secs_f64() * 1_000.0; + + #[cfg(debug_assertions)] + let lexical_started_at = Instant::now(); + let lexical = match prepared_fts_query.as_deref() { + Some(prepared_query) => state + .database + .lexical_search(prepared_query, folder_id, candidate_limit)?, None => Vec::new(), }; + #[cfg(debug_assertions)] + let lexical_ms = lexical_started_at.elapsed().as_secs_f64() * 1_000.0; + #[cfg(debug_assertions)] + let lexical_count = lexical.len(); + + #[cfg(debug_assertions)] + let mut vector_lock_wait_ms = 0.0; + #[cfg(debug_assertions)] + let mut vector_scan_ms = 0.0; + #[cfg(debug_assertions)] + let mut vector_store_len = 0usize; + let semantic = query_vector.map_or_else(Vec::new, |query_vector| { - state - .vectors - .read() - .top_k(query_vector, folder_id, SEMANTIC_CANDIDATES) + #[cfg(debug_assertions)] + let vector_lock_started_at = Instant::now(); + let vectors = state.vectors.read(); + #[cfg(debug_assertions)] + { + vector_lock_wait_ms = vector_lock_started_at.elapsed().as_secs_f64() * 1_000.0; + vector_store_len = vectors.len(); + } + #[cfg(debug_assertions)] + let vector_scan_started_at = Instant::now(); + let matches = vectors.top_k_with_diagnostics( + query_vector, + folder_id, + candidate_limit, + _diagnostic_id, + ); + #[cfg(debug_assertions)] + { + vector_scan_ms = vector_scan_started_at.elapsed().as_secs_f64() * 1_000.0; + } + matches }); + #[cfg(debug_assertions)] + let semantic_count = semantic.len(); + #[cfg(debug_assertions)] + let rank_maps_started_at = Instant::now(); let lexical_ranks = lexical .iter() .enumerate() @@ -69,9 +251,24 @@ pub fn search( .filter(|item| !lexical_ids.contains(item.image_id.as_str())) .map(|item| item.image_id.clone()) .collect::>(); + #[cfg(debug_assertions)] + let rank_maps_ms = rank_maps_started_at.elapsed().as_secs_f64() * 1_000.0; + #[cfg(debug_assertions)] + let missing_count = missing_ids.len(); + + #[cfg(debug_assertions)] + let missing_lookup_started_at = Instant::now(); + let missing_assets = state.database.images_by_ids(&missing_ids)?; + #[cfg(debug_assertions)] + let missing_lookup_ms = missing_lookup_started_at.elapsed().as_secs_f64() * 1_000.0; + let mut candidates = lexical; - candidates.extend(state.database.images_by_ids(&missing_ids)?); + candidates.extend(missing_assets); + #[cfg(debug_assertions)] + let candidate_count = candidates.len(); + #[cfg(debug_assertions)] + let ranking_started_at = Instant::now(); let mut ranked = candidates .into_iter() .map(|mut asset| { @@ -84,11 +281,56 @@ pub fn search( (asset, score) }) .collect::>(); + #[cfg(debug_assertions)] + let ranking_ms = ranking_started_at.elapsed().as_secs_f64() * 1_000.0; + + #[cfg(debug_assertions)] + let sort_started_at = Instant::now(); ranked.sort_by(|(left_asset, left_score), (right_asset, right_score)| { right_score .total_cmp(left_score) .then_with(|| right_asset.modified_at.cmp(&left_asset.modified_at)) }); - ranked.truncate(limit); - Ok(ranked.into_iter().map(|(asset, _)| asset).collect()) + let page = ranked + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + + #[cfg(debug_assertions)] + let top_results = page + .iter() + .take(8) + .enumerate() + .map(|(index, (asset, score))| { + format!( + "{}:{}:{}:{score:.4}", + offset + index + 1, + asset.id, + asset.name, + ) + }) + .collect::>() + .join(" | "); + + let items = page + .into_iter() + .map(|(asset, _)| asset) + .collect::>(); + #[cfg(debug_assertions)] + let sort_ms = sort_started_at.elapsed().as_secs_f64() * 1_000.0; + + #[cfg(debug_assertions)] + tracing::event( + "search.pipeline", + format!( + "id={} mode={mode} query={query:?} folder_id={folder_id:?} requested_limit={requested_limit} effective_limit={limit} offset={offset} window_end={window_end} candidate_limit={candidate_limit} total_available={total} query_vector_dimensions={} normalize_ms={normalize_ms:.2} fts_prepare_ms={fts_prepare_ms:.2} count_ms={count_ms:.2} lexical_db_ms={lexical_ms:.2} lexical_candidates={lexical_count} vector_lock_wait_ms={vector_lock_wait_ms:.2} vector_scan_ms={vector_scan_ms:.2} vector_store_len={vector_store_len} semantic_candidates={semantic_count} rank_maps_ms={rank_maps_ms:.2} missing_semantic_ids={missing_count} missing_lookup_ms={missing_lookup_ms:.2} merged_candidates={candidate_count} ranking_ms={ranking_ms:.2} sort_page_ms={sort_ms:.2} results={} total_ms={:.2} top_results={top_results:?}", + diagnostic_id.unwrap_or("-"), + query_vector.map_or(0, |vector| vector.len()), + items.len(), + total_started_at.elapsed().as_secs_f64() * 1_000.0, + ), + ); + + Ok(SearchPage { items, total }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0e0a981..af8cbe48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -162,13 +162,17 @@ pub fn run() { commands::folders::index_folder, commands::folders::pending_images, commands::semantic::prepare_ai_images, + commands::semantic::prepare_visual_query_image, + commands::semantic::get_image_embedding, commands::semantic::save_embeddings, commands::semantic::explain_results, commands::semantic::top_image_tags, commands::search::get_thumbnail, commands::search::search_images, + commands::search::search_image_page, commands::files::open_in_file_manager, commands::files::copy_image_to_clipboard, + commands::files::convert_image, commands::files::open_in_imagyx, onboarding::open_onboarding, commands::spotlight::set_spotlight_expanded, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0a367b87..f6c0d4d0 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -71,6 +71,15 @@ pub struct SearchRequest { pub limit: Option, pub offset: Option, pub query_vector: Option>, + pub mode: Option, + pub exclude_image_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchPage { + pub items: Vec, + pub total: usize, } #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/vector_store/mod.rs b/src-tauri/src/vector_store/mod.rs index d3f9347d..5874f466 100644 --- a/src-tauri/src/vector_store/mod.rs +++ b/src-tauri/src/vector_store/mod.rs @@ -43,6 +43,24 @@ impl VectorStore { self.entries.len() } + pub fn count(&self, folder_id: Option<&str>) -> usize { + match folder_id { + Some(folder_id) => self + .entries + .iter() + .filter(|entry| entry.folder_id == folder_id) + .count(), + None => self.entries.len(), + } + } + + pub fn contains_in_scope(&self, image_id: &str, folder_id: Option<&str>) -> bool { + let Some(position) = self.positions.get(image_id).copied() else { + return false; + }; + folder_id.is_none_or(|folder_id| self.entries[position].folder_id == folder_id) + } + pub fn clear(&mut self) { self.dimensions = 0; self.vectors.clear(); diff --git a/src-tauri/src/vector_store/search.rs b/src-tauri/src/vector_store/search.rs index 39cc1f82..fb42d615 100644 --- a/src-tauri/src/vector_store/search.rs +++ b/src-tauri/src/vector_store/search.rs @@ -3,8 +3,14 @@ use std::{ collections::BinaryHeap, }; +#[cfg(debug_assertions)] +use std::time::Instant; + use rayon::prelude::*; +#[cfg(debug_assertions)] +use crate::tracing; + use super::{VectorMatch, VectorStore, maintenance::normalize_in_place}; impl VectorStore { @@ -14,19 +20,64 @@ impl VectorStore { folder_id: Option<&str>, limit: usize, ) -> Vec { + self.top_k_with_diagnostics(query, folder_id, limit, None) + } + + pub fn top_k_with_diagnostics( + &self, + query: &[f32], + folder_id: Option<&str>, + limit: usize, + _diagnostic_id: Option<&str>, + ) -> Vec { + #[cfg(debug_assertions)] + let diagnostic_id = _diagnostic_id; + #[cfg(debug_assertions)] + let total_started_at = Instant::now(); + if limit == 0 || query.len() != self.dimensions || self.dimensions == 0 { + #[cfg(debug_assertions)] + tracing::event( + "search.vector", + format!( + "id={} status=skipped reason=invalid_input query_dimensions={} store_dimensions={} vectors={} limit={limit} folder_id={folder_id:?}", + diagnostic_id.unwrap_or("-"), + query.len(), + self.dimensions, + self.entries.len(), + ), + ); return Vec::new(); } + + #[cfg(debug_assertions)] + let normalize_started_at = Instant::now(); let mut normalized_query = query.to_vec(); normalize_in_place(&mut normalized_query); + #[cfg(debug_assertions)] + let normalize_ms = normalize_started_at.elapsed().as_secs_f64() * 1_000.0; + if normalized_query .iter() .all(|value| value.abs() <= f32::EPSILON) { + #[cfg(debug_assertions)] + tracing::event( + "search.vector", + format!( + "id={} status=skipped reason=zero_vector query_dimensions={} vectors={} normalize_ms={normalize_ms:.2} total_ms={:.2}", + diagnostic_id.unwrap_or("-"), + query.len(), + self.entries.len(), + total_started_at.elapsed().as_secs_f64() * 1_000.0, + ), + ); return Vec::new(); } let dimensions = self.dimensions; + #[cfg(debug_assertions)] + let scan_started_at = Instant::now(); let heap = self .vectors .par_chunks_exact(dimensions) @@ -61,7 +112,11 @@ impl VectorStore { left }, ); + #[cfg(debug_assertions)] + let parallel_scan_ms = scan_started_at.elapsed().as_secs_f64() * 1_000.0; + #[cfg(debug_assertions)] + let materialize_started_at = Instant::now(); let mut matches = heap .into_iter() .map(|Reverse(candidate)| VectorMatch { @@ -70,6 +125,24 @@ impl VectorStore { }) .collect::>(); matches.sort_by(|left, right| right.score.total_cmp(&left.score)); + #[cfg(debug_assertions)] + let materialize_sort_ms = materialize_started_at.elapsed().as_secs_f64() * 1_000.0; + + #[cfg(debug_assertions)] + tracing::event( + "search.vector", + format!( + "id={} status=complete query_dimensions={} store_dimensions={} vectors={} folder_id={folder_id:?} requested_results={limit} results={} normalize_ms={normalize_ms:.2} parallel_scan_ms={parallel_scan_ms:.2} materialize_sort_ms={materialize_sort_ms:.2} best_score={:.4} total_ms={:.2}", + diagnostic_id.unwrap_or("-"), + query.len(), + self.dimensions, + self.entries.len(), + matches.len(), + matches.first().map_or(0.0, |item| item.score), + total_started_at.elapsed().as_secs_f64() * 1_000.0, + ), + ); + matches } } diff --git a/src/App.vue b/src/App.vue index 51ae94b9..29458621 100644 --- a/src/App.vue +++ b/src/App.vue @@ -21,6 +21,7 @@ import { useShortcutStore } from "./stores/shortcut"; import { useThemeStore } from "./stores/theme"; import { semanticRuntime } from "./services/semantic"; import { registerSemanticQueryProvider } from "./services/semantic-provider"; +import { spotlightSearchTiming } from "./config/spotlight-search"; import { capitalize, useTagTypewriter } from "./useTagTypewriter"; import { debounce, perfLog } from "./utils"; import { folderQuerySuggestions, formatFolderQuery, parseFolderQuery } from "./utils/folder-query"; @@ -77,7 +78,7 @@ const searchLater = debounce(() => { store.selectedFolderId = parsed.folder?.id ?? null; store.setQuery(parsed.query); void store.refreshImages(); -}, 180); +}, spotlightSearchTiming.lexicalDebounceMs); watch(localQuery, () => { folderSuggestionIndex.value = 0; @@ -144,13 +145,12 @@ function handleTypeToSearch(event: KeyboardEvent) { void nextTick(() => searchHeader.value?.focusSearch()); } -async function openImageFromSpotlight(imageId: string) { +function openImageFromSpotlight(image: ImageAsset) { localQuery.value = ""; store.query = ""; store.selectedFolderId = null; - await store.refreshImages(); - previewImage.value = - store.images.find((image) => image.id === imageId) ?? null; + previewImage.value = image; + void store.refreshImages(); } function scheduleEarlyTextWarmup() { @@ -201,8 +201,8 @@ onMounted(async () => { unlistenResumeIndexing, unlistenAddFolder, ] = await Promise.all([ - listen("open-image-requested", (event) => { - void openImageFromSpotlight(event.payload); + listen("open-image-requested", (event) => { + openImageFromSpotlight(event.payload); }), listen("open-onboarding-requested", () => onboarding.show()), listen("pause-indexing-requested", () => pauseIndexing()), @@ -251,7 +251,7 @@ onBeforeUnmount(() => { :model-ready="store.appInfo?.aiReady ?? false" :model-backend="store.appInfo?.aiBackend ?? 'Automatic'" :searching="store.semanticSearching" - :result-count="store.images.length" + :result-count="store.totalResults" :folder-suggestions="folderSuggestions" :folder-suggestion-index="folderSuggestionIndex" @folder-select="selectFolderSuggestion" diff --git a/src/api/tauri.ts b/src/api/tauri.ts index 8dff6383..99e8b54b 100644 --- a/src/api/tauri.ts +++ b/src/api/tauri.ts @@ -1,16 +1,202 @@ +import { reactive } from 'vue' import { convertFileSrc, invoke } from '@tauri-apps/api/core' import type { AppInfo, FolderIndexCoverage, FollowedFolder, ImageAsset, + ImageConversionFormat, + ImageConversionResult, ImageEmbedding, ImageExplanation, ModelDownloadProgress, QueryConcept, RuntimeStats, + SearchPage, SearchRequest, } from '../types' +import { perfLog } from '../utils' +import { nativeDialogIsOpen } from '../services/native-dialog-state' +import { spotlightSearchPagination } from '../services/spotlight-search-pagination' +import { visualSearchSession } from '../services/visual-search-session' + +type SearchMode = 'browse' | 'lexical' | 'hybrid' | 'visual' + +let searchDiagnosticSequence = 0 + +function searchMode(request: SearchRequest): SearchMode { + if (request.mode === 'visual') return 'visual' + if (!request.query.trim()) return 'browse' + return request.queryVector?.length ? 'hybrid' : 'lexical' +} + +function nextSearchDiagnosticId(mode: SearchMode): string | undefined { + if (!import.meta.env.DEV) return undefined + searchDiagnosticSequence += 1 + const surface = typeof document === 'undefined' + ? 'unknown' + : document.documentElement.dataset.window ?? 'unknown' + return `${surface}-${mode}-${Date.now().toString(36)}-${searchDiagnosticSequence}` +} + +function deferSearchResultLog(callback: () => void): void { + if (!import.meta.env.DEV) return + window.requestAnimationFrame(() => { + window.setTimeout(callback, 0) + }) +} + +function logSearchResults( + diagnosticId: string, + mode: SearchMode, + request: SearchRequest, + page: SearchPage, + durationMs: number, +): void { + if (!import.meta.env.DEV) return + console.groupCollapsed( + `[Imagyx][Search][${diagnosticId}] ${mode} · ${page.items.length}/${page.total} résultat(s) · ${durationMs.toFixed(1)} ms`, + ) + console.log('Request', { + diagnosticId, + mode, + query: request.query, + folderId: request.folderId ?? null, + limit: request.limit ?? 2_000, + offset: request.offset ?? 0, + queryVectorDimensions: request.queryVector?.length ?? 0, + excludeImageId: request.excludeImageId ?? null, + returned: page.items.length, + total: page.total, + }) + console.table(page.items.map((image, index) => ({ + rank: (request.offset ?? 0) + index + 1, + id: image.id, + name: image.name, + format: image.extension.toLocaleUpperCase(), + semanticScore: image.semanticScore == null + ? null + : Number(image.semanticScore.toFixed(4)), + folderId: image.folderId, + }))) + console.groupEnd() +} + +async function invokeSearchPage( + request: SearchRequest, + trackAsInitialSearch: boolean, +): Promise { + const sessionRequest = visualSearchSession.requestForActiveSession(request) + const mode = searchMode(sessionRequest) + const diagnosticId = import.meta.env.DEV + ? sessionRequest.diagnosticId ?? nextSearchDiagnosticId(mode) + : undefined + const normalizedRequest: SearchRequest = { + ...sessionRequest, + limit: sessionRequest.limit ?? 2_000, + offset: sessionRequest.offset ?? 0, + diagnosticId, + } + // Spotlight keeps the synthetic visual token as its cache/session identity, + // while carrying the resolved vector and native mode for deeper pages. + const paginationRequest: SearchRequest = { + ...request, + limit: normalizedRequest.limit, + offset: normalizedRequest.offset, + diagnosticId, + queryVector: normalizedRequest.queryVector, + mode: normalizedRequest.mode, + excludeImageId: normalizedRequest.excludeImageId, + } + const paginationContext = trackAsInitialSearch + ? spotlightSearchPagination.beginInitialSearch(paginationRequest) + : null + const startedAt = import.meta.env.DEV ? performance.now() : 0 + + if (import.meta.env.DEV && diagnosticId) { + console.info(`[Imagyx][Search][${diagnosticId}] start`, { + mode, + query: normalizedRequest.query, + sessionQuery: request.query, + folderId: normalizedRequest.folderId ?? null, + limit: normalizedRequest.limit, + offset: normalizedRequest.offset, + queryVectorDimensions: normalizedRequest.queryVector?.length ?? 0, + excludeImageId: normalizedRequest.excludeImageId ?? null, + }) + } + + try { + const page = await invoke('search_image_page', { + request: { + query: normalizedRequest.query, + folderId: normalizedRequest.folderId ?? null, + limit: normalizedRequest.limit, + offset: normalizedRequest.offset, + queryVector: normalizedRequest.queryVector ?? null, + mode: normalizedRequest.mode ?? null, + excludeImageId: normalizedRequest.excludeImageId ?? null, + }, + diagnosticId: import.meta.env.DEV ? diagnosticId ?? null : null, + }) + const items = paginationContext + ? reactive(page.items) as ImageAsset[] + : page.items + const normalizedPage = { items, total: page.total } + + if (paginationContext) { + spotlightSearchPagination.completeInitialSearch( + paginationContext, + paginationRequest, + normalizedPage, + items, + ) + } + + if (import.meta.env.DEV) { + const durationMs = performance.now() - startedAt + perfLog('SearchIPC', `${mode} Rust round trip`, durationMs, { + diagnosticId, + query: normalizedRequest.query, + sessionQuery: request.query, + offset: normalizedRequest.offset, + results: items.length, + total: normalizedPage.total, + queryVectorDimensions: normalizedRequest.queryVector?.length ?? 0, + }) + if (diagnosticId) { + deferSearchResultLog(() => logSearchResults( + diagnosticId, + mode, + normalizedRequest, + normalizedPage, + durationMs, + )) + } + } + return normalizedPage + } catch (error) { + if (import.meta.env.DEV) { + const durationMs = performance.now() - startedAt + console.error(`[Imagyx][Search][${diagnosticId ?? 'unknown'}] failed after ${durationMs.toFixed(1)} ms`, { + mode, + query: normalizedRequest.query, + sessionQuery: request.query, + offset: normalizedRequest.offset, + error, + }) + } + throw error + } +} + +async function searchImages(request: SearchRequest): Promise { + return (await invokeSearchPage(request, true)).items +} + +async function searchImagePage(request: SearchRequest): Promise { + return invokeSearchPage(request, false) +} export const imagyxApi = { appInfo: () => invoke('get_app_info'), @@ -35,6 +221,10 @@ export const imagyxApi = { invoke('pending_images', { folderId: folderId ?? null }), prepareAiImages: (imageIds: string[], batchId: string) => invoke('prepare_ai_images', { imageIds, batchId }), + prepareVisualQueryImage: (path: string) => + invoke('prepare_visual_query_image', { path }), + imageEmbedding: (imageId: string) => + invoke('get_image_embedding', { imageId }), saveEmbeddings: (embeddings: ImageEmbedding[]) => invoke('save_embeddings', { embeddings }), explainResults: (imageIds: string[], concepts: QueryConcept[]) => @@ -47,23 +237,21 @@ export const imagyxApi = { path: image.path, modifiedAt: image.modifiedAt, }), - search: (request: SearchRequest) => - invoke('search_images', { - request: { - query: request.query, - folderId: request.folderId ?? null, - limit: request.limit ?? 2_000, - offset: request.offset ?? 0, - queryVector: request.queryVector ?? null, - }, - }), + search: searchImages, + searchPage: searchImagePage, openInFileManager: (path: string, reveal = false) => invoke('open_in_file_manager', { path, reveal }), copyImage: (path: string) => invoke('copy_image_to_clipboard', { path }), + convertImage: (imageId: string, targetFormat: ImageConversionFormat) => + invoke('convert_image', { imageId, targetFormat }), openInImagyx: (imageId: string) => invoke('open_in_imagyx', { imageId }), openOnboarding: () => invoke('open_onboarding'), setSpotlightExpanded: (expanded: boolean) => invoke('set_spotlight_expanded', { expanded }), - hideSpotlight: () => invoke('hide_spotlight'), + hideSpotlight: () => ( + nativeDialogIsOpen() + ? Promise.resolve() + : invoke('hide_spotlight') + ), fileUrl: (path: string) => convertFileSrc(path), } diff --git a/src/components/ImageGrid.vue b/src/components/ImageGrid.vue index 6764be2b..fd1a8a7d 100644 --- a/src/components/ImageGrid.vue +++ b/src/components/ImageGrid.vue @@ -1,8 +1,9 @@