diff --git a/src/lib.rs b/src/lib.rs index 4b74472..d5cfa15 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,10 @@ pub struct Config { pub speed: f64, pub theme: Option, pub show_progress_bar: bool, + pub output_frames: bool, + pub output_filename: String, + pub start: Option, + pub end: Option, } impl Default for Config { @@ -59,6 +65,10 @@ impl Default for Config { speed: DEFAULT_SPEED, theme: Default::default(), show_progress_bar: true, + output_frames: false, + output_filename: "".to_string(), + start: None, + end: None, } } } @@ -129,7 +139,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 { @@ -155,8 +165,40 @@ pub fn run(input: I, output: O, config: Config) -> 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 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); @@ -188,50 +230,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(frame_count as u64)) + } 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(frame_count as u64); + 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..93d3a25 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())] @@ -105,6 +109,14 @@ struct Cli { #[clap(long)] rows: Option, + /// Start at specific time (seconds) + #[clap(long)] + start: Option, + + /// End at specific time (seconds) + #[clap(long)] + end: Option, + /// Enable verbose logging #[clap(short, long, action = ArgAction::Count)] verbose: u8, @@ -172,6 +184,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 +203,21 @@ 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(), + start: cli.start, + end: cli.end, }; - 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) }