fix: fail on refused portal requests and catch black recordings
Rust checks / windows-cross-check (push) Failing after 40s
Rust checks / linux (push) Failing after 5m38s

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:
2026-10-08 15:01:25 +02:00
parent 91f119e6b6
commit 06377de707
7 changed files with 208 additions and 83 deletions
+53 -32
View File
@@ -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]
+2 -2
View File
@@ -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};
+46 -7
View File
@@ -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);
+27 -3
View File
@@ -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
View File
@@ -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
+40 -31
View File
@@ -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}"
+7 -6
View File
@@ -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