use super::{clamp_u8, validate_buffer, PixelFormat, VideoResult};
#[derive(Debug, Clone)]
pub struct GrainConfig {
pub intensity: f32,
pub size: u32,
pub monochromatic: bool,
pub shadow_boost: f32,
pub seed: u64,
}
impl Default for GrainConfig {
fn default() -> Self {
Self {
intensity: 12.0,
size: 1,
monochromatic: true,
shadow_boost: 0.3,
seed: 42,
}
}
}
impl GrainConfig {
#[must_use]
pub fn film_35mm() -> Self {
Self {
intensity: 8.0,
size: 1,
monochromatic: true,
shadow_boost: 0.25,
seed: 1,
}
}
#[must_use]
pub fn film_16mm() -> Self {
Self {
intensity: 25.0,
size: 2,
monochromatic: false,
shadow_boost: 0.6,
seed: 2,
}
}
#[must_use]
pub fn digital_noise() -> Self {
Self {
intensity: 6.0,
size: 1,
monochromatic: false,
shadow_boost: 0.1,
seed: 3,
}
}
}
#[inline]
fn noise1(x: u64) -> f32 {
let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z = z ^ (z >> 31);
#[allow(clippy::cast_possible_wrap, clippy::cast_precision_loss)]
let result = (z as i64 as f32) / (i64::MAX as f32);
result
}
pub struct GrainEffect {
config: GrainConfig,
}
impl GrainEffect {
#[must_use]
pub fn new(config: GrainConfig) -> Self {
Self { config }
}
pub fn apply(
&self,
data: &mut [u8],
width: usize,
height: usize,
format: PixelFormat,
) -> VideoResult<()> {
validate_buffer(data, width, height, format)?;
let bpp = format.bytes_per_pixel();
let cfg = &self.config;
let grain_size = cfg.size.max(1) as usize;
for py in 0..height {
for px in 0..width {
let gx = (px / grain_size) as u64;
let gy = (py / grain_size) as u64;
let idx = (py * width + px) * bpp;
let lum =
(f32::from(data[idx]) + f32::from(data[idx + 1]) + f32::from(data[idx + 2]))
/ (3.0 * 255.0);
let shadow_mul = 1.0 + cfg.shadow_boost * (1.0 - lum);
let effective_intensity = cfg.intensity * shadow_mul;
if cfg.monochromatic {
let g = noise1(
gx.wrapping_mul(0x9E37_79B9) ^ gy.wrapping_mul(0x6C62_272E) ^ cfg.seed,
);
let grain_val = g * effective_intensity;
data[idx] = clamp_u8(f32::from(data[idx]) + grain_val);
data[idx + 1] = clamp_u8(f32::from(data[idx + 1]) + grain_val);
data[idx + 2] = clamp_u8(f32::from(data[idx + 2]) + grain_val);
} else {
for c in 0..3 {
let g = noise1(
gx.wrapping_mul(0x9E37_79B9)
^ gy.wrapping_mul(0x6C62_272E)
^ cfg.seed
^ (c as u64 * 0x517C_C1B7),
);
let grain_val = g * effective_intensity;
data[idx + c] = clamp_u8(f32::from(data[idx + c]) + grain_val);
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mid_gray(w: usize, h: usize) -> Vec<u8> {
vec![128u8; w * h * 3]
}
#[test]
fn test_grain_default_applies() {
let mut buf = mid_gray(64, 64);
let g = GrainEffect::new(GrainConfig::default());
assert!(g.apply(&mut buf, 64, 64, PixelFormat::Rgb).is_ok());
}
#[test]
fn test_grain_changes_image() {
let orig = mid_gray(32, 32);
let mut buf = orig.clone();
let g = GrainEffect::new(GrainConfig::default());
g.apply(&mut buf, 32, 32, PixelFormat::Rgb).unwrap();
assert_ne!(buf, orig, "Grain should change pixel values");
}
#[test]
fn test_grain_zero_intensity_unchanged() {
let orig = mid_gray(32, 32);
let mut buf = orig.clone();
let cfg = GrainConfig {
intensity: 0.0,
shadow_boost: 0.0,
..Default::default()
};
let g = GrainEffect::new(cfg);
g.apply(&mut buf, 32, 32, PixelFormat::Rgb).unwrap();
assert_eq!(buf, orig, "Zero intensity should not change image");
}
#[test]
fn test_grain_different_seeds_differ() {
let orig = mid_gray(32, 32);
let mut buf1 = orig.clone();
GrainEffect::new(GrainConfig {
seed: 1,
..Default::default()
})
.apply(&mut buf1, 32, 32, PixelFormat::Rgb)
.unwrap();
let mut buf2 = orig.clone();
GrainEffect::new(GrainConfig {
seed: 2,
..Default::default()
})
.apply(&mut buf2, 32, 32, PixelFormat::Rgb)
.unwrap();
assert_ne!(buf1, buf2, "Different seeds should produce different grain");
}
#[test]
fn test_grain_color_noise() {
let mut buf = mid_gray(32, 32);
let cfg = GrainConfig {
monochromatic: false,
intensity: 20.0,
..Default::default()
};
let g = GrainEffect::new(cfg);
g.apply(&mut buf, 32, 32, PixelFormat::Rgb).unwrap();
let has_color_diff = buf
.chunks_exact(3)
.any(|px| px[0] != px[1] || px[1] != px[2]);
assert!(
has_color_diff,
"Color noise should introduce channel differences"
);
}
#[test]
fn test_grain_film_35mm() {
let mut buf = mid_gray(32, 32);
let g = GrainEffect::new(GrainConfig::film_35mm());
assert!(g.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_ok());
}
#[test]
fn test_grain_film_16mm() {
let mut buf = mid_gray(32, 32);
let g = GrainEffect::new(GrainConfig::film_16mm());
assert!(g.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_ok());
}
#[test]
fn test_grain_large_size_clumps() {
let orig = mid_gray(32, 32);
let mut buf = orig.clone();
let cfg = GrainConfig {
size: 4,
intensity: 30.0,
..Default::default()
};
let g = GrainEffect::new(cfg);
g.apply(&mut buf, 32, 32, PixelFormat::Rgb).unwrap();
let bl = [buf[0], buf[1], buf[2]];
let adjacent = [buf[3], buf[4], buf[5]];
assert_eq!(bl, adjacent, "Pixels within same grain cell should match");
}
#[test]
fn test_grain_rgba() {
let mut buf = vec![128u8; 32 * 32 * 4];
let g = GrainEffect::new(GrainConfig::default());
assert!(g.apply(&mut buf, 32, 32, PixelFormat::Rgba).is_ok());
}
#[test]
fn test_grain_wrong_size_err() {
let mut buf = vec![0u8; 5];
let g = GrainEffect::new(GrainConfig::default());
assert!(g.apply(&mut buf, 32, 32, PixelFormat::Rgb).is_err());
}
}