From 53e16f9c7411cf1b1c13b5db8fe55b301b8df409 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:53:55 +0200 Subject: [PATCH 001/153] enable AVIF image conversion --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9e6cc48..b4fd52f 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"] } From e1bd174434ad5f744d0cc0a776e0364012457de4 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:54:15 +0200 Subject: [PATCH 002/153] index AVIF images and expose asset preparation --- src-tauri/src/indexer/files.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/indexer/files.rs b/src-tauri/src/indexer/files.rs index ebd6047..cfe1889 100644 --- a/src-tauri/src/indexer/files.rs +++ b/src-tauri/src/indexer/files.rs @@ -32,7 +32,7 @@ pub(super) fn is_changed(path: &Path, existing: &HashMap) -> .is_none_or(|existing| *existing != fingerprint) } -pub(super) fn prepare_asset( +pub(crate) fn prepare_asset( folder: &FollowedFolder, path: &Path, ) -> Result { @@ -81,7 +81,16 @@ 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 +105,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"))); From 97a525ed17b1790b681885203c7f71e684deb130 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:54:22 +0200 Subject: [PATCH 003/153] expose prepared assets to conversion --- src-tauri/src/indexer/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/indexer/mod.rs b/src-tauri/src/indexer/mod.rs index 871daa0..c0e8dac 100644 --- a/src-tauri/src/indexer/mod.rs +++ b/src-tauri/src/indexer/mod.rs @@ -3,6 +3,7 @@ mod progress; mod scan; mod search; +pub(crate) use files::prepare_asset; pub use crate::vector_store::cosine_similarity; pub use scan::{index_folder, pending_assets}; pub use search::search; From 0f5206a0b7fb13a903a97adf4577e776ffb65c00 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:54:38 +0200 Subject: [PATCH 004/153] persist converted image relationships --- src-tauri/src/database/derivatives.rs | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src-tauri/src/database/derivatives.rs diff --git a/src-tauri/src/database/derivatives.rs b/src-tauri/src/database/derivatives.rs new file mode 100644 index 0000000..7bd14d7 --- /dev/null +++ b/src-tauri/src/database/derivatives.rs @@ -0,0 +1,103 @@ +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"); + } +} From adb164d7622ec753dcf7c29f479b4f0090013c04 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:54:47 +0200 Subject: [PATCH 005/153] register derivative database support --- src-tauri/src/database/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 862a18a..5f23d58 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; From 5bb1103a1e3cdc6b1afe3dfce9c3912f61e0362f Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:55:12 +0200 Subject: [PATCH 006/153] add converted image relationship schema --- src-tauri/src/database/migrations.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src-tauri/src/database/migrations.rs b/src-tauri/src/database/migrations.rs index d96bacf..d544acf 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, From 4046c39bf08413376eba1003b34485b2a9d65c85 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:56:32 +0200 Subject: [PATCH 007/153] add native multi-format image conversion --- src-tauri/src/commands/files.rs | 321 +++++++++++++++++++++++++++++++- 1 file changed, 320 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 720ff16..2b1767f 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -1,14 +1,70 @@ use std::{ borrow::Cow, + fs::{self, File}, + io::{BufWriter, Write}, path::{Path, PathBuf}, process::Command, sync::Arc, }; use arboard::{Clipboard, ImageData}; +use image::{ + DynamicImage, GenericImageView, 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, 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( @@ -84,6 +140,21 @@ 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, @@ -112,6 +183,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 + .vectors + .read() + .vector(&source.id) + .map(<[f32]>::to_vec); + 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 +398,52 @@ 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]); + } +} From 5078f7b453c5340fccee2c0a25ef8c0487c2326a Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:56:56 +0200 Subject: [PATCH 008/153] register image conversion command --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0e0a98..81792ef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -169,6 +169,7 @@ pub fn run() { commands::search::search_images, 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, From 932ca9b9759db5e1fb4c5365a17df34f5860729a Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:57:11 +0200 Subject: [PATCH 009/153] add image conversion frontend types --- src/types.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/types.ts b/src/types.ts index 0c39484..ce33847 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,22 @@ export interface ImageAsset { semanticMatches?: SemanticMatch[] } +export type ImageConversionFormat = 'avif' | 'webp' | 'png' | 'jpg' | 'ico' + +export type ImageConversionStage = 'decoding' | 'encoding' | 'indexing' | 'complete' + +export interface ImageConversionProgress { + sourceImageId: string + targetFormat: ImageConversionFormat + progress: number + stage: ImageConversionStage +} + +export interface ImageConversionResult { + image: ImageAsset + inheritedSemanticIndex: boolean +} + export interface ImageEmbedding { imageId: string vector: number[] From cc0931b79a48faa31d80090fc0d6518188ca5163 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:57:27 +0200 Subject: [PATCH 010/153] expose image conversion through Tauri API --- src/api/tauri.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/api/tauri.ts b/src/api/tauri.ts index 8dff638..e848a29 100644 --- a/src/api/tauri.ts +++ b/src/api/tauri.ts @@ -4,6 +4,8 @@ import type { FolderIndexCoverage, FollowedFolder, ImageAsset, + ImageConversionFormat, + ImageConversionResult, ImageEmbedding, ImageExplanation, ModelDownloadProgress, @@ -60,6 +62,8 @@ export const imagyxApi = { 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) => From 16358e906d688211f2491147f363bb5be5244d06 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:58:53 +0200 Subject: [PATCH 011/153] add Spotlight conversion workflow --- .../Spotlight/SpotlightConverter.vue | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 src/components/Spotlight/SpotlightConverter.vue diff --git a/src/components/Spotlight/SpotlightConverter.vue b/src/components/Spotlight/SpotlightConverter.vue new file mode 100644 index 0000000..6defaa0 --- /dev/null +++ b/src/components/Spotlight/SpotlightConverter.vue @@ -0,0 +1,436 @@ + + + + + From 067b94f922bdb6bbdc0722c123d9dcea545c9ec2 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:00:16 +0200 Subject: [PATCH 012/153] add conversion action to Spotlight results --- src/components/Spotlight/SpotlightResults.vue | 218 ++++++++++++------ 1 file changed, 148 insertions(+), 70 deletions(-) diff --git a/src/components/Spotlight/SpotlightResults.vue b/src/components/Spotlight/SpotlightResults.vue index faf50e9..4cb087b 100644 --- a/src/components/Spotlight/SpotlightResults.vue +++ b/src/components/Spotlight/SpotlightResults.vue @@ -7,12 +7,14 @@ import { FolderOpen, FolderPlus, LoaderCircle, + RefreshCw, Search, } from '@lucide/vue' import type { FolderIndexCoverage, ImageAsset } from '../../types' import ThumbnailImage from '../ThumbnailImage.vue' import Button from '../ui/Button/Button.vue' import KbdChip from '../ui/KbdChip/KbdChip.vue' +import SpotlightConverter from './SpotlightConverter.vue' import SpotlightIndexProgress from './SpotlightIndexProgress.vue' import SpotlightIndexCoverageNotice from './SpotlightIndexCoverageNotice.vue' import type { SpotlightIndexJob } from './types' @@ -51,6 +53,8 @@ const RESULT_ROW_HEIGHT = 72 const RESULT_OVERSCAN = 4 const viewport = ref(null) const resultList = ref(null) +const converterView = ref | null>(null) +const converterSource = ref(null) const canScrollDown = ref(false) const scrollTop = ref(0) const viewportHeight = ref(0) @@ -87,6 +91,10 @@ watch( ) function updateScrollState() { + if (converterSource.value) { + canScrollDown.value = false + return + } const element = viewport.value if (!element) { canScrollDown.value = false @@ -108,6 +116,7 @@ function scheduleScrollState() { } function scrollToIndex(index: number) { + if (converterSource.value) return void nextTick(() => { const element = viewport.value const list = resultList.value @@ -122,15 +131,61 @@ function scrollToIndex(index: number) { }) } +function openConverter(image: ImageAsset) { + converterSource.value = image + canScrollDown.value = false +} + +function closeConverter() { + converterSource.value = null + void nextTick(scheduleScrollState) +} + +function keepConvertedBesideSource(image: ImageAsset) { + const source = converterSource.value + if (!source) return + const existingIndex = props.results.findIndex((result) => result.id === image.id) + if (existingIndex >= 0) { + props.results[existingIndex] = image + emit('select', existingIndex) + return + } + const sourceIndex = props.results.findIndex((result) => result.id === source.id) + const insertAt = sourceIndex >= 0 ? sourceIndex + 1 : props.results.length + props.results.splice(insertAt, 0, image) + emit('select', insertAt) +} + +function handleWindowKeydown(event: KeyboardEvent) { + if (converterSource.value) { + const handled = converterView.value?.handleKeydown(event) ?? false + if (handled) { + event.preventDefault() + event.stopImmediatePropagation() + } + return + } + if (!(event.ctrlKey || event.metaKey) || !event.shiftKey) return + const key = event.key.toLocaleLowerCase() + if (key !== 'c' && event.code !== 'KeyC') return + const image = props.results[props.selectedIndex] + if (!image) return + event.preventDefault() + event.stopImmediatePropagation() + openConverter(image) +} + onMounted(() => { resizeObserver = new ResizeObserver(scheduleScrollState) if (viewport.value) resizeObserver.observe(viewport.value) + window.addEventListener('keydown', handleWindowKeydown, { capture: true }) scheduleScrollState() }) onBeforeUnmount(() => { if (scrollFrame) window.cancelAnimationFrame(scrollFrame) resizeObserver?.disconnect() + window.removeEventListener('keydown', handleWindowKeydown, { capture: true }) }) defineExpose({ scrollToIndex }) @@ -138,7 +193,23 @@ defineExpose({ scrollToIndex }) @@ -374,6 +462,7 @@ defineExpose({ scrollToIndex }) scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--border-strong) 78%, transparent) transparent; } +.spotlight-results--with-actions { padding-bottom: 86px; } .spotlight-library-loading { display: grid; place-items: center; @@ -488,29 +577,107 @@ defineExpose({ scrollToIndex }) font-size: 10px; font-weight: 700; font-variant-numeric: tabular-nums; - transition: opacity 120ms ease, transform 160ms ease; } -.spotlight-result--selected .spotlight-score { opacity: 0; transform: translate3d(7px, 0, 0); pointer-events: none; } -.spotlight-actions { +.spotlight-action-dock { position: absolute; - right: 8px; - top: 50%; - z-index: 2; + right: 0; + bottom: 0; + left: 0; + z-index: 6; + padding: 30px 13px 10px; + pointer-events: none; + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--surface-elevated) 60%, transparent) 34%, + color-mix(in srgb, var(--surface-elevated) 97%, transparent) 78% + ); + box-shadow: inset 0 -13px 17px -17px rgb(2 6 23 / 0.32); + backdrop-filter: blur(13px) saturate(1.08); +} +.spotlight-action-dock__bar { display: flex; align-items: center; - gap: 5px; - padding-left: 34px; - opacity: 0; - transform: translate3d(0, -50%, 0); - pointer-events: none; - background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--primary-soft) 90%, var(--surface)) 19%); - border-radius: var(--radius-md); - transition: opacity 80ms ease; -} -.spotlight-result--selected .spotlight-actions, -.spotlight-result:focus-within .spotlight-actions { opacity: 1; pointer-events: auto; will-change: opacity; } -.spotlight-action-button { min-width: 62px; min-height: 31px; padding-inline: 7px; border-radius: 9px; font-size: 10px; } -.spotlight-action-button--success { animation: action-success 280ms cubic-bezier(0.16, 1, 0.3, 1) both; } + justify-content: space-between; + gap: 10px; + width: 100%; + min-height: 48px; + padding: 6px; + pointer-events: auto; + border: 1px solid color-mix(in srgb, var(--border-strong) 76%, transparent); + border-radius: 15px; + background: color-mix(in srgb, var(--surface-elevated) 88%, transparent); + box-shadow: inset 0 1px rgb(255 255 255 / 0.09), 0 14px 30px -24px rgb(2 6 23 / 0.68); + backdrop-filter: blur(24px) saturate(1.16); +} +.spotlight-dock-button { + min-height: 35px; + padding-inline: 10px; + border-radius: 10px; + font-size: 10px; +} +.spotlight-dock-button--copy { min-width: 126px; } +.spotlight-dock-button--more { min-width: 151px; } +.spotlight-dock-button--success { animation: action-success 280ms cubic-bezier(0.16, 1, 0.3, 1) both; } +.spotlight-action-hub { position: relative; } +.spotlight-action-popover { + position: absolute; + right: 0; + bottom: calc(100% + 12px); + width: 286px; + padding: 6px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border-strong) 82%, transparent); + border-radius: 15px; + background: color-mix(in srgb, var(--surface-elevated) 94%, transparent); + box-shadow: inset 0 1px rgb(255 255 255 / 0.08), 0 22px 48px -24px rgb(2 6 23 / 0.72); + backdrop-filter: blur(28px) saturate(1.18); + transform-origin: calc(100% - 34px) 100%; +} +.spotlight-action-popover button { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + min-height: 43px; + padding: 6px 8px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--text); + text-align: left; + cursor: default; + transition: background-color 120ms ease, transform 150ms cubic-bezier(0.16, 1, 0.3, 1); +} +.spotlight-action-popover button:hover, +.spotlight-action-popover button:focus-visible { + outline: none; + background: color-mix(in srgb, var(--primary-soft) 68%, var(--surface)); + transform: translate3d(2px, 0, 0); +} +.spotlight-action-popover button strong { + min-width: 0; + overflow: hidden; + font-size: 11px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} +.spotlight-action-popover__icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border)); + border-radius: 9px; + background: color-mix(in srgb, var(--primary-soft) 48%, var(--surface)); + color: var(--primary-text); +} +.action-popover-enter-active, +.action-popover-leave-active { transition: opacity 130ms ease, transform 170ms cubic-bezier(0.16, 1, 0.3, 1), filter 130ms ease; } +.action-popover-enter-from, +.action-popover-leave-to { opacity: 0; transform: translate3d(0, 7px, 0) scale(0.965); filter: blur(3px); } .spotlight-loading-list { display: grid; gap: 8px; padding: 4px; } .spotlight-loading-list span { height: 70px; @@ -541,7 +708,9 @@ defineExpose({ scrollToIndex }) @keyframes action-success { 0% { transform: scale(0.94); } 55% { transform: scale(1.04); } 100% { transform: none; } } @keyframes skeleton-shimmer { to { background-position: -160% 0; } } @media (prefers-reduced-motion: reduce) { - .spotlight-action-button--success, - .spin { animation-duration: 0.01ms; } + .spotlight-dock-button--success, + .action-popover-enter-active, + .action-popover-leave-active, + .spin { animation-duration: 0.01ms; transition-duration: 0.01ms; } } From f88314362fc08152cd15579d3dd65975e42ec75a Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:46:33 +0200 Subject: [PATCH 083/153] translate Spotlight action dock labels --- src/i18n/fr/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/fr/spotlight.ts b/src/i18n/fr/spotlight.ts index b82cdaf..d383ba7 100644 --- a/src/i18n/fr/spotlight.ts +++ b/src/i18n/fr/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'Recherche rapide', 'spotlight.clear_search': 'Effacer la recherche', 'spotlight.open_settings': 'Ouvrir les réglages', + 'spotlight.actions.toolbar': 'Actions de l’image', + 'spotlight.actions.more': 'Plus d’actions', 'spotlight.convert.action': 'Convertir l’image', 'spotlight.convert.title': 'Convertir', From 7db29958640f810902707c212c9c761c77e73b47 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:46:54 +0200 Subject: [PATCH 084/153] translate Spotlight action dock labels --- src/i18n/en/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/en/spotlight.ts b/src/i18n/en/spotlight.ts index 4c28112..e9e453b 100644 --- a/src/i18n/en/spotlight.ts +++ b/src/i18n/en/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'Quick search', 'spotlight.clear_search': 'Clear search', 'spotlight.open_settings': 'Open settings', + 'spotlight.actions.toolbar': 'Image actions', + 'spotlight.actions.more': 'More actions', 'spotlight.convert.action': 'Convert image', 'spotlight.convert.title': 'Convert', From edcc7489fa6b72423879901b7844dd05e729cec7 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:47:15 +0200 Subject: [PATCH 085/153] translate Spotlight action dock labels --- src/i18n/de/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/de/spotlight.ts b/src/i18n/de/spotlight.ts index 5ffd5fa..47f37e4 100644 --- a/src/i18n/de/spotlight.ts +++ b/src/i18n/de/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'Schnellsuche', 'spotlight.clear_search': 'Suche löschen', 'spotlight.open_settings': 'Einstellungen öffnen', + 'spotlight.actions.toolbar': 'Bildaktionen', + 'spotlight.actions.more': 'Weitere Aktionen', 'spotlight.convert.action': 'Bild konvertieren', 'spotlight.convert.title': 'Konvertieren', From 46282ffff6576b207e4cc1b1395f93c5fe892eeb Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:47:36 +0200 Subject: [PATCH 086/153] translate Spotlight action dock labels --- src/i18n/es/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/es/spotlight.ts b/src/i18n/es/spotlight.ts index 9bf1ae4..5a90d52 100644 --- a/src/i18n/es/spotlight.ts +++ b/src/i18n/es/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'Búsqueda rápida', 'spotlight.clear_search': 'Limpiar búsqueda', 'spotlight.open_settings': 'Abrir ajustes', + 'spotlight.actions.toolbar': 'Acciones de imagen', + 'spotlight.actions.more': 'Más acciones', 'spotlight.convert.action': 'Convertir imagen', 'spotlight.convert.title': 'Convertir', From 190c9b141375f5710e2c3322e224dd096f5db2d9 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:47:58 +0200 Subject: [PATCH 087/153] translate Spotlight action dock labels --- src/i18n/it/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/it/spotlight.ts b/src/i18n/it/spotlight.ts index 99f516c..70c1cd2 100644 --- a/src/i18n/it/spotlight.ts +++ b/src/i18n/it/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'Ricerca rapida', 'spotlight.clear_search': 'Cancella ricerca', 'spotlight.open_settings': 'Apri impostazioni', + 'spotlight.actions.toolbar': 'Azioni immagine', + 'spotlight.actions.more': 'Altre azioni', 'spotlight.convert.action': 'Converti immagine', 'spotlight.convert.title': 'Converti', From fe3f05124a238809b4f11c5d11c5d15b28502746 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:48:18 +0200 Subject: [PATCH 088/153] translate Spotlight action dock labels --- src/i18n/pt/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/pt/spotlight.ts b/src/i18n/pt/spotlight.ts index 8dbde08..96c8e9f 100644 --- a/src/i18n/pt/spotlight.ts +++ b/src/i18n/pt/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'Pesquisa rápida', 'spotlight.clear_search': 'Limpar pesquisa', 'spotlight.open_settings': 'Abrir configurações', + 'spotlight.actions.toolbar': 'Ações da imagem', + 'spotlight.actions.more': 'Mais ações', 'spotlight.convert.action': 'Converter imagem', 'spotlight.convert.title': 'Converter', From 3759580a38a8782984d7b011888209578110d5bd Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:48:42 +0200 Subject: [PATCH 089/153] translate Spotlight action dock labels --- src/i18n/ru/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/ru/spotlight.ts b/src/i18n/ru/spotlight.ts index 9a869f8..aa764e7 100644 --- a/src/i18n/ru/spotlight.ts +++ b/src/i18n/ru/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'Быстрый поиск', 'spotlight.clear_search': 'Очистить поиск', 'spotlight.open_settings': 'Открыть настройки', + 'spotlight.actions.toolbar': 'Действия с изображением', + 'spotlight.actions.more': 'Другие действия', 'spotlight.convert.action': 'Конвертировать изображение', 'spotlight.convert.title': 'Конвертация', From 41fa330674690c78ed6c271be3f04f773db9e829 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:49:01 +0200 Subject: [PATCH 090/153] translate Spotlight action dock labels --- src/i18n/zh/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/zh/spotlight.ts b/src/i18n/zh/spotlight.ts index 763516a..1eec330 100644 --- a/src/i18n/zh/spotlight.ts +++ b/src/i18n/zh/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': '快速搜索', 'spotlight.clear_search': '清除搜索', 'spotlight.open_settings': '打开设置', + 'spotlight.actions.toolbar': '图片操作', + 'spotlight.actions.more': '更多操作', 'spotlight.convert.action': '转换图片', 'spotlight.convert.title': '转换', From 74ba1fc3be3c4f0f6a8410fe986757db97e976d8 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:49:21 +0200 Subject: [PATCH 091/153] translate Spotlight action dock labels --- src/i18n/ja/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/ja/spotlight.ts b/src/i18n/ja/spotlight.ts index efd6fb2..f230406 100644 --- a/src/i18n/ja/spotlight.ts +++ b/src/i18n/ja/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'クイック検索', 'spotlight.clear_search': '検索をクリア', 'spotlight.open_settings': '設定を開く', + 'spotlight.actions.toolbar': '画像の操作', + 'spotlight.actions.more': 'その他の操作', 'spotlight.convert.action': '画像を変換', 'spotlight.convert.title': '変換', From ff3aeb23356f6b075f5e6f58316a599260668fdb Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:49:50 +0200 Subject: [PATCH 092/153] translate Spotlight action dock labels --- src/i18n/ar/spotlight.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/ar/spotlight.ts b/src/i18n/ar/spotlight.ts index 63edabc..0aeacc5 100644 --- a/src/i18n/ar/spotlight.ts +++ b/src/i18n/ar/spotlight.ts @@ -10,6 +10,8 @@ export default { 'spotlight.quick_search': 'بحث سريع', 'spotlight.clear_search': 'مسح البحث', 'spotlight.open_settings': 'فتح الإعدادات', + 'spotlight.actions.toolbar': 'إجراءات الصورة', + 'spotlight.actions.more': 'مزيد من الإجراءات', 'spotlight.convert.action': 'تحويل الصورة', 'spotlight.convert.title': 'تحويل', From cb3d93ed778c9ffcadccc04b5b526c26c44cd9c0 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:51:04 +0200 Subject: [PATCH 093/153] guard Spotlight action popover shortcuts --- src/services/spotlight-converter-keyboard.ts | 90 +++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/src/services/spotlight-converter-keyboard.ts b/src/services/spotlight-converter-keyboard.ts index f61d68c..7aaf9b6 100644 --- a/src/services/spotlight-converter-keyboard.ts +++ b/src/services/spotlight-converter-keyboard.ts @@ -1,6 +1,8 @@ const CONVERTER_SELECTOR = ".spotlight-converter"; const BACK_BUTTON_SELECTOR = ".spotlight-conversion-input__back"; const CONVERT_BUTTON_SELECTOR = ".spotlight-converter__command"; +const ACTION_POPOVER_SELECTOR = ".spotlight-action-popover"; +const MORE_ACTIONS_BUTTON_SELECTOR = ".spotlight-dock-button--more"; let installed = false; @@ -8,43 +10,85 @@ function converterIsOpen(): boolean { return document.querySelector(CONVERTER_SELECTOR) !== null; } -function clickEnabledButton(selector: string): boolean { +function actionPopoverIsOpen(): boolean { + return document.querySelector(ACTION_POPOVER_SELECTOR) !== null; +} + +function enabledButton(selector: string): HTMLButtonElement | null { const button = document.querySelector(selector); - if (!button || button.disabled) return false; + return button && !button.disabled ? button : null; +} + +function clickEnabledButton(selector: string): boolean { + const button = enabledButton(selector); + if (!button) return false; button.click(); return true; } -function handleConverterKeydown(event: KeyboardEvent): void { - if (event.isComposing || !converterIsOpen()) return; - if (event.key !== "Escape" && event.key !== "Enter") return; - - // This listener is installed before Vue mounts so converter keys never reach - // Spotlight's global Escape/Enter handlers and accidentally hide/open Imagyx. +function consume(event: KeyboardEvent): void { event.preventDefault(); event.stopImmediatePropagation(); +} + +function handleConverterKeydown(event: KeyboardEvent): void { + if (event.isComposing) return; + + if (converterIsOpen()) { + if (event.key !== "Escape" && event.key !== "Enter") return; + + // Installed before Vue mounts so converter keys never reach Spotlight's + // global Escape/Enter handlers and accidentally hide/open Imagyx. + consume(event); - if (event.key === "Escape") { - const wentBack = clickEnabledButton(BACK_BUTTON_SELECTOR); + if (event.key === "Escape") { + const wentBack = clickEnabledButton(BACK_BUTTON_SELECTOR); + if (import.meta.env.DEV) { + console.debug("[Imagyx][SpotlightConverterKeyboard] Escape", { + action: wentBack ? "back-to-search" : "ignored-while-converting", + }); + } + return; + } + + const hasModifier = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey; + const started = !event.repeat && !hasModifier + ? clickEnabledButton(CONVERT_BUTTON_SELECTOR) + : false; + + if (import.meta.env.DEV) { + console.debug("[Imagyx][SpotlightConverterKeyboard] Enter", { + action: started ? "convert" : "consumed", + repeat: event.repeat, + hasModifier, + }); + } + return; + } + + const key = event.key.toLocaleLowerCase(); + const primaryModifier = event.ctrlKey || event.metaKey; + if (primaryModifier && !event.shiftKey && !event.altKey && key === "k") { + if (!enabledButton(MORE_ACTIONS_BUTTON_SELECTOR)) return; + consume(event); + const toggled = !event.repeat && clickEnabledButton(MORE_ACTIONS_BUTTON_SELECTOR); if (import.meta.env.DEV) { - console.debug("[Imagyx][SpotlightConverterKeyboard] Escape", { - action: wentBack ? "back-to-search" : "ignored-while-converting", + console.debug("[Imagyx][SpotlightActionDockKeyboard] Ctrl+K", { + action: toggled ? "toggle-actions" : "consumed-repeat", + repeat: event.repeat, }); } return; } - const hasModifier = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey; - const started = !event.repeat && !hasModifier - ? clickEnabledButton(CONVERT_BUTTON_SELECTOR) - : false; - - if (import.meta.env.DEV) { - console.debug("[Imagyx][SpotlightConverterKeyboard] Enter", { - action: started ? "convert" : "consumed", - repeat: event.repeat, - hasModifier, - }); + if (event.key === "Escape" && actionPopoverIsOpen()) { + consume(event); + const closed = clickEnabledButton(MORE_ACTIONS_BUTTON_SELECTOR); + if (import.meta.env.DEV) { + console.debug("[Imagyx][SpotlightActionDockKeyboard] Escape", { + action: closed ? "close-actions" : "consumed", + }); + } } } From b28daaf9839240b305074700dbc16d60204565e2 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:26:25 +0200 Subject: [PATCH 094/153] extend search requests for visual queries --- src-tauri/src/models.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 7a4464e..f6c0d4d 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -71,6 +71,8 @@ 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)] From f6b54f124061e1d276ac931ac05f089bcac6ac7d Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:26:48 +0200 Subject: [PATCH 095/153] route pure visual searches through native search --- src-tauri/src/commands/search.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index ad42b76..cdeef45 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -63,13 +63,15 @@ async fn execute_search_page( #[cfg(debug_assertions)] let execution_started_at = Instant::now(); #[cfg(debug_assertions)] - let mode = if request.query.trim().is_empty() { - "browse" - } else if request.query_vector.is_some() { - "hybrid" - } else { - "lexical" - }; + 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); @@ -78,9 +80,10 @@ async fn execute_search_page( tracing::event( "search.command.start", format!( - "id={diagnostic_label} mode={mode} query={:?} folder_id={:?} limit={limit} offset={offset} vector_dimensions={} spawn_blocking_queue_ms={queue_wait_ms:.2}", + "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), ), ); @@ -90,6 +93,8 @@ async fn execute_search_page( &request.query, request.query_vector.as_deref(), request.folder_id.as_deref(), + request.mode.as_deref(), + request.exclude_image_id.as_deref(), limit, offset, diagnostic_id.as_deref(), From 639d7a4b71c85b77568fd257cf74fe37b263ff65 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:27:31 +0200 Subject: [PATCH 096/153] expose vector counts for visual search --- src-tauri/src/vector_store/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src-tauri/src/vector_store/mod.rs b/src-tauri/src/vector_store/mod.rs index d3f9347..5874f46 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(); From e94e69c2b3f5c3d2af48383b6e7897cf38d101a9 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:28:27 +0200 Subject: [PATCH 097/153] add paginated pure visual similarity search --- src-tauri/src/indexer/search.rs | 82 ++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/indexer/search.rs b/src-tauri/src/indexer/search.rs index 2c705d1..5e7345a 100644 --- a/src-tauri/src/indexer/search.rs +++ b/src-tauri/src/indexer/search.rs @@ -21,6 +21,8 @@ pub fn search_page_with_diagnostics( 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, _diagnostic_id: Option<&str>, @@ -29,11 +31,87 @@ pub fn search_page_with_diagnostics( 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 total = vectors + .count(folder_id) + .saturating_sub(usize::from(excluded_in_scope)); + let candidate_limit = window_end + .saturating_add(usize::from(exclude_image_id.is_some())) + .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; From 6d4de292c1c6c7567c5dceb9c86912e0454af5fe Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:29:17 +0200 Subject: [PATCH 098/153] expose stored image embeddings for similarity search --- src-tauri/src/commands/semantic.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src-tauri/src/commands/semantic.rs b/src-tauri/src/commands/semantic.rs index 230ccb0..e321369 100644 --- a/src-tauri/src/commands/semantic.rs +++ b/src-tauri/src/commands/semantic.rs @@ -170,6 +170,19 @@ pub async fn prepare_ai_images( Ok(Response::new(packed)) } +#[tauri::command(rename_all = "camelCase")] +pub fn get_image_embedding( + image_id: String, + state: State<'_, Arc>, +) -> Result, String> { + state + .vectors + .read() + .vector(&image_id) + .map(ToOwned::to_owned) + .ok_or_else(|| "Cette image n’a pas encore d’index visuel".to_owned()) +} + #[tauri::command] pub fn save_embeddings( embeddings: Vec, From e4e47698399da6bf19dce25dbd861705a2c2d9b6 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:29:41 +0200 Subject: [PATCH 099/153] register visual search embedding command --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8639b83..c1d605d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -162,6 +162,7 @@ pub fn run() { commands::folders::index_folder, commands::folders::pending_images, commands::semantic::prepare_ai_images, + commands::semantic::get_image_embedding, commands::semantic::save_embeddings, commands::semantic::explain_results, commands::semantic::top_image_tags, From 2e4290a65544ade6c299f6f581b7d5e7c47b4bcd Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:30:01 +0200 Subject: [PATCH 100/153] add shared visual search types --- src/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/types.ts b/src/types.ts index ec04a36..7f22701 100644 --- a/src/types.ts +++ b/src/types.ts @@ -135,6 +135,8 @@ export interface IndexProgress { message: string } +export type SearchMode = 'text' | 'visual' + export interface SearchRequest { query: string folderId?: string @@ -142,6 +144,8 @@ export interface SearchRequest { offset?: number queryVector?: number[] diagnosticId?: string + mode?: SearchMode + excludeImageId?: string } export interface SearchPage { From 8946759fb003940115c20357822223a4bb94b284 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:30:39 +0200 Subject: [PATCH 101/153] add shared visual search session --- src/services/visual-search-session.ts | 149 ++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/services/visual-search-session.ts diff --git a/src/services/visual-search-session.ts b/src/services/visual-search-session.ts new file mode 100644 index 0000000..d5aa493 --- /dev/null +++ b/src/services/visual-search-session.ts @@ -0,0 +1,149 @@ +import { reactive, readonly } from 'vue' +import type { ImageAsset, SearchRequest } from '../types' + +const VISUAL_QUERY_PREFIX = '\u2063imagyx-visual:' +const SESSION_EVENT = 'imagyx-visual-search-session' + +export type VisualSearchSourceKind = 'indexed' | 'file' | 'clipboard' | 'drop' +export type VisualSearchStatus = 'idle' | 'loading' | 'ready' | 'error' + +interface VisualSearchState { + status: VisualSearchStatus + token: string + sourceKind: VisualSearchSourceKind | null + label: string + previewUrl: string | null + sourceImage: ImageAsset | null + vector: number[] | null + excludeImageId: string | null + error: string | null +} + +const state = reactive({ + status: 'idle', + token: '', + sourceKind: null, + label: '', + previewUrl: null, + sourceImage: null, + vector: null, + excludeImageId: null, + error: null, +}) + +let sessionSequence = 0 +let ownedPreviewUrl: string | null = null + +function dispatchSessionEvent() { + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent(SESSION_EVENT, { + detail: { + status: state.status, + token: state.token, + }, + })) +} + +function releaseOwnedPreview() { + if (!ownedPreviewUrl) return + URL.revokeObjectURL(ownedPreviewUrl) + ownedPreviewUrl = null +} + +function begin( + sourceKind: VisualSearchSourceKind, + label: string, + options: { previewUrl?: string | null; sourceImage?: ImageAsset | null } = {}, +) { + releaseOwnedPreview() + state.status = 'loading' + state.token = '' + state.sourceKind = sourceKind + state.label = label + state.previewUrl = options.previewUrl ?? null + state.sourceImage = options.sourceImage ?? null + state.vector = null + state.excludeImageId = null + state.error = null + dispatchSessionEvent() +} + +function activate( + vector: number[], + options: { + sourceKind: VisualSearchSourceKind + label: string + previewUrl?: string | null + sourceImage?: ImageAsset | null + excludeImageId?: string | null + ownsPreviewUrl?: boolean + }, +): string { + if (!vector.length) throw new Error('Visual search embedding is empty') + if (options.ownsPreviewUrl && options.previewUrl) ownedPreviewUrl = options.previewUrl + sessionSequence += 1 + state.status = 'ready' + state.token = `${VISUAL_QUERY_PREFIX}${Date.now().toString(36)}-${sessionSequence}` + state.sourceKind = options.sourceKind + state.label = options.label + state.previewUrl = options.previewUrl ?? null + state.sourceImage = options.sourceImage ?? null + state.vector = vector + state.excludeImageId = options.excludeImageId ?? null + state.error = null + dispatchSessionEvent() + return state.token +} + +function fail(reason: unknown) { + state.status = 'error' + state.vector = null + state.token = '' + state.error = String(reason) + dispatchSessionEvent() +} + +function clear() { + releaseOwnedPreview() + state.status = 'idle' + state.token = '' + state.sourceKind = null + state.label = '' + state.previewUrl = null + state.sourceImage = null + state.vector = null + state.excludeImageId = null + state.error = null + dispatchSessionEvent() +} + +function isVisualQuery(query: string): boolean { + return Boolean(state.token) && query === state.token +} + +function requestForActiveSession(request: SearchRequest): SearchRequest { + if (!isVisualQuery(request.query) || state.status !== 'ready' || !state.vector) return request + return { + ...request, + query: '', + queryVector: state.vector, + mode: 'visual', + excludeImageId: state.excludeImageId ?? undefined, + } +} + +function vectorForQuery(query: string): number[] | null { + return isVisualQuery(query) && state.status === 'ready' ? state.vector : null +} + +export const visualSearchSession = { + state: readonly(state), + eventName: SESSION_EVENT, + begin, + activate, + fail, + clear, + isVisualQuery, + requestForActiveSession, + vectorForQuery, +} From 0860c54629252891564bf46cac37cd75ebadccb3 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:32:07 +0200 Subject: [PATCH 102/153] share one MobileCLIP vision worker across search features --- src/services/vision-worker-client.ts | 43 ++++++++++++++-------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/services/vision-worker-client.ts b/src/services/vision-worker-client.ts index 624b070..e469a45 100644 --- a/src/services/vision-worker-client.ts +++ b/src/services/vision-worker-client.ts @@ -99,7 +99,6 @@ type ErrorResponse = { } type WorkerResponse = ReadyResponse | ResultResponse | ErrorResponse - type SuccessfulResponse = Exclude type TimedResponse = { @@ -114,15 +113,17 @@ type PendingRequest = { reject: (error: Error) => void } -export class VisionWorkerClient { - private worker: Worker | null = null - private initialization: Promise | null = null - private readonly pending = new Map() - private nextRequestId = 1 +// Every surface and feature uses this shared module-level worker. Creating a +// client is cheap and never loads a second MobileCLIP model into memory. +let sharedWorker: Worker | null = null +let sharedInitialization: Promise | null = null +const sharedPending = new Map() +let sharedNextRequestId = 1 +export class VisionWorkerClient { initialize(modelId: string, localModelPath: string): Promise { - if (!this.initialization) { - this.initialization = this.request((requestId) => ({ + if (!sharedInitialization) { + sharedInitialization = this.request((requestId) => ({ type: 'init', requestId, modelId, @@ -133,7 +134,7 @@ export class VisionWorkerClient { return ready }) } - return this.initialization + return sharedInitialization } async infer(pixels: ArrayBuffer, count: number, batchId: string): Promise { @@ -189,11 +190,11 @@ export class VisionWorkerClient { transfer: Transferable[] = [], ): Promise> { const worker = this.ensureWorker() - const requestId = this.nextRequestId - this.nextRequestId += 1 + const requestId = sharedNextRequestId + sharedNextRequestId += 1 return new Promise>((resolve, reject) => { - this.pending.set(requestId, { + sharedPending.set(requestId, { sentAtPerfMs: performance.now(), resolve: (response, roundTripMs, receivedAtEpochMs) => resolve({ response: response as T, @@ -207,7 +208,7 @@ export class VisionWorkerClient { } private ensureWorker(): Worker { - if (this.worker) return this.worker + if (sharedWorker) return sharedWorker const worker = new Worker(new URL('../workers/vision-worker.ts', import.meta.url), { type: 'module', @@ -216,9 +217,9 @@ export class VisionWorkerClient { worker.onmessage = (event: MessageEvent) => { const receivedAtEpochMs = Date.now() const response = event.data - const pending = this.pending.get(response.requestId) + const pending = sharedPending.get(response.requestId) if (!pending) return - this.pending.delete(response.requestId) + sharedPending.delete(response.requestId) const roundTripMs = performance.now() - pending.sentAtPerfMs if (response.type === 'error') { pending.reject(new Error(response.message)) @@ -232,15 +233,15 @@ export class VisionWorkerClient { worker.onmessageerror = () => { this.failWorker(new Error('Message invalide reçu du worker vision')) } - this.worker = worker + sharedWorker = worker return worker } private failWorker(error: Error) { - for (const pending of this.pending.values()) pending.reject(error) - this.pending.clear() - this.worker?.terminate() - this.worker = null - this.initialization = null + for (const pending of sharedPending.values()) pending.reject(error) + sharedPending.clear() + sharedWorker?.terminate() + sharedWorker = null + sharedInitialization = null } } From 35bfd96a9b15e11ee24eeea203f24fe90cc6ee3b Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:32:49 +0200 Subject: [PATCH 103/153] prepare external images for visual search --- src-tauri/src/commands/semantic.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src-tauri/src/commands/semantic.rs b/src-tauri/src/commands/semantic.rs index e321369..5ada776 100644 --- a/src-tauri/src/commands/semantic.rs +++ b/src-tauri/src/commands/semantic.rs @@ -170,6 +170,24 @@ 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, From a90bc320224c3ba5803fa1509e0c2940c2bc292c Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:33:15 +0200 Subject: [PATCH 104/153] register external visual query preparation --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c1d605d..af8cbe4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -162,6 +162,7 @@ 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, From 4309cc0a60f05feea72634d885cb6e791d5a70e4 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:33:58 +0200 Subject: [PATCH 105/153] encode visual search sources with MobileCLIP --- src/services/visual-search.ts | 157 ++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/services/visual-search.ts diff --git a/src/services/visual-search.ts b/src/services/visual-search.ts new file mode 100644 index 0000000..4d309cf --- /dev/null +++ b/src/services/visual-search.ts @@ -0,0 +1,157 @@ +import { convertFileSrc, invoke } from '@tauri-apps/api/core' +import type { ImageAsset } from '../types' +import { VisionWorkerClient } from './vision-worker-client' +import { visualSearchSession, type VisualSearchSourceKind } from './visual-search-session' + +const MODEL_ID = 'Xenova/mobileclip_s0' +const IMAGE_EDGE = 224 +const IMAGE_CHANNELS = 3 +const IMAGE_BYTES = IMAGE_EDGE * IMAGE_EDGE * IMAGE_CHANNELS +const visionWorker = new VisionWorkerClient() +let visionReady: Promise | null = null +let querySequence = 0 + +function filename(path: string): string { + return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path +} + +async function ensureVisionReady() { + if (!visionReady) { + visionReady = (async () => { + const modelRoot = await invoke('prepare_local_model', { modelKey: 'mobileclip-s0' }) + const modelsDir = modelRoot.replace(/[\\/]+Xenova[\\/]mobileclip_s0$/, '') + const localModelPath = `${convertFileSrc(modelsDir).replace(/\/$/, '')}/` + await visionWorker.initialize(MODEL_ID, localModelPath) + })().catch((error) => { + visionReady = null + throw error + }) + } + await visionReady +} + +async function inferPixels(pixels: ArrayBuffer): Promise { + if (pixels.byteLength !== IMAGE_BYTES) { + throw new Error(`Invalid visual query pixels: ${pixels.byteLength} bytes`) + } + await ensureVisionReady() + querySequence += 1 + const result = await visionWorker.infer(pixels, 1, `visual-query-${Date.now().toString(36)}-${querySequence}`) + const vector = result.vectors[0] + if (!vector?.length) throw new Error('MobileCLIP returned an empty visual query') + return vector +} + +async function blobPixels(blob: Blob): Promise { + const bitmap = await createImageBitmap(blob) + try { + const canvas = document.createElement('canvas') + canvas.width = IMAGE_EDGE + canvas.height = IMAGE_EDGE + const context = canvas.getContext('2d', { alpha: false, willReadFrequently: true }) + if (!context) throw new Error('Canvas 2D is unavailable') + + const sourceRatio = bitmap.width / Math.max(1, bitmap.height) + const targetRatio = 1 + let sourceX = 0 + let sourceY = 0 + let sourceWidth = bitmap.width + let sourceHeight = bitmap.height + if (sourceRatio > targetRatio) { + sourceWidth = bitmap.height + sourceX = (bitmap.width - sourceWidth) / 2 + } else if (sourceRatio < targetRatio) { + sourceHeight = bitmap.width + sourceY = (bitmap.height - sourceHeight) / 2 + } + context.drawImage( + bitmap, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + 0, + 0, + IMAGE_EDGE, + IMAGE_EDGE, + ) + const rgba = context.getImageData(0, 0, IMAGE_EDGE, IMAGE_EDGE).data + const rgb = new Uint8Array(IMAGE_BYTES) + for (let source = 0, target = 0; source < rgba.length; source += 4) { + rgb[target] = rgba[source] ?? 0 + rgb[target + 1] = rgba[source + 1] ?? 0 + rgb[target + 2] = rgba[source + 2] ?? 0 + target += 3 + } + return rgb.buffer + } finally { + bitmap.close() + } +} + +async function findSimilar(image: ImageAsset): Promise { + visualSearchSession.begin('indexed', image.name, { sourceImage: image }) + try { + const vector = await invoke('get_image_embedding', { imageId: image.id }) + return visualSearchSession.activate(vector, { + sourceKind: 'indexed', + label: image.name, + sourceImage: image, + excludeImageId: image.id, + }) + } catch (error) { + visualSearchSession.fail(error) + throw error + } +} + +async function searchPath(path: string, sourceKind: VisualSearchSourceKind = 'file'): Promise { + const label = filename(path) + const previewUrl = convertFileSrc(path) + visualSearchSession.begin(sourceKind, label, { previewUrl }) + try { + const pixels = await invoke('prepare_visual_query_image', { path }) + const vector = await inferPixels(pixels) + return visualSearchSession.activate(vector, { + sourceKind, + label, + previewUrl, + }) + } catch (error) { + visualSearchSession.fail(error) + throw error + } +} + +async function searchBlob( + blob: Blob, + label: string, + sourceKind: Extract, +): Promise { + if (blob.type && !blob.type.startsWith('image/')) { + throw new Error('The dropped or pasted file is not an image') + } + const previewUrl = URL.createObjectURL(blob) + visualSearchSession.begin(sourceKind, label, { previewUrl }) + try { + const vector = await inferPixels(await blobPixels(blob)) + return visualSearchSession.activate(vector, { + sourceKind, + label, + previewUrl, + ownsPreviewUrl: true, + }) + } catch (error) { + URL.revokeObjectURL(previewUrl) + visualSearchSession.fail(error) + throw error + } +} + +export const visualSearch = { + findSimilar, + searchPath, + searchFile: (file: File, sourceKind: 'clipboard' | 'drop' = 'drop') => + searchBlob(file, file.name || 'image', sourceKind), + searchBlob, +} From c927e15e4cd88572385f22aa9a2b2fdb9efda2e3 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:34:40 +0200 Subject: [PATCH 106/153] route both search surfaces through visual search sessions --- src/api/tauri.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/api/tauri.ts b/src/api/tauri.ts index 60347e1..1319648 100644 --- a/src/api/tauri.ts +++ b/src/api/tauri.ts @@ -17,12 +17,14 @@ import type { } from '../types' import { perfLog } from '../utils' import { spotlightSearchPagination } from '../services/spotlight-search-pagination' +import { visualSearchSession } from '../services/visual-search-session' -type SearchMode = 'browse' | 'lexical' | 'hybrid' +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' } @@ -62,6 +64,7 @@ function logSearchResults( 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, }) @@ -82,14 +85,15 @@ async function invokeSearchPage( request: SearchRequest, trackAsInitialSearch: boolean, ): Promise { - const mode = searchMode(request) + const sessionRequest = visualSearchSession.requestForActiveSession(request) + const mode = searchMode(sessionRequest) const diagnosticId = import.meta.env.DEV - ? request.diagnosticId ?? nextSearchDiagnosticId(mode) + ? sessionRequest.diagnosticId ?? nextSearchDiagnosticId(mode) : undefined const normalizedRequest: SearchRequest = { - ...request, - limit: request.limit ?? 2_000, - offset: request.offset ?? 0, + ...sessionRequest, + limit: sessionRequest.limit ?? 2_000, + offset: sessionRequest.offset ?? 0, diagnosticId, } const paginationContext = trackAsInitialSearch @@ -105,6 +109,7 @@ async function invokeSearchPage( limit: normalizedRequest.limit, offset: normalizedRequest.offset, queryVectorDimensions: normalizedRequest.queryVector?.length ?? 0, + excludeImageId: normalizedRequest.excludeImageId ?? null, }) } @@ -116,6 +121,8 @@ async function invokeSearchPage( 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, }) @@ -199,6 +206,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[]) => From 2bc2575d8929f1a531a116ca121f3b49a6a7c66c Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:36:24 +0200 Subject: [PATCH 107/153] add the shared Imagyx search input --- src/components/UnifiedSearchInput.vue | 478 ++++++++++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 src/components/UnifiedSearchInput.vue diff --git a/src/components/UnifiedSearchInput.vue b/src/components/UnifiedSearchInput.vue new file mode 100644 index 0000000..99f7ab0 --- /dev/null +++ b/src/components/UnifiedSearchInput.vue @@ -0,0 +1,478 @@ + + + + + From ee6628133468d70b678acfc253cda8d7725a0408 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:36:53 +0200 Subject: [PATCH 108/153] use the shared search input in the main app --- src/components/SearchHeader.vue | 149 +++++--------------------------- 1 file changed, 24 insertions(+), 125 deletions(-) diff --git a/src/components/SearchHeader.vue b/src/components/SearchHeader.vue index ebb91ed..4040be6 100644 --- a/src/components/SearchHeader.vue +++ b/src/components/SearchHeader.vue @@ -1,15 +1,11 @@ @@ -141,15 +98,14 @@ defineExpose({ focusSearch, selectSearch }) -webkit-backdrop-filter: blur(16px); -webkit-app-region: drag; } - .search-header-controls { position: absolute; top: 0; right: 12px; - height: 38px; + z-index: 10; display: flex; align-items: center; - z-index: 10; + height: 38px; pointer-events: auto; -webkit-app-region: no-drag; } @@ -160,61 +116,4 @@ defineExpose({ focusSearch, selectSearch }) pointer-events: auto; -webkit-app-region: no-drag; } - -.floating-search-input { - min-height: 48px; - border: 1px solid var(--border) !important; - border-radius: 13px; - background: var(--surface-elevated); - box-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.1); -} -.floating-search-input:focus-within, -.floating-search-input:hover { - border-color: transparent !important; - outline: none !important; -} - -.header-icon-wrapper { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; -} - -.search-trailing-actions { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.search-count-badge { - min-width: 86px; - text-align: center; - padding: 3px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-full); - background: color-mix(in srgb, var(--surface-hover) 88%, transparent); - color: var(--text-muted); - font-size: 11px; - font-variant-numeric: tabular-nums; - font-weight: 500; - transition: width 150ms ease, opacity 120ms ease; -} - -.spin { - animation: spin 0.85s linear infinite; - color: var(--primary); -} - -.icon-swap-enter-active, -.icon-swap-leave-active { - transition: opacity 140ms ease, transform 140ms ease; -} -.icon-swap-enter-from { opacity: 0; transform: scale(0.85); } -.icon-swap-leave-to { opacity: 0; transform: scale(0.85); } - -@keyframes spin { - to { transform: rotate(1turn); } -} From efc83d9aa8d4c8fe5478e311acd54eebd42431c7 Mon Sep 17 00:00:00 2001 From: ExtraBinoss <113912449+ExtraBinoss@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:37:15 +0200 Subject: [PATCH 109/153] use the shared search input in Spotlight --- src/components/Spotlight/SpotlightInput.vue | 133 +++----------------- 1 file changed, 17 insertions(+), 116 deletions(-) diff --git a/src/components/Spotlight/SpotlightInput.vue b/src/components/Spotlight/SpotlightInput.vue index 4af27f5..fbae969 100644 --- a/src/components/Spotlight/SpotlightInput.vue +++ b/src/components/Spotlight/SpotlightInput.vue @@ -1,13 +1,11 @@