Files
clipbin-desktop/crates/clipbin-obs/src/runtime.rs
T
harry f2eb7139da 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.
2026-10-08 14:12:06 +02:00

201 lines
6.3 KiB
Rust

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")
);
}
}
}