moq/video.rs
1//! Native video decode via [`moq_video`].
2//!
3//! The video counterpart to [`audio`](crate::audio)'s decoder: subscribe to an
4//! H.264 track and hand back decoded raw frames, with the decode happening
5//! inside the FFI boundary (VideoToolbox on macOS, openh264 elsewhere; no
6//! ffmpeg). Sibling to `moq_consume_video`, which delivers the
7//! still-encoded frames for a caller that brings its own decoder.
8//!
9//! Only H.264 is supported. A non-H.264 rendition fails the subscribe with a
10//! terminal error on the callback.
11
12use std::ffi::c_void;
13use std::time::Duration;
14
15use tokio::sync::oneshot;
16
17use crate::ffi::OnStatus;
18use crate::{Error, Id, NonZeroSlab, State, ffi};
19
20// ---- C-visible types ----
21
22/// Decode-side configuration the caller passes to [`moq_consume_video_raw`].
23///
24/// Output is always tightly-packed I420 (see [`moq_video_frame`]); there is no
25/// format/resolution knob yet. The struct exists so future options (a pixel
26/// format, a target size) stay additive.
27#[repr(C)]
28#[allow(non_camel_case_types)]
29pub struct moq_video_decoder_output {
30 /// Upper bound on buffering before skipping a stalled group, in
31 /// milliseconds. Same congestion-control knob as
32 /// `moq_consume_video`'s `max_latency_ms`. 0 = skip aggressively
33 /// (the moq-mux default); set to your playout buffer for a softer skip.
34 pub latency_max_ms: u64,
35}
36
37/// One decoded video frame: packed I420 plus a presentation timestamp.
38///
39/// `data` is `width * height * 3 / 2` bytes: the Y plane (`width * height`),
40/// then U, then V (`width/2 * height/2` each), no row padding. It's BT.601
41/// limited range. `width` and `height` are even. `data` is owned by the consume
42/// slab and stays valid until the same id is released with
43/// [`moq_consume_video_raw_frame_free`].
44#[repr(C)]
45#[allow(non_camel_case_types)]
46pub struct moq_video_frame {
47 pub timestamp_us: u64,
48 pub width: u32,
49 pub height: u32,
50 pub data: *const u8,
51 pub data_size: usize,
52}
53
54// ---- State extension (used internally by lib.rs) ----
55
56/// Raw-video consume state: decoder tasks plus their buffered decoded frames.
57#[derive(Default)]
58pub struct Video {
59 consumer_tasks: NonZeroSlab<Option<VideoTaskEntry>>,
60 frames: NonZeroSlab<VideoFrame>,
61}
62
63/// A delivered frame, flattened to CPU I420 at delivery time: the C ABI hands
64/// out a stable byte pointer, so a GPU-decoded frame (e.g. NVDEC) is downloaded
65/// exactly once here.
66struct VideoFrame {
67 timestamp_us: u64,
68 width: u32,
69 height: u32,
70 data: bytes::Bytes,
71}
72
73/// A spawned task entry: `close` signals shutdown, `callback` delivers status.
74///
75/// Same lifetime contract as the audio decoder: the task delivers one final
76/// terminal callback and then removes itself, so `user_data` stays valid until
77/// that callback fires. `close` is an `Option` so `consume_close` can drop just
78/// the sender without removing the entry.
79struct VideoTaskEntry {
80 close: Option<oneshot::Sender<()>>,
81 callback: OnStatus,
82}
83
84impl Video {
85 pub fn consume(
86 &mut self,
87 broadcast: &moq_net::broadcast::Consumer,
88 catalog: &hang::catalog::VideoConfig,
89 name: &str,
90 config: moq_video::decode::Config,
91 on_frame: OnStatus,
92 ) -> Result<Id, Error> {
93 let broadcast = broadcast.clone();
94 let catalog = catalog.clone();
95 let name = name.to_string();
96
97 let channel = oneshot::channel();
98 let entry = VideoTaskEntry {
99 close: Some(channel.0),
100 callback: on_frame,
101 };
102 let id = self.consumer_tasks.insert(Some(entry))?;
103
104 // `Consumer::new` subscribes (blocking on SUBSCRIBE_OK), so run it inside
105 // the task to keep this entrypoint non-blocking.
106 tokio::spawn(async move {
107 let res = async move {
108 let consumer = moq_video::decode::Consumer::new(&broadcast, &catalog, name, config).await?;
109 Self::run(on_frame, consumer, channel.1).await
110 }
111 .await;
112
113 // Deliver one final terminal callback (code <= 0), then drop the entry.
114 // Pull it out from under the lock so the callback never runs while held.
115 let entry = State::lock().video.consumer_tasks.remove(id).flatten();
116 if let Some(entry) = entry {
117 entry.callback.call(res);
118 }
119 });
120
121 Ok(id)
122 }
123
124 async fn run(
125 callback: OnStatus,
126 mut consumer: moq_video::decode::Consumer,
127 mut close: oneshot::Receiver<()>,
128 ) -> Result<(), Error> {
129 loop {
130 // `biased` so a pending close always wins over a ready frame.
131 let frame = tokio::select! {
132 biased;
133 _ = &mut close => return Ok(()),
134 frame = consumer.read() => match frame? {
135 Some(frame) => frame,
136 None => return Ok(()),
137 },
138 };
139
140 // Flatten to CPU bytes outside the lock (a GPU frame downloads here),
141 // then hold the lock only to buffer it; release before the callback.
142 let frame = VideoFrame {
143 // The C ABI carries microseconds; the decoded frame's Timestamp is
144 // constrained to a QUIC VarInt, so the microsecond value fits a u64.
145 timestamp_us: frame.timestamp.as_micros() as u64,
146 width: frame.size.width,
147 height: frame.size.height,
148 data: frame.surface.into_i420()?,
149 };
150 let frame_id = State::lock().video.frames.insert(frame)?;
151 callback.call(Ok(frame_id));
152 }
153 }
154
155 pub fn consume_close(&mut self, id: Id) -> Result<(), Error> {
156 // Signal shutdown; the task delivers a final callback and removes itself.
157 self.consumer_tasks
158 .get_mut(id)
159 .and_then(|entry| entry.as_mut())
160 .ok_or(Error::TrackNotFound)?
161 .close
162 .take()
163 .ok_or(Error::TrackNotFound)?;
164 Ok(())
165 }
166
167 pub fn frame_info(&self, id: Id, dst: &mut moq_video_frame) -> Result<(), Error> {
168 let frame = self.frames.get(id).ok_or(Error::FrameNotFound)?;
169 *dst = moq_video_frame {
170 timestamp_us: frame.timestamp_us,
171 width: frame.width,
172 height: frame.height,
173 data: frame.data.as_ptr(),
174 data_size: frame.data.len(),
175 };
176 Ok(())
177 }
178
179 pub fn frame_free(&mut self, id: Id) -> Result<(), Error> {
180 self.frames.remove(id).ok_or(Error::FrameNotFound)?;
181 Ok(())
182 }
183}
184
185// ---- C entry points ----
186
187/// Subscribe to a video track and decode it into raw I420 frames.
188///
189/// The catalog `index` selects which video rendition to subscribe to, matching
190/// the existing `moq_consume_video` selection model. Only H.264 is
191/// supported; a non-H.264 rendition fails on the terminal callback.
192///
193/// Returns a non-zero handle on success or a negative error code.
194///
195/// `on_frame` is called with a positive frame id per decoded frame, then exactly
196/// once more with a terminal code: `0` (closed cleanly) or a negative error.
197/// After the terminal (`<= 0`) callback, `on_frame` is never called again and
198/// `user_data` is never touched again, so release `user_data` there. The terminal
199/// callback fires even after [`moq_consume_video_raw_close`].
200///
201/// # Safety
202/// - `output` must point to a valid [`moq_video_decoder_output`].
203/// - `user_data` must stay valid until the terminal (`<= 0`) `on_frame` callback.
204#[unsafe(no_mangle)]
205pub unsafe extern "C" fn moq_consume_video_raw(
206 catalog: u32,
207 index: u32,
208 output: *const moq_video_decoder_output,
209 on_frame: Option<extern "C" fn(user_data: *mut c_void, frame: i32)>,
210 user_data: *mut c_void,
211) -> i32 {
212 ffi::enter(move || {
213 let catalog = ffi::parse_id(catalog)?;
214 let raw = unsafe { output.as_ref() }.ok_or(Error::InvalidPointer)?;
215
216 let mut config = moq_video::decode::Config::new();
217 config.latency_max = if raw.latency_max_ms == 0 {
218 None
219 } else {
220 Some(Duration::from_millis(raw.latency_max_ms))
221 };
222 let on_frame = unsafe { OnStatus::new(user_data, on_frame) };
223
224 let mut state = State::lock();
225 let (broadcast, video_cfg, name) = state.consume.video_rendition(catalog, index as usize)?;
226
227 let State { video, .. } = &mut *state;
228 video.consume(&broadcast, &video_cfg, &name, config, on_frame)
229 })
230}
231
232/// Stop a video (raw) consumer's background task.
233///
234/// Returns immediately: zero on success, or a negative code if already closed.
235/// Does NOT free `user_data`; the on-frame callback still fires once more with a
236/// terminal `0` (or a negative error), which is where `user_data` should be
237/// released. Frame ids already delivered are likewise not freed; release each
238/// with [`moq_consume_video_raw_frame_free`].
239#[unsafe(no_mangle)]
240pub extern "C" fn moq_consume_video_raw_close(consumer: u32) -> i32 {
241 ffi::enter(move || {
242 let consumer = ffi::parse_id(consumer)?;
243 State::lock().video.consume_close(consumer)
244 })
245}
246
247/// Copy a delivered frame's metadata into `dst`.
248///
249/// The written `dst->data` pointer remains valid until the same `id` is released
250/// with [`moq_consume_video_raw_frame_free`].
251///
252/// # Safety
253/// - `dst` must point to a writable [`moq_video_frame`].
254#[unsafe(no_mangle)]
255pub unsafe extern "C" fn moq_consume_video_raw_frame(id: u32, dst: *mut moq_video_frame) -> i32 {
256 ffi::enter(move || {
257 let id = ffi::parse_id(id)?;
258 let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?;
259 State::lock().video.frame_info(id, dst)
260 })
261}
262
263/// Free a frame previously delivered through the consume callback. Required for
264/// every delivered frame id; closing the parent consumer is not enough.
265#[unsafe(no_mangle)]
266pub extern "C" fn moq_consume_video_raw_frame_free(id: u32) -> i32 {
267 ffi::enter(move || {
268 let id = ffi::parse_id(id)?;
269 State::lock().video.frame_free(id)
270 })
271}