Compare commits

..
7 Commits
Author SHA1 Message Date
harry 91f119e6b6 refactor: drop unused current segment from Health
Rust checks / windows-cross-check (push) Successful in 42s
Rust checks / linux (push) Failing after 5m47s
2026-10-08 14:32:43 +02:00
harry 3588d5b212 docs: add validation guide, hardware and Windows scripts, CI
Documents what the rig proved and what it found, adds the hardware sign-off
script for the Hyprland machine and an (untested) Windows validation script,
and updates CI to build the cached OBS runtime, run tests and lint the Windows
code paths.
2026-10-08 14:31:54 +02:00
harry 9178a303ca test: add Linux rig validation suite
Runs encoder verification, Wayland and X11 capture with segment and remux
checks, and five failure scenarios that must end in an honest failed status:
source lost, window closed, unwritable segments, low disk space and a portal
that grants no source.
2026-10-08 14:29:28 +02:00
harry 444758f9ec feat: measure footage across segments and test X11 capture
Matroska output can stay buffered until a segment closes, so progress counts
bytes across all segments and the stall limit scales with segment length.
Unknown capture source types now fail instead of recording an empty
placeholder. The rig gains an Xwayland mode for Xcomposite window capture.
2026-10-08 14:25:23 +02:00
harry 263e7a964e feat: detect lost sources, stalled writes and low disk space
PipeWire capture keeps its size and last frame after its stream stops, so the
recorder follows the stream state OBS logs. Recording fails when the segment
on disk stops growing (OBS kept counting bytes into a dead muxer and missed a
full disk) and stops while enough space remains to finalise the last segment.
Failures stay visible after the output stops.

Adds a check command that verifies keyframe-aligned segments and a remux
across segment boundaries, and gives the rig desktop audio.
2026-10-08 14:19:57 +02:00
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
harry 80b6abdb32 build: assemble pinned OBS 32.0.4 runtimes for Linux and Windows
Linux builds libobs and an allowlist of official plugins from source in OBS's
relocatable layout. Windows copies the official release byte-for-byte so the
Game Capture hook keeps its signature. Adds a headless Wayland rig for
PipeWire/portal capture tests on machines without a display.
2026-10-08 13:51:22 +02:00
24 changed files with 3574 additions and 22 deletions
+6
View File
@@ -0,0 +1,6 @@
# Development builds use the pinned OBS runtime assembled by scripts/obs/.
# Packaged builds find the runtime relative to the executable instead.
[env]
CLIPBIN_OBS_RUNTIME_DIR = { value = "target/obs-runtime", relative = true }
# Linux: link libobs from the bundled runtime rather than a system OBS.
PKG_CONFIG_PATH = { value = "target/obs-runtime/linux-x86_64/lib/pkgconfig", relative = true }
+42 -4
View File
@@ -5,20 +5,58 @@ on:
pull_request: pull_request:
jobs: jobs:
checks: linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: https://github.com/dtolnay/rust-toolchain@1.98.0 - uses: https://github.com/dtolnay/rust-toolchain@1.98.0
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- name: Install native UI build dependencies - name: Install native build dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential pkg-config libfontconfig1-dev libfreetype6-dev libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev # GPUI, plus clang for the libobs bindings.
sudo apt-get install -y build-essential pkg-config clang libfontconfig1-dev libfreetype6-dev \
libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev
# OBS 32.0.4 libobs and the bundled plugins (scripts/obs/build-linux.sh).
sudo apt-get install -y cmake ninja-build extra-cmake-modules libavcodec-dev libavdevice-dev \
libavfilter-dev libavformat-dev libavutil-dev libswresample-dev libswscale-dev libx264-dev \
libcurl4-openssl-dev libmbedtls-dev libgl1-mesa-dev libglvnd-dev libgles2-mesa-dev libjansson-dev \
libx11-dev libx11-xcb-dev libxcb-randr0-dev libxcb-shm0-dev libxcb-xinerama0-dev \
libxcb-composite0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcomposite-dev libxinerama-dev \
libxss-dev libpci-dev libpipewire-0.3-dev libpulse-dev libudev-dev libva-dev libdrm-dev \
librist-dev libsrt-openssl-dev uthash-dev libsimde-dev nlohmann-json3-dev libffmpeg-nvenc-dev \
uuid-dev libglib2.0-dev
- name: Cache the OBS runtime
uses: https://github.com/actions/cache@v4
with:
path: target/obs-runtime/linux-x86_64
key: obs-runtime-linux-${{ hashFiles('scripts/obs/versions.env', 'scripts/obs/build-linux.sh') }}
- name: Build the OBS runtime
run: |
if [ ! -f target/obs-runtime/linux-x86_64/VERSION ]; then
scripts/obs/build-linux.sh
fi
- name: Check formatting - name: Check formatting
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Check Rust - name: Lint
run: cargo clippy --locked --workspace --all-targets -- -D warnings run: cargo clippy --locked --workspace --all-targets -- -D warnings
- name: Test
run: cargo test --locked --workspace
- name: Build - name: Build
run: cargo build --locked --workspace run: cargo build --locked --workspace
windows-cross-check:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/dtolnay/rust-toolchain@1.98.0
with:
components: clippy
targets: x86_64-pc-windows-msvc
# Type-checks the Windows code paths; nothing is linked or run. The GPUI
# crate needs the MSVC C toolchain and is checked on Windows instead.
- name: Lint Windows code paths
run: >
cargo clippy --locked --target x86_64-pc-windows-msvc
-p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
Generated
+59 -2
View File
@@ -496,6 +496,26 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.13.2",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.3",
"shlex 1.3.0",
"syn 2.0.119",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -851,6 +871,23 @@ dependencies = [
"gpui", "gpui",
] ]
[[package]]
name = "clipbin-obs"
version = "0.1.0"
dependencies = [
"clipbin-core",
"fs4",
"libobs",
]
[[package]]
name = "clipbin-probe"
version = "0.1.0"
dependencies = [
"clipbin-core",
"clipbin-obs",
]
[[package]] [[package]]
name = "cocoa" name = "cocoa"
version = "0.25.0" version = "0.25.0"
@@ -1806,6 +1843,16 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "fs4"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
dependencies = [
"rustix 1.1.5",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "futf" name = "futf"
version = "0.1.5" version = "0.1.5"
@@ -2084,7 +2131,7 @@ dependencies = [
"as-raw-xcb-connection", "as-raw-xcb-connection",
"ashpd 0.11.1", "ashpd 0.11.1",
"async-task", "async-task",
"bindgen", "bindgen 0.71.1",
"blade-graphics", "blade-graphics",
"blade-macros", "blade-macros",
"blade-util", "blade-util",
@@ -2236,7 +2283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen", "bindgen 0.71.1",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-video", "core-video",
"ctor", "ctor",
@@ -2940,6 +2987,16 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libobs"
version = "5.0.1+32.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8956a7534b34783f962fd8f9b16383bcf3a2b535629fd68628b205e849bd17"
dependencies = [
"bindgen 0.72.1",
"pkg-config",
]
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.25" version = "0.1.25"
+10 -1
View File
@@ -1,5 +1,10 @@
[workspace] [workspace]
members = ["crates/clipbin-core", "crates/clipbin-desktop"] members = [
"crates/clipbin-core",
"crates/clipbin-desktop",
"crates/clipbin-obs",
"crates/clipbin-probe",
]
default-members = ["crates/clipbin-desktop"] default-members = ["crates/clipbin-desktop"]
resolver = "3" resolver = "3"
@@ -13,7 +18,11 @@ publish = false
[workspace.dependencies] [workspace.dependencies]
clipbin-core = { path = "crates/clipbin-core" } clipbin-core = { path = "crates/clipbin-core" }
clipbin-obs = { path = "crates/clipbin-obs" }
gpui = "=0.2.2" gpui = "=0.2.2"
# The tested OBS set: the libobs crate's bundled headers are OBS 32.0.4, matching
# scripts/obs/versions.env. Update them together.
libobs = "=5.0.1"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
+38 -14
View File
@@ -1,26 +1,34 @@
# Clipbin Desktop # Clipbin Desktop
An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust and GPUI provide the desktop interface; the recording engine will use libobs and official OBS capture components. An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust and GPUI provide the desktop interface; the recording engine uses libobs and official OBS capture components.
[SPEC.md](SPEC.md) defines the agreed v1 product requirements. [SPEC.md](SPEC.md) defines the agreed v1 product requirements.
## Current state ## Current state
This is the initial project scaffold. It opens a native GPUI window, displays the agreed recording defaults, and always reports **Not recording**. The recording foundation is being validated (see [docs/validation.md](docs/validation.md)). `clipbin-obs` records official OBS capture sources through one hardware encoder into keyframe-aligned segments and reports honest status; `clipbin-probe` exercises it. It passes the Linux headless rig; hardware sign-off on Hyprland and all Windows validation are still to do.
Capture, Steam detection, recording, playback, clipping, bookmarks, persistence, global hotkeys, autostart, and background recording are not implemented yet. Closing the window exits this initial shell. The full application will keep its recorder running independently of window closure, as specified in SPEC.md. The desktop app is still the initial shell: it opens a GPUI window, displays the agreed recording defaults, and always reports **Not recording**. Steam detection, the recorder in the app, playback, clipping, bookmarks, persistence, global hotkeys, autostart, and background recording are not implemented yet.
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
## Layout ## Layout
```text ```text
crates/ crates/
clipbin-core/ Settings and recorder state; independent of GPUI and native OBS. clipbin-core/ Settings and recorder state; independent of GPUI and native OBS.
clipbin-obs/ Recording engine over the pinned libobs runtime; all unsafe code is in src/ffi.rs.
clipbin-probe/ Validation probe for capture, encoders, segments and status.
clipbin-desktop/ GPUI application and initial session-library view. clipbin-desktop/ GPUI application and initial session-library view.
.gitea/workflows/ Rust formatting and compilation checks. scripts/obs/ Assemble the pinned OBS runtime for Linux (built from source) and Windows (official release).
scripts/rig/ Headless Wayland/X11 desktop for capture tests on machines without a display.
scripts/validate/ Validation suites: Linux rig, Linux hardware, Windows.
docs/validation.md Validation status and findings.
.gitea/workflows/ Formatting, lint, test and Windows cross-checks.
SPEC.md Product requirements and acceptance checks. SPEC.md Product requirements and acceptance checks.
``` ```
The core currently has no external dependencies. Keep capture and session operations out of the UI thread when the recording engine is added. The core has no external dependencies. Keep capture and session operations out of the UI thread.
## Development ## Development
@@ -31,9 +39,23 @@ Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2
On Arch/CachyOS, install the native build dependencies: On Arch/CachyOS, install the native build dependencies:
```sh ```sh
sudo pacman -S --needed base-devel pkgconf fontconfig freetype2 wayland libxcb libxkbcommon libxkbcommon-x11 openssl sudo pacman -S --needed base-devel pkgconf fontconfig freetype2 wayland libxcb libxkbcommon libxkbcommon-x11 openssl clang
``` ```
### OBS runtime
Clipbin bundles a pinned OBS **32.0.4** runtime (`scripts/obs/versions.env`) with only the official plugins it uses. The `libobs` crate's headers match that version; the engine refuses to start against any other.
- Linux: `scripts/obs/build-linux.sh` builds libobs and the plugins from source into `target/obs-runtime/linux-x86_64`. Arch/CachyOS build dependencies:
```sh
sudo pacman -S --needed cmake ninja extra-cmake-modules ffmpeg x264 pipewire libpulse libdrm libva jansson curl libxcomposite libxinerama libxrandr wayland-protocols simde uthash nlohmann-json mbedtls3 pciutils srt librist ffnvcodec-headers amf-headers
```
- Windows: `scripts/obs/assemble-windows.sh` (Git Bash) verifies and unpacks the official release zip into `target/obs-runtime/windows-x86_64`, copying files byte-for-byte so the Game Capture hook keeps its signature.
Development builds find the runtime through `.cargo/config.toml`; `clipbin-probe`'s build script places `obs-ffmpeg-mux` (and on Windows the OBS DLLs) next to the executable, where OBS looks for them.
On Debian/Ubuntu: On Debian/Ubuntu:
```sh ```sh
@@ -41,7 +63,7 @@ sudo apt-get update
sudo apt-get install build-essential pkg-config libfontconfig1-dev libfreetype6-dev libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev sudo apt-get install build-essential pkg-config libfontconfig1-dev libfreetype6-dev libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev
``` ```
For Windows, use the MSVC Rust toolchain and Visual Studio Build Tools with the Desktop development with C++ workload. Windows support needs validation on a Windows machine. For Windows, use the MSVC Rust toolchain and Visual Studio Build Tools with the Desktop development with C++ workload. Windows support needs validation on a Windows machine; see [docs/validation.md](docs/validation.md#windows).
Run the desktop shell from an active graphical session: Run the desktop shell from an active graphical session:
@@ -58,9 +80,13 @@ Checks:
```sh ```sh
cargo fmt --all -- --check cargo fmt --all -- --check
cargo clippy --locked --workspace --all-targets -- -D warnings cargo clippy --locked --workspace --all-targets -- -D warnings
cargo build --locked --workspace cargo test --locked --workspace
# Windows code paths, from any platform (rustup target add x86_64-pc-windows-msvc):
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
``` ```
Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`.
The core can also be checked without native UI dependencies: The core can also be checked without native UI dependencies:
```sh ```sh
@@ -69,14 +95,12 @@ cargo check --locked -p clipbin-core
## First recording milestone ## First recording milestone
1. Validate official OBS capture and recording-health signals on CachyOS, Hyprland and the RX 9070 XT at 1440p60. 1. Validate official OBS capture and recording-health signals on CachyOS, Hyprland and the RX 9070 XT at 1440p60. *Rig validated; hardware sign-off pending.*
2. Select and pin a tested OBS runtime, Rust bindings, and official plugin set together. Preserve the original signed Windows capture-hook binaries. 2. Select and pin a tested OBS runtime, Rust bindings, and official plugin set together. Preserve the original signed Windows capture-hook binaries. *Pinned to OBS 32.0.4 and libobs crate 5.0.1; Windows runtime unvalidated.*
3. Prove one continuous hardware encoder can produce short keyframe-aligned segments and remux clips across their boundaries while recording continues. 3. Prove one continuous hardware encoder can produce short keyframe-aligned segments and remux clips across their boundaries while recording continues. *Proven in the rig with VA-API H.264 and HEVC.*
4. Integrate the recorder without blocking GPUI, then add native playback and seeking. 4. Integrate the recorder without blocking GPUI, then add native playback and seeking.
OBS is not linked or bundled in this scaffold. Adding an unused native dependency would not validate the required runtime/plugin compatibility. The runtime bundle and adapter belong to the first recording milestone. The capture integration uses official OBS components only: Windows Game Capture/Window Capture, X11 Xcomposite, and Wayland PipeWire/desktop-portal capture. Third-party game hooks are excluded.
The capture integration must use official OBS components: Windows Game Capture/Window Capture, X11 Xcomposite, and Wayland PipeWire/desktop-portal capture. Third-party game hooks are excluded.
## License ## License
+18
View File
@@ -0,0 +1,18 @@
[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
fs4 = { version = "1.1", default-features = false, features = ["sync"] }
libobs.workspace = true
# Raw libobs calls are confined to src/ffi.rs, which opts back in to unsafe code.
[lints.rust]
unsafe_code = "deny"
+208
View File
@@ -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");
}
}
+109
View File
@@ -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, &registered),
[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)
);
}
}
}
}
+873
View File
@@ -0,0 +1,873 @@
//! 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, 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_stream_state(&text) {
let code = if state == "streaming" {
STREAMING
} else {
NOT_STREAMING
};
PIPEWIRE_STREAM.store(code, Ordering::Relaxed);
}
eprintln!("obs {label}: {text}");
}
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
unsafe { sys::bfree(message.array.cast()) };
}
/// The state of the PipeWire capture stream, as last logged by OBS.
///
/// OBS 32.0.4's PipeWire capture keeps reporting its last size and rendering
/// its last frame after the stream stops, and exposes the stream state only
/// through this log line. Clipbin records one capture source at a time.
static PIPEWIRE_STREAM: AtomicU8 = AtomicU8::new(NO_PIPEWIRE_STREAM);
const NO_PIPEWIRE_STREAM: u8 = 0;
const STREAMING: u8 = 1;
const NOT_STREAMING: u8 = 2;
/// Parses `[pipewire] Stream 0x... state: "streaming" (error: none)` from
/// linux-pipewire's on_state_changed_cb.
fn pipewire_stream_state(message: &str) -> Option<&str> {
let rest = message.strip_prefix("[pipewire] Stream ")?;
let state = rest.split_once(" state: \"")?.1;
state.split_once('"').map(|(state, _)| state)
}
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_STREAM.store(NO_PIPEWIRE_STREAM, 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),
)
};
let source_streaming = match PIPEWIRE_STREAM.load(Ordering::Relaxed) {
NO_PIPEWIRE_STREAM => None,
state => Some(state == STREAMING),
};
Health {
source_width: width,
source_height: height,
source_streaming,
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_stream_state_is_read_from_the_plugin_log() {
let line = r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#;
assert_eq!(pipewire_stream_state(line), Some("paused"));
assert_eq!(
pipewire_stream_state("[pipewire] Screencast session created"),
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);
}
}
+18
View File
@@ -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};
+621
View File
@@ -0,0 +1,621 @@
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, &registered)
.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(&current_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,
written: Mutex::default(),
segment_dir: config.segment_dir,
};
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>>>,
written: Mutex<Written>,
segment_dir: PathBuf,
}
/// Bytes on disk across this recording's segments.
#[derive(Default)]
struct Written {
current: Option<String>,
finished_bytes: u64,
}
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 what reached disk. 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);
health.disk_available_bytes = fs4::available_space(&self.segment_dir).ok();
let current = self
.current_segment
.lock()
.ok()
.and_then(|path| path.clone());
let size = |path: &str| std::fs::metadata(path).map_or(0, |metadata| metadata.len());
if let Ok(mut written) = self.written.lock() {
if written.current != current {
// The previous segment is complete; count its final size.
if let Some(finished) = written.current.take() {
written.finished_bytes += size(&finished);
}
written.current = current;
}
health.written_bytes =
written.finished_bytes + written.current.as_deref().map_or(0, size);
}
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,
/// For PipeWire capture, whether its stream is delivering frames. `None`
/// for sources that report loss through their size instead.
pub source_streaming: Option<bool>,
pub output_active: bool,
pub total_frames: u64,
pub dropped_frames: u64,
pub total_bytes: u64,
pub lagged_frames: u64,
/// Bytes on disk across all of this recording's segments.
pub written_bytes: u64,
/// Free space on the recording volume, if it could be read.
pub disk_available_bytes: Option<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,
min_free_bytes: u64,
stall_limit: Duration,
last_written: u64,
last_progress: Instant,
}
impl StatusTracker {
/// Free space below which recording stops, so the last segment can still
/// be finalised rather than failing mid-write.
pub const DEFAULT_MIN_FREE_BYTES: u64 = 1 << 30;
/// Matroska output can stay buffered until its segment closes, so footage
/// may appear on disk only once per segment. A stall is declared after two
/// segment lengths without growth.
pub fn new(now: Instant, min_free_bytes: u64, segment_length: Duration) -> Self {
Self {
status: RecordingStatus::Starting,
min_free_bytes,
stall_limit: (segment_length * 2 + Duration::from_secs(1)).max(Duration::from_secs(3)),
last_written: 0,
last_progress: now,
}
}
/// How long footage may stop reaching disk before recording counts as failed.
pub fn stall_limit(&self) -> Duration {
self.stall_limit
}
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;
}
let progressed = health.written_bytes > self.last_written;
if progressed {
self.last_written = health.written_bytes;
self.last_progress = now;
}
let has_picture = health.source_width > 0
&& health.source_height > 0
&& health.source_streaming != Some(false);
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
if let Some(available) = health.disk_available_bytes
&& available < self.min_free_bytes
{
self.status = RecordingStatus::Failed {
reason: format!(
"not enough disk space: {} MB free, {} MB needed",
available / 1_000_000,
self.min_free_bytes / 1_000_000
),
};
return &self.status;
}
self.status = match (&self.status, has_picture, progressed || !stalled) {
(_, true, true)
if health.output_active && health.total_frames > 0 && self.last_written > 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
}
/// A failure stays visible after the output stops cleanly; it clears only
/// when a new recording starts.
pub fn stopped(&mut self, reason: StopReason, last_error: Option<&str>) -> &RecordingStatus {
self.status = match reason {
StopReason::Success if matches!(self.status, RecordingStatus::Failed { .. }) => {
return &self.status;
}
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::*;
const SEGMENT: Duration = Duration::from_secs(2);
fn healthy(bytes: u64) -> Health {
Health {
source_width: 1920,
source_height: 1080,
source_streaming: None,
output_active: true,
total_frames: bytes / 1000,
dropped_frames: 0,
total_bytes: bytes,
lagged_frames: 0,
written_bytes: bytes,
disk_available_bytes: Some(u64::MAX),
}
}
#[test]
fn low_disk_space_fails_before_the_disk_is_full() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 1000, SEGMENT);
assert_eq!(
tracker.sample(&healthy(5000), start),
&RecordingStatus::Recording
);
let low = Health {
disk_available_bytes: Some(999),
..healthy(9000)
};
assert!(
matches!(tracker.sample(&low, start), RecordingStatus::Failed { reason } if reason.contains("disk space"))
);
}
#[test]
fn footage_must_reach_disk_not_just_the_muxer() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
let unwritten = Health {
written_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 + tracker.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, 0, SEGMENT);
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, 0, SEGMENT);
tracker.sample(&healthy(5000), start);
let later = start + tracker.stall_limit();
assert!(matches!(
tracker.sample(&healthy(5000), later),
RecordingStatus::Failed { .. }
));
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
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 a_stopped_pipewire_stream_is_a_lost_source_despite_its_size() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
let streaming = Health {
source_streaming: Some(true),
..healthy(5000)
};
assert_eq!(
tracker.sample(&streaming, start),
&RecordingStatus::Recording
);
let paused = Health {
source_streaming: Some(false),
..healthy(9000)
};
assert!(matches!(
tracker.sample(&paused, start),
RecordingStatus::Failed { .. }
));
}
#[test]
fn output_errors_fail_and_clean_stops_go_idle() {
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
assert!(matches!(
tracker.stopped(StopReason::NoSpace, Some("disk full")),
RecordingStatus::Failed { reason } if reason.contains("disk full")
));
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
assert_eq!(
tracker.stopped(StopReason::Success, None),
&RecordingStatus::Idle
);
}
#[test]
fn stopping_after_a_failure_keeps_the_failure() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
tracker.sample(&healthy(5000), start);
let lost = Health {
source_width: 0,
..healthy(6000)
};
tracker.sample(&lost, start);
assert!(matches!(
tracker.stopped(StopReason::Success, None),
RecordingStatus::Failed { .. }
));
}
#[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));
}
}
+200
View File
@@ -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")
);
}
}
}
+67
View File
@@ -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());
}
}
+16
View File
@@ -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
+56
View File
@@ -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();
}
}
+528
View File
@@ -0,0 +1,528 @@
//! 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]
//! [--min-free-mb MB] [--out DIR]
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--out DIR]
//!
//! `check` inspects the segments `record` wrote, using the ffprobe and ffmpeg
//! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`).
use std::{
env,
path::{Path, PathBuf},
process::{Command, 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),
"check" => check(&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|check [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,
min_free_mb: u64,
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,
min_free_mb: StatusTracker::DEFAULT_MIN_FREE_BYTES / 1_000_000,
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,
"--min-free-mb" => options.min_free_mb = number(value()?)? as u64,
"--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,
options.min_free_mb * 1_000_000,
Duration::from_secs(options.segment_seconds as u64),
);
let mut status = RecordingStatus::Starting;
let mut recorded = false;
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{} streaming={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
elapsed(),
health.source_width,
health.source_height,
health.source_streaming,
health.output_active,
health.total_frames,
health.dropped_frames,
health.lagged_frames,
health.total_bytes,
health.written_bytes
);
report_status(&mut status, tracker.sample(&health, now), &elapsed());
recorded |= status == RecordingStatus::Recording;
}
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}");
}
println!("recorded={recorded}");
Ok(recorded
&& outcome == Some(StopReason::Success)
&& !matches!(status, RecordingStatus::Failed { .. }))
}
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()),
}
}
}
/// Checks the recorded segments: each starts with a keyframe in the chosen
/// codec and lasts about one segment length, and consecutive segments remux
/// into one MP4 across their boundaries without re-encoding and decode cleanly.
fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
let dir = options.out.join("session");
let mut segments: Vec<PathBuf> = std::fs::read_dir(&dir)?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| {
path.extension()
.is_some_and(|ext| ext == clipbin_obs::SEGMENT_EXTENSION)
})
.collect();
segments.sort();
println!("segments={}", segments.len());
if segments.len() < 3 {
println!("check=failed need at least three segments");
return Ok(false);
}
let expected_codec = codec_key(options.codec);
let mut ok = true;
let last = segments.len() - 1;
for (index, segment) in segments.iter().enumerate() {
let name = segment.file_name().unwrap_or_default().to_string_lossy();
let codec = ffprobe(
segment,
&[
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
],
)?;
let audio_tracks = ffprobe(
segment,
&["-select_streams", "a", "-show_entries", "stream=index"],
)?
.lines()
.count();
let duration: f64 = ffprobe(segment, &["-show_entries", "format=duration"])?
.parse()
.unwrap_or(0.0);
let first_flags = ffprobe(
segment,
&[
"-select_streams",
"v:0",
"-read_intervals",
"%+#1",
"-show_entries",
"packet=flags",
],
)?;
let starts_on_keyframe = first_flags.starts_with('K');
// The final segment is cut short by the stop request.
let full_length = index == last || duration >= options.segment_seconds as f64 * 0.9;
let segment_ok =
codec == expected_codec && starts_on_keyframe && full_length && audio_tracks > 0;
ok &= segment_ok;
println!(
"segment={name} ok={segment_ok} codec={codec} keyframe_start={starts_on_keyframe} duration={duration:.3} audio_tracks={audio_tracks}"
);
}
// A clip spanning the boundaries between the first three segments.
let list = options.out.join("concat.txt");
let entries: String = segments[..3]
.iter()
.map(|segment| {
format!(
"file '{}'\n",
segment.to_string_lossy().replace('\'', "'\\''")
)
})
.collect();
std::fs::write(&list, entries)?;
let clip = options.out.join("cross-boundary-clip.mp4");
let remux = Command::new("ffmpeg")
.args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i"])
.arg(&list)
.args(["-map", "0", "-c", "copy", "-movflags", "+faststart"])
.arg(&clip)
.status()?;
let clip_codec = ffprobe(
&clip,
&[
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
],
)?;
let clip_duration: f64 = ffprobe(&clip, &["-show_entries", "format=duration"])?
.parse()
.unwrap_or(0.0);
let decode = Command::new("ffmpeg")
.args(["-v", "error", "-xerror", "-i"])
.arg(&clip)
.args(["-f", "null", "-"])
.output()?;
let decode_errors = String::from_utf8_lossy(&decode.stderr).lines().count();
let clip_ok = remux.success()
&& clip_codec == expected_codec
&& decode.status.success()
&& decode_errors == 0;
ok &= clip_ok;
println!(
"clip ok={clip_ok} codec={clip_codec} duration={clip_duration:.3} decode_errors={decode_errors} path={}",
clip.display()
);
println!("check={}", if ok { "ok" } else { "failed" });
Ok(ok)
}
fn ffprobe(path: &Path, query: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("ffprobe")
.args(["-v", "error"])
.args(query)
.args(["-of", "default=noprint_wrappers=1:nokey=1"])
.arg(path)
.output()
.map_err(|error| format!("could not run ffprobe (is FFmpeg installed?): {error}"))?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
+117
View File
@@ -0,0 +1,117 @@
# Recording foundation validation
The first recording milestone proves the OBS capture, encoder and segment
pipeline before the UI is built (SPEC.md, "Implementation validation before
building out the UI"). `clipbin-probe` drives the real `clipbin-obs` engine and
prints `key=value` lines; the scripts in `scripts/validate/` turn them into
pass/fail results.
| Where | Script | Status |
| --- | --- | --- |
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 9 scenarios pass on devbox (Intel UHD 770, VA-API) |
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Not run yet |
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
## Probe commands
```sh
cargo run -p clipbin-probe -- encoders --verify # hardware encoders per codec, proven by encoding
cargo run -p clipbin-probe -- record --target monitor # records; prints status, health and segments
cargo run -p clipbin-probe -- check # keyframes, codec, durations, cross-boundary remux
```
`record` targets `monitor`, `window` or `game` (Game Capture on Windows; the
same as `window` elsewhere). On Windows and X11, `--window` takes OBS's window
identifier; on Wayland the portal picker chooses and `--restore-token` reuses
an earlier choice. See the top of `crates/clipbin-probe/src/main.rs` for all
options. `check` needs FFmpeg's `ffprobe` and `ffmpeg` on `PATH`.
## Linux headless rig
`scripts/rig/headless.sh [--x11] <command>` runs a command inside a private
D-Bus session with PipeWire, a virtual audio sink, headless Sway on the GPU,
xdg-desktop-portal-wlr (auto-selecting the only output) and a 60 FPS test
pattern with a tone. `--x11` runs the game and command on Sway's Xwayland.
Arch packages: `sway xdg-desktop-portal xdg-desktop-portal-wlr xorg-xwayland
xorg-xwininfo pipewire pipewire-pulse wireplumber libpulse ffmpeg` plus a VA-API
driver (`intel-media-driver` or `libva-mesa-driver`).
The suite covers:
1. Encoder verification: H.264 must be usable; HEVC and AV1 are reported.
2. Wayland monitor capture in H.264 and HEVC: segments start on keyframes,
last 2 s, remux to MP4 across boundaries, decode cleanly, carry the tone.
3. X11 window capture (Xcomposite).
4. Failures that must end in `status=failed`: PipeWire stream lost, captured
X11 window closed, segment directory unwritable, free space below the margin.
5. A portal that grants no usable source never reports `Recording`.
## Linux hardware sign-off
Run `scripts/validate/linux-hardware.sh` from the Hyprland session. It verifies
encoders at 1440p60, records the monitor with each usable codec (the first run
shows the portal picker; later runs pass the restore token and ask whether the
picker stayed away), records a window, and asks you to close a window
mid-recording. Send back `report.txt` from the output directory.
## Windows
Run `scripts/validate/windows.ps1 -GameWindow "<title>:<class>:<exe>"` with a
game running. It assembles the official OBS 32.0.4 runtime, checks the Game
Capture hook signatures, builds the probe, verifies NVENC/AMF/QSV encoders and
records Monitor, Game and Window Capture.
Windows status:
- `clipbin-core`, `clipbin-obs` and `clipbin-probe` pass `cargo clippy` for
`x86_64-pc-windows-msvc` (cross-checked from Linux). Nothing has been linked
or run on Windows.
- The GPUI desktop crate has not been checked for Windows; its `ring`
dependency needs the MSVC C toolchain.
- The assembled runtime keeps the six Game Capture hook binaries byte-identical
to the release, with their Authenticode signatures present; signature
validity has not been checked on Windows.
- Unvalidated: encoder ids and settings for NVENC, AMF and QSV; Game Capture
hooking and its loss detection (the engine expects the source size to drop
to 0); WASAPI audio; `obs-ffmpeg-mux.exe` beside the executable; linking
`dstr_vprintf` through the libobs crate's `obs.lib`.
## Findings for the next milestones
- **Plugin loading.** libobs always adds default plugin paths: the build-time
install prefix on Linux and `../../obs-plugins/64bit` relative to the
working directory on Windows. Searching would load other OBS installations'
plugins or load ours twice, so the engine opens exactly the bundled
allowlist (`runtime::PLUGINS`) by path.
- **Segment timestamps.** The FFmpeg muxer restarts each segment's video and
audio timestamps at zero independently. Concatenating segments naively leaves
22–43 ms video gaps at boundaries. Video is constant frame rate and AAC
packets are continuous, so a segment's exact session offset is the count of
earlier frames and audio packets; the A/V offset stays within one AAC packet
(≤21 ms) and does not drift. The timeline and export work must place
segments this way.
- **Availability.** With 1 s keyframes and 2 s segments, splits happen on
schedule every 2.0 s, so the newest complete footage is about 2 s behind
plus finalisation: inside the spec's 2–5 s window.
- **Honest status needs more than OBS's counters.** OBS kept counting bytes
into a dead muxer process, and once reported a clean stop after the disk
filled. Status therefore requires footage to grow on disk, and recording
stops while 1 GB remains so the last segment can be finalised.
- **PipeWire source loss.** OBS's PipeWire capture keeps its size and renders
its last frame after the stream stops, and only logs the stream state. The
engine reads that log line (stable for the pinned OBS version). A more robust
follow-up is watching the PipeWire node state directly.
- **Audio capture failures are not detected yet.** When the PulseAudio server
was unreachable, recording continued with a silent track.
- **Linux runtime dependencies.** The Linux OBS build links the system's
FFmpeg, PipeWire and other libraries, so it is tied to the distribution it
was built on. Packaging (for example Flatpak, or bundling FFmpeg) is an open
decision; OBS 32.0 needs mbedtls 3 (`mbedtls3` on Arch).
- **OBS objects.** Scenes and sources are created private (OBS 31+ otherwise
registers scenes with the main canvas, which keeps them alive), unknown
source types fail instead of becoming placeholders, and shutdown drains
libobs's destroy queue first.
- **Plugin configuration.** libobs is started without a module config path;
set one under the app's config directory before plugins that save settings
are added.
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Assemble the pinned OBS runtime for Windows x64 from the official release zip.
#
# Files are copied byte-for-byte from the OBS release so the signed Game
# Capture hook (graphics-hook64.dll, inject-helper, get-graphics-offsets) keeps
# its original signature. Runs on Linux or Git Bash; needs curl, unzip and
# sha256sum.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=versions.env
source "${script_dir}/versions.env"
cache_dir="${CLIPBIN_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/clipbin}"
zip_name="OBS-Studio-${OBS_VERSION}-Windows-x64.zip"
zip_path="${cache_dir}/${zip_name}"
extract_dir="${cache_dir}/obs-windows-${OBS_VERSION}"
bundle_dir="${CLIPBIN_OBS_RUNTIME:-${script_dir}/../../target/obs-runtime/windows-x86_64}"
# Official plugins the recorder uses: Game/Window Capture, WASAPI audio,
# hardware encoders (AMF lives in obs-ffmpeg) and the FFmpeg muxer.
plugins=(win-capture win-wasapi obs-ffmpeg obs-nvenc obs-qsv11)
# OBS front-end, scripting and software-encoder files Clipbin does not use.
# Subdirectories of bin/64bit are Qt plugins and are skipped too.
excluded_bin='^(obs64\.exe|obs-frontend-api\.dll|obs-scripting\.dll|lua51\.dll|Qt6.*\.dll|datachannel\.dll|libx264-.*\.dll|.*\.pdb)$'
mkdir -p "${cache_dir}"
if [[ ! -f "${zip_path}" ]]; then
curl -fL --retry 3 -o "${zip_path}.partial" \
"https://github.com/obsproject/obs-studio/releases/download/${OBS_VERSION}/${zip_name}"
mv "${zip_path}.partial" "${zip_path}"
fi
echo "${OBS_WINDOWS_X64_SHA256} ${zip_path}" | sha256sum --check --quiet
rm -rf "${extract_dir}"
unzip -q "${zip_path}" -d "${extract_dir}"
# Keep the OBS install layout: OBS looks for obs-ffmpeg-mux.exe next to the
# running executable, so Clipbin's executable lives in bin/64bit.
rm -rf "${bundle_dir}"
mkdir -p "${bundle_dir}/bin/64bit" "${bundle_dir}/obs-plugins/64bit" "${bundle_dir}/data/obs-plugins"
for file in "${extract_dir}"/bin/64bit/*; do
name="$(basename "${file}")"
if [[ -f "${file}" && ! "${name}" =~ ${excluded_bin} ]]; then
cp -a "${file}" "${bundle_dir}/bin/64bit/"
fi
done
cp -a "${extract_dir}/data/libobs" "${bundle_dir}/data/libobs"
for plugin in "${plugins[@]}"; do
cp -a "${extract_dir}/obs-plugins/64bit/${plugin}.dll" "${bundle_dir}/obs-plugins/64bit/"
if [[ -d "${extract_dir}/data/obs-plugins/${plugin}" ]]; then
cp -a "${extract_dir}/data/obs-plugins/${plugin}" "${bundle_dir}/data/obs-plugins/"
fi
done
find "${bundle_dir}" -name '*.pdb' -delete
# Record the signed capture-hook files so a Windows check can verify them
# against Get-AuthenticodeSignature before packaging.
(cd "${bundle_dir}" && sha256sum data/obs-plugins/win-capture/*.exe data/obs-plugins/win-capture/*.dll) \
> "${bundle_dir}/SIGNED-HOOKS.sha256"
cat > "${bundle_dir}/VERSION" <<EOF
obs=${OBS_VERSION}
source=${zip_name}
sha256=${OBS_WINDOWS_X64_SHA256}
plugins=${plugins[*]}
EOF
echo "OBS ${OBS_VERSION} Windows runtime assembled in ${bundle_dir}"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Build the pinned OBS runtime for Linux and assemble the bundle Clipbin ships.
#
# Only libobs and an allowlist of official OBS plugins enter the bundle. The
# Windows bundle uses the official OBS release binaries instead (see
# scripts/obs/fetch-windows.ps1) so the signed Game Capture hooks stay intact.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=versions.env
source "${script_dir}/versions.env"
cache_dir="${CLIPBIN_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/clipbin}"
source_dir="${cache_dir}/obs-studio-${OBS_VERSION}"
build_dir="${cache_dir}/obs-build-${OBS_VERSION}"
stage_dir="${cache_dir}/obs-stage-${OBS_VERSION}"
bundle_dir="${CLIPBIN_OBS_RUNTIME:-${script_dir}/../../target/obs-runtime/linux-x86_64}"
# Official plugins the recorder uses: capture, audio, hardware encoders and the
# FFmpeg muxer. obs-outputs (RTMP, native MP4) is built but not shipped.
plugins=(linux-capture linux-pipewire linux-pulseaudio obs-ffmpeg obs-nvenc)
# OBS 32.0 does not build against mbedtls 4. Arch ships 3.x as mbedtls3.
extra_cmake_args=()
if [[ -d /usr/lib/mbedtls3/cmake/MbedTLS ]]; then
extra_cmake_args+=(-DMbedTLS_DIR=/usr/lib/mbedtls3/cmake/MbedTLS)
fi
if [[ ! -d "${source_dir}" ]]; then
git clone --depth 1 --branch "${OBS_VERSION}" --recurse-submodules --shallow-submodules \
https://github.com/obsproject/obs-studio.git "${source_dir}"
fi
actual_commit="$(git -C "${source_dir}" rev-parse HEAD)"
if [[ "${actual_commit}" != "${OBS_COMMIT}" ]]; then
echo "OBS source is at ${actual_commit}, expected ${OBS_COMMIT} (${OBS_VERSION})" >&2
exit 1
fi
cmake -S "${source_dir}" -B "${build_dir}" -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_INSTALL_PREFIX="${stage_dir}" \
-DENABLE_RELOCATABLE=ON \
-DENABLE_PORTABLE_CONFIG=ON \
-DENABLE_FRONTEND=OFF \
-DENABLE_UI=OFF \
-DENABLE_BROWSER=OFF \
-DENABLE_WEBSOCKET=OFF \
-DENABLE_WEBRTC=OFF \
-DENABLE_SCRIPTING=OFF \
-DENABLE_VLC=OFF \
-DENABLE_AJA=OFF \
-DENABLE_DECKLINK=OFF \
-DENABLE_VST=OFF \
-DENABLE_QSV11=OFF \
-DENABLE_LIBFDK=OFF \
-DENABLE_JACK=OFF \
-DENABLE_SNDIO=OFF \
-DENABLE_OSS=OFF \
-DENABLE_V4L2=OFF \
-DENABLE_VIRTUALCAM=OFF \
-DENABLE_NEW_MPEGTS_OUTPUT=OFF \
-DENABLE_PIPEWIRE=ON \
-DENABLE_WAYLAND=ON \
-DENABLE_PULSEAUDIO=ON \
-DENABLE_NVENC=ON \
-DENABLE_HEVC=ON \
"${extra_cmake_args[@]}"
cmake --build "${build_dir}"
rm -rf "${stage_dir}"
cmake --install "${build_dir}"
# The bundle keeps OBS's relocatable install layout: binaries in bin/ (OBS
# looks for obs-ffmpeg-mux next to the running executable), libraries in lib/,
# and plugins in lib/obs-plugins/ so their $ORIGIN/.. runpath resolves libobs.
rm -rf "${bundle_dir}"
mkdir -p "${bundle_dir}/bin" "${bundle_dir}/lib/obs-plugins" "${bundle_dir}/lib/pkgconfig" \
"${bundle_dir}/share/obs/obs-plugins"
cp -a "${stage_dir}/bin/obs-ffmpeg-mux" "${bundle_dir}/bin/"
cp -a "${stage_dir}"/lib/libobs.so* "${stage_dir}"/lib/libobs-opengl.so* "${bundle_dir}/lib/"
cp -a "${stage_dir}/share/obs/libobs" "${bundle_dir}/share/obs/libobs"
for plugin in "${plugins[@]}"; do
cp -a "${stage_dir}/lib/obs-plugins/${plugin}.so" "${bundle_dir}/lib/obs-plugins/"
if [[ -d "${stage_dir}/share/obs/obs-plugins/${plugin}" ]]; then
cp -a "${stage_dir}/share/obs/obs-plugins/${plugin}" "${bundle_dir}/share/obs/obs-plugins/"
fi
done
# Lets the libobs crate link against this runtime instead of a system OBS.
sed -e 's|^prefix=.*|prefix=${pcfiledir}/../..|' \
"${stage_dir}/lib/pkgconfig/libobs.pc" > "${bundle_dir}/lib/pkgconfig/libobs.pc"
cat > "${bundle_dir}/VERSION" <<EOF
obs=${OBS_VERSION}
commit=${OBS_COMMIT}
plugins=${plugins[*]}
EOF
echo "OBS ${OBS_VERSION} runtime assembled in ${bundle_dir}"
+5
View File
@@ -0,0 +1,5 @@
# The tested OBS version set. The libobs crate's headers must match OBS_VERSION.
OBS_VERSION=32.0.4
OBS_COMMIT=dcdbd2e9048ae66237fdab32dbba72b745b1b9c5
# Official release zip; the digest is the one GitHub publishes for the asset.
OBS_WINDOWS_X64_SHA256=fd367a7f156319426f1a600720c1288ad0011da168dff41e0f94dbb18cc1dc0f
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Run a command inside a disposable headless desktop for capture tests.
#
# scripts/rig/headless.sh [--x11] <command> [args...]
#
# Starts a private D-Bus session with PipeWire, WirePlumber, a virtual audio
# sink, headless Sway on the GPU and xdg-desktop-portal-wlr (the portal
# auto-selects the only output). With --x11, the game and the command run as
# X11 clients of Sway's Xwayland instead, which has GPU rendering (DRI3) and an
# EWMH window manager like a real X11 desktop. A 60 FPS test pattern with a
# 440 Hz tone stands in for a game. Everything is torn down when the command
# exits.
#
# The command sees CLIPBIN_RIG_DIR (logs, plus game.pid and xdpw.pid for the
# test game and the portal backend) and, with --x11, CLIPBIN_RIG_GAME_WINDOW
# (the test window's X id). Set CLIPBIN_RIG_KEEP_LOGS=<dir> to keep the logs.
#
# This exercises the capture paths on a machine without a display. It does not
# replace validation on real hardware (Hyprland, an X11 desktop, Windows).
set -euo pipefail
mode=wayland
if [[ "${1:-}" == "--x11" ]]; then
mode=x11
shift
fi
if [[ $# -eq 0 ]]; then
echo "usage: $0 [--x11] <command> [args...]" >&2
exit 2
fi
if [[ -z "${CLIPBIN_RIG_INNER:-}" ]]; then
args=("$@")
[[ "${mode}" == x11 ]] && args=(--x11 "${args[@]}")
exec env CLIPBIN_RIG_INNER=1 dbus-run-session -- "$0" "${args[@]}"
fi
rig_dir="$(mktemp -d "${TMPDIR:-/tmp}/clipbin-rig.XXXXXX")"
pids=()
cleanup() {
for pid in "${pids[@]}"; do kill "${pid}" 2>/dev/null || true; done
wait 2>/dev/null || true
# The D-Bus-activated document portal mounts a FUSE filesystem here.
fusermount3 -u "${XDG_RUNTIME_DIR}/doc" 2>/dev/null || true
rm -rf "${rig_dir}"
}
trap cleanup EXIT
export XDG_RUNTIME_DIR="${rig_dir}/runtime"
export XDG_CONFIG_HOME="${rig_dir}/config"
export XDG_CURRENT_DESKTOP=sway XDG_SESSION_TYPE=wayland
mkdir -p "${XDG_RUNTIME_DIR}" "${XDG_CONFIG_HOME}/sway" "${XDG_CONFIG_HOME}/xdg-desktop-portal-wlr"
chmod 700 "${XDG_RUNTIME_DIR}"
unset WAYLAND_DISPLAY DISPLAY
width="${CLIPBIN_RIG_WIDTH:-1920}"
height="${CLIPBIN_RIG_HEIGHT:-1080}"
wait_for() {
local what="$1" check="$2"
for _ in $(seq 100); do
if eval "${check}"; then return 0; fi
sleep 0.1
done
echo "rig: ${what} did not start; logs in ${rig_dir}" >&2
exit 1
}
pipewire > "${rig_dir}/pipewire.log" 2>&1 & pids+=($!)
wait_for pipewire '[[ -S "${XDG_RUNTIME_DIR}/pipewire-0" ]]'
wireplumber > "${rig_dir}/wireplumber.log" 2>&1 & pids+=($!)
pipewire-pulse > "${rig_dir}/pipewire-pulse.log" 2>&1 & pids+=($!)
wait_for pipewire-pulse '[[ -S "${XDG_RUNTIME_DIR}/pulse/native" ]]'
# No sound card here: route playback to a virtual sink whose monitor OBS
# records as desktop audio.
pactl load-module module-null-sink sink_name=clipbin-rig-sink > /dev/null
pactl set-default-sink clipbin-rig-sink
printf '%s\n' \
"output HEADLESS-1 resolution ${width}x${height}@60Hz" \
"default_border none" \
"xwayland force" \
"exec echo \"\$DISPLAY\" > ${rig_dir}/xwayland-display" \
> "${XDG_CONFIG_HOME}/sway/config"
printf '%s\n' \
"[screencast]" \
"chooser_type=none" \
"output_name=${CLIPBIN_RIG_PORTAL_OUTPUT:-HEADLESS-1}" \
"max_fps=60" \
> "${XDG_CONFIG_HOME}/xdg-desktop-portal-wlr/config"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_HEADLESS_OUTPUTS=1 \
sway --config "${XDG_CONFIG_HOME}/sway/config" > "${rig_dir}/sway.log" 2>&1 & pids+=($!)
wait_for sway 'compgen -G "${XDG_RUNTIME_DIR}/wayland-*" > /dev/null'
WAYLAND_DISPLAY="$(basename "$(compgen -G "${XDG_RUNTIME_DIR}/wayland-*" | grep -v lock | head -1)")"
SWAYSOCK="$(compgen -G "${XDG_RUNTIME_DIR}/sway-ipc.*" | head -1)"
export WAYLAND_DISPLAY SWAYSOCK
dbus-update-activation-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_RUNTIME_DIR XDG_CONFIG_HOME SWAYSOCK
/usr/lib/xdg-desktop-portal-wlr > "${rig_dir}/xdpw.log" 2>&1 & pids+=($!)
echo "${pids[-1]}" > "${rig_dir}/xdpw.pid"
/usr/lib/xdg-desktop-portal -r > "${rig_dir}/xdp.log" 2>&1 & pids+=($!)
video_driver=wayland
if [[ "${mode}" == x11 ]]; then
wait_for Xwayland '[[ -s "${rig_dir}/xwayland-display" ]]'
DISPLAY="$(cat "${rig_dir}/xwayland-display")"
export DISPLAY XDG_SESSION_TYPE=x11
wait_for Xwayland 'xwininfo -root > /dev/null 2>&1'
unset WAYLAND_DISPLAY
video_driver=x11
fi
# A moving 60 FPS picture with a 440 Hz tone, standing in for a game.
SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \
-f lavfi "testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0];sine=frequency=440:sample_rate=48000[out1]" \
> "${rig_dir}/game.log" 2>&1 & pids+=($!)
echo "${pids[-1]}" > "${rig_dir}/game.pid"
if [[ "${mode}" == x11 ]]; then
wait_for "the test window" 'xwininfo -name clipbin-test-game > /dev/null 2>&1'
CLIPBIN_RIG_GAME_WINDOW="$(xwininfo -name clipbin-test-game | awk '/Window id:/ { print strtonum($4) }')"
export CLIPBIN_RIG_GAME_WINDOW
fi
sleep 1
export CLIPBIN_RIG_DIR="${rig_dir}"
echo "rig: ready (${mode}, logs in ${rig_dir})" >&2
status=0
"$@" || status=$?
if [[ -n "${CLIPBIN_RIG_KEEP_LOGS:-}" ]]; then
mkdir -p "${CLIPBIN_RIG_KEEP_LOGS}" && cp "${rig_dir}"/*.log "${CLIPBIN_RIG_KEEP_LOGS}/"
fi
exit "${status}"
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Hardware sign-off for Linux: run from a real desktop session (Hyprland,
# another Wayland compositor, or X11) on the machine being validated.
#
# scripts/validate/linux-hardware.sh [output-dir]
#
# Records at 1440p60 with every usable hardware codec, checks the segments,
# confirms the portal restores its selection without asking again, and
# records a window. Writes report.txt in the output directory.
set -uo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${TMPDIR:-/tmp}/clipbin-hardware}"
probe="${repo}/target/debug/clipbin-probe"
report="${out}/report.txt"
rm -rf "${out}" && mkdir -p "${out}"
format=(--width 2560 --height 1440 --fps 60 --bitrate 40000)
cargo build --locked -p clipbin-probe --manifest-path "${repo}/Cargo.toml" || exit 1
log() { echo "$*" | tee -a "${report}"; }
ask() {
local answer
read -r -p "$1 [y/n] " answer
log "answer: $1 -> ${answer}"
}
log "Clipbin hardware validation $(date -Is)"
log "host: $(uname -r) session=${XDG_SESSION_TYPE:-?} desktop=${XDG_CURRENT_DESKTOP:-?}"
log "gpu: $(lspci 2>/dev/null | grep -iE 'vga|display' | cut -d: -f3- | xargs)"
"${probe}" encoders --verify "${format[@]}" --out "${out}/encoders" > "${out}/encoders.log" 2> "${out}/encoders.obs.log"
log "$(grep -E '^(obs_version|usable\.|verify\.)' "${out}/encoders.log")"
token=""
for codec in h264 hevc av1; do
if grep -q "^usable\.${codec}=none" "${out}/encoders.log"; then
log "SKIP ${codec}: no usable hardware encoder"
continue
fi
if [[ -z "${token}" ]]; then
echo
echo "Recording a monitor for 20 seconds with ${codec}. The portal will ask"
echo "you to pick a monitor: pick one and keep some motion on screen."
fi
dir="${out}/monitor-${codec}"
restore=()
[[ -n "${token}" ]] && restore=(--restore-token "${token}")
"${probe}" record --target monitor --codec "${codec}" "${restore[@]}" --seconds 20 "${format[@]}" \
--out "${dir}" > "${dir}.log" 2> "${dir}.obs.log"
record_exit=$?
"${probe}" check --codec "${codec}" --out "${dir}" >> "${dir}.log" 2>&1
check_exit=$?
log "monitor-${codec}: record_exit=${record_exit} check_exit=${check_exit} $(grep -E '^(encoder|recorded|check)=' "${dir}.log" | tr '\n' ' ')"
log "$(grep -E 'status=' "${dir}.log" | tr '\n' ' ')"
if [[ -z "${token}" ]]; then
token="$(sed -n 's/^restore_token=//p' "${dir}.log")"
log "restore token received: $([[ -n "${token}" ]] && echo yes || echo no)"
else
ask "Did ${codec} start WITHOUT showing the portal picker again?"
fi
done
echo
echo "Now recording a window for 20 seconds. Start a game (or any moving"
echo "window) first, then pick it in the portal."
read -r -p "Press Enter when ready. " _
dir="${out}/window-h264"
"${probe}" record --target window --seconds 20 "${format[@]}" --out "${dir}" > "${dir}.log" 2> "${dir}.obs.log"
record_exit=$?
"${probe}" check --out "${dir}" >> "${dir}.log" 2>&1
log "window-h264: record_exit=${record_exit} check_exit=$? $(grep -E '^(recorded|check)=' "${dir}.log" | tr '\n' ' ')"
echo
echo "Last: source loss. Record a window again, then close that window"
echo "about 5 seconds in. The probe should report a failure."
read -r -p "Press Enter when ready. " _
dir="${out}/window-closed"
"${probe}" record --target window --seconds 20 "${format[@]}" --out "${dir}" > "${dir}.log" 2> "${dir}.obs.log"
log "window-closed: exit=$? $(grep -E 'status=failed' "${dir}.log" | head -1)"
echo
echo "Report: ${report}"
echo "Recordings and logs: ${out}"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Runs the recording validation scenarios in the headless rig and reports
# PASS/FAIL for each. Needs the Linux OBS runtime (scripts/obs/build-linux.sh),
# a VA-API or NVENC capable GPU, and the rig packages listed in
# docs/validation.md.
#
# scripts/validate/linux-rig.sh [output-dir]
set -uo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-${TMPDIR:-/tmp}/clipbin-validation}"
rig="${repo}/scripts/rig/headless.sh"
probe="${repo}/target/debug/clipbin-probe"
rm -rf "${out}" && mkdir -p "${out}"
cargo build --locked -p clipbin-probe --manifest-path "${repo}/Cargo.toml" || exit 1
failures=0
result() {
local name="$1" ok="$2" detail="${3:-}"
if [[ "${ok}" == 0 ]]; then
echo "PASS ${name} ${detail}"
else
echo "FAIL ${name} ${detail} (log: ${out}/${name}.log)"
failures=$((failures + 1))
fi
}
# has <log> <pattern>: the probe printed a line matching pattern.
has() { grep -qE "$2" "$1"; }
# 1. Hardware encoders: H.264 must be usable; HEVC and AV1 are reported.
log="${out}/encoders.log"
"${rig}" "${probe}" encoders --verify --out "${out}/encoders" > "${log}" 2> "${out}/encoders.obs.log"
result encoders "$?" "$(grep -E '^usable\.' "${log}" | tr '\n' ' ')"
# 2. Wayland monitor capture through the portal: segments, remux, audio.
for codec in h264 hevc; do
if has "${log}" "^usable\.${codec}=none"; then
echo "SKIP wayland-${codec} (no ${codec} hardware encoder)"
continue
fi
name="wayland-${codec}"
dir="${out}/${name}"
"${rig}" "${probe}" record --codec "${codec}" --seconds 9 --out "${dir}" > "${out}/${name}.log" 2>&1 \
&& "${probe}" check --codec "${codec}" --out "${dir}" >> "${out}/${name}.log" 2>&1
ok=$?
volume="$(ffmpeg -v info -i "${dir}/session/segment-000001.mkv" -map 0:a -af volumedetect -f null - 2>&1 \
| awk '/max_volume/ { print $5 }')"
if [[ "${ok}" == 0 ]] && awk -v v="${volume:--999}" 'BEGIN { exit !(v > -40) }'; then ok=0; else ok=1; fi
result "${name}" "${ok}" "audio_max_volume=${volume:-none}dB"
done
# 3. X11 window capture through Xcomposite (Xwayland in the rig).
name=x11-window
"${rig}" --x11 bash -c "'${probe}' record --target window --window \"\${CLIPBIN_RIG_GAME_WINDOW}\" --seconds 9 --out '${out}/${name}'" \
> "${out}/${name}.log" 2>&1 \
&& "${probe}" check --out "${out}/${name}" >> "${out}/${name}.log" 2>&1
result "${name}" "$?"
# expect_failure <name> <reason pattern>: recording started, then failed honestly.
expect_failure() {
local name="$1" reason="$2" log="${out}/$1.log"
if [[ "${probe_exit}" != 0 ]] && has "${log}" "status=Recording" && has "${log}" "status=failed reason=${reason}"; then
result "${name}" 0 "$(grep -m1 -o 'status=failed reason=.*' "${log}")"
else
result "${name}" 1
fi
}
# 4. The PipeWire stream ends mid-recording (portal backend exits).
name=wayland-source-lost
"${rig}" bash -c "'${probe}' record --seconds 15 --out '${out}/${name}' & p=\$!; sleep 5; kill \$(cat \"\${CLIPBIN_RIG_DIR}/xdpw.pid\"); wait \$p" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
expect_failure "${name}" "the capture source stopped"
# 5. The captured X11 window closes mid-recording.
name=x11-window-closed
"${rig}" --x11 bash -c "'${probe}' record --target window --window \"\${CLIPBIN_RIG_GAME_WINDOW}\" --seconds 15 --out '${out}/${name}' & p=\$!; sleep 5; kill \$(cat \"\${CLIPBIN_RIG_DIR}/game.pid\"); wait \$p" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
expect_failure "${name}" "the capture source stopped"
# 6. Segments can no longer be written (directory made read-only).
name=write-failure
"${rig}" bash -c "'${probe}' record --seconds 20 --out '${out}/${name}' & p=\$!; sleep 4; chmod 500 '${out}/${name}/session'; wait \$p" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
chmod 700 "${out}/${name}/session" 2>/dev/null
expect_failure "${name}" "(no footage was written|Error)"
# 7. Free space falls below the safety margin.
name=low-disk-space
"${rig}" bash -c "'${probe}' record --seconds 10 --out '${out}/${name}' --min-free-mb \$(( \$(df --output=avail -B 1000000 '${out}' | tail -1) + 1 ))" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
if [[ "${probe_exit}" != 0 ]] && has "${out}/${name}.log" "status=failed reason=not enough disk space" \
&& ! has "${out}/${name}.log" "status=Recording"; then
result "${name}" 0 "$(grep -m1 -o 'status=failed reason=.*' "${out}/${name}.log")"
else
result "${name}" 1
fi
# 8. The portal grants nothing usable: recording never starts or claims to.
name=portal-no-source
CLIPBIN_RIG_PORTAL_OUTPUT=NO-SUCH-OUTPUT "${rig}" "${probe}" record --seconds 8 --out "${out}/${name}" \
> "${out}/${name}.log" 2>&1
probe_exit=$?
if [[ "${probe_exit}" != 0 ]] && ! has "${out}/${name}.log" "status=Recording"; then
result "${name}" 0 "$(grep 'status=' "${out}/${name}.log" | tail -1)"
else
result "${name}" 1
fi
echo
if [[ "${failures}" == 0 ]]; then
echo "All scenarios passed. Logs in ${out}"
else
echo "${failures} scenario(s) failed. Logs in ${out}"
fi
exit $(( failures > 0 ))
+80
View File
@@ -0,0 +1,80 @@
# Hardware validation for Windows. Not yet run on Windows: it is the first
# job for the Windows implementor, who should fix what it finds.
#
# powershell -ExecutionPolicy Bypass -File scripts\validate\windows.ps1 [-Out <dir>] [-GameWindow "<title>:<class>:<exe>"]
#
# Needs: Rust (rustup, MSVC toolchain), Visual Studio Build Tools (C++),
# Git for Windows (its bash runs scripts/obs/assemble-windows.sh) and FFmpeg
# on PATH for the segment checks (winget install Gyan.FFmpeg).
#
# -GameWindow is OBS's window identifier for a running game, as shown in OBS
# Studio's Game Capture "Window" list: "<title>:<window class>:<executable>".
param(
[string]$Out = "$env:TEMP\clipbin-windows",
[string]$GameWindow = ""
)
$ErrorActionPreference = "Stop"
$repo = Resolve-Path "$PSScriptRoot\..\.."
$report = Join-Path $Out "report.txt"
Remove-Item -Recurse -Force $Out -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $Out | Out-Null
function Log($text) { $text | Tee-Object -FilePath $report -Append }
Log "Clipbin Windows validation $(Get-Date -Format o)"
Log "os: $((Get-CimInstance Win32_OperatingSystem).Caption) $([Environment]::OSVersion.Version)"
Log "gpu: $((Get-CimInstance Win32_VideoController).Name -join ', ')"
# 1. The pinned official OBS runtime, with the Game Capture hook still signed.
& bash "$repo/scripts/obs/assemble-windows.sh"
if ($LASTEXITCODE -ne 0) { throw "assembling the OBS runtime failed" }
$runtime = Join-Path $repo "target\obs-runtime\windows-x86_64"
Get-ChildItem "$runtime\data\obs-plugins\win-capture\*" -Include *.exe, *.dll | ForEach-Object {
$signature = Get-AuthenticodeSignature $_.FullName
Log ("signature {0}: {1} ({2})" -f $_.Name, $signature.Status, $signature.SignerCertificate.Subject)
}
# 2. Build the probe (also checks the Windows code paths compile and link).
Push-Location $repo
cargo build --locked -p clipbin-probe
if ($LASTEXITCODE -ne 0) { throw "building clipbin-probe failed" }
Pop-Location
$probe = Join-Path $repo "target\debug\clipbin-probe.exe"
$format = @("--width", "2560", "--height", "1440", "--fps", "60", "--bitrate", "40000")
function Record($name, $codec, [string[]]$arguments) {
$dir = Join-Path $Out $name
& $probe record @arguments --codec $codec @format --seconds 20 --out $dir > "$dir.log" 2> "$dir.obs.log"
$recordExit = $LASTEXITCODE
& $probe check --codec $codec --out $dir >> "$dir.log" 2>&1
$checkExit = $LASTEXITCODE
Log "${name}: record_exit=$recordExit check_exit=$checkExit $((Select-String -Path "$dir.log" -Pattern '^(encoder|recorded|check)=').Line -join ' ')"
Log ((Select-String -Path "$dir.log" -Pattern 'status=').Line -join ' ')
}
# 3. Hardware encoders: NVENC, AMF or QSV for H.264, HEVC and AV1.
& $probe encoders --verify @format --out (Join-Path $Out "encoders") > "$Out\encoders.log" 2> "$Out\encoders.obs.log"
Log ((Select-String -Path "$Out\encoders.log" -Pattern '^(obs_version|usable\.|verify\.)').Line -join "`n")
# 4. Monitor capture with each usable codec.
foreach ($codec in "h264", "hevc", "av1") {
if (Select-String -Path "$Out\encoders.log" -Pattern "^usable\.$codec=none" -Quiet) {
Log "SKIP ${codec}: no usable hardware encoder"
continue
}
Record "monitor-$codec" $codec @("--target", "monitor")
}
# 5. Game Capture (signed hook) and Window Capture of a running game.
if ($GameWindow) {
Record "game-capture" "h264" @("--target", "game", "--window", $GameWindow)
Record "window-capture" "h264" @("--target", "window", "--window", $GameWindow)
Write-Host "Last: start a recording, then quit the game about 5 seconds in."
Read-Host "Press Enter when ready"
$dir = Join-Path $Out "game-closed"
& $probe record --target game --window $GameWindow @format --seconds 20 --out $dir > "$dir.log" 2> "$dir.obs.log"
Log "game-closed: exit=$LASTEXITCODE $((Select-String -Path "$dir.log" -Pattern 'status=failed').Line | Select-Object -First 1)"
} else {
Log "SKIP game capture: pass -GameWindow with a running game's window identifier"
}
Write-Host "Report: $report"