Skip to main content

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 and the rendition it will emit are both resolved by the caller,
288	/// and before this, so a config this machine can't encode fails without leaving
289	/// a track advertised that will 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		rendition: hang::catalog::VideoConfig,
297		encoder: moq_video::encode::Sink,
298	) -> Result<Id, Error> {
299		let producer = moq_video::encode::Producer::new(broadcast.clone(), catalog, rendition)?;
300		self.producers.insert(Shared::new(VideoEncoder {
301			encoder,
302			producer,
303			format,
304			size: config.size(),
305		}))
306	}
307
308	/// Resolve a producer handle, so the caller can encode with the global lock
309	/// released.
310	///
311	/// Bind the result before locking it: a temporary [`State`] guard lives to the
312	/// end of the statement that created it, so resolving and locking in one
313	/// expression would put the encode back under the global lock.
314	pub(crate) fn producer(&self, id: Id) -> Result<Shared<VideoEncoder>, Error> {
315		self.producers.get(id).cloned().ok_or(Error::MediaNotFound)
316	}
317
318	/// Resolve a producer and drop its id, so nothing can be published to it after.
319	pub(crate) fn remove(&mut self, id: Id) -> Result<Shared<VideoEncoder>, Error> {
320		self.producers.remove(id).ok_or(Error::MediaNotFound)
321	}
322
323	pub fn consume(
324		&mut self,
325		broadcast: &moq_net::broadcast::Consumer,
326		catalog: &hang::catalog::VideoConfig,
327		name: &str,
328		config: moq_video::decode::Config,
329		on_frame: OnStatus,
330	) -> Result<Id, Error> {
331		let broadcast = broadcast.clone();
332		let catalog = catalog.clone();
333		let name = name.to_string();
334
335		let channel = oneshot::channel();
336		let entry = VideoTaskEntry {
337			close: Some(channel.0),
338			callback: on_frame,
339		};
340		let id = self.consumer_tasks.insert(Some(entry))?;
341
342		// `Consumer::new` subscribes (blocking on SUBSCRIBE_OK), so run it inside
343		// the task to keep this entrypoint non-blocking.
344		tokio::spawn(async move {
345			let res = async move {
346				let consumer = moq_video::decode::Consumer::new(&broadcast, &catalog, name, config).await?;
347				Self::run(on_frame, consumer, channel.1).await
348			}
349			.await;
350
351			// Deliver one final terminal callback (code <= 0), then drop the entry.
352			// Pull it out from under the lock so the callback never runs while held.
353			let entry = State::lock().video.consumer_tasks.remove(id).flatten();
354			if let Some(entry) = entry {
355				entry.callback.call(res);
356			}
357		});
358
359		Ok(id)
360	}
361
362	async fn run(
363		callback: OnStatus,
364		mut consumer: moq_video::decode::Consumer,
365		mut close: oneshot::Receiver<()>,
366	) -> Result<(), Error> {
367		loop {
368			// `biased` so a pending close always wins over a ready frame.
369			let frame = tokio::select! {
370				biased;
371				_ = &mut close => return Ok(()),
372				frame = consumer.read() => match frame? {
373					Some(frame) => frame,
374					None => return Ok(()),
375				},
376			};
377
378			// Flatten to CPU bytes outside the lock (a GPU frame downloads here),
379			// then hold the lock only to buffer it; release before the callback.
380			let size = frame.size();
381			let frame = VideoFrame {
382				// The C ABI carries microseconds; the decoded frame's Timestamp is
383				// constrained to a QUIC VarInt, so the microsecond value fits a u64.
384				timestamp_us: frame.timestamp.as_micros() as u64,
385				width: size.width,
386				height: size.height,
387				data: frame.surface.into_i420()?,
388			};
389			let frame_id = State::lock().video.frames.insert(frame)?;
390			callback.call(Ok(frame_id));
391		}
392	}
393
394	pub fn consume_close(&mut self, id: Id) -> Result<(), Error> {
395		// Signal shutdown; the task delivers a final callback and removes itself.
396		self.consumer_tasks
397			.get_mut(id)
398			.and_then(|entry| entry.as_mut())
399			.ok_or(Error::TrackNotFound)?
400			.close
401			.take()
402			.ok_or(Error::TrackNotFound)?;
403		Ok(())
404	}
405
406	pub fn frame_info(&self, id: Id, dst: &mut moq_video_frame) -> Result<(), Error> {
407		let frame = self.frames.get(id).ok_or(Error::FrameNotFound)?;
408		*dst = moq_video_frame {
409			timestamp_us: frame.timestamp_us,
410			width: frame.width,
411			height: frame.height,
412			data: frame.data.as_ptr(),
413			data_size: frame.data.len(),
414		};
415		Ok(())
416	}
417
418	pub fn frame_free(&mut self, id: Id) -> Result<(), Error> {
419		self.frames.remove(id).ok_or(Error::FrameNotFound)?;
420		Ok(())
421	}
422}
423
424// ---- C entry points ----
425
426fn pixel_format_from_u32(value: u32) -> Result<moq_video_pixel_format, Error> {
427	Ok(match value {
428		v if v == moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_I420 as u32 => {
429			moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_I420
430		}
431		v if v == moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_RGBA as u32 => {
432			moq_video_pixel_format::MOQ_VIDEO_PIXEL_FORMAT_RGBA
433		}
434		_ => return Err(Error::InvalidCode),
435	})
436}
437
438fn codec_from_u32(value: u32) -> Result<moq_video::encode::Codec, Error> {
439	use moq_video::encode::Codec;
440	Ok(match value {
441		v if v == moq_video_codec::MOQ_VIDEO_CODEC_H264 as u32 => Codec::H264,
442		v if v == moq_video_codec::MOQ_VIDEO_CODEC_H265 as u32 => Codec::H265,
443		_ => return Err(Error::InvalidCode),
444	})
445}
446
447/// # Safety
448/// - `output->encoder` must point to `output->encoder_len` bytes of UTF-8 when
449///   `output->kind` is `MOQ_VIDEO_ENCODER_KIND_NAMED`.
450unsafe fn encoder_kind(output: &moq_video_encoder_output) -> Result<moq_video::encode::Kind, Error> {
451	use moq_video::encode::Kind;
452	Ok(match output.kind {
453		v if v == moq_video_encoder_kind::MOQ_VIDEO_ENCODER_KIND_AUTO as u32 => Kind::Auto,
454		v if v == moq_video_encoder_kind::MOQ_VIDEO_ENCODER_KIND_HARDWARE as u32 => Kind::Hardware,
455		v if v == moq_video_encoder_kind::MOQ_VIDEO_ENCODER_KIND_SOFTWARE as u32 => Kind::Software,
456		v if v == moq_video_encoder_kind::MOQ_VIDEO_ENCODER_KIND_NAMED as u32 => {
457			Kind::Named(unsafe { ffi::parse_str(output.encoder, output.encoder_len)? }.to_string())
458		}
459		_ => return Err(Error::InvalidCode),
460	})
461}
462
463/// Open a video track on a broadcast, encoding the raw frames you publish to it.
464///
465/// The encoder is opened here, so an unsupported codec, resolution, or backend
466/// fails now rather than on the first frame. The track is named after the codec
467/// (`.avc3` / `.hev1`) and its catalog rendition is published immediately, read
468/// out of the encoder rather than guessed, so a subscriber can find the track
469/// before a frame is written to it.
470///
471/// Returns a non-zero handle on success or a negative error code.
472///
473/// # Safety
474/// - `input` / `output` must point to fully populated structs.
475/// - `output->encoder` must point to `output->encoder_len` bytes of UTF-8 when
476///   `output->kind` is `MOQ_VIDEO_ENCODER_KIND_NAMED`.
477#[unsafe(no_mangle)]
478pub unsafe extern "C" fn moq_publish_video_raw(
479	broadcast: u32,
480	input: *const moq_video_encoder_input,
481	output: *const moq_video_encoder_output,
482) -> i32 {
483	ffi::enter(move || {
484		let broadcast = ffi::parse_id(broadcast)?;
485		let raw_input = unsafe { input.as_ref() }.ok_or(Error::InvalidPointer)?;
486		let raw_output = unsafe { output.as_ref() }.ok_or(Error::InvalidPointer)?;
487
488		let format = pixel_format_from_u32(raw_input.format)?;
489
490		let mut config = moq_video::encode::Config::new(raw_input.width, raw_input.height, raw_input.framerate);
491		config.codec = codec_from_u32(raw_output.codec)?;
492		config.kind = unsafe { encoder_kind(raw_output)? };
493		// The C ABI spells an unset knob as 0, which neither field accepts as a real
494		// value: a zero bitrate or GOP is the default, not a request.
495		config.bitrate = (raw_output.bitrate != 0).then_some(raw_output.bitrate);
496		if raw_output.gop != 0 {
497			config.gop = raw_output.gop;
498		}
499
500		// Both before the global lock is taken: bringing up a hardware encoder is slow
501		// enough that every other call would wait behind it. The probe runs first and
502		// closes its encoder before this one opens, so only one codec session is live.
503		let rendition = block_on(config.probe())?;
504		let encoder = block_on(moq_video::encode::Sink::open(&config))?;
505
506		let mut state = State::lock();
507		let State { publish, video, .. } = &mut *state;
508		let (broadcast_producer, catalog) = publish.pair_mut(broadcast)?;
509
510		video.publish(broadcast_producer, catalog.clone(), format, &config, rendition, encoder)
511	})
512}
513
514/// Encode and publish one raw frame.
515///
516/// `frame->data` is borrowed for the duration of the call and must be exactly one
517/// picture in the pixel format and at the resolution declared by
518/// [`moq_video_encoder_input`].
519/// A backend that pipelines publishes an earlier frame's output here, so a call
520/// that emits nothing is normal rather than an error.
521///
522/// # Safety
523/// - `frame` must point to a valid [`moq_video_encoder_frame`].
524/// - `frame->data` must point to `frame->data_size` bytes.
525#[unsafe(no_mangle)]
526pub unsafe extern "C" fn moq_publish_video_raw_frame(producer: u32, frame: *const moq_video_encoder_frame) -> i32 {
527	ffi::enter(move || {
528		let producer = ffi::parse_id(producer)?;
529		let frame = unsafe { frame.as_ref() }.ok_or(Error::InvalidPointer)?;
530		let data = unsafe { ffi::parse_slice(frame.data, frame.data_size)? };
531
532		let producer = State::lock().video.producer(producer)?;
533		producer
534			.lock()
535			.as_mut()
536			.ok_or(Error::MediaNotFound)?
537			.publish_frame(frame.timestamp_us, data)
538	})
539}
540
541/// Cut a new group at the next published frame.
542///
543/// Optional. The encoder already keyframes every `moq_video_encoder_output.gop`
544/// frames, and each of those cuts a group, so a subscriber can always join
545/// without you calling this. Reach for it only to place the boundaries yourself:
546/// aligning groups with something the encoder cannot see, such as a scene change,
547/// a source switch, or resuming after an idle gap.
548///
549/// The next frame is encoded as a keyframe, which closes the open group and
550/// starts a new one at it. Calling this repeatedly before that frame arrives cuts
551/// once, not several times.
552#[unsafe(no_mangle)]
553pub extern "C" fn moq_publish_video_raw_cut(producer: u32) -> i32 {
554	ffi::enter(move || {
555		let producer = ffi::parse_id(producer)?;
556		let producer = State::lock().video.producer(producer)?;
557		producer.lock().as_mut().ok_or(Error::MediaNotFound)?.publish_cut();
558		Ok(())
559	})
560}
561
562/// Retune a live encoder to `bitrate` bits per second, taking effect from
563/// roughly the next frame. No keyframe is forced, so this is cheap enough to
564/// drive from a congestion controller.
565///
566/// The configured bitrate is a ceiling on some backends (openh264 rejects a raise
567/// above the rate it opened at), so set `bitrate` to the highest you will ask
568/// for and adapt downwards from there.
569///
570/// Returns a negative code if this backend cannot retune while running. That is
571/// not fatal: the encoder keeps running at its current rate, so stop adapting
572/// rather than stop publishing.
573#[unsafe(no_mangle)]
574pub extern "C" fn moq_publish_video_raw_bitrate(producer: u32, bitrate: u64) -> i32 {
575	ffi::enter(move || {
576		let producer = ffi::parse_id(producer)?;
577		let producer = State::lock().video.producer(producer)?;
578		producer
579			.lock()
580			.as_mut()
581			.ok_or(Error::MediaNotFound)?
582			.publish_bitrate(bitrate)
583	})
584}
585
586/// Flush any frames the codec is still holding and finalize the video track.
587///
588/// The handle is released, so nothing can be published to it afterwards.
589#[unsafe(no_mangle)]
590pub extern "C" fn moq_publish_video_raw_finish(producer: u32) -> i32 {
591	ffi::enter(move || {
592		let producer = ffi::parse_id(producer)?;
593		// The id is dropped first, so nothing new queues behind the drain; whatever
594		// is mid-encode still finishes before this takes the encoder.
595		let producer = State::lock().video.remove(producer)?;
596		producer.take().ok_or(Error::MediaNotFound)?.publish_finish()
597	})
598}
599
600/// Subscribe to a video track and decode it into raw I420 frames.
601///
602/// The catalog `index` selects which video rendition to subscribe to, matching
603/// the existing `moq_consume_video` selection model. Only H.264 is
604/// supported; a non-H.264 rendition fails on the terminal callback.
605///
606/// Returns a non-zero handle on success or a negative error code.
607///
608/// `on_frame` is called with a positive frame id per decoded frame, then exactly
609/// once more with a terminal code: `0` (closed cleanly) or a negative error.
610/// After the terminal (`<= 0`) callback, `on_frame` is never called again and
611/// `user_data` is never touched again, so release `user_data` there. The terminal
612/// callback fires even after [`moq_consume_video_raw_close`].
613///
614/// # Safety
615/// - `output` must point to a valid [`moq_video_decoder_output`].
616/// - `user_data` must stay valid until the terminal (`<= 0`) `on_frame` callback.
617#[unsafe(no_mangle)]
618pub unsafe extern "C" fn moq_consume_video_raw(
619	catalog: u32,
620	index: u32,
621	output: *const moq_video_decoder_output,
622	on_frame: Option<extern "C" fn(user_data: *mut c_void, frame: i32)>,
623	user_data: *mut c_void,
624) -> i32 {
625	ffi::enter(move || {
626		let catalog = ffi::parse_id(catalog)?;
627		let raw = unsafe { output.as_ref() }.ok_or(Error::InvalidPointer)?;
628
629		let mut config = moq_video::decode::Config::new();
630		config.latency_max = if raw.latency_max_ms == 0 {
631			None
632		} else {
633			Some(Duration::from_millis(raw.latency_max_ms))
634		};
635		let on_frame = unsafe { OnStatus::new(user_data, on_frame) };
636
637		let mut state = State::lock();
638		let (broadcast, video_cfg, name) = state.consume.video_rendition(catalog, index as usize)?;
639
640		let State { video, .. } = &mut *state;
641		video.consume(&broadcast, &video_cfg, &name, config, on_frame)
642	})
643}
644
645/// Stop a video (raw) consumer's background task.
646///
647/// Returns immediately: zero on success, or a negative code if already closed.
648/// Does NOT free `user_data`; the on-frame callback still fires once more with a
649/// terminal `0` (or a negative error), which is where `user_data` should be
650/// released. Frame ids already delivered are likewise not freed; release each
651/// with [`moq_consume_video_raw_frame_free`].
652#[unsafe(no_mangle)]
653pub extern "C" fn moq_consume_video_raw_close(consumer: u32) -> i32 {
654	ffi::enter(move || {
655		let consumer = ffi::parse_id(consumer)?;
656		State::lock().video.consume_close(consumer)
657	})
658}
659
660/// Copy a delivered frame's metadata into `dst`.
661///
662/// The written `dst->data` pointer remains valid until the same `id` is released
663/// with [`moq_consume_video_raw_frame_free`].
664///
665/// # Safety
666/// - `dst` must point to a writable [`moq_video_frame`].
667#[unsafe(no_mangle)]
668pub unsafe extern "C" fn moq_consume_video_raw_frame(id: u32, dst: *mut moq_video_frame) -> i32 {
669	ffi::enter(move || {
670		let id = ffi::parse_id(id)?;
671		let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?;
672		State::lock().video.frame_info(id, dst)
673	})
674}
675
676/// Free a frame previously delivered through the consume callback. Required for
677/// every delivered frame id; closing the parent consumer is not enough.
678#[unsafe(no_mangle)]
679pub extern "C" fn moq_consume_video_raw_frame_free(id: u32) -> i32 {
680	ffi::enter(move || {
681		let id = ffi::parse_id(id)?;
682		State::lock().video.frame_free(id)
683	})
684}
685#[cfg(test)]
686mod tests {
687	use super::*;
688
689	/// A video track wired up without an encoder, plus a subscriber on it: enough
690	/// to pin what [`finalize`] shows the far end.
691	async fn track_under_test() -> (
692		moq_video::encode::Producer<moq_mux::catalog::hang::Extra>,
693		moq_net::track::Subscriber,
694	) {
695		let mut broadcast = moq_net::broadcast::Info::new().produce();
696		let catalog =
697			moq_mux::catalog::Producer::with_catalog(&mut broadcast, moq_mux::catalog::hang::Catalog::default())
698				.unwrap();
699		let consumer = broadcast.consume();
700		// Probed rather than hand-built, so the test track carries what a real one would.
701		let rendition = moq_video::encode::Config::new(320, 240, 30).probe().await.unwrap();
702		let producer = moq_video::encode::Producer::new(broadcast, catalog, rendition).unwrap();
703
704		let name = producer.demand().name().to_string();
705		let track = consumer.track(&name).unwrap().subscribe(None).await.unwrap();
706		(producer, track)
707	}
708
709	/// A clean finish reaches the subscriber as the end of the track, which is what
710	/// makes the abort case below meaningful rather than vacuous.
711	#[tokio::test]
712	async fn a_successful_drain_ends_the_track_cleanly() {
713		let (producer, mut track) = track_under_test().await;
714		finalize(producer, Ok(())).unwrap();
715		assert!(matches!(track.recv_group().await, Ok(None)), "expected a clean end");
716	}
717
718	/// Regression: a lost tail must reach the subscriber as an abort. Finishing the
719	/// track anyway would report a truncated stream as a complete one, and only the
720	/// publisher would ever know otherwise.
721	#[tokio::test]
722	async fn a_failed_drain_aborts_the_track() {
723		let (producer, mut track) = track_under_test().await;
724		let err = moq_video::Error::Codec(anyhow::anyhow!("the codec lost the tail"));
725		finalize(producer, Err(err)).unwrap_err();
726
727		let Err(err) = track.recv_group().await else {
728			panic!("expected an abort, not a clean end");
729		};
730		assert!(
731			err.to_string().contains("the codec lost the tail"),
732			"the abort should carry the drain failure: {err}"
733		);
734	}
735}