후속 개선: 원격 썸네일·자동재연결, 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();
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={onBgMouseDown}
|
||||
tabindex={0}
|
||||
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 outline-none"
|
||||
>
|
||||
@@ -260,7 +328,24 @@ export const JustifiedGrid: Component = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative" style={{ height: `${layout()?.totalHeight ?? 0}px` }}>
|
||||
<div
|
||||
class="relative"
|
||||
data-gridbg="1"
|
||||
style={{ height: `${layout()?.totalHeight ?? 0}px` }}
|
||||
>
|
||||
<Show when={marqueeRect()}>
|
||||
{(r) => (
|
||||
<div
|
||||
class="pointer-events-none absolute z-10 border border-[var(--accent)] bg-[var(--accent-soft)]"
|
||||
style={{
|
||||
left: `${r().left}px`,
|
||||
top: `${r().top}px`,
|
||||
width: `${r().width}px`,
|
||||
height: `${r().height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<For each={indices()}>
|
||||
{(i) => {
|
||||
const snap = snapshot()!;
|
||||
|
||||
@@ -109,6 +109,11 @@ export function clearSelection(): void {
|
||||
setAnchorIndex(-1);
|
||||
}
|
||||
|
||||
/** 마퀴(드래그 사각형) 선택 — id 집합으로 직접 설정 */
|
||||
export function setSelectionFromIds(ids: number[]): void {
|
||||
setSelected(new Set<number>(ids));
|
||||
}
|
||||
|
||||
// ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ────────
|
||||
const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({});
|
||||
export { thumbVersions };
|
||||
|
||||
Reference in New Issue
Block a user