feat: keep Clipbin recording in the tray
Add native Linux SNI and Windows notification-area trays, single-instance reopening, background startup and login entries. Preserve recording and discovery after close, with clean Quit and reachable no-tray fallbacks. Track capture-source audio delivery independently of silent output tracks and warn without stopping video. Give clip metadata its own row and shape labels at final bounds to avoid GPUI 0.2.2 truncation cache and text-run bugs. Validated with strict workspace checks, 57 tests, Windows core/OBS/probe cross-clippy, all ten capture scenarios, tray/session/clips/playback rigs and narrow/wide screenshots. Document runtime validation gaps.
This commit is contained in:
@@ -13,6 +13,7 @@ use std::{
|
||||
atomic::{AtomicU8, AtomicU64, Ordering},
|
||||
mpsc::Sender,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use libobs as sys;
|
||||
@@ -611,6 +612,26 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
|
||||
stem.strip_prefix("segment-")?.parse().ok()
|
||||
}
|
||||
|
||||
struct AudioClock {
|
||||
started: Instant,
|
||||
last_millis: AtomicU64,
|
||||
}
|
||||
|
||||
unsafe extern "C" fn audio_captured(
|
||||
param: *mut c_void,
|
||||
_: *mut sys::obs_source_t,
|
||||
_: *const sys::audio_data,
|
||||
_: bool,
|
||||
) {
|
||||
// SAFETY: each callback holds the stable AudioClock allocation until removal;
|
||||
// OBS serialises removal against callbacks with source->audio_cb_mutex.
|
||||
let clock = unsafe { &*param.cast::<AudioClock>() };
|
||||
clock.last_millis.store(
|
||||
clock.started.elapsed().as_millis() as u64,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
/// The live OBS objects for one recording. Capture starts at once; encoders
|
||||
/// and the segment output are created only when the source has a picture,
|
||||
/// so nothing is written while the user picks a source or capture is down.
|
||||
@@ -622,6 +643,7 @@ pub(crate) struct Pipeline {
|
||||
sources: Vec<(Option<u32>, *mut sys::obs_source_t)>,
|
||||
video_source: *mut sys::obs_source_t,
|
||||
microphone: bool,
|
||||
audio_clocks: Vec<(*mut sys::obs_source_t, Box<AudioClock>)>,
|
||||
pending: Mutex<Option<OutputSpec>>,
|
||||
output: Mutex<Option<Output>>,
|
||||
}
|
||||
@@ -648,6 +670,7 @@ impl Pipeline {
|
||||
sources: Vec::new(),
|
||||
video_source: ptr::null_mut(),
|
||||
microphone: capture.microphone.is_some(),
|
||||
audio_clocks: Vec::new(),
|
||||
pending: Mutex::new(None),
|
||||
output: Mutex::new(None),
|
||||
name_prefix: name_prefix.clone(),
|
||||
@@ -672,6 +695,16 @@ impl Pipeline {
|
||||
if let Some(source_spec) = source_spec {
|
||||
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
|
||||
pipeline.sources.push((Some(channel), source));
|
||||
let mut clock = Box::new(AudioClock {
|
||||
started: Instant::now(),
|
||||
last_millis: AtomicU64::new(0),
|
||||
});
|
||||
sys::obs_source_add_audio_capture_callback(
|
||||
source,
|
||||
Some(audio_captured),
|
||||
(&mut *clock as *mut AudioClock).cast(),
|
||||
);
|
||||
pipeline.audio_clocks.push((source, clock));
|
||||
let mixers = tracks
|
||||
.iter()
|
||||
.filter(|t| **t == track || **t == COMBINED_TRACK)
|
||||
@@ -769,6 +802,11 @@ impl Pipeline {
|
||||
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
|
||||
written_bytes: 0,
|
||||
disk_available_bytes: None,
|
||||
audio_lost: self.audio_clocks.iter().any(|(_, clock)| {
|
||||
(clock.started.elapsed().as_millis() as u64)
|
||||
.saturating_sub(clock.last_millis.load(Ordering::Relaxed))
|
||||
>= 5_000
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,6 +835,13 @@ impl Drop for Pipeline {
|
||||
drop(self.output.get_mut().ok().and_then(Option::take));
|
||||
// SAFETY: runs on the OBS thread (Recording drops the Pipeline there).
|
||||
unsafe {
|
||||
for (source, clock) in &mut self.audio_clocks {
|
||||
sys::obs_source_remove_audio_capture_callback(
|
||||
*source,
|
||||
Some(audio_captured),
|
||||
(&mut **clock as *mut AudioClock).cast(),
|
||||
);
|
||||
}
|
||||
sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut());
|
||||
for (channel, source) in &self.sources {
|
||||
if let Some(channel) = channel {
|
||||
|
||||
@@ -304,6 +304,8 @@ pub struct Health {
|
||||
/// PipeWire capture's portal and stream state. `None` for other sources,
|
||||
/// which report loss through their size, and before the portal answers.
|
||||
pub pipewire: Option<PipeWireState>,
|
||||
/// A configured capture source has delivered no audio for five seconds.
|
||||
pub audio_lost: bool,
|
||||
pub output_active: bool,
|
||||
pub total_frames: u64,
|
||||
pub dropped_frames: u64,
|
||||
@@ -409,17 +411,27 @@ impl StatusTracker {
|
||||
(_, true, true)
|
||||
if health.output_active && health.total_frames > 0 && self.last_written > 0 =>
|
||||
{
|
||||
RecordingStatus::Recording
|
||||
if health.audio_lost {
|
||||
RecordingStatus::Degraded {
|
||||
reason: "audio capture lost".into(),
|
||||
}
|
||||
} else {
|
||||
RecordingStatus::Recording
|
||||
}
|
||||
}
|
||||
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, false, _) => {
|
||||
RecordingStatus::Failed {
|
||||
reason: "the capture source stopped providing a picture".into(),
|
||||
}
|
||||
}
|
||||
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, true, false) => {
|
||||
RecordingStatus::Failed {
|
||||
reason: format!(
|
||||
"no footage was written for {} seconds",
|
||||
self.stall_limit.as_secs()
|
||||
),
|
||||
}
|
||||
}
|
||||
(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(),
|
||||
};
|
||||
@@ -511,10 +523,33 @@ mod tests {
|
||||
total_bytes: bytes,
|
||||
lagged_frames: 0,
|
||||
written_bytes: bytes,
|
||||
audio_lost: false,
|
||||
disk_available_bytes: Some(u64::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_loss_degrades_and_recovers_without_masking_video_loss() {
|
||||
let now = Instant::now();
|
||||
let mut tracker = StatusTracker::new(now, 0, SEGMENT);
|
||||
let mut health = healthy(10_000);
|
||||
assert_eq!(tracker.sample(&health, now), &RecordingStatus::Recording);
|
||||
health.audio_lost = true;
|
||||
assert!(matches!(
|
||||
tracker.sample(&health, now),
|
||||
RecordingStatus::Degraded { .. }
|
||||
));
|
||||
health.audio_lost = false;
|
||||
assert_eq!(tracker.sample(&health, now), &RecordingStatus::Recording);
|
||||
health.audio_lost = true;
|
||||
tracker.sample(&health, now);
|
||||
health.source_width = 0;
|
||||
assert!(matches!(
|
||||
tracker.sample(&health, now),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_disk_space_fails_before_the_disk_is_full() {
|
||||
let start = Instant::now();
|
||||
|
||||
Reference in New Issue
Block a user