Files
Archive/src-tauri/crates/archive-media/src/stream.rs
T
강 한andClaude Opus 4.8 394d1b9805 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>
2026-07-16 22:04:06 +09:00

161 lines
4.9 KiB
Rust

//! 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();
}
}
}