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.
This commit is contained in:
2026-10-08 14:25:23 +02:00
parent 263e7a964e
commit 444758f9ec
4 changed files with 142 additions and 75 deletions
+60 -36
View File
@@ -137,6 +137,7 @@ impl Engine {
pipeline: Some(pipeline),
events,
current_segment,
written: Mutex::default(),
segment_dir: config.segment_dir,
};
recording
@@ -178,9 +179,17 @@ pub struct Recording {
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,
@@ -198,22 +207,29 @@ impl Recording {
&self.events
}
/// OBS's counters plus the size of the segment being written. 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.
/// 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 segment = self
let current = self
.current_segment
.lock()
.ok()
.and_then(|current| current.clone());
if let Some(path) = segment {
health.current_segment_bytes =
std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
health.current_segment = Some(path);
.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.clone();
}
health.written_bytes = written.finished_bytes + current.as_deref().map_or(0, size);
}
health.current_segment = current;
health
}
@@ -263,7 +279,8 @@ pub struct Health {
pub total_bytes: u64,
pub lagged_frames: u64,
pub current_segment: Option<String>,
pub current_segment_bytes: 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>,
}
@@ -277,27 +294,34 @@ pub struct Health {
pub struct StatusTracker {
status: RecordingStatus,
min_free_bytes: u64,
last_written: (Option<String>, u64),
stall_limit: Duration,
last_written: u64,
last_progress: Instant,
}
impl StatusTracker {
/// How long footage may stop arriving before recording counts as failed.
pub const STALL_LIMIT: Duration = Duration::from_secs(3);
/// 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;
pub fn new(now: Instant, min_free_bytes: u64) -> Self {
/// 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,
last_written: (None, 0),
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
}
@@ -306,17 +330,15 @@ impl StatusTracker {
if matches!(self.status, RecordingStatus::Failed { .. }) {
return &self.status;
}
// Progress means the segment on disk grew or a new one began.
let written = (health.current_segment.clone(), health.current_segment_bytes);
let progressed = written.1 > 0 && written != self.last_written;
let progressed = health.written_bytes > self.last_written;
if progressed {
self.last_written = written;
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;
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
if let Some(available) = health.disk_available_bytes
&& available < self.min_free_bytes
{
@@ -332,7 +354,7 @@ impl StatusTracker {
self.status = match (&self.status, has_picture, progressed || !stalled) {
(_, true, true)
if health.output_active && health.total_frames > 0 && self.last_written.1 > 0 =>
if health.output_active && health.total_frames > 0 && self.last_written > 0 =>
{
RecordingStatus::Recording
}
@@ -342,7 +364,7 @@ impl StatusTracker {
(RecordingStatus::Recording, true, false) => RecordingStatus::Failed {
reason: format!(
"no footage was written for {} seconds",
Self::STALL_LIMIT.as_secs()
self.stall_limit.as_secs()
),
},
(_, false, _) => RecordingStatus::WaitingForCapture,
@@ -423,6 +445,8 @@ impl std::error::Error for EngineError {}
mod tests {
use super::*;
const SEGMENT: Duration = Duration::from_secs(2);
fn healthy(bytes: u64) -> Health {
Health {
source_width: 1920,
@@ -434,7 +458,7 @@ mod tests {
total_bytes: bytes,
lagged_frames: 0,
current_segment: Some("segment-000000.mkv".into()),
current_segment_bytes: bytes,
written_bytes: bytes,
disk_available_bytes: Some(u64::MAX),
}
}
@@ -442,7 +466,7 @@ mod tests {
#[test]
fn low_disk_space_fails_before_the_disk_is_full() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 1000);
let mut tracker = StatusTracker::new(start, 1000, SEGMENT);
assert_eq!(
tracker.sample(&healthy(5000), start),
&RecordingStatus::Recording
@@ -459,9 +483,9 @@ mod tests {
#[test]
fn footage_must_reach_disk_not_just_the_muxer() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0);
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
let unwritten = Health {
current_segment_bytes: 0,
written_bytes: 0,
..healthy(5000)
};
assert_ne!(
@@ -477,7 +501,7 @@ mod tests {
total_bytes: 90_000,
..healthy(5000)
};
let later = start + StatusTracker::STALL_LIMIT;
let later = start + tracker.stall_limit();
assert!(matches!(
tracker.sample(&stuck, later),
RecordingStatus::Failed { .. }
@@ -487,7 +511,7 @@ mod tests {
#[test]
fn recording_needs_a_picture_and_written_footage() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0);
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
let no_picture = Health {
source_width: 0,
source_height: 0,
@@ -510,15 +534,15 @@ mod tests {
#[test]
fn a_stall_or_lost_source_fails_instead_of_pausing() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0);
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
tracker.sample(&healthy(5000), start);
let later = start + StatusTracker::STALL_LIMIT;
let later = start + tracker.stall_limit();
assert!(matches!(
tracker.sample(&healthy(5000), later),
RecordingStatus::Failed { .. }
));
let mut tracker = StatusTracker::new(start, 0);
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
tracker.sample(&healthy(5000), start);
let lost = Health {
source_width: 0,
@@ -538,7 +562,7 @@ mod tests {
#[test]
fn a_stopped_pipewire_stream_is_a_lost_source_despite_its_size() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0);
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
let streaming = Health {
source_streaming: Some(true),
..healthy(5000)
@@ -559,12 +583,12 @@ mod tests {
#[test]
fn output_errors_fail_and_clean_stops_go_idle() {
let mut tracker = StatusTracker::new(Instant::now(), 0);
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);
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
assert_eq!(
tracker.stopped(StopReason::Success, None),
&RecordingStatus::Idle
@@ -574,7 +598,7 @@ mod tests {
#[test]
fn stopping_after_a_failure_keeps_the_failure() {
let start = Instant::now();
let mut tracker = StatusTracker::new(start, 0);
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
tracker.sample(&healthy(5000), start);
let lost = Health {
source_width: 0,