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::{orient_i420, DeviceFormat, Normalizer, Orientation};
37use decode::{new_decoder, sniff_keyframe, Codec, DecodeError, Decoder};
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: "/tmp/selkies_webcam0.sock".into(),
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
103fn parse_pixel_format(name: &str) -> Option<u32> {
104    match name.trim().to_ascii_uppercase().as_str() {
105        "I420" | "YU12" | "YUV420" => Some(ring::V4L2_PIX_FMT_YUV420),
106        "NV12" => Some(ring::V4L2_PIX_FMT_NV12),
107        "YUYV" | "YUY2" => Some(ring::V4L2_PIX_FMT_YUYV),
108        "MJPEG" | "MJPG" | "JPEG" => Some(ring::V4L2_PIX_FMT_MJPEG),
109        _ => None,
110    }
111}
112
113/// Counters read by `VirtualCamera.stats()`.
114#[derive(Default)]
115struct Stats {
116    pushed: AtomicU64,
117    decoded: AtomicU64,
118    published: AtomicU64,
119    passthrough: AtomicU64,
120    dropped: AtomicU64,
121    skipped: AtomicU64,
122    errors: AtomicU64,
123    input_width: AtomicU32,
124    input_height: AtomicU32,
125    input_codec: AtomicU32,
126    input_rotation: AtomicU32,
127    /// Whether a PipeWire consumer was linked at the last fan-out. The interposer counts its
128    /// own clients and a kernel device's openers are the kernel's to know, so this is the one
129    /// sink whose consumers only it can see.
130    pipewire_streaming: AtomicBool,
131}
132
133struct Job {
134    codec: Codec,
135    keyframe: bool,
136    orientation: Orientation,
137    data: Vec<u8>,
138}
139
140struct QueueState {
141    jobs: VecDeque<Job>,
142    closed: bool,
143}
144
145/// Bounded hand-off to the decoder thread that drops the oldest frame when the decoder falls
146/// behind: a camera must stay live, never grow latency.
147struct Queue {
148    state: Mutex<QueueState>,
149    cv: Condvar,
150    capacity: usize,
151}
152
153impl Queue {
154    fn new(capacity: usize) -> Self {
155        Queue { state: Mutex::new(QueueState { jobs: VecDeque::new(), closed: false }), cv: Condvar::new(), capacity: capacity.max(1) }
156    }
157
158    /// Returns the job evicted to make room, if any.
159    fn push(&self, job: Job) -> Option<Job> {
160        let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
161        let evicted = if st.jobs.len() >= self.capacity { st.jobs.pop_front() } else { None };
162        st.jobs.push_back(job);
163        drop(st);
164        self.cv.notify_one();
165        evicted
166    }
167
168    fn pop(&self) -> Option<Job> {
169        let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
170        loop {
171            if let Some(j) = st.jobs.pop_front() {
172                return Some(j);
173            }
174            if st.closed {
175                return None;
176            }
177            st = self.cv.wait(st).unwrap_or_else(|e| e.into_inner());
178        }
179    }
180
181    fn close(&self) {
182        self.state.lock().unwrap_or_else(|e| e.into_inner()).closed = true;
183        self.cv.notify_all();
184    }
185}
186
187/// Recycled encoded-frame buffers so the steady state allocates nothing per frame.
188struct Pool(Mutex<Vec<Vec<u8>>>);
189
190impl Pool {
191    fn take(&self, len: usize) -> Vec<u8> {
192        let mut v = self.0.lock().unwrap_or_else(|e| e.into_inner()).pop().unwrap_or_default();
193        v.clear();
194        v.reserve(len);
195        v
196    }
197
198    fn put(&self, v: Vec<u8>) {
199        let mut pool = self.0.lock().unwrap_or_else(|e| e.into_inner());
200        if pool.len() < 8 {
201            pool.push(v);
202        }
203    }
204}
205
206struct Running {
207    queue: Arc<Queue>,
208    thread: Option<JoinHandle<()>>,
209    server: Arc<Server>,
210    keyframe_wanted: Arc<AtomicBool>,
211    pool: Arc<Pool>,
212    device_path: Arc<Mutex<String>>,
213    format: RingFormat,
214    pipewire: bool,
215}
216
217/// A virtual camera fed with encoded frames from Python.
218#[pyclass]
219pub struct VirtualCamera {
220    running: Mutex<Option<Running>>,
221    stats: Arc<Stats>,
222}
223
224fn monotonic_ns() -> u64 {
225    let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
226    unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
227    ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64
228}
229
230struct WorkerConfig {
231    queue: Arc<Queue>,
232    ring: Ring,
233    server: Arc<Server>,
234    stats: Arc<Stats>,
235    keyframe_wanted: Arc<AtomicBool>,
236    pool: Arc<Pool>,
237    v4l2out: Option<V4l2Output>,
238    device_path: Arc<Mutex<String>>,
239    pipewire: Option<PipeWireSink>,
240}
241
242/// Re-encode quality for frames that must be fitted into an MJPEG device (a camera of another
243/// size, an H.264/VP8 uplink); frames of the device's own size pass through as received.
244const MJPEG_REENCODE_QUALITY: i32 = 85;
245
246/// JPEG writer for the MJPEG device: the fitted, full-range I420 picture compressed straight into
247/// the ring slot.
248struct MjpegEncoder {
249    comp: turbojpeg::Compressor,
250    i420: Vec<u8>,
251}
252
253impl MjpegEncoder {
254    fn new() -> Result<Self, String> {
255        let mut comp = turbojpeg::Compressor::new().map_err(|e| format!("turbojpeg compressor: {}", e))?;
256        comp.set_quality(MJPEG_REENCODE_QUALITY).map_err(|e| format!("turbojpeg quality: {}", e))?;
257        comp.set_subsamp(turbojpeg::Subsamp::Sub2x2).map_err(|e| format!("turbojpeg subsampling: {}", e))?;
258        Ok(MjpegEncoder { comp, i420: Vec::new() })
259    }
260
261    fn write_frame(&mut self, normalizer: &mut Normalizer, src: &convert::I420View<'_>, dev: &DeviceFormat, out: &mut [u8]) -> usize {
262        let need = dev.frame_bytes();
263        self.i420.resize(need, 0);
264        if normalizer.write_frame(src, dev, &mut self.i420) == 0 {
265            return 0;
266        }
267        let img = turbojpeg::YuvImage { pixels: &self.i420[..], width: dev.width, align: 1, height: dev.height, subsamp: turbojpeg::Subsamp::Sub2x2 };
268        self.comp.compress_yuv_to_slice(img, out).unwrap_or(0)
269    }
270}
271
272/// After a publish: wake the socket clients and mirror the frame into the kernel device and the
273/// PipeWire node where those sinks are up, retiring a sink that failed.
274fn fan_out(ring: &Ring, server: &Server, v4l2out: &mut Option<V4l2Output>, device_path: &Mutex<String>, pipewire: &mut Option<PipeWireSink>, ts: u64, stats: &Stats) {
275    server.ring_doorbell();
276    if let Some(frame) = ring.latest_frame() {
277        if let Some(out) = v4l2out.as_mut() {
278            out.write_frame(frame);
279            if out.is_failed() {
280                device_path.lock().unwrap_or_else(|e| e.into_inner()).clear();
281                *v4l2out = None;
282            }
283        }
284        if let Some(pw) = pipewire.as_mut() {
285            pw.publish(frame, ts);
286            stats.pipewire_streaming.store(pw.is_streaming(), Ordering::Relaxed);
287            if pw.is_failed() {
288                eprintln!("[webcam] PipeWire node stopped; PipeWire sink disabled");
289                *pipewire = None;
290                stats.pipewire_streaming.store(false, Ordering::Relaxed);
291            }
292        }
293    }
294}
295
296/// Decoder thread body: decode, fit, publish, wake clients. A dropped or undecodable inter-coded
297/// frame parks the stream until the next keyframe and asks the client for one, so a consumer never
298/// sees the smear of predictions built on a missing reference.
299fn worker(cfg: WorkerConfig) {
300    let WorkerConfig { queue, mut ring, server, stats, keyframe_wanted, pool, mut v4l2out, device_path, mut pipewire } = cfg;
301    let fmt = *ring.format();
302    let dev = DeviceFormat { width: fmt.width as usize, height: fmt.height as usize, fourcc: fmt.fourcc };
303    let mut normalizer = Normalizer::new();
304    let mut oriented = convert::I420Buffer::new(2, 2);
305    let mut decoder: Option<Box<dyn Decoder>> = None;
306    let mut need_keyframe = true;
307    let mut last_error_log = Instant::now() - Duration::from_secs(60);
308    let log_error = |msg: String, last: &mut Instant| {
309        if last.elapsed() >= Duration::from_secs(5) {
310            eprintln!("[webcam] {}", msg);
311            *last = Instant::now();
312        }
313    };
314    let mjpeg_device = dev.fourcc == ring::V4L2_PIX_FMT_MJPEG;
315    let mut jpeg_out = if mjpeg_device {
316        match MjpegEncoder::new() {
317            Ok(enc) => Some(enc),
318            Err(e) => {
319                eprintln!("[webcam] {}; only MJPEG frames of the device size can be served", e);
320                None
321            }
322        }
323    } else {
324        None
325    };
326
327    while let Some(job) = queue.pop() {
328        if mjpeg_device && job.codec == Codec::Mjpeg && job.orientation.is_upright() {
329            // The device speaks the uplink's own format: an upright frame of the device's
330            // size is published as received, undecoded; any other goes through decode and fit.
331            if let Ok(hdr) = turbojpeg::read_header(&job.data) {
332                stats.input_width.store(hdr.width as u32, Ordering::Relaxed);
333                stats.input_height.store(hdr.height as u32, Ordering::Relaxed);
334                let n = job.data.len();
335                if hdr.width == dev.width && hdr.height == dev.height && n <= fmt.sizeimage as usize {
336                    let ts = monotonic_ns();
337                    if ring.publish(ts, |slot| {
338                        slot[..n].copy_from_slice(&job.data);
339                        n
340                    }) {
341                        stats.passthrough.fetch_add(1, Ordering::Relaxed);
342                        stats.published.fetch_add(1, Ordering::Relaxed);
343                        fan_out(&ring, &server, &mut v4l2out, &device_path, &mut pipewire, ts, &stats);
344                    }
345                    pool.put(job.data);
346                    continue;
347                }
348            }
349        }
350        if decoder.as_ref().map(|d| d.codec()) != Some(job.codec) {
351            match new_decoder(job.codec) {
352                Ok(d) => decoder = Some(d),
353                Err(e) => {
354                    stats.errors.fetch_add(1, Ordering::Relaxed);
355                    log_error(format!("no decoder for {}: {}", job.codec.name(), e), &mut last_error_log);
356                    pool.put(job.data);
357                    continue;
358                }
359            }
360            need_keyframe = true;
361        }
362        let dec = decoder.as_mut().expect("decoder present");
363        let keyframe = job.keyframe || sniff_keyframe(job.codec, &job.data).unwrap_or(false);
364        if need_keyframe && job.codec.is_inter_coded() && !keyframe {
365            stats.skipped.fetch_add(1, Ordering::Relaxed);
366            keyframe_wanted.store(true, Ordering::Relaxed);
367            pool.put(job.data);
368            continue;
369        }
370        match dec.decode(&job.data) {
371            Ok(true) => {
372                need_keyframe = false;
373                stats.decoded.fetch_add(1, Ordering::Relaxed);
374                if let Some(view) = dec.frame() {
375                    stats.input_width.store(view.width as u32, Ordering::Relaxed);
376                    stats.input_height.store(view.height as u32, Ordering::Relaxed);
377                    let view = if job.orientation.is_upright() {
378                        view
379                    } else {
380                        orient_i420(&view, job.orientation, &mut oriented);
381                        oriented.view(view.full_range)
382                    };
383                    let ts = monotonic_ns();
384                    let published = ring.publish(ts, |slot| match jpeg_out.as_mut() {
385                        Some(enc) => enc.write_frame(&mut normalizer, &view, &dev, slot),
386                        None if mjpeg_device => 0,
387                        None => normalizer.write_frame(&view, &dev, slot),
388                    });
389                    if published {
390                        stats.published.fetch_add(1, Ordering::Relaxed);
391                        fan_out(&ring, &server, &mut v4l2out, &device_path, &mut pipewire, ts, &stats);
392                    }
393                }
394            }
395            Ok(false) => {}
396            Err(DecodeError::Corrupt(e)) => {
397                stats.errors.fetch_add(1, Ordering::Relaxed);
398                if job.codec.is_inter_coded() {
399                    need_keyframe = true;
400                    keyframe_wanted.store(true, Ordering::Relaxed);
401                }
402                log_error(format!("{} decode error: {}", job.codec.name(), e), &mut last_error_log);
403            }
404            Err(DecodeError::Fatal(e)) => {
405                stats.errors.fetch_add(1, Ordering::Relaxed);
406                log_error(format!("{} decoder reset: {}", job.codec.name(), e), &mut last_error_log);
407                decoder = None;
408                need_keyframe = true;
409                keyframe_wanted.store(true, Ordering::Relaxed);
410            }
411        }
412        pool.put(job.data);
413    }
414}
415
416#[pymethods]
417impl VirtualCamera {
418    #[classattr]
419    const CODEC_MJPEG: u32 = Codec::Mjpeg as u32;
420    #[classattr]
421    const CODEC_H264: u32 = Codec::H264 as u32;
422    #[classattr]
423    const CODEC_VP8: u32 = Codec::Vp8 as u32;
424    #[classattr]
425    const CODEC_VP9: u32 = Codec::Vp9 as u32;
426    #[classattr]
427    const CODEC_AV1: u32 = Codec::Av1 as u32;
428    #[classattr]
429    const CODEC_HEVC: u32 = Codec::Hevc as u32;
430    /// `push()` result bit: the decoder needs a keyframe from the client to resume.
431    #[classattr]
432    const KEYFRAME_WANTED: u32 = 1;
433
434    #[new]
435    fn new() -> Self {
436        VirtualCamera { running: Mutex::new(None), stats: Arc::new(Stats::default()) }
437    }
438
439    /// Bind the socket, allocate the ring, open the kernel device if configured, and start the
440    /// decoder thread. Restarting a running camera stops it first.
441    fn start(&self, py: Python<'_>, settings: &VirtualCameraSettings) -> PyResult<()> {
442        let fourcc = parse_pixel_format(&settings.pixel_format)
443            .ok_or_else(|| PyValueError::new_err(format!("unsupported pixel_format '{}'", settings.pixel_format)))?;
444        if settings.width < 2 || settings.height < 2 || settings.width > 8192 || settings.height > 8192 {
445            return Err(PyValueError::new_err("width/height out of range"));
446        }
447        if settings.fps_num == 0 || settings.fps_den == 0 {
448            return Err(PyValueError::new_err("fps_num/fps_den must be positive"));
449        }
450        let fmt = RingFormat::for_fourcc(fourcc, settings.width & !1, settings.height & !1, settings.fps_num, settings.fps_den)
451            .ok_or_else(|| PyValueError::new_err("unsupported pixel_format"))?;
452        let socket_path = settings.socket_path.clone();
453        let slots = settings.slots;
454        let queue_depth = settings.queue_depth.max(1) as usize;
455        let device_setting = settings.device_path.trim().to_string();
456        let want_pipewire = settings.pipewire;
457        let pipewire_node_name = settings.pipewire_node_name.trim().to_string();
458        let stats = self.stats.clone();
459
460        self.stop(py);
461
462        let started = py.detach(move || -> Result<Running, String> {
463            let ring = Ring::new(fmt, slots).map_err(|e| format!("ring allocation failed: {}", e))?;
464            let server = Arc::new(Server::bind(&socket_path, ring.config_bytes(), ring.fd())
465                .map_err(|e| format!("bind({}) failed: {}", socket_path, e))?);
466            let v4l2out = match device_setting.as_str() {
467                "" | "false" | "no" | "off" | "none" => None,
468                "auto" | "true" | "yes" | "on" => V4l2Output::find_loopback_device().and_then(|p| match V4l2Output::open(&p, &fmt) {
469                    Ok(o) => Some(o),
470                    Err(e) => {
471                        eprintln!("[webcam] {}; kernel device sink disabled", e);
472                        None
473                    }
474                }),
475                path => match V4l2Output::open(path, &fmt) {
476                    Ok(o) => Some(o),
477                    Err(e) => {
478                        eprintln!("[webcam] {}; kernel device sink disabled", e);
479                        None
480                    }
481                },
482            };
483            let device_path = Arc::new(Mutex::new(v4l2out.as_ref().map(|o| o.path().to_string()).unwrap_or_default()));
484            let pipewire = if want_pipewire {
485                match PipeWireSink::connect(&pipewire_node_name, "Selkies Virtual Camera", &fmt) {
486                    Ok(sink) => Some(sink),
487                    Err(e) => {
488                        eprintln!("[webcam] PipeWire sink unavailable: {}", e);
489                        None
490                    }
491                }
492            } else {
493                None
494            };
495            let pipewire_on = pipewire.is_some();
496            let queue = Arc::new(Queue::new(queue_depth));
497            let keyframe_wanted = Arc::new(AtomicBool::new(true));
498            let pool = Arc::new(Pool(Mutex::new(Vec::new())));
499            let cfg = WorkerConfig {
500                queue: queue.clone(),
501                ring,
502                server: server.clone(),
503                stats,
504                keyframe_wanted: keyframe_wanted.clone(),
505                pool: pool.clone(),
506                v4l2out,
507                device_path: device_path.clone(),
508                pipewire,
509            };
510            let thread = thread::Builder::new()
511                .name("pixelflux-webcam".into())
512                .spawn(move || worker(cfg))
513                .map_err(|e| format!("decoder thread spawn failed: {}", e))?;
514            Ok(Running { queue, thread: Some(thread), server, keyframe_wanted, pool, device_path, format: fmt, pipewire: pipewire_on })
515        });
516        match started {
517            Ok(r) => {
518                *self.running.lock().unwrap_or_else(|e| e.into_inner()) = Some(r);
519                Ok(())
520            }
521            Err(e) => Err(PyRuntimeError::new_err(e)),
522        }
523    }
524
525    /// Hand one encoded frame to the decoder. `data` is any buffer-protocol object; the encoded
526    /// payload starts at `offset`. `rotation` (clockwise degrees: 0, 90, 180 or 270) and `flip`
527    /// (a horizontal mirror applied after the rotation) carry the frame's upright transform when
528    /// the client's encoder left it as metadata instead of baking it into the pixels; the decoded
529    /// picture is oriented before it is fitted. Returns a bit set (`KEYFRAME_WANTED`) the caller
530    /// relays to the client. Raises when the camera is not running, the codec id is unknown or the
531    /// rotation is not a quarter turn.
532    #[pyo3(signature = (data, codec, keyframe = false, offset = 0, rotation = 0, flip = false))]
533    fn push(&self, py: Python<'_>, data: PyBuffer<u8>, codec: u32, keyframe: bool, offset: usize, rotation: u32, flip: bool) -> PyResult<u32> {
534        let codec = Codec::from_id(codec).ok_or_else(|| PyValueError::new_err(format!("unknown codec id {}", codec)))?;
535        if rotation % 90 != 0 || rotation >= 360 {
536            return Err(PyValueError::new_err("rotation must be 0, 90, 180 or 270"));
537        }
538        let orientation = Orientation { quarter_turns: (rotation / 90) as u8, hflip: flip };
539        if !data.is_c_contiguous() {
540            return Err(PyValueError::new_err("frame buffer must be contiguous"));
541        }
542        let guard = self.running.lock().unwrap_or_else(|e| e.into_inner());
543        let running = guard.as_ref().ok_or_else(|| PyRuntimeError::new_err("virtual camera is not running"))?;
544        let total = data.len_bytes();
545        if offset >= total {
546            return Ok(running.keyframe_wanted.swap(false, Ordering::Relaxed) as u32);
547        }
548        let bytes = unsafe { std::slice::from_raw_parts(data.buf_ptr() as *const u8, total) };
549        let mut buf = running.pool.take(total - offset);
550        buf.extend_from_slice(&bytes[offset..]);
551        let _ = py;
552        self.stats.pushed.fetch_add(1, Ordering::Relaxed);
553        self.stats.input_codec.store(codec as u32, Ordering::Relaxed);
554        self.stats.input_rotation.store(rotation, Ordering::Relaxed);
555        if let Some(evicted) = running.queue.push(Job { codec, keyframe, orientation, data: buf }) {
556            self.stats.dropped.fetch_add(1, Ordering::Relaxed);
557            if evicted.codec.is_inter_coded() {
558                running.keyframe_wanted.store(true, Ordering::Relaxed);
559            }
560            running.pool.put(evicted.data);
561        }
562        Ok(running.keyframe_wanted.swap(false, Ordering::Relaxed) as u32)
563    }
564
565    /// Stop the decoder thread, close every interposer client and remove the socket.
566    fn stop(&self, py: Python<'_>) {
567        let running = self.running.lock().unwrap_or_else(|e| e.into_inner()).take();
568        if let Some(mut r) = running {
569            py.detach(move || {
570                r.queue.close();
571                if let Some(t) = r.thread.take() {
572                    let _ = t.join();
573                }
574                drop(r.server);
575            });
576        }
577    }
578
579    #[getter]
580    fn is_running(&self) -> bool {
581        self.running.lock().unwrap_or_else(|e| e.into_inner()).is_some()
582    }
583
584    /// Interposer clients that completed the handshake.
585    #[getter]
586    fn clients(&self) -> usize {
587        self.running.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|r| r.server.client_count()).unwrap_or(0)
588    }
589
590    #[getter]
591    fn socket_path(&self) -> Option<String> {
592        self.running.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|r| r.server.path().to_string())
593    }
594
595    /// Kernel device currently mirrored, or "" when none is in use.
596    #[getter]
597    fn device_path(&self) -> String {
598        self.running
599            .lock()
600            .unwrap_or_else(|e| e.into_inner())
601            .as_ref()
602            .map(|r| r.device_path.lock().unwrap_or_else(|e| e.into_inner()).clone())
603            .unwrap_or_default()
604    }
605
606    /// Counters and the negotiated geometry.
607    fn stats<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
608        let d = PyDict::new(py);
609        let s = &self.stats;
610        d.set_item("pushed", s.pushed.load(Ordering::Relaxed))?;
611        d.set_item("decoded", s.decoded.load(Ordering::Relaxed))?;
612        d.set_item("published", s.published.load(Ordering::Relaxed))?;
613        d.set_item("passthrough", s.passthrough.load(Ordering::Relaxed))?;
614        d.set_item("dropped", s.dropped.load(Ordering::Relaxed))?;
615        d.set_item("skipped", s.skipped.load(Ordering::Relaxed))?;
616        d.set_item("errors", s.errors.load(Ordering::Relaxed))?;
617        d.set_item("input_width", s.input_width.load(Ordering::Relaxed))?;
618        d.set_item("input_height", s.input_height.load(Ordering::Relaxed))?;
619        d.set_item("input_rotation", s.input_rotation.load(Ordering::Relaxed))?;
620        let codec = s.input_codec.load(Ordering::Relaxed);
621        d.set_item("input_codec", Codec::from_id(codec).map(|c| c.name()).unwrap_or(""))?;
622        let guard = self.running.lock().unwrap_or_else(|e| e.into_inner());
623        if let Some(r) = guard.as_ref() {
624            d.set_item("clients", r.server.client_count())?;
625            d.set_item("width", r.format.width)?;
626            d.set_item("height", r.format.height)?;
627            d.set_item("fps_num", r.format.fps_num)?;
628            d.set_item("fps_den", r.format.fps_den)?;
629            d.set_item("pixel_format", v4l2out::fourcc_str(r.format.fourcc))?;
630            d.set_item("socket_path", r.server.path())?;
631            d.set_item("device_path", r.device_path.lock().unwrap_or_else(|e| e.into_inner()).clone())?;
632            d.set_item("pipewire", r.pipewire)?;
633        d.set_item("pipewire_streaming", s.pipewire_streaming.load(Ordering::Relaxed))?;
634        } else {
635            d.set_item("clients", 0)?;
636        }
637        Ok(d)
638    }
639
640    /// Byte layout of the shared-memory ring and the on-connect config struct, for the interposer
641    /// ABI tests.
642    #[staticmethod]
643    fn shm_layout<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
644        let d = PyDict::new(py);
645        d.set_item("magic", ring::SHM_MAGIC)?;
646        d.set_item("version", ring::SHM_VERSION)?;
647        d.set_item("ctrl_offset", ring::CTRL_OFFSET)?;
648        d.set_item("ctrl_stride", ring::CTRL_STRIDE)?;
649        d.set_item("data_offset", ring::DATA_OFFSET)?;
650        d.set_item("max_slots", ring::MAX_SLOTS)?;
651        d.set_item("config_size", ring::CONFIG_SIZE)?;
652        d.set_item(
653            "config_fields",
654            [
655                "magic", "version", "width", "height", "fourcc", "fps_num", "fps_den", "n_slots", "slot_size",
656                "data_offset", "ctrl_offset", "ctrl_stride", "bytesperline", "sizeimage",
657            ],
658        )?;
659        d.set_item(
660            "header_fields",
661            [
662                "magic", "version", "width", "height", "fourcc", "fps_num", "fps_den", "n_slots", "slot_size",
663                "data_offset", "bytesperline", "sizeimage", "latest_slot", "_pad",
664            ],
665        )?;
666        d.set_item("header_latest_frame_seq_offset", 56)?;
667        d.set_item("ctrl_fields", [("seq", 0, 4), ("bytesused", 4, 4), ("frame_seq", 8, 8), ("ts_ns", 16, 8)])?;
668        Ok(d)
669    }
670}
671
672impl Drop for VirtualCamera {
673    fn drop(&mut self) {
674        if let Some(mut r) = self.running.lock().unwrap_or_else(|e| e.into_inner()).take() {
675            r.queue.close();
676            if let Some(t) = r.thread.take() {
677                let _ = t.join();
678            }
679        }
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn queue_drops_oldest() {
689        let q = Queue::new(2);
690        assert!(q.push(Job { codec: Codec::H264, keyframe: true, orientation: Orientation::UPRIGHT, data: vec![1] }).is_none());
691        assert!(q.push(Job { codec: Codec::H264, keyframe: false, orientation: Orientation::UPRIGHT, data: vec![2] }).is_none());
692        let evicted = q.push(Job { codec: Codec::H264, keyframe: false, orientation: Orientation::UPRIGHT, data: vec![3] }).unwrap();
693        assert_eq!(evicted.data, vec![1]);
694        assert_eq!(q.pop().unwrap().data, vec![2]);
695        assert_eq!(q.pop().unwrap().data, vec![3]);
696        q.close();
697        assert!(q.pop().is_none());
698    }
699
700    #[test]
701    fn pixel_format_names() {
702        assert_eq!(parse_pixel_format("i420"), Some(ring::V4L2_PIX_FMT_YUV420));
703        assert_eq!(parse_pixel_format("NV12"), Some(ring::V4L2_PIX_FMT_NV12));
704        assert_eq!(parse_pixel_format(" yuyv "), Some(ring::V4L2_PIX_FMT_YUYV));
705        assert_eq!(parse_pixel_format("MJPG"), Some(ring::V4L2_PIX_FMT_MJPEG));
706        assert_eq!(parse_pixel_format("mjpeg"), Some(ring::V4L2_PIX_FMT_MJPEG));
707        assert_eq!(parse_pixel_format("rgb"), None);
708        let mjpeg = RingFormat::for_fourcc(ring::V4L2_PIX_FMT_MJPEG, 640, 480, 30, 1).unwrap();
709        assert_eq!((mjpeg.bytesperline, mjpeg.sizeimage), (0, 640 * 480 * 2));
710    }
711
712    #[test]
713    fn mjpeg_encoder_writes_a_jpeg_of_the_device_size() {
714        let dev = DeviceFormat { width: 64, height: 32, fourcc: ring::V4L2_PIX_FMT_MJPEG };
715        let mut src = convert::I420Buffer::new(32, 32);
716        src.data.fill(200);
717        let mut enc = MjpegEncoder::new().unwrap();
718        let mut normalizer = Normalizer::new();
719        let mut slot = vec![0u8; 64 * 32 * 2];
720        let n = enc.write_frame(&mut normalizer, &src.view(true), &dev, &mut slot);
721        assert!(n > 0 && n <= slot.len());
722        let hdr = turbojpeg::read_header(&slot[..n]).unwrap();
723        assert_eq!((hdr.width, hdr.height), (64, 32));
724    }
725}