pipewire-vircam 0.3.0

Create a PipeWire virtual camera from Rust: register a camera node and fill its frames from your own code
//! POD construction for `pipewire-vircam`.
//!
//! Builds the EnumFormat parameter objects (one per advertised
//! (format, size, fps) combination, all with *plain* values — OBS's
//! `camera-portal.c` parses the size as a plain `Rectangle` and drops
//! range/choice encodings, so every advertised (format, size, fps) is a
//! plain value) and the `ParamBuffers` / `ParamMeta` negotiation replies.
//!
//! The `pipewire` crate's safe `connect`/`update_params` take `&mut [&Pod]`,
//! which cannot be built from owned PODs (as of pipewire 0.10: `Pod` is
//! `Copy`, but no `&mut`-reference coercion into `&mut [&Pod]` exists), so
//! we serialize PODs to owned blobs and pass POD pointers to the C
//! functions directly (see `camera.rs`).

use pipewire as pw;
use pipewire::spa::{
    param::{
        format::{FormatProperties, MediaSubtype, MediaType},
        ParamType,
    },
    pod::{serialize::PodSerializer, Object, Pod, Property, PropertyFlags, Value},
    utils::{Choice, ChoiceEnum, Fraction, Rectangle, SpaTypes},
};

use crate::{Config, Format};

/// Hard ceiling on `Config::max_buffers` (keeps the SPA range sane).
pub const MAX_BUFFERS: i32 = 16;

/// One `EnumFormat` Format object with plain (fixed) values for a single
/// (format, size, fps) combination.
pub fn enumformat_pod(format: Format, width: u32, height: u32, fps: u32) -> Vec<u8> {
    let obj = pw::spa::pod::object!(
        SpaTypes::ObjectParamFormat,
        ParamType::EnumFormat,
        pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
        pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
        pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format.video_format()),
        pw::spa::pod::property!(
            FormatProperties::VideoSize,
            Rectangle,
            Rectangle { width, height }
        ),
        pw::spa::pod::property!(
            FormatProperties::VideoFramerate,
            Fraction,
            Fraction { num: fps, denom: 1 }
        ),
    );
    serialize(obj)
}

/// `ParamBuffers` reply: accept 2..MAX_BUFFERS buffers of the negotiated
/// geometry. `num_planes` is the number of data blocks (planes) per buffer;
/// `stride`/`height` describe the first (primary) plane — PipeWire derives
/// the per-plane layout from the negotiated video format.
pub fn buffers_pod(stride: u32, height: u32, num_planes: u32, max_buffers: u32) -> Vec<u8> {
    // Consumers pick a count in 2..=max; default is the midpoint-ish 4
    // (or lower if the range is tight).
    let min = 2u32.min(max_buffers);
    let max = max_buffers.clamp(2, MAX_BUFFERS as u32).max(min);
    let default = 4.min(max).max(min);
    let obj = Object {
        type_: SpaTypes::ObjectParamBuffers.as_raw(),
        id: ParamType::Buffers.as_raw(),
        properties: vec![
            Property {
                key: pw::spa::sys::SPA_PARAM_BUFFERS_buffers,
                flags: PropertyFlags::empty(),
                value: Value::Choice(pw::spa::pod::ChoiceValue::Int(Choice(
                    pw::spa::utils::ChoiceFlags::empty(),
                    ChoiceEnum::Range {
                        default: default as i32,
                        min: min as i32,
                        max: max as i32,
                    },
                ))),
            },
            Property {
                key: pw::spa::sys::SPA_PARAM_BUFFERS_blocks,
                flags: PropertyFlags::empty(),
                value: Value::Int(num_planes as i32),
            },
            Property {
                key: pw::spa::sys::SPA_PARAM_BUFFERS_size,
                flags: PropertyFlags::empty(),
                value: Value::Int(stride as i32 * height as i32),
            },
            Property {
                key: pw::spa::sys::SPA_PARAM_BUFFERS_stride,
                flags: PropertyFlags::empty(),
                value: Value::Int(stride as i32),
            },
        ],
    };
    serialize(obj)
}

/// `ParamMeta` object asking for a `Header` meta alongside the video data.
pub fn meta_pod() -> Vec<u8> {
    let obj = Object {
        type_: SpaTypes::ObjectParamMeta.as_raw(),
        id: ParamType::Meta.as_raw(),
        properties: vec![
            Property {
                key: pw::spa::sys::SPA_PARAM_META_type,
                flags: PropertyFlags::empty(),
                value: Value::Int(pw::spa::sys::SPA_META_Header as i32),
            },
            Property {
                key: pw::spa::sys::SPA_PARAM_META_size,
                flags: PropertyFlags::empty(),
                value: Value::Int(std::mem::size_of::<pw::spa::sys::spa_meta_header>() as i32),
            },
        ],
    };
    serialize(obj)
}

/// The `EnumFormat` blobs to advertise for `config`: one plain-values Format
/// object per unique (format, size, fps) combination, followed by a `meta`
/// (Header) request.
pub fn advertised_param_blobs(config: &Config) -> Vec<Vec<u8>> {
    // Deduplicate (format, size, fps) combinations before advertising.
    // Loop order: FORMAT outer, FPS inner, so entries with the same
    // (size, format) are *consecutive* — this lets OBS group them into a
    // single "size + format" row with an fps sub-list, instead of showing
    // each fps as a separate row.
    let mut advertised: Vec<(Format, u32, u32, u32)> = Vec::new();
    for mode in &config.modes {
        for &format in &mode.formats {
            for &fps in &mode.fps {
                let entry = (format, mode.width, mode.height, fps);
                if !advertised.contains(&entry) {
                    advertised.push(entry);
                }
            }
        }
    }
    let mut blobs: Vec<Vec<u8>> = Vec::with_capacity(advertised.len() + 1);
    for &(format, width, height, fps) in &advertised {
        blobs.push(enumformat_pod(format, width, height, fps));
    }
    blobs.push(meta_pod());
    blobs
}

/// Serialize a POD `Object` into an owned blob. The blob is parsed back
/// before it leaves this module, so a serialization regression panics here
/// rather than deep in PipeWire.
pub fn serialize(obj: Object) -> Vec<u8> {
    let (cursor, _size) =
        PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &Value::Object(obj))
            .expect("failed to serialize POD");
    let bytes = cursor.into_inner();
    // Sanity: round-trip parse before the blob leaves this module.
    Pod::from_bytes(&bytes).expect("failed to parse serialized POD");
    bytes
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Mode;
    use libspa::param::video::VideoInfoRaw;

    /// A distinctive geometry: width != height != fps, so a size/framerate
    /// swap or a "denom always 1" assumption is caught by the round-trip.
    const W: u32 = 384;
    const H: u32 = 272;
    const FPS: u32 = 47;

    /// `enumformat_pod` must serialize to a POD that the video-format parser
    /// reads back as the exact (format, size, fps) we asked for — the OBS
    /// "plain (fixed) values" regression guard.
    #[test]
    fn enumformat_pod_roundtrip() {
        for f in Format::all() {
            let blob = enumformat_pod(*f, W, H, FPS);
            let pod = Pod::from_bytes(&blob).expect("serialize should round-trip");
            let mut info = VideoInfoRaw::default();
            info.parse(pod)
                .expect("EnumFormat must parse as a video format");
            assert_eq!(
                info.format(),
                f.video_format(),
                "format id mismatch for {f:?}"
            );
            assert_eq!(info.size().width, W);
            assert_eq!(info.size().height, H);
            assert_eq!(info.framerate().num, FPS);
            assert_eq!(info.framerate().denom, 1);
        }
    }

    /// Two fps values for the same (format, size) must serialize to distinct
    /// PODs carrying the exact framerate — the multi-fps regression guard.
    #[test]
    fn enumformat_pod_multi_fps() {
        for fps in [15u32, 30, 60] {
            let blob = enumformat_pod(Format::Rgba, W, H, fps);
            let pod = Pod::from_bytes(&blob).expect("serialize should round-trip");
            let mut info = VideoInfoRaw::default();
            info.parse(pod)
                .expect("EnumFormat must parse as a video format");
            assert_eq!(info.framerate().num, fps);
            assert_eq!(info.framerate().denom, 1);
            assert_eq!(info.size().width, W);
            assert_eq!(info.size().height, H);
        }
    }

    /// Duplicate (format, size, fps) combinations are advertised once, and
    /// the advertised blobs end with the `meta` (Header) request.
    #[test]
    fn advertised_param_blobs_dedup() {
        let config = Config {
            name: "cam".into(),
            media_name: "cam".into(),
            modes: vec![
                Mode {
                    width: W,
                    height: H,
                    fps: vec![FPS, FPS],
                    formats: vec![Format::Rgba, Format::Rgba],
                },
                Mode {
                    width: W,
                    height: H,
                    fps: vec![FPS + 1],
                    formats: vec![Format::Rgba],
                },
            ],
            max_buffers: 4,
        };
        let blobs = advertised_param_blobs(&config);
        // (Rgba, WxH, FPS) twice -> 1, (Rgba, WxH, FPS + 1) -> 1, plus meta.
        assert_eq!(blobs.len(), 3);
        let meta = Pod::from_bytes(&blobs[2]).expect("meta pod must parse");
        let obj = meta.as_object().expect("meta pod must be an object");
        assert_eq!(obj.id().0, ParamType::Meta.as_raw());
    }

    /// Grouping invariant: for each (format, size), all fps entries must be
    /// *consecutive* in the advertised blob list. This is what lets OBS group
    /// them into one "size + format" row with an fps sub-list, instead of
    /// showing each fps as a separate row. If the loop order in
    /// `advertised_param_blobs` regresses (e.g. fps outer, format inner),
    /// this test fails.
    #[test]
    fn advertised_param_blobs_grouped_by_format_then_size() {
        // Two sizes, two formats, three fps each — enough to catch an
        // interspersed ordering.
        let config = Config {
            name: "cam".into(),
            media_name: "cam".into(),
            modes: vec![
                Mode {
                    width: 1920,
                    height: 1080,
                    fps: vec![24, 30],
                    formats: vec![Format::Rgba, Format::Nv12],
                },
                Mode {
                    width: 1280,
                    height: 720,
                    fps: vec![24, 30],
                    formats: vec![Format::Rgba, Format::Nv12],
                },
            ],
            max_buffers: 4,
        };
        let blobs = advertised_param_blobs(&config);
        // Parse each blob back to (format_id, width, height, fps).
        let mut seen: Vec<(u32, u32, u32, u32)> = Vec::new();
        for blob in &blobs {
            let pod = Pod::from_bytes(blob).expect("blob must parse");
            let obj = pod.as_object().expect("must be an object");
            // The meta pod (last) has ParamType::Meta, not EnumFormat — skip.
            if obj.id().0 != ParamType::EnumFormat.as_raw() {
                continue;
            }
            let mut info = VideoInfoRaw::default();
            info.parse(pod).expect("must parse as video format");
            seen.push((
                info.format().0,
                info.size().width,
                info.size().height,
                info.framerate().num,
            ));
        }
        // Core invariant: each (format, size) group must be a *contiguous
        // run* — no other (format, size) may appear after a group has
        // started and then reappear later.
        let mut groups_in_order: Vec<(u32, u32, u32)> = Vec::new();
        for (fmt, w, h, _) in &seen {
            let group = (*fmt, *w, *h);
            match groups_in_order.iter().position(|g| *g == group) {
                // First time seeing this group, or the immediately previous
                // entry's group (contiguous) — OK.
                Some(pos) => {
                    if pos != groups_in_order.len() - 1 {
                        // This group was seen before but NOT as the most
                        // recent group — it was split.
                        panic!(
                            "group ({fmt}, {w}x{h}) is not contiguous — fps
                            for a (format, size) must be advertised
                            consecutively"
                        );
                    }
                }
                None => groups_in_order.push(group),
            }
        }
    }

    /// `buffers_pod` and `meta_pod` serialize to well-formed (parseable)
    /// objects and are non-trivial in size.
    #[test]
    fn buffers_and_meta_pods_roundtrip() {
        for blob in [
            buffers_pod(7680, 1080, 1, 4),
            buffers_pod(1920, 1080, 3, 16), // e.g. I420
            buffers_pod(1920, 1080, 3, 2),  // tight range: min == max == 2
            meta_pod(),
        ] {
            let pod = Pod::from_bytes(&blob).expect("serialize should round-trip");
            assert!(
                pod.size() > 0 && !pod.as_bytes().is_empty(),
                "pod must be non-trivial"
            );
        }
    }
}