Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
.direnv
/target
result
.DS_Store
12 changes: 12 additions & 0 deletions demo.cast

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/asciicast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::theme::Theme;

pub struct Asciicast<'a> {
pub header: Header,
pub events: Box<dyn Iterator<Item = Result<OutputEvent>> + 'a>,
pub events: Box<dyn Iterator<Item = Result<Event>> + 'a>,
}

pub struct Header {
Expand All @@ -20,6 +20,12 @@ pub struct Header {
pub idle_time_limit: Option<f64>,
}

#[derive(Debug, PartialEq)]
pub enum Event {
Output(f64, String),
Marker(f64, String),
}

pub type OutputEvent = (f64, String);

impl Default for Header {
Expand Down
4 changes: 2 additions & 2 deletions src/asciicast/v1.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::{bail, Result};
use serde::Deserialize;

use super::{Asciicast, Header};
use super::{Asciicast, Event, Header};

#[derive(Deserialize)]
struct V1 {
Expand Down Expand Up @@ -34,7 +34,7 @@ pub fn load(json: String) -> Result<Asciicast<'static>> {
let time = *prev_time + event.time;
*prev_time = time;

Some(Ok((time, event.data)))
Some(Ok(Event::Output(time, event.data)))
}));

Ok(Asciicast { header, events })
Expand Down
14 changes: 7 additions & 7 deletions src/asciicast/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::io;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Deserializer};

use super::{Asciicast, Header, Theme};
use super::{Asciicast, Event, Header, Theme};

#[derive(Deserialize)]
struct V2Header {
Expand Down Expand Up @@ -76,7 +76,7 @@ impl Parser {
}
}

fn parse_line(line: io::Result<String>) -> Option<Result<(f64, String)>> {
fn parse_line(line: io::Result<String>) -> Option<Result<Event>> {
match line {
Ok(line) => {
if line.is_empty() {
Expand All @@ -90,13 +90,13 @@ fn parse_line(line: io::Result<String>) -> Option<Result<(f64, String)>> {
}
}

fn parse_event(line: String) -> Result<Option<(f64, String)>> {
fn parse_event(line: String) -> Result<Option<Event>> {
let event = serde_json::from_str::<V2Event>(&line).context("asciicast parse error")?;

let output = if let V2EventCode::Output = event.code {
Some((event.time, event.data))
} else {
None
let output = match event.code {
V2EventCode::Output => Some(Event::Output(event.time, event.data)),
V2EventCode::Marker => Some(Event::Marker(event.time, event.data)),
_ => None,
};

Ok(output)
Expand Down
14 changes: 7 additions & 7 deletions src/asciicast/v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::io;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Deserializer};

use super::{Asciicast, Header, Theme};
use super::{Asciicast, Event, Header, Theme};

#[derive(Deserialize)]
struct V3Header {
Expand Down Expand Up @@ -90,7 +90,7 @@ impl Parser {
Asciicast { header, events }
}

fn parse_line(&mut self, line: io::Result<String>) -> Option<Result<(f64, String)>> {
fn parse_line(&mut self, line: io::Result<String>) -> Option<Result<Event>> {
match line {
Ok(line) => {
if line.is_empty() || line.starts_with('#') {
Expand All @@ -104,16 +104,16 @@ impl Parser {
}
}

fn parse_event(&mut self, line: String) -> Result<Option<(f64, String)>> {
fn parse_event(&mut self, line: String) -> Result<Option<Event>> {
let event = serde_json::from_str::<V3Event>(&line).context("asciicast parse error")?;

let time = self.prev_time + event.time;
self.prev_time = time;

let output = if let V3EventCode::Output = event.code {
Some((time, event.data))
} else {
None
let output = match event.code {
V3EventCode::Output => Some(Event::Output(time, event.data)),
V3EventCode::Marker => Some(Event::Marker(time, event.data)),
_ => None,
};

Ok(output)
Expand Down
123 changes: 104 additions & 19 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod renderer;
mod theme;
mod vt;

use std::collections::HashMap;
use std::fmt::{Debug, Display};
use std::io::{BufRead, Write};
use std::{iter, thread, time::Instant};
Expand All @@ -13,7 +14,8 @@ use anyhow::{anyhow, Result};
use clap::ArgEnum;
use log::info;

use crate::asciicast::Asciicast;
use crate::asciicast::{Asciicast, Event, Header};
use crate::renderer::Renderer as _;

pub const DEFAULT_FONT_FAMILY: &str =
"JetBrains Mono,Fira Code,SF Mono,Menlo,Consolas,DejaVu Sans Mono,Liberation Mono";
Expand All @@ -26,6 +28,8 @@ pub const DEFAULT_SPEED: f64 = 1.0;
pub const DEFAULT_IDLE_TIME_LIMIT: f64 = 5.0;

pub struct Config {
pub width: Option<usize>,
pub height: Option<usize>,
pub cols: Option<usize>,
pub font_dirs: Vec<String>,
pub font_family: String,
Expand All @@ -40,11 +44,14 @@ pub struct Config {
pub speed: f64,
pub theme: Option<Theme>,
pub show_progress_bar: bool,
pub fill_background: bool,
}

impl Default for Config {
fn default() -> Self {
Self {
width: None,
height: None,
cols: None,
font_dirs: vec![],
font_family: String::from(DEFAULT_FONT_FAMILY),
Expand All @@ -59,6 +66,7 @@ impl Default for Config {
speed: DEFAULT_SPEED,
theme: Default::default(),
show_progress_bar: true,
fill_background: true,
}
}
}
Expand All @@ -70,6 +78,16 @@ pub enum Renderer {
Fontdue,
}

#[derive(Clone, Debug, ArgEnum, Default)]
pub enum OutputMode {
/// Write a single animated GIF of the entire input
#[default]
AnimatedGif,

/// Write a snapshot PNG of each marker in the input
SnapshotMarkers,
}

#[derive(Clone, Debug, ArgEnum, Default)]
pub enum Theme {
Asciinema,
Expand Down Expand Up @@ -129,9 +147,7 @@ impl Display for Theme {
}
}

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

fn renderer_settings(header: &Header, config: &Config) -> Result<renderer::Settings> {
if header.term_cols == 0 || header.term_rows == 0 {
return Err(anyhow!(
"the recording has invalid terminal size: {}x{}",
Expand All @@ -145,19 +161,6 @@ pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) ->
config.rows.unwrap_or(header.term_rows as usize),
);

let itl = config
.idle_time_limit
.or(header.idle_time_limit)
.unwrap_or(DEFAULT_IDLE_TIME_LIMIT);

let events = iter::once(Ok((0.0, "".to_owned()))).chain(events);
let events = events::limit_idle_time(events, itl);
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);

info!("terminal size: {}x{}", terminal_size.0, terminal_size.1);

let (font_db, font_families) = fonts::init(&config.font_dirs, &config.font_family)
Expand All @@ -167,7 +170,8 @@ pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) ->

let theme_opt = config
.theme
.or_else(|| header.term_theme.map(Theme::Embedded))
.clone()
.or_else(|| header.term_theme.clone().map(Theme::Embedded))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clones are needed because this is now accessing config and header through shared refs, previously it moved out of them.

.unwrap_or(Theme::Dracula);

info!("selected theme: {}", theme_opt);
Expand All @@ -179,8 +183,17 @@ pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) ->
font_size: config.font_size,
line_height: config.line_height,
theme: theme_opt.try_into()?,
pixel_width: config.width,
pixel_height: config.height,
fill_background: config.fill_background,
};
Ok(settings)
}

pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) -> Result<()> {
let Asciicast { header, events } = asciicast::open(input)?;
let settings = renderer_settings(&header, &config)?;
let terminal_size = settings.terminal_size;
let mut renderer: Box<dyn renderer::Renderer> = match config.renderer {
Renderer::Fontdue => Box::new(renderer::fontdue(settings)),
Renderer::Resvg => Box::new(renderer::resvg(settings)),
Expand All @@ -190,6 +203,24 @@ pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) ->

info!("gif dimensions: {}x{}", width, height);

let itl = config
.idle_time_limit
.or(header.idle_time_limit)
.unwrap_or(DEFAULT_IDLE_TIME_LIMIT);

let events = events.into_iter().filter_map(|event| match event {
Ok(Event::Output(time, data)) => Some(Ok((time, data))),
Ok(Event::Marker(..)) => None,
Err(e) => Some(Err(e)),
});
let events = iter::once(Ok((0.0, "".to_owned()))).chain(events);
let events = events::limit_idle_time(events, itl);
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);

let repeat = if config.no_loop {
gifski::Repeat::Finite(0)
} else {
Expand Down Expand Up @@ -223,7 +254,7 @@ pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) ->

for (i, frame) in frames.enumerate() {
let (time, lines, cursor) = frame?;
let image = renderer.render(lines, cursor);
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)?;
}
Expand All @@ -240,3 +271,57 @@ pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) ->

Ok(())
}

pub fn write_snapshots<I: BufRead>(input: I, snapshots_path: &str, config: Config) -> Result<()> {
let Asciicast { header, events } = asciicast::open(input)?;
let settings = renderer_settings(&header, &config)?;
let terminal_size = settings.terminal_size;
let renderer = renderer::resvg(settings);

let (width, height) = renderer.pixel_size();

info!("snapshot dimensions: {}x{}", width, height);

let mut vt = avt::Vt::builder()
.size(terminal_size.0, terminal_size.1)
.scrollback_limit(0)
.build();

let mut label_counters: HashMap<String, u32> = HashMap::new();

for event in events {
match event? {
Event::Output(_time, data) => {
vt.feed_str(&data);
}
Event::Marker(_time, label) => {
let label = sanitize(&label);
let counter = label_counters.entry(label.clone()).or_insert(0);
let name = if label.is_empty() { "marker" } else { &label };
let filename = if *counter == 0 {
format!("{snapshots_path}/{name}")
} else {
format!("{snapshots_path}/{name}({counter:03})")
};
*counter += 1;

let lines = vt.view();
let cursor: Option<(usize, usize)> = vt.cursor().into();
info!("rendering {}.svg, {}.png, {}.txt", filename, filename, filename);
use std::fs;
let svg = renderer.render_svg(lines, cursor);
let pixmap = renderer.render_pixmap(&svg);
fs::write(format!("{}.txt", filename), vt.dump())?;
fs::write(format!("{}.svg", filename), &svg)?;
pixmap.save_png(format!("{}.png", filename))?;
}
}
}
Ok(())
}

fn sanitize(s: &str) -> String {
s.chars()
.filter(|c| matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | ' ' | '.'))
.collect()
}
Loading