Skip to main content

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}