Skip to main content

pixelflux/recorder/
mod.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! Built-in MP4 recorder: an independent pipeline that captures the desktop to a
8//! ready-to-play fragmented MP4 without needing any connected client.
9//!
10//! One implementation serves three control surfaces — the Python module functions
11//! (`start_recording` / `stop_recording` / `recording_status`), the `PIXELFLUX_RECORD*`
12//! environment variables (record from process start), and the `record_start` /
13//! `record_stop` / `record_status` endpoints on the Computer Use HTTP server — so their
14//! behavior cannot drift.
15//!
16//! The recorder is a consumer of the existing capture machinery, never a hook inside an
17//! encoder:
18//!
19//! * **X11**: it always runs its own [`crate::x11::run_capture`] instance against the root
20//!   window (a second capture of the same root is independent of any streaming session), with
21//!   the encoded frames delivered straight into the recorder's queue.
22//! * **Wayland**: the compositor lives in this process and allows one capture per output, so
23//!   the recorder attaches at the delivery layer. When the output is already being captured
24//!   for a streaming client it taps that stream (and paces recovery keyframes through the
25//!   standard `RequestIdr` command); when the output is idle it starts its own capture with no
26//!   Python callback and taps the identical delivery point.
27//!
28//! Frames cross into the recorder through one bounded queue, mirroring the Unix-socket sink's
29//! isolation contract: the delivery thread only clones an `Arc` and `try_send`s, an overflowing
30//! queue drops frames (never blocks the pipeline), and all muxing happens on the recorder's own
31//! writer thread. Timestamps are wall-clock, so the damage-driven, variable-rate frame flow
32//! lands at its true times in the MP4.
33//!
34//! Sound comes from an Ogg Opus stream on a Unix socket, the shape pcmflux serves, read by a
35//! thread of its own: the `OpusHead` declares the track, and each packet's decode time is
36//! its place under its page's granule position, laid onto the recording clock from the first
37//! packet's arrival.
38
39pub 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
56/// Recorder queue bound, matching the socket sink's stalled-consumer policy: a writer that
57/// falls this far behind loses frames instead of growing memory or blocking the encoder.
58const QUEUE_CAP: usize = 256;
59
60/// What crosses into the writer: a video frame as its `Arc`-shared encoded payload, the byte
61/// offset where the Annex-B stream starts (past the wire header when present), and the
62/// wall-clock capture time; or an audio packet with its decode time on the 48 kHz clock.
63enum Tap {
64    Video(Arc<Vec<u8>>, usize, u64),
65    Audio(Vec<u8>, u64),
66}
67
68/// Audio packets held before the video's first IDR opens the file: ten seconds of 20 ms
69/// packets, the oldest dropped past that.
70const EARLY_AUDIO_CAP: usize = 500;
71
72/// Which capture system feeds the recording.
73#[derive(Clone, Copy, PartialEq, Eq, Debug)]
74pub enum PreferredBackend {
75    X11,
76    Wayland,
77}
78
79/// Resolved recording parameters. Environment variables provide the defaults; explicit
80/// Python/REST arguments override them.
81#[derive(Clone)]
82pub struct RecordOptions {
83    pub path: String,
84    pub display_id: u32,
85    /// Capture fps cap for a recorder-owned capture (`<= 0` = default 30). An attached
86    /// recording follows the live session's rate.
87    pub fps: f64,
88    /// Bitrate override in kbps for a recorder-owned capture (`<= 0` = settings default).
89    pub bitrate_kbps: i32,
90    /// Recovery-keyframe cadence in seconds (`<= 0` = default 2.0). Recorder-owned captures
91    /// carry it in their settings (scheduled IDRs); attached recordings pace standard
92    /// request-IDR commands at this interval.
93    pub keyframe_interval_s: f64,
94    pub backend: Option<PreferredBackend>,
95    /// Full capture settings for a recorder-owned capture; `None` derives them (full root /
96    /// current output geometry, H.264 full-frame).
97    pub capture: Option<RustCaptureSettings>,
98    /// Unix socket serving an Ogg Opus stream to record as the audio track; empty for none.
99    pub audio_socket: String,
100}
101
102impl RecordOptions {
103    /// Options seeded entirely from `PIXELFLUX_RECORD_*` environment variables.
104    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
136/// Live counters shared between the feeding side (delivery threads), the writer thread, and
137/// the status surfaces.
138struct 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/// Point-in-time view of the recorder, identical across the Python, env, and REST surfaces.
181#[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/// The wayland-side tap handle consulted by [`wayland_tap`]; cloneable so the delivery
200/// thread can drop the registry lock before doing any work.
201#[derive(Clone)]
202struct WlTap {
203    display_id: u32,
204    tx: Sender<Tap>,
205    shared: Arc<RecShared>,
206    /// Standard request-IDR path for attached recordings (`None` when the recorder owns the
207    /// capture and scheduled keyframes ride in its settings).
208    idr: Option<IdrRequester>,
209    keyframe_interval_us: u64,
210}
211
212/// Sends `ThreadCommand::RequestIdr` for one display over the compositor's command channel.
213#[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
227/// How the active recording is fed and what must be torn down on stop.
228enum RecordingMode {
229    /// Recorder-owned X11 capture: its controls and capture thread.
230    X11Own {
231        controls: Arc<crate::x11::Controls>,
232        join: thread::JoinHandle<()>,
233    },
234    /// Recorder-owned Wayland capture on `display_id` (stopped unless a streaming client has
235    /// since taken ownership of the display).
236    WaylandOwn { display_id: u32 },
237    /// Tap on a streaming client's live Wayland capture; nothing to stop.
238    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);
255/// Fast idle guard for [`wayland_tap`]: the delivery threads pay one relaxed atomic load
256/// per frame while no recording is armed.
257static WL_TAP_ARMED: AtomicBool = AtomicBool::new(false);
258static WL_TAP: Mutex<Option<WlTap>> = Mutex::new(None);
259
260/// Delivery-layer feed from the Wayland pipelines (readback encode loop and zero-copy
261/// tick). Near-zero cost when idle; while recording it clones an `Arc` into the bounded
262/// queue and never blocks.
263pub(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
279/// Issue a standard request-IDR when the cadence interval has elapsed. Compare-and-swap on
280/// the last-request stamp so concurrent delivery threads emit one request per interval.
281fn 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
297/// Enqueue one delivered frame for muxing. Only a single full-frame H.264 stripe is
298/// recordable; JPEG or striped output is counted and skipped so the surfaces can report a
299/// clear "nothing recordable" error instead of writing a corrupt file.
300fn 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
335/// Writer-thread body: drain the queue, convert Annex-B to AVCC, and mux. Output starts at
336/// the first IDR with parameter sets; earlier frames are discarded and audio packets held.
337/// Runs until every sender is dropped (stop) or a write error occurs, then finalizes the
338/// file and publishes the final status.
339fn 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        // The writer always holds one sample back to learn its duration, so it counts as muxed.
404        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
452/// Connect to the audio sink within a moment and read its headers: the reader positioned
453/// at the first audio packet, and the `OpusHead` the track is declared from.
454fn 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
482/// Where a page's first packet starts, in samples: its granule position counts to the end of
483/// its last packet, so the page starts what its packets hold (`samples`) before that. A
484/// producer that sends nothing through a silence (pcmflux's silence gate) leaves the gap
485/// before the page, which then lands after it rather than against the page before; a final
486/// page whose granule trims its end starts where the one before ended.
487fn page_start(position: u64, granule: u64, samples: u64) -> u64 {
488    granule.saturating_sub(samples).max(position)
489}
490
491/// Audio-thread body: dates each page's packets from `page_start`, laid onto the recording
492/// clock from the first packet's arrival. Packets wait for their page's last, which carries
493/// the granule; an unfinished page's packets at the stream's end follow the page before. Ends
494/// with the stream, or on stop.
495fn 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
560/// Capture settings for a recorder-owned capture: explicit settings when given (validated
561/// H.264), otherwise derived defaults, always forced to the one recordable shape (full-frame
562/// H.264, no socket rebind, scheduled recovery keyframes).
563fn 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
594/// Start a recording. Exactly one may be active per process; returns the initial status.
595pub 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    // The recording is the desktop: readable by its owner alone, and never written through a
636    // link someone else may have planted at a predictable name in a shared directory.
637    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                // The stream is mid-GOP: a standard request-IDR gives the file its first
677                // decodable frame promptly.
678                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                // StartCapture has no reply; liveness shows up in the alive set.
717                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                // Slow X server setup: the capture is still coming up; proceed.
772                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
847/// Stop the active recording, finalize the MP4, and return the final status. Errors when no
848/// recording is active or when nothing recordable was ever received.
849pub 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            // A streaming client that reconfigured this display now owns it; the capture
867            // must survive the recorder's exit.
868            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                // The next start classifies attached-vs-own against wayland_alive:
875                // wait for the compositor to drain the stop, or an immediate restart
876                // attaches to the dying capture and records nothing.
877                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    // The audio thread holds a sender of its own: it goes first, so the writer's queue
886    // closes once the last packet is in.
887    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
904/// The current status: the live recording when one is active, otherwise the last finished
905/// one; `None` when the process has never recorded.
906pub 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
930/// Finalize any active recording at interpreter shutdown so the MP4's last buffered sample
931/// is flushed. Best-effort; the fragmented layout keeps even an unflushed file playable.
932pub fn finalize_on_exit() {
933    if ACTIVE.lock().unwrap().is_some() {
934        let _ = stop();
935    }
936}
937
938/// `PIXELFLUX_RECORD=<path>`: record from process start. X11 (via `DISPLAY`) starts
939/// immediately; otherwise a background thread waits for the in-process Wayland compositor to
940/// come up and then starts. Retries transient failures until the deadline.
941pub 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
991/// Serialize a status into the JSON shape shared by the REST endpoints.
992pub 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    /// Each packet keeps its place under its page's granule: contiguous pages follow each other,
1027    /// the page after a gap a gated producer left sits after that gap, the packets of one page
1028    /// follow one another, and a final page whose granule trims its end follows the one before.
1029    #[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}