use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AnimationTimeline {
pub video_id: String,
pub events: Vec<AnimationEvent>,
}
impl AnimationTimeline {
#[must_use]
pub fn event_count(&self) -> usize {
self.events.len()
}
#[must_use]
pub fn has_events(&self) -> bool {
!self.events.is_empty()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AnimationEvent {
pub name: String,
pub event_type: AnimationEventType,
pub expected_secs: f64,
pub duration_secs: Option<f64>,
pub easing: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnimationEventType {
Enter,
Exit,
TransitionStart,
TransitionEnd,
Keyframe,
PhysicsEvent,
}
impl std::fmt::Display for AnimationEventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Enter => write!(f, "enter"),
Self::Exit => write!(f, "exit"),
Self::TransitionStart => write!(f, "transition_start"),
Self::TransitionEnd => write!(f, "transition_end"),
Self::Keyframe => write!(f, "keyframe"),
Self::PhysicsEvent => write!(f, "physics_event"),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct AnimationReport {
pub video_id: String,
pub verdict: AnimationVerdict,
pub events: Vec<EventResult>,
pub total_events: usize,
pub verified_events: usize,
pub max_delta_ms: f64,
pub mean_delta_ms: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct EventResult {
pub name: String,
pub event_type: AnimationEventType,
pub expected_secs: f64,
pub actual_secs: Option<f64>,
pub delta_ms: Option<f64>,
pub passed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnimationVerdict {
Pass,
Fail,
NoEvents,
}
impl std::fmt::Display for AnimationVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pass => write!(f, "PASS"),
Self::Fail => write!(f, "FAIL"),
Self::NoEvents => write!(f, "NO EVENTS"),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum EasingFunction {
Linear,
EaseIn,
EaseOut,
EaseInOut,
CubicIn,
CubicOut,
CubicInOut,
Bounce,
CubicBezier(f64, f64, f64, f64),
}
impl EasingFunction {
#[must_use]
pub fn evaluate(&self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
match self {
Self::Linear => t,
Self::EaseIn => t * t,
Self::EaseOut => t * (2.0 - t),
Self::EaseInOut => {
if t < 0.5 {
2.0 * t * t
} else {
-1.0 + (4.0 - 2.0 * t) * t
}
}
Self::CubicIn => t * t * t,
Self::CubicOut => {
let t1 = t - 1.0;
t1 * t1 * t1 + 1.0
}
Self::CubicInOut => {
if t < 0.5 {
4.0 * t * t * t
} else {
let t1 = 2.0 * t - 2.0;
0.5 * t1 * t1 * t1 + 1.0
}
}
Self::Bounce => bounce_ease_out(t),
Self::CubicBezier(x1, y1, x2, y2) => cubic_bezier_approx(t, *x1, *y1, *x2, *y2),
}
}
}
fn bounce_ease_out(t: f64) -> f64 {
if t < 1.0 / 2.75 {
7.5625 * t * t
} else if t < 2.0 / 2.75 {
let t = t - 1.5 / 2.75;
7.5625 * t * t + 0.75
} else if t < 2.5 / 2.75 {
let t = t - 2.25 / 2.75;
7.5625 * t * t + 0.9375
} else {
let t = t - 2.625 / 2.75;
7.5625 * t * t + 0.984_375
}
}
fn cubic_bezier_approx(t: f64, _x1: f64, y1: f64, _x2: f64, y2: f64) -> f64 {
let mt = 1.0 - t;
let mt2 = mt * mt;
let t2 = t * t;
mt2 * mt * 0.0 + 3.0 * mt2 * t * y1 + 3.0 * mt * t2 * y2 + t2 * t * 1.0
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_animation_verdict_display() {
assert_eq!(AnimationVerdict::Pass.to_string(), "PASS");
assert_eq!(AnimationVerdict::Fail.to_string(), "FAIL");
assert_eq!(AnimationVerdict::NoEvents.to_string(), "NO EVENTS");
}
#[test]
fn test_animation_event_type_display() {
assert_eq!(AnimationEventType::Enter.to_string(), "enter");
assert_eq!(
AnimationEventType::PhysicsEvent.to_string(),
"physics_event"
);
}
#[test]
fn test_timeline_event_count() {
let timeline = AnimationTimeline {
video_id: "test".to_string(),
events: vec![
AnimationEvent {
name: "event1".to_string(),
event_type: AnimationEventType::Enter,
expected_secs: 1.0,
duration_secs: None,
easing: None,
},
AnimationEvent {
name: "event2".to_string(),
event_type: AnimationEventType::Exit,
expected_secs: 2.0,
duration_secs: None,
easing: None,
},
],
};
assert_eq!(timeline.event_count(), 2);
assert!(timeline.has_events());
}
#[test]
fn test_timeline_empty() {
let timeline = AnimationTimeline {
video_id: "empty".to_string(),
events: vec![],
};
assert_eq!(timeline.event_count(), 0);
assert!(!timeline.has_events());
}
#[test]
fn test_timeline_json_roundtrip() {
let timeline = AnimationTimeline {
video_id: "test".to_string(),
events: vec![AnimationEvent {
name: "bullet_land".to_string(),
event_type: AnimationEventType::PhysicsEvent,
expected_secs: 1.7,
duration_secs: Some(0.05),
easing: Some("bounce".to_string()),
}],
};
let json = serde_json::to_string(&timeline).unwrap();
let parsed: AnimationTimeline = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.video_id, "test");
assert_eq!(parsed.events.len(), 1);
assert_eq!(
parsed.events[0].event_type,
AnimationEventType::PhysicsEvent
);
}
#[test]
fn test_easing_linear() {
let f = EasingFunction::Linear;
assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_easing_ease_in() {
let f = EasingFunction::EaseIn;
assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(0.5) - 0.25).abs() < f64::EPSILON); assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_easing_ease_out() {
let f = EasingFunction::EaseOut;
assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
assert!(f.evaluate(0.5) > 0.5);
}
#[test]
fn test_easing_ease_in_out() {
let f = EasingFunction::EaseInOut;
assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_easing_cubic_in() {
let f = EasingFunction::CubicIn;
assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(0.5) - 0.125).abs() < f64::EPSILON); assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_easing_cubic_out() {
let f = EasingFunction::CubicOut;
assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_easing_cubic_in_out() {
let f = EasingFunction::CubicInOut;
assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_easing_bounce() {
let f = EasingFunction::Bounce;
assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
assert!(f.evaluate(0.5) > 0.0);
}
#[test]
fn test_easing_cubic_bezier() {
let f = EasingFunction::CubicBezier(0.25, 0.1, 0.25, 1.0); assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_easing_clamp() {
let f = EasingFunction::Linear;
assert!((f.evaluate(-0.5)).abs() < f64::EPSILON);
assert!((f.evaluate(1.5) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_event_result() {
let result = EventResult {
name: "bullet_land".to_string(),
event_type: AnimationEventType::PhysicsEvent,
expected_secs: 1.7,
actual_secs: Some(1.71),
delta_ms: Some(10.0),
passed: true,
};
assert!(result.passed);
}
#[test]
fn test_animation_report_serialization() {
let report = AnimationReport {
video_id: "test".to_string(),
verdict: AnimationVerdict::Pass,
events: vec![],
total_events: 0,
verified_events: 0,
max_delta_ms: 0.0,
mean_delta_ms: 0.0,
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"verdict\":\"Pass\""));
}
}