use std::io;
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AnimationParameter {
RotationAngle,
Scale,
ColorHue,
WindingNumber,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterpolationMode {
Linear,
Sinusoidal,
}
#[derive(Debug, Clone)]
pub struct FrameInterpolator {
pub parameter: AnimationParameter,
pub start: f64,
pub end: f64,
pub mode: InterpolationMode,
}
impl FrameInterpolator {
pub fn new(
parameter: AnimationParameter,
start: f64,
end: f64,
mode: InterpolationMode,
) -> Self {
Self {
parameter,
start,
end,
mode,
}
}
pub fn evaluate(&self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
let eased = match self.mode {
InterpolationMode::Linear => t,
InterpolationMode::Sinusoidal => 0.5 * (1.0 - (std::f64::consts::PI * t).cos()),
};
self.start + (self.end - self.start) * eased
}
}
#[derive(Debug, Clone)]
pub struct AnimationConfig {
pub frames: usize,
pub fps: u32,
pub width: u32,
pub height: u32,
pub output_dir: PathBuf,
}
impl Default for AnimationConfig {
fn default() -> Self {
Self {
frames: 60,
fps: 30,
width: 1920,
height: 1080,
output_dir: PathBuf::from("./frames"),
}
}
}
impl AnimationConfig {
pub fn duration_secs(&self) -> f64 {
self.frames as f64 / self.fps.max(1) as f64
}
}
#[derive(Debug, Clone, Default)]
pub struct AnimationStats {
pub frames_written: usize,
pub duration_ms: u64,
pub avg_frame_ms: f64,
}
#[derive(Debug)]
pub enum ExportError {
Io(io::Error),
Render(String),
InvalidConfig(String),
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExportError::Io(e) => write!(f, "IO error: {e}"),
ExportError::Render(s) => write!(f, "render error: {s}"),
ExportError::InvalidConfig(s) => write!(f, "invalid config: {s}"),
}
}
}
impl std::error::Error for ExportError {}
impl From<io::Error> for ExportError {
fn from(e: io::Error) -> Self {
ExportError::Io(e)
}
}
pub struct AnimationExporter {
config: AnimationConfig,
interpolators: Vec<FrameInterpolator>,
}
impl AnimationExporter {
pub fn new(config: AnimationConfig, interpolators: Vec<FrameInterpolator>) -> Self {
Self {
config,
interpolators,
}
}
pub fn frame_path(&self, frame_index: usize) -> PathBuf {
self.config
.output_dir
.join(format!("frame_{:04}.png", frame_index))
}
pub fn param_values_at(&self, frame_index: usize) -> Vec<(AnimationParameter, f64)> {
let t = if self.config.frames <= 1 {
0.0
} else {
frame_index as f64 / (self.config.frames - 1) as f64
};
self.interpolators
.iter()
.map(|interp| (interp.parameter, interp.evaluate(t)))
.collect()
}
pub fn export<F>(&self, mut render_frame: F) -> Result<AnimationStats, ExportError>
where
F: FnMut(usize, &[(AnimationParameter, f64)], &Path) -> Result<(), String>,
{
if self.config.frames == 0 {
return Err(ExportError::InvalidConfig("frames must be > 0".to_string()));
}
std::fs::create_dir_all(&self.config.output_dir)?;
let start = Instant::now();
let mut frames_written = 0usize;
for frame_idx in 0..self.config.frames {
let params = self.param_values_at(frame_idx);
let output_path = self.frame_path(frame_idx);
render_frame(frame_idx, ¶ms, &output_path).map_err(ExportError::Render)?;
frames_written += 1;
}
let duration_ms = start.elapsed().as_millis() as u64;
let avg_frame_ms = if frames_written > 0 {
duration_ms as f64 / frames_written as f64
} else {
0.0
};
Ok(AnimationStats {
frames_written,
duration_ms,
avg_frame_ms,
})
}
pub fn write_test_frame(path: &Path, width: u32, height: u32) -> Result<(), ExportError> {
let mut pixels = vec![255u8; (width * height * 4) as usize];
for y in 0..height {
for x in 0..width {
let idx = ((y * width + x) * 4) as usize;
pixels[idx] = (x * 255 / width.max(1)) as u8; pixels[idx + 1] = (y * 255 / height.max(1)) as u8; pixels[idx + 2] = 128; pixels[idx + 3] = 255; }
}
crate::export::save_rgba_png(path, width, height, &pixels)
.map(|_| ())
.map_err(|e| match e {
crate::export::ExportError::Io(io) => ExportError::Io(io),
other => ExportError::Render(other.to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linear_interpolation_endpoints() {
let interp = FrameInterpolator::new(
AnimationParameter::RotationAngle,
0.0,
1.0,
InterpolationMode::Linear,
);
assert!((interp.evaluate(0.0) - 0.0).abs() < 1e-12);
assert!((interp.evaluate(1.0) - 1.0).abs() < 1e-12);
}
#[test]
fn test_linear_interpolation_midpoint() {
let interp = FrameInterpolator::new(
AnimationParameter::Scale,
0.0,
2.0,
InterpolationMode::Linear,
);
assert!((interp.evaluate(0.5) - 1.0).abs() < 1e-12);
}
#[test]
fn test_sinusoidal_interpolation_endpoints() {
let interp = FrameInterpolator::new(
AnimationParameter::ColorHue,
0.0,
360.0,
InterpolationMode::Sinusoidal,
);
assert!((interp.evaluate(0.0) - 0.0).abs() < 1e-9);
assert!((interp.evaluate(1.0) - 360.0).abs() < 1e-9);
}
#[test]
fn test_sinusoidal_interpolation_midpoint() {
let interp = FrameInterpolator::new(
AnimationParameter::Scale,
0.0,
1.0,
InterpolationMode::Sinusoidal,
);
assert!((interp.evaluate(0.5) - 0.5).abs() < 1e-9);
}
#[test]
fn test_interpolation_clamps_t() {
let interp = FrameInterpolator::new(
AnimationParameter::Scale,
1.0,
2.0,
InterpolationMode::Linear,
);
assert!((interp.evaluate(-1.0) - 1.0).abs() < 1e-12);
assert!((interp.evaluate(2.0) - 2.0).abs() < 1e-12);
}
#[test]
fn test_animation_config_duration() {
let cfg = AnimationConfig {
frames: 60,
fps: 30,
..AnimationConfig::default()
};
assert!((cfg.duration_secs() - 2.0).abs() < 1e-12);
}
#[test]
fn test_animation_config_duration_zero_fps() {
let cfg = AnimationConfig {
frames: 30,
fps: 0,
..AnimationConfig::default()
};
assert!((cfg.duration_secs() - 30.0).abs() < 1e-12);
}
#[test]
fn test_frame_path_format() {
let exporter = AnimationExporter::new(
AnimationConfig {
output_dir: PathBuf::from("/tmp/test"),
..AnimationConfig::default()
},
vec![],
);
let p = exporter.frame_path(42);
assert!(p.to_string_lossy().contains("frame_0042.png"));
}
#[test]
fn test_param_values_at_first_frame() {
let interp = FrameInterpolator::new(
AnimationParameter::RotationAngle,
1.0,
5.0,
InterpolationMode::Linear,
);
let exporter = AnimationExporter::new(
AnimationConfig {
frames: 10,
..AnimationConfig::default()
},
vec![interp],
);
let vals = exporter.param_values_at(0);
assert_eq!(vals.len(), 1);
assert!((vals[0].1 - 1.0).abs() < 1e-12);
}
#[test]
fn test_param_values_at_last_frame() {
let interp = FrameInterpolator::new(
AnimationParameter::RotationAngle,
1.0,
5.0,
InterpolationMode::Linear,
);
let exporter = AnimationExporter::new(
AnimationConfig {
frames: 10,
..AnimationConfig::default()
},
vec![interp],
);
let vals = exporter.param_values_at(9);
assert!((vals[0].1 - 5.0).abs() < 1e-12);
}
#[test]
fn test_export_zero_frames_error() {
let exporter = AnimationExporter::new(
AnimationConfig {
frames: 0,
..AnimationConfig::default()
},
vec![],
);
let result = exporter.export(|_, _, _| Ok(()));
assert!(matches!(result, Err(ExportError::InvalidConfig(_))));
}
#[test]
fn test_export_writes_correct_frame_count() {
let dir = tempfile::tempdir().unwrap();
let exporter = AnimationExporter::new(
AnimationConfig {
frames: 5,
fps: 10,
width: 4,
height: 4,
output_dir: dir.path().to_path_buf(),
},
vec![],
);
let stats = exporter.export(|_, _, _| Ok(())).unwrap();
assert_eq!(stats.frames_written, 5);
}
#[test]
fn test_export_propagates_render_error() {
let dir = tempfile::tempdir().unwrap();
let exporter = AnimationExporter::new(
AnimationConfig {
frames: 3,
..AnimationConfig::default().with_dir(dir.path())
},
vec![],
);
let result = exporter.export(|i, _, _| {
if i == 1 {
Err("render failed at frame 1".to_string())
} else {
Ok(())
}
});
assert!(matches!(result, Err(ExportError::Render(_))));
}
#[test]
fn test_write_test_frame_produces_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test_frame.png");
AnimationExporter::write_test_frame(&path, 4, 4).unwrap();
assert!(path.exists());
let meta = std::fs::metadata(&path).unwrap();
assert!(meta.len() > 8, "PNG file should have content");
}
#[test]
fn test_write_test_frame_valid_png_signature() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sig_test.png");
AnimationExporter::write_test_frame(&path, 2, 2).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(
&bytes[..8],
&[137, 80, 78, 71, 13, 10, 26, 10],
"PNG signature mismatch"
);
}
#[test]
fn test_animation_stats_default() {
let stats = AnimationStats::default();
assert_eq!(stats.frames_written, 0);
assert_eq!(stats.duration_ms, 0);
assert_eq!(stats.avg_frame_ms, 0.0);
}
#[test]
fn test_multiple_interpolators() {
let interps = vec![
FrameInterpolator::new(
AnimationParameter::RotationAngle,
0.0,
6.28,
InterpolationMode::Linear,
),
FrameInterpolator::new(
AnimationParameter::ColorHue,
0.0,
360.0,
InterpolationMode::Sinusoidal,
),
];
let exporter = AnimationExporter::new(
AnimationConfig {
frames: 4,
..AnimationConfig::default()
},
interps,
);
let vals = exporter.param_values_at(2);
assert_eq!(vals.len(), 2);
assert_eq!(vals[0].0, AnimationParameter::RotationAngle);
assert_eq!(vals[1].0, AnimationParameter::ColorHue);
}
}
impl AnimationConfig {
fn with_dir(mut self, dir: &Path) -> Self {
self.output_dir = dir.to_path_buf();
self
}
}