record-daemon: initial commit

This commit is contained in:
2026-03-19 17:48:07 +01:00
commit d6c0334369
30 changed files with 9486 additions and 0 deletions
+293
View File
@@ -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());
}
}
+303
View File
@@ -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));
}
}
+241
View File
@@ -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"));
}
}
+360
View File
@@ -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);
}
}
+228
View File
@@ -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());
}
}