1use std::{
5 collections::VecDeque,
6 io::Write,
7 process::{Child, ChildStdin, Command, Stdio},
8};
9
10use machine_vision_formats::pixel_format::PixFmt;
11
12const FFMPEG: &str = "ffmpeg";
13
14#[derive(thiserror::Error, Debug)]
15pub enum Error {
16 #[error("IO error: {0}")]
17 Io(#[from] std::io::Error),
18 #[error("ffmpeg error ({})", output.status)]
19 FfmpegError { output: std::process::Output },
20 #[error("string not valid UTF8")]
21 FromUtf8Error(#[from] std::string::FromUtf8Error),
22 #[error("unexpected ffmpeg output: {0}")]
23 UnexpectedFfmpegOutput(String),
24 #[error("the frame format or size changed mid-stream")]
25 FormatOrSizeChanged,
26 #[error("no direct ffmpeg raw-video pixel format for {0}; conversion unimplemented")]
30 UnimplementedPixelFormat(PixFmt),
31}
32
33type Result<T> = std::result::Result<T, Error>;
34
35pub fn ffmpeg_pixel_format(pixfmt: PixFmt) -> Result<&'static str> {
42 use PixFmt::*;
43 Ok(match pixfmt {
48 Mono8 => "gray",
49 RGB8 => "rgb24",
50 YUV422 => "uyvy422",
52 BayerRG8 => "bayer_rggb8",
53 BayerGR8 => "bayer_grbg8",
54 BayerGB8 => "bayer_gbrg8",
55 BayerBG8 => "bayer_bggr8",
56 other => return Err(Error::UnimplementedPixelFormat(other)),
57 })
58}
59
60pub struct FfmpegWriter {
68 fname: String,
69 ffmpeg_codec_args: FfmpegCodecArgs,
70 raten: usize,
71 rated: usize,
72 count: usize,
73 running: Option<Running>,
74}
75
76struct Running {
78 child: Child,
79 stdin: ChildStdin,
80 pixfmt: PixFmt,
81 width: u32,
82 height: u32,
83}
84
85type FfmpegCodecArgList = Option<Vec<(String, String)>>;
86
87const DEFAULT_OUTPUT_PIXFMT: &str = "yuv420p";
93
94#[derive(Debug, PartialEq, Clone)]
95pub struct FfmpegCodecArgs {
96 pub device_args: FfmpegCodecArgList,
97 pub pre_codec_args: FfmpegCodecArgList,
98 pub codec: Option<String>,
99 pub post_codec_args: FfmpegCodecArgList,
100 pub pixfmt: Option<String>,
105 pub max_bframes: Option<u32>,
108}
109
110impl Default for FfmpegCodecArgs {
111 fn default() -> Self {
112 Self {
113 device_args: None,
114 pre_codec_args: None,
115 codec: None,
116 post_codec_args: None,
117 pixfmt: Some(DEFAULT_OUTPUT_PIXFMT.to_string()),
118 max_bframes: None,
119 }
120 }
121}
122
123fn prefix() -> Vec<String> {
124 zq(&["-nostats", "-hide_banner", "-nostdin", "-y"])
125}
126
127fn zq(x: &[&str]) -> Vec<String> {
128 x.iter().map(|x| (*x).into()).collect()
129}
130
131fn zq2(opt_x: Option<&Vec<(String, String)>>) -> Vec<String> {
132 if let Some(x) = opt_x {
133 x.iter()
134 .flat_map(|(x1, x2)| [x1.clone(), x2.clone()])
135 .collect()
136 } else {
137 vec![]
138 }
139}
140
141impl FfmpegCodecArgs {
142 fn to_args(&self, input_args: &[String]) -> Vec<String> {
153 const VIDEO_CODEC: &str = "-c:v";
154 let output_color_range = zq(&["-color_range", "pc"]);
155 let input: Vec<String> = input_args.to_vec();
156 let stdin_input = zq(&["-i", "-"]);
157 let output_pixfmt = match &self.pixfmt {
161 Some(pixfmt) => zq(&["-pix_fmt", pixfmt]),
162 None => vec![],
163 };
164 let output_bframes = match &self.max_bframes {
165 Some(max_bframes) => vec!["-bf".to_string(), max_bframes.to_string()],
166 None => vec![],
167 };
168 if let Some(codec) = &self.codec {
169 vec![
170 prefix(),
171 zq2(self.device_args.as_ref()),
172 input,
173 stdin_input,
174 zq2(self.pre_codec_args.as_ref()),
175 zq(&[VIDEO_CODEC, codec]),
176 output_pixfmt,
177 output_bframes,
178 zq2(self.post_codec_args.as_ref()),
179 output_color_range,
180 ]
181 } else {
182 assert_eq!(self.device_args, None);
183 assert_eq!(self.pre_codec_args, None);
184 assert_eq!(self.post_codec_args, None);
185 vec![
186 prefix(),
187 input,
188 stdin_input,
189 output_pixfmt,
190 output_bframes,
191 output_color_range,
192 ]
193 }
194 .into_iter()
195 .flatten()
196 .collect()
197 }
198
199 fn from_str(s: &str) -> Option<Self> {
200 match s {
201 "vaapi" => Some(Self {
203 device_args: Some(vec![("-vaapi_device".into(), "/dev/dri/renderD128".into())]),
204 pre_codec_args: Some(vec![("-vf".into(), "format=nv12,hwupload".into())]),
205 codec: Some("h264_vaapi".to_string()),
206 pixfmt: None,
212 ..Default::default()
213 }),
214 "videotoolbox" => Some(Self {
215 codec: Some("h264_videotoolbox".into()),
216 ..Default::default()
217 }),
218 _ => None,
219 }
220 }
221}
222
223pub fn ffmpeg_version() -> Result<String> {
224 let args = ["-hide_banner", "-nostdin", "-version"];
225 let ffmpeg_child = Command::new(FFMPEG)
226 .args(args)
227 .stdin(Stdio::piped())
228 .stdout(Stdio::piped())
229 .stderr(Stdio::piped())
230 .spawn()?;
231 let out = ffmpeg_child.wait_with_output()?;
232 let lines = String::from_utf8(out.stdout)?;
233
234 let mut ffmpeg_stderr_iter = lines.split_ascii_whitespace();
235 assert_eq!(ffmpeg_stderr_iter.next(), Some("ffmpeg"));
236 assert_eq!(ffmpeg_stderr_iter.next(), Some("version"));
237
238 if let Some(version_str) = ffmpeg_stderr_iter.next() {
239 Ok(version_str.into())
240 } else {
241 Err(Error::UnexpectedFfmpegOutput(lines))
242 }
243}
244
245pub fn platform_hardware_encoder() -> Result<FfmpegCodecArgs> {
246 let args = ["-hide_banner", "-nostdin", "-hwaccels"];
247 let ffmpeg_child = Command::new(FFMPEG)
248 .args(args)
249 .stdin(Stdio::piped())
250 .stdout(Stdio::piped())
251 .stderr(Stdio::piped())
252 .spawn()?;
253 let out = ffmpeg_child.wait_with_output()?;
254 let lines = String::from_utf8(out.stdout)?;
255 let mut lines: VecDeque<_> = lines.split("\n").collect();
256 let line0 = lines.pop_front().unwrap();
257 if line0 != "Hardware acceleration methods:" {
258 return Err(Error::UnexpectedFfmpegOutput(line0.into()));
259 }
260 for line in lines.into_iter() {
261 if let Some(opt) = FfmpegCodecArgs::from_str(line) {
262 return Ok(opt);
263 }
264 }
265 Ok(FfmpegCodecArgs {
266 ..Default::default()
267 })
268}
269
270impl FfmpegWriter {
271 pub fn new(
272 fname: &str,
273 ffmpeg_codec_args: FfmpegCodecArgs,
274 rate: Option<(usize, usize)>,
275 ) -> Result<Self> {
276 let (raten, rated) = rate.unwrap_or((25, 1));
277 Ok(Self {
280 fname: fname.to_string(),
281 ffmpeg_codec_args,
282 raten,
283 rated,
284 count: 0,
285 running: None,
286 })
287 }
288
289 fn start(&mut self, frame: &strand_dynamic_frame::DynamicFrame) -> Result<()> {
291 let pixfmt = frame.pixel_format();
292 let ff_pixfmt = ffmpeg_pixel_format(pixfmt)?;
293 let width = frame.width();
294 let height = frame.height();
295
296 let input_args = vec![
301 "-f".to_string(),
302 "rawvideo".to_string(),
303 "-pixel_format".to_string(),
304 ff_pixfmt.to_string(),
305 "-video_size".to_string(),
306 format!("{width}x{height}"),
307 "-framerate".to_string(),
308 format!("{}/{}", self.raten, self.rated),
309 "-color_range".to_string(),
310 "pc".to_string(),
311 ];
312
313 let mut args = self.ffmpeg_codec_args.to_args(&input_args);
314 args.push(self.fname.clone());
315
316 let show_ffmpeg = match std::env::var_os("FFMPEG_WRITER_SHOW") {
317 Some(v) => &v != "0",
318 None => false,
319 };
320 if show_ffmpeg {
321 println!("ffmpeg {}", args.join(" "));
322 }
323
324 let mut cmd0 = Command::new(FFMPEG);
325 let cmd = cmd0.args(args).stdin(Stdio::piped());
326 let cmd = if show_ffmpeg {
327 cmd
328 } else {
329 cmd.stdout(Stdio::piped()).stderr(Stdio::piped())
330 };
331 let mut child = cmd.spawn()?;
332 let stdin = child.stdin.take().expect("failed to get stdin");
333
334 self.running = Some(Running {
335 child,
336 stdin,
337 pixfmt,
338 width,
339 height,
340 });
341 Ok(())
342 }
343
344 pub fn write_dynamic_frame(
346 &mut self,
347 frame: &strand_dynamic_frame::DynamicFrame,
348 ) -> Result<std::time::Duration> {
349 if self.running.is_none() {
350 self.start(frame)?;
351 }
352 let running = self.running.as_mut().unwrap();
353 if frame.pixel_format() != running.pixfmt
354 || frame.width() != running.width
355 || frame.height() != running.height
356 {
357 return Err(Error::FormatOrSizeChanged);
358 }
359
360 let stdin = &mut running.stdin;
362 let io_result: std::io::Result<()> = strand_dynamic_frame::match_all_dynamic_fmts!(
363 frame,
364 x,
365 {
366 use machine_vision_formats::iter::HasRowChunksExact;
367 let mut res = Ok(());
368 for row in x.rowchunks_exact() {
369 if let Err(e) = stdin.write_all(row) {
370 res = Err(e);
371 break;
372 }
373 }
374 res
375 },
376 Error::UnimplementedPixelFormat(frame.pixel_format())
378 );
379
380 match io_result {
381 Ok(()) => {}
382 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
383 return Err(self.collect_ffmpeg_error());
385 }
386 Err(e) => return Err(e.into()),
387 }
388
389 let num = self.rated * self.count;
390 let dur_sec = num as f64 / self.raten as f64;
391 let pts = std::time::Duration::from_secs_f64(dur_sec);
392 self.count += 1;
393 Ok(pts)
394 }
395
396 fn collect_ffmpeg_error(&mut self) -> Error {
398 let mut running = self.running.take().unwrap();
399 let status = match running.child.wait() {
400 Ok(status) => status,
401 Err(e) => return Error::Io(e),
402 };
403 use std::io::Read;
404 let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
405 if let Some(mut out) = running.child.stdout.take() {
406 let _ = out.read_to_end(&mut stdout);
407 }
408 if let Some(mut err) = running.child.stderr.take() {
409 let _ = err.read_to_end(&mut stderr);
410 }
411 Error::FfmpegError {
412 output: std::process::Output {
413 status,
414 stdout,
415 stderr,
416 },
417 }
418 }
419
420 pub fn close(self) -> Result<()> {
421 let Some(running) = self.running else {
423 return Ok(());
425 };
426 let Running { child, stdin, .. } = running;
427 std::mem::drop(stdin);
428 let output = child.wait_with_output()?;
429 if output.status.success() {
430 Ok(())
431 } else {
432 Err(Error::FfmpegError { output })
433 }
434 }
435}
436
437#[cfg(test)]
438mod test {
439 use super::*;
440 use machine_vision_formats::PixFmt;
441 use strand_dynamic_frame::DynamicFrameOwned;
442
443 #[test]
448 fn to_args_emits_output_pixfmt() {
449 let default_args = FfmpegCodecArgs {
450 codec: Some("libx264".to_string()),
451 ..Default::default()
452 };
453 let args = default_args.to_args(&[]);
454 let pixfmt_at = args.iter().position(|a| a == "-pix_fmt").unwrap();
455 assert_eq!(args[pixfmt_at + 1], "yuv420p");
456 let codec_at = args.iter().position(|a| a == "-c:v").unwrap();
457 assert!(codec_at < pixfmt_at, "pix_fmt must come after the codec");
458
459 let no_pixfmt = FfmpegCodecArgs {
461 codec: Some("libx264".to_string()),
462 pixfmt: None,
463 max_bframes: None,
464 ..Default::default()
465 };
466 let args = no_pixfmt.to_args(&[]);
467 assert!(!args.iter().any(|a| a == "-pix_fmt"));
468 assert!(!args.iter().any(|a| a == "-bf"));
469
470 let overridden = FfmpegCodecArgs {
473 codec: Some("libx264".to_string()),
474 post_codec_args: Some(vec![("-pix_fmt".into(), "yuv444p".into())]),
475 ..Default::default()
476 };
477 let args = overridden.to_args(&[]);
478 let last_pixfmt = args.iter().rposition(|a| a == "-pix_fmt").unwrap();
479 assert_eq!(args[last_pixfmt + 1], "yuv444p");
480 }
481
482 struct Case {
484 pixfmt: PixFmt,
485 ffmpeg_pixfmt: &'static str,
491 bytes_per_pixel: usize,
493 codec: &'static str,
495 ext: &'static str,
497 }
498
499 fn ffmpeg_decode_raw(path: &std::path::Path, pix_fmt: &str) -> Vec<u8> {
502 let output = std::process::Command::new(FFMPEG)
503 .args(["-nostdin", "-loglevel", "error", "-i"])
504 .arg(path)
505 .args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
506 .output()
507 .expect("running ffmpeg to decode the recording");
508 assert!(
509 output.status.success(),
510 "ffmpeg decode failed: {}",
511 String::from_utf8_lossy(&output.stderr)
512 );
513 output.stdout
514 }
515
516 fn assert_roundtrips_exactly(case: &Case) {
517 assert_eq!(
519 ffmpeg_pixel_format(case.pixfmt).unwrap(),
520 case.ffmpeg_pixfmt,
521 "unexpected ffmpeg pixel-format mapping for {:?}",
522 case.pixfmt
523 );
524 let ffmpeg_pixfmt = case.ffmpeg_pixfmt;
525 let (width, height) = (64u32, 48u32);
526 let valid_stride = width as usize * case.bytes_per_pixel;
527 let pad = 16usize;
530 let stride = valid_stride + pad;
531
532 let mut buf = vec![0xAAu8; height as usize * stride]; let mut expected = Vec::with_capacity(height as usize * valid_stride);
536 for row in 0..height as usize {
537 for i in 0..valid_stride {
538 let v = ((row * valid_stride + i) * 31 + 7) as u8;
539 buf[row * stride + i] = v;
540 expected.push(v);
541 }
542 }
543
544 let frame = DynamicFrameOwned::from_buf(width, height, stride, buf, case.pixfmt).unwrap();
545
546 let tmp = tempfile::tempdir().unwrap();
547 let out_path = tmp.path().join(format!("roundtrip.{}", case.ext));
548 {
549 let codec_args = FfmpegCodecArgs {
550 codec: Some(case.codec.to_string()),
551 pixfmt: None,
556 max_bframes: None,
557 ..Default::default()
558 };
559 let mut wtr = FfmpegWriter::new(out_path.to_str().unwrap(), codec_args, None).unwrap();
560 wtr.write_dynamic_frame(&frame.borrow()).unwrap();
561 wtr.close().unwrap();
562 }
563
564 let got = ffmpeg_decode_raw(&out_path, ffmpeg_pixfmt);
565 assert_eq!(
566 got.len(),
567 expected.len(),
568 "{:?} ({ffmpeg_pixfmt}): decoded byte count differs",
569 case.pixfmt
570 );
571 assert!(
572 got == expected,
573 "{:?} ({ffmpeg_pixfmt}): pixel data did not round-trip exactly through ffmpeg",
574 case.pixfmt
575 );
576 }
577
578 #[test]
589 fn frame_data_roundtrips_exactly_via_ffmpeg() {
590 let cases = [
591 Case {
592 pixfmt: PixFmt::Mono8,
593 ffmpeg_pixfmt: "gray",
594 bytes_per_pixel: 1,
595 codec: "ffv1",
596 ext: "mkv",
597 },
598 Case {
599 pixfmt: PixFmt::RGB8,
600 ffmpeg_pixfmt: "rgb24",
601 bytes_per_pixel: 3,
602 codec: "ffv1",
603 ext: "mkv",
604 },
605 Case {
606 pixfmt: PixFmt::YUV422,
607 ffmpeg_pixfmt: "uyvy422",
608 bytes_per_pixel: 2,
609 codec: "rawvideo",
610 ext: "nut",
611 },
612 Case {
613 pixfmt: PixFmt::BayerRG8,
614 ffmpeg_pixfmt: "bayer_rggb8",
615 bytes_per_pixel: 1,
616 codec: "rawvideo",
617 ext: "nut",
618 },
619 ];
620 for case in &cases {
621 assert_roundtrips_exactly(case);
622 }
623 }
624}