1use std::sync::Arc;
32use std::sync::Mutex;
33use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
34use std::time::{Duration, Instant};
35
36use edit::{ClickEvent, CursorSample};
37use media::audio_mix::AudioMixer;
38use media::encode::{LiveGstreamerEncoder, VideoEncoder};
39use serde::{Deserialize, Serialize};
40
41use crate::click_capture::ClickTap;
42use crate::cursor_capture::CursorPoller;
43
44const DEFAULT_AUDIO_CHANNELS: u8 = 2;
47
48pub type FrameSlot = Arc<Mutex<Option<Vec<u8>>>>;
53
54pub type SharedAudioMixer = Arc<Mutex<AudioMixer>>;
58
59#[must_use]
61pub fn new_frame_slot() -> FrameSlot {
62 Arc::new(Mutex::new(None))
63}
64
65#[must_use]
67pub fn new_audio_mixer() -> SharedAudioMixer {
68 Arc::new(Mutex::new(
69 AudioMixer::new(DEFAULT_AUDIO_CHANNELS).expect("DEFAULT_AUDIO_CHANNELS > 0"),
70 ))
71}
72
73static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
78
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
83pub enum SessionState {
84 #[default]
86 Idle,
87 Starting,
91 Running,
93 Stopping,
97}
98
99impl SessionState {
100 #[must_use]
102 pub fn try_start(self) -> Self {
103 match self {
104 Self::Idle => Self::Starting,
105 other => other,
106 }
107 }
108
109 #[must_use]
112 pub fn mark_running(self) -> Self {
113 match self {
114 Self::Starting => Self::Running,
115 other => other,
116 }
117 }
118
119 #[must_use]
121 pub fn try_stop(self) -> Self {
122 match self {
123 Self::Running | Self::Starting => Self::Stopping,
124 other => other,
125 }
126 }
127
128 #[must_use]
130 pub fn finish_stop(self) -> Self {
131 match self {
132 Self::Stopping => Self::Idle,
133 other => other,
134 }
135 }
136}
137
138#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
142pub enum StreamKind {
143 Camera,
146 Screen,
148 Microphone,
151 SystemAudio,
153}
154
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158pub struct StreamHealth {
159 pub kind: StreamKind,
161 pub lifecycle: String,
166 pub frame_count: u64,
168 pub last_frame_ms_ago: Option<u64>,
173}
174
175#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
180#[allow(
181 clippy::struct_excessive_bools,
182 reason = "Each bool maps to one of the four physical input channels (camera / screen / mic / system audio). They're inherently independent flags — a bitflag would be less readable across the IPC seam where Leptos consumes them as `{ camera: bool, screen: bool, ... }`."
183)]
184pub struct SessionStreams {
185 pub camera: bool,
188 pub screen: bool,
190 pub microphone: bool,
192 pub system_audio: bool,
194}
195
196impl SessionStreams {
197 #[must_use]
200 pub fn any_enabled(self) -> bool {
201 self.camera || self.screen || self.microphone || self.system_audio
202 }
203
204 pub fn enabled_kinds(self) -> impl Iterator<Item = StreamKind> {
209 [
210 (self.camera, StreamKind::Camera),
211 (self.screen, StreamKind::Screen),
212 (self.microphone, StreamKind::Microphone),
213 (self.system_audio, StreamKind::SystemAudio),
214 ]
215 .into_iter()
216 .filter_map(|(on, kind)| if on { Some(kind) } else { None })
217 }
218}
219
220#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct RecordingSession {
242 pub id: u64,
246 pub started_at: Instant,
250 pub started_at_unix_secs: u64,
255 pub state: SessionState,
257 pub streams: SessionStreams,
259}
260
261impl RecordingSession {
262 #[must_use]
266 pub fn starting(streams: SessionStreams) -> Self {
267 Self {
268 id: NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed),
269 started_at: Instant::now(),
270 started_at_unix_secs: std::time::SystemTime::now()
271 .duration_since(std::time::UNIX_EPOCH)
272 .map_or(0, |d| d.as_secs()),
273 state: SessionState::Starting,
274 streams,
275 }
276 }
277
278 pub fn mark_running(&mut self) {
281 self.state = self.state.mark_running();
282 }
283
284 pub fn begin_stop(&mut self) {
287 self.state = self.state.try_stop();
288 }
289
290 pub fn finish_stop(&mut self) {
294 self.state = self.state.finish_stop();
295 }
296
297 pub fn abort(&mut self) {
302 self.state = SessionState::Idle;
303 }
304
305 #[must_use]
308 pub fn elapsed(&self) -> Duration {
309 self.started_at.elapsed()
310 }
311}
312
313pub struct RecordingState {
327 pub session: Mutex<Option<RecordingSession>>,
329 pub encoder: Mutex<Option<EncoderHandle>>,
331 pub camera_frame_slot: FrameSlot,
335 pub screen_frame_slot: FrameSlot,
340 pub audio_mixer: SharedAudioMixer,
344 pub pending_export: Mutex<Option<PendingExport>>,
352 pub cursor_poller: Mutex<Option<CursorPoller>>,
357 pub pending_cursor_track: Mutex<Option<Vec<CursorSample>>>,
362 pub click_tap: Mutex<Option<ClickTap>>,
367 pub pending_clicks: Mutex<Option<Vec<ClickEvent>>>,
372}
373
374impl Default for RecordingState {
375 fn default() -> Self {
376 Self {
377 session: Mutex::new(None),
378 encoder: Mutex::new(None),
379 camera_frame_slot: new_frame_slot(),
380 screen_frame_slot: new_frame_slot(),
381 audio_mixer: new_audio_mixer(),
382 pending_export: Mutex::new(None),
383 cursor_poller: Mutex::new(None),
384 pending_cursor_track: Mutex::new(None),
385 click_tap: Mutex::new(None),
386 pending_clicks: Mutex::new(None),
387 }
388 }
389}
390
391impl RecordingState {
392 #[must_use]
394 pub fn is_active(&self) -> bool {
395 self.session
396 .lock()
397 .unwrap_or_else(std::sync::PoisonError::into_inner)
398 .is_some()
399 }
400
401 pub fn start_cursor_capture(&self, screen_source_id: Option<&str>) {
404 *self
405 .pending_cursor_track
406 .lock()
407 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
408 let rect = crate::cursor_capture::display_bounds_for_source(screen_source_id);
411 *self
412 .cursor_poller
413 .lock()
414 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(CursorPoller::start(rect));
415 }
416
417 pub fn finish_cursor_capture(&self, project_fps: u32) {
420 let poller = self
421 .cursor_poller
422 .lock()
423 .unwrap_or_else(std::sync::PoisonError::into_inner)
424 .take();
425 if let Some(poller) = poller {
426 let samples = poller.stop();
427 let track = crate::cursor_capture::samples_to_track(&samples, project_fps);
428 if !track.is_empty() {
429 *self
430 .pending_cursor_track
431 .lock()
432 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(track);
433 }
434 }
435 }
436
437 #[must_use]
439 pub fn take_cursor_track(&self) -> Option<Vec<CursorSample>> {
440 self.pending_cursor_track
441 .lock()
442 .unwrap_or_else(std::sync::PoisonError::into_inner)
443 .take()
444 }
445
446 pub fn clear_cursor_track(&self) {
449 *self
450 .pending_cursor_track
451 .lock()
452 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
453 }
454
455 pub fn start_click_capture(&self, screen_source_id: Option<&str>) {
459 *self
460 .pending_clicks
461 .lock()
462 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
463 let rect = crate::cursor_capture::display_bounds_for_source(screen_source_id);
464 *self
465 .click_tap
466 .lock()
467 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(ClickTap::start(rect));
468 }
469
470 pub fn finish_click_capture(&self, project_fps: u32) {
474 let tap = self
475 .click_tap
476 .lock()
477 .unwrap_or_else(std::sync::PoisonError::into_inner)
478 .take();
479 if let Some(tap) = tap {
480 let samples = tap.stop();
481 let clicks = crate::click_capture::samples_to_clicks(&samples, project_fps);
482 if !clicks.is_empty() {
483 *self
484 .pending_clicks
485 .lock()
486 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(clicks);
487 }
488 }
489 }
490
491 #[must_use]
493 pub fn take_clicks(&self) -> Option<Vec<ClickEvent>> {
494 self.pending_clicks
495 .lock()
496 .unwrap_or_else(std::sync::PoisonError::into_inner)
497 .take()
498 }
499
500 pub fn clear_clicks(&self) {
503 *self
504 .pending_clicks
505 .lock()
506 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
507 }
508
509 #[must_use]
512 pub fn snapshot(&self) -> Option<RecordingSession> {
513 self.session
514 .lock()
515 .unwrap_or_else(std::sync::PoisonError::into_inner)
516 .clone()
517 }
518
519 pub fn install_encoder(&self, handle: EncoderHandle) {
523 let mut guard = self
524 .encoder
525 .lock()
526 .unwrap_or_else(std::sync::PoisonError::into_inner);
527 *guard = Some(handle);
528 }
529
530 #[must_use]
533 pub fn take_encoder(&self) -> Option<EncoderHandle> {
534 self.encoder
535 .lock()
536 .unwrap_or_else(std::sync::PoisonError::into_inner)
537 .take()
538 }
539
540 pub fn set_pending_export(&self, pending: PendingExport) {
542 let mut guard = self
543 .pending_export
544 .lock()
545 .unwrap_or_else(std::sync::PoisonError::into_inner);
546 *guard = Some(pending);
547 }
548
549 #[must_use]
552 pub fn take_pending_export(&self) -> Option<PendingExport> {
553 self.pending_export
554 .lock()
555 .unwrap_or_else(std::sync::PoisonError::into_inner)
556 .take()
557 }
558
559 #[must_use]
563 pub fn has_pending_export(&self) -> bool {
564 self.pending_export
565 .lock()
566 .unwrap_or_else(std::sync::PoisonError::into_inner)
567 .is_some()
568 }
569
570 #[must_use]
573 pub fn pending_export_view(&self) -> Option<PendingExportView> {
574 self.pending_export
575 .lock()
576 .unwrap_or_else(std::sync::PoisonError::into_inner)
577 .as_ref()
578 .map(PendingExport::view)
579 }
580}
581
582pub struct EncoderHandle {
592 pub cancel: Arc<AtomicBool>,
594 pub encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>>,
598 pub output_path: std::path::PathBuf,
602 pub feed_thread: Option<std::thread::JoinHandle<()>>,
605}
606
607impl std::fmt::Debug for EncoderHandle {
608 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609 f.debug_struct("EncoderHandle")
610 .field("output_path", &self.output_path)
611 .field("feed_thread_alive", &self.feed_thread.is_some())
612 .finish_non_exhaustive()
613 }
614}
615
616impl EncoderHandle {
617 pub fn start_with_real_capture(
635 encoder_config: media::encode::EncoderConfig,
636 camera_slot: FrameSlot,
637 screen_slot: FrameSlot,
638 audio_mixer: SharedAudioMixer,
639 screen_dims: wisp::recording::StreamDimensions,
640 cam_dims: wisp::recording::StreamDimensions,
641 ) -> Result<Self, media::encode::EncodeError> {
642 crate::recording_paths::ensure_parent_dir(&encoder_config.output_path)
643 .map_err(media::encode::EncodeError::Io)?;
644
645 let width = encoder_config.width;
646 let height = encoder_config.height;
647 let framerate = encoder_config.framerate;
648 let output_path = encoder_config.output_path.clone();
649
650 let inner = LiveGstreamerEncoder::new(encoder_config)?;
651 let encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>> =
652 Arc::new(Mutex::new(Some(Box::new(inner))));
653 let cancel = Arc::new(AtomicBool::new(false));
654
655 let cancel_thread = Arc::clone(&cancel);
656 let encoder_thread = Arc::clone(&encoder);
657 let feed_thread = std::thread::Builder::new()
658 .name(format!("recording-encoder-real-{width}x{height}"))
659 .spawn(move || {
660 feed_real_capture(
661 encoder_thread,
662 cancel_thread,
663 camera_slot,
664 screen_slot,
665 audio_mixer,
666 width,
667 height,
668 framerate,
669 screen_dims,
670 cam_dims,
671 );
672 })
673 .map_err(|err| media::encode::EncodeError::Spawn {
674 source: err,
675 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
676 })?;
677
678 Ok(Self {
679 cancel,
680 encoder,
681 output_path,
682 feed_thread: Some(feed_thread),
683 })
684 }
685
686 pub fn start_with_test_pattern(
694 encoder_config: media::encode::EncoderConfig,
695 palette_seed: u64,
696 ) -> Result<Self, media::encode::EncodeError> {
697 crate::recording_paths::ensure_parent_dir(&encoder_config.output_path)
700 .map_err(media::encode::EncodeError::Io)?;
701
702 let width = encoder_config.width;
703 let height = encoder_config.height;
704 let framerate = encoder_config.framerate;
705 let channels = encoder_config.channels;
706 let sample_rate = encoder_config.sample_rate;
707 let output_path = encoder_config.output_path.clone();
708
709 let inner = LiveGstreamerEncoder::new(encoder_config)?;
710 let encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>> =
711 Arc::new(Mutex::new(Some(Box::new(inner))));
712 let cancel = Arc::new(AtomicBool::new(false));
713
714 let cancel_thread = Arc::clone(&cancel);
715 let encoder_thread = Arc::clone(&encoder);
716 let feed_thread = std::thread::Builder::new()
717 .name(format!("recording-encoder-feed-{palette_seed}"))
718 .spawn(move || {
719 feed_test_pattern(
720 encoder_thread,
721 cancel_thread,
722 width,
723 height,
724 framerate,
725 channels,
726 sample_rate,
727 palette_seed,
728 );
729 })
730 .map_err(|err| media::encode::EncodeError::Spawn {
731 source: err,
732 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
733 })?;
734
735 Ok(Self {
736 cancel,
737 encoder,
738 output_path,
739 feed_thread: Some(feed_thread),
740 })
741 }
742
743 pub fn finalize_now(mut self) -> Result<std::path::PathBuf, media::encode::EncodeError> {
751 self.cancel.store(true, Ordering::Relaxed);
752 if let Some(handle) = self.feed_thread.take() {
753 let _ = handle.join();
754 }
755 let encoder = self
756 .encoder
757 .lock()
758 .unwrap_or_else(std::sync::PoisonError::into_inner)
759 .take();
760 match encoder {
761 Some(boxed) => boxed.finalize(),
762 None => Ok(self.output_path),
763 }
764 }
765}
766
767fn cfr_catchup_frames(
775 elapsed: Duration,
776 frame_interval: Duration,
777 pushed: u64,
778 max_burst: u64,
779) -> u64 {
780 let interval_us = frame_interval.as_micros().max(1);
781 let due = u64::try_from(elapsed.as_micros() / interval_us).unwrap_or(u64::MAX);
783 due.saturating_add(1).saturating_sub(pushed).min(max_burst)
784}
785
786#[allow(
795 clippy::too_many_arguments,
796 clippy::needless_pass_by_value,
797 reason = "arg list is the necessary capture-state surface; Arcs are taken by value because this fn IS the thread body."
798)]
799fn feed_real_capture(
800 encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>>,
801 cancel: Arc<AtomicBool>,
802 camera_slot: FrameSlot,
803 screen_slot: FrameSlot,
804 audio_mixer: SharedAudioMixer,
805 width: u32,
806 height: u32,
807 framerate: u32,
808 screen_dims: wisp::recording::StreamDimensions,
809 cam_dims: wisp::recording::StreamDimensions,
810) {
811 let mut compose =
812 match crate::recording_compose::RecordingCompose::new(width, height, screen_dims, cam_dims)
813 {
814 Ok(c) => c,
815 Err(err) => {
816 tracing::error!(
817 ?err,
818 "feed_real_capture: RecordingCompose::new failed; exiting"
819 );
820 return;
821 }
822 };
823
824 let interval_us = 1_000_000_u64 / u64::from(framerate.max(1));
825 let frame_interval = Duration::from_micros(interval_us);
826 let max_burst = u64::from(framerate.max(1));
829 let mut anchor: Option<Instant> = None;
832 let mut last_bytes: Option<Vec<u8>> = None;
833 let mut frames_pushed: u64 = 0;
834 let mut audio_chunks_pushed: u64 = 0;
835
836 while !cancel.load(Ordering::Relaxed) {
837 if let Some(frame) = compose.compose_frame(&camera_slot, &screen_slot) {
841 last_bytes = Some(frame.bytes);
842 if anchor.is_none() {
843 anchor = Some(Instant::now());
844 }
845 }
846
847 if let (Some(start), Some(bytes)) = (anchor, last_bytes.as_ref()) {
854 let due = cfr_catchup_frames(start.elapsed(), frame_interval, frames_pushed, max_burst);
855 if due > 0 {
856 let mut guard = encoder
857 .lock()
858 .unwrap_or_else(std::sync::PoisonError::into_inner);
859 let Some(ref mut enc) = *guard else { break };
860 let mut failed = false;
861 for _ in 0..due {
862 let pts = Duration::from_micros(frames_pushed * interval_us);
863 if let Err(err) = enc.push_video_frame(bytes, pts) {
864 tracing::warn!(?err, "feed_real_capture: push_video_frame failed");
865 failed = true;
866 break;
867 }
868 frames_pushed = frames_pushed.saturating_add(1);
869 }
870 drop(guard);
871 if failed {
872 break;
873 }
874 }
875 }
876
877 let audio_samples = {
881 let mut mixer_guard = audio_mixer
882 .lock()
883 .unwrap_or_else(std::sync::PoisonError::into_inner);
884 mixer_guard.pull()
885 };
886 if !audio_samples.is_empty() {
887 let mut guard = encoder
888 .lock()
889 .unwrap_or_else(std::sync::PoisonError::into_inner);
890 if let Some(ref mut enc) = *guard {
891 let pts = Duration::from_micros(frames_pushed.saturating_sub(1) * interval_us);
892 if let Err(err) = enc.push_audio_chunk(&audio_samples, pts) {
893 tracing::warn!(
894 ?err,
895 "feed_real_capture: push_audio_chunk failed (continuing)"
896 );
897 } else {
898 audio_chunks_pushed = audio_chunks_pushed.saturating_add(1);
899 }
900 }
901 }
902
903 match anchor {
907 Some(start) => {
908 let target = start + Duration::from_micros(frames_pushed * interval_us);
909 if let Some(sleep) = target.checked_duration_since(Instant::now()) {
910 std::thread::sleep(sleep);
911 }
912 }
913 None => std::thread::sleep(frame_interval),
914 }
915 }
916 tracing::info!(
917 frames = frames_pushed,
918 audio_chunks_pushed,
919 "feed_real_capture: feed thread exiting"
920 );
921}
922
923#[allow(
927 clippy::cast_possible_truncation,
928 clippy::cast_sign_loss,
929 clippy::too_many_arguments,
930 clippy::needless_pass_by_value,
931 reason = "test-pattern math: u64 seed → u8 channel via wrapping arithmetic; arg list is the necessary EncoderConfig surface for this self-contained feeder; Arc<...> args are taken by value because this fn IS the thread body — the caller move-spawns and the Arcs are dropped when the closure returns."
932)]
933fn feed_test_pattern(
934 encoder: Arc<Mutex<Option<Box<dyn VideoEncoder>>>>,
935 cancel: Arc<AtomicBool>,
936 width: u32,
937 height: u32,
938 framerate: u32,
939 channels: u8,
940 sample_rate: u32,
941 palette_seed: u64,
942) {
943 let frame_interval = Duration::from_micros(1_000_000_u64 / u64::from(framerate.max(1)));
944 let bytes_per_frame = (width as usize) * (height as usize) * 4;
945 let mut frame = vec![0u8; bytes_per_frame];
946 let b = (palette_seed.wrapping_mul(73) & 0xff) as u8;
948 let g = (palette_seed.wrapping_mul(151) & 0xff) as u8;
949 let r = (palette_seed.wrapping_mul(229) & 0xff) as u8;
950 for px in frame.chunks_exact_mut(4) {
951 px[0] = b;
952 px[1] = g;
953 px[2] = r;
954 px[3] = 255;
955 }
956 let samples_per_frame =
959 (u64::from(sample_rate) / u64::from(framerate.max(1))) as usize * channels as usize;
960 let silence = vec![0.0_f32; samples_per_frame];
961
962 let started_at = Instant::now();
963 let mut next_pts_frames: u64 = 0;
964 while !cancel.load(Ordering::Relaxed) {
965 let pts =
966 Duration::from_micros(next_pts_frames * (1_000_000_u64 / u64::from(framerate.max(1))));
967 {
968 let mut guard = encoder
969 .lock()
970 .unwrap_or_else(std::sync::PoisonError::into_inner);
971 if let Some(ref mut enc) = *guard {
972 if let Err(err) = enc.push_video_frame(&frame, pts) {
973 tracing::warn!(?err, "feed_test_pattern: push_video_frame failed");
974 break;
975 }
976 if let Err(err) = enc.push_audio_chunk(&silence, pts) {
977 tracing::trace!(
978 ?err,
979 "feed_test_pattern: push_audio_chunk failed (continuing)"
980 );
981 }
982 } else {
983 break;
984 }
985 }
986 next_pts_frames = next_pts_frames.saturating_add(1);
987 let target = started_at + frame_interval * (next_pts_frames as u32);
989 if let Some(sleep) = target.checked_duration_since(Instant::now()) {
990 std::thread::sleep(sleep);
991 }
992 }
993 tracing::info!(
994 frames = next_pts_frames,
995 "feed_test_pattern: feed thread exiting"
996 );
997}
998
999#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1006pub struct RecordingConfig {
1007 pub streams: SessionStreams,
1009 #[serde(default)]
1011 pub camera_id: String,
1012 #[serde(default)]
1014 pub microphone_id: String,
1015 #[serde(default)]
1018 pub screen_source_id: Option<String>,
1019 #[serde(default)]
1022 pub output_path: Option<String>,
1023 #[serde(default)]
1027 pub format: Option<String>,
1028}
1029
1030#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1033pub struct RecordingStatusView {
1034 pub session_id: Option<u64>,
1036 pub state: SessionState,
1038 pub elapsed_ms: u64,
1041 pub streams: Vec<StreamHealth>,
1043}
1044
1045impl RecordingStatusView {
1046 #[must_use]
1049 pub fn idle() -> Self {
1050 Self {
1051 session_id: None,
1052 state: SessionState::Idle,
1053 elapsed_ms: 0,
1054 streams: Vec::new(),
1055 }
1056 }
1057}
1058
1059#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1064pub struct RecordingSummary {
1065 pub session_id: u64,
1067 pub elapsed_ms: u64,
1070 pub streams: Vec<StreamHealth>,
1072 pub output_path: Option<String>,
1075 #[serde(default)]
1079 pub pending_export: Option<PendingExportView>,
1080}
1081
1082#[derive(Clone, Debug, PartialEq, Eq)]
1088pub struct PendingExport {
1089 pub scratch_path: std::path::PathBuf,
1093 pub duration_ms: u64,
1095 pub started_at_unix_secs: u64,
1098}
1099
1100impl PendingExport {
1101 #[must_use]
1104 pub fn view(&self) -> PendingExportView {
1105 PendingExportView {
1106 duration_ms: self.duration_ms,
1107 suggested_basename: crate::recording_paths::default_basename(self.started_at_unix_secs),
1108 }
1109 }
1110}
1111
1112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1115pub struct PendingExportView {
1116 pub duration_ms: u64,
1118 pub suggested_basename: String,
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126 use super::*;
1127
1128 #[test]
1131 fn state_default_is_idle() {
1132 assert_eq!(SessionState::default(), SessionState::Idle);
1133 }
1134
1135 #[test]
1136 fn state_full_round_trip() {
1137 let mut s = SessionState::default();
1138 s = s.try_start();
1139 assert_eq!(s, SessionState::Starting);
1140 s = s.mark_running();
1141 assert_eq!(s, SessionState::Running);
1142 s = s.try_stop();
1143 assert_eq!(s, SessionState::Stopping);
1144 s = s.finish_stop();
1145 assert_eq!(s, SessionState::Idle);
1146 }
1147
1148 #[test]
1149 fn state_re_entrant_start_is_noop() {
1150 for s in [
1151 SessionState::Starting,
1152 SessionState::Running,
1153 SessionState::Stopping,
1154 ] {
1155 assert_eq!(s.try_start(), s);
1156 }
1157 }
1158
1159 #[test]
1160 fn state_mark_running_only_advances_from_starting() {
1161 assert_eq!(SessionState::Idle.mark_running(), SessionState::Idle);
1162 assert_eq!(SessionState::Starting.mark_running(), SessionState::Running);
1163 assert_eq!(SessionState::Running.mark_running(), SessionState::Running);
1166 assert_eq!(
1167 SessionState::Stopping.mark_running(),
1168 SessionState::Stopping
1169 );
1170 }
1171
1172 #[test]
1173 fn state_stop_only_advances_from_starting_or_running() {
1174 assert_eq!(SessionState::Idle.try_stop(), SessionState::Idle);
1175 assert_eq!(SessionState::Starting.try_stop(), SessionState::Stopping);
1176 assert_eq!(SessionState::Running.try_stop(), SessionState::Stopping);
1177 assert_eq!(SessionState::Stopping.try_stop(), SessionState::Stopping);
1178 }
1179
1180 #[test]
1181 fn state_finish_stop_only_advances_from_stopping() {
1182 assert_eq!(SessionState::Idle.finish_stop(), SessionState::Idle);
1183 assert_eq!(SessionState::Starting.finish_stop(), SessionState::Starting);
1184 assert_eq!(SessionState::Running.finish_stop(), SessionState::Running);
1185 assert_eq!(SessionState::Stopping.finish_stop(), SessionState::Idle);
1186 }
1187
1188 #[test]
1189 fn state_serde_round_trip() {
1190 for v in [
1191 SessionState::Idle,
1192 SessionState::Starting,
1193 SessionState::Running,
1194 SessionState::Stopping,
1195 ] {
1196 let json = serde_json::to_string(&v).unwrap();
1197 let back: SessionState = serde_json::from_str(&json).unwrap();
1198 assert_eq!(back, v);
1199 }
1200 }
1201
1202 #[test]
1205 fn streams_default_is_all_off() {
1206 let s = SessionStreams::default();
1207 assert!(!s.any_enabled());
1208 assert_eq!(s.enabled_kinds().count(), 0);
1209 }
1210
1211 #[test]
1212 fn streams_any_enabled_true_when_any_field_on() {
1213 for s in [
1214 SessionStreams {
1215 camera: true,
1216 ..Default::default()
1217 },
1218 SessionStreams {
1219 screen: true,
1220 ..Default::default()
1221 },
1222 SessionStreams {
1223 microphone: true,
1224 ..Default::default()
1225 },
1226 SessionStreams {
1227 system_audio: true,
1228 ..Default::default()
1229 },
1230 ] {
1231 assert!(s.any_enabled());
1232 }
1233 }
1234
1235 #[test]
1236 fn streams_enabled_kinds_walks_in_canonical_order() {
1237 let all_on = SessionStreams {
1238 camera: true,
1239 screen: true,
1240 microphone: true,
1241 system_audio: true,
1242 };
1243 let kinds: Vec<_> = all_on.enabled_kinds().collect();
1244 assert_eq!(
1245 kinds,
1246 vec![
1247 StreamKind::Camera,
1248 StreamKind::Screen,
1249 StreamKind::Microphone,
1250 StreamKind::SystemAudio,
1251 ]
1252 );
1253 }
1254
1255 #[test]
1256 fn streams_enabled_kinds_filters_off_channels() {
1257 let cam_only = SessionStreams {
1258 camera: true,
1259 ..Default::default()
1260 };
1261 let kinds: Vec<_> = cam_only.enabled_kinds().collect();
1262 assert_eq!(kinds, vec![StreamKind::Camera]);
1263 }
1264
1265 #[test]
1268 fn session_starts_with_unique_increasing_ids() {
1269 let a = RecordingSession::starting(SessionStreams::default());
1270 let b = RecordingSession::starting(SessionStreams::default());
1271 let c = RecordingSession::starting(SessionStreams::default());
1272 assert!(b.id > a.id);
1273 assert!(c.id > b.id);
1274 }
1275
1276 #[test]
1277 fn session_starting_state_is_starting() {
1278 let s = RecordingSession::starting(SessionStreams::default());
1279 assert_eq!(s.state, SessionState::Starting);
1280 }
1281
1282 #[test]
1283 fn session_full_lifecycle_round_trip() {
1284 let mut s = RecordingSession::starting(SessionStreams {
1285 camera: true,
1286 microphone: true,
1287 ..Default::default()
1288 });
1289 assert_eq!(s.state, SessionState::Starting);
1290 s.mark_running();
1291 assert_eq!(s.state, SessionState::Running);
1292 s.begin_stop();
1293 assert_eq!(s.state, SessionState::Stopping);
1294 s.finish_stop();
1295 assert_eq!(s.state, SessionState::Idle);
1296 }
1297
1298 #[test]
1299 fn session_abort_flips_state_to_idle_from_any_state() {
1300 for start_state in [
1301 SessionState::Starting,
1302 SessionState::Running,
1303 SessionState::Stopping,
1304 ] {
1305 let mut s = RecordingSession::starting(SessionStreams::default());
1306 s.state = start_state;
1307 s.abort();
1308 assert_eq!(s.state, SessionState::Idle);
1309 }
1310 }
1311
1312 #[test]
1313 fn session_elapsed_is_monotonically_nondecreasing() {
1314 let s = RecordingSession::starting(SessionStreams::default());
1315 let first = s.elapsed();
1316 for _ in 0..10_000 {
1319 std::hint::spin_loop();
1320 }
1321 let second = s.elapsed();
1322 assert!(second >= first);
1323 }
1324
1325 #[test]
1328 fn stream_kind_serde_round_trip() {
1329 for k in [
1330 StreamKind::Camera,
1331 StreamKind::Screen,
1332 StreamKind::Microphone,
1333 StreamKind::SystemAudio,
1334 ] {
1335 let json = serde_json::to_string(&k).unwrap();
1336 let back: StreamKind = serde_json::from_str(&json).unwrap();
1337 assert_eq!(back, k);
1338 }
1339 }
1340
1341 #[test]
1342 fn stream_health_serde_round_trip() {
1343 let h = StreamHealth {
1344 kind: StreamKind::Camera,
1345 lifecycle: "Running".to_string(),
1346 frame_count: 1234,
1347 last_frame_ms_ago: Some(42),
1348 };
1349 let json = serde_json::to_string(&h).unwrap();
1350 let back: StreamHealth = serde_json::from_str(&json).unwrap();
1351 assert_eq!(back, h);
1352 }
1353
1354 #[test]
1359 fn new_frame_slot_starts_none() {
1360 let slot = new_frame_slot();
1361 assert!(slot.lock().unwrap().is_none());
1362 }
1363
1364 #[test]
1365 fn cursor_track_handoff_is_consume_once_and_clearable() {
1366 let state = RecordingState::default();
1370 assert!(
1371 state.take_cursor_track().is_none(),
1372 "none until a recording"
1373 );
1374
1375 *state
1376 .pending_cursor_track
1377 .lock()
1378 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1379 Some(vec![CursorSample::new(0, 0.5, 0.5)]);
1380 assert_eq!(state.take_cursor_track().map(|t| t.len()), Some(1), "taken");
1381 assert!(state.take_cursor_track().is_none(), "consumed once");
1382
1383 *state
1384 .pending_cursor_track
1385 .lock()
1386 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1387 Some(vec![CursorSample::new(1, 0.1, 0.1)]);
1388 state.clear_cursor_track();
1389 assert!(state.take_cursor_track().is_none(), "cleared");
1390 }
1391
1392 #[test]
1393 fn click_log_handoff_is_consume_once_and_clearable() {
1394 let state = RecordingState::default();
1397 assert!(state.take_clicks().is_none(), "none until a recording");
1398
1399 *state
1400 .pending_clicks
1401 .lock()
1402 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1403 Some(vec![ClickEvent::new(10, 0.4, 0.6)]);
1404 assert_eq!(state.take_clicks().map(|c| c.len()), Some(1), "taken");
1405 assert!(state.take_clicks().is_none(), "consumed once");
1406
1407 *state
1408 .pending_clicks
1409 .lock()
1410 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1411 Some(vec![ClickEvent::new(20, 0.1, 0.1)]);
1412 state.clear_clicks();
1413 assert!(state.take_clicks().is_none(), "cleared");
1414 }
1415
1416 #[test]
1417 fn frame_slot_latest_wins_on_overwrite() {
1418 let slot = new_frame_slot();
1419 *slot.lock().unwrap() = Some(vec![1, 2, 3]);
1420 *slot.lock().unwrap() = Some(vec![4, 5, 6, 7]);
1421 let read = slot.lock().unwrap().clone();
1422 assert_eq!(read, Some(vec![4, 5, 6, 7]));
1423 }
1424
1425 #[test]
1426 fn frame_slot_take_clears() {
1427 let slot = new_frame_slot();
1428 *slot.lock().unwrap() = Some(vec![1, 2, 3]);
1429 let taken = slot.lock().unwrap().take();
1430 assert_eq!(taken, Some(vec![1, 2, 3]));
1431 assert!(slot.lock().unwrap().is_none());
1432 }
1433
1434 #[test]
1435 fn frame_slot_is_arc_cheap_to_clone() {
1436 let slot = new_frame_slot();
1437 let clone1 = Arc::clone(&slot);
1438 let clone2 = Arc::clone(&slot);
1439 *clone1.lock().unwrap() = Some(vec![0xff; 16]);
1440 assert_eq!(clone2.lock().unwrap().as_ref().unwrap().len(), 16);
1441 assert_eq!(slot.lock().unwrap().as_ref().unwrap().len(), 16);
1442 }
1443
1444 #[test]
1445 fn new_audio_mixer_has_stereo_channels() {
1446 let mixer = new_audio_mixer();
1447 assert_eq!(mixer.lock().unwrap().channels(), DEFAULT_AUDIO_CHANNELS);
1448 assert_eq!(mixer.lock().unwrap().channels(), 2);
1449 }
1450
1451 #[test]
1452 fn shared_audio_mixer_is_arc_cheap_to_clone() {
1453 let mixer = new_audio_mixer();
1454 let writer_clone = Arc::clone(&mixer);
1455 let reader_clone = Arc::clone(&mixer);
1456 writer_clone.lock().unwrap().push_mic(&[0.1, 0.2]).unwrap();
1457 assert_eq!(reader_clone.lock().unwrap().mic_queued(), 2);
1458 }
1459
1460 #[test]
1461 fn recording_state_default_wires_slots_and_mixer() {
1462 let state = RecordingState::default();
1463 assert!(state.camera_frame_slot.lock().unwrap().is_none());
1464 assert!(state.screen_frame_slot.lock().unwrap().is_none());
1465 assert_eq!(state.audio_mixer.lock().unwrap().channels(), 2);
1466 }
1467
1468 #[test]
1469 fn recording_state_starts_inactive() {
1470 let s = RecordingState::default();
1471 assert!(!s.is_active());
1472 assert!(s.snapshot().is_none());
1473 }
1474
1475 #[test]
1476 fn recording_state_holds_then_releases_session() {
1477 let s = RecordingState::default();
1478 {
1479 let mut guard = s.session.lock().unwrap();
1480 *guard = Some(RecordingSession::starting(SessionStreams {
1481 camera: true,
1482 ..Default::default()
1483 }));
1484 }
1485 assert!(s.is_active());
1486 let snap = s.snapshot().unwrap();
1487 assert_eq!(snap.state, SessionState::Starting);
1488 assert!(snap.streams.camera);
1489
1490 s.session.lock().unwrap().take();
1492 assert!(!s.is_active());
1493 }
1494
1495 #[test]
1498 fn status_view_idle_is_empty() {
1499 let view = RecordingStatusView::idle();
1500 assert!(view.session_id.is_none());
1501 assert_eq!(view.state, SessionState::Idle);
1502 assert_eq!(view.elapsed_ms, 0);
1503 assert!(view.streams.is_empty());
1504 }
1505
1506 #[test]
1507 fn status_view_serde_round_trip() {
1508 let view = RecordingStatusView {
1509 session_id: Some(7),
1510 state: SessionState::Running,
1511 elapsed_ms: 12_345,
1512 streams: vec![StreamHealth {
1513 kind: StreamKind::Camera,
1514 lifecycle: "Running".into(),
1515 frame_count: 90,
1516 last_frame_ms_ago: Some(33),
1517 }],
1518 };
1519 let json = serde_json::to_string(&view).unwrap();
1520 let back: RecordingStatusView = serde_json::from_str(&json).unwrap();
1521 assert_eq!(back, view);
1522 }
1523
1524 #[test]
1525 fn summary_serde_round_trip() {
1526 let summary = RecordingSummary {
1527 session_id: 42,
1528 elapsed_ms: 10_000,
1529 streams: vec![],
1530 output_path: Some("/tmp/screen.mp4".into()),
1531 pending_export: None,
1532 };
1533 let json = serde_json::to_string(&summary).unwrap();
1534 let back: RecordingSummary = serde_json::from_str(&json).unwrap();
1535 assert_eq!(back, summary);
1536 }
1537
1538 fn sample_pending() -> PendingExport {
1541 PendingExport {
1542 scratch_path: std::path::PathBuf::from("/tmp/scratch-7.mp4"),
1543 duration_ms: 8_000,
1544 started_at_unix_secs: 1_763_402_400,
1545 }
1546 }
1547
1548 #[test]
1549 fn pending_export_set_take_round_trip() {
1550 let state = RecordingState::default();
1551 assert!(!state.has_pending_export());
1552 assert!(state.take_pending_export().is_none());
1553
1554 state.set_pending_export(sample_pending());
1555 assert!(state.has_pending_export());
1556
1557 let taken = state.take_pending_export().expect("pending present");
1558 assert_eq!(taken, sample_pending());
1559 assert!(!state.has_pending_export());
1561 assert!(state.take_pending_export().is_none());
1562 }
1563
1564 #[test]
1565 fn pending_export_view_drops_scratch_path_and_strips_extension() {
1566 let view = sample_pending().view();
1567 assert_eq!(view.duration_ms, 8_000);
1568 assert_eq!(view.suggested_basename, "Screen-2025-11-17-180000");
1569 assert!(
1571 std::path::Path::new(&view.suggested_basename)
1572 .extension()
1573 .is_none()
1574 );
1575 }
1576
1577 #[test]
1578 fn pending_export_view_matches_state_accessor() {
1579 let state = RecordingState::default();
1580 assert!(state.pending_export_view().is_none());
1581 state.set_pending_export(sample_pending());
1582 assert_eq!(state.pending_export_view(), Some(sample_pending().view()));
1583 }
1584
1585 #[test]
1586 fn pending_export_view_serde_round_trip() {
1587 let view = sample_pending().view();
1588 let json = serde_json::to_string(&view).unwrap();
1589 let back: PendingExportView = serde_json::from_str(&json).unwrap();
1590 assert_eq!(back, view);
1591 }
1592
1593 #[test]
1594 fn config_serde_round_trip_with_all_optionals() {
1595 let cfg = RecordingConfig {
1596 streams: SessionStreams {
1597 camera: true,
1598 microphone: true,
1599 ..Default::default()
1600 },
1601 camera_id: "cam-feedface".into(),
1602 microphone_id: "mic-cafebabe".into(),
1603 screen_source_id: Some("display-1".into()),
1604 output_path: Some("/tmp/test.mp4".into()),
1605 format: Some("mp4-h264".into()),
1606 };
1607 let json = serde_json::to_string(&cfg).unwrap();
1608 let back: RecordingConfig = serde_json::from_str(&json).unwrap();
1609 assert_eq!(back, cfg);
1610 }
1611
1612 #[test]
1613 fn config_serde_back_compat_when_optionals_absent() {
1614 let legacy =
1617 r#"{"streams":{"camera":true,"screen":false,"microphone":false,"system_audio":false}}"#;
1618 let parsed: RecordingConfig = serde_json::from_str(legacy).unwrap();
1619 assert!(parsed.streams.camera);
1620 assert_eq!(parsed.camera_id, "");
1621 assert_eq!(parsed.microphone_id, "");
1622 assert!(parsed.screen_source_id.is_none());
1623 assert!(parsed.output_path.is_none());
1624 assert!(parsed.format.is_none());
1625 }
1626
1627 #[test]
1628 fn stream_health_handles_none_last_frame() {
1629 let h = StreamHealth {
1631 kind: StreamKind::SystemAudio,
1632 lifecycle: "Starting".to_string(),
1633 frame_count: 0,
1634 last_frame_ms_ago: None,
1635 };
1636 let json = serde_json::to_string(&h).unwrap();
1637 let back: StreamHealth = serde_json::from_str(&json).unwrap();
1638 assert_eq!(back, h);
1639 }
1640
1641 #[test]
1642 fn cfr_catchup_locks_frame_count_to_wallclock() {
1643 let fi = Duration::from_micros(1_000_000 / 30); assert_eq!(cfr_catchup_frames(Duration::ZERO, fi, 0, 100), 1);
1646 assert_eq!(cfr_catchup_frames(Duration::from_secs(1), fi, 1, 100), 30);
1649 assert_eq!(cfr_catchup_frames(Duration::from_secs(1), fi, 5, 100), 26);
1652 assert_eq!(cfr_catchup_frames(Duration::from_secs(10), fi, 0, 60), 60);
1654 assert_eq!(cfr_catchup_frames(Duration::ZERO, fi, 1, 100), 0);
1656 assert_eq!(cfr_catchup_frames(Duration::from_secs(1), fi, 31, 100), 0);
1657 }
1658}