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 DisposalMethod {
#[default]
None,
DoNotDispose,
RestoreBackground,
RestorePrevious,
}
impl From<gif::DisposalMethod> for DisposalMethod {
fn from(method: gif::DisposalMethod) -> Self {
match method {
gif::DisposalMethod::Any => Self::None,
gif::DisposalMethod::Keep => Self::DoNotDispose,
gif::DisposalMethod::Background => Self::RestoreBackground,
gif::DisposalMethod::Previous => Self::RestorePrevious,
}
}
}
#[derive(Debug, Clone)]
pub struct GifFrame {
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
pub delay_ms: u32,
pub index: usize,
pub disposal: DisposalMethod,
pub left: u16,
pub top: u16,
}
pub struct GifPlayer {
path: PathBuf,
decoder: gif::Decoder<BufReader<File>>,
canvas_width: u16,
canvas_height: u16,
canvas: Vec<u8>,
previous_canvas: Vec<u8>,
frame_count: Option<usize>,
gif_loop_count: Option<u16>,
current_frame: usize,
current_loop: u16,
loops_completed: bool,
previous_disposal: DisposalMethod,
previous_rect: (u16, u16, u16, u16),
terminal_width: usize,
terminal_height: usize,
}
impl std::fmt::Debug for GifPlayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GifPlayer")
.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.gif_loop_count)
.field("current_frame", &self.current_frame)
.field("current_loop", &self.current_loop)
.finish_non_exhaustive()
}
}
impl GifPlayer {
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 mut options = gif::DecodeOptions::new();
options.set_color_output(gif::ColorOutput::RGBA);
let decoder = options.read_info(reader).map_err(|e| DotmaxError::GifError {
path: path.clone(),
message: format!("Failed to decode GIF: {e}"),
})?;
let canvas_width = decoder.width();
let canvas_height = decoder.height();
let gif_loop_count = match decoder.repeat() {
gif::Repeat::Infinite => Some(0),
gif::Repeat::Finite(n) => Some(n),
};
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));
Ok(Self {
path,
decoder,
canvas_width,
canvas_height,
canvas,
previous_canvas,
frame_count: None,
gif_loop_count,
current_frame: 0,
current_loop: 1,
loops_completed: false,
previous_disposal: DisposalMethod::None,
previous_rect: (0, 0, 0, 0),
terminal_width,
terminal_height,
})
}
#[must_use]
pub const fn canvas_width(&self) -> u16 {
self.canvas_width
}
#[must_use]
pub const fn canvas_height(&self) -> u16 {
self.canvas_height
}
fn decode_next_frame(&mut self) -> Option<Result<GifFrame>> {
let frame = match self.decoder.read_next_frame() {
Ok(Some(f)) => f,
Ok(None) => return None,
Err(e) => {
tracing::warn!("GIF frame decode error at frame {}: {:?}", self.current_frame, e);
return Some(Err(DotmaxError::GifError {
path: self.path.clone(),
message: format!("Frame {} decode error: {e}", self.current_frame),
}));
}
};
let delay_ms = if frame.delay == 0 { 100 } else { u32::from(frame.delay) * 10 };
let gif_frame = GifFrame {
pixels: frame.buffer.to_vec(),
width: u32::from(frame.width),
height: u32::from(frame.height),
delay_ms,
index: self.current_frame,
disposal: frame.dispose.into(),
left: frame.left,
top: frame.top,
};
Some(Ok(gif_frame))
}
fn apply_previous_disposal(&mut self) {
let (left, top, width, height) = self.previous_rect;
if width == 0 || height == 0 {
return;
}
match self.previous_disposal {
DisposalMethod::None | DisposalMethod::DoNotDispose => {
}
DisposalMethod::RestoreBackground => {
for y in 0..height {
let canvas_y = (top + y) as usize;
if canvas_y >= self.canvas_height as usize {
continue;
}
for x in 0..width {
let canvas_x = (left + x) 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; }
}
}
}
DisposalMethod::RestorePrevious => {
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: &GifFrame) {
let frame_width = frame.width as usize;
let frame_height = frame.height as usize;
let canvas_width = self.canvas_width as usize;
for y in 0..frame_height {
let canvas_y = (frame.top as usize) + y;
if canvas_y >= self.canvas_height as usize {
continue;
}
for x in 0..frame_width {
let canvas_x = (frame.left as usize) + x;
if canvas_x >= canvas_width {
continue;
}
let frame_idx = (y * frame_width + x) * 4;
let canvas_idx = (canvas_y * canvas_width + canvas_x) * 4;
if frame_idx + 3 < frame.pixels.len() && canvas_idx + 3 < self.canvas.len() {
let alpha = frame.pixels[frame_idx + 3];
if alpha == 255 {
self.canvas[canvas_idx] = frame.pixels[frame_idx];
self.canvas[canvas_idx + 1] = frame.pixels[frame_idx + 1];
self.canvas[canvas_idx + 2] = frame.pixels[frame_idx + 2];
self.canvas[canvas_idx + 3] = 255;
} else if alpha > 0 {
let src_a = f32::from(alpha) / 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 {
for i in 0..3 {
let src = f32::from(frame.pixels[frame_idx + i]);
let dst = f32::from(self.canvas[canvas_idx + i]);
let blended = src.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(
u32::from(self.canvas_width),
u32::from(self.canvas_height),
self.canvas.clone(),
)
.ok_or_else(|| DotmaxError::GifError {
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 mut options = gif::DecodeOptions::new();
options.set_color_output(gif::ColorOutput::RGBA);
self.decoder = options.read_info(reader).map_err(|e| DotmaxError::GifError {
path: self.path.clone(),
message: format!("Failed to reopen GIF: {e}"),
})?;
Ok(())
}
}
impl MediaPlayer for GifPlayer {
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.gif_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_disposal = DisposalMethod::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.disposal == DisposalMethod::RestorePrevious {
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_disposal = frame.disposal;
self.previous_rect = (frame.left, frame.top, frame.width as u16, frame.height as u16);
self.current_frame += 1;
let duration = Duration::from_millis(u64::from(frame.delay_ms));
Some(Ok((grid, duration)))
}
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_disposal = DisposalMethod::None;
self.previous_rect = (0, 0, 0, 0);
if let Err(e) = self.reopen_decoder() {
tracing::warn!("Failed to reset GIF decoder: {:?}", e);
}
}
fn frame_count(&self) -> Option<usize> {
self.frame_count
}
fn loop_count(&self) -> Option<u16> {
self.gif_loop_count
}
fn handle_resize(&mut self, width: usize, height: usize) {
self.terminal_width = width;
self.terminal_height = height;
tracing::debug!("GifPlayer resized to {}x{}", width, height);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn _assert_gif_player_is_send<T: Send>() {}
fn _assert_gif_player_send() {
fn assert_send<T: Send>() {}
assert_send::<GifPlayer>();
}
#[test]
fn test_disposal_method_from_gif() {
assert_eq!(
DisposalMethod::from(gif::DisposalMethod::Any),
DisposalMethod::None
);
assert_eq!(
DisposalMethod::from(gif::DisposalMethod::Keep),
DisposalMethod::DoNotDispose
);
assert_eq!(
DisposalMethod::from(gif::DisposalMethod::Background),
DisposalMethod::RestoreBackground
);
assert_eq!(
DisposalMethod::from(gif::DisposalMethod::Previous),
DisposalMethod::RestorePrevious
);
}
#[test]
fn test_disposal_method_default() {
assert_eq!(DisposalMethod::default(), DisposalMethod::None);
}
#[test]
fn test_gif_frame_debug() {
let frame = GifFrame {
pixels: vec![0; 16],
width: 2,
height: 2,
delay_ms: 100,
index: 0,
disposal: DisposalMethod::None,
left: 0,
top: 0,
};
let debug_str = format!("{:?}", frame);
assert!(debug_str.contains("GifFrame"));
assert!(debug_str.contains("delay_ms: 100"));
}
#[test]
fn test_gif_player_new_animated() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.gif");
if path.exists() {
let player = GifPlayer::new(path);
assert!(player.is_ok(), "Should load animated GIF: {:?}", player.err());
let player = player.unwrap();
assert_eq!(player.canvas_width(), 10);
assert_eq!(player.canvas_height(), 10);
assert_eq!(player.loop_count(), Some(0));
}
}
#[test]
fn test_gif_player_new_static() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/static.gif");
if path.exists() {
let player = GifPlayer::new(path);
assert!(player.is_ok(), "Should load static GIF: {:?}", player.err());
}
}
#[test]
fn test_gif_player_new_nonexistent() {
let player = GifPlayer::new("nonexistent.gif");
assert!(player.is_err(), "Should fail for nonexistent file");
}
#[test]
fn test_gif_player_next_frame() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.gif");
if path.exists() {
let mut player = GifPlayer::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_gif_player_loop_twice() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/loop_twice.gif");
if path.exists() {
let player = GifPlayer::new(path).unwrap();
assert_eq!(player.loop_count(), Some(2), "Should loop twice");
}
}
#[test]
fn test_gif_player_reset() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.gif");
if path.exists() {
let mut player = GifPlayer::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_gif_player_frame_delay() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.gif");
if path.exists() {
let mut player = GifPlayer::new(path).unwrap();
let (_, delay) = player.next_frame().unwrap().unwrap();
assert_eq!(delay.as_millis(), 100, "Delay should be 100ms");
}
}
#[test]
fn test_gif_player_debug() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.gif");
if path.exists() {
let player = GifPlayer::new(path).unwrap();
let debug_str = format!("{:?}", player);
assert!(debug_str.contains("GifPlayer"));
assert!(debug_str.contains("canvas_width"));
}
}
#[test]
fn test_gif_player_as_media_player_trait_object() {
use std::path::Path;
let path = Path::new("tests/fixtures/media/animated.gif");
if path.exists() {
let player = GifPlayer::new(path).unwrap();
let _trait_obj: Box<dyn MediaPlayer> = Box::new(player);
}
}
}