Skip to main content

pixelflux/webcam/
mod.rs

1//! Virtual camera: the browser's webcam, delivered to applications as a V4L2 capture device.
2//!
3//! The client encodes its camera (H.264/VP8/… over the WebRTC media track or WebCodecs over the
4//! WebSocket, MJPEG as the last-resort canvas path) and Selkies hands each encoded frame to
5//! [`VirtualCamera::push`], which returns at once. A worker thread decodes, fits the picture into the
6//! device's fixed raw format, and publishes it to every sink at once:
7//!
8//! - the shared-memory ring served over a Unix socket to the Selkies V4L2 interposer
9//!   (`LD_PRELOAD`, no privileges, no kernel module), see [`ring`] and [`server`];
10//! - a v4l2loopback output device where one is configured or found, see [`v4l2out`] — the kernel
11//!   path for hosts and privileged containers, as `/dev/uinput` is for gamepads;
12//! - a PipeWire `Video/Source` node where a daemon is reachable, see [`pipewire`] — for
13//!   PipeWire-native consumers and the `pipewire-v4l2` wrapper.
14//!
15//! Nothing on the hot path holds the GIL beyond copying the encoded bytes out of the Python buffer,
16//! and no worker thread ever calls back into Python.
17
18pub mod convert;
19pub mod decode;
20pub mod pipewire;
21pub mod ring;
22pub mod server;
23pub mod v4l2out;
24
25use std::collections::VecDeque;
26use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
27use std::sync::{Arc, Condvar, Mutex};
28use std::thread::{self, JoinHandle};
29use std::time::{Duration, Instant};
30
31use pyo3::buffer::PyBuffer;
32use pyo3::exceptions::{PyRuntimeError, PyValueError};
33use pyo3::prelude::*;
34use pyo3::types::PyDict;
35
36use convert::{DeviceFormat, Normalizer, Orientation, orient_i420};
37use decode::{Codec, DecodeError, Decoder, new_decoder, sniff_keyframe};
38use pipewire::PipeWireSink;
39use ring::{Ring, RingFormat};
40use server::Server;
41use v4l2out::V4l2Output;
42
43/// Configuration for [`VirtualCamera::start`].
44#[pyclass(dict)]
45pub struct VirtualCameraSettings {
46    /// Unix socket the interposer connects to.
47    #[pyo3(get, set)]
48    pub socket_path: String,
49    /// Advertised frame size; incoming frames are scaled and letterboxed to fit.
50    #[pyo3(get, set)]
51    pub width: u32,
52    #[pyo3(get, set)]
53    pub height: u32,
54    /// Advertised frame rate as a fraction.
55    #[pyo3(get, set)]
56    pub fps_num: u32,
57    #[pyo3(get, set)]
58    pub fps_den: u32,
59    /// Raw device pixel format: "I420", "NV12", or "YUYV".
60    #[pyo3(get, set)]
61    /// Device pixel format: "I420" (the default; the browsers' preference), "NV12", "YUYV",
62    /// or "MJPEG" — a compressed device that carries an MJPEG uplink's frames as received
63    /// (no decode, one copy) and re-encodes only frames that must be fitted into the device
64    /// size or arrive in another codec.
65    pub pixel_format: String,
66    /// Ring slots (2..4); more slots tolerate slower readers.
67    #[pyo3(get, set)]
68    pub slots: u32,
69    /// Encoded frames buffered ahead of the decoder before the oldest is dropped.
70    #[pyo3(get, set)]
71    pub queue_depth: u32,
72    /// v4l2loopback output device: "" for none, "auto" to use the first one found, or a path.
73    #[pyo3(get, set)]
74    pub device_path: String,
75    /// Also publish the camera as a PipeWire `Video/Source` node when a daemon is reachable.
76    #[pyo3(get, set)]
77    pub pipewire: bool,
78    /// PipeWire node name (the description shown to users is fixed).
79    #[pyo3(get, set)]
80    pub pipewire_node_name: String,
81}
82
83#[pymethods]
84impl VirtualCameraSettings {
85    #[new]
86    fn new() -> Self {
87        VirtualCameraSettings {
88            socket_path: format!("{}/selkies_webcam0.sock", crate::webcam::socket_dir()),
89            width: 1280,
90            height: 720,
91            fps_num: 30,
92            fps_den: 1,
93            pixel_format: "I420".into(),
94            slots: 3,
95            queue_depth: 4,
96            device_path: "auto".into(),
97            pipewire: true,
98            pipewire_node_name: "selkies-webcam".into(),
99        }
100    }
101}
102
103/// Where the camera socket lives unless a caller names one: the session's private runtime
104/// directory, where no other account can take the name first, else `/tmp`. The v4l2
105/// interposer looks in the same place.
106pub fn socket_dir() -> String {
107    std::env::var("XDG_RUNTIME_DIR")
108        .ok()
109        .filter(|d| !d.is_empty())
110        .unwrap_or_else(|| "/tmp".into())
111}
112
113fn parse_pixel_format(name: &str) -> Option<u32> {
114    match name.trim().to_ascii_uppercase().as_str() {
115        "I420" | "YU12" | "YUV420" => Some(ring::V4L2_PIX_FMT_YUV420),
116        "NV12" => Some(ring::V4L2_PIX_FMT_NV12),
117        "YUYV" | "YUY2" => Some(ring::V4L2_PIX_FMT_YUYV),
118        "MJPEG" | "MJPG" | "JPEG" => Some(ring::V4L2_PIX_FMT_MJPEG),
119        _ => None,
120    }
121}
122
123/// Counters read by `VirtualCamera.stats()`.
124#[derive(Default)]
125struct Stats {
126    pushed: AtomicU64,
127    decoded: AtomicU64,
128    published: AtomicU64,
129    passthrough: AtomicU64,
130    dropped: AtomicU64,
131    skipped: AtomicU64,
132    errors: AtomicU64,
133    input_width: AtomicU32,
134    input_height: AtomicU32,
135    input_codec: AtomicU32,
136    input_rotation: AtomicU32,
137    /// Whether a PipeWire consumer was linked at the last fan-out. The interposer counts its
138    /// own clients and a kernel device's openers are the kernel's to know, so this is the one
139    /// sink whose consumers only it can see.
140    pipewire_streaming: AtomicBool,
141}
142
143struct Job {
144    codec: Codec,
145    keyframe: bool,
146    orientation: Orientation,
147    data: Vec<u8>,
148}
149
150struct QueueState {
151    jobs: VecDeque<Job>,
152    closed: bool,
153}
154
155/// Bounded hand-off to the decoder thread that drops the oldest frame when the decoder falls
156/// behind: a camera must stay live, never grow latency.
157struct Queue {
158    state: Mutex<QueueState>,
159    cv: Condvar,
160    capacity: usize,
161}
162
163impl Queue {
164    fn new(capacity: usize) -> Self {
165        Queue {
166            state: Mutex::new(QueueState {
167                jobs: VecDeque::new(),
168                closed: false,
169            }),
170            cv: Condvar::new(),
171            capacity: capacity.max(1),
172        }
173    }
174
175    /// Returns the job evicted to make room, if any.
176    fn push(&self, job: Job) -> Option<Job> {
177        let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
178        let evicted = if st.jobs.len() >= self.capacity {
179            st.jobs.pop_front()
180        } else {
181            None
182        };
183        st.jobs.push_back(job);
184        drop(st);
185        self.cv.notify_one();
186        evicted
187    }
188
189    fn pop(&self) -> Option<Job> {
190        let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
191        loop {
192            if let Some(j) = st.jobs.pop_front() {
193                return Some(j);
194            }
195            if st.closed {
196                return None;
197            }
198            st = self.cv.wait(st).unwrap_or_else(|e| e.into_inner());
199        }
200    }
201
202    fn close(&self) {
203        self.state.lock().unwrap_or_else(|e| e.into_inner()).closed = true;
204        self.cv.notify_all();
205    }
206}
207
208/// Recycled encoded-frame buffers so the steady state allocates nothing per frame.
209struct Pool(Mutex<Vec<Vec<u8>>>);
210
211impl Pool {
212    fn take(&self, len: usize) -> Vec<u8> {
213        let mut v = self
214            .0
215            .lock()
216            .unwrap_or_else(|e| e.into_inner())
217            .pop()
218            .unwrap_or_default();
219        v.clear();
220        v.reserve(len);
221        v
222    }
223
224    fn put(&self, v: Vec<u8>) {
225        let mut pool = self.0.lock().unwrap_or_else(|e| e.into_inner());
226        if pool.len() < 8 {
227            pool.push(v);
228        }
229    }
230}
231
232struct Running {
233    queue: Arc<Queue>,
234    thread: Option<JoinHandle<()>>,
235    server: Arc<Server>,
236    keyframe_wanted: Arc<AtomicBool>,
237    pool: Arc<Pool>,
238    device_path: Arc<Mutex<String>>,
239    format: RingFormat,
240    pipewire: bool,
241}
242
243/// A virtual camera fed with encoded frames from Python.
244#[pyclass]
245pub struct VirtualCamera {
246    running: Mutex<Option<Running>>,
247    stats: Arc<Stats>,
248}
249
250fn monotonic_ns() -> u64 {
251    let mut ts = libc::timespec {
252        tv_sec: 0,
253        tv_nsec: 0,
254    };
255    unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
256    ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64
257}
258
259struct WorkerConfig {
260    queue: Arc<Queue>,
261    ring: Ring,
262    server: Arc<Server>,
263    stats: Arc<Stats>,
264    keyframe_wanted: Arc<AtomicBool>,
265    pool: Arc<Pool>,
266    v4l2out: Option<V4l2Output>,
267    device_path: Arc<Mutex<String>>,
268    pipewire: Option<PipeWireSink>,
269}
270
271/// Re-encode quality for frames that must be fitted into an MJPEG device (a camera of another
272/// size, an H.264/VP8 uplink); frames of the device's own size pass through as received.
273const MJPEG_REENCODE_QUALITY: i32 = 85;
274
275/// JPEG writer for the MJPEG device: the fitted, full-range I420 picture compressed straight into
276/// the ring slot.
277struct MjpegEncoder {
278    comp: turbojpeg::Compressor,
279    i420: Vec<u8>,
280}
281
282impl MjpegEncoder {
283    fn new() -> Result<Self, String> {
284        let mut comp =
285            turbojpeg::Compressor::new().map_err(|e| format!("turbojpeg compressor: {}", e))?;
286        comp.set_quality(MJPEG_REENCODE_QUALITY)
287            .map_err(|e| format!("turbojpeg quality: {}", e))?;
288        comp.set_subsamp(turbojpeg::Subsamp::Sub2x2)
289            .map_err(|e| format!("turbojpeg subsampling: {}", e))?;
290        Ok(MjpegEncoder {
291            comp,
292            i420: Vec::new(),
293        })
294    }
295
296    fn write_frame(
297        &mut self,
298        normalizer: &mut Normalizer,
299        src: &convert::I420View<'_>,
300        dev: &DeviceFormat,
301        out: &mut [u8],
302    ) -> usize {
303        let need = dev.frame_bytes();
304        self.i420.resize(need, 0);
305        if normalizer.write_frame(src, dev, &mut self.i420) == 0 {
306            return 0;
307        }
308        let img = turbojpeg::YuvImage {
309            pixels: &self.i420[..],
310            width: dev.width,
311            align: 1,
312            height: dev.height,
313            subsamp: turbojpeg::Subsamp::Sub2x2,
314        };
315        self.comp.compress_yuv_to_slice(img, out).unwrap_or(0)
316    }
317}
318
319/// After a publish: wake the socket clients and mirror the frame into the kernel device and the
320/// PipeWire node where those sinks are up, retiring a sink that failed.
321fn fan_out(
322    ring: &Ring,
323    server: &Server,
324    v4l2out: &mut Option<V4l2Output>,
325    device_path: &Mutex<String>,
326    pipewire: &mut Option<PipeWireSink>,
327    ts: u64,
328    stats: &Stats,
329) {
330    server.ring_doorbell();
331    if let Some(frame) = ring.latest_frame() {
332        if let Some(out) = v4l2out.as_mut() {
333            out.write_frame(frame);
334            if out.is_failed() {
335                device_path
336                    .lock()
337                    .unwrap_or_else(|e| e.into_inner())
338                    .clear();
339                *v4l2out = None;
340            }
341        }
342        if let Some(pw) = pipewire.as_mut() {
343            pw.publish(frame, ts);
344            stats
345                .pipewire_streaming
346                .store(pw.is_streaming(), Ordering::Relaxed);
347            if pw.is_failed() {
348                eprintln!("[webcam] PipeWire node stopped; PipeWire sink disabled");
349                *pipewire = None;
350                stats.pipewire_streaming.store(false, Ordering::Relaxed);
351            }
352        }
353    }
354}
355
356/// Decoder thread body: decode, fit, publish, wake clients. A dropped or undecodable inter-coded
357/// frame parks the stream until the next keyframe and asks the client for one, so a consumer never
358/// sees the smear of predictions built on a missing reference.
359fn worker(cfg: WorkerConfig) {
360    let WorkerConfig {
361        queue,
362        mut ring,
363        server,
364        stats,
365        keyframe_wanted,
366        pool,
367        mut v4l2out,
368        device_path,
369        mut pipewire,
370    } = cfg;
371    let fmt = *ring.format();
372    let dev = DeviceFormat {
373        width: fmt.width as usize,
374        height: fmt.height as usize,
375        fourcc: fmt.fourcc,
376    };
377    let mut normalizer = Normalizer::new();
378    let mut oriented = convert::I420Buffer::new(2, 2);
379    let mut decoder: Option<Box<dyn Decoder>> = None;
380    let mut need_keyframe = true;
381    let mut last_error_log = Instant::now() - Duration::from_secs(60);
382    let log_error = |msg: String, last: &mut Instant| {
383        if last.elapsed() >= Duration::from_secs(5) {
384            eprintln!("[webcam] {}", msg);
385            *last = Instant::now();
386        }
387    };
388    let mjpeg_device = dev.fourcc == ring::V4L2_PIX_FMT_MJPEG;
389    let mut jpeg_out = if mjpeg_device {
390        match MjpegEncoder::new() {
391            Ok(enc) => Some(enc),
392            Err(e) => {
393                eprintln!(
394                    "[webcam] {}; only MJPEG frames of the device size can be served",
395                    e
396                );
397                None
398            }
399        }
400    } else {
401        None
402    };
403
404    while let Some(job) = queue.pop() {
405        if mjpeg_device && job.codec == Codec::Jpeg && job.orientation.is_upright() {
406            // The device speaks the uplink's own format: an upright frame of the device's
407            // size is published as received, undecoded; any other goes through decode and fit.
408            if let Ok(hdr) = turbojpeg::read_header(&job.data) {
409                stats.input_width.store(hdr.width as u32, Ordering::Relaxed);
410                stats
411                    .input_height
412                    .store(hdr.height as u32, Ordering::Relaxed);
413                let n = job.data.len();
414                if hdr.width == dev.width && hdr.height == dev.height && n <= fmt.sizeimage as usize
415                {
416                    let ts = monotonic_ns();
417                    if ring.publish(ts, |slot| {
418                        slot[..n].copy_from_slice(&job.data);
419                        n
420                    }) {
421                        stats.passthrough.fetch_add(1, Ordering::Relaxed);
422                        stats.published.fetch_add(1, Ordering::Relaxed);
423                        fan_out(
424                            &ring,
425                            &server,
426                            &mut v4l2out,
427                            &device_path,
428                            &mut pipewire,
429                            ts,
430                            &stats,
431                        );
432                    }
433                    pool.put(job.data);
434                    continue;
435                }
436            }
437        }
438        if decoder.as_ref().map(|d| d.codec()) != Some(job.codec) {
439            match new_decoder(job.codec) {
440                Ok(d) => decoder = Some(d),
441                Err(e) => {
442                    stats.errors.fetch_add(1, Ordering::Relaxed);
443                    log_error(
444                        format!("no decoder for {}: {}", job.codec.name(), e),
445                        &mut last_error_log,
446                    );
447                    pool.put(job.data);
448                    continue;
449                }
450            }
451            need_keyframe = true;
452        }
453        let dec = decoder.as_mut().expect("decoder present");
454        let keyframe = job.keyframe || sniff_keyframe(job.codec, &job.data).unwrap_or(false);
455        if need_keyframe && job.codec.is_inter_coded() && !keyframe {
456            stats.skipped.fetch_add(1, Ordering::Relaxed);
457            keyframe_wanted.store(true, Ordering::Relaxed);
458            pool.put(job.data);
459            continue;
460        }
461        match dec.decode(&job.data) {
462            Ok(true) => {
463                need_keyframe = false;
464                stats.decoded.fetch_add(1, Ordering::Relaxed);
465                if let Some(view) = dec.frame() {
466                    stats
467                        .input_width
468                        .store(view.width as u32, Ordering::Relaxed);
469                    stats
470                        .input_height
471                        .store(view.height as u32, Ordering::Relaxed);
472                    let view = if job.orientation.is_upright() {
473                        view
474                    } else {
475                        orient_i420(&view, job.orientation, &mut oriented);
476                        oriented.view(view.full_range)
477                    };
478                    let ts = monotonic_ns();
479                    let published = ring.publish(ts, |slot| match jpeg_out.as_mut() {
480                        Some(enc) => enc.write_frame(&mut normalizer, &view, &dev, slot),
481                        None if mjpeg_device => 0,
482                        None => normalizer.write_frame(&view, &dev, slot),
483                    });
484                    if published {
485                        stats.published.fetch_add(1, Ordering::Relaxed);
486                        fan_out(
487                            &ring,
488                            &server,
489                            &mut v4l2out,
490                            &device_path,
491                            &mut pipewire,
492                            ts,
493                            &stats,
494                        );
495                    }
496                }
497            }
498            Ok(false) => {}
499            Err(DecodeError::Corrupt(e)) => {
500                stats.errors.fetch_add(1, Ordering::Relaxed);
501                if job.codec.is_inter_coded() {
502                    need_keyframe = true;
503                    keyframe_wanted.store(true, Ordering::Relaxed);
504                }
505                log_error(
506                    format!("{} decode error: {}", job.codec.name(), e),
507                    &mut last_error_log,
508                );
509            }
510            Err(DecodeError::Fatal(e)) => {
511                stats.errors.fetch_add(1, Ordering::Relaxed);
512                log_error(
513                    format!("{} decoder reset: {}", job.codec.name(), e),
514                    &mut last_error_log,
515                );
516                decoder = None;
517                need_keyframe = true;
518                keyframe_wanted.store(true, Ordering::Relaxed);
519            }
520        }
521        pool.put(job.data);
522    }
523}
524
525#[pymethods]
526impl VirtualCamera {
527    #[classattr]
528    const CODEC_MJPEG: u32 = Codec::Jpeg.id();
529    #[classattr]
530    const CODEC_H264: u32 = Codec::H264.id();
531    #[classattr]
532    const CODEC_VP8: u32 = Codec::Vp8.id();
533    #[classattr]
534    const CODEC_VP9: u32 = Codec::Vp9.id();
535    #[classattr]
536    const CODEC_AV1: u32 = Codec::Av1.id();
537    #[classattr]
538    const CODEC_HEVC: u32 = Codec::H265.id();
539    /// `push()` result bit: the decoder needs a keyframe from the client to resume.
540    #[classattr]
541    const KEYFRAME_WANTED: u32 = 1;
542
543    #[new]
544    fn new() -> Self {
545        VirtualCamera {
546            running: Mutex::new(None),
547            stats: Arc::new(Stats::default()),
548        }
549    }
550
551    /// Bind the socket, allocate the ring, open the kernel device if configured, and start the
552    /// decoder thread. Restarting a running camera stops it first.
553    fn start(&self, py: Python<'_>, settings: &VirtualCameraSettings) -> PyResult<()> {
554        let fourcc = parse_pixel_format(&settings.pixel_format).ok_or_else(|| {
555            PyValueError::new_err(format!(
556                "unsupported pixel_format '{}'",
557                settings.pixel_format
558            ))
559        })?;
560        if settings.width < 2
561            || settings.height < 2
562            || settings.width > 8192
563            || settings.height > 8192
564        {
565            return Err(PyValueError::new_err("width/height out of range"));
566        }
567        if settings.fps_num == 0 || settings.fps_den == 0 {
568            return Err(PyValueError::new_err("fps_num/fps_den must be positive"));
569        }
570        let fmt = RingFormat::for_fourcc(
571            fourcc,
572            settings.width & !1,
573            settings.height & !1,
574            settings.fps_num,
575            settings.fps_den,
576        )
577        .ok_or_else(|| PyValueError::new_err("unsupported pixel_format"))?;
578        let socket_path = settings.socket_path.clone();
579        let slots = settings.slots;
580        let queue_depth = settings.queue_depth.max(1) as usize;
581        let device_setting = settings.device_path.trim().to_string();
582        let want_pipewire = settings.pipewire;
583        let pipewire_node_name = settings.pipewire_node_name.trim().to_string();
584        let stats = self.stats.clone();
585
586        self.stop(py);
587
588        let started = py.detach(move || -> Result<Running, String> {
589            let ring =
590                Ring::new(fmt, slots).map_err(|e| format!("ring allocation failed: {}", e))?;
591            let server = Arc::new(
592                Server::bind(&socket_path, ring.config_bytes(), ring.fd())
593                    .map_err(|e| format!("bind({}) failed: {}", socket_path, e))?,
594            );
595            let v4l2out = match device_setting.as_str() {
596                "" | "false" | "no" | "off" | "none" => None,
597                "auto" | "true" | "yes" | "on" => {
598                    V4l2Output::find_loopback_device().and_then(|p| {
599                        match V4l2Output::open(&p, &fmt) {
600                            Ok(o) => Some(o),
601                            Err(e) => {
602                                eprintln!("[webcam] {}; kernel device sink disabled", e);
603                                None
604                            }
605                        }
606                    })
607                }
608                path => match V4l2Output::open(path, &fmt) {
609                    Ok(o) => Some(o),
610                    Err(e) => {
611                        eprintln!("[webcam] {}; kernel device sink disabled", e);
612                        None
613                    }
614                },
615            };
616            let device_path = Arc::new(Mutex::new(
617                v4l2out
618                    .as_ref()
619                    .map(|o| o.path().to_string())
620                    .unwrap_or_default(),
621            ));
622            let pipewire = if want_pipewire {
623                match PipeWireSink::connect(&pipewire_node_name, "Selkies Virtual Camera", &fmt) {
624                    Ok(sink) => Some(sink),
625                    Err(e) => {
626                        eprintln!("[webcam] PipeWire sink unavailable: {}", e);
627                        None
628                    }
629                }
630            } else {
631                None
632            };
633            let pipewire_on = pipewire.is_some();
634            let queue = Arc::new(Queue::new(queue_depth));
635            let keyframe_wanted = Arc::new(AtomicBool::new(true));
636            let pool = Arc::new(Pool(Mutex::new(Vec::new())));
637            let cfg = WorkerConfig {
638                queue: queue.clone(),
639                ring,
640                server: server.clone(),
641                stats,
642                keyframe_wanted: keyframe_wanted.clone(),
643                pool: pool.clone(),
644                v4l2out,
645                device_path: device_path.clone(),
646                pipewire,
647            };
648            let thread = thread::Builder::new()
649                .name("pixelflux-webcam".into())
650                .spawn(move || worker(cfg))
651                .map_err(|e| format!("decoder thread spawn failed: {}", e))?;
652            Ok(Running {
653                queue,
654                thread: Some(thread),
655                server,
656                keyframe_wanted,
657                pool,
658                device_path,
659                format: fmt,
660                pipewire: pipewire_on,
661            })
662        });
663        match started {
664            Ok(r) => {
665                *self.running.lock().unwrap_or_else(|e| e.into_inner()) = Some(r);
666                Ok(())
667            }
668            Err(e) => Err(PyRuntimeError::new_err(e)),
669        }
670    }
671
672    /// Hand one encoded frame to the decoder. `data` is any buffer-protocol object; the encoded
673    /// payload starts at `offset`. `rotation` (clockwise degrees: 0, 90, 180, or 270) and `flip`
674    /// (a horizontal mirror applied after the rotation) carry the frame's upright transform when
675    /// the client's encoder left it as metadata instead of baking it into the pixels; the decoded
676    /// picture is oriented before it is fitted. Returns a bit set (`KEYFRAME_WANTED`) the caller
677    /// relays to the client. Raises when the camera is not running, the codec id is unknown, or the
678    /// rotation is not a quarter turn.
679    #[allow(clippy::too_many_arguments)]
680    #[pyo3(signature = (data, codec, keyframe = false, offset = 0, rotation = 0, flip = false))]
681    #[allow(clippy::too_many_arguments)]
682    fn push(
683        &self,
684        py: Python<'_>,
685        data: PyBuffer<u8>,
686        codec: u32,
687        keyframe: bool,
688        offset: usize,
689        rotation: u32,
690        flip: bool,
691    ) -> PyResult<u32> {
692        let codec = Codec::from_id(codec)
693            .ok_or_else(|| PyValueError::new_err(format!("unknown codec id {}", codec)))?;
694        if !rotation.is_multiple_of(90) || rotation >= 360 {
695            return Err(PyValueError::new_err("rotation must be 0, 90, 180, or 270"));
696        }
697        let orientation = Orientation {
698            quarter_turns: (rotation / 90) as u8,
699            hflip: flip,
700        };
701        if !data.is_c_contiguous() {
702            return Err(PyValueError::new_err("frame buffer must be contiguous"));
703        }
704        let guard = self.running.lock().unwrap_or_else(|e| e.into_inner());
705        let running = guard
706            .as_ref()
707            .ok_or_else(|| PyRuntimeError::new_err("virtual camera is not running"))?;
708        let total = data.len_bytes();
709        if offset >= total {
710            return Ok(running.keyframe_wanted.swap(false, Ordering::Relaxed) as u32);
711        }
712        let bytes = unsafe { std::slice::from_raw_parts(data.buf_ptr() as *const u8, total) };
713        let mut buf = running.pool.take(total - offset);
714        buf.extend_from_slice(&bytes[offset..]);
715        let _ = py;
716        self.stats.pushed.fetch_add(1, Ordering::Relaxed);
717        self.stats
718            .input_codec
719            .store(codec as u32, Ordering::Relaxed);
720        self.stats.input_rotation.store(rotation, Ordering::Relaxed);
721        if let Some(evicted) = running.queue.push(Job {
722            codec,
723            keyframe,
724            orientation,
725            data: buf,
726        }) {
727            self.stats.dropped.fetch_add(1, Ordering::Relaxed);
728            if evicted.codec.is_inter_coded() {
729                running.keyframe_wanted.store(true, Ordering::Relaxed);
730            }
731            running.pool.put(evicted.data);
732        }
733        Ok(running.keyframe_wanted.swap(false, Ordering::Relaxed) as u32)
734    }
735
736    /// Stop the decoder thread, close every interposer client, and remove the socket.
737    fn stop(&self, py: Python<'_>) {
738        let running = self
739            .running
740            .lock()
741            .unwrap_or_else(|e| e.into_inner())
742            .take();
743        if let Some(mut r) = running {
744            py.detach(move || {
745                r.queue.close();
746                if let Some(t) = r.thread.take() {
747                    let _ = t.join();
748                }
749                drop(r.server);
750            });
751        }
752    }
753
754    #[getter]
755    fn is_running(&self) -> bool {
756        self.running
757            .lock()
758            .unwrap_or_else(|e| e.into_inner())
759            .is_some()
760    }
761
762    /// Interposer clients that completed the handshake.
763    #[getter]
764    fn clients(&self) -> usize {
765        self.running
766            .lock()
767            .unwrap_or_else(|e| e.into_inner())
768            .as_ref()
769            .map(|r| r.server.client_count())
770            .unwrap_or(0)
771    }
772
773    #[getter]
774    fn socket_path(&self) -> Option<String> {
775        self.running
776            .lock()
777            .unwrap_or_else(|e| e.into_inner())
778            .as_ref()
779            .map(|r| r.server.path().to_string())
780    }
781
782    /// Kernel device currently mirrored, or "" when none is in use.
783    #[getter]
784    fn device_path(&self) -> String {
785        self.running
786            .lock()
787            .unwrap_or_else(|e| e.into_inner())
788            .as_ref()
789            .map(|r| {
790                r.device_path
791                    .lock()
792                    .unwrap_or_else(|e| e.into_inner())
793                    .clone()
794            })
795            .unwrap_or_default()
796    }
797
798    /// Counters and the negotiated geometry.
799    fn stats<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
800        let d = PyDict::new(py);
801        let s = &self.stats;
802        d.set_item("pushed", s.pushed.load(Ordering::Relaxed))?;
803        d.set_item("decoded", s.decoded.load(Ordering::Relaxed))?;
804        d.set_item("published", s.published.load(Ordering::Relaxed))?;
805        d.set_item("passthrough", s.passthrough.load(Ordering::Relaxed))?;
806        d.set_item("dropped", s.dropped.load(Ordering::Relaxed))?;
807        d.set_item("skipped", s.skipped.load(Ordering::Relaxed))?;
808        d.set_item("errors", s.errors.load(Ordering::Relaxed))?;
809        d.set_item("input_width", s.input_width.load(Ordering::Relaxed))?;
810        d.set_item("input_height", s.input_height.load(Ordering::Relaxed))?;
811        d.set_item("input_rotation", s.input_rotation.load(Ordering::Relaxed))?;
812        let codec = s.input_codec.load(Ordering::Relaxed);
813        d.set_item(
814            "input_codec",
815            Codec::from_id(codec).map(|c| c.name()).unwrap_or(""),
816        )?;
817        let guard = self.running.lock().unwrap_or_else(|e| e.into_inner());
818        if let Some(r) = guard.as_ref() {
819            d.set_item("clients", r.server.client_count())?;
820            d.set_item("width", r.format.width)?;
821            d.set_item("height", r.format.height)?;
822            d.set_item("fps_num", r.format.fps_num)?;
823            d.set_item("fps_den", r.format.fps_den)?;
824            d.set_item("pixel_format", v4l2out::fourcc_str(r.format.fourcc))?;
825            d.set_item("socket_path", r.server.path())?;
826            d.set_item(
827                "device_path",
828                r.device_path
829                    .lock()
830                    .unwrap_or_else(|e| e.into_inner())
831                    .clone(),
832            )?;
833            d.set_item("pipewire", r.pipewire)?;
834            d.set_item(
835                "pipewire_streaming",
836                s.pipewire_streaming.load(Ordering::Relaxed),
837            )?;
838        } else {
839            d.set_item("clients", 0)?;
840        }
841        Ok(d)
842    }
843
844    /// Byte layout of the shared-memory ring and the on-connect config struct, for the interposer
845    /// ABI tests.
846    #[staticmethod]
847    fn shm_layout<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
848        let d = PyDict::new(py);
849        d.set_item("magic", ring::SHM_MAGIC)?;
850        d.set_item("version", ring::SHM_VERSION)?;
851        d.set_item("ctrl_offset", ring::CTRL_OFFSET)?;
852        d.set_item("ctrl_stride", ring::CTRL_STRIDE)?;
853        d.set_item("data_offset", ring::DATA_OFFSET)?;
854        d.set_item("max_slots", ring::MAX_SLOTS)?;
855        d.set_item("config_size", ring::CONFIG_SIZE)?;
856        d.set_item(
857            "config_fields",
858            [
859                "magic",
860                "version",
861                "width",
862                "height",
863                "fourcc",
864                "fps_num",
865                "fps_den",
866                "n_slots",
867                "slot_size",
868                "data_offset",
869                "ctrl_offset",
870                "ctrl_stride",
871                "bytesperline",
872                "sizeimage",
873            ],
874        )?;
875        d.set_item(
876            "header_fields",
877            [
878                "magic",
879                "version",
880                "width",
881                "height",
882                "fourcc",
883                "fps_num",
884                "fps_den",
885                "n_slots",
886                "slot_size",
887                "data_offset",
888                "bytesperline",
889                "sizeimage",
890                "latest_slot",
891                "_pad",
892            ],
893        )?;
894        d.set_item("header_latest_frame_seq_offset", 56)?;
895        d.set_item(
896            "ctrl_fields",
897            [
898                ("seq", 0, 4),
899                ("bytesused", 4, 4),
900                ("frame_seq", 8, 8),
901                ("ts_ns", 16, 8),
902            ],
903        )?;
904        Ok(d)
905    }
906}
907
908impl Drop for VirtualCamera {
909    fn drop(&mut self) {
910        if let Some(mut r) = self
911            .running
912            .lock()
913            .unwrap_or_else(|e| e.into_inner())
914            .take()
915        {
916            r.queue.close();
917            if let Some(t) = r.thread.take() {
918                let _ = t.join();
919            }
920        }
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    #[test]
929    fn queue_drops_oldest() {
930        let q = Queue::new(2);
931        assert!(
932            q.push(Job {
933                codec: Codec::H264,
934                keyframe: true,
935                orientation: Orientation::UPRIGHT,
936                data: vec![1]
937            })
938            .is_none()
939        );
940        assert!(
941            q.push(Job {
942                codec: Codec::H264,
943                keyframe: false,
944                orientation: Orientation::UPRIGHT,
945                data: vec![2]
946            })
947            .is_none()
948        );
949        let evicted = q
950            .push(Job {
951                codec: Codec::H264,
952                keyframe: false,
953                orientation: Orientation::UPRIGHT,
954                data: vec![3],
955            })
956            .unwrap();
957        assert_eq!(evicted.data, vec![1]);
958        assert_eq!(q.pop().unwrap().data, vec![2]);
959        assert_eq!(q.pop().unwrap().data, vec![3]);
960        q.close();
961        assert!(q.pop().is_none());
962    }
963
964    #[test]
965    fn pixel_format_names() {
966        assert_eq!(parse_pixel_format("i420"), Some(ring::V4L2_PIX_FMT_YUV420));
967        assert_eq!(parse_pixel_format("NV12"), Some(ring::V4L2_PIX_FMT_NV12));
968        assert_eq!(parse_pixel_format(" yuyv "), Some(ring::V4L2_PIX_FMT_YUYV));
969        assert_eq!(parse_pixel_format("MJPG"), Some(ring::V4L2_PIX_FMT_MJPEG));
970        assert_eq!(parse_pixel_format("mjpeg"), Some(ring::V4L2_PIX_FMT_MJPEG));
971        assert_eq!(parse_pixel_format("rgb"), None);
972        let mjpeg = RingFormat::for_fourcc(ring::V4L2_PIX_FMT_MJPEG, 640, 480, 30, 1).unwrap();
973        assert_eq!((mjpeg.bytesperline, mjpeg.sizeimage), (0, 640 * 480 * 2));
974    }
975
976    #[test]
977    fn mjpeg_encoder_writes_a_jpeg_of_the_device_size() {
978        let dev = DeviceFormat {
979            width: 64,
980            height: 32,
981            fourcc: ring::V4L2_PIX_FMT_MJPEG,
982        };
983        let mut src = convert::I420Buffer::new(32, 32);
984        src.data.fill(200);
985        let mut enc = MjpegEncoder::new().unwrap();
986        let mut normalizer = Normalizer::new();
987        let mut slot = vec![0u8; 64 * 32 * 2];
988        let n = enc.write_frame(&mut normalizer, &src.view(true), &dev, &mut slot);
989        assert!(n > 0 && n <= slot.len());
990        let hdr = turbojpeg::read_header(&slot[..n]).unwrap();
991        assert_eq!((hdr.width, hdr.height), (64, 32));
992    }
993}