ffmpeg_sidecar/
log_parser.rs

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