폴더 추가 시 UI 멈춤 수정 — 백그라운드 워커 저우선순위 + JPEG DCT 축소 디코드

원인: 실사진(수십 MP) 스캔 시 메타 추출(rayon)과 썸네일 워커가
NORMAL 우선순위로 전 코어를 포화시켜 WebView UI 스레드를 굶김.
풀해상도 JPEG 디코드는 파일당 ~72MB를 할당해 병렬 시 메모리 대역폭까지 포화.

- prio 모듈: 현재 스레드 우선순위 낮추기(Win BELOW_NORMAL / macOS nice+5),
  메타 전용 저우선순위 rayon 풀(코어-1, 전역 풀과 분리)
- 썸네일 워커 스레드·스캔 스레드를 저우선순위로 → CPU 포화 시 UI 우선 스케줄
- meta 이미지/영상 추출을 저우선순위 풀에서 실행
- JPEG는 jpeg-decoder DCT 축소 디코드(1/8 등) 사용:
  24MP 기준 풀디코드 63ms/72MB → 축소 38ms/1MB (1.7배·메모리 ~70배 절감),
  RGB24/L8만 처리하고 그 외(CMYK)는 기존 경로로 폴백

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-19 14:19:18 +09:00
co-authored by Claude Fable 5
parent b4b1ef3241
commit 31a0487419
8 changed files with 183 additions and 20 deletions
+10
View File
@@ -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"
@@ -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"
@@ -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;
+25 -19
View File
@@ -95,21 +95,24 @@ pub fn extract_image_meta(
break;
}
let results: Vec<MetaResult> = 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<MetaResult> = 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<crate::ffmpeg::MediaInfo>)> = batch
.par_iter()
.map(|(id, path)| (*id, crate::ffmpeg::probe(tools, path).ok()))
.collect();
let results: Vec<(i64, Option<crate::ffmpeg::MediaInfo>)> =
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| {
@@ -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<rayon::ThreadPool> = 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 풀 생성 실패")
})
}
@@ -133,6 +133,8 @@ pub fn thumb_path(thumb_root: &std::path::Path, size_class: u8, key: &str) -> Pa
}
fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
// 썸네일 디코드/리사이즈는 CPU 집약적 — UI 스레드가 굶지 않도록 우선순위를 낮춘다.
crate::prio::set_current_thread_low();
loop {
// hi 우선, 없으면 lo를 짧게 대기
let id = match hi_rx.try_recv() {
+96 -1
View File
@@ -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<DynamicImage> {
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();
+2
View File
@@ -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);