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 size = frame.size();
143 let frame = VideoFrame {
144 // The C ABI carries microseconds; the decoded frame's Timestamp is
145 // constrained to a QUIC VarInt, so the microsecond value fits a u64.
146 timestamp_us: frame.timestamp.as_micros() as u64,
147 width: size.width,
148 height: size.height,
149 data: frame.surface.into_i420()?,
150 };
151 let frame_id = State::lock().video.frames.insert(frame)?;
152 callback.call(Ok(frame_id));
153 }
154 }
155
156 pub fn consume_close(&mut self, id: Id) -> Result<(), Error> {
157 // Signal shutdown; the task delivers a final callback and removes itself.
158 self.consumer_tasks
159 .get_mut(id)
160 .and_then(|entry| entry.as_mut())
161 .ok_or(Error::TrackNotFound)?
162 .close
163 .take()
164 .ok_or(Error::TrackNotFound)?;
165 Ok(())
166 }
167
168 pub fn frame_info(&self, id: Id, dst: &mut moq_video_frame) -> Result<(), Error> {
169 let frame = self.frames.get(id).ok_or(Error::FrameNotFound)?;
170 *dst = moq_video_frame {
171 timestamp_us: frame.timestamp_us,
172 width: frame.width,
173 height: frame.height,
174 data: frame.data.as_ptr(),
175 data_size: frame.data.len(),
176 };
177 Ok(())
178 }
179
180 pub fn frame_free(&mut self, id: Id) -> Result<(), Error> {
181 self.frames.remove(id).ok_or(Error::FrameNotFound)?;
182 Ok(())
183 }
184}
185
186// ---- C entry points ----
187
188/// Subscribe to a video track and decode it into raw I420 frames.
189///
190/// The catalog `index` selects which video rendition to subscribe to, matching
191/// the existing `moq_consume_video` selection model. Only H.264 is
192/// supported; a non-H.264 rendition fails on the terminal callback.
193///
194/// Returns a non-zero handle on success or a negative error code.
195///
196/// `on_frame` is called with a positive frame id per decoded frame, then exactly
197/// once more with a terminal code: `0` (closed cleanly) or a negative error.
198/// After the terminal (`<= 0`) callback, `on_frame` is never called again and
199/// `user_data` is never touched again, so release `user_data` there. The terminal
200/// callback fires even after [`moq_consume_video_raw_close`].
201///
202/// # Safety
203/// - `output` must point to a valid [`moq_video_decoder_output`].
204/// - `user_data` must stay valid until the terminal (`<= 0`) `on_frame` callback.
205#[unsafe(no_mangle)]
206pub unsafe extern "C" fn moq_consume_video_raw(
207 catalog: u32,
208 index: u32,
209 output: *const moq_video_decoder_output,
210 on_frame: Option<extern "C" fn(user_data: *mut c_void, frame: i32)>,
211 user_data: *mut c_void,
212) -> i32 {
213 ffi::enter(move || {
214 let catalog = ffi::parse_id(catalog)?;
215 let raw = unsafe { output.as_ref() }.ok_or(Error::InvalidPointer)?;
216
217 let mut config = moq_video::decode::Config::new();
218 config.latency_max = if raw.latency_max_ms == 0 {
219 None
220 } else {
221 Some(Duration::from_millis(raw.latency_max_ms))
222 };
223 let on_frame = unsafe { OnStatus::new(user_data, on_frame) };
224
225 let mut state = State::lock();
226 let (broadcast, video_cfg, name) = state.consume.video_rendition(catalog, index as usize)?;
227
228 let State { video, .. } = &mut *state;
229 video.consume(&broadcast, &video_cfg, &name, config, on_frame)
230 })
231}
232
233/// Stop a video (raw) consumer's background task.
234///
235/// Returns immediately: zero on success, or a negative code if already closed.
236/// Does NOT free `user_data`; the on-frame callback still fires once more with a
237/// terminal `0` (or a negative error), which is where `user_data` should be
238/// released. Frame ids already delivered are likewise not freed; release each
239/// with [`moq_consume_video_raw_frame_free`].
240#[unsafe(no_mangle)]
241pub extern "C" fn moq_consume_video_raw_close(consumer: u32) -> i32 {
242 ffi::enter(move || {
243 let consumer = ffi::parse_id(consumer)?;
244 State::lock().video.consume_close(consumer)
245 })
246}
247
248/// Copy a delivered frame's metadata into `dst`.
249///
250/// The written `dst->data` pointer remains valid until the same `id` is released
251/// with [`moq_consume_video_raw_frame_free`].
252///
253/// # Safety
254/// - `dst` must point to a writable [`moq_video_frame`].
255#[unsafe(no_mangle)]
256pub unsafe extern "C" fn moq_consume_video_raw_frame(id: u32, dst: *mut moq_video_frame) -> i32 {
257 ffi::enter(move || {
258 let id = ffi::parse_id(id)?;
259 let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?;
260 State::lock().video.frame_info(id, dst)
261 })
262}
263
264/// Free a frame previously delivered through the consume callback. Required for
265/// every delivered frame id; closing the parent consumer is not enough.
266#[unsafe(no_mangle)]
267pub extern "C" fn moq_consume_video_raw_frame_free(id: u32) -> i32 {
268 ffi::enter(move || {
269 let id = ffi::parse_id(id)?;
270 State::lock().video.frame_free(id)
271 })
272}