moq/video.rs
1//! Native video encode/decode via [`moq_video`].
2//!
3//! The video counterpart to [`audio`](crate::audio): publish raw pictures as an
4//! encoded video track, and subscribe to one and hand back decoded raw frames,
5//! with the codec running inside the FFI boundary (VideoToolbox on macOS, Media
6//! Foundation on Windows, NVENC/VAAPI/NVDEC on Linux, openh264 as the software
7//! fallback; no ffmpeg). Siblings to `moq_publish_media_*` /
8//! `moq_consume_video`, which carry already-encoded frames for a caller that
9//! brings its own codec.
10//!
11//! Decode is H.264 only; a non-H.264 rendition fails the subscribe with a
12//! terminal error on the callback. Encode covers H.264 and H.265 (see
13//! [`moq_video_codec`]).
14
15use std::ffi::{c_char, c_void};
16use std::time::Duration;
17
18use tokio::sync::oneshot;
19
20use crate::ffi::OnStatus;
21use crate::{Error, Id, NonZeroSlab, State, ffi};
22
23// ---- C-visible types ----
24
25/// Pixel layout of the raw frames handed to [`moq_publish_video_raw_frame`].
26///
27/// The enum is exposed in the C header for readability, but ABI fields that
28/// carry it are typed `u32`. A C caller passing an unknown discriminant gets
29/// `Error::InvalidCode` instead of UB.
30#[repr(C)]
31#[allow(non_camel_case_types)]
32#[derive(Clone, Copy, Debug)]
33pub enum moq_video_pixel_format {
34 /// Tightly-packed planar I420: Y, then U, then V, no row padding.
35 /// `width * height * 3 / 2` bytes, the same layout [`moq_consume_video_raw`]
36 /// hands back.
37 MOQ_VIDEO_PIXEL_FORMAT_I420 = 0,
38 /// Tightly-packed RGBA, `width * height * 4` bytes, no row padding.
39 MOQ_VIDEO_PIXEL_FORMAT_RGBA = 1,
40}
41
42/// Output video codec for [`moq_publish_video_raw`].
43///
44/// Not every codec has a backend on every machine: H.265 is hardware-only, so
45/// publishing it fails where no hardware encoder is available.
46#[repr(C)]
47#[allow(non_camel_case_types)]
48#[derive(Clone, Copy, Debug)]
49pub enum moq_video_codec {
50 /// H.264 / AVC, published as an `avc3` track.
51 MOQ_VIDEO_CODEC_H264 = 0,
52 /// H.265 / HEVC, published as a `hev1` track.
53 MOQ_VIDEO_CODEC_H265 = 1,
54}
55
56/// Which encoder implementation [`moq_publish_video_raw`] should use.
57#[repr(C)]
58#[allow(non_camel_case_types)]
59#[derive(Clone, Copy, Debug)]
60pub enum moq_video_encoder_kind {
61 /// Prefer a platform hardware encoder, falling back to software.
62 MOQ_VIDEO_ENCODER_KIND_AUTO = 0,
63 /// Hardware only; fails if none is available.
64 MOQ_VIDEO_ENCODER_KIND_HARDWARE = 1,
65 /// Software only (openh264, H.264 only).
66 MOQ_VIDEO_ENCODER_KIND_SOFTWARE = 2,
67 /// A specific backend, named by `moq_video_encoder_output::encoder`.
68 MOQ_VIDEO_ENCODER_KIND_NAMED = 3,
69}
70
71/// Raw frame layout the caller hands to [`moq_publish_video_raw_frame`], plus
72/// the resolution and rate the encoder is opened at. Every published frame must
73/// match `width` x `height`; scale before publishing if your source moves.
74#[repr(C)]
75#[allow(non_camel_case_types)]
76pub struct moq_video_encoder_input {
77 /// `moq_video_pixel_format` discriminant.
78 pub format: u32,
79 /// Encoded width in pixels. Must be even (I420 chroma is subsampled 2x2).
80 pub width: u32,
81 /// Encoded height in pixels. Must be even.
82 pub height: u32,
83 /// Nominal frames per second, used for the codec time base and the default
84 /// bitrate and keyframe interval. Must be non-zero.
85 pub framerate: u32,
86}
87
88/// Codec-side configuration for [`moq_publish_video_raw`]. Every knob spells
89/// "unset" as 0.
90#[repr(C)]
91#[allow(non_camel_case_types)]
92pub struct moq_video_encoder_output {
93 /// `moq_video_codec` discriminant.
94 pub codec: u32,
95 /// Target bitrate in bits per second. 0 derives one from the resolution and
96 /// framerate.
97 pub bitrate: u64,
98 /// Keyframe interval in frames: a subscriber joining mid-stream waits at
99 /// most this many frames before it can decode. 0 uses ~2 seconds.
100 pub gop: u32,
101 /// `moq_video_encoder_kind` discriminant.
102 pub kind: u32,
103 /// Backend name, UTF-8, e.g. `"videotoolbox"`, `"nvenc"`, `"vaapi"`,
104 /// `"mediafoundation"`, `"openh264"`. Read only when `kind` is
105 /// `MOQ_VIDEO_ENCODER_KIND_NAMED`.
106 pub encoder: *const c_char,
107 pub encoder_len: usize,
108}
109
110/// One raw frame handed to [`moq_publish_video_raw_frame`].
111///
112/// Pixel format and resolution are fixed by [`moq_video_encoder_input`] at
113/// publish time, so a frame carries neither: `data` is exactly one picture in
114/// that layout, borrowed for the duration of the call (the encoder copies before
115/// returning). The decode side has its own [`moq_video_frame`], which does carry
116/// dimensions, since there they are what the stream turned out to be.
117#[repr(C)]
118#[allow(non_camel_case_types)]
119pub struct moq_video_encoder_frame {
120 /// Presentation timestamp, in microseconds.
121 pub timestamp_us: u64,
122 pub data: *const u8,
123 pub data_size: usize,
124}
125
126/// Decode-side configuration the caller passes to [`moq_consume_video_raw`].
127///
128/// Output is always tightly-packed I420 (see [`moq_video_frame`]); there is no
129/// format/resolution knob yet. The struct exists so future options (a pixel
130/// format, a target size) stay additive.
131#[repr(C)]
132#[allow(non_camel_case_types)]
133pub struct moq_video_decoder_output {
134 /// Upper bound on buffering before skipping a stalled group, in
135 /// milliseconds. Same congestion-control knob as
136 /// `moq_consume_video`'s `max_latency_ms`. 0 = skip aggressively
137 /// (the moq-mux default); set to your playout buffer for a softer skip.
138 pub latency_max_ms: u64,
139}
140
141/// One decoded video frame from [`moq_consume_video_raw`]: packed I420 plus a
142/// presentation timestamp.
143///
144/// `data` is the Y plane (`width * height`), then U, then V (`width/2 *
145/// height/2` each), no row padding, BT.601 limited range, with `width` and
146/// `height` even. It's owned by the consume slab and stays valid until the same
147/// id is released with [`moq_consume_video_raw_frame_free`].
148///
149/// The publish side has its own [`moq_video_encoder_frame`], which carries no
150/// dimensions because the encoder already fixed them.
151#[repr(C)]
152#[allow(non_camel_case_types)]
153pub struct moq_video_frame {
154 pub timestamp_us: u64,
155 pub width: u32,
156 pub height: u32,
157 pub data: *const u8,
158 pub data_size: usize,
159}
160
161// ---- State extension (used internally by lib.rs) ----
162
163/// Raw-video state: encoders being published, plus decoder tasks and their
164/// buffered decoded frames.
165#[derive(Default)]
166pub struct Video {
167 producers: NonZeroSlab<VideoEncoder>,
168 consumer_tasks: NonZeroSlab<Option<VideoTaskEntry>>,
169 frames: NonZeroSlab<VideoFrame>,
170}
171
172/// Wait out an encode-thread round trip from a C entry point.
173///
174/// The C ABI hands back a status code, so there is no executor to yield to and
175/// this is where [`Sink`](moq_video::encode::Sink)'s futures stop. Blocking is
176/// also what paces the caller: a raw frame is megabytes, so a publish free to run
177/// ahead of the codec would queue pictures without bound.
178///
179/// `pollster` rather than a tokio helper because those panic when the calling
180/// thread is driving a runtime, which the one dispatching a callback is.
181fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
182 pollster::block_on(future)
183}
184
185/// An encoder paired with the track publishing its output, plus the pixel format
186/// its caller feeds it (fixed at publish time, so a frame carries only pixels and
187/// a timestamp).
188///
189/// The encoder is a [`Sink`](moq_video::encode::Sink) rather than a bare
190/// `Encoder` because [`ffi::enter`] serializes calls without confining them:
191/// each one runs on whichever thread the C caller used, so a bare `Encoder`
192/// would be built on one thread and dropped on another, unbalancing the
193/// per-thread COM apartment the Windows backend opens. The sink owns the thread
194/// instead, so every caller is welcome.
195struct VideoEncoder {
196 encoder: moq_video::encode::Sink,
197 producer: moq_video::encode::Producer<moq_mux::catalog::hang::Extra>,
198 format: moq_video_pixel_format,
199 /// The encoded resolution, from the publish config. Frames carry only pixels,
200 /// so this is what says how to read them.
201 size: moq_video::Size,
202}
203
204/// A delivered frame, flattened to CPU I420 at delivery time: the C ABI hands
205/// out a stable byte pointer, so a GPU-decoded frame (e.g. NVDEC) is downloaded
206/// exactly once here.
207struct VideoFrame {
208 timestamp_us: u64,
209 width: u32,
210 height: u32,
211 data: bytes::Bytes,
212}
213
214/// End a video track, given the result of draining its encoder into it.
215///
216/// A clean finish is a promise that the track holds everything the publisher
217/// produced, so a lost tail has to end the track as an abort instead. Finishing
218/// anyway would leave a truncated stream indistinguishable from a complete one,
219/// and only the local caller would ever learn otherwise.
220fn finalize(
221 producer: moq_video::encode::Producer<moq_mux::catalog::hang::Extra>,
222 drained: Result<(), moq_video::Error>,
223) -> Result<(), Error> {
224 match drained {
225 Ok(()) => Ok(producer.finish()?),
226 Err(err) => {
227 producer.abort(moq_net::Error::Transport(err.to_string()));
228 Err(err.into())
229 }
230 }
231}
232
233/// A spawned task entry: `close` signals shutdown, `callback` delivers status.
234///
235/// Same lifetime contract as the audio decoder: the task delivers one final
236/// terminal callback and then removes itself, so `user_data` stays valid until
237/// that callback fires. `close` is an `Option` so `consume_close` can drop just
238/// the sender without removing the entry.
239struct VideoTaskEntry {
240 close: Option<oneshot::Sender<()>>,
241 callback: OnStatus,
242}
243
244impl Video {
245 pub fn publish(
246 &mut self,
247 broadcast: &moq_net::broadcast::Producer,
248 catalog: moq_mux::catalog::Producer<moq_mux::catalog::hang::Extra>,
249 format: moq_video_pixel_format,
250 config: moq_video::encode::Config,
251 ) -> Result<Id, Error> {
252 // Open the encoder first: a config this machine can't encode should fail
253 // without leaving a track advertised that will never carry frames.
254 let encoder = block_on(moq_video::encode::Sink::open(&config))?;
255 let producer = moq_video::encode::Producer::new(broadcast.clone(), catalog, config.codec)?;
256 self.producers.insert(VideoEncoder {
257 encoder,
258 producer,
259 format,
260 size: config.size(),
261 })
262 }
263
264 pub fn publish_frame(&mut self, id: Id, timestamp_us: u64, data: &[u8]) -> Result<(), Error> {
265 let entry = self.producers.get_mut(id).ok_or(Error::MediaNotFound)?;
266
267 // A buffer that isn't one picture at the configured size is rejected here,
268 // by the surface constructors, rather than reinterpreted.
269 let size = entry.size;
270 let surface = match entry.format {
271 moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_I420 => {
272 moq_video::Surface::I420(moq_video::I420::new(size.width, size.height, data.to_vec())?)
273 }
274 moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_RGBA => moq_video::Surface::rgba(data, size)?,
275 };
276
277 let frame = moq_video::Frame::new(surface, moq_net::Timestamp::from_micros(timestamp_us)?);
278 // A backend that pipelines hands back an earlier frame's output, so this is
279 // zero or more access units rather than one per call.
280 let encoded = block_on(entry.encoder.encode(frame))?;
281 entry.producer.publish(&encoded)?;
282 Ok(())
283 }
284
285 pub fn publish_cut(&mut self, id: Id) -> Result<(), Error> {
286 let entry = self.producers.get_mut(id).ok_or(Error::MediaNotFound)?;
287 // A keyframe is what a cut is on the wire: the importer closes the open
288 // group and starts a new one at it.
289 entry.encoder.keyframe();
290 Ok(())
291 }
292
293 pub fn publish_bitrate(&mut self, id: Id, bitrate: u64) -> Result<(), Error> {
294 let entry = self.producers.get_mut(id).ok_or(Error::MediaNotFound)?;
295 block_on(entry.encoder.set_bitrate(bitrate))?;
296 Ok(())
297 }
298
299 pub fn publish_finish(&mut self, id: Id) -> Result<(), Error> {
300 let entry = self.producers.remove(id).ok_or(Error::MediaNotFound)?;
301 let VideoEncoder {
302 encoder, mut producer, ..
303 } = entry;
304 // Drain the codec into the track before ending it, so the last frames land
305 // in it rather than being dropped with the encoder.
306 let drained = block_on(encoder.finish()).and_then(|encoded| producer.publish(&encoded));
307 finalize(producer, drained)
308 }
309
310 pub fn consume(
311 &mut self,
312 broadcast: &moq_net::broadcast::Consumer,
313 catalog: &hang::catalog::VideoConfig,
314 name: &str,
315 config: moq_video::decode::Config,
316 on_frame: OnStatus,
317 ) -> Result<Id, Error> {
318 let broadcast = broadcast.clone();
319 let catalog = catalog.clone();
320 let name = name.to_string();
321
322 let channel = oneshot::channel();
323 let entry = VideoTaskEntry {
324 close: Some(channel.0),
325 callback: on_frame,
326 };
327 let id = self.consumer_tasks.insert(Some(entry))?;
328
329 // `Consumer::new` subscribes (blocking on SUBSCRIBE_OK), so run it inside
330 // the task to keep this entrypoint non-blocking.
331 tokio::spawn(async move {
332 let res = async move {
333 let consumer = moq_video::decode::Consumer::new(&broadcast, &catalog, name, config).await?;
334 Self::run(on_frame, consumer, channel.1).await
335 }
336 .await;
337
338 // Deliver one final terminal callback (code <= 0), then drop the entry.
339 // Pull it out from under the lock so the callback never runs while held.
340 let entry = State::lock().video.consumer_tasks.remove(id).flatten();
341 if let Some(entry) = entry {
342 entry.callback.call(res);
343 }
344 });
345
346 Ok(id)
347 }
348
349 async fn run(
350 callback: OnStatus,
351 mut consumer: moq_video::decode::Consumer,
352 mut close: oneshot::Receiver<()>,
353 ) -> Result<(), Error> {
354 loop {
355 // `biased` so a pending close always wins over a ready frame.
356 let frame = tokio::select! {
357 biased;
358 _ = &mut close => return Ok(()),
359 frame = consumer.read() => match frame? {
360 Some(frame) => frame,
361 None => return Ok(()),
362 },
363 };
364
365 // Flatten to CPU bytes outside the lock (a GPU frame downloads here),
366 // then hold the lock only to buffer it; release before the callback.
367 let size = frame.size();
368 let frame = VideoFrame {
369 // The C ABI carries microseconds; the decoded frame's Timestamp is
370 // constrained to a QUIC VarInt, so the microsecond value fits a u64.
371 timestamp_us: frame.timestamp.as_micros() as u64,
372 width: size.width,
373 height: size.height,
374 data: frame.surface.into_i420()?,
375 };
376 let frame_id = State::lock().video.frames.insert(frame)?;
377 callback.call(Ok(frame_id));
378 }
379 }
380
381 pub fn consume_close(&mut self, id: Id) -> Result<(), Error> {
382 // Signal shutdown; the task delivers a final callback and removes itself.
383 self.consumer_tasks
384 .get_mut(id)
385 .and_then(|entry| entry.as_mut())
386 .ok_or(Error::TrackNotFound)?
387 .close
388 .take()
389 .ok_or(Error::TrackNotFound)?;
390 Ok(())
391 }
392
393 pub fn frame_info(&self, id: Id, dst: &mut moq_video_frame) -> Result<(), Error> {
394 let frame = self.frames.get(id).ok_or(Error::FrameNotFound)?;
395 *dst = moq_video_frame {
396 timestamp_us: frame.timestamp_us,
397 width: frame.width,
398 height: frame.height,
399 data: frame.data.as_ptr(),
400 data_size: frame.data.len(),
401 };
402 Ok(())
403 }
404
405 pub fn frame_free(&mut self, id: Id) -> Result<(), Error> {
406 self.frames.remove(id).ok_or(Error::FrameNotFound)?;
407 Ok(())
408 }
409}
410
411// ---- C entry points ----
412
413fn pixel_format_from_u32(value: u32) -> Result<moq_video_pixel_format, Error> {
414 Ok(match value {
415 v if v == moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_I420 as u32 => {
416 moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_I420
417 }
418 v if v == moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_RGBA as u32 => {
419 moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_RGBA
420 }
421 _ => return Err(Error::InvalidCode),
422 })
423}
424
425fn codec_from_u32(value: u32) -> Result<moq_video::encode::Codec, Error> {
426 use moq_video::encode::Codec;
427 Ok(match value {
428 v if v == moq_video_codec::MOQ_VIDEO_CODEC_H264 as u32 => Codec::H264,
429 v if v == moq_video_codec::MOQ_VIDEO_CODEC_H265 as u32 => Codec::H265,
430 _ => return Err(Error::InvalidCode),
431 })
432}
433
434/// # Safety
435/// - `output->encoder` must point to `output->encoder_len` bytes of UTF-8 when
436/// `output->kind` is `MOQ_VIDEO_ENCODER_KIND_NAMED`.
437unsafe fn encoder_kind(output: &moq_video_encoder_output) -> Result<moq_video::encode::Kind, Error> {
438 use moq_video::encode::Kind;
439 Ok(match output.kind {
440 v if v == moq_video_encoder_kind::MOQ_VIDEO_ENCODER_KIND_AUTO as u32 => Kind::Auto,
441 v if v == moq_video_encoder_kind::MOQ_VIDEO_ENCODER_KIND_HARDWARE as u32 => Kind::Hardware,
442 v if v == moq_video_encoder_kind::MOQ_VIDEO_ENCODER_KIND_SOFTWARE as u32 => Kind::Software,
443 v if v == moq_video_encoder_kind::MOQ_VIDEO_ENCODER_KIND_NAMED as u32 => {
444 Kind::Named(unsafe { ffi::parse_str(output.encoder, output.encoder_len)? }.to_string())
445 }
446 _ => return Err(Error::InvalidCode),
447 })
448}
449
450/// Open a video track on a broadcast, encoding the raw frames you publish to it.
451///
452/// The encoder is opened here, so an unsupported codec, resolution, or backend
453/// fails now rather than on the first frame. The track is named after the codec
454/// (`.avc3` / `.hev1`) and its catalog rendition appears once the first keyframe
455/// has been encoded, which is where the resolution and codec string come from.
456///
457/// Returns a non-zero handle on success or a negative error code.
458///
459/// # Safety
460/// - `input` / `output` must point to fully populated structs.
461/// - `output->encoder` must point to `output->encoder_len` bytes of UTF-8 when
462/// `output->kind` is `MOQ_VIDEO_ENCODER_KIND_NAMED`.
463#[unsafe(no_mangle)]
464pub unsafe extern "C" fn moq_publish_video_raw(
465 broadcast: u32,
466 input: *const moq_video_encoder_input,
467 output: *const moq_video_encoder_output,
468) -> i32 {
469 ffi::enter(move || {
470 let broadcast = ffi::parse_id(broadcast)?;
471 let raw_input = unsafe { input.as_ref() }.ok_or(Error::InvalidPointer)?;
472 let raw_output = unsafe { output.as_ref() }.ok_or(Error::InvalidPointer)?;
473
474 let format = pixel_format_from_u32(raw_input.format)?;
475
476 let mut config = moq_video::encode::Config::new(raw_input.width, raw_input.height, raw_input.framerate);
477 config.codec = codec_from_u32(raw_output.codec)?;
478 config.kind = unsafe { encoder_kind(raw_output)? };
479 // The C ABI spells an unset knob as 0, which neither field accepts as a real
480 // value: a zero bitrate or GOP is the default, not a request.
481 config.bitrate = (raw_output.bitrate != 0).then_some(raw_output.bitrate);
482 if raw_output.gop != 0 {
483 config.gop = raw_output.gop;
484 }
485
486 let mut state = State::lock();
487 let State { publish, video, .. } = &mut *state;
488 let (broadcast_producer, catalog) = publish.pair_mut(broadcast)?;
489
490 video.publish(broadcast_producer, catalog.clone(), format, config)
491 })
492}
493
494/// Encode and publish one raw frame.
495///
496/// `frame->data` is borrowed for the duration of the call and must be exactly one
497/// picture in the pixel format and at the resolution declared by
498/// [`moq_video_encoder_input`].
499/// A backend that pipelines publishes an earlier frame's output here, so a call
500/// that emits nothing is normal rather than an error.
501///
502/// # Safety
503/// - `frame` must point to a valid [`moq_video_encoder_frame`].
504/// - `frame->data` must point to `frame->data_size` bytes.
505#[unsafe(no_mangle)]
506pub unsafe extern "C" fn moq_publish_video_raw_frame(producer: u32, frame: *const moq_video_encoder_frame) -> i32 {
507 ffi::enter(move || {
508 let producer = ffi::parse_id(producer)?;
509 let frame = unsafe { frame.as_ref() }.ok_or(Error::InvalidPointer)?;
510 let data = unsafe { ffi::parse_slice(frame.data, frame.data_size)? };
511
512 State::lock().video.publish_frame(producer, frame.timestamp_us, data)
513 })
514}
515
516/// Cut a new group at the next published frame.
517///
518/// Optional. The encoder already keyframes every `moq_video_encoder_output.gop`
519/// frames, and each of those cuts a group, so a subscriber can always join
520/// without you calling this. Reach for it only to place the boundaries yourself:
521/// aligning groups with something the encoder cannot see, such as a scene change,
522/// a source switch, or resuming after an idle gap.
523///
524/// The next frame is encoded as a keyframe, which closes the open group and
525/// starts a new one at it. Calling this repeatedly before that frame arrives cuts
526/// once, not several times.
527#[unsafe(no_mangle)]
528pub extern "C" fn moq_publish_video_raw_cut(producer: u32) -> i32 {
529 ffi::enter(move || {
530 let producer = ffi::parse_id(producer)?;
531 State::lock().video.publish_cut(producer)
532 })
533}
534
535/// Retune a live encoder to `bitrate` bits per second, taking effect from
536/// roughly the next frame. No keyframe is forced, so this is cheap enough to
537/// drive from a congestion controller.
538///
539/// The configured bitrate is a ceiling on some backends (openh264 rejects a raise
540/// above the rate it opened at), so set `bitrate` to the highest you will ask
541/// for and adapt downwards from there.
542///
543/// Returns a negative code if this backend cannot retune while running. That is
544/// not fatal: the encoder keeps running at its current rate, so stop adapting
545/// rather than stop publishing.
546#[unsafe(no_mangle)]
547pub extern "C" fn moq_publish_video_raw_bitrate(producer: u32, bitrate: u64) -> i32 {
548 ffi::enter(move || {
549 let producer = ffi::parse_id(producer)?;
550 State::lock().video.publish_bitrate(producer, bitrate)
551 })
552}
553
554/// Flush any frames the codec is still holding and finalize the video track.
555///
556/// The handle is released, so nothing can be published to it afterwards.
557#[unsafe(no_mangle)]
558pub extern "C" fn moq_publish_video_raw_finish(producer: u32) -> i32 {
559 ffi::enter(move || {
560 let producer = ffi::parse_id(producer)?;
561 State::lock().video.publish_finish(producer)
562 })
563}
564
565/// Subscribe to a video track and decode it into raw I420 frames.
566///
567/// The catalog `index` selects which video rendition to subscribe to, matching
568/// the existing `moq_consume_video` selection model. Only H.264 is
569/// supported; a non-H.264 rendition fails on the terminal callback.
570///
571/// Returns a non-zero handle on success or a negative error code.
572///
573/// `on_frame` is called with a positive frame id per decoded frame, then exactly
574/// once more with a terminal code: `0` (closed cleanly) or a negative error.
575/// After the terminal (`<= 0`) callback, `on_frame` is never called again and
576/// `user_data` is never touched again, so release `user_data` there. The terminal
577/// callback fires even after [`moq_consume_video_raw_close`].
578///
579/// # Safety
580/// - `output` must point to a valid [`moq_video_decoder_output`].
581/// - `user_data` must stay valid until the terminal (`<= 0`) `on_frame` callback.
582#[unsafe(no_mangle)]
583pub unsafe extern "C" fn moq_consume_video_raw(
584 catalog: u32,
585 index: u32,
586 output: *const moq_video_decoder_output,
587 on_frame: Option<extern "C" fn(user_data: *mut c_void, frame: i32)>,
588 user_data: *mut c_void,
589) -> i32 {
590 ffi::enter(move || {
591 let catalog = ffi::parse_id(catalog)?;
592 let raw = unsafe { output.as_ref() }.ok_or(Error::InvalidPointer)?;
593
594 let mut config = moq_video::decode::Config::new();
595 config.latency_max = if raw.latency_max_ms == 0 {
596 None
597 } else {
598 Some(Duration::from_millis(raw.latency_max_ms))
599 };
600 let on_frame = unsafe { OnStatus::new(user_data, on_frame) };
601
602 let mut state = State::lock();
603 let (broadcast, video_cfg, name) = state.consume.video_rendition(catalog, index as usize)?;
604
605 let State { video, .. } = &mut *state;
606 video.consume(&broadcast, &video_cfg, &name, config, on_frame)
607 })
608}
609
610/// Stop a video (raw) consumer's background task.
611///
612/// Returns immediately: zero on success, or a negative code if already closed.
613/// Does NOT free `user_data`; the on-frame callback still fires once more with a
614/// terminal `0` (or a negative error), which is where `user_data` should be
615/// released. Frame ids already delivered are likewise not freed; release each
616/// with [`moq_consume_video_raw_frame_free`].
617#[unsafe(no_mangle)]
618pub extern "C" fn moq_consume_video_raw_close(consumer: u32) -> i32 {
619 ffi::enter(move || {
620 let consumer = ffi::parse_id(consumer)?;
621 State::lock().video.consume_close(consumer)
622 })
623}
624
625/// Copy a delivered frame's metadata into `dst`.
626///
627/// The written `dst->data` pointer remains valid until the same `id` is released
628/// with [`moq_consume_video_raw_frame_free`].
629///
630/// # Safety
631/// - `dst` must point to a writable [`moq_video_frame`].
632#[unsafe(no_mangle)]
633pub unsafe extern "C" fn moq_consume_video_raw_frame(id: u32, dst: *mut moq_video_frame) -> i32 {
634 ffi::enter(move || {
635 let id = ffi::parse_id(id)?;
636 let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?;
637 State::lock().video.frame_info(id, dst)
638 })
639}
640
641/// Free a frame previously delivered through the consume callback. Required for
642/// every delivered frame id; closing the parent consumer is not enough.
643#[unsafe(no_mangle)]
644pub extern "C" fn moq_consume_video_raw_frame_free(id: u32) -> i32 {
645 ffi::enter(move || {
646 let id = ffi::parse_id(id)?;
647 State::lock().video.frame_free(id)
648 })
649}
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 /// A video track wired up without an encoder, plus a subscriber on it: enough
655 /// to pin what [`finalize`] shows the far end.
656 async fn track_under_test() -> (
657 moq_video::encode::Producer<moq_mux::catalog::hang::Extra>,
658 moq_net::track::Subscriber,
659 ) {
660 let mut broadcast = moq_net::broadcast::Info::new().produce();
661 let catalog =
662 moq_mux::catalog::Producer::with_catalog(&mut broadcast, moq_mux::catalog::hang::Catalog::default())
663 .unwrap();
664 let consumer = broadcast.consume();
665 let producer = moq_video::encode::Producer::new(broadcast, catalog, moq_video::encode::Codec::H264).unwrap();
666
667 let name = producer.demand().name().to_string();
668 let track = consumer.track(&name).unwrap().subscribe(None).await.unwrap();
669 (producer, track)
670 }
671
672 /// A clean finish reaches the subscriber as the end of the track, which is what
673 /// makes the abort case below meaningful rather than vacuous.
674 #[tokio::test]
675 async fn a_successful_drain_ends_the_track_cleanly() {
676 let (producer, mut track) = track_under_test().await;
677 finalize(producer, Ok(())).unwrap();
678 assert!(matches!(track.recv_group().await, Ok(None)), "expected a clean end");
679 }
680
681 /// Regression: a lost tail must reach the subscriber as an abort. Finishing the
682 /// track anyway would report a truncated stream as a complete one, and only the
683 /// publisher would ever know otherwise.
684 #[tokio::test]
685 async fn a_failed_drain_aborts_the_track() {
686 let (producer, mut track) = track_under_test().await;
687 let err = moq_video::Error::Codec(anyhow::anyhow!("the codec lost the tail"));
688 finalize(producer, Err(err)).unwrap_err();
689
690 let Err(err) = track.recv_group().await else {
691 panic!("expected an abort, not a clean end");
692 };
693 assert!(
694 err.to_string().contains("the codec lost the tail"),
695 "the abort should carry the drain failure: {err}"
696 );
697 }
698}