use rustmotion_core::schema::scenario::{BlurDirection, PostEffect, VhsTrackingLine};
use rustmotion_core::schema::time::{TimeCtx, TimePoint};
pub fn apply_post_effects(
buf: &mut [u8],
w: u32,
h: u32,
effects: &[PostEffect],
frame_index: u32,
time: f64,
) {
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);
}
PostEffect::Flash {
at,
color,
intensity,
duration,
} => {
apply_flash(buf, at, color, *intensity, *duration, time);
}
PostEffect::Vhs {
at,
duration,
bands,
offset,
noise,
scanlines,
tracking_line,
seed,
} => {
apply_vhs(
buf,
w,
h,
VhsParams {
at,
duration: *duration,
bands: *bands,
offset: *offset,
noise: *noise,
scanlines: *scanlines,
tracking_line: tracking_line.as_ref(),
seed: *seed,
},
time,
);
}
}
}
}
impl<'a> VhsParams<'a> {
pub fn with_tracking(mut self, line: Option<&'a VhsTrackingLine>) -> Self {
self.tracking_line = line;
self
}
}
pub struct VhsParams<'a> {
pub at: &'a TimePoint,
pub duration: f32,
pub bands: u32,
pub offset: f32,
pub noise: f32,
pub scanlines: f32,
pub tracking_line: Option<&'a VhsTrackingLine>,
pub seed: u64,
}
fn scramble(seed: u64) -> u64 {
let mut x = seed.wrapping_add(0x9E3779B97F4A7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
x ^ (x >> 31)
}
fn unit_from(seed: u64) -> f32 {
(scramble(seed) >> 40) as f32 / 16_777_216.0
}
pub fn apply_vhs(buf: &mut [u8], w: u32, h: u32, params: VhsParams, time: f64) {
if params.duration <= 0.0 || params.bands == 0 || w == 0 || h == 0 {
return;
}
let ctx = TimeCtx::default();
let Ok(start) = params.at.resolve_relative(&ctx) else {
return;
};
let elapsed = time - start;
if elapsed < 0.0 || elapsed >= params.duration as f64 {
return;
}
let row_bytes = w as usize * 4;
let band_height = (h as f32 / params.bands as f32).max(1.0);
let tear_step = (elapsed * 12.0).floor() as u64;
let source = buf.to_vec();
for y in 0..h {
let band = (y as f32 / band_height) as u64;
let band_seed = params.seed ^ scramble(band ^ scramble(tear_step));
let shift = ((unit_from(band_seed) * 2.0 - 1.0) * params.offset).round() as i64;
if shift == 0 {
continue;
}
let row = y as usize * row_bytes;
for x in 0..w as i64 {
let from = x - shift;
let target = row + x as usize * 4;
if from < 0 || from >= w as i64 {
buf[target..target + 4].copy_from_slice(&[0, 0, 0, 255]);
} else {
let src = row + from as usize * 4;
let pixel = [
source[src],
source[src + 1],
source[src + 2],
source[src + 3],
];
buf[target..target + 4].copy_from_slice(&pixel);
}
}
}
if params.noise > 0.0 {
let strength = params.noise.clamp(0.0, 1.0);
for (index, px) in buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let n = unit_from(params.seed ^ scramble(index as u64 ^ tear_step)) * 2.0 - 1.0;
let delta = (n * strength * 90.0) as i32;
for channel in px.iter_mut().take(3) {
*channel = (*channel as i32 + delta).clamp(0, 255) as u8;
}
}
}
if params.scanlines > 0.0 {
let keep = 1.0 - params.scanlines.clamp(0.0, 1.0) * 0.6;
for y in (0..h).step_by(2) {
let row = y as usize * row_bytes;
for px in buf[row..row + row_bytes].as_chunks_mut::<4>().0 {
for channel in px.iter_mut().take(3) {
*channel = (*channel as f32 * keep) as u8;
}
}
}
}
if let Some(line) = params.tracking_line {
let travelled = (elapsed as f32 * line.speed).fract();
let centre = travelled * h as f32;
let half = (line.thickness.max(1.0) / 2.0).ceil() as i64;
let (lr, lg, lb) = parse_hex_rgb(&line.color);
for delta in -half..=half {
let y = centre as i64 + delta;
if y < 0 || y >= h as i64 {
continue;
}
let row = y as usize * row_bytes;
for px in buf[row..row + row_bytes].as_chunks_mut::<4>().0 {
px[0] = blend(px[0], lr, 0.75);
px[1] = blend(px[1], lg, 0.75);
px[2] = blend(px[2], lb, 0.75);
}
}
}
}
pub fn apply_flash(
buf: &mut [u8],
at: &TimePoint,
color: &str,
intensity: f32,
duration: f32,
time: f64,
) {
if duration <= 0.0 || intensity <= 0.0 {
return;
}
let ctx = TimeCtx::default();
let Ok(start) = at.resolve_relative(&ctx) else {
return;
};
let dt = time - start;
if dt < 0.0 || dt >= duration as f64 {
return;
}
let alpha = intensity.clamp(0.0, 1.0) * (1.0 - (dt / duration as f64) as f32);
let (fr, fg, fb) = parse_hex_rgb(color);
for px in buf.as_chunks_mut::<4>().0 {
px[0] = blend(px[0], fr, alpha);
px[1] = blend(px[1], fg, alpha);
px[2] = blend(px[2], fb, alpha);
}
}
fn blend(dst: u8, src: u8, a: f32) -> u8 {
(dst as f32 + (src as f32 - dst as f32) * a)
.round()
.clamp(0.0, 255.0) as u8
}
fn parse_hex_rgb(hex: &str) -> (u8, u8, u8) {
let h = hex.trim_start_matches('#');
if h.len() < 6 {
return (255, 255, 255);
}
let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).unwrap_or(255);
(byte(0), byte(2), byte(4))
}
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);
}
}
fn vhs_frame(w: u32, h: u32) -> Vec<u8> {
let mut buf = vec![0u8; (w * h * 4) as usize];
for px in buf.as_chunks_mut::<4>().0 {
px.copy_from_slice(&[128, 128, 128, 255]);
}
buf
}
fn vhs(
duration: f32,
bands: u32,
offset: f32,
noise: f32,
scanlines: f32,
tracking: Option<&VhsTrackingLine>,
) -> VhsParams<'_> {
static AT: TimePoint = TimePoint::Seconds(0.0);
VhsParams {
at: &AT,
duration,
bands,
offset,
noise,
scanlines,
tracking_line: None,
seed: 7,
}
.with_tracking(tracking)
}
#[test]
fn vhs_outside_its_window_changes_nothing() {
let (w, h) = (32u32, 32u32);
let before = vhs_frame(w, h);
for time in [-0.2, 0.6, 5.0] {
let mut buf = before.clone();
apply_vhs(&mut buf, w, h, vhs(0.5, 8, 20.0, 0.0, 0.0, None), time);
assert_eq!(
buf, before,
"a tear bounded by at/duration must leave every frame outside it untouched, \
t={time}"
);
}
}
#[test]
fn vhs_displaces_bands_sideways_inside_its_window() {
let (w, h) = (64u32, 64u32);
let before = vhs_frame(w, h);
let mut buf = before.clone();
apply_vhs(&mut buf, w, h, vhs(0.5, 8, 24.0, 0.0, 0.0, None), 0.2);
assert_ne!(buf, before, "the frame must be torn inside the window");
let black_edges = buf
.as_chunks::<4>()
.0
.iter()
.filter(|px| px[0] == 0 && px[1] == 0 && px[2] == 0)
.count();
assert!(
black_edges > 0,
"a band slid sideways leaves the frame edge empty — that gap is the tear"
);
}
#[test]
fn vhs_is_deterministic_for_one_seed_and_one_instant() {
let (w, h) = (48u32, 48u32);
let mut a = vhs_frame(w, h);
let mut b = vhs_frame(w, h);
apply_vhs(&mut a, w, h, vhs(0.5, 10, 30.0, 0.4, 0.3, None), 0.25);
apply_vhs(&mut b, w, h, vhs(0.5, 10, 30.0, 0.4, 0.3, None), 0.25);
assert_eq!(a, b, "two renders of the same frame must be byte-identical");
}
#[test]
fn vhs_scanlines_darken_every_other_row() {
let (w, h) = (16u32, 16u32);
let mut buf = vhs_frame(w, h);
apply_vhs(&mut buf, w, h, vhs(0.5, 1, 0.0, 0.0, 1.0, None), 0.1);
let row_mean = |y: u32| -> f32 {
let row = (y * w * 4) as usize;
buf[row..row + (w * 4) as usize]
.as_chunks::<4>()
.0
.iter()
.map(|px| px[0] as f32)
.sum::<f32>()
/ w as f32
};
assert!(
row_mean(0) < row_mean(1) - 20.0,
"even rows must be darkened and odd ones left alone: {} vs {}",
row_mean(0),
row_mean(1)
);
}
#[test]
fn vhs_tracking_line_paints_its_colour_somewhere() {
let (w, h) = (32u32, 32u32);
let line = VhsTrackingLine {
color: "#3DA5FF".into(),
speed: 1.0,
thickness: 3.0,
};
let mut buf = vhs_frame(w, h);
apply_vhs(&mut buf, w, h, vhs(1.0, 1, 0.0, 0.0, 0.0, Some(&line)), 0.5);
let blue_rows = buf
.as_chunks::<4>()
.0
.iter()
.filter(|px| px[2] > px[0] + 40)
.count();
assert!(
blue_rows > 0,
"the tracking line must paint its own colour across a band of rows"
);
}
#[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,
0.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,
0.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, 0.0);
assert_eq!(buf, orig);
}
#[test]
fn a_flash_peaks_on_its_own_instant_and_is_gone_after_its_duration() {
let flash = PostEffect::Flash {
at: TimePoint::Seconds(1.0),
color: "#FF0000".to_string(),
intensity: 1.0,
duration: 0.2,
};
let sample = |t: f64| {
let mut buf = vec![0u8; 4 * 4];
apply_post_effects(&mut buf, 2, 2, std::slice::from_ref(&flash), 0, t);
buf[0]
};
assert_eq!(sample(0.5), 0, "before its instant, nothing");
assert_eq!(sample(1.0), 255, "on its instant, full intensity");
assert!(sample(1.1) > 0 && sample(1.1) < 255, "mid-decay");
assert_eq!(sample(1.2), 0, "after its duration, nothing");
assert_eq!(sample(5.0), 0, "long after, nothing");
}
}