use crate::cluster::{Centroid, Cluster};
use crate::orb::{render_static, 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 MotionPreset {
Still,
Slow,
Lively,
}
impl MotionPreset {
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn coefficients(self) -> (f32, f32, u32) {
match self {
MotionPreset::Still => (0.0, 0.0, 0),
MotionPreset::Slow => (0.06, 0.05, 1),
MotionPreset::Lively => (0.12, 0.10, 2),
}
}
}
#[derive(Debug, Clone)]
pub struct AnimateOptions {
pub width: u32,
pub height: u32,
pub orb_size: f32,
pub blur: f32,
pub saturation: f32,
pub motion: MotionPreset,
pub seed: u64,
}
impl Default for AnimateOptions {
fn default() -> Self {
Self {
width: 1080,
height: 1920,
orb_size: 1.0,
blur: 0.5,
saturation: 1.0,
motion: MotionPreset::Slow,
seed: 0,
}
}
}
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) = opts.motion.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 = amp_pos * scale_x * sin_loop(p.a, t, freq_scale, p.phi_x);
let dy = amp_pos * scale_y * sin_loop(p.b, t, freq_scale, p.phi_y);
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 s_factor = 1.0 + amp_color * color_phase;
let l_factor = 1.0 + (amp_color * 0.5) * color_phase;
let new_color = modulate_color(c.color, s_factor, l_factor);
Cluster {
color: new_color,
centroid: Centroid { x: new_x, y: new_y },
weight: c.weight,
}
})
.collect();
let render_opts = RenderOptions {
width: opts.width,
height: opts.height,
orb_size: opts.orb_size,
blur: opts.blur,
saturation: opts.saturation,
};
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 {
AnimateOptions {
width: 64,
height: 64,
orb_size: 1.0,
blur: 0.5,
saturation: 1.0,
motion,
seed: 12345,
}
}
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 preset in [
MotionPreset::Still,
MotionPreset::Slow,
MotionPreset::Lively,
] {
let (_, _, scale) = preset.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}");
}
}
}
}