Skip to main content

pinray_core/
session.rs

1use std::time::Duration;
2
3use tracing::{debug, instrument};
4
5use crate::{
6    audio::AudioFrame,
7    backend::{AudioBackend, BackendBundle, BackendInfo, BackendResolver, VideoBackend},
8    config::{AudioCapture, CursorMode, SessionConfig, VideoCaptureTarget},
9    error::{PinrayError, Result},
10    frame::{CaptureEvent, ColorSpace, PixelFormat, Rect},
11};
12
13pub struct CaptureSession {
14    config: SessionConfig,
15    backend_info: BackendInfo,
16    video_backend: Option<Box<dyn VideoBackend>>,
17    audio_backend: Option<Box<dyn AudioBackend>>,
18    running: bool,
19}
20
21impl CaptureSession {
22    pub fn new(config: SessionConfig, bundle: BackendBundle) -> Self {
23        Self {
24            config,
25            backend_info: bundle.info,
26            video_backend: bundle.video,
27            audio_backend: bundle.audio,
28            running: false,
29        }
30    }
31
32    pub fn config(&self) -> &SessionConfig {
33        &self.config
34    }
35
36    pub fn backend_info(&self) -> &BackendInfo {
37        &self.backend_info
38    }
39
40    pub fn is_running(&self) -> bool {
41        self.running
42    }
43
44    #[instrument(skip(self), fields(backend = ?self.backend_info.kind))]
45    pub fn start(&mut self) -> Result<()> {
46        if self.running {
47            return Ok(());
48        }
49
50        if let Some(video) = self.video_backend.as_mut() {
51            video.start()?;
52        }
53
54        if let Some(audio) = self.audio_backend.as_mut() {
55            audio.start()?;
56        }
57
58        self.running = true;
59        debug!("capture session started");
60        Ok(())
61    }
62
63    #[instrument(skip(self), fields(backend = ?self.backend_info.kind))]
64    pub fn stop(&mut self) -> Result<()> {
65        if !self.running {
66            return Ok(());
67        }
68
69        if let Some(audio) = self.audio_backend.as_mut() {
70            audio.stop()?;
71        }
72
73        if let Some(video) = self.video_backend.as_mut() {
74            video.stop()?;
75        }
76
77        self.running = false;
78        debug!("capture session stopped");
79        Ok(())
80    }
81
82    pub fn next_event(&mut self, timeout: Option<Duration>) -> Result<CaptureEvent> {
83        match (self.video_backend.as_mut(), self.audio_backend.as_mut()) {
84            (Some(video), None) => video.next_event(timeout),
85            (None, Some(audio)) => audio.next_audio(timeout).map(CaptureEvent::Audio),
86            (Some(video), Some(audio)) => {
87                // Drain pending audio first (non-blocking); otherwise a busy
88                // video stream would starve audio, since audio is only polled
89                // after a video timeout.
90                match audio.next_audio(Some(Duration::ZERO)) {
91                    Ok(frame) => return Ok(CaptureEvent::Audio(frame)),
92                    Err(PinrayError::Timeout(_)) => {}
93                    Err(error) => return Err(error),
94                }
95                match video.next_event(timeout) {
96                    Ok(event) => Ok(event),
97                    Err(PinrayError::Timeout(_)) => {
98                        audio.next_audio(timeout).map(CaptureEvent::Audio)
99                    }
100                    Err(error) => Err(error),
101                }
102            }
103            (None, None) => Err(PinrayError::BackendNotSelected),
104        }
105    }
106
107    pub fn next_audio(&mut self, timeout: Option<Duration>) -> Result<AudioFrame> {
108        let audio = self
109            .audio_backend
110            .as_mut()
111            .ok_or(PinrayError::BackendNotSelected)?;
112        audio.next_audio(timeout)
113    }
114}
115
116#[derive(Debug, Clone, Default)]
117pub struct SessionBuilder {
118    config: SessionConfig,
119}
120
121impl SessionBuilder {
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    pub fn config(&self) -> &SessionConfig {
127        &self.config
128    }
129
130    pub fn backend_preference(
131        mut self,
132        backend_preference: crate::backend::BackendPreference,
133    ) -> Self {
134        self.config.backend_preference = backend_preference;
135        self
136    }
137
138    pub fn video_target(mut self, target: VideoCaptureTarget) -> Self {
139        self.config.video_target = Some(target);
140        self
141    }
142
143    pub fn audio(mut self, audio_capture: AudioCapture) -> Self {
144        self.config.audio_capture = Some(audio_capture);
145        self
146    }
147
148    pub fn pixel_format(mut self, pixel_format: PixelFormat) -> Self {
149        self.config.pixel_format = pixel_format;
150        self
151    }
152
153    pub fn restore_token(mut self, restore_token: impl Into<String>) -> Self {
154        self.config.restore_token = Some(restore_token.into());
155        self
156    }
157
158    pub fn color_space(mut self, color_space: Option<ColorSpace>) -> Self {
159        self.config.color_space = color_space;
160        self
161    }
162
163    pub fn cursor_mode(mut self, cursor_mode: CursorMode) -> Self {
164        self.config.cursor_mode = cursor_mode;
165        self
166    }
167
168    pub fn crop_rect(mut self, crop_rect: Option<Rect>) -> Self {
169        self.config.crop_rect = crop_rect;
170        self
171    }
172
173    pub fn frame_rate(mut self, frame_rate: Option<u32>) -> Self {
174        self.config.frame_rate = frame_rate;
175        self
176    }
177
178    pub fn queue_depth(mut self, queue_depth: u32) -> Self {
179        self.config.queue_depth = queue_depth;
180        self
181    }
182
183    #[instrument(skip(self, resolver))]
184    pub fn build_with_resolver<R>(self, resolver: &R) -> Result<CaptureSession>
185    where
186        R: BackendResolver,
187    {
188        self.config.validate()?;
189        let bundle = resolver.resolve(&self.config)?;
190        Ok(CaptureSession::new(self.config, bundle))
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use std::collections::VecDeque;
197    use std::time::Duration;
198
199    use super::{CaptureSession, SessionBuilder};
200    use crate::{
201        audio::{AudioData, AudioFrame, SampleFormat},
202        backend::{AudioBackend, BackendBundle, BackendInfo, BackendKind, VideoBackend},
203        config::SessionConfig,
204        error::{PinrayError, Result},
205        frame::{CaptureEvent, FrameData, PixelFormat, VideoFrame},
206    };
207
208    #[test]
209    fn builder_requires_video_or_audio() {
210        let config = SessionBuilder::new().config().clone();
211        assert!(config.validate().is_err());
212    }
213
214    #[test]
215    fn queue_depth_must_be_positive() {
216        let config = SessionBuilder::new().queue_depth(0).config().clone();
217        assert!(config.validate().is_err());
218    }
219
220    fn mock_info() -> BackendInfo {
221        BackendInfo {
222            kind: BackendKind::LinuxWaylandPortal,
223            supports_audio: true,
224            zero_copy: false,
225            notes: "mock",
226        }
227    }
228
229    /// Video backend that always has a frame ready.
230    struct BusyVideo;
231
232    impl VideoBackend for BusyVideo {
233        fn info(&self) -> BackendInfo {
234            mock_info()
235        }
236        fn start(&mut self) -> Result<()> {
237            Ok(())
238        }
239        fn stop(&mut self) -> Result<()> {
240            Ok(())
241        }
242        fn next_event(&mut self, _timeout: Option<Duration>) -> Result<CaptureEvent> {
243            Ok(CaptureEvent::Video(VideoFrame {
244                stream_time_ns: 0,
245                sequence: 0,
246                width: 1,
247                height: 1,
248                stride: 4,
249                pixel_format: PixelFormat::Bgra8888,
250                color_space: None,
251                data: FrameData::Host(vec![0; 4]),
252                damage: None,
253            }))
254        }
255    }
256
257    struct QueuedAudio(VecDeque<AudioFrame>);
258
259    impl AudioBackend for QueuedAudio {
260        fn info(&self) -> BackendInfo {
261            mock_info()
262        }
263        fn start(&mut self) -> Result<()> {
264            Ok(())
265        }
266        fn stop(&mut self) -> Result<()> {
267            Ok(())
268        }
269        fn next_audio(&mut self, timeout: Option<Duration>) -> Result<AudioFrame> {
270            self.0
271                .pop_front()
272                .ok_or(PinrayError::Timeout(timeout.unwrap_or_default()))
273        }
274    }
275
276    #[test]
277    fn repeated_start_stop_is_stable() {
278        let bundle = BackendBundle {
279            info: mock_info(),
280            video: Some(Box::new(BusyVideo)),
281            audio: None,
282        };
283        let mut session = CaptureSession::new(SessionConfig::default(), bundle);
284
285        for _ in 0..100 {
286            session.start().unwrap();
287            assert!(session.is_running());
288            assert!(matches!(
289                session.next_event(Some(Duration::from_millis(1))),
290                Ok(CaptureEvent::Video(_))
291            ));
292            session.stop().unwrap();
293            assert!(!session.is_running());
294        }
295    }
296
297    #[test]
298    fn busy_video_does_not_starve_audio() {
299        let audio_frame = AudioFrame {
300            stream_time_ns: 0,
301            sequence: 0,
302            sample_rate: 48_000,
303            channels: 2,
304            sample_format: SampleFormat::F32,
305            data: AudioData::Interleaved(vec![0; 8]),
306        };
307        let bundle = BackendBundle {
308            info: mock_info(),
309            video: Some(Box::new(BusyVideo)),
310            audio: Some(Box::new(QueuedAudio(VecDeque::from([
311                audio_frame.clone(),
312                audio_frame,
313            ])))),
314        };
315        let mut session = CaptureSession::new(SessionConfig::default(), bundle);
316
317        let mut audio_events = 0;
318        let mut video_events = 0;
319        for _ in 0..6 {
320            match session.next_event(Some(Duration::from_millis(1))).unwrap() {
321                CaptureEvent::Audio(_) => audio_events += 1,
322                CaptureEvent::Video(_) => video_events += 1,
323                other => panic!("unexpected event: {other:?}"),
324            }
325        }
326        assert_eq!(audio_events, 2, "queued audio must be drained");
327        assert_eq!(video_events, 4);
328    }
329}