1pub mod mp4;
40pub mod ogg;
41
42use std::collections::VecDeque;
43use std::os::unix::fs::OpenOptionsExt;
44use std::os::unix::net::UnixStream;
45use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
46use std::sync::{Arc, Mutex, mpsc};
47use std::thread;
48use std::time::{Duration, Instant};
49
50use crossbeam_channel::{Receiver, Sender, TrySendError, bounded};
51
52use crate::RustCaptureSettings;
53use crate::ThreadCommand;
54use crate::encoders::software::EncodedStripe;
55
56const QUEUE_CAP: usize = 256;
59
60enum Tap {
64 Video(Arc<Vec<u8>>, usize, u64),
65 Audio(Vec<u8>, u64),
66}
67
68const EARLY_AUDIO_CAP: usize = 500;
71
72#[derive(Clone, Copy, PartialEq, Eq, Debug)]
74pub enum PreferredBackend {
75 X11,
76 Wayland,
77}
78
79#[derive(Clone)]
82pub struct RecordOptions {
83 pub path: String,
84 pub display_id: u32,
85 pub fps: f64,
88 pub bitrate_kbps: i32,
90 pub keyframe_interval_s: f64,
94 pub backend: Option<PreferredBackend>,
95 pub capture: Option<RustCaptureSettings>,
98 pub audio_socket: String,
100}
101
102impl RecordOptions {
103 pub fn from_env(path: String) -> Self {
105 let f = |k: &str| std::env::var(k).ok().and_then(|v| v.parse::<f64>().ok());
106 let backend = match std::env::var("PIXELFLUX_RECORD_BACKEND").ok().as_deref() {
107 Some("x11") => Some(PreferredBackend::X11),
108 Some("wayland") => Some(PreferredBackend::Wayland),
109 _ => None,
110 };
111 Self {
112 path,
113 display_id: f("PIXELFLUX_RECORD_DISPLAY").map(|v| v as u32).unwrap_or(0),
114 fps: f("PIXELFLUX_RECORD_FPS").unwrap_or(0.0),
115 bitrate_kbps: f("PIXELFLUX_RECORD_BITRATE").map(|v| v as i32).unwrap_or(0),
116 keyframe_interval_s: f("PIXELFLUX_RECORD_KEYFRAME_S").unwrap_or(0.0),
117 backend,
118 capture: None,
119 audio_socket: std::env::var("PIXELFLUX_RECORD_AUDIO").unwrap_or_default(),
120 }
121 }
122
123 fn effective_fps(&self) -> f64 {
124 if self.fps > 0.0 { self.fps } else { 30.0 }
125 }
126
127 fn effective_keyframe_s(&self) -> f64 {
128 if self.keyframe_interval_s > 0.0 {
129 self.keyframe_interval_s
130 } else {
131 2.0
132 }
133 }
134}
135
136struct RecShared {
139 start: Instant,
140 enqueued: AtomicU64,
141 dropped: AtomicU64,
142 skipped_non_h264: AtomicU64,
143 muxed: AtomicU64,
144 sync_frames: AtomicU64,
145 audio_muxed: AtomicU64,
146 bytes: AtomicU64,
147 width: AtomicU32,
148 height: AtomicU32,
149 last_idr_req_us: AtomicU64,
150 error: Mutex<Option<String>>,
151}
152
153impl RecShared {
154 fn new() -> Arc<Self> {
155 Arc::new(Self {
156 start: Instant::now(),
157 enqueued: AtomicU64::new(0),
158 dropped: AtomicU64::new(0),
159 skipped_non_h264: AtomicU64::new(0),
160 muxed: AtomicU64::new(0),
161 sync_frames: AtomicU64::new(0),
162 audio_muxed: AtomicU64::new(0),
163 bytes: AtomicU64::new(0),
164 width: AtomicU32::new(0),
165 height: AtomicU32::new(0),
166 last_idr_req_us: AtomicU64::new(0),
167 error: Mutex::new(None),
168 })
169 }
170
171 fn set_error(&self, msg: String) {
172 let mut g = self.error.lock().unwrap();
173 if g.is_none() {
174 eprintln!("[recorder] {msg}");
175 *g = Some(msg);
176 }
177 }
178}
179
180#[derive(Clone, Debug)]
182pub struct RecordingStatus {
183 pub active: bool,
184 pub path: String,
185 pub backend: &'static str,
186 pub mode: &'static str,
187 pub frames: u64,
188 pub sync_frames: u64,
189 pub audio_frames: u64,
190 pub dropped: u64,
191 pub skipped_non_h264: u64,
192 pub bytes: u64,
193 pub duration_s: f64,
194 pub width: u32,
195 pub height: u32,
196 pub error: Option<String>,
197}
198
199#[derive(Clone)]
202struct WlTap {
203 display_id: u32,
204 tx: Sender<Tap>,
205 shared: Arc<RecShared>,
206 idr: Option<IdrRequester>,
209 keyframe_interval_us: u64,
210}
211
212#[derive(Clone)]
214struct IdrRequester {
215 tx: smithay::reexports::calloop::channel::Sender<ThreadCommand>,
216 display_id: u32,
217}
218
219impl IdrRequester {
220 fn request(&self) {
221 let _ = self.tx.send(ThreadCommand::RequestIdr {
222 display_id: self.display_id,
223 });
224 }
225}
226
227enum RecordingMode {
229 X11Own {
231 controls: Arc<crate::x11::Controls>,
232 join: thread::JoinHandle<()>,
233 },
234 WaylandOwn { display_id: u32 },
237 WaylandAttached,
239}
240
241struct ActiveRecording {
242 path: String,
243 backend: &'static str,
244 mode_name: &'static str,
245 mode: RecordingMode,
246 tx: Sender<Tap>,
247 shared: Arc<RecShared>,
248 writer: thread::JoinHandle<()>,
249 audio_stop: Arc<AtomicBool>,
250 audio: Option<thread::JoinHandle<()>>,
251}
252
253static ACTIVE: Mutex<Option<ActiveRecording>> = Mutex::new(None);
254static LAST_FINISHED: Mutex<Option<RecordingStatus>> = Mutex::new(None);
255static WL_TAP_ARMED: AtomicBool = AtomicBool::new(false);
258static WL_TAP: Mutex<Option<WlTap>> = Mutex::new(None);
259
260pub(crate) fn wayland_tap(display_id: u32, stripes: &[EncodedStripe]) {
264 if !WL_TAP_ARMED.load(Ordering::Relaxed) {
265 return;
266 }
267 let Some(tap) = WL_TAP.lock().unwrap().clone() else {
268 return;
269 };
270 if tap.display_id != display_id {
271 return;
272 }
273 offer_frame(&tap.shared, &tap.tx, stripes);
274 if let Some(ref idr) = tap.idr {
275 pace_idr_requests(&tap.shared, idr, tap.keyframe_interval_us);
276 }
277}
278
279fn pace_idr_requests(shared: &RecShared, idr: &IdrRequester, interval_us: u64) {
282 if interval_us == 0 {
283 return;
284 }
285 let now = shared.start.elapsed().as_micros() as u64;
286 let last = shared.last_idr_req_us.load(Ordering::Relaxed);
287 if now.saturating_sub(last) >= interval_us
288 && shared
289 .last_idr_req_us
290 .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
291 .is_ok()
292 {
293 idr.request();
294 }
295}
296
297fn offer_frame(shared: &RecShared, tx: &Sender<Tap>, stripes: &[EncodedStripe]) {
301 if stripes.is_empty() {
302 return;
303 }
304 if stripes.len() != 1
305 || stripes[0].codec != crate::encoders::Codec::H264
306 || stripes[0].stripe_y_start != 0
307 {
308 shared.skipped_non_h264.fetch_add(1, Ordering::Relaxed);
309 return;
310 }
311 let s = &stripes[0];
312 if s.data.is_empty() {
313 return;
314 }
315 let offset = if s.data.len() >= 10 && s.data[0] == 0x04 {
316 10
317 } else {
318 0
319 };
320 if s.data.len() == offset {
321 return;
322 }
323 let pts_us = shared.start.elapsed().as_micros() as u64;
324 match tx.try_send(Tap::Video(s.data.clone(), offset, pts_us)) {
325 Ok(()) => {
326 shared.enqueued.fetch_add(1, Ordering::Relaxed);
327 }
328 Err(TrySendError::Full(_)) => {
329 shared.dropped.fetch_add(1, Ordering::Relaxed);
330 }
331 Err(TrySendError::Disconnected(_)) => {}
332 }
333}
334
335fn writer_thread(
340 rx: Receiver<Tap>,
341 file: std::fs::File,
342 path: String,
343 backend: &'static str,
344 mode_name: &'static str,
345 audio: Option<mp4::AudioTrackConfig>,
346 shared: Arc<RecShared>,
347) {
348 let mut writer = mp4::FragmentWriter::new(std::io::BufWriter::new(file));
349 if let Some(cfg) = audio {
350 writer = writer.with_audio(cfg);
351 }
352 let mut builder = mp4::H264SampleBuilder::new();
353 let mut early_audio: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
354
355 'recv: for item in rx.iter() {
356 let (buf, offset, pts_us) = match item {
357 Tap::Video(buf, offset, pts_us) => (buf, offset, pts_us),
358 Tap::Audio(packet, dts) => {
359 if !writer.init_written() {
360 if early_audio.len() >= EARLY_AUDIO_CAP {
361 early_audio.pop_front();
362 }
363 early_audio.push_back((packet, dts));
364 } else if let Err(e) = writer.push_audio(packet, dts) {
365 shared.set_error(format!("MP4 write failed: {e}"));
366 break 'recv;
367 }
368 shared
369 .audio_muxed
370 .store(writer.stats().audio_samples, Ordering::Relaxed);
371 continue;
372 }
373 };
374 let Some(sample) = builder.build_sample(&buf[offset..]) else {
375 continue;
376 };
377 if !writer.init_written() {
378 if !sample.sync || !builder.have_parameter_sets() {
379 continue;
380 }
381 let Some(cfg) = builder.track_config() else {
382 shared.set_error("failed to parse H.264 SPS for MP4 init".to_string());
383 break 'recv;
384 };
385 shared.width.store(cfg.width, Ordering::Relaxed);
386 shared.height.store(cfg.height, Ordering::Relaxed);
387 if let Err(e) = writer.write_init(&cfg) {
388 shared.set_error(format!("MP4 init write failed: {e}"));
389 break 'recv;
390 }
391 for (packet, dts) in early_audio.drain(..) {
392 if let Err(e) = writer.push_audio(packet, dts) {
393 shared.set_error(format!("MP4 write failed: {e}"));
394 break 'recv;
395 }
396 }
397 }
398 if let Err(e) = writer.push_sample(sample.data, sample.sync, pts_us) {
399 shared.set_error(format!("MP4 write failed: {e}"));
400 break 'recv;
401 }
402 let st = writer.stats();
403 shared.muxed.store(st.samples + 1, Ordering::Relaxed);
405 shared.sync_frames.store(st.sync_samples, Ordering::Relaxed);
406 shared.bytes.store(st.bytes, Ordering::Relaxed);
407 }
408
409 let final_stats = match writer.finish() {
410 Ok(st) => st,
411 Err(e) => {
412 shared.set_error(format!("MP4 finalize failed: {e}"));
413 mp4::Mp4Stats::default()
414 }
415 };
416 if final_stats.samples == 0 {
417 shared.set_error(
418 "no recordable H.264 frames received (the session must produce a single \
419 full-frame H.264 stream; JPEG and striped modes cannot be recorded)"
420 .to_string(),
421 );
422 let _ = std::fs::remove_file(&path);
423 }
424 let status = RecordingStatus {
425 active: false,
426 path,
427 backend,
428 mode: mode_name,
429 frames: final_stats.samples,
430 sync_frames: final_stats.sync_samples,
431 audio_frames: final_stats.audio_samples,
432 dropped: shared.dropped.load(Ordering::Relaxed),
433 skipped_non_h264: shared.skipped_non_h264.load(Ordering::Relaxed),
434 bytes: final_stats.bytes,
435 duration_s: final_stats.duration_us as f64 / 1e6,
436 width: shared.width.load(Ordering::Relaxed),
437 height: shared.height.load(Ordering::Relaxed),
438 error: shared.error.lock().unwrap().clone(),
439 };
440 println!(
441 "[recorder] finished {}: {} frames ({} sync), {} audio packets, {:.2}s, {} bytes",
442 status.path,
443 status.frames,
444 status.sync_frames,
445 status.audio_frames,
446 status.duration_s,
447 status.bytes
448 );
449 *LAST_FINISHED.lock().unwrap() = Some(status);
450}
451
452fn open_audio(path: &str) -> Result<(ogg::PageReader<UnixStream>, ogg::OpusHead), String> {
455 let deadline = Instant::now() + Duration::from_secs(2);
456 let stream = loop {
457 match UnixStream::connect(path) {
458 Ok(s) => break s,
459 Err(e) if Instant::now() < deadline => {
460 let _ = e;
461 thread::sleep(Duration::from_millis(100));
462 }
463 Err(e) => return Err(format!("audio socket {path}: {e}")),
464 }
465 };
466 stream
467 .set_read_timeout(Some(Duration::from_secs(2)))
468 .map_err(|e| e.to_string())?;
469 let mut reader = ogg::PageReader::new(stream);
470 let (first, _) = reader
471 .next_packet()
472 .map_err(|e| format!("audio socket {path}: {e}"))?
473 .ok_or_else(|| format!("audio socket {path} closed before its headers"))?;
474 let head = ogg::parse_opus_head(&first)
475 .ok_or_else(|| format!("audio socket {path} does not start with OpusHead"))?;
476 reader
477 .next_packet()
478 .map_err(|e| format!("audio socket {path}: {e}"))?;
479 Ok((reader, head))
480}
481
482fn page_start(position: u64, granule: u64, samples: u64) -> u64 {
488 granule.saturating_sub(samples).max(position)
489}
490
491fn audio_thread(
496 mut reader: ogg::PageReader<UnixStream>,
497 tx: Sender<Tap>,
498 shared: Arc<RecShared>,
499 stop: Arc<AtomicBool>,
500) {
501 let _ = reader
502 .inner_mut()
503 .set_read_timeout(Some(Duration::from_millis(200)));
504 let mut position: u64 = 0;
505 let mut anchor: Option<(u64, u64)> = None;
506 let mut page: Vec<(Vec<u8>, u64)> = Vec::new();
507 let mut open = true;
508 while open {
509 let granule = if stop.load(Ordering::Relaxed) {
510 None
511 } else {
512 match reader.next_packet() {
513 Ok(Some((packet, granule))) => {
514 let samples = mp4::opus_packet_samples(&packet) as u64;
515 page.push((packet, samples));
516 match granule {
517 Some(granule) => Some(granule),
518 None => continue,
519 }
520 }
521 Ok(None) => None,
522 Err(e)
523 if matches!(
524 e.kind(),
525 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
526 ) =>
527 {
528 continue;
529 }
530 Err(e) => {
531 eprintln!("[recorder] audio stream ended: {e}");
532 None
533 }
534 }
535 };
536 open = granule.is_some();
537 let samples: u64 = page.iter().map(|p| p.1).sum();
538 position = granule.map_or(position, |g| page_start(position, g, samples));
539 let (clock, base) = *anchor.get_or_insert_with(|| {
540 (
541 shared.start.elapsed().as_micros() as u64 * (mp4::OPUS_TIMESCALE as u64)
542 / 1_000_000,
543 position,
544 )
545 });
546 for (packet, samples) in page.drain(..) {
547 let dts = clock + position.saturating_sub(base);
548 position += samples;
549 match tx.try_send(Tap::Audio(packet, dts)) {
550 Ok(()) => {}
551 Err(TrySendError::Full(_)) => {
552 shared.dropped.fetch_add(1, Ordering::Relaxed);
553 }
554 Err(TrySendError::Disconnected(_)) => return,
555 }
556 }
557 }
558}
559
560fn own_capture_settings(opts: &RecordOptions) -> Result<RustCaptureSettings, String> {
564 let mut s = match &opts.capture {
565 Some(explicit) => {
566 if explicit.codec != crate::encoders::Codec::H264 {
567 return Err("recording requires H.264 capture settings (codec='h264')".to_string());
568 }
569 explicit.clone()
570 }
571 None => RustCaptureSettings {
572 width: 0,
573 height: 0,
574 codec: crate::encoders::Codec::H264,
575 capture_cursor: true,
576 target_fps: opts.effective_fps(),
577 ..Default::default()
578 },
579 };
580 s.video_fullframe = true;
581 s.recording_socket = String::new();
582 if opts.fps > 0.0 {
583 s.target_fps = opts.fps;
584 }
585 if opts.bitrate_kbps > 0 {
586 s.video_bitrate_kbps = opts.bitrate_kbps;
587 }
588 if s.keyframe_interval_s <= 0.0 {
589 s.keyframe_interval_s = opts.effective_keyframe_s();
590 }
591 Ok(s)
592}
593
594pub fn start(opts: RecordOptions) -> Result<RecordingStatus, String> {
596 let mut guard = ACTIVE.lock().unwrap();
597 if guard.is_some() {
598 return Err("a recording is already active".to_string());
599 }
600 if opts.path.is_empty() {
601 return Err("recording path is empty".to_string());
602 }
603 if let Some(cap) = &opts.capture
604 && cap.codec != crate::encoders::Codec::H264
605 {
606 return Err("recording requires H.264 capture settings (codec='h264')".to_string());
607 }
608
609 let wl_tx = crate::computer_use::wayland_command_sender();
610 let backend = match opts.backend {
611 Some(PreferredBackend::X11) => PreferredBackend::X11,
612 Some(PreferredBackend::Wayland) => {
613 if wl_tx.is_none() {
614 return Err("no Wayland compositor is running in this process".to_string());
615 }
616 PreferredBackend::Wayland
617 }
618 None => {
619 if wl_tx.is_some() {
620 PreferredBackend::Wayland
621 } else if std::env::var("DISPLAY")
622 .map(|v| !v.is_empty())
623 .unwrap_or(false)
624 {
625 PreferredBackend::X11
626 } else {
627 return Err(
628 "no capture backend available: no in-process Wayland compositor and DISPLAY is unset"
629 .to_string(),
630 );
631 }
632 }
633 };
634
635 let file = std::fs::OpenOptions::new()
638 .write(true)
639 .create(true)
640 .truncate(true)
641 .mode(0o600)
642 .custom_flags(libc::O_NOFOLLOW)
643 .open(&opts.path)
644 .map_err(|e| format!("cannot create {}: {e}", opts.path))?;
645 let shared = RecShared::new();
646 let (tx, rx) = bounded::<Tap>(QUEUE_CAP);
647 let audio_source = if opts.audio_socket.is_empty() {
648 None
649 } else {
650 match open_audio(&opts.audio_socket) {
651 Ok(source) => Some(source),
652 Err(e) => {
653 eprintln!("[recorder] recording without sound: {e}");
654 None
655 }
656 }
657 };
658
659 let (mode, mode_name, backend_name) = match backend {
660 PreferredBackend::Wayland => {
661 let cmd_tx = wl_tx.unwrap();
662 let display_id = opts.display_id;
663 let attached = crate::wayland_alive().lock().unwrap().contains(&display_id);
664 if attached {
665 *WL_TAP.lock().unwrap() = Some(WlTap {
666 display_id,
667 tx: tx.clone(),
668 shared: shared.clone(),
669 idr: Some(IdrRequester {
670 tx: cmd_tx.clone(),
671 display_id,
672 }),
673 keyframe_interval_us: (opts.effective_keyframe_s() * 1e6) as u64,
674 });
675 WL_TAP_ARMED.store(true, Ordering::Relaxed);
676 let _ = cmd_tx.send(ThreadCommand::RequestIdr { display_id });
679 (RecordingMode::WaylandAttached, "attached", "wayland")
680 } else {
681 let mut settings = own_capture_settings(&opts)?;
682 if settings.width <= 0 || settings.height <= 0 {
683 let (reply_tx, reply_rx) = mpsc::channel();
684 cmd_tx
685 .send(ThreadCommand::ListOutputs { reply: reply_tx })
686 .map_err(|_| "wayland compositor is not accepting commands".to_string())?;
687 let outputs = reply_rx
688 .recv_timeout(Duration::from_secs(5))
689 .map_err(|_| "wayland compositor did not report its outputs".to_string())?;
690 let out = outputs
691 .iter()
692 .find(|o| o.0 == display_id)
693 .ok_or_else(|| format!("no wayland output with display id {display_id}"))?;
694 settings.width = out.3;
695 settings.height = out.4;
696 settings.scale = out.5;
697 }
698 *WL_TAP.lock().unwrap() = Some(WlTap {
699 display_id,
700 tx: tx.clone(),
701 shared: shared.clone(),
702 idr: None,
703 keyframe_interval_us: 0,
704 });
705 WL_TAP_ARMED.store(true, Ordering::Relaxed);
706 cmd_tx
707 .send(ThreadCommand::StartCapture {
708 display_id,
709 callback: None,
710 settings,
711 })
712 .map_err(|_| {
713 disarm_tap();
714 "wayland compositor is not accepting commands".to_string()
715 })?;
716 let deadline = Instant::now() + Duration::from_secs(3);
718 loop {
719 if crate::wayland_alive().lock().unwrap().contains(&display_id) {
720 break;
721 }
722 if Instant::now() >= deadline {
723 disarm_tap();
724 return Err(format!(
725 "wayland capture on display {display_id} failed to start"
726 ));
727 }
728 thread::sleep(Duration::from_millis(20));
729 }
730 (
731 RecordingMode::WaylandOwn { display_id },
732 "own-capture",
733 "wayland",
734 )
735 }
736 }
737 PreferredBackend::X11 => {
738 let settings = own_capture_settings(&opts)?;
739 let controls = Arc::new(crate::x11::Controls::new(&settings));
740 crate::live_x11().lock().unwrap().push(controls.clone());
741 let err_slot: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
742 let err_slot2 = err_slot.clone();
743 let (etid_tx, etid_rx) = mpsc::channel();
744 let cap_controls = controls.clone();
745 let feed_tx = tx.clone();
746 let feed_shared = shared.clone();
747 let join = thread::spawn(move || {
748 let on_frame = move |stripes: Vec<EncodedStripe>| {
749 offer_frame(&feed_shared, &feed_tx, &stripes);
750 };
751 if let Err(e) = crate::x11::run_capture(settings, cap_controls, etid_tx, on_frame) {
752 eprintln!("[recorder] x11 capture error: {e}");
753 *err_slot2.lock().unwrap() = Some(e);
754 }
755 });
756 match etid_rx.recv_timeout(Duration::from_secs(3)) {
757 Ok(_) => {}
758 Err(mpsc::RecvTimeoutError::Disconnected) => {
759 let _ = join.join();
760 crate::live_x11()
761 .lock()
762 .unwrap()
763 .retain(|c| !Arc::ptr_eq(c, &controls));
764 let msg = err_slot
765 .lock()
766 .unwrap()
767 .clone()
768 .unwrap_or_else(|| "X11 capture exited during start".to_string());
769 return Err(msg);
770 }
771 Err(mpsc::RecvTimeoutError::Timeout) => {}
773 }
774 (
775 RecordingMode::X11Own { controls, join },
776 "own-capture",
777 "x11",
778 )
779 }
780 };
781
782 let audio_stop = Arc::new(AtomicBool::new(false));
783 let (audio_cfg, audio) = match audio_source {
784 Some((reader, head)) => {
785 let cfg = mp4::AudioTrackConfig {
786 sample_entry: mp4::opus_sample_entry(&head),
787 };
788 let (tx, shared, stop) = (tx.clone(), shared.clone(), audio_stop.clone());
789 let join = thread::Builder::new()
790 .name("pf-recorder-audio".to_string())
791 .spawn(move || audio_thread(reader, tx, shared, stop))
792 .map_err(|e| format!("failed to spawn recorder audio thread: {e}"))?;
793 (Some(cfg), Some(join))
794 }
795 None => (None, None),
796 };
797 let writer = {
798 let shared = shared.clone();
799 let path = opts.path.clone();
800 thread::Builder::new()
801 .name("pf-recorder".to_string())
802 .spawn(move || {
803 writer_thread(rx, file, path, backend_name, mode_name, audio_cfg, shared)
804 })
805 .map_err(|e| format!("failed to spawn recorder writer thread: {e}"))?
806 };
807
808 println!(
809 "[recorder] recording to {} ({} {})",
810 opts.path, backend_name, mode_name
811 );
812 let status = RecordingStatus {
813 active: true,
814 path: opts.path.clone(),
815 backend: backend_name,
816 mode: mode_name,
817 frames: 0,
818 sync_frames: 0,
819 audio_frames: 0,
820 dropped: 0,
821 skipped_non_h264: 0,
822 bytes: 0,
823 duration_s: 0.0,
824 width: 0,
825 height: 0,
826 error: None,
827 };
828 *guard = Some(ActiveRecording {
829 path: opts.path,
830 backend: backend_name,
831 mode_name,
832 mode,
833 tx,
834 shared,
835 writer,
836 audio_stop,
837 audio,
838 });
839 Ok(status)
840}
841
842fn disarm_tap() {
843 WL_TAP_ARMED.store(false, Ordering::Relaxed);
844 *WL_TAP.lock().unwrap() = None;
845}
846
847pub fn stop() -> Result<RecordingStatus, String> {
850 let active = ACTIVE
851 .lock()
852 .unwrap()
853 .take()
854 .ok_or_else(|| "no recording is active".to_string())?;
855 disarm_tap();
856 match active.mode {
857 RecordingMode::X11Own { controls, join } => {
858 controls.stop.store(true, Ordering::Relaxed);
859 let _ = join.join();
860 crate::live_x11()
861 .lock()
862 .unwrap()
863 .retain(|c| !Arc::ptr_eq(c, &controls));
864 }
865 RecordingMode::WaylandOwn { display_id } => {
866 let client_owns = crate::wayland_owners()
869 .lock()
870 .unwrap()
871 .contains_key(&display_id);
872 if !client_owns && let Some(tx) = crate::computer_use::wayland_command_sender() {
873 let _ = tx.send(ThreadCommand::StopCapture { display_id });
874 let (ack_tx, ack_rx) = mpsc::channel();
878 if tx.send(ThreadCommand::Barrier { reply: ack_tx }).is_ok() {
879 let _ = ack_rx.recv_timeout(Duration::from_secs(2));
880 }
881 }
882 }
883 RecordingMode::WaylandAttached => {}
884 }
885 active.audio_stop.store(true, Ordering::Relaxed);
888 if let Some(join) = active.audio {
889 let _ = join.join();
890 }
891 drop(active.tx);
892 let _ = active.writer.join();
893 let finished = LAST_FINISHED
894 .lock()
895 .unwrap()
896 .clone()
897 .ok_or_else(|| format!("recording of {} produced no final status", active.path))?;
898 match finished.error {
899 Some(ref e) => Err(e.clone()),
900 None => Ok(finished),
901 }
902}
903
904pub fn status() -> Option<RecordingStatus> {
907 let guard = ACTIVE.lock().unwrap();
908 if let Some(a) = guard.as_ref() {
909 return Some(RecordingStatus {
910 active: true,
911 path: a.path.clone(),
912 backend: a.backend,
913 mode: a.mode_name,
914 frames: a.shared.muxed.load(Ordering::Relaxed),
915 sync_frames: a.shared.sync_frames.load(Ordering::Relaxed),
916 audio_frames: a.shared.audio_muxed.load(Ordering::Relaxed),
917 dropped: a.shared.dropped.load(Ordering::Relaxed),
918 skipped_non_h264: a.shared.skipped_non_h264.load(Ordering::Relaxed),
919 bytes: a.shared.bytes.load(Ordering::Relaxed),
920 duration_s: a.shared.start.elapsed().as_secs_f64(),
921 width: a.shared.width.load(Ordering::Relaxed),
922 height: a.shared.height.load(Ordering::Relaxed),
923 error: a.shared.error.lock().unwrap().clone(),
924 });
925 }
926 drop(guard);
927 LAST_FINISHED.lock().unwrap().clone()
928}
929
930pub fn finalize_on_exit() {
933 if ACTIVE.lock().unwrap().is_some() {
934 let _ = stop();
935 }
936}
937
938pub fn autostart_from_env() {
942 use std::sync::OnceLock;
943 static STARTED: OnceLock<()> = OnceLock::new();
944 let Ok(path) = std::env::var("PIXELFLUX_RECORD") else {
945 return;
946 };
947 if path.is_empty() {
948 return;
949 }
950 let mut first = false;
951 STARTED.get_or_init(|| first = true);
952 if !first {
953 return;
954 }
955 thread::spawn(move || {
956 let opts = RecordOptions::from_env(path);
957 let deadline = Instant::now() + Duration::from_secs(300);
958 let mut last_err = String::new();
959 loop {
960 let wayland_up = crate::computer_use::wayland_command_sender().is_some();
961 let x11_up = std::env::var("DISPLAY")
962 .map(|v| !v.is_empty())
963 .unwrap_or(false);
964 let ready = match opts.backend {
965 Some(PreferredBackend::X11) => x11_up,
966 Some(PreferredBackend::Wayland) => wayland_up,
967 None => wayland_up || x11_up,
968 };
969 if ready {
970 match start(opts.clone()) {
971 Ok(_) => return,
972 Err(e) => last_err = e,
973 }
974 }
975 if Instant::now() >= deadline {
976 eprintln!(
977 "[recorder] PIXELFLUX_RECORD autostart gave up: {}",
978 if last_err.is_empty() {
979 "no capture backend appeared"
980 } else {
981 &last_err
982 }
983 );
984 return;
985 }
986 thread::sleep(Duration::from_millis(250));
987 }
988 });
989}
990
991pub fn status_to_json(s: &RecordingStatus) -> serde_json::Value {
993 serde_json::json!({
994 "active": s.active,
995 "path": s.path,
996 "backend": s.backend,
997 "mode": s.mode,
998 "frames": s.frames,
999 "sync_frames": s.sync_frames,
1000 "audio_frames": s.audio_frames,
1001 "dropped": s.dropped,
1002 "skipped_non_h264": s.skipped_non_h264,
1003 "bytes": s.bytes,
1004 "duration_s": s.duration_s,
1005 "width": s.width,
1006 "height": s.height,
1007 "error": s.error,
1008 })
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use std::io::Write;
1015
1016 fn ogg_page(granule: u64, packets: &[&[u8]]) -> Vec<u8> {
1017 let mut out = b"OggS\0\0".to_vec();
1018 out.extend_from_slice(&granule.to_le_bytes());
1019 out.extend_from_slice(&[0u8; 12]);
1020 out.push(packets.len() as u8);
1021 out.extend(packets.iter().map(|p| p.len() as u8));
1022 packets.iter().for_each(|p| out.extend_from_slice(p));
1023 out
1024 }
1025
1026 #[test]
1030 fn audio_packets_keep_their_place_across_a_gap() {
1031 let (mut writer, reader) = UnixStream::pair().unwrap();
1032 let packet: &[u8] = &[0xf8, 0, 0];
1033 for (granule, packets) in [(960, 1), (1_920, 1), (50_880, 1), (52_800, 2), (53_000, 1)] {
1034 writer
1035 .write_all(&ogg_page(granule, &vec![packet; packets]))
1036 .unwrap();
1037 }
1038 drop(writer);
1039 let (tx, rx) = bounded::<Tap>(16);
1040 audio_thread(
1041 ogg::PageReader::new(reader),
1042 tx,
1043 RecShared::new(),
1044 Arc::new(AtomicBool::new(false)),
1045 );
1046 let dts: Vec<u64> = rx
1047 .try_iter()
1048 .map(|t| match t {
1049 Tap::Audio(_, dts) => dts,
1050 _ => unreachable!(),
1051 })
1052 .collect();
1053 assert_eq!(
1054 dts.iter().map(|d| d - dts[0]).collect::<Vec<_>>(),
1055 [0, 960, 49_920, 50_880, 51_840, 52_800]
1056 );
1057 }
1058}