Skip to content

Commit b08668f

Browse files
committed
Add ability to export frames instead of GIF
1 parent 489de4d commit b08668f

5 files changed

Lines changed: 121 additions & 48 deletions

File tree

src/lib.rs

Lines changed: 79 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ mod theme;
66
mod vt;
77

88
use std::fmt::{Debug, Display};
9-
use std::io::{BufRead, Write};
9+
use std::fs::File;
10+
use std::io::BufRead;
1011
use std::{iter, thread, time::Instant};
1112

12-
use anyhow::{anyhow, Result};
13+
use anyhow::{anyhow, Context, Result};
1314
use clap::ArgEnum;
15+
use gifski::progress::{NoProgress, ProgressBar, ProgressReporter};
1416
use log::info;
1517

1618
use crate::asciicast::Asciicast;
@@ -40,6 +42,8 @@ pub struct Config {
4042
pub speed: f64,
4143
pub theme: Option<Theme>,
4244
pub show_progress_bar: bool,
45+
pub output_frames: bool,
46+
pub output_filename: String,
4347
}
4448

4549
impl Default for Config {
@@ -59,6 +63,8 @@ impl Default for Config {
5963
speed: DEFAULT_SPEED,
6064
theme: Default::default(),
6165
show_progress_bar: true,
66+
output_frames: false,
67+
output_filename: "".to_string(),
6268
}
6369
}
6470
}
@@ -129,7 +135,7 @@ impl Display for Theme {
129135
}
130136
}
131137

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

135141
if header.term_cols == 0 || header.term_rows == 0 {
@@ -188,50 +194,84 @@ pub fn run<I: BufRead, O: Write + Send>(input: I, output: O, config: Config) ->
188194

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

191-
info!("gif dimensions: {}x{}", width, height);
197+
info!("output dimensions: {}x{}", width, height);
192198

193-
let repeat = if config.no_loop {
194-
gifski::Repeat::Finite(0)
195-
} else {
196-
gifski::Repeat::Infinite
197-
};
199+
let start_time = Instant::now();
198200

199-
let settings = gifski::Settings {
200-
width: Some(width as u32),
201-
height: Some(height as u32),
202-
fast: true,
203-
repeat,
204-
..Default::default()
205-
};
201+
if config.output_frames {
202+
let dir_path = std::path::PathBuf::from(&config.output_filename);
206203

207-
let (collector, writer) = gifski::new(settings)?;
208-
let start_time = Instant::now();
204+
// create directory if it does not already exist
205+
if !dir_path.try_exists()? {
206+
std::fs::create_dir(&dir_path)?;
207+
}
209208

210-
thread::scope(|s| {
211-
let writer_handle = s.spawn(move || {
212-
if config.show_progress_bar {
213-
let mut pr = gifski::progress::ProgressBar::new(count);
214-
let result = writer.write(output, &mut pr);
215-
pr.finish();
216-
println!();
217-
result
218-
} else {
219-
let mut pr = gifski::progress::NoProgress {};
220-
writer.write(output, &mut pr)
221-
}
222-
});
209+
// make sure output directory is empty
210+
if !dir_path.read_dir()?.next().is_none() {
211+
return Err(anyhow!("Output directory is not empty"));
212+
}
213+
214+
let mut pr: Box<dyn ProgressReporter> = if config.show_progress_bar {
215+
Box::new(ProgressBar::new(count))
216+
} else {
217+
Box::new(NoProgress {})
218+
};
223219

224220
for (i, frame) in frames.enumerate() {
225-
let (time, lines, cursor) = frame?;
226-
let image = renderer.render(lines, cursor);
227-
let time = if i == 0 { 0.0 } else { time };
228-
collector.add_frame_rgba(i, image, time + config.last_frame_duration)?;
221+
let (_, lines, cursor) = frame?;
222+
223+
let svg = renderer.render_pixmap(lines, cursor);
224+
svg.save_png(dir_path.join(format!("{}.png", i)))
225+
.with_context(|| anyhow!("Could not encode frame {i}"))?;
226+
227+
if !pr.increase() {
228+
return Err(anyhow!("Progress bar interrupted"));
229+
}
229230
}
231+
} else {
232+
let repeat = if config.no_loop {
233+
gifski::Repeat::Finite(0)
234+
} else {
235+
gifski::Repeat::Infinite
236+
};
237+
238+
let settings = gifski::Settings {
239+
width: Some(width as u32),
240+
height: Some(height as u32),
241+
fast: true,
242+
repeat,
243+
..Default::default()
244+
};
245+
246+
let (collector, writer) = gifski::new(settings)?;
247+
let output = File::create(&config.output_filename)?;
248+
249+
thread::scope(|s| {
250+
let writer_handle = s.spawn(move || {
251+
if config.show_progress_bar {
252+
let mut pr = gifski::progress::ProgressBar::new(count);
253+
let result = writer.write(output, &mut pr);
254+
pr.finish();
255+
println!();
256+
result
257+
} else {
258+
let mut pr = gifski::progress::NoProgress {};
259+
writer.write(output, &mut pr)
260+
}
261+
});
262+
263+
for (i, frame) in frames.enumerate() {
264+
let (time, lines, cursor) = frame?;
265+
let image = renderer.render(lines, cursor);
266+
let time = if i == 0 { 0.0 } else { time };
267+
collector.add_frame_rgba(i, image, time + config.last_frame_duration)?;
268+
}
230269

231-
drop(collector);
232-
writer_handle.join().unwrap()?;
233-
Result::<()>::Ok(())
234-
})?;
270+
drop(collector);
271+
writer_handle.join().unwrap()?;
272+
Result::<()>::Ok(())
273+
})?;
274+
}
235275

236276
info!(
237277
"rendering finished in {}s",

src/main.rs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,14 @@ impl clap::builder::TypedValueParser for ThemeValueParser {
4848
#[clap(author, version, about, long_about = None)]
4949
struct Cli {
5050
/// asciicast path/filename or URL
51-
input_filename_or_url: String,
51+
#[clap(value_name = "INPUT_FILENAME_OR_URL")]
52+
input: String,
5253

5354
/// GIF path/filename
54-
output_filename: String,
55+
///
56+
/// If path ends with a slash or is a directory frames are saved instead
57+
#[clap(value_name = "OUTPUT_FILENAME_OR_DIR")]
58+
output: String,
5559

5660
/// Select frame rendering backend
5761
#[clap(long, arg_enum, default_value_t = agg::Renderer::default())]
@@ -172,6 +176,10 @@ fn main() -> Result<()> {
172176
.format_timestamp(None)
173177
.init();
174178

179+
// output frames if path ends with a path separator or is a directory
180+
let output_frames = cli.output.ends_with(std::path::MAIN_SEPARATOR_STR)
181+
|| std::fs::metadata(&cli.output).is_ok_and(|x| x.is_dir());
182+
175183
let config = agg::Config {
176184
cols: cli.cols,
177185
font_dirs: cli.font_dir,
@@ -187,15 +195,19 @@ fn main() -> Result<()> {
187195
speed: cli.speed,
188196
theme: cli.theme.map(|theme| theme.0),
189197
show_progress_bar: !cli.quiet,
198+
output_frames: output_frames,
199+
output_filename: cli.output.clone(),
190200
};
191201

192-
let input = BufReader::new(reader(&cli.input_filename_or_url)?);
193-
let mut output = File::create(&cli.output_filename)?;
194-
195-
match agg::run(input, &mut output, config) {
202+
let input = BufReader::new(reader(&cli.input)?);
203+
match agg::run(input, config) {
196204
Ok(ok) => Ok(ok),
197205
Err(err) => {
198-
std::fs::remove_file(cli.output_filename)?;
206+
// do not try to delete a directory
207+
if !output_frames {
208+
std::fs::remove_file(&cli.output)?;
209+
}
210+
199211
Err(err)
200212
}
201213
}

src/renderer.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ mod resvg;
33

44
use imgref::ImgVec;
55
use rgb::{RGB8, RGBA8};
6+
use tiny_skia::Pixmap;
67

78
use crate::theme::Theme;
89

910
pub trait Renderer {
11+
fn render_pixmap(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> Pixmap;
1012
fn render(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> ImgVec<RGBA8>;
1113
fn pixel_size(&self) -> (usize, usize);
1214
}

src/renderer/fontdue.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::theme::Theme;
33
use imgref::ImgVec;
44
use log::debug;
55
use rgb::RGBA8;
6+
use tiny_skia::{PremultipliedColorU8, Pixmap};
67
use std::collections::HashMap;
78

89
type CharVariant = (char, bool, bool);
@@ -167,6 +168,19 @@ fn mix_colors(fg: RGBA8, bg: RGBA8, ratio: u8) -> RGBA8 {
167168
}
168169

169170
impl Renderer for FontdueRenderer {
171+
fn render_pixmap(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> tiny_skia::Pixmap {
172+
let buf = self.render(lines, cursor);
173+
let mut pixmap = Pixmap::new(buf.width() as u32, buf.height() as u32).unwrap();
174+
175+
// TODO could this be done more efficiently as it is currently copying all the data
176+
let pixels = pixmap.pixels_mut();
177+
for (i, color) in buf.pixels().enumerate() {
178+
pixels[i] = PremultipliedColorU8::from_rgba(color.r, color.g, color.b, color.a).unwrap();
179+
}
180+
181+
pixmap
182+
}
183+
170184
fn render(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> ImgVec<RGBA8> {
171185
let mut buf: Vec<RGBA8> =
172186
vec![self.theme.background.alpha(255); self.pixel_width * self.pixel_height];

src/renderer/resvg.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ impl<'a> ResvgRenderer<'a> {
241241
}
242242

243243
impl<'a> Renderer for ResvgRenderer<'a> {
244-
fn render(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> ImgVec<RGBA8> {
244+
fn render_pixmap(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> tiny_skia::Pixmap {
245245
let mut svg = self.header.clone();
246246
self.push_lines(&mut svg, lines, cursor);
247247
svg.push_str(Self::footer());
@@ -251,7 +251,12 @@ impl<'a> Renderer for ResvgRenderer<'a> {
251251
tiny_skia::Pixmap::new(self.pixel_width as u32, self.pixel_height as u32).unwrap();
252252

253253
resvg::render(&tree, self.transform, &mut pixmap.as_mut());
254-
let buf = pixmap.take().as_rgba().to_vec();
254+
255+
pixmap
256+
}
257+
258+
fn render(&mut self, lines: Vec<avt::Line>, cursor: Option<(usize, usize)>) -> ImgVec<RGBA8> {
259+
let buf = self.render_pixmap(lines, cursor).take().as_rgba().to_vec();
255260

256261
ImgVec::new(buf, self.pixel_width, self.pixel_height)
257262
}

0 commit comments

Comments
 (0)