grafton_ndi/capture.rs
1//! Unified frame capture abstraction via trait-based generics.
2//!
3//! This module provides a single generic RAII guard and capture path for all frame types
4//! (video, audio, metadata), eliminating duplication while maintaining zero-cost abstraction.
5//!
6//! # Architecture
7//!
8//! The public capture kind describes Rust frame types; private strategies
9//! describe SDK routing and freeing:
10//!
11//! - [`FrameFree`] is the *free strategy*: the SDK instance handle type plus the
12//! `free_*` call that releases one captured frame. The generic RAII guard
13//! [`Guard<'owner, S>`](Guard) is written once against it, so every owning
14//! surface — the [`Receiver`](crate::Receiver) capture path and
15//! [`FrameSync`](crate::FrameSync) — shares one guard rather than hand-rolling
16//! its own.
17//! - [`CaptureKind`] exposes only borrowed/owned frame types and conversion.
18//! - `CaptureStrategy` privately connects each receiver kind to its SDK capture
19//! call and frame-type discriminant.
20//!
21//! # Example
22//!
23//! ```text
24//! // Internal schematic; this module is not part of the public API
25//! use crate::capture::{Guard, VideoKind};
26//!
27//! let guard = unsafe { Guard::<VideoKind>::new(instance, frame) };
28//! // Guard automatically calls the correct free function when dropped
29//! ```
30
31use std::{marker::PhantomData, rc::Rc};
32
33use crate::{
34 frames::{
35 AudioFrame, AudioFrameRef, MetadataFrame, MetadataFrameRef, VideoFrame, VideoFrameRef,
36 },
37 ndi_lib::*,
38 Result,
39};
40
41/// Sealed trait module to prevent external implementations of [`FrameFree`] and
42/// [`CaptureKind`].
43mod sealed {
44 pub trait Sealed {}
45
46 impl Sealed for super::VideoKind {}
47 impl Sealed for super::AudioKind {}
48 impl Sealed for super::MetadataKind {}
49 impl Sealed for super::FrameSyncVideoFree {}
50 impl Sealed for super::FrameSyncAudioFree {}
51}
52
53/// The *free strategy* for one captured-frame family: the SDK instance handle
54/// type plus the SDK call that releases a single frame.
55///
56/// This is the single axis along which the RAII `Guard` varies. The receiver
57/// kinds ([`VideoKind`], [`AudioKind`], [`MetadataKind`]) free through
58/// `NDIlib_recv_free_*`; the FrameSync strategies ([`FrameSyncVideoFree`],
59/// [`FrameSyncAudioFree`]) free through `NDIlib_framesync_free_*`. Factoring the
60/// free call out of [`CaptureKind`] lets both families reuse one guard and one
61/// borrowed-reference core instead of maintaining parallel copies.
62///
63/// This is a sealed trait — it cannot be implemented outside this crate.
64///
65/// # Safety
66///
67/// Implementors must ensure [`free`](Self::free) releases a frame that was
68/// populated by the matching SDK capture call through the same `instance`.
69pub(crate) trait FrameFree: sealed::Sealed + 'static {
70 /// The SDK instance handle that owns the frame buffers (and through which
71 /// they must be freed).
72 type Instance: Copy;
73
74 /// The raw FFI frame type from the NDI SDK.
75 type RawFrame: Default + Copy;
76
77 /// The `Debug` struct name of the borrowed reference that wraps this guard
78 /// (e.g. `"VideoFrameRef"` or `"FrameSyncVideoRef"`), so the shared generic
79 /// `Debug` impls render the historically-correct type name.
80 const REF_DEBUG_NAME: &'static str;
81
82 /// Free a single captured frame through its owning instance.
83 ///
84 /// # Safety
85 ///
86 /// - `instance` must be the instance that produced `frame` (the FrameSync
87 /// strategies additionally tolerate a null `instance` by short-circuiting).
88 /// - `frame` must have been populated by a successful capture for this
89 /// strategy and not already freed.
90 unsafe fn free(instance: Self::Instance, frame: &mut Self::RawFrame);
91}
92
93/// A sealed receiver capture kind with its borrowed and owned frame types.
94///
95/// Implemented for [`VideoKind`], [`AudioKind`], and [`MetadataKind`]. Raw SDK
96/// routing and freeing are private implementation details. Generic callers can
97/// use [`crate::Capture`] with this trait and convert `K::Ref<'_>` into
98/// `K::Owned` through [`Self::ref_to_owned`].
99///
100/// Receiver frame references implement `Send + Sync` and can move to scoped
101/// worker threads while borrowing their receiver. FrameSync references use
102/// separate private strategies and remain thread-affine.
103#[allow(private_bounds)]
104pub trait CaptureKind: CaptureStrategy + Sized {
105 /// A zero-copy captured frame borrowing the receiver.
106 type Ref<'rx>;
107 /// An owned frame that can outlive the receiver.
108 type Owned;
109
110 /// Copy a borrowed view into an owned frame.
111 fn ref_to_owned(frame: &Self::Ref<'_>) -> Result<Self::Owned>;
112}
113
114/// Private SDK routing paired with the corresponding free strategy.
115/// Implementations must capture, wrap, and free the same raw frame family.
116pub(crate) trait CaptureStrategy:
117 FrameFree<Instance = NDIlib_recv_instance_t> + Sized
118{
119 /// The frame-type discriminant `NDIlib_recv_capture_v3` returns for this kind.
120 const FRAME_TYPE: NDIlib_frame_type_e;
121
122 /// Run a capture for this kind, routing `frame` into the matching
123 /// `NDIlib_recv_capture_v3` slot and ignoring the others.
124 ///
125 /// # Safety
126 ///
127 /// - `instance` must be a valid NDI receiver instance.
128 /// - `frame` must point to a writable [`RawFrame`](FrameFree::RawFrame).
129 unsafe fn capture_into(
130 instance: NDIlib_recv_instance_t,
131 frame: *mut Self::RawFrame,
132 timeout_ms: u32,
133 ) -> NDIlib_frame_type_e;
134
135 /// Wrap a freshly captured frame guard in its borrowed view, validating any
136 /// kind-specific invariants (e.g. FourCC) during construction.
137 ///
138 /// # Safety
139 ///
140 /// `guard` must own a frame that `capture_into` reported as
141 /// [`FRAME_TYPE`](Self::FRAME_TYPE).
142 unsafe fn make_ref<'rx>(guard: Guard<'rx, Self>) -> Result<<Self as CaptureKind>::Ref<'rx>>
143 where
144 Self: CaptureKind;
145}
146
147/// Marker type for video frame capture operations.
148pub struct VideoKind;
149
150impl FrameFree for VideoKind {
151 type Instance = NDIlib_recv_instance_t;
152 type RawFrame = NDIlib_video_frame_v2_t;
153 const REF_DEBUG_NAME: &'static str = "VideoFrameRef";
154
155 unsafe fn free(instance: Self::Instance, frame: &mut Self::RawFrame) {
156 NDIlib_recv_free_video_v2(instance, frame);
157 }
158}
159
160impl CaptureKind for VideoKind {
161 type Ref<'rx> = VideoFrameRef<'rx>;
162 type Owned = VideoFrame;
163 fn ref_to_owned(frame: &Self::Ref<'_>) -> Result<Self::Owned> {
164 frame.to_owned()
165 }
166}
167
168impl CaptureStrategy for VideoKind {
169 const FRAME_TYPE: NDIlib_frame_type_e = NDIlib_frame_type_e_NDIlib_frame_type_video;
170
171 unsafe fn capture_into(
172 instance: NDIlib_recv_instance_t,
173 frame: *mut Self::RawFrame,
174 timeout_ms: u32,
175 ) -> NDIlib_frame_type_e {
176 NDIlib_recv_capture_v3(
177 instance,
178 frame,
179 std::ptr::null_mut(), // no audio
180 std::ptr::null_mut(), // no metadata
181 timeout_ms,
182 )
183 }
184
185 unsafe fn make_ref<'rx>(guard: Guard<'rx, Self>) -> Result<<Self as CaptureKind>::Ref<'rx>> {
186 VideoFrameRef::new(guard)
187 }
188}
189
190/// Marker type for audio frame capture operations.
191pub struct AudioKind;
192
193impl FrameFree for AudioKind {
194 type Instance = NDIlib_recv_instance_t;
195 type RawFrame = NDIlib_audio_frame_v3_t;
196 const REF_DEBUG_NAME: &'static str = "AudioFrameRef";
197
198 unsafe fn free(instance: Self::Instance, frame: &mut Self::RawFrame) {
199 NDIlib_recv_free_audio_v3(instance, frame);
200 }
201}
202
203impl CaptureKind for AudioKind {
204 type Ref<'rx> = AudioFrameRef<'rx>;
205 type Owned = AudioFrame;
206 fn ref_to_owned(frame: &Self::Ref<'_>) -> Result<Self::Owned> {
207 frame.to_owned()
208 }
209}
210
211impl CaptureStrategy for AudioKind {
212 const FRAME_TYPE: NDIlib_frame_type_e = NDIlib_frame_type_e_NDIlib_frame_type_audio;
213
214 unsafe fn capture_into(
215 instance: NDIlib_recv_instance_t,
216 frame: *mut Self::RawFrame,
217 timeout_ms: u32,
218 ) -> NDIlib_frame_type_e {
219 NDIlib_recv_capture_v3(
220 instance,
221 std::ptr::null_mut(), // no video
222 frame,
223 std::ptr::null_mut(), // no metadata
224 timeout_ms,
225 )
226 }
227
228 unsafe fn make_ref<'rx>(guard: Guard<'rx, Self>) -> Result<<Self as CaptureKind>::Ref<'rx>> {
229 AudioFrameRef::new(guard)
230 }
231}
232
233/// Marker type for metadata frame capture operations.
234pub struct MetadataKind;
235
236impl FrameFree for MetadataKind {
237 type Instance = NDIlib_recv_instance_t;
238 type RawFrame = NDIlib_metadata_frame_t;
239 const REF_DEBUG_NAME: &'static str = "MetadataFrameRef";
240
241 unsafe fn free(instance: Self::Instance, frame: &mut Self::RawFrame) {
242 NDIlib_recv_free_metadata(instance, frame);
243 }
244}
245
246impl CaptureKind for MetadataKind {
247 type Ref<'rx> = MetadataFrameRef<'rx>;
248 type Owned = MetadataFrame;
249 fn ref_to_owned(frame: &Self::Ref<'_>) -> Result<Self::Owned> {
250 // Metadata `to_owned` is infallible; lift it into the shared `Result` shape.
251 Ok(frame.to_owned())
252 }
253}
254
255impl CaptureStrategy for MetadataKind {
256 const FRAME_TYPE: NDIlib_frame_type_e = NDIlib_frame_type_e_NDIlib_frame_type_metadata;
257
258 unsafe fn capture_into(
259 instance: NDIlib_recv_instance_t,
260 frame: *mut Self::RawFrame,
261 timeout_ms: u32,
262 ) -> NDIlib_frame_type_e {
263 NDIlib_recv_capture_v3(
264 instance,
265 std::ptr::null_mut(), // no video
266 std::ptr::null_mut(), // no audio
267 frame,
268 timeout_ms,
269 )
270 }
271
272 unsafe fn make_ref<'rx>(guard: Guard<'rx, Self>) -> Result<<Self as CaptureKind>::Ref<'rx>> {
273 MetadataFrameRef::new(guard)
274 }
275}
276
277/// Free strategy for video frames captured through [`FrameSync`](crate::FrameSync).
278///
279/// Frees via `NDIlib_framesync_free_video`. Unlike the receiver strategies this
280/// tolerates a null instance handle (the borrowed-frame tests construct guards
281/// with a null instance), short-circuiting the free in that case.
282pub struct FrameSyncVideoFree;
283
284impl FrameFree for FrameSyncVideoFree {
285 type Instance = NDIlib_framesync_instance_t;
286 type RawFrame = NDIlib_video_frame_v2_t;
287 const REF_DEBUG_NAME: &'static str = "FrameSyncVideoRef";
288
289 unsafe fn free(instance: Self::Instance, frame: &mut Self::RawFrame) {
290 if instance.is_null() {
291 return;
292 }
293 NDIlib_framesync_free_video(instance, frame);
294 }
295}
296
297/// Free strategy for audio frames captured through [`FrameSync`](crate::FrameSync).
298///
299/// Frees via `NDIlib_framesync_free_audio_v2`, tolerating a null instance handle
300/// the same way [`FrameSyncVideoFree`] does.
301pub struct FrameSyncAudioFree;
302
303impl FrameFree for FrameSyncAudioFree {
304 type Instance = NDIlib_framesync_instance_t;
305 type RawFrame = NDIlib_audio_frame_v3_t;
306 const REF_DEBUG_NAME: &'static str = "FrameSyncAudioRef";
307
308 unsafe fn free(instance: Self::Instance, frame: &mut Self::RawFrame) {
309 if instance.is_null() {
310 return;
311 }
312 #[cfg(test)]
313 crate::framesync::audio_test_calls::free();
314 NDIlib_framesync_free_audio_v2(instance, frame);
315 }
316}
317
318/// Generic RAII guard for captured NDI frames.
319///
320/// This guard ensures that captured frames are freed exactly once via the free
321/// strategy `S`'s [`FrameFree::free`] call. The strategy is determined by the
322/// `S: FrameFree` type parameter, enabling compile-time dispatch with zero
323/// runtime cost — receiver captures use `NDIlib_recv_free_*`, FrameSync captures
324/// use `NDIlib_framesync_free_*`, all through the same guard.
325///
326/// The lifetime parameter `'owner` ties this guard to the owner that created it
327/// (a `Receiver` or a `FrameSync`), preventing use-after-free by ensuring the
328/// owner cannot be dropped while this guard is alive.
329///
330/// # Type Parameters
331///
332/// - `'owner`: Lifetime of the owning instance's borrow
333/// - `S`: The free strategy (video/audio/metadata, receiver or FrameSync)
334///
335/// # Safety
336///
337/// This struct stores raw FFI types and must only be created through the `unsafe fn new()`
338/// constructor, which requires the caller to guarantee validity of the instance and frame.
339pub struct Guard<'owner, S: FrameFree> {
340 instance: S::Instance,
341 frame: S::RawFrame,
342 // Ties the guard (and the borrowed refs built on it) to the owning instance's
343 // borrow. The owner type is irrelevant to the borrow checker, so a unit
344 // reference is enough to carry `'owner` covariantly.
345 _owner: PhantomData<&'owner ()>,
346 // FrameSync buffers remain conservative about cross-thread use. Receiver
347 // strategies explicitly opt in below using the SDK's cross-thread free
348 // guarantee, rather than raw-pointer auto traits from generated bindings.
349 _thread_affine: PhantomData<Rc<()>>,
350}
351
352impl<'owner, S: FrameFree> Guard<'owner, S> {
353 /// Create a new frame guard.
354 ///
355 /// # Safety
356 ///
357 /// The caller must ensure that:
358 /// - `instance` is a valid instance for the free strategy `S` (or null only
359 /// for the FrameSync strategies, which short-circuit their free)
360 /// - `frame` was populated by a successful capture for `S` and not yet freed
361 /// - the owning instance and captured allocation remain valid for all of
362 /// `'owner`; callers must tie this lifetime to a borrow of that owner
363 pub(crate) unsafe fn new(instance: S::Instance, frame: S::RawFrame) -> Self {
364 Self {
365 instance,
366 frame,
367 _owner: PhantomData,
368 _thread_affine: PhantomData,
369 }
370 }
371
372 /// Get a reference to the underlying raw frame.
373 pub(crate) fn frame(&self) -> &S::RawFrame {
374 &self.frame
375 }
376}
377
378// SAFETY: NDI-RECV explicitly allows captured video, audio, and metadata frames
379// to be freed on another thread. Each guard exclusively owns its captured frame;
380// shared references expose immutable SDK storage until the guard is dropped.
381// The owner borrow keeps the thread-safe Receiver alive throughout this period.
382// https://docs.ndi.video/all/developing-with-ndi/sdk/ndi-recv
383unsafe impl Send for Guard<'_, VideoKind> {}
384unsafe impl Sync for Guard<'_, VideoKind> {}
385unsafe impl Send for Guard<'_, AudioKind> {}
386unsafe impl Sync for Guard<'_, AudioKind> {}
387unsafe impl Send for Guard<'_, MetadataKind> {}
388unsafe impl Sync for Guard<'_, MetadataKind> {}
389
390impl<'owner, S: FrameFree> Drop for Guard<'owner, S> {
391 fn drop(&mut self) {
392 // SAFETY: The constructor guarantees that instance and frame are valid
393 unsafe {
394 S::free(self.instance, &mut self.frame);
395 }
396 }
397}
398
399/// Result of a generic capture operation.
400///
401/// This enum represents the possible outcomes of calling `NDIlib_recv_capture_v3`:
402/// - `Frame`: Successfully captured a frame of the expected type
403/// - `None`: No frame available (timeout)
404/// - `Error`: The SDK returned an error frame
405pub(crate) enum CaptureResult<'rx, K: CaptureKind> {
406 /// Successfully captured a frame of the expected type.
407 Frame(Guard<'rx, K>),
408 /// No frame available within timeout, or a different frame type was returned.
409 None,
410 /// A capability invalidation notification, without a frame buffer.
411 StatusChanged,
412 /// The SDK returned an error frame.
413 Error,
414}
415
416/// Capture a frame of kind `K` from an NDI receiver.
417///
418/// Routes the frame into the matching `NDIlib_recv_capture_v3` slot via
419/// [`CaptureStrategy::capture_into`] and classifies the result. Other frame types
420/// are treated as [`CaptureResult::None`]; status changes are retained separately.
421///
422/// # Safety
423///
424/// `instance` must be a valid NDI receiver instance that remains alive for
425/// `'rx`. The caller must tie `'rx` to a borrow of its owning Receiver.
426pub(crate) unsafe fn capture_raw<'rx, K: CaptureKind>(
427 instance: NDIlib_recv_instance_t,
428 timeout_ms: u32,
429) -> CaptureResult<'rx, K> {
430 let mut frame = K::RawFrame::default();
431 let frame_type = K::capture_into(instance, &mut frame, timeout_ms);
432
433 match frame_type {
434 t if t == K::FRAME_TYPE => CaptureResult::Frame(Guard::<K>::new(instance, frame)),
435 NDIlib_frame_type_e_NDIlib_frame_type_none => CaptureResult::None,
436 NDIlib_frame_type_e_NDIlib_frame_type_status_change => CaptureResult::StatusChanged,
437 NDIlib_frame_type_e_NDIlib_frame_type_error => CaptureResult::Error,
438 _ => CaptureResult::None, // Other frame types are ignored
439 }
440}
441
442// Convenience aliases for the receiver guard kinds that have a kind-specific
443// constructor (audio validates a concrete layout; metadata wraps its own ref).
444// Video and the FrameSync families name `Guard<'_, Strategy>` directly.
445/// RAII guard for a captured audio frame.
446///
447/// Automatically calls `NDIlib_recv_free_audio_v3` when dropped.
448pub type RecvAudioGuard<'rx> = Guard<'rx, AudioKind>;
449
450/// RAII guard for a captured metadata frame.
451///
452/// Automatically calls `NDIlib_recv_free_metadata` when dropped.
453pub type RecvMetadataGuard<'rx> = Guard<'rx, MetadataKind>;
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn test_guard_sizes() {
461 use std::mem::size_of;
462
463 // Guards should be compact - instance pointer + frame struct + zero-sized PhantomData
464 assert!(size_of::<RecvAudioGuard>() > 0);
465 assert!(size_of::<RecvMetadataGuard>() > 0);
466
467 // Generic guard with different kinds should have same overhead per kind
468 // (sizes differ only due to RawFrame size differences)
469 assert!(size_of::<Guard<VideoKind>>() > 0);
470 assert!(size_of::<Guard<AudioKind>>() > 0);
471 assert!(size_of::<Guard<MetadataKind>>() > 0);
472 assert!(size_of::<Guard<FrameSyncVideoFree>>() > 0);
473 assert!(size_of::<Guard<FrameSyncAudioFree>>() > 0);
474 }
475
476 #[test]
477 fn test_frame_type_constants() {
478 // Verify frame type constants match expected values
479 assert_eq!(
480 VideoKind::FRAME_TYPE,
481 NDIlib_frame_type_e_NDIlib_frame_type_video
482 );
483 assert_eq!(
484 AudioKind::FRAME_TYPE,
485 NDIlib_frame_type_e_NDIlib_frame_type_audio
486 );
487 assert_eq!(
488 MetadataKind::FRAME_TYPE,
489 NDIlib_frame_type_e_NDIlib_frame_type_metadata
490 );
491 }
492}