Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tellur-renderer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
238 changes: 210 additions & 28 deletions tellur-renderer/src/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -143,50 +153,51 @@ 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::<u64>() {
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> {
Expand Down Expand Up @@ -257,6 +268,9 @@ impl FfmpegEncoder {
}
bar.finish();
}
if let Some(bar) = &info_bar {
bar.finish();
}

write_result?;

Expand All @@ -269,3 +283,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<u64> = None;
let mut bitrate: Option<String> = 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::<u64>() {
encode_bar.set_position(n);
}
}
"total_size" => {
if let Ok(n) = value.parse::<i64>() {
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()));
},
)
}
Loading