From a4486547e8b5881f292af1b84dc8e63697afda6f Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 12 Jun 2026 08:13:12 +0100 Subject: [PATCH] perf(thumbnails): scaled JPEG decode and continuous worker batching Major speedups for large-folder thumbnail generation: - JPEGs decode through mozjpeg at the smallest DCT scale covering 320px instead of full resolution, with catch_unwind and a fallback to the image crate for anything mozjpeg rejects. EXIF orientation preserved in both paths. - RGB8 pipeline end to end (no RGBA round-trip) and CatmullRom resize in place of Lanczos3. - Video posters grab the seeked frame directly instead of running the ffmpeg thumbnail filter (which decoded ~100 frames), with decode threads capped at 2 per process. - Workers only sleep when the queue is empty rather than 250ms after every batch. Image batches commit before videos start, and videos run sequentially off the rayon pool (blocking ffmpeg waits were starving image decode) with per-item commits so progress keeps moving through slow stretches. --- src-tauri/Cargo.lock | 63 +++++++++++++++++ src-tauri/Cargo.toml | 5 ++ src-tauri/src/indexer.rs | 74 ++++++++++++-------- src-tauri/src/thumbnail.rs | 136 +++++++++++++++++++++++++++++++++---- 4 files changed, 236 insertions(+), 42 deletions(-) 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));