1use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct AnimationTimeline {
14 pub video_id: String,
16 pub events: Vec<AnimationEvent>,
18}
19
20impl AnimationTimeline {
21 #[must_use]
23 pub fn event_count(&self) -> usize {
24 self.events.len()
25 }
26
27 #[must_use]
29 pub fn has_events(&self) -> bool {
30 !self.events.is_empty()
31 }
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct AnimationEvent {
37 pub name: String,
39 pub event_type: AnimationEventType,
41 pub expected_secs: f64,
43 pub duration_secs: Option<f64>,
45 pub easing: Option<String>,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51pub enum AnimationEventType {
52 Enter,
54 Exit,
56 TransitionStart,
58 TransitionEnd,
60 Keyframe,
62 PhysicsEvent,
64}
65
66impl std::fmt::Display for AnimationEventType {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 Self::Enter => write!(f, "enter"),
70 Self::Exit => write!(f, "exit"),
71 Self::TransitionStart => write!(f, "transition_start"),
72 Self::TransitionEnd => write!(f, "transition_end"),
73 Self::Keyframe => write!(f, "keyframe"),
74 Self::PhysicsEvent => write!(f, "physics_event"),
75 }
76 }
77}
78
79#[derive(Clone, Debug, Serialize)]
81pub struct AnimationReport {
82 pub video_id: String,
84 pub verdict: AnimationVerdict,
86 pub events: Vec<EventResult>,
88 pub total_events: usize,
90 pub verified_events: usize,
92 pub max_delta_ms: f64,
94 pub mean_delta_ms: f64,
96}
97
98#[derive(Clone, Debug, Serialize)]
100pub struct EventResult {
101 pub name: String,
103 pub event_type: AnimationEventType,
105 pub expected_secs: f64,
107 pub actual_secs: Option<f64>,
109 pub delta_ms: Option<f64>,
111 pub passed: bool,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub enum AnimationVerdict {
118 Pass,
120 Fail,
122 NoEvents,
124}
125
126impl std::fmt::Display for AnimationVerdict {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 match self {
129 Self::Pass => write!(f, "PASS"),
130 Self::Fail => write!(f, "FAIL"),
131 Self::NoEvents => write!(f, "NO EVENTS"),
132 }
133 }
134}
135
136#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
138pub enum EasingFunction {
139 Linear,
141 EaseIn,
143 EaseOut,
145 EaseInOut,
147 CubicIn,
149 CubicOut,
151 CubicInOut,
153 Bounce,
155 CubicBezier(f64, f64, f64, f64),
157}
158
159impl EasingFunction {
160 #[must_use]
164 pub fn evaluate(&self, t: f64) -> f64 {
165 let t = t.clamp(0.0, 1.0);
166 match self {
167 Self::Linear => t,
168 Self::EaseIn => t * t,
169 Self::EaseOut => t * (2.0 - t),
170 Self::EaseInOut => {
171 if t < 0.5 {
172 2.0 * t * t
173 } else {
174 -1.0 + (4.0 - 2.0 * t) * t
175 }
176 }
177 Self::CubicIn => t * t * t,
178 Self::CubicOut => {
179 let t1 = t - 1.0;
180 t1 * t1 * t1 + 1.0
181 }
182 Self::CubicInOut => {
183 if t < 0.5 {
184 4.0 * t * t * t
185 } else {
186 let t1 = 2.0 * t - 2.0;
187 0.5 * t1 * t1 * t1 + 1.0
188 }
189 }
190 Self::Bounce => bounce_ease_out(t),
191 Self::CubicBezier(x1, y1, x2, y2) => cubic_bezier_approx(t, *x1, *y1, *x2, *y2),
192 }
193 }
194}
195
196fn bounce_ease_out(t: f64) -> f64 {
198 if t < 1.0 / 2.75 {
199 7.5625 * t * t
200 } else if t < 2.0 / 2.75 {
201 let t = t - 1.5 / 2.75;
202 7.5625 * t * t + 0.75
203 } else if t < 2.5 / 2.75 {
204 let t = t - 2.25 / 2.75;
205 7.5625 * t * t + 0.9375
206 } else {
207 let t = t - 2.625 / 2.75;
208 7.5625 * t * t + 0.984_375
209 }
210}
211
212fn cubic_bezier_approx(t: f64, _x1: f64, y1: f64, _x2: f64, y2: f64) -> f64 {
214 let mt = 1.0 - t;
217 let mt2 = mt * mt;
218 let t2 = t * t;
219 mt2 * mt * 0.0 + 3.0 * mt2 * t * y1 + 3.0 * mt * t2 * y2 + t2 * t * 1.0
220}
221
222#[cfg(test)]
223#[allow(clippy::unwrap_used)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn test_animation_verdict_display() {
229 assert_eq!(AnimationVerdict::Pass.to_string(), "PASS");
230 assert_eq!(AnimationVerdict::Fail.to_string(), "FAIL");
231 assert_eq!(AnimationVerdict::NoEvents.to_string(), "NO EVENTS");
232 }
233
234 #[test]
235 fn test_animation_event_type_display() {
236 assert_eq!(AnimationEventType::Enter.to_string(), "enter");
237 assert_eq!(
238 AnimationEventType::PhysicsEvent.to_string(),
239 "physics_event"
240 );
241 }
242
243 #[test]
244 fn test_timeline_event_count() {
245 let timeline = AnimationTimeline {
246 video_id: "test".to_string(),
247 events: vec![
248 AnimationEvent {
249 name: "event1".to_string(),
250 event_type: AnimationEventType::Enter,
251 expected_secs: 1.0,
252 duration_secs: None,
253 easing: None,
254 },
255 AnimationEvent {
256 name: "event2".to_string(),
257 event_type: AnimationEventType::Exit,
258 expected_secs: 2.0,
259 duration_secs: None,
260 easing: None,
261 },
262 ],
263 };
264 assert_eq!(timeline.event_count(), 2);
265 assert!(timeline.has_events());
266 }
267
268 #[test]
269 fn test_timeline_empty() {
270 let timeline = AnimationTimeline {
271 video_id: "empty".to_string(),
272 events: vec![],
273 };
274 assert_eq!(timeline.event_count(), 0);
275 assert!(!timeline.has_events());
276 }
277
278 #[test]
279 fn test_timeline_json_roundtrip() {
280 let timeline = AnimationTimeline {
281 video_id: "test".to_string(),
282 events: vec![AnimationEvent {
283 name: "bullet_land".to_string(),
284 event_type: AnimationEventType::PhysicsEvent,
285 expected_secs: 1.7,
286 duration_secs: Some(0.05),
287 easing: Some("bounce".to_string()),
288 }],
289 };
290 let json = serde_json::to_string(&timeline).unwrap();
291 let parsed: AnimationTimeline = serde_json::from_str(&json).unwrap();
292 assert_eq!(parsed.video_id, "test");
293 assert_eq!(parsed.events.len(), 1);
294 assert_eq!(
295 parsed.events[0].event_type,
296 AnimationEventType::PhysicsEvent
297 );
298 }
299
300 #[test]
301 fn test_easing_linear() {
302 let f = EasingFunction::Linear;
303 assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
304 assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
305 assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
306 }
307
308 #[test]
309 fn test_easing_ease_in() {
310 let f = EasingFunction::EaseIn;
311 assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
312 assert!((f.evaluate(0.5) - 0.25).abs() < f64::EPSILON); assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
314 }
315
316 #[test]
317 fn test_easing_ease_out() {
318 let f = EasingFunction::EaseOut;
319 assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
320 assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
321 assert!(f.evaluate(0.5) > 0.5);
323 }
324
325 #[test]
326 fn test_easing_ease_in_out() {
327 let f = EasingFunction::EaseInOut;
328 assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
329 assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
330 assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
331 }
332
333 #[test]
334 fn test_easing_cubic_in() {
335 let f = EasingFunction::CubicIn;
336 assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
337 assert!((f.evaluate(0.5) - 0.125).abs() < f64::EPSILON); assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
339 }
340
341 #[test]
342 fn test_easing_cubic_out() {
343 let f = EasingFunction::CubicOut;
344 assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
345 assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
346 }
347
348 #[test]
349 fn test_easing_cubic_in_out() {
350 let f = EasingFunction::CubicInOut;
351 assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
352 assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
353 assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
354 }
355
356 #[test]
357 fn test_easing_bounce() {
358 let f = EasingFunction::Bounce;
359 assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
360 assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
361 assert!(f.evaluate(0.5) > 0.0);
363 }
364
365 #[test]
366 fn test_easing_cubic_bezier() {
367 let f = EasingFunction::CubicBezier(0.25, 0.1, 0.25, 1.0); assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
369 assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
370 }
371
372 #[test]
373 fn test_easing_clamp() {
374 let f = EasingFunction::Linear;
375 assert!((f.evaluate(-0.5)).abs() < f64::EPSILON);
376 assert!((f.evaluate(1.5) - 1.0).abs() < f64::EPSILON);
377 }
378
379 #[test]
380 fn test_event_result() {
381 let result = EventResult {
382 name: "bullet_land".to_string(),
383 event_type: AnimationEventType::PhysicsEvent,
384 expected_secs: 1.7,
385 actual_secs: Some(1.71),
386 delta_ms: Some(10.0),
387 passed: true,
388 };
389 assert!(result.passed);
390 }
391
392 #[test]
393 fn test_animation_report_serialization() {
394 let report = AnimationReport {
395 video_id: "test".to_string(),
396 verdict: AnimationVerdict::Pass,
397 events: vec![],
398 total_events: 0,
399 verified_events: 0,
400 max_delta_ms: 0.0,
401 mean_delta_ms: 0.0,
402 };
403 let json = serde_json::to_string(&report).unwrap();
404 assert!(json.contains("\"verdict\":\"Pass\""));
405 }
406}