ffpb 0.2.0

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 GRADIENT_START: (u8, u8, u8) = (168, 85, 247);
const GRADIENT_END: (u8, u8, u8) = (236, 72, 153);
const UNFILLED_COLOR: (u8, u8, u8) = (55, 65, 81);
const DONE_COLOR: (u8, u8, u8) = (52, 211, 153);
const PERCENT_COLOR: (u8, u8, u8) = (243, 244, 246);
const LABEL_COLOR: (u8, u8, u8) = (243, 244, 246);
const STAT_COLOR: (u8, u8, u8) = (156, 163, 175);
const SEP_COLOR: (u8, u8, u8) = (75, 85, 99);

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 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")
    }
}

#[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();
        for _ in 0..self.lines_rendered {
            let _ = write!(stderr, "\x1b[A\x1b[2K");
        }
        let _ = stderr.flush();
    }

    fn render(&mut self, stats: &ProgressStats, finished: bool) {
        self.clear_lines();

        let mut buf = String::with_capacity(512);

        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");
        } else {
            fg(&mut buf, LABEL_COLOR.0, LABEL_COLOR.1, LABEL_COLOR.2);
            bold(&mut buf);
            buf.push_str("Encoding");
        }
        reset(&mut buf);
        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
                }
            }
        };

        let is_indeterminate = self.total_duration_us.is_none() && !finished;

        if is_indeterminate {
            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, GRADIENT_START, GRADIENT_END);
                    fg(&mut buf, r, g, b);
                    buf.push('');
                } else {
                    fg(
                        &mut buf,
                        UNFILLED_COLOR.0,
                        UNFILLED_COLOR.1,
                        UNFILLED_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, GRADIENT_START, GRADIENT_END)
                    };
                    fg(&mut buf, color.0, color.1, color.2);
                    buf.push('');
                } else {
                    fg(
                        &mut buf,
                        UNFILLED_COLOR.0,
                        UNFILLED_COLOR.1,
                        UNFILLED_COLOR.2,
                    );
                    buf.push('');
                }
            }
            reset(&mut buf);

            buf.push_str("  ");
            fg(&mut buf, PERCENT_COLOR.0, PERCENT_COLOR.1, PERCENT_COLOR.2);
            bold(&mut buf);
            let _ = write!(buf, "{:.1}%", progress_fraction * 100.0);
            reset(&mut buf);
            let elapsed_us = self.started_at.elapsed().as_micros() as u64;
            fg(&mut buf, STAT_COLOR.0, STAT_COLOR.1, STAT_COLOR.2);
            let _ = write!(buf, " ({})", format_time(elapsed_us));
            reset(&mut buf);

            if !finished
                && let Some(total) = self.total_duration_us
                && stats.speed > 0.0
                && stats.out_time_us < total
            {
                let remaining_us = total.saturating_sub(stats.out_time_us);
                let eta_us = (remaining_us as f64 / stats.speed) as u64;
                fg(&mut buf, SEP_COLOR.0, SEP_COLOR.1, SEP_COLOR.2);
                buf.push_str("");
                fg(&mut buf, STAT_COLOR.0, STAT_COLOR.1, STAT_COLOR.2);
                let _ = write!(buf, "ETA {}", format_time(eta_us));
                reset(&mut buf);
            }
        }
        buf.push('\n');

        buf.push_str(indent);
        let mut stat_parts = Vec::new();
        stat_parts.push(format!("{} fr", stats.frame));
        stat_parts.push(format!("{:.1} fps", stats.fps));
        stat_parts.push(format!("{:.1} q", stats.q));
        stat_parts.push(format_size(stats.total_size));

        let time_str = if let Some(total) = self.total_duration_us {
            format!("{}/{}", format_time(stats.out_time_us), format_time(total))
        } else {
            format_time(stats.out_time_us)
        };
        stat_parts.push(time_str);

        if stats.bitrate_kbps > 0.0 {
            stat_parts.push(format!("{:.1} kbps", stats.bitrate_kbps));
        }

        if stats.speed > 0.0 {
            stat_parts.push(format!("{:.2}x", stats.speed));
        }

        for (i, part) in stat_parts.iter().enumerate() {
            if i > 0 {
                fg(&mut buf, SEP_COLOR.0, SEP_COLOR.1, SEP_COLOR.2);
                buf.push_str("");
            }
            fg(&mut buf, STAT_COLOR.0, STAT_COLOR.1, STAT_COLOR.2);
            buf.push_str(part);
        }
        reset(&mut buf);
        buf.push('\n');

        self.lines_rendered = 3;

        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();
    }
}