diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c5f0d6c..0cb9c1e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -170,6 +170,7 @@ dependencies = [ "image", "image_hasher", "imagesize", + "jpeg-decoder", "jwalk", "kamadak-exif", "rayon", @@ -3187,6 +3188,15 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.103" diff --git a/src-tauri/crates/archive-indexer/Cargo.toml b/src-tauri/crates/archive-indexer/Cargo.toml index 94f8dbb..9227dd0 100644 --- a/src-tauri/crates/archive-indexer/Cargo.toml +++ b/src-tauri/crates/archive-indexer/Cargo.toml @@ -12,6 +12,7 @@ crossbeam-channel = { workspace = true } xxhash-rust = { version = "0.8", features = ["xxh3"] } blake3 = { version = "1", features = ["mmap", "rayon"] } image = "0.25" +jpeg-decoder = "0.3" image_hasher = "3" fast_image_resize = "6" imagesize = "0.14" diff --git a/src-tauri/crates/archive-indexer/src/lib.rs b/src-tauri/crates/archive-indexer/src/lib.rs index e7fe19b..282154f 100644 --- a/src-tauri/crates/archive-indexer/src/lib.rs +++ b/src-tauri/crates/archive-indexer/src/lib.rs @@ -9,6 +9,7 @@ pub mod meta; pub mod model; pub mod phash; pub mod pipeline; +pub mod prio; pub mod scan; pub mod tags; pub mod thumbq; diff --git a/src-tauri/crates/archive-indexer/src/meta.rs b/src-tauri/crates/archive-indexer/src/meta.rs index 71f42af..ea9d3bf 100644 --- a/src-tauri/crates/archive-indexer/src/meta.rs +++ b/src-tauri/crates/archive-indexer/src/meta.rs @@ -95,21 +95,24 @@ pub fn extract_image_meta( break; } - let results: Vec = batch - .par_iter() - .map(|(id, path)| { - let dims = imagesize::size(path).ok(); - let (orientation, taken_at) = exif_of(path); - MetaResult { - id: *id, - width: dims.map(|d| d.width as u32), - height: dims.map(|d| d.height as u32), - orientation, - taken_at, - ok: dims.is_some(), - } - }) - .collect(); + // 저우선순위 백그라운드 풀에서 실행 — CPU 포화 시 UI 스레드 우선 스케줄 + let results: Vec = crate::prio::bg_pool().install(|| { + batch + .par_iter() + .map(|(id, path)| { + let dims = imagesize::size(path).ok(); + let (orientation, taken_at) = exif_of(path); + MetaResult { + id: *id, + width: dims.map(|d| d.width as u32), + height: dims.map(|d| d.height as u32), + orientation, + taken_at, + ok: dims.is_some(), + } + }) + .collect() + }); total += results.len() as u64; db.with_write(move |conn| { @@ -166,10 +169,13 @@ pub fn extract_video_meta( break; } - let results: Vec<(i64, Option)> = batch - .par_iter() - .map(|(id, path)| (*id, crate::ffmpeg::probe(tools, path).ok())) - .collect(); + let results: Vec<(i64, Option)> = + crate::prio::bg_pool().install(|| { + batch + .par_iter() + .map(|(id, path)| (*id, crate::ffmpeg::probe(tools, path).ok())) + .collect() + }); total += results.len() as u64; db.with_write(move |conn| { diff --git a/src-tauri/crates/archive-indexer/src/prio.rs b/src-tauri/crates/archive-indexer/src/prio.rs new file mode 100644 index 0000000..689f5d9 --- /dev/null +++ b/src-tauri/crates/archive-indexer/src/prio.rs @@ -0,0 +1,46 @@ +//! 백그라운드 워커 스레드 우선순위 낮추기 — 스캔/썸네일 작업이 CPU를 점유해도 +//! UI(WebView) 스레드가 굶지 않도록 한다. Windows만 실제 동작, 그 외는 no-op. + +/// 현재 스레드를 낮은 우선순위로 설정한다 (Windows: BELOW_NORMAL). +pub fn set_current_thread_low() { + #[cfg(windows)] + unsafe { + // kernel32: GetCurrentThread / SetThreadPriority — C 의존성 없음(직접 FFI) + const THREAD_PRIORITY_BELOW_NORMAL: i32 = -1; + extern "system" { + fn GetCurrentThread() -> isize; + fn SetThreadPriority(h: isize, prio: i32) -> i32; + } + let h = GetCurrentThread(); + SetThreadPriority(h, THREAD_PRIORITY_BELOW_NORMAL); + } + #[cfg(target_os = "macos")] + unsafe { + // setpriority(PRIO_PROCESS=0, tid=0(self), niceness) — 스레드 니스 상향 + extern "C" { + fn setpriority(which: i32, who: u32, prio: i32) -> i32; + } + setpriority(0, 0, 5); + } +} + +/// 스캔 메타데이터 추출용 저우선순위 rayon 풀. +/// - 워커를 (코어-1)로 제한해 UI/OS에 코어를 최소 하나 남긴다. +/// - 각 워커 스레드를 BELOW_NORMAL로 낮춰, CPU 포화 시 UI 스레드가 우선 스케줄되게 한다. +/// 전역 rayon 풀과 분리되어 blake3/dedupe 등 다른 병렬 작업에 영향을 주지 않는다. +pub fn bg_pool() -> &'static rayon::ThreadPool { + use std::sync::OnceLock; + static POOL: OnceLock = OnceLock::new(); + POOL.get_or_init(|| { + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + let workers = cores.saturating_sub(1).max(1); + rayon::ThreadPoolBuilder::new() + .num_threads(workers) + .thread_name(|i| format!("bg-meta-{i}")) + .start_handler(|_| set_current_thread_low()) + .build() + .expect("백그라운드 rayon 풀 생성 실패") + }) +} diff --git a/src-tauri/crates/archive-indexer/src/thumbq.rs b/src-tauri/crates/archive-indexer/src/thumbq.rs index 1916edb..7111165 100644 --- a/src-tauri/crates/archive-indexer/src/thumbq.rs +++ b/src-tauri/crates/archive-indexer/src/thumbq.rs @@ -133,6 +133,8 @@ pub fn thumb_path(thumb_root: &std::path::Path, size_class: u8, key: &str) -> Pa } fn worker_loop(ctx: Arc, hi_rx: Receiver, lo_rx: Receiver) { + // 썸네일 디코드/리사이즈는 CPU 집약적 — UI 스레드가 굶지 않도록 우선순위를 낮춘다. + crate::prio::set_current_thread_low(); loop { // hi 우선, 없으면 lo를 짧게 대기 let id = match hi_rx.try_recv() { diff --git a/src-tauri/crates/archive-indexer/src/thumbs.rs b/src-tauri/crates/archive-indexer/src/thumbs.rs index a0c2412..0f22291 100644 --- a/src-tauri/crates/archive-indexer/src/thumbs.rs +++ b/src-tauri/crates/archive-indexer/src/thumbs.rs @@ -73,15 +73,58 @@ pub fn make_image_thumb( long_edge: u32, quality: u8, ) -> Result<(u32, u32, u32, u32)> { + let orientation = read_orientation(src); + + // JPEG 빠른 경로: DCT 축소 디코드(1/2·1/4·1/8). 6000×4000 사진을 풀디코드(≈72MB) + // 하지 않고 750×500(≈1MB)만 디코드해 CPU·메모리를 대폭 절감한다. 실패 시 아래로 폴백. + if is_jpeg_ext(src) { + if let Some(scaled) = decode_jpeg_scaled(src, long_edge) { + let oriented = apply_orientation(scaled, orientation); + let (w, h) = (oriented.width(), oriented.height()); + let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?; + return Ok((w, h, dw, dh)); + } + } + let decoded = image::ImageReader::open(src)? .with_guessed_format()? .decode()?; - let oriented = apply_orientation(decoded, read_orientation(src)); + let oriented = apply_orientation(decoded, orientation); let (w, h) = (oriented.width(), oriented.height()); let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?; Ok((w, h, dw, dh)) } +fn is_jpeg_ext(src: &Path) -> bool { + src.extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("jpg") || e.eq_ignore_ascii_case("jpeg")) + .unwrap_or(false) +} + +/// jpeg-decoder의 DCT 스케일 기능으로 축소 디코드한다. 긴 변이 대략 `target` 이상이 되는 +/// 가장 큰 축소(1/1·1/2·1/4·1/8)를 고른다. RGB24/L8만 처리하고 그 외(CMYK 등)는 None(폴백). +fn decode_jpeg_scaled(src: &Path, target: u32) -> Option { + use jpeg_decoder::PixelFormat; + let file = std::fs::File::open(src).ok()?; + let mut dec = jpeg_decoder::Decoder::new(std::io::BufReader::new(file)); + dec.read_info().ok()?; + // 회전으로 가로/세로가 뒤바뀔 수 있으므로 두 축 모두 target으로 요청(축소만 하므로 안전). + let t = target.clamp(1, u16::MAX as u32) as u16; + dec.scale(t, t).ok()?; + let pixels = dec.decode().ok()?; + let info = dec.info()?; + let (w, h) = (info.width as u32, info.height as u32); + if w == 0 || h == 0 { + return None; + } + match info.pixel_format { + PixelFormat::RGB24 => image::RgbImage::from_raw(w, h, pixels).map(DynamicImage::ImageRgb8), + PixelFormat::L8 => image::GrayImage::from_raw(w, h, pixels).map(DynamicImage::ImageLuma8), + _ => None, + } +} + /// 이미 디코드된 이미지에서 썸네일 생성(원격 EXIF 내장 썸네일 등). 반환: (썸네일 w, h). pub fn make_image_thumb_from( img: &DynamicImage, @@ -248,6 +291,58 @@ mod tests { assert_eq!(found[2], 0x22); } + #[test] + fn jpeg_dct_scaled_fast_path() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("big.jpg"); + let dst = dir.path().join("thumbs/aa/key.jpg"); + + // 2400×1600 JPEG — DCT 축소 경로가 타겟(256)보다 크게 디코드 후 리사이즈. + let img = image::RgbImage::from_fn(2400, 1600, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, 100]) + }); + img.save(&src).unwrap(); // 확장자(.jpg)로 JPEG 인코딩 + + // fast path 직접 검증: 축소 디코드 결과가 원본보다 작고, 긴 변이 타겟 이상. + let scaled = decode_jpeg_scaled(&src, 256).expect("DCT 축소 디코드"); + assert!(scaled.width() < 2400, "축소되어야 함: {}", scaled.width()); + assert!(scaled.width().max(scaled.height()) >= 256); + + // 최종 썸네일: 긴 변 256, 2400×1600(3:2) → 256×170 + let (_, _, dw, dh) = make_image_thumb(&src, &dst, 256, 80).unwrap(); + assert_eq!((dw, dh), (256, 170)); + assert!(dst.is_file()); + let reopened = image::ImageReader::open(&dst).unwrap().decode().unwrap(); + assert_eq!(reopened.width(), 256); + } + + #[test] + #[ignore = "타이밍 측정용 — cargo test -- --ignored --nocapture"] + fn bench_full_vs_dct_decode() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("huge.jpg"); + // 6000×4000 (24MP) 실사진 규모 JPEG + let img = image::RgbImage::from_fn(6000, 4000, |x, y| { + image::Rgb([(x % 256) as u8, ((x + y) % 256) as u8, (y % 256) as u8]) + }); + img.save(&src).unwrap(); + + let t0 = std::time::Instant::now(); + let full = image::ImageReader::open(&src).unwrap().with_guessed_format().unwrap().decode().unwrap(); + let full_ms = t0.elapsed().as_secs_f64() * 1000.0; + assert_eq!(full.width(), 6000); + + let t1 = std::time::Instant::now(); + let scaled = decode_jpeg_scaled(&src, 256).unwrap(); + let dct_ms = t1.elapsed().as_secs_f64() * 1000.0; + + eprintln!( + "24MP JPEG 디코드: 풀={full_ms:.0}ms(6000×4000, ~72MB) vs DCT축소={dct_ms:.0}ms({}×{}) → {:.1}배 빠름", + scaled.width(), scaled.height(), full_ms / dct_ms.max(0.001) + ); + assert!(scaled.width() <= 750, "1/8 축소: {}", scaled.width()); + } + #[test] fn make_thumb_from_generated_png() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 53c5b2d..b0c4aa4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -240,6 +240,8 @@ pub fn spawn_scan_thread( std::thread::Builder::new() .name("scan".into()) .spawn(move || { + // 스캔/메타 오케스트레이션 스레드도 저우선순위로 — UI 응답성 우선 + archive_indexer::prio::set_current_thread_low(); run_scan_pipeline(&db, &tools, &thumbs, source_id, Path::new(&root), &cancel, |p| { if let Some(ch) = &on_progress { let _ = ch.send(p);