use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::image::ImageRenderer;
use crate::{BrailleGrid, DotmaxError, Result};
use super::MediaPlayer;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BlendOp {
#[default]
Source,
Over,
}
impl From<png::BlendOp> for BlendOp {
fn from(op: png::BlendOp) -> Self {
match op {
png::BlendOp::Source => Self::Source,
png::BlendOp::Over => Self::Over,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DisposeOp {
#[default]
None,
Background,
Previous,
}
impl From<png::DisposeOp> for DisposeOp {
fn from(op: png::DisposeOp) -> Self {
match op {
png::DisposeOp::None => Self::None,
png::DisposeOp::Background => Self::Background,
png::DisposeOp::Previous => Self::Previous,
}
}
}
#[derive(Debug, Clone)]
pub struct ApngFrame {
pub width: u32,
pub height: u32,
pub x_offset: u32,
pub y_offset: u32,
pub delay: Duration,
pub index: usize,
pub blend_op: BlendOp,
pub dispose_op: DisposeOp,
}
pub struct ApngPlayer {
path: PathBuf,
decoder: png::Reader<BufReader<File>>,
canvas_width: u32,
canvas_height: u32,
canvas: Vec<u8>,
previous_canvas: Vec<u8>,
frame_count: Option<usize>,
apng_loop_count: Option<u16>,
current_frame: usize,
current_loop: u16,
loops_completed: bool,
previous_dispose: DisposeOp,
previous_rect: (u32, u32, u32, u32),
terminal_width: usize,
terminal_height: usize,
frame_buffer: Vec<u8>,
is_first_frame: bool,
}
impl std::fmt::Debug for ApngPlayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApngPlayer")
.field("path", &self.path)
.field("canvas_width", &self.canvas_width)
.field("canvas_height", &self.canvas_height)
.field("frame_count", &self.frame_count)
.field("loop_count", &self.apng_loop_count)
.field("current_frame", &self.current_frame)
.field("current_loop", &self.current_loop)
.finish_non_exhaustive()
}
}
impl ApngPlayer {
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let file = File::open(&path)?;
let reader = BufReader::new(file);
let decoder = png::Decoder::new(reader);
let png_reader = decoder.read_info().map_err(|e| DotmaxError::ApngError {
path: path.clone(),
message: format!("Failed to decode APNG: {e}"),
})?;
let info = png_reader.info();
let canvas_width = info.width;
let canvas_height = info.height;
let animation_control = info.animation_control();
let (frame_count, apng_loop_count) = animation_control.map_or_else(
|| {
tracing::warn!("APNG file {:?} has no animation control chunk", path);
(Some(1), Some(1))
},
|actl| {
let loops = if actl.num_plays == 0 {
Some(0) } else {
Some(actl.num_plays as u16)
};
(Some(actl.num_frames as usize), loops)
},
);
tracing::info!(
"Loaded APNG: {}x{}, {} frames, loop_count={:?}",
canvas_width,
canvas_height,
frame_count.unwrap_or(0),
apng_loop_count
);
let canvas_size = (canvas_width as usize) * (canvas_height as usize) * 4;
let canvas = vec![0u8; canvas_size];
let previous_canvas = vec![0u8; canvas_size];
let (terminal_width, terminal_height) = crossterm::terminal::size()
.map(|(w, h)| (w as usize, h as usize))
.unwrap_or((80, 24));
let frame_buffer = vec![0u8; png_reader.output_buffer_size().unwrap_or(canvas_size)];
Ok(Self {
path,
decoder: png_reader,
canvas_width,
canvas_height,
canvas,
previous_canvas,
frame_count,
apng_loop_count,
current_frame: 0,
current_loop: 1,
loops_completed: false,
previous_dispose: DisposeOp::None,
previous_rect: (0, 0, 0, 0),
terminal_width,
terminal_height,
frame_buffer,
is_first_frame: true,
})
}
#[must_use]
pub const fn canvas_width(&self) -> u32 {
self.canvas_width
}
#[must_use]
pub const fn canvas_height(&self) -> u32 {
self.canvas_height
}
fn decode_next_frame(&mut self) -> Option<Result<ApngFrame>> {
let output_info = match self.decoder.next_frame(&mut self.frame_buffer) {
Ok(info) => info,
Err(e) => {
tracing::warn!(
"APNG frame decode error at frame {}: {:?}",
self.current_frame,
e
);
return None; }
};
let frame_control = self.decoder.info().frame_control();
let (x_offset, y_offset, width, height, delay, blend_op, dispose_op) =
if let Some(fctl) = frame_control {
let delay = Self::calculate_delay(fctl.delay_num, fctl.delay_den);
(
fctl.x_offset,
fctl.y_offset,
fctl.width,
fctl.height,
delay,
BlendOp::from(fctl.blend_op),
DisposeOp::from(fctl.dispose_op),
)
} else {
(
0,
0,
self.canvas_width,
self.canvas_height,
Duration::from_millis(100),
BlendOp::Source,
DisposeOp::None,
)
};
tracing::debug!(
"Frame {}: {}x{} at ({},{}), delay={:?}, blend={:?}, dispose={:?}",
self.current_frame,
width,
height,
x_offset,
y_offset,
delay,
blend_op,
dispose_op
);
let expected_size = output_info.buffer_size();
if self.frame_buffer.len() < expected_size {
self.frame_buffer.resize(expected_size, 0);
}
Some(Ok(ApngFrame {
width,
height,
x_offset,
y_offset,
delay,
index: self.current_frame,
blend_op,
dispose_op,
}))
}
fn calculate_delay(delay_num: u16, delay_den: u16) -> Duration {
let millis = if delay_den == 0 {
u64::from(delay_num) * 10
} else {
(u64::from(delay_num) * 1000) / u64::from(delay_den)
};
let millis = if millis == 0 { 100 } else { millis.max(10) };
Duration::from_millis(millis)
}
fn apply_previous_disposal(&mut self) {
let (x, y, width, height) = self.previous_rect;
if width == 0 || height == 0 {
return;
}
match self.previous_dispose {
DisposeOp::None => {
}
DisposeOp::Background => {
for row in 0..height {
let canvas_y = (y + row) as usize;
if canvas_y >= self.canvas_height as usize {
continue;
}
for col in 0..width {
let canvas_x = (x + col) as usize;
if canvas_x >= self.canvas_width as usize {
continue;
}
let idx = (canvas_y * self.canvas_width as usize + canvas_x) * 4;
if idx + 3 < self.canvas.len() {
self.canvas[idx] = 0; self.canvas[idx + 1] = 0; self.canvas[idx + 2] = 0; self.canvas[idx + 3] = 0; }
}
}
}
DisposeOp::Previous => {
self.canvas.copy_from_slice(&self.previous_canvas);
}
}
}
fn save_canvas_state(&mut self) {
self.previous_canvas.copy_from_slice(&self.canvas);
}
fn composite_frame(&mut self, frame: &ApngFrame) {
let frame_width = frame.width as usize;
let frame_height = frame.height as usize;
let canvas_width = self.canvas_width as usize;
let output_bytes_per_pixel = self.decoder.info().bytes_per_pixel();
for row in 0..frame_height {
let canvas_y = (frame.y_offset as usize) + row;
if canvas_y >= self.canvas_height as usize {
continue;
}
for col in 0..frame_width {
let canvas_x = (frame.x_offset as usize) + col;
if canvas_x >= canvas_width {
continue;
}
let frame_idx = (row * frame_width + col) * output_bytes_per_pixel;
let canvas_idx = (canvas_y * canvas_width + canvas_x) * 4;
if frame_idx + output_bytes_per_pixel > self.frame_buffer.len()
|| canvas_idx + 4 > self.canvas.len()
{
continue;
}
let (r, g, b, a) = match self.decoder.info().color_type {
png::ColorType::Rgba => (
self.frame_buffer[frame_idx],
self.frame_buffer[frame_idx + 1],
self.frame_buffer[frame_idx + 2],
self.frame_buffer[frame_idx + 3],
),
png::ColorType::Rgb => (
self.frame_buffer[frame_idx],
self.frame_buffer[frame_idx + 1],
self.frame_buffer[frame_idx + 2],
255,
),
png::ColorType::GrayscaleAlpha => {
let gray = self.frame_buffer[frame_idx];
let alpha = self.frame_buffer[frame_idx + 1];
(gray, gray, gray, alpha)
}
png::ColorType::Grayscale => {
let gray = self.frame_buffer[frame_idx];
(gray, gray, gray, 255)
}
png::ColorType::Indexed => {
let idx = self.frame_buffer[frame_idx] as usize;
self.decoder.info().palette.as_ref().map_or(
(0, 0, 0, 255),
|palette| {
if idx * 3 + 2 < palette.len() {
(palette[idx * 3], palette[idx * 3 + 1], palette[idx * 3 + 2], 255)
} else {
(0, 0, 0, 255)
}
},
)
}
};
match frame.blend_op {
BlendOp::Source => {
self.canvas[canvas_idx] = r;
self.canvas[canvas_idx + 1] = g;
self.canvas[canvas_idx + 2] = b;
self.canvas[canvas_idx + 3] = a;
}
BlendOp::Over => {
if a == 255 {
self.canvas[canvas_idx] = r;
self.canvas[canvas_idx + 1] = g;
self.canvas[canvas_idx + 2] = b;
self.canvas[canvas_idx + 3] = 255;
} else if a > 0 {
let src_a = f32::from(a) / 255.0;
let dst_a = f32::from(self.canvas[canvas_idx + 3]) / 255.0;
let out_a = src_a + dst_a * (1.0 - src_a);
if out_a > 0.0 {
let src_rgb = [f32::from(r), f32::from(g), f32::from(b)];
for (i, &src_component) in src_rgb.iter().enumerate() {
let dst = f32::from(self.canvas[canvas_idx + i]);
let blended =
src_component.mul_add(src_a, dst * dst_a * (1.0 - src_a))
/ out_a;
self.canvas[canvas_idx + i] = blended as u8;
}
self.canvas[canvas_idx + 3] = (out_a * 255.0) as u8;
}
}
}
}
}
}
}
fn canvas_to_grid(&self) -> Result<BrailleGrid> {
let img = image::RgbaImage::from_raw(self.canvas_width, self.canvas_height, self.canvas.clone())
.ok_or_else(|| DotmaxError::ApngError {
path: self.path.clone(),
message: "Failed to create image from canvas".to_string(),
})?;
let grid = ImageRenderer::new()
.load_from_rgba(img)
.resize(self.terminal_width, self.terminal_height, true)?
.render()?;
Ok(grid)
}
fn reopen_decoder(&mut self) -> Result<()> {
let file = File::open(&self.path)?;
let reader = BufReader::new(file);
let decoder = png::Decoder::new(reader);
self.decoder = decoder.read_info().map_err(|e| DotmaxError::ApngError {
path: self.path.clone(),
message: format!("Failed to reopen APNG: {e}"),
})?;
let canvas_size = (self.canvas_width as usize) * (self.canvas_height as usize) * 4;
self.frame_buffer = vec![0u8; self.decoder.output_buffer_size().unwrap_or(canvas_size)];
self.is_first_frame = true;
Ok(())
}
}
impl MediaPlayer for ApngPlayer {
fn next_frame(&mut self) -> Option<Result<(BrailleGrid, Duration)>> {
if self.loops_completed {
return None;
}
self.apply_previous_disposal();
let frame = match self.decode_next_frame() {
Some(Ok(f)) => f,
Some(Err(e)) => {
tracing::warn!("Skipping corrupted frame {}: {:?}", self.current_frame, e);
self.current_frame += 1;
return self.next_frame();
}
None => {
if let Some(frame_count) = self.frame_count {
tracing::debug!("Loop {} complete ({} frames)", self.current_loop, frame_count);
} else {
self.frame_count = Some(self.current_frame);
tracing::debug!("First loop complete, {} frames", self.current_frame);
}
let should_loop = match self.apng_loop_count {
Some(0) => true, Some(n) if self.current_loop < n => true,
_ => false,
};
if should_loop {
self.current_loop += 1;
self.current_frame = 0;
self.canvas.fill(0);
self.previous_canvas.fill(0);
self.previous_dispose = DisposeOp::None;
self.previous_rect = (0, 0, 0, 0);
if let Err(e) = self.reopen_decoder() {
return Some(Err(e));
}
return self.next_frame();
}
self.loops_completed = true;
return None;
}
};
if frame.dispose_op == DisposeOp::Previous {
self.save_canvas_state();
}
self.composite_frame(&frame);
let grid = match self.canvas_to_grid() {
Ok(g) => g,
Err(e) => return Some(Err(e)),
};
self.previous_dispose = frame.dispose_op;
self.previous_rect = (frame.x_offset, frame.y_offset, frame.width, frame.height);
self.current_frame += 1;
self.is_first_frame = false;
Some(Ok((grid, frame.delay)))
}
fn reset(&mut self) {
self.current_frame = 0;
self.current_loop = 1;
self.loops_completed = false;
self.canvas.fill(0);
self.previous_canvas.fill(0);
self.previous_dispose = DisposeOp::None;
self.previous_rect = (0, 0, 0, 0);
self.is_first_frame = true;
if let Err(e) = self.reopen_decoder() {
tracing::warn!("Failed to reset APNG decoder: {:?}", e);
}
}
fn frame_count(&self) -> Option<usize> {
self.frame_count
}
fn loop_count(&self) -> Option<u16> {
self.apng_loop_count
}
fn handle_resize(&mut self, width: usize, height: usize) {
self.terminal_width = width;
self.terminal_height = height;
tracing::debug!("ApngPlayer resized to {}x{}", width, height);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn _assert_apng_player_send() {
fn assert_send<T: Send>() {}
assert_send::<ApngPlayer>();
}
#[test]
fn test_blend_op_from_png() {
assert_eq!(BlendOp::from(png::BlendOp::Source), BlendOp::Source);
assert_eq!(BlendOp::from(png::BlendOp::Over), BlendOp::Over);
}
#[test]
fn test_dispose_op_from_png() {
assert_eq!(DisposeOp::from(png::DisposeOp::None), DisposeOp::None);
assert_eq!(
DisposeOp::from(png::DisposeOp::Background),
DisposeOp::Background
);
assert_eq!(
DisposeOp::from(png::DisposeOp::Previous),
DisposeOp::Previous
);
}
#[test]
fn test_blend_op_default() {
assert_eq!(BlendOp::default(), BlendOp::Source);
}
#[test]
fn test_dispose_op_default() {
assert_eq!(DisposeOp::default(), DisposeOp::None);
}
#[test]
fn test_calculate_delay_normal() {
assert_eq!(
ApngPlayer::calculate_delay(1, 10),
Duration::from_millis(100)
);
}
#[test]
fn test_calculate_delay_zero_denominator() {
assert_eq!(
ApngPlayer::calculate_delay(10, 0),
Duration::from_millis(100)
);
}
#[test]
fn test_calculate_delay_zero_numerator() {
assert_eq!(
ApngPlayer::calculate_delay(0, 100),
Duration::from_millis(100)
);
}
#[test]
fn test_calculate_delay_minimum() {
assert_eq!(
ApngPlayer::calculate_delay(1, 1000),
Duration::from_millis(10)
);
}
#[test]
fn test_apng_frame_debug() {
let frame = ApngFrame {
width: 100,
height: 100,
x_offset: 0,
y_offset: 0,
delay: Duration::from_millis(100),
index: 0,
blend_op: BlendOp::Source,
dispose_op: DisposeOp::None,
};
let debug_str = format!("{:?}", frame);
assert!(debug_str.contains("ApngFrame"));
assert!(debug_str.contains("100"));
}
#[test]
fn test_apng_player_new_nonexistent() {
let player = ApngPlayer::new("nonexistent.png");
assert!(player.is_err(), "Should fail for nonexistent file");
}
#[test]
fn test_apng_player_new_animated() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.png");
if path.exists() {
let player = ApngPlayer::new(path);
assert!(player.is_ok(), "Should load animated PNG: {:?}", player.err());
let player = player.unwrap();
assert_eq!(player.canvas_width(), 10);
assert_eq!(player.canvas_height(), 10);
assert_eq!(player.frame_count(), Some(3));
assert_eq!(player.loop_count(), Some(0));
}
}
#[test]
fn test_apng_player_new_static_png() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/static_png.png");
if path.exists() {
let player = ApngPlayer::new(path);
assert!(player.is_ok(), "Should load static PNG: {:?}", player.err());
let player = player.unwrap();
assert_eq!(player.frame_count(), Some(1));
}
}
#[test]
fn test_apng_player_next_frame() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.png");
if path.exists() {
let mut player = ApngPlayer::new(path).unwrap();
let frame1 = player.next_frame();
assert!(frame1.is_some(), "Should have first frame");
let (grid, delay) = frame1.unwrap().unwrap();
assert!(grid.width() > 0);
assert!(grid.height() > 0);
assert_eq!(delay.as_millis(), 100); }
}
#[test]
fn test_apng_player_loop_twice() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/loop_twice.png");
if path.exists() {
let player = ApngPlayer::new(path).unwrap();
assert_eq!(player.loop_count(), Some(2), "Should loop twice");
}
}
#[test]
fn test_apng_player_reset() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.png");
if path.exists() {
let mut player = ApngPlayer::new(path).unwrap();
let _ = player.next_frame();
let _ = player.next_frame();
player.reset();
let frame = player.next_frame();
assert!(frame.is_some(), "Should have frame after reset");
}
}
#[test]
fn test_apng_player_debug() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.png");
if path.exists() {
let player = ApngPlayer::new(path).unwrap();
let debug_str = format!("{:?}", player);
assert!(debug_str.contains("ApngPlayer"));
assert!(debug_str.contains("canvas_width"));
}
}
#[test]
fn test_apng_player_as_media_player_trait_object() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.png");
if path.exists() {
let player = ApngPlayer::new(path).unwrap();
let _trait_obj: Box<dyn MediaPlayer> = Box::new(player);
}
}
}