use std::path::PathBuf;
use std::time::Duration;
use crate::style::Rect;
const RECORD_ENV: &str = "TUI_LIPAN_RECORD";
const VIEWPORT_ENV: &str = "TUI_LIPAN_RECORD_VIEWPORT";
const FPS_ENV: &str = "TUI_LIPAN_RECORD_FPS";
const KEYS_ENV: &str = "TUI_LIPAN_RECORD_KEYS";
const SCRIPT_ENV: &str = "TUI_LIPAN_RECORD_SCRIPT";
const KEY_DELAY_ENV: &str = "TUI_LIPAN_RECORD_KEY_DELAY_MS";
const SETTLE_ENV: &str = "TUI_LIPAN_RECORD_SETTLE_MS";
const FRAMES_ENV: &str = "TUI_LIPAN_RECORD_FRAMES";
const DEFAULT_VIEWPORT: Rect = Rect {
x: 0,
y: 0,
w: 100,
h: 30,
};
const DEFAULT_FPS: u16 = 30;
const DEFAULT_KEY_DELAY_MS: u64 = 400;
const DEFAULT_SETTLE_MS: u64 = 1200;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct HeadlessRecordConfig {
pub(super) path: PathBuf,
pub(super) viewport: Rect,
pub(super) fps: u16,
pub(super) actions: Vec<crate::ui_snapshot::Action>,
pub(super) key_delay: Duration,
pub(super) settle: Duration,
pub(super) frames_dir: Option<PathBuf>,
}
impl HeadlessRecordConfig {
pub(super) fn from_env() -> Option<crate::Result<Self>> {
let raw = std::env::var_os(RECORD_ENV)?;
if raw.is_empty() {
return None;
}
let actions = match crate::ui_snapshot::resolve_actions(
std::env::var(SCRIPT_ENV).ok().as_deref(),
std::env::var(KEYS_ENV).ok().as_deref(),
) {
Ok(actions) => actions,
Err(err) => return Some(Err(err)),
};
Some(Ok(Self {
path: PathBuf::from(raw),
viewport: env_viewport().unwrap_or(DEFAULT_VIEWPORT),
fps: env_u64(FPS_ENV)
.and_then(|fps| u16::try_from(fps).ok())
.unwrap_or(DEFAULT_FPS)
.max(1),
actions,
key_delay: Duration::from_millis(
env_u64(KEY_DELAY_ENV).unwrap_or(DEFAULT_KEY_DELAY_MS),
),
settle: Duration::from_millis(env_u64(SETTLE_ENV).unwrap_or(DEFAULT_SETTLE_MS)),
frames_dir: std::env::var_os(FRAMES_ENV)
.filter(|dir| !dir.is_empty())
.map(PathBuf::from),
}))
}
pub(super) fn step(&self) -> Duration {
Duration::from_secs_f64(1.0 / f64::from(self.fps))
}
}
fn env_viewport() -> Option<Rect> {
let raw = std::env::var(VIEWPORT_ENV).ok()?;
let (w, h) = raw.split_once(['x', 'X'])?;
let w: u16 = w.trim().parse().ok()?;
let h: u16 = h.trim().parse().ok()?;
if w == 0 || h == 0 {
return None;
}
Some(Rect { x: 0, y: 0, w, h })
}
fn env_u64(key: &str) -> Option<u64> {
std::env::var(key).ok()?.trim().parse().ok()
}
pub(super) struct FrameSink {
dir: Option<PathBuf>,
#[cfg(feature = "ui-snapshot-png")]
written: usize,
#[cfg(feature = "ui-snapshot-png")]
previous: Option<(crate::capture::CapturedFrame, Vec<u8>)>,
}
impl FrameSink {
pub(super) fn new(dir: Option<PathBuf>) -> crate::Result<Self> {
if let Some(dir) = dir.as_ref() {
std::fs::create_dir_all(dir)?;
}
Ok(Self {
dir,
#[cfg(feature = "ui-snapshot-png")]
written: 0,
#[cfg(feature = "ui-snapshot-png")]
previous: None,
})
}
#[cfg(feature = "ui-snapshot-png")]
pub(super) fn capture(&mut self, frame: &crate::capture::CapturedFrame) -> crate::Result<()> {
let Some(dir) = self.dir.as_ref() else {
return Ok(());
};
let bytes = match self.previous.as_ref() {
Some((last, bytes)) if last == frame => bytes.clone(),
_ => frame.to_png(&crate::capture::PngOptions::default())?,
};
std::fs::write(dir.join(format!("frame_{:05}.png", self.written)), &bytes)?;
self.previous = Some((frame.clone(), bytes));
self.written += 1;
Ok(())
}
#[cfg(not(feature = "ui-snapshot-png"))]
pub(super) fn capture(&mut self, _frame: &crate::capture::CapturedFrame) -> crate::Result<()> {
Ok(())
}
pub(super) fn report(&self, fps: u16) {
let Some(dir) = self.dir.as_ref() else {
return;
};
#[cfg(not(feature = "ui-snapshot-png"))]
{
let _ = fps;
eprintln!(
"warning: no frames written to {}; frame export needs `--features ui-snapshot-png`",
dir.display()
);
}
#[cfg(feature = "ui-snapshot-png")]
{
println!("wrote {} frames to {}", self.written, dir.display());
println!(
" ffmpeg -framerate {fps} -i {}/frame_%05d.png -pix_fmt yuv420p -movflags +faststart out.mp4",
dir.display()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(fps: u16) -> HeadlessRecordConfig {
HeadlessRecordConfig {
path: PathBuf::from("out.cast"),
viewport: DEFAULT_VIEWPORT,
fps,
actions: Vec::new(),
key_delay: Duration::from_millis(DEFAULT_KEY_DELAY_MS),
settle: Duration::from_millis(DEFAULT_SETTLE_MS),
frames_dir: None,
}
}
#[test]
fn a_sink_without_a_directory_is_inert() {
let mut sink = FrameSink::new(None).expect("inert sink");
let frame = crate::capture::CapturedFrame {
viewport: DEFAULT_VIEWPORT,
width: 1,
height: 1,
cells: vec![crate::capture::CapturedCell {
symbol: " ".to_owned(),
fg: crate::style::Color::Reset,
bg: crate::style::Color::Reset,
underline_color: crate::style::Color::Reset,
modifiers: crate::capture::CellModifiers::default(),
}],
cursor: None,
};
sink.capture(&frame).expect("inert capture succeeds");
sink.report(30);
}
#[test]
fn step_is_the_reciprocal_of_the_frame_rate() {
assert_eq!(config(30).step(), Duration::from_secs_f64(1.0 / 30.0));
assert_eq!(config(1).step(), Duration::from_secs(1));
}
}