폴더 추가 시 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:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user