Skip to main content

pixelflux/wayland/
pwcapture.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//! PipeWire consumer for a portal monitor stream: one `Video/Input` stream per host output on
8//! the connection the portal opened, delivering the compositor's frames to the host-capture
9//! consumer as [`HostFrame`]s.
10//!
11//! The compositor allocates and fills the buffers, so a frame is never copied here: a dmabuf
12//! buffer becomes a `Dmabuf` the encoder imports in place (built once per buffer from
13//! duplicated fds, so the encoder's import cache keeps hitting), a memfd buffer is mapped once
14//! and read in place by the CPU path. A buffer stays dequeued from the moment its frame is
15//! published until the consumer releases it, which is the same slot discipline the Wayland
16//! capture threads follow, so the retained frame an IDR re-encodes is still the compositor's
17//! memory. Frames are handed on from the stream's own thread the instant `process` runs, and
18//! the format offered to the compositor asks it to send at most the capture's rate, so nothing
19//! is captured only to be dropped and nothing waits for a timer.
20//!
21//! Format negotiation follows the modifier convention consumers and compositors agree on: each
22//! importable `(fourcc, modifiers)` pair the encoder's EGL display reported is one format entry
23//! with a mandatory, non-fixating modifier choice, followed by plain entries the compositor
24//! answers with memfd buffers; the compositor picks, and the fixated format decides whether
25//! dmabuf or memfd buffers are requested. Cursor metadata is requested so a metadata-mode
26//! session delivers the host's real cursor sprite to the cursor callback, and damage metadata
27//! so unchanged stripes are recognized.
28
29use std::ffi::{CStr, c_char, c_int, c_void};
30use std::os::fd::{FromRawFd, OwnedFd};
31use std::ptr;
32use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, AtomicPtr, AtomicU64, Ordering};
33use std::sync::{Arc, Mutex};
34use std::time::{Duration, Instant};
35
36use smithay::backend::allocator::dmabuf::{Dmabuf, DmabufFlags};
37use smithay::backend::allocator::{Fourcc, Modifier};
38use smithay::utils::{Physical, Rectangle};
39
40use crate::pipewire::*;
41use crate::wayland::cursor::CursorJob;
42use crate::wayland::host::{FrameSink, HostCpuFrame, HostFrame, now_ns, trace};
43
44const FOURCC_XR24: u32 = 0x3432_5258;
45const FOURCC_AR24: u32 = 0x3432_5241;
46const FOURCC_XB24: u32 = 0x3432_4258;
47const FOURCC_AB24: u32 = 0x3432_4241;
48const MAX_DAMAGE_REGIONS: u32 = 16;
49const MAX_CURSOR_EDGE: u32 = 256;
50
51/// The SPA pixel formats a consumer of BGRA-order pixels takes, best first, with the DRM fourcc
52/// each corresponds to (SPA names byte order, DRM names the little-endian word).
53const SHM_FORMATS: [(u32, u32); 4] = [
54    (SPA_VIDEO_FORMAT_BGRX, FOURCC_XR24),
55    (SPA_VIDEO_FORMAT_BGRA, FOURCC_AR24),
56    (SPA_VIDEO_FORMAT_RGBX, FOURCC_XB24),
57    (SPA_VIDEO_FORMAT_RGBA, FOURCC_AB24),
58];
59
60fn spa_to_fourcc(spa: u32) -> Option<u32> {
61    SHM_FORMATS.iter().find(|(s, _)| *s == spa).map(|(_, f)| *f)
62}
63
64/// The wl_shm-style code `HostCpuFrame` converts by: 0/1 for the ARGB/XRGB words, DRM fourcc
65/// for the rest.
66fn shm_code(fourcc: u32) -> u32 {
67    match fourcc {
68        FOURCC_AR24 => 0,
69        FOURCC_XR24 => 1,
70        other => other,
71    }
72}
73
74/// One PipeWire connection, shared by every stream of a portal session.
75pub struct PwCore {
76    api: Api,
77    thread_loop: *mut c_void,
78    context: *mut c_void,
79    core: *mut c_void,
80}
81
82unsafe impl Send for PwCore {}
83unsafe impl Sync for PwCore {}
84
85impl PwCore {
86    /// Connect on the fd the portal handed out (PipeWire owns and closes it).
87    pub fn connect(fd: OwnedFd) -> Result<Arc<Self>, String> {
88        let api = *api()?;
89        unsafe {
90            let thread_loop = (api.thread_loop_new)(c"pixelflux-portal-pw".as_ptr(), ptr::null());
91            if thread_loop.is_null() {
92                return Err("pw_thread_loop_new failed".into());
93            }
94            let mut this = PwCore {
95                api,
96                thread_loop,
97                context: ptr::null_mut(),
98                core: ptr::null_mut(),
99            };
100            this.context =
101                (api.context_new)((api.thread_loop_get_loop)(thread_loop), ptr::null_mut(), 0);
102            if this.context.is_null() {
103                return Err("pw_context_new failed".into());
104            }
105            if (api.thread_loop_start)(thread_loop) < 0 {
106                return Err("pw_thread_loop_start failed".into());
107            }
108            (api.thread_loop_lock)(thread_loop);
109            this.core = (api.context_connect_fd)(
110                this.context,
111                std::os::fd::IntoRawFd::into_raw_fd(fd),
112                ptr::null_mut(),
113                0,
114            );
115            (api.thread_loop_unlock)(thread_loop);
116            if this.core.is_null() {
117                return Err("pw_context_connect_fd failed".into());
118            }
119            Ok(Arc::new(this))
120        }
121    }
122
123    /// Run `f` with the loop locked, which every stream call from outside the loop needs.
124    fn locked<R>(&self, f: impl FnOnce() -> R) -> R {
125        unsafe { (self.api.thread_loop_lock)(self.thread_loop) };
126        let r = f();
127        unsafe { (self.api.thread_loop_unlock)(self.thread_loop) };
128        r
129    }
130}
131
132impl Drop for PwCore {
133    fn drop(&mut self) {
134        unsafe {
135            (self.api.thread_loop_lock)(self.thread_loop);
136            if !self.core.is_null() {
137                (self.api.core_disconnect)(self.core);
138            }
139            (self.api.thread_loop_unlock)(self.thread_loop);
140            (self.api.thread_loop_stop)(self.thread_loop);
141            if !self.context.is_null() {
142                (self.api.context_destroy)(self.context);
143            }
144            (self.api.thread_loop_destroy)(self.thread_loop);
145        }
146    }
147}
148
149/// What a stream asks the compositor for.
150#[derive(Clone, Debug, PartialEq)]
151pub struct StreamConfig {
152    /// Offer dmabuf import (with `dma_formats`); off, only memfd frames are offered for.
153    pub zero_copy: bool,
154    /// `(fourcc, modifiers)` the encoder's display imports.
155    pub dma_formats: Vec<(u32, Vec<u64>)>,
156    /// Rate cap the compositor is asked to hold, in millihertz.
157    pub fps_milli: u32,
158    /// The size the consumer expects (the preferred value of the size range).
159    pub expect: (i32, i32),
160}
161
162/// The fixated format of a stream.
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub struct Negotiated {
165    pub width: i32,
166    pub height: i32,
167    pub fourcc: u32,
168    pub modifier: Option<u64>,
169}
170
171/// One buffer the compositor handed the stream: its PipeWire handle plus the import built
172/// on first use, kept for the buffer's life so the same fds reach the encoder each time.
173#[derive(Default)]
174struct BufferSlot {
175    pw: *mut PwBuffer,
176    dmabuf: Option<Dmabuf>,
177    cpu: Option<Arc<memmap2::Mmap>>,
178    /// Published to the consumer and not yet released: PipeWire does not own it meanwhile.
179    held: bool,
180}
181
182/// State shared with the stream callbacks (handed to PipeWire as the listener's user data).
183struct Shared {
184    api: Api,
185    stream: AtomicPtr<c_void>,
186    sink: FrameSink,
187    cursor_tx: std::sync::mpsc::Sender<CursorJob>,
188    cfg: Mutex<StreamConfig>,
189    negotiated: Mutex<Option<Negotiated>>,
190    buffers: Mutex<Vec<BufferSlot>>,
191    /// Bumped on every renegotiation; a release naming an older generation is ignored.
192    generation: AtomicU64,
193    state: AtomicI32,
194    errored: AtomicBool,
195    frames: AtomicU64,
196    stamps: Mutex<Stamps>,
197    cursor_pos: AtomicI64,
198    cursor_hash: AtomicU64,
199    /// The pods handed to `pw_stream_update_params`, alive for the stream's life.
200    params: Mutex<Vec<Vec<u8>>>,
201}
202
203// The raw PipeWire buffer pointers in the slots are only dereferenced on the stream's own loop
204// or under its lock; everything else in the state is atomics and mutexes.
205unsafe impl Send for Shared {}
206unsafe impl Sync for Shared {}
207
208fn enum_format_pod(spa_format: u32, modifiers: Option<&[u64]>, cfg: &StreamConfig) -> Vec<u8> {
209    object(SPA_TYPE_OBJECT_FORMAT, SPA_PARAM_ENUM_FORMAT, |p| {
210        prop(p, SPA_FORMAT_MEDIA_TYPE, |v| {
211            pod_id(v, SPA_MEDIA_TYPE_VIDEO)
212        });
213        prop(p, SPA_FORMAT_MEDIA_SUBTYPE, |v| {
214            pod_id(v, SPA_MEDIA_SUBTYPE_RAW)
215        });
216        prop(p, SPA_FORMAT_VIDEO_FORMAT, |v| pod_id(v, spa_format));
217        if let Some(mods) = modifiers {
218            let mut values: Vec<i64> = Vec::with_capacity(mods.len() + 1);
219            values.push(mods[0] as i64);
220            values.extend(mods.iter().map(|m| *m as i64));
221            prop_flags(
222                p,
223                SPA_FORMAT_VIDEO_MODIFIER,
224                SPA_POD_PROP_FLAG_MANDATORY | SPA_POD_PROP_FLAG_DONT_FIXATE,
225                |v| pod_choice_long(v, SPA_CHOICE_ENUM, &values),
226            );
227        }
228        let (w, h) = (cfg.expect.0.max(1) as u32, cfg.expect.1.max(1) as u32);
229        prop(p, SPA_FORMAT_VIDEO_SIZE, |v| {
230            pod_choice_rect(v, SPA_CHOICE_RANGE, &[(w, h), (1, 1), (16384, 16384)])
231        });
232        prop(p, SPA_FORMAT_VIDEO_FRAMERATE, |v| pod_frac(v, 0, 1));
233        let fps = (cfg.fps_milli.max(1000), 1000);
234        prop(p, SPA_FORMAT_VIDEO_MAX_FRAMERATE, |v| {
235            pod_choice_frac(v, SPA_CHOICE_RANGE, &[fps, (1, 1), fps])
236        });
237    })
238}
239
240/// The `EnumFormat` list for `cfg`: modifier-bearing entries for every importable fourcc when
241/// dmabufs are wanted, then the plain entries every compositor answers with memfd buffers.
242fn enum_format_pods(cfg: &StreamConfig) -> Vec<Vec<u8>> {
243    let mut pods = Vec::new();
244    if cfg.zero_copy {
245        for (fourcc, mods) in &cfg.dma_formats {
246            let spa = match *fourcc {
247                FOURCC_XR24 => SPA_VIDEO_FORMAT_BGRX,
248                FOURCC_AR24 => SPA_VIDEO_FORMAT_BGRA,
249                _ => continue,
250            };
251            if !mods.is_empty() {
252                pods.push(enum_format_pod(spa, Some(mods), cfg));
253            }
254        }
255    }
256    for (spa, _) in SHM_FORMATS {
257        pods.push(enum_format_pod(spa, None, cfg));
258    }
259    pods
260}
261
262/// Buffers and metadata asked for once the format is fixated: dmabuf or memfd blocks per the
263/// modifier's presence, three to eight buffers so one can be held retained while another is
264/// in flight and the compositor still has one to fill, plus header, damage, and cursor metas.
265fn negotiated_params(n: &Negotiated, dmabuf: bool) -> Vec<Vec<u8>> {
266    let data_type = 1i32
267        << if dmabuf {
268            SPA_DATA_DMABUF
269        } else {
270            SPA_DATA_MEMFD
271        };
272    let buffers = object(SPA_TYPE_OBJECT_PARAM_BUFFERS, SPA_PARAM_BUFFERS, |p| {
273        prop(p, SPA_PARAM_BUFFERS_BUFFERS, |v| {
274            pod_choice_int(v, SPA_CHOICE_RANGE, &[4, 3, 8])
275        });
276        prop(p, SPA_PARAM_BUFFERS_BLOCKS, |v| pod_int(v, 1));
277        prop(p, SPA_PARAM_BUFFERS_SIZE, |v| {
278            pod_int(v, n.width * n.height * 4)
279        });
280        prop(p, SPA_PARAM_BUFFERS_STRIDE, |v| pod_int(v, n.width * 4));
281        prop(p, SPA_PARAM_BUFFERS_ALIGN, |v| pod_int(v, 16));
282        prop(p, SPA_PARAM_BUFFERS_DATATYPE, |v| pod_int(v, data_type));
283    });
284    let region = SPA_META_REGION_SIZE as i32;
285    vec![
286        buffers,
287        meta(SPA_META_HEADER, |v| pod_int(v, SPA_META_HEADER_SIZE as i32)),
288        meta(SPA_META_VIDEO_DAMAGE, |v| {
289            pod_choice_int(
290                v,
291                SPA_CHOICE_RANGE,
292                &[4 * region, region, MAX_DAMAGE_REGIONS as i32 * region],
293            )
294        }),
295        meta(SPA_META_VIDEO_CROP, |v| pod_int(v, region)),
296        meta(SPA_META_CURSOR, |v| {
297            pod_choice_int(
298                v,
299                SPA_CHOICE_RANGE,
300                &[
301                    cursor_meta_size(64, 64) as i32,
302                    cursor_meta_size(1, 1) as i32,
303                    cursor_meta_size(MAX_CURSOR_EDGE, MAX_CURSOR_EDGE) as i32,
304                ],
305            )
306        }),
307    ]
308}
309
310/// A `Meta` param asking for one metadata type of the given size (a plain or ranged Int).
311fn meta(ty: u32, size: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
312    object(SPA_TYPE_OBJECT_PARAM_META, SPA_PARAM_META, |p| {
313        prop(p, SPA_PARAM_META_TYPE, |v| pod_id(v, ty));
314        prop(p, SPA_PARAM_META_SIZE, size);
315    })
316}
317
318/// The fixated `Format` param: pixel format, size, and whether a modifier (dmabuf) was agreed.
319fn parse_format(pod: &[u8]) -> Option<Negotiated> {
320    let (ty, _, props) = object_props(pod)?;
321    if ty != SPA_TYPE_OBJECT_FORMAT {
322        return None;
323    }
324    let spa = find_prop(&props, SPA_FORMAT_VIDEO_FORMAT)?.as_u32()?;
325    let (w, h) = find_prop(&props, SPA_FORMAT_VIDEO_SIZE)?.as_pair()?;
326    let modifier = find_prop(&props, SPA_FORMAT_VIDEO_MODIFIER)
327        .and_then(|p| p.as_i64())
328        .map(|m| m as u64);
329    Some(Negotiated {
330        width: w as i32,
331        height: h as i32,
332        fourcc: spa_to_fourcc(spa)?,
333        modifier,
334    })
335}
336
337unsafe extern "C" fn on_state_changed(
338    data: *mut c_void,
339    _old: c_int,
340    new: c_int,
341    error: *const c_char,
342) {
343    let shared = unsafe { &*(data as *const Shared) };
344    shared.state.store(new, Ordering::Release);
345    let name = match new {
346        PW_STREAM_STATE_ERROR => "error",
347        PW_STREAM_STATE_UNCONNECTED => "unconnected",
348        PW_STREAM_STATE_PAUSED => "paused",
349        PW_STREAM_STATE_STREAMING => "streaming",
350        _ => "connecting",
351    };
352    let msg = if error.is_null() {
353        String::new()
354    } else {
355        unsafe { CStr::from_ptr(error) }
356            .to_string_lossy()
357            .into_owned()
358    };
359    eprintln!(
360        "[HostCapture] output {}: portal stream {name} {msg}",
361        shared.sink.index
362    );
363    if new == PW_STREAM_STATE_ERROR {
364        shared.errored.store(true, Ordering::Release);
365    }
366}
367
368unsafe extern "C" fn on_param_changed(data: *mut c_void, id: u32, param: *const c_void) {
369    if id != SPA_PARAM_FORMAT {
370        return;
371    }
372    let shared = unsafe { &*(data as *const Shared) };
373    shared.generation.fetch_add(1, Ordering::AcqRel);
374    if param.is_null() {
375        *shared.negotiated.lock().unwrap() = None;
376        return;
377    }
378    let Some(negotiated) = parse_format(unsafe { pod_bytes(param) }) else {
379        eprintln!(
380            "[HostCapture] output {}: unusable PipeWire format",
381            shared.sink.index
382        );
383        return;
384    };
385    eprintln!(
386        "[HostCapture] output {} portal stream: {}x{} {} ({})",
387        shared.sink.index,
388        negotiated.width,
389        negotiated.height,
390        String::from_utf8_lossy(&negotiated.fourcc.to_le_bytes()),
391        match negotiated.modifier {
392            Some(m) => format!("dmabuf, modifier {m:#x}"),
393            None => "memfd".to_string(),
394        }
395    );
396    let pods = negotiated_params(&negotiated, negotiated.modifier.is_some());
397    *shared.negotiated.lock().unwrap() = Some(negotiated);
398    let stream = shared.stream.load(Ordering::Acquire);
399    if stream.is_null() {
400        return;
401    }
402    let mut ptrs: Vec<*const c_void> = pods.iter().map(|p| p.as_ptr() as *const c_void).collect();
403    unsafe { (shared.api.stream_update_params)(stream, ptrs.as_mut_ptr(), ptrs.len() as u32) };
404    *shared.params.lock().unwrap() = pods;
405}
406
407unsafe extern "C" fn on_add_buffer(data: *mut c_void, b: *mut PwBuffer) {
408    let shared = unsafe { &*(data as *const Shared) };
409    let mut buffers = shared.buffers.lock().unwrap();
410    let idx = buffers
411        .iter()
412        .position(|s| s.pw.is_null())
413        .unwrap_or_else(|| {
414            buffers.push(BufferSlot::default());
415            buffers.len() - 1
416        });
417    buffers[idx] = BufferSlot {
418        pw: b,
419        ..BufferSlot::default()
420    };
421    unsafe { (*b).user_data = idx as *mut c_void };
422}
423
424unsafe extern "C" fn on_remove_buffer(data: *mut c_void, b: *mut PwBuffer) {
425    let shared = unsafe { &*(data as *const Shared) };
426    let idx = unsafe { (*b).user_data } as usize;
427    if let Some(slot) = shared.buffers.lock().unwrap().get_mut(idx) {
428        *slot = BufferSlot::default();
429    }
430}
431
432/// Read the cursor meta of a buffer: position, and a changed sprite forwarded to the cursor
433/// worker (an empty sprite hides it).
434unsafe fn read_cursor(shared: &Shared, meta: &SpaMeta) {
435    if meta.size < std::mem::size_of::<SpaMetaCursor>() as u32 || meta.data.is_null() {
436        return;
437    }
438    let cursor = unsafe { &*(meta.data as *const SpaMetaCursor) };
439    if cursor.id == 0 {
440        return;
441    }
442    shared.cursor_pos.store(
443        ((cursor.x as i64) << 32) | (cursor.y as u32 as i64),
444        Ordering::Relaxed,
445    );
446    if cursor.bitmap_offset < std::mem::size_of::<SpaMetaCursor>() as u32
447        || cursor.bitmap_offset + std::mem::size_of::<SpaMetaBitmap>() as u32 > meta.size
448    {
449        return;
450    }
451    let bitmap = unsafe {
452        &*((meta.data as *const u8).add(cursor.bitmap_offset as usize) as *const SpaMetaBitmap)
453    };
454    if bitmap.format == 0 {
455        return;
456    }
457    let (w, h) = (bitmap.width, bitmap.height);
458    if w == 0 || h == 0 || bitmap.offset == 0 {
459        if shared.cursor_hash.swap(0, Ordering::Relaxed) != 0 {
460            let _ = shared.cursor_tx.send(CursorJob::Hide);
461        }
462        return;
463    }
464    let stride = bitmap.stride.max(0) as usize;
465    let end = cursor.bitmap_offset as usize
466        + bitmap.offset as usize
467        + stride * (h as usize - 1)
468        + w as usize * 4;
469    if end > meta.size as usize || w > MAX_CURSOR_EDGE || h > MAX_CURSOR_EDGE {
470        return;
471    }
472    let pixels = unsafe {
473        (meta.data as *const u8).add(cursor.bitmap_offset as usize + bitmap.offset as usize)
474    };
475    let mut rgba = Vec::with_capacity((w * h * 4) as usize);
476    for y in 0..h as usize {
477        let row = unsafe { std::slice::from_raw_parts(pixels.add(y * stride), w as usize * 4) };
478        for px in row.as_chunks::<4>().0 {
479            let (r, g, b, a) = match bitmap.format {
480                SPA_VIDEO_FORMAT_BGRA | SPA_VIDEO_FORMAT_BGRX => (
481                    px[2],
482                    px[1],
483                    px[0],
484                    if bitmap.format == SPA_VIDEO_FORMAT_BGRX {
485                        0xff
486                    } else {
487                        px[3]
488                    },
489                ),
490                SPA_VIDEO_FORMAT_ARGB => (px[1], px[2], px[3], px[0]),
491                SPA_VIDEO_FORMAT_ABGR => (px[3], px[2], px[1], px[0]),
492                _ => (
493                    px[0],
494                    px[1],
495                    px[2],
496                    if bitmap.format == SPA_VIDEO_FORMAT_RGBX {
497                        0xff
498                    } else {
499                        px[3]
500                    },
501                ),
502            };
503            rgba.extend_from_slice(&[r, g, b, a]);
504        }
505    }
506    let hash = xxhash_rust::xxh3::xxh3_64(&rgba) ^ ((w as u64) << 32 | h as u64);
507    let previous = shared.cursor_hash.swap(hash, Ordering::Relaxed);
508    if previous != hash {
509        if previous == 0 {
510            eprintln!(
511                "[HostCapture] output {}: portal cursor sprites arrive ({w}x{h}, hotspot {},{})",
512                shared.sink.index, cursor.hot_x, cursor.hot_y
513            );
514        }
515        let _ = shared.cursor_tx.send(CursorJob::Gles {
516            hash,
517            width: w as i32,
518            height: h as i32,
519            bytes: rgba,
520            hot_x: cursor.hot_x,
521            hot_y: cursor.hot_y,
522        });
523    }
524}
525
526/// The damage regions of a buffer, or the whole frame when the compositor sent none.
527unsafe fn read_damage(buf: &SpaBuffer, width: i32, height: i32) -> Vec<Rectangle<i32, Physical>> {
528    let mut damage = Vec::new();
529    for i in 0..buf.n_metas as usize {
530        let m = unsafe { &*buf.metas.add(i) };
531        if m.type_ != SPA_META_VIDEO_DAMAGE || m.data.is_null() {
532            continue;
533        }
534        let n = m.size as usize / std::mem::size_of::<SpaMetaRegion>();
535        for j in 0..n {
536            let r = unsafe { *(m.data as *const SpaMetaRegion).add(j) };
537            if r.width == 0 || r.height == 0 {
538                break;
539            }
540            damage.push(Rectangle::new(
541                (r.x, r.y).into(),
542                (r.width as i32, r.height as i32).into(),
543            ));
544        }
545    }
546    if damage.is_empty() {
547        damage.push(Rectangle::new((0, 0).into(), (width, height).into()));
548    }
549    damage
550}
551
552/// How many frame periods before its arrival a compositor's stamp may lie and still be taken
553/// as the frame's capture instant (`Stamps`).
554const STAMP_MAX_AGE_PERIODS: i64 = 4;
555
556/// Why a portal frame's capture instant is its arrival rather than the compositor's stamp.
557#[derive(Clone, Copy, Debug, PartialEq, Eq)]
558enum StampRefusal {
559    Missing,
560    Ahead,
561    Stale,
562    Repeated,
563}
564
565impl StampRefusal {
566    fn says(self) -> &'static str {
567        match self {
568            StampRefusal::Missing => "missing",
569            StampRefusal::Ahead => "ahead of the frame's arrival",
570            StampRefusal::Stale => "older than four frame periods at arrival",
571            StampRefusal::Repeated => "no later than the last frame's",
572        }
573    }
574}
575
576/// The capture instant of each portal frame, which the encoded frame carries as `capture_ns`
577/// into the RTP timestamp, abs-capture-time and pipeline_ms: the compositor's header stamp
578/// where it reads as a `CLOCK_MONOTONIC` instant of this frame, else the frame's arrival. A
579/// stamp is taken when it is no later than the arrival, at most `STAMP_MAX_AGE_PERIODS`
580/// frame periods before it, and later than the last instant given, so a stamp reused from an
581/// earlier frame, one on another clock's scale, or one ahead of now moves neither. Which source
582/// each refusal falls back to is logged the first time it happens on a stream.
583#[derive(Default)]
584struct Stamps {
585    last: i64,
586    refusals_seen: u8,
587}
588
589impl Stamps {
590    fn instant(
591        &mut self,
592        pts: Option<i64>,
593        arrival: i64,
594        period_ns: i64,
595    ) -> (i64, Option<StampRefusal>) {
596        let refusal = match pts {
597            None => Some(StampRefusal::Missing),
598            Some(pts) if pts > arrival => Some(StampRefusal::Ahead),
599            Some(pts) if arrival - pts > STAMP_MAX_AGE_PERIODS * period_ns => {
600                Some(StampRefusal::Stale)
601            }
602            Some(pts) if pts <= self.last => Some(StampRefusal::Repeated),
603            Some(_) => None,
604        };
605        let at = match (pts, refusal) {
606            (Some(pts), None) => pts,
607            _ => arrival.max(self.last + 1),
608        };
609        self.last = at;
610        (at, refusal)
611    }
612
613    /// Whether this refusal is the first of its kind on the stream.
614    fn first(&mut self, refusal: StampRefusal) -> bool {
615        let bit = 1 << refusal as u8;
616        let first = self.refusals_seen & bit == 0;
617        self.refusals_seen |= bit;
618        first
619    }
620}
621
622/// The compositor's stamp from the buffer's header meta, when it carries one.
623fn header_pts(buf: &SpaBuffer) -> Option<i64> {
624    (0..buf.n_metas as usize)
625        .map(|i| unsafe { &*buf.metas.add(i) })
626        .find(|m| m.type_ == SPA_META_HEADER && m.size >= SPA_META_HEADER_SIZE && !m.data.is_null())
627        .map(|m| unsafe { (*(m.data as *const SpaMetaHeader)).pts })
628        .filter(|pts| *pts > 0)
629}
630
631/// Build the slot's import on its first frame: the dmabuf planes as a `Dmabuf` of duplicated
632/// fds, or the memfd mapped whole.
633unsafe fn import_slot(slot: &mut BufferSlot, buf: &SpaBuffer, n: &Negotiated) -> bool {
634    if slot.dmabuf.is_some() || slot.cpu.is_some() {
635        return true;
636    }
637    let d0 = unsafe { &*buf.datas };
638    if d0.type_ == SPA_DATA_DMABUF {
639        let mut builder = Dmabuf::builder(
640            (n.width, n.height),
641            Fourcc::try_from(n.fourcc).unwrap_or(Fourcc::Xrgb8888),
642            Modifier::from(n.modifier.unwrap_or(u64::from(Modifier::Invalid))),
643            DmabufFlags::empty(),
644        );
645        for i in 0..buf.n_datas as usize {
646            let d = unsafe { &*buf.datas.add(i) };
647            let dup = unsafe { libc::dup(d.fd as c_int) };
648            if dup < 0 {
649                return false;
650            }
651            let chunk = unsafe { &*d.chunk };
652            builder.add_plane(
653                unsafe { OwnedFd::from_raw_fd(dup) },
654                chunk.offset,
655                chunk.stride as u32,
656            );
657        }
658        slot.dmabuf = builder.build();
659        slot.dmabuf.is_some()
660    } else if d0.type_ == SPA_DATA_MEMFD {
661        let file = unsafe { std::fs::File::from_raw_fd(libc::dup(d0.fd as c_int)) };
662        let map = unsafe {
663            memmap2::MmapOptions::new()
664                .offset(d0.mapoffset as u64)
665                .len(d0.maxsize as usize)
666                .map(&file)
667        };
668        match map {
669            Ok(m) => {
670                slot.cpu = Some(Arc::new(m));
671                true
672            }
673            Err(e) => {
674                eprintln!("[HostCapture] memfd map: {e}");
675                false
676            }
677        }
678    } else {
679        false
680    }
681}
682
683unsafe extern "C" fn on_process(data: *mut c_void) {
684    let shared = unsafe { &*(data as *const Shared) };
685    let stream = shared.stream.load(Ordering::Acquire);
686    if stream.is_null() {
687        return;
688    }
689    let Some(n) = *shared.negotiated.lock().unwrap() else {
690        return;
691    };
692    // Take everything queued: cursor-only buffers go straight back, and of several frames
693    // only the newest is worth publishing.
694    let mut newest: Option<ptr::NonNull<PwBuffer>> = None;
695    loop {
696        let b = unsafe { (shared.api.stream_dequeue_buffer)(stream) };
697        if b.is_null() {
698            break;
699        }
700        let buf = unsafe { &*(*b).buffer };
701        for i in 0..buf.n_metas as usize {
702            let m = unsafe { &*buf.metas.add(i) };
703            if m.type_ == SPA_META_CURSOR {
704                unsafe { read_cursor(shared, m) };
705            }
706        }
707        let has_video = buf.n_datas > 0 && {
708            let d = unsafe { &*buf.datas };
709            let chunk = unsafe { &*d.chunk };
710            chunk.size > 0 && chunk.flags & SPA_CHUNK_FLAG_CORRUPTED == 0
711        };
712        if has_video {
713            if let Some(previous) = newest {
714                unsafe { (shared.api.stream_queue_buffer)(stream, previous.as_ptr()) };
715            }
716            newest = ptr::NonNull::new(b);
717        } else {
718            unsafe { (shared.api.stream_queue_buffer)(stream, b) };
719        }
720    }
721    let Some(newest) = newest.map(|p| p.as_ptr()) else {
722        return;
723    };
724    let buf = unsafe { &*(*newest).buffer };
725    let idx = unsafe { (*newest).user_data } as usize;
726    let generation = shared.generation.load(Ordering::Acquire);
727    let pts = header_pts(buf);
728    let arrival = now_ns();
729    let period_ns = 1_000_000_000_000 / shared.cfg.lock().unwrap().fps_milli.max(1000) as i64;
730    let (stamp_ns, refusal) = shared
731        .stamps
732        .lock()
733        .unwrap()
734        .instant(pts, arrival, period_ns);
735    let frame = {
736        let mut buffers = shared.buffers.lock().unwrap();
737        let Some(slot) = buffers.get_mut(idx).filter(|s| s.pw == newest) else {
738            unsafe { (shared.api.stream_queue_buffer)(stream, newest) };
739            return;
740        };
741        if !unsafe { import_slot(slot, buf, &n) } {
742            unsafe { (shared.api.stream_queue_buffer)(stream, newest) };
743            return;
744        }
745        let chunk = unsafe { &*(*buf.datas).chunk };
746        let cpu = slot.cpu.as_ref().map(|map| HostCpuFrame {
747            map: map.clone(),
748            offset: chunk.offset as usize,
749            stride: if chunk.stride > 0 {
750                chunk.stride as usize
751            } else {
752                n.width as usize * 4
753            },
754            format: shm_code(n.fourcc),
755        });
756        slot.held = true;
757        HostFrame {
758            generation,
759            slot: idx,
760            dmabuf: slot.dmabuf.clone(),
761            cpu,
762            width: n.width,
763            height: n.height,
764            damage: unsafe { read_damage(buf, n.width, n.height) },
765            stamp_ns,
766        }
767    };
768    let count = shared.frames.fetch_add(1, Ordering::Relaxed);
769    let age_ms = (arrival - stamp_ns) as f64 / 1e6;
770    if count == 0 {
771        eprintln!(
772            "[HostCapture] output {}: first portal frame {}x{}, {} damage rect(s), {}",
773            shared.sink.index,
774            n.width,
775            n.height,
776            frame.damage.len(),
777            match refusal {
778                None => format!("{age_ms:.1} ms after the compositor stamped it"),
779                Some(why) => format!(
780                    "stamped at arrival: the compositor's stamp is {}",
781                    why.says()
782                ),
783            }
784        );
785    }
786    if let Some(why) = refusal.filter(|why| count > 0 && shared.stamps.lock().unwrap().first(*why))
787    {
788        eprintln!(
789            "[HostCapture] output {}: frame {count} stamped at arrival: the compositor's stamp is {}",
790            shared.sink.index,
791            why.says()
792        );
793    }
794    if trace() {
795        eprintln!(
796            "[HostTrace] output {} frame {count} process +{age_ms:.2}ms pts {} arrival {arrival}{}",
797            shared.sink.index,
798            pts.map_or("none".to_string(), |pts| pts.to_string()),
799            refusal.map_or(String::new(), |why| format!(" refused: {}", why.says()))
800        );
801    }
802    if !shared.sink.send(frame) {
803        if let Some(slot) = shared.buffers.lock().unwrap().get_mut(idx) {
804            slot.held = false;
805        }
806        unsafe { (shared.api.stream_queue_buffer)(stream, newest) };
807    }
808}
809
810/// One output's stream: the consumer end of a portal monitor stream.
811pub struct PwStream {
812    core: Arc<PwCore>,
813    stream: *mut c_void,
814    shared: Arc<Shared>,
815    _events: Box<PwStreamEvents>,
816    _hook: Box<[u64; 8]>,
817}
818
819unsafe impl Send for PwStream {}
820
821impl PwStream {
822    /// Connect to the stream node `node_id` and start negotiating `cfg`; frames reach `sink`,
823    /// cursor sprites `cursor_tx`.
824    pub(crate) fn connect(
825        core: &Arc<PwCore>,
826        node_id: u32,
827        sink: FrameSink,
828        cursor_tx: std::sync::mpsc::Sender<CursorJob>,
829        cfg: StreamConfig,
830    ) -> Result<Self, String> {
831        let api = core.api;
832        let pods = enum_format_pods(&cfg);
833        let shared = Arc::new(Shared {
834            api,
835            stream: AtomicPtr::new(ptr::null_mut()),
836            sink,
837            cursor_tx,
838            cfg: Mutex::new(cfg),
839            negotiated: Mutex::new(None),
840            buffers: Mutex::new(Vec::new()),
841            generation: AtomicU64::new(0),
842            state: AtomicI32::new(0),
843            errored: AtomicBool::new(false),
844            frames: AtomicU64::new(0),
845            stamps: Mutex::new(Stamps::default()),
846            cursor_pos: AtomicI64::new(i64::MIN),
847            cursor_hash: AtomicU64::new(0),
848            params: Mutex::new(Vec::new()),
849        });
850        let events = Box::new(PwStreamEvents {
851            state_changed: Some(on_state_changed),
852            param_changed: Some(on_param_changed),
853            add_buffer: Some(on_add_buffer),
854            remove_buffer: Some(on_remove_buffer),
855            process: Some(on_process),
856            ..PwStreamEvents::empty()
857        });
858        let mut this = PwStream {
859            core: core.clone(),
860            stream: ptr::null_mut(),
861            shared,
862            _events: events,
863            _hook: Box::new([0u64; 8]),
864        };
865        let result = core.locked(|| unsafe {
866            let props = (api.properties_new)(ptr::null::<c_char>());
867            if props.is_null() {
868                return Err("pw_properties_new failed".to_string());
869            }
870            for (k, v) in [
871                (c"media.type", c"Video"),
872                (c"media.category", c"Capture"),
873                (c"media.role", c"Screen"),
874            ] {
875                (api.properties_set)(props, k.as_ptr(), v.as_ptr());
876            }
877            this.stream = (api.stream_new)(core.core, c"pixelflux host capture".as_ptr(), props);
878            if this.stream.is_null() {
879                return Err("pw_stream_new failed".to_string());
880            }
881            this.shared.stream.store(this.stream, Ordering::Release);
882            (api.stream_add_listener)(
883                this.stream,
884                this._hook.as_mut_ptr() as *mut c_void,
885                &*this._events,
886                Arc::as_ptr(&this.shared) as *mut c_void,
887            );
888            let mut ptrs: Vec<*const c_void> =
889                pods.iter().map(|p| p.as_ptr() as *const c_void).collect();
890            let rc = (api.stream_connect)(
891                this.stream,
892                PW_DIRECTION_INPUT,
893                node_id,
894                PW_STREAM_FLAG_AUTOCONNECT,
895                ptrs.as_mut_ptr(),
896                ptrs.len() as u32,
897            );
898            if rc < 0 {
899                return Err(format!("pw_stream_connect failed ({rc})"));
900            }
901            Ok(())
902        });
903        *this.shared.params.lock().unwrap() = pods;
904        result?;
905        Ok(this)
906    }
907
908    /// Offer a new configuration; unchanged asks cost nothing, a change renegotiates.
909    pub fn reconfigure(&self, cfg: StreamConfig) {
910        let pods = {
911            let mut cur = self.shared.cfg.lock().unwrap();
912            if *cur == cfg {
913                return;
914            }
915            *cur = cfg;
916            enum_format_pods(&cur)
917        };
918        self.core.locked(|| unsafe {
919            let mut ptrs: Vec<*const c_void> =
920                pods.iter().map(|p| p.as_ptr() as *const c_void).collect();
921            (self.shared.api.stream_update_params)(
922                self.stream,
923                ptrs.as_mut_ptr(),
924                ptrs.len() as u32,
925            );
926        });
927        *self.shared.params.lock().unwrap() = pods;
928    }
929
930    /// Return a published frame's buffer to the compositor.
931    pub fn release(&self, generation: u64, slot: usize) {
932        if generation != self.shared.generation.load(Ordering::Acquire) {
933            return;
934        }
935        self.core.locked(|| {
936            let mut buffers = self.shared.buffers.lock().unwrap();
937            if let Some(s) = buffers.get_mut(slot).filter(|s| s.held && !s.pw.is_null()) {
938                s.held = false;
939                unsafe { (self.shared.api.stream_queue_buffer)(self.stream, s.pw) };
940            }
941        });
942    }
943
944    /// Return every held buffer (an idled output has no consumer for them).
945    pub fn release_all(&self) {
946        self.core.locked(|| {
947            let mut buffers = self.shared.buffers.lock().unwrap();
948            for s in buffers.iter_mut().filter(|s| s.held && !s.pw.is_null()) {
949                s.held = false;
950                unsafe { (self.shared.api.stream_queue_buffer)(self.stream, s.pw) };
951            }
952        });
953    }
954
955    pub fn set_active(&self, active: bool) {
956        self.core
957            .locked(|| unsafe { (self.shared.api.stream_set_active)(self.stream, active) });
958    }
959
960    pub fn negotiated(&self) -> Option<Negotiated> {
961        *self.shared.negotiated.lock().unwrap()
962    }
963
964    pub fn errored(&self) -> bool {
965        self.shared.errored.load(Ordering::Acquire)
966            || self.shared.state.load(Ordering::Acquire) == PW_STREAM_STATE_ERROR
967    }
968
969    pub fn frames(&self) -> u64 {
970        self.shared.frames.load(Ordering::Relaxed)
971    }
972
973    /// The host cursor position the last cursor meta carried, in stream pixels.
974    pub fn cursor_position(&self) -> Option<(i32, i32)> {
975        let v = self.shared.cursor_pos.load(Ordering::Relaxed);
976        (v != i64::MIN).then_some(((v >> 32) as i32, v as i32))
977    }
978
979    /// Block up to `timeout` for the stream to reach a state where frames may flow.
980    pub fn wait_negotiated(&self, timeout: Duration) -> bool {
981        let deadline = Instant::now() + timeout;
982        while Instant::now() < deadline {
983            if self.negotiated().is_some() || self.errored() {
984                return self.negotiated().is_some();
985            }
986            std::thread::sleep(Duration::from_millis(5));
987        }
988        false
989    }
990}
991
992impl Drop for PwStream {
993    fn drop(&mut self) {
994        self.core.locked(|| unsafe {
995            self.shared.stream.store(ptr::null_mut(), Ordering::Release);
996            if !self.stream.is_null() {
997                (self.shared.api.stream_disconnect)(self.stream);
998                (self.shared.api.stream_destroy)(self.stream);
999            }
1000        });
1001    }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007
1008    const PERIOD: i64 = 16_683_333;
1009    const NOW: i64 = 5_000_000_000_000;
1010
1011    /// A stamp a little before its frame's arrival, later than the last, is the capture
1012    /// instant; each refusal falls back to the arrival, which still advances past the last.
1013    #[test]
1014    fn a_compositor_stamp_is_taken_only_where_it_is_plausible() {
1015        let mut stamps = Stamps::default();
1016        assert_eq!(
1017            stamps.instant(Some(NOW - 3_000_000), NOW, PERIOD),
1018            (NOW - 3_000_000, None)
1019        );
1020        let next = NOW + PERIOD;
1021        assert_eq!(
1022            stamps.instant(Some(NOW - 3_000_000), next, PERIOD),
1023            (next, Some(StampRefusal::Repeated)),
1024            "a stamp reused from the previous frame"
1025        );
1026        let next = next + PERIOD;
1027        assert_eq!(
1028            stamps.instant(Some(next + 1), next, PERIOD),
1029            (next, Some(StampRefusal::Ahead)),
1030            "a stamp ahead of the arrival"
1031        );
1032        let next = next + PERIOD;
1033        assert_eq!(
1034            stamps.instant(Some(next - 5 * PERIOD), next, PERIOD),
1035            (next, Some(StampRefusal::Stale)),
1036            "a stamp five frame periods old"
1037        );
1038        let next = next + PERIOD;
1039        assert_eq!(
1040            stamps.instant(Some(1_759_000_000_000_000_000), next, PERIOD),
1041            (next, Some(StampRefusal::Ahead)),
1042            "a CLOCK_REALTIME stamp"
1043        );
1044        let next = next + PERIOD;
1045        assert_eq!(
1046            stamps.instant(Some(40_000_000), next, PERIOD),
1047            (next, Some(StampRefusal::Stale)),
1048            "a stamp counted from the stream's start"
1049        );
1050        let next = next + PERIOD;
1051        assert_eq!(
1052            stamps.instant(None, next, PERIOD),
1053            (next, Some(StampRefusal::Missing)),
1054            "no stamp (the header meta absent, or zero)"
1055        );
1056        let next = next + PERIOD;
1057        assert_eq!(
1058            stamps.instant(Some(next - 2_000_000), next, PERIOD),
1059            (next - 2_000_000, None)
1060        );
1061    }
1062
1063    /// A stamp that is plausible on its own but earlier than an arrival given to the frame
1064    /// before is refused too, so the instants never go back.
1065    #[test]
1066    fn the_instants_never_go_back() {
1067        let mut stamps = Stamps::default();
1068        let (first, why) = stamps.instant(None, NOW, PERIOD);
1069        assert_eq!((first, why), (NOW, Some(StampRefusal::Missing)));
1070        let (second, why) = stamps.instant(Some(NOW - 1_000_000), NOW + 1_000_000, PERIOD);
1071        assert_eq!(why, Some(StampRefusal::Repeated));
1072        assert!(second > first);
1073        let mut seen = Stamps::default();
1074        assert!(seen.first(StampRefusal::Repeated));
1075        assert!(!seen.first(StampRefusal::Repeated));
1076        assert!(seen.first(StampRefusal::Stale));
1077    }
1078
1079    fn cfg(zero_copy: bool) -> StreamConfig {
1080        StreamConfig {
1081            zero_copy,
1082            dma_formats: vec![
1083                (FOURCC_XR24, vec![0x0300_0000_0000_0001, 0]),
1084                (FOURCC_AR24, vec![0]),
1085            ],
1086            fps_milli: 59_940,
1087            expect: (1920, 1080),
1088        }
1089    }
1090
1091    /// A zero-copy ask lists one modifier-bearing entry per importable fourcc ahead of the
1092    /// plain entries, each holding the rate cap and the expected size; a CPU ask lists only
1093    /// the plain entries.
1094    #[test]
1095    fn enum_formats_lead_with_modifiers_when_zero_copy() {
1096        let pods = enum_format_pods(&cfg(true));
1097        assert_eq!(pods.len(), 2 + SHM_FORMATS.len());
1098        let (_, _, first) = object_props(&pods[0]).unwrap();
1099        let modifier = find_prop(&first, SPA_FORMAT_VIDEO_MODIFIER).unwrap();
1100        assert_eq!(
1101            modifier.flags,
1102            SPA_POD_PROP_FLAG_MANDATORY | SPA_POD_PROP_FLAG_DONT_FIXATE
1103        );
1104        assert_eq!(modifier.as_i64(), Some(0x0300_0000_0000_0001));
1105        assert_eq!(
1106            find_prop(&first, SPA_FORMAT_VIDEO_FORMAT).unwrap().as_u32(),
1107            Some(SPA_VIDEO_FORMAT_BGRX)
1108        );
1109        assert_eq!(
1110            find_prop(&first, SPA_FORMAT_VIDEO_SIZE).unwrap().as_pair(),
1111            Some((1920, 1080))
1112        );
1113        assert_eq!(
1114            find_prop(&first, SPA_FORMAT_VIDEO_MAX_FRAMERATE)
1115                .unwrap()
1116                .as_pair(),
1117            Some((59_940, 1000))
1118        );
1119        let (_, _, plain) = object_props(&pods[2]).unwrap();
1120        assert!(find_prop(&plain, SPA_FORMAT_VIDEO_MODIFIER).is_none());
1121        let cpu = enum_format_pods(&cfg(false));
1122        assert_eq!(cpu.len(), SHM_FORMATS.len());
1123        for pod in &cpu {
1124            let (_, _, props) = object_props(pod).unwrap();
1125            assert!(find_prop(&props, SPA_FORMAT_VIDEO_MODIFIER).is_none());
1126        }
1127    }
1128
1129    /// A fixated format the compositor sends back parses into geometry, fourcc, and the
1130    /// dmabuf decision, and the buffer params answer with the matching data type.
1131    #[test]
1132    fn fixated_format_parses_and_selects_buffer_type() {
1133        let fixated = object(SPA_TYPE_OBJECT_FORMAT, SPA_PARAM_FORMAT, |p| {
1134            prop(p, SPA_FORMAT_MEDIA_TYPE, |v| {
1135                pod_id(v, SPA_MEDIA_TYPE_VIDEO)
1136            });
1137            prop(p, SPA_FORMAT_MEDIA_SUBTYPE, |v| {
1138                pod_id(v, SPA_MEDIA_SUBTYPE_RAW)
1139            });
1140            prop(p, SPA_FORMAT_VIDEO_FORMAT, |v| {
1141                pod_id(v, SPA_VIDEO_FORMAT_BGRA)
1142            });
1143            prop(p, SPA_FORMAT_VIDEO_MODIFIER, |v| {
1144                pod_choice_long(v, SPA_CHOICE_NONE, &[0x0300_0000_0000_0007])
1145            });
1146            prop(p, SPA_FORMAT_VIDEO_SIZE, |v| pod_rect(v, 1280, 720));
1147            prop(p, SPA_FORMAT_VIDEO_FRAMERATE, |v| pod_frac(v, 0, 1));
1148        });
1149        let n = parse_format(&fixated).unwrap();
1150        assert_eq!(
1151            n,
1152            Negotiated {
1153                width: 1280,
1154                height: 720,
1155                fourcc: FOURCC_AR24,
1156                modifier: Some(0x0300_0000_0000_0007)
1157            }
1158        );
1159        let params = negotiated_params(&n, true);
1160        let (_, _, buffers) = object_props(&params[0]).unwrap();
1161        assert_eq!(
1162            find_prop(&buffers, SPA_PARAM_BUFFERS_DATATYPE)
1163                .unwrap()
1164                .as_u32(),
1165            Some(1 << SPA_DATA_DMABUF)
1166        );
1167        assert_eq!(
1168            find_prop(&buffers, SPA_PARAM_BUFFERS_BUFFERS)
1169                .unwrap()
1170                .as_u32(),
1171            Some(4)
1172        );
1173        let memfd = object(SPA_TYPE_OBJECT_FORMAT, SPA_PARAM_FORMAT, |p| {
1174            prop(p, SPA_FORMAT_VIDEO_FORMAT, |v| {
1175                pod_id(v, SPA_VIDEO_FORMAT_RGBX)
1176            });
1177            prop(p, SPA_FORMAT_VIDEO_SIZE, |v| pod_rect(v, 640, 480));
1178        });
1179        let n = parse_format(&memfd).unwrap();
1180        assert_eq!((n.fourcc, n.modifier), (FOURCC_XB24, None));
1181        let params = negotiated_params(&n, false);
1182        let (_, _, buffers) = object_props(&params[0]).unwrap();
1183        assert_eq!(
1184            find_prop(&buffers, SPA_PARAM_BUFFERS_DATATYPE)
1185                .unwrap()
1186                .as_u32(),
1187            Some(1 << SPA_DATA_MEMFD)
1188        );
1189        assert_eq!(shm_code(FOURCC_XR24), 1);
1190        assert_eq!(shm_code(FOURCC_AR24), 0);
1191        assert_eq!(shm_code(FOURCC_XB24), FOURCC_XB24);
1192    }
1193}