perf: pre-scale ascii images when transitions are enabled (#550)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled

This pre-scales images when the presentation is reloaded and transitions
are enabled. For now only filesystem images are scaled but the next PR
will do this for generated ones as well. The end result of this is when
you transition between slides and there's images in them, the animation
is smooth the first time because the scaling, which is costly, is done
ahead of time.
This commit is contained in:
Matias Fontanini 2025-04-14 17:57:15 -07:00 committed by GitHub
commit a97e66fedf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 16 deletions

View File

@ -13,6 +13,7 @@ use crate::{
},
render::{
ErrorSource, RenderError, RenderResult, TerminalDrawer, TerminalDrawerOptions,
ascii_scaler::AsciiScaler,
engine::{MaxSize, RenderEngine, RenderEngineOptions},
operation::RenderAsyncState,
properties::WindowSize,
@ -109,7 +110,7 @@ impl<'a> Presenter<'a> {
self.resources.watch_presentation_file(path.to_path_buf());
}
self.state = PresenterState::Presenting(Presentation::from(vec![]));
self.try_reload(path, true);
self.try_reload(path, true)?;
let drawer_options = TerminalDrawerOptions {
font_size_fallback: self.options.font_size_fallback,
@ -148,10 +149,11 @@ impl<'a> Presenter<'a> {
break;
}
CommandSideEffect::Reload => {
self.try_reload(path, false);
self.try_reload(path, false)?;
break;
}
CommandSideEffect::Redraw => {
self.try_scale_transition_images()?;
break;
}
CommandSideEffect::NextSlide => {
@ -326,9 +328,9 @@ impl<'a> Presenter<'a> {
if needs_redraw { CommandSideEffect::Redraw } else { CommandSideEffect::None }
}
fn try_reload(&mut self, path: &Path, force: bool) {
fn try_reload(&mut self, path: &Path, force: bool) -> RenderResult {
if matches!(self.options.mode, PresentMode::Presentation) && !force {
return;
return Ok(());
}
self.slides_with_pending_async_renders.clear();
self.resources.clear_watches();
@ -344,12 +346,25 @@ impl<'a> Presenter<'a> {
}
self.slides_with_pending_async_renders = presentation.slides_with_async_renders().into_iter().collect();
self.state = self.validate_overflows(presentation);
self.try_scale_transition_images()?;
}
Err(e) => {
let presentation = mem::take(&mut self.state).into_presentation();
self.state = PresenterState::failure(e, presentation, ErrorSource::Presentation, FailureMode::Other);
}
};
Ok(())
}
fn try_scale_transition_images(&self) -> RenderResult {
if self.options.transition.is_none() {
return Ok(());
}
let options = RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() };
let scaler = AsciiScaler::new(options, self.resources.image_registry());
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
scaler.process(self.state.presentation(), &dimensions)?;
Ok(())
}
fn is_displaying_other_error(&self) -> bool {

106
src/render/ascii_scaler.rs Normal file
View File

@ -0,0 +1,106 @@
use super::{
RenderError,
engine::{RenderEngine, RenderEngineOptions},
};
use crate::{
ImageRegistry, WindowSize,
presentation::Presentation,
terminal::{
image::{Image, ImageSource},
printer::{TerminalCommand, TerminalError, TerminalIo},
},
};
use std::thread;
use unicode_width::UnicodeWidthStr;
pub(crate) struct AsciiScaler {
options: RenderEngineOptions,
registry: ImageRegistry,
}
impl AsciiScaler {
pub(crate) fn new(options: RenderEngineOptions, registry: ImageRegistry) -> Self {
Self { options, registry }
}
pub(crate) fn process(self, presentation: &Presentation, dimensions: &WindowSize) -> Result<(), RenderError> {
let mut collector = ImageCollector::default();
for slide in presentation.iter_slides() {
let engine = RenderEngine::new(&mut collector, dimensions.clone(), self.options.clone());
engine.render(slide.iter_operations())?;
}
thread::spawn(move || Self::scale(collector.images, self.registry));
Ok(())
}
fn scale(images: Vec<ScalableImage>, registry: ImageRegistry) {
for image in images {
let ascii_image = registry.as_ascii(&image.image);
ascii_image.cache_scaling(image.columns, image.rows);
}
}
}
struct ScalableImage {
image: Image,
rows: u16,
columns: u16,
}
struct ImageCollector {
current_column: u16,
current_row: u16,
current_row_height: u16,
images: Vec<ScalableImage>,
}
impl Default for ImageCollector {
fn default() -> Self {
Self { current_row: 0, current_column: 0, current_row_height: 1, images: Default::default() }
}
}
impl TerminalIo for ImageCollector {
fn execute(&mut self, command: &TerminalCommand<'_>) -> Result<(), TerminalError> {
use TerminalCommand::*;
match command {
MoveTo { column, row } => {
self.current_column = *column;
self.current_row = *row;
}
MoveToRow(row) => self.current_row = *row,
MoveToColumn(column) => self.current_column = *column,
MoveDown(amount) => self.current_row = self.current_row.saturating_add(*amount),
MoveRight(amount) => self.current_column = self.current_column.saturating_add(*amount),
MoveLeft(amount) => self.current_column = self.current_column.saturating_sub(*amount),
MoveToNextLine => {
self.current_row = self.current_row.saturating_add(1);
self.current_column = 0;
self.current_row_height = 1;
}
PrintText { content, style } => {
self.current_column = self.current_column.saturating_add(content.width() as u16);
self.current_row_height = self.current_row_height.max(style.size as u16);
}
PrintImage { image, options } => {
// we can only really cache filesystem images for now
if matches!(image.source, ImageSource::Filesystem(_)) {
let image =
ScalableImage { image: image.clone(), rows: options.rows * 2, columns: options.columns };
self.images.push(image);
}
}
ClearScreen => {
self.current_column = 0;
self.current_row = 0;
self.current_row_height = 1;
}
BeginUpdate | EndUpdate | Flush | SetColors(_) | SetBackgroundColor(_) => (),
};
Ok(())
}
fn cursor_row(&self) -> u16 {
self.current_row
}
}

View File

@ -1,3 +1,4 @@
pub(crate) mod ascii_scaler;
pub(crate) mod engine;
pub(crate) mod layout;
pub(crate) mod operation;

View File

@ -23,6 +23,18 @@ pub(crate) struct AsciiImage {
cached_sizes: Arc<Mutex<HashMap<(u16, u16), RgbaImage>>>,
}
impl AsciiImage {
pub(crate) fn cache_scaling(&self, columns: u16, rows: u16) {
let mut cached_sizes = self.cached_sizes.lock().unwrap();
// lookup on cache/resize the image and store it in cache
let cache_key = (columns, rows);
if cached_sizes.get(&cache_key).is_none() {
let image = self.image.resize_exact(columns as u32, rows as u32, FilterType::Triangle);
cached_sizes.insert(cache_key, image.into_rgba8());
}
}
}
impl ImageProperties for AsciiImage {
fn dimensions(&self) -> (u32, u32) {
self.image.dimensions()
@ -91,20 +103,14 @@ impl PrintImage for AsciiPrinter {
{
let columns = options.columns;
let rows = options.rows * 2;
let mut cached_sizes = image.cached_sizes.lock().unwrap();
// Scale it first
image.cache_scaling(columns, rows);
// lookup on cache/resize the image and store it in cache
let cache_key = (columns, rows);
let image = match cached_sizes.get(&cache_key) {
Some(image) => image,
None => {
let image = image.image.resize_exact(columns as u32, rows as u32, FilterType::Triangle);
cached_sizes.insert(cache_key, image.into_rgba8());
cached_sizes.get(&cache_key).unwrap()
}
};
// The strategy here is taken from viuer: use half vertical ascii blocks in combination
// with foreground/background colors to fit 2 vertical pixels per cell. That is, cell (x, y)
// will contain the pixels at (x, y) and (x, y + 1) combined.
let cached_sizes = image.cached_sizes.lock().unwrap();
let image = cached_sizes.get(&cache_key).expect("scaled image no longer there");
let default_background = options.background_color.map(Color::from);
// Iterate pixel rows in pairs to be able to merge both pixels in a single iteration.