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