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:
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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, ®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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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, ®istered)
|
||||
.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(¤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<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));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user