use crate::config::Config;
use crate::error::{EgerError, Result};
use crate::render::{self, RenderOutput, RenderTarget};
use ::image::AnimationDecoder;
use rayon::prelude::*;
use std::path::{Path, PathBuf};
#[cfg(feature = "video")]
use std::sync::Arc;
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)?)
}
pub fn render_image_file(
path: &Path,
config: &Config,
target: &RenderTarget,
) -> Result<RenderOutput> {
let img = ::image::open(path)?;
render_dynamic_image(&img, config, target)
}
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::dispatch(target, &grid, Some(config.color_depth))
}
#[cfg(feature = "video")]
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::dispatch(&target, &grid, Some(config.color_depth))
})
.await?
}
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(feature = "video")]
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)
}
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"),
}
}
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 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()
}
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;
use ::image::codecs::gif::{GifEncoder, Repeat};
use ::image::{Delay, Frame, RgbaImage};
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();
}
#[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 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();
}
#[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());
}
}