Skip to main content

moq_video/decode/
consumer.rs

1//! Subscribe to an encoded H.264, H.265, or AV1 track and emit raw I420 frames.
2
3use std::collections::VecDeque;
4
5use hang::catalog::VideoConfig;
6
7use super::decoder::{Config, Start};
8use super::sink::Sink;
9use crate::Error;
10use crate::Frame;
11
12/// Subscribe to a moq-mux video track and emit decoded I420.
13///
14/// The codec/backend are fixed at construction; [`read`](Self::read) returns
15/// plain [`Frame`]s. The direct mirror of `moq_audio::decode::Consumer`.
16pub struct Consumer {
17	/// A [`Sink`] rather than a bare `Decoder`: the read loop below is held
18	/// across `.await` by every caller (libmoq's spawned task, moq-transcode),
19	/// so the codec would otherwise migrate between executor workers and
20	/// unbalance the per-thread COM apartment the Windows backend opens.
21	decoder: Sink,
22	track: moq_mux::container::Consumer<moq_mux::catalog::hang::Container>,
23	/// Frames a single access unit decoded to but `read` hasn't returned yet.
24	/// One AU yields one frame in the low-delay path, but a backend may hand back
25	/// more, so we buffer to keep `read` one-frame-per-call.
26	pending: VecDeque<Frame>,
27	/// Whether the ended track's decoder has already been drained.
28	drained: bool,
29	/// Last container discontinuity observed. A change starts a fresh codec epoch.
30	discontinuity: u64,
31}
32
33impl Consumer {
34	/// Subscribe to `name` in `broadcast`, decoding it per the catalog entry.
35	/// Errors if the rendition's codec is not supported by a native backend.
36	pub async fn new(
37		broadcast: &moq_net::broadcast::Consumer,
38		catalog: &VideoConfig,
39		name: impl Into<String>,
40		config: Config,
41	) -> Result<Self, Error> {
42		let decoder = Sink::open(catalog, &config).await?;
43
44		let name = name.into();
45		let track = broadcast.track(&name)?;
46		let mut subscriber = track
47			.subscribe(moq_net::track::Subscription::default().with_priority(hang::catalog::PRIORITY.video))
48			.await?;
49		// A decoder often opens on a track that is already cached: a replacement
50		// decoder subscribes while its predecessor still holds groups, and a
51		// rendition switched away from and back to stays warm for
52		// `TRACK_IDLE_LINGER`. A caller that asked for `Start::Latest` wants
53		// none of that backlog, because a cursor starting at sequence zero
54		// replays every cached group at decode speed before reaching live
55		// media, which on a thirty-second retention is half a minute of pictures raced
56		// through.
57		//
58		// This moves the local read cursor and deliberately not
59		// `Subscription::group_start`. That field is a request to the publisher,
60		// aggregated across every live subscriber, so naming a stale cached
61		// sequence there asks the publisher to rewind the track for everyone
62		// reading it. What a player wants is to skip what it already has.
63		if config.start == Start::Latest
64			&& let Some(live_edge) = track.latest()
65		{
66			subscriber.start_at(live_edge);
67		}
68		let track = subscriber;
69		// The catalog says how the track is framed, and it is not always the legacy
70		// wire: `moq import fmp4` publishes CMAF. Reading a moof+mdat fragment as a
71		// varint timestamp plus a payload decodes to garbage rather than failing.
72		let container = moq_mux::catalog::hang::Container::try_from(&catalog.container)?;
73		let mut track = moq_mux::container::Consumer::new(track, container);
74		if let Some(latency) = config.latency_max {
75			track = track.with_latency(latency);
76		}
77
78		Ok(Self {
79			decoder,
80			track,
81			pending: VecDeque::new(),
82			drained: false,
83			discontinuity: 0,
84		})
85	}
86
87	/// The decoder backend name in use, e.g. `"videotoolbox"` or `"openh264"`.
88	pub fn name(&self) -> &str {
89		self.decoder.name()
90	}
91
92	/// Read the next decoded I420 frame, or `None` after the track ends and the
93	/// decoder's buffered tail has been drained.
94	pub async fn read(&mut self) -> Result<Option<Frame>, Error> {
95		loop {
96			if let Some(frame) = self.pending.pop_front() {
97				return Ok(Some(frame));
98			}
99			if self.drained {
100				return Ok(None);
101			}
102
103			let mux_frame = self.track.read().await?;
104			let discontinuity = self.track.discontinuity();
105			if discontinuity != self.discontinuity {
106				// The tail belongs to the abandoned codec epoch. Draining resets the
107				// backend for reuse, but none of those pictures may cross the seam.
108				self.decoder.flush().await?;
109				self.pending.clear();
110				self.discontinuity = discontinuity;
111			}
112
113			let Some(mux_frame) = mux_frame else {
114				// The flag goes up only once the tail is in hand, so a read
115				// dropped before the drain ran retries it rather than reporting
116				// an end the stream has not reached. Flushing twice is safe: the
117				// second hands back nothing.
118				let tail = self.decoder.flush().await;
119				// Set before the error is returned, not after. A flush that
120				// failed once fails the same way every time, and the track has
121				// ended either way, so leaving the flag down turns one bad
122				// drain into a caller that reads, fails, and reads again with
123				// nothing in between to wait on. A caller that treats a codec
124				// error as one lost picture and carries on then spins.
125				self.drained = true;
126				self.pending.extend(tail?);
127				continue;
128			};
129
130			self.pending.extend(
131				self.decoder
132					.decode(mux_frame.payload, mux_frame.timestamp, mux_frame.keyframe)
133					.await?,
134			);
135		}
136	}
137}
138
139#[cfg(test)]
140mod tests {
141	use bytes::Bytes;
142	use moq_net::Timestamp;
143
144	use super::*;
145	use crate::decode::Kind;
146	use crate::decode::backend::probe;
147	use crate::encode::{Config as EncodeConfig, Encoder, Kind as EncodeKind, Producer as EncodeProducer};
148
149	#[tokio::test]
150	async fn reads_cmaf_container_declared_by_catalog() {
151		let mut source_broadcast = moq_net::broadcast::Info::new().produce();
152		let source_subscriber = source_broadcast.consume();
153		let source_catalog = moq_mux::catalog::Producer::new(&mut source_broadcast).unwrap();
154		let config = EncodeConfig {
155			kind: EncodeKind::Software,
156			..EncodeConfig::new(320, 240, 30)
157		};
158		let rendition = config.probe().await.unwrap();
159		let mut producer = EncodeProducer::new(source_broadcast, source_catalog, rendition).unwrap();
160		let mut encoder = Encoder::new(&config).unwrap();
161		let rgba = vec![0x80u8; 320 * 240 * 4];
162		for index in 0..2 {
163			encoder.keyframe();
164			let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
165			let frame = crate::Frame::new(surface, moq_net::Timestamp::from_micros(index * 33_333).unwrap());
166			producer.publish(&encoder.encode(&frame).unwrap()).unwrap();
167		}
168
169		let origin = moq_net::Origin::random().produce();
170		let mut requests = origin.dynamic();
171		let served = source_subscriber.clone();
172		tokio::spawn(async move {
173			while let Ok(request) = requests.requested_broadcast().await {
174				request.accept(served.clone());
175			}
176		});
177		let catalog = moq_mux::catalog::Consumer::<()>::new(&source_subscriber, moq_mux::catalog::CatalogFormat::Hang)
178			.await
179			.unwrap();
180		let source = moq_mux::Source::new(origin.consume(), "test");
181		let mut export = moq_mux::container::fmp4::Export::new(source, catalog);
182		let init = export.next().await.unwrap().expect("CMAF init");
183		let fragment = export.next().await.unwrap().expect("CMAF fragment");
184
185		let mut broadcast = moq_net::broadcast::Info::new().produce();
186		let subscriber = broadcast.consume();
187		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
188		let mut import = moq_mux::container::fmp4::Import::new(broadcast, catalog.reserve());
189		import.decode(&init).unwrap();
190		import.decode(&fragment).unwrap();
191
192		let snapshot = catalog.snapshot();
193		let (name, config) = snapshot.video.renditions.iter().next().expect("video rendition");
194		assert!(matches!(config.container, hang::catalog::Container::Cmaf { .. }));
195		let mut consumer = Consumer::new(
196			&subscriber,
197			config,
198			name,
199			Config {
200				kind: Kind::Software,
201				..Config::new()
202			},
203		)
204		.await
205		.unwrap();
206
207		let frame = consumer.read().await.unwrap().expect("decoded frame");
208		assert_eq!(frame.size(), crate::Size::new(320, 240));
209	}
210
211	/// A decoder opened on a track that already holds groups starts at the
212	/// newest one, not at the oldest still cached.
213	///
214	/// A player rebuilding its decoder (a backend change, a rendition pin) opens
215	/// a second consumer while the first still holds the groups it has not
216	/// released. Starting those at sequence zero replays the whole retention at
217	/// decode speed before the picture reaches live media.
218	#[tokio::test]
219	async fn a_second_consumer_starts_at_the_live_edge() {
220		let mut broadcast = moq_net::broadcast::Info::new().produce();
221		let track = broadcast.create_track("video", hang::container::track_info()).unwrap();
222		// Kept so the aggregated subscription can be read back below.
223		let published = track.clone();
224		let subscriber = broadcast.consume();
225		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
226		// A keyframe opens a group, so this is three groups a second apart.
227		for index in 0..3u64 {
228			producer
229				.write(moq_mux::container::Frame {
230					timestamp: Timestamp::from_micros(index * 1_000_000).unwrap(),
231					duration: None,
232					payload: Bytes::from_static(b"access unit"),
233					keyframe: true,
234				})
235				.unwrap();
236		}
237		producer.finish().unwrap();
238
239		let catalog = VideoConfig::new(hang::catalog::H264 {
240			inline: true,
241			profile: 0x42,
242			constraints: 0,
243			level: 30,
244		});
245		let mut consumer = Consumer::new(
246			&subscriber,
247			&catalog,
248			"video",
249			Config {
250				kind: Kind::Named(probe::BUFFERED_NAME.into()),
251				start: Start::Latest,
252				..Config::new()
253			},
254		)
255		.await
256		.unwrap();
257
258		// The buffered probe stamps each picture with the access unit's own
259		// timestamp, so this says which group the read started from. It is the
260		// backend to use here rather than the plain probe, whose event log is
261		// process-wide and belongs to the thread-affinity test.
262		let frame = consumer.read().await.unwrap().expect("a decoded frame");
263		assert_eq!(
264			frame.timestamp,
265			Timestamp::from_micros(2_000_000).unwrap(),
266			"a fresh consumer replayed the groups an earlier reader still holds",
267		);
268
269		// The skip is the local read cursor and nothing else. Asking for it
270		// through `Subscription::group_start` would look equivalent and is not:
271		// the field is aggregated across every live subscriber and tells the
272		// publisher what to send, so naming a cached sequence there rewinds the
273		// track for everyone reading it. A rendition switched away from and back
274		// to is the case that bites, because its cached sequence is stale by
275		// then and the publisher resends the broadcast from it.
276		assert_eq!(
277			published.subscription().and_then(|sub| sub.group_start),
278			None,
279			"the publisher was asked to rewind the track",
280		);
281	}
282
283	/// The default reads everything the track holds.
284	///
285	/// `Start::Latest` is a player's policy and not the API's: a recorder, an
286	/// export, or a test decoding a track that was written before it subscribed
287	/// wants every group, and dropping media nobody asked to drop is the worse
288	/// of the two mistakes. This is the half that a live-edge default breaks,
289	/// so it is pinned beside the other one.
290	#[tokio::test]
291	async fn the_default_reads_every_cached_group() {
292		let mut broadcast = moq_net::broadcast::Info::new().produce();
293		let track = broadcast.create_track("video", hang::container::track_info()).unwrap();
294		let subscriber = broadcast.consume();
295		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
296		for index in 0..3u64 {
297			producer
298				.write(moq_mux::container::Frame {
299					timestamp: Timestamp::from_micros(index * 1_000_000).unwrap(),
300					duration: None,
301					payload: Bytes::from_static(b"access unit"),
302					keyframe: true,
303				})
304				.unwrap();
305		}
306		producer.finish().unwrap();
307
308		let catalog = VideoConfig::new(hang::catalog::H264 {
309			inline: true,
310			profile: 0x42,
311			constraints: 0,
312			level: 30,
313		});
314		let mut consumer = Consumer::new(
315			&subscriber,
316			&catalog,
317			"video",
318			Config {
319				// The buffered probe rather than the plain one: the plain probe's
320				// event log is process-wide and belongs to the thread-affinity test.
321				kind: Kind::Named(probe::BUFFERED_NAME.into()),
322				..Config::new()
323			},
324		)
325		.await
326		.unwrap();
327
328		let mut seen = Vec::new();
329		while let Some(frame) = consumer.read().await.unwrap() {
330			seen.push(frame.timestamp);
331		}
332		assert_eq!(
333			seen,
334			vec![
335				Timestamp::from_micros(0).unwrap(),
336				Timestamp::from_micros(1_000_000).unwrap(),
337				Timestamp::from_micros(2_000_000).unwrap(),
338			],
339			"the default dropped groups the caller never asked to drop",
340		);
341	}
342
343	/// A track ends before a decoder that reorders pictures does. The consumer
344	/// drains the backend once and returns its tail before reporting the end.
345	#[tokio::test]
346	async fn track_end_drains_buffered_decoder() {
347		let mut broadcast = moq_net::broadcast::Info::new().produce();
348		let track = broadcast.create_track("video", hang::container::track_info()).unwrap();
349		let subscriber = broadcast.consume();
350		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
351		for index in 0..2u64 {
352			producer
353				.write(moq_mux::container::Frame {
354					timestamp: Timestamp::from_micros(index * 33_333).unwrap(),
355					duration: None,
356					payload: Bytes::from_static(b"access unit"),
357					keyframe: index == 0,
358				})
359				.unwrap();
360		}
361		producer.finish().unwrap();
362
363		let catalog = VideoConfig::new(hang::catalog::H264 {
364			inline: true,
365			profile: 0x42,
366			constraints: 0,
367			level: 30,
368		});
369		let mut consumer = Consumer::new(
370			&subscriber,
371			&catalog,
372			"video",
373			Config {
374				kind: Kind::Named(probe::BUFFERED_NAME.into()),
375				..Config::new()
376			},
377		)
378		.await
379		.unwrap();
380
381		let mut timestamps = Vec::new();
382		while let Some(frame) = consumer.read().await.unwrap() {
383			timestamps.push(frame.timestamp.as_micros());
384		}
385		assert_eq!(timestamps, vec![0, 33_333]);
386		assert!(
387			consumer.read().await.unwrap().is_none(),
388			"the decoder was drained twice"
389		);
390	}
391
392	/// A declared discontinuity abandons the previous codec epoch. A delayed
393	/// picture from before the seam is drained and discarded before the first new
394	/// keyframe is decoded.
395	#[tokio::test]
396	async fn discontinuity_discards_buffered_tail() {
397		let mut broadcast = moq_net::broadcast::Info::new().produce();
398		let track = broadcast.create_track("video", hang::container::track_info()).unwrap();
399		let subscriber = broadcast.consume();
400		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
401		producer
402			.write(moq_mux::container::Frame {
403				timestamp: Timestamp::from_micros(100_000).unwrap(),
404				duration: None,
405				payload: Bytes::from_static(b"old access unit"),
406				keyframe: true,
407			})
408			.unwrap();
409		producer.discontinuity().unwrap();
410		producer
411			.write(moq_mux::container::Frame {
412				timestamp: Timestamp::ZERO,
413				duration: None,
414				payload: Bytes::from_static(b"new access unit"),
415				keyframe: true,
416			})
417			.unwrap();
418		producer.finish().unwrap();
419
420		let catalog = VideoConfig::new(hang::catalog::H264 {
421			inline: true,
422			profile: 0x42,
423			constraints: 0,
424			level: 30,
425		});
426		let mut consumer = Consumer::new(
427			&subscriber,
428			&catalog,
429			"video",
430			Config {
431				kind: Kind::Named(probe::BUFFERED_NAME.into()),
432				..Config::new()
433			},
434		)
435		.await
436		.unwrap();
437
438		let mut timestamps = Vec::new();
439		while let Some(frame) = consumer.read().await.unwrap() {
440			timestamps.push(frame.timestamp.as_micros());
441		}
442		assert_eq!(timestamps, vec![0]);
443	}
444
445	/// Cancellation while a threaded flush is in flight leaves the sink poisoned.
446	/// The next read surfaces that error rather than reporting a clean end and
447	/// silently discarding the tail.
448	#[cfg(not(target_os = "macos"))]
449	#[tokio::test]
450	async fn cancelled_track_end_flush_is_not_reported_as_drained() {
451		probe::prepare_blocking_flush();
452		let mut broadcast = moq_net::broadcast::Info::new().produce();
453		let track = broadcast.create_track("video", hang::container::track_info()).unwrap();
454		let subscriber = broadcast.consume();
455		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
456		producer.finish().unwrap();
457
458		let catalog = VideoConfig::new(hang::catalog::H264 {
459			inline: true,
460			profile: 0x42,
461			constraints: 0,
462			level: 30,
463		});
464		let mut consumer = Consumer::new(
465			&subscriber,
466			&catalog,
467			"video",
468			Config {
469				kind: Kind::Named(probe::BLOCKING_FLUSH_NAME.into()),
470				..Config::new()
471			},
472		)
473		.await
474		.unwrap();
475
476		let mut read = Box::pin(consumer.read());
477		let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
478		loop {
479			tokio::select! {
480				_result = &mut read => panic!("flush returned before cancellation"),
481				_ = tokio::time::sleep(std::time::Duration::from_millis(1)) => {
482					if probe::flush_entered() {
483						break;
484					}
485					if tokio::time::Instant::now() >= deadline {
486						probe::release_flush();
487						panic!("flush never reached the codec thread");
488					}
489				}
490			}
491		}
492		drop(read);
493		probe::release_flush();
494
495		let err = match consumer.read().await {
496			Err(err) => err,
497			Ok(_) => panic!("cancelled flush must poison the sink"),
498		};
499		assert!(err.to_string().contains("cancelled call"), "unexpected error: {err}");
500		assert!(matches!(err, crate::Error::CodecGone(_)));
501		assert!(consumer.read().await.unwrap().is_none());
502	}
503
504	/// VAAPI returns its buffered tail before the consumer reports track end.
505	#[cfg(all(target_os = "linux", feature = "vaapi"))]
506	#[tokio::test]
507	async fn the_track_ending_drains_the_decoder() {
508		const FRAMES: u64 = 5;
509		let config = EncodeConfig {
510			kind: EncodeKind::Software,
511			..EncodeConfig::new(320, 240, 30)
512		};
513		let catalog = config.probe().await.expect("probe the software encoder");
514
515		let mut broadcast = moq_net::broadcast::Info::new().produce();
516		let track = broadcast.create_track("video", hang::container::track_info()).unwrap();
517		let subscriber = broadcast.consume();
518		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
519
520		let mut encoder = Encoder::new(&config).unwrap();
521		let rgba = vec![0x80u8; 320 * 240 * 4];
522		for index in 0..FRAMES {
523			if index == 0 {
524				encoder.keyframe();
525			}
526			let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
527			let frame = crate::Frame::new(surface, moq_net::Timestamp::from_micros(index * 33_333).unwrap());
528			for encoded in encoder.encode(&frame).unwrap() {
529				producer
530					.write(moq_mux::container::Frame {
531						timestamp: encoded.timestamp,
532						duration: None,
533						payload: encoded.payload,
534						keyframe: index == 0,
535					})
536					.unwrap();
537			}
538		}
539		producer.finish().unwrap();
540
541		let decode = Config {
542			kind: Kind::Named("vaapi".into()),
543			..Config::new()
544		};
545		// The hardware gate: no libva, no render node, or no H.264 decode
546		// entrypoint and the named backend refuses to open.
547		let Ok(mut consumer) = Consumer::new(&subscriber, &catalog, "video", decode).await else {
548			return;
549		};
550
551		let mut timestamps = Vec::new();
552		while let Some(frame) = consumer.read().await.unwrap() {
553			timestamps.push(frame.timestamp.as_micros());
554		}
555		let expected: Vec<u128> = (0..FRAMES as u128).map(|index| index * 33_333).collect();
556		assert_eq!(timestamps, expected, "the track ended before the stream did");
557
558		// The end stays the end: the drain runs once, so a caller that keeps
559		// reading past it does not get the tail a second time.
560		assert!(consumer.read().await.unwrap().is_none());
561	}
562}