Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램

Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼
사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드,
인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포.

- archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram
- archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트)
- archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수
- archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡
- 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널

Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-16 22:04:06 +09:00
co-authored by Claude Opus 4.8
commit 394d1b9805
132 changed files with 23468 additions and 0 deletions
@@ -0,0 +1,68 @@
//! ffmpeg 사이드카 실기 테스트 — src-tauri/binaries에 사이드카가 있을 때만 실행된다.
//! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버)
use archive_indexer::ffmpeg::{self, FfTools};
use std::path::{Path, PathBuf};
fn tools() -> Option<FfTools> {
// crates/archive-indexer → ../../binaries
let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../binaries");
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
"x86_64-pc-windows-msvc"
} else if cfg!(all(windows, target_arch = "aarch64")) {
"aarch64-pc-windows-msvc"
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else {
"x86_64-apple-darwin"
};
let ext = if cfg!(windows) { ".exe" } else { "" };
let ffmpeg = bin.join(format!("ffmpeg-{triple}{ext}"));
let ffprobe = bin.join(format!("ffprobe-{triple}{ext}"));
if ffmpeg.is_file() && ffprobe.is_file() {
Some(FfTools { ffmpeg, ffprobe })
} else {
None
}
}
fn make_test_video(t: &FfTools, dir: &Path) -> PathBuf {
let out = dir.join("테스트영상.mp4");
let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
let status = ffmpeg::command(&t.ffmpeg)
.args([
"-y", "-v", "error",
"-f", "lavfi", "-i", "testsrc2=duration=2:size=320x240:rate=25",
"-c:v", vcodec, "-b:v", "300k",
])
.arg(&out)
.status()
.expect("ffmpeg 실행 실패");
assert!(status.success(), "테스트 영상 생성 실패");
out
}
#[test]
fn probe_and_thumbnail_roundtrip() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀 (scripts/fetch-ffmpeg.ps1 실행 후 재시도)");
return;
};
let dir = tempfile::tempdir().unwrap();
let video = make_test_video(&t, dir.path());
// ffprobe 메타
let info = ffmpeg::probe(&t, &video).unwrap();
assert_eq!(info.vcodec.as_deref(), Some("h264"));
assert_eq!(info.width, Some(320));
assert_eq!(info.height, Some(240));
let dur = info.duration_ms.expect("duration 없음");
assert!((1500..=2500).contains(&dur), "duration {dur}ms");
assert!(info.fps.unwrap() > 20.0);
// 프레임 썸네일
let thumb = dir.path().join("thumbs/aa/key.jpg");
let (w, h) = ffmpeg::extract_frame_jpeg(&t, &video, &thumb, 0.5, 256).unwrap();
assert!(thumb.is_file());
assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}");
}
@@ -0,0 +1,51 @@
//! 성능 측정 (기본 무시). 실행:
//! ARCHIVE_PERF_DIR=<50k폴더> cargo test -p archive-indexer --release --test perf_scan -- --ignored --nocapture
use archive_db::Db;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Instant;
#[test]
#[ignore]
fn scan_perf() {
let Ok(dir) = std::env::var("ARCHIVE_PERF_DIR") else {
eprintln!("ARCHIVE_PERF_DIR 미설정 — 건너뜀");
return;
};
let dbdir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("perf.db")).unwrap());
let root = std::path::Path::new(&dir);
let cancel = AtomicBool::new(false);
let dir_owned = dir.clone();
db.with_write(move |c| {
c.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','perf',?1)", [&dir_owned])?;
Ok(())
})
.unwrap();
// 1차 스캔 (전체)
let t0 = Instant::now();
let stats = archive_indexer::pipeline::scan_source(&db, 1, root, &cancel, |_| {}).unwrap();
let scan1 = t0.elapsed();
// 메타데이터
let t1 = Instant::now();
let meta = archive_indexer::meta::extract_image_meta(&db, 1, &cancel).unwrap();
let meta_dur = t1.elapsed();
// 2차 스캔 (무변경 — 스냅샷 비교)
let t2 = Instant::now();
let stats2 = archive_indexer::pipeline::scan_source(&db, 1, root, &cancel, |_| {}).unwrap();
let scan2 = t2.elapsed();
eprintln!("=== 성능 (릴리스) ===");
eprintln!("파일 수: {}", stats.seen);
eprintln!("1차 스캔: {:.2}s ({} added)", scan1.as_secs_f64(), stats.added);
eprintln!("메타 추출: {:.2}s ({} 이미지)", meta_dur.as_secs_f64(), meta);
eprintln!("무변경 재스캔: {:.2}s (added={}, changed={})", scan2.as_secs_f64(), stats2.added, stats2.changed);
assert_eq!(stats2.added, 0);
assert_eq!(stats2.changed, 0);
}