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>
161 lines
4.9 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|