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