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.
This commit is contained in:
2026-06-12 08:13:12 +01:00
parent 665c315f56
commit a4486547e8
4 changed files with 236 additions and 42 deletions
+63
View File
@@ -143,6 +143,12 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -635,6 +641,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -3108,6 +3116,16 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.94" version = "0.3.94"
@@ -3559,6 +3577,31 @@ dependencies = [
"pxfm", "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]] [[package]]
name = "muda" name = "muda"
version = "0.17.2" version = "0.17.2"
@@ -3586,6 +3629,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" 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]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.18" version = "0.2.18"
@@ -4388,6 +4441,7 @@ dependencies = [
"kamadak-exif", "kamadak-exif",
"log", "log",
"memmap2", "memmap2",
"mozjpeg",
"notify", "notify",
"ort", "ort",
"r2d2", "r2d2",
@@ -5102,6 +5156,15 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
+5
View File
@@ -53,6 +53,7 @@ csv = "1"
kamadak-exif = "0.5" kamadak-exif = "0.5"
notify = "6" notify = "6"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
mozjpeg = "0.10.13"
# ── Dev-mode performance ──────────────────────────────────────────────────── # ── Dev-mode performance ────────────────────────────────────────────────────
# opt-level=1 on the main crate keeps incremental compile short. # opt-level=1 on the main crate keeps incremental compile short.
@@ -81,6 +82,10 @@ opt-level = 3
opt-level = 3 opt-level = 3
[profile.dev.package.fast_image_resize] [profile.dev.package.fast_image_resize]
opt-level = 3 opt-level = 3
[profile.dev.package.mozjpeg]
opt-level = 3
[profile.dev.package.mozjpeg-sys]
opt-level = 3
# Parallel work scheduler # Parallel work scheduler
[profile.dev.package.rayon] [profile.dev.package.rayon]
+36 -22
View File
@@ -204,10 +204,16 @@ pub fn start_thumbnail_worker(
cache_dir: PathBuf, cache_dir: PathBuf,
) { ) {
std::thread::spawn(move || loop { std::thread::spawn(move || loop {
if let Err(error) = process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) { // 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); 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<Vec<ImageRecor
Ok(committed) Ok(committed)
} }
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty.
fn process_thumbnail_batch( fn process_thumbnail_batch(
app: &AppHandle, app: &AppHandle,
pool: &DbPool, pool: &DbPool,
media_tools: &MediaTools, media_tools: &MediaTools,
cache_dir: &Path, cache_dir: &Path,
) -> Result<()> { ) -> Result<bool> {
let jobs = { let jobs = {
with_db_write_lock(|| { with_db_write_lock(|| {
let mut conn = pool.get()?; let mut conn = pool.get()?;
@@ -578,7 +586,7 @@ fn process_thumbnail_batch(
}; };
if jobs.is_empty() { if jobs.is_empty() {
return Ok(()); return Ok(false);
} }
println!("Thumbnail batch claimed: {} items", jobs.len()); println!("Thumbnail batch claimed: {} items", jobs.len());
@@ -586,33 +594,39 @@ fn process_thumbnail_batch(
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) = let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
jobs.into_iter().partition(|job| job.media_kind == "image"); jobs.into_iter().partition(|job| job.media_kind == "image");
let mut results = image_jobs // Images: parallel decode, committed as one batch.
if !image_jobs.is_empty() {
let results = image_jobs
.par_iter() .par_iter()
.map(|job| { .map(|job| {
( (
job.image_id, job.image_id,
if job.media_kind == "image" { thumbnail::generate_image_thumbnail(Path::new(&job.path), cache_dir).map(Some),
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::<Vec<_>>(); .collect::<Vec<_>>();
persist_thumbnail_results(app, pool, results)?;
for job in video_jobs {
results.push((
job.image_id,
thumbnail::generate_video_thumbnail(media_tools, Path::new(&job.path), cache_dir)
.map(Some),
));
} }
// 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<Option<thumbnail::GeneratedThumbnail>>)>,
) -> Result<()> {
let updated_images = { let updated_images = {
with_db_write_lock(|| { with_db_write_lock(|| {
let mut conn = pool.get()?; let mut conn = pool.get()?;
+124 -12
View File
@@ -31,29 +31,26 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
}); });
} }
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?; let img = decode_for_thumbnail(image_path)?;
let mut decoder = reader.into_decoder()?;
let orientation = decoder.orientation()?;
let mut img = image::DynamicImage::from_decoder(decoder)?;
img.apply_orientation(orientation);
let src = image::DynamicImage::ImageRgba8(img.into_rgba8()); // RGB8 throughout: JPEG output has no alpha, so decoding/resizing RGBA
// only to flatten at encode time wastes a third of the bandwidth.
let src = image::DynamicImage::ImageRgb8(img.into_rgb8());
let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE); let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE);
let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap()); let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap());
let mut resizer = fir::Resizer::new(); let mut resizer = fir::Resizer::new();
let options = fir::ResizeOptions::new() let options = fir::ResizeOptions::new()
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3)); .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom));
resizer.resize(&src, &mut dst, Some(&options))?; resizer.resize(&src, &mut dst, Some(&options))?;
let thumb = image::RgbaImage::from_raw(dst_width, dst_height, dst.buffer().to_vec()) let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?; .ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
if let Some(parent) = out_path.parent() { if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let thumb = image::DynamicImage::ImageRgba8(thumb).into_rgb8();
let mut output_file = std::fs::File::create(&out_path)?; let mut output_file = std::fs::File::create(&out_path)?;
let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82); let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82);
encoder.encode_image(&thumb)?; encoder.encode_image(&thumb)?;
@@ -64,6 +61,80 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
}) })
} }
/// Decodes an image for thumbnailing, with EXIF orientation already applied.
///
/// JPEGs take a fast path that decodes at reduced resolution (DCT scaling),
/// which skips most of the IDCT/upsampling work for large photos. Anything
/// that path can't handle falls back to the `image` crate.
fn decode_for_thumbnail(image_path: &Path) -> Result<image::DynamicImage> {
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<image::DynamicImage> {
let path = image_path.to_path_buf();
let decoded = std::panic::catch_unwind(move || -> std::io::Result<(Vec<u8>, 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::<u8>()?;
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<image::metadata::Orientation> {
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( pub fn generate_video_thumbnail(
tools: &MediaTools, tools: &MediaTools,
video_path: &Path, video_path: &Path,
@@ -88,6 +159,8 @@ pub fn generate_video_thumbnail(
let attempts: [&[&str]; 3] = [ let attempts: [&[&str]; 3] = [
&[ &[
"-y", "-y",
"-threads",
"2",
"-ss", "-ss",
"00:00:00.000", "00:00:00.000",
"-i", "-i",
@@ -95,13 +168,15 @@ pub fn generate_video_thumbnail(
"-frames:v", "-frames:v",
"1", "1",
"-vf", "-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", "scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v", "-q:v",
"4", "4",
&output_path, &output_path,
], ],
&[ &[
"-y", "-y",
"-threads",
"2",
"-ss", "-ss",
"00:00:00.250", "00:00:00.250",
"-i", "-i",
@@ -109,19 +184,21 @@ pub fn generate_video_thumbnail(
"-frames:v", "-frames:v",
"1", "1",
"-vf", "-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", "scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v", "-q:v",
"4", "4",
&output_path, &output_path,
], ],
&[ &[
"-y", "-y",
"-threads",
"2",
"-i", "-i",
path_str.as_ref(), path_str.as_ref(),
"-frames:v", "-frames:v",
"1", "1",
"-vf", "-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", "scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v", "-q:v",
"4", "4",
&output_path, &output_path,
@@ -165,6 +242,41 @@ fn hash_path(s: &str) -> String {
format!("{:016x}", xxhash_rust::xxh3::xxh3_64(s.as_bytes())) 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) { fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
if width <= max_size && height <= max_size { if width <= max_size && height <= max_size {
return (width.max(1), height.max(1)); return (width.max(1), height.max(1));