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 h264;
12pub mod opus;
13pub mod vp8;
14pub mod vp9;
15
16use bytes::Bytes;
17use hang::catalog::VideoConfig;
18use str0m::format::Codec;
19
20use crate::Result;
21
22/// One codec frame received from str0m, paired with a microsecond timestamp.
23///
24/// Used by the ingest path. The session loop converts str0m's
25/// [`MediaTime`](str0m::media::MediaTime) to microseconds so individual
26/// bridges don't need to repeat the math.
27#[derive(Clone, Debug)]
28pub struct Frame {
29	pub timestamp_us: u64,
30	pub payload: Bytes,
31}
32
33/// Bridges depacketized media frames from str0m to a hang broadcast track.
34///
35/// One bridge per `m=` line on the ingest side. The session loop calls
36/// [`Bridge::push`] once per [`MediaData`](str0m::media::MediaData) event
37/// with the codec frame; the bridge handles any codec-specific transformations
38/// (e.g. Annex-B to AVCC for H.264) and forwards the frame into the matching
39/// moq-mux importer.
40pub trait Bridge: Send {
41	fn push(&mut self, frame: Frame) -> Result<()>;
42}
43
44/// One RTP-ready codec frame produced by an egress [`Track`].
45///
46/// `timestamp_us` stays in microseconds; the session loop converts it to
47/// the negotiated codec's clock domain when calling
48/// [`Writer::write`](str0m::media::Writer::write).
49#[derive(Clone, Debug)]
50pub struct PacketizedFrame {
51	pub timestamp_us: u64,
52	pub payload: Bytes,
53}
54
55/// A subscribed moq-mux track, normalized to the bitstream shape str0m's
56/// Frame API expects.
57///
58/// One [`Track`] per `m=` line on the egress side. The egress source spawns
59/// a pump task per track that polls [`Track::next`] and forwards frames to
60/// the session loop.
61pub struct Track {
62	consumer: moq_mux::container::Consumer<moq_mux::catalog::hang::Container>,
63	codec: Codec,
64	convert: TrackConvert,
65}
66
67/// Codec-specific per-frame transform.
68enum TrackConvert {
69	/// Opus / VP8 / VP9 / AV1, plus inline-parameter H.264 (avc3) and H.265
70	/// (hev1): the stored bitstream is already in the shape str0m's
71	/// packetizer wants, so it passes through untouched.
72	Passthrough,
73	/// Out-of-band-parameter H.264 (avc1) and H.265 (hvc1): length-prefixed
74	/// NALU rewritten to Annex-B, with the cached parameter sets (SPS+PPS,
75	/// plus VPS for H.265) prepended to every keyframe. Both codecs share this
76	/// path; only the config record parsed to build it differs (avcC vs hvcC).
77	/// Mirrors moq-mux's `h264::Export` / `h265::Export`.
78	LengthPrefixed { length_size: usize, keyframe_prefix: Bytes },
79}
80
81impl Track {
82	/// Audio track for an Opus rendition.
83	pub async fn opus(broadcast: &moq_net::BroadcastConsumer, name: &str) -> Result<Self> {
84		let container = moq_mux::catalog::hang::Container::Legacy;
85		// The consumer starts at the latest (in-progress) group, which begins at a
86		// keyframe, so a late joiner gets a decodable start immediately rather than
87		// waiting for the next group boundary.
88		let track = broadcast.subscribe_track(&moq_net::Track::new(name))?;
89		let consumer = moq_mux::container::Consumer::new(track, container);
90		Ok(Self {
91			consumer,
92			codec: Codec::Opus,
93			convert: TrackConvert::Passthrough,
94		})
95	}
96
97	/// Video track. Codec inferred from `config.codec`; for H.264 / H.265 the
98	/// bitstream shape (inline vs out-of-band parameter sets) is inferred from
99	/// `config.description` (avc1/hvc1 vs avc3/hev1).
100	pub async fn video(broadcast: &moq_net::BroadcastConsumer, name: &str, config: &VideoConfig) -> Result<Self> {
101		let container: moq_mux::catalog::hang::Container = (&config.container).try_into()?;
102		// The consumer starts at the latest (in-progress) group, which begins at a
103		// keyframe, so a late-joining peer gets a decodable start.
104		let track = broadcast.subscribe_track(&moq_net::Track::new(name))?;
105		let consumer = moq_mux::container::Consumer::new(track, container);
106
107		let (codec, convert) = match &config.codec {
108			hang::catalog::VideoCodec::VP8 => (Codec::Vp8, TrackConvert::Passthrough),
109			hang::catalog::VideoCodec::VP9(_) => (Codec::Vp9, TrackConvert::Passthrough),
110			hang::catalog::VideoCodec::AV1(_) => (Codec::Av1, TrackConvert::Passthrough),
111			hang::catalog::VideoCodec::H264(_) => (Codec::H264, h264_convert(config)?),
112			hang::catalog::VideoCodec::H265(_) => (Codec::H265, h265_convert(config)?),
113			other => return Err(crate::Error::UnsupportedCodec(format!("{other:?}"))),
114		};
115
116		Ok(Self {
117			consumer,
118			codec,
119			convert,
120		})
121	}
122
123	pub fn codec(&self) -> Codec {
124		self.codec
125	}
126
127	/// Pull the next RTP-ready frame. Returns `None` when the track ends.
128	pub async fn next(&mut self) -> Result<Option<PacketizedFrame>> {
129		loop {
130			let Some(frame) = self.consumer.read().await? else {
131				return Ok(None);
132			};
133			let payload = match &self.convert {
134				TrackConvert::Passthrough => frame.payload,
135				TrackConvert::LengthPrefixed {
136					length_size,
137					keyframe_prefix,
138				} => {
139					let prefix = frame.keyframe.then(|| keyframe_prefix.as_ref());
140					moq_mux::codec::annexb::from_length_prefixed(&frame.payload, *length_size, prefix)
141						.map_err(|err| crate::Error::Other(anyhow::anyhow!("annexb: {err}")))?
142				}
143			};
144			if payload.is_empty() {
145				continue;
146			}
147			return Ok(Some(PacketizedFrame {
148				timestamp_us: frame.timestamp.as_micros() as u64,
149				payload,
150			}));
151		}
152	}
153}
154
155/// Build the per-frame transform for an H.264 rendition.
156///
157/// avc3 (inline SPS/PPS, empty `description`) passes through. avc1 (out-of-band
158/// avcC in `description`) parses the avcC and prebuilds the Annex-B SPS+PPS
159/// prefix to prepend ahead of every keyframe.
160fn h264_convert(config: &VideoConfig) -> Result<TrackConvert> {
161	let Some(avcc) = config.description.as_ref().filter(|d| !d.is_empty()) else {
162		return Ok(TrackConvert::Passthrough);
163	};
164	let params = moq_mux::codec::h264::Avcc::parse(avcc)
165		.map_err(|err| crate::Error::Other(anyhow::anyhow!("avcc parse: {err}")))?;
166	// Without SPS+PPS the keyframe prefix would be empty and every keyframe
167	// would reach the peer without inline parameter sets, i.e. undecodable.
168	// Fail loudly instead, matching moq-mux's `h264::Export`.
169	if params.sps.is_empty() || params.pps.is_empty() {
170		return Err(crate::Error::Other(anyhow::anyhow!(
171			"avc1 avcC is missing parameter sets (sps={}, pps={})",
172			params.sps.len(),
173			params.pps.len()
174		)));
175	}
176	let keyframe_prefix = moq_mux::codec::annexb::build_prefix(params.sps.iter().chain(params.pps.iter()));
177	Ok(TrackConvert::LengthPrefixed {
178		length_size: params.length_size,
179		keyframe_prefix,
180	})
181}
182
183/// Build the per-frame transform for an H.265 rendition.
184///
185/// The H.265 analogue of [`h264_convert`]: hev1 (inline VPS/SPS/PPS) passes
186/// through; hvc1 (out-of-band hvcC) parses the hvcC and prebuilds the Annex-B
187/// VPS+SPS+PPS prefix to prepend ahead of every keyframe.
188fn h265_convert(config: &VideoConfig) -> Result<TrackConvert> {
189	let Some(hvcc) = config.description.as_ref().filter(|d| !d.is_empty()) else {
190		return Ok(TrackConvert::Passthrough);
191	};
192	let params = moq_mux::codec::h265::Hvcc::parse(hvcc)
193		.map_err(|err| crate::Error::Other(anyhow::anyhow!("hvcc parse: {err}")))?;
194	// Same reasoning as `h264_convert`: a keyframe with no inline VPS/SPS/PPS
195	// is undecodable, so reject an hvcC that omits any of them.
196	if params.vps.is_empty() || params.sps.is_empty() || params.pps.is_empty() {
197		return Err(crate::Error::Other(anyhow::anyhow!(
198			"hvc1 hvcC is missing parameter sets (vps={}, sps={}, pps={})",
199			params.vps.len(),
200			params.sps.len(),
201			params.pps.len()
202		)));
203	}
204	let keyframe_prefix =
205		moq_mux::codec::annexb::build_prefix(params.vps.iter().chain(params.sps.iter()).chain(params.pps.iter()));
206	Ok(TrackConvert::LengthPrefixed {
207		length_size: params.length_size,
208		keyframe_prefix,
209	})
210}
211
212#[cfg(test)]
213mod tests {
214	use hang::catalog::{H264, H265, VideoConfig};
215
216	use super::*;
217
218	fn config(codec: impl Into<hang::catalog::VideoCodec>, description: Option<Bytes>) -> VideoConfig {
219		let mut config = VideoConfig::new(codec);
220		config.description = description;
221		config
222	}
223
224	fn h264(inline: bool) -> H264 {
225		H264 {
226			inline,
227			profile: 0x42,
228			constraints: 0,
229			level: 0x1f,
230		}
231	}
232
233	fn h265(in_band: bool) -> H265 {
234		H265 {
235			in_band,
236			profile_space: 0,
237			profile_idc: 1,
238			profile_compatibility_flags: [0; 4],
239			tier_flag: false,
240			level_idc: 0x5d,
241			constraint_flags: [0; 6],
242		}
243	}
244
245	/// Minimal avcC carrying one SPS + one PPS (lengthSizeMinusOne = 3).
246	fn build_avcc(sps: &[u8], pps: &[u8]) -> Bytes {
247		let mut v = vec![1, sps[1], sps[2], sps[3], 0xff, 0xe1];
248		v.extend_from_slice(&(sps.len() as u16).to_be_bytes());
249		v.extend_from_slice(sps);
250		v.push(1);
251		v.extend_from_slice(&(pps.len() as u16).to_be_bytes());
252		v.extend_from_slice(pps);
253		Bytes::from(v)
254	}
255
256	/// Minimal hvcC carrying one VPS + SPS + PPS array (lengthSizeMinusOne = 3).
257	/// VPS/SPS/PPS NAL unit types are 32/33/34.
258	fn build_hvcc(vps: &[u8], sps: &[u8], pps: &[u8]) -> Bytes {
259		let mut v = vec![0u8; 21];
260		v.push(0xff); // [21] lengthSizeMinusOne = 3 in the low 2 bits
261		v.push(3); // [22] numOfArrays
262		for (nal_type, nal) in [(32u8, vps), (33, sps), (34, pps)] {
263			v.push(nal_type); // array header: low 6 bits = NAL unit type
264			v.extend_from_slice(&1u16.to_be_bytes()); // numNalus
265			v.extend_from_slice(&(nal.len() as u16).to_be_bytes());
266			v.extend_from_slice(nal);
267		}
268		Bytes::from(v)
269	}
270
271	#[test]
272	fn h264_avc3_passthrough() {
273		let cfg = config(h264(true), None);
274		assert!(matches!(h264_convert(&cfg).unwrap(), TrackConvert::Passthrough));
275	}
276
277	#[test]
278	fn h264_avc1_length_prefixed() {
279		let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f, 0xde];
280		let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80];
281		let cfg = config(h264(false), Some(build_avcc(sps, pps)));
282
283		let TrackConvert::LengthPrefixed {
284			length_size,
285			keyframe_prefix,
286		} = h264_convert(&cfg).unwrap()
287		else {
288			panic!("expected LengthPrefixed");
289		};
290		assert_eq!(length_size, 4);
291		assert!(keyframe_prefix.starts_with(&[0, 0, 0, 1]), "Annex-B start code");
292		assert!(keyframe_prefix.windows(sps.len()).any(|w| w == sps), "SPS in prefix");
293		assert!(keyframe_prefix.windows(pps.len()).any(|w| w == pps), "PPS in prefix");
294	}
295
296	#[test]
297	fn h265_hev1_passthrough() {
298		let cfg = config(h265(true), None);
299		assert!(matches!(h265_convert(&cfg).unwrap(), TrackConvert::Passthrough));
300	}
301
302	#[test]
303	fn h265_hvc1_length_prefixed() {
304		let vps: &[u8] = &[0x40, 0x01, 0x0c, 0x01];
305		let sps: &[u8] = &[0x42, 0x01, 0x01, 0x01];
306		let pps: &[u8] = &[0x44, 0x01, 0xc0, 0xf7];
307		let cfg = config(h265(false), Some(build_hvcc(vps, sps, pps)));
308
309		let TrackConvert::LengthPrefixed {
310			length_size,
311			keyframe_prefix,
312		} = h265_convert(&cfg).unwrap()
313		else {
314			panic!("expected LengthPrefixed");
315		};
316		assert_eq!(length_size, 4);
317		// Parameter sets are prefixed in VPS, SPS, PPS order.
318		let v = keyframe_prefix.windows(vps.len()).position(|w| w == vps).expect("VPS");
319		let s = keyframe_prefix.windows(sps.len()).position(|w| w == sps).expect("SPS");
320		let p = keyframe_prefix.windows(pps.len()).position(|w| w == pps).expect("PPS");
321		assert!(v < s && s < p, "VPS < SPS < PPS order in prefix");
322	}
323
324	/// An avc1 avcC that parses but carries no SPS/PPS must be rejected rather
325	/// than silently producing keyframes without inline parameter sets.
326	#[test]
327	fn h264_avc1_missing_param_sets_errors() {
328		// 6-byte header (numSPS = 0 in the low 5 bits of byte 5) + a zero PPS count.
329		let avcc = Bytes::from(vec![1, 0x42, 0, 0x1f, 0xff, 0xe0, 0x00]);
330		let cfg = config(h264(false), Some(avcc));
331		assert!(h264_convert(&cfg).is_err(), "missing SPS/PPS must error");
332	}
333}