use std::collections::VecDeque;
use super::{clamp_u8, validate_buffer, PixelFormat, VideoResult};
#[derive(Debug, Clone)]
pub struct MotionBlurConfig {
pub angle_degrees: f32,
pub samples: usize,
}
impl Default for MotionBlurConfig {
fn default() -> Self {
Self {
angle_degrees: 0.0,
samples: 15,
}
}
}
impl MotionBlurConfig {
#[must_use]
pub const fn horizontal(samples: usize) -> Self {
Self {
angle_degrees: 0.0,
samples,
}
}
#[must_use]
pub const fn vertical(samples: usize) -> Self {
Self {
angle_degrees: 90.0,
samples,
}
}
#[must_use]
pub const fn diagonal(samples: usize) -> Self {
Self {
angle_degrees: 45.0,
samples,
}
}
}
pub struct MotionBlur {
config: MotionBlurConfig,
}
impl MotionBlur {
#[must_use]
pub fn new(config: MotionBlurConfig) -> Self {
Self { config }
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub fn apply(
&self,
data: &mut [u8],
width: usize,
height: usize,
format: PixelFormat,
) -> VideoResult<()> {
validate_buffer(data, width, height, format)?;
if self.config.samples <= 1 {
return Ok(()); }
let bpp = format.bytes_per_pixel();
let angle_rad = self.config.angle_degrees.to_radians();
let cos_a = angle_rad.cos();
let sin_a = angle_rad.sin();
let n = self.config.samples as f32;
let source = data.to_vec();
for py in 0..height {
for px in 0..width {
let mut acc_r = 0.0f32;
let mut acc_g = 0.0f32;
let mut acc_b = 0.0f32;
let mut acc_a = 0.0f32;
for s in 0..self.config.samples {
let t = s as f32 - (n - 1.0) * 0.5;
let sx = px as f32 + t * cos_a;
let sy = py as f32 + t * sin_a;
let pixel = super::sample_bilinear(&source, width, height, bpp, sx, sy);
acc_r += pixel[0];
acc_g += pixel[1];
acc_b += pixel[2];
acc_a += pixel[3];
}
let inv_n = 1.0 / n;
let idx = (py * width + px) * bpp;
data[idx] = clamp_u8(acc_r * inv_n);
data[idx + 1] = clamp_u8(acc_g * inv_n);
data[idx + 2] = clamp_u8(acc_b * inv_n);
if bpp == 4 {
data[idx + 3] = clamp_u8(acc_a * inv_n);
}
}
}
Ok(())
}
}
pub struct MotionBlurCache {
cached_frames: VecDeque<Vec<u8>>,
max_cache_size: usize,
running_sum: Vec<u32>,
}
impl MotionBlurCache {
#[must_use]
pub fn new(max_samples: usize) -> Self {
Self {
cached_frames: VecDeque::new(),
max_cache_size: max_samples.max(1),
running_sum: Vec::new(),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.cached_frames.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cached_frames.is_empty()
}
pub fn push_frame(&mut self, frame: Vec<u8>) {
if !self.running_sum.is_empty() && frame.len() != self.running_sum.len() {
self.cached_frames.clear();
self.running_sum.clear();
}
if self.running_sum.is_empty() {
self.running_sum = vec![0u32; frame.len()];
}
if self.cached_frames.len() == self.max_cache_size {
if let Some(oldest) = self.cached_frames.pop_front() {
for (sum, &old) in self.running_sum.iter_mut().zip(oldest.iter()) {
*sum = sum.saturating_sub(old as u32);
}
}
}
for (sum, &new_val) in self.running_sum.iter_mut().zip(frame.iter()) {
*sum += new_val as u32;
}
self.cached_frames.push_back(frame);
}
#[must_use]
pub fn accumulated_blend(&self) -> Vec<u8> {
let n = self.cached_frames.len();
if n == 0 {
return Vec::new();
}
self.running_sum
.iter()
.map(|&s| (s / n as u32).min(255) as u8)
.collect()
}
pub fn clear(&mut self) {
self.cached_frames.clear();
self.running_sum.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn checkerboard(w: usize, h: usize) -> Vec<u8> {
let mut buf = vec![0u8; w * h * 3];
for py in 0..h {
for px in 0..w {
let val = if (px + py) % 2 == 0 { 255u8 } else { 0u8 };
let idx = (py * w + px) * 3;
buf[idx] = val;
buf[idx + 1] = val;
buf[idx + 2] = val;
}
}
buf
}
#[test]
fn test_motion_blur_horizontal() {
let orig = checkerboard(64, 64);
let mut buf = orig.clone();
let mb = MotionBlur::new(MotionBlurConfig::horizontal(9));
mb.apply(&mut buf, 64, 64, PixelFormat::Rgb)
.expect("apply should succeed");
let diff: u32 = buf
.iter()
.zip(orig.iter())
.map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs())
.sum();
assert!(diff > 0, "Blur should change the image");
}
#[test]
fn test_motion_blur_samples_1_no_change() {
let orig = checkerboard(32, 32);
let mut buf = orig.clone();
let mb = MotionBlur::new(MotionBlurConfig {
samples: 1,
..Default::default()
});
mb.apply(&mut buf, 32, 32, PixelFormat::Rgb)
.expect("apply should succeed");
assert_eq!(buf, orig, "1 sample should not change image");
}
#[test]
fn test_motion_blur_vertical() {
let mut buf = checkerboard(32, 32);
let mb = MotionBlur::new(MotionBlurConfig::vertical(7));
assert!(mb.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_ok());
}
#[test]
fn test_motion_blur_diagonal() {
let mut buf = checkerboard(32, 32);
let mb = MotionBlur::new(MotionBlurConfig::diagonal(7));
assert!(mb.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_ok());
}
#[test]
fn test_motion_blur_rgba() {
let mut buf = vec![200u8; 32 * 32 * 4];
let mb = MotionBlur::new(MotionBlurConfig::default());
assert!(mb.apply(&mut buf, 32, 32, PixelFormat::Rgba).is_ok());
}
#[test]
fn test_motion_blur_wrong_size_err() {
let mut buf = vec![0u8; 5];
let mb = MotionBlur::new(MotionBlurConfig::default());
assert!(mb.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_err());
}
#[test]
fn test_motion_blur_constant_image_unchanged() {
let orig = vec![128u8; 32 * 32 * 3];
let mut buf = orig.clone();
let mb = MotionBlur::new(MotionBlurConfig::horizontal(11));
mb.apply(&mut buf, 32, 32, PixelFormat::Rgb)
.expect("apply should succeed");
assert_eq!(buf, orig, "Constant image should survive blur");
}
#[test]
fn test_motion_blur_cache_correct() {
let frame = vec![100u8; 16 * 16 * 3];
let mut cache = MotionBlurCache::new(3);
cache.push_frame(frame.clone());
cache.push_frame(frame.clone());
cache.push_frame(frame.clone());
assert_eq!(cache.len(), 3);
let blend = cache.accumulated_blend();
assert_eq!(blend.len(), frame.len());
for (&b, &f) in blend.iter().zip(frame.iter()) {
assert_eq!(
b, f,
"blend of 3 identical frames should equal the frame: blend={b}, frame={f}"
);
}
}
#[test]
fn test_motion_blur_cache_rolling() {
let mut cache = MotionBlurCache::new(3);
for value in [10u8, 20, 30, 40, 50] {
let frame = vec![value; 4]; cache.push_frame(frame);
}
assert_eq!(cache.len(), 3, "cache should hold exactly 3 frames");
let blend = cache.accumulated_blend();
assert_eq!(blend.len(), 4);
for &b in &blend {
assert_eq!(
b, 40,
"rolling cache average of last 3 frames (30,40,50) should be 40, got {b}"
);
}
}
}