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};
13use crate::ffmpeg_time_duration::FfmpegTimeDuration;
14
15#[derive(Debug, Clone, PartialEq)]
16enum LogSection {
17  Input(u32),
18  Output(u32),
19  StreamMapping,
20  Other,
21}
22
23pub struct FfmpegLogParser<R: Read> {
24  reader: BufReader<R>,
25  cur_section: LogSection,
26}
27
28impl<R: Read> FfmpegLogParser<R> {
29  /// Consume lines from the inner reader until obtaining a completed
30  /// `FfmpegEvent`, returning it.
31  ///
32  /// Typically this consumes a single line, but in the case of multi-line
33  /// input/output stream specifications, nested method calls will consume
34  /// additional lines until the entire vector of Inputs/Outputs is parsed.
35  ///
36  /// Line endings can be marked by three possible delimiters:
37  /// - `\n` (MacOS),
38  /// - `\r\n` (Windows)
39  /// - `\r` (Windows, progress updates which overwrite the previous line)
40  pub fn parse_next_event(&mut self) -> anyhow::Result<FfmpegEvent> {
41    let mut buf = Vec::<u8>::new();
42    let bytes_read = read_until_any(&mut self.reader, b"\r\n", &mut buf);
43    let line_cow = String::from_utf8_lossy(buf.as_slice());
44    let line = line_cow.trim();
45    let raw_log_message = line.to_string();
46    match bytes_read? {
47      0 => Ok(FfmpegEvent::LogEOF),
48      _ => {
49        // Track log section
50        if let Some(input_number) = try_parse_input(line) {
51          self.cur_section = LogSection::Input(input_number);
52          return Ok(FfmpegEvent::ParsedInput(FfmpegInput {
53            index: input_number,
54            duration: None,
55            raw_log_message,
56          }));
57        } else if let Some(output) = try_parse_output(line) {
58          self.cur_section = LogSection::Output(output.index);
59          return Ok(FfmpegEvent::ParsedOutput(output));
60        } else if line.contains("Stream mapping:") {
61          self.cur_section = LogSection::StreamMapping;
62        }
63
64        // Parse
65        if let Some(version) = try_parse_version(line) {
66          Ok(FfmpegEvent::ParsedVersion(FfmpegVersion {
67            version,
68            raw_log_message,
69          }))
70        } else if let Some(configuration) = try_parse_configuration(line) {
71          Ok(FfmpegEvent::ParsedConfiguration(FfmpegConfiguration {
72            configuration,
73            raw_log_message,
74          }))
75        } else if let Some(duration) = try_parse_duration(line) {
76          match self.cur_section {
77            LogSection::Input(input_index) => Ok(FfmpegEvent::ParsedDuration(FfmpegDuration {
78              input_index,
79              duration,
80              raw_log_message,
81            })),
82            _ => Ok(FfmpegEvent::Log(LogLevel::Info, line.to_string())),
83          }
84        } else if self.cur_section == LogSection::StreamMapping && line.contains("  Stream #") {
85          Ok(FfmpegEvent::ParsedStreamMapping(line.to_string()))
86        } else if let Some(stream) = try_parse_stream(line) {
87          match self.cur_section {
88            LogSection::Input(_) => Ok(FfmpegEvent::ParsedInputStream(stream)),
89            LogSection::Output(_) => Ok(FfmpegEvent::ParsedOutputStream(stream)),
90            LogSection::Other | LogSection::StreamMapping => Err(anyhow::Error::msg(format!(
91              "Unexpected stream specification: {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(FfmpegTimeDuration::from_str)
222    .map(FfmpegTimeDuration::as_seconds)
223}
224
225/// Parse an output section like the following, extracting the index of the input:
226///
227/// ## Example:
228///
229/// ```rust
230/// use ffmpeg_sidecar::log_parser::try_parse_output;
231/// use ffmpeg_sidecar::event::FfmpegOutput;
232/// let line = "[info] Output #0, mp4, to 'test.mp4':\n";
233/// let output = try_parse_output(line);
234/// assert!(output == Some(FfmpegOutput {
235///   index: 0,
236///   to: "test.mp4".to_string(),
237///   raw_log_message: line.to_string(),
238/// }));
239/// ```
240///
241pub fn try_parse_output(mut string: &str) -> Option<FfmpegOutput> {
242  let raw_log_message = string.to_string();
243
244  string = string
245    .strip_prefix("[info]")
246    .unwrap_or(string)
247    .trim()
248    .strip_prefix("Output #")?;
249
250  let index = string
251    .split_whitespace()
252    .next()
253    .and_then(|s| s.split(',').next())
254    .and_then(|s| s.parse::<u32>().ok())?;
255
256  let to = string
257    .split(" to '")
258    .nth(1)?
259    .split('\'')
260    .next()?
261    .to_string();
262
263  Some(FfmpegOutput {
264    index,
265    to,
266    raw_log_message,
267  })
268}
269
270/// Parses a line that represents a stream.
271///
272/// ## Examples
273///
274/// ### Video
275///
276/// #### Input stream
277///
278/// ```rust
279/// use ffmpeg_sidecar::log_parser::try_parse_stream;
280/// let line = "[info]   Stream #0:0: Video: wrapped_avframe, rgb24, 320x240 [SAR 1:1 DAR 4:3], 25 fps, 25 tbr, 25 tbn\n";
281/// let stream = try_parse_stream(line).unwrap();
282/// assert!(stream.format == "wrapped_avframe");
283/// assert!(stream.language == "");
284/// assert!(stream.parent_index == 0);
285/// assert!(stream.stream_index == 0);
286/// assert!(stream.is_video());
287/// let video_data = stream.video_data().unwrap();
288/// assert!(video_data.pix_fmt == "rgb24");
289/// assert!(video_data.width == 320);
290/// assert!(video_data.height == 240);
291/// assert!(video_data.fps == 25.0);
292///  ```
293///
294///  #### Output stream
295///
296///  ```rust
297///  use ffmpeg_sidecar::log_parser::try_parse_stream;
298///  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";
299///  let stream = try_parse_stream(line).unwrap();
300///  assert!(stream.format == "h264");
301///  assert!(stream.language == "eng");
302///  assert!(stream.parent_index == 1);
303///  assert!(stream.stream_index == 5);
304///  assert!(stream.is_video());
305///  let video_data = stream.video_data().unwrap();
306///  assert!(video_data.pix_fmt == "yuv444p");
307///  assert!(video_data.width == 320);
308///  assert!(video_data.height == 240);
309///  assert!(video_data.fps == 25.0);
310///  ```
311///
312/// ### Audio
313///
314/// #### Input Stream
315///
316/// ```rust
317/// use ffmpeg_sidecar::log_parser::try_parse_stream;
318/// let line = "[info]   Stream #0:1(eng): Audio: opus, 48000 Hz, stereo, fltp (default)\n";
319/// let stream = try_parse_stream(line).unwrap();
320/// assert!(stream.format == "opus");
321/// assert!(stream.language == "eng");
322/// assert!(stream.parent_index == 0);
323/// assert!(stream.stream_index == 1);
324/// assert!(stream.is_audio());
325/// let audio_data = stream.audio_data().unwrap();
326/// assert!(audio_data.sample_rate == 48000);
327/// assert!(audio_data.channels == "stereo");
328/// ```
329///
330/// ```rust
331/// use ffmpeg_sidecar::log_parser::try_parse_stream;
332/// let line = "[info]   Stream #3:10(ger): Audio: dts (DTS-HD MA), 48000 Hz, 7.1, s32p (24 bit)\n";
333/// let stream = try_parse_stream(line).unwrap();
334/// assert!(stream.format == "dts");
335/// assert!(stream.language == "ger");
336/// assert!(stream.parent_index == 3);
337/// assert!(stream.stream_index == 10);
338/// assert!(stream.is_audio());
339/// let audio_data = stream.audio_data().unwrap();
340/// assert!(audio_data.sample_rate == 48000);
341/// assert!(audio_data.channels == "7.1");
342/// ```
343///
344/// ### Output stream
345///
346/// ```rust
347/// use ffmpeg_sidecar::log_parser::try_parse_stream;
348/// let line = "[info]   Stream #10:1: Audio: mp2, 44100 Hz, mono, s16, 384 kb/s\n";
349/// let stream = try_parse_stream(line).unwrap();
350/// assert!(stream.format == "mp2");
351/// assert!(stream.language == "");
352/// assert!(stream.parent_index == 10);
353/// assert!(stream.stream_index == 1);
354/// assert!(stream.is_audio());
355/// let audio_data = stream.audio_data().unwrap();
356/// assert!(audio_data.sample_rate == 44100);
357/// assert!(audio_data.channels == "mono");
358/// ```
359///
360/// ### Subtitle
361///
362/// #### Input Stream
363///
364/// ```rust
365/// use ffmpeg_sidecar::log_parser::try_parse_stream;
366/// let line = "[info]   Stream #0:4(eng): Subtitle: ass (default) (forced)\n";
367/// let stream = try_parse_stream(line).unwrap();
368/// assert!(stream.format == "ass");
369/// assert!(stream.language == "eng");
370/// assert!(stream.parent_index == 0);
371/// assert!(stream.stream_index == 4);
372/// assert!(stream.is_subtitle());
373/// ```
374///
375/// ```rust
376/// use ffmpeg_sidecar::log_parser::try_parse_stream;
377/// let line = "[info]   Stream #0:13(dut): Subtitle: hdmv_pgs_subtitle, 1920x1080\n";
378/// let stream = try_parse_stream(line).unwrap();
379/// assert!(stream.format == "hdmv_pgs_subtitle");
380/// assert!(stream.language == "dut");
381/// assert!(stream.parent_index == 0);
382/// assert!(stream.stream_index == 13);
383/// assert!(stream.is_subtitle());
384/// ```
385/// ### Other
386///
387/// #### Input Stream
388///
389/// ```rust
390/// use ffmpeg_sidecar::log_parser::try_parse_stream;
391/// let line = "[info]   Stream #0:2(und): Data: none (rtp  / 0x20707472), 53 kb/s (default)\n";
392/// let stream = try_parse_stream(line).unwrap();
393/// assert!(stream.format == "none");
394/// assert!(stream.language == "und");
395/// assert!(stream.parent_index == 0);
396/// assert!(stream.stream_index == 2);
397/// assert!(stream.is_other());
398/// ```
399///
400/// ```rust
401/// use ffmpeg_sidecar::log_parser::try_parse_stream;
402/// let line = "[info]   Stream #0:2[0x3](eng): Data: bin_data (text / 0x74786574)\n";
403/// let stream = try_parse_stream(line).unwrap();
404/// assert!(stream.format == "bin_data");
405/// assert!(stream.language == "eng");
406/// assert!(stream.parent_index == 0);
407/// assert!(stream.stream_index == 2);
408/// assert!(stream.is_other());
409/// ```
410pub fn try_parse_stream(string: &str) -> Option<Stream> {
411  let raw_log_message = string.to_string();
412
413  let string = string
414    .strip_prefix("[info]")
415    .unwrap_or(string)
416    .trim()
417    .strip_prefix("Stream #")?;
418  let mut comma_iter = CommaIter::new(string);
419  let mut colon_iter = comma_iter.next()?.split(':');
420
421  let parent_index = colon_iter.next()?.parse::<u32>().ok()?;
422
423  // Here handle pattern such as `2[0x3](eng)`
424  let indices_and_maybe_language = colon_iter
425    .next()?
426    // Remove everything inside and including square brackets
427    .split(['[', ']'])
428    .step_by(2)
429    .collect::<String>();
430  let mut parenthesis_iter = indices_and_maybe_language.split('(');
431  let stream_index = parenthesis_iter.next()?.trim().parse::<u32>().ok()?;
432  let language = parenthesis_iter.next().map_or("".to_string(), |lang| {
433    lang.trim_end_matches(')').to_string()
434  });
435
436  // Here handle pattern such as `Video: av1 (Main)`
437  let stream_type = colon_iter.next()?.trim();
438  let format = colon_iter
439    .next()?
440    .trim()
441    .split(&[' ', '(']) // trim trailing junk like `(Main)`
442    .next()?
443    .to_string();
444
445  // For audio and video handle remaining string in specialized functions.
446  let type_specific_data: StreamTypeSpecificData = match stream_type {
447    "Audio" => try_parse_audio_stream(comma_iter)?,
448    "Subtitle" => StreamTypeSpecificData::Subtitle(),
449    "Video" => try_parse_video_stream(comma_iter)?,
450    _ => StreamTypeSpecificData::Other(),
451  };
452
453  Some(Stream {
454    format,
455    language,
456    parent_index,
457    stream_index,
458    raw_log_message,
459    type_specific_data,
460  })
461}
462
463/// Parses the log output part that is specific to audio streams.
464fn try_parse_audio_stream(mut comma_iter: CommaIter) -> Option<StreamTypeSpecificData> {
465  let sample_rate = comma_iter
466    .next()?
467    .split_whitespace()
468    .next()?
469    .parse::<u32>()
470    .ok()?;
471
472  let channels = comma_iter.next()?.trim().to_string();
473
474  Some(StreamTypeSpecificData::Audio(AudioStream {
475    sample_rate,
476    channels,
477  }))
478}
479
480/// Parses the log output part that is specific to video streams.
481fn try_parse_video_stream(mut comma_iter: CommaIter) -> Option<StreamTypeSpecificData> {
482  let pix_fmt = comma_iter
483    .next()?
484    .trim()
485    .split(&[' ', '(']) // trim trailing junk like "(tv, progressive)"
486    .next()?
487    .to_string();
488
489  let dims = comma_iter.next()?.split_whitespace().next()?;
490  let mut dims_iter = dims.split('x');
491  let width = dims_iter.next()?.parse::<u32>().ok()?;
492  let height = dims_iter.next()?.parse::<u32>().ok()?;
493
494  // FPS does not have to be the next part, so we iterate until we find it. There is nothing else we
495  // are interested in at this point, so its OK to skip anything in-between.
496  let fps = comma_iter
497    .find_map(|part| {
498      if part.trim().ends_with("fps") {
499        part.split_whitespace().next()
500      } else {
501        None
502      }
503    })
504    .and_then(|fps_str| fps_str.parse::<f32>().ok())?;
505
506  Some(StreamTypeSpecificData::Video(VideoStream {
507    pix_fmt,
508    width,
509    height,
510    fps,
511  }))
512}
513
514/// Parse a progress update line from ffmpeg.
515/// For audio-only progress events, the `frame`, `fps`, and `q` fields will be `0`.
516///
517/// ## Example
518/// ```rust
519/// use ffmpeg_sidecar::log_parser::try_parse_progress;
520/// 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";
521/// let progress = try_parse_progress(line).unwrap();
522/// assert!(progress.frame == 1996);
523/// assert!(progress.fps == 1984.0);
524/// assert!(progress.q == -1.0);
525/// assert!(progress.size_kb == 372);
526/// assert!(progress.time == "00:01:19.72");
527/// assert!(progress.bitrate_kbps == 38.2);
528/// assert!(progress.speed == 79.2);
529/// ```
530pub fn try_parse_progress(mut string: &str) -> Option<FfmpegProgress> {
531  let raw_log_message = string.to_string();
532
533  string = string.strip_prefix("[info]").unwrap_or(string).trim();
534
535  let frame = string
536    .split("frame=")
537    .nth(1)
538    .and_then(|s| s.split_whitespace().next())
539    .and_then(|s| s.parse::<u32>().ok())
540    .unwrap_or(0);
541  let fps = string
542    .split("fps=")
543    .nth(1)
544    .and_then(|s| s.split_whitespace().next())
545    .and_then(|s| s.parse::<f32>().ok())
546    .unwrap_or(0.0);
547  let q = string
548    .split("q=")
549    .nth(1)
550    .and_then(|s| s.split_whitespace().next())
551    .and_then(|s| s.parse::<f32>().ok())
552    .unwrap_or(0.0);
553  let size_kb = string
554    .split("size=") // captures "Lsize=" AND "size="
555    .nth(1)?
556    .split_whitespace()
557    .next()
558    .map(|s| s.trim())
559    .and_then(|s| {
560      s.strip_suffix("KiB") // FFmpeg v7.0 and later
561        .or_else(|| s.strip_suffix("kB")) // FFmpeg v6.0 and prior
562        .or_else(|| s.ends_with("N/A").then_some("0")) // handles "N/A"
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  FfmpegTimeDuration::from_str(str).map(FfmpegTimeDuration::as_seconds)
620}
621
622#[cfg(test)]
623mod tests {
624  use super::*;
625  use crate::{command::BackgroundCommand, paths::ffmpeg_path};
626  use std::{
627    io::{Cursor, Seek, SeekFrom, Write},
628    process::{Command, Stdio},
629  };
630
631  #[test]
632  fn test_parse_version() {
633    let cmd = Command::new(ffmpeg_path())
634      .create_no_window()
635      .arg("-version")
636      .stdout(Stdio::piped())
637      // ⚠ notice that ffmpeg emits on stdout when `-version` or `-help` is passed!
638      .spawn()
639      .unwrap();
640
641    let stdout = cmd.stdout.unwrap();
642    let mut parser = FfmpegLogParser::new(stdout);
643    while let Ok(event) = parser.parse_next_event() {
644      if let FfmpegEvent::ParsedVersion(_) = event {
645        return;
646      }
647    }
648    panic!() // should have found a version
649  }
650
651  #[test]
652  fn test_parse_configuration() {
653    let cmd = Command::new(ffmpeg_path())
654      .create_no_window()
655      .arg("-version")
656      .stdout(Stdio::piped())
657      // ⚠ notice that ffmpeg emits on stdout when `-version` or `-help` is passed!
658      .spawn()
659      .unwrap();
660
661    let stdout = cmd.stdout.unwrap();
662    let mut parser = FfmpegLogParser::new(stdout);
663    while let Ok(event) = parser.parse_next_event() {
664      if let FfmpegEvent::ParsedConfiguration(_) = event {
665        return;
666      }
667    }
668    panic!() // should have found a configuration
669  }
670
671  /// Test case from https://github.com/nathanbabcock/ffmpeg-sidecar/issues/2#issue-1606661255
672  #[test]
673  fn test_macos_line_endings() {
674    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";
675
676    // Emulate a stderr channel
677    let mut cursor = Cursor::new(Vec::new());
678    cursor.write_all(stdout_str.as_bytes()).unwrap();
679    cursor.seek(SeekFrom::Start(0)).unwrap();
680
681    let reader = BufReader::new(cursor);
682    let mut parser = FfmpegLogParser::new(reader);
683    let mut num_events = 0;
684    while let Ok(event) = parser.parse_next_event() {
685      match event {
686        FfmpegEvent::LogEOF => break,
687        _ => num_events += 1,
688      }
689    }
690    assert!(num_events > 1);
691  }
692
693  /// Test case for https://github.com/nathanbabcock/ffmpeg-sidecar/issues/31
694  /// Covers regression in progress parsing introduced in FFmpeg 7.0
695  /// The string format for `Lsize` units went from `kB` to `KiB`
696  #[test]
697  fn test_parse_progress_v7() {
698    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";
699    let progress = try_parse_progress(line).unwrap();
700    assert_eq!(progress.frame, 5);
701    assert_eq!(progress.fps, 0.0);
702    assert_eq!(progress.q, -1.0);
703    assert_eq!(progress.size_kb, 10);
704    assert_eq!(progress.time, "00:00:03.00");
705    assert_eq!(progress.bitrate_kbps, 27.2);
706    assert_eq!(progress.speed, 283.0);
707  }
708
709  /// Check for handling first progress message w/ bitrate=N/A and speed=N/A
710  /// These never appeared on Windows but showed up on Ubuntu and MacOS
711  #[test]
712  fn test_parse_progress_empty() {
713    let line =
714      "[info] frame=    0 fps=0.0 q=-0.0 size=       0kB time=00:00:00.00 bitrate=N/A speed=N/A\n";
715    let progress = try_parse_progress(line).unwrap();
716    assert_eq!(progress.frame, 0);
717    assert_eq!(progress.fps, 0.0);
718    assert_eq!(progress.q, -0.0);
719    assert_eq!(progress.size_kb, 0);
720    assert_eq!(progress.time, "00:00:00.00");
721    assert_eq!(progress.bitrate_kbps, 0.0);
722    assert_eq!(progress.speed, 0.0);
723  }
724
725  /// Check for handling progress message with no size.
726  /// These can occur when exporting frames to image files (i.e. jpeg).
727  #[test]
728  fn test_parse_progress_no_size() {
729    let line = "[info] frame=  163 fps= 13 q=4.4 size=N/A time=00:13:35.00 bitrate=N/A speed=64.7x";
730    let progress = try_parse_progress(line).unwrap();
731    assert_eq!(progress.frame, 163);
732    assert_eq!(progress.fps, 13.0);
733    assert_eq!(progress.q, 4.4);
734    assert_eq!(progress.size_kb, 0);
735    assert_eq!(progress.time, "00:13:35.00");
736    assert_eq!(progress.bitrate_kbps, 0.0);
737    assert_eq!(progress.speed, 64.7);
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
757  #[test]
758  fn test_audio_progress() {
759    let line = "[info] size=      66kB time=00:00:02.21 bitrate= 245.0kbits/s speed=1.07x\n";
760    let progress = try_parse_progress(line).unwrap();
761    assert!(progress.frame == 0);
762    assert!(progress.fps == 0.0);
763    assert!(progress.q == 0.0);
764    assert!(progress.size_kb == 66);
765    assert!(progress.time == "00:00:02.21");
766    assert!(progress.bitrate_kbps == 245.0);
767    assert!(progress.speed == 1.07);
768  }
769}