후속 개선: 원격 썸네일·자동재연결, 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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user