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:
@@ -0,0 +1,160 @@
|
||||
//! ffmpeg 리먹스/트랜스코딩 스트림 잡 관리.
|
||||
//!
|
||||
//! 웹뷰가 재생 못 하는 컨테이너/코덱은 ffmpeg로 fMP4를 만들어 chunked로
|
||||
//! 흘려보낸다. 시킹은 잡 재생성(-ss 입력 시킹) + 프론트 seekBase 오프셋 방식.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VideoMode {
|
||||
/// 스트림 카피 (컨테이너만 교체)
|
||||
Copy,
|
||||
/// 카피 + hvc1 태그 (macOS HEVC)
|
||||
CopyHvc1,
|
||||
/// H.264 하드웨어 인코더로 변환
|
||||
Transcode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AudioMode {
|
||||
Copy,
|
||||
/// AAC 변환 (AC-3/DTS/Vorbis 등)
|
||||
Aac,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamSpec {
|
||||
pub input: PathBuf,
|
||||
pub start_seconds: f64,
|
||||
pub video: VideoMode,
|
||||
pub audio: AudioMode,
|
||||
}
|
||||
|
||||
pub struct StreamManager {
|
||||
ffmpeg: Option<PathBuf>,
|
||||
specs: Mutex<HashMap<u64, StreamSpec>>,
|
||||
procs: Mutex<HashMap<u64, tokio::process::Child>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl StreamManager {
|
||||
pub fn new(ffmpeg: Option<PathBuf>) -> Self {
|
||||
StreamManager {
|
||||
ffmpeg,
|
||||
specs: Mutex::new(HashMap::new()),
|
||||
procs: Mutex::new(HashMap::new()),
|
||||
next_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn available(&self) -> bool {
|
||||
self.ffmpeg.is_some()
|
||||
}
|
||||
|
||||
/// 잡을 등록하고 id를 돌려준다 (프로세스는 GET 시점에 기동).
|
||||
pub fn create(&self, spec: StreamSpec) -> u64 {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
self.specs.lock().unwrap().insert(id, spec);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn stop(&self, id: u64) {
|
||||
self.specs.lock().unwrap().remove(&id);
|
||||
if let Some(mut child) = self.procs.lock().unwrap().remove(&id) {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_all(&self) {
|
||||
self.specs.lock().unwrap().clear();
|
||||
let mut procs = self.procs.lock().unwrap();
|
||||
for (_, mut child) in procs.drain() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// GET 핸들러에서 호출 — ffmpeg를 기동하고 stdout을 돌려준다.
|
||||
pub(crate) fn spawn(&self, id: u64) -> Option<tokio::process::ChildStdout> {
|
||||
let spec = self.specs.lock().unwrap().get(&id).cloned()?;
|
||||
let ffmpeg = self.ffmpeg.clone()?;
|
||||
|
||||
// 같은 잡의 이전 프로세스는 종료 (시킹 재요청 등)
|
||||
if let Some(mut old) = self.procs.lock().unwrap().remove(&id) {
|
||||
let _ = old.start_kill();
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&ffmpeg);
|
||||
cmd.args(["-v", "error"]);
|
||||
if spec.start_seconds > 0.01 {
|
||||
cmd.args(["-ss", &format!("{:.3}", spec.start_seconds)]);
|
||||
}
|
||||
cmd.arg("-i").arg(&spec.input);
|
||||
cmd.args(["-map", "0:v:0", "-map", "0:a:0?"]);
|
||||
|
||||
match spec.video {
|
||||
VideoMode::Copy => {
|
||||
cmd.args(["-c:v", "copy"]);
|
||||
}
|
||||
VideoMode::CopyHvc1 => {
|
||||
cmd.args(["-c:v", "copy", "-tag:v", "hvc1"]);
|
||||
}
|
||||
VideoMode::Transcode => {
|
||||
#[cfg(windows)]
|
||||
cmd.args(["-c:v", "h264_mf", "-b:v", "6M"]);
|
||||
#[cfg(target_os = "macos")]
|
||||
cmd.args(["-c:v", "h264_videotoolbox", "-b:v", "6M"]);
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
cmd.args(["-c:v", "libx264", "-preset", "veryfast", "-crf", "23"]);
|
||||
// H.264는 짝수 해상도 필수
|
||||
cmd.args(["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"]);
|
||||
}
|
||||
}
|
||||
match spec.audio {
|
||||
AudioMode::Copy => {
|
||||
cmd.args(["-c:a", "copy"]);
|
||||
}
|
||||
AudioMode::Aac => {
|
||||
cmd.args(["-c:a", "aac", "-b:a", "192k", "-ac", "2"]);
|
||||
}
|
||||
}
|
||||
cmd.args([
|
||||
"-movflags",
|
||||
"frag_keyframe+empty_moov+default_base_moof",
|
||||
"-f",
|
||||
"mp4",
|
||||
"pipe:1",
|
||||
]);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null());
|
||||
cmd.kill_on_drop(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("ffmpeg 스트림 기동 실패: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let stdout = child.stdout.take()?;
|
||||
self.procs.lock().unwrap().insert(id, child);
|
||||
Some(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StreamManager {
|
||||
fn drop(&mut self) {
|
||||
let mut procs = self.procs.lock().unwrap();
|
||||
for (_, mut child) in procs.drain() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user