From 35059df44a561074f0a724045243d5e1cd010e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=81=99=E3=81=B1=E3=82=8B?= Date: Mon, 25 May 2026 03:45:58 +0900 Subject: [PATCH 1/2] feat: redesign encoder progress display --- Cargo.lock | 1 + tellur-renderer/Cargo.toml | 1 + tellur-renderer/src/video.rs | 239 +++++++++++++++++++++++++++++++---- 3 files changed, 213 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba0c7c8..47c1ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,6 +275,7 @@ name = "tellur-renderer" version = "0.1.0" dependencies = [ "bytes", + "console", "indicatif", "tellur-core", "thiserror", diff --git a/tellur-renderer/Cargo.toml b/tellur-renderer/Cargo.toml index 74b2d70..06aacad 100644 --- a/tellur-renderer/Cargo.toml +++ b/tellur-renderer/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] bytes = "1.11.1" +console = "0.16.3" indicatif = "0.18.4" tellur-core = { path = "../tellur-core" } thiserror = "2.0.18" diff --git a/tellur-renderer/src/video.rs b/tellur-renderer/src/video.rs index 5f05d87..2d51263 100644 --- a/tellur-renderer/src/video.rs +++ b/tellur-renderer/src/video.rs @@ -10,18 +10,28 @@ //! Frames are produced by repeatedly calling `Timeline::build(t, resolution)` //! with `t = frame_idx / fps` for `frame_idx` in `0..ceil(duration * fps)`. //! -//! Progress: a two-row progress display (`Render` / `Encode`) is shown by -//! default, driven by [`indicatif`]. The Render row counts frames as we -//! produce them; the Encode row is updated by parsing `frame=N` lines that -//! `ffmpeg` writes to stdout via `-progress pipe:1`. Disable via -//! [`FfmpegEncoder::progress`]. +//! Progress: a three-row display, driven by [`indicatif`]: +//! +//! ```text +//! Render ━━━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ 120/300 ( 40%) 00:01:18 > 00:00:42 +//! Encode ━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ 100/300 ( 33%) 00:01:23 > 00:00:42 +//! 42.50 MiB @ 3500.2kbits/s +//! ``` +//! +//! Row 1 (`Render`) counts frames as we produce them. Row 2 (`Encode`) +//! is updated by parsing `key=value` blocks that `ffmpeg` writes to +//! stdout via `-progress pipe:1`. Row 3 surfaces the running output +//! size and bitrate as reported by ffmpeg. Both bars show +//! `eta > elapsed` in zero-padded `HH:MM:SS` form. The bar fills the +//! terminal width; separators and the total count are dimmed so the +//! live numbers stand out. Disable via [`FfmpegEncoder::progress`]. use std::io::{BufRead, BufReader, Read, Write}; use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::{ChildStdout, Command, Stdio}; use std::thread; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; use tellur_core::raster::{PixelFormat, Resolution}; use tellur_core::time::TimelineTime; use tellur_core::timeline::Timeline; @@ -143,50 +153,52 @@ impl FfmpegEncoder { let mut child = cmd.spawn().map_err(FfmpegError::Spawn)?; let mut stdin = child.stdin.take().expect("stdin piped"); - // Set up the two-row progress display and a thread that drains + // Set up the three-row progress display and a thread that drains // ffmpeg's progress output. The `_multi` guard keeps the multi-bar - // alive for the duration of the encode; dropping it after both bars + // alive for the duration of the encode; dropping it after the bars // finish lets indicatif clear/finalize the lines cleanly. - let (_multi, render_bar, encode_bar, progress_thread) = if self.progress { + let (_multi, render_bar, encode_bar, info_bar, progress_thread) = if self.progress { let multi = MultiProgress::new(); - let style = ProgressStyle::with_template( - "{msg:7} {bar:40.cyan/blue} {pos:>5}/{len} ({percent:>3}%) {elapsed_precise}", - ) - .expect("static template parses") - .progress_chars("##-"); let render_bar = multi.add(ProgressBar::new(total_frames)); - render_bar.set_style(style.clone()); - render_bar.set_message("Render"); + render_bar.set_style(make_bar_style("Render", GREEN, total_frames)); let encode_bar = multi.add(ProgressBar::new(total_frames)); - encode_bar.set_style(style); - encode_bar.set_message("Encode"); + encode_bar.set_style(make_bar_style("Encode", ORANGE, total_frames)); + + // Third row: size @ bitrate text only. We piggyback on a + // length-1 `ProgressBar` whose template renders just `{msg}` + // so indicatif owns the line lifecycle (redraw, clear, + // finish) without painting a bar. Leading indent matches the + // bar rows' left margin + label width + gap (= 2 + 6 + 2). + // The size and bitrate values keep the default foreground; + // only the `@` separator is muted (applied inside `set_message`). + let info_bar = multi.add(ProgressBar::new(1)); + info_bar.set_style( + ProgressStyle::with_template(" {msg}") + .expect("static template parses"), + ); + info_bar.set_message("-"); let stdout = child .stdout .take() .expect("stdout piped when progress=true"); let encode_bar_for_thread = encode_bar.clone(); + let info_bar_for_thread = info_bar.clone(); let handle = thread::spawn(move || { - let reader = BufReader::new(stdout); - for line in reader.lines().map_while(Result::ok) { - if let Some(rest) = line.strip_prefix("frame=") { - if let Ok(n) = rest.trim().parse::() { - encode_bar_for_thread.set_position(n); - } - } - } + drive_encode_progress(stdout, encode_bar_for_thread, info_bar_for_thread) }); ( Some(multi), Some(render_bar), Some(encode_bar), + Some(info_bar), Some(handle), ) } else { - (None, None, None, None) + (None, None, None, None, None) }; let write_result = (|| -> Result<(), FfmpegError> { @@ -257,6 +269,9 @@ impl FfmpegEncoder { } bar.finish(); } + if let Some(bar) = &info_bar { + bar.finish(); + } write_result?; @@ -269,3 +284,171 @@ impl FfmpegEncoder { Ok(()) } } + +// Drains ffmpeg's `-progress pipe:1` stream and translates it into bar +// updates. The stream is plain text `key=value`, one pair per line, with +// each block terminated by `progress=continue` (or `progress=end` at EOF). +// `frame=` drives the Encode bar's position; `total_size=` / `bitrate=` +// are accumulated and flushed as a single combined message onto the +// info bar (third row) at each block boundary so the displayed string +// is always self-consistent. +fn drive_encode_progress(stdout: ChildStdout, encode_bar: ProgressBar, info_bar: ProgressBar) { + let reader = BufReader::new(stdout); + let mut total_size: Option = None; + let mut bitrate: Option = None; + for line in reader.lines().map_while(Result::ok) { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + match key { + "frame" => { + if let Ok(n) = value.parse::() { + encode_bar.set_position(n); + } + } + "total_size" => { + if let Ok(n) = value.parse::() { + if n >= 0 { + total_size = Some(n as u64); + } + } + } + "bitrate" if value != "N/A" => { + bitrate = Some(value.to_string()); + } + "progress" => { + let size_str = total_size.map(format_bytes).unwrap_or_else(|| "-".into()); + let br = bitrate.as_deref().unwrap_or("-"); + info_bar.set_message(format!("{size_str} {MUTED}@{RESET} {br}")); + } + _ => {} + } + } +} + +fn format_bytes(b: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + let bf = b as f64; + if bf >= GIB { + format!("{:.2} GiB", bf / GIB) + } else if bf >= MIB { + format!("{:.2} MiB", bf / MIB) + } else if bf >= KIB { + format!("{:.2} KiB", bf / KIB) + } else { + format!("{b} B") + } +} + +// ── progress styling ──────────────────────────────────────────────────── +// +// Match the look of the `~/dotfiles` Claude statusline. Three foreground +// tiers, applied consistently across all three rows: +// +// - **Label color** (GREEN for Render, ORANGE for Encode): the label +// itself, the filled bar segment, and the live counters (`pos`, +// `percent`). These are the values you actively watch. +// - **Default (white)**: the time values (`eta`, `elapsed`). Important +// enough to read clearly, but not tied to a specific row's identity. +// - **MUTED grey**: everything else — the unfilled bar, separators +// (`/`, `(`, `%)`, `>`), the total count, and the `@` between size +// and bitrate on the third row. De-emphasized so the live numbers +// stand out. (Size and bitrate themselves use the default color.) +// +// The bar fills the terminal width minus the rest of the template and +// a two-column margin on each side; time is shown as `eta > elapsed` +// (remaining first, since that's the figure you usually want). + +const GREEN: &str = "\x1b[38;2;151;201;195m"; +const ORANGE: &str = "\x1b[38;2;209;154;102m"; +/// Medium grey for de-emphasized text — halfway between the terminal's +/// default foreground and a heavy dim, so the muted parts recede behind +/// the live numbers without disappearing. +const MUTED: &str = "\x1b[38;2;128;128;128m"; +const RESET: &str = "\x1b[0m"; + +/// Smallest bar we'll draw when the terminal is narrow. +const MIN_BAR_WIDTH: usize = 8; +/// Fallback terminal width when we can't query the TTY (piped output etc.). +const FALLBACK_TERM_WIDTH: usize = 80; + +fn fmt_hms(secs: u64) -> String { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + format!("{h:02}:{m:02}:{s:02}") +} + +/// Visible (non-ANSI) length of everything in the template except +/// `{custom_bar}`. Lets the custom bar fill the remaining columns. +/// +/// Layout (the spaces are intentional — same shape for both bars): +/// +/// ```text +/// LLLLLL [bar] PPPPP/NNN (XXX%) HH:MM:SS > HH:MM:SS +/// ^^ ^^ ^^ ^^ ^^ ^^ +/// left label+ bar+ pos/len percent right +/// pad gap gap pad +/// ``` +/// +/// Left and right two-column margins keep the bar from butting against +/// the terminal edges. +fn bar_overhead(label_display_len: usize, len_digits: usize) -> usize { + // left_pad + label + " " (label gap) + " " (post-bar gap) + // + "PPPPP" + "/" + len + " " + "(" + "XXX" + "%" + ")" + " " + // + "HH:MM:SS" + " > " + "HH:MM:SS" + right_pad + 2 + label_display_len + 2 + 2 + 5 + 1 + len_digits + 2 + 1 + 3 + 1 + 1 + 2 + 8 + 3 + 8 + 2 +} + +fn term_width() -> usize { + console::Term::stdout() + .size_checked() + .map(|(_, cols)| cols as usize) + .unwrap_or(FALLBACK_TERM_WIDTH) +} + +// `label` and `label_color` are static so the captured closures satisfy +// indicatif's `Fn + Send + Sync + 'static` bound on custom keys. +fn make_bar_style(label: &'static str, label_color: &'static str, total: u64) -> ProgressStyle { + let len_digits = total.to_string().len().max(1); + let overhead = bar_overhead(label.chars().count(), len_digits); + + let template = format!( + " {label_color}{label}{RESET} {{custom_bar}} \ + {label_color}{{pos:>5}}{RESET}{MUTED}/{{len}}{RESET} \ + {MUTED}({RESET}{label_color}{{percent:>3}}{RESET}{MUTED}%){RESET} \ + {{eta_hms}} {MUTED}>{RESET} {{elapsed_hms}}" + ); + ProgressStyle::with_template(&template) + .expect("static template parses") + .with_key( + "custom_bar", + move |state: &ProgressState, w: &mut dyn std::fmt::Write| { + let bar_width = term_width().saturating_sub(overhead).max(MIN_BAR_WIDTH); + let frac = state.fraction(); + let filled = ((frac * bar_width as f32).round() as usize).min(bar_width); + let empty = bar_width - filled; + let _ = write!( + w, + "{label_color}{}{MUTED}{}{RESET}", + "━".repeat(filled), + "╌".repeat(empty), + ); + }, + ) + .with_key( + "elapsed_hms", + |state: &ProgressState, w: &mut dyn std::fmt::Write| { + let _ = write!(w, "{}", fmt_hms(state.elapsed().as_secs())); + }, + ) + .with_key( + "eta_hms", + |state: &ProgressState, w: &mut dyn std::fmt::Write| { + let _ = write!(w, "{}", fmt_hms(state.eta().as_secs())); + }, + ) +} From 29e7b305c541c757cb5562f5acc33153b54ea087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=81=99=E3=81=B1=E3=82=8B?= Date: Mon, 25 May 2026 03:51:38 +0900 Subject: [PATCH 2/2] style: apply cargo fmt --- tellur-renderer/src/video.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tellur-renderer/src/video.rs b/tellur-renderer/src/video.rs index 2d51263..4d64cda 100644 --- a/tellur-renderer/src/video.rs +++ b/tellur-renderer/src/video.rs @@ -175,8 +175,7 @@ impl FfmpegEncoder { // only the `@` separator is muted (applied inside `set_message`). let info_bar = multi.add(ProgressBar::new(1)); info_bar.set_style( - ProgressStyle::with_template(" {msg}") - .expect("static template parses"), + ProgressStyle::with_template(" {msg}").expect("static template parses"), ); info_bar.set_message("-");