this repo has no description
at main 217 lines 6.1 kB view raw
1use std::path::{Path, PathBuf}; 2use std::process::ExitStatus; 3 4use anyhow::{Context, Result, bail}; 5use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 6use tokio::process::{Child, ChildStdin, Command}; 7use tokio::task::JoinHandle; 8use tracing::{debug, info, warn}; 9 10use crate::frame::RgbFrame; 11 12#[derive(Debug, Clone)] 13pub struct EncoderSettings { 14 pub width: u32, 15 pub height: u32, 16 pub fps: u32, 17 pub bitrate_kbps: u32, 18 pub keyint_sec: u32, 19 pub x264_opts: String, 20 pub output: String, 21 pub include_silent_audio: bool, 22 pub ffmpeg_path: PathBuf, 23} 24 25pub fn build_ffmpeg_args(settings: &EncoderSettings) -> Vec<String> { 26 build_ffmpeg_args_with_loglevel(settings, "warning") 27} 28 29pub fn build_ffmpeg_args_with_loglevel(settings: &EncoderSettings, loglevel: &str) -> Vec<String> { 30 let keyint = settings.fps.saturating_mul(settings.keyint_sec).max(1); 31 let bufsize = settings.bitrate_kbps.saturating_mul(2); 32 33 let mut args = vec![ 34 "-hide_banner".to_string(), 35 "-loglevel".to_string(), 36 loglevel.to_string(), 37 "-stats_period".to_string(), 38 "5".to_string(), 39 "-stats".to_string(), 40 "-f".to_string(), 41 "rawvideo".to_string(), 42 "-pix_fmt".to_string(), 43 "rgb24".to_string(), 44 "-s".to_string(), 45 format!("{}x{}", settings.width, settings.height), 46 "-r".to_string(), 47 settings.fps.to_string(), 48 "-i".to_string(), 49 "-".to_string(), 50 ]; 51 52 if settings.include_silent_audio { 53 args.extend([ 54 "-f".to_string(), 55 "lavfi".to_string(), 56 "-i".to_string(), 57 "anullsrc=r=48000:cl=stereo".to_string(), 58 ]); 59 } 60 61 args.extend([ 62 "-c:v".to_string(), 63 "libx264".to_string(), 64 "-preset".to_string(), 65 "veryfast".to_string(), 66 "-pix_fmt".to_string(), 67 "yuv420p".to_string(), 68 "-b:v".to_string(), 69 format!("{}k", settings.bitrate_kbps), 70 "-maxrate".to_string(), 71 format!("{}k", settings.bitrate_kbps), 72 "-bufsize".to_string(), 73 format!("{}k", bufsize), 74 "-g".to_string(), 75 keyint.to_string(), 76 "-keyint_min".to_string(), 77 keyint.to_string(), 78 "-x264-params".to_string(), 79 settings.x264_opts.clone(), 80 ]); 81 82 if settings.include_silent_audio { 83 args.extend([ 84 "-c:a".to_string(), 85 "aac".to_string(), 86 "-b:a".to_string(), 87 "128k".to_string(), 88 "-ar".to_string(), 89 "48000".to_string(), 90 "-ac".to_string(), 91 "2".to_string(), 92 ]); 93 } else { 94 args.push("-an".to_string()); 95 } 96 97 args.extend(["-f".to_string(), "flv".to_string(), settings.output.clone()]); 98 99 args 100} 101 102#[derive(Debug)] 103pub struct FfmpegEncoder { 104 child: Child, 105 stdin: ChildStdin, 106 stderr_task: JoinHandle<()>, 107} 108 109impl FfmpegEncoder { 110 pub async fn spawn(settings: &EncoderSettings, verbose: bool) -> Result<Self> { 111 let args = 112 build_ffmpeg_args_with_loglevel(settings, if verbose { "info" } else { "warning" }); 113 114 info!( 115 ffmpeg = %settings.ffmpeg_path.display(), 116 output = %settings.output, 117 "starting ffmpeg" 118 ); 119 120 let mut cmd = Command::new(&settings.ffmpeg_path); 121 cmd.args(&args) 122 .stdin(std::process::Stdio::piped()) 123 .stderr(std::process::Stdio::piped()) 124 .stdout(std::process::Stdio::null()); 125 126 let mut child = cmd.spawn().with_context(|| { 127 format!( 128 "failed to spawn ffmpeg from {}", 129 settings.ffmpeg_path.display() 130 ) 131 })?; 132 133 let stdin = child.stdin.take().context("ffmpeg stdin unavailable")?; 134 135 let stderr = child.stderr.take().context("ffmpeg stderr unavailable")?; 136 137 let stderr_task = tokio::spawn(async move { 138 let mut lines = BufReader::new(stderr).lines(); 139 while let Ok(Some(line)) = lines.next_line().await { 140 if verbose { 141 info!(target: "ffmpeg", "{line}"); 142 } else { 143 debug!(target: "ffmpeg", "{line}"); 144 } 145 } 146 }); 147 148 Ok(Self { 149 child, 150 stdin, 151 stderr_task, 152 }) 153 } 154 155 pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> { 156 self.child 157 .try_wait() 158 .context("failed to poll ffmpeg process") 159 } 160 161 pub async fn write_frame(&mut self, frame: &RgbFrame) -> Result<()> { 162 if frame.width == 0 || frame.height == 0 { 163 bail!("invalid frame dimensions {}x{}", frame.width, frame.height); 164 } 165 166 if let Some(status) = self.try_wait()? { 167 bail!("ffmpeg exited early with status {status}"); 168 } 169 170 self.stdin 171 .write_all(&frame.data) 172 .await 173 .context("failed writing frame to ffmpeg stdin")?; 174 175 Ok(()) 176 } 177 178 pub async fn kill_and_wait(&mut self) { 179 match self.child.kill().await { 180 Ok(()) => {} 181 Err(err) => warn!("failed to kill ffmpeg: {err}"), 182 } 183 184 match self.child.wait().await { 185 Ok(status) => debug!("ffmpeg exited after kill: {status}"), 186 Err(err) => warn!("failed waiting on ffmpeg: {err}"), 187 } 188 } 189 190 pub async fn wait_for_exit(mut self) -> Result<ExitStatus> { 191 drop(self.stdin); 192 let status = self 193 .child 194 .wait() 195 .await 196 .context("failed waiting for ffmpeg exit")?; 197 198 self.stderr_task.abort(); 199 Ok(status) 200 } 201} 202 203pub fn ffmpeg_executable_name() -> &'static str { 204 if cfg!(target_os = "windows") { 205 "ffmpeg.exe" 206 } else { 207 "ffmpeg" 208 } 209} 210 211pub fn default_ffmpeg_sidecar_path(exe_dir: &Path) -> PathBuf { 212 exe_dir 213 .join("..") 214 .join("sidecar") 215 .join("ffmpeg") 216 .join(ffmpeg_executable_name()) 217}