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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user