diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index def55ac..0bb8918 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -312,6 +312,10 @@ pub struct Scene { /// (world) Keep this scene visible after its time window ends. #[serde(default)] pub persist: bool, + /// Post-processing effects applied to the full frame buffer after Skia renders. + /// Effects are additive and applied in declaration order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub effects: Vec, /// Post-resolution background (populated by include.rs, ignored by serde). #[serde(skip)] #[schemars(skip)] @@ -363,6 +367,91 @@ fn default_camera_zoom() -> f32 { 1.0 } +/// Direction for progressive blur. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum BlurDirection { + Top, + Bottom, +} + +impl Default for BlurDirection { + fn default() -> Self { + BlurDirection::Bottom + } +} + +/// A post-processing effect applied to the full frame buffer after Skia renders. +/// Effects are pure Rust, deterministic, and applied in declaration order. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PostEffect { + /// Film grain noise overlaid on every pixel. + Grain { + /// Noise strength clamped to 0..1. Default: 0.15. + #[serde(default = "default_grain_intensity")] + intensity: f32, + /// Base seed for the noise hash. Default: 42. + #[serde(default = "default_grain_seed")] + seed: u64, + /// When true, the pattern changes per frame (`seed ^ frame_index`). Default: true. + #[serde(default = "default_true")] + animated: bool, + }, + /// Darken the frame edges towards the corners. + Vignette { + /// Darkness strength clamped to 0..1. Default: 0.5. + #[serde(default = "default_vignette_intensity")] + intensity: f32, + /// Fraction of the half-diagonal where darkening starts. Default: 0.75. + #[serde(default = "default_vignette_radius")] + radius: f32, + }, + /// Reduce spatial resolution by averaging square pixel blocks. + Pixelate { + /// Block size in pixels, clamped to 1..=256. Default: 8. + #[serde(default = "default_pixelate_size")] + size: u32, + }, + /// Blur that grows from zero at `start` to `max_radius` at the frame edge. + ProgressiveBlur { + /// Which edge gets the maximum blur. Default: bottom. + #[serde(default)] + direction: BlurDirection, + /// Fraction of the frame height where blur begins (0.0..1.0). Default: 0.5. + #[serde(default = "default_blur_start")] + start: f32, + /// Maximum box-blur radius in pixels at the far edge. Default: 12.0. + #[serde(default = "default_blur_max_radius")] + max_radius: f32, + }, +} + +fn default_grain_intensity() -> f32 { + 0.15 +} +fn default_grain_seed() -> u64 { + 42 +} +fn default_true() -> bool { + true +} +fn default_vignette_intensity() -> f32 { + 0.5 +} +fn default_vignette_radius() -> f32 { + 0.75 +} +fn default_pixelate_size() -> u32 { + 8 +} +fn default_blur_start() -> f32 { + 0.5 +} +fn default_blur_max_radius() -> f32 { + 12.0 +} + /// Scene-level flex layout configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SceneLayout { diff --git a/crates/rustmotion-html/src/lib.rs b/crates/rustmotion-html/src/lib.rs index 28ca81b..0ac625e 100644 --- a/crates/rustmotion-html/src/lib.rs +++ b/crates/rustmotion-html/src/lib.rs @@ -53,6 +53,8 @@ pub enum HtmlError { MissingDuration, #[error("background attribute contains invalid JSON: {0}")] InvalidBackgroundJson(String), + #[error("effects attribute contains invalid JSON: {0}")] + InvalidEffectsJson(String), #[error("anim attribute contains invalid JSON: {0}")] InvalidAnimJson(String), #[error("invalid anim DSL: {0}")] diff --git a/crates/rustmotion-html/src/scene.rs b/crates/rustmotion-html/src/scene.rs index e061167..7f711de 100644 --- a/crates/rustmotion-html/src/scene.rs +++ b/crates/rustmotion-html/src/scene.rs @@ -7,6 +7,12 @@ use crate::parse_background_attr; use crate::style::coerce_value; use crate::HtmlError; +/// Parse an `effects` attribute value: must be a JSON array. +fn parse_effects_attr(raw: &str) -> Result { + let trimmed = raw.trim(); + serde_json::from_str(trimmed).map_err(|e| HtmlError::InvalidEffectsJson(e.to_string())) +} + /// Map a `` element to a scene JSON object. Defaults to a centered flex /// layout (overridable via `align`/`justify`/`direction`/`gap`/`padding` attrs). pub(crate) fn scene_to_value(handle: &Handle) -> Result { @@ -42,6 +48,18 @@ pub(crate) fn scene_to_value(handle: &Handle) -> Result { obj.insert("background".into(), parse_background_attr(&bg)?); } + // effects: JSON array of PostEffect objects + if let Some(fx) = get("effects") { + let parsed = parse_effects_attr(&fx)?; + // Validate that it is an array (not just any JSON value) + if !parsed.is_array() { + return Err(HtmlError::InvalidEffectsJson( + "effects must be a JSON array".into(), + )); + } + obj.insert("effects".into(), parsed); + } + // transition: type is required; duration and easing are optional extras let has_transition_params = get("transition-duration").is_some() || get("transition-easing").is_some(); @@ -127,6 +145,67 @@ mod tests { ); } + // --- effects --- + + #[test] + fn scene_effects_json_array_transpiled() { + let dom = crate::parse_fragment_dom( + r##""##, + ); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let v = scene_to_value(&el).unwrap(); + assert_eq!(v["effects"][0]["type"], json!("grain")); + assert_eq!(v["effects"][0]["intensity"], json!(0.2)); + } + + #[test] + fn scene_effects_invalid_json_is_error() { + let dom = + crate::parse_fragment_dom(r#""#); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let err = scene_to_value(&el).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::InvalidEffectsJson(_)), + "expected InvalidEffectsJson, got: {err:?}" + ); + } + + #[test] + fn scene_effects_non_array_is_error() { + let dom = crate::parse_fragment_dom( + r##""##, + ); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let err = scene_to_value(&el).unwrap_err(); + assert!( + matches!(err, crate::HtmlError::InvalidEffectsJson(_)), + "expected InvalidEffectsJson (non-array), got: {err:?}" + ); + } + + #[test] + fn scene_without_effects_has_no_effects_key() { + let dom = crate::parse_fragment_dom(r#""#); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let v = scene_to_value(&el).unwrap(); + assert!( + v.get("effects").is_none(), + "effects key must be absent when not declared" + ); + } + + #[test] + fn scene_effects_multiple_entries() { + let dom = crate::parse_fragment_dom( + r##""##, + ); + let el = crate::find_element(&dom.document, "scene").unwrap(); + let v = scene_to_value(&el).unwrap(); + assert_eq!(v["effects"].as_array().unwrap().len(), 2); + assert_eq!(v["effects"][0]["type"], json!("vignette")); + assert_eq!(v["effects"][1]["type"], json!("pixelate")); + } + // --- transition with duration and easing --- #[test] diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index b1f3b34..45490e1 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -78,8 +78,8 @@ pub fn render_frame_task_scaled( scale_factor: f32, ) -> Result> { use crate::engine::render::{ - render_scene_bg_scaled, render_scene_fg_scaled, render_scene_frame, - render_scene_frame_scaled, render_scene_frame_scaled_with_prev_bg, + post_effects::apply_post_effects, render_scene_bg_scaled, render_scene_fg_scaled, + render_scene_frame, render_scene_frame_scaled, render_scene_frame_scaled_with_prev_bg, }; match task { @@ -97,14 +97,24 @@ pub fn render_frame_task_scaled( } else { None }; - render_scene_frame_scaled_with_prev_bg( + let mut pixels = render_scene_frame_scaled_with_prev_bg( config, scene, *frame_in_scene, *scene_total_frames, scale_factor, prev_bg, - ) + )?; + let scaled_w = (config.width as f32 * scale_factor) as u32; + let scaled_h = (config.height as f32 * scale_factor) as u32; + apply_post_effects( + &mut pixels, + scaled_w, + scaled_h, + &scene.effects, + *frame_in_scene, + ); + Ok(pixels) } FrameTask::SlideTransition { view_idx, @@ -158,7 +168,8 @@ pub fn render_frame_task_scaled( *scene_b_total_frames, scale_factor, )?; - return Ok(camera_pan_transition( + // CameraPan composites two scenes; apply scene_b effects to the composited result. + let mut composited = camera_pan_transition( &bg, &fg_a, &fg_b, @@ -168,7 +179,15 @@ pub fn render_frame_task_scaled( dx * scale_factor, dy * scale_factor, easing, - )); + ); + apply_post_effects( + &mut composited, + scaled_w, + scaled_h, + &scenes[*scene_b_idx].effects, + *frame_in_transition, + ); + return Ok(composited); } let (frame_a, frame_b) = if scale_factor == 1.0 { @@ -203,14 +222,26 @@ pub fn render_frame_task_scaled( (a, b) }; - Ok(apply_transition( + // For slide transitions, apply effects of scene_b to the composited result. + // Rationale: the transition is the "entry" of scene_b; its post-effects + // (e.g. vignette) should appear on the blended frames to avoid a + // jarring pop when the transition ends and Normal frames begin. + let mut composited = apply_transition( &frame_a, &frame_b, scaled_w, scaled_h, progress, transition_type, - )) + ); + apply_post_effects( + &mut composited, + scaled_w, + scaled_h, + &scenes[*scene_b_idx].effects, + *frame_in_transition, + ); + Ok(composited) } FrameTask::WorldFrame { view_idx, diff --git a/crates/rustmotion/src/engine/render/mod.rs b/crates/rustmotion/src/engine/render/mod.rs index 16cb7ac..7b97a76 100644 --- a/crates/rustmotion/src/engine/render/mod.rs +++ b/crates/rustmotion/src/engine/render/mod.rs @@ -1,5 +1,6 @@ mod background; mod canvas_guard; +pub mod post_effects; mod scene; pub(crate) use canvas_guard::CanvasGuard; diff --git a/crates/rustmotion/src/engine/render/post_effects.rs b/crates/rustmotion/src/engine/render/post_effects.rs new file mode 100644 index 0000000..6bb5327 --- /dev/null +++ b/crates/rustmotion/src/engine/render/post_effects.rs @@ -0,0 +1,658 @@ +//! Pure-Rust frame-buffer post-processing effects. +//! +//! All functions operate on a flat RGBA8888 byte slice (`w * h * 4` bytes, row-major). +//! They are deterministic: same parameters and `frame_index` always yield the same +//! output. Alpha is always preserved unchanged by every effect. +//! +//! # Performance notes +//! - **grain / vignette / pixelate**: O(w·h), no allocation. +//! - **progressive_blur**: O(w·h·max_radius). For a 1920×1080 frame with +//! `max_radius = 12`, this is roughly 25M iterations (two-pass separable box blur +//! with a variable window per row). Expect ~10–30ms on a modern core. Use only +//! when encoding offline; avoid on the studio preview hot path if performance matters. + +use rustmotion_core::schema::scenario::{BlurDirection, PostEffect}; + +/// Apply a sequence of post-processing effects in order to an RGBA frame buffer. +/// +/// `buf` must be exactly `w * h * 4` bytes (RGBA8888, row-major). +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); + } + } + } +} + +// ─── Grain ─────────────────────────────────────────────────────────────────── + +/// Overlay film-grain noise on every pixel. +/// +/// Uses a deterministic splitmix64-style hash keyed on `(seed_effective, x, y)` +/// to produce a per-pixel offset in [−intensity·64, +intensity·64] added to R, G, B. +/// When `animated = true`, `seed_effective = seed ^ frame_index` so successive frames +/// show different noise patterns. +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); + // Map to [−amplitude, +amplitude] + 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; + // alpha [base + 3] untouched + } + } +} + +/// Deterministic hash for grain: three-round splitmix64 over a compound key. +#[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) +} + +// ─── Vignette ──────────────────────────────────────────────────────────────── + +/// Darken pixels towards the corners using a smooth radial falloff. +/// +/// `radius` is the fraction of the half-diagonal at which darkening begins (default 0.75). +/// The multiplier at each pixel is `1 - intensity × smoothstep(radius, 1.0, d)`, +/// where `d` is the normalised distance from the image centre (0=centre, 1=corner). +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; + // Half-diagonal (normalisation factor so distance = 1 at the corner) + 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; + // alpha untouched + } + } +} + +/// Hermite-based smooth step between `edge0` and `edge1`. +#[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) +} + +// ─── Pixelate ──────────────────────────────────────────────────────────────── + +/// Reduce spatial resolution by averaging `size×size` blocks. +/// +/// Partial blocks at the right/bottom edges are handled correctly. +/// Alpha is averaged too for consistency (no special treatment). +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 + size - 1) / size; + let by_count = (h + size - 1) / 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); + + // Compute block average + 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, + ]; + + // Write average to every pixel in the block + 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]; + } + } + } + } +} + +// ─── Progressive blur ──────────────────────────────────────────────────────── + +/// Apply a separable box blur whose radius grows linearly from 0 at `start·h` +/// to `max_radius` at the frame edge in the given `direction`. +/// +/// # Algorithm +/// Two-pass separable blur (horizontal then vertical). Each pass iterates every +/// pixel and for each row computes the blur radius at that row's distance from the +/// `start` boundary. The radius is 0 (no-op) above `start` and increases linearly +/// to `max_radius` at the far edge. +/// +/// # Cost +/// O(w·h·max_radius) — two passes, each O(w·h) with an inner sliding-window of +/// max size `2*max_radius+1`. For 1920×1080 with `max_radius=12` that is roughly +/// 50M byte reads/writes per effect, taking ~15–40ms on a typical core. +/// +/// # Rows untouched above `start` +/// Any row where the computed radius rounds to 0 is copied verbatim (identity). +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); + + // For each row, compute the blur radius based on distance from start boundary. + // Returns radius in pixels (0 means no blur for that row). + 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 + }; + + // Pass 1: horizontal box blur into a temporary buffer. + 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 { + // Identity row — copy directly + 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; + } + // Sliding window horizontal blur for this row + for x in 0..w { + let x0 = x.saturating_sub(r); + let x1 = (x + r).min(w - 1); + let count = (x1 - x0 + 1) as u32; + 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; + } + } + + // Pass 2: vertical box blur from tmp back into buf. + for y in 0..h { + let r = radius_for_row(y); + if r == 0 { + // Identity row + 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) as u32; + 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; + } + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::schema::scenario::{BlurDirection, PostEffect}; + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// Solid-colour 4×4 RGBA buffer (all pixels set to (r, g, b, 255)). + fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> Vec { + 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 + } + + /// Count distinct RGBA colours in a buffer. + 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() + } + + // ── Grain ──────────────────────────────────────────────────────────────── + + #[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); + // Set non-255 alpha to verify it is untouched + 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"); + } + } + + // ── Vignette ───────────────────────────────────────────────────────────── + + #[test] + fn vignette_center_pixel_unchanged() { + // Use a large odd-dimension canvas so the centre pixel is exact. + 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); + + // Centre pixel: distance = 0 → smoothstep = 0 → factor = 1 → unchanged + 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); + + // Top-left corner (0, 0) + let corner_r = buf[0] as u16; + // Centre pixel + 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"); + } + } + + // ── Pixelate ───────────────────────────────────────────────────────────── + + #[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() { + // Checkerboard 1×1 pattern: alternating red/blue pixels + 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() { + // Solid red 8×8; after pixelate every block is still the same (red). + 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); + } + } + + // ── Progressive blur ───────────────────────────────────────────────────── + + #[test] + fn progressive_blur_rows_above_start_untouched() { + // Checkerboard 1×1 pattern: each pixel alternates between two 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] = 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(); + // start=0.75: only the bottom 25% (rows 6..8) get blur. + apply_progressive_blur(&mut buf, w, h, &BlurDirection::Bottom, 0.75, 8.0); + + // Rows 0..5 (above start=0.75*8=6) must be pixel-exact. + 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() { + // Checkerboard pattern; bottom half should have reduced color variance after blur. + 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; + } + } + // Save original variance in bottom rows + 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}" + ); + } + + // ── Effect ordering ─────────────────────────────────────────────────────── + + #[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"); + } + + // ── apply_post_effects no-op on empty slice ─────────────────────────────── + + #[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); + } +} diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 879ac9e..a029be2 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -2096,3 +2096,111 @@ mod motion_blur_trail { ); } } + +// ─── Post-effects pipeline tests ────────────────────────────────────────────── + +#[cfg(test)] +mod post_effects_pipeline { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + use crate::loader::load_scenario_from_source; + + /// Render frame 0 of the first scene in a scenario JSON string. + fn render_first_frame(json: &str) -> Vec { + let scenario = load_scenario_from_source(None, Some(json)).expect("load"); + let tasks = build_frame_tasks(&scenario); + let task = tasks + .iter() + .find(|t| matches!(t, FrameTask::Normal { .. })) + .expect("normal task"); + render_frame_task(&scenario.video, &scenario, task).expect("render") + } + + #[test] + fn vignette_makes_corners_darker_than_center() { + // A scene with strong vignette on a light background — corners must be darker. + // Using JSON string concatenation to avoid raw string + hex conflicts. + let bg = "#ffffff"; + let with_vignette = format!( + r#"{{"video":{{"width":100,"height":100,"background":"{bg}"}},"scenes":[{{"duration":1.0,"effects":[{{"type":"vignette","intensity":0.9,"radius":0.3}}],"children":[]}}]}}"# + ); + let without_vignette = format!( + r#"{{"video":{{"width":100,"height":100,"background":"{bg}"}},"scenes":[{{"duration":1.0,"children":[]}}]}}"# + ); + + let buf_v = render_first_frame(&with_vignette); + let buf_plain = render_first_frame(&without_vignette); + + // Top-left corner pixel (0, 0) — RGBA at byte 0 + let corner_r_vignette = buf_v[0] as u16; + let corner_r_plain = buf_plain[0] as u16; + assert!( + corner_r_vignette < corner_r_plain, + "vignette corner must be darker: vignette={corner_r_vignette} plain={corner_r_plain}" + ); + + // Centre pixel (50, 50) in a 100×100 image + let center_base = (50 * 100 + 50) * 4; + let center_r_vignette = buf_v[center_base] as u16; + let center_r_plain = buf_plain[center_base] as u16; + assert!( + center_r_vignette >= center_r_plain.saturating_sub(5), + "vignette center should be approximately unchanged: vignette={center_r_vignette} plain={center_r_plain}" + ); + } + + #[test] + fn scene_effects_field_defaults_to_empty() { + // A scene without an effects key must parse without error and render cleanly. + let json = + r#"{"video":{"width":32,"height":32},"scenes":[{"duration":1.0,"children":[]}]}"#; + let scenario = load_scenario_from_source(None, Some(json)).expect("load"); + let scene = &scenario.views[0].scenes[0]; + assert!( + scene.effects.is_empty(), + "effects must default to empty Vec" + ); + } + + #[test] + fn grain_effect_changes_buffer_vs_no_effect() { + // Grain at high intensity should change pixels on a plain background. + let bg = "#808080"; + let with_grain = format!( + r#"{{"video":{{"width":32,"height":32,"background":"{bg}"}},"scenes":[{{"duration":1.0,"effects":[{{"type":"grain","intensity":0.5,"seed":42,"animated":false}}],"children":[]}}]}}"# + ); + let without = format!( + r#"{{"video":{{"width":32,"height":32,"background":"{bg}"}},"scenes":[{{"duration":1.0,"children":[]}}]}}"# + ); + let a = render_first_frame(&with_grain); + let b = render_first_frame(&without); + assert_ne!(a, b, "grain effect must change the buffer"); + } + + #[test] + fn post_effect_schema_deserializes_all_variants() { + use rustmotion_core::schema::scenario::PostEffect; + let cases = [ + r#"{"type":"grain","intensity":0.2,"seed":10,"animated":true}"#, + r#"{"type":"vignette","intensity":0.6,"radius":0.8}"#, + r#"{"type":"pixelate","size":8}"#, + r#"{"type":"progressive_blur","direction":"bottom","start":0.5,"max_radius":12.0}"#, + r#"{"type":"progressive_blur","direction":"top","start":0.3,"max_radius":8.0}"#, + ]; + for case in &cases { + serde_json::from_str::(case) + .unwrap_or_else(|e| panic!("failed: {e}\nJSON: {case}")); + } + } + + #[test] + fn post_effect_unknown_type_fails() { + // Unknown type tags must produce a deserialization error. + use rustmotion_core::schema::scenario::PostEffect; + let bad = r#"{"type":"unknown_effect"}"#; + let result = serde_json::from_str::(bad); + assert!( + result.is_err(), + "unknown effect type must fail to deserialize" + ); + } +}