use std::time::{Duration, Instant};
use super::types::{BlendMode, EffectLayer, EffectType};
#[derive(Debug, Clone)]
pub struct EffectInstance {
pub id: String,
pub effect_type: EffectType,
pub target_fixtures: Vec<String>, pub priority: u8, pub layer: EffectLayer, pub blend_mode: BlendMode, pub start_time: Option<Instant>, pub cue_time: Option<Duration>, pub up_time: Option<Duration>, pub hold_time: Option<Duration>, pub down_time: Option<Duration>, pub enabled: bool,
}
impl EffectInstance {
pub fn new(
id: String,
effect_type: EffectType,
target_fixtures: Vec<String>,
up_time: Option<Duration>,
hold_time: Option<Duration>,
down_time: Option<Duration>,
) -> Self {
let duration = if matches!(&effect_type, EffectType::Dimmer { .. }) {
None
} else {
Some(effect_type.duration())
};
let default_hold_time = if matches!(&effect_type, EffectType::Dimmer { .. }) {
None
} else {
duration
};
let final_hold_time = hold_time.or(default_hold_time);
Self {
id,
effect_type,
target_fixtures,
priority: 0,
layer: EffectLayer::Background,
blend_mode: BlendMode::Replace,
start_time: None,
cue_time: None,
up_time,
hold_time: final_hold_time,
down_time,
enabled: true,
}
}
#[cfg(test)]
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = priority;
self
}
#[cfg(test)]
pub fn with_timing(mut self, start_time: Option<Instant>, hold_time: Option<Duration>) -> Self {
self.start_time = start_time;
self.hold_time = hold_time;
self
}
pub fn calculate_crossfade_multiplier(&self, elapsed: Duration) -> f64 {
let up_time = self.up_time.unwrap_or(Duration::ZERO);
let hold_time = self.hold_time.unwrap_or(Duration::ZERO);
let down_time = self.down_time.unwrap_or(Duration::ZERO);
let up_end = up_time;
let hold_end = up_time + hold_time;
let total_end = up_time + hold_time + down_time;
let eps = Duration::from_micros(1);
if up_time.is_zero() {
if elapsed <= hold_end + eps {
1.0
} else if !down_time.is_zero() && elapsed < total_end + eps {
let fade_out_elapsed = elapsed.saturating_sub(hold_end);
let t = (fade_out_elapsed.as_secs_f64() / down_time.as_secs_f64()).clamp(0.0, 1.0);
1.0 - t
} else if elapsed > total_end + eps {
0.0
} else {
1.0
}
} else if elapsed < up_end + eps {
(elapsed.as_secs_f64() / up_time.as_secs_f64()).clamp(0.0, 1.0)
} else if elapsed <= hold_end + eps {
1.0
} else if !down_time.is_zero() && elapsed < total_end + eps {
let fade_out_elapsed = elapsed.saturating_sub(hold_end);
let t = (fade_out_elapsed.as_secs_f64() / down_time.as_secs_f64()).clamp(0.0, 1.0);
1.0 - t
} else if elapsed > total_end + eps {
0.0
} else {
1.0
}
}
pub fn total_duration(&self) -> Duration {
if let EffectType::Dimmer { duration, .. } = &self.effect_type {
return *duration;
}
self.up_time.unwrap_or(Duration::ZERO)
+ self.hold_time.unwrap_or(Duration::ZERO)
+ self.down_time.unwrap_or(Duration::ZERO)
}
pub fn has_reached_terminal_state(&self, elapsed: Duration) -> bool {
let eps = Duration::from_micros(1);
match &self.effect_type {
EffectType::Dimmer { duration, .. } => elapsed + eps >= *duration,
_ => {
let d = self.total_duration();
elapsed + eps >= d
}
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::lighting::effects::color::Color;
use crate::lighting::effects::tempo_aware::{TempoAwareFrequency, TempoAwareSpeed};
use crate::lighting::effects::types::{
ChaseDirection, ChasePattern, CycleDirection, CycleTransition, DimmerCurve,
};
fn static_effect(duration: Duration) -> EffectType {
EffectType::Static {
parameters: HashMap::new(),
duration,
}
}
fn dimmer_effect(start: f64, end: f64, dur: Duration) -> EffectType {
EffectType::Dimmer {
start_level: start,
end_level: end,
duration: dur,
curve: DimmerCurve::Linear,
}
}
fn strobe_effect(duration: Duration) -> EffectType {
EffectType::Strobe {
frequency: TempoAwareFrequency::Fixed(10.0),
duration,
}
}
fn color_cycle_effect(duration: Duration) -> EffectType {
EffectType::ColorCycle {
colors: vec![Color::new(255, 0, 0), Color::new(0, 0, 255)],
speed: TempoAwareSpeed::Fixed(1.0),
direction: CycleDirection::Forward,
transition: CycleTransition::Fade,
duration,
}
}
fn chase_effect(duration: Duration) -> EffectType {
EffectType::Chase {
pattern: ChasePattern::Linear,
speed: TempoAwareSpeed::Fixed(1.0),
direction: ChaseDirection::LeftToRight,
transition: CycleTransition::Snap,
duration,
}
}
fn rainbow_effect(duration: Duration) -> EffectType {
EffectType::Rainbow {
speed: TempoAwareSpeed::Fixed(0.5),
saturation: 1.0,
brightness: 1.0,
duration,
}
}
fn make_instance(effect_type: EffectType) -> EffectInstance {
EffectInstance::new(
"test".to_string(),
effect_type,
vec!["fixture1".to_string()],
None,
None,
None,
)
}
fn make_instance_timed(
effect_type: EffectType,
up: Option<Duration>,
hold: Option<Duration>,
down: Option<Duration>,
) -> EffectInstance {
EffectInstance::new(
"test".to_string(),
effect_type,
vec!["fixture1".to_string()],
up,
hold,
down,
)
}
#[test]
fn new_static_sets_hold_from_duration() {
let inst = make_instance(static_effect(Duration::from_secs(5)));
assert_eq!(inst.hold_time, Some(Duration::from_secs(5)));
}
#[test]
fn new_strobe_sets_hold_from_duration() {
let inst = make_instance(strobe_effect(Duration::from_secs(3)));
assert_eq!(inst.hold_time, Some(Duration::from_secs(3)));
}
#[test]
fn new_color_cycle_sets_hold_from_duration() {
let inst = make_instance(color_cycle_effect(Duration::from_secs(10)));
assert_eq!(inst.hold_time, Some(Duration::from_secs(10)));
}
#[test]
fn new_dimmer_no_default_timing() {
let inst = make_instance(dimmer_effect(0.0, 1.0, Duration::from_secs(3)));
assert!(inst.up_time.is_none());
assert!(inst.hold_time.is_none());
assert!(inst.down_time.is_none());
}
#[test]
fn new_defaults_layer_and_blend() {
let inst = make_instance(static_effect(Duration::from_secs(5)));
assert_eq!(inst.layer, EffectLayer::Background);
assert_eq!(inst.blend_mode, BlendMode::Replace);
assert_eq!(inst.priority, 0);
assert!(inst.enabled);
}
#[test]
fn new_with_user_timing_overrides() {
let inst = make_instance_timed(
static_effect(Duration::from_secs(5)),
Some(Duration::from_secs(1)),
Some(Duration::from_secs(2)),
Some(Duration::from_secs(1)),
);
assert_eq!(inst.up_time, Some(Duration::from_secs(1)));
assert_eq!(inst.hold_time, Some(Duration::from_secs(2)));
assert_eq!(inst.down_time, Some(Duration::from_secs(1)));
}
#[test]
fn crossfade_hold_only() {
let inst = make_instance(static_effect(Duration::from_secs(2)));
assert!((inst.calculate_crossfade_multiplier(Duration::ZERO) - 1.0).abs() < 1e-9);
assert!((inst.calculate_crossfade_multiplier(Duration::from_secs(1)) - 1.0).abs() < 1e-9);
assert!((inst.calculate_crossfade_multiplier(Duration::from_secs(3)) - 0.0).abs() < 1e-9);
}
#[test]
fn crossfade_with_hold_and_down() {
let inst = make_instance_timed(
static_effect(Duration::from_secs(5)),
None,
Some(Duration::from_secs(1)),
Some(Duration::from_secs(2)),
);
let mult = inst.calculate_crossfade_multiplier(Duration::from_secs(2));
assert!((mult - 0.5).abs() < 1e-9);
}
#[test]
fn crossfade_fade_in_phase() {
let inst = make_instance_timed(
static_effect(Duration::from_secs(5)),
Some(Duration::from_secs(2)),
Some(Duration::from_secs(2)),
None,
);
let mult = inst.calculate_crossfade_multiplier(Duration::from_secs(1));
assert!((mult - 0.5).abs() < 1e-9);
}
#[test]
fn crossfade_hold_phase_after_fade_in() {
let inst = make_instance_timed(
static_effect(Duration::from_secs(5)),
Some(Duration::from_secs(1)),
Some(Duration::from_secs(2)),
None,
);
let mult = inst.calculate_crossfade_multiplier(Duration::from_secs(2));
assert!((mult - 1.0).abs() < 1e-9);
}
#[test]
fn crossfade_fade_out_phase() {
let inst = make_instance_timed(
static_effect(Duration::from_secs(5)),
None,
Some(Duration::from_secs(1)),
Some(Duration::from_secs(2)),
);
let mult = inst.calculate_crossfade_multiplier(Duration::from_secs(2));
assert!((mult - 0.5).abs() < 1e-9);
}
#[test]
fn crossfade_after_total_end() {
let inst = make_instance_timed(
static_effect(Duration::from_secs(5)),
Some(Duration::from_secs(1)),
Some(Duration::from_secs(1)),
Some(Duration::from_secs(1)),
);
let mult = inst.calculate_crossfade_multiplier(Duration::from_secs(4));
assert!((mult - 0.0).abs() < 1e-9);
}
#[test]
fn total_duration_static() {
let inst = make_instance(static_effect(Duration::from_secs(5)));
assert_eq!(inst.total_duration(), Duration::from_secs(5));
}
#[test]
fn total_duration_static_with_up_down() {
let inst = make_instance_timed(
static_effect(Duration::from_secs(5)),
Some(Duration::from_secs(1)),
None, Some(Duration::from_secs(1)),
);
assert_eq!(inst.total_duration(), Duration::from_secs(7));
}
#[test]
fn total_duration_dimmer() {
let inst = make_instance(dimmer_effect(0.0, 1.0, Duration::from_secs(3)));
assert_eq!(inst.total_duration(), Duration::from_secs(3));
}
#[test]
fn total_duration_strobe() {
let inst = make_instance(strobe_effect(Duration::from_secs(5)));
assert_eq!(inst.total_duration(), Duration::from_secs(5));
}
#[test]
fn total_duration_color_cycle() {
let inst = make_instance(color_cycle_effect(Duration::from_secs(10)));
assert_eq!(inst.total_duration(), Duration::from_secs(10));
}
#[test]
fn total_duration_chase() {
let inst = make_instance(chase_effect(Duration::from_secs(8)));
assert_eq!(inst.total_duration(), Duration::from_secs(8));
}
#[test]
fn total_duration_rainbow() {
let inst = make_instance(rainbow_effect(Duration::from_secs(6)));
assert_eq!(inst.total_duration(), Duration::from_secs(6));
}
#[test]
fn total_duration_with_timing_override() {
let inst = make_instance_timed(
static_effect(Duration::from_secs(5)),
Some(Duration::from_secs(1)),
Some(Duration::from_secs(2)),
Some(Duration::from_secs(1)),
);
assert_eq!(inst.total_duration(), Duration::from_secs(4));
}
#[test]
fn terminal_dimmer_zero_duration() {
let inst = make_instance(dimmer_effect(0.0, 1.0, Duration::ZERO));
assert!(inst.has_reached_terminal_state(Duration::ZERO));
}
#[test]
fn terminal_dimmer_at_end() {
let inst = make_instance(dimmer_effect(0.0, 1.0, Duration::from_secs(2)));
assert!(inst.has_reached_terminal_state(Duration::from_secs(2)));
}
#[test]
fn terminal_dimmer_not_yet() {
let inst = make_instance(dimmer_effect(0.0, 1.0, Duration::from_secs(2)));
assert!(!inst.has_reached_terminal_state(Duration::from_secs(1)));
}
#[test]
fn terminal_static_at_end() {
let inst = make_instance(static_effect(Duration::from_secs(3)));
assert!(!inst.has_reached_terminal_state(Duration::from_secs(2)));
assert!(inst.has_reached_terminal_state(Duration::from_secs(3)));
}
#[test]
fn terminal_strobe_at_end() {
let inst = make_instance(strobe_effect(Duration::from_secs(2)));
assert!(!inst.has_reached_terminal_state(Duration::from_secs(1)));
assert!(inst.has_reached_terminal_state(Duration::from_secs(2)));
}
#[test]
fn terminal_color_cycle_at_end() {
let inst = make_instance(color_cycle_effect(Duration::from_secs(5)));
assert!(!inst.has_reached_terminal_state(Duration::from_secs(3)));
assert!(inst.has_reached_terminal_state(Duration::from_secs(5)));
}
#[test]
fn with_priority_sets_priority() {
let inst = make_instance(static_effect(Duration::from_secs(5))).with_priority(5);
assert_eq!(inst.priority, 5);
}
#[test]
fn with_timing_sets_fields() {
let now = Instant::now();
let inst = make_instance(static_effect(Duration::from_secs(5)))
.with_timing(Some(now), Some(Duration::from_secs(10)));
assert_eq!(inst.start_time, Some(now));
assert_eq!(inst.hold_time, Some(Duration::from_secs(10)));
}
}