ffpb 0.1.2

A coloured progress bar for ffmpeg.
Documentation
use kdam::{tqdm, BarExt, Column, RichProgress};
use regex::{Captures, Regex};
use std::{
    io::{stderr, BufRead, BufReader, Error, ErrorKind, IsTerminal, Read, Write},
    num::ParseIntError,
    process::{Command, Stdio},
    thread,
    time::Duration,
};

fn new_error(msg: &str) -> Error {
    Error::new(ErrorKind::Other, msg)
}

fn time_to_secs(x: &Captures) -> Result<usize, ParseIntError> {
    let hours = x.get(1).unwrap().as_str().parse::<usize>()?;
    let minutes = x.get(2).unwrap().as_str().parse::<usize>()?;
    let seconds = x.get(3).unwrap().as_str().parse::<usize>()?;
    Ok((((hours * 60) + minutes) * 60) + seconds)
}

/// Call ffmpeg command with args and coloured progress bar.
///
/// # Example
///
/// ```rust
/// fn main() {
///     let args = ["-i", "test.mp4", "-c:v", "copy", "test.mkv"]
///     .iter()
///     .map(|x| x.to_string())
///     .collect::<Vec<String>>();
///
///     ffpb::ffmpeg(&args).unwrap();
/// }
/// ```
pub fn ffmpeg(args: &[String]) -> Result<(), Error> {
    kdam::term::init(stderr().is_terminal());

    let ffmpeg = Command::new("ffmpeg")
        .args(args)
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|_| new_error("failed to launch ffmpeg binary."))?
        .stderr
        .ok_or_else(|| new_error("failed to capture ffmpeg standard error."))?;

    let mut reader = BufReader::new(ffmpeg);
    let mut pb = RichProgress::new(
        tqdm!(unit = " second".to_owned(), dynamic_ncols = true),
        vec![
            Column::Animation,
            Column::Percentage(2),
            Column::Text("•".to_owned()),
            Column::CountTotal,
            Column::Text("•".to_owned()),
            Column::ElapsedTime,
            Column::Text(">".to_owned()),
            Column::RemainingTime,
            Column::Text("•".to_owned()),
            Column::Text("[red]0 FPS".to_owned()),
        ],
    );

    let mut duration = None;
    let mut fps = None;
    let mut check_overwrite = true;
    let mut read_byte = b'\n';

    let duration_rx = Regex::new(r"Duration: (\d{2}):(\d{2}):(\d{2})\.\d{2}").unwrap();
    let fps_rx = Regex::new(r"(\d{2}\.\d{2}|\d{2}) fps").unwrap();
    let progress_rx = Regex::new(r"time=(\d{2}):(\d{2}):(\d{2})\.\d{2}").unwrap();

    loop {
        let mut prepend_text = "".to_owned();

        if check_overwrite {
            let mut pre_buf = [0; 5];
            reader
                .read_exact(&mut pre_buf)
                .map_err(|_| new_error("no such file or directory."))?;
            prepend_text.push_str(&String::from_utf8_lossy(&pre_buf));

            match prepend_text.as_str() {
                "File " => {
                    // File 'test.mp4' already exists. Overwrite? [y/N] y
                    let mut msg = String::new();

                    loop {
                        let mut post_buf = vec![];
                        reader.read_until(b']', &mut post_buf)?;
                        msg.push_str(&String::from_utf8(post_buf).unwrap());
                        let len = msg.len();

                        if 5 > len {
                            continue;
                        }

                        if msg
                            .get((len - 5)..len)
                            .map(|x| x == "[y/N]")
                            .unwrap_or(true)
                        {
                            break;
                        }
                    }

                    eprint!("File {} ", msg);
                    stderr().flush()?;
                    check_overwrite = false;
                    read_byte = b'\r';
                }

                "Press" => {
                    // Press [q] to stop, [?] for help
                    check_overwrite = false;
                    read_byte = b'\r';
                }

                _ => (),
            }
        } else {
            thread::sleep(Duration::from_secs_f32(0.1));
        }

        let mut buf = vec![];
        reader.read_until(read_byte, &mut buf)?;

        if let Ok(line) = String::from_utf8(buf) {
            let std_line = prepend_text + &line;

            if std_line == "" {
                pb.refresh()?;
                eprintln!();
                break;
            }

            if duration.is_none() {
                if let Some(x) = duration_rx.captures_iter(&std_line).next() {
                    duration = Some(
                        time_to_secs(&x)
                            .map_err(|_| new_error("couldn't parse total duration."))?,
                    );
                    pb.pb.total = duration.unwrap();
                }
            }

            if fps.is_none() {
                if let Some(x) = fps_rx.captures_iter(&std_line).next() {
                    fps = Some(
                        x.get(1)
                            .unwrap()
                            .as_str()
                            .parse::<f32>()
                            .map_err(|_| new_error("couldn't parse fps."))?,
                    );
                    pb.pb.unit = " frame".to_owned();
                }
            }

            if let Some(x) = progress_rx.captures_iter(&std_line).next() {
                let mut current =
                    time_to_secs(&x).map_err(|_| new_error("couldn't parse current duration."))?;

                if let Some(frames) = fps {
                    current *= frames as usize;
                    if pb.pb.total == duration.unwrap_or(0) {
                        pb.pb.total *= frames as usize;
                    }
                }

                pb.replace(9, Column::Text(format!("[red]{:.0} FPS", pb.pb.rate())));

                if current >= pb.pb.total {
                    pb.write(std_line.replace("\r", "").replace("\n", ""))?;
                }

                pb.update_to(current)?;
            }
        } else {
            break;
        }
    }

    Ok(())
}

/*
ffmpeg version n6.1.1-1-g61b88b4dda-20240204 Copyright (c) 2000-2023 the FFmpeg developers
  built with gcc 13.2.0 (crosstool-NG 1.25.0.232_c175b21)
  configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --pkg-config=pkg-config --cross-prefix=x86_64-w64-mingw32- --arch=x86_64 --target-os=mingw32 --enable-gpl --enable-version3 --disable-debug --enable-shared --disable-static --disable-w32threads --enable-pthreads --enable-iconv --enable-libxml2 --enable-zlib --enable-libfreetype --enable-libfribidi --enable-gmp --enable-lzma --enable-fontconfig --enable-libharfbuzz --enable-libvorbis --enable-opencl --disable-libpulse --enable-libvmaf --disable-libxcb --disable-xlib --enable-amf --enable-libaom --enable-libaribb24 --enable-avisynth --enable-chromaprint --enable-libdav1d --enable-libdavs2 --disable-libfdk-aac --enable-ffnvcodec --enable-cuda-llvm --enable-frei0r --enable-libgme --enable-libkvazaar --enable-libaribcaption --enable-libass --enable-libbluray --enable-libjxl --enable-libmp3lame --enable-libopus --enable-librist --enable-libssh --enable-libtheora --enable-libvpx --enable-libwebp --enable-lv2 --enable-libvpl --enable-openal --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenh264 --enable-libopenjpeg --enable-libopenmpt --enable-librav1e --enable-librubberband --enable-schannel --enable-sdl2 --enable-libsoxr --enable-libsrt --enable-libsvtav1 --enable-libtwolame --enable-libuavs3d --disable-libdrm --enable-vaapi --enable-libvidstab --enable-vulkan --enable-libshaderc --enable-libplacebo --enable-libx264 --enable-libx265 --enable-libxavs2 --enable-libxvid --enable-libzimg --enable-libzvbi --extra-cflags=-DLIBTWOLAME_STATIC --extra-cxxflags= --extra-ldflags=-pthread --extra-ldexeflags= --extra-libs=-lgomp --extra-version=20240204
  libavutil      58. 29.100 / 58. 29.100
  libavcodec     60. 31.102 / 60. 31.102
  libavformat    60. 16.100 / 60. 16.100
  libavdevice    60.  3.100 / 60.  3.100
  libavfilter     9. 12.100 /  9. 12.100
  libswscale      7.  5.100 /  7.  5.100
  libswresample   4. 12.100 /  4. 12.100
  libpostproc    57.  3.100 / 57.  3.100
Input #0, matroska,webm, from 'test.mkv':
  Metadata:
    encoder         : libebml v1.3.6 + libmatroska v1.4.9
    creation_time   : 2021-09-12T10:11:51.000000Z
    Encoded by      : ****
  Duration: 01:02:34.16, start: 0.000000, bitrate: 860 kb/s
  Stream #0:0: Video: hevc (Main 10), yuv420p10le(tv, bt709/bt709/unknown), 1280x720, SAR 1:1 DAR 16:9, 23.98 fps, 23.98 tbr, 1k tbn (default)
    Metadata:
      BPS-eng         : 777350
      DURATION-eng    : 01:02:33.542000000
      NUMBER_OF_FRAMES-eng: 89995
      NUMBER_OF_BYTES-eng: 364727328
      _STATISTICS_WRITING_APP-eng: mkvmerge v24.0.0 ('Beyond The Pale') 64-bit
      _STATISTICS_WRITING_DATE_UTC-eng: 2021-09-12 10:11:51
      _STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
  Stream #0:1(kor): Audio: aac (LC), 48000 Hz, stereo, fltp
    Metadata:
      title           : Korean
      BPS-eng         : 80609
      DURATION-eng    : 01:02:34.155000000
      NUMBER_OF_FRAMES-eng: 175976
      NUMBER_OF_BYTES-eng: 37827792
      _STATISTICS_WRITING_APP-eng: mkvmerge v24.0.0 ('Beyond The Pale') 64-bit
      _STATISTICS_WRITING_DATE_UTC-eng: 2021-09-12 10:11:51
      _STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
  Stream #0:2(eng): Subtitle: subrip
    Metadata:
      BPS-eng         : 60
      DURATION-eng    : 01:01:49.497000000
      NUMBER_OF_FRAMES-eng: 834
      NUMBER_OF_BYTES-eng: 27926
      _STATISTICS_WRITING_APP-eng: mkvmerge v24.0.0 ('Beyond The Pale') 64-bit
      _STATISTICS_WRITING_DATE_UTC-eng: 2021-09-12 10:11:51
      _STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
  Stream #0:3(eng): Subtitle: subrip
    Metadata:
      BPS-eng         : 60
      DURATION-eng    : 01:01:49.497000000
      NUMBER_OF_FRAMES-eng: 834
      NUMBER_OF_BYTES-eng: 27926
      _STATISTICS_WRITING_APP-eng: mkvmerge v24.0.0 ('Beyond The Pale') 64-bit
      _STATISTICS_WRITING_DATE_UTC-eng: 2021-09-12 10:11:51
      _STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
  Stream #0:4: Video: png, rgba(pc, gbr/unknown/unknown), 150x150 [SAR 2835:2835 DAR 1:1], 90k tbr, 90k tbn (attached pic)
    Metadata:
      filename        : cover.png
      mimetype        : image/png
File 'test.mp4' already exists. Overwrite? [y/N] y
Stream mapping:
  Stream #0:0 -> #0:0 (copy)
  Stream #0:1 -> #0:1 (aac (native) -> aac (native))
Press [q] to stop, [?] for help
Output #0, mp4, to 'test.mp4':
  Metadata:
    Encoded by      : ****
    encoder         : Lavf60.16.100
  Stream #0:0: Video: hevc (Main 10) (hev1 / 0x31766568), yuv420p10le(tv, bt709/bt709/unknown), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 23.98 fps, 23.98 tbr, 16k tbn (default)
    Metadata:
      BPS-eng         : 777350
      DURATION-eng    : 01:02:33.542000000
      NUMBER_OF_FRAMES-eng: 89995
      NUMBER_OF_BYTES-eng: 364727328
      _STATISTICS_WRITING_APP-eng: mkvmerge v24.0.0 ('Beyond The Pale') 64-bit
      _STATISTICS_WRITING_DATE_UTC-eng: 2021-09-12 10:11:51
      _STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
  Stream #0:1(kor): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 128 kb/s
    Metadata:
      title           : Korean
      BPS-eng         : 80609
      DURATION-eng    : 01:02:34.155000000
      NUMBER_OF_FRAMES-eng: 175976
      NUMBER_OF_BYTES-eng: 37827792
      _STATISTICS_WRITING_APP-eng: mkvmerge v24.0.0 ('Beyond The Pale') 64-bit
      _STATISTICS_WRITING_DATE_UTC-eng: 2021-09-12 10:11:51
      _STATISTICS_TAGS-eng: BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES
      encoder         : Lavc60.31.102 aac
[out#0/mp4 @ 0000022163a68340] video:50451kB audio:8865kB subtitle:0kB other streams:0kB global headers:2kB muxing overhead: 0.967485%
size=   59890kB time=00:09:23.44 bitrate= 870.7kbits/s speed=60.9x
*/