record-daemon: initial commit
This commit is contained in:
193
record-daemon/src/timeline/mod.rs
Normal file
193
record-daemon/src/timeline/mod.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! Timeline module for storing game events and mapping them to video timestamps.
|
||||
|
||||
mod mapper;
|
||||
mod store;
|
||||
|
||||
pub use mapper::EventMapper;
|
||||
pub use store::{RecordingMetadata, TimelineStore, TimestampedEvent};
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::lqp::GameEvent;
|
||||
|
||||
/// A timeline of events for a recording.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Timeline {
|
||||
/// Recording ID.
|
||||
pub recording_id: Uuid,
|
||||
/// Recording start time.
|
||||
pub start_time: DateTime<Utc>,
|
||||
/// Recording end time.
|
||||
pub end_time: Option<DateTime<Utc>>,
|
||||
/// Total duration in seconds.
|
||||
pub duration_secs: i64,
|
||||
/// Events in the timeline.
|
||||
pub events: Vec<TimestampedEvent>,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// Get the duration as chrono Duration.
|
||||
pub fn duration(&self) -> Duration {
|
||||
Duration::seconds(self.duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// Create a new timeline for a recording.
|
||||
pub fn new(recording_id: Uuid) -> Self {
|
||||
Self {
|
||||
recording_id,
|
||||
start_time: Utc::now(),
|
||||
end_time: None,
|
||||
duration_secs: 0,
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an event to the timeline.
|
||||
pub fn add_event(
|
||||
&mut self,
|
||||
event: GameEvent,
|
||||
video_timestamp: Duration,
|
||||
game_timestamp: Option<Duration>,
|
||||
) {
|
||||
let timestamped = TimestampedEvent {
|
||||
video_timestamp,
|
||||
game_timestamp,
|
||||
timestamp: Utc::now(),
|
||||
event_type: event_type_name(&event),
|
||||
description: event.description(),
|
||||
event,
|
||||
};
|
||||
|
||||
self.events.push(timestamped);
|
||||
}
|
||||
|
||||
/// Finalize the timeline.
|
||||
pub fn finalize(&mut self) {
|
||||
self.end_time = Some(Utc::now());
|
||||
self.duration_secs = (self.end_time.unwrap() - self.start_time).num_seconds();
|
||||
}
|
||||
|
||||
/// Get events within a time range.
|
||||
pub fn events_in_range(&self, start: Duration, end: Duration) -> Vec<&TimestampedEvent> {
|
||||
self.events
|
||||
.iter()
|
||||
.filter(|e| e.video_timestamp >= start && e.video_timestamp <= end)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get events of a specific type.
|
||||
pub fn events_of_type(&self, event_type: &str) -> Vec<&TimestampedEvent> {
|
||||
self.events
|
||||
.iter()
|
||||
.filter(|e| e.event_type == event_type)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the number of events.
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
/// Export timeline to JSON.
|
||||
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string_pretty(self)
|
||||
}
|
||||
|
||||
/// Export timeline to CSV.
|
||||
pub fn to_csv(&self) -> String {
|
||||
let mut csv =
|
||||
String::from("video_timestamp,game_timestamp,event_type,description,timestamp\n");
|
||||
|
||||
for event in &self.events {
|
||||
let video_ts = format_timestamp(event.video_timestamp);
|
||||
let game_ts = event
|
||||
.game_timestamp
|
||||
.map(format_timestamp)
|
||||
.unwrap_or_default();
|
||||
|
||||
csv.push_str(&format!(
|
||||
"{},{},{},{},{}\n",
|
||||
video_ts,
|
||||
game_ts,
|
||||
event.event_type,
|
||||
event.description,
|
||||
event.timestamp.to_rfc3339(),
|
||||
));
|
||||
}
|
||||
|
||||
csv
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a duration as HH:MM:SS.mmm
|
||||
fn format_timestamp(duration: Duration) -> String {
|
||||
let total_ms = duration.num_milliseconds();
|
||||
let hours = total_ms / 3_600_000;
|
||||
let minutes = (total_ms % 3_600_000) / 60_000;
|
||||
let seconds = (total_ms % 60_000) / 1000;
|
||||
let millis = total_ms % 1000;
|
||||
|
||||
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis)
|
||||
}
|
||||
|
||||
/// Get the event type name.
|
||||
fn event_type_name(event: &GameEvent) -> String {
|
||||
match event {
|
||||
GameEvent::MatchFound(_) => "match_found",
|
||||
GameEvent::GameStart(_) => "game_start",
|
||||
GameEvent::Kill(_) => "kill",
|
||||
GameEvent::Death(_) => "death",
|
||||
GameEvent::Objective(_) => "objective",
|
||||
GameEvent::GameEnd(_) => "game_end",
|
||||
GameEvent::Unknown => "unknown",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lqp::{KillEvent, ObjectiveEvent, ObjectiveType};
|
||||
|
||||
#[test]
|
||||
fn test_timeline_creation() {
|
||||
let id = Uuid::new_v4();
|
||||
let timeline = Timeline::new(id);
|
||||
|
||||
assert_eq!(timeline.recording_id, id);
|
||||
assert!(timeline.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_event() {
|
||||
let id = Uuid::new_v4();
|
||||
let mut timeline = Timeline::new(id);
|
||||
|
||||
let event = GameEvent::Kill(KillEvent {
|
||||
killer: "Player1".to_string(),
|
||||
killer_champion: Some("Ahri".to_string()),
|
||||
victim: "Player2".to_string(),
|
||||
victim_champion: Some("Lux".to_string()),
|
||||
solo_kill: true,
|
||||
assists: 0,
|
||||
position: None,
|
||||
game_time: Some(120.0),
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
|
||||
timeline.add_event(event, Duration::seconds(5), Some(Duration::seconds(120)));
|
||||
|
||||
assert_eq!(timeline.event_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_timestamp() {
|
||||
let duration = Duration::milliseconds(3723456); // 1:02:03.456
|
||||
let formatted = format_timestamp(duration);
|
||||
assert_eq!(formatted, "01:02:03.456");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user