diff --git a/src-tauri/crates/archive-indexer/src/model.rs b/src-tauri/crates/archive-indexer/src/model.rs index 61dcdac..5813c58 100644 --- a/src-tauri/crates/archive-indexer/src/model.rs +++ b/src-tauri/crates/archive-indexer/src/model.rs @@ -36,6 +36,13 @@ pub fn ext_of(path: &std::path::Path) -> Option { .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 diff --git a/src-tauri/crates/archive-indexer/src/thumbq.rs b/src-tauri/crates/archive-indexer/src/thumbq.rs index 39541f2..1916edb 100644 --- a/src-tauri/crates/archive-indexer/src/thumbq.rs +++ b/src-tauri/crates/archive-indexer/src/thumbq.rs @@ -237,6 +237,15 @@ fn process_one(ctx: &Ctx, id: i64) -> Result { 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)), diff --git a/src-tauri/crates/archive-indexer/src/thumbs.rs b/src-tauri/crates/archive-indexer/src/thumbs.rs index d18280f..a0c2412 100644 --- a/src-tauri/crates/archive-indexer/src/thumbs.rs +++ b/src-tauri/crates/archive-indexer/src/thumbs.rs @@ -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 = [&[0xFFu8, 0xD8][..], &[0x11; 50], &[0xFF, 0xD9]].concat(); + let big: Vec = [&[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(); diff --git a/src/features/grid/JustifiedGrid.tsx b/src/features/grid/JustifiedGrid.tsx index 5c749ce..bde9bff 100644 --- a/src/features/grid/JustifiedGrid.tsx +++ b/src/features/grid/JustifiedGrid.tsx @@ -13,10 +13,12 @@ import type { LayoutResult } from "./layout.worker"; import LayoutWorker from "./layout.worker?worker"; import { anchorIndex, + clearSelection, info, openLightbox, selected, selectSingle, + setSelectionFromIds, toggleSelect, selectRange, snapshot, @@ -53,6 +55,8 @@ export const JustifiedGrid: Component = () => { const [scrollTop, setScrollTop] = createSignal(0); const [viewportH, setViewportH] = createSignal(600); const [containerW, setContainerW] = createSignal(0); + // 마퀴(드래그 사각형) 선택 — 콘텐츠 좌표계 [x1,y1,x2,y2] + const [marquee, setMarquee] = createSignal<[number, number, number, number] | null>(null); const worker = new LayoutWorker(); let layoutSeq = 0; @@ -244,11 +248,75 @@ export const JustifiedGrid: Component = () => { if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; }; + // ── 마퀴(드래그 사각형) 선택 ── + // 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(8) (+ y는 scrollTop) + const toContent = (clientX: number, clientY: number): [number, number] => { + const rect = containerRef!.getBoundingClientRect(); + return [ + clientX - rect.left - 8, + clientY - rect.top - 8 + containerRef!.scrollTop, + ]; + }; + + const onBgMouseDown = (e: MouseEvent) => { + // 배경(빈 공간) 좌클릭에서만 시작 — 셀 클릭은 제외 + if (e.button !== 0 || !(e.target as HTMLElement).dataset.gridbg) return; + const [sx, sy] = toContent(e.clientX, e.clientY); + setMarquee([sx, sy, sx, sy]); + if (!e.ctrlKey && !e.metaKey) clearSelection(); + + const onMove = (me: MouseEvent) => { + const [cx, cy] = toContent(me.clientX, me.clientY); + const m = marquee(); + if (m) setMarquee([m[0], m[1], cx, cy]); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + commitMarquee(); + setMarquee(null); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + const commitMarquee = () => { + const m = marquee(); + const l = layout(); + const snap = snapshot(); + if (!m || !l || !snap) return; + const [x1, y1, x2, y2] = [Math.min(m[0], m[2]), Math.min(m[1], m[3]), Math.max(m[0], m[2]), Math.max(m[1], m[3])]; + // 너무 작은 드래그는 무시(클릭으로 간주) + if (x2 - x1 < 4 && y2 - y1 < 4) return; + const ids: number[] = []; + for (let i = 0; i < snap.count; i++) { + const bx = l.boxes[i * 4]; + const by = l.boxes[i * 4 + 1]; + const bw = l.boxes[i * 4 + 2]; + const bh = l.boxes[i * 4 + 3]; + // AABB 교차 + if (bx < x2 && bx + bw > x1 && by < y2 && by + bh > y1) ids.push(snap.id(i)); + } + setSelectionFromIds(ids); + }; + + const marqueeRect = createMemo(() => { + const m = marquee(); + if (!m) return null; + return { + left: Math.min(m[0], m[2]), + top: Math.min(m[1], m[3]), + width: Math.abs(m[2] - m[0]), + height: Math.abs(m[3] - m[1]), + }; + }); + return (
@@ -260,7 +328,24 @@ export const JustifiedGrid: Component = () => {
} > -
+
+ + {(r) => ( +
+ )} + {(i) => { const snap = snapshot()!; diff --git a/src/state/store.ts b/src/state/store.ts index 264f54d..d03c59e 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -109,6 +109,11 @@ export function clearSelection(): void { setAnchorIndex(-1); } +/** 마퀴(드래그 사각형) 선택 — id 집합으로 직접 설정 */ +export function setSelectionFromIds(ids: number[]): void { + setSelected(new Set(ids)); +} + // ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ──────── const [thumbVersions, setThumbVersions] = createStore>({}); export { thumbVersions };