88 lines
3.2 KiB
Rust
88 lines
3.2 KiB
Rust
//! Image → terminal-art conversion, used wherever the player shows pictures
|
||
//! (artist tiles, release covers, now-playing).
|
||
//!
|
||
//! The format is "half-block art": each terminal cell renders `▀` with the
|
||
//! foreground colored as the top pixel and the background as the bottom
|
||
//! pixel, giving 2 vertical pixels per cell. This needs no terminal image
|
||
//! protocol (sixel/kitty), so it works everywhere crossterm does, and it
|
||
//! looks far better than glyph-luminance ASCII at tile sizes. The UI layer
|
||
//! converts `ArtImage` cells into styled spans.
|
||
|
||
use anyhow::{Context as _, Result};
|
||
|
||
/// Cache key for the shared artwork cache: the same image can be cached at
|
||
/// several cell sizes (grid tile vs page header).
|
||
pub fn cache_key(url: &str, width_cells: u16, height_cells: u16) -> String {
|
||
format!("{url}#{width_cells}x{height_cells}")
|
||
}
|
||
|
||
/// Decoded, cell-sized art. `pixels` holds `width_cells * height_cells * 2`
|
||
/// RGB triples, row-major, two pixel rows per cell row (top, then bottom).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct ArtImage {
|
||
pub width_cells: u16,
|
||
pub height_cells: u16,
|
||
pixels: Vec<[u8; 3]>,
|
||
}
|
||
|
||
impl ArtImage {
|
||
/// Top and bottom pixel of the cell at (column, cell row).
|
||
pub fn cell(&self, x: u16, y: u16) -> ([u8; 3], [u8; 3]) {
|
||
let width = self.width_cells as usize;
|
||
let top = (y as usize * 2) * width + x as usize;
|
||
let bottom = (y as usize * 2 + 1) * width + x as usize;
|
||
(self.pixels[top], self.pixels[bottom])
|
||
}
|
||
}
|
||
|
||
/// Decode image bytes (jpeg/png/webp/gif/bmp) and scale them to fill a
|
||
/// `width_cells` × `height_cells` terminal area, center-cropping overflow
|
||
/// like CSS object-fit: cover.
|
||
pub fn decode_to_cells(bytes: &[u8], width_cells: u16, height_cells: u16) -> Result<ArtImage> {
|
||
let width_px = u32::from(width_cells.max(1));
|
||
let height_px = u32::from(height_cells.max(1)) * 2;
|
||
let image = image::load_from_memory(bytes).context("unsupported or corrupt image")?;
|
||
let image = image
|
||
.resize_to_fill(width_px, height_px, image::imageops::FilterType::Triangle)
|
||
.into_rgb8();
|
||
let pixels = image.pixels().map(|p| p.0).collect();
|
||
Ok(ArtImage {
|
||
width_cells: width_cells.max(1),
|
||
height_cells: height_cells.max(1),
|
||
pixels,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn png_2x2() -> Vec<u8> {
|
||
// red, green / blue, white
|
||
let mut img = image::RgbImage::new(2, 2);
|
||
img.put_pixel(0, 0, image::Rgb([255, 0, 0]));
|
||
img.put_pixel(1, 0, image::Rgb([0, 255, 0]));
|
||
img.put_pixel(0, 1, image::Rgb([0, 0, 255]));
|
||
img.put_pixel(1, 1, image::Rgb([255, 255, 255]));
|
||
let mut out = std::io::Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgb8(img)
|
||
.write_to(&mut out, image::ImageFormat::Png)
|
||
.unwrap();
|
||
out.into_inner()
|
||
}
|
||
|
||
#[test]
|
||
fn decodes_to_requested_cell_grid() {
|
||
let art = decode_to_cells(&png_2x2(), 2, 1).unwrap();
|
||
assert_eq!((art.width_cells, art.height_cells), (2, 1));
|
||
let (top, bottom) = art.cell(0, 0);
|
||
assert_eq!(top, [255, 0, 0]);
|
||
assert_eq!(bottom, [0, 0, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_garbage() {
|
||
assert!(decode_to_cells(b"not an image", 4, 4).is_err());
|
||
}
|
||
}
|