record-daemon: fix obs recording
This commit is contained in:
2410
record-daemon/Cargo.lock
generated
2410
record-daemon/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture source type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SourceType {
|
||||
/// Game capture source.
|
||||
GameCapture,
|
||||
/// Window capture source.
|
||||
WindowCapture,
|
||||
/// Monitor capture source.
|
||||
MonitorCapture,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// Note: Actual window finding would use platform-specific APIs
|
||||
// On Linux: X11/Wayland
|
||||
// On Windows: Win32 API
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is an NVIDIA encoder.
|
||||
pub fn is_nvenc(&self) -> bool {
|
||||
matches!(self.settings, EncoderSettings::Nvenc { .. })
|
||||
}
|
||||
|
||||
/// Video encoder trait for abstraction over different encoders.
|
||||
pub trait VideoEncoder {
|
||||
/// Get the encoder ID.
|
||||
fn id(&self) -> &str;
|
||||
/// Check if this is an AMD encoder.
|
||||
pub fn is_amf(&self) -> bool {
|
||||
matches!(self.settings, EncoderSettings::Amf { .. })
|
||||
}
|
||||
|
||||
/// Get the current bitrate.
|
||||
fn bitrate(&self) -> u32;
|
||||
/// Check if this is a software encoder.
|
||||
pub fn is_software(&self) -> bool {
|
||||
matches!(self.settings, EncoderSettings::X264 { .. })
|
||||
}
|
||||
|
||||
/// Update the bitrate.
|
||||
fn set_bitrate(&mut self, bitrate: u32);
|
||||
/// 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;
|
||||
|
||||
/// Get encoder-specific settings as JSON.
|
||||
fn settings_json(&self) -> serde_json::Value;
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user