use crate::cluster::Cluster;
use crate::orb::{adjust_saturation_pub, render_one_orb, OrbShape, OrbStyle};
use image::RgbaImage;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use std::f32::consts::TAU;
use tiny_skia::{Color, Pixmap};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionDirection {
LeftToRight,
RightToLeft,
TopToBottom,
BottomToTop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionSpeed {
VerySlow,
Slow,
}
impl MotionSpeed {
pub fn cycle_count(self) -> u32 {
match self {
MotionSpeed::VerySlow => 1,
MotionSpeed::Slow => 2,
}
}
}
#[derive(Debug, Clone)]
pub struct AnimateOptions {
pub width: u32,
pub height: u32,
pub orb_size: f32,
pub blur: f32,
pub saturation: f32,
pub direction: MotionDirection,
pub speed: MotionSpeed,
pub seed: u64,
pub count: Option<usize>,
pub background: [u8; 4],
pub shape: OrbShape,
}
impl Default for AnimateOptions {
fn default() -> Self {
Self {
width: 1080,
height: 1920,
orb_size: 1.0,
blur: 0.5,
saturation: 1.0,
direction: MotionDirection::LeftToRight,
speed: MotionSpeed::Slow,
seed: 0,
count: None,
background: [0, 0, 0, 255],
shape: OrbShape::Circle,
}
}
}
const BREATH_RADIUS_MAX_FACTOR: f32 = 1.10;
const BREATH_RADIUS_AMPLITUDE: f32 = 0.10;
#[derive(Debug, Clone, Copy)]
struct OrbParams {
phase: f32,
phi_radius: f32,
phi_blur: f32,
phi_opacity: f32,
style: OrbStyle,
cluster_idx: usize,
cross_axis: f32,
speed_mult: u32,
}
fn pick_weighted(rng: &mut ChaCha8Rng, weights: &[f32], total: f32) -> usize {
if total <= 0.0 || weights.is_empty() {
return 0;
}
debug_assert!(
!weights.is_empty(),
"pick_weighted assumes non-empty weights after early return"
);
let r = rng.gen::<f32>() * total;
let mut acc = 0.0;
for (i, &w) in weights.iter().enumerate() {
acc += w.max(0.0);
if r <= acc {
return i;
}
}
weights.len() - 1
}
fn generate_orb_params(seed: u64, n_orbs: usize, cluster_weights: &[f32]) -> Vec<OrbParams> {
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let total_w: f32 = cluster_weights.iter().map(|w| w.max(0.0)).sum();
(0..n_orbs)
.map(|_| {
let phase = rng.gen_range(0.0..1.0);
let phi_radius = rng.gen_range(0.0..TAU);
let phi_blur = rng.gen_range(0.0..TAU);
let phi_opacity = rng.gen_range(0.0..TAU);
let cross_axis = rng.gen_range(0.0..1.0);
let style = if rng.gen::<u32>() & 1 == 0 {
OrbStyle::Rim
} else {
OrbStyle::Soft
};
let cluster_idx = pick_weighted(&mut rng, cluster_weights, total_w);
let speed_mult = rng.gen_range(1..=2);
OrbParams {
phase,
phi_radius,
phi_blur,
phi_opacity,
style,
cluster_idx,
cross_axis,
speed_mult,
}
})
.collect()
}
#[inline]
fn sin_loop(f: u32, t: f32, scale: u32, phi: f32) -> f32 {
let raw = (f as f32 * t * scale as f32).fract();
(raw * TAU + phi).sin()
}
pub fn render_frame(clusters: &[Cluster], opts: &AnimateOptions, t: f32) -> RgbaImage {
let params = precompute_orb_params(opts, clusters);
render_frame_with_params(clusters, opts, ¶ms, t)
}
#[derive(Debug, Clone)]
pub struct CachedOrbParams {
params: Vec<OrbParams>,
}
pub fn precompute_orb_params(opts: &AnimateOptions, clusters: &[Cluster]) -> CachedOrbParams {
let n_orbs = opts
.count
.unwrap_or(clusters.len())
.min(MAX_ORB_COUNT)
.max(if clusters.is_empty() { 0 } else { 1 });
let cluster_weights: Vec<f32> = clusters.iter().map(|c| c.weight.max(0.0)).collect();
CachedOrbParams {
params: generate_orb_params(opts.seed, n_orbs, &cluster_weights),
}
}
pub fn render_frame_with_params(
clusters: &[Cluster],
opts: &AnimateOptions,
cache: &CachedOrbParams,
t: f32,
) -> RgbaImage {
let cycle = opts.speed.cycle_count();
let params = &cache.params;
let width = opts.width.max(1);
let height = opts.height.max(1);
if let OrbShape::Aquarelle(_) = opts.shape {
return render_frame_aquarelle(clusters, opts, params, t);
}
let mut pixmap =
Pixmap::new(width, height).expect("pixmap allocation should succeed for >0 dimensions");
let [br, bg, bb, ba] = opts.background;
if ba > 0 {
pixmap.fill(Color::from_rgba8(br, bg, bb, ba));
}
if clusters.is_empty() {
return finalize_pixmap(pixmap, width, height);
}
let base_radius_unit = (width.min(height) as f32) * 0.25 * opts.orb_size.max(0.0);
let saturation = opts.saturation.max(0.0);
let base_blur = opts.blur.clamp(0.0, 1.0);
let progress_axis_pixels = match opts.direction {
MotionDirection::LeftToRight | MotionDirection::RightToLeft => width as f32,
MotionDirection::TopToBottom | MotionDirection::BottomToTop => height as f32,
};
for p in params.iter() {
let c = &clusters[p.cluster_idx.min(clusters.len() - 1)];
let r_pixels_max = base_radius_unit * c.weight.max(0.0).sqrt() * BREATH_RADIUS_MAX_FACTOR;
let r_normalized = if progress_axis_pixels > 0.0 {
r_pixels_max / progress_axis_pixels
} else {
0.0
};
let extent = 1.0 + 2.0 * r_normalized;
let advance_steps = (cycle as f32 * p.speed_mult as f32 * t).fract();
let raw = p.phase * extent + advance_steps * extent;
let pos = raw.rem_euclid(extent) - r_normalized;
let (nx, ny) = match opts.direction {
MotionDirection::LeftToRight => (pos, p.cross_axis),
MotionDirection::RightToLeft => (1.0 - pos, p.cross_axis),
MotionDirection::TopToBottom => (p.cross_axis, pos),
MotionDirection::BottomToTop => (p.cross_axis, 1.0 - pos),
};
let radius_factor = 1.0 + 0.10 * sin_loop(1, t, 1, p.phi_radius);
let blur_delta = 0.15 * sin_loop(1, t, 1, p.phi_blur);
let opacity_factor = 1.0 + 0.05 * sin_loop(1, t, 1, p.phi_opacity);
let radius = base_radius_unit * c.weight.max(0.0).sqrt() * radius_factor;
if radius <= 0.0 {
continue;
}
let cx = nx * width as f32;
let cy = ny * height as f32;
let rgb = adjust_saturation_pub(c.color, saturation);
let blur = (base_blur + blur_delta).clamp(0.0, 1.0);
let opacity = opacity_factor.clamp(0.0, 1.0);
render_one_orb(&mut pixmap, (cx, cy), radius, rgb, blur, opacity, p.style);
}
finalize_pixmap(pixmap, width, height)
}
const MAX_ORB_COUNT: usize = 1024;
fn render_frame_aquarelle(
clusters: &[Cluster],
opts: &AnimateOptions,
params: &[OrbParams],
t: f32,
) -> RgbaImage {
use crate::cluster::Centroid;
use crate::orb::{render_static, RenderOptions};
let cycle = opts.speed.cycle_count();
let modulated: Vec<Cluster> = clusters
.iter()
.zip(params.iter())
.map(|(c, p)| {
let advance = (cycle as f32 * p.speed_mult as f32 * t).fract();
let progress = (p.phase + advance).rem_euclid(1.0);
let (nx, ny) = match opts.direction {
MotionDirection::LeftToRight => (progress, c.centroid.y),
MotionDirection::RightToLeft => (1.0 - progress, c.centroid.y),
MotionDirection::TopToBottom => (c.centroid.x, progress),
MotionDirection::BottomToTop => (c.centroid.x, 1.0 - progress),
};
let radius_factor = 1.0 + BREATH_RADIUS_AMPLITUDE * sin_loop(1, t, 1, p.phi_radius);
let weight_scale = radius_factor * radius_factor;
Cluster {
color: c.color,
centroid: Centroid { x: nx, y: ny },
weight: (c.weight * weight_scale).max(0.0),
}
})
.collect();
let render_opts = RenderOptions {
width: opts.width,
height: opts.height,
orb_size: opts.orb_size,
blur: opts.blur,
saturation: opts.saturation,
background: opts.background,
shape: opts.shape,
};
render_static(&modulated, &render_opts)
}
fn finalize_pixmap(pixmap: Pixmap, width: u32, height: u32) -> RgbaImage {
let mut buf = pixmap.take();
for px in buf.chunks_exact_mut(4) {
let a = px[3];
if a == 0 {
px[0] = 0;
px[1] = 0;
px[2] = 0;
} else if a < 255 {
let inv = 255.0 / a as f32;
px[0] = (px[0] as f32 * inv).round().clamp(0.0, 255.0) as u8;
px[1] = (px[1] as f32 * inv).round().clamp(0.0, 255.0) as u8;
px[2] = (px[2] as f32 * inv).round().clamp(0.0, 255.0) as u8;
}
}
RgbaImage::from_raw(width, height, buf)
.expect("raw buffer length matches width * height * 4 by construction")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cluster::Centroid;
fn cluster(color: [u8; 3], cx: f32, cy: f32, weight: f32) -> Cluster {
Cluster {
color,
centroid: Centroid { x: cx, y: cy },
weight,
}
}
fn sample_clusters() -> Vec<Cluster> {
vec![
cluster([220, 60, 60], 0.3, 0.4, 0.5),
cluster([60, 120, 220], 0.7, 0.6, 0.3),
cluster([200, 200, 80], 0.5, 0.2, 0.2),
]
}
fn opts_with(direction: MotionDirection, speed: MotionSpeed) -> AnimateOptions {
AnimateOptions {
width: 64,
height: 64,
orb_size: 1.0,
blur: 0.5,
saturation: 1.0,
direction,
speed,
seed: 12345,
count: None,
background: [0, 0, 0, 255],
shape: OrbShape::Circle,
}
}
fn pixels_equal(a: &RgbaImage, b: &RgbaImage) -> bool {
a.dimensions() == b.dimensions() && a.as_raw() == b.as_raw()
}
#[test]
fn t_zero_and_t_one_match() {
let opts = opts_with(MotionDirection::LeftToRight, MotionSpeed::Slow);
let clusters = sample_clusters();
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 1.0);
assert!(
pixels_equal(&a, &b),
"t=0.0 and t=1.0 must produce identical frames (loop closure)"
);
}
#[test]
fn same_seed_same_t_deterministic() {
let opts = opts_with(MotionDirection::LeftToRight, MotionSpeed::Slow);
let clusters = sample_clusters();
let a = render_frame(&clusters, &opts, 0.37);
let b = render_frame(&clusters, &opts, 0.37);
assert!(
pixels_equal(&a, &b),
"same seed + same t must produce identical frames"
);
}
#[test]
fn different_t_produces_different_frame() {
let opts = opts_with(MotionDirection::LeftToRight, MotionSpeed::Slow);
let clusters = sample_clusters();
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 0.5);
assert!(
!pixels_equal(&a, &b),
"different t must produce different frames under Slow motion"
);
}
#[test]
fn different_seed_changes_layout() {
let clusters = sample_clusters();
let mut opts_a = opts_with(MotionDirection::LeftToRight, MotionSpeed::Slow);
let mut opts_b = opts_with(MotionDirection::LeftToRight, MotionSpeed::Slow);
opts_a.seed = 1;
opts_b.seed = 2;
let a = render_frame(&clusters, &opts_a, 0.25);
let b = render_frame(&clusters, &opts_b, 0.25);
assert!(
!pixels_equal(&a, &b),
"different seed should change orb phase (and hence the frame)"
);
}
#[test]
fn dimensions_match_options() {
let opts = AnimateOptions {
width: 80,
height: 120,
..AnimateOptions::default()
};
let clusters = sample_clusters();
let img = render_frame(&clusters, &opts, 0.1);
assert_eq!(img.width(), 80);
assert_eq!(img.height(), 120);
}
#[test]
fn all_direction_speed_combinations_loop_closed() {
let clusters = sample_clusters();
for dir in [
MotionDirection::LeftToRight,
MotionDirection::RightToLeft,
MotionDirection::TopToBottom,
MotionDirection::BottomToTop,
] {
for speed in [MotionSpeed::VerySlow, MotionSpeed::Slow] {
let opts = opts_with(dir, speed);
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 1.0);
assert!(
pixels_equal(&a, &b),
"loop closure broken for direction={dir:?} speed={speed:?}"
);
}
}
}
#[test]
fn left_to_right_does_not_move_vertically() {
let clusters = vec![cluster([255, 0, 0], 0.5, 0.7, 0.5)];
let opts = opts_with(MotionDirection::LeftToRight, MotionSpeed::Slow);
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 0.5);
let bright_y = |img: &RgbaImage| -> u32 {
let mut best = 0u32;
let mut best_v = 0u32;
for y in 0..img.height() {
let mut row_sum = 0u32;
for x in 0..img.width() {
row_sum += img.get_pixel(x, y)[0] as u32;
}
if row_sum > best_v {
best_v = row_sum;
best = y;
}
}
best
};
assert_eq!(
bright_y(&a),
bright_y(&b),
"LeftToRight must not shift vertically"
);
}
#[test]
fn top_to_bottom_does_not_move_horizontally() {
let clusters = vec![cluster([255, 0, 0], 0.3, 0.5, 0.5)];
let opts = opts_with(MotionDirection::TopToBottom, MotionSpeed::Slow);
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 0.5);
let bright_x = |img: &RgbaImage| -> u32 {
let mut best = 0u32;
let mut best_v = 0u32;
for x in 0..img.width() {
let mut col_sum = 0u32;
for y in 0..img.height() {
col_sum += img.get_pixel(x, y)[0] as u32;
}
if col_sum > best_v {
best_v = col_sum;
best = x;
}
}
best
};
assert_eq!(
bright_x(&a),
bright_x(&b),
"TopToBottom must not shift horizontally"
);
}
#[test]
fn left_to_right_advances_x_over_time() {
let clusters = vec![cluster([255, 255, 255], 0.5, 0.5, 1.0)];
let opts = AnimateOptions {
width: 128,
height: 128,
seed: 7,
..AnimateOptions::default()
};
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 0.25);
assert!(
!pixels_equal(&a, &b),
"LeftToRight must shift horizontally between t=0 and t=0.25"
);
}
#[test]
fn wrap_brings_orb_back_at_t_one() {
let clusters = sample_clusters();
for speed in [MotionSpeed::VerySlow, MotionSpeed::Slow] {
let opts = opts_with(MotionDirection::LeftToRight, speed);
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 1.0);
assert!(
pixels_equal(&a, &b),
"wrap loop must bring frame back at t=1 (speed={speed:?})"
);
}
}
#[test]
fn cycle_count_matches_speed() {
assert_eq!(MotionSpeed::VerySlow.cycle_count(), 1);
assert_eq!(MotionSpeed::Slow.cycle_count(), 2);
}
#[test]
fn count_expands_orb_pool_beyond_clusters() {
let clusters = sample_clusters();
let mut opts = opts_with(MotionDirection::LeftToRight, MotionSpeed::Slow);
opts.count = None;
let img_default = render_frame(&clusters, &opts, 0.0);
opts.count = Some(40);
let img_expanded = render_frame(&clusters, &opts, 0.0);
let mean_r = |img: &RgbaImage| -> f64 {
let mut s = 0u64;
for px in img.pixels() {
s += px[0] as u64;
}
s as f64 / (img.width() as f64 * img.height() as f64)
};
let m_def = mean_r(&img_default);
let m_exp = mean_r(&img_expanded);
assert!(
m_exp > m_def + 0.5,
"expanding count should increase mean brightness; default={m_def}, expanded={m_exp}"
);
}
#[test]
fn style_is_mixed_across_orbs() {
let p = generate_orb_params(7, 64, &[1.0]);
let n_rim = p.iter().filter(|q| q.style == OrbStyle::Rim).count();
let n_soft = p.iter().filter(|q| q.style == OrbStyle::Soft).count();
assert!(
(20..=44).contains(&n_rim),
"Rim count out of expected band 20..=44; got rim={n_rim} soft={n_soft}"
);
assert!(
(20..=44).contains(&n_soft),
"Soft count out of expected band 20..=44; got rim={n_rim} soft={n_soft}"
);
}
#[test]
fn breath_axes_are_independent() {
let p = generate_orb_params(42, 16, &[1.0]);
let mut all_three_same = 0;
for op in &p {
if (op.phi_radius - op.phi_blur).abs() < 1e-6
&& (op.phi_blur - op.phi_opacity).abs() < 1e-6
{
all_three_same += 1;
}
}
assert_eq!(
all_three_same, 0,
"breath axes must not be synchronized for any orb"
);
}
#[test]
fn speed_mult_distribution() {
let p = generate_orb_params(99, 64, &[1.0]);
let n1 = p.iter().filter(|q| q.speed_mult == 1).count();
let n2 = p.iter().filter(|q| q.speed_mult == 2).count();
assert!(
(20..=44).contains(&n1),
"speed_mult=1 count out of expected band 20..=44; got n1={n1}, n2={n2}"
);
assert!(
(20..=44).contains(&n2),
"speed_mult=2 count out of expected band 20..=44; got n1={n1}, n2={n2}"
);
for q in &p {
assert!(
(1..=2).contains(&q.speed_mult),
"speed_mult must be 1 or 2; got {}",
q.speed_mult
);
}
}
#[test]
fn breath_phases_are_seeded_per_orb_and_per_axis() {
let p = generate_orb_params(42, 16, &[1.0]);
for op in &p {
assert!(
(op.phi_radius - op.phi_blur).abs() > 1e-6
|| (op.phi_blur - op.phi_opacity).abs() > 1e-6,
"breath axes must not all share the same phase: phi_radius={} phi_blur={} phi_opacity={}",
op.phi_radius,
op.phi_blur,
op.phi_opacity
);
}
let mut min_r = f32::INFINITY;
let mut max_r = f32::NEG_INFINITY;
let mut min_b = f32::INFINITY;
let mut max_b = f32::NEG_INFINITY;
let mut min_o = f32::INFINITY;
let mut max_o = f32::NEG_INFINITY;
for op in &p {
min_r = min_r.min(op.phi_radius);
max_r = max_r.max(op.phi_radius);
min_b = min_b.min(op.phi_blur);
max_b = max_b.max(op.phi_blur);
min_o = min_o.min(op.phi_opacity);
max_o = max_o.max(op.phi_opacity);
}
assert!(
max_r - min_r > 1.0,
"phi_radius spread too narrow ({} .. {})",
min_r,
max_r
);
assert!(
max_b - min_b > 1.0,
"phi_blur spread too narrow ({} .. {})",
min_b,
max_b
);
assert!(
max_o - min_o > 1.0,
"phi_opacity spread too narrow ({} .. {})",
min_o,
max_o
);
}
#[test]
fn wrap_buffer_keeps_orbs_offscreen_at_seam() {
let clusters = vec![cluster([255, 255, 255], 0.5, 0.5, 1.0)];
let width = 128u32;
let height = 128u32;
let opts = AnimateOptions {
width,
height,
orb_size: 1.0,
seed: 11,
count: Some(1),
direction: MotionDirection::LeftToRight,
speed: MotionSpeed::VerySlow,
..AnimateOptions::default()
};
let params = generate_orb_params(opts.seed, 1, &[1.0]);
let p = params[0];
let base_radius_unit = (width.min(height) as f32) * 0.25;
let r_pixels_max = base_radius_unit * 1.0_f32.sqrt() * BREATH_RADIUS_MAX_FACTOR;
let r_normalized = r_pixels_max / width as f32;
let extent = 1.0 + 2.0 * r_normalized;
let cycle = opts.speed.cycle_count() as f32 * p.speed_mult as f32;
let mut found_offscreen_t: Option<f32> = None;
for i in 0..1000 {
let t = i as f32 / 1000.0;
let advance_steps = (cycle * t).fract();
let raw = p.phase * extent + advance_steps * extent;
let pos = raw.rem_euclid(extent) - r_normalized;
if pos <= -r_normalized + 0.001 || pos >= 1.0 + r_normalized - 0.001 {
found_offscreen_t = Some(t);
break;
}
}
let t_off = found_offscreen_t.expect("should find an off-screen instant within [0,1)");
let img = render_frame(&clusters, &opts, t_off);
let mut max_r = 0u8;
for px in img.pixels() {
if px[0] > max_r {
max_r = px[0];
}
}
assert!(
max_r < 16,
"off-screen orb should not contribute visible pixels; max_r={max_r} at t={t_off}"
);
}
#[test]
fn loop_continuity_at_t1_with_speed_mult() {
let clusters = sample_clusters();
let mut opts = opts_with(MotionDirection::LeftToRight, MotionSpeed::Slow);
opts.count = Some(64);
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 1.0);
assert!(
pixels_equal(&a, &b),
"loop must close at t=1 even with mixed speed_mult"
);
}
}