async_ffmpeg_sidecar/
log_parser.rs

1// mostly copied from https://github.com/nathanbabcock/ffmpeg-sidecar/blob/ba61acabecbf360c7f0d0228233641396012878c/src/log_parser.rs
2// and adapted to use tokio instead sync
3
4use crate::comma_iter::CommaIter;
5use crate::event::{
6  AudioStream, FfmpegConfiguration, FfmpegDuration, FfmpegEvent, FfmpegInput, FfmpegOutput,
7  FfmpegProgress, FfmpegStream, FfmpegVersion, LogLevel, StreamTypeSpecificData, VideoStream,
8};
9use tokio::io::{AsyncBufRead, AsyncBufReadExt, BufReader, Lines};
10
11#[derive(Debug, Clone, PartialEq)]
12enum LogSection {
13  Input(u32),
14  Output(u32),
15  StreamMapping,
16  Other,
17}
18
19pub struct FfmpegLogParser<R: AsyncBufRead + Unpin> {
20  lines: Lines<BufReader<R>>,
21  cur_section: LogSection,
22}
23
24impl<R: AsyncBufRead + Unpin> FfmpegLogParser<R> {
25  /// Consume lines from the inner reader until obtaining a completed
26  /// `FfmpegEvent`, returning it.
27  ///
28  /// Typically, this consumes a single line, but in the case of multi-line
29  /// input/output stream specifications, nested method calls will consume
30  /// additional lines until the entire vector of Input/Outputs is parsed.
31  ///
32  /// Line endings can be marked by three possible delimiters:
33  /// - `\n` (macOS)
34  /// - `\r\n` (Windows)
35  /// - `\r` (Windows, progress updates which overwrite the previous line)
36  pub async fn parse_next_event(&mut self) -> anyhow::Result<FfmpegEvent> {
37    let Some(line) = self.lines.next_line().await? else {
38      return Ok(FfmpegEvent::LogEOF);
39    };
40
41    // let line = from_utf8(line)?.trim();
42    let raw_log_message = line.clone();
43
44    // Track log section
45    if let Some(input_number) = try_parse_input(&line) {
46      self.cur_section = LogSection::Input(input_number);
47      return Ok(FfmpegEvent::ParsedInput(FfmpegInput {
48        index: input_number,
49        duration: None,
50        raw_log_message,
51      }));
52    } else if let Some(output) = try_parse_output(&line) {
53      self.cur_section = LogSection::Output(output.index);
54      return Ok(FfmpegEvent::ParsedOutput(output));
55    } else if line.contains("Stream mapping:") {
56      self.cur_section = LogSection::StreamMapping;
57    }
58
59    // Parse
60    if let Some(version) = try_parse_version(&line) {
61      Ok(FfmpegEvent::ParsedVersion(FfmpegVersion {
62        version,
63        raw_log_message,
64      }))
65    } else if let Some(configuration) = try_parse_configuration(&line) {
66      Ok(FfmpegEvent::ParsedConfiguration(FfmpegConfiguration {
67        configuration,
68        raw_log_message,
69      }))
70    } else if let Some(duration) = try_parse_duration(&line) {
71      match self.cur_section {
72        LogSection::Input(input_index) => Ok(FfmpegEvent::ParsedDuration(FfmpegDuration {
73          input_index,
74          duration,
75          raw_log_message,
76        })),
77        _ => Ok(FfmpegEvent::Log(LogLevel::Info, line.to_string())),
78      }
79    } else if self.cur_section == LogSection::StreamMapping && line.contains("  Stream #") {
80      Ok(FfmpegEvent::ParsedStreamMapping(line.to_string()))
81    } else if let Some(stream) = try_parse_stream(&line) {
82      match self.cur_section {
83        LogSection::Input(_) => Ok(FfmpegEvent::ParsedInputStream(stream)),
84        LogSection::Output(_) => Ok(FfmpegEvent::ParsedOutputStream(stream)),
85        LogSection::Other | LogSection::StreamMapping => Err(anyhow::Error::msg(format!(
86          "Unexpected stream specification: {}",
87          line
88        ))),
89      }
90    } else if let Some(progress) = try_parse_progress(&line) {
91      self.cur_section = LogSection::Other;
92      Ok(FfmpegEvent::Progress(progress))
93    } else if line.contains("[info]") {
94      Ok(FfmpegEvent::Log(LogLevel::Info, line.to_string()))
95    } else if line.contains("[warning]") {
96      Ok(FfmpegEvent::Log(LogLevel::Warning, line.to_string()))
97    } else if line.contains("[error]") {
98      Ok(FfmpegEvent::Log(LogLevel::Error, line.to_string()))
99    } else if line.contains("[fatal]") {
100      Ok(FfmpegEvent::Log(LogLevel::Fatal, line.to_string()))
101    } else {
102      Ok(FfmpegEvent::Log(LogLevel::Unknown, line.to_string()))
103    }
104  }
105
106  pub fn new(inner: R) -> Self {
107    let buf_read = BufReader::new(inner);
108    let lines = buf_read.lines();
109
110    Self {
111      lines,
112      cur_section: LogSection::Other,
113    }
114  }
115}
116
117/// Parses the ffmpeg version string from the stderr stream,
118/// typically the very first line of output:
119///
120/// ```rust
121/// use async_ffmpeg_sidecar::log_parser::try_parse_version;
122///
123/// let line = "[info] ffmpeg version 2023-01-18-git-ba36e6ed52-full_build-www.gyan.dev Copyright (c) 2000-2023 the FFmpeg developers\n";
124///
125/// let version = try_parse_version(line).unwrap();
126///
127/// assert_eq!(version, "2023-01-18-git-ba36e6ed52-full_build-www.gyan.dev");
128/// ```
129pub fn try_parse_version(string: &str) -> Option<String> {
130  string
131    .strip_prefix("[info]")
132    .unwrap_or(string)
133    .trim()
134    .strip_prefix("ffmpeg version ")?
135    .split_whitespace()
136    .next()
137    .map(|s| s.to_string())
138}
139
140/// Parses the list of configuration flags ffmpeg was built with.
141/// Typically the second line of log output.
142///
143/// ## Example:
144///
145/// ```rust
146/// use async_ffmpeg_sidecar::log_parser::try_parse_configuration;
147///
148/// let line = "[info]   configuration: --enable-gpl --enable-version3 --enable-static\n";
149/// // Typically much longer, 20-30+ flags
150///
151/// let version = try_parse_configuration(line).unwrap();
152///
153/// assert_eq!(version.len(), 3);
154/// assert_eq!(version[0], "--enable-gpl");
155/// assert_eq!(version[1], "--enable-version3");
156/// assert_eq!(version[2], "--enable-static");
157/// ```
158///
159pub fn try_parse_configuration(string: &str) -> Option<Vec<String>> {
160  string
161    .strip_prefix("[info]")
162    .unwrap_or(string)
163    .trim()
164    .strip_prefix("configuration: ")
165    .map(|s| s.split_whitespace().map(|s| s.to_string()).collect())
166}
167
168/// Parse an input section like the following extracting the index of the input:
169///
170/// ## Example
171///
172/// ```rust
173/// use async_ffmpeg_sidecar::log_parser::try_parse_input;
174/// let line = "[info] Input #0, lavfi, from 'testsrc=duration=5':\n";
175/// let input = try_parse_input(line);
176/// assert_eq!(input, Some(0));
177/// ```
178pub fn try_parse_input(string: &str) -> Option<u32> {
179  string
180    .strip_prefix("[info]")
181    .unwrap_or(string)
182    .trim()
183    .strip_prefix("Input #")?
184    .split_whitespace()
185    .next()
186    .and_then(|s| s.split(',').next())
187    .and_then(|s| s.parse::<u32>().ok())
188}
189
190/// ## Example
191///
192/// ```rust
193/// use async_ffmpeg_sidecar::log_parser::try_parse_duration;
194/// let line = "[info]   Duration: 00:00:05.00, start: 0.000000, bitrate: 16 kb/s, start: 0.000000, bitrate: N/A\n";
195/// let duration = try_parse_duration(line);
196/// assert_eq!(duration, Some(5.0));
197/// ```
198///
199/// ### Unknown duration
200///
201/// ```rust
202/// use async_ffmpeg_sidecar::log_parser::try_parse_duration;
203/// let line = "[info]   Duration: N/A, start: 0.000000, bitrate: N/A\n";
204/// let duration = try_parse_duration(line);
205/// assert_eq!(duration, None);
206/// ```
207pub fn try_parse_duration(string: &str) -> Option<f64> {
208  string
209    .strip_prefix("[info]")
210    .unwrap_or(string)
211    .trim()
212    .strip_prefix("Duration:")?
213    .trim()
214    .split(',')
215    .next()
216    .and_then(parse_time_str)
217}
218
219/// Parse an output section like the following, extracting the index of the input
220///
221/// ## Example:
222///
223/// ```rust
224/// use async_ffmpeg_sidecar::log_parser::try_parse_output;
225/// use async_ffmpeg_sidecar::event::FfmpegOutput;
226/// let line = "[info] Output #0, mp4, to 'test.mp4':\n";
227/// let output = try_parse_output(line);
228/// assert_eq!(output, Some(FfmpegOutput {
229///     index: 0,
230///     to: "test.mp4".to_string(),
231///     raw_log_message: line.to_string()
232/// }));
233/// ```
234pub fn try_parse_output(mut s: &str) -> Option<FfmpegOutput> {
235  let raw_log_message = s.to_string();
236
237  s = s
238    .strip_prefix("[info]")
239    .unwrap_or(s)
240    .trim()
241    .strip_prefix("Output #")?;
242
243  let index = s
244    .split_whitespace()
245    .next()
246    .and_then(|s| s.split(',').next())
247    .and_then(|s| s.parse::<u32>().ok())?;
248
249  let to = s.split(" to '").nth(1)?.split('\'').next()?.to_string();
250
251  Some(FfmpegOutput {
252    index,
253    to,
254    raw_log_message,
255  })
256}
257
258/// Parses a line that represents a stream.
259///
260/// ## Examples
261///
262/// ### Video
263///
264/// #### Input stream
265///
266/// ```rust
267/// use async_ffmpeg_sidecar::log_parser::try_parse_stream;
268/// let line = "[info]   Stream #0:0: Video: wrapped_avframe, rgb24, 320x240 [SAR 1:1 DAR 4:3], 25 fps, 25 tbr, 25 tbn\n";
269/// let stream = try_parse_stream(line).unwrap();
270/// assert_eq!(stream.format, "wrapped_avframe");;
271/// assert_eq!(stream.language, "");
272/// assert_eq!(stream.parent_index, 0);
273/// assert_eq!(stream.stream_index, 0);
274/// assert!(stream.is_video());
275///
276/// let video_data = stream.video_data().unwrap();
277/// assert_eq!(video_data.pix_fmt, "rgb24");
278/// assert_eq!(video_data.width, 320);
279/// assert_eq!(video_data.height, 240);
280/// assert_eq!(video_data.fps, 25.0);
281/// ```
282///
283///  #### Output stream
284///
285/// ```rust
286///  use async_ffmpeg_sidecar::log_parser::try_parse_stream;
287///  let line = "[info]   Stream #1:5(eng): Video: h264 (avc1 / 0x31637661), yuv444p(tv, progressive), 320x240 [SAR 1:1 DAR 4:3], q=2-31, 25 fps, 12800 tbn\n";
288///  let stream = try_parse_stream(line).unwrap();
289///  assert_eq!(stream.format, "h264");
290///  assert_eq!(stream.language, "eng");
291///  assert_eq!(stream.parent_index, 1);
292///  assert_eq!(stream.stream_index, 5);
293///  assert!(stream.is_video());
294///  let video_data = stream.video_data().unwrap();
295///  assert_eq!(video_data.pix_fmt, "yuv444p");
296///  assert_eq!(video_data.width, 320);
297///  assert_eq!(video_data.height, 240);
298///  assert_eq!(video_data.fps, 25.0);
299///  ```
300///
301/// ### Audio
302///
303/// #### Input Stream
304///
305/// ```rust
306/// use async_ffmpeg_sidecar::log_parser::try_parse_stream;
307/// let line = "[info]   Stream #0:1(eng): Audio: opus, 48000 Hz, stereo, fltp (default)\n";
308/// let stream = try_parse_stream(line).unwrap();
309/// assert_eq!(stream.format, "opus");
310/// assert_eq!(stream.language, "eng");
311/// assert_eq!(stream.parent_index, 0);
312/// assert_eq!(stream.stream_index, 1);
313/// assert!(stream.is_audio());
314/// let audio_data = stream.audio_data().unwrap();
315/// assert_eq!(audio_data.sample_rate, 48000);
316/// assert_eq!(audio_data.channels, "stereo");
317/// ```
318///
319/// ```rust
320/// use async_ffmpeg_sidecar::log_parser::try_parse_stream;
321/// let line = "[info]   Stream #3:10(ger): Audio: dts (DTS-HD MA), 48000 Hz, 7.1, s32p (24 bit)\n";
322/// let stream = try_parse_stream(line).unwrap();
323/// assert_eq!(stream.format, "dts");
324/// assert_eq!(stream.language, "ger");
325/// assert_eq!(stream.parent_index, 3);
326/// assert_eq!(stream.stream_index, 10);
327/// assert!(stream.is_audio());
328/// let audio_data = stream.audio_data().unwrap();
329/// assert_eq!(audio_data.sample_rate, 48000);
330/// assert_eq!(audio_data.channels, "7.1");
331/// ```
332///
333/// ### Output stream
334///
335/// ```rust
336/// use async_ffmpeg_sidecar::log_parser::try_parse_stream;
337/// let line = "[info]   Stream #10:1: Audio: mp2, 44100 Hz, mono, s16, 384 kb/s\n";
338/// let stream = try_parse_stream(line).unwrap();
339/// assert_eq!(stream.format, "mp2");
340/// assert_eq!(stream.language, "");
341/// assert_eq!(stream.parent_index, 10);
342/// assert_eq!(stream.stream_index, 1);
343/// assert!(stream.is_audio());
344/// let audio_data = stream.audio_data().unwrap();
345/// assert_eq!(audio_data.sample_rate, 44100);
346/// assert_eq!(audio_data.channels, "mono");
347/// ```
348///
349/// ### Subtitle
350///
351/// #### Input Stream
352///
353/// ```rust
354/// use async_ffmpeg_sidecar::log_parser::try_parse_stream;
355/// let line = "[info]   Stream #0:4(eng): Subtitle: ass (default) (forced)\n";
356/// let stream = try_parse_stream(line).unwrap();
357/// assert_eq!(stream.format, "ass");
358/// assert_eq!(stream.language, "eng");
359/// assert_eq!(stream.parent_index, 0);
360/// assert_eq!(stream.stream_index, 4);
361/// assert!(stream.is_subtitle());
362/// ```
363///
364/// ```rust
365/// use async_ffmpeg_sidecar::log_parser::try_parse_stream;
366/// let line = "[info]   Stream #0:13(dut): Subtitle: hdmv_pgs_subtitle, 1920x1080\n";
367/// let stream = try_parse_stream(line).unwrap();
368/// assert_eq!(stream.format, "hdmv_pgs_subtitle");
369/// assert_eq!(stream.language, "dut");
370/// assert_eq!(stream.parent_index, 0);
371/// assert_eq!(stream.stream_index, 13);
372/// assert!(stream.is_subtitle());
373/// ```
374/// ### Other
375///
376/// #### Input Stream
377///
378/// ```rust
379/// use async_ffmpeg_sidecar::log_parser::try_parse_stream;
380/// let line = "[info]   Stream #0:2(und): Data: none (rtp  / 0x20707472), 53 kb/s (default)\n";
381/// let stream = try_parse_stream(line).unwrap();
382/// assert_eq!(stream.format, "none");
383/// assert_eq!(stream.language, "und");
384/// assert_eq!(stream.parent_index, 0);
385/// assert_eq!(stream.stream_index, 2);
386/// assert!(stream.is_other());
387/// ```
388///
389/// ```rust
390/// use async_ffmpeg_sidecar::log_parser::try_parse_stream;
391/// let line = "[info]   Stream #0:2[0x3](eng): Data: bin_data (text / 0x74786574)\n";
392/// let stream = try_parse_stream(line).unwrap();
393/// assert_eq!(stream.format, "bin_data");
394/// assert_eq!(stream.language, "eng");
395/// assert_eq!(stream.parent_index, 0);
396/// assert_eq!(stream.stream_index, 2);
397/// assert!(stream.is_other());
398/// ```
399pub fn try_parse_stream(s: &str) -> Option<FfmpegStream> {
400  let raw_log_message = s.to_string();
401
402  let s = s
403    .strip_prefix("[info]")
404    .unwrap_or(s)
405    .trim()
406    .strip_prefix("Stream #")?;
407  let mut comma_iter = CommaIter::new(s);
408  let mut colon_iter = comma_iter.next()?.split(':');
409
410  let parent_index = colon_iter.next()?.parse::<u32>().ok()?;
411
412  // Here handle the pattern such as `2[0x3](eng)`
413  let indices_nad_maybe_language = colon_iter
414    .next()?
415    // Remove everything inside and including square brackets
416    .split(['[', ']'])
417    .step_by(2)
418    .collect::<String>();
419  let mut paranthesis_iter = indices_nad_maybe_language.split('(');
420  let stream_index = paranthesis_iter.next()?.trim().parse::<u32>().ok()?;
421  let language = paranthesis_iter.next().map_or("".to_string(), |lang| {
422    lang.trim_end_matches(')').to_string()
423  });
424
425  // Here handle the pattern such as `Video: av1 (Main)`
426  let stream_type = colon_iter.next()?.trim();
427  let format = colon_iter
428    .next()?
429    .trim()
430    .split(&[' ', '(']) // trim trailing junk like `(Main)`
431    .next()?
432    .to_string();
433
434  // For audio and video handle remaining string in specialized functions.
435  let type_specific_data: StreamTypeSpecificData = match stream_type {
436    "Audio" => try_parse_audio_stream(comma_iter)?,
437    "Subtitle" => StreamTypeSpecificData::Subtitle,
438    "Video" => try_parse_video_stream(comma_iter)?,
439    _ => StreamTypeSpecificData::Other,
440  };
441
442  Some(FfmpegStream {
443    format,
444    language,
445    parent_index,
446    stream_index,
447    raw_log_message,
448    type_specific_data,
449  })
450}
451
452/// Parses the log output part that is specific to audio streams.
453fn try_parse_audio_stream(mut comma_iter: CommaIter) -> Option<StreamTypeSpecificData> {
454  let sample_rate = comma_iter
455    .next()?
456    .split_whitespace()
457    .next()?
458    .parse::<u32>()
459    .ok()?;
460
461  let channels = comma_iter.next()?.trim().to_string();
462
463  Some(StreamTypeSpecificData::Audio(AudioStream {
464    sample_rate,
465    channels,
466  }))
467}
468
469/// Parses the log output part that is specific to video streams.
470fn try_parse_video_stream(mut comma_iter: CommaIter) -> Option<StreamTypeSpecificData> {
471  let pix_fmt = comma_iter
472    .next()?
473    .trim()
474    .split(&[' ', '(']) // trim trailing junk like "(tv, progressive)"
475    .next()?
476    .to_string();
477
478  let dims = comma_iter.next()?.split_whitespace().next()?;
479  let mut dims_iter = dims.split('x');
480  let width = dims_iter.next()?.parse::<u32>().ok()?;
481  let height = dims_iter.next()?.parse::<u32>().ok()?;
482
483  // FPS does not have to be the next part, so we iterate until we find it. There is nothing else we
484  // are interested in at this point, so its OK to skip anything in-between.
485  let fps = comma_iter
486    .find_map(|part| {
487      if part.trim().ends_with("fps") {
488        part.split_whitespace().next()
489      } else {
490        None
491      }
492    })
493    .and_then(|fps_str| fps_str.parse::<f32>().ok())?;
494
495  Some(StreamTypeSpecificData::Video(VideoStream {
496    pix_fmt,
497    width,
498    height,
499    fps,
500  }))
501}
502
503/// Parse a progress update line from ffmpeg.
504///
505/// ## Example
506/// ```rust
507/// use async_ffmpeg_sidecar::log_parser::try_parse_progress;
508/// let line = "[info] frame= 1996 fps=1984 q=-1.0 Lsize=     372kB time=00:01:19.72 bitrate=  38.2kbits/s speed=79.2x\n";
509/// let progress = try_parse_progress(line).unwrap();
510/// assert_eq!(progress.frame, 1996);
511/// assert_eq!(progress.fps, 1984.0);
512/// assert_eq!(progress.q, -1.0);
513/// assert_eq!(progress.size_kb, 372);
514/// assert_eq!(progress.time, "00:01:19.72");
515/// assert_eq!(progress.bitrate_kbps, 38.2);
516/// assert_eq!(progress.speed, 79.2);
517/// ```
518pub fn try_parse_progress(mut string: &str) -> Option<FfmpegProgress> {
519  let raw_log_message = string.to_string();
520
521  string = string.strip_prefix("[info]").unwrap_or(string).trim();
522
523  let frame = string
524    .split("frame=")
525    .nth(1)?
526    .split_whitespace()
527    .next()?
528    .parse::<u32>()
529    .ok()?;
530  let fps = string
531    .split("fps=")
532    .nth(1)?
533    .split_whitespace()
534    .next()?
535    .parse::<f32>()
536    .ok()?;
537  let q = string
538    .split("q=")
539    .nth(1)?
540    .split_whitespace()
541    .next()?
542    .parse::<f32>()
543    .ok()?;
544  let size_kb = string
545    .split("size=") // captures "Lsize=" AND "size="
546    .nth(1)?
547    .split_whitespace()
548    .next()
549    .map(|s| s.trim())
550    .and_then(|s| {
551      s.strip_suffix("KiB") // FFmpeg v7.0 and later
552        .or_else(|| s.strip_suffix("kB")) // FFmpeg v6.0 and prior
553        .or_else(|| s.ends_with("N/A").then(|| "0")) // handles "N/A"
554    })?
555    .parse::<u32>()
556    .ok()?;
557  let time = string
558    .split("time=")
559    .nth(1)?
560    .split_whitespace()
561    .next()?
562    .to_string();
563  let bitrate_kbps = string
564    .split("bitrate=")
565    .nth(1)?
566    .split_whitespace()
567    .next()?
568    .trim()
569    .replace("kbits/s", "")
570    .parse::<f32>()
571    .unwrap_or(0.0); // handles "N/A"
572  let speed = string
573    .split("speed=")
574    .nth(1)?
575    .split_whitespace()
576    .next()?
577    .strip_suffix('x')
578    .map(|s| s.parse::<f32>().unwrap_or(0.0))
579    .unwrap_or(0.0);
580
581  Some(FfmpegProgress {
582    frame,
583    fps,
584    q,
585    size_kb,
586    time,
587    bitrate_kbps,
588    speed,
589    raw_log_message,
590  })
591}
592
593/// Parse a time string in the format `HOURS:MM:SS.MILLISECONDS` into a number of seconds.
594///
595/// <https://trac.ffmpeg.org/wiki/Seeking#Time-unit>
596///
597/// ## Examples
598///
599/// ```rust
600/// use async_ffmpeg_sidecar::log_parser::parse_time_str;
601/// assert_eq!(parse_time_str("00:00:00.00"), Some(0.0));
602/// assert_eq!(parse_time_str("5"), Some(5.0));
603/// assert_eq!(parse_time_str("0.123"), Some(0.123));
604/// assert_eq!(parse_time_str("1:00.0"), Some(60.0));
605/// assert_eq!(parse_time_str("1:01.0"), Some(61.0));
606/// assert_eq!(parse_time_str("1:01:01.123"), Some(3661.123));
607/// assert_eq!(parse_time_str("N/A"), None);
608/// ```
609pub fn parse_time_str(s: &str) -> Option<f64> {
610  let mut seconds = 0.0;
611
612  let mut smh = s.split(':').rev();
613  if let Some(sec) = smh.next() {
614    seconds += sec.parse::<f64>().ok()?;
615  }
616
617  if let Some(min) = smh.next() {
618    seconds += min.parse::<f64>().ok()? * 60.0;
619  }
620
621  if let Some(hrs) = smh.next() {
622    seconds += hrs.parse::<f64>().ok()? * 60.0 * 60.0;
623  }
624
625  Some(seconds)
626}
627
628#[cfg(test)]
629mod tests {
630  use super::*;
631  // use crate::{command::BackgroundCommand, paths::ffmpeg_path};
632  use crate::command::BackgroundCommand;
633  use crate::paths::ffmpeg_path;
634  use std::io::{Cursor, Seek, SeekFrom, Write};
635  use std::process::Stdio;
636  use tokio::process::Command;
637
638  #[tokio::test]
639  async fn test_parse_version() {
640    let cmd = Command::new(ffmpeg_path())
641      .create_no_window()
642      .arg("-version")
643      .stdout(Stdio::piped())
644      // ⚠ notice that ffmpeg emits on stdout when `-version` or `-help` is passed!
645      .spawn()
646      .unwrap();
647
648    let stdout = cmd.stdout.unwrap();
649    let reader = BufReader::new(stdout);
650    let mut parser = FfmpegLogParser::new(reader);
651    while let Ok(event) = parser.parse_next_event().await {
652      if let FfmpegEvent::ParsedVersion(_) = event {
653        return;
654      }
655    }
656    panic!() // should have found a version
657  }
658
659  #[tokio::test]
660  async fn test_parse_configuration() {
661    let cmd = Command::new(ffmpeg_path())
662      .create_no_window()
663      .arg("-version")
664      .stdout(Stdio::piped())
665      // ⚠ notice that ffmpeg emits on stdout when `-version` or `-help` is passed!
666      .spawn()
667      .unwrap();
668
669    let stdout = cmd.stdout.unwrap();
670    let reader = BufReader::new(stdout);
671    let mut parser = FfmpegLogParser::new(reader);
672    while let Ok(event) = parser.parse_next_event().await {
673      if let FfmpegEvent::ParsedConfiguration(_) = event {
674        return;
675      }
676    }
677    panic!() // should have found a configuration
678  }
679
680  /// Test case from https://github.com/nathanbabcock/ffmpeg-sidecar/issues/2#issue-1606661255
681  #[tokio::test]
682  async fn test_macos_line_endings() {
683    let stdout_str = "[info] ffmpeg version N-109875-geabc304d12-tessus  https://evermeet.cx/ffmpeg/  Copyright (c) 2000-2023 the FFmpeg developers\n[info]   built with Apple clang version 11.0.0 (clang-1100.0.33.17)\n[info]   configuration: --cc=/usr/bin/clang --prefix=/opt/ffmpeg --extra-version=tessus --enable-avisynth --enable-fontconfig --enable-gpl --enable-libaom --enable-libass --enable-libbluray --enable-libdav1d --enable-libfreetype --enable-libgsm --enable-libmodplug --enable-libmp3lame --enable-libmysofa --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenh264 --enable-libopenjpeg --enable-libopus --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvmaf --enable-libvo-amrwbenc --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxavs --enable-libxvid --enable-libzimg --enable-libzmq --enable-libzvbi --enable-version3 --pkg-config-flags=--static --disable-ffplay\n[info]   libavutil      58.  1.100 / 58.  1.100\n[info]   libavcodec     60.  2.100 / 60.  2.100\n[info]   libavformat    60.  2.100 / 60.  2.100\n[info]   libavdevice    60.  0.100 / 60.  0.100\n[info]   libavfilter     9.  2.100 /  9.  2.100\n[info]   libswscale      7.  0.100 /  7.  0.100\n[info]   libswresample   4.  9.100 /  4.  9.100\n[info]   libpostproc    57.  0.100 / 57.  0.100\n[info] Input #0, lavfi, from 'testsrc=duration=10':\n[info]   Duration: N/A, start: 0.000000, bitrate: N/A\n[info]   Stream #0:0: Video: wrapped_avframe, rgb24, 320x240 [SAR 1:1 DAR 4:3], 25 fps, 25 tbr, 25 tbn\n[info] Stream mapping:\n[info]   Stream #0:0 -> #0:0 (wrapped_avframe (native) -> rawvideo (native))\n[info] Press [q] to stop, [?] for help\n[info] Output #0, rawvideo, to 'pipe:':\n[info]   Metadata:\n[info]     encoder         : Lavf60.2.100\n[info]   Stream #0:0: Video: rawvideo (RGB[24] / 0x18424752), rgb24(progressive), 320x240 [SAR 1:1 DAR 4:3], q=2-31, 46080 kb/s, 25 fps, 25 tbn\n[info]     Metadata:\n[info]       encoder         : Lavc60.2.100 rawvideo\n[info] frame=    0 fps=0.0 q=0.0 size=       0kB time=-577014:32:22.77 bitrate=  -0.0kbits/s speed=N/A";
684
685    // Emulate a stderr channel
686    let mut cursor = Cursor::new(Vec::new());
687    cursor.write_all(stdout_str.as_bytes()).unwrap();
688    cursor.seek(SeekFrom::Start(0)).unwrap();
689
690    let reader = BufReader::new(cursor);
691    let mut parser = FfmpegLogParser::new(reader);
692    let mut num_events = 0;
693    while let Ok(event) = parser.parse_next_event().await {
694      match event {
695        FfmpegEvent::LogEOF => break,
696        _ => num_events += 1,
697      }
698    }
699    assert!(num_events > 1);
700  }
701
702  /// Test case for https://github.com/nathanbabcock/ffmpeg-sidecar/issues/31
703  /// Covers regression in progress parsing introduced in FFmpeg 7.0
704  /// The string format for `Lsize` units went from `kB` to `KiB`
705  #[test]
706  fn test_parse_progress_v7() {
707    let line = "[info] frame=    5 fps=0.0 q=-1.0 Lsize=      10KiB time=00:00:03.00 bitrate=  27.2kbits/s speed= 283x\n";
708    let progress = try_parse_progress(line).unwrap();
709    assert_eq!(progress.frame, 5);
710    assert_eq!(progress.fps, 0.0);
711    assert_eq!(progress.q, -1.0);
712    assert_eq!(progress.size_kb, 10);
713    assert_eq!(progress.time, "00:00:03.00");
714    assert_eq!(progress.bitrate_kbps, 27.2);
715    assert_eq!(progress.speed, 283.0);
716  }
717
718  #[test]
719  fn test_parse_progress_no_size() {
720    let line = "[info] frame=  163 fps= 13 q=4.4 size=N/A time=00:13:35.00 bitrate=N/A speed=64.7x";
721    let progress = try_parse_progress(line).unwrap();
722    assert!(progress.frame == 163);
723    assert!(progress.fps == 13.0);
724    assert!(progress.q == 4.4);
725    assert!(progress.size_kb == 0);
726    assert!(progress.time == "00:13:35.00");
727    assert!(progress.bitrate_kbps == 0.0);
728    assert!(progress.speed == 64.7);
729  }
730
731  /// Check for handling first progress message w/ bitrate=N/A and speed=N/A
732  /// These never appeared on Windows but showed up on Ubuntu and MacOS
733  #[test]
734  fn test_parse_progress_empty() {
735    let line =
736      "[info] frame=    0 fps=0.0 q=-0.0 size=       0kB time=00:00:00.00 bitrate=N/A speed=N/A\n";
737    let progress = try_parse_progress(line).unwrap();
738    assert_eq!(progress.frame, 0);
739    assert_eq!(progress.fps, 0.0);
740    assert_eq!(progress.q, -0.0);
741    assert_eq!(progress.size_kb, 0);
742    assert_eq!(progress.time, "00:00:00.00");
743    assert_eq!(progress.bitrate_kbps, 0.0);
744    assert_eq!(progress.speed, 0.0);
745  }
746}