diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5e8dbe0..df4ef2c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -143,6 +143,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -635,6 +641,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -3108,6 +3116,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.94" @@ -3559,6 +3577,31 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mozjpeg" +version = "0.10.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7891b80aaa86097d38d276eb98b3805d6280708c4e0a1e6f6aed9380c51fec9" +dependencies = [ + "arrayvec", + "bytemuck", + "libc", + "mozjpeg-sys", + "rgb", +] + +[[package]] +name = "mozjpeg-sys" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0dc668bf9bf888c88e2fb1ab16a406d2c380f1d082b20d51dd540ab2aa70c1" +dependencies = [ + "cc", + "dunce", + "libc", + "nasm-rs", +] + [[package]] name = "muda" version = "0.17.2" @@ -3586,6 +3629,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "jobserver", + "log", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -4388,6 +4441,7 @@ dependencies = [ "kamadak-exif", "log", "memmap2", + "mozjpeg", "notify", "ort", "r2d2", @@ -5102,6 +5156,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6c201c7..84a8156 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,7 @@ csv = "1" kamadak-exif = "0.5" notify = "6" tauri-plugin-notification = "2" +mozjpeg = "0.10.13" # ── Dev-mode performance ──────────────────────────────────────────────────── # opt-level=1 on the main crate keeps incremental compile short. @@ -81,6 +82,10 @@ opt-level = 3 opt-level = 3 [profile.dev.package.fast_image_resize] opt-level = 3 +[profile.dev.package.mozjpeg] +opt-level = 3 +[profile.dev.package.mozjpeg-sys] +opt-level = 3 # Parallel work scheduler [profile.dev.package.rayon] diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 95bd5ae..35583c4 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -204,10 +204,16 @@ pub fn start_thumbnail_worker( cache_dir: PathBuf, ) { std::thread::spawn(move || loop { - if let Err(error) = process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) { - eprintln!("Thumbnail worker error: {}", error); + // Only back off when the queue is empty (or errored); while jobs are + // pending, claim the next batch immediately. + match process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) { + Ok(true) => {} + Ok(false) => std::thread::sleep(std::time::Duration::from_millis(250)), + Err(error) => { + eprintln!("Thumbnail worker error: {}", error); + std::thread::sleep(std::time::Duration::from_millis(250)); + } } - std::thread::sleep(std::time::Duration::from_millis(250)); }); } @@ -554,12 +560,14 @@ fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result Result<()> { +) -> Result { let jobs = { with_db_write_lock(|| { let mut conn = pool.get()?; @@ -578,7 +586,7 @@ fn process_thumbnail_batch( }; if jobs.is_empty() { - return Ok(()); + return Ok(false); } println!("Thumbnail batch claimed: {} items", jobs.len()); @@ -586,33 +594,39 @@ fn process_thumbnail_batch( let (image_jobs, video_jobs): (Vec<_>, Vec<_>) = jobs.into_iter().partition(|job| job.media_kind == "image"); - let mut results = image_jobs - .par_iter() - .map(|job| { - ( - job.image_id, - if job.media_kind == "image" { - thumbnail::generate_image_thumbnail(Path::new(&job.path), cache_dir).map(Some) - } else { - thumbnail::generate_video_thumbnail( - media_tools, - Path::new(&job.path), - cache_dir, - ) - .map(Some) - }, - ) - }) - .collect::>(); - - for job in video_jobs { - results.push(( - job.image_id, - thumbnail::generate_video_thumbnail(media_tools, Path::new(&job.path), cache_dir) - .map(Some), - )); + // Images: parallel decode, committed as one batch. + if !image_jobs.is_empty() { + let results = image_jobs + .par_iter() + .map(|job| { + ( + job.image_id, + thumbnail::generate_image_thumbnail(Path::new(&job.path), cache_dir).map(Some), + ) + }) + .collect::>(); + persist_thumbnail_results(app, pool, results)?; } + // Videos: sequential, off the rayon pool — each ffmpeg call blocks its + // thread, and a video-heavy batch on the shared pool would starve image + // decoding across all workers. Committed per item so progress keeps + // moving through slow stretches. + for job in &video_jobs { + let result = + thumbnail::generate_video_thumbnail(media_tools, Path::new(&job.path), cache_dir) + .map(Some); + persist_thumbnail_results(app, pool, vec![(job.image_id, result)])?; + } + + Ok(true) +} + +fn persist_thumbnail_results( + app: &AppHandle, + pool: &DbPool, + results: Vec<(i64, anyhow::Result>)>, +) -> Result<()> { let updated_images = { with_db_write_lock(|| { let mut conn = pool.get()?; diff --git a/src-tauri/src/thumbnail.rs b/src-tauri/src/thumbnail.rs index 4efcf2f..ce74bfb 100644 --- a/src-tauri/src/thumbnail.rs +++ b/src-tauri/src/thumbnail.rs @@ -31,29 +31,26 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result Result Result { + if is_jpeg(image_path) { + if let Some(img) = decode_jpeg_scaled(image_path) { + return Ok(img); + } + } + + let reader = image::ImageReader::open(image_path)?.with_guessed_format()?; + let mut decoder = reader.into_decoder()?; + let orientation = decoder.orientation()?; + let mut img = image::DynamicImage::from_decoder(decoder)?; + img.apply_orientation(orientation); + Ok(img) +} + +fn is_jpeg(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg")) +} + +/// Decodes a JPEG at the smallest DCT scale that still covers `THUMB_SIZE`. +/// Returns `None` on any failure (corrupt file, CMYK without conversion +/// support, etc.) so the caller can fall back to the generic decoder. +/// mozjpeg reports errors by panicking, hence the `catch_unwind`. +fn decode_jpeg_scaled(image_path: &Path) -> Option { + let path = image_path.to_path_buf(); + let decoded = std::panic::catch_unwind(move || -> std::io::Result<(Vec, u32, u32)> { + let mut decompress = mozjpeg::Decompress::new_path(&path)?; + let (width, height) = decompress.size(); + decompress.scale(scale_numerator(width.max(height))); + let mut started = decompress.rgb()?; + let (out_width, out_height) = (started.width() as u32, started.height() as u32); + let pixels = started.read_scanlines::()?; + started.finish()?; + Ok((pixels, out_width, out_height)) + }) + .ok()? + .ok()?; + + let (pixels, width, height) = decoded; + let buffer = image::RgbImage::from_raw(width, height, pixels)?; + let mut img = image::DynamicImage::ImageRgb8(buffer); + if let Some(orientation) = exif_orientation(image_path) { + img.apply_orientation(orientation); + } + Some(img) +} + +/// Smallest numerator (of /8) that keeps the longest side at or above +/// `THUMB_SIZE`, so the subsequent resize never upscales. +fn scale_numerator(max_dimension: usize) -> u8 { + for numerator in 1..=8u8 { + if max_dimension * numerator as usize / 8 >= THUMB_SIZE as usize { + return numerator; + } + } + 8 +} + +fn exif_orientation(path: &Path) -> Option { + let file = std::fs::File::open(path).ok()?; + let mut reader = std::io::BufReader::new(file); + let exif = exif::Reader::new().read_from_container(&mut reader).ok()?; + let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?; + let value = field.value.get_uint(0)?; + image::metadata::Orientation::from_exif(value as u8) +} + pub fn generate_video_thumbnail( tools: &MediaTools, video_path: &Path, @@ -88,6 +159,8 @@ pub fn generate_video_thumbnail( let attempts: [&[&str]; 3] = [ &[ "-y", + "-threads", + "2", "-ss", "00:00:00.000", "-i", @@ -95,13 +168,15 @@ pub fn generate_video_thumbnail( "-frames:v", "1", "-vf", - "thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", + "scale=320:-1:force_original_aspect_ratio=decrease", "-q:v", "4", &output_path, ], &[ "-y", + "-threads", + "2", "-ss", "00:00:00.250", "-i", @@ -109,19 +184,21 @@ pub fn generate_video_thumbnail( "-frames:v", "1", "-vf", - "thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", + "scale=320:-1:force_original_aspect_ratio=decrease", "-q:v", "4", &output_path, ], &[ "-y", + "-threads", + "2", "-i", path_str.as_ref(), "-frames:v", "1", "-vf", - "thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", + "scale=320:-1:force_original_aspect_ratio=decrease", "-q:v", "4", &output_path, @@ -165,6 +242,41 @@ fn hash_path(s: &str) -> String { format!("{:016x}", xxhash_rust::xxh3::xxh3_64(s.as_bytes())) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scale_numerator_picks_smallest_sufficient() { + assert_eq!(scale_numerator(6000), 1); + assert_eq!(scale_numerator(640), 4); + assert_eq!(scale_numerator(320), 8); + assert_eq!(scale_numerator(100), 8); + } + + #[test] + fn jpeg_fast_path_decodes_at_reduced_resolution() { + let dir = std::env::temp_dir().join("phokus-thumb-test"); + std::fs::create_dir_all(&dir).unwrap(); + let src_path = dir.join("large.jpg"); + let img = + image::RgbImage::from_fn(1600, 1200, |x, _| image::Rgb([(x % 256) as u8, 64, 128])); + img.save(&src_path).unwrap(); + + // 1600 max dim -> numerator 2 -> 400x300 decode output + let decoded = decode_jpeg_scaled(&src_path).expect("fast path should handle plain JPEG"); + assert_eq!((decoded.width(), decoded.height()), (400, 300)); + + let cache = dir.join("cache"); + let _ = std::fs::remove_file(thumb_path(&cache, &src_path.to_string_lossy())); + let result = generate_image_thumbnail(&src_path, &cache).unwrap(); + assert_eq!(result.width, Some(1600)); + assert_eq!(result.height, Some(1200)); + let (thumb_w, thumb_h) = image::image_dimensions(&result.path).unwrap(); + assert_eq!((thumb_w, thumb_h), (320, 240)); + } +} + fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) { if width <= max_size && height <= max_size { return (width.max(1), height.max(1));