this repo has no description
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}