use crate::config::Config;
use crate::error::{EgerError, Result};
use crate::render::{self, RenderOutput, RenderTarget};
#[cfg(feature = "image")]
use ::image::AnimationDecoder;
use rayon::prelude::*;
use std::path::{Path, PathBuf};
#[cfg(feature = "video")]
use std::sync::Arc;
#[cfg(feature = "image")]
use std::time::Duration;
#[cfg(feature = "parallel")]
pub(crate) fn convert(
width: u32,
height: u32,
buf: &[u8],
config: &Config,
) -> Result<iascii::grid::Grid> {
let pool = config.thread_pool()?;
Ok(iascii::convert_with_pool(
width,
height,
buf,
&config.ascii,
Some(pool.as_ref()),
)?)
}
#[cfg(not(feature = "parallel"))]
pub(crate) fn convert(
width: u32,
height: u32,
buf: &[u8],
config: &Config,
) -> Result<iascii::grid::Grid> {
Ok(iascii::convert_image(width, height, buf, &config.ascii)?)
}
#[cfg(feature = "image")]
pub fn render_image_file(
path: &Path,
config: &Config,
target: &RenderTarget,
) -> Result<RenderOutput> {
let img = ::image::open(path)?;
render_dynamic_image(&img, config, target)
}
#[cfg(feature = "image")]
pub fn render_dynamic_image(
img: &::image::DynamicImage,
config: &Config,
target: &RenderTarget,
) -> Result<RenderOutput> {
let rgb = img.to_rgb8();
let width = rgb.width();
let height = rgb.height();
let grid = convert(width, height, rgb.as_raw(), config)?;
render_grid_styled(&grid, config, target)
}
pub(crate) fn render_grid_styled(
grid: &iascii::grid::Grid,
config: &Config,
target: &RenderTarget,
) -> Result<RenderOutput> {
#[cfg(feature = "dither")]
if config.style == crate::style::Style::Full {
if let Some(options) = &config.dither {
let text = crate::dither::render_ansi_dithered(grid, config.color_depth, *options);
return render::dispatch_text(target, text);
}
}
match config.style {
crate::style::Style::Full => render::dispatch(target, grid, Some(config.color_depth)),
style => {
let text = style.render(grid, config.color_depth);
render::dispatch_text(target, text)
}
}
}
pub fn render_rgb_buffer(
width: u32,
height: u32,
buf: &[u8],
config: &Config,
target: &RenderTarget,
) -> Result<RenderOutput> {
let expected = width as usize * height as usize * 3;
if buf.len() != expected {
return Err(EgerError::Render(format!(
"RGB8 buffer is {} bytes but {width}x{height} needs {expected}",
buf.len()
)));
}
let grid = convert(width, height, buf, config)?;
render_grid_styled(&grid, config, target)
}
#[cfg(all(feature = "video", feature = "image"))]
pub async fn render_image_file_async(
path: PathBuf,
config: Arc<Config>,
target: RenderTarget,
) -> Result<RenderOutput> {
let raw_bytes = tokio::fs::read(&path).await?;
tokio::task::spawn_blocking(move || {
let img = ::image::load_from_memory(&raw_bytes)?;
let rgb = img.to_rgb8();
let width = rgb.width();
let height = rgb.height();
let grid = convert(width, height, rgb.as_raw(), &config)?;
render_grid_styled(&grid, &config, &target)
})
.await?
}
#[cfg(feature = "image")]
pub fn render_batch_parallel(
config: &Config,
target_for: impl Fn(&Path) -> RenderTarget + Sync,
) -> Result<Vec<(PathBuf, Result<RenderOutput>)>> {
let files = config.files()?;
let pool = config.thread_pool()?;
let results = pool.install(|| {
files
.into_par_iter()
.map(|path| {
let target = target_for(&path);
let outcome = render_image_file(&path, config, &target);
(path, outcome)
})
.collect::<Vec<_>>()
});
Ok(results)
}
#[cfg(all(feature = "video", feature = "image"))]
pub async fn render_batch_async(
config: Arc<Config>,
target_for: impl Fn(&Path) -> RenderTarget + Send + Sync + 'static,
) -> Result<Vec<(PathBuf, Result<RenderOutput>)>> {
let files = config.files()?;
let target_for = Arc::new(target_for);
let mut set = tokio::task::JoinSet::new();
for path in files {
let config = Arc::clone(&config);
let target_for = Arc::clone(&target_for);
set.spawn(async move {
let target = target_for(&path);
let outcome = render_image_file_async(path.clone(), config, target).await;
(path, outcome)
});
}
let mut results = Vec::with_capacity(set.len());
while let Some(joined) = set.join_next().await {
results.push(joined?);
}
Ok(results)
}
#[cfg(feature = "image")]
pub fn image_to_string(path: &Path, config: &Config) -> Result<String> {
match render_image_file(path, config, &RenderTarget::String)? {
RenderOutput::Text(text) => Ok(text),
_ => unreachable!("RenderTarget::String always yields RenderOutput::Text"),
}
}
#[cfg(feature = "image")]
pub fn image_to_lines(path: &Path, config: &Config) -> Result<Vec<String>> {
match render_image_file(path, config, &RenderTarget::Lines)? {
RenderOutput::Lines(lines) => Ok(lines),
_ => unreachable!("RenderTarget::Lines always yields RenderOutput::Lines"),
}
}
pub fn rgb_buffer_to_string(
width: u32,
height: u32,
buf: &[u8],
config: &Config,
) -> Result<String> {
match render_rgb_buffer(width, height, buf, config, &RenderTarget::String)? {
RenderOutput::Text(text) => Ok(text),
_ => unreachable!("RenderTarget::String always yields RenderOutput::Text"),
}
}
#[cfg(feature = "image")]
pub fn gif_to_lines(path: &Path, config: &Config) -> Result<Vec<(Vec<String>, Duration)>> {
let file = std::fs::File::open(path)?;
let decoder = ::image::codecs::gif::GifDecoder::new(std::io::BufReader::new(file))
.map_err(EgerError::Image)?;
decoder
.into_frames()
.map(|frame| {
let frame = frame.map_err(EgerError::Image)?;
let (numer, denom) = frame.delay().numer_denom_ms();
let delay = Duration::from_millis(numer as u64 / (denom.max(1)) as u64);
let buffer = frame.into_buffer();
let dynamic = ::image::DynamicImage::ImageRgba8(buffer);
let lines = match render_dynamic_image(&dynamic, config, &RenderTarget::Lines)? {
RenderOutput::Lines(lines) => lines,
_ => unreachable!("RenderTarget::Lines always yields RenderOutput::Lines"),
};
Ok((lines, delay))
})
.collect()
}
#[cfg(feature = "image")]
pub fn image_to_file(path: &Path, config: &Config, output: Option<&Path>) -> Result<PathBuf> {
let output = output
.map(Path::to_path_buf)
.or_else(|| config.output.clone())
.ok_or_else(|| EgerError::Render("no output path provided or configured".into()))?;
match render_image_file(path, config, &RenderTarget::File(output))? {
RenderOutput::Written(path) => Ok(path),
_ => unreachable!("RenderTarget::File always yields RenderOutput::Written"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MediaType;
#[cfg(feature = "image")]
use ::image::codecs::gif::{GifEncoder, Repeat};
#[cfg(feature = "image")]
use ::image::{Delay, Frame, RgbaImage};
#[cfg(feature = "image")]
fn write_test_gif(path: &Path) {
let mut file = std::fs::File::create(path).unwrap();
let mut encoder = GifEncoder::new(&mut file);
encoder.set_repeat(Repeat::Infinite).unwrap();
let black = RgbaImage::from_pixel(2, 2, ::image::Rgba([0, 0, 0, 255]));
let white = RgbaImage::from_pixel(2, 2, ::image::Rgba([255, 255, 255, 255]));
encoder
.encode_frame(Frame::from_parts(
black,
0,
0,
Delay::from_numer_denom_ms(50, 1),
))
.unwrap();
encoder
.encode_frame(Frame::from_parts(
white,
0,
0,
Delay::from_numer_denom_ms(120, 1),
))
.unwrap();
}
fn tempfile(name: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("eger-image-test-{}-{name}", std::process::id()));
path
}
#[test]
fn convert_produces_a_grid_with_the_requested_dimensions() {
let path = tempfile("convert-dummy.png");
std::fs::write(&path, b"").unwrap();
let config = Config::builder(MediaType::Image)
.from_file(&path)
.explicit_dimensions(3, 3)
.build()
.unwrap();
let buf = vec![255u8, 0, 0].repeat(9);
let grid = convert(3, 3, &buf, &config).unwrap();
assert_eq!(grid.width(), 3);
assert_eq!(grid.height(), 3);
std::fs::remove_file(&path).ok();
}
#[test]
fn convert_handles_a_grid_large_enough_to_cross_the_parallel_threshold() {
let path = tempfile("convert-large-dummy.png");
std::fs::write(&path, b"").unwrap();
let config = Config::builder(MediaType::Image)
.from_file(&path)
.explicit_dimensions(40, 150)
.build()
.unwrap();
let buf = vec![128u8; 10 * 10 * 3]; let grid = convert(10, 10, &buf, &config).unwrap();
assert_eq!(grid.height(), 150);
std::fs::remove_file(&path).ok();
}
#[cfg(feature = "image")]
#[test]
fn render_batch_parallel_with_an_oversized_item_does_not_deadlock() {
let dir =
std::env::temp_dir().join(format!("eger-nested-parallel-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["a.png", "b.png"] {
let img = ::image::RgbImage::from_pixel(4, 4, ::image::Rgb([200, 50, 50]));
img.save(dir.join(name)).unwrap();
}
let config = Config::builder(MediaType::Image)
.from_dir(&dir, r"\.png$")
.explicit_dimensions(30, 120) .num_threads(2)
.build()
.unwrap();
let results = render_batch_parallel(&config, |_| RenderTarget::Lines).unwrap();
assert_eq!(results.len(), 2);
for (_, outcome) in results {
let output = outcome.unwrap();
match output {
RenderOutput::Lines(lines) => assert_eq!(lines.len(), 120),
other => panic!("expected Lines, got {other:?}"),
}
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn render_rgb_buffer_rejects_a_wrong_sized_buffer() {
let path = tempfile("buffer-dummy.png");
std::fs::write(&path, b"").unwrap();
let config = Config::builder(MediaType::Image)
.from_file(&path)
.explicit_dimensions(4, 4)
.build()
.unwrap();
let bad = vec![0u8; 4 * 4 * 3 - 1];
assert!(render_rgb_buffer(4, 4, &bad, &config, &RenderTarget::String).is_err());
std::fs::remove_file(&path).ok();
}
#[test]
fn render_rgb_buffer_honors_style_plain_and_mono() {
let path = tempfile("buffer-styled-dummy.png");
std::fs::write(&path, b"").unwrap();
let mut buf = Vec::with_capacity(8 * 4 * 3);
for y in 0..4u32 {
for x in 0..8u32 {
buf.extend_from_slice(&[x as u8 * 32, y as u8 * 64, 40]);
}
}
let plain_config = Config::builder(MediaType::Image)
.from_file(&path)
.explicit_dimensions(8, 4)
.style(crate::style::Style::Plain)
.build()
.unwrap();
let plain = rgb_buffer_to_string(8, 4, &buf, &plain_config).unwrap();
assert!(!plain.contains('\x1b'));
assert_eq!(plain.lines().count(), 4);
let mono_config = Config::builder(MediaType::Image)
.from_file(&path)
.explicit_dimensions(8, 4)
.style(crate::style::Style::Mono(crate::rgb::Rgb::GREEN))
.build()
.unwrap();
let mono = rgb_buffer_to_string(8, 4, &buf, &mono_config).unwrap();
assert!(mono.contains("38;2;"), "mono should emit color escapes");
for line in mono.lines() {
let mut rest = line;
while let Some(start) = rest.find("38;2;") {
let esc = &rest[start + 5..];
let end = esc.find('m').unwrap();
let parts: Vec<&str> = esc[..end].split(';').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], "0");
assert_eq!(parts[2], "0");
rest = &esc[end..];
}
}
std::fs::remove_file(&path).ok();
}
#[cfg(feature = "image")]
#[test]
fn gif_to_lines_decodes_every_frame_with_its_own_delay() {
let path = tempfile("two-frame.gif");
write_test_gif(&path);
let config = Config::builder(MediaType::Image)
.from_file(&path)
.explicit_dimensions(2, 2)
.build()
.unwrap();
let frames = gif_to_lines(&path, &config).unwrap();
assert_eq!(frames.len(), 2, "should decode both encoded frames");
assert_eq!(frames[0].1, Duration::from_millis(50));
assert_eq!(frames[1].1, Duration::from_millis(120));
for (lines, _) in &frames {
assert_eq!(
lines.len(),
2,
"explicit_dimensions height should be honored"
);
}
std::fs::remove_file(&path).ok();
}
#[cfg(feature = "image")]
#[test]
fn gif_to_lines_errors_on_a_missing_file() {
let config = Config::builder(MediaType::Image)
.from_file(tempfile("does-not-exist.gif"))
.build();
assert!(config.is_err());
}
}