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,98 @@
//! ffmpeg 스트림(리먹스) 실기 테스트 — 사이드카가 있을 때만 실행.
use archive_media::stream::{AudioMode, StreamSpec, VideoMode};
use archive_media::PathResolver;
use std::path::{Path, PathBuf};
use std::sync::Arc;
fn ffmpeg_path() -> Option<PathBuf> {
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 p = bin.join(format!("ffmpeg-{triple}{ext}"));
p.is_file().then_some(p)
}
struct NoResolver;
#[async_trait::async_trait]
impl PathResolver for NoResolver {
fn resolve(&self, _: i64) -> Option<PathBuf> {
None
}
}
fn make_mkv(ffmpeg: &Path, dir: &Path) -> PathBuf {
let out = dir.join("입력.mkv");
let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
let status = std::process::Command::new(ffmpeg)
.args([
"-y", "-v", "error",
"-f", "lavfi", "-i", "testsrc2=duration=3:size=320x240:rate=25",
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
"-c:v", vcodec, "-b:v", "300k", "-c:a", "ac3", "-shortest",
])
.arg(&out)
.status()
.unwrap();
assert!(status.success());
out
}
#[tokio::test]
async fn remux_mkv_to_fmp4_stream() {
let Some(ffmpeg) = ffmpeg_path() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = tempfile::tempdir().unwrap();
let mkv = make_mkv(&ffmpeg, dir.path());
let server = archive_media::start(Arc::new(NoResolver), Some(ffmpeg)).await.unwrap();
// MKV(h264 + ac3) → 영상 카피 + 오디오 AAC 변환 (모드 3)
let job = server.streams.create(StreamSpec {
input: mkv,
start_seconds: 0.0,
video: VideoMode::Copy,
audio: AudioMode::Aac,
});
let url = server.stream_url(job);
let res = reqwest::get(&url).await.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(res.headers()["content-type"], "video/mp4");
let body = res.bytes().await.unwrap();
assert!(body.len() > 10_000, "스트림이 너무 짧음: {} bytes", body.len());
// fMP4 시그니처: 4번째 바이트부터 'ftyp'
assert_eq!(&body[4..8], b"ftyp", "fMP4 헤더가 아님");
// fragmented mp4는 moof 박스를 포함
assert!(
body.windows(4).any(|w| w == b"moof"),
"fragmented MP4가 아님 (moof 없음)"
);
server.streams.stop(job);
// 시킹 시나리오: 1.5초 지점에서 재기동
let job2 = server.streams.create(StreamSpec {
input: dir.path().join("입력.mkv"),
start_seconds: 1.5,
video: VideoMode::Copy,
audio: AudioMode::Aac,
});
let res2 = reqwest::get(server.stream_url(job2)).await.unwrap();
assert_eq!(res2.status(), 200);
let body2 = res2.bytes().await.unwrap();
assert!(body2.len() > 5_000);
assert!(body2.len() < body.len(), "1.5초 이후 스트림이 전체보다 짧아야 함");
server.streams.stop(job2);
}