fix: fail on refused portal requests and catch black recordings
The portal reports refusals only in OBS's log; a denied, cancelled or failed source selection now fails with an actionable reason instead of waiting forever. check --expect-motion rejects black or frozen footage, and the hardware script checks only runs that recorded, tests restore tokens in their own step, and treats closing a window as informational: on Hyprland the stream keeps its last frame, so sessions must end on game exit.
This commit is contained in:
@@ -19,7 +19,7 @@ use libobs as sys;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
capture::{SettingValue, SourceSpec},
|
capture::{SettingValue, SourceSpec},
|
||||||
recorder::{Health, RecorderEvent},
|
recorder::{Health, PipeWireState, RecorderEvent},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn cstring(value: &str) -> CString {
|
fn cstring(value: &str) -> CString {
|
||||||
@@ -149,13 +149,8 @@ unsafe extern "C" fn log_to_stderr(
|
|||||||
_ => "debug",
|
_ => "debug",
|
||||||
};
|
};
|
||||||
if let Some(text) = owned(message.array) {
|
if let Some(text) = owned(message.array) {
|
||||||
if let Some(state) = pipewire_stream_state(&text) {
|
if let Some(state) = pipewire_state(&text) {
|
||||||
let code = if state == "streaming" {
|
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
|
||||||
STREAMING
|
|
||||||
} else {
|
|
||||||
NOT_STREAMING
|
|
||||||
};
|
|
||||||
PIPEWIRE_STREAM.store(code, Ordering::Relaxed);
|
|
||||||
}
|
}
|
||||||
eprintln!("obs {label}: {text}");
|
eprintln!("obs {label}: {text}");
|
||||||
}
|
}
|
||||||
@@ -163,22 +158,38 @@ unsafe extern "C" fn log_to_stderr(
|
|||||||
unsafe { sys::bfree(message.array.cast()) };
|
unsafe { sys::bfree(message.array.cast()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The state of the PipeWire capture stream, as last logged by OBS.
|
/// The PipeWire capture state, as last logged by OBS (0 = nothing logged).
|
||||||
///
|
///
|
||||||
/// OBS 32.0.4's PipeWire capture keeps reporting its last size and rendering
|
/// 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
|
/// its last frame after its stream stops, and reports portal refusals and
|
||||||
/// through this log line. Clipbin records one capture source at a time.
|
/// stream states only in its log (linux-pipewire's screencast-portal.c and
|
||||||
static PIPEWIRE_STREAM: AtomicU8 = AtomicU8::new(NO_PIPEWIRE_STREAM);
|
/// pipewire.c). Clipbin records one capture source at a time.
|
||||||
const NO_PIPEWIRE_STREAM: u8 = 0;
|
static PIPEWIRE_STATE: AtomicU8 = AtomicU8::new(0);
|
||||||
const STREAMING: u8 = 1;
|
|
||||||
const NOT_STREAMING: u8 = 2;
|
|
||||||
|
|
||||||
/// Parses `[pipewire] Stream 0x... state: "streaming" (error: none)` from
|
fn pipewire_state(message: &str) -> Option<PipeWireState> {
|
||||||
/// linux-pipewire's on_state_changed_cb.
|
if let Some(rest) = message.strip_prefix("[pipewire] Stream ") {
|
||||||
fn pipewire_stream_state(message: &str) -> Option<&str> {
|
// `[pipewire] Stream 0x... state: "streaming" (error: none)`
|
||||||
let rest = message.strip_prefix("[pipewire] Stream ")?;
|
let state = rest.split_once(" state: \"")?.1.split_once('"')?.0;
|
||||||
let state = rest.split_once(" state: \"")?.1;
|
return Some(if state == "streaming" {
|
||||||
state.split_once('"').map(|(state, _)| state)
|
PipeWireState::Streaming
|
||||||
|
} else {
|
||||||
|
PipeWireState::Stopped
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The user denied or cancelled the portal request, or it failed.
|
||||||
|
let refused = message.starts_with("[pipewire] Error ")
|
||||||
|
|| (message.starts_with("[pipewire] Failed to ")
|
||||||
|
&& message.ends_with("denied or cancelled by user"));
|
||||||
|
refused.then_some(PipeWireState::Refused)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_pipewire_state() -> Option<PipeWireState> {
|
||||||
|
match PIPEWIRE_STATE.load(Ordering::Relaxed) {
|
||||||
|
state if state == PipeWireState::Streaming as u8 => Some(PipeWireState::Streaming),
|
||||||
|
state if state == PipeWireState::Stopped as u8 => Some(PipeWireState::Stopped),
|
||||||
|
state if state == PipeWireState::Refused as u8 => Some(PipeWireState::Refused),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
|
pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
|
||||||
@@ -568,7 +579,7 @@ impl Pipeline {
|
|||||||
events: Sender<RecorderEvent>,
|
events: Sender<RecorderEvent>,
|
||||||
current_segment: Arc<Mutex<Option<String>>>,
|
current_segment: Arc<Mutex<Option<String>>>,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
PIPEWIRE_STREAM.store(NO_PIPEWIRE_STREAM, Ordering::Relaxed);
|
PIPEWIRE_STATE.store(0, Ordering::Relaxed);
|
||||||
// OBS releases objects asynchronously, so names must not be reused.
|
// OBS releases objects asynchronously, so names must not be reused.
|
||||||
static RECORDINGS: AtomicU64 = AtomicU64::new(0);
|
static RECORDINGS: AtomicU64 = AtomicU64::new(0);
|
||||||
let id = RECORDINGS.fetch_add(1, Ordering::Relaxed);
|
let id = RECORDINGS.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -745,14 +756,10 @@ impl Pipeline {
|
|||||||
sys::obs_source_get_height(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 {
|
Health {
|
||||||
source_width: width,
|
source_width: width,
|
||||||
source_height: height,
|
source_height: height,
|
||||||
source_streaming,
|
pipewire: load_pipewire_state(),
|
||||||
output_active: sys::obs_output_active(self.output),
|
output_active: sys::obs_output_active(self.output),
|
||||||
total_frames: sys::obs_output_get_total_frames(self.output).max(0) as u64,
|
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,
|
dropped_frames: sys::obs_output_get_frames_dropped(self.output).max(0) as u64,
|
||||||
@@ -852,13 +859,27 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pipewire_stream_state_is_read_from_the_plugin_log() {
|
fn pipewire_state_is_read_from_the_plugin_log() {
|
||||||
let line = r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#;
|
let state = |line| pipewire_state(line);
|
||||||
assert_eq!(pipewire_stream_state(line), Some("paused"));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pipewire_stream_state("[pipewire] Screencast session created"),
|
state(r#"[pipewire] Stream 0x7fe128037440 state: "streaming" (error: none)"#),
|
||||||
None
|
Some(PipeWireState::Streaming)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state(r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#),
|
||||||
|
Some(PipeWireState::Stopped)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state("[pipewire] Failed to select source, denied or cancelled by user"),
|
||||||
|
Some(PipeWireState::Refused)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state("[pipewire] Error selecting screencast source: Timeout"),
|
||||||
|
Some(PipeWireState::Refused)
|
||||||
|
);
|
||||||
|
assert_eq!(state("[pipewire] Screencast session created"), None);
|
||||||
|
// OBS cancelling its own pending request at teardown is not a refusal.
|
||||||
|
assert_eq!(state("[portals] Request cancelled"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub mod runtime;
|
|||||||
mod thread;
|
mod thread;
|
||||||
|
|
||||||
pub use recorder::{
|
pub use recorder::{
|
||||||
Engine, EngineError, Health, RecorderEvent, Recording, RecordingConfig, SEGMENT_EXTENSION,
|
Engine, EngineError, Health, PipeWireState, RecorderEvent, Recording, RecordingConfig,
|
||||||
StatusTracker, StopReason, VideoFormat,
|
SEGMENT_EXTENSION, StatusTracker, StopReason, VideoFormat,
|
||||||
};
|
};
|
||||||
pub use runtime::{RuntimeError, RuntimeLayout};
|
pub use runtime::{RuntimeError, RuntimeLayout};
|
||||||
|
|||||||
@@ -270,9 +270,9 @@ pub enum RecorderEvent {
|
|||||||
pub struct Health {
|
pub struct Health {
|
||||||
pub source_width: u32,
|
pub source_width: u32,
|
||||||
pub source_height: u32,
|
pub source_height: u32,
|
||||||
/// For PipeWire capture, whether its stream is delivering frames. `None`
|
/// PipeWire capture's portal and stream state. `None` for other sources,
|
||||||
/// for sources that report loss through their size instead.
|
/// which report loss through their size, and before the portal answers.
|
||||||
pub source_streaming: Option<bool>,
|
pub pipewire: Option<PipeWireState>,
|
||||||
pub output_active: bool,
|
pub output_active: bool,
|
||||||
pub total_frames: u64,
|
pub total_frames: u64,
|
||||||
pub dropped_frames: u64,
|
pub dropped_frames: u64,
|
||||||
@@ -284,6 +284,15 @@ pub struct Health {
|
|||||||
pub disk_available_bytes: Option<u64>,
|
pub disk_available_bytes: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PipeWireState {
|
||||||
|
Streaming = 1,
|
||||||
|
/// The stream exists but is not delivering frames.
|
||||||
|
Stopped = 2,
|
||||||
|
/// The portal request was denied, cancelled by the user, or failed.
|
||||||
|
Refused = 3,
|
||||||
|
}
|
||||||
|
|
||||||
/// Derives the status shown to the user from health samples and output events.
|
/// 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
|
/// Recording is reported only while the source has a picture and encoded data
|
||||||
@@ -334,9 +343,16 @@ impl StatusTracker {
|
|||||||
self.last_written = health.written_bytes;
|
self.last_written = health.written_bytes;
|
||||||
self.last_progress = now;
|
self.last_progress = now;
|
||||||
}
|
}
|
||||||
|
if health.pipewire == Some(PipeWireState::Refused) {
|
||||||
|
self.status = RecordingStatus::Failed {
|
||||||
|
reason: "screen capture was not allowed: the source selection was denied, cancelled or failed"
|
||||||
|
.into(),
|
||||||
|
};
|
||||||
|
return &self.status;
|
||||||
|
}
|
||||||
let has_picture = health.source_width > 0
|
let has_picture = health.source_width > 0
|
||||||
&& health.source_height > 0
|
&& health.source_height > 0
|
||||||
&& health.source_streaming != Some(false);
|
&& matches!(health.pipewire, None | Some(PipeWireState::Streaming));
|
||||||
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
|
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
|
||||||
if let Some(available) = health.disk_available_bytes
|
if let Some(available) = health.disk_available_bytes
|
||||||
&& available < self.min_free_bytes
|
&& available < self.min_free_bytes
|
||||||
@@ -450,7 +466,7 @@ mod tests {
|
|||||||
Health {
|
Health {
|
||||||
source_width: 1920,
|
source_width: 1920,
|
||||||
source_height: 1080,
|
source_height: 1080,
|
||||||
source_streaming: None,
|
pipewire: None,
|
||||||
output_active: true,
|
output_active: true,
|
||||||
total_frames: bytes / 1000,
|
total_frames: bytes / 1000,
|
||||||
dropped_frames: 0,
|
dropped_frames: 0,
|
||||||
@@ -562,7 +578,7 @@ mod tests {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||||
let streaming = Health {
|
let streaming = Health {
|
||||||
source_streaming: Some(true),
|
pipewire: Some(PipeWireState::Streaming),
|
||||||
..healthy(5000)
|
..healthy(5000)
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -570,7 +586,7 @@ mod tests {
|
|||||||
&RecordingStatus::Recording
|
&RecordingStatus::Recording
|
||||||
);
|
);
|
||||||
let paused = Health {
|
let paused = Health {
|
||||||
source_streaming: Some(false),
|
pipewire: Some(PipeWireState::Stopped),
|
||||||
..healthy(9000)
|
..healthy(9000)
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -579,6 +595,29 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_portal_request_fails_while_waiting_for_capture() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||||
|
let waiting = Health {
|
||||||
|
source_width: 0,
|
||||||
|
source_height: 0,
|
||||||
|
..healthy(5000)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
tracker.sample(&waiting, start),
|
||||||
|
&RecordingStatus::WaitingForCapture
|
||||||
|
);
|
||||||
|
let refused = Health {
|
||||||
|
pipewire: Some(PipeWireState::Refused),
|
||||||
|
..waiting
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
tracker.sample(&refused, start),
|
||||||
|
RecordingStatus::Failed { reason } if reason.contains("not allowed")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn output_errors_fail_and_clean_stops_go_idle() {
|
fn output_errors_fail_and_clean_stops_go_idle() {
|
||||||
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
|
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS]
|
//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS]
|
||||||
//! [--width W --height H --fps F] [--no-audio] [--mic]
|
//! [--width W --height H --fps F] [--no-audio] [--mic]
|
||||||
//! [--min-free-mb MB] [--out DIR]
|
//! [--min-free-mb MB] [--out DIR]
|
||||||
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--out DIR]
|
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--expect-motion] [--out DIR]
|
||||||
//!
|
//!
|
||||||
//! `check` inspects the segments `record` wrote, using the ffprobe and ffmpeg
|
//! `check` inspects the segments `record` wrote, using the ffprobe and ffmpeg
|
||||||
//! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`).
|
//! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`).
|
||||||
@@ -63,6 +63,7 @@ fn usage(error: &str) -> ExitCode {
|
|||||||
|
|
||||||
struct Options {
|
struct Options {
|
||||||
verify: bool,
|
verify: bool,
|
||||||
|
expect_motion: bool,
|
||||||
target: String,
|
target: String,
|
||||||
window: String,
|
window: String,
|
||||||
restore_token: Option<String>,
|
restore_token: Option<String>,
|
||||||
@@ -83,6 +84,7 @@ impl Options {
|
|||||||
fn parse(args: &[String]) -> Result<Self, String> {
|
fn parse(args: &[String]) -> Result<Self, String> {
|
||||||
let mut options = Self {
|
let mut options = Self {
|
||||||
verify: false,
|
verify: false,
|
||||||
|
expect_motion: false,
|
||||||
target: "monitor".into(),
|
target: "monitor".into(),
|
||||||
window: String::new(),
|
window: String::new(),
|
||||||
restore_token: None,
|
restore_token: None,
|
||||||
@@ -112,6 +114,7 @@ impl Options {
|
|||||||
};
|
};
|
||||||
match flag.as_str() {
|
match flag.as_str() {
|
||||||
"--verify" => options.verify = true,
|
"--verify" => options.verify = true,
|
||||||
|
"--expect-motion" => options.expect_motion = true,
|
||||||
"--target" => options.target = value()?,
|
"--target" => options.target = value()?,
|
||||||
"--window" => options.window = value()?,
|
"--window" => options.window = value()?,
|
||||||
"--restore-token" => options.restore_token = Some(value()?),
|
"--restore-token" => options.restore_token = Some(value()?),
|
||||||
@@ -337,11 +340,11 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
next_sample += Duration::from_secs(1);
|
next_sample += Duration::from_secs(1);
|
||||||
let health = recording.health();
|
let health = recording.health();
|
||||||
println!(
|
println!(
|
||||||
"t={} health source={}x{} streaming={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
|
"t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
|
||||||
elapsed(),
|
elapsed(),
|
||||||
health.source_width,
|
health.source_width,
|
||||||
health.source_height,
|
health.source_height,
|
||||||
health.source_streaming,
|
health.pipewire,
|
||||||
health.output_active,
|
health.output_active,
|
||||||
health.total_frames,
|
health.total_frames,
|
||||||
health.dropped_frames,
|
health.dropped_frames,
|
||||||
@@ -466,6 +469,27 @@ fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A black or frozen capture still produces valid segments. When the
|
||||||
|
// captured content was moving, the middle segment must show it.
|
||||||
|
if options.expect_motion {
|
||||||
|
let middle = &segments[segments.len() / 2];
|
||||||
|
let distinct = Command::new("ffmpeg")
|
||||||
|
.args(["-v", "error", "-i"])
|
||||||
|
.arg(middle)
|
||||||
|
.args(["-map", "0:v:0", "-vf", "mpdecimate=max=0", "-f", "null", "-", "-stats_period", "100"])
|
||||||
|
.args(["-progress", "pipe:1"])
|
||||||
|
.output()?;
|
||||||
|
let frames = String::from_utf8_lossy(&distinct.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.strip_prefix("frame="))
|
||||||
|
.filter_map(|frames| frames.parse::<u64>().ok())
|
||||||
|
.last()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let moving = frames > 1;
|
||||||
|
ok &= moving;
|
||||||
|
println!("motion ok={moving} distinct_frames={frames} segment={}", middle.display());
|
||||||
|
}
|
||||||
|
|
||||||
// A clip spanning the boundaries between the first three segments.
|
// A clip spanning the boundaries between the first three segments.
|
||||||
let list = options.out.join("concat.txt");
|
let list = options.out.join("concat.txt");
|
||||||
let entries: String = segments[..3]
|
let entries: String = segments[..3]
|
||||||
|
|||||||
+33
-2
@@ -9,7 +9,7 @@ pass/fail results.
|
|||||||
| Where | Script | Status |
|
| 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, 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 |
|
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | First run 2026-10-08: H.264, HEVC and AV1 all usable; restore token still to confirm |
|
||||||
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
|
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
|
||||||
|
|
||||||
## Probe commands
|
## Probe commands
|
||||||
@@ -45,7 +45,11 @@ The suite covers:
|
|||||||
3. X11 window capture (Xcomposite).
|
3. X11 window capture (Xcomposite).
|
||||||
4. Failures that must end in `status=failed`: PipeWire stream lost, captured
|
4. Failures that must end in `status=failed`: PipeWire stream lost, captured
|
||||||
X11 window closed, segment directory unwritable, free space below the margin.
|
X11 window closed, segment directory unwritable, free space below the margin.
|
||||||
5. A portal that grants no usable source never reports `Recording`.
|
5. A portal that refuses the request fails with an actionable reason and
|
||||||
|
never reports `Recording`.
|
||||||
|
|
||||||
|
Segment checks run with `--expect-motion`, so black or frozen footage (which
|
||||||
|
still makes valid segments) fails.
|
||||||
|
|
||||||
## Linux hardware sign-off
|
## Linux hardware sign-off
|
||||||
|
|
||||||
@@ -55,6 +59,24 @@ 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
|
picker stayed away), records a window, and asks you to close a window
|
||||||
mid-recording. Send back `report.txt` from the output directory.
|
mid-recording. Send back `report.txt` from the output directory.
|
||||||
|
|
||||||
|
### First hardware run (2026-10-08)
|
||||||
|
|
||||||
|
CachyOS 7.2.9, Hyprland, RX 9070 XT, 1440p60, OBS 32.0.4:
|
||||||
|
|
||||||
|
- H.264, HEVC and AV1 all encode through VA-API (`*_ffmpeg_vaapi_tex`).
|
||||||
|
Monitor and window capture recorded keyframe-aligned 2 s segments that
|
||||||
|
remuxed across boundaries with no decode errors.
|
||||||
|
- The restore token came back on one of three runs, probably because the
|
||||||
|
"Allow a restore token" box in the picker was ticked only once. The script
|
||||||
|
now has a dedicated restore step.
|
||||||
|
- One run never got a portal selection: the picker either did not appear or
|
||||||
|
was not answered, and the probe stayed in `Waiting for capture` for 20 s
|
||||||
|
while writing black segments that `check` passed. `check --expect-motion`
|
||||||
|
now catches black footage; a refused request now fails at once.
|
||||||
|
- Closing the captured window (Super+W) is not visible to capture: xdph keeps
|
||||||
|
the PipeWire stream `streaming` and OBS repeats the window's last frame.
|
||||||
|
See findings.
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
Run `scripts/validate/windows.ps1 -GameWindow "<title>:<class>:<exe>"` with a
|
Run `scripts/validate/windows.ps1 -GameWindow "<title>:<class>:<exe>"` with a
|
||||||
@@ -102,6 +124,15 @@ Windows status:
|
|||||||
its last frame after the stream stops, and only logs the stream state. The
|
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
|
engine reads that log line (stable for the pinned OBS version). A more robust
|
||||||
follow-up is watching the PipeWire node state directly.
|
follow-up is watching the PipeWire node state directly.
|
||||||
|
- **Closed windows on Wayland.** With Hyprland's portal, closing a captured
|
||||||
|
window leaves the stream running on its last frame, with no state change or
|
||||||
|
error. Capture cannot tell this apart from a static window, so sessions must
|
||||||
|
end from game process exit (SPEC: "Stop/finalise the session when its game
|
||||||
|
exits"), which the Steam/process detection milestone provides.
|
||||||
|
- **Writing while waiting for capture.** The output starts before the source
|
||||||
|
has a picture, so a source that never arrives produces black segments. The
|
||||||
|
session timeline should show that time as missing footage, or the output
|
||||||
|
should start only once capture is usable.
|
||||||
- **Audio capture failures are not detected yet.** When the PulseAudio server
|
- **Audio capture failures are not detected yet.** When the PulseAudio server
|
||||||
was unreachable, recording continued with a silent track.
|
was unreachable, recording continued with a silent track.
|
||||||
- **Linux runtime dependencies.** The Linux OBS build links the system's
|
- **Linux runtime dependencies.** The Linux OBS build links the system's
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ ask() {
|
|||||||
log "answer: $1 -> ${answer}"
|
log "answer: $1 -> ${answer}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# record <name> <codec> [probe args...]: records 20 s, then checks the
|
||||||
|
# segments only if the probe reached Recording.
|
||||||
|
record() {
|
||||||
|
local name="$1" codec="$2" dir="${out}/$1"
|
||||||
|
shift 2
|
||||||
|
"${probe}" record --codec "${codec}" "$@" --seconds 20 "${format[@]}" --out "${dir}" \
|
||||||
|
> "${dir}.log" 2> "${dir}.obs.log"
|
||||||
|
local record_exit=$?
|
||||||
|
if grep -q '^recorded=true' "${dir}.log"; then
|
||||||
|
"${probe}" check --codec "${codec}" --expect-motion --out "${dir}" >> "${dir}.log" 2>&1
|
||||||
|
log "${name}: record_exit=${record_exit} check_exit=$? $(grep -E '^(encoder|check|motion)' "${dir}.log" | tr '\n' ' ')"
|
||||||
|
else
|
||||||
|
log "${name}: FAIL never reached Recording (record_exit=${record_exit})"
|
||||||
|
fi
|
||||||
|
log " $(grep -E 'status=' "${dir}.log" | tr '\n' ' ')"
|
||||||
|
}
|
||||||
|
|
||||||
log "Clipbin hardware validation $(date -Is)"
|
log "Clipbin hardware validation $(date -Is)"
|
||||||
log "host: $(uname -r) session=${XDG_SESSION_TYPE:-?} desktop=${XDG_CURRENT_DESKTOP:-?}"
|
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)"
|
log "gpu: $(lspci 2>/dev/null | grep -iE 'vga|display' | cut -d: -f3- | xargs)"
|
||||||
@@ -32,52 +49,44 @@ 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"
|
"${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")"
|
log "$(grep -E '^(obs_version|usable\.|verify\.)' "${out}/encoders.log")"
|
||||||
|
|
||||||
token=""
|
echo
|
||||||
|
echo "Each recording opens the screen-share picker: pick a monitor with motion on"
|
||||||
|
echo "it (a video or game). Tick \"Allow a restore token\" (Hyprland) or"
|
||||||
|
echo "\"Remember this selection\" when the picker offers it."
|
||||||
for codec in h264 hevc av1; do
|
for codec in h264 hevc av1; do
|
||||||
if grep -q "^usable\.${codec}=none" "${out}/encoders.log"; then
|
if grep -q "^usable\.${codec}=none" "${out}/encoders.log"; then
|
||||||
log "SKIP ${codec}: no usable hardware encoder"
|
log "SKIP ${codec}: no usable hardware encoder"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ -z "${token}" ]]; then
|
record "monitor-${codec}" "${codec}" --target monitor
|
||||||
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
|
done
|
||||||
|
|
||||||
|
# Restoring the last selection must not show the picker again.
|
||||||
|
token="$(sed -n 's/^restore_token=//p' "${out}"/monitor-*.log | tail -1)"
|
||||||
|
if [[ -n "${token}" ]]; then
|
||||||
|
echo
|
||||||
|
echo "Recording again with the saved selection. The picker should NOT appear."
|
||||||
|
record monitor-restored h264 --target monitor --restore-token "${token}"
|
||||||
|
ask "Did the recording start WITHOUT showing the picker?"
|
||||||
|
else
|
||||||
|
log "restore: FAIL no restore token was returned (was \"Allow a restore token\" ticked?)"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Now recording a window for 20 seconds. Start a game (or any moving"
|
echo "Now recording a window for 20 seconds. Start a game (or any moving"
|
||||||
echo "window) first, then pick it in the portal."
|
echo "window) first, then pick it in the picker."
|
||||||
read -r -p "Press Enter when ready. " _
|
read -r -p "Press Enter when ready. " _
|
||||||
dir="${out}/window-h264"
|
record window-h264 h264 --target window
|
||||||
"${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' ' ')"
|
|
||||||
|
|
||||||
|
# On Wayland the portal may keep streaming a closed window's last frame, so
|
||||||
|
# this is informational: Clipbin ends sessions when the game process exits.
|
||||||
echo
|
echo
|
||||||
echo "Last: source loss. Record a window again, then close that window"
|
echo "Last: record a window again and close it (quit the app) about 5 seconds in."
|
||||||
echo "about 5 seconds in. The probe should report a failure."
|
|
||||||
read -r -p "Press Enter when ready. " _
|
read -r -p "Press Enter when ready. " _
|
||||||
dir="${out}/window-closed"
|
dir="${out}/window-closed"
|
||||||
"${probe}" record --target window --seconds 20 "${format[@]}" --out "${dir}" > "${dir}.log" 2> "${dir}.obs.log"
|
"${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)"
|
log "window-closed (informational): exit=$? $(grep -E 'status=' "${dir}.log" | tail -1)"
|
||||||
|
ask "Did you quit the captured app about 5 seconds in?"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Report: ${report}"
|
echo "Report: ${report}"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ for codec in h264 hevc; do
|
|||||||
name="wayland-${codec}"
|
name="wayland-${codec}"
|
||||||
dir="${out}/${name}"
|
dir="${out}/${name}"
|
||||||
"${rig}" "${probe}" record --codec "${codec}" --seconds 9 --out "${dir}" > "${out}/${name}.log" 2>&1 \
|
"${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
|
&& "${probe}" check --codec "${codec}" --expect-motion --out "${dir}" >> "${out}/${name}.log" 2>&1
|
||||||
ok=$?
|
ok=$?
|
||||||
volume="$(ffmpeg -v info -i "${dir}/session/segment-000001.mkv" -map 0:a -af volumedetect -f null - 2>&1 \
|
volume="$(ffmpeg -v info -i "${dir}/session/segment-000001.mkv" -map 0:a -af volumedetect -f null - 2>&1 \
|
||||||
| awk '/max_volume/ { print $5 }')"
|
| awk '/max_volume/ { print $5 }')"
|
||||||
@@ -54,7 +54,7 @@ done
|
|||||||
name=x11-window
|
name=x11-window
|
||||||
"${rig}" --x11 bash -c "'${probe}' record --target window --window \"\${CLIPBIN_RIG_GAME_WINDOW}\" --seconds 9 --out '${out}/${name}'" \
|
"${rig}" --x11 bash -c "'${probe}' record --target window --window \"\${CLIPBIN_RIG_GAME_WINDOW}\" --seconds 9 --out '${out}/${name}'" \
|
||||||
> "${out}/${name}.log" 2>&1 \
|
> "${out}/${name}.log" 2>&1 \
|
||||||
&& "${probe}" check --out "${out}/${name}" >> "${out}/${name}.log" 2>&1
|
&& "${probe}" check --expect-motion --out "${out}/${name}" >> "${out}/${name}.log" 2>&1
|
||||||
result "${name}" "$?"
|
result "${name}" "$?"
|
||||||
|
|
||||||
# expect_failure <name> <reason pattern>: recording started, then failed honestly.
|
# expect_failure <name> <reason pattern>: recording started, then failed honestly.
|
||||||
@@ -101,13 +101,14 @@ else
|
|||||||
result "${name}" 1
|
result "${name}" 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 8. The portal grants nothing usable: recording never starts or claims to.
|
# 8. The portal refuses the request: an actionable failure, never Recording.
|
||||||
name=portal-no-source
|
name=portal-refused
|
||||||
CLIPBIN_RIG_PORTAL_OUTPUT=NO-SUCH-OUTPUT "${rig}" "${probe}" record --seconds 8 --out "${out}/${name}" \
|
CLIPBIN_RIG_PORTAL_OUTPUT=NO-SUCH-OUTPUT "${rig}" "${probe}" record --seconds 8 --out "${out}/${name}" \
|
||||||
> "${out}/${name}.log" 2>&1
|
> "${out}/${name}.log" 2>&1
|
||||||
probe_exit=$?
|
probe_exit=$?
|
||||||
if [[ "${probe_exit}" != 0 ]] && ! has "${out}/${name}.log" "status=Recording"; then
|
if [[ "${probe_exit}" != 0 ]] && ! has "${out}/${name}.log" "status=Recording" \
|
||||||
result "${name}" 0 "$(grep 'status=' "${out}/${name}.log" | tail -1)"
|
&& has "${out}/${name}.log" "status=failed reason=screen capture was not allowed"; then
|
||||||
|
result "${name}" 0 "$(grep -m1 -o 'status=failed reason=.*' "${out}/${name}.log")"
|
||||||
else
|
else
|
||||||
result "${name}" 1
|
result "${name}" 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user