From f2eb7139dada835208085ec58e36e034d519a457 Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 8 Oct 2026 14:12:00 +0200 Subject: [PATCH] feat: add OBS recording engine and validation probe clipbin-obs starts the pinned libobs on a dedicated thread, opens exactly the bundled plugin allowlist, and records official capture sources through one hardware encoder into keyframe-aligned Matroska segments. Status reaches Recording only once the source has a picture and the segment grows on disk. clipbin-probe lists and verifies hardware encoders and records from a real capture source, printing status, health and segment events for validation. --- .cargo/config.toml | 6 + Cargo.lock | 50 +- Cargo.toml | 11 +- crates/clipbin-obs/Cargo.toml | 17 + crates/clipbin-obs/src/capture.rs | 208 ++++++++ crates/clipbin-obs/src/encoders.rs | 109 ++++ crates/clipbin-obs/src/ffi.rs | 822 +++++++++++++++++++++++++++++ crates/clipbin-obs/src/lib.rs | 18 + crates/clipbin-obs/src/recorder.rs | 509 ++++++++++++++++++ crates/clipbin-obs/src/runtime.rs | 200 +++++++ crates/clipbin-obs/src/thread.rs | 67 +++ crates/clipbin-probe/Cargo.toml | 16 + crates/clipbin-probe/build.rs | 56 ++ crates/clipbin-probe/src/main.rs | 384 ++++++++++++++ 14 files changed, 2470 insertions(+), 3 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 crates/clipbin-obs/Cargo.toml create mode 100644 crates/clipbin-obs/src/capture.rs create mode 100644 crates/clipbin-obs/src/encoders.rs create mode 100644 crates/clipbin-obs/src/ffi.rs create mode 100644 crates/clipbin-obs/src/lib.rs create mode 100644 crates/clipbin-obs/src/recorder.rs create mode 100644 crates/clipbin-obs/src/runtime.rs create mode 100644 crates/clipbin-obs/src/thread.rs create mode 100644 crates/clipbin-probe/Cargo.toml create mode 100644 crates/clipbin-probe/build.rs create mode 100644 crates/clipbin-probe/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..09efc84 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +# Development builds use the pinned OBS runtime assembled by scripts/obs/. +# Packaged builds find the runtime relative to the executable instead. +[env] +CLIPBIN_OBS_RUNTIME_DIR = { value = "target/obs-runtime", relative = true } +# Linux: link libobs from the bundled runtime rather than a system OBS. +PKG_CONFIG_PATH = { value = "target/obs-runtime/linux-x86_64/lib/pkgconfig", relative = true } diff --git a/Cargo.lock b/Cargo.lock index e98c947..e0a6471 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,6 +496,26 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -851,6 +871,22 @@ dependencies = [ "gpui", ] +[[package]] +name = "clipbin-obs" +version = "0.1.0" +dependencies = [ + "clipbin-core", + "libobs", +] + +[[package]] +name = "clipbin-probe" +version = "0.1.0" +dependencies = [ + "clipbin-core", + "clipbin-obs", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -2084,7 +2120,7 @@ dependencies = [ "as-raw-xcb-connection", "ashpd 0.11.1", "async-task", - "bindgen", + "bindgen 0.71.1", "blade-graphics", "blade-macros", "blade-util", @@ -2236,7 +2272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" dependencies = [ "anyhow", - "bindgen", + "bindgen 0.71.1", "core-foundation 0.10.0", "core-video", "ctor", @@ -2940,6 +2976,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libobs" +version = "5.0.1+32.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8956a7534b34783f962fd8f9b16383bcf3a2b535629fd68628b205e849bd17" +dependencies = [ + "bindgen 0.72.1", + "pkg-config", +] + [[package]] name = "libredox" version = "0.1.25" diff --git a/Cargo.toml b/Cargo.toml index 9da3da8..3e5cf88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] -members = ["crates/clipbin-core", "crates/clipbin-desktop"] +members = [ + "crates/clipbin-core", + "crates/clipbin-desktop", + "crates/clipbin-obs", + "crates/clipbin-probe", +] default-members = ["crates/clipbin-desktop"] resolver = "3" @@ -13,7 +18,11 @@ publish = false [workspace.dependencies] clipbin-core = { path = "crates/clipbin-core" } +clipbin-obs = { path = "crates/clipbin-obs" } gpui = "=0.2.2" +# The tested OBS set: the libobs crate's bundled headers are OBS 32.0.4, matching +# scripts/obs/versions.env. Update them together. +libobs = "=5.0.1" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/clipbin-obs/Cargo.toml b/crates/clipbin-obs/Cargo.toml new file mode 100644 index 0000000..89e1181 --- /dev/null +++ b/crates/clipbin-obs/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "clipbin-obs" +description = "Clipbin's recording engine adapter over the pinned libobs runtime." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +clipbin-core.workspace = true +libobs.workspace = true + +# Raw libobs calls are confined to src/ffi.rs, which opts back in to unsafe code. +[lints.rust] +unsafe_code = "deny" diff --git a/crates/clipbin-obs/src/capture.rs b/crates/clipbin-obs/src/capture.rs new file mode 100644 index 0000000..04292a4 --- /dev/null +++ b/crates/clipbin-obs/src/capture.rs @@ -0,0 +1,208 @@ +/// What the user chose to record. Switching from a window to a monitor always +/// needs the user's choice; the recorder never broadens the target by itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CaptureTarget { + /// Windows: Game Capture hooked into one game window, the default for games. + /// Elsewhere this is the same as `Window`. + Game { window: WindowMatch }, + /// A single window: Windows Window Capture, X11 Xcomposite, or a + /// PipeWire window stream selected through the desktop portal on Wayland. + Window { window: WindowMatch }, + /// A whole monitor, including whatever is shown during alt-tab. + Monitor { monitor: MonitorMatch }, +} + +/// Identifies a window. On Windows this is OBS's `title:class:executable` +/// string; on X11 it is the Xcomposite window id; on Wayland the portal picks +/// the window, so only `restore_token` matters. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WindowMatch { + pub obs_window: String, + pub restore_token: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MonitorMatch { + /// Windows monitor device id, or X11 screen number; unused on Wayland. + pub obs_monitor: String, + pub restore_token: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SettingValue { + String(String), + Int(i64), + Bool(bool), +} + +/// An OBS source id with its settings. +#[derive(Debug, Clone, PartialEq)] +pub struct SourceSpec { + pub id: &'static str, + pub settings: Vec<(&'static str, SettingValue)>, +} + +impl SourceSpec { + fn new(id: &'static str) -> Self { + Self { + id, + settings: Vec::new(), + } + } + + fn with(mut self, key: &'static str, value: SettingValue) -> Self { + self.settings.push((key, value)); + self + } +} + +/// Which Linux display server the recorder is running under. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinuxSession { + Wayland, + X11, +} + +impl LinuxSession { + pub fn detect() -> Option { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + Some(Self::Wayland) + } else if std::env::var_os("DISPLAY").is_some() { + Some(Self::X11) + } else { + None + } + } +} + +impl CaptureTarget { + #[cfg(windows)] + pub fn video_source(&self) -> SourceSpec { + // libobs/util/windows/window-helpers.h: CLASS, TITLE, EXE. + const WINDOW_PRIORITY_EXE: i64 = 2; + use SettingValue::*; + match self { + Self::Game { window } => SourceSpec::new("game_capture") + .with("capture_mode", String("window".into())) + .with("window", String(window.obs_window.clone())) + // Match the executable first: survives title changes. + .with("priority", Int(WINDOW_PRIORITY_EXE)) + .with("capture_cursor", Bool(false)), + // Other Game Capture settings, including its anti-cheat + // compatibility hook, keep OBS's defaults. + Self::Window { window } => SourceSpec::new("window_capture") + .with("window", String(window.obs_window.clone())) + .with("priority", Int(WINDOW_PRIORITY_EXE)) + .with("cursor", Bool(false)), + Self::Monitor { monitor } => SourceSpec::new("monitor_capture") + .with("monitor_id", String(monitor.obs_monitor.clone())) + .with("capture_cursor", Bool(true)), + } + } + + #[cfg(not(windows))] + pub fn video_source(&self, session: LinuxSession) -> SourceSpec { + use SettingValue::*; + match (session, self) { + (LinuxSession::Wayland, Self::Game { window } | Self::Window { window }) => { + portal_source( + "pipewire-window-capture-source", + window.restore_token.as_deref(), + ) + } + (LinuxSession::Wayland, Self::Monitor { monitor }) => portal_source( + "pipewire-screen-capture-source", + monitor.restore_token.as_deref(), + ), + (LinuxSession::X11, Self::Game { window } | Self::Window { window }) => { + SourceSpec::new("xcomposite_input") + .with("capture_window", String(window.obs_window.clone())) + .with("show_cursor", Bool(false)) + } + (LinuxSession::X11, Self::Monitor { monitor }) => SourceSpec::new("xshm_input_v2") + .with("screen", Int(monitor.obs_monitor.parse().unwrap_or(0))) + .with("show_cursor", Bool(true)), + } + } + + pub fn restore_token(&self) -> Option<&str> { + match self { + Self::Game { window } | Self::Window { window } => window.restore_token.as_deref(), + Self::Monitor { monitor } => monitor.restore_token.as_deref(), + } + } +} + +#[cfg(not(windows))] +fn portal_source(id: &'static str, restore_token: Option<&str>) -> SourceSpec { + let spec = SourceSpec::new(id).with("ShowCursor", SettingValue::Bool(true)); + match restore_token { + // Lets the portal restore the earlier selection without asking again. + Some(token) => spec.with("RestoreToken", SettingValue::String(token.to_owned())), + None => spec, + } +} + +/// Desktop audio and, when opted in, the default microphone. +pub fn system_audio_source() -> SourceSpec { + #[cfg(windows)] + let id = "wasapi_output_capture"; + #[cfg(not(windows))] + let id = "pulse_output_capture"; + SourceSpec::new(id).with("device_id", SettingValue::String("default".into())) +} + +pub fn microphone_source() -> SourceSpec { + #[cfg(windows)] + let id = "wasapi_input_capture"; + #[cfg(not(windows))] + let id = "pulse_input_capture"; + SourceSpec::new(id).with("device_id", SettingValue::String("default".into())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(windows))] + #[test] + fn wayland_monitor_uses_the_portal_and_restores_the_selection() { + let target = CaptureTarget::Monitor { + monitor: MonitorMatch { + obs_monitor: String::new(), + restore_token: Some("tok".into()), + }, + }; + let spec = target.video_source(LinuxSession::Wayland); + assert_eq!(spec.id, "pipewire-screen-capture-source"); + assert!( + spec.settings + .contains(&("RestoreToken", SettingValue::String("tok".into()))) + ); + } + + #[cfg(not(windows))] + #[test] + fn game_capture_is_window_capture_off_windows() { + let window = WindowMatch::default(); + let game = CaptureTarget::Game { + window: window.clone(), + }; + let plain = CaptureTarget::Window { window }; + for session in [LinuxSession::Wayland, LinuxSession::X11] { + assert_eq!(game.video_source(session), plain.video_source(session)); + } + } + + #[cfg(windows)] + #[test] + fn game_target_uses_official_game_capture() { + let target = CaptureTarget::Game { + window: WindowMatch { + obs_window: "Game:GameClass:game.exe".into(), + restore_token: None, + }, + }; + assert_eq!(target.video_source().id, "game_capture"); + } +} diff --git a/crates/clipbin-obs/src/encoders.rs b/crates/clipbin-obs/src/encoders.rs new file mode 100644 index 0000000..26e92bd --- /dev/null +++ b/crates/clipbin-obs/src/encoders.rs @@ -0,0 +1,109 @@ +use clipbin_core::VideoCodec; + +/// Official OBS hardware encoders for each codec, most preferred first. +/// +/// Texture-based encoders come first because they take frames straight from +/// the GPU; their counterparts copy each frame through system memory. Software +/// encoders are deliberately absent: a codec without a working hardware +/// encoder is unavailable, never silently software-encoded. +pub fn hardware_candidates(codec: VideoCodec) -> &'static [&'static str] { + #[cfg(windows)] + return match codec { + VideoCodec::H264 => &[ + "obs_nvenc_h264_tex", + "h264_texture_amf", + "obs_qsv11_v2", + "obs_nvenc_h264_soft", + "obs_qsv11_soft_v2", + "h264_fallback_amf", + ], + VideoCodec::Hevc => &[ + "obs_nvenc_hevc_tex", + "h265_texture_amf", + "obs_qsv11_hevc", + "obs_nvenc_hevc_soft", + "obs_qsv11_hevc_soft", + "h265_fallback_amf", + ], + VideoCodec::Av1 => &[ + "obs_nvenc_av1_tex", + "av1_texture_amf", + "obs_qsv11_av1", + "obs_nvenc_av1_soft", + "obs_qsv11_av1_soft", + "av1_fallback_amf", + ], + }; + + #[cfg(not(windows))] + return match codec { + VideoCodec::H264 => &[ + "obs_nvenc_h264_tex", + "ffmpeg_vaapi_tex", + "obs_nvenc_h264_soft", + "ffmpeg_vaapi", + ], + VideoCodec::Hevc => &[ + "obs_nvenc_hevc_tex", + "hevc_ffmpeg_vaapi_tex", + "obs_nvenc_hevc_soft", + "hevc_ffmpeg_vaapi", + ], + VideoCodec::Av1 => &[ + "obs_nvenc_av1_tex", + "av1_ffmpeg_vaapi_tex", + "obs_nvenc_av1_soft", + "av1_ffmpeg_vaapi", + ], + }; +} + +/// Registered encoders for `codec`, in preference order. +/// +/// Registration only means the plugin loaded. Whether the device can actually +/// encode is proven by starting an output; see `Recorder`. +pub fn registered_candidates<'a>( + codec: VideoCodec, + registered: &[impl AsRef], +) -> Vec<&'a str> { + hardware_candidates(codec) + .iter() + .copied() + .filter(|id| registered.iter().any(|r| r.as_ref() == *id)) + .collect() +} + +pub const ALL_CODECS: [VideoCodec; 3] = [VideoCodec::H264, VideoCodec::Hevc, VideoCodec::Av1]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidates_keep_preference_order_and_skip_unregistered() { + let first = hardware_candidates(VideoCodec::Hevc)[0]; + let third = hardware_candidates(VideoCodec::Hevc)[2]; + let registered = [third, "obs_x264", first]; + assert_eq!( + registered_candidates(VideoCodec::Hevc, ®istered), + [first, third] + ); + } + + #[test] + fn software_encoders_are_never_candidates() { + for codec in ALL_CODECS { + for id in hardware_candidates(codec) { + assert!( + ![ + "obs_x264", + "ffmpeg_svt_av1", + "ffmpeg_aom_av1", + "ffmpeg_openh264" + ] + .contains(id) + ); + } + } + } +} diff --git a/crates/clipbin-obs/src/ffi.rs b/crates/clipbin-obs/src/ffi.rs new file mode 100644 index 0000000..ade5720 --- /dev/null +++ b/crates/clipbin-obs/src/ffi.rs @@ -0,0 +1,822 @@ +//! All raw libobs calls: startup with an exact plugin allowlist, the FFmpeg +//! muxer's split-file signals, scene wiring, audio mixers and health counters. +//! +//! Every function here must run on the OBS thread; `Engine` guarantees that by +//! only calling them through `ObsThread::run`. +#![allow(unsafe_code)] + +use std::{ + ffi::{CStr, CString, c_char, c_void}, + ptr, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + mpsc::Sender, + }, +}; + +use libobs as sys; + +use crate::{ + capture::{SettingValue, SourceSpec}, + recorder::{Health, RecorderEvent}, +}; + +fn cstring(value: &str) -> CString { + CString::new(value).expect("OBS strings contain no NUL bytes") +} + +/// Takes a borrowed C string from libobs. +fn owned(value: *const c_char) -> Option { + if value.is_null() { + return None; + } + // SAFETY: libobs returned a valid NUL-terminated string that outlives this call. + Some( + unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned(), + ) +} + +struct Data(*mut sys::obs_data_t); + +impl Data { + fn new(settings: &[(&str, SettingValue)]) -> Self { + // SAFETY: obs_data_create returns a new reference that Drop releases. + let data = Self(unsafe { sys::obs_data_create() }); + for (key, value) in settings { + data.set(key, value); + } + data + } + + fn set(&self, key: &str, value: &SettingValue) { + let key = cstring(key); + // SAFETY: self.0 is a live obs_data_t and both strings outlive the call. + unsafe { + match value { + SettingValue::String(value) => { + sys::obs_data_set_string(self.0, key.as_ptr(), cstring(value).as_ptr()) + } + SettingValue::Int(value) => sys::obs_data_set_int(self.0, key.as_ptr(), *value), + SettingValue::Bool(value) => sys::obs_data_set_bool(self.0, key.as_ptr(), *value), + } + } + } +} + +impl Drop for Data { + fn drop(&mut self) { + // SAFETY: releases the reference taken in Data::new or a getter. + unsafe { sys::obs_data_release(self.0) } + } +} + +pub(crate) struct StartupSpec { + pub libobs_data_dir: String, + /// `(library path, data directory)` for each plugin to load. + pub plugins: Vec<(String, String)>, + pub width: u32, + pub height: u32, + pub frames_per_second: u32, +} + +/// What `startup` loaded. Dropping it does not shut OBS down; call `shutdown`. +pub(crate) struct Session { + pub version: String, + pub plugins_failed: Vec, + /// Held open until `shutdown` has stopped libobs rendering through it. + #[cfg(target_os = "linux")] + _display: linux::Display, +} + +// SAFETY: the display connection is only used on the OBS thread. +unsafe impl Send for Session {} + +/// The OBS version the bindings were generated from; the runtime must match. +pub(crate) fn header_version() -> (u32, u32, u32) { + ( + sys::LIBOBS_API_MAJOR_VER, + sys::LIBOBS_API_MINOR_VER, + sys::LIBOBS_API_PATCH_VER, + ) +} + +/// libobs's growable string, used to format log messages with its own printf. +#[repr(C)] +struct Dstr { + array: *mut c_char, + len: usize, + capacity: usize, +} + +#[cfg(windows)] +type VaList = sys::va_list; +#[cfg(not(windows))] +type VaList = *mut sys::__va_list_tag; + +unsafe extern "C" { + // Exported by libobs (util/dstr.h); not part of the generated bindings. + fn dstr_vprintf(dst: *mut Dstr, format: *const c_char, args: VaList); +} + +/// Sends libobs logging to stderr, keeping stdout for Clipbin's own output. +/// Debug messages are shown only when `CLIPBIN_OBS_DEBUG` is set. +unsafe extern "C" fn log_to_stderr( + level: i32, + format: *const c_char, + args: VaList, + _: *mut c_void, +) { + if level >= sys::LOG_DEBUG as i32 && std::env::var_os("CLIPBIN_OBS_DEBUG").is_none() { + return; + } + let mut message = Dstr { + array: ptr::null_mut(), + len: 0, + capacity: 0, + }; + // SAFETY: format and args come from libobs's blog call; dstr_vprintf + // allocates message.array with bmalloc, released with bfree. + unsafe { dstr_vprintf(&mut message, format, args) }; + let label = match level { + level if level <= sys::LOG_ERROR as i32 => "error", + level if level <= sys::LOG_WARNING as i32 => "warning", + level if level <= sys::LOG_INFO as i32 => "info", + _ => "debug", + }; + if let Some(text) = owned(message.array) { + eprintln!("obs {label}: {text}"); + } + // SAFETY: frees the buffer dstr_vprintf allocated (null is allowed). + unsafe { sys::bfree(message.array.cast()) }; +} + +pub(crate) fn startup(spec: &StartupSpec) -> Result { + // SAFETY: installs a handler with the signature libobs expects. + unsafe { sys::base_set_log_handler(Some(log_to_stderr), ptr::null_mut()) }; + + // SAFETY: obs_get_version_string returns a static string. + let version = owned(unsafe { sys::obs_get_version_string() }).unwrap_or_default(); + let (major, minor, patch) = header_version(); + if version != format!("{major}.{minor}.{patch}") { + return Err(format!( + "the OBS runtime is version {version}, but Clipbin was built for {major}.{minor}.{patch}" + )); + } + + #[cfg(target_os = "linux")] + let display = linux::Display::connect()?; + + // SAFETY: runs once on the OBS thread before any other libobs call. + unsafe { + if !sys::obs_startup(cstring("en-US").as_ptr(), ptr::null(), ptr::null_mut()) { + return Err("libobs did not start".into()); + } + // libobs appends file names directly, so the data path ends in '/'. + sys::obs_add_data_path(cstring(&format!("{}/", spec.libobs_data_dir)).as_ptr()); + + let audio = sys::obs_audio_info { + samples_per_sec: 48_000, + speakers: sys::speaker_layout_SPEAKERS_STEREO, + }; + if !sys::obs_reset_audio(&audio) { + return Err("libobs could not set up audio".into()); + } + + let graphics_module = cstring(if cfg!(windows) { + "libobs-d3d11" + } else { + "libobs-opengl" + }); + let mut video: sys::obs_video_info = std::mem::zeroed(); + video.graphics_module = graphics_module.as_ptr(); + video.fps_num = spec.frames_per_second; + video.fps_den = 1; + video.base_width = spec.width; + video.base_height = spec.height; + video.output_width = spec.width; + video.output_height = spec.height; + video.output_format = sys::video_format_VIDEO_FORMAT_NV12; + video.gpu_conversion = true; + video.colorspace = sys::video_colorspace_VIDEO_CS_709; + video.range = sys::video_range_type_VIDEO_RANGE_DEFAULT; + video.scale_type = sys::obs_scale_type_OBS_SCALE_BICUBIC; + let code = sys::obs_reset_video(&mut video); + if code != sys::OBS_VIDEO_SUCCESS as i32 { + sys::obs_shutdown(); + return Err(format!("libobs could not start video (code {code})")); + } + + // Open exactly the bundled plugins. obs_load_all_modules would also + // search libobs's built-in default paths, which can hold other OBS + // installations' plugins. + let mut plugins_failed = Vec::new(); + for (library, data) in &spec.plugins { + let mut module = ptr::null_mut(); + let code = sys::obs_open_module( + &mut module, + cstring(library).as_ptr(), + cstring(data).as_ptr(), + ); + if code != sys::MODULE_SUCCESS as i32 { + sys::obs_shutdown(); + return Err(format!("could not open OBS plugin {library} (code {code})")); + } + // A plugin may decline to initialise, e.g. NVENC without an NVIDIA GPU. + if !sys::obs_init_module(module) { + plugins_failed.push(library.clone()); + } + } + sys::obs_post_load_modules(); + sys::obs_log_loaded_modules(); + + Ok(Session { + version, + plugins_failed, + #[cfg(target_os = "linux")] + _display: display, + }) + } +} + +pub(crate) fn shutdown(session: Session) { + // SAFETY: runs on the OBS thread after every OBS object was released. + // Released sources are destroyed on a libobs task thread, and destroying + // a scene queues its sources' destruction in turn. Shutting down before + // the queue drains destroys them twice (seen with PipeWire capture). + unsafe { + while sys::obs_wait_for_destroy_queue() {} + sys::obs_shutdown(); + } + drop(session); +} + +#[cfg(target_os = "linux")] +mod linux { + use std::{ + ffi::{c_char, c_int, c_void}, + ptr, + }; + + use super::sys; + use crate::capture::LinuxSession; + + #[link(name = "wayland-client")] + unsafe extern "C" { + fn wl_display_connect(name: *const c_char) -> *mut c_void; + fn wl_display_disconnect(display: *mut c_void); + } + + #[link(name = "X11")] + unsafe extern "C" { + fn XOpenDisplay(name: *const c_char) -> *mut c_void; + fn XCloseDisplay(display: *mut c_void) -> c_int; + } + + /// The display connection libobs renders through (EGL on Wayland or X11). + pub(super) struct Display { + session: LinuxSession, + handle: *mut c_void, + } + + impl Display { + pub(super) fn connect() -> Result { + let session = LinuxSession::detect() + .ok_or("no Wayland or X11 session (WAYLAND_DISPLAY/DISPLAY unset)")?; + // SAFETY: connects to the display named by the environment; the + // handle is closed in Drop after obs_shutdown. + let handle = unsafe { + match session { + LinuxSession::Wayland => wl_display_connect(ptr::null()), + LinuxSession::X11 => XOpenDisplay(ptr::null()), + } + }; + if handle.is_null() { + return Err(format!("could not connect to the {session:?} display")); + } + let platform = match session { + LinuxSession::Wayland => sys::obs_nix_platform_type_OBS_NIX_PLATFORM_WAYLAND, + LinuxSession::X11 => sys::obs_nix_platform_type_OBS_NIX_PLATFORM_X11_EGL, + }; + // SAFETY: must precede obs_startup; handle stays valid until Drop. + unsafe { + sys::obs_set_nix_platform(platform); + sys::obs_set_nix_platform_display(handle); + } + Ok(Self { session, handle }) + } + } + + impl Drop for Display { + fn drop(&mut self) { + // SAFETY: handle came from the matching connect call. + unsafe { + match self.session { + LinuxSession::Wayland => wl_display_disconnect(self.handle), + LinuxSession::X11 => { + XCloseDisplay(self.handle); + } + } + } + } + } + + #[link(name = "glib-2.0")] + unsafe extern "C" { + fn g_main_loop_new(context: *mut c_void, is_running: c_int) -> *mut c_void; + fn g_main_loop_run(main_loop: *mut c_void); + fn g_main_loop_quit(main_loop: *mut c_void); + fn g_main_loop_unref(main_loop: *mut c_void); + } + + /// The default GLib main loop. OBS's PipeWire capture talks to the desktop + /// portal over GDBus, whose replies are dispatched by this loop. + pub(crate) struct GlibLoop { + main_loop: *mut c_void, + thread: Option>, + } + + struct LoopPtr(*mut c_void); + // SAFETY: GMainLoop run/quit are thread-safe. + unsafe impl Send for LoopPtr {} + + impl GlibLoop { + pub(crate) fn start() -> Self { + // SAFETY: a new loop on the default context, released in Drop. + let main_loop = unsafe { g_main_loop_new(ptr::null_mut(), 0) }; + let running = LoopPtr(main_loop); + let thread = std::thread::Builder::new() + .name("glib".into()) + .spawn(move || { + let running = running; + // SAFETY: the loop outlives this thread (joined in Drop). + unsafe { g_main_loop_run(running.0) } + }) + .expect("the GLib thread can be spawned"); + Self { + main_loop, + thread: Some(thread), + } + } + } + + impl Drop for GlibLoop { + fn drop(&mut self) { + // SAFETY: quits the running loop, joins its thread, then frees it. + unsafe { g_main_loop_quit(self.main_loop) }; + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + unsafe { g_main_loop_unref(self.main_loop) }; + } + } +} + +#[cfg(target_os = "linux")] +pub(crate) use linux::GlibLoop; + +/// Video encoder ids registered by the loaded plugins, excluding deprecated +/// and internal ones. +pub(crate) fn registered_video_encoders() -> Vec<(String, String)> { + let mut encoders = Vec::new(); + for index in 0.. { + let mut id: *const c_char = ptr::null(); + // SAFETY: libobs writes a static id string for each registered encoder. + if !unsafe { sys::obs_enum_encoder_types(index, &mut id) } { + break; + } + // SAFETY: id is a registered encoder id. + let (kind, caps, codec) = unsafe { + ( + sys::obs_get_encoder_type(id), + sys::obs_get_encoder_caps(id), + sys::obs_get_encoder_codec(id), + ) + }; + let hidden = sys::OBS_ENCODER_CAP_DEPRECATED | sys::OBS_ENCODER_CAP_INTERNAL; + if kind == sys::obs_encoder_type_OBS_ENCODER_VIDEO + && caps & hidden == 0 + && let (Some(id), Some(codec)) = (owned(id), owned(codec)) + { + encoders.push((id, codec)); + } + } + encoders +} + +pub(crate) struct PipelineSpec { + pub canvas: (u32, u32), + pub video_source: Option, + pub system_audio: Option, + pub microphone: Option, + pub video_encoder: String, + pub video_encoder_settings: Vec<(&'static str, SettingValue)>, + pub audio_bitrate_kbps: i64, + pub segment_dir: String, + pub segment_extension: &'static str, + pub segment_seconds: i64, +} + +/// OBS output channels, following the OBS front end's convention. +const VIDEO_CHANNEL: u32 = 0; +const SYSTEM_AUDIO_CHANNEL: u32 = 1; +const MICROPHONE_CHANNEL: u32 = 3; + +/// Audio track (mixer) indices. +pub(crate) const SYSTEM_TRACK: usize = 0; +pub(crate) const MICROPHONE_TRACK: usize = 1; +pub(crate) const COMBINED_TRACK: usize = 2; + +pub(crate) fn segment_stem(index: u64) -> String { + format!("segment-{index:06}") +} + +/// State shared with libobs signal callbacks, which run on OBS threads. +struct SignalContext { + events: Mutex>, + output: *mut sys::obs_output_t, + first_segment: String, + current_segment: Arc>>, +} + +impl SignalContext { + fn segment_started(&self, path: String) { + if let Ok(mut current) = self.current_segment.lock() { + *current = Some(path.clone()); + } + self.send(RecorderEvent::SegmentStarted { path }); + } + + fn send(&self, event: RecorderEvent) { + if let Ok(events) = self.events.lock() { + let _ = events.send(event); + } + } +} + +const SIGNALS: [( + &str, + unsafe extern "C" fn(*mut c_void, *mut sys::calldata_t), +); 3] = [ + ("start", on_start), + ("stop", on_stop), + ("file_changed", on_file_changed), +]; + +unsafe extern "C" fn on_start(data: *mut c_void, _: *mut sys::calldata_t) { + // SAFETY: data is the SignalContext registered in Pipeline::build, which + // outlives the connection. + let context = unsafe { &*(data as *const SignalContext) }; + context.send(RecorderEvent::OutputStarted); + context.segment_started(context.first_segment.clone()); +} + +unsafe extern "C" fn on_stop(data: *mut c_void, calldata: *mut sys::calldata_t) { + // SAFETY: as in on_start; calldata is valid for the duration of the signal. + let context = unsafe { &*(data as *const SignalContext) }; + let mut code: i64 = 0; + let name = cstring("code"); + // SAFETY: "code" is a long long in the output stop signal's calldata. + unsafe { + sys::calldata_get_data( + calldata, + name.as_ptr(), + (&mut code as *mut i64).cast(), + size_of::(), + ); + } + // SAFETY: the output outlives its signal connections. + let last_error = owned(unsafe { sys::obs_output_get_last_error(context.output) }); + context.send(RecorderEvent::OutputStopped { code, last_error }); +} + +/// The muxer names each new segment from the output's `format` setting just +/// before it emits this signal, on the same thread. Setting the next stem here +/// gives every segment a sequential name. +unsafe extern "C" fn on_file_changed(data: *mut c_void, calldata: *mut sys::calldata_t) { + // SAFETY: as in on_stop. + let context = unsafe { &*(data as *const SignalContext) }; + let mut next_file: *const c_char = ptr::null(); + let name = cstring("next_file"); + // SAFETY: "next_file" is a string in the file_changed calldata. + unsafe { sys::calldata_get_string(calldata, name.as_ptr(), &mut next_file) }; + let Some(path) = owned(next_file) else { return }; + + if let Some(index) = segment_index(&path) { + // SAFETY: obs_output_get_settings returns a new reference, released by Data. + let settings = Data(unsafe { sys::obs_output_get_settings(context.output) }); + settings.set("format", &SettingValue::String(segment_stem(index + 1))); + } + context.segment_started(path); +} + +pub(crate) fn segment_index(path: &str) -> Option { + let name = path.rsplit(['/', '\\']).next()?; + let stem = name.split('.').next()?; + stem.strip_prefix("segment-")?.parse().ok() +} + +/// The live OBS objects for one recording. +pub(crate) struct Pipeline { + scene: *mut sys::obs_scene_t, + /// Each source with the output channel it is bound to, if any. + sources: Vec<(Option, *mut sys::obs_source_t)>, + video_source: *mut sys::obs_source_t, + video_encoder: *mut sys::obs_encoder_t, + audio_encoders: Vec<*mut sys::obs_encoder_t>, + output: *mut sys::obs_output_t, + signals: Option>, +} + +// SAFETY: the pointers are only dereferenced on the OBS thread (see module docs). +unsafe impl Send for Pipeline {} +unsafe impl Sync for Pipeline {} + +impl Pipeline { + pub(crate) fn build( + spec: &PipelineSpec, + events: Sender, + current_segment: Arc>>, + ) -> Result { + // OBS releases objects asynchronously, so names must not be reused. + static RECORDINGS: AtomicU64 = AtomicU64::new(0); + let id = RECORDINGS.fetch_add(1, Ordering::Relaxed); + let name = |what: &str| cstring(&format!("clipbin-{id}-{what}")); + + // SAFETY: runs on the OBS thread. Each object is stored in `pipeline` + // as soon as it exists, so Drop releases it if a later step fails. + unsafe { + let mut pipeline = Self { + // Private: internal to Clipbin, not registered with the main canvas. + scene: sys::obs_scene_create_private(name("scene").as_ptr()), + sources: Vec::new(), + video_source: ptr::null_mut(), + video_encoder: ptr::null_mut(), + audio_encoders: Vec::new(), + output: ptr::null_mut(), + signals: None, + }; + + if let Some(source) = &spec.video_source { + pipeline.video_source = create_source(source, &name("video"))?; + pipeline.sources.push((None, pipeline.video_source)); + let item = sys::obs_scene_add(pipeline.scene, pipeline.video_source); + sys::obs_sceneitem_set_bounds_type( + item, + sys::obs_bounds_type_OBS_BOUNDS_SCALE_INNER, + ); + let mut bounds: sys::vec2 = std::mem::zeroed(); + bounds.__bindgen_anon_1.__bindgen_anon_1.x = spec.canvas.0 as f32; + bounds.__bindgen_anon_1.__bindgen_anon_1.y = spec.canvas.1 as f32; + sys::obs_sceneitem_set_bounds(item, &bounds); + } + sys::obs_set_output_source(VIDEO_CHANNEL, sys::obs_scene_get_source(pipeline.scene)); + + // With the microphone on, system and microphone audio get their + // own tracks for editing plus a combined track for playback. With + // only system audio, its single track is already the combined mix. + // The FFmpeg muxer needs at least one audio track, so recording + // without audio sources still writes a silent one. + let tracks: &[usize] = match &spec.microphone { + Some(_) => &[SYSTEM_TRACK, MICROPHONE_TRACK, COMBINED_TRACK], + None => &[SYSTEM_TRACK], + }; + for (source_spec, channel, track) in [ + (&spec.system_audio, SYSTEM_AUDIO_CHANNEL, SYSTEM_TRACK), + (&spec.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK), + ] { + if let Some(source_spec) = source_spec { + let source = create_source(source_spec, &name(&format!("audio-{channel}")))?; + pipeline.sources.push((Some(channel), source)); + let mixers = tracks + .iter() + .filter(|t| **t == track || **t == COMBINED_TRACK) + .fold(0, |mask, t| mask | (1 << t)); + sys::obs_source_set_audio_mixers(source, mixers); + sys::obs_set_output_source(channel, source); + } + } + + let encoder_settings = Data::new(&spec.video_encoder_settings); + pipeline.video_encoder = sys::obs_video_encoder_create( + cstring(&spec.video_encoder).as_ptr(), + name("video-encoder").as_ptr(), + encoder_settings.0, + ptr::null_mut(), + ); + if pipeline.video_encoder.is_null() { + return Err(format!( + "could not create video encoder {}", + spec.video_encoder + )); + } + sys::obs_encoder_set_video(pipeline.video_encoder, sys::obs_get_video()); + + for &track in tracks { + let settings = + Data::new(&[("bitrate", SettingValue::Int(spec.audio_bitrate_kbps))]); + let encoder = sys::obs_audio_encoder_create( + cstring("ffmpeg_aac").as_ptr(), + name(&format!("audio-encoder-{track}")).as_ptr(), + settings.0, + track, + ptr::null_mut(), + ); + if encoder.is_null() { + return Err("could not create the AAC audio encoder".into()); + } + pipeline.audio_encoders.push(encoder); + sys::obs_encoder_set_audio(encoder, sys::obs_get_audio()); + } + + let first_segment = format!( + "{}/{}.{}", + spec.segment_dir, + segment_stem(0), + spec.segment_extension + ); + let output_settings = Data::new(&[ + ("path", SettingValue::String(first_segment.clone())), + ("directory", SettingValue::String(spec.segment_dir.clone())), + ("format", SettingValue::String(segment_stem(1))), + ( + "extension", + SettingValue::String(spec.segment_extension.into()), + ), + ("split_file", SettingValue::Bool(true)), + ("max_time_sec", SettingValue::Int(spec.segment_seconds)), + ("max_size_mb", SettingValue::Int(0)), + ("allow_overwrite", SettingValue::Bool(false)), + ]); + pipeline.output = sys::obs_output_create( + cstring("ffmpeg_muxer").as_ptr(), + name("segments").as_ptr(), + output_settings.0, + ptr::null_mut(), + ); + if pipeline.output.is_null() { + return Err("could not create the FFmpeg muxer output".into()); + } + sys::obs_output_set_video_encoder(pipeline.output, pipeline.video_encoder); + for (index, encoder) in pipeline.audio_encoders.iter().enumerate() { + sys::obs_output_set_audio_encoder(pipeline.output, *encoder, index); + } + + let signals = Box::new(SignalContext { + events: Mutex::new(events), + output: pipeline.output, + first_segment, + current_segment, + }); + let handler = sys::obs_output_get_signal_handler(pipeline.output); + for (name, callback) in SIGNALS { + sys::signal_handler_connect( + handler, + cstring(name).as_ptr(), + Some(callback), + (&*signals as *const SignalContext).cast_mut().cast(), + ); + } + pipeline.signals = Some(signals); + + Ok(pipeline) + } + } + + /// Starts writing segments. A `true` result only means OBS accepted the + /// request; recording is confirmed by `OutputStarted` and written frames. + pub(crate) fn start(&self) -> Result<(), String> { + // SAFETY: output is live for the Pipeline's lifetime. + unsafe { + if sys::obs_output_start(self.output) { + Ok(()) + } else { + Err(owned(sys::obs_output_get_last_error(self.output)) + .unwrap_or_else(|| "the output did not start".into())) + } + } + } + + pub(crate) fn stop(&self) { + // SAFETY: output is live; stopping an inactive output is a no-op. + unsafe { sys::obs_output_stop(self.output) } + } + + /// OBS's view of the recording; `Recording::health` adds what reached disk. + pub(crate) fn health(&self) -> Health { + // SAFETY: all objects are live for the Pipeline's lifetime. + unsafe { + let (width, height) = if self.video_source.is_null() { + (0, 0) + } else { + ( + sys::obs_source_get_width(self.video_source), + sys::obs_source_get_height(self.video_source), + ) + }; + Health { + source_width: width, + source_height: height, + output_active: sys::obs_output_active(self.output), + total_frames: sys::obs_output_get_total_frames(self.output).max(0) as u64, + dropped_frames: sys::obs_output_get_frames_dropped(self.output).max(0) as u64, + total_bytes: sys::obs_output_get_total_bytes(self.output), + lagged_frames: sys::obs_get_lagged_frames() as u64, + current_segment: None, + current_segment_bytes: 0, + } + } + } + + /// The portal restore token PipeWire capture saved after the user's + /// selection, so the next session can restore it without asking again. + pub(crate) fn restore_token(&self) -> Option { + if self.video_source.is_null() { + return None; + } + // SAFETY: video_source is live; saving refreshes its settings. + unsafe { + sys::obs_source_save(self.video_source); + let settings = Data(sys::obs_source_get_settings(self.video_source)); + let token = owned(sys::obs_data_get_string( + settings.0, + cstring("RestoreToken").as_ptr(), + )); + token.filter(|token| !token.is_empty()) + } + } +} + +impl Drop for Pipeline { + fn drop(&mut self) { + // SAFETY: runs on the OBS thread (Recording drops the Pipeline there). + // Releases in reverse order of creation, after detaching callbacks; + // a partially built Pipeline has null or empty fields for later steps. + unsafe { + if !self.output.is_null() { + sys::obs_output_stop(self.output); + if let Some(signals) = &self.signals { + let handler = sys::obs_output_get_signal_handler(self.output); + for (name, callback) in SIGNALS { + sys::signal_handler_disconnect( + handler, + cstring(name).as_ptr(), + Some(callback), + (&**signals as *const SignalContext).cast_mut().cast(), + ); + } + } + sys::obs_output_release(self.output); + } + if !self.video_encoder.is_null() { + sys::obs_encoder_release(self.video_encoder); + } + for encoder in &self.audio_encoders { + sys::obs_encoder_release(*encoder); + } + sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut()); + for (channel, source) in &self.sources { + if let Some(channel) = channel { + sys::obs_set_output_source(*channel, ptr::null_mut()); + } + sys::obs_source_release(*source); + } + sys::obs_scene_release(self.scene); + } + } +} + +/// # Safety +/// Must run on the OBS thread. +unsafe fn create_source(spec: &SourceSpec, name: &CStr) -> Result<*mut sys::obs_source_t, String> { + let settings = Data::new(&spec.settings); + // SAFETY: arguments are valid; the caller owns the returned reference. + let source = unsafe { + sys::obs_source_create_private(cstring(spec.id).as_ptr(), name.as_ptr(), settings.0) + }; + if source.is_null() { + Err(format!( + "OBS could not create the {} source (is its plugin loaded?)", + spec.id + )) + } else { + Ok(source) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn segment_names_round_trip() { + assert_eq!( + segment_index(&format!("/rec/{}.mkv", segment_stem(42))), + Some(42) + ); + assert_eq!(segment_index(r"C:/rec\segment-000007.mkv"), Some(7)); + assert_eq!(segment_index("/rec/other.mkv"), None); + } +} diff --git a/crates/clipbin-obs/src/lib.rs b/crates/clipbin-obs/src/lib.rs new file mode 100644 index 0000000..e65dde0 --- /dev/null +++ b/crates/clipbin-obs/src/lib.rs @@ -0,0 +1,18 @@ +//! Clipbin's recording engine on top of the pinned libobs runtime. +//! +//! Capture uses only official OBS sources: Game Capture and Window Capture on +//! Windows, Xcomposite on X11, and PipeWire through the desktop portal on +//! Wayland. See SPEC.md. + +pub mod capture; +pub mod encoders; +mod ffi; +mod recorder; +pub mod runtime; +mod thread; + +pub use recorder::{ + Engine, EngineError, Health, RecorderEvent, Recording, RecordingConfig, SEGMENT_EXTENSION, + StatusTracker, StopReason, VideoFormat, +}; +pub use runtime::{RuntimeError, RuntimeLayout}; diff --git a/crates/clipbin-obs/src/recorder.rs b/crates/clipbin-obs/src/recorder.rs new file mode 100644 index 0000000..34eef70 --- /dev/null +++ b/crates/clipbin-obs/src/recorder.rs @@ -0,0 +1,509 @@ +use std::{ + fmt, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + mpsc::{self, Receiver}, + }, + time::{Duration, Instant}, +}; + +use clipbin_core::{RecordingStatus, VideoCodec}; + +use crate::{ + RuntimeLayout, + capture::{SettingValue, SourceSpec}, + encoders::registered_candidates, + ffi::{self, Pipeline, PipelineSpec, Session, StartupSpec}, + runtime::PLUGINS, + thread::ObsThread, +}; + +/// Segments are Matroska: a segment cut short by a crash stays readable, and +/// export remuxes to MP4 without re-encoding. +pub const SEGMENT_EXTENSION: &str = "mkv"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VideoFormat { + pub width: u32, + pub height: u32, + pub frames_per_second: u32, +} + +/// The single libobs instance. Dropping it shuts OBS down. +pub struct Engine { + obs: Arc, + session: Option, + format: VideoFormat, + #[cfg(target_os = "linux")] + _glib: ffi::GlibLoop, +} + +impl Engine { + pub fn start(layout: &RuntimeLayout, format: VideoFormat) -> Result { + let path = |path: PathBuf| path.to_string_lossy().into_owned(); + let spec = StartupSpec { + libobs_data_dir: path(layout.libobs_data_dir()), + plugins: PLUGINS + .iter() + .map(|plugin| { + ( + path(layout.plugin_library(plugin)), + path(layout.plugin_data_dir(plugin)), + ) + }) + .collect(), + width: format.width, + height: format.height, + frames_per_second: format.frames_per_second, + }; + #[cfg(target_os = "linux")] + let glib = ffi::GlibLoop::start(); + let obs = Arc::new(ObsThread::spawn()); + let session = obs + .run(move || ffi::startup(&spec)) + .map_err(EngineError::Startup)?; + Ok(Self { + obs, + session: Some(session), + format, + #[cfg(target_os = "linux")] + _glib: glib, + }) + } + + pub fn obs_version(&self) -> &str { + &self.session().version + } + + /// Bundled plugins that declined to initialise, such as NVENC without an + /// NVIDIA GPU. Their encoders are simply not registered. + pub fn plugins_failed(&self) -> &[String] { + &self.session().plugins_failed + } + + fn session(&self) -> &Session { + self.session.as_ref().expect("the session lives until drop") + } + + /// Registered video encoders as `(id, codec)` pairs, such as `("ffmpeg_vaapi_tex", "h264")`. + pub fn registered_video_encoders(&self) -> Vec<(String, String)> { + self.obs.run(ffi::registered_video_encoders) + } + + /// Registered hardware encoders for `codec`, most preferred first. + pub fn hardware_encoders(&self, codec: VideoCodec) -> Vec { + let registered: Vec = self + .registered_video_encoders() + .into_iter() + .map(|(id, _)| id) + .collect(); + registered_candidates(codec, ®istered) + .into_iter() + .map(str::to_owned) + .collect() + } + + pub fn start_recording(&self, config: RecordingConfig) -> Result { + std::fs::create_dir_all(&config.segment_dir).map_err(EngineError::Io)?; + let spec = PipelineSpec { + canvas: (self.format.width, self.format.height), + video_source: config.video_source, + system_audio: config.system_audio, + microphone: config.microphone, + video_encoder: config.video_encoder, + video_encoder_settings: vec![ + ("rate_control", SettingValue::String("CBR".into())), + ("bitrate", SettingValue::Int(config.video_bitrate_kbps)), + ( + "keyint_sec", + SettingValue::Int(config.keyframe_interval_seconds), + ), + ], + audio_bitrate_kbps: 160, + segment_dir: forward_slashes(&config.segment_dir), + segment_extension: SEGMENT_EXTENSION, + segment_seconds: config.segment_seconds, + }; + let (sender, events) = mpsc::channel(); + let current_segment = Arc::new(Mutex::new(None)); + let segment = Arc::clone(¤t_segment); + let pipeline = self + .obs + .run(move || Pipeline::build(&spec, sender, segment).map(Arc::new)) + .map_err(EngineError::Pipeline)?; + let recording = Recording { + obs: Arc::clone(&self.obs), + pipeline: Some(pipeline), + events, + current_segment, + }; + recording + .with_pipeline(|pipeline| pipeline.start()) + .map_err(EngineError::Start)?; + Ok(recording) + } +} + +impl Drop for Engine { + fn drop(&mut self) { + if let Some(session) = self.session.take() { + self.obs.run(move || ffi::shutdown(session)); + } + } +} + +/// Paths in OBS settings use forward slashes on every platform. +fn forward_slashes(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +pub struct RecordingConfig { + pub video_source: Option, + pub system_audio: Option, + pub microphone: Option, + pub video_encoder: String, + pub video_bitrate_kbps: i64, + pub keyframe_interval_seconds: i64, + /// Segments split at the first keyframe after this many seconds. + pub segment_seconds: i64, + pub segment_dir: PathBuf, +} + +/// An active recording. Dropping it stops the output and releases its OBS +/// objects on the OBS thread. Drop recordings before their `Engine`. +pub struct Recording { + obs: Arc, + pipeline: Option>, + events: Receiver, + current_segment: Arc>>, +} + +impl Recording { + fn with_pipeline( + &self, + operation: impl FnOnce(&Pipeline) -> T + Send + 'static, + ) -> T { + let pipeline = Arc::clone( + self.pipeline + .as_ref() + .expect("the pipeline lives until drop"), + ); + self.obs.run(move || operation(&pipeline)) + } + + pub fn events(&self) -> &Receiver { + &self.events + } + + /// OBS's counters plus the size of the segment being written. OBS counts + /// bytes handed to its muxer process, which keeps rising even if that + /// process cannot write, so only growth on disk proves footage is saved. + pub fn health(&self) -> Health { + let mut health = self.with_pipeline(Pipeline::health); + let segment = self + .current_segment + .lock() + .ok() + .and_then(|current| current.clone()); + if let Some(path) = segment { + health.current_segment_bytes = + std::fs::metadata(&path).map_or(0, |metadata| metadata.len()); + health.current_segment = Some(path); + } + health + } + + pub fn restore_token(&self) -> Option { + self.with_pipeline(Pipeline::restore_token) + } + + /// Requests a stop; `OutputStopped` follows once the last segment is finalised. + pub fn stop(&self) { + self.with_pipeline(Pipeline::stop) + } +} + +impl Drop for Recording { + fn drop(&mut self) { + if let Some(pipeline) = self.pipeline.take() { + // OBS objects must be released on the OBS thread. + self.obs.run(move || drop(pipeline)); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecorderEvent { + /// OBS reports the output as started. Not yet proof that footage is written. + OutputStarted, + /// The muxer opened a new segment file; the previous one is complete. + SegmentStarted { path: String }, + OutputStopped { + code: i64, + last_error: Option, + }, +} + +/// Counters for honest recording status: OBS accepting a start request is not +/// enough, frames must reach disk and the source must have a picture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Health { + pub source_width: u32, + pub source_height: u32, + pub output_active: bool, + pub total_frames: u64, + pub dropped_frames: u64, + pub total_bytes: u64, + pub lagged_frames: u64, + pub current_segment: Option, + pub current_segment_bytes: u64, +} + +/// Derives the status shown to the user from health samples and output events. +/// +/// Recording is reported only while the source has a picture and encoded data +/// keeps reaching the muxer. A stall after recording began is a failure, not a +/// silent pause. +#[derive(Debug)] +pub struct StatusTracker { + status: RecordingStatus, + last_written: (Option, u64), + last_progress: Instant, +} + +impl StatusTracker { + /// How long footage may stop arriving before recording counts as failed. + pub const STALL_LIMIT: Duration = Duration::from_secs(3); + + pub fn new(now: Instant) -> Self { + Self { + status: RecordingStatus::Starting, + last_written: (None, 0), + last_progress: now, + } + } + + pub fn status(&self) -> &RecordingStatus { + &self.status + } + + pub fn sample(&mut self, health: &Health, now: Instant) -> &RecordingStatus { + if matches!(self.status, RecordingStatus::Failed { .. }) { + return &self.status; + } + // Progress means the segment on disk grew or a new one began. + let written = (health.current_segment.clone(), health.current_segment_bytes); + let progressed = written.1 > 0 && written != self.last_written; + if progressed { + self.last_written = written; + self.last_progress = now; + } + let has_picture = health.source_width > 0 && health.source_height > 0; + let stalled = now.duration_since(self.last_progress) >= Self::STALL_LIMIT; + + self.status = match (&self.status, has_picture, progressed || !stalled) { + (_, true, true) + if health.output_active && health.total_frames > 0 && self.last_written.1 > 0 => + { + RecordingStatus::Recording + } + (RecordingStatus::Recording, false, _) => RecordingStatus::Failed { + reason: "the capture source stopped providing a picture".into(), + }, + (RecordingStatus::Recording, true, false) => RecordingStatus::Failed { + reason: format!( + "no footage was written for {} seconds", + Self::STALL_LIMIT.as_secs() + ), + }, + (_, false, _) => RecordingStatus::WaitingForCapture, + (status, _, _) => status.clone(), + }; + &self.status + } + + pub fn stopped(&mut self, reason: StopReason, last_error: Option<&str>) -> &RecordingStatus { + self.status = match reason { + StopReason::Success => RecordingStatus::Idle, + reason => RecordingStatus::Failed { + reason: match last_error { + Some(error) => format!("{reason:?}: {error}"), + None => format!("{reason:?}"), + }, + }, + }; + &self.status + } +} + +/// Why an output stopped, from `obs-output.h`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopReason { + Success, + BadPath, + Unsupported, + NoSpace, + EncodeError, + Disconnected, + Error, + Other(i64), +} + +impl StopReason { + pub fn from_code(code: i64) -> Self { + match code as i32 { + code if code == libobs::OBS_OUTPUT_SUCCESS as i32 => Self::Success, + libobs::OBS_OUTPUT_BAD_PATH => Self::BadPath, + libobs::OBS_OUTPUT_UNSUPPORTED => Self::Unsupported, + libobs::OBS_OUTPUT_NO_SPACE => Self::NoSpace, + libobs::OBS_OUTPUT_ENCODE_ERROR => Self::EncodeError, + libobs::OBS_OUTPUT_DISCONNECTED => Self::Disconnected, + libobs::OBS_OUTPUT_ERROR => Self::Error, + _ => Self::Other(code), + } + } +} + +#[derive(Debug)] +pub enum EngineError { + Startup(String), + Io(std::io::Error), + Pipeline(String), + Start(String), +} + +impl fmt::Display for EngineError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Startup(error) => write!(formatter, "OBS did not start: {error}"), + Self::Io(error) => write!(formatter, "{error}"), + Self::Pipeline(error) => write!(formatter, "could not set up recording: {error}"), + Self::Start(error) => write!(formatter, "recording did not start: {error}"), + } + } +} + +impl std::error::Error for EngineError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn healthy(bytes: u64) -> Health { + Health { + source_width: 1920, + source_height: 1080, + output_active: true, + total_frames: bytes / 1000, + dropped_frames: 0, + total_bytes: bytes, + lagged_frames: 0, + current_segment: Some("segment-000000.mkv".into()), + current_segment_bytes: bytes, + } + } + + #[test] + fn footage_must_reach_disk_not_just_the_muxer() { + let start = Instant::now(); + let mut tracker = StatusTracker::new(start); + let unwritten = Health { + current_segment_bytes: 0, + ..healthy(5000) + }; + assert_ne!( + tracker.sample(&unwritten, start), + &RecordingStatus::Recording + ); + assert_eq!( + tracker.sample(&healthy(5000), start), + &RecordingStatus::Recording + ); + // OBS keeps counting, but the file on disk stopped growing. + let stuck = Health { + total_bytes: 90_000, + ..healthy(5000) + }; + let later = start + StatusTracker::STALL_LIMIT; + assert!(matches!( + tracker.sample(&stuck, later), + RecordingStatus::Failed { .. } + )); + } + + #[test] + fn recording_needs_a_picture_and_written_footage() { + let start = Instant::now(); + let mut tracker = StatusTracker::new(start); + let no_picture = Health { + source_width: 0, + source_height: 0, + ..healthy(5000) + }; + assert_eq!( + tracker.sample(&no_picture, start), + &RecordingStatus::WaitingForCapture + ); + assert_eq!( + tracker.sample(&healthy(0), start), + &RecordingStatus::WaitingForCapture + ); + assert_eq!( + tracker.sample(&healthy(5000), start), + &RecordingStatus::Recording + ); + } + + #[test] + fn a_stall_or_lost_source_fails_instead_of_pausing() { + let start = Instant::now(); + let mut tracker = StatusTracker::new(start); + tracker.sample(&healthy(5000), start); + let later = start + StatusTracker::STALL_LIMIT; + assert!(matches!( + tracker.sample(&healthy(5000), later), + RecordingStatus::Failed { .. } + )); + + let mut tracker = StatusTracker::new(start); + tracker.sample(&healthy(5000), start); + let lost = Health { + source_width: 0, + ..healthy(6000) + }; + assert!(matches!( + tracker.sample(&lost, start), + RecordingStatus::Failed { .. } + )); + // Failure persists until a new recording starts. + assert!(matches!( + tracker.sample(&healthy(9000), start), + RecordingStatus::Failed { .. } + )); + } + + #[test] + fn output_errors_fail_and_clean_stops_go_idle() { + let mut tracker = StatusTracker::new(Instant::now()); + assert!(matches!( + tracker.stopped(StopReason::NoSpace, Some("disk full")), + RecordingStatus::Failed { reason } if reason.contains("disk full") + )); + let mut tracker = StatusTracker::new(Instant::now()); + assert_eq!( + tracker.stopped(StopReason::Success, None), + &RecordingStatus::Idle + ); + } + + #[test] + fn stop_codes_map_to_reasons() { + assert_eq!(StopReason::from_code(0), StopReason::Success); + assert_eq!( + StopReason::from_code(libobs::OBS_OUTPUT_NO_SPACE.into()), + StopReason::NoSpace + ); + assert_eq!(StopReason::from_code(-99), StopReason::Other(-99)); + } +} diff --git a/crates/clipbin-obs/src/runtime.rs b/crates/clipbin-obs/src/runtime.rs new file mode 100644 index 0000000..bb8ab82 --- /dev/null +++ b/crates/clipbin-obs/src/runtime.rs @@ -0,0 +1,200 @@ +use std::{ + env, fmt, io, + path::{Path, PathBuf}, +}; + +/// Where the bundled OBS runtime's data and plugins live. +/// +/// Packaged builds keep OBS's own install layout and place the Clipbin +/// executable where OBS expects its front end, because OBS finds helper +/// executables such as `obs-ffmpeg-mux` next to the running executable: +/// +/// * Linux: `/bin/clipbin`, `/lib/obs-plugins`, `/share/obs`. +/// * Windows: `/bin/64bit/clipbin.exe`, `/obs-plugins/64bit`, `/data`. +/// +/// Development builds use `CLIPBIN_OBS_RUNTIME_DIR` (see `.cargo/config.toml`), +/// the directory holding `-` runtimes assembled by `scripts/obs/`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeLayout { + root: PathBuf, +} + +impl RuntimeLayout { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// Finds the runtime for this executable, preferring the development override. + pub fn discover() -> Result { + // Debug builds also remember the development runtime from build time, + // so binaries in target/ work when started outside Cargo. + let dev_dir = env::var_os("CLIPBIN_OBS_RUNTIME_DIR") + .map(PathBuf::from) + .or_else(|| { + cfg!(debug_assertions) + .then(|| option_env!("CLIPBIN_OBS_RUNTIME_DIR").map(PathBuf::from))? + }); + let layout = match dev_dir { + Some(dir) => Self::new(dir.join(platform_dir())), + None => { + let exe = env::current_exe().map_err(RuntimeError::Executable)?; + Self::from_executable(&exe).ok_or_else(|| RuntimeError::Layout(exe.clone()))? + } + }; + layout.check()?; + Ok(layout) + } + + fn from_executable(exe: &Path) -> Option { + let bin_dir = exe.parent()?; + let root = if cfg!(windows) { + bin_dir.parent()?.parent()? + } else { + bin_dir.parent()? + }; + Some(Self::new(root)) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn libobs_data_dir(&self) -> PathBuf { + if cfg!(windows) { + self.root.join("data").join("libobs") + } else { + self.root.join("share").join("obs").join("libobs") + } + } + + pub fn plugin_bin_dir(&self) -> PathBuf { + if cfg!(windows) { + self.root.join("obs-plugins").join("64bit") + } else { + self.root.join("lib").join("obs-plugins") + } + } + + pub fn plugin_data_dir(&self, plugin: &str) -> PathBuf { + let base = if cfg!(windows) { + self.root.join("data").join("obs-plugins") + } else { + self.root.join("share").join("obs").join("obs-plugins") + }; + base.join(plugin) + } + + pub fn plugin_library(&self, plugin: &str) -> PathBuf { + let extension = if cfg!(windows) { "dll" } else { "so" }; + self.plugin_bin_dir().join(format!("{plugin}.{extension}")) + } + + /// The OBS version recorded when the runtime was assembled. + pub fn version(&self) -> Option { + let manifest = std::fs::read_to_string(self.root.join("VERSION")).ok()?; + manifest + .lines() + .find_map(|line| line.strip_prefix("obs=")) + .map(str::to_owned) + } + + fn check(&self) -> Result<(), RuntimeError> { + if !self.libobs_data_dir().is_dir() { + return Err(RuntimeError::Missing(self.libobs_data_dir())); + } + for plugin in PLUGINS { + if !self.plugin_library(plugin).is_file() { + return Err(RuntimeError::Missing(self.plugin_library(plugin))); + } + } + Ok(()) + } +} + +/// The official OBS plugins Clipbin loads, and the only ones it ships. +/// Keep in sync with the allowlists in scripts/obs/. +#[cfg(windows)] +pub const PLUGINS: &[&str] = &[ + "win-capture", + "win-wasapi", + "obs-ffmpeg", + "obs-nvenc", + "obs-qsv11", +]; +#[cfg(not(windows))] +pub const PLUGINS: &[&str] = &[ + "linux-capture", + "linux-pipewire", + "linux-pulseaudio", + "obs-ffmpeg", + "obs-nvenc", +]; + +/// The runtime directory name scripts/obs/ assembles for this platform, such as +/// `linux-x86_64` or `windows-x86_64`. +pub fn platform_dir() -> String { + format!("{}-{}", env::consts::OS, env::consts::ARCH) +} + +#[derive(Debug)] +pub enum RuntimeError { + Executable(io::Error), + Layout(PathBuf), + Missing(PathBuf), +} + +impl fmt::Display for RuntimeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Executable(error) => write!(formatter, "cannot locate the executable: {error}"), + Self::Layout(exe) => write!( + formatter, + "{} is not inside an OBS runtime layout", + exe.display() + ), + Self::Missing(dir) => write!( + formatter, + "the OBS runtime is incomplete: {} is missing (run scripts/obs/ to assemble it)", + dir.display() + ), + } + } +} + +impl std::error::Error for RuntimeError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packaged_layout_is_relative_to_the_executable() { + let (exe, root) = if cfg!(windows) { + (r"C:\Clipbin\bin\64bit\clipbin.exe", r"C:\Clipbin") + } else { + ("/opt/clipbin/bin/clipbin", "/opt/clipbin") + }; + let layout = RuntimeLayout::from_executable(Path::new(exe)).unwrap(); + assert_eq!(layout.root(), Path::new(root)); + } + + #[test] + fn plugin_paths_follow_the_obs_install_layout() { + let layout = RuntimeLayout::new("rt"); + if cfg!(windows) { + assert_eq!(layout.plugin_bin_dir(), Path::new(r"rt\obs-plugins\64bit")); + assert_eq!(layout.libobs_data_dir(), Path::new(r"rt\data\libobs")); + } else { + assert_eq!(layout.plugin_bin_dir(), Path::new("rt/lib/obs-plugins")); + assert_eq!(layout.libobs_data_dir(), Path::new("rt/share/obs/libobs")); + assert_eq!( + layout.plugin_library("obs-ffmpeg"), + Path::new("rt/lib/obs-plugins/obs-ffmpeg.so") + ); + assert_eq!( + layout.plugin_data_dir("obs-ffmpeg"), + Path::new("rt/share/obs/obs-plugins/obs-ffmpeg") + ); + } + } +} diff --git a/crates/clipbin-obs/src/thread.rs b/crates/clipbin-obs/src/thread.rs new file mode 100644 index 0000000..e503924 --- /dev/null +++ b/crates/clipbin-obs/src/thread.rs @@ -0,0 +1,67 @@ +use std::{ + sync::mpsc::{self, Sender}, + thread::{self, JoinHandle}, +}; + +type Job = Box; + +/// The one thread that makes libobs calls. libobs is not thread-safe for most +/// calls, so every OBS operation is sent here and run in order. +pub(crate) struct ObsThread { + jobs: Option>, + handle: Option>, +} + +impl ObsThread { + pub(crate) fn spawn() -> Self { + let (jobs, receiver) = mpsc::channel::(); + let handle = thread::Builder::new() + .name("obs".into()) + .spawn(move || { + for job in receiver { + job(); + } + }) + .expect("the OBS thread can be spawned"); + Self { + jobs: Some(jobs), + handle: Some(handle), + } + } + + /// Runs `job` on the OBS thread and waits for its result. + pub(crate) fn run(&self, job: impl FnOnce() -> T + Send + 'static) -> T { + let (result, receiver) = mpsc::sync_channel(1); + self.jobs + .as_ref() + .expect("the OBS thread runs until drop") + .send(Box::new(move || { + let _ = result.send(job()); + })) + .expect("the OBS thread is running"); + receiver.recv().expect("OBS jobs run to completion") + } +} + +impl Drop for ObsThread { + fn drop(&mut self) { + drop(self.jobs.take()); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jobs_run_on_one_thread_in_order() { + let obs = ObsThread::spawn(); + let first = obs.run(|| thread::current().id()); + let second = obs.run(|| thread::current().id()); + assert_eq!(first, second); + assert_ne!(first, thread::current().id()); + } +} diff --git a/crates/clipbin-probe/Cargo.toml b/crates/clipbin-probe/Cargo.toml new file mode 100644 index 0000000..85a211b --- /dev/null +++ b/crates/clipbin-probe/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "clipbin-probe" +description = "Validation probe for Clipbin's OBS capture, encoder and segment pipeline." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +clipbin-core.workspace = true +clipbin-obs.workspace = true + +[lints] +workspace = true diff --git a/crates/clipbin-probe/build.rs b/crates/clipbin-probe/build.rs new file mode 100644 index 0000000..65b4420 --- /dev/null +++ b/crates/clipbin-probe/build.rs @@ -0,0 +1,56 @@ +//! Makes development builds run against the OBS runtime in target/obs-runtime. +//! +//! OBS starts `obs-ffmpeg-mux` from the running executable's directory, and on +//! Windows the OBS DLLs must sit beside the executable too, so they are linked +//! (Unix) or copied (Windows) into the Cargo profile directory. A symlink keeps +//! the helper's `$ORIGIN/../lib` runpath pointing into the runtime. Packaged +//! builds place the executable in the runtime's own bin directory instead (see +//! clipbin_obs::RuntimeLayout). +use std::{env, fs, path::PathBuf}; + +fn main() { + println!("cargo:rerun-if-env-changed=CLIPBIN_OBS_RUNTIME_DIR"); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + + if target_os == "linux" { + // Packaged layout: /bin/ loads /lib/libobs.so. + println!("cargo:rustc-link-arg-bins=-Wl,-rpath,$ORIGIN/../lib"); + } + + let Some(runtime_dir) = env::var_os("CLIPBIN_OBS_RUNTIME_DIR") else { + return; + }; + let runtime = PathBuf::from(runtime_dir).join(format!("{target_os}-{arch}")); + // OUT_DIR is /build/-/out. + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let profile_dir = out_dir.ancestors().nth(3).unwrap().to_path_buf(); + + let (bin_dir, lib_dir) = match target_os.as_str() { + "windows" => (runtime.join("bin").join("64bit"), None), + _ => (runtime.join("bin"), Some(runtime.join("lib"))), + }; + if let Some(lib_dir) = lib_dir { + println!("cargo:rustc-link-arg-bins=-Wl,-rpath,{}", lib_dir.display()); + } + let Ok(entries) = fs::read_dir(&bin_dir) else { + println!( + "cargo:warning=OBS runtime not found at {}; run scripts/obs/ to assemble it", + runtime.display() + ); + return; + }; + println!("cargo:rerun-if-changed={}", bin_dir.display()); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let destination = profile_dir.join(entry.file_name()); + let _ = fs::remove_file(&destination); + #[cfg(unix)] + std::os::unix::fs::symlink(&path, &destination).unwrap(); + #[cfg(not(unix))] + fs::copy(&path, &destination).unwrap(); + } +} diff --git a/crates/clipbin-probe/src/main.rs b/crates/clipbin-probe/src/main.rs new file mode 100644 index 0000000..c909e77 --- /dev/null +++ b/crates/clipbin-probe/src/main.rs @@ -0,0 +1,384 @@ +//! Validates the recording foundation on real hardware before the UI is built: +//! the pinned OBS runtime, hardware encoders, official capture sources, +//! keyframe-aligned segments and honest status. Prints `key=value` lines that +//! scripts/validate/ checks. +//! +//! clipbin-probe encoders [--verify] [--out DIR] +//! clipbin-probe record [--target monitor|window|game] [--window OBS_WINDOW] +//! [--restore-token TOKEN] [--codec h264|hevc|av1] [--encoder ID] +//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS] +//! [--width W --height H --fps F] [--no-audio] [--mic] [--out DIR] + +use std::{ + env, + path::PathBuf, + process::ExitCode, + sync::mpsc::RecvTimeoutError, + time::{Duration, Instant}, +}; + +use clipbin_core::{RecordingStatus, VideoCodec}; +use clipbin_obs::{ + Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason, VideoFormat, + capture::{self, CaptureTarget, MonitorMatch, WindowMatch}, + encoders::ALL_CODECS, +}; + +fn main() -> ExitCode { + let mut args: Vec = env::args().skip(1).collect(); + let command = if args.is_empty() { + String::new() + } else { + args.remove(0) + }; + let options = match Options::parse(&args) { + Ok(options) => options, + Err(error) => return usage(&error), + }; + let result = match command.as_str() { + "encoders" => encoders(&options), + "record" => record(&options), + _ => return usage("expected a command"), + }; + match result { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::FAILURE, + Err(error) => { + println!("error={error}"); + ExitCode::FAILURE + } + } +} + +fn usage(error: &str) -> ExitCode { + eprintln!("{error}\nusage: clipbin-probe encoders|record [options] (see src/main.rs)"); + ExitCode::from(2) +} + +struct Options { + verify: bool, + target: String, + window: String, + restore_token: Option, + codec: VideoCodec, + encoder: Option, + seconds: u64, + segment_seconds: i64, + keyint: i64, + bitrate: i64, + format: VideoFormat, + audio: bool, + mic: bool, + out: PathBuf, +} + +impl Options { + fn parse(args: &[String]) -> Result { + let mut options = Self { + verify: false, + target: "monitor".into(), + window: String::new(), + restore_token: None, + codec: VideoCodec::H264, + encoder: None, + seconds: 20, + segment_seconds: 2, + keyint: 1, + bitrate: 20_000, + format: VideoFormat { + width: 1920, + height: 1080, + frames_per_second: 60, + }, + audio: true, + mic: false, + out: env::temp_dir().join("clipbin-probe"), + }; + let mut args = args.iter(); + while let Some(flag) = args.next() { + let mut value = || args.next().cloned().ok_or(format!("{flag} needs a value")); + let number = |value: String| { + value + .parse::() + .map_err(|_| format!("{flag}: not a number")) + }; + match flag.as_str() { + "--verify" => options.verify = true, + "--target" => options.target = value()?, + "--window" => options.window = value()?, + "--restore-token" => options.restore_token = Some(value()?), + "--codec" => options.codec = parse_codec(&value()?)?, + "--encoder" => options.encoder = Some(value()?), + "--seconds" => options.seconds = number(value()?)? as u64, + "--segment-seconds" => options.segment_seconds = number(value()?)?, + "--keyint" => options.keyint = number(value()?)?, + "--bitrate" => options.bitrate = number(value()?)?, + "--width" => options.format.width = number(value()?)? as u32, + "--height" => options.format.height = number(value()?)? as u32, + "--fps" => options.format.frames_per_second = number(value()?)? as u32, + "--no-audio" => options.audio = false, + "--mic" => options.mic = true, + "--out" => options.out = value()?.into(), + _ => return Err(format!("unknown option {flag}")), + } + } + Ok(options) + } + + fn capture_target(&self) -> Result { + let window = WindowMatch { + obs_window: self.window.clone(), + restore_token: self.restore_token.clone(), + }; + match self.target.as_str() { + "game" => Ok(CaptureTarget::Game { window }), + "window" => Ok(CaptureTarget::Window { window }), + "monitor" => Ok(CaptureTarget::Monitor { + monitor: MonitorMatch { + obs_monitor: self.window.clone(), + restore_token: self.restore_token.clone(), + }, + }), + other => Err(format!("unknown target {other}")), + } + } +} + +fn parse_codec(value: &str) -> Result { + match value { + "h264" => Ok(VideoCodec::H264), + "hevc" => Ok(VideoCodec::Hevc), + "av1" => Ok(VideoCodec::Av1), + other => Err(format!("unknown codec {other}")), + } +} + +fn codec_key(codec: VideoCodec) -> &'static str { + match codec { + VideoCodec::H264 => "h264", + VideoCodec::Hevc => "hevc", + VideoCodec::Av1 => "av1", + } +} + +fn start_engine(options: &Options) -> Result> { + let layout = RuntimeLayout::discover()?; + println!("runtime={}", layout.root().display()); + println!( + "runtime_obs_version={}", + layout.version().unwrap_or_default() + ); + let engine = Engine::start(&layout, options.format)?; + println!("obs_version={}", engine.obs_version()); + for plugin in engine.plugins_failed() { + println!("plugin_not_initialised={plugin}"); + } + println!("platform={}", clipbin_obs::runtime::platform_dir()); + #[cfg(not(windows))] + println!("session={:?}", capture::LinuxSession::detect()); + Ok(engine) +} + +/// Lists hardware encoders per codec. With `--verify`, proves each codec by +/// recording a few seconds with its preferred encoder and checking frames land +/// on disk: a registered encoder can still fail on the actual device. +fn encoders(options: &Options) -> Result> { + let engine = start_engine(options)?; + for (id, codec) in engine.registered_video_encoders() { + println!("registered_encoder={id} codec={codec}"); + } + let mut all_ok = true; + for codec in ALL_CODECS { + let key = codec_key(codec); + let candidates = engine.hardware_encoders(codec); + println!("candidates.{key}={}", candidates.join(",")); + if !options.verify { + continue; + } + let mut usable = None; + for encoder in &candidates { + let dir = options.out.join(format!("verify-{key}-{encoder}")); + let _ = std::fs::remove_dir_all(&dir); + let config = RecordingConfig { + video_source: None, + system_audio: None, + microphone: None, + video_encoder: encoder.clone(), + video_bitrate_kbps: options.bitrate, + keyframe_interval_seconds: options.keyint, + segment_seconds: options.segment_seconds, + segment_dir: dir, + }; + match verify_encoder(&engine, config) { + Ok(frames) => { + println!("verify.{key}.{encoder}=ok frames={frames}"); + usable = Some(encoder.clone()); + break; + } + Err(error) => println!("verify.{key}.{encoder}=failed {error}"), + } + } + match usable { + Some(encoder) => println!("usable.{key}={encoder}"), + None => { + println!("usable.{key}=none"); + all_ok &= codec != VideoCodec::H264; + } + } + } + Ok(all_ok) +} + +fn verify_encoder(engine: &Engine, config: RecordingConfig) -> Result { + let recording = engine + .start_recording(config) + .map_err(|error| error.to_string())?; + std::thread::sleep(Duration::from_secs(3)); + let health = recording.health(); + recording.stop(); + let stop = wait_for_stop(&recording, Duration::from_secs(10)); + match stop { + Some((StopReason::Success, _)) if health.total_frames > 0 && health.total_bytes > 0 => { + Ok(health.total_frames) + } + Some((reason, error)) => Err(format!( + "stopped={reason:?} frames={} bytes={} error={}", + health.total_frames, + health.total_bytes, + error.unwrap_or_default() + )), + None => Err("the output did not stop".into()), + } +} + +fn wait_for_stop( + recording: &clipbin_obs::Recording, + timeout: Duration, +) -> Option<(StopReason, Option)> { + let deadline = Instant::now() + timeout; + while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { + match recording.events().recv_timeout(remaining) { + Ok(RecorderEvent::OutputStopped { code, last_error }) => { + return Some((StopReason::from_code(code), last_error)); + } + Ok(_) => {} + Err(_) => break, + } + } + None +} + +/// Records from a real capture source, printing every status change and +/// segment so scripts/validate/ can check timing, keyframes and continuity. +fn record(options: &Options) -> Result> { + let engine = start_engine(options)?; + let target = options.capture_target()?; + #[cfg(windows)] + let video_source = target.video_source(); + #[cfg(not(windows))] + let video_source = target.video_source( + capture::LinuxSession::detect() + .ok_or("no Wayland or X11 session (WAYLAND_DISPLAY/DISPLAY unset)")?, + ); + println!("video_source={}", video_source.id); + + let encoder = match &options.encoder { + Some(encoder) => encoder.clone(), + None => engine + .hardware_encoders(options.codec) + .into_iter() + .next() + .ok_or_else(|| format!("no hardware {} encoder is registered", options.codec))?, + }; + println!("encoder={encoder}"); + + let dir = options.out.join("session"); + let _ = std::fs::remove_dir_all(&dir); + println!("segment_dir={}", dir.display()); + let config = RecordingConfig { + video_source: Some(video_source), + system_audio: options.audio.then(capture::system_audio_source), + microphone: options.mic.then(capture::microphone_source), + video_encoder: encoder, + video_bitrate_kbps: options.bitrate, + keyframe_interval_seconds: options.keyint, + segment_seconds: options.segment_seconds, + segment_dir: dir, + }; + + let started = Instant::now(); + let elapsed = || format!("{:.3}", started.elapsed().as_secs_f64()); + let recording = engine.start_recording(config)?; + let mut tracker = StatusTracker::new(started); + let mut status = RecordingStatus::Starting; + println!("t={} status={}", elapsed(), status.label()); + + let stop_at = started + Duration::from_secs(options.seconds); + let mut next_sample = started; + let mut stopping = false; + let mut outcome = None; + while outcome.is_none() { + let now = Instant::now(); + if now >= next_sample { + next_sample += Duration::from_secs(1); + let health = recording.health(); + println!( + "t={} health source={}x{} active={} frames={} dropped={} lagged={} muxer_bytes={} segment_bytes={}", + elapsed(), + health.source_width, + health.source_height, + health.output_active, + health.total_frames, + health.dropped_frames, + health.lagged_frames, + health.total_bytes, + health.current_segment_bytes + ); + report_status(&mut status, tracker.sample(&health, now), &elapsed()); + } + if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) { + stopping = true; + println!("t={} stop_requested", elapsed()); + recording.stop(); + } + match recording.events().recv_timeout(Duration::from_millis(100)) { + Ok(RecorderEvent::OutputStarted) => println!("t={} output_started", elapsed()), + Ok(RecorderEvent::SegmentStarted { path }) => { + println!("t={} segment_started={path}", elapsed()) + } + Ok(RecorderEvent::OutputStopped { code, last_error }) => { + let reason = StopReason::from_code(code); + println!( + "t={} output_stopped={reason:?} error={}", + elapsed(), + last_error.clone().unwrap_or_default() + ); + report_status( + &mut status, + tracker.stopped(reason, last_error.as_deref()), + &elapsed(), + ); + outcome = Some(reason); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + } + + if let Some(token) = recording.restore_token() { + println!("restore_token={token}"); + } + Ok(outcome == Some(StopReason::Success)) +} + +fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed: &str) { + if shown != current { + *shown = current.clone(); + match current { + RecordingStatus::Failed { reason } => { + println!("t={elapsed} status=failed reason={reason}") + } + status => println!("t={elapsed} status={}", status.label()), + } + } +}