use crate::cluster::{Centroid, Cluster};
use crate::orb::{render_static, OrbShape, RenderOptions};
use image::RgbaImage;
use palette::{FromColor, Hsl, IntoColor, Srgb};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use std::f32::consts::TAU;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionShape {
Still,
Lissajous,
Vertical,
Horizontal,
Diagonal,
Breathe,
Twinkle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionSpeed {
Subtle,
Slow,
Lively,
}
impl MotionSpeed {
pub(crate) fn coefficients(self) -> (f32, f32, u32) {
match self {
MotionSpeed::Subtle => (0.02, 0.03, 1),
MotionSpeed::Slow => (0.06, 0.05, 1),
MotionSpeed::Lively => (0.12, 0.10, 2),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionPreset {
Still,
Slow,
Lively,
}
impl MotionPreset {
pub fn split(self) -> (MotionShape, MotionSpeed) {
match self {
MotionPreset::Still => (MotionShape::Still, MotionSpeed::Slow),
MotionPreset::Slow => (MotionShape::Lissajous, MotionSpeed::Slow),
MotionPreset::Lively => (MotionShape::Lissajous, MotionSpeed::Lively),
}
}
}
#[derive(Debug, Clone)]
pub struct AnimateOptions {
pub width: u32,
pub height: u32,
pub orb_size: f32,
pub blur: f32,
pub saturation: f32,
pub motion_shape: MotionShape,
pub motion_speed: MotionSpeed,
pub seed: u64,
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,
motion_shape: MotionShape::Lissajous,
motion_speed: MotionSpeed::Slow,
seed: 0,
background: [0, 0, 0, 255],
shape: OrbShape::Circle,
}
}
}
pub(crate) const FREQ_RATIOS: &[(u32, u32)] = &[(1, 2), (2, 3), (3, 4), (1, 3), (2, 5)];
#[derive(Debug, Clone, Copy)]
struct OrbitParams {
a: u32,
b: u32,
phi_x: f32,
phi_y: f32,
phi_color: f32,
}
fn generate_orbit_params(seed: u64, n_clusters: usize) -> Vec<OrbitParams> {
let mut rng = ChaCha8Rng::seed_from_u64(seed);
(0..n_clusters)
.map(|_| {
let (a, b) = FREQ_RATIOS[rng.gen_range(0..FREQ_RATIOS.len())];
OrbitParams {
a,
b,
phi_x: rng.gen_range(0.0..TAU),
phi_y: rng.gen_range(0.0..TAU),
phi_color: rng.gen_range(0.0..TAU),
}
})
.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()
}
fn modulate_color(rgb: [u8; 3], s_factor: f32, l_factor: f32) -> [u8; 3] {
let srgb = Srgb::new(
rgb[0] as f32 / 255.0,
rgb[1] as f32 / 255.0,
rgb[2] as f32 / 255.0,
);
let mut hsl: Hsl = Hsl::from_color(srgb);
hsl.saturation = (hsl.saturation * s_factor).clamp(0.0, 1.0);
hsl.lightness = (hsl.lightness * l_factor).clamp(0.0, 1.0);
let out: Srgb = hsl.into_color();
[
(out.red.clamp(0.0, 1.0) * 255.0).round() as u8,
(out.green.clamp(0.0, 1.0) * 255.0).round() as u8,
(out.blue.clamp(0.0, 1.0) * 255.0).round() as u8,
]
}
pub fn render_frame(clusters: &[Cluster], opts: &AnimateOptions, t: f32) -> RgbaImage {
let (amp_pos, amp_color, freq_scale) = if opts.motion_shape == MotionShape::Still {
(0.0, 0.0, 0)
} else {
opts.motion_speed.coefficients()
};
let params = generate_orbit_params(opts.seed, clusters.len());
let min_side = opts.width.min(opts.height) as f32;
let scale_x = min_side / opts.width as f32; let scale_y = min_side / opts.height as f32;
let modulated: Vec<Cluster> = clusters
.iter()
.zip(params.iter())
.map(|(c, p)| {
let (dx, dy, radius_factor) = match opts.motion_shape {
MotionShape::Still => (0.0, 0.0, 1.0),
MotionShape::Lissajous => (
amp_pos * scale_x * sin_loop(p.a, t, freq_scale, p.phi_x),
amp_pos * scale_y * sin_loop(p.b, t, freq_scale, p.phi_y),
1.0,
),
MotionShape::Vertical => (
0.0,
amp_pos * scale_y * sin_loop(1, t, freq_scale, p.phi_y),
1.0,
),
MotionShape::Horizontal => (
amp_pos * scale_x * sin_loop(1, t, freq_scale, p.phi_x),
0.0,
1.0,
),
MotionShape::Diagonal => {
let v = sin_loop(1, t, freq_scale, p.phi_x);
(amp_pos * scale_x * v, amp_pos * scale_y * v, 1.0)
}
MotionShape::Breathe => {
let phase = sin_loop(1, t, freq_scale, p.phi_color);
let r = (1.0 + 2.0 * amp_pos * phase).max(0.0);
(0.0, 0.0, r)
}
MotionShape::Twinkle => {
let phase = sin_loop(2, t, freq_scale, p.phi_color);
let r = (1.0 + 5.0 * amp_pos * phase).max(0.0);
(0.0, 0.0, r)
}
};
let new_x = (c.centroid.x + dx).clamp(0.0, 1.0);
let new_y = (c.centroid.y + dy).clamp(0.0, 1.0);
let color_phase = sin_loop(1, t, freq_scale, p.phi_color);
let l_amp = match opts.motion_shape {
MotionShape::Twinkle => amp_color,
_ => amp_color * 0.5,
};
let s_factor = 1.0 + amp_color * color_phase;
let l_factor = 1.0 + l_amp * color_phase;
let new_color = modulate_color(c.color, s_factor, l_factor);
let weight_scale = radius_factor * radius_factor;
Cluster {
color: new_color,
centroid: Centroid { x: new_x, y: new_y },
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)
}
#[cfg(test)]
mod tests {
use super::*;
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 small_opts(motion: MotionPreset) -> AnimateOptions {
let (shape, speed) = motion.split();
AnimateOptions {
width: 64,
height: 64,
orb_size: 1.0,
blur: 0.5,
saturation: 1.0,
motion_shape: shape,
motion_speed: speed,
seed: 12345,
background: [0, 0, 0, 255],
shape: OrbShape::Circle,
}
}
fn opts_with(shape: MotionShape, speed: MotionSpeed) -> AnimateOptions {
AnimateOptions {
width: 64,
height: 64,
orb_size: 1.0,
blur: 0.5,
saturation: 1.0,
motion_shape: shape,
motion_speed: speed,
seed: 12345,
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 = small_opts(MotionPreset::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 = small_opts(MotionPreset::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 = small_opts(MotionPreset::Slow);
let clusters = sample_clusters();
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 0.25);
assert!(
!pixels_equal(&a, &b),
"different t must produce different frames under Slow motion"
);
}
#[test]
fn still_motion_independent_of_t() {
let opts = small_opts(MotionPreset::Still);
let clusters = sample_clusters();
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 0.3);
let c = render_frame(&clusters, &opts, 0.7);
assert!(pixels_equal(&a, &b), "Still: t=0.0 vs t=0.3 must match");
assert!(pixels_equal(&b, &c), "Still: t=0.3 vs t=0.7 must match");
}
#[test]
fn lively_amplitude_larger_than_slow() {
let clusters = sample_clusters();
let slow_opts = small_opts(MotionPreset::Slow);
let lively_opts = small_opts(MotionPreset::Lively);
let slow = render_frame(&clusters, &slow_opts, 0.25);
let lively = render_frame(&clusters, &lively_opts, 0.25);
assert!(
!pixels_equal(&slow, &lively),
"Slow and Lively must render differently at t=0.25"
);
let slow_t0 = render_frame(&clusters, &slow_opts, 0.0);
let lively_t0 = render_frame(&clusters, &lively_opts, 0.0);
let slow_diff: u64 = slow
.as_raw()
.iter()
.zip(slow_t0.as_raw().iter())
.map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs() as u64)
.sum();
let lively_diff: u64 = lively
.as_raw()
.iter()
.zip(lively_t0.as_raw().iter())
.map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs() as u64)
.sum();
assert!(
lively_diff > slow_diff,
"Lively diff ({}) should exceed Slow diff ({}) from t=0 reference",
lively_diff,
slow_diff
);
}
#[test]
fn different_seed_changes_orbit() {
let clusters = sample_clusters();
let mut opts_a = small_opts(MotionPreset::Slow);
let mut opts_b = small_opts(MotionPreset::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 the orbit (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 freq_scale_combinations_have_integer_period() {
for &(a, b) in FREQ_RATIOS {
for speed in [MotionSpeed::Subtle, MotionSpeed::Slow, MotionSpeed::Lively] {
let (_, _, scale) = speed.coefficients();
let prod_a = (a as f32 * 1.0 * scale as f32).fract();
let prod_b = (b as f32 * 1.0 * scale as f32).fract();
assert_eq!(prod_a, 0.0, "a={a} scale={scale}");
assert_eq!(prod_b, 0.0, "b={b} scale={scale}");
}
}
}
#[test]
fn all_shape_speed_combinations_loop_closed() {
let clusters = sample_clusters();
for shape in [
MotionShape::Still,
MotionShape::Lissajous,
MotionShape::Vertical,
MotionShape::Horizontal,
MotionShape::Diagonal,
MotionShape::Breathe,
MotionShape::Twinkle,
] {
for speed in [MotionSpeed::Subtle, MotionSpeed::Slow, MotionSpeed::Lively] {
let opts = opts_with(shape, 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 shape={shape:?} speed={speed:?}"
);
}
}
}
#[test]
fn vertical_does_not_move_horizontally() {
let clusters = vec![cluster([255, 0, 0], 0.5, 0.3, 0.5)];
let opts = opts_with(MotionShape::Vertical, MotionSpeed::Lively);
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 0.25);
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),
"Vertical shape must not shift horizontally"
);
}
#[test]
fn breathe_keeps_center_changes_radius() {
let clusters = sample_clusters();
let opts = opts_with(MotionShape::Breathe, MotionSpeed::Slow);
let a = render_frame(&clusters, &opts, 0.0);
let b = render_frame(&clusters, &opts, 0.5);
assert!(
!pixels_equal(&a, &b),
"Breathe must produce different frame at t=0 vs t=0.5 (radius modulation)"
);
}
#[test]
fn motion_preset_split_back_compat() {
assert_eq!(
MotionPreset::Still.split(),
(MotionShape::Still, MotionSpeed::Slow)
);
assert_eq!(
MotionPreset::Slow.split(),
(MotionShape::Lissajous, MotionSpeed::Slow)
);
assert_eq!(
MotionPreset::Lively.split(),
(MotionShape::Lissajous, MotionSpeed::Lively)
);
}
}