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.
This commit is contained in:
2026-10-08 14:12:06 +02:00
parent 80b6abdb32
commit f2eb7139da
14 changed files with 2470 additions and 3 deletions
+6
View File
@@ -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 }
Generated
+48 -2
View File
@@ -496,6 +496,26 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -851,6 +871,22 @@ dependencies = [
"gpui", "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]] [[package]]
name = "cocoa" name = "cocoa"
version = "0.25.0" version = "0.25.0"
@@ -2084,7 +2120,7 @@ dependencies = [
"as-raw-xcb-connection", "as-raw-xcb-connection",
"ashpd 0.11.1", "ashpd 0.11.1",
"async-task", "async-task",
"bindgen", "bindgen 0.71.1",
"blade-graphics", "blade-graphics",
"blade-macros", "blade-macros",
"blade-util", "blade-util",
@@ -2236,7 +2272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen", "bindgen 0.71.1",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-video", "core-video",
"ctor", "ctor",
@@ -2940,6 +2976,16 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" 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]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.25" version = "0.1.25"
+10 -1
View File
@@ -1,5 +1,10 @@
[workspace] [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"] default-members = ["crates/clipbin-desktop"]
resolver = "3" resolver = "3"
@@ -13,7 +18,11 @@ publish = false
[workspace.dependencies] [workspace.dependencies]
clipbin-core = { path = "crates/clipbin-core" } clipbin-core = { path = "crates/clipbin-core" }
clipbin-obs = { path = "crates/clipbin-obs" }
gpui = "=0.2.2" 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] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
+17
View File
@@ -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"
+208
View File
@@ -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<String>,
}
#[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<String>,
}
#[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<Self> {
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");
}
}
+109
View File
@@ -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<str>],
) -> 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, &registered),
[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)
);
}
}
}
}
+822
View File
@@ -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<String> {
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<String>,
/// 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<Session, String> {
// 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<Self, String> {
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<std::thread::JoinHandle<()>>,
}
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<SourceSpec>,
pub system_audio: Option<SourceSpec>,
pub microphone: Option<SourceSpec>,
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<Sender<RecorderEvent>>,
output: *mut sys::obs_output_t,
first_segment: String,
current_segment: Arc<Mutex<Option<String>>>,
}
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::<i64>(),
);
}
// 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<u64> {
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<u32>, *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<Box<SignalContext>>,
}
// 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<RecorderEvent>,
current_segment: Arc<Mutex<Option<String>>>,
) -> Result<Self, String> {
// 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<String> {
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);
}
}
+18
View File
@@ -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};
+509
View File
@@ -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<ObsThread>,
session: Option<Session>,
format: VideoFormat,
#[cfg(target_os = "linux")]
_glib: ffi::GlibLoop,
}
impl Engine {
pub fn start(layout: &RuntimeLayout, format: VideoFormat) -> Result<Self, EngineError> {
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<String> {
let registered: Vec<String> = self
.registered_video_encoders()
.into_iter()
.map(|(id, _)| id)
.collect();
registered_candidates(codec, &registered)
.into_iter()
.map(str::to_owned)
.collect()
}
pub fn start_recording(&self, config: RecordingConfig) -> Result<Recording, EngineError> {
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(&current_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<SourceSpec>,
pub system_audio: Option<SourceSpec>,
pub microphone: Option<SourceSpec>,
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<ObsThread>,
pipeline: Option<Arc<Pipeline>>,
events: Receiver<RecorderEvent>,
current_segment: Arc<Mutex<Option<String>>>,
}
impl Recording {
fn with_pipeline<T: Send + 'static>(
&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<RecorderEvent> {
&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<String> {
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<String>,
},
}
/// 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<String>,
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<String>, 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));
}
}
+200
View File
@@ -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: `<root>/bin/clipbin`, `<root>/lib/obs-plugins`, `<root>/share/obs`.
/// * Windows: `<root>/bin/64bit/clipbin.exe`, `<root>/obs-plugins/64bit`, `<root>/data`.
///
/// Development builds use `CLIPBIN_OBS_RUNTIME_DIR` (see `.cargo/config.toml`),
/// the directory holding `<os>-<arch>` runtimes assembled by `scripts/obs/`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeLayout {
root: PathBuf,
}
impl RuntimeLayout {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
/// Finds the runtime for this executable, preferring the development override.
pub fn discover() -> Result<Self, RuntimeError> {
// 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<Self> {
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<String> {
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")
);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
use std::{
sync::mpsc::{self, Sender},
thread::{self, JoinHandle},
};
type Job = Box<dyn FnOnce() + Send>;
/// 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<Sender<Job>>,
handle: Option<JoinHandle<()>>,
}
impl ObsThread {
pub(crate) fn spawn() -> Self {
let (jobs, receiver) = mpsc::channel::<Job>();
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<T: Send + 'static>(&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());
}
}
+16
View File
@@ -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
+56
View File
@@ -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: <root>/bin/<exe> loads <root>/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 <profile>/build/<package>-<hash>/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();
}
}
+384
View File
@@ -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<String> = 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<String>,
codec: VideoCodec,
encoder: Option<String>,
seconds: u64,
segment_seconds: i64,
keyint: i64,
bitrate: i64,
format: VideoFormat,
audio: bool,
mic: bool,
out: PathBuf,
}
impl Options {
fn parse(args: &[String]) -> Result<Self, String> {
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::<i64>()
.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<CaptureTarget, String> {
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<VideoCodec, String> {
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<Engine, Box<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
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<u64, String> {
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<String>)> {
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<bool, Box<dyn std::error::Error>> {
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()),
}
}
}