Files
clipbin-desktop/crates/clipbin-obs/src/ffi.rs
T
harry afc54b363e
Rust checks / windows-cross-check (push) Successful in 41s
Rust checks / linux (push) Failing after 5m39s
fix: time out a stalled portal and sample motion across segments
Hyprland's portal sometimes creates the session and never answers source
selection, before any picker appears. Recording now fails if the portal has
not reached its picker within 10 s; time in the picker is not limited. The
motion check uses the segment with the most motion, since capture can start
late.
2026-10-08 15:16:52 +02:00

913 lines
34 KiB
Rust

//! 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::{AtomicU8, AtomicU64, Ordering},
mpsc::Sender,
},
};
use libobs as sys;
use crate::{
capture::{SettingValue, SourceSpec},
recorder::{Health, PipeWireState, 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,
) {
// LOG_* constants are u32 in the Linux bindings and i32 in the Windows ones.
let level = i64::from(level);
if level >= i64::from(sys::LOG_DEBUG) && 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 <= i64::from(sys::LOG_ERROR) => "error",
level if level <= i64::from(sys::LOG_WARNING) => "warning",
level if level <= i64::from(sys::LOG_INFO) => "info",
_ => "debug",
};
if let Some(text) = owned(message.array) {
if let Some(state) = pipewire_state(&text) {
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
}
eprintln!("obs {label}: {text}");
}
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
unsafe { sys::bfree(message.array.cast()) };
}
/// The PipeWire capture state, as last logged by OBS (0 = nothing logged).
///
/// OBS 32.0.4's PipeWire capture keeps reporting its last size and rendering
/// its last frame after its stream stops, and reports portal refusals and
/// stream states only in its log (linux-pipewire's screencast-portal.c and
/// pipewire.c). Clipbin records one capture source at a time.
static PIPEWIRE_STATE: AtomicU8 = AtomicU8::new(0);
fn pipewire_state(message: &str) -> Option<PipeWireState> {
if let Some(rest) = message.strip_prefix("[pipewire] Stream ") {
// `[pipewire] Stream 0x... state: "streaming" (error: none)`
let state = rest.split_once(" state: \"")?.1.split_once('"')?.0;
return Some(if state == "streaming" {
PipeWireState::Streaming
} else {
PipeWireState::Stopped
});
}
// Requested from the portal, then the picker is shown (or a restore
// token is used): only the first step can stall without the user.
if message == "PipeWire initialized" {
return Some(PipeWireState::Requesting);
}
if message.starts_with("[pipewire] Asking for ") {
return Some(PipeWireState::Selecting);
}
// The user denied or cancelled the portal request, or it failed.
let refused = message.starts_with("[pipewire] Error ")
|| (message.starts_with("[pipewire] Failed to ")
&& message.ends_with("denied or cancelled by user"));
refused.then_some(PipeWireState::Refused)
}
fn load_pipewire_state() -> Option<PipeWireState> {
match PIPEWIRE_STATE.load(Ordering::Relaxed) {
state if state == PipeWireState::Requesting as u8 => Some(PipeWireState::Requesting),
state if state == PipeWireState::Selecting as u8 => Some(PipeWireState::Selecting),
state if state == PipeWireState::Streaming as u8 => Some(PipeWireState::Streaming),
state if state == PipeWireState::Stopped as u8 => Some(PipeWireState::Stopped),
state if state == PipeWireState::Refused as u8 => Some(PipeWireState::Refused),
_ => None,
}
}
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> {
PIPEWIRE_STATE.store(0, Ordering::Relaxed);
// 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,
pipewire: load_pipewire_state(),
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,
written_bytes: 0,
disk_available_bytes: None,
}
}
}
/// 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 id = cstring(spec.id);
// OBS creates a placeholder for unknown source types instead of failing.
// SAFETY: looks up a registered type by id.
if unsafe { sys::obs_source_get_display_name(id.as_ptr()) }.is_null() {
return Err(format!(
"the {} capture source is not available (its OBS plugin did not register it)",
spec.id
));
}
let settings = Data::new(&spec.settings);
// SAFETY: arguments are valid; the caller owns the returned reference.
let source = unsafe { sys::obs_source_create_private(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 pipewire_state_is_read_from_the_plugin_log() {
let state = |line| pipewire_state(line);
assert_eq!(
state(r#"[pipewire] Stream 0x7fe128037440 state: "streaming" (error: none)"#),
Some(PipeWireState::Streaming)
);
assert_eq!(
state(r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#),
Some(PipeWireState::Stopped)
);
assert_eq!(
state("[pipewire] Failed to select source, denied or cancelled by user"),
Some(PipeWireState::Refused)
);
assert_eq!(
state("[pipewire] Error selecting screencast source: Timeout"),
Some(PipeWireState::Refused)
);
assert_eq!(
state("PipeWire initialized"),
Some(PipeWireState::Requesting)
);
assert_eq!(
state("[pipewire] Asking for monitor and window"),
Some(PipeWireState::Selecting)
);
assert_eq!(state("[pipewire] Screencast session created"), None);
// OBS cancelling its own pending request at teardown is not a refusal.
assert_eq!(state("[portals] Request cancelled"), None);
}
#[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);
}
}