record-daemon: initial commit
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
//! Game capture source configuration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Game capture source for recording game footage.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameCapture {
|
||||
/// Source name.
|
||||
pub name: String,
|
||||
/// Window title to capture (if specific window).
|
||||
pub window: Option<String>,
|
||||
/// Process name to capture.
|
||||
pub process_name: Option<String>,
|
||||
/// Capture mode.
|
||||
pub mode: CaptureMode,
|
||||
/// Whether to capture cursor.
|
||||
pub capture_cursor: bool,
|
||||
}
|
||||
|
||||
impl Default for GameCapture {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Game Capture".to_string(),
|
||||
window: None,
|
||||
process_name: Some("League of Legends.exe".to_string()),
|
||||
mode: CaptureMode::Any,
|
||||
capture_cursor: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GameCapture {
|
||||
/// Create a new game capture source.
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the window to capture.
|
||||
pub fn with_window(mut self, window: &str) -> Self {
|
||||
self.window = Some(window.to_string());
|
||||
self.mode = CaptureMode::Window;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the process name to capture.
|
||||
pub fn with_process(mut self, process: &str) -> Self {
|
||||
self.process_name = Some(process.to_string());
|
||||
self.mode = CaptureMode::Process;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to capture the cursor.
|
||||
pub fn with_cursor(mut self, capture: bool) -> Self {
|
||||
self.capture_cursor = capture;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create the OBS source.
|
||||
///
|
||||
/// Note: This would create the actual obs_source_t in libobs.
|
||||
pub fn create_source(&self) -> Result<CaptureSource> {
|
||||
info!("Creating game capture source: {}", self.name);
|
||||
|
||||
// Note: Actual libobs source creation would happen here
|
||||
// obs_source_create("game_capture", name, settings, nullptr)
|
||||
|
||||
let source = CaptureSource {
|
||||
name: self.name.clone(),
|
||||
source_type: SourceType::GameCapture,
|
||||
active: false,
|
||||
};
|
||||
|
||||
debug!("Game capture source created");
|
||||
Ok(source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CaptureMode {
|
||||
/// Capture any fullscreen application.
|
||||
Any,
|
||||
/// Capture a specific window.
|
||||
Window,
|
||||
/// Capture a specific process.
|
||||
Process,
|
||||
}
|
||||
|
||||
/// Capture source abstraction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CaptureSource {
|
||||
/// Source name.
|
||||
pub name: String,
|
||||
/// Source type.
|
||||
pub source_type: SourceType,
|
||||
/// Whether the source is active.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl CaptureSource {
|
||||
/// Check if the source is active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Activate the source.
|
||||
pub fn activate(&mut self) -> Result<()> {
|
||||
if self.active {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("Activating capture source: {}", self.name);
|
||||
// Note: Actual activation would involve obs_source_set_enabled
|
||||
self.active = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deactivate the source.
|
||||
pub fn deactivate(&mut self) -> Result<()> {
|
||||
if !self.active {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("Deactivating capture source: {}", self.name);
|
||||
// Note: Actual deactivation would involve obs_source_set_enabled
|
||||
self.active = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Source type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SourceType {
|
||||
/// Game capture source.
|
||||
GameCapture,
|
||||
/// Window capture source.
|
||||
WindowCapture,
|
||||
/// Monitor capture source.
|
||||
MonitorCapture,
|
||||
}
|
||||
|
||||
/// Window capture source (alternative to game capture).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WindowCapture {
|
||||
/// Source name.
|
||||
pub name: String,
|
||||
/// Window title.
|
||||
pub window_title: String,
|
||||
/// Window class (X11).
|
||||
pub window_class: Option<String>,
|
||||
/// Whether to capture cursor.
|
||||
pub capture_cursor: bool,
|
||||
}
|
||||
|
||||
impl WindowCapture {
|
||||
/// Create a new window capture source.
|
||||
pub fn new(name: &str, window_title: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
window_title: window_title.to_string(),
|
||||
window_class: None,
|
||||
capture_cursor: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the window class (for X11).
|
||||
pub fn with_class(mut self, class: &str) -> Self {
|
||||
self.window_class = Some(class.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Create the OBS source.
|
||||
pub fn create_source(&self) -> Result<CaptureSource> {
|
||||
info!(
|
||||
"Creating window capture source: {} ({})",
|
||||
self.name, self.window_title
|
||||
);
|
||||
|
||||
let source = CaptureSource {
|
||||
name: self.name.clone(),
|
||||
source_type: SourceType::WindowCapture,
|
||||
active: false,
|
||||
};
|
||||
|
||||
Ok(source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitor capture source (fallback).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MonitorCapture {
|
||||
/// Source name.
|
||||
pub name: String,
|
||||
/// Monitor index.
|
||||
pub monitor: u32,
|
||||
/// Whether to capture cursor.
|
||||
pub capture_cursor: bool,
|
||||
}
|
||||
|
||||
impl MonitorCapture {
|
||||
/// Create a new monitor capture source.
|
||||
pub fn new(name: &str, monitor: u32) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
monitor,
|
||||
capture_cursor: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the OBS source.
|
||||
pub fn create_source(&self) -> Result<CaptureSource> {
|
||||
info!(
|
||||
"Creating monitor capture source: {} (monitor {})",
|
||||
self.name, self.monitor
|
||||
);
|
||||
|
||||
let source = CaptureSource {
|
||||
name: self.name.clone(),
|
||||
source_type: SourceType::MonitorCapture,
|
||||
active: false,
|
||||
};
|
||||
|
||||
Ok(source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the League of Legends game window.
|
||||
pub fn find_league_window() -> Option<String> {
|
||||
// Note: Actual window finding would use platform-specific APIs
|
||||
// On Linux: X11/Wayland
|
||||
// On Windows: Win32 API
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Would use x11rb or similar to find window
|
||||
// For now, return the expected window title
|
||||
Some("League of Legends (TM) Client".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Would use FindWindowW
|
||||
Some("League of Legends (TM) Client".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_game_capture_creation() {
|
||||
let capture = GameCapture::new("Test Capture");
|
||||
assert_eq!(capture.name, "Test Capture");
|
||||
assert_eq!(capture.mode, CaptureMode::Any);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_capture_with_process() {
|
||||
let capture = GameCapture::new("Test").with_process("League of Legends.exe");
|
||||
|
||||
assert_eq!(
|
||||
capture.process_name,
|
||||
Some("League of Legends.exe".to_string())
|
||||
);
|
||||
assert_eq!(capture.mode, CaptureMode::Process);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capture_source_activation() {
|
||||
let mut source = CaptureSource {
|
||||
name: "Test".to_string(),
|
||||
source_type: SourceType::GameCapture,
|
||||
active: false,
|
||||
};
|
||||
|
||||
assert!(!source.is_active());
|
||||
source.activate().unwrap();
|
||||
assert!(source.is_active());
|
||||
source.deactivate().unwrap();
|
||||
assert!(!source.is_active());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Video and audio encoder configuration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{AmfQuality, EncoderPreset, QualityLevel};
|
||||
|
||||
/// Video encoder configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EncoderConfig {
|
||||
/// Encoder ID (e.g., "jim_nvenc", "x264").
|
||||
pub encoder_id: String,
|
||||
/// Target bitrate in kbps.
|
||||
pub bitrate: u32,
|
||||
/// Keyframe interval in seconds.
|
||||
pub keyframe_interval: u32,
|
||||
/// Encoder-specific settings.
|
||||
pub settings: EncoderSettings,
|
||||
}
|
||||
|
||||
/// Encoder-specific settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum EncoderSettings {
|
||||
/// NVIDIA NVENC settings.
|
||||
Nvenc {
|
||||
/// Constant Quality level (0-51, lower = better).
|
||||
cq_level: u32,
|
||||
/// Use two-pass encoding.
|
||||
two_pass: bool,
|
||||
/// Preset (p1-p7, lower = faster).
|
||||
preset: String,
|
||||
/// Rate control mode.
|
||||
rate_control: NvencRateControl,
|
||||
},
|
||||
/// AMD AMF settings.
|
||||
Amf {
|
||||
/// Quality preset.
|
||||
quality: AmfQuality,
|
||||
/// Rate control method.
|
||||
rate_control: AmfRateControl,
|
||||
},
|
||||
/// x264 settings.
|
||||
X264 {
|
||||
/// Preset name.
|
||||
preset: String,
|
||||
/// Constant Rate Factor (if using CRF).
|
||||
crf: Option<u32>,
|
||||
/// Use threads.
|
||||
threads: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// NVENC rate control modes.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NvencRateControl {
|
||||
/// Constant bitrate.
|
||||
Cbr,
|
||||
/// Variable bitrate.
|
||||
#[default]
|
||||
Vbr,
|
||||
/// Constant quality.
|
||||
Cqp,
|
||||
}
|
||||
|
||||
/// AMF rate control modes.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AmfRateControl {
|
||||
/// Constant bitrate.
|
||||
Cbr,
|
||||
/// Variable bitrate.
|
||||
#[default]
|
||||
Vbr,
|
||||
/// Constant QP.
|
||||
Cqp,
|
||||
}
|
||||
|
||||
impl EncoderConfig {
|
||||
/// Create encoder config from preset.
|
||||
pub fn from_preset(preset: &EncoderPreset) -> Self {
|
||||
match preset {
|
||||
EncoderPreset::Nvenc {
|
||||
bitrate,
|
||||
cq_level,
|
||||
two_pass,
|
||||
} => Self {
|
||||
encoder_id: "jim_nvenc".to_string(),
|
||||
bitrate: *bitrate,
|
||||
keyframe_interval: 2,
|
||||
settings: EncoderSettings::Nvenc {
|
||||
cq_level: *cq_level,
|
||||
two_pass: *two_pass,
|
||||
preset: "p4".to_string(), // Balanced preset
|
||||
rate_control: NvencRateControl::Vbr,
|
||||
},
|
||||
},
|
||||
EncoderPreset::Amf { bitrate, quality } => Self {
|
||||
encoder_id: "amd_amf_h264".to_string(),
|
||||
bitrate: *bitrate,
|
||||
keyframe_interval: 2,
|
||||
settings: EncoderSettings::Amf {
|
||||
quality: *quality,
|
||||
rate_control: AmfRateControl::Vbr,
|
||||
},
|
||||
},
|
||||
EncoderPreset::X264 {
|
||||
preset,
|
||||
bitrate,
|
||||
crf,
|
||||
} => Self {
|
||||
encoder_id: "x264".to_string(),
|
||||
bitrate: *bitrate,
|
||||
keyframe_interval: 2,
|
||||
settings: EncoderSettings::X264 {
|
||||
preset: preset.clone(),
|
||||
crf: *crf,
|
||||
threads: 0, // Auto
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the encoder name for display.
|
||||
pub fn encoder_name(&self) -> &str {
|
||||
&self.encoder_id
|
||||
}
|
||||
|
||||
/// Check if this is a hardware encoder.
|
||||
pub fn is_hardware(&self) -> bool {
|
||||
matches!(
|
||||
self.settings,
|
||||
EncoderSettings::Nvenc { .. } | EncoderSettings::Amf { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Video encoder trait for abstraction over different encoders.
|
||||
pub trait VideoEncoder {
|
||||
/// Get the encoder ID.
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Get the current bitrate.
|
||||
fn bitrate(&self) -> u32;
|
||||
|
||||
/// Update the bitrate.
|
||||
fn set_bitrate(&mut self, bitrate: u32);
|
||||
|
||||
/// Get encoder-specific settings as JSON.
|
||||
fn settings_json(&self) -> serde_json::Value;
|
||||
}
|
||||
|
||||
/// Audio encoder configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioEncoderConfig {
|
||||
/// Encoder ID (usually "ffmpeg_aac").
|
||||
pub encoder_id: String,
|
||||
/// Bitrate in kbps.
|
||||
pub bitrate: u32,
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate: u32,
|
||||
/// Number of channels.
|
||||
pub channels: u32,
|
||||
}
|
||||
|
||||
impl Default for AudioEncoderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
encoder_id: "ffmpeg_aac".to_string(),
|
||||
bitrate: 192,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio encoder trait.
|
||||
pub trait AudioEncoder {
|
||||
/// Get the encoder ID.
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Get the current bitrate.
|
||||
fn bitrate(&self) -> u32;
|
||||
|
||||
/// Update the bitrate.
|
||||
fn set_bitrate(&mut self, bitrate: u32);
|
||||
}
|
||||
|
||||
/// Detect available hardware encoders.
|
||||
pub fn detect_hardware_encoders() -> Vec<EncoderCapability> {
|
||||
let mut capabilities = Vec::new();
|
||||
|
||||
// Note: Actual detection would query the system for GPU availability
|
||||
// For now, we check environment variables and common indicators
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Check for NVIDIA
|
||||
if std::path::Path::new("/dev/nvidia0").exists() {
|
||||
capabilities.push(EncoderCapability::Nvenc);
|
||||
}
|
||||
|
||||
// Check for AMD
|
||||
if std::path::Path::new("/sys/class/drm").exists() {
|
||||
// Would check for AMD GPU
|
||||
// capabilities.push(EncoderCapability::Amf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// On Windows, would use DXGI to detect GPUs
|
||||
// For now, assume software encoding
|
||||
}
|
||||
|
||||
// Always available
|
||||
capabilities.push(EncoderCapability::Software);
|
||||
|
||||
capabilities
|
||||
}
|
||||
|
||||
/// Encoder capability.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EncoderCapability {
|
||||
/// NVIDIA NVENC.
|
||||
Nvenc,
|
||||
/// AMD AMF.
|
||||
Amf,
|
||||
/// Intel QuickSync.
|
||||
QuickSync,
|
||||
/// Software (x264).
|
||||
Software,
|
||||
}
|
||||
|
||||
impl EncoderCapability {
|
||||
/// Get the recommended encoder preset for this capability.
|
||||
pub fn recommended_preset(&self, quality: QualityLevel) -> EncoderPreset {
|
||||
let bitrate = quality.recommended_bitrate();
|
||||
|
||||
match self {
|
||||
EncoderCapability::Nvenc => EncoderPreset::Nvenc {
|
||||
bitrate,
|
||||
cq_level: 20,
|
||||
two_pass: true,
|
||||
},
|
||||
EncoderCapability::Amf => EncoderPreset::Amf {
|
||||
bitrate,
|
||||
quality: AmfQuality::Balanced,
|
||||
},
|
||||
EncoderCapability::QuickSync => EncoderPreset::X264 {
|
||||
preset: "veryfast".to_string(),
|
||||
bitrate,
|
||||
crf: None,
|
||||
},
|
||||
EncoderCapability::Software => EncoderPreset::X264 {
|
||||
preset: "superfast".to_string(),
|
||||
bitrate: (bitrate as f32 * 0.8) as u32,
|
||||
crf: Some(23),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encoder_config_from_nvenc_preset() {
|
||||
let preset = EncoderPreset::Nvenc {
|
||||
bitrate: 8000,
|
||||
cq_level: 20,
|
||||
two_pass: true,
|
||||
};
|
||||
|
||||
let config = EncoderConfig::from_preset(&preset);
|
||||
|
||||
assert_eq!(config.encoder_id, "jim_nvenc");
|
||||
assert_eq!(config.bitrate, 8000);
|
||||
assert!(config.is_hardware());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encoder_config_from_x264_preset() {
|
||||
let preset = EncoderPreset::X264 {
|
||||
preset: "fast".to_string(),
|
||||
bitrate: 6000,
|
||||
crf: Some(23),
|
||||
};
|
||||
|
||||
let config = EncoderConfig::from_preset(&preset);
|
||||
|
||||
assert_eq!(config.encoder_id, "x264");
|
||||
assert!(!config.is_hardware());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_hardware_encoders() {
|
||||
let capabilities = detect_hardware_encoders();
|
||||
assert!(!capabilities.is_empty());
|
||||
assert!(capabilities.contains(&EncoderCapability::Software));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Recording module for video capture using libobs.
|
||||
//!
|
||||
//! This module provides the recording engine that captures game footage
|
||||
//! and encodes it using hardware or software encoders.
|
||||
|
||||
mod capture;
|
||||
pub mod encoder;
|
||||
mod obs_context;
|
||||
mod output;
|
||||
|
||||
pub use capture::{CaptureSource, GameCapture};
|
||||
pub use encoder::{AudioEncoder, EncoderConfig, VideoEncoder};
|
||||
pub use obs_context::{ObsContext, ObsContextBuilder};
|
||||
pub use output::{OutputConfig, RecordingOutput, RecordingResult};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use parking_lot::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Settings;
|
||||
use crate::error::{RecordingError, Result};
|
||||
|
||||
/// Recording engine that manages the entire recording pipeline.
|
||||
pub struct RecordingEngine {
|
||||
/// OBS context.
|
||||
context: Option<ObsContext>,
|
||||
/// Current settings.
|
||||
settings: Arc<RwLock<Settings>>,
|
||||
/// Current recording output.
|
||||
current_output: Option<RecordingOutput>,
|
||||
/// Recording start time.
|
||||
start_time: Option<DateTime<Utc>>,
|
||||
/// Whether recording is active.
|
||||
is_recording: bool,
|
||||
}
|
||||
|
||||
impl RecordingEngine {
|
||||
/// Create a new recording engine.
|
||||
pub fn new(settings: Settings) -> Self {
|
||||
Self {
|
||||
context: None,
|
||||
settings: Arc::new(RwLock::new(settings)),
|
||||
current_output: None,
|
||||
start_time: None,
|
||||
is_recording: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the recording engine.
|
||||
///
|
||||
/// This sets up OBS and prepares for recording.
|
||||
pub fn initialize(&mut self) -> Result<()> {
|
||||
info!("Initializing recording engine");
|
||||
|
||||
let settings = self.settings.read().clone();
|
||||
|
||||
// Build OBS context
|
||||
let context = ObsContextBuilder::new()
|
||||
.with_video_settings(settings.video.clone())
|
||||
.with_audio_settings(settings.audio.clone())
|
||||
.with_output_dir(&settings.output.path)
|
||||
.build()?;
|
||||
|
||||
self.context = Some(context);
|
||||
info!("Recording engine initialized successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update settings.
|
||||
pub fn update_settings(&self, new_settings: Settings) -> Result<()> {
|
||||
let mut settings = self.settings.write();
|
||||
*settings = new_settings;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current settings.
|
||||
pub fn settings(&self) -> Settings {
|
||||
self.settings.read().clone()
|
||||
}
|
||||
|
||||
/// Check if currently recording.
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.is_recording
|
||||
}
|
||||
|
||||
/// Start recording.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `game_id` - Optional game ID for the recording.
|
||||
/// * `champion` - Optional champion name for the filename.
|
||||
pub fn start_recording(&mut self, game_id: Option<u64>, champion: Option<&str>) -> Result<()> {
|
||||
if self.is_recording {
|
||||
return Err(RecordingError::AlreadyRecording.into());
|
||||
}
|
||||
|
||||
let settings = self.settings.read().clone();
|
||||
|
||||
// Generate output filename
|
||||
let filename = self.generate_filename(game_id, champion);
|
||||
let output_path = settings.output.path.join(&filename);
|
||||
|
||||
info!("Starting recording to: {:?}", output_path);
|
||||
|
||||
// Start the recording
|
||||
let context = self.context.as_mut().ok_or(RecordingError::ObsInitError(
|
||||
"OBS not initialized".to_string(),
|
||||
))?;
|
||||
context.start_recording(&output_path)?;
|
||||
|
||||
self.current_output = Some(RecordingOutput {
|
||||
path: output_path,
|
||||
game_id,
|
||||
champion: champion.map(String::from),
|
||||
});
|
||||
self.start_time = Some(Utc::now());
|
||||
self.is_recording = true;
|
||||
|
||||
info!("Recording started successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording.
|
||||
pub fn stop_recording(&mut self) -> Result<RecordingResult> {
|
||||
if !self.is_recording {
|
||||
return Err(RecordingError::NotRecording.into());
|
||||
}
|
||||
|
||||
let context = self.context.as_mut().ok_or(RecordingError::ObsInitError(
|
||||
"OBS not initialized".to_string(),
|
||||
))?;
|
||||
|
||||
info!("Stopping recording");
|
||||
|
||||
// Stop the recording
|
||||
context.stop_recording()?;
|
||||
|
||||
let output = self
|
||||
.current_output
|
||||
.take()
|
||||
.ok_or(RecordingError::StopError("No active output".to_string()))?;
|
||||
|
||||
let start_time = self
|
||||
.start_time
|
||||
.take()
|
||||
.ok_or(RecordingError::StopError("No start time".to_string()))?;
|
||||
|
||||
let end_time = Utc::now();
|
||||
let duration = end_time - start_time;
|
||||
|
||||
self.is_recording = false;
|
||||
|
||||
let result = RecordingResult {
|
||||
path: output.path,
|
||||
game_id: output.game_id,
|
||||
champion: output.champion,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
};
|
||||
|
||||
info!("Recording stopped: {:?}", result.path);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get the current recording timestamp (time since recording started).
|
||||
pub fn current_timestamp(&self) -> Option<chrono::Duration> {
|
||||
if !self.is_recording {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.start_time.map(|start| Utc::now() - start)
|
||||
}
|
||||
|
||||
/// Generate a filename for the recording.
|
||||
fn generate_filename(&self, game_id: Option<u64>, champion: Option<&str>) -> String {
|
||||
let settings = self.settings.read();
|
||||
let now = Utc::now();
|
||||
|
||||
let date = now.format("%Y-%m-%d").to_string();
|
||||
let time = now.format("%H-%M-%S").to_string();
|
||||
let game_id_str = game_id.map(|id| id.to_string()).unwrap_or_default();
|
||||
let champion_str = champion.unwrap_or("unknown");
|
||||
|
||||
let filename = settings
|
||||
.output
|
||||
.naming_pattern
|
||||
.replace("{date}", &date)
|
||||
.replace("{time}", &time)
|
||||
.replace("{game_id}", &game_id_str)
|
||||
.replace("{champion}", champion_str);
|
||||
|
||||
format!("{}.{}", filename, settings.output.container)
|
||||
}
|
||||
|
||||
/// Shutdown the recording engine.
|
||||
pub fn shutdown(&mut self) -> Result<()> {
|
||||
if self.is_recording {
|
||||
self.stop_recording()?;
|
||||
}
|
||||
|
||||
if let Some(mut context) = self.context.take() {
|
||||
context.shutdown()?;
|
||||
}
|
||||
|
||||
info!("Recording engine shut down");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RecordingEngine {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.shutdown() {
|
||||
warn!("Error shutting down recording engine: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_recording_engine_creation() {
|
||||
let settings = Settings::default();
|
||||
let engine = RecordingEngine::new(settings);
|
||||
assert!(!engine.is_recording());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_generation() {
|
||||
let settings = Settings::default();
|
||||
let engine = RecordingEngine::new(settings);
|
||||
|
||||
let filename = engine.generate_filename(Some(12345), Some("Ahri"));
|
||||
assert!(filename.ends_with(".mp4"));
|
||||
assert!(filename.contains("Ahri"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
//! OBS context initialization and management.
|
||||
//!
|
||||
//! This module handles the lifecycle of the OBS library context.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::{AudioSettings, VideoSettings};
|
||||
use crate::error::{RecordingError, Result};
|
||||
|
||||
/// OBS video settings for initialization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObsVideoInfo {
|
||||
/// Graphics adapter index (-1 for default).
|
||||
pub adapter: i32,
|
||||
/// Output resolution width.
|
||||
pub output_width: u32,
|
||||
/// Output resolution height.
|
||||
pub output_height: u32,
|
||||
/// Frames per second numerator.
|
||||
pub fps_num: u32,
|
||||
/// Frames per second denominator.
|
||||
pub fps_den: u32,
|
||||
/// Base resolution width.
|
||||
pub base_width: u32,
|
||||
/// Base resolution height.
|
||||
pub base_height: u32,
|
||||
/// Output format.
|
||||
pub output_format: ObsVideoFormat,
|
||||
}
|
||||
|
||||
impl Default for ObsVideoInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
adapter: -1,
|
||||
output_width: 1920,
|
||||
output_height: 1080,
|
||||
fps_num: 60,
|
||||
fps_den: 1,
|
||||
base_width: 1920,
|
||||
base_height: 1080,
|
||||
output_format: ObsVideoFormat::Nv12,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObsVideoInfo {
|
||||
/// Create video info from settings.
|
||||
pub fn from_settings(settings: &VideoSettings) -> Self {
|
||||
let (width, height) = settings.quality.resolution();
|
||||
let fps = settings.frame_rate;
|
||||
|
||||
Self {
|
||||
adapter: -1,
|
||||
output_width: width,
|
||||
output_height: height,
|
||||
fps_num: fps,
|
||||
fps_den: 1,
|
||||
base_width: width,
|
||||
base_height: height,
|
||||
output_format: ObsVideoFormat::Nv12,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Video output format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ObsVideoFormat {
|
||||
/// NV12 format (common for hardware encoders).
|
||||
Nv12,
|
||||
/// I420 format.
|
||||
I420,
|
||||
/// I444 format.
|
||||
I444,
|
||||
/// RGBA format.
|
||||
Rgba,
|
||||
}
|
||||
|
||||
/// Builder for OBS context.
|
||||
pub struct ObsContextBuilder {
|
||||
video_settings: Option<VideoSettings>,
|
||||
audio_settings: Option<AudioSettings>,
|
||||
output_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl ObsContextBuilder {
|
||||
/// Create a new OBS context builder.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
video_settings: None,
|
||||
audio_settings: None,
|
||||
output_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set video settings.
|
||||
pub fn with_video_settings(mut self, settings: VideoSettings) -> Self {
|
||||
self.video_settings = Some(settings);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set audio settings.
|
||||
pub fn with_audio_settings(mut self, settings: AudioSettings) -> Self {
|
||||
self.audio_settings = Some(settings);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set output directory.
|
||||
pub fn with_output_dir(mut self, dir: &Path) -> Self {
|
||||
self.output_dir = Some(dir.to_path_buf());
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the OBS context.
|
||||
pub fn build(self) -> Result<ObsContext> {
|
||||
let video_settings = self.video_settings.unwrap_or_default();
|
||||
let audio_settings = self.audio_settings.unwrap_or_default();
|
||||
let output_dir = self
|
||||
.output_dir
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("./recordings"));
|
||||
|
||||
// Ensure output directory exists
|
||||
if !output_dir.exists() {
|
||||
std::fs::create_dir_all(&output_dir)
|
||||
.map_err(|e| RecordingError::OutputDirError(e.to_string()))?;
|
||||
}
|
||||
|
||||
let video_info = ObsVideoInfo::from_settings(&video_settings);
|
||||
|
||||
let context = ObsContext {
|
||||
video_info,
|
||||
audio_settings,
|
||||
output_dir,
|
||||
initialized: false,
|
||||
recording: false,
|
||||
current_output: None,
|
||||
};
|
||||
|
||||
// Note: Actual libobs initialization would happen here
|
||||
// For now, we create a stub that can be extended with actual libobs bindings
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ObsContextBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// OBS context wrapper.
|
||||
///
|
||||
/// This manages the OBS library lifecycle and provides access to
|
||||
/// recording functionality.
|
||||
pub struct ObsContext {
|
||||
/// Video configuration.
|
||||
video_info: ObsVideoInfo,
|
||||
/// Audio configuration.
|
||||
audio_settings: AudioSettings,
|
||||
/// Output directory for recordings.
|
||||
output_dir: std::path::PathBuf,
|
||||
/// Whether OBS has been initialized.
|
||||
initialized: bool,
|
||||
/// Whether currently recording.
|
||||
recording: bool,
|
||||
/// Current output path (if recording).
|
||||
current_output: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl ObsContext {
|
||||
/// Check if OBS is initialized.
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.initialized
|
||||
}
|
||||
|
||||
/// Check if currently recording.
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.recording
|
||||
}
|
||||
|
||||
/// Get the video info.
|
||||
pub fn video_info(&self) -> &ObsVideoInfo {
|
||||
&self.video_info
|
||||
}
|
||||
|
||||
/// Start recording to the specified output path.
|
||||
pub fn start_recording(&mut self, output_path: &Path) -> Result<()> {
|
||||
if self.recording {
|
||||
return Err(RecordingError::AlreadyRecording.into());
|
||||
}
|
||||
|
||||
if !self.initialized {
|
||||
// Initialize on first use
|
||||
self.initialize()?;
|
||||
}
|
||||
|
||||
info!("Starting OBS recording to: {:?}", output_path);
|
||||
|
||||
// Note: Actual libobs recording start would happen here
|
||||
// This is a stub implementation
|
||||
|
||||
self.current_output = Some(output_path.to_path_buf());
|
||||
self.recording = true;
|
||||
|
||||
debug!("OBS recording started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording.
|
||||
pub fn stop_recording(&mut self) -> Result<()> {
|
||||
if !self.recording {
|
||||
return Err(RecordingError::NotRecording.into());
|
||||
}
|
||||
|
||||
info!("Stopping OBS recording");
|
||||
|
||||
// Note: Actual libobs recording stop would happen here
|
||||
// This is a stub implementation
|
||||
|
||||
self.recording = false;
|
||||
self.current_output = None;
|
||||
|
||||
debug!("OBS recording stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize OBS.
|
||||
fn initialize(&mut self) -> Result<()> {
|
||||
if self.initialized {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Initializing OBS context");
|
||||
|
||||
// Note: Actual libobs initialization would happen here
|
||||
// This would involve:
|
||||
// 1. obs_startup()
|
||||
// 2. obs_reset_video()
|
||||
// 3. obs_reset_audio()
|
||||
// 4. Loading modules (obs-ffmpeg, etc.)
|
||||
// 5. Creating scene and source
|
||||
|
||||
// For now, we simulate initialization
|
||||
debug!(
|
||||
"OBS video config: {}x{} @ {}fps",
|
||||
self.video_info.output_width,
|
||||
self.video_info.output_height,
|
||||
self.video_info.fps_num / self.video_info.fps_den
|
||||
);
|
||||
|
||||
if self.audio_settings.enabled {
|
||||
debug!(
|
||||
"OBS audio config: {} channels @ {}Hz",
|
||||
self.audio_settings.channels, self.audio_settings.sample_rate
|
||||
);
|
||||
}
|
||||
|
||||
self.initialized = true;
|
||||
info!("OBS context initialized successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shutdown OBS.
|
||||
pub fn shutdown(&mut self) -> Result<()> {
|
||||
if self.recording {
|
||||
self.stop_recording()?;
|
||||
}
|
||||
|
||||
if self.initialized {
|
||||
info!("Shutting down OBS context");
|
||||
|
||||
// Note: Actual libobs shutdown would happen here
|
||||
// obs_shutdown()
|
||||
|
||||
self.initialized = false;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ObsContext {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.shutdown() {
|
||||
warn!("Error shutting down OBS context: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: When actual libobs bindings are available, we would add
|
||||
// FFI bindings here. For now, this provides the interface that
|
||||
// will be implemented with real libobs calls.
|
||||
|
||||
/// Stub module for libobs FFI bindings.
|
||||
///
|
||||
/// When actual bindings are available, this would contain:
|
||||
/// - obs_startup
|
||||
/// - obs_shutdown
|
||||
/// - obs_reset_video
|
||||
/// - obs_reset_audio
|
||||
/// - obs_scene_create
|
||||
/// - obs_source_create
|
||||
/// - obs_output_create
|
||||
/// - obs_encoder_create
|
||||
/// etc.
|
||||
pub mod ffi {
|
||||
//! libobs FFI bindings (stub).
|
||||
//!
|
||||
//! This module will contain the actual FFI bindings to libobs.
|
||||
//! Currently using stubs until libobs-rs or similar bindings are integrated.
|
||||
|
||||
/// Placeholder for obs_video_info struct.
|
||||
#[repr(C)]
|
||||
pub struct ObsVideoInfo {
|
||||
pub adapter: i32,
|
||||
pub output_width: u32,
|
||||
pub output_height: u32,
|
||||
pub fps_num: u32,
|
||||
pub fps_den: u32,
|
||||
pub base_width: u32,
|
||||
pub base_height: u32,
|
||||
pub output_format: u32,
|
||||
}
|
||||
|
||||
/// Placeholder for obs_audio_info struct.
|
||||
#[repr(C)]
|
||||
pub struct ObsAudioInfo {
|
||||
pub samples_per_sec: u32,
|
||||
pub speakers: u32,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_obs_context_builder() {
|
||||
let context = ObsContextBuilder::new()
|
||||
.with_video_settings(VideoSettings::default())
|
||||
.with_audio_settings(AudioSettings::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
assert!(!context.is_initialized());
|
||||
assert!(!context.is_recording());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_info_from_settings() {
|
||||
let settings = VideoSettings::default();
|
||||
let info = ObsVideoInfo::from_settings(&settings);
|
||||
|
||||
assert_eq!(info.output_width, 1920);
|
||||
assert_eq!(info.output_height, 1080);
|
||||
assert_eq!(info.fps_num, 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Recording output configuration and results.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Output configuration for recordings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutputConfig {
|
||||
/// Output container format.
|
||||
pub container: String,
|
||||
/// Output file path pattern.
|
||||
pub path_pattern: String,
|
||||
/// Maximum file size before splitting (MB). 0 = no limit.
|
||||
pub max_size_mb: u32,
|
||||
/// Maximum duration before splitting (seconds). 0 = no limit.
|
||||
pub max_duration_secs: u32,
|
||||
/// Whether to use fragmented MP4 for better crash recovery.
|
||||
pub fragmented: bool,
|
||||
}
|
||||
|
||||
impl Default for OutputConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
container: "mp4".to_string(),
|
||||
path_pattern: "{date}_{time}".to_string(),
|
||||
max_size_mb: 0,
|
||||
max_duration_secs: 0,
|
||||
fragmented: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recording output handle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecordingOutput {
|
||||
/// Output file path.
|
||||
pub path: PathBuf,
|
||||
/// Game ID if available.
|
||||
pub game_id: Option<u64>,
|
||||
/// Champion name if available.
|
||||
pub champion: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of a completed recording.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordingResult {
|
||||
/// Output file path.
|
||||
pub path: PathBuf,
|
||||
/// Game ID if available.
|
||||
pub game_id: Option<u64>,
|
||||
/// Champion name if available.
|
||||
pub champion: Option<String>,
|
||||
/// Recording start time.
|
||||
pub start_time: DateTime<Utc>,
|
||||
/// Recording end time.
|
||||
pub end_time: DateTime<Utc>,
|
||||
/// Recording duration.
|
||||
// #[serde(with = "chrono::serde::seconds")]
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
impl RecordingResult {
|
||||
/// Get the file size in bytes.
|
||||
pub fn file_size(&self) -> Option<u64> {
|
||||
std::fs::metadata(&self.path).ok().map(|m| m.len())
|
||||
}
|
||||
|
||||
/// Get the file size in a human-readable format.
|
||||
pub fn file_size_human(&self) -> String {
|
||||
let bytes = self.file_size().unwrap_or(0);
|
||||
let mb = bytes as f64 / (1024.0 * 1024.0);
|
||||
format!("{:.2} MB", mb)
|
||||
}
|
||||
|
||||
/// Get the duration in a human-readable format.
|
||||
pub fn duration_human(&self) -> String {
|
||||
let total_secs = self.duration.num_seconds();
|
||||
let hours = total_secs / 3600;
|
||||
let mins = (total_secs % 3600) / 60;
|
||||
let secs = total_secs % 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{}h {}m {}s", hours, mins, secs)
|
||||
} else if mins > 0 {
|
||||
format!("{}m {}s", mins, secs)
|
||||
} else {
|
||||
format!("{}s", secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the recording file exists.
|
||||
pub fn exists(&self) -> bool {
|
||||
self.path.exists()
|
||||
}
|
||||
|
||||
/// Delete the recording file.
|
||||
pub fn delete(&self) -> std::io::Result<()> {
|
||||
std::fs::remove_file(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Output format type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OutputFormat {
|
||||
/// MP4 container.
|
||||
Mp4,
|
||||
/// MKV container.
|
||||
Mkv,
|
||||
/// MOV container.
|
||||
Mov,
|
||||
/// FLV container.
|
||||
Flv,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
/// Get the file extension.
|
||||
pub fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::Mp4 => "mp4",
|
||||
OutputFormat::Mkv => "mkv",
|
||||
OutputFormat::Mov => "mov",
|
||||
OutputFormat::Flv => "flv",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the OBS output ID.
|
||||
pub fn obs_output_id(&self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::Mp4 => "ffmpeg_muxer",
|
||||
OutputFormat::Mkv => "ffmpeg_muxer",
|
||||
OutputFormat::Mov => "ffmpeg_muxer",
|
||||
OutputFormat::Flv => "ffmpeg_muxer",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the FFmpeg format name.
|
||||
pub fn ffmpeg_format(&self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::Mp4 => "mp4",
|
||||
OutputFormat::Mkv => "matroska",
|
||||
OutputFormat::Mov => "mov",
|
||||
OutputFormat::Flv => "flv",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for OutputFormat {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"mp4" => OutputFormat::Mp4,
|
||||
"mkv" => OutputFormat::Mkv,
|
||||
"mov" => OutputFormat::Mov,
|
||||
"flv" => OutputFormat::Flv,
|
||||
_ => OutputFormat::Mp4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recording statistics.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RecordingStats {
|
||||
/// Total frames recorded.
|
||||
pub frames_recorded: u64,
|
||||
/// Frames dropped due to encoding lag.
|
||||
pub frames_dropped: u64,
|
||||
/// Average frame time in milliseconds.
|
||||
pub avg_frame_time_ms: f64,
|
||||
/// Current bitrate in kbps.
|
||||
pub current_bitrate: u32,
|
||||
/// Recording duration in seconds.
|
||||
pub duration_secs: f64,
|
||||
}
|
||||
|
||||
impl RecordingStats {
|
||||
/// Calculate the drop rate percentage.
|
||||
pub fn drop_rate(&self) -> f64 {
|
||||
if self.frames_recorded == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.frames_dropped as f64 / self.frames_recorded as f64) * 100.0
|
||||
}
|
||||
|
||||
/// Check if the recording is healthy (low drop rate).
|
||||
pub fn is_healthy(&self) -> bool {
|
||||
self.drop_rate() < 5.0 // Less than 5% dropped frames
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_output_format_from_str() {
|
||||
assert_eq!(OutputFormat::from("mp4"), OutputFormat::Mp4);
|
||||
assert_eq!(OutputFormat::from("MKV"), OutputFormat::Mkv);
|
||||
assert_eq!(OutputFormat::from("unknown"), OutputFormat::Mp4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_result_duration_human() {
|
||||
let result = RecordingResult {
|
||||
path: PathBuf::from("/tmp/test.mp4"),
|
||||
game_id: Some(12345),
|
||||
champion: Some("Ahri".to_string()),
|
||||
start_time: Utc::now(),
|
||||
end_time: Utc::now() + Duration::seconds(125),
|
||||
duration: Duration::seconds(125),
|
||||
};
|
||||
|
||||
assert_eq!(result.duration_human(), "2m 5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stats_drop_rate() {
|
||||
let stats = RecordingStats {
|
||||
frames_recorded: 1000,
|
||||
frames_dropped: 50,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(stats.drop_rate(), 5.0);
|
||||
assert!(stats.is_healthy());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user