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 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}