From b08668f9751d824de9c31bebd5a3563a985a8e15 Mon Sep 17 00:00:00 2001 From: sandorex Date: Fri, 10 Apr 2026 16:53:40 +0200 Subject: [PATCH 1/3] Add ability to export frames instead of GIF --- src/lib.rs | 118 +++++++++++++++++++++++++++------------- src/main.rs | 26 ++++++--- src/renderer.rs | 2 + src/renderer/fontdue.rs | 14 +++++ src/renderer/resvg.rs | 9 ++- 5 files changed, 121 insertions(+), 48 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4b74472..cceaa9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,11 +6,13 @@ mod theme; mod vt; use std::fmt::{Debug, Display}; -use std::io::{BufRead, Write}; +use std::fs::File; +use std::io::BufRead; use std::{iter, thread, time::Instant}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use clap::ArgEnum; +use gifski::progress::{NoProgress, ProgressBar, ProgressReporter}; use log::info; use crate::asciicast::Asciicast; @@ -40,6 +42,8 @@ pub struct Config { pub speed: f64, pub theme: Option, pub show_progress_bar: bool, + pub output_frames: bool, + pub output_filename: String, } impl Default for Config { @@ -59,6 +63,8 @@ impl Default for Config { speed: DEFAULT_SPEED, theme: Default::default(), show_progress_bar: true, + output_frames: false, + output_filename: "".to_string(), } } } @@ -129,7 +135,7 @@ impl Display for Theme { } } -pub fn run(input: I, output: O, config: Config) -> Result<()> { +pub fn run(input: I, config: Config) -> Result<()> { let Asciicast { header, events, .. } = asciicast::open(input)?; if header.term_cols == 0 || header.term_rows == 0 { @@ -188,50 +194,84 @@ pub fn run(input: I, output: O, config: Config) -> let (width, height) = renderer.pixel_size(); - info!("gif dimensions: {}x{}", width, height); + info!("output dimensions: {}x{}", width, height); - let repeat = if config.no_loop { - gifski::Repeat::Finite(0) - } else { - gifski::Repeat::Infinite - }; + let start_time = Instant::now(); - let settings = gifski::Settings { - width: Some(width as u32), - height: Some(height as u32), - fast: true, - repeat, - ..Default::default() - }; + if config.output_frames { + let dir_path = std::path::PathBuf::from(&config.output_filename); - let (collector, writer) = gifski::new(settings)?; - let start_time = Instant::now(); + // create directory if it does not already exist + if !dir_path.try_exists()? { + std::fs::create_dir(&dir_path)?; + } - thread::scope(|s| { - let writer_handle = s.spawn(move || { - if config.show_progress_bar { - let mut pr = gifski::progress::ProgressBar::new(count); - let result = writer.write(output, &mut pr); - pr.finish(); - println!(); - result - } else { - let mut pr = gifski::progress::NoProgress {}; - writer.write(output, &mut pr) - } - }); + // make sure output directory is empty + if !dir_path.read_dir()?.next().is_none() { + return Err(anyhow!("Output directory is not empty")); + } + + let mut pr: Box = if config.show_progress_bar { + Box::new(ProgressBar::new(count)) + } else { + Box::new(NoProgress {}) + }; for (i, frame) in frames.enumerate() { - let (time, lines, cursor) = frame?; - let image = renderer.render(lines, cursor); - let time = if i == 0 { 0.0 } else { time }; - collector.add_frame_rgba(i, image, time + config.last_frame_duration)?; + let (_, lines, cursor) = frame?; + + let svg = renderer.render_pixmap(lines, cursor); + svg.save_png(dir_path.join(format!("{}.png", i))) + .with_context(|| anyhow!("Could not encode frame {i}"))?; + + if !pr.increase() { + return Err(anyhow!("Progress bar interrupted")); + } } + } else { + let repeat = if config.no_loop { + gifski::Repeat::Finite(0) + } else { + gifski::Repeat::Infinite + }; + + let settings = gifski::Settings { + width: Some(width as u32), + height: Some(height as u32), + fast: true, + repeat, + ..Default::default() + }; + + let (collector, writer) = gifski::new(settings)?; + let output = File::create(&config.output_filename)?; + + thread::scope(|s| { + let writer_handle = s.spawn(move || { + if config.show_progress_bar { + let mut pr = gifski::progress::ProgressBar::new(count); + let result = writer.write(output, &mut pr); + pr.finish(); + println!(); + result + } else { + let mut pr = gifski::progress::NoProgress {}; + writer.write(output, &mut pr) + } + }); + + for (i, frame) in frames.enumerate() { + let (time, lines, cursor) = frame?; + let image = renderer.render(lines, cursor); + let time = if i == 0 { 0.0 } else { time }; + collector.add_frame_rgba(i, image, time + config.last_frame_duration)?; + } - drop(collector); - writer_handle.join().unwrap()?; - Result::<()>::Ok(()) - })?; + drop(collector); + writer_handle.join().unwrap()?; + Result::<()>::Ok(()) + })?; + } info!( "rendering finished in {}s", diff --git a/src/main.rs b/src/main.rs index e225ac3..27188e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,10 +48,14 @@ impl clap::builder::TypedValueParser for ThemeValueParser { #[clap(author, version, about, long_about = None)] struct Cli { /// asciicast path/filename or URL - input_filename_or_url: String, + #[clap(value_name = "INPUT_FILENAME_OR_URL")] + input: String, /// GIF path/filename - output_filename: String, + /// + /// If path ends with a slash or is a directory frames are saved instead + #[clap(value_name = "OUTPUT_FILENAME_OR_DIR")] + output: String, /// Select frame rendering backend #[clap(long, arg_enum, default_value_t = agg::Renderer::default())] @@ -172,6 +176,10 @@ fn main() -> Result<()> { .format_timestamp(None) .init(); + // output frames if path ends with a path separator or is a directory + let output_frames = cli.output.ends_with(std::path::MAIN_SEPARATOR_STR) + || std::fs::metadata(&cli.output).is_ok_and(|x| x.is_dir()); + let config = agg::Config { cols: cli.cols, font_dirs: cli.font_dir, @@ -187,15 +195,19 @@ fn main() -> Result<()> { speed: cli.speed, theme: cli.theme.map(|theme| theme.0), show_progress_bar: !cli.quiet, + output_frames: output_frames, + output_filename: cli.output.clone(), }; - let input = BufReader::new(reader(&cli.input_filename_or_url)?); - let mut output = File::create(&cli.output_filename)?; - - match agg::run(input, &mut output, config) { + let input = BufReader::new(reader(&cli.input)?); + match agg::run(input, config) { Ok(ok) => Ok(ok), Err(err) => { - std::fs::remove_file(cli.output_filename)?; + // do not try to delete a directory + if !output_frames { + std::fs::remove_file(&cli.output)?; + } + Err(err) } } diff --git a/src/renderer.rs b/src/renderer.rs index 5d386e8..4b85cd4 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -3,10 +3,12 @@ mod resvg; use imgref::ImgVec; use rgb::{RGB8, RGBA8}; +use tiny_skia::Pixmap; use crate::theme::Theme; pub trait Renderer { + fn render_pixmap(&mut self, lines: Vec, cursor: Option<(usize, usize)>) -> Pixmap; fn render(&mut self, lines: Vec, cursor: Option<(usize, usize)>) -> ImgVec; fn pixel_size(&self) -> (usize, usize); } diff --git a/src/renderer/fontdue.rs b/src/renderer/fontdue.rs index 4a4fd34..3e35222 100644 --- a/src/renderer/fontdue.rs +++ b/src/renderer/fontdue.rs @@ -3,6 +3,7 @@ use crate::theme::Theme; use imgref::ImgVec; use log::debug; use rgb::RGBA8; +use tiny_skia::{PremultipliedColorU8, Pixmap}; use std::collections::HashMap; type CharVariant = (char, bool, bool); @@ -167,6 +168,19 @@ fn mix_colors(fg: RGBA8, bg: RGBA8, ratio: u8) -> RGBA8 { } impl Renderer for FontdueRenderer { + fn render_pixmap(&mut self, lines: Vec, cursor: Option<(usize, usize)>) -> tiny_skia::Pixmap { + let buf = self.render(lines, cursor); + let mut pixmap = Pixmap::new(buf.width() as u32, buf.height() as u32).unwrap(); + + // TODO could this be done more efficiently as it is currently copying all the data + let pixels = pixmap.pixels_mut(); + for (i, color) in buf.pixels().enumerate() { + pixels[i] = PremultipliedColorU8::from_rgba(color.r, color.g, color.b, color.a).unwrap(); + } + + pixmap + } + fn render(&mut self, lines: Vec, cursor: Option<(usize, usize)>) -> ImgVec { let mut buf: Vec = vec![self.theme.background.alpha(255); self.pixel_width * self.pixel_height]; diff --git a/src/renderer/resvg.rs b/src/renderer/resvg.rs index ecab714..d883916 100644 --- a/src/renderer/resvg.rs +++ b/src/renderer/resvg.rs @@ -241,7 +241,7 @@ impl<'a> ResvgRenderer<'a> { } impl<'a> Renderer for ResvgRenderer<'a> { - fn render(&mut self, lines: Vec, cursor: Option<(usize, usize)>) -> ImgVec { + fn render_pixmap(&mut self, lines: Vec, cursor: Option<(usize, usize)>) -> tiny_skia::Pixmap { let mut svg = self.header.clone(); self.push_lines(&mut svg, lines, cursor); svg.push_str(Self::footer()); @@ -251,7 +251,12 @@ impl<'a> Renderer for ResvgRenderer<'a> { tiny_skia::Pixmap::new(self.pixel_width as u32, self.pixel_height as u32).unwrap(); resvg::render(&tree, self.transform, &mut pixmap.as_mut()); - let buf = pixmap.take().as_rgba().to_vec(); + + pixmap + } + + fn render(&mut self, lines: Vec, cursor: Option<(usize, usize)>) -> ImgVec { + let buf = self.render_pixmap(lines, cursor).take().as_rgba().to_vec(); ImgVec::new(buf, self.pixel_width, self.pixel_height) } From 8512e84aebed5383cc662c3cdc587be2c7fa1442 Mon Sep 17 00:00:00 2001 From: sandorex Date: Fri, 10 Apr 2026 19:38:43 +0200 Subject: [PATCH 2/3] Add --start and --end options to export segments --- src/lib.rs | 30 +++++++++++++++++++++++++----- src/main.rs | 10 ++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cceaa9d..4d75cb2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,8 @@ pub struct Config { pub show_progress_bar: bool, pub output_frames: bool, pub output_filename: String, + pub start: Option, + pub end: Option, } impl Default for Config { @@ -65,6 +67,8 @@ impl Default for Config { show_progress_bar: true, output_frames: false, output_filename: "".to_string(), + start: None, + end: None, } } } @@ -161,7 +165,7 @@ pub fn run(input: I, config: Config) -> Result<()> { let events = events::accelerate(events, config.speed); let events = events::batch(events, config.fps_cap); let events = events.collect::>(); - let count = events.len() as u64; + let mut frame_count = events.len() as u64; let frames = vt::frames(events.into_iter(), terminal_size); info!("terminal size: {}x{}", terminal_size.0, terminal_size.1); @@ -196,6 +200,22 @@ pub fn run(input: I, config: Config) -> Result<()> { info!("output dimensions: {}x{}", width, height); + let start = config.start.unwrap_or(0); + let end = config.end + .unwrap_or(frame_count as usize); + + if end > (frame_count as usize) { + return Err(anyhow!("End frame is past the last frame ({end} > {frame_count})")); + } + + if start >= end { + return Err(anyhow!("Output segment is invalid ({start} - {end})")); + } + + frame_count = (end - start) as u64; + + info!("output segment: {start} - {end}"); + let start_time = Instant::now(); if config.output_frames { @@ -212,12 +232,12 @@ pub fn run(input: I, config: Config) -> Result<()> { } let mut pr: Box = if config.show_progress_bar { - Box::new(ProgressBar::new(count)) + Box::new(ProgressBar::new(frame_count)) } else { Box::new(NoProgress {}) }; - for (i, frame) in frames.enumerate() { + for (i, frame) in frames.enumerate().skip(start).take(frame_count as usize) { let (_, lines, cursor) = frame?; let svg = renderer.render_pixmap(lines, cursor); @@ -249,7 +269,7 @@ pub fn run(input: I, config: Config) -> Result<()> { thread::scope(|s| { let writer_handle = s.spawn(move || { if config.show_progress_bar { - let mut pr = gifski::progress::ProgressBar::new(count); + let mut pr = gifski::progress::ProgressBar::new(frame_count); let result = writer.write(output, &mut pr); pr.finish(); println!(); @@ -260,7 +280,7 @@ pub fn run(input: I, config: Config) -> Result<()> { } }); - for (i, frame) in frames.enumerate() { + for (i, frame) in frames.enumerate().skip(start).take(frame_count as usize) { let (time, lines, cursor) = frame?; let image = renderer.render(lines, cursor); let time = if i == 0 { 0.0 } else { time }; diff --git a/src/main.rs b/src/main.rs index 27188e8..5513c36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,6 +109,14 @@ struct Cli { #[clap(long)] rows: Option, + /// Start at frame + #[clap(long)] + start: Option, + + /// End at frame + #[clap(long)] + end: Option, + /// Enable verbose logging #[clap(short, long, action = ArgAction::Count)] verbose: u8, @@ -197,6 +205,8 @@ fn main() -> Result<()> { show_progress_bar: !cli.quiet, output_frames: output_frames, output_filename: cli.output.clone(), + start: cli.start, + end: cli.end, }; let input = BufReader::new(reader(&cli.input)?); From 64f5dc3c137fd404a675786db76d9f6a68856ad6 Mon Sep 17 00:00:00 2001 From: sandorex Date: Sat, 11 Apr 2026 11:01:55 +0200 Subject: [PATCH 3/3] Select segment in seconds instead of frames --- src/lib.rs | 64 +++++++++++++++++++++++++++++++++-------------------- src/main.rs | 8 +++---- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4d75cb2..d5cfa15 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,8 +44,8 @@ pub struct Config { pub show_progress_bar: bool, pub output_frames: bool, pub output_filename: String, - pub start: Option, - pub end: Option, + pub start: Option, + pub end: Option, } impl Default for Config { @@ -165,8 +165,40 @@ pub fn run(input: I, config: Config) -> Result<()> { let events = events::accelerate(events, config.speed); let events = events::batch(events, config.fps_cap); let events = events.collect::>(); - let mut frame_count = events.len() as u64; - let frames = vt::frames(events.into_iter(), terminal_size); + + match (config.start, config.end) { + (Some(x), Some(y)) if x >= y => return Err(anyhow!("End time is before or equal to the start time ({x} - {y})")), + _ => {}, + } + + // find last frame if end is specified + let frame_count = if let Some(end) = config.end { + match events.iter().position(|x| x.as_ref().is_ok_and(|(time, _)| *time > end)) { + Some(x) => x, + // assume last frame + None => events.len(), + } + } else { + events.len() + }; + + let start = if let Some(start) = config.start { + match events.iter().position(|x| x.as_ref().is_ok_and(|(time, _)| *time > start)) { + Some(x) => x, + None => 0usize, + } + } else { + 0usize + }; + + // take only the specified frames + let frames = vt::frames(events.into_iter().take(frame_count), terminal_size); + + // skip frames that dont need to be rendered + let frames = frames.skip(start); + + // subtract start so progress bar shows accurate number of frames + let frame_count = frame_count - start; info!("terminal size: {}x{}", terminal_size.0, terminal_size.1); @@ -200,22 +232,6 @@ pub fn run(input: I, config: Config) -> Result<()> { info!("output dimensions: {}x{}", width, height); - let start = config.start.unwrap_or(0); - let end = config.end - .unwrap_or(frame_count as usize); - - if end > (frame_count as usize) { - return Err(anyhow!("End frame is past the last frame ({end} > {frame_count})")); - } - - if start >= end { - return Err(anyhow!("Output segment is invalid ({start} - {end})")); - } - - frame_count = (end - start) as u64; - - info!("output segment: {start} - {end}"); - let start_time = Instant::now(); if config.output_frames { @@ -232,12 +248,12 @@ pub fn run(input: I, config: Config) -> Result<()> { } let mut pr: Box = if config.show_progress_bar { - Box::new(ProgressBar::new(frame_count)) + Box::new(ProgressBar::new(frame_count as u64)) } else { Box::new(NoProgress {}) }; - for (i, frame) in frames.enumerate().skip(start).take(frame_count as usize) { + for (i, frame) in frames.enumerate() { let (_, lines, cursor) = frame?; let svg = renderer.render_pixmap(lines, cursor); @@ -269,7 +285,7 @@ pub fn run(input: I, config: Config) -> Result<()> { thread::scope(|s| { let writer_handle = s.spawn(move || { if config.show_progress_bar { - let mut pr = gifski::progress::ProgressBar::new(frame_count); + let mut pr = gifski::progress::ProgressBar::new(frame_count as u64); let result = writer.write(output, &mut pr); pr.finish(); println!(); @@ -280,7 +296,7 @@ pub fn run(input: I, config: Config) -> Result<()> { } }); - for (i, frame) in frames.enumerate().skip(start).take(frame_count as usize) { + for (i, frame) in frames.enumerate() { let (time, lines, cursor) = frame?; let image = renderer.render(lines, cursor); let time = if i == 0 { 0.0 } else { time }; diff --git a/src/main.rs b/src/main.rs index 5513c36..93d3a25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,13 +109,13 @@ struct Cli { #[clap(long)] rows: Option, - /// Start at frame + /// Start at specific time (seconds) #[clap(long)] - start: Option, + start: Option, - /// End at frame + /// End at specific time (seconds) #[clap(long)] - end: Option, + end: Option, /// Enable verbose logging #[clap(short, long, action = ArgAction::Count)]