1pub mod mp4;
35
36use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
37use std::sync::{mpsc, Arc, Mutex};
38use std::thread;
39use std::time::{Duration, Instant};
40
41use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
42
43use crate::encoders::software::EncodedStripe;
44use crate::RustCaptureSettings;
45use crate::ThreadCommand;
46
47const QUEUE_CAP: usize = 256;
50
51type TapFrame = (Arc<Vec<u8>>, usize, u64);
54
55#[derive(Clone, Copy, PartialEq, Eq, Debug)]
57pub enum PreferredBackend {
58 X11,
59 Wayland,
60}
61
62#[derive(Clone)]
65pub struct RecordOptions {
66 pub path: String,
67 pub display_id: u32,
68 pub fps: f64,
71 pub bitrate_kbps: i32,
73 pub keyframe_interval_s: f64,
77 pub backend: Option<PreferredBackend>,
78 pub capture: Option<RustCaptureSettings>,
81}
82
83impl RecordOptions {
84 pub fn from_env(path: String) -> Self {
86 let f = |k: &str| std::env::var(k).ok().and_then(|v| v.parse::<f64>().ok());
87 let backend = match std::env::var("PIXELFLUX_RECORD_BACKEND").ok().as_deref() {
88 Some("x11") => Some(PreferredBackend::X11),
89 Some("wayland") => Some(PreferredBackend::Wayland),
90 _ => None,
91 };
92 Self {
93 path,
94 display_id: f("PIXELFLUX_RECORD_DISPLAY").map(|v| v as u32).unwrap_or(0),
95 fps: f("PIXELFLUX_RECORD_FPS").unwrap_or(0.0),
96 bitrate_kbps: f("PIXELFLUX_RECORD_BITRATE").map(|v| v as i32).unwrap_or(0),
97 keyframe_interval_s: f("PIXELFLUX_RECORD_KEYFRAME_S").unwrap_or(0.0),
98 backend,
99 capture: None,
100 }
101 }
102
103 fn effective_fps(&self) -> f64 {
104 if self.fps > 0.0 { self.fps } else { 30.0 }
105 }
106
107 fn effective_keyframe_s(&self) -> f64 {
108 if self.keyframe_interval_s > 0.0 { self.keyframe_interval_s } else { 2.0 }
109 }
110}
111
112struct RecShared {
115 start: Instant,
116 enqueued: AtomicU64,
117 dropped: AtomicU64,
118 skipped_non_h264: AtomicU64,
119 muxed: AtomicU64,
120 sync_frames: AtomicU64,
121 bytes: AtomicU64,
122 width: AtomicU32,
123 height: AtomicU32,
124 last_idr_req_us: AtomicU64,
125 error: Mutex<Option<String>>,
126}
127
128impl RecShared {
129 fn new() -> Arc<Self> {
130 Arc::new(Self {
131 start: Instant::now(),
132 enqueued: AtomicU64::new(0),
133 dropped: AtomicU64::new(0),
134 skipped_non_h264: AtomicU64::new(0),
135 muxed: AtomicU64::new(0),
136 sync_frames: AtomicU64::new(0),
137 bytes: AtomicU64::new(0),
138 width: AtomicU32::new(0),
139 height: AtomicU32::new(0),
140 last_idr_req_us: AtomicU64::new(0),
141 error: Mutex::new(None),
142 })
143 }
144
145 fn set_error(&self, msg: String) {
146 let mut g = self.error.lock().unwrap();
147 if g.is_none() {
148 eprintln!("[recorder] {msg}");
149 *g = Some(msg);
150 }
151 }
152}
153
154#[derive(Clone, Debug)]
156pub struct RecordingStatus {
157 pub active: bool,
158 pub path: String,
159 pub backend: &'static str,
160 pub mode: &'static str,
161 pub frames: u64,
162 pub sync_frames: u64,
163 pub dropped: u64,
164 pub skipped_non_h264: u64,
165 pub bytes: u64,
166 pub duration_s: f64,
167 pub width: u32,
168 pub height: u32,
169 pub error: Option<String>,
170}
171
172#[derive(Clone)]
175struct WlTap {
176 display_id: u32,
177 tx: Sender<TapFrame>,
178 shared: Arc<RecShared>,
179 idr: Option<IdrRequester>,
182 keyframe_interval_us: u64,
183}
184
185#[derive(Clone)]
187struct IdrRequester {
188 tx: smithay::reexports::calloop::channel::Sender<ThreadCommand>,
189 display_id: u32,
190}
191
192impl IdrRequester {
193 fn request(&self) {
194 let _ = self.tx.send(ThreadCommand::RequestIdr { display_id: self.display_id });
195 }
196}
197
198enum RecordingMode {
200 X11Own {
202 controls: Arc<crate::x11::Controls>,
203 join: thread::JoinHandle<()>,
204 },
205 WaylandOwn { display_id: u32 },
208 WaylandAttached,
210}
211
212struct ActiveRecording {
213 path: String,
214 backend: &'static str,
215 mode_name: &'static str,
216 mode: RecordingMode,
217 tx: Sender<TapFrame>,
218 shared: Arc<RecShared>,
219 writer: thread::JoinHandle<()>,
220}
221
222static ACTIVE: Mutex<Option<ActiveRecording>> = Mutex::new(None);
223static LAST_FINISHED: Mutex<Option<RecordingStatus>> = Mutex::new(None);
224static WL_TAP_ARMED: AtomicBool = AtomicBool::new(false);
227static WL_TAP: Mutex<Option<WlTap>> = Mutex::new(None);
228
229pub(crate) fn wayland_tap(display_id: u32, stripes: &[EncodedStripe]) {
233 if !WL_TAP_ARMED.load(Ordering::Relaxed) {
234 return;
235 }
236 let Some(tap) = WL_TAP.lock().unwrap().clone() else { return };
237 if tap.display_id != display_id {
238 return;
239 }
240 offer_frame(&tap.shared, &tap.tx, stripes);
241 if let Some(ref idr) = tap.idr {
242 pace_idr_requests(&tap.shared, idr, tap.keyframe_interval_us);
243 }
244}
245
246fn pace_idr_requests(shared: &RecShared, idr: &IdrRequester, interval_us: u64) {
249 if interval_us == 0 {
250 return;
251 }
252 let now = shared.start.elapsed().as_micros() as u64;
253 let last = shared.last_idr_req_us.load(Ordering::Relaxed);
254 if now.saturating_sub(last) >= interval_us
255 && shared
256 .last_idr_req_us
257 .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
258 .is_ok()
259 {
260 idr.request();
261 }
262}
263
264fn offer_frame(shared: &RecShared, tx: &Sender<TapFrame>, stripes: &[EncodedStripe]) {
268 if stripes.is_empty() {
269 return;
270 }
271 if stripes.len() != 1 || stripes[0].data_type != 2 || stripes[0].stripe_y_start != 0 {
272 shared.skipped_non_h264.fetch_add(1, Ordering::Relaxed);
273 return;
274 }
275 let s = &stripes[0];
276 if s.data.is_empty() {
277 return;
278 }
279 let offset = if s.data.len() >= 10 && s.data[0] == 0x04 { 10 } else { 0 };
280 if s.data.len() == offset {
281 return;
282 }
283 let pts_us = shared.start.elapsed().as_micros() as u64;
284 match tx.try_send((s.data.clone(), offset, pts_us)) {
285 Ok(()) => {
286 shared.enqueued.fetch_add(1, Ordering::Relaxed);
287 }
288 Err(TrySendError::Full(_)) => {
289 shared.dropped.fetch_add(1, Ordering::Relaxed);
290 }
291 Err(TrySendError::Disconnected(_)) => {}
292 }
293}
294
295fn writer_thread(
300 rx: Receiver<TapFrame>,
301 file: std::fs::File,
302 path: String,
303 backend: &'static str,
304 mode_name: &'static str,
305 shared: Arc<RecShared>,
306) {
307 let mut writer = mp4::FragmentWriter::new(std::io::BufWriter::new(file));
308 let mut builder = mp4::H264SampleBuilder::new();
309
310 'recv: for (buf, offset, pts_us) in rx.iter() {
311 let Some(sample) = builder.build_sample(&buf[offset..]) else { continue };
312 if !writer.init_written() {
313 if !sample.sync || !builder.have_parameter_sets() {
314 continue;
315 }
316 let Some(cfg) = builder.track_config() else {
317 shared.set_error("failed to parse H.264 SPS for MP4 init".to_string());
318 break 'recv;
319 };
320 shared.width.store(cfg.width, Ordering::Relaxed);
321 shared.height.store(cfg.height, Ordering::Relaxed);
322 if let Err(e) = writer.write_init(&cfg) {
323 shared.set_error(format!("MP4 init write failed: {e}"));
324 break 'recv;
325 }
326 }
327 if let Err(e) = writer.push_sample(sample.data, sample.sync, pts_us) {
328 shared.set_error(format!("MP4 write failed: {e}"));
329 break 'recv;
330 }
331 let st = writer.stats();
332 shared.muxed.store(st.samples + 1, Ordering::Relaxed);
334 shared.sync_frames.store(st.sync_samples, Ordering::Relaxed);
335 shared.bytes.store(st.bytes, Ordering::Relaxed);
336 }
337
338 let final_stats = match writer.finish() {
339 Ok(st) => st,
340 Err(e) => {
341 shared.set_error(format!("MP4 finalize failed: {e}"));
342 mp4::Mp4Stats::default()
343 }
344 };
345 if final_stats.samples == 0 {
346 shared.set_error(
347 "no recordable H.264 frames received (the session must produce a single \
348 full-frame H.264 stream; JPEG and striped modes cannot be recorded)"
349 .to_string(),
350 );
351 let _ = std::fs::remove_file(&path);
352 }
353 let status = RecordingStatus {
354 active: false,
355 path,
356 backend,
357 mode: mode_name,
358 frames: final_stats.samples,
359 sync_frames: final_stats.sync_samples,
360 dropped: shared.dropped.load(Ordering::Relaxed),
361 skipped_non_h264: shared.skipped_non_h264.load(Ordering::Relaxed),
362 bytes: final_stats.bytes,
363 duration_s: final_stats.duration_us as f64 / 1e6,
364 width: shared.width.load(Ordering::Relaxed),
365 height: shared.height.load(Ordering::Relaxed),
366 error: shared.error.lock().unwrap().clone(),
367 };
368 println!(
369 "[recorder] finished {}: {} frames ({} sync), {:.2}s, {} bytes",
370 status.path, status.frames, status.sync_frames, status.duration_s, status.bytes
371 );
372 *LAST_FINISHED.lock().unwrap() = Some(status);
373}
374
375fn own_capture_settings(opts: &RecordOptions) -> Result<RustCaptureSettings, String> {
379 let mut s = match &opts.capture {
380 Some(explicit) => {
381 if explicit.output_mode != 1 {
382 return Err(
383 "recording requires H.264 capture settings (output_mode=1); JPEG cannot be recorded"
384 .to_string(),
385 );
386 }
387 explicit.clone()
388 }
389 None => RustCaptureSettings {
390 width: 0,
391 height: 0,
392 output_mode: 1,
393 capture_cursor: true,
394 target_fps: opts.effective_fps(),
395 ..Default::default()
396 },
397 };
398 s.video_fullframe = true;
399 s.recording_socket = String::new();
400 if opts.fps > 0.0 {
401 s.target_fps = opts.fps;
402 }
403 if opts.bitrate_kbps > 0 {
404 s.video_bitrate_kbps = opts.bitrate_kbps;
405 }
406 if s.keyframe_interval_s <= 0.0 {
407 s.keyframe_interval_s = opts.effective_keyframe_s();
408 }
409 Ok(s)
410}
411
412pub fn start(opts: RecordOptions) -> Result<RecordingStatus, String> {
414 let mut guard = ACTIVE.lock().unwrap();
415 if guard.is_some() {
416 return Err("a recording is already active".to_string());
417 }
418 if opts.path.is_empty() {
419 return Err("recording path is empty".to_string());
420 }
421 if let Some(cap) = &opts.capture
422 && cap.output_mode != 1 {
423 return Err(
424 "recording requires H.264 capture settings (output_mode=1); JPEG cannot be recorded"
425 .to_string(),
426 );
427 }
428
429 let wl_tx = crate::computer_use::wayland_command_sender();
430 let backend = match opts.backend {
431 Some(PreferredBackend::X11) => PreferredBackend::X11,
432 Some(PreferredBackend::Wayland) => {
433 if wl_tx.is_none() {
434 return Err("no Wayland compositor is running in this process".to_string());
435 }
436 PreferredBackend::Wayland
437 }
438 None => {
439 if wl_tx.is_some() {
440 PreferredBackend::Wayland
441 } else if std::env::var("DISPLAY").map(|v| !v.is_empty()).unwrap_or(false) {
442 PreferredBackend::X11
443 } else {
444 return Err(
445 "no capture backend available: no in-process Wayland compositor and DISPLAY is unset"
446 .to_string(),
447 );
448 }
449 }
450 };
451
452 let file = std::fs::File::create(&opts.path)
453 .map_err(|e| format!("cannot create {}: {e}", opts.path))?;
454 let shared = RecShared::new();
455 let (tx, rx) = bounded::<TapFrame>(QUEUE_CAP);
456
457 let (mode, mode_name, backend_name) = match backend {
458 PreferredBackend::Wayland => {
459 let cmd_tx = wl_tx.unwrap();
460 let display_id = opts.display_id;
461 let attached = crate::wayland_alive().lock().unwrap().contains(&display_id);
462 if attached {
463 *WL_TAP.lock().unwrap() = Some(WlTap {
464 display_id,
465 tx: tx.clone(),
466 shared: shared.clone(),
467 idr: Some(IdrRequester { tx: cmd_tx.clone(), display_id }),
468 keyframe_interval_us: (opts.effective_keyframe_s() * 1e6) as u64,
469 });
470 WL_TAP_ARMED.store(true, Ordering::Relaxed);
471 let _ = cmd_tx.send(ThreadCommand::RequestIdr { display_id });
474 (RecordingMode::WaylandAttached, "attached", "wayland")
475 } else {
476 let mut settings = own_capture_settings(&opts)?;
477 if settings.width <= 0 || settings.height <= 0 {
478 let (reply_tx, reply_rx) = mpsc::channel();
479 cmd_tx
480 .send(ThreadCommand::ListOutputs { reply: reply_tx })
481 .map_err(|_| "wayland compositor is not accepting commands".to_string())?;
482 let outputs = reply_rx
483 .recv_timeout(Duration::from_secs(5))
484 .map_err(|_| "wayland compositor did not report its outputs".to_string())?;
485 let out = outputs
486 .iter()
487 .find(|o| o.0 == display_id)
488 .ok_or_else(|| format!("no wayland output with display id {display_id}"))?;
489 settings.width = out.3;
490 settings.height = out.4;
491 settings.scale = out.5;
492 }
493 *WL_TAP.lock().unwrap() = Some(WlTap {
494 display_id,
495 tx: tx.clone(),
496 shared: shared.clone(),
497 idr: None,
498 keyframe_interval_us: 0,
499 });
500 WL_TAP_ARMED.store(true, Ordering::Relaxed);
501 cmd_tx
502 .send(ThreadCommand::StartCapture { display_id, callback: None, settings })
503 .map_err(|_| {
504 disarm_tap();
505 "wayland compositor is not accepting commands".to_string()
506 })?;
507 let deadline = Instant::now() + Duration::from_secs(3);
509 loop {
510 if crate::wayland_alive().lock().unwrap().contains(&display_id) {
511 break;
512 }
513 if Instant::now() >= deadline {
514 disarm_tap();
515 return Err(format!(
516 "wayland capture on display {display_id} failed to start"
517 ));
518 }
519 thread::sleep(Duration::from_millis(20));
520 }
521 (RecordingMode::WaylandOwn { display_id }, "own-capture", "wayland")
522 }
523 }
524 PreferredBackend::X11 => {
525 let settings = own_capture_settings(&opts)?;
526 let controls = Arc::new(crate::x11::Controls::new(&settings));
527 crate::live_x11().lock().unwrap().push(controls.clone());
528 let err_slot: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
529 let err_slot2 = err_slot.clone();
530 let (etid_tx, etid_rx) = mpsc::channel();
531 let cap_controls = controls.clone();
532 let feed_tx = tx.clone();
533 let feed_shared = shared.clone();
534 let join = thread::spawn(move || {
535 let on_frame = move |stripes: Vec<EncodedStripe>| {
536 offer_frame(&feed_shared, &feed_tx, &stripes);
537 };
538 if let Err(e) = crate::x11::run_capture(settings, cap_controls, etid_tx, on_frame) {
539 eprintln!("[recorder] x11 capture error: {e}");
540 *err_slot2.lock().unwrap() = Some(e);
541 }
542 });
543 match etid_rx.recv_timeout(Duration::from_secs(3)) {
544 Ok(_) => {}
545 Err(mpsc::RecvTimeoutError::Disconnected) => {
546 let _ = join.join();
547 crate::live_x11().lock().unwrap().retain(|c| !Arc::ptr_eq(c, &controls));
548 let msg = err_slot
549 .lock()
550 .unwrap()
551 .clone()
552 .unwrap_or_else(|| "X11 capture exited during start".to_string());
553 return Err(msg);
554 }
555 Err(mpsc::RecvTimeoutError::Timeout) => {}
557 }
558 (RecordingMode::X11Own { controls, join }, "own-capture", "x11")
559 }
560 };
561
562 let writer = {
563 let shared = shared.clone();
564 let path = opts.path.clone();
565 thread::Builder::new()
566 .name("pf-recorder".to_string())
567 .spawn(move || writer_thread(rx, file, path, backend_name, mode_name, shared))
568 .map_err(|e| format!("failed to spawn recorder writer thread: {e}"))?
569 };
570
571 println!(
572 "[recorder] recording to {} ({} {})",
573 opts.path, backend_name, mode_name
574 );
575 let status = RecordingStatus {
576 active: true,
577 path: opts.path.clone(),
578 backend: backend_name,
579 mode: mode_name,
580 frames: 0,
581 sync_frames: 0,
582 dropped: 0,
583 skipped_non_h264: 0,
584 bytes: 0,
585 duration_s: 0.0,
586 width: 0,
587 height: 0,
588 error: None,
589 };
590 *guard = Some(ActiveRecording {
591 path: opts.path,
592 backend: backend_name,
593 mode_name,
594 mode,
595 tx,
596 shared,
597 writer,
598 });
599 Ok(status)
600}
601
602fn disarm_tap() {
603 WL_TAP_ARMED.store(false, Ordering::Relaxed);
604 *WL_TAP.lock().unwrap() = None;
605}
606
607pub fn stop() -> Result<RecordingStatus, String> {
610 let active = ACTIVE
611 .lock()
612 .unwrap()
613 .take()
614 .ok_or_else(|| "no recording is active".to_string())?;
615 disarm_tap();
616 match active.mode {
617 RecordingMode::X11Own { controls, join } => {
618 controls.stop.store(true, Ordering::Relaxed);
619 let _ = join.join();
620 crate::live_x11().lock().unwrap().retain(|c| !Arc::ptr_eq(c, &controls));
621 }
622 RecordingMode::WaylandOwn { display_id } => {
623 let client_owns = crate::wayland_owners().lock().unwrap().contains_key(&display_id);
626 if !client_owns
627 && let Some(tx) = crate::computer_use::wayland_command_sender() {
628 let _ = tx.send(ThreadCommand::StopCapture { display_id });
629 let (ack_tx, ack_rx) = mpsc::channel();
633 if tx.send(ThreadCommand::Barrier { reply: ack_tx }).is_ok() {
634 let _ = ack_rx.recv_timeout(Duration::from_secs(2));
635 }
636 }
637 }
638 RecordingMode::WaylandAttached => {}
639 }
640 drop(active.tx);
641 let _ = active.writer.join();
642 let finished = LAST_FINISHED.lock().unwrap().clone().ok_or_else(|| {
643 format!("recording of {} produced no final status", active.path)
644 })?;
645 match finished.error {
646 Some(ref e) => Err(e.clone()),
647 None => Ok(finished),
648 }
649}
650
651pub fn status() -> Option<RecordingStatus> {
654 let guard = ACTIVE.lock().unwrap();
655 if let Some(a) = guard.as_ref() {
656 return Some(RecordingStatus {
657 active: true,
658 path: a.path.clone(),
659 backend: a.backend,
660 mode: a.mode_name,
661 frames: a.shared.muxed.load(Ordering::Relaxed),
662 sync_frames: a.shared.sync_frames.load(Ordering::Relaxed),
663 dropped: a.shared.dropped.load(Ordering::Relaxed),
664 skipped_non_h264: a.shared.skipped_non_h264.load(Ordering::Relaxed),
665 bytes: a.shared.bytes.load(Ordering::Relaxed),
666 duration_s: a.shared.start.elapsed().as_secs_f64(),
667 width: a.shared.width.load(Ordering::Relaxed),
668 height: a.shared.height.load(Ordering::Relaxed),
669 error: a.shared.error.lock().unwrap().clone(),
670 });
671 }
672 drop(guard);
673 LAST_FINISHED.lock().unwrap().clone()
674}
675
676pub fn finalize_on_exit() {
679 if ACTIVE.lock().unwrap().is_some() {
680 let _ = stop();
681 }
682}
683
684pub fn autostart_from_env() {
688 use std::sync::OnceLock;
689 static STARTED: OnceLock<()> = OnceLock::new();
690 let Ok(path) = std::env::var("PIXELFLUX_RECORD") else { return };
691 if path.is_empty() {
692 return;
693 }
694 let mut first = false;
695 STARTED.get_or_init(|| first = true);
696 if !first {
697 return;
698 }
699 thread::spawn(move || {
700 let opts = RecordOptions::from_env(path);
701 let deadline = Instant::now() + Duration::from_secs(300);
702 let mut last_err = String::new();
703 loop {
704 let wayland_up = crate::computer_use::wayland_command_sender().is_some();
705 let x11_up = std::env::var("DISPLAY").map(|v| !v.is_empty()).unwrap_or(false);
706 let ready = match opts.backend {
707 Some(PreferredBackend::X11) => x11_up,
708 Some(PreferredBackend::Wayland) => wayland_up,
709 None => wayland_up || x11_up,
710 };
711 if ready {
712 match start(opts.clone()) {
713 Ok(_) => return,
714 Err(e) => last_err = e,
715 }
716 }
717 if Instant::now() >= deadline {
718 eprintln!(
719 "[recorder] PIXELFLUX_RECORD autostart gave up: {}",
720 if last_err.is_empty() { "no capture backend appeared" } else { &last_err }
721 );
722 return;
723 }
724 thread::sleep(Duration::from_millis(250));
725 }
726 });
727}
728
729pub fn status_to_json(s: &RecordingStatus) -> serde_json::Value {
731 serde_json::json!({
732 "active": s.active,
733 "path": s.path,
734 "backend": s.backend,
735 "mode": s.mode,
736 "frames": s.frames,
737 "sync_frames": s.sync_frames,
738 "dropped": s.dropped,
739 "skipped_non_h264": s.skipped_non_h264,
740 "bytes": s.bytes,
741 "duration_s": s.duration_s,
742 "width": s.width,
743 "height": s.height,
744 "error": s.error,
745 })
746}