Skip to content
Draft
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
158 changes: 117 additions & 41 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,6 +42,10 @@ pub struct Config {
pub speed: f64,
pub theme: Option<Theme>,
pub show_progress_bar: bool,
pub output_frames: bool,
pub output_filename: String,
pub start: Option<f64>,
pub end: Option<f64>,
}

impl Default for Config {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -129,7 +139,7 @@ impl Display for Theme {
}
}

pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) -> Result<()> {
pub fn run<I: BufRead>(input: I, config: Config) -> Result<()> {
let Asciicast { header, events, .. } = asciicast::open(input)?;

if header.term_cols == 0 || header.term_rows == 0 {
Expand All @@ -155,8 +165,40 @@ pub fn run<I: BufRead, O: Write + Send>(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::<Vec<_>>();
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);

Expand Down Expand Up @@ -188,50 +230,84 @@ pub fn run<I: BufRead, O: Write + Send>(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<dyn ProgressReporter> = 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",
Expand Down
36 changes: 29 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())]
Expand Down Expand Up @@ -105,6 +109,14 @@ struct Cli {
#[clap(long)]
rows: Option<usize>,

/// Start at specific time (seconds)
#[clap(long)]
start: Option<f64>,

/// End at specific time (seconds)
#[clap(long)]
end: Option<f64>,

/// Enable verbose logging
#[clap(short, long, action = ArgAction::Count)]
verbose: u8,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<avt::Line>, cursor: Option<(usize, usize)>) -> Pixmap;
fn render(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> ImgVec<RGBA8>;
fn pixel_size(&self) -> (usize, usize);
}
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/fontdue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<avt::Line>, 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<avt::Line>, cursor: Option<(usize, usize)>) -> ImgVec<RGBA8> {
let mut buf: Vec<RGBA8> =
vec![self.theme.background.alpha(255); self.pixel_width * self.pixel_height];
Expand Down
9 changes: 7 additions & 2 deletions src/renderer/resvg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ impl<'a> ResvgRenderer<'a> {
}

impl<'a> Renderer for ResvgRenderer<'a> {
fn render(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> ImgVec<RGBA8> {
fn render_pixmap(&mut self, lines: Vec<avt::Line>, 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());
Expand All @@ -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<avt::Line>, cursor: Option<(usize, usize)>) -> ImgVec<RGBA8> {
let buf = self.render_pixmap(lines, cursor).take().as_rgba().to_vec();

ImgVec::new(buf, self.pixel_width, self.pixel_height)
}
Expand Down