ffpb 0.2.1

A modern, cli progress bar for ffmpeg
Documentation
use std::{
    fmt::Write as FmtWrite,
    io::{self, Write},
    time::Instant,
};

const BAR_WIDTH: usize = 40;
const PB_START: (u8, u8, u8) = (168, 85, 247);
const PB_END: (u8, u8, u8) = (236, 72, 153);
const DIM_COLOR: (u8, u8, u8) = (55, 65, 81);
const DONE_COLOR: (u8, u8, u8) = (75, 181, 67);

fn fg(buf: &mut String, r: u8, g: u8, b: u8) {
    let _ = write!(buf, "\x1b[38;2;{r};{g};{b}m");
}

fn bold(buf: &mut String) {
    buf.push_str("\x1b[1m");
}

fn dim(buf: &mut String) {
    buf.push_str("\x1b[2m");
}

fn reset(buf: &mut String) {
    buf.push_str("\x1b[0m");
}

fn lerp_color(t: f64, from: (u8, u8, u8), to: (u8, u8, u8)) -> (u8, u8, u8) {
    let r = from.0 as f64 + (to.0 as f64 - from.0 as f64) * t;
    let g = from.1 as f64 + (to.1 as f64 - from.1 as f64) * t;
    let b = from.2 as f64 + (to.2 as f64 - from.2 as f64) * t;
    (r as u8, g as u8, b as u8)
}

fn format_size(bytes: u64) -> String {
    const KIB: f64 = 1024.0;
    const MIB: f64 = 1024.0 * 1024.0;
    const GIB: f64 = 1024.0 * 1024.0 * 1024.0;

    let b = bytes as f64;
    if b >= GIB {
        format!("{:.1} GiB", b / GIB)
    } else if b >= MIB {
        format!("{:.1} MiB", b / MIB)
    } else if b >= KIB {
        format!("{:.1} KiB", b / KIB)
    } else {
        format!("{bytes} B")
    }
}

pub fn format_time(us: u64) -> String {
    let total_secs = us / 1_000_000;
    let hours = total_secs / 3600;
    let mins = (total_secs % 3600) / 60;
    let secs = total_secs % 60;

    if hours > 0 {
        format!("{hours}h{mins}m{secs}s")
    } else if mins > 0 {
        format!("{mins}m{secs}s")
    } else {
        format!("{secs}s")
    }
}

pub fn format_time_clock(us: u64) -> String {
    let total_secs = us / 1_000_000;
    let hours = total_secs / 3600;
    let mins = (total_secs % 3600) / 60;
    let secs = total_secs % 60;

    if hours > 0 {
        format!("{hours:02}:{mins:02}:{secs:02}")
    } else {
        format!("{mins:02}:{secs:02}")
    }
}

#[derive(Default)]
pub struct ProgressStats {
    pub frame: u64,
    pub fps: f64,
    pub bitrate_kbps: f64,
    pub total_size: u64,
    pub out_time_us: u64,
    pub speed: f64,
    pub q: f64,
    pub is_end: bool,
}

pub struct ProgressBar {
    total_duration_us: Option<u64>,
    last_render: Option<Instant>,
    started_at: Instant,
    lines_rendered: usize,
    pulse_frame: usize,
    compact: bool,
}

impl ProgressBar {
    pub fn new(total_duration_us: Option<u64>, compact: bool) -> Self {
        eprint!("\x1b[?25l");
        Self {
            total_duration_us,
            last_render: None,
            started_at: Instant::now(),
            lines_rendered: 0,
            pulse_frame: 0,
            compact,
        }
    }

    pub fn set_total_duration(&mut self, us: u64) {
        self.total_duration_us = Some(us);
    }

    pub fn update(&mut self, stats: &ProgressStats, force: bool) {
        if !force
            && let Some(last) = self.last_render
            && last.elapsed().as_millis() < 1000
        {
            return;
        }
        self.last_render = Some(Instant::now());
        self.pulse_frame = self.pulse_frame.wrapping_add(1);
        self.render(stats, false);
    }

    pub fn finish(&mut self, stats: &ProgressStats) {
        self.render(stats, true);
        self.lines_rendered = 0;
        eprint!("\x1b[?25h");
        let _ = io::stderr().flush();
    }

    pub fn interrupt(&mut self) {
        self.clear_lines();
        eprint!("\x1b[?25h");
        let _ = io::stderr().flush();
    }

    fn clear_lines(&self) {
        let mut stderr = io::stderr().lock();
        let _ = write!(stderr, "\x1b[2K");
        for _ in 1..self.lines_rendered {
            let _ = write!(stderr, "\x1b[A\x1b[2K");
        }
        let _ = write!(stderr, "\r");
        let _ = stderr.flush();
    }

    fn render(&mut self, stats: &ProgressStats, finished: bool) {
        self.clear_lines();
        let mut buf = String::with_capacity(100);
        let indent = if self.compact { "" } else { "  " };

        buf.push_str(indent);

        if finished {
            fg(&mut buf, DONE_COLOR.0, DONE_COLOR.1, DONE_COLOR.2);
            bold(&mut buf);
            buf.push_str("Done");
            reset(&mut buf);
        } else {
            bold(&mut buf);
            buf.push_str("Encoded");
            reset(&mut buf);

            fg(&mut buf, PB_START.0, PB_START.1, PB_START.2);
            let _ = write!(buf, " {}", format_time_clock(stats.out_time_us));
            reset(&mut buf);

            if let Some(total) = self.total_duration_us {
                let _ = write!(buf, "/{}", format_time_clock(total));
            }

            let elapsed_us = self.started_at.elapsed().as_micros() as u64;
            let _ = write!(buf, " in {}", format_time(elapsed_us));
        }

        buf.push('\n');
        buf.push_str(indent);

        let progress_fraction = match self.total_duration_us {
            Some(total) if total > 0 => {
                let frac = stats.out_time_us as f64 / total as f64;
                if finished { 1.0 } else { frac.min(1.0) }
            }
            _ => {
                if finished {
                    1.0
                } else {
                    0.0
                }
            }
        };

        if self.total_duration_us.is_none() && !finished {
            let pulse_width = 7;
            let cycle = (self.pulse_frame * 3) % (BAR_WIDTH + pulse_width);

            for i in 0..BAR_WIDTH {
                let in_pulse = i >= cycle.saturating_sub(pulse_width) && i < cycle;

                if in_pulse {
                    let t = (i as f64) / (BAR_WIDTH as f64);
                    let (r, g, b) = lerp_color(t, PB_START, PB_END);
                    fg(&mut buf, r, g, b);
                    buf.push('');
                } else {
                    fg(&mut buf, DIM_COLOR.0, DIM_COLOR.1, DIM_COLOR.2);
                    buf.push('');
                }
            }
            reset(&mut buf);
        } else {
            let filled = (progress_fraction * BAR_WIDTH as f64).round() as usize;
            let filled = filled.min(BAR_WIDTH);

            for i in 0..BAR_WIDTH {
                if i < filled {
                    let t = i as f64 / (BAR_WIDTH - 1) as f64;
                    let color = if finished {
                        DONE_COLOR
                    } else {
                        lerp_color(t, PB_START, PB_END)
                    };
                    fg(&mut buf, color.0, color.1, color.2);
                    buf.push('');
                } else {
                    fg(&mut buf, DIM_COLOR.0, DIM_COLOR.1, DIM_COLOR.2);
                    buf.push('');
                }
            }
            reset(&mut buf);

            bold(&mut buf);
            let _ = write!(buf, " {:.1}%", progress_fraction * 100.0);
            reset(&mut buf);

            if !finished
                && let Some(total) = self.total_duration_us
                && stats.out_time_us > 0
                && stats.out_time_us < total
            {
                let eta_us = (self.started_at.elapsed().as_micros() as f64
                    * (total.saturating_sub(stats.out_time_us) as f64 / stats.out_time_us as f64))
                    as u64;
                dim(&mut buf);
                buf.push_str("");
                reset(&mut buf);
                fg(&mut buf, PB_START.0, PB_START.1, PB_START.2);
                let _ = write!(buf, "eta {}", format_time(eta_us));
                reset(&mut buf);
            }
        }

        buf.push('\n');
        buf.push_str(indent);

        let _ = write!(buf, "{}", stats.frame);
        dim(&mut buf);
        buf.push_str(" @ ");
        reset(&mut buf);
        let _ = write!(buf, "{:.1} fps", stats.fps);
        dim(&mut buf);
        buf.push_str("");
        reset(&mut buf);
        let _ = write!(buf, "{:.1}q", stats.q);
        dim(&mut buf);
        buf.push_str("");
        reset(&mut buf);
        let _ = write!(buf, "{}", format_size(stats.total_size));
        dim(&mut buf);
        buf.push_str("");
        reset(&mut buf);
        let _ = write!(buf, "{:.1} kbps", stats.bitrate_kbps);
        dim(&mut buf);
        buf.push_str("");
        reset(&mut buf);
        let _ = write!(buf, "{:.1}x", stats.speed);

        self.lines_rendered = 3;

        if finished {
            buf.push('\n');
        }

        let mut stderr = io::stderr().lock();
        let _ = write!(stderr, "{buf}");
        let _ = stderr.flush();
    }
}

impl Drop for ProgressBar {
    fn drop(&mut self) {
        eprint!("\x1b[?25h");
        let _ = io::stderr().flush();
    }
}