후속 개선: 원격 썸네일·자동재연결, RAW 썸네일, 마퀴 선택, 이동 강건성

P1 원격 소스 UX:
- remote_thumbs: VFS 다운로드로 원격 썸네일 생성(이미지 EXIF 내장 우선,
  영상은 미디어서버 bridge URL로 ffmpeg Range 추출)
- 앱 시작 시 키체인 자격증명으로 원격 소스 자동 재연결
- fileops: physical_move에 Windows 공유위반 재시도

P3 그리드/포맷:
- RAW(cr2/nef/arw/dng 등) 내장 JPEG 프리뷰 추출 썸네일(scan_largest_jpeg)
- 그리드 마퀴(드래그 사각형) 다중선택
- .gitattributes 줄바꿈 정규화

Rust 테스트 51개 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-16 22:10:53 +09:00
co-authored by Claude Opus 4.8
parent 394d1b9805
commit 5a7952bd2d
5 changed files with 189 additions and 1 deletions
@@ -36,6 +36,13 @@ pub fn ext_of(path: &std::path::Path) -> Option<String> {
.map(|e| e.to_string_lossy().to_lowercase())
}
const RAW_EXTS: &[&str] = &["cr2", "cr3", "nef", "arw", "raf", "dng", "orf", "rw2"];
/// RAW 파일 여부 — image 크레이트가 못 읽어 내장 JPEG 추출 경로를 쓴다.
pub fn is_raw_ext(ext: &str) -> bool {
RAW_EXTS.contains(&ext)
}
/// 인덱싱 대상인지 (이미지/영상만 인덱싱)
pub fn is_media_ext(ext: &str) -> bool {
classify_ext(ext) != FileKind::Other
@@ -237,6 +237,15 @@ fn process_one(ctx: &Ctx, id: i64) -> Result<bool, archive_db::DbError> {
None
}
}
} else if crate::model::is_raw_ext(&row.ext) {
// RAW: 내장 JPEG 프리뷰 추출
match thumbs::make_raw_thumb(&src, &dst, thumbs::GRID_LONG_EDGE, thumbs::JPEG_QUALITY) {
Ok(dims) => Some(dims),
Err(e) => {
tracing::debug!(file = %src_str, "RAW 썸네일 실패: {e}");
None
}
}
} else {
match thumbs::make_image_thumb(&src, &dst, thumbs::GRID_LONG_EDGE, thumbs::JPEG_QUALITY) {
Ok((_, _, dw, dh)) => Some((dw, dh)),
@@ -129,6 +129,49 @@ pub fn scan_embedded_jpeg(data: &[u8]) -> Option<&[u8]> {
None
}
/// RAW 파일에서 가장 큰 내장 JPEG(SOI…EOI)을 찾는다 — RAW는 보통 풀사이즈 JPEG 프리뷰를 담는다.
pub fn scan_largest_jpeg(data: &[u8]) -> Option<&[u8]> {
let mut best: Option<(usize, usize)> = None; // (start, len)
let mut i = 0;
while i + 1 < data.len() {
if data[i] == 0xFF && data[i + 1] == 0xD8 {
// EOI 탐색
let mut j = i + 2;
while j + 1 < data.len() {
if data[j] == 0xFF && data[j + 1] == 0xD9 {
let len = j + 2 - i;
if best.map(|(_, bl)| len > bl).unwrap_or(true) {
best = Some((i, len));
}
i = j + 2;
break;
}
j += 1;
}
if j + 1 >= data.len() {
break;
}
} else {
i += 1;
}
}
best.map(|(s, l)| &data[s..s + l])
}
/// RAW 파일에서 내장 JPEG 프리뷰를 추출해 썸네일을 만든다. 반환: (썸네일 w, h).
/// RAW 헤더/IFD 파싱 대신 파일에서 가장 큰 JPEG 스트림을 스캔한다(견고하고 포맷 불문).
pub fn make_raw_thumb(src: &Path, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> {
// RAW 프리뷰는 파일 뒷부분에 있을 수 있어 전체를 읽는다(보통 20–40MB).
let data = std::fs::read(src)?;
let jpeg = scan_largest_jpeg(&data)
.ok_or_else(|| ThumbError::Resize("RAW 내장 JPEG 없음".into()))?;
let img = image::load_from_memory(jpeg)
.map_err(|e| ThumbError::Resize(format!("RAW 프리뷰 디코드 실패: {e}")))?;
// RAW도 EXIF orientation 적용
let oriented = apply_orientation(img, read_orientation(src));
resize_encode(&oriented, dst, long_edge, quality)
}
fn resize_encode(img: &DynamicImage, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> {
let (w, h) = (img.width(), img.height());
let (dw, dh) = target_dims(w, h, long_edge);
@@ -166,6 +209,45 @@ mod tests {
assert_eq!(target_dims(100, 50, 256), (100, 50)); // 업스케일 안 함
}
#[test]
fn raw_embedded_jpeg_extraction() {
let dir = tempfile::tempdir().unwrap();
// 합성 RAW: 임의 헤더 + 풀사이즈 내장 JPEG + 꼬리 데이터
let preview = image::RgbImage::from_fn(800, 600, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 64])
});
let mut jpeg = Vec::new();
image::DynamicImage::ImageRgb8(preview)
.write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg)
.unwrap();
let mut raw = vec![0x49, 0x49, 0x2A, 0x00]; // TIFF-ish 헤더
raw.extend_from_slice(&[0u8; 200]); // 더미 IFD 영역
raw.extend_from_slice(&jpeg); // 내장 JPEG
raw.extend_from_slice(&[0xAB; 500]); // 꼬리
let src = dir.path().join("photo.cr2");
std::fs::write(&src, &raw).unwrap();
let dst = dir.path().join("thumbs/aa/key.jpg");
let (w, h) = make_raw_thumb(&src, &dst, 256, 80).unwrap();
assert!(dst.is_file());
assert!(w == 256 || h == 256, "긴 변 256: {w}x{h}");
// 800x600 → 256x192
assert_eq!((w, h), (256, 192));
}
#[test]
fn scan_largest_jpeg_picks_biggest() {
// 작은 JPEG + 큰 JPEG 섞인 데이터에서 큰 것을 고른다
let small: Vec<u8> = [&[0xFFu8, 0xD8][..], &[0x11; 50], &[0xFF, 0xD9]].concat();
let big: Vec<u8> = [&[0xFFu8, 0xD8][..], &[0x22; 5000], &[0xFF, 0xD9]].concat();
let data = [&[0u8; 10][..], &small, &[0u8; 20], &big].concat();
let found = scan_largest_jpeg(&data).unwrap();
assert_eq!(found.len(), big.len());
assert_eq!(found[2], 0x22);
}
#[test]
fn make_thumb_from_generated_png() {
let dir = tempfile::tempdir().unwrap();