use rustmotion_core::schema::scenario::{BlurDirection, PostEffect};
pub fn apply_post_effects(
buf: &mut [u8],
w: u32,
h: u32,
effects: &[PostEffect],
frame_index: u32,
) {
for effect in effects {
match effect {
PostEffect::Grain {
intensity,
seed,
animated,
} => {
apply_grain(buf, w, h, *intensity, *seed, *animated, frame_index);
}
PostEffect::Vignette { intensity, radius } => {
apply_vignette(buf, w, h, *intensity, *radius);
}
PostEffect::Pixelate { size } => {
apply_pixelate(buf, w, h, *size);
}
PostEffect::ProgressiveBlur {
direction,
start,
max_radius,
} => {
apply_progressive_blur(buf, w, h, direction, *start, *max_radius);
}
}
}
}
pub fn apply_grain(
buf: &mut [u8],
w: u32,
h: u32,
intensity: f32,
seed: u64,
animated: bool,
frame_index: u32,
) {
let intensity = intensity.clamp(0.0, 1.0);
if intensity == 0.0 {
return;
}
let seed_eff: u64 = if animated {
seed ^ (frame_index as u64)
} else {
seed
};
let amplitude = (intensity * 64.0) as i32;
for y in 0..h {
for x in 0..w {
let h_val = splitmix_hash(seed_eff, x as u64, y as u64);
let offset = (h_val % (2 * amplitude as u64 + 1)) as i32 - amplitude;
let base = ((y * w + x) * 4) as usize;
buf[base] = (buf[base] as i32 + offset).clamp(0, 255) as u8;
buf[base + 1] = (buf[base + 1] as i32 + offset).clamp(0, 255) as u8;
buf[base + 2] = (buf[base + 2] as i32 + offset).clamp(0, 255) as u8;
}
}
}
#[inline(always)]
fn splitmix_hash(seed: u64, x: u64, y: u64) -> u64 {
let mut z = seed
.wrapping_add(x.wrapping_mul(0x9e3779b97f4a7c15))
.wrapping_add(y.wrapping_mul(0x6c62272e07bb0142));
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
z ^ (z >> 31)
}
pub fn apply_vignette(buf: &mut [u8], w: u32, h: u32, intensity: f32, radius: f32) {
let intensity = intensity.clamp(0.0, 1.0);
if intensity == 0.0 {
return;
}
let radius = radius.clamp(0.0, 1.0);
let cx = (w as f32 - 1.0) / 2.0;
let cy = (h as f32 - 1.0) / 2.0;
let diag = (cx * cx + cy * cy).sqrt();
for y in 0..h {
for x in 0..w {
let dx = (x as f32 - cx) / diag;
let dy = (y as f32 - cy) / diag;
let dist = (dx * dx + dy * dy).sqrt();
let factor = 1.0 - intensity * smoothstep(radius, 1.0, dist);
let base = ((y * w + x) * 4) as usize;
buf[base] = (buf[base] as f32 * factor) as u8;
buf[base + 1] = (buf[base + 1] as f32 * factor) as u8;
buf[base + 2] = (buf[base + 2] as f32 * factor) as u8;
}
}
}
#[inline(always)]
fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
pub fn apply_pixelate(buf: &mut [u8], w: u32, h: u32, size: u32) {
let size = size.clamp(1, 256);
if size <= 1 {
return;
}
let bx_count = w.div_ceil(size);
let by_count = h.div_ceil(size);
for by in 0..by_count {
for bx in 0..bx_count {
let x0 = bx * size;
let y0 = by * size;
let x1 = (x0 + size).min(w);
let y1 = (y0 + size).min(h);
let mut sum = [0u32; 4];
let mut count = 0u32;
for py in y0..y1 {
for px in x0..x1 {
let base = ((py * w + px) * 4) as usize;
sum[0] += buf[base] as u32;
sum[1] += buf[base + 1] as u32;
sum[2] += buf[base + 2] as u32;
sum[3] += buf[base + 3] as u32;
count += 1;
}
}
let avg = [
(sum[0] / count) as u8,
(sum[1] / count) as u8,
(sum[2] / count) as u8,
(sum[3] / count) as u8,
];
for py in y0..y1 {
for px in x0..x1 {
let base = ((py * w + px) * 4) as usize;
buf[base] = avg[0];
buf[base + 1] = avg[1];
buf[base + 2] = avg[2];
buf[base + 3] = avg[3];
}
}
}
}
}
pub fn apply_progressive_blur(
buf: &mut [u8],
w: u32,
h: u32,
direction: &BlurDirection,
start: f32,
max_radius: f32,
) {
if max_radius <= 0.0 || w == 0 || h == 0 {
return;
}
let max_radius = max_radius.max(0.0);
let radius_for_row = |y: u32| -> u32 {
let yf = y as f32;
let hf = h as f32;
let start_y = start.clamp(0.0, 1.0) * hf;
let frac = match direction {
BlurDirection::Bottom => {
if yf <= start_y {
0.0
} else {
(yf - start_y) / (hf - start_y).max(1.0)
}
}
BlurDirection::Top => {
let bottom_start = (1.0 - start.clamp(0.0, 1.0)) * hf;
if yf >= bottom_start {
0.0
} else {
(bottom_start - yf) / bottom_start.max(1.0)
}
}
};
(frac * max_radius).round() as u32
};
let len = (w * h * 4) as usize;
let mut tmp = vec![0u8; len];
for y in 0..h {
let r = radius_for_row(y);
if r == 0 {
let row_start = (y * w * 4) as usize;
let row_end = row_start + (w * 4) as usize;
tmp[row_start..row_end].copy_from_slice(&buf[row_start..row_end]);
continue;
}
for x in 0..w {
let x0 = x.saturating_sub(r);
let x1 = (x + r).min(w - 1);
let count = x1 - x0 + 1;
let mut sum = [0u32; 4];
for sx in x0..=x1 {
let base = ((y * w + sx) * 4) as usize;
sum[0] += buf[base] as u32;
sum[1] += buf[base + 1] as u32;
sum[2] += buf[base + 2] as u32;
sum[3] += buf[base + 3] as u32;
}
let base = ((y * w + x) * 4) as usize;
tmp[base] = (sum[0] / count) as u8;
tmp[base + 1] = (sum[1] / count) as u8;
tmp[base + 2] = (sum[2] / count) as u8;
tmp[base + 3] = (sum[3] / count) as u8;
}
}
for y in 0..h {
let r = radius_for_row(y);
if r == 0 {
let row_start = (y * w * 4) as usize;
let row_end = row_start + (w * 4) as usize;
buf[row_start..row_end].copy_from_slice(&tmp[row_start..row_end]);
continue;
}
for x in 0..w {
let y0 = y.saturating_sub(r);
let y1 = (y + r).min(h - 1);
let count = y1 - y0 + 1;
let mut sum = [0u32; 4];
for sy in y0..=y1 {
let base = ((sy * w + x) * 4) as usize;
sum[0] += tmp[base] as u32;
sum[1] += tmp[base + 1] as u32;
sum[2] += tmp[base + 2] as u32;
sum[3] += tmp[base + 3] as u32;
}
let base = ((y * w + x) * 4) as usize;
buf[base] = (sum[0] / count) as u8;
buf[base + 1] = (sum[1] / count) as u8;
buf[base + 2] = (sum[2] / count) as u8;
buf[base + 3] = (sum[3] / count) as u8;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustmotion_core::schema::scenario::{BlurDirection, PostEffect};
fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
let mut buf = vec![0u8; (w * h * 4) as usize];
for i in 0..(w * h) as usize {
buf[i * 4] = r;
buf[i * 4 + 1] = g;
buf[i * 4 + 2] = b;
buf[i * 4 + 3] = 255;
}
buf
}
fn unique_colors(buf: &[u8]) -> usize {
use std::collections::HashSet;
let mut set = HashSet::new();
for chunk in buf.chunks(4) {
set.insert((chunk[0], chunk[1], chunk[2], chunk[3]));
}
set.len()
}
#[test]
fn grain_intensity_zero_is_identity() {
let orig = solid(8, 8, 128, 128, 128);
let mut buf = orig.clone();
apply_grain(&mut buf, 8, 8, 0.0, 42, true, 0);
assert_eq!(buf, orig, "intensity=0 must be identity");
}
#[test]
fn grain_is_deterministic() {
let mut a = solid(8, 8, 128, 128, 128);
let mut b = solid(8, 8, 128, 128, 128);
apply_grain(&mut a, 8, 8, 0.3, 42, true, 5);
apply_grain(&mut b, 8, 8, 0.3, 42, true, 5);
assert_eq!(a, b, "same params must produce identical output");
}
#[test]
fn grain_animated_differs_across_frames() {
let mut f0 = solid(8, 8, 128, 128, 128);
let mut f1 = solid(8, 8, 128, 128, 128);
apply_grain(&mut f0, 8, 8, 0.3, 42, true, 0);
apply_grain(&mut f1, 8, 8, 0.3, 42, true, 1);
assert_ne!(f0, f1, "animated=true: frame 0 and frame 1 must differ");
}
#[test]
fn grain_not_animated_same_across_frames() {
let mut f0 = solid(8, 8, 128, 128, 128);
let mut f1 = solid(8, 8, 128, 128, 128);
apply_grain(&mut f0, 8, 8, 0.3, 42, false, 0);
apply_grain(&mut f1, 8, 8, 0.3, 42, false, 1);
assert_eq!(f0, f1, "animated=false: both frames must be identical");
}
#[test]
fn grain_alpha_preserved() {
let mut buf = solid(4, 4, 200, 200, 200);
for i in 0..16 {
buf[i * 4 + 3] = 100;
}
apply_grain(&mut buf, 4, 4, 0.5, 1, true, 0);
for i in 0..16 {
assert_eq!(buf[i * 4 + 3], 100, "alpha must not change");
}
}
#[test]
fn vignette_center_pixel_unchanged() {
let w = 101u32;
let h = 101u32;
let mut buf = solid(w, h, 200, 200, 200);
apply_vignette(&mut buf, w, h, 0.8, 0.5);
let cx = w / 2;
let cy = h / 2;
let base = ((cy * w + cx) * 4) as usize;
assert_eq!(buf[base], 200, "centre R must be unchanged");
assert_eq!(buf[base + 1], 200, "centre G must be unchanged");
assert_eq!(buf[base + 2], 200, "centre B must be unchanged");
}
#[test]
fn vignette_corners_darker_than_center() {
let w = 100u32;
let h = 100u32;
let mut buf = solid(w, h, 200, 200, 200);
apply_vignette(&mut buf, w, h, 0.8, 0.5);
let corner_r = buf[0] as u16;
let cx = w / 2;
let cy = h / 2;
let center_base = ((cy * w + cx) * 4) as usize;
let center_r = buf[center_base] as u16;
assert!(
corner_r < center_r,
"corners must be darker than centre: corner={corner_r} center={center_r}"
);
}
#[test]
fn vignette_alpha_preserved() {
let mut buf = solid(10, 10, 200, 200, 200);
for i in 0..100 {
buf[i * 4 + 3] = 77;
}
apply_vignette(&mut buf, 10, 10, 0.8, 0.5);
for i in 0..100 {
assert_eq!(buf[i * 4 + 3], 77, "alpha must not change");
}
}
#[test]
fn pixelate_size_one_is_identity() {
let orig = solid(8, 8, 100, 150, 200);
let mut buf = orig.clone();
apply_pixelate(&mut buf, 8, 8, 1);
assert_eq!(buf, orig, "size=1 must be identity");
}
#[test]
fn pixelate_reduces_unique_colors() {
let w = 8u32;
let h = 8u32;
let mut buf = vec![0u8; (w * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let base = ((y * w + x) * 4) as usize;
if (x + y) % 2 == 0 {
buf[base] = 255;
buf[base + 1] = 0;
buf[base + 2] = 0;
} else {
buf[base] = 0;
buf[base + 1] = 0;
buf[base + 2] = 255;
}
buf[base + 3] = 255;
}
}
let colors_before = unique_colors(&buf);
apply_pixelate(&mut buf, w, h, 4);
let colors_after = unique_colors(&buf);
assert!(
colors_after < colors_before,
"pixelate must reduce unique colors: before={colors_before} after={colors_after}"
);
}
#[test]
fn pixelate_uniform_block() {
let mut buf = solid(8, 8, 255, 0, 0);
apply_pixelate(&mut buf, 8, 8, 4);
for chunk in buf.chunks(4) {
assert_eq!(chunk[0], 255);
assert_eq!(chunk[1], 0);
assert_eq!(chunk[2], 0);
}
}
#[test]
fn progressive_blur_rows_above_start_untouched() {
let w = 8u32;
let h = 8u32;
let mut buf = vec![0u8; (w * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let base = ((y * w + x) * 4) as usize;
if (x + y) % 2 == 0 {
buf[base] = 200;
buf[base + 1] = 100;
buf[base + 2] = 50;
} else {
buf[base] = 50;
buf[base + 1] = 100;
buf[base + 2] = 200;
}
buf[base + 3] = 255;
}
}
let orig = buf.clone();
apply_progressive_blur(&mut buf, w, h, &BlurDirection::Bottom, 0.75, 8.0);
for y in 0..6u32 {
for x in 0..w {
let base = ((y * w + x) * 4) as usize;
assert_eq!(
buf[base..base + 4],
orig[base..base + 4],
"row {y}, col {x} must be untouched"
);
}
}
}
#[test]
fn progressive_blur_bottom_reduces_variance() {
let w = 16u32;
let h = 16u32;
let mut buf = vec![0u8; (w * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let base = ((y * w + x) * 4) as usize;
let v = if (x + y) % 2 == 0 { 0u8 } else { 255u8 };
buf[base] = v;
buf[base + 1] = v;
buf[base + 2] = v;
buf[base + 3] = 255;
}
}
let variance_before: f64 = {
let mut sum = 0u64;
let mut sum_sq = 0u64;
let mut n = 0u64;
for y in (h / 2)..h {
for x in 0..w {
let base = ((y * w + x) * 4) as usize;
let v = buf[base] as u64;
sum += v;
sum_sq += v * v;
n += 1;
}
}
let mean = sum as f64 / n as f64;
sum_sq as f64 / n as f64 - mean * mean
};
apply_progressive_blur(&mut buf, w, h, &BlurDirection::Bottom, 0.0, 6.0);
let variance_after: f64 = {
let mut sum = 0u64;
let mut sum_sq = 0u64;
let mut n = 0u64;
for y in (h / 2)..h {
for x in 0..w {
let base = ((y * w + x) * 4) as usize;
let v = buf[base] as u64;
sum += v;
sum_sq += v * v;
n += 1;
}
}
let mean = sum as f64 / n as f64;
sum_sq as f64 / n as f64 - mean * mean
};
assert!(
variance_after < variance_before,
"bottom half variance must drop after blur: before={variance_before:.1} after={variance_after:.1}"
);
}
#[test]
fn grain_then_pixelate_differs_from_pixelate_then_grain() {
let base_buf = solid(8, 8, 128, 100, 80);
let mut a = base_buf.clone();
apply_post_effects(
&mut a,
8,
8,
&[
PostEffect::Grain {
intensity: 0.4,
seed: 7,
animated: false,
},
PostEffect::Pixelate { size: 4 },
],
0,
);
let mut b = base_buf.clone();
apply_post_effects(
&mut b,
8,
8,
&[
PostEffect::Pixelate { size: 4 },
PostEffect::Grain {
intensity: 0.4,
seed: 7,
animated: false,
},
],
0,
);
assert_ne!(a, b, "grain+pixelate must differ from pixelate+grain");
}
#[test]
fn no_effects_is_identity() {
let orig = solid(4, 4, 200, 150, 100);
let mut buf = orig.clone();
apply_post_effects(&mut buf, 4, 4, &[], 0);
assert_eq!(buf, orig);
}
}