//! 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 { if value.is_null() { return None; } // SAFETY: libobs returned a valid NUL-terminated string that outlives this call. Some( unsafe { CStr::from_ptr(value) } .to_string_lossy() .into_owned(), ) } struct Data(*mut sys::obs_data_t); impl Data { fn new(settings: &[(&str, SettingValue)]) -> Self { // SAFETY: obs_data_create returns a new reference that Drop releases. let data = Self(unsafe { sys::obs_data_create() }); for (key, value) in settings { data.set(key, value); } data } fn set(&self, key: &str, value: &SettingValue) { let key = cstring(key); // SAFETY: self.0 is a live obs_data_t and both strings outlive the call. unsafe { match value { SettingValue::String(value) => { sys::obs_data_set_string(self.0, key.as_ptr(), cstring(value).as_ptr()) } SettingValue::Int(value) => sys::obs_data_set_int(self.0, key.as_ptr(), *value), SettingValue::Bool(value) => sys::obs_data_set_bool(self.0, key.as_ptr(), *value), } } } } impl Drop for Data { fn drop(&mut self) { // SAFETY: releases the reference taken in Data::new or a getter. unsafe { sys::obs_data_release(self.0) } } } pub(crate) struct StartupSpec { pub libobs_data_dir: String, /// `(library path, data directory)` for each plugin to load. pub plugins: Vec<(String, String)>, pub width: u32, pub height: u32, pub frames_per_second: u32, } /// What `startup` loaded. Dropping it does not shut OBS down; call `shutdown`. pub(crate) struct Session { pub version: String, pub plugins_failed: Vec, /// Held open until `shutdown` has stopped libobs rendering through it. #[cfg(target_os = "linux")] _display: linux::Display, } // SAFETY: the display connection is only used on the OBS thread. unsafe impl Send for Session {} /// The OBS version the bindings were generated from; the runtime must match. pub(crate) fn header_version() -> (u32, u32, u32) { ( sys::LIBOBS_API_MAJOR_VER, sys::LIBOBS_API_MINOR_VER, sys::LIBOBS_API_PATCH_VER, ) } /// libobs's growable string, used to format log messages with its own printf. #[repr(C)] struct Dstr { array: *mut c_char, len: usize, capacity: usize, } #[cfg(windows)] type VaList = sys::va_list; #[cfg(not(windows))] type VaList = *mut sys::__va_list_tag; unsafe extern "C" { // Exported by libobs (util/dstr.h); not part of the generated bindings. fn dstr_vprintf(dst: *mut Dstr, format: *const c_char, args: VaList); } /// Sends libobs logging to stderr, keeping stdout for Clipbin's own output. /// Debug messages are shown only when `CLIPBIN_OBS_DEBUG` is set. unsafe extern "C" fn log_to_stderr( level: i32, format: *const c_char, args: VaList, _: *mut c_void, ) { // 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 { 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 { 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 { // SAFETY: installs a handler with the signature libobs expects. unsafe { sys::base_set_log_handler(Some(log_to_stderr), ptr::null_mut()) }; // SAFETY: obs_get_version_string returns a static string. let version = owned(unsafe { sys::obs_get_version_string() }).unwrap_or_default(); let (major, minor, patch) = header_version(); if version != format!("{major}.{minor}.{patch}") { return Err(format!( "the OBS runtime is version {version}, but Clipbin was built for {major}.{minor}.{patch}" )); } #[cfg(target_os = "linux")] let display = linux::Display::connect()?; // SAFETY: runs once on the OBS thread before any other libobs call. unsafe { if !sys::obs_startup(cstring("en-US").as_ptr(), ptr::null(), ptr::null_mut()) { return Err("libobs did not start".into()); } // libobs appends file names directly, so the data path ends in '/'. sys::obs_add_data_path(cstring(&format!("{}/", spec.libobs_data_dir)).as_ptr()); let audio = sys::obs_audio_info { samples_per_sec: 48_000, speakers: sys::speaker_layout_SPEAKERS_STEREO, }; if !sys::obs_reset_audio(&audio) { return Err("libobs could not set up audio".into()); } let graphics_module = cstring(if cfg!(windows) { "libobs-d3d11" } else { "libobs-opengl" }); let mut video: sys::obs_video_info = std::mem::zeroed(); video.graphics_module = graphics_module.as_ptr(); video.fps_num = spec.frames_per_second; video.fps_den = 1; video.base_width = spec.width; video.base_height = spec.height; video.output_width = spec.width; video.output_height = spec.height; video.output_format = sys::video_format_VIDEO_FORMAT_NV12; video.gpu_conversion = true; video.colorspace = sys::video_colorspace_VIDEO_CS_709; video.range = sys::video_range_type_VIDEO_RANGE_DEFAULT; video.scale_type = sys::obs_scale_type_OBS_SCALE_BICUBIC; let code = sys::obs_reset_video(&mut video); if code != sys::OBS_VIDEO_SUCCESS as i32 { sys::obs_shutdown(); return Err(format!("libobs could not start video (code {code})")); } // Open exactly the bundled plugins. obs_load_all_modules would also // search libobs's built-in default paths, which can hold other OBS // installations' plugins. let mut plugins_failed = Vec::new(); for (library, data) in &spec.plugins { let mut module = ptr::null_mut(); let code = sys::obs_open_module( &mut module, cstring(library).as_ptr(), cstring(data).as_ptr(), ); if code != sys::MODULE_SUCCESS as i32 { sys::obs_shutdown(); return Err(format!("could not open OBS plugin {library} (code {code})")); } // A plugin may decline to initialise, e.g. NVENC without an NVIDIA GPU. if !sys::obs_init_module(module) { plugins_failed.push(library.clone()); } } sys::obs_post_load_modules(); sys::obs_log_loaded_modules(); Ok(Session { version, plugins_failed, #[cfg(target_os = "linux")] _display: display, }) } } pub(crate) fn shutdown(session: Session) { // SAFETY: runs on the OBS thread after every OBS object was released. // Released sources are destroyed on a libobs task thread, and destroying // a scene queues its sources' destruction in turn. Shutting down before // the queue drains destroys them twice (seen with PipeWire capture). unsafe { while sys::obs_wait_for_destroy_queue() {} sys::obs_shutdown(); } drop(session); } #[cfg(target_os = "linux")] mod linux { use std::{ ffi::{c_char, c_int, c_void}, ptr, }; use super::sys; use crate::capture::LinuxSession; #[link(name = "wayland-client")] unsafe extern "C" { fn wl_display_connect(name: *const c_char) -> *mut c_void; fn wl_display_disconnect(display: *mut c_void); } #[link(name = "X11")] unsafe extern "C" { fn XOpenDisplay(name: *const c_char) -> *mut c_void; fn XCloseDisplay(display: *mut c_void) -> c_int; } /// The display connection libobs renders through (EGL on Wayland or X11). pub(super) struct Display { session: LinuxSession, handle: *mut c_void, } impl Display { pub(super) fn connect() -> Result { let session = LinuxSession::detect() .ok_or("no Wayland or X11 session (WAYLAND_DISPLAY/DISPLAY unset)")?; // SAFETY: connects to the display named by the environment; the // handle is closed in Drop after obs_shutdown. let handle = unsafe { match session { LinuxSession::Wayland => wl_display_connect(ptr::null()), LinuxSession::X11 => XOpenDisplay(ptr::null()), } }; if handle.is_null() { return Err(format!("could not connect to the {session:?} display")); } let platform = match session { LinuxSession::Wayland => sys::obs_nix_platform_type_OBS_NIX_PLATFORM_WAYLAND, LinuxSession::X11 => sys::obs_nix_platform_type_OBS_NIX_PLATFORM_X11_EGL, }; // SAFETY: must precede obs_startup; handle stays valid until Drop. unsafe { sys::obs_set_nix_platform(platform); sys::obs_set_nix_platform_display(handle); } Ok(Self { session, handle }) } } impl Drop for Display { fn drop(&mut self) { // SAFETY: handle came from the matching connect call. unsafe { match self.session { LinuxSession::Wayland => wl_display_disconnect(self.handle), LinuxSession::X11 => { XCloseDisplay(self.handle); } } } } } #[link(name = "glib-2.0")] unsafe extern "C" { fn g_main_loop_new(context: *mut c_void, is_running: c_int) -> *mut c_void; fn g_main_loop_run(main_loop: *mut c_void); fn g_main_loop_quit(main_loop: *mut c_void); fn g_main_loop_unref(main_loop: *mut c_void); } /// The default GLib main loop. OBS's PipeWire capture talks to the desktop /// portal over GDBus, whose replies are dispatched by this loop. pub(crate) struct GlibLoop { main_loop: *mut c_void, thread: Option>, } struct LoopPtr(*mut c_void); // SAFETY: GMainLoop run/quit are thread-safe. unsafe impl Send for LoopPtr {} impl GlibLoop { pub(crate) fn start() -> Self { // SAFETY: a new loop on the default context, released in Drop. let main_loop = unsafe { g_main_loop_new(ptr::null_mut(), 0) }; let running = LoopPtr(main_loop); let thread = std::thread::Builder::new() .name("glib".into()) .spawn(move || { let running = running; // SAFETY: the loop outlives this thread (joined in Drop). unsafe { g_main_loop_run(running.0) } }) .expect("the GLib thread can be spawned"); Self { main_loop, thread: Some(thread), } } } impl Drop for GlibLoop { fn drop(&mut self) { // SAFETY: quits the running loop, joins its thread, then frees it. unsafe { g_main_loop_quit(self.main_loop) }; if let Some(thread) = self.thread.take() { let _ = thread.join(); } unsafe { g_main_loop_unref(self.main_loop) }; } } } #[cfg(target_os = "linux")] pub(crate) use linux::GlibLoop; /// Video encoder ids registered by the loaded plugins, excluding deprecated /// and internal ones. pub(crate) fn registered_video_encoders() -> Vec<(String, String)> { let mut encoders = Vec::new(); for index in 0.. { let mut id: *const c_char = ptr::null(); // SAFETY: libobs writes a static id string for each registered encoder. if !unsafe { sys::obs_enum_encoder_types(index, &mut id) } { break; } // SAFETY: id is a registered encoder id. let (kind, caps, codec) = unsafe { ( sys::obs_get_encoder_type(id), sys::obs_get_encoder_caps(id), sys::obs_get_encoder_codec(id), ) }; let hidden = sys::OBS_ENCODER_CAP_DEPRECATED | sys::OBS_ENCODER_CAP_INTERNAL; if kind == sys::obs_encoder_type_OBS_ENCODER_VIDEO && caps & hidden == 0 && let (Some(id), Some(codec)) = (owned(id), owned(codec)) { encoders.push((id, codec)); } } encoders } pub(crate) struct PipelineSpec { pub canvas: (u32, u32), pub video_source: Option, pub system_audio: Option, pub microphone: Option, pub video_encoder: String, pub video_encoder_settings: Vec<(&'static str, SettingValue)>, pub audio_bitrate_kbps: i64, pub segment_dir: String, pub segment_extension: &'static str, pub segment_seconds: i64, } /// OBS output channels, following the OBS front end's convention. const VIDEO_CHANNEL: u32 = 0; const SYSTEM_AUDIO_CHANNEL: u32 = 1; const MICROPHONE_CHANNEL: u32 = 3; /// Audio track (mixer) indices. pub(crate) const SYSTEM_TRACK: usize = 0; pub(crate) const MICROPHONE_TRACK: usize = 1; pub(crate) const COMBINED_TRACK: usize = 2; pub(crate) fn segment_stem(index: u64) -> String { format!("segment-{index:06}") } /// State shared with libobs signal callbacks, which run on OBS threads. struct SignalContext { events: Mutex>, output: *mut sys::obs_output_t, first_segment: String, current_segment: Arc>>, } impl SignalContext { fn segment_started(&self, path: String) { if let Ok(mut current) = self.current_segment.lock() { *current = Some(path.clone()); } self.send(RecorderEvent::SegmentStarted { path }); } fn send(&self, event: RecorderEvent) { if let Ok(events) = self.events.lock() { let _ = events.send(event); } } } const SIGNALS: [( &str, unsafe extern "C" fn(*mut c_void, *mut sys::calldata_t), ); 3] = [ ("start", on_start), ("stop", on_stop), ("file_changed", on_file_changed), ]; unsafe extern "C" fn on_start(data: *mut c_void, _: *mut sys::calldata_t) { // SAFETY: data is the SignalContext registered in Pipeline::build, which // outlives the connection. let context = unsafe { &*(data as *const SignalContext) }; context.send(RecorderEvent::OutputStarted); context.segment_started(context.first_segment.clone()); } unsafe extern "C" fn on_stop(data: *mut c_void, calldata: *mut sys::calldata_t) { // SAFETY: as in on_start; calldata is valid for the duration of the signal. let context = unsafe { &*(data as *const SignalContext) }; let mut code: i64 = 0; let name = cstring("code"); // SAFETY: "code" is a long long in the output stop signal's calldata. unsafe { sys::calldata_get_data( calldata, name.as_ptr(), (&mut code as *mut i64).cast(), size_of::(), ); } // SAFETY: the output outlives its signal connections. let last_error = owned(unsafe { sys::obs_output_get_last_error(context.output) }); context.send(RecorderEvent::OutputStopped { code, last_error }); } /// The muxer names each new segment from the output's `format` setting just /// before it emits this signal, on the same thread. Setting the next stem here /// gives every segment a sequential name. unsafe extern "C" fn on_file_changed(data: *mut c_void, calldata: *mut sys::calldata_t) { // SAFETY: as in on_stop. let context = unsafe { &*(data as *const SignalContext) }; let mut next_file: *const c_char = ptr::null(); let name = cstring("next_file"); // SAFETY: "next_file" is a string in the file_changed calldata. unsafe { sys::calldata_get_string(calldata, name.as_ptr(), &mut next_file) }; let Some(path) = owned(next_file) else { return }; if let Some(index) = segment_index(&path) { // SAFETY: obs_output_get_settings returns a new reference, released by Data. let settings = Data(unsafe { sys::obs_output_get_settings(context.output) }); settings.set("format", &SettingValue::String(segment_stem(index + 1))); } context.segment_started(path); } pub(crate) fn segment_index(path: &str) -> Option { let name = path.rsplit(['/', '\\']).next()?; let stem = name.split('.').next()?; stem.strip_prefix("segment-")?.parse().ok() } /// The live OBS objects for one recording. pub(crate) struct Pipeline { scene: *mut sys::obs_scene_t, /// Each source with the output channel it is bound to, if any. sources: Vec<(Option, *mut sys::obs_source_t)>, video_source: *mut sys::obs_source_t, video_encoder: *mut sys::obs_encoder_t, audio_encoders: Vec<*mut sys::obs_encoder_t>, output: *mut sys::obs_output_t, signals: Option>, } // SAFETY: the pointers are only dereferenced on the OBS thread (see module docs). unsafe impl Send for Pipeline {} unsafe impl Sync for Pipeline {} impl Pipeline { pub(crate) fn build( spec: &PipelineSpec, events: Sender, current_segment: Arc>>, ) -> Result { 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 { 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); } }