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