record-daemon: fix obs recording

This commit is contained in:
2026-03-20 20:34:44 +01:00
parent dbb224e118
commit 1166424c29
12 changed files with 3717 additions and 515 deletions

2410
record-daemon/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -62,11 +62,23 @@ base64 = "0.22"
# Regex for lockfile parsing
regex = "1"
# OBS recording library
libobs-simple = { version = "8.0.1" }
libobs-bootstrapper = { version = "0.3.1", features = ["install_dummy_dll"] }
libobs-wrapper = { version = "9.0.4" }
# Display info for monitor capture
display-info = "0.5"
# Signal handling for graceful shutdown (Unix only)
[target.'cfg(unix)'.dependencies]
signal-hook = "0.3"
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
# Windows-specific dependencies
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser"] }
[dev-dependencies]
# Testing utilities
tokio-test = "0.4"

View File

@@ -118,7 +118,8 @@ impl IpcServer {
info!("Starting IPC server at {:?}", self.config.socket_path);
let listener = PlatformListener::bind(&self.config.socket_path).map_err(IpcError::BindError)?;
let listener =
PlatformListener::bind(&self.config.socket_path).map_err(IpcError::BindError)?;
self.listener = Some(listener);
@@ -146,7 +147,8 @@ impl IpcServer {
// Accept new connection
match listener.accept().await {
Ok((stream, addr)) => {
self.handle_new_connection(stream, format!("{:?}", addr)).await;
self.handle_new_connection(stream, format!("{:?}", addr))
.await;
}
Err(e) => {
warn!("Failed to accept connection: {}", e);
@@ -182,7 +184,8 @@ impl IpcServer {
tokio::spawn(async move {
*client_count.write().await += 1;
if let Err(e) = Self::handle_connection(stream, handlers, shutdown.clone(), notification_tx).await
if let Err(e) =
Self::handle_connection(stream, handlers, shutdown.clone(), notification_tx).await
{
error!("Connection error: {}", e);
}
@@ -232,8 +235,11 @@ impl IpcServer {
Ok(msg) => msg,
Err(e) => {
warn!("Failed to parse message: {}", e);
let response = IpcResponse::error(uuid::Uuid::nil(), format!("Parse error: {}", e));
writer.send(response.to_json()?).await
let response =
IpcResponse::error(uuid::Uuid::nil(), format!("Parse error: {}", e));
writer
.send(response.to_json()?)
.await
.map_err(|e| IpcError::CodecError(e.to_string()))?;
continue;
}
@@ -241,9 +247,7 @@ impl IpcServer {
// Handle message
let response = match message.message_type {
MessageType::Request => {
handlers.handle(message).await
}
MessageType::Request => handlers.handle(message).await,
MessageType::Notification => {
// Notifications don't get responses
trace!("Received notification: {:?}", message);
@@ -258,7 +262,9 @@ impl IpcServer {
// Send response
let response_json = response.to_json()?;
writer.send(response_json).await
writer
.send(response_json)
.await
.map_err(|e| IpcError::CodecError(e.to_string()))?;
}
@@ -271,8 +277,7 @@ impl IpcServer {
// Remove socket file
if self.config.socket_path.exists() {
std::fs::remove_file(&self.config.socket_path)
.map_err(IpcError::BindError)?;
std::fs::remove_file(&self.config.socket_path).map_err(IpcError::BindError)?;
}
info!("IPC server stopped");
@@ -362,7 +367,10 @@ impl IpcServer {
pub async fn run(&self) -> Result<()> {
use tokio::net::windows::named_pipe::ServerOptions;
info!("IPC server listening for connections on {:?}", self.config.socket_path);
info!(
"IPC server listening for connections on {:?}",
self.config.socket_path
);
let pipe_name: String = self.config.socket_path.to_string_lossy().into_owned();
@@ -465,19 +473,23 @@ impl IpcServer {
Ok(msg) => msg,
Err(e) => {
warn!("Failed to parse message: {}", e);
let response = IpcResponse::error(uuid::Uuid::nil(), format!("Parse error: {}", e));
writer.write_all(response.to_json()?.as_bytes()).await
let response =
IpcResponse::error(uuid::Uuid::nil(), format!("Parse error: {}", e));
writer
.write_all(response.to_json()?.as_bytes())
.await
.map_err(IpcError::WriteError)?;
writer
.write_all(b"\n")
.await
.map_err(IpcError::WriteError)?;
writer.write_all(b"\n").await.map_err(IpcError::WriteError)?;
continue;
}
};
// Handle message
let response = match message.message_type {
MessageType::Request => {
handlers.handle(message).await
}
MessageType::Request => handlers.handle(message).await,
MessageType::Notification => {
// Notifications don't get responses
trace!("Received notification: {:?}", message);
@@ -492,8 +504,14 @@ impl IpcServer {
// Send response
let response_json = response.to_json()?;
writer.write_all(response_json.as_bytes()).await.map_err(IpcError::WriteError)?;
writer.write_all(b"\n").await.map_err(IpcError::WriteError)?;
writer
.write_all(response_json.as_bytes())
.await
.map_err(IpcError::WriteError)?;
writer
.write_all(b"\n")
.await
.map_err(IpcError::WriteError)?;
}
Ok(())
@@ -527,7 +545,10 @@ mod tests {
#[test]
fn test_ipc_server_config_default() {
let config = IpcServerConfig::default();
assert!(config.socket_path.to_string_lossy().contains("record-daemon"));
assert!(config
.socket_path
.to_string_lossy()
.contains("record-daemon"));
assert_eq!(config.max_connections, 10);
assert_eq!(config.timeout_secs, 30);
}

View File

@@ -243,7 +243,6 @@ impl LqpClient {
// Create a TLS connector that accepts the self-signed certificate from League Client
use tokio_tungstenite::Connector;
use tokio_tungstenite::tungstenite::http::HeaderValue;
let config = rustls::ClientConfig::builder()
.dangerous()
@@ -260,16 +259,14 @@ impl LqpClient {
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", tokio_tungstenite::tungstenite::handshake::client::generate_key())
.header(
"Sec-WebSocket-Key",
tokio_tungstenite::tungstenite::handshake::client::generate_key(),
)
.body(())
.map_err(|e| LqpError::ConnectionFailed(e.to_string()))?;
let (ws_stream, _) = connect_async_tls_with_config(
request,
None,
false,
Some(connector),
)
let (ws_stream, _) = connect_async_tls_with_config(request, None, false, Some(connector))
.await
.map_err(|e| LqpError::WebSocketError(e.to_string()))?;
@@ -306,9 +303,7 @@ impl LqpClient {
match msg {
Ok(Message::Text(text)) => {
debug!("Received text message: {} bytes", text.len());
if text.is_empty() {
debug!("Empty text message received, skipping");
continue;
}
if let Some(event) = Self::parse_websocket_message(&text) {
@@ -370,8 +365,6 @@ impl LqpClient {
/// Parse a WebSocket message into a game event.
fn parse_websocket_message(text: &str) -> Option<GameEvent> {
debug!("WebSocket message: {}", text);
// Parse the message array format: [type, callback, data]
let value: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v,
@@ -383,10 +376,8 @@ impl LqpClient {
// Check if it's an event message (type 8)
if let Some(arr) = value.as_array() {
debug!("Message is array with {} elements", arr.len());
if arr.len() >= 3 {
let msg_type = arr.first()?.as_u64()?;
debug!("Message type: {}", msg_type);
if msg_type == 8 {
// Event message format: [8, "OnJsonApiEvent", {"data": ..., "eventType": ..., "uri": ...}]
@@ -397,9 +388,10 @@ impl LqpClient {
// Extract the actual URI and data from the event
let uri = event_data.get("uri")?.as_str()?;
let data = event_data.get("data")?;
let event_type = event_data.get("eventType").and_then(|t| t.as_str()).unwrap_or("Update");
debug!("OnJsonApiEvent: uri={}, eventType={}, data={:?}", uri, event_type, data);
let event_type = event_data
.get("eventType")
.and_then(|t| t.as_str())
.unwrap_or("Update");
return Self::parse_event_from_uri(uri, event_type, data);
} else {
@@ -411,6 +403,8 @@ impl LqpClient {
} else if msg_type == 0 {
// Welcome message
info!("WebSocket welcome message received");
} else {
debug!("Unknown message type {msg_type} received");
}
}
} else {
@@ -421,7 +415,11 @@ impl LqpClient {
}
/// Parse an event based on the URI.
fn parse_event_from_uri(uri: &str, event_type: &str, data: &serde_json::Value) -> Option<GameEvent> {
fn parse_event_from_uri(
uri: &str,
event_type: &str,
data: &serde_json::Value,
) -> Option<GameEvent> {
info!("Parsing event from URI: {} (type: {})", uri, event_type);
// Handle gameflow phase changes
@@ -449,7 +447,8 @@ impl LqpClient {
info!("Game is now in progress!");
// Extract game info
let game_id = data.get("gameData")
let game_id = data
.get("gameData")
.and_then(|gd| gd.get("gameId"))
.and_then(|id| id.as_u64())
.unwrap_or(0);

View File

@@ -3,6 +3,7 @@
use std::sync::Arc;
use clap::Parser;
use libobs_bootstrapper::{ObsBootstrapper, ObsBootstrapperOptions, ObsBootstrapperResult};
use parking_lot::RwLock;
use tokio::sync::broadcast;
use tracing::{debug, error, info, warn};
@@ -81,11 +82,31 @@ impl Daemon {
async fn init(&mut self) -> Result<()> {
info!("Initializing record daemon v{}", record_daemon::VERSION);
// Initialize recording engine
// Initialize recording engine (blocking operation)
let settings = self.settings.read().clone();
let recording_engine = self.recording_engine.clone();
let result = tokio::task::spawn_blocking(move || {
let mut engine = RecordingEngine::new(settings);
engine.initialize()?;
*self.recording_engine.write() = Some(engine);
if let Err(e) = engine.initialize() {
return Err(format!("Failed to initialize recording engine: {:?}", e));
}
*recording_engine.write() = Some(engine);
Ok::<_, String>(())
})
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(record_daemon::error::RecordingError::ObsInitError(e).into()),
Err(e) => {
return Err(record_daemon::error::RecordingError::ObsInitError(format!(
"spawn_blocking error during init: {:?}",
e
))
.into())
}
}
// Load existing recordings from disk
self.timeline_store.read().load_from_disk()?;
@@ -201,26 +222,79 @@ impl Daemon {
/// Handle a game event.
async fn handle_game_event(&self, event: GameEvent) -> Result<()> {
debug!("Game event: {:?}", event);
use std::io::Write;
info!("[EVENT_HANDLER] Game event received: {:?}", event);
std::io::stderr().flush().ok();
// Process state transitions
if let Some(transition) = self.state_machine.process_event(&event) {
info!("[EVENT_HANDLER] State transition: {:?}", transition);
std::io::stderr().flush().ok();
self.state_machine.transition(transition.clone());
// Handle recording start/stop
match transition {
StateTransition::GameStarted { game_id, champion } => {
info!(
"[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}",
game_id, champion
);
std::io::stderr().flush().ok();
// If already recording, stop the current recording first
if self.state_machine.is_recording() {
info!("Stopping previous recording before starting new one");
info!(
"[EVENT_HANDLER] Stopping previous recording before starting new one"
);
std::io::stderr().flush().ok();
if let Err(e) = self.stop_recording().await {
warn!("Failed to stop previous recording: {}", e);
warn!("[EVENT_HANDLER] Failed to stop previous recording: {}", e);
std::io::stderr().flush().ok();
}
}
self.start_recording(game_id, champion.as_deref()).await?;
info!("[EVENT_HANDLER] Calling start_recording...");
std::io::stderr().flush().ok();
// Wrap the start_recording call to catch any panics
let start_result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// We need to use a blocking approach here since we're in catch_unwind
// The actual async call happens outside
}));
if let Err(panic_info) = start_result {
error!(
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
panic_info
);
std::io::stderr().flush().ok();
eprintln!(
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
panic_info
);
}
if let Err(e) = self.start_recording(game_id, champion.as_deref()).await {
error!("[EVENT_HANDLER] Failed to start recording: {}", e);
std::io::stderr().flush().ok();
// Don't propagate error - keep daemon running
} else {
info!("[EVENT_HANDLER] start_recording completed successfully");
std::io::stderr().flush().ok();
}
}
StateTransition::GameEnded => {
self.stop_recording().await?;
info!("[EVENT_HANDLER] GameEnded transition");
std::io::stderr().flush().ok();
if let Err(e) = self.stop_recording().await {
error!("[EVENT_HANDLER] Failed to stop recording: {}", e);
std::io::stderr().flush().ok();
// Don't propagate error - keep daemon running
}
}
_ => {}
}
@@ -237,36 +311,80 @@ impl Daemon {
}
}
info!("[EVENT_HANDLER] Event handling complete");
std::io::stderr().flush().ok();
Ok(())
}
/// Start recording.
async fn start_recording(&self, game_id: u64, champion: Option<&str>) -> Result<()> {
info!("Starting recording for game {} ({:?})", game_id, champion);
info!(
"Daemon::start_recording called - game {} ({:?})",
game_id, champion
);
// Clone Arc references for use in spawn_blocking
let recording_engine = self.recording_engine.clone();
let event_mapper = self.event_mapper.clone();
let champion_owned = champion.map(|s| s.to_string());
// Use spawn_blocking to avoid blocking the async runtime
tokio::task::spawn_blocking(move || {
info!("Acquiring recording engine write lock...");
let mut engine_guard = recording_engine.write();
info!("Recording engine lock acquired");
let mut engine_guard = self.recording_engine.write();
if let Some(ref mut engine) = *engine_guard {
engine.start_recording(Some(game_id), champion)?;
self.event_mapper.write().start();
info!("Calling engine.start_recording...");
engine.start_recording(Some(game_id), champion_owned.as_deref())?;
info!("engine.start_recording returned successfully");
event_mapper.write().start();
info!("Event mapper started");
} else {
warn!("Recording engine is None!");
}
info!("Daemon::start_recording completed successfully");
Ok(())
})
.await
.map_err(|e| {
record_daemon::error::RecordingError::ObsInitError(format!(
"spawn_blocking error: {:?}",
e
))
})?
}
/// Stop recording.
async fn stop_recording(&self) -> Result<()> {
info!("Stopping recording");
let mut engine_guard = self.recording_engine.write();
// Clone Arc references for use in spawn_blocking
let recording_engine = self.recording_engine.clone();
let event_mapper = self.event_mapper.clone();
let timeline_store = self.timeline_store.clone();
// Use spawn_blocking to avoid blocking the async runtime
tokio::task::spawn_blocking(move || {
let mut engine_guard = recording_engine.write();
if let Some(ref mut engine) = *engine_guard {
let result = engine.stop_recording()?;
self.event_mapper.write().stop();
event_mapper.write().stop();
// Save to timeline
self.timeline_store.write().add_recording(result)?;
timeline_store.write().add_recording(result)?;
}
Ok(())
})
.await
.map_err(|e| {
record_daemon::error::RecordingError::ObsInitError(format!(
"spawn_blocking error: {:?}",
e
))
})?
}
/// Shutdown the daemon.
@@ -283,9 +401,28 @@ impl Daemon {
ipc_server.stop().await?;
}
// Shutdown recording engine
if let Some(ref mut engine) = *self.recording_engine.write() {
engine.shutdown()?;
// Shutdown recording engine (blocking operation)
let recording_engine = self.recording_engine.clone();
let result = tokio::task::spawn_blocking(move || {
if let Some(ref mut engine) = *recording_engine.write() {
if let Err(e) = engine.shutdown() {
return Err(format!("Failed to shutdown recording engine: {:?}", e));
}
}
Ok::<_, String>(())
})
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(record_daemon::error::RecordingError::ObsInitError(e).into()),
Err(e) => {
return Err(record_daemon::error::RecordingError::ObsInitError(format!(
"spawn_blocking error during shutdown: {:?}",
e
))
.into())
}
}
info!("Daemon shutdown complete");
@@ -302,6 +439,25 @@ fn init_logging(level: &str) {
.with(filter)
.with(tracing_subscriber::fmt::layer())
.init();
// Set up panic hook to log panics
std::panic::set_hook(Box::new(|panic_info| {
let location = panic_info
.location()
.map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()))
.unwrap_or_else(|| "unknown location".to_string());
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic".to_string()
};
error!("PANIC at {}: {}", location, message);
eprintln!("PANIC at {}: {}", location, message);
}));
}
#[tokio::main]
@@ -313,6 +469,28 @@ async fn main() -> Result<()> {
info!("Record Daemon v{} starting", record_daemon::VERSION);
// Bootstrap OBS - download and extract if needed
info!("Bootstrapping OBS...");
let bootstrap_options = ObsBootstrapperOptions::default().set_update(false);
let bootstrap_result = ObsBootstrapper::bootstrap(&bootstrap_options).await;
match bootstrap_result {
Ok(ObsBootstrapperResult::Restart) => {
info!("OBS has been downloaded and extracted.");
return Ok(());
}
Ok(ObsBootstrapperResult::None) => {
info!("OBS bootstrap complete, continuing...");
}
Err(e) => {
error!("Failed to bootstrap OBS: {:?}", e);
return Err(record_daemon::error::RecordingError::ObsInitError(format!(
"OBS bootstrap failed: {:?}",
e
))
.into());
}
}
// Load configuration
let settings = if let Some(config_path) = args.config {
config::ConfigPersistence::new(config_path).load()?
@@ -324,7 +502,7 @@ async fn main() -> Result<()> {
let mut daemon = Daemon::new(settings);
// Handle shutdown signals
let shutdown_tx = daemon.shutdown_tx.clone();
let _shutdown_tx = daemon.shutdown_tx.clone();
#[cfg(unix)]
{

View File

@@ -1,11 +1,13 @@
//! Game capture source configuration.
//! Game capture source configuration using libobs-simple.
//!
//! This module provides capture sources for recording game footage,
//! including game capture, window capture, and monitor capture.
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use crate::error::Result;
/// Game capture source for recording game footage.
///
/// Uses OBS game capture for efficient GPU-based capture.
#[derive(Debug, Clone)]
pub struct GameCapture {
/// Source name.
@@ -18,6 +20,8 @@ pub struct GameCapture {
pub mode: CaptureMode,
/// Whether to capture cursor.
pub capture_cursor: bool,
/// Window class (Windows only).
pub window_class: Option<String>,
}
impl Default for GameCapture {
@@ -26,8 +30,9 @@ impl Default for GameCapture {
name: "Game Capture".to_string(),
window: None,
process_name: Some("League of Legends.exe".to_string()),
mode: CaptureMode::Any,
mode: CaptureMode::Process,
capture_cursor: false,
window_class: Some("RiotWindowClass".to_string()),
}
}
}
@@ -41,6 +46,18 @@ impl GameCapture {
}
}
/// Create a game capture configured for League of Legends.
pub fn for_league_of_legends() -> Self {
Self {
name: "League of Legends Capture".to_string(),
window: Some("League of Legends (TM) Client".to_string()),
process_name: Some("League of Legends.exe".to_string()),
mode: CaptureMode::Window,
capture_cursor: false,
window_class: Some("RiotWindowClass".to_string()),
}
}
/// Set the window to capture.
pub fn with_window(mut self, window: &str) -> Self {
self.window = Some(window.to_string());
@@ -55,105 +72,56 @@ impl GameCapture {
self
}
/// Set the window class (Windows only).
pub fn with_window_class(mut self, class: &str) -> Self {
self.window_class = Some(class.to_string());
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)
/// Get the window string for OBS in the format "Title:Class:Executable".
pub fn window_string(&self) -> Option<String> {
match (&self.window, &self.window_class, &self.process_name) {
(Some(window), Some(class), Some(process)) => {
Some(format!("{}:{}:{}", window, class, process))
}
(Some(window), None, Some(process)) => Some(format!("{}::{}", window, process)),
(Some(window), Some(class), None) => Some(format!("{}:{}:", window, class)),
_ => None,
}
}
}
/// Capture mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum CaptureMode {
/// Capture any fullscreen application.
Any,
/// Capture a specific window.
Window,
/// Capture a specific process.
#[default]
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).
///
/// Uses OBS window capture which works with more applications
/// but may have slightly higher overhead than game capture.
#[derive(Debug, Clone)]
pub struct WindowCapture {
/// Source name.
pub name: String,
/// Window title.
pub window_title: String,
/// Window class (X11).
/// Window class (X11/Windows).
pub window_class: Option<String>,
/// Whether to capture cursor.
pub capture_cursor: bool,
@@ -170,35 +138,28 @@ impl WindowCapture {
}
}
/// Set the window class (for X11).
/// Set the window class (for X11/Windows).
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)
/// Set whether to capture the cursor.
pub fn with_cursor(mut self, capture: bool) -> Self {
self.capture_cursor = capture;
self
}
}
/// Monitor capture source (fallback).
///
/// Captures an entire monitor. Useful as a fallback when
/// game capture or window capture don't work.
#[derive(Debug, Clone)]
pub struct MonitorCapture {
/// Source name.
pub name: String,
/// Monitor index.
/// Monitor index (0-based).
pub monitor: u32,
/// Whether to capture cursor.
pub capture_cursor: bool,
@@ -214,39 +175,39 @@ impl MonitorCapture {
}
}
/// 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)
/// Set whether to capture the cursor.
pub fn with_cursor(mut self, capture: bool) -> Self {
self.capture_cursor = capture;
self
}
}
/// 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
/// Capture source type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceType {
/// Game capture source.
GameCapture,
/// Window capture source.
WindowCapture,
/// Monitor capture source.
MonitorCapture,
}
#[cfg(target_os = "linux")]
/// Find the League of Legends game window.
///
/// Returns the window title if found, or a default if not found.
pub fn find_league_window() -> Option<String> {
#[cfg(target_os = "windows")]
{
// Would use x11rb or similar to find window
// On Windows, we can use the Win32 API to find the window
// For now, return the expected window title
Some("League of Legends (TM) Client".to_string())
}
#[cfg(target_os = "windows")]
#[cfg(target_os = "linux")]
{
// Would use FindWindowW
// On Linux, we would use X11 or Wayland APIs
// For now, return the expected window title
Some("League of Legends (TM) Client".to_string())
}
@@ -254,6 +215,11 @@ pub fn find_league_window() -> Option<String> {
None
}
/// Get the default capture configuration for League of Legends.
pub fn league_capture_config() -> GameCapture {
GameCapture::for_league_of_legends()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -262,7 +228,7 @@ mod tests {
fn test_game_capture_creation() {
let capture = GameCapture::new("Test Capture");
assert_eq!(capture.name, "Test Capture");
assert_eq!(capture.mode, CaptureMode::Any);
assert_eq!(capture.mode, CaptureMode::Process);
}
#[test]
@@ -277,17 +243,23 @@ mod tests {
}
#[test]
fn test_capture_source_activation() {
let mut source = CaptureSource {
name: "Test".to_string(),
source_type: SourceType::GameCapture,
active: false,
};
fn test_league_capture_config() {
let capture = league_capture_config();
assert_eq!(capture.name, "League of Legends Capture");
assert_eq!(
capture.process_name,
Some("League of Legends.exe".to_string())
);
assert_eq!(capture.window_class, Some("RiotWindowClass".to_string()));
}
assert!(!source.is_active());
source.activate().unwrap();
assert!(source.is_active());
source.deactivate().unwrap();
assert!(!source.is_active());
#[test]
fn test_window_string() {
let capture = GameCapture::for_league_of_legends();
let window_str = capture.window_string();
assert!(window_str.is_some());
let s = window_str.unwrap();
assert!(s.contains("League of Legends"));
assert!(s.contains("RiotWindowClass"));
}
}

View File

@@ -1,4 +1,7 @@
//! Video and audio encoder configuration.
//!
//! This module provides encoder configuration types that integrate with
//! libobs-simple for hardware and software encoding.
use serde::{Deserialize, Serialize};
@@ -17,6 +20,22 @@ pub struct EncoderConfig {
pub settings: EncoderSettings,
}
impl Default for EncoderConfig {
fn default() -> Self {
Self {
encoder_id: "jim_nvenc".to_string(),
bitrate: 6000,
keyframe_interval: 2,
settings: EncoderSettings::Nvenc {
cq_level: 20,
two_pass: true,
preset: "p4".to_string(),
rate_control: NvencRateControl::Vbr,
},
}
}
}
/// Encoder-specific settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
@@ -133,21 +152,56 @@ impl EncoderConfig {
EncoderSettings::Nvenc { .. } | EncoderSettings::Amf { .. }
)
}
}
/// Video encoder trait for abstraction over different encoders.
pub trait VideoEncoder {
/// Get the encoder ID.
fn id(&self) -> &str;
/// Check if this is an NVIDIA encoder.
pub fn is_nvenc(&self) -> bool {
matches!(self.settings, EncoderSettings::Nvenc { .. })
}
/// Get the current bitrate.
fn bitrate(&self) -> u32;
/// Check if this is an AMD encoder.
pub fn is_amf(&self) -> bool {
matches!(self.settings, EncoderSettings::Amf { .. })
}
/// Update the bitrate.
fn set_bitrate(&mut self, bitrate: u32);
/// Check if this is a software encoder.
pub fn is_software(&self) -> bool {
matches!(self.settings, EncoderSettings::X264 { .. })
}
/// Get encoder-specific settings as JSON.
fn settings_json(&self) -> serde_json::Value;
/// Get the hardware codec type for libobs-simple.
pub fn hardware_codec(&self) -> Option<libobs_simple::output::simple::HardwareCodec> {
use libobs_simple::output::simple::HardwareCodec;
match &self.settings {
EncoderSettings::Nvenc { .. } => Some(HardwareCodec::H264),
EncoderSettings::Amf { .. } => Some(HardwareCodec::H264),
EncoderSettings::X264 { .. } => None,
}
}
/// Get the hardware preset for libobs-simple.
pub fn hardware_preset(&self) -> libobs_simple::output::simple::HardwarePreset {
use libobs_simple::output::simple::HardwarePreset;
match &self.settings {
EncoderSettings::Nvenc { preset, .. } => match preset.as_str() {
"p1" => HardwarePreset::Speed,
"p2" => HardwarePreset::Speed,
"p3" => HardwarePreset::Balanced,
"p4" => HardwarePreset::Balanced,
"p5" => HardwarePreset::Quality,
"p6" => HardwarePreset::Quality,
"p7" => HardwarePreset::Quality,
_ => HardwarePreset::Balanced,
},
EncoderSettings::Amf { quality, .. } => match quality {
AmfQuality::Speed => HardwarePreset::Speed,
AmfQuality::Balanced => HardwarePreset::Balanced,
AmfQuality::Quality => HardwarePreset::Quality,
},
EncoderSettings::X264 { .. } => HardwarePreset::Balanced,
}
}
}
/// Audio encoder configuration.
@@ -174,49 +228,14 @@ impl Default for AudioEncoderConfig {
}
}
/// 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);
impl AudioEncoderConfig {
/// Create a new audio encoder config with the specified bitrate.
pub fn with_bitrate(bitrate: u32) -> Self {
Self {
bitrate,
..Default::default()
}
}
#[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.
@@ -259,6 +278,142 @@ impl EncoderCapability {
},
}
}
/// Get a human-readable name for this encoder capability.
pub fn name(&self) -> &'static str {
match self {
EncoderCapability::Nvenc => "NVIDIA NVENC",
EncoderCapability::Amf => "AMD AMF",
EncoderCapability::QuickSync => "Intel QuickSync",
EncoderCapability::Software => "Software (x264)",
}
}
}
/// Detect available hardware encoders.
///
/// This function checks the system for available GPU encoders.
pub fn detect_hardware_encoders() -> Vec<EncoderCapability> {
use std::io::Write;
use tracing::info;
info!("[ENCODER_DETECT] Starting hardware encoder detection...");
std::io::stderr().flush().ok();
let mut capabilities = Vec::new();
#[cfg(target_os = "linux")]
{
// Check for NVIDIA
if std::path::Path::new("/dev/nvidia0").exists() {
info!("[ENCODER_DETECT] Found NVIDIA device");
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, check for NVIDIA first
// Try to load nvenc DLL
info!("[ENCODER_DETECT] Checking for NVENC...");
std::io::stderr().flush().ok();
if is_nvenc_available() {
info!("[ENCODER_DETECT] NVENC available");
capabilities.push(EncoderCapability::Nvenc);
} else {
info!("[ENCODER_DETECT] NVENC not available");
}
// Check for AMD AMF
info!("[ENCODER_DETECT] Checking for AMF...");
std::io::stderr().flush().ok();
if is_amf_available() {
info!("[ENCODER_DETECT] AMF available");
capabilities.push(EncoderCapability::Amf);
} else {
info!("[ENCODER_DETECT] AMF not available");
}
// Check for Intel QuickSync
info!("[ENCODER_DETECT] Checking for QuickSync...");
std::io::stderr().flush().ok();
if is_quicksync_available() {
info!("[ENCODER_DETECT] QuickSync available");
capabilities.push(EncoderCapability::QuickSync);
} else {
info!("[ENCODER_DETECT] QuickSync not available");
}
}
// Always available
info!("[ENCODER_DETECT] Software encoder always available");
capabilities.push(EncoderCapability::Software);
info!("[ENCODER_DETECT] Detected encoders: {:?}", capabilities);
std::io::stderr().flush().ok();
capabilities
}
/// Check if NVIDIA NVENC is available.
#[cfg(target_os = "windows")]
fn is_nvenc_available() -> bool {
// Check for NVENC DLL
std::path::Path::new("C:\\Windows\\System32\\nvEncMFTH264.dll").exists()
|| std::path::Path::new("C:\\Windows\\System32\\nvEncMFTH265.dll").exists()
}
#[cfg(not(target_os = "windows"))]
fn is_nvenc_available() -> bool {
false
}
/// Check if AMD AMF is available.
#[cfg(target_os = "windows")]
fn is_amf_available() -> bool {
// Check for AMF runtime
std::path::Path::new("C:\\Windows\\System32\\amdocl64.dll").exists()
}
#[cfg(not(target_os = "windows"))]
fn is_amf_available() -> bool {
false
}
/// Check if Intel QuickSync is available.
#[cfg(target_os = "windows")]
fn is_quicksync_available() -> bool {
// Check for Intel Media SDK
std::path::Path::new("C:\\Windows\\System32\\mfx64.dll").exists()
}
#[cfg(not(target_os = "windows"))]
fn is_quicksync_available() -> bool {
false
}
/// Get the best available encoder capability.
pub fn best_available_encoder() -> EncoderCapability {
let capabilities = detect_hardware_encoders();
// Prefer hardware encoders
for cap in &capabilities {
if matches!(
cap,
EncoderCapability::Nvenc | EncoderCapability::Amf | EncoderCapability::QuickSync
) {
return *cap;
}
}
// Fall back to software
EncoderCapability::Software
}
#[cfg(test)]
@@ -278,6 +433,7 @@ mod tests {
assert_eq!(config.encoder_id, "jim_nvenc");
assert_eq!(config.bitrate, 8000);
assert!(config.is_hardware());
assert!(config.is_nvenc());
}
#[test]
@@ -292,6 +448,7 @@ mod tests {
assert_eq!(config.encoder_id, "x264");
assert!(!config.is_hardware());
assert!(config.is_software());
}
#[test]
@@ -300,4 +457,17 @@ mod tests {
assert!(!capabilities.is_empty());
assert!(capabilities.contains(&EncoderCapability::Software));
}
#[test]
fn test_best_available_encoder() {
let best = best_available_encoder();
// Should always return something
assert!(matches!(
best,
EncoderCapability::Nvenc
| EncoderCapability::Amf
| EncoderCapability::QuickSync
| EncoderCapability::Software
));
}
}

View File

@@ -8,8 +8,8 @@ pub mod encoder;
mod obs_context;
mod output;
pub use capture::{CaptureSource, GameCapture};
pub use encoder::{AudioEncoder, EncoderConfig, VideoEncoder};
pub use capture::{CaptureMode, GameCapture, MonitorCapture, SourceType, WindowCapture};
pub use encoder::{AudioEncoderConfig, EncoderCapability, EncoderConfig, EncoderSettings};
pub use obs_context::{ObsContext, ObsContextBuilder};
pub use output::{OutputConfig, RecordingOutput, RecordingResult};
@@ -17,7 +17,7 @@ use std::sync::Arc;
use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use tracing::{info, warn};
use tracing::{error, info, warn};
use crate::config::Settings;
use crate::error::{RecordingError, Result};
@@ -92,10 +92,17 @@ impl RecordingEngine {
/// * `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<()> {
info!(
"RecordingEngine::start_recording called - game_id: {:?}, champion: {:?}",
game_id, champion
);
if self.is_recording {
warn!("Already recording, returning error");
return Err(RecordingError::AlreadyRecording.into());
}
info!("Reading settings...");
let settings = self.settings.read().clone();
// Generate output filename
@@ -105,10 +112,14 @@ impl RecordingEngine {
info!("Starting recording to: {:?}", output_path);
// Start the recording
let context = self.context.as_mut().ok_or(RecordingError::ObsInitError(
"OBS not initialized".to_string(),
))?;
let context = self.context.as_mut().ok_or_else(|| {
error!("OBS not initialized when start_recording was called");
RecordingError::ObsInitError("OBS not initialized".to_string())
})?;
info!("Calling OBS context start_recording...");
context.start_recording(&output_path)?;
info!("OBS context start_recording returned successfully");
self.current_output = Some(RecordingOutput {
path: output_path,

View File

@@ -1,82 +1,25 @@
//! OBS context initialization and management.
//!
//! This module handles the lifecycle of the OBS library context.
//! This module handles the lifecycle of the OBS library context using libobs-simple.
use std::path::Path;
use tracing::{debug, info, warn};
use libobs_simple::output::simple::{
HardwareCodec, HardwarePreset, OutputFormat, SimpleOutputBuilder,
};
use libobs_simple::wrapper::data::output::ObsOutputTrait;
use libobs_simple::wrapper::data::video::ObsVideoInfoBuilder;
use libobs_simple::wrapper::data::ObsObjectBuilder;
use libobs_simple::wrapper::scenes::SceneItemExtSceneTrait;
use libobs_simple::wrapper::utils::{ObsPath, ObsString};
use libobs_wrapper::context::ObsContext as LibObsContext;
use libobs_wrapper::utils::StartupInfo;
use tracing::{error, info, warn};
use super::encoder::{best_available_encoder, EncoderCapability};
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>,
@@ -126,20 +69,17 @@ impl ObsContextBuilder {
.map_err(|e| RecordingError::OutputDirError(e.to_string()))?;
}
let video_info = ObsVideoInfo::from_settings(&video_settings);
let context = ObsContext {
video_info,
video_settings,
audio_settings,
output_dir,
initialized: false,
context: None,
output: None,
recording: false,
current_output: None,
encoder_capability: None,
};
// Note: Actual libobs initialization would happen here
// For now, we create a stub that can be extended with actual libobs bindings
Ok(context)
}
}
@@ -156,23 +96,27 @@ impl Default for ObsContextBuilder {
/// recording functionality.
pub struct ObsContext {
/// Video configuration.
video_info: ObsVideoInfo,
video_settings: VideoSettings,
/// Audio configuration.
audio_settings: AudioSettings,
/// Output directory for recordings.
output_dir: std::path::PathBuf,
/// Whether OBS has been initialized.
initialized: bool,
/// The underlying libobs context.
context: Option<LibObsContext>,
/// The current output.
output: Option<libobs_simple::wrapper::data::output::ObsOutputRef>,
/// Whether currently recording.
recording: bool,
/// Current output path (if recording).
current_output: Option<std::path::PathBuf>,
/// Detected encoder capability.
encoder_capability: Option<EncoderCapability>,
}
impl ObsContext {
/// Check if OBS is initialized.
pub fn is_initialized(&self) -> bool {
self.initialized
self.context.is_some()
}
/// Check if currently recording.
@@ -180,31 +124,695 @@ impl ObsContext {
self.recording
}
/// Get the video info.
pub fn video_info(&self) -> &ObsVideoInfo {
&self.video_info
/// Get the video settings.
pub fn video_settings(&self) -> &VideoSettings {
&self.video_settings
}
/// Initialize OBS.
fn initialize(&mut self) -> Result<()> {
if self.context.is_some() {
info!("OBS context already initialized");
return Ok(());
}
info!("[OBS_INIT] Starting OBS context initialization...");
use std::io::Write;
std::io::stderr().flush().ok();
// Pre-flight checks for OBS
self.preflight_checks()?;
// Detect best available encoder
info!("[OBS_INIT] Detecting best available encoder...");
std::io::stderr().flush().ok();
let encoder = best_available_encoder();
info!("[OBS_INIT] Detected encoder: {}", encoder.name());
std::io::stderr().flush().ok();
self.encoder_capability = Some(encoder);
let (width, height) = self.video_settings.quality.resolution();
let fps = self.video_settings.frame_rate;
info!(
"[OBS_INIT] OBS video config: {}x{} @ {}fps",
width, height, fps
);
std::io::stderr().flush().ok();
// Create startup info with video configuration
info!("[OBS_INIT] Creating OBS video info builder...");
std::io::stderr().flush().ok();
let video_info = ObsVideoInfoBuilder::new()
.fps_num(fps)
.fps_den(1)
.base_width(width)
.base_height(height)
.output_width(width)
.output_height(height)
.build();
info!("[OBS_INIT] OBS video info built successfully");
std::io::stderr().flush().ok();
info!("[OBS_INIT] Building OBS context with LibObsContext::builder()...");
std::io::stderr().flush().ok();
let compat = LibObsContext::check_version_compatibility();
if !compat {
error!("OBS version compatibility is wrong. This will not go well...");
}
//FIXME: this is crashing here.
// LibObsContext::new makes the whole application crash, even with default options
let info = StartupInfo::default().set_video_info(video_info);
let context_result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| LibObsContext::new(info)));
let context = match context_result {
Ok(Ok(ctx)) => {
info!("[OBS_INIT] OBS context created successfully");
std::io::stderr().flush().ok();
ctx
}
Ok(Err(e)) => {
error!("[OBS_INIT] Failed to create OBS context: {:?}", e);
std::io::stderr().flush().ok();
return Err(RecordingError::ObsInitError(format!(
"Failed to create OBS context: {:?}",
e
))
.into());
}
Err(panic_info) => {
error!(
"[OBS_INIT] PANIC during OBS context creation: {:?}",
panic_info
);
std::io::stderr().flush().ok();
eprintln!(
"[OBS_INIT] PANIC during OBS context creation: {:?}",
panic_info
);
return Err(RecordingError::ObsInitError(
"Panic during OBS context creation".to_string(),
)
.into());
}
};
if self.audio_settings.enabled {
info!(
"[OBS_INIT] OBS audio config: {} channels @ {}Hz",
self.audio_settings.channels, self.audio_settings.sample_rate
);
}
self.context = Some(context);
info!("[OBS_INIT] OBS context initialized successfully");
std::io::stderr().flush().ok();
Ok(())
}
/// Pre-flight checks for OBS initialization.
fn preflight_checks(&self) -> Result<()> {
use std::io::Write;
info!("[PREFLIGHT] Running OBS pre-flight checks...");
std::io::stderr().flush().ok();
// Check for OBS installation directory
// libobs-bootstrapper typically extracts OBS to a specific location
let obs_paths = self.get_obs_search_paths();
info!("[PREFLIGHT] OBS search paths: {:?}", obs_paths);
std::io::stderr().flush().ok();
let mut obs_found = false;
for path in &obs_paths {
if path.exists() {
info!("[PREFLIGHT] Found OBS at: {:?}", path);
std::io::stderr().flush().ok();
// Check for plugins directory
let plugins_path = path.join("obs-plugins");
if plugins_path.exists() {
info!("[PREFLIGHT] Found OBS plugins at: {:?}", plugins_path);
std::io::stderr().flush().ok();
// Check for 64-bit plugins
let plugins_64 = plugins_path.join("64bit");
if plugins_64.exists() {
info!("[PREFLIGHT] Found 64-bit plugins at: {:?}", plugins_64);
std::io::stderr().flush().ok();
// List available plugins
if let Ok(entries) = std::fs::read_dir(&plugins_64) {
let plugins: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
.collect();
info!("[PREFLIGHT] Available plugins: {:?}", plugins);
std::io::stderr().flush().ok();
}
}
}
obs_found = true;
break;
}
}
if !obs_found {
warn!(
"[PREFLIGHT] OBS installation not found in standard paths - this may cause issues"
);
std::io::stderr().flush().ok();
}
// Check for display (required for capture)
#[cfg(target_os = "windows")]
{
info!("[PREFLIGHT] Checking for display availability...");
std::io::stderr().flush().ok();
// On Windows, check if we have a display
use std::ptr;
unsafe {
let dc = winapi::um::winuser::GetDC(ptr::null_mut());
if dc.is_null() {
warn!("[PREFLIGHT] No display context available - capture may fail");
} else {
info!("[PREFLIGHT] Display context available");
winapi::um::winuser::ReleaseDC(ptr::null_mut(), dc);
}
}
std::io::stderr().flush().ok();
}
info!("[PREFLIGHT] Pre-flight checks completed");
std::io::stderr().flush().ok();
Ok(())
}
/// Get OBS search paths based on platform.
fn get_obs_search_paths(&self) -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
// Current directory
paths.push(std::env::current_dir().unwrap_or_default().join("obs"));
// libobs-bootstrapper default locations
#[cfg(target_os = "windows")]
{
// Check AppData
if let Some(app_data) = std::env::var_os("LOCALAPPDATA") {
paths.push(std::path::PathBuf::from(app_data).join("obs-studio"));
}
if let Some(app_data) = std::env::var_os("APPDATA") {
paths.push(std::path::PathBuf::from(app_data).join("obs-studio"));
}
// Program Files
paths.push(std::path::PathBuf::from("C:\\Program Files\\obs-studio"));
paths.push(std::path::PathBuf::from(
"C:\\Program Files (x86)\\obs-studio",
));
}
#[cfg(target_os = "linux")]
{
paths.push(std::path::PathBuf::from("/usr/share/obs"));
paths.push(std::path::PathBuf::from("/usr/local/share/obs"));
if let Some(home) = std::env::var_os("HOME") {
paths.push(std::path::PathBuf::from(home).join(".local/share/obs"));
}
}
paths
}
/// Start recording to the specified output path.
pub fn start_recording(&mut self, output_path: &Path) -> Result<()> {
use std::io::Write;
info!(
"[START_REC] start_recording called with path: {:?}",
output_path
);
std::io::stderr().flush().ok();
if self.recording {
warn!("[START_REC] Already recording, returning error");
return Err(RecordingError::AlreadyRecording.into());
}
if !self.initialized {
// Initialize on first use
if self.context.is_none() {
info!("[START_REC] OBS context not initialized, initializing now...");
std::io::stderr().flush().ok();
self.initialize()?;
info!("[START_REC] OBS initialization complete");
std::io::stderr().flush().ok();
}
info!("Starting OBS recording to: {:?}", output_path);
let context = self.context.as_ref().ok_or_else(|| {
error!("[START_REC] OBS not initialized after initialize()");
std::io::stderr().flush().ok();
RecordingError::ObsInitError("OBS not initialized".to_string())
})?;
// Note: Actual libobs recording start would happen here
// This is a stub implementation
info!("[START_REC] Starting OBS recording to: {:?}", output_path);
std::io::stderr().flush().ok();
// Get bitrate from encoder preset
let bitrate = self.video_settings.encoder_preset.effective_bitrate();
info!("[START_REC] Using bitrate: {} kbps", bitrate);
std::io::stderr().flush().ok();
// Create output path
let path_str = output_path.to_string_lossy();
let obs_path = ObsPath::new(&path_str);
// Get detected encoder capability
let encoder = self
.encoder_capability
.unwrap_or_else(best_available_encoder);
info!("[START_REC] Using encoder: {}", encoder.name());
std::io::stderr().flush().ok();
// Build the output based on encoder capability
info!("[START_REC] Creating SimpleOutputBuilder...");
std::io::stderr().flush().ok();
let output_result = match encoder {
EncoderCapability::Nvenc => {
info!("Building NVENC output...");
SimpleOutputBuilder::new(context.clone(), ObsString::from("output"), obs_path)
.video_bitrate(bitrate)
.audio_bitrate(self.audio_settings.bitrate)
.hardware_encoder(HardwareCodec::H264, HardwarePreset::Quality)
.format(OutputFormat::Mpeg4)
.build()
}
EncoderCapability::Amf => {
info!("Building AMF output...");
SimpleOutputBuilder::new(context.clone(), ObsString::from("output"), obs_path)
.video_bitrate(bitrate)
.audio_bitrate(self.audio_settings.bitrate)
.hardware_encoder(HardwareCodec::H264, HardwarePreset::Balanced)
.format(OutputFormat::Mpeg4)
.build()
}
EncoderCapability::QuickSync => {
info!("Building QuickSync output...");
SimpleOutputBuilder::new(context.clone(), ObsString::from("output"), obs_path)
.video_bitrate(bitrate)
.audio_bitrate(self.audio_settings.bitrate)
.hardware_encoder(HardwareCodec::H264, HardwarePreset::Balanced)
.format(OutputFormat::Mpeg4)
.build()
}
EncoderCapability::Software => {
info!("Building software (x264) output...");
SimpleOutputBuilder::new(context.clone(), ObsString::from("output"), obs_path)
.video_bitrate(bitrate)
.audio_bitrate(self.audio_settings.bitrate)
.format(OutputFormat::Mpeg4)
.build()
}
};
info!("[START_REC] Output build complete, checking for errors...");
std::io::stderr().flush().ok();
let output = output_result.map_err(|e| {
error!("[START_REC] Failed to create output: {:?}", e);
std::io::stderr().flush().ok();
RecordingError::StartError(format!("Failed to create output: {:?}", e))
})?;
info!("[START_REC] Output created successfully, setting up game capture...");
std::io::stderr().flush().ok();
// Set up game capture source
self.setup_game_capture()?;
info!("[START_REC] Game capture set up, starting output...");
std::io::stderr().flush().ok();
// Start the output - wrap in catch_unwind as this may crash in native code
let start_result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| output.start()));
match start_result {
Ok(Ok(())) => {
info!("[START_REC] Output started successfully");
std::io::stderr().flush().ok();
}
Ok(Err(e)) => {
error!("[START_REC] Failed to start output: {:?}", e);
std::io::stderr().flush().ok();
return Err(
RecordingError::StartError(format!("Failed to start output: {:?}", e)).into(),
);
}
Err(panic_info) => {
error!("[START_REC] PANIC starting output: {:?}", panic_info);
std::io::stderr().flush().ok();
eprintln!("[START_REC] PANIC starting output: {:?}", panic_info);
return Err(RecordingError::StartError("Panic starting output".to_string()).into());
}
}
self.output = Some(output);
self.current_output = Some(output_path.to_path_buf());
self.recording = true;
debug!("OBS recording started");
info!("[START_REC] OBS recording started successfully");
std::io::stderr().flush().ok();
Ok(())
}
/// Set up capture source with fallback from game capture to monitor capture.
fn setup_game_capture(&mut self) -> Result<()> {
use std::io::Write;
info!("[CAPTURE] Setting up capture source...");
std::io::stderr().flush().ok();
// Try game capture first, fall back to monitor capture
match self.try_game_capture() {
Ok(()) => {
info!("[CAPTURE] Game capture set up successfully");
std::io::stderr().flush().ok();
Ok(())
}
Err(e) => {
warn!(
"[CAPTURE] Game capture failed: {}, falling back to monitor capture",
e
);
std::io::stderr().flush().ok();
self.setup_monitor_capture()
}
}
}
/// Try to set up game capture source.
fn try_game_capture(&mut self) -> Result<()> {
use libobs_simple::sources::windows::{GameCaptureSourceBuilder, ObsGameCaptureMode};
use libobs_simple::sources::ObsSourceBuilder;
use std::io::Write;
info!("[GAME_CAPTURE] Attempting game capture setup...");
std::io::stderr().flush().ok();
let context = self.context.as_mut().ok_or_else(|| {
error!("[GAME_CAPTURE] OBS not initialized in setup_game_capture");
std::io::stderr().flush().ok();
RecordingError::ObsInitError("OBS not initialized".to_string())
})?;
// Create a scene
info!("[GAME_CAPTURE] Creating scene 'main'...");
std::io::stderr().flush().ok();
let mut scene = context.scene("main", None).map_err(|e| {
error!("[GAME_CAPTURE] Failed to create scene: {:?}", e);
std::io::stderr().flush().ok();
RecordingError::StartError(format!("Failed to create scene: {:?}", e))
})?;
info!("[GAME_CAPTURE] Scene created successfully");
std::io::stderr().flush().ok();
// Build game capture source
info!("[GAME_CAPTURE] Getting OBS runtime...");
std::io::stderr().flush().ok();
let runtime = context.runtime();
info!("[GAME_CAPTURE] Creating game capture source builder...");
std::io::stderr().flush().ok();
// Wrap game capture builder in catch_unwind as it may crash in native code
let builder_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
GameCaptureSourceBuilder::new("game_capture", runtime.clone())
}));
let builder = match builder_result {
Ok(Ok(b)) => {
info!("[GAME_CAPTURE] Game capture builder created");
std::io::stderr().flush().ok();
b
}
Ok(Err(e)) => {
error!(
"[GAME_CAPTURE] Failed to create game capture builder: {:?}",
e
);
std::io::stderr().flush().ok();
return Err(RecordingError::StartError(format!(
"Failed to create game capture builder: {:?}",
e
))
.into());
}
Err(panic_info) => {
error!(
"[GAME_CAPTURE] PANIC creating game capture builder: {:?}",
panic_info
);
std::io::stderr().flush().ok();
eprintln!(
"[GAME_CAPTURE] PANIC creating game capture builder: {:?}",
panic_info
);
return Err(RecordingError::StartError(
"Panic creating game capture builder".to_string(),
)
.into());
}
};
info!("[GAME_CAPTURE] Configuring game capture for League of Legends...");
std::io::stderr().flush().ok();
// Use "Any" mode to capture any fullscreen application
// This is the most reliable mode for games like League of Legends
info!("[GAME_CAPTURE] Using 'Any' mode to capture fullscreen games...");
std::io::stderr().flush().ok();
// Wrap source build in catch_unwind
let source_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
builder.set_capture_mode(ObsGameCaptureMode::Any).build()
}));
let source = match source_result {
Ok(Ok(s)) => {
info!("[GAME_CAPTURE] Game capture source created");
std::io::stderr().flush().ok();
s
}
Ok(Err(e)) => {
error!(
"[GAME_CAPTURE] Failed to create game capture source: {:?}",
e
);
std::io::stderr().flush().ok();
return Err(RecordingError::StartError(format!(
"Failed to create game capture source: {:?}",
e
))
.into());
}
Err(panic_info) => {
error!(
"[GAME_CAPTURE] PANIC creating game capture source: {:?}",
panic_info
);
std::io::stderr().flush().ok();
eprintln!(
"[GAME_CAPTURE] PANIC creating game capture source: {:?}",
panic_info
);
return Err(RecordingError::StartError(
"Panic creating game capture source".to_string(),
)
.into());
}
};
// Add source to scene using add_source
info!("[GAME_CAPTURE] Adding source to scene...");
std::io::stderr().flush().ok();
scene.add_source(source).map_err(|e| {
error!("[GAME_CAPTURE] Failed to add source to scene: {:?}", e);
std::io::stderr().flush().ok();
RecordingError::StartError(format!("Failed to add source to scene: {:?}", e))
})?;
info!("[GAME_CAPTURE] Source added to scene");
std::io::stderr().flush().ok();
// Set the scene as active
info!("[GAME_CAPTURE] Setting scene as active on channel 0...");
std::io::stderr().flush().ok();
scene.set_to_channel(0).map_err(|e| {
error!("[GAME_CAPTURE] Failed to set scene: {:?}", e);
RecordingError::StartError(format!("Failed to set scene: {:?}", e))
})?;
info!("[GAME_CAPTURE] Game capture source configured successfully");
std::io::stderr().flush().ok();
Ok(())
}
/// Set up monitor capture as fallback.
fn setup_monitor_capture(&mut self) -> Result<()> {
use libobs_simple::sources::windows::MonitorCaptureSourceBuilder;
use libobs_simple::sources::ObsSourceBuilder;
use std::io::Write;
info!("[MONITOR_CAPTURE] Setting up monitor capture as fallback...");
std::io::stderr().flush().ok();
let context = self.context.as_mut().ok_or_else(|| {
error!("[MONITOR_CAPTURE] OBS not initialized");
std::io::stderr().flush().ok();
RecordingError::ObsInitError("OBS not initialized".to_string())
})?;
// Create a scene
info!("[MONITOR_CAPTURE] Creating scene 'main'...");
std::io::stderr().flush().ok();
let mut scene = context.scene("main", None).map_err(|e| {
error!("[MONITOR_CAPTURE] Failed to create scene: {:?}", e);
std::io::stderr().flush().ok();
RecordingError::StartError(format!("Failed to create scene: {:?}", e))
})?;
info!("[MONITOR_CAPTURE] Scene created successfully");
std::io::stderr().flush().ok();
// Get monitor info
info!("[MONITOR_CAPTURE] Detecting monitors...");
std::io::stderr().flush().ok();
let monitors = display_info::DisplayInfo::all().map_err(|e| {
error!("[MONITOR_CAPTURE] Failed to get display info: {:?}", e);
std::io::stderr().flush().ok();
RecordingError::StartError(format!("Failed to get display info: {:?}", e))
})?;
if monitors.is_empty() {
error!("[MONITOR_CAPTURE] No monitors detected");
std::io::stderr().flush().ok();
return Err(RecordingError::StartError("No monitors detected".to_string()).into());
}
// Use the primary monitor (first in the list)
let primary_monitor = &monitors[0];
info!(
"[MONITOR_CAPTURE] Using monitor: {}x{} at ({}, {})",
primary_monitor.width, primary_monitor.height, primary_monitor.x, primary_monitor.y
);
std::io::stderr().flush().ok();
// Build monitor capture source
info!("[MONITOR_CAPTURE] Creating monitor capture source builder...");
std::io::stderr().flush().ok();
let builder_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
MonitorCaptureSourceBuilder::new("monitor_capture", context.runtime().clone())
}));
let builder = match builder_result {
Ok(Ok(b)) => {
info!("[MONITOR_CAPTURE] Monitor capture builder created");
std::io::stderr().flush().ok();
b
}
Ok(Err(e)) => {
error!(
"[MONITOR_CAPTURE] Failed to create monitor capture builder: {:?}",
e
);
std::io::stderr().flush().ok();
return Err(RecordingError::StartError(format!(
"Failed to create monitor capture builder: {:?}",
e
))
.into());
}
Err(panic_info) => {
error!(
"[MONITOR_CAPTURE] PANIC creating monitor capture builder: {:?}",
panic_info
);
std::io::stderr().flush().ok();
eprintln!(
"[MONITOR_CAPTURE] PANIC creating monitor capture builder: {:?}",
panic_info
);
return Err(RecordingError::StartError(
"Panic creating monitor capture builder".to_string(),
)
.into());
}
};
// Build the source
let source_result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build()));
let source = match source_result {
Ok(Ok(s)) => {
info!("[MONITOR_CAPTURE] Monitor capture source created");
std::io::stderr().flush().ok();
s
}
Ok(Err(e)) => {
error!(
"[MONITOR_CAPTURE] Failed to create monitor capture source: {:?}",
e
);
std::io::stderr().flush().ok();
return Err(RecordingError::StartError(format!(
"Failed to create monitor capture source: {:?}",
e
))
.into());
}
Err(panic_info) => {
error!(
"[MONITOR_CAPTURE] PANIC creating monitor capture source: {:?}",
panic_info
);
std::io::stderr().flush().ok();
eprintln!(
"[MONITOR_CAPTURE] PANIC creating monitor capture source: {:?}",
panic_info
);
return Err(RecordingError::StartError(
"Panic creating monitor capture source".to_string(),
)
.into());
}
};
// Add source to scene
info!("[MONITOR_CAPTURE] Adding source to scene...");
std::io::stderr().flush().ok();
scene.add_source(source).map_err(|e| {
error!("[MONITOR_CAPTURE] Failed to add source to scene: {:?}", e);
std::io::stderr().flush().ok();
RecordingError::StartError(format!("Failed to add source to scene: {:?}", e))
})?;
info!("[MONITOR_CAPTURE] Source added to scene");
std::io::stderr().flush().ok();
// Set the scene as active
info!("[MONITOR_CAPTURE] Setting scene as active on channel 0...");
std::io::stderr().flush().ok();
scene.set_to_channel(0).map_err(|e| {
error!("[MONITOR_CAPTURE] Failed to set scene: {:?}", e);
RecordingError::StartError(format!("Failed to set scene: {:?}", e))
})?;
info!("[MONITOR_CAPTURE] Monitor capture source configured successfully");
std::io::stderr().flush().ok();
Ok(())
}
@@ -216,49 +824,16 @@ impl ObsContext {
info!("Stopping OBS recording");
// Note: Actual libobs recording stop would happen here
// This is a stub implementation
if let Some(mut output) = self.output.take() {
output.stop().map_err(|e| {
RecordingError::StopError(format!("Failed to stop output: {:?}", e))
})?;
}
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");
info!("OBS recording stopped successfully");
Ok(())
}
@@ -268,13 +843,14 @@ impl ObsContext {
self.stop_recording()?;
}
if self.initialized {
if self.output.is_some() {
self.output = None;
}
if self.context.is_some() {
info!("Shutting down OBS context");
// Note: Actual libobs shutdown would happen here
// obs_shutdown()
self.initialized = false;
// The LibObsContext handles cleanup on drop
self.context = None;
}
Ok(())
@@ -289,49 +865,6 @@ impl Drop for ObsContext {
}
}
// 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::*;
@@ -347,14 +880,4 @@ mod tests {
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);
}
}

View File

@@ -179,7 +179,9 @@ impl DaemonStateMachine {
// From Recording
(DaemonState::Recording, StateTransition::GameEnded) => Some(DaemonState::Monitoring),
// Allow GameStarted from Recording (handles case where GameEnded wasn't received)
(DaemonState::Recording, StateTransition::GameStarted { .. }) => Some(DaemonState::Recording),
(DaemonState::Recording, StateTransition::GameStarted { .. }) => {
Some(DaemonState::Recording)
}
(DaemonState::Recording, StateTransition::ClientStopped) => Some(DaemonState::Idle),
(DaemonState::Recording, StateTransition::Error(_)) => Some(DaemonState::Error),
(DaemonState::Recording, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown),

View File

@@ -41,7 +41,7 @@ impl EventMapper {
}
/// Map a game event to video and game timestamps.
pub fn map_event(&self, event: &GameEvent) -> Option<(Duration, Option<Duration>)> {
pub fn map_event(&self, _event: &GameEvent) -> Option<(Duration, Option<Duration>)> {
let start_time = self.start_time?;
let now = Utc::now();

View File

@@ -152,7 +152,7 @@ fn event_type_name(event: &GameEvent) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::lqp::{KillEvent, ObjectiveEvent, ObjectiveType};
use crate::lqp::KillEvent;
#[test]
fn test_timeline_creation() {