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` commands of [`crate::computer_use`] — 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::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
47use std::sync::{Arc, Mutex, mpsc};
48use std::thread;
49use std::time::{Duration, Instant};
50
51use crate::CaptureSettings;
52use crate::ThreadCommand;
53use crate::encoders::software::EncodedStripe;
54
55/// Recorder queue bound, matching the socket sink's stalled-consumer policy: a writer that
56/// falls this far behind loses frames instead of growing memory or blocking the encoder.
57const QUEUE_CAP: usize = 256;
58
59/// What crosses into the writer: a video frame as its `Arc`-shared encoded payload, the byte
60/// offset where the Annex-B stream starts (past the wire header when present), and the
61/// wall-clock capture time; or an audio packet with its decode time on the 48 kHz clock.
62enum Tap {
63    #[expect(
64        clippy::rc_buffer,
65        reason = "the encoder's buffer as it hands it over; an `Arc<[u8]>` would copy every frame"
66    )]
67    Video(Arc<Vec<u8>>, usize, u64),
68    Audio(Vec<u8>, u64),
69}
70
71/// Audio packets held before the video's first IDR opens the file: ten seconds of 20 ms
72/// packets, the oldest dropped past that.
73const EARLY_AUDIO_CAP: usize = 500;
74
75/// Which capture system feeds the recording.
76#[derive(Clone, Copy, PartialEq, Eq, Debug)]
77pub enum PreferredBackend {
78    X11,
79    Wayland,
80}
81
82/// Resolved recording parameters. Environment variables provide the defaults; explicit
83/// Python/REST arguments override them.
84#[derive(Clone)]
85pub struct RecordOptions {
86    pub path: String,
87    pub display_id: u32,
88    /// Capture fps cap for a recorder-owned capture (`<= 0` = default 30). An attached
89    /// recording follows the live session's rate.
90    pub fps: f64,
91    /// Bitrate override in kbps for a recorder-owned capture (`<= 0` = settings default).
92    pub bitrate_kbps: i32,
93    /// Recovery-keyframe cadence in seconds (`<= 0` = default 2.0). Recorder-owned captures
94    /// carry it in their settings (scheduled IDRs); attached recordings pace standard
95    /// request-IDR commands at this interval.
96    pub keyframe_interval_s: f64,
97    pub backend: Option<PreferredBackend>,
98    /// Full capture settings for a recorder-owned capture; `None` derives them (full root /
99    /// current output geometry, H.264 full-frame).
100    pub capture: Option<CaptureSettings>,
101    /// Unix socket serving an Ogg Opus stream to record as the audio track; empty for none.
102    pub audio_socket: String,
103}
104
105impl RecordOptions {
106    /// Options seeded entirely from `PIXELFLUX_RECORD_*` environment variables.
107    pub fn from_env(path: String) -> Self {
108        let f = |k: &str| std::env::var(k).ok().and_then(|v| v.parse::<f64>().ok());
109        let backend = match std::env::var("PIXELFLUX_RECORD_BACKEND").ok().as_deref() {
110            Some("x11") => Some(PreferredBackend::X11),
111            Some("wayland") => Some(PreferredBackend::Wayland),
112            _ => None,
113        };
114        Self {
115            path,
116            display_id: f("PIXELFLUX_RECORD_DISPLAY").map(|v| v as u32).unwrap_or(0),
117            fps: f("PIXELFLUX_RECORD_FPS").unwrap_or(0.0),
118            bitrate_kbps: f("PIXELFLUX_RECORD_BITRATE").map(|v| v as i32).unwrap_or(0),
119            keyframe_interval_s: f("PIXELFLUX_RECORD_KEYFRAME_S").unwrap_or(0.0),
120            backend,
121            capture: None,
122            audio_socket: std::env::var("PIXELFLUX_RECORD_AUDIO").unwrap_or_default(),
123        }
124    }
125
126    fn effective_fps(&self) -> f64 {
127        if self.fps > 0.0 { self.fps } else { 30.0 }
128    }
129
130    fn effective_keyframe_s(&self) -> f64 {
131        if self.keyframe_interval_s > 0.0 {
132            self.keyframe_interval_s
133        } else {
134            2.0
135        }
136    }
137}
138
139/// Live counters shared between the feeding side (delivery threads), the writer thread, and
140/// the status surfaces.
141struct RecShared {
142    start: Instant,
143    enqueued: AtomicU64,
144    dropped: AtomicU64,
145    skipped_non_h264: AtomicU64,
146    muxed: AtomicU64,
147    sync_frames: AtomicU64,
148    audio_muxed: AtomicU64,
149    bytes: AtomicU64,
150    width: AtomicU32,
151    height: AtomicU32,
152    last_idr_req_us: AtomicU64,
153    error: Mutex<Option<String>>,
154}
155
156impl RecShared {
157    fn new() -> Arc<Self> {
158        Arc::new(Self {
159            start: Instant::now(),
160            enqueued: AtomicU64::new(0),
161            dropped: AtomicU64::new(0),
162            skipped_non_h264: AtomicU64::new(0),
163            muxed: AtomicU64::new(0),
164            sync_frames: AtomicU64::new(0),
165            audio_muxed: AtomicU64::new(0),
166            bytes: AtomicU64::new(0),
167            width: AtomicU32::new(0),
168            height: AtomicU32::new(0),
169            last_idr_req_us: AtomicU64::new(0),
170            error: Mutex::new(None),
171        })
172    }
173
174    fn set_error(&self, msg: String) {
175        let mut g = self.error.lock().unwrap();
176        if g.is_none() {
177            eprintln!("[recorder] {msg}");
178            *g = Some(msg);
179        }
180    }
181}
182
183/// Point-in-time view of the recorder, identical across the Python, env, and REST surfaces.
184#[derive(Clone, Debug)]
185pub struct RecordingStatus {
186    pub active: bool,
187    pub path: String,
188    pub backend: &'static str,
189    pub mode: &'static str,
190    pub frames: u64,
191    pub sync_frames: u64,
192    pub audio_frames: u64,
193    pub dropped: u64,
194    pub skipped_non_h264: u64,
195    pub bytes: u64,
196    pub duration_s: f64,
197    pub width: u32,
198    pub height: u32,
199    pub error: Option<String>,
200}
201
202/// The wayland-side tap handle consulted by [`wayland_tap`]; cloneable so the delivery
203/// thread can drop the registry lock before doing any work.
204#[derive(Clone)]
205struct WlTap {
206    display_id: u32,
207    tx: SyncSender<Tap>,
208    shared: Arc<RecShared>,
209    /// Standard request-IDR path for attached recordings (`None` when the recorder owns the
210    /// capture and scheduled keyframes ride in its settings).
211    idr: Option<IdrRequester>,
212    keyframe_interval_us: u64,
213}
214
215/// Sends `ThreadCommand::RequestIdr` for one display over the compositor's command channel.
216#[derive(Clone)]
217struct IdrRequester {
218    tx: smithay::reexports::calloop::channel::Sender<ThreadCommand>,
219    display_id: u32,
220}
221
222impl IdrRequester {
223    fn request(&self) {
224        let _ = self.tx.send(ThreadCommand::RequestIdr {
225            display_id: self.display_id,
226        });
227    }
228}
229
230/// How the active recording is fed and what must be torn down on stop.
231enum RecordingMode {
232    /// Recorder-owned X11 capture: its controls and capture thread.
233    X11Own {
234        controls: Arc<crate::x11::Controls>,
235        join: thread::JoinHandle<()>,
236    },
237    /// Recorder-owned Wayland capture on `display_id` (stopped unless a streaming client has
238    /// since taken ownership of the display).
239    WaylandOwn { display_id: u32 },
240    /// Tap on a streaming client's live Wayland capture; nothing to stop.
241    WaylandAttached,
242}
243
244struct ActiveRecording {
245    path: String,
246    backend: &'static str,
247    mode_name: &'static str,
248    mode: RecordingMode,
249    tx: SyncSender<Tap>,
250    shared: Arc<RecShared>,
251    writer: thread::JoinHandle<()>,
252    audio_stop: Arc<AtomicBool>,
253    audio: Option<thread::JoinHandle<()>>,
254}
255
256static ACTIVE: Mutex<Option<ActiveRecording>> = Mutex::new(None);
257static LAST_FINISHED: Mutex<Option<RecordingStatus>> = Mutex::new(None);
258/// Fast idle guard for [`wayland_tap`]: the delivery threads pay one relaxed atomic load
259/// per frame while no recording is armed.
260static WL_TAP_ARMED: AtomicBool = AtomicBool::new(false);
261static WL_TAP: Mutex<Option<WlTap>> = Mutex::new(None);
262
263/// Delivery-layer feed from the Wayland pipelines (readback encode loop and zero-copy
264/// tick). Near-zero cost when idle; while recording it clones an `Arc` into the bounded
265/// queue and never blocks.
266pub(crate) fn wayland_tap(display_id: u32, stripes: &[EncodedStripe]) {
267    if !WL_TAP_ARMED.load(Ordering::Relaxed) {
268        return;
269    }
270    let Some(tap) = WL_TAP.lock().unwrap().clone() else {
271        return;
272    };
273    if tap.display_id != display_id {
274        return;
275    }
276    offer_frame(&tap.shared, &tap.tx, stripes);
277    if let Some(ref idr) = tap.idr {
278        pace_idr_requests(&tap.shared, idr, tap.keyframe_interval_us);
279    }
280}
281
282/// Issue a standard request-IDR when the cadence interval has elapsed. Compare-and-swap on
283/// the last-request stamp so concurrent delivery threads emit one request per interval.
284fn pace_idr_requests(shared: &RecShared, idr: &IdrRequester, interval_us: u64) {
285    if interval_us == 0 {
286        return;
287    }
288    let now = shared.start.elapsed().as_micros() as u64;
289    let last = shared.last_idr_req_us.load(Ordering::Relaxed);
290    if now.saturating_sub(last) >= interval_us
291        && shared
292            .last_idr_req_us
293            .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
294            .is_ok()
295    {
296        idr.request();
297    }
298}
299
300/// Enqueue one delivered frame for muxing. Only H.264 is recordable; JPEG or another codec is
301/// counted and skipped so the surfaces can report a
302/// clear "nothing recordable" error instead of writing a corrupt file.
303fn offer_frame(shared: &RecShared, tx: &SyncSender<Tap>, stripes: &[EncodedStripe]) {
304    if stripes.is_empty() {
305        return;
306    }
307    if stripes.len() != 1
308        || stripes[0].codec != crate::encoders::Codec::H264
309        || stripes[0].stripe_y_start != 0
310    {
311        shared.skipped_non_h264.fetch_add(1, Ordering::Relaxed);
312        return;
313    }
314    let s = &stripes[0];
315    if s.data.is_empty() {
316        return;
317    }
318    let offset = if s.data.len() >= 10 && s.data[0] == 0x04 {
319        10
320    } else {
321        0
322    };
323    if s.data.len() == offset {
324        return;
325    }
326    let pts_us = shared.start.elapsed().as_micros() as u64;
327    match tx.try_send(Tap::Video(s.data.clone(), offset, pts_us)) {
328        Ok(()) => {
329            shared.enqueued.fetch_add(1, Ordering::Relaxed);
330        }
331        Err(TrySendError::Full(_)) => {
332            shared.dropped.fetch_add(1, Ordering::Relaxed);
333        }
334        Err(TrySendError::Disconnected(_)) => {}
335    }
336}
337
338/// Writer-thread body: drain the queue, convert Annex-B to AVCC, and mux. Output starts at
339/// the first IDR with parameter sets; earlier frames are discarded and audio packets held.
340/// Runs until every sender is dropped (stop) or a write error occurs, then finalizes the
341/// file and publishes the final status.
342fn writer_thread(
343    rx: Receiver<Tap>,
344    file: std::fs::File,
345    path: String,
346    backend: &'static str,
347    mode_name: &'static str,
348    audio: Option<mp4::AudioTrackConfig>,
349    shared: Arc<RecShared>,
350) {
351    let mut writer = mp4::FragmentWriter::new(std::io::BufWriter::new(file));
352    if let Some(cfg) = audio {
353        writer = writer.with_audio(cfg);
354    }
355    let mut builder = mp4::H264SampleBuilder::new();
356    let mut early_audio: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
357
358    'recv: for item in rx.iter() {
359        let (buf, offset, pts_us) = match item {
360            Tap::Video(buf, offset, pts_us) => (buf, offset, pts_us),
361            Tap::Audio(packet, dts) => {
362                if !writer.init_written() {
363                    if early_audio.len() >= EARLY_AUDIO_CAP {
364                        early_audio.pop_front();
365                    }
366                    early_audio.push_back((packet, dts));
367                } else if let Err(e) = writer.push_audio(packet, dts) {
368                    shared.set_error(format!("MP4 write failed: {e}"));
369                    break 'recv;
370                }
371                shared
372                    .audio_muxed
373                    .store(writer.stats().audio_samples, Ordering::Relaxed);
374                continue;
375            }
376        };
377        let Some(sample) = builder.build_sample(&buf[offset..]) else {
378            continue;
379        };
380        if !writer.init_written() {
381            if !sample.sync || !builder.have_parameter_sets() {
382                continue;
383            }
384            let Some(cfg) = builder.track_config() else {
385                shared.set_error("failed to parse H.264 SPS for MP4 init".to_string());
386                break 'recv;
387            };
388            shared.width.store(cfg.width, Ordering::Relaxed);
389            shared.height.store(cfg.height, Ordering::Relaxed);
390            if let Err(e) = writer.write_init(&cfg) {
391                shared.set_error(format!("MP4 init write failed: {e}"));
392                break 'recv;
393            }
394            for (packet, dts) in early_audio.drain(..) {
395                if let Err(e) = writer.push_audio(packet, dts) {
396                    shared.set_error(format!("MP4 write failed: {e}"));
397                    break 'recv;
398                }
399            }
400        }
401        if let Err(e) = writer.push_sample(sample.data, sample.sync, pts_us) {
402            shared.set_error(format!("MP4 write failed: {e}"));
403            break 'recv;
404        }
405        let st = writer.stats();
406        // The writer always holds one sample back to learn its duration, so it counts as muxed.
407        shared.muxed.store(st.samples + 1, Ordering::Relaxed);
408        shared.sync_frames.store(st.sync_samples, Ordering::Relaxed);
409        shared.bytes.store(st.bytes, Ordering::Relaxed);
410    }
411
412    let final_stats = match writer.finish() {
413        Ok(st) => st,
414        Err(e) => {
415            shared.set_error(format!("MP4 finalize failed: {e}"));
416            mp4::Mp4Stats::default()
417        }
418    };
419    if final_stats.samples == 0 {
420        shared.set_error(
421            "no recordable H.264 frames received (the session must produce a single \
422             full-frame H.264 stream; JPEG and the other codecs cannot be recorded)"
423                .to_string(),
424        );
425        let _ = std::fs::remove_file(&path);
426    }
427    let status = RecordingStatus {
428        active: false,
429        path,
430        backend,
431        mode: mode_name,
432        frames: final_stats.samples,
433        sync_frames: final_stats.sync_samples,
434        audio_frames: final_stats.audio_samples,
435        dropped: shared.dropped.load(Ordering::Relaxed),
436        skipped_non_h264: shared.skipped_non_h264.load(Ordering::Relaxed),
437        bytes: final_stats.bytes,
438        duration_s: final_stats.duration_us as f64 / 1e6,
439        width: shared.width.load(Ordering::Relaxed),
440        height: shared.height.load(Ordering::Relaxed),
441        error: shared.error.lock().unwrap().clone(),
442    };
443    println!(
444        "[recorder] finished {}: {} frames ({} sync), {} audio packets, {:.2}s, {} bytes",
445        status.path,
446        status.frames,
447        status.sync_frames,
448        status.audio_frames,
449        status.duration_s,
450        status.bytes
451    );
452    *LAST_FINISHED.lock().unwrap() = Some(status);
453}
454
455/// Connect to the audio sink within a moment and read its headers: the reader positioned
456/// at the first audio packet, and the `OpusHead` the track is declared from.
457fn open_audio(path: &str) -> Result<(ogg::PageReader<UnixStream>, ogg::OpusHead), String> {
458    let deadline = Instant::now() + Duration::from_secs(2);
459    let stream = loop {
460        match UnixStream::connect(path) {
461            Ok(s) => break s,
462            Err(e) if Instant::now() < deadline => {
463                let _ = e;
464                thread::sleep(Duration::from_millis(100));
465            }
466            Err(e) => return Err(format!("audio socket {path}: {e}")),
467        }
468    };
469    stream
470        .set_read_timeout(Some(Duration::from_secs(2)))
471        .map_err(|e| e.to_string())?;
472    let mut reader = ogg::PageReader::new(stream);
473    let (first, _) = reader
474        .next_packet()
475        .map_err(|e| format!("audio socket {path}: {e}"))?
476        .ok_or_else(|| format!("audio socket {path} closed before its headers"))?;
477    let head = ogg::parse_opus_head(&first)
478        .ok_or_else(|| format!("audio socket {path} does not start with OpusHead"))?;
479    reader
480        .next_packet()
481        .map_err(|e| format!("audio socket {path}: {e}"))?;
482    Ok((reader, head))
483}
484
485/// Where a page's first packet starts, in samples: its granule position counts to the end of
486/// its last packet, so the page starts what its packets hold (`samples`) before that. A
487/// producer that sends nothing through a silence (pcmflux's silence gate) leaves the gap
488/// before the page, which then lands after it rather than against the page before; a final
489/// page whose granule trims its end starts where the one before ended.
490fn page_start(position: u64, granule: u64, samples: u64) -> u64 {
491    granule.saturating_sub(samples).max(position)
492}
493
494/// Audio-thread body: dates each page's packets from `page_start`, laid onto the recording
495/// clock from the first packet's arrival. Packets wait for their page's last, which carries
496/// the granule; an unfinished page's packets at the stream's end follow the page before. Ends
497/// with the stream, or on stop.
498fn audio_thread(
499    mut reader: ogg::PageReader<UnixStream>,
500    tx: SyncSender<Tap>,
501    shared: Arc<RecShared>,
502    stop: Arc<AtomicBool>,
503) {
504    let _ = reader
505        .inner_mut()
506        .set_read_timeout(Some(Duration::from_millis(200)));
507    let mut position: u64 = 0;
508    let mut anchor: Option<(u64, u64)> = None;
509    let mut page: Vec<(Vec<u8>, u64)> = Vec::new();
510    let mut open = true;
511    while open {
512        let granule = if stop.load(Ordering::Relaxed) {
513            None
514        } else {
515            match reader.next_packet() {
516                Ok(Some((packet, granule))) => {
517                    let samples = u64::from(mp4::opus_packet_samples(&packet));
518                    page.push((packet, samples));
519                    match granule {
520                        Some(granule) => Some(granule),
521                        None => continue,
522                    }
523                }
524                Ok(None) => None,
525                Err(e)
526                    if matches!(
527                        e.kind(),
528                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
529                    ) =>
530                {
531                    continue;
532                }
533                Err(e) => {
534                    eprintln!("[recorder] audio stream ended: {e}");
535                    None
536                }
537            }
538        };
539        open = granule.is_some();
540        let samples: u64 = page.iter().map(|p| p.1).sum();
541        position = granule.map_or(position, |g| page_start(position, g, samples));
542        let (clock, base) = *anchor.get_or_insert_with(|| {
543            (
544                shared.start.elapsed().as_micros() as u64 * u64::from(mp4::OPUS_TIMESCALE)
545                    / 1_000_000,
546                position,
547            )
548        });
549        for (packet, samples) in page.drain(..) {
550            let dts = clock + position.saturating_sub(base);
551            position += samples;
552            match tx.try_send(Tap::Audio(packet, dts)) {
553                Ok(()) => {}
554                Err(TrySendError::Full(_)) => {
555                    shared.dropped.fetch_add(1, Ordering::Relaxed);
556                }
557                Err(TrySendError::Disconnected(_)) => return,
558            }
559        }
560    }
561}
562
563/// Capture settings for a recorder-owned capture: explicit settings when given (validated
564/// H.264), otherwise derived defaults, always forced to the one recordable shape (full-frame
565/// H.264, no socket rebind, scheduled recovery keyframes).
566fn own_capture_settings(opts: &RecordOptions) -> Result<CaptureSettings, String> {
567    let mut s = match &opts.capture {
568        Some(explicit) => {
569            if explicit.codec != crate::encoders::Codec::H264 {
570                return Err("recording requires H.264 capture settings (codec='h264')".to_string());
571            }
572            explicit.clone()
573        }
574        None => CaptureSettings {
575            width: 0,
576            height: 0,
577            codec: crate::encoders::Codec::H264,
578            capture_cursor: true,
579            target_fps: opts.effective_fps(),
580            ..Default::default()
581        },
582    };
583    s.recording_socket = String::new();
584    if opts.fps > 0.0 {
585        s.target_fps = opts.fps;
586    }
587    if opts.bitrate_kbps > 0 {
588        s.video_bitrate_kbps = opts.bitrate_kbps;
589    }
590    if s.keyframe_interval_s <= 0.0 {
591        s.keyframe_interval_s = opts.effective_keyframe_s();
592    }
593    Ok(s)
594}
595
596/// Start a recording. Exactly one may be active per process; returns the initial status.
597pub fn start(opts: RecordOptions) -> Result<RecordingStatus, String> {
598    let mut guard = ACTIVE.lock().unwrap();
599    if guard.is_some() {
600        return Err("a recording is already active".to_string());
601    }
602    if opts.path.is_empty() {
603        return Err("recording path is empty".to_string());
604    }
605    if let Some(cap) = &opts.capture
606        && cap.codec != crate::encoders::Codec::H264
607    {
608        return Err("recording requires H.264 capture settings (codec='h264')".to_string());
609    }
610
611    let wl_tx = crate::computer_use::wayland_command_sender();
612    let backend = match opts.backend {
613        Some(PreferredBackend::X11) => PreferredBackend::X11,
614        Some(PreferredBackend::Wayland) => {
615            if wl_tx.is_none() {
616                return Err("no Wayland compositor is running in this process".to_string());
617            }
618            PreferredBackend::Wayland
619        }
620        None => {
621            if wl_tx.is_some() {
622                PreferredBackend::Wayland
623            } else if std::env::var("DISPLAY")
624                .map(|v| !v.is_empty())
625                .unwrap_or(false)
626            {
627                PreferredBackend::X11
628            } else {
629                return Err(
630                    "no capture backend available: no in-process Wayland compositor and DISPLAY is unset"
631                        .to_string(),
632                );
633            }
634        }
635    };
636
637    let file = open_recording(&opts.path)?;
638    let shared = RecShared::new();
639    let (tx, rx) = sync_channel::<Tap>(QUEUE_CAP);
640    let audio_source = if opts.audio_socket.is_empty() {
641        None
642    } else {
643        match open_audio(&opts.audio_socket) {
644            Ok(source) => Some(source),
645            Err(e) => {
646                eprintln!("[recorder] recording without sound: {e}");
647                None
648            }
649        }
650    };
651
652    let (mode, mode_name, backend_name) = match backend {
653        PreferredBackend::Wayland => {
654            let cmd_tx = wl_tx.unwrap();
655            let display_id = opts.display_id;
656            let attached = crate::wayland_alive().lock().unwrap().contains(&display_id);
657            if attached {
658                *WL_TAP.lock().unwrap() = Some(WlTap {
659                    display_id,
660                    tx: tx.clone(),
661                    shared: shared.clone(),
662                    idr: Some(IdrRequester {
663                        tx: cmd_tx.clone(),
664                        display_id,
665                    }),
666                    keyframe_interval_us: (opts.effective_keyframe_s() * 1e6) as u64,
667                });
668                WL_TAP_ARMED.store(true, Ordering::Relaxed);
669                // The stream is mid-GOP: a standard request-IDR gives the file its first
670                // decodable frame promptly.
671                let _ = cmd_tx.send(ThreadCommand::RequestIdr { display_id });
672                (RecordingMode::WaylandAttached, "attached", "wayland")
673            } else {
674                let mut settings = own_capture_settings(&opts)?;
675                if settings.width <= 0 || settings.height <= 0 {
676                    let (reply_tx, reply_rx) = mpsc::channel();
677                    cmd_tx
678                        .send(ThreadCommand::ListOutputs { reply: reply_tx })
679                        .map_err(|_| "wayland compositor is not accepting commands".to_string())?;
680                    let outputs = reply_rx
681                        .recv_timeout(Duration::from_secs(5))
682                        .map_err(|_| "wayland compositor did not report its outputs".to_string())?;
683                    let out = outputs
684                        .iter()
685                        .find(|o| o.0 == display_id)
686                        .ok_or_else(|| format!("no wayland output with display id {display_id}"))?;
687                    settings.width = out.3;
688                    settings.height = out.4;
689                    settings.scale = out.5;
690                }
691                *WL_TAP.lock().unwrap() = Some(WlTap {
692                    display_id,
693                    tx: tx.clone(),
694                    shared: shared.clone(),
695                    idr: None,
696                    keyframe_interval_us: 0,
697                });
698                WL_TAP_ARMED.store(true, Ordering::Relaxed);
699                cmd_tx
700                    .send(ThreadCommand::StartCapture {
701                        display_id,
702                        callback: None,
703                        settings,
704                    })
705                    .map_err(|_| {
706                        disarm_tap();
707                        "wayland compositor is not accepting commands".to_string()
708                    })?;
709                // StartCapture has no reply; liveness shows up in the alive set.
710                let deadline = Instant::now() + Duration::from_secs(3);
711                loop {
712                    if crate::wayland_alive().lock().unwrap().contains(&display_id) {
713                        break;
714                    }
715                    if Instant::now() >= deadline {
716                        disarm_tap();
717                        return Err(format!(
718                            "wayland capture on display {display_id} failed to start"
719                        ));
720                    }
721                    thread::sleep(Duration::from_millis(20));
722                }
723                (
724                    RecordingMode::WaylandOwn { display_id },
725                    "own-capture",
726                    "wayland",
727                )
728            }
729        }
730        PreferredBackend::X11 => {
731            let settings = own_capture_settings(&opts)?;
732            let controls = Arc::new(crate::x11::Controls::new(&settings));
733            crate::live_x11().lock().unwrap().push(controls.clone());
734            let err_slot: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
735            let err_slot2 = err_slot.clone();
736            let (etid_tx, etid_rx) = mpsc::channel();
737            let cap_controls = controls.clone();
738            let feed_tx = tx.clone();
739            let feed_shared = shared.clone();
740            let join = thread::spawn(move || {
741                let on_frame = move |stripes: Vec<EncodedStripe>| {
742                    offer_frame(&feed_shared, &feed_tx, &stripes);
743                };
744                if let Err(e) = crate::x11::run_capture(settings, cap_controls, etid_tx, on_frame) {
745                    eprintln!("[recorder] x11 capture error: {e}");
746                    *err_slot2.lock().unwrap() = Some(e);
747                }
748            });
749            match etid_rx.recv_timeout(Duration::from_secs(3)) {
750                Ok(_) => {}
751                Err(mpsc::RecvTimeoutError::Disconnected) => {
752                    let _ = join.join();
753                    crate::live_x11()
754                        .lock()
755                        .unwrap()
756                        .retain(|c| !Arc::ptr_eq(c, &controls));
757                    let msg = err_slot
758                        .lock()
759                        .unwrap()
760                        .clone()
761                        .unwrap_or_else(|| "X11 capture exited during start".to_string());
762                    return Err(msg);
763                }
764                // Slow X server setup: the capture is still coming up; proceed.
765                Err(mpsc::RecvTimeoutError::Timeout) => {}
766            }
767            (
768                RecordingMode::X11Own { controls, join },
769                "own-capture",
770                "x11",
771            )
772        }
773    };
774
775    let audio_stop = Arc::new(AtomicBool::new(false));
776    let (audio_cfg, audio) = match audio_source {
777        Some((reader, head)) => {
778            let cfg = mp4::AudioTrackConfig {
779                sample_entry: mp4::opus_sample_entry(&head),
780            };
781            let (tx, shared, stop) = (tx.clone(), shared.clone(), audio_stop.clone());
782            let join = thread::Builder::new()
783                .name("pf-recorder-audio".to_string())
784                .spawn(move || audio_thread(reader, tx, shared, stop))
785                .map_err(|e| format!("failed to spawn recorder audio thread: {e}"))?;
786            (Some(cfg), Some(join))
787        }
788        None => (None, None),
789    };
790    let writer = {
791        let shared = shared.clone();
792        let path = opts.path.clone();
793        thread::Builder::new()
794            .name("pf-recorder".to_string())
795            .spawn(move || {
796                writer_thread(rx, file, path, backend_name, mode_name, audio_cfg, shared)
797            })
798            .map_err(|e| format!("failed to spawn recorder writer thread: {e}"))?
799    };
800
801    println!(
802        "[recorder] recording to {} ({} {})",
803        opts.path, backend_name, mode_name
804    );
805    let status = RecordingStatus {
806        active: true,
807        path: opts.path.clone(),
808        backend: backend_name,
809        mode: mode_name,
810        frames: 0,
811        sync_frames: 0,
812        audio_frames: 0,
813        dropped: 0,
814        skipped_non_h264: 0,
815        bytes: 0,
816        duration_s: 0.0,
817        width: 0,
818        height: 0,
819        error: None,
820    };
821    *guard = Some(ActiveRecording {
822        path: opts.path,
823        backend: backend_name,
824        mode_name,
825        mode,
826        tx,
827        shared,
828        writer,
829        audio_stop,
830        audio,
831    });
832    Ok(status)
833}
834
835fn disarm_tap() {
836    WL_TAP_ARMED.store(false, Ordering::Relaxed);
837    *WL_TAP.lock().unwrap() = None;
838}
839
840/// Stop the active recording, finalize the MP4, and return the final status. Errors when no
841/// recording is active or when nothing recordable was ever received.
842pub fn stop() -> Result<RecordingStatus, String> {
843    let active = ACTIVE
844        .lock()
845        .unwrap()
846        .take()
847        .ok_or_else(|| "no recording is active".to_string())?;
848    disarm_tap();
849    match active.mode {
850        RecordingMode::X11Own { controls, join } => {
851            controls.stop.store(true, Ordering::Relaxed);
852            let _ = join.join();
853            crate::live_x11()
854                .lock()
855                .unwrap()
856                .retain(|c| !Arc::ptr_eq(c, &controls));
857        }
858        RecordingMode::WaylandOwn { display_id } => {
859            // A streaming client that reconfigured this display now owns it; the capture
860            // must survive the recorder's exit.
861            let client_owns = crate::wayland_owners()
862                .lock()
863                .unwrap()
864                .contains_key(&display_id);
865            if !client_owns && let Some(tx) = crate::computer_use::wayland_command_sender() {
866                let _ = tx.send(ThreadCommand::StopCapture { display_id });
867                // The next start classifies attached-vs-own against wayland_alive:
868                // wait for the compositor to drain the stop, or an immediate restart
869                // attaches to the dying capture and records nothing.
870                let (ack_tx, ack_rx) = mpsc::channel();
871                if tx.send(ThreadCommand::Barrier { reply: ack_tx }).is_ok() {
872                    let _ = ack_rx.recv_timeout(Duration::from_secs(2));
873                }
874            }
875        }
876        RecordingMode::WaylandAttached => {}
877    }
878    // The audio thread holds a sender of its own: it goes first, so the writer's queue
879    // closes once the last packet is in.
880    active.audio_stop.store(true, Ordering::Relaxed);
881    if let Some(join) = active.audio {
882        let _ = join.join();
883    }
884    drop(active.tx);
885    let _ = active.writer.join();
886    let finished = LAST_FINISHED
887        .lock()
888        .unwrap()
889        .clone()
890        .ok_or_else(|| format!("recording of {} produced no final status", active.path))?;
891    match finished.error {
892        Some(ref e) => Err(e.clone()),
893        None => Ok(finished),
894    }
895}
896
897/// The current status: the live recording when one is active, otherwise the last finished
898/// one; `None` when the process has never recorded.
899pub fn status() -> Option<RecordingStatus> {
900    let guard = ACTIVE.lock().unwrap();
901    if let Some(a) = guard.as_ref() {
902        return Some(RecordingStatus {
903            active: true,
904            path: a.path.clone(),
905            backend: a.backend,
906            mode: a.mode_name,
907            frames: a.shared.muxed.load(Ordering::Relaxed),
908            sync_frames: a.shared.sync_frames.load(Ordering::Relaxed),
909            audio_frames: a.shared.audio_muxed.load(Ordering::Relaxed),
910            dropped: a.shared.dropped.load(Ordering::Relaxed),
911            skipped_non_h264: a.shared.skipped_non_h264.load(Ordering::Relaxed),
912            bytes: a.shared.bytes.load(Ordering::Relaxed),
913            duration_s: a.shared.start.elapsed().as_secs_f64(),
914            width: a.shared.width.load(Ordering::Relaxed),
915            height: a.shared.height.load(Ordering::Relaxed),
916            error: a.shared.error.lock().unwrap().clone(),
917        });
918    }
919    drop(guard);
920    LAST_FINISHED.lock().unwrap().clone()
921}
922
923/// Finalize any active recording at interpreter shutdown so the MP4's last buffered sample
924/// is flushed. Best-effort; the fragmented layout keeps even an unflushed file playable.
925pub fn finalize_on_exit() {
926    if ACTIVE.lock().unwrap().is_some() {
927        let _ = stop();
928    }
929}
930
931/// `PIXELFLUX_RECORD=<path>`: record from process start. X11 (via `DISPLAY`) starts
932/// immediately; otherwise a background thread waits for the in-process Wayland compositor to
933/// come up and then starts. Retries transient failures until the deadline.
934pub fn autostart_from_env() {
935    use std::sync::OnceLock;
936    static STARTED: OnceLock<()> = OnceLock::new();
937    let Ok(path) = std::env::var("PIXELFLUX_RECORD") else {
938        return;
939    };
940    if path.is_empty() {
941        return;
942    }
943    let mut first = false;
944    STARTED.get_or_init(|| first = true);
945    if !first {
946        return;
947    }
948    thread::spawn(move || {
949        let opts = RecordOptions::from_env(path);
950        let deadline = Instant::now() + Duration::from_secs(300);
951        let mut last_err = String::new();
952        loop {
953            let wayland_up = crate::computer_use::wayland_command_sender().is_some();
954            let x11_up = std::env::var("DISPLAY")
955                .map(|v| !v.is_empty())
956                .unwrap_or(false);
957            let ready = match opts.backend {
958                Some(PreferredBackend::X11) => x11_up,
959                Some(PreferredBackend::Wayland) => wayland_up,
960                None => wayland_up || x11_up,
961            };
962            if ready {
963                match start(opts.clone()) {
964                    Ok(_) => return,
965                    Err(e) => last_err = e,
966                }
967            }
968            if Instant::now() >= deadline {
969                eprintln!(
970                    "[recorder] PIXELFLUX_RECORD autostart gave up: {}",
971                    if last_err.is_empty() {
972                        "no capture backend appeared"
973                    } else {
974                        &last_err
975                    }
976                );
977                return;
978            }
979            thread::sleep(Duration::from_millis(250));
980        }
981    });
982}
983
984/// Serialize a status into the JSON shape shared by the REST endpoints.
985pub fn status_to_json(s: &RecordingStatus) -> serde_json::Value {
986    serde_json::json!({
987        "active": s.active,
988        "path": s.path,
989        "backend": s.backend,
990        "mode": s.mode,
991        "frames": s.frames,
992        "sync_frames": s.sync_frames,
993        "audio_frames": s.audio_frames,
994        "dropped": s.dropped,
995        "skipped_non_h264": s.skipped_non_h264,
996        "bytes": s.bytes,
997        "duration_s": s.duration_s,
998        "width": s.width,
999        "height": s.height,
1000        "error": s.error,
1001    })
1002}
1003
1004/// Open the recording's file. The recording is the desktop: readable by its owner alone,
1005/// never written through a link someone else may have planted at a predictable name in a
1006/// shared directory, nor into a file there that is another's, or has another name too.
1007fn open_recording(path: &str) -> Result<std::fs::File, String> {
1008    use std::os::unix::fs::{MetadataExt, PermissionsExt};
1009    let file = std::fs::OpenOptions::new()
1010        .write(true)
1011        .create(true)
1012        .truncate(true)
1013        .mode(0o600)
1014        .custom_flags(libc::O_NOFOLLOW)
1015        .open(path)
1016        .map_err(|e| format!("cannot create {path}: {e}"))?;
1017    let meta = file
1018        .metadata()
1019        .map_err(|e| format!("cannot inspect {path}: {e}"))?;
1020    if meta.uid() != unsafe { libc::geteuid() } || meta.nlink() != 1 {
1021        return Err(format!("{path} is not this user's file alone"));
1022    }
1023    file.set_permissions(std::fs::Permissions::from_mode(0o600))
1024        .map_err(|e| format!("cannot restrict {path}: {e}"))?;
1025    Ok(file)
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use std::io::Write;
1032
1033    /// The recording opens only into a file of this user's alone: a new one, or one of its
1034    /// own made private, never through a link nor into a file that has another name too.
1035    #[test]
1036    fn a_recording_opens_only_into_a_file_of_this_users_alone() {
1037        use std::os::unix::fs::PermissionsExt;
1038        let dir = std::env::temp_dir().join(format!("pf-record-open-{}", std::process::id()));
1039        let _ = std::fs::remove_dir_all(&dir);
1040        std::fs::create_dir(&dir).unwrap();
1041        let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
1042        let mode = |name: &str| {
1043            std::fs::metadata(dir.join(name))
1044                .unwrap()
1045                .permissions()
1046                .mode()
1047                & 0o777
1048        };
1049
1050        assert!(open_recording(&p("new.mp4")).is_ok());
1051        assert_eq!(mode("new.mp4"), 0o600);
1052        std::fs::write(dir.join("own.mp4"), b"old").unwrap();
1053        std::fs::set_permissions(dir.join("own.mp4"), std::fs::Permissions::from_mode(0o644))
1054            .unwrap();
1055        assert!(open_recording(&p("own.mp4")).is_ok());
1056        assert_eq!(mode("own.mp4"), 0o600);
1057        std::os::unix::fs::symlink(dir.join("own.mp4"), dir.join("link.mp4")).unwrap();
1058        assert!(open_recording(&p("link.mp4")).is_err());
1059        std::fs::hard_link(dir.join("own.mp4"), dir.join("second-name.mp4")).unwrap();
1060        assert!(open_recording(&p("second-name.mp4")).is_err());
1061        std::fs::remove_dir_all(&dir).unwrap();
1062    }
1063
1064    fn ogg_page(granule: u64, packets: &[&[u8]]) -> Vec<u8> {
1065        let mut out = b"OggS\0\0".to_vec();
1066        out.extend_from_slice(&granule.to_le_bytes());
1067        out.extend_from_slice(&[0u8; 12]);
1068        out.push(packets.len() as u8);
1069        out.extend(packets.iter().map(|p| p.len() as u8));
1070        packets.iter().for_each(|p| out.extend_from_slice(p));
1071        out
1072    }
1073
1074    /// Each packet keeps its place under its page's granule: contiguous pages follow each other,
1075    /// the page after a gap a gated producer left sits after that gap, the packets of one page
1076    /// follow one another, and a final page whose granule trims its end follows the one before.
1077    #[test]
1078    fn audio_packets_keep_their_place_across_a_gap() {
1079        let (mut writer, reader) = UnixStream::pair().unwrap();
1080        let packet: &[u8] = &[0xf8, 0, 0];
1081        for (granule, packets) in [(960, 1), (1_920, 1), (50_880, 1), (52_800, 2), (53_000, 1)] {
1082            writer
1083                .write_all(&ogg_page(granule, &vec![packet; packets]))
1084                .unwrap();
1085        }
1086        drop(writer);
1087        let (tx, rx) = sync_channel::<Tap>(16);
1088        audio_thread(
1089            ogg::PageReader::new(reader),
1090            tx,
1091            RecShared::new(),
1092            Arc::new(AtomicBool::new(false)),
1093        );
1094        let dts: Vec<u64> = rx
1095            .try_iter()
1096            .map(|t| match t {
1097                Tap::Audio(_, dts) => dts,
1098                _ => unreachable!(),
1099            })
1100            .collect();
1101        assert_eq!(
1102            dts.iter().map(|d| d - dts[0]).collect::<Vec<_>>(),
1103            [0, 960, 49_920, 50_880, 51_840, 52_800]
1104        );
1105    }
1106}