Skip to main content

moq_rtc/codec/
mod.rs

1//! Per-codec bridges between moq-mux and str0m.
2//!
3//! Two directions:
4//! - **Ingest** ([`Bridge`]): str0m hands a decoded codec frame via
5//!   `Event::MediaData`; the bridge converts it into the shape the
6//!   moq-mux importer expects and publishes it.
7//! - **Egress** ([`Track`]): the egress source subscribes to a moq-mux
8//!   broadcast and the track yields RTP-ready codec frames that the
9//!   session loop hands to [`str0m::media::Writer::write`].
10
11pub mod av1;
12pub mod h264;
13pub mod h265;
14pub mod opus;
15pub mod vp8;
16pub mod vp9;
17
18#[cfg(test)]
19mod bitstream_test;
20
21use bytes::Bytes;
22use hang::catalog::VideoConfig;
23
24use crate::Result;
25
26/// One codec frame received from str0m, paired with a microsecond timestamp.
27///
28/// Used by the ingest path. The session loop converts str0m's
29/// [`MediaTime`](str0m::media::MediaTime) to microseconds so individual
30/// bridges don't need to repeat the math.
31#[derive(Clone, Debug)]
32pub struct Frame {
33	pub timestamp_us: u64,
34	pub payload: Bytes,
35}
36
37/// Bridges depacketized media frames from str0m to a hang broadcast track.
38///
39/// One bridge per `m=` line on the ingest side. The session loop calls
40/// [`Bridge::push`] once per [`MediaData`](str0m::media::MediaData) event
41/// with the codec frame; the bridge handles any codec-specific transformations
42/// (e.g. Annex-B to AVCC for H.264) and forwards the frame into the matching
43/// moq-mux importer.
44pub trait Bridge: Send {
45	fn push(&mut self, frame: Frame) -> Result<()>;
46
47	/// Abort the published track with `err` so subscribers see the real cause
48	/// (the peer disconnected, an ICE failure) rather than a bare `Error::Dropped`.
49	///
50	/// Consumes the bridge: the track is dead afterwards.
51	fn abort(self: Box<Self>, err: moq_net::Error);
52}
53
54/// A mux importer whose catalog configuration is resolved from its first frame.
55pub(crate) trait DeferredImport: Send + Sized {
56	/// Create the importer and its unresolved catalog rendition.
57	fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result<Self>;
58
59	/// Decode one complete codec frame.
60	fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()>;
61
62	/// Abort the active media track.
63	fn abort(self, err: moq_net::Error);
64}
65
66impl DeferredImport for moq_mux::codec::vp8::Import {
67	fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result<Self> {
68		Self::new(track, reserved, Default::default())
69	}
70
71	fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()> {
72		moq_mux::codec::vp8::Import::decode(self, frame, Some(pts))
73	}
74
75	fn abort(self, err: moq_net::Error) {
76		moq_mux::codec::vp8::Import::abort(self, err);
77	}
78}
79
80impl DeferredImport for moq_mux::codec::vp9::Import {
81	fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result<Self> {
82		Self::new(track, reserved, Default::default())
83	}
84
85	fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()> {
86		moq_mux::codec::vp9::Import::decode(self, frame, Some(pts))
87	}
88
89	fn abort(self, err: moq_net::Error) {
90		moq_mux::codec::vp9::Import::abort(self, err);
91	}
92}
93
94struct PendingVideo {
95	track: moq_net::track::Producer,
96	catalog: moq_mux::catalog::Producer,
97}
98
99enum DeferredState<I> {
100	Pending(Box<PendingVideo>),
101	Active(Box<I>),
102	Failed(Box<moq_net::track::Producer>),
103	Poisoned,
104}
105
106/// Defers a video importer's catalog reservation until its first frame.
107pub(crate) struct DeferredVideo<I> {
108	state: DeferredState<I>,
109}
110
111impl<I: DeferredImport> DeferredVideo<I> {
112	/// Create the media track without gating the initial catalog snapshot.
113	pub fn new(
114		mut broadcast: moq_net::broadcast::Producer,
115		catalog: moq_mux::catalog::Producer,
116		suffix: &str,
117	) -> Result<Self> {
118		let track = broadcast.unique_track(suffix, catalog.track_info())?;
119		Ok(Self {
120			state: DeferredState::Pending(Box::new(PendingVideo { track, catalog })),
121		})
122	}
123
124	/// Decode a frame, creating the importer on first use.
125	pub fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> Result<()> {
126		if let DeferredState::Active(import) = &mut self.state {
127			return import.decode(frame, pts).map_err(Into::into);
128		}
129
130		let DeferredState::Pending(pending) = std::mem::replace(&mut self.state, DeferredState::Poisoned) else {
131			return Err(crate::Error::Other(anyhow::anyhow!(
132				"video bridge initialization already failed"
133			)));
134		};
135		let reserved = pending.catalog.reserve();
136		let abort = pending.track.clone();
137		let import = match I::create(pending.track, reserved) {
138			Ok(import) => import,
139			Err(err) => {
140				self.state = DeferredState::Failed(Box::new(abort));
141				return Err(err.into());
142			}
143		};
144		self.state = DeferredState::Active(Box::new(import));
145		let DeferredState::Active(import) = &mut self.state else {
146			unreachable!();
147		};
148		import.decode(frame, pts).map_err(Into::into)
149	}
150
151	/// Abort the media track in either lifecycle state.
152	pub fn abort(self, err: moq_net::Error) {
153		match self.state {
154			DeferredState::Pending(pending) => {
155				let _ = pending.track.abort(err);
156			}
157			DeferredState::Active(import) => import.abort(err),
158			DeferredState::Failed(track) => {
159				let _ = track.abort(err);
160			}
161			DeferredState::Poisoned => {}
162		}
163	}
164}
165
166/// One RTP-ready codec frame produced by an egress [`Track`].
167///
168/// `timestamp_us` stays in microseconds; the session loop converts it to
169/// the negotiated codec's clock domain when calling
170/// [`Writer::write`](str0m::media::Writer::write).
171#[derive(Clone, Debug)]
172pub struct PacketizedFrame {
173	pub timestamp_us: u64,
174	pub payload: Bytes,
175}
176
177/// A subscribed moq-mux track, normalized to the bitstream shape str0m's
178/// Frame API expects.
179///
180/// One [`Track`] per `m=` line on the egress side. The egress source spawns
181/// a pump task per track that polls [`Track::next`] and forwards frames to
182/// the session loop.
183pub struct Track {
184	consumer: moq_mux::container::Consumer<moq_mux::catalog::hang::Container>,
185	convert: TrackConvert,
186}
187
188/// Codec-specific per-frame transform.
189enum TrackConvert {
190	/// Opus / VP8 / VP9 / AV1, plus inline-parameter H.264 (avc3) and H.265
191	/// (hev1): the stored bitstream is already in the shape str0m's
192	/// packetizer wants, so it passes through untouched.
193	Passthrough,
194	/// Out-of-band-parameter H.264 (avc1) and H.265 (hvc1): length-prefixed
195	/// NALU rewritten to Annex-B, with the cached parameter sets (SPS+PPS,
196	/// plus VPS for H.265) prepended to every keyframe. Both codecs share this
197	/// path; only the config record parsed to build it differs (avcC vs hvcC).
198	/// Mirrors moq-mux's `h264::Export` / `h265::Export`.
199	LengthPrefixed { length_size: usize, keyframe_prefix: Bytes },
200}
201
202impl Track {
203	/// Audio track for an Opus rendition, from a subscribed `track`.
204	pub fn opus(track: moq_net::track::Subscriber) -> Self {
205		let container = moq_mux::catalog::hang::Container::Legacy;
206		let consumer = moq_mux::container::Consumer::new(track, container);
207		Self {
208			consumer,
209			convert: TrackConvert::Passthrough,
210		}
211	}
212
213	/// Video track from a subscribed `track` consumer. Codec inferred from
214	/// `config.codec`; for H.264 / H.265 the bitstream shape (inline vs out-of-band
215	/// parameter sets) is inferred from `config.description` (avc1/hvc1 vs avc3/hev1).
216	pub fn video(track: moq_net::track::Subscriber, config: &VideoConfig) -> Result<Self> {
217		let container: moq_mux::catalog::hang::Container = (&config.container).try_into()?;
218		let consumer = moq_mux::container::Consumer::new(track, container);
219
220		let convert = match &config.codec {
221			hang::catalog::VideoCodec::VP8 => TrackConvert::Passthrough,
222			hang::catalog::VideoCodec::VP9(_) => TrackConvert::Passthrough,
223			hang::catalog::VideoCodec::AV1(_) => TrackConvert::Passthrough,
224			hang::catalog::VideoCodec::H264(_) => h264_convert(config)?,
225			hang::catalog::VideoCodec::H265(_) => h265_convert(config)?,
226			other => return Err(crate::Error::UnsupportedCodec(format!("{other:?}"))),
227		};
228
229		Ok(Self { consumer, convert })
230	}
231
232	/// Pull the next RTP-ready frame. Returns `None` when the track ends.
233	pub async fn next(&mut self) -> Result<Option<PacketizedFrame>> {
234		loop {
235			let Some(frame) = self.consumer.read().await? else {
236				return Ok(None);
237			};
238			let payload = match &self.convert {
239				TrackConvert::Passthrough => frame.payload,
240				TrackConvert::LengthPrefixed {
241					length_size,
242					keyframe_prefix,
243				} => {
244					let prefix = frame.keyframe.then(|| keyframe_prefix.as_ref());
245					moq_mux::codec::annexb::from_length_prefixed(&frame.payload, *length_size, prefix)
246						.map_err(|err| crate::Error::Other(anyhow::anyhow!("annexb: {err}")))?
247				}
248			};
249			if payload.is_empty() {
250				continue;
251			}
252			return Ok(Some(PacketizedFrame {
253				timestamp_us: frame.timestamp.as_micros() as u64,
254				payload,
255			}));
256		}
257	}
258}
259
260/// Build the per-frame transform for an H.264 rendition.
261///
262/// avc3 (inline SPS/PPS, empty `description`) passes through. avc1 (out-of-band
263/// avcC in `description`) parses the avcC and prebuilds the Annex-B SPS+PPS
264/// prefix to prepend ahead of every keyframe.
265fn h264_convert(config: &VideoConfig) -> Result<TrackConvert> {
266	let Some(avcc) = config.description.as_ref().filter(|d| !d.is_empty()) else {
267		return Ok(TrackConvert::Passthrough);
268	};
269	let params = moq_mux::codec::h264::Avcc::parse(avcc)
270		.map_err(|err| crate::Error::Other(anyhow::anyhow!("avcc parse: {err}")))?;
271	// Without SPS+PPS the keyframe prefix would be empty and every keyframe
272	// would reach the peer without inline parameter sets, i.e. undecodable.
273	// Fail loudly instead, matching moq-mux's `h264::Export`.
274	if params.sps.is_empty() || params.pps.is_empty() {
275		return Err(crate::Error::Other(anyhow::anyhow!(
276			"avc1 avcC is missing parameter sets (sps={}, pps={})",
277			params.sps.len(),
278			params.pps.len()
279		)));
280	}
281	let keyframe_prefix = moq_mux::codec::annexb::build_prefix(params.sps.iter().chain(params.pps.iter()));
282	Ok(TrackConvert::LengthPrefixed {
283		length_size: params.length_size,
284		keyframe_prefix,
285	})
286}
287
288/// Build the per-frame transform for an H.265 rendition.
289///
290/// The H.265 analogue of [`h264_convert`]: hev1 (inline VPS/SPS/PPS) passes
291/// through; hvc1 (out-of-band hvcC) parses the hvcC and prebuilds the Annex-B
292/// VPS+SPS+PPS prefix to prepend ahead of every keyframe.
293fn h265_convert(config: &VideoConfig) -> Result<TrackConvert> {
294	let Some(hvcc) = config.description.as_ref().filter(|d| !d.is_empty()) else {
295		return Ok(TrackConvert::Passthrough);
296	};
297	let params = moq_mux::codec::h265::Hvcc::parse(hvcc)
298		.map_err(|err| crate::Error::Other(anyhow::anyhow!("hvcc parse: {err}")))?;
299	// Same reasoning as `h264_convert`: a keyframe with no inline VPS/SPS/PPS
300	// is undecodable, so reject an hvcC that omits any of them.
301	if params.vps.is_empty() || params.sps.is_empty() || params.pps.is_empty() {
302		return Err(crate::Error::Other(anyhow::anyhow!(
303			"hvc1 hvcC is missing parameter sets (vps={}, sps={}, pps={})",
304			params.vps.len(),
305			params.sps.len(),
306			params.pps.len()
307		)));
308	}
309	let keyframe_prefix =
310		moq_mux::codec::annexb::build_prefix(params.vps.iter().chain(params.sps.iter()).chain(params.pps.iter()));
311	Ok(TrackConvert::LengthPrefixed {
312		length_size: params.length_size,
313		keyframe_prefix,
314	})
315}
316
317#[cfg(test)]
318mod tests {
319	use hang::catalog::{H264, H265, VideoConfig};
320
321	use super::*;
322
323	fn config(codec: impl Into<hang::catalog::VideoCodec>, description: Option<Bytes>) -> VideoConfig {
324		let mut config = VideoConfig::new(codec);
325		config.description = description;
326		config
327	}
328
329	fn h264(inline: bool) -> H264 {
330		H264 {
331			inline,
332			profile: 0x42,
333			constraints: 0,
334			level: 0x1f,
335		}
336	}
337
338	fn h265(in_band: bool) -> H265 {
339		H265 {
340			in_band,
341			profile_space: 0,
342			profile_idc: 1,
343			profile_compatibility_flags: [0; 4],
344			tier_flag: false,
345			level_idc: 0x5d,
346			constraint_flags: [0; 6],
347		}
348	}
349
350	/// Minimal avcC carrying one SPS + one PPS (lengthSizeMinusOne = 3).
351	fn build_avcc(sps: &[u8], pps: &[u8]) -> Bytes {
352		let mut v = vec![1, sps[1], sps[2], sps[3], 0xff, 0xe1];
353		v.extend_from_slice(&(sps.len() as u16).to_be_bytes());
354		v.extend_from_slice(sps);
355		v.push(1);
356		v.extend_from_slice(&(pps.len() as u16).to_be_bytes());
357		v.extend_from_slice(pps);
358		Bytes::from(v)
359	}
360
361	/// Minimal hvcC carrying one VPS + SPS + PPS array (lengthSizeMinusOne = 3).
362	/// VPS/SPS/PPS NAL unit types are 32/33/34.
363	fn build_hvcc(vps: &[u8], sps: &[u8], pps: &[u8]) -> Bytes {
364		let mut v = vec![0u8; 21];
365		v.push(0xff); // [21] lengthSizeMinusOne = 3 in the low 2 bits
366		v.push(3); // [22] numOfArrays
367		for (nal_type, nal) in [(32u8, vps), (33, sps), (34, pps)] {
368			v.push(nal_type); // array header: low 6 bits = NAL unit type
369			v.extend_from_slice(&1u16.to_be_bytes()); // numNalus
370			v.extend_from_slice(&(nal.len() as u16).to_be_bytes());
371			v.extend_from_slice(nal);
372		}
373		Bytes::from(v)
374	}
375
376	#[test]
377	fn h264_avc3_passthrough() {
378		let cfg = config(h264(true), None);
379		assert!(matches!(h264_convert(&cfg).unwrap(), TrackConvert::Passthrough));
380	}
381
382	#[test]
383	fn h264_avc1_length_prefixed() {
384		let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f, 0xde];
385		let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80];
386		let cfg = config(h264(false), Some(build_avcc(sps, pps)));
387
388		let TrackConvert::LengthPrefixed {
389			length_size,
390			keyframe_prefix,
391		} = h264_convert(&cfg).unwrap()
392		else {
393			panic!("expected LengthPrefixed");
394		};
395		assert_eq!(length_size, 4);
396		assert!(keyframe_prefix.starts_with(&[0, 0, 0, 1]), "Annex-B start code");
397		assert!(keyframe_prefix.windows(sps.len()).any(|w| w == sps), "SPS in prefix");
398		assert!(keyframe_prefix.windows(pps.len()).any(|w| w == pps), "PPS in prefix");
399	}
400
401	#[test]
402	fn h265_hev1_passthrough() {
403		let cfg = config(h265(true), None);
404		assert!(matches!(h265_convert(&cfg).unwrap(), TrackConvert::Passthrough));
405	}
406
407	#[test]
408	fn h265_hvc1_length_prefixed() {
409		let vps: &[u8] = &[0x40, 0x01, 0x0c, 0x01];
410		let sps: &[u8] = &[0x42, 0x01, 0x01, 0x01];
411		let pps: &[u8] = &[0x44, 0x01, 0xc0, 0xf7];
412		let cfg = config(h265(false), Some(build_hvcc(vps, sps, pps)));
413
414		let TrackConvert::LengthPrefixed {
415			length_size,
416			keyframe_prefix,
417		} = h265_convert(&cfg).unwrap()
418		else {
419			panic!("expected LengthPrefixed");
420		};
421		assert_eq!(length_size, 4);
422		// Parameter sets are prefixed in VPS, SPS, PPS order.
423		let v = keyframe_prefix.windows(vps.len()).position(|w| w == vps).expect("VPS");
424		let s = keyframe_prefix.windows(sps.len()).position(|w| w == sps).expect("SPS");
425		let p = keyframe_prefix.windows(pps.len()).position(|w| w == pps).expect("PPS");
426		assert!(v < s && s < p, "VPS < SPS < PPS order in prefix");
427	}
428
429	/// An avc1 avcC that parses but carries no SPS/PPS must be rejected rather
430	/// than silently producing keyframes without inline parameter sets.
431	#[test]
432	fn h264_avc1_missing_param_sets_errors() {
433		// 6-byte header (numSPS = 0 in the low 5 bits of byte 5) + a zero PPS count.
434		let avcc = Bytes::from(vec![1, 0x42, 0, 0x1f, 0xff, 0xe0, 0x00]);
435		let cfg = config(h264(false), Some(avcc));
436		assert!(h264_convert(&cfg).is_err(), "missing SPS/PPS must error");
437	}
438}