Skip to main content

moq_transcode/
lib.rs

1//! Just-in-time live transcoding for hang broadcasts.
2//!
3//! [`run`] consumes a source broadcast and fills a derivative broadcast: a
4//! catalog advertising lower renditions (rungs) of the source video plus
5//! references back to the source renditions, and one output video track per
6//! rung. The catalog is published immediately and deterministically (codec
7//! strings are computed from the ladder, not the bitstream), but nothing is
8//! encoded until a subscriber actually asks:
9//!
10//! - Subscribing to a rung attaches it to a shared live decode of the source
11//!   (one subscription and one decoder per source, no matter how many rungs
12//!   are active); each rung resizes and encodes its own copy, group for group,
13//!   stopping when the last subscriber leaves.
14//! - Fetching a specific group fetches that same group from the source and
15//!   transcodes just that group. Output groups mirror source sequence numbers
16//!   1:1, so group N of every rung is the same content as source group N.
17//!
18//! The codec work is `moq-video`: hardware where available (NVDEC + NVENC on
19//! Linux, VideoToolbox on macOS, Media Foundation on Windows), with the default
20//! `openh264` feature providing H.264 software fallback. On an NVIDIA GPU the whole pipeline is
21//! GPU-resident: NVDEC decodes and scales in hardware and NVENC encodes the
22//! CUDA frame in place, with no CPU copies. macOS and Windows also resize on
23//! the GPU; set [`Config::resize`]'s output to `Output::Cpu` to decode to CPU
24//! pixels and resize there.
25
26pub mod active;
27pub mod ladder;
28
29mod catalog;
30mod config;
31mod error;
32mod feed;
33mod pipeline;
34mod rung;
35
36pub use config::Config;
37pub use ladder::{Ladder, Rung};
38
39pub use error::Error;
40
41/// Transcode `source` into `output` until the source broadcast ends.
42///
43/// A shorthand for [`Transcoder::new`] followed by [`Transcoder::run`], for a
44/// caller with nothing to observe.
45pub async fn run(
46	source: moq_net::broadcast::Consumer,
47	output: moq_net::broadcast::Producer,
48	config: Config,
49) -> Result<(), Error> {
50	Transcoder::new(source, output, config)?.run().await
51}
52
53/// A transcoder, split from the future that drives it.
54///
55/// Reads the source catalog, publishes the derivative catalog (rungs strictly
56/// below the source, plus source renditions referenced via [`Config::source`]),
57/// and serves each rung just-in-time: a rung track only materializes when a
58/// consumer asks for it, and only encodes while consumed. Where `output` is
59/// announced (and how its path relates to the source) is the caller's business.
60///
61/// The split exists so a caller can attach [`active`] before any encoding
62/// starts. [`run`](Self::run) consumes the transcoder, so take the cursors you
63/// want first.
64pub struct Transcoder {
65	source: moq_net::broadcast::Consumer,
66	output: moq_net::broadcast::Producer,
67	config: Config,
68	derived: moq_mux::catalog::Producer,
69	// Consumers asking for a rung before (or after) it exists queue here.
70	dynamic: moq_net::broadcast::Dynamic,
71	active: active::Producer,
72}
73
74impl Transcoder {
75	/// Register the catalog tracks and the on-demand rung handler on `output`.
76	///
77	/// Synchronous, and everything a consumer can race is in place by the time
78	/// it returns, so announce `output` after this rather than before.
79	pub fn new(
80		source: moq_net::broadcast::Consumer,
81		mut output: moq_net::broadcast::Producer,
82		config: Config,
83	) -> Result<Self, Error> {
84		// The catalog starts empty and fills in during `run`, exactly like a
85		// media importer that hasn't seen parameter sets yet.
86		let derived = moq_mux::catalog::Producer::new(&mut output, moq_mux::catalog::Config::default())?;
87		let dynamic = output.dynamic();
88
89		Ok(Self {
90			source,
91			output,
92			config,
93			derived,
94			dynamic,
95			active: active::Producer::default(),
96		})
97	}
98
99	/// A cursor over the renditions this transcoder produces.
100	///
101	/// Each call returns an independent cursor, positioned before the ladder so
102	/// it reports every rendition once and everything already encoding. See
103	/// [`active::Consumer`].
104	pub fn active(&self) -> active::Consumer {
105		self.active.consume()
106	}
107
108	/// Serve the ladder until the source broadcast ends.
109	pub async fn run(self) -> Result<(), Error> {
110		let Self {
111			source,
112			output,
113			config,
114			mut derived,
115			mut dynamic,
116			active,
117		} = self;
118
119		// The source catalog drives everything; wait for a snapshot with a usable
120		// video rendition (the first may precede the source publishing its video).
121		let track = source
122			.track(hang::Catalog::DEFAULT_NAME)?
123			.subscribe(hang::Catalog::default_subscription())
124			.await?;
125		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track);
126		let mut decoders = catalog::Decoders::new(config.feed_decoder());
127		let (source_name, source_config, snapshot) = loop {
128			let Some(snapshot) = catalogs.next().await? else {
129				return Err(Error::NoSource);
130			};
131			match catalog::choose_source(&snapshot.video, &mut decoders).await {
132				Ok((name, config)) => break (name, config, snapshot),
133				Err(Error::NoSource) => tracing::debug!("no transcodable rendition yet; waiting for a catalog update"),
134				Err(err) => return Err(err),
135			}
136		};
137		// The ladder, the shared decode behind it, and the rungs serving off it.
138		// Resolved again on every source catalog snapshot, so a source that resizes
139		// mid-stream takes the ladder with it.
140		let mut ladder = pipeline::Pipeline::new(
141			source.clone(),
142			config.clone(),
143			active,
144			decoders,
145			source_name,
146			source_config,
147		)
148		.await?;
149
150		// Publish the derivative catalog before any encoder exists, so subscribers
151		// can pick a rung immediately. Commit so a catalog that cannot be published fails
152		// here rather than serving rungs nobody can discover.
153		{
154			let mut guard = derived.modify()?;
155			catalog::populate(&mut guard, &snapshot, ladder.rungs(), config.source.as_ref())?;
156			guard.commit()?;
157		}
158
159		// Serve rung requests and follow source catalog updates until the source ends.
160		let mut tasks = tokio::task::JoinSet::new();
161		loop {
162			tokio::select! {
163				request = dynamic.requested_track() => {
164					// Err means the broadcast closed; nothing left to serve.
165					let Ok(request) = request else { break };
166					match ladder.rung(request.name())? {
167						Some(rung) => { tasks.spawn(rung::serve(rung, request)); }
168						None => request.reject(moq_net::Error::NotFound),
169					}
170				},
171				update = catalogs.next() => match update {
172					Ok(Some(snapshot)) => {
173						ladder.follow(&snapshot.video).await?;
174						let mut guard = derived.modify()?;
175						catalog::populate(&mut guard, &snapshot, ladder.rungs(), config.source.as_ref())?;
176						guard.commit()?;
177					}
178					// The source ended (or its catalog track died): wind down.
179					Ok(None) => break,
180					Err(err) => {
181						tracing::debug!(%err, "source catalog ended");
182						break;
183					}
184				},
185				Some(result) = tasks.join_next() => match result {
186					Ok(Ok(())) => {}
187					Ok(Err(err)) => tracing::warn!(%err, "rung failed"),
188					Err(err) => tracing::warn!(%err, "rung panicked"),
189				}
190			}
191		}
192
193		// Wind the rungs down. On a clean source end they are already finishing on
194		// their own (the live path saw the source track end), so `shutdown` just
195		// joins them. But `run` also breaks on a catalog-track error while the
196		// source media and viewers are still live, and a rung task only self-ends on
197		// source-media-end or broadcast-close, not catalog-end. Aborting rather than
198		// awaiting keeps that case from hanging forever here.
199		tasks.shutdown().await;
200
201		derived.finish()?;
202		output.finish();
203		Ok(())
204	}
205}
206
207#[cfg(test)]
208mod tests {
209
210	use super::*;
211
212	/// A live source broadcast; the producers are kept so the tracks stay open
213	/// for the duration of the test.
214	struct Source {
215		broadcast: moq_net::broadcast::Producer,
216		catalog: moq_mux::catalog::Producer,
217		_track: moq_net::track::Producer,
218		/// The picture the catalog currently advertises, so a republish that only
219		/// changes the description keeps it.
220		size: (u32, u32),
221	}
222
223	impl Source {
224		/// Publish the source video rendition at `width`x`height`, replacing
225		/// whatever the catalog said before. An importer does exactly this when
226		/// the picture changes size: the next keyframe's SPS is republished.
227		fn resize(&mut self, width: u32, height: u32) {
228			self.publish(width, height, None);
229		}
230
231		/// Republish the current rendition with new out-of-band parameter sets,
232		/// which is a new decode stream at the same picture.
233		fn describe(&mut self, description: Option<bytes::Bytes>) {
234			let (width, height) = self.size;
235			self.publish(width, height, description);
236		}
237
238		fn publish(&mut self, width: u32, height: u32, description: Option<bytes::Bytes>) {
239			let mut video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
240				inline: true,
241				profile: 0x42,
242				constraints: 0,
243				level: 30,
244			});
245			video.coded_width = Some(width);
246			video.coded_height = Some(height);
247			video.bitrate = Some(1_000_000);
248			video.framerate = Some(30.0);
249			video.description = description;
250			self.size = (width, height);
251
252			let mut guard = self.catalog.modify().unwrap();
253			guard.video = hang::catalog::Video::default();
254			guard.video.insert("video", video).unwrap();
255		}
256	}
257
258	/// A source broadcast carrying a catalog and an empty video track: enough to
259	/// resolve a ladder, since no rung encodes until someone asks.
260	fn source_catalog(width: u32, height: u32) -> Source {
261		let mut broadcast = moq_net::broadcast::Info::default().produce();
262		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
263		let track = broadcast
264			.create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video))
265			.unwrap();
266
267		let mut source = Source {
268			broadcast,
269			catalog,
270			_track: track,
271			size: (width, height),
272		};
273		source.resize(width, height);
274		source
275	}
276
277	/// Read derived catalog snapshots until one satisfies `ready`, so a test
278	/// doesn't race the transcoder's own catalog writes.
279	async fn await_catalog(
280		catalogs: &mut moq_mux::catalog::hang::Consumer<()>,
281		ready: impl Fn(&moq_mux::catalog::hang::Catalog) -> bool,
282	) -> moq_mux::catalog::hang::Catalog {
283		loop {
284			let snapshot = catalogs.next().await.unwrap().unwrap();
285			if ready(&snapshot) {
286				return snapshot;
287			}
288		}
289	}
290
291	/// Subscribe to a derived track, waiting for the transcoder to register it.
292	async fn subscribe(consumer: &moq_net::broadcast::Consumer, name: &str) -> moq_net::track::Subscriber {
293		let track = loop {
294			match consumer.track(name) {
295				Ok(track) => break track,
296				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
297				Err(err) => panic!("track {name}: {err}"),
298			}
299		};
300		track.subscribe(None).await.unwrap()
301	}
302
303	/// H.264 NAL unit types in an Annex-B buffer, found via 3-byte start codes (a
304	/// 4-byte `00 00 00 01` code contains `00 00 01` too, so this catches both).
305	fn nal_types(annexb: &[u8]) -> Vec<u8> {
306		let mut types = Vec::new();
307		let mut i = 0;
308		while i + 3 < annexb.len() {
309			if annexb[i..i + 3] == [0, 0, 1] {
310				types.push(annexb[i + 3] & 0x1f);
311				i += 3;
312			} else {
313				i += 1;
314			}
315		}
316		types
317	}
318
319	/// Write one gray 320x240 keyframe into `group`, so a fetch of it has something
320	/// to decode while the group is still open.
321	fn write_keyframe(group: &mut moq_net::group::Producer) {
322		let mut encoder = moq_video::encode::Encoder::new(&{
323			let mut config = moq_video::encode::Config::new(320, 240, moq_video::Rate::new(30, 1).unwrap());
324			config.kind = moq_video::encode::Kind::Software;
325			config
326		})
327		.unwrap();
328		encoder.cut().unwrap();
329		let gray = vec![0x80u8; 320 * 240 * 4];
330		for encoded in encoder.encode(&gray_frame(&gray, 0)).unwrap() {
331			hang::container::Frame {
332				timestamp: encoded.timestamp,
333				payload: encoded.payload,
334			}
335			.write_to(group)
336			.unwrap();
337		}
338	}
339
340	/// Wrap a gray 320x240 RGBA buffer as a raw frame at `timestamp` microseconds.
341	fn gray_frame(rgba: &[u8], timestamp: u64) -> moq_video::Frame {
342		let surface = moq_video::Surface::rgba(rgba, moq_video::Size::new(320, 240)).unwrap();
343		moq_video::Frame::new(surface, moq_net::Timestamp::from_micros(timestamp).unwrap())
344	}
345
346	/// Build a 320x240 avc3 source broadcast: a catalog plus a video track with
347	/// `groups` groups of `frames` gray frames each, encoded with openh264.
348	fn source_broadcast(groups: u64, frames: u64) -> Source {
349		let mut broadcast = moq_net::broadcast::Info::default().produce();
350		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
351
352		let mut video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
353			inline: true,
354			profile: 0x42,
355			constraints: 0,
356			level: 30,
357		});
358		video.coded_width = Some(320);
359		video.coded_height = Some(240);
360		video.bitrate = Some(1_000_000);
361		video.framerate = Some(30.0);
362		catalog.modify().unwrap().video.insert("video", video).unwrap();
363
364		let info = hang::container::track_info(hang::catalog::PRIORITY.video);
365		let track = broadcast.create_track("video", info).unwrap();
366
367		let mut encoder = moq_video::encode::Encoder::new(&{
368			let mut config = moq_video::encode::Config::new(320, 240, moq_video::Rate::new(30, 1).unwrap());
369			config.kind = moq_video::encode::Kind::Software;
370			config
371		})
372		.unwrap();
373		let gray = vec![0x80u8; 320 * 240 * 4];
374
375		for sequence in 0..groups {
376			let mut group = track.create_group(sequence.into()).unwrap();
377			for index in 0..frames {
378				let timestamp = (sequence * frames + index) * 33_333;
379				if index == 0 {
380					encoder.cut().unwrap();
381				}
382				for encoded in encoder.encode(&gray_frame(&gray, timestamp)).unwrap() {
383					let frame = hang::container::Frame {
384						timestamp: encoded.timestamp,
385						payload: encoded.payload,
386					};
387					frame.write_to(&mut group).unwrap();
388				}
389			}
390			group.finish().unwrap();
391		}
392
393		Source {
394			broadcast,
395			catalog,
396			_track: track,
397			size: (320, 240),
398		}
399	}
400
401	/// A source like [`source_broadcast`], but the groups arrive over (paused)
402	/// time instead of all at once, so several rungs can attach to the shared
403	/// live feed before the first group exists. Returns the broadcast plus the
404	/// producing task's handle (the track producer lives inside it).
405	fn source_broadcast_live(groups: u64, frames: u64) -> (Source, tokio::task::JoinHandle<()>) {
406		let mut broadcast = moq_net::broadcast::Info::default().produce();
407		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
408
409		let mut video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
410			inline: true,
411			profile: 0x42,
412			constraints: 0,
413			level: 30,
414		});
415		video.coded_width = Some(320);
416		video.coded_height = Some(240);
417		video.bitrate = Some(1_000_000);
418		video.framerate = Some(30.0);
419		catalog.modify().unwrap().video.insert("video", video).unwrap();
420
421		let info = hang::container::track_info(hang::catalog::PRIORITY.video);
422		let track = broadcast.create_track("video", info).unwrap();
423
424		let source = Source {
425			broadcast,
426			catalog,
427			// The producing task owns the real track producer; park a clone so
428			// the struct shape matches `source_broadcast`.
429			_track: track.clone(),
430			size: (320, 240),
431		};
432
433		let task = tokio::spawn(async move {
434			let mut encoder = moq_video::encode::Sink::open(&{
435				let mut config = moq_video::encode::Config::new(320, 240, moq_video::Rate::new(30, 1).unwrap());
436				config.kind = moq_video::encode::Kind::Software;
437				config
438			})
439			.await
440			.unwrap();
441			let gray = vec![0x80u8; 320 * 240 * 4];
442
443			for sequence in 0..groups {
444				// Paces the source: a real sleep, since the rungs encode off the
445				// executor and cannot be sequenced by paused-time idle detection.
446				// Also the window the subscribers attach in, before group 0.
447				tokio::time::sleep(std::time::Duration::from_millis(100)).await;
448				let mut group = track.create_group(sequence.into()).unwrap();
449				for index in 0..frames {
450					let timestamp = (sequence * frames + index) * 33_333;
451					if index == 0 {
452						encoder.cut().await.unwrap();
453					}
454					for encoded in encoder.encode(gray_frame(&gray, timestamp)).await.unwrap() {
455						let frame = hang::container::Frame {
456							timestamp: encoded.timestamp,
457							payload: encoded.payload,
458						};
459						frame.write_to(&mut group).unwrap();
460					}
461				}
462				group.finish().unwrap();
463			}
464			// Keep the track open until aborted, like a live source.
465			std::future::pending::<()>().await;
466		});
467
468		(source, task)
469	}
470
471	/// Two rungs subscribed at once ride one shared live decode (the feed):
472	/// both must produce complete groups mirroring the source sequences.
473	#[tokio::test]
474	async fn live_multi_rung() {
475		// Real time on purpose, unlike most timed tests here. The rungs encode on
476		// their own threads (`encode::Sink`), so a rung waiting on one looks idle
477		// to tokio and `pause()` auto-advances the source's sleep while the encode
478		// is still in flight. The source then outruns the feed's bounded broadcast
479		// and every rung sees `Lagged` instead of its frames. Real sleeps pace the
480		// source against the encoders the way a live source does.
481		let (source, producer_task) = source_broadcast_live(3, 5);
482		let config = Config {
483			ladder: Ladder::new([
484				Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000)),
485				Rung::new(60, moq_net::bandwidth::Rate::from_bps(50_000)),
486			])
487			.unwrap(),
488			encoder: moq_video::encode::Kind::Software,
489			decoder: moq_video::decode::Kind::Software,
490			source: None,
491			..Default::default()
492		};
493
494		let output = moq_net::broadcast::Info::default().produce();
495		let consumer = output.consume();
496		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
497
498		// Attach both rungs before the first source group exists (paused time:
499		// the producer's sleep only fires once every rung is parked on the feed).
500		let mut subscribers = Vec::new();
501		for name in ["video/120p", "video/60p"] {
502			let track = loop {
503				match consumer.track(name) {
504					Ok(track) => break track,
505					Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
506					Err(err) => panic!("rung track {name}: {err}"),
507				}
508			};
509			subscribers.push((name, track.subscribe(None).await.unwrap().ordered()));
510		}
511
512		// Every rung receives a complete group with all 5 source frames.
513		for (name, subscriber) in &mut subscribers {
514			let mut group = subscriber.next_group().await.unwrap().unwrap();
515			let payload = group.read_frame().await.unwrap().unwrap();
516			let frame = hang::container::Frame::decode(payload.payload).unwrap();
517			assert!(
518				frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
519				"{name} output is not Annex-B"
520			);
521			while group.read_frame().await.unwrap().is_some() {}
522			assert_eq!(group.frame_count(), 5, "{name} dropped frames");
523		}
524
525		producer_task.abort();
526		transcoder.abort();
527	}
528
529	/// The multi-rung live path on real hardware: one shared NVDEC session
530	/// decodes the source, the GPU box filter resizes per rung, and each rung's
531	/// NVENC session encodes the CUDA frame in place. Skips without a GPU.
532	#[cfg_attr(
533		target_os = "windows",
534		ignore = "explicit live-DXVA GPU probe; VideoProcessorBlt can hang on affected drivers"
535	)]
536	#[tokio::test]
537	async fn live_multi_rung_hardware() {
538		if !hardware_available() {
539			eprintln!("skipping: no hardware decoder + encoder available");
540			return;
541		}
542		// Real time on purpose, unlike most timed tests here. The rungs encode on
543		// their own threads (`encode::Sink`), so a rung waiting on one looks idle
544		// to tokio and `pause()` auto-advances the source's sleep while the encode
545		// is still in flight. The source then outruns the feed's bounded broadcast
546		// and every rung sees `Lagged` instead of its frames. Real sleeps pace the
547		// source against the encoders the way a live source does.
548		let (source, producer_task) = source_broadcast_live(3, 5);
549		// 180p and 120p: NVENC rejects tiny frames (80x60 is below its minimum
550		// encode resolution), so the hardware ladder stays a bit larger than the
551		// software test's.
552		let config = Config {
553			ladder: Ladder::new([
554				Rung::new(180, moq_net::bandwidth::Rate::from_bps(200_000)),
555				Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000)),
556			])
557			.unwrap(),
558			encoder: moq_video::encode::Kind::Hardware,
559			decoder: moq_video::decode::Kind::Hardware,
560			source: None,
561			..Default::default()
562		};
563
564		let output = moq_net::broadcast::Info::default().produce();
565		let consumer = output.consume();
566		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
567
568		let mut subscribers = Vec::new();
569		for name in ["video/180p", "video/120p"] {
570			let track = loop {
571				match consumer.track(name) {
572					Ok(track) => break track,
573					Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
574					Err(err) => panic!("rung track {name}: {err}"),
575				}
576			};
577			subscribers.push((name, track.subscribe(None).await.unwrap().ordered()));
578		}
579
580		for (name, subscriber) in &mut subscribers {
581			let mut group = subscriber.next_group().await.unwrap().unwrap();
582			let payload = group.read_frame().await.unwrap().unwrap();
583			let frame = hang::container::Frame::decode(payload.payload).unwrap();
584			assert!(
585				frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
586				"{name} output is not Annex-B"
587			);
588			while group.read_frame().await.unwrap().is_some() {}
589			assert_eq!(group.frame_count(), 5, "{name} dropped frames");
590		}
591
592		producer_task.abort();
593		transcoder.abort();
594	}
595
596	/// Whether a hardware decoder AND encoder are usable here (e.g. a Linux box
597	/// with the NVIDIA driver). Probed through the public API so the hardware
598	/// test skips cleanly on GPU-less CI.
599	fn hardware_available() -> bool {
600		let mut encode = moq_video::encode::Config::new(160, 120, moq_video::Rate::new(30, 1).unwrap());
601		encode.kind = moq_video::encode::Kind::Hardware;
602		if moq_video::encode::Encoder::new(&encode).is_err() {
603			return false;
604		}
605
606		let video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
607			inline: true,
608			profile: 0x42,
609			constraints: 0,
610			level: 30,
611		});
612		let mut decode = moq_video::decode::Config::new();
613		decode.kind = moq_video::decode::Kind::Hardware;
614		moq_video::decode::Decoder::new(&video, &decode).is_ok()
615	}
616
617	#[cfg(feature = "vaapi")]
618	fn vaapi_decoder_available() -> bool {
619		let video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
620			inline: true,
621			profile: 0x42,
622			constraints: 0,
623			level: 30,
624		});
625		let mut decode = moq_video::decode::Config::new();
626		decode.kind = moq_video::decode::Kind::Named("vaapi".to_string());
627		moq_video::decode::Decoder::new(&video, &decode).is_ok()
628	}
629
630	/// A fetched group drains VAAPI before finishing, so its buffered tail is
631	/// encoded into that group rather than dropped with the decoder.
632	#[cfg(feature = "vaapi")]
633	#[tokio::test]
634	async fn vaapi_fetch_keeps_the_buffered_tail() {
635		let source = source_broadcast(1, 5);
636		let config = Config {
637			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
638			encoder: moq_video::encode::Kind::Software,
639			decoder: moq_video::decode::Kind::Named("vaapi".to_string()),
640			source: None,
641			..Default::default()
642		};
643
644		if !vaapi_decoder_available() {
645			eprintln!("skipping: no VA-API H.264 decoder");
646			return;
647		}
648		let output = moq_net::broadcast::Info::default().produce();
649		let consumer = output.consume();
650		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
651
652		let track = loop {
653			match consumer.track("video/120p") {
654				Ok(track) => break track,
655				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
656				Err(err) => panic!("rung track: {err}"),
657			}
658		};
659		let mut fetched = track.fetch_group(0, None).await.unwrap();
660		while fetched.read_frame().await.unwrap().is_some() {}
661		assert_eq!(
662			fetched.frame_count(),
663			5,
664			"VAAPI dropped the fetched group's buffered tail"
665		);
666
667		transcoder.abort();
668	}
669
670	/// A live group drains VAAPI before its end marker, so its buffered tail does
671	/// not move past the boundary into the next group.
672	#[cfg(feature = "vaapi")]
673	#[tokio::test]
674	async fn vaapi_live_keeps_the_buffered_tail_in_its_group() {
675		if !vaapi_decoder_available() {
676			eprintln!("skipping: no VA-API H.264 decoder");
677			return;
678		}
679
680		let (source, producer_task) = source_broadcast_live(1, 5);
681		let config = Config {
682			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
683			encoder: moq_video::encode::Kind::Software,
684			decoder: moq_video::decode::Kind::Named("vaapi".to_string()),
685			source: None,
686			..Default::default()
687		};
688
689		let output = moq_net::broadcast::Info::default().produce();
690		let consumer = output.consume();
691		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
692
693		let track = loop {
694			match consumer.track("video/120p") {
695				Ok(track) => break track,
696				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
697				Err(err) => panic!("rung track: {err}"),
698			}
699		};
700		let mut subscriber = track.subscribe(None).await.unwrap();
701		let mut group = subscriber.recv_group().await.unwrap().unwrap();
702		while group.read_frame().await.unwrap().is_some() {}
703		assert_eq!(
704			group.frame_count(),
705			5,
706			"VAAPI moved the live group's buffered tail past its end"
707		);
708
709		producer_task.abort();
710		transcoder.abort();
711	}
712
713	/// The GPU pipeline end to end: hardware decode (NVDEC, scaling in the
714	/// decoder) into hardware encode (NVENC, consuming the CUDA frame in place).
715	/// Skips on machines without both; on a Linux + NVIDIA box this is the
716	/// zero-copy transcode path under the real broadcast plumbing.
717	#[cfg_attr(
718		target_os = "windows",
719		ignore = "explicit live-DXVA GPU probe; VideoProcessorBlt can hang on affected drivers"
720	)]
721	#[tokio::test]
722	async fn end_to_end_hardware() {
723		if !hardware_available() {
724			eprintln!("skipping: no hardware decoder + encoder available");
725			return;
726		}
727
728		let source = source_broadcast(2, 5);
729		let config = Config {
730			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
731			encoder: moq_video::encode::Kind::Hardware,
732			decoder: moq_video::decode::Kind::Hardware,
733			source: None,
734			..Default::default()
735		};
736
737		let output = moq_net::broadcast::Info::default().produce();
738		let consumer = output.consume();
739		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
740
741		// Fetch a specific group: runs a one-shot pipeline to completion, so all
742		// 5 source frames must come through the GPU path.
743		let track = loop {
744			match consumer.track("video/120p") {
745				Ok(track) => break track,
746				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
747				Err(err) => panic!("rung track: {err}"),
748			}
749		};
750		let mut fetched = track.fetch_group(0, None).await.unwrap();
751		let payload = fetched.read_frame().await.unwrap().unwrap();
752		let frame = hang::container::Frame::decode(payload.payload).unwrap();
753		assert!(
754			frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
755			"hardware rung output is not Annex-B"
756		);
757		while fetched.read_frame().await.unwrap().is_some() {}
758		assert_eq!(fetched.frame_count(), 5, "hardware transcode dropped frames");
759
760		transcoder.abort();
761	}
762
763	#[tokio::test]
764	async fn end_to_end() {
765		let source = source_broadcast(2, 5);
766
767		let config = Config {
768			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
769			encoder: moq_video::encode::Kind::Software,
770			decoder: moq_video::decode::Kind::Software,
771			source: Some(moq_net::path::RelativeOwned::from(".".to_string())),
772			..Default::default()
773		};
774
775		// The passthrough reference (`..`) resolves against the output broadcast's path, so
776		// the output must be minted through an origin: a standalone producer has no path, and
777		// `..` from it would escape, failing the catalog read below.
778		let origin = moq_tokio::origin::spawn();
779		let output = origin.create_broadcast("room/transcode").unwrap();
780		let consumer = output.consume();
781		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
782
783		// The derivative catalog appears before anything is encoded, with the
784		// rung sized against the source and the passthrough reference. Yield
785		// until the spawned transcoder has run its synchronous prologue (the
786		// catalog tracks and dynamic handler register before its first await).
787		let track = loop {
788			match consumer.track(hang::Catalog::DEFAULT_NAME) {
789				Ok(track) => break track,
790				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
791				Err(err) => panic!("catalog track: {err}"),
792			}
793		};
794		let track = track.subscribe(None).await.unwrap();
795		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track);
796		// The catalog track exists from the start but may open empty; the rung
797		// appears once the transcoder has read the source catalog.
798		let derived = loop {
799			let snapshot = catalogs.next().await.unwrap().unwrap();
800			if snapshot.video.renditions.contains_key("video/120p") {
801				break snapshot;
802			}
803		};
804
805		let rung = derived.video.renditions.get("video/120p").expect("rung missing");
806		assert_eq!(rung.coded_width, Some(160));
807		assert_eq!(rung.coded_height, Some(120));
808		assert_eq!(rung.bitrate, Some(100_000));
809		assert!(rung.codec.to_string().starts_with("avc3."));
810
811		let passthrough = derived.video.renditions.get("video").expect("passthrough missing");
812		assert_eq!(passthrough.broadcast.as_ref().map(|b| b.as_ref()), Some("."));
813
814		// Subscribing to the rung starts the live loop, which mirrors source
815		// group sequences 1:1.
816		let mut subscriber = consumer
817			.track("video/120p")
818			.unwrap()
819			.subscribe(None)
820			.await
821			.unwrap()
822			.ordered();
823		let mut group = subscriber.next_group().await.unwrap().unwrap();
824		assert!(group.sequence <= 1, "unexpected sequence {}", group.sequence);
825		let payload = group.read_frame().await.unwrap().unwrap();
826		let frame = hang::container::Frame::decode(payload.payload).unwrap();
827		assert!(
828			frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
829			"rung output is not Annex-B"
830		);
831
832		// Fetching a specific past group transcodes source group 0 on demand.
833		let mut fetched = consumer
834			.track("video/120p")
835			.unwrap()
836			.fetch_group(0, None)
837			.await
838			.unwrap();
839		let mut timestamps = Vec::new();
840		let mut first_payload = None;
841		while let Some(payload) = fetched.read_frame().await.unwrap() {
842			let frame = hang::container::Frame::decode(payload.payload).unwrap();
843			assert!(!frame.payload.is_empty());
844			timestamps.push(frame.timestamp.as_micros());
845			first_payload = first_payload.or(Some(frame.payload));
846		}
847
848		// The group has to open on an IDR, or a subscriber starting here decodes
849		// nothing: the rung asks its encoder for one at every group boundary. An
850		// Annex-B start code alone doesn't prove it, since a delta frame has one too,
851		// so check the NAL types: SPS (7) and PPS (8) inline ahead of an IDR (5),
852		// which is what avc3 promises.
853		let types = nal_types(&first_payload.expect("the group had no frames"));
854		assert!(types.contains(&7), "group does not open with an SPS: {types:?}");
855		assert!(types.contains(&8), "group does not open with a PPS: {types:?}");
856		assert!(types.contains(&5), "group does not open with an IDR: {types:?}");
857		// Each output frame keeps the presentation time of the source frame it was
858		// transcoded from, including the tail the encoder drains at the end of the
859		// group. Collapsing them onto one instant would stall playback here.
860		assert_eq!(timestamps, (0..5).map(|i| i * 33_333).collect::<Vec<u128>>());
861		// The fetched group is complete: the source group had 5 frames, and a
862		// finished transcode carries them all through.
863		let total = fetched.finished().await.unwrap();
864		assert_eq!(total, 5);
865
866		transcoder.abort();
867	}
868
869	/// The whole point of [`active`]: a caller metering or pricing the work is
870	/// handed the ladder, sees each rendition start and stop, and can bill the
871	/// seconds in between. Nothing else distinguishes a transcoder publishing a
872	/// catalog from one saturating a GPU.
873	#[tokio::test]
874	async fn reports_active_rungs() {
875		let source = source_broadcast(2, 5);
876
877		let config = Config {
878			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
879			encoder: moq_video::encode::Kind::Software,
880			decoder: moq_video::decode::Kind::Software,
881			source: None,
882			..Default::default()
883		};
884
885		let output = moq_net::broadcast::Info::default().produce();
886		let consumer = output.consume();
887		let transcoder = Transcoder::new(source.broadcast.consume(), output, config).unwrap();
888		let mut active = transcoder.active();
889		let driver = tokio::spawn(transcoder.run());
890
891		// The ladder arrives once resolved, before anyone has asked for a rung.
892		let update = active.next().await.unwrap();
893		let rendition = update.rendition;
894		assert_eq!(rendition.name(), "video/120p");
895		assert_eq!(rendition.size().height, 120);
896		assert_eq!(rendition.bitrate(), moq_net::bandwidth::Rate::from_bps(100_000));
897		assert!(!update.encoding, "encoding before anyone asked");
898		assert_eq!(rendition.frames(), 0);
899
900		let mut subscriber = consumer
901			.track("video/120p")
902			.unwrap()
903			.subscribe(None)
904			.await
905			.unwrap()
906			.ordered();
907		let update = active.next().await.unwrap();
908		assert_eq!(update.rendition.name(), "video/120p");
909		assert!(update.encoding);
910
911		// Real frames, so the counters are counting encoding rather than intent.
912		let mut group = subscriber.next_group().await.unwrap().unwrap();
913		group.read_frame().await.unwrap().unwrap();
914		assert!(rendition.frames() > 0);
915		assert!(rendition.bytes() > 0);
916
917		// Demand gone: the rung stops encoding and the cursor reports the edge.
918		drop(group);
919		drop(subscriber);
920		let update = active.next().await.unwrap();
921		assert_eq!(update.rendition.name(), "video/120p");
922		assert!(!update.encoding);
923
924		// The rendition is idle, but the totals survive for the final bill.
925		assert!(rendition.frames() > 0);
926		assert!(rendition.bytes() > 0);
927
928		driver.abort();
929	}
930
931	/// A source that resizes mid-stream takes the ladder with it.
932	///
933	/// `moq_video::encode::publish_capture` opens its source twice by design (once
934	/// to probe the mode, once when the first subscriber arrives), and a window
935	/// A source that changes aspect ratio keeps every rung height while moving
936	/// every rung width, so a rung retires and its replacement serves the same
937	/// height. That replacement must not reuse the retired track's name: a clean
938	/// end is terminal, and a relay keeps the finished logical track (only an
939	/// *aborted* one is dropped and requested again), so a subscriber asking for
940	/// the old name would get its EOF forever and never reach the transcoder.
941	#[tokio::test]
942	async fn a_resized_rung_takes_a_fresh_name() {
943		let mut source = source_catalog(640, 360);
944
945		let config = Config {
946			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
947			encoder: moq_video::encode::Kind::Software,
948			decoder: moq_video::decode::Kind::Software,
949			source: None,
950			..Default::default()
951		};
952
953		let output = moq_net::broadcast::Info::default().produce();
954		let consumer = output.consume();
955		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
956
957		let track = loop {
958			match consumer.track(hang::Catalog::DEFAULT_NAME) {
959				Ok(track) => break track,
960				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
961				Err(err) => panic!("catalog track: {err}"),
962			}
963		};
964		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap());
965
966		let derived = await_catalog(&mut catalogs, |snapshot| {
967			snapshot.video.renditions.contains_key("video/120p")
968		})
969		.await;
970		assert_eq!(
971			derived.video.renditions.get("video/120p").and_then(|v| v.coded_width),
972			Some(212),
973			"640x360 should give 120p a 212 wide picture"
974		);
975		let mut retired = subscribe(&consumer, "video/120p").await;
976
977		// Same height, wider pixels: 120p stays 120 tall and goes from 212 to 160.
978		source.resize(480, 360);
979
980		let derived = await_catalog(&mut catalogs, |snapshot| {
981			!snapshot.video.renditions.contains_key("video/120p")
982		})
983		.await;
984		let replacement = derived
985			.video
986			.renditions
987			.get("video/120p.2")
988			.expect("the resized rung was not republished under a fresh name");
989		assert_eq!(replacement.coded_width, Some(160));
990		assert_eq!(replacement.coded_height, Some(120));
991
992		// The retired name ends cleanly, and the replacement is a track the
993		// transcoder has never finished, so it serves.
994		let ended = tokio::time::timeout(std::time::Duration::from_secs(5), retired.recv_group())
995			.await
996			.expect("the retired rung never ended its track")
997			.expect("the retired rung aborted instead of finishing");
998		assert!(ended.is_none(), "expected a clean end, got a group");
999
1000		// The replacement is a track the transcoder has never finished, so it serves.
1001		subscribe(&consumer, "video/120p.2").await;
1002
1003		transcoder.abort();
1004	}
1005
1006	/// capture derives its geometry from the window on each open, so the picture a
1007	/// transcoder advertises a ladder for is routinely not the one it ends up
1008	/// carrying. The rungs that no longer fit have to retire, the ones that still
1009	/// do have to keep serving, and the passthrough entry has to follow.
1010	#[tokio::test]
1011	async fn ladder_follows_a_source_resize() {
1012		let mut source = source_catalog(640, 360);
1013
1014		let config = Config {
1015			// 360p is admitted at 640x360 only because its bitrate undercuts the
1016			// source's; 240p and 120p fit outright.
1017			ladder: Ladder::new([
1018				Rung::new(360, moq_net::bandwidth::Rate::from_bps(900_000)),
1019				Rung::new(240, moq_net::bandwidth::Rate::from_bps(300_000)),
1020				Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000)),
1021			])
1022			.unwrap(),
1023			encoder: moq_video::encode::Kind::Software,
1024			decoder: moq_video::decode::Kind::Software,
1025			source: Some(moq_net::path::RelativeOwned::from(".".to_string())),
1026			..Default::default()
1027		};
1028
1029		let output = moq_net::broadcast::Info::default().produce();
1030		let consumer = output.consume();
1031		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
1032
1033		let track = loop {
1034			match consumer.track(hang::Catalog::DEFAULT_NAME) {
1035				Ok(track) => break track,
1036				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
1037				Err(err) => panic!("catalog track: {err}"),
1038			}
1039		};
1040		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap());
1041
1042		let derived = await_catalog(&mut catalogs, |snapshot| {
1043			snapshot.video.renditions.contains_key("video/360p")
1044		})
1045		.await;
1046		assert!(derived.video.renditions.contains_key("video/240p"));
1047		assert!(derived.video.renditions.contains_key("video/120p"));
1048		assert_eq!(
1049			derived.video.renditions.get("video").and_then(|v| v.coded_width),
1050			Some(640),
1051			"the passthrough entry should describe the source"
1052		);
1053
1054		// Two live subscribers: one on a rung the smaller picture has no room for,
1055		// one on a rung that survives it unchanged.
1056		let mut retired = subscribe(&consumer, "video/240p").await;
1057		let mut kept = subscribe(&consumer, "video/120p").await;
1058
1059		// 320x180 keeps the source aspect ratio, so 120p stays 212x120 while 360p
1060		// and 240p are now taller than the source.
1061		source.resize(320, 180);
1062
1063		let derived = await_catalog(&mut catalogs, |snapshot| {
1064			!snapshot.video.renditions.contains_key("video/360p")
1065		})
1066		.await;
1067		assert!(
1068			!derived.video.renditions.contains_key("video/240p"),
1069			"240p outlived the resize"
1070		);
1071		let rung = derived
1072			.video
1073			.renditions
1074			.get("video/120p")
1075			.expect("120p was retired too");
1076		assert_eq!(rung.coded_width, Some(212));
1077		assert_eq!(rung.coded_height, Some(120));
1078		assert_eq!(
1079			derived.video.renditions.get("video").and_then(|v| v.coded_width),
1080			Some(320),
1081			"the passthrough entry should follow the source"
1082		);
1083
1084		// The retired rung ends its track, so a subscriber reselects the way it
1085		// would on any other rendition going away, rather than stalling or seeing
1086		// an abort it would read as a failure.
1087		let ended = tokio::time::timeout(std::time::Duration::from_secs(5), retired.recv_group())
1088			.await
1089			.expect("the retired rung never ended its track")
1090			.expect("the retired rung aborted instead of finishing");
1091		assert!(ended.is_none(), "expected a clean end, got a group");
1092
1093		// The rung the new picture still fits keeps serving: its subscriber sees
1094		// nothing at all, since the source has no media.
1095		assert!(
1096			tokio::time::timeout(std::time::Duration::from_millis(100), kept.recv_group())
1097				.await
1098				.is_err(),
1099			"a rung that still fits was retired anyway"
1100		);
1101
1102		transcoder.abort();
1103	}
1104
1105	/// Retiring a rung stops taking new work, but a group already being produced
1106	/// has to run to a clean end: the consumer reads it out and then sees the
1107	/// track finish, rather than the group going away under it.
1108	///
1109	/// A rung consumer is demand on its own, so `live` starts the moment the
1110	/// track is taken below and produces group 0 from the still-open source
1111	/// group. The fetch below then resolves straight from the track cache. What
1112	/// that pins down is `live` riding out the group it is part way through after
1113	/// retirement, plus `serve` joining its two halves rather than letting either
1114	/// cancel the other. [`retirement_waits_for_an_unclaimed_fetch`] covers the
1115	/// other half of the boundary.
1116	#[tokio::test]
1117	async fn retirement_rides_out_an_open_live_group() {
1118		let mut source = source_catalog(320, 240);
1119		let mut group = source._track.create_group(0u64.into()).unwrap();
1120		write_keyframe(&mut group);
1121
1122		let config = Config {
1123			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
1124			encoder: moq_video::encode::Kind::Software,
1125			decoder: moq_video::decode::Kind::Software,
1126			source: None,
1127			..Default::default()
1128		};
1129		let output = moq_net::broadcast::Info::default().produce();
1130		let consumer = output.consume();
1131		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
1132
1133		let catalog = loop {
1134			match consumer.track(hang::Catalog::DEFAULT_NAME) {
1135				Ok(track) => break track,
1136				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
1137				Err(err) => panic!("catalog track: {err}"),
1138			}
1139		};
1140		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(catalog.subscribe(None).await.unwrap());
1141		await_catalog(&mut catalogs, |snapshot| {
1142			snapshot.video.renditions.contains_key("video/120p")
1143		})
1144		.await;
1145
1146		// Resolving the info waits for the transcoder to accept the track. Then
1147		// wait until the live path has claimed group 0, so the fetch below can only
1148		// resolve from the track cache and retirement finds that live group open.
1149		let rung = consumer.track("video/120p").unwrap();
1150		rung.query().await.unwrap();
1151		while rung.latest() != Some(0) {
1152			tokio::task::yield_now().await;
1153		}
1154		let mut fetched = rung.fetch_group(0, None).await.unwrap();
1155
1156		// Group 0 is still being written from a source group that is still open, so
1157		// retiring now has to leave it running until that source group ends.
1158		source.resize(160, 90);
1159		tokio::time::timeout(
1160			std::time::Duration::from_secs(5),
1161			await_catalog(&mut catalogs, |snapshot| {
1162				!snapshot.video.renditions.contains_key("video/120p")
1163			}),
1164		)
1165		.await
1166		.expect("the ladder never retired the rung");
1167		group.finish().unwrap();
1168
1169		let finished = tokio::time::timeout(std::time::Duration::from_secs(5), async {
1170			while fetched.read_frame().await?.is_some() {}
1171			fetched.finished().await
1172		})
1173		.await
1174		.expect("the accepted fetch never finished");
1175		assert!(finished.is_ok(), "retirement aborted the accepted group: {finished:?}");
1176
1177		transcoder.abort();
1178	}
1179
1180	/// The other half of the retirement boundary: a fetch that is still opening
1181	/// its decoder has claimed no output group, so retirement must not declare a
1182	/// final sequence until it has. Finishing at retirement instead computes the
1183	/// boundary from the groups produced so far (none) and refuses the very fetch
1184	/// the handler drained its loop to keep.
1185	///
1186	/// Reaching the fetch handler takes a group the live path cannot produce.
1187	/// Holding the consumer needed to fetch is itself the demand that starts the
1188	/// live path, and the live path serves the same sequences from the same
1189	/// source, so the source publishes no group at all and serves this one
1190	/// through a [`moq_net::track::Dynamic`] instead. That handle also parks the
1191	/// fetch at exactly the point in question: past the rung's handler, before
1192	/// its `GroupRequest::accept`.
1193	#[tokio::test]
1194	async fn retirement_waits_for_an_unclaimed_fetch() {
1195		let mut source = source_catalog(320, 240);
1196		// The source track carries no live groups; this serves them on demand, so
1197		// the test decides when the rung's fetch gets past its source read.
1198		let source_fetches = source._track.dynamic();
1199
1200		let config = Config {
1201			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
1202			encoder: moq_video::encode::Kind::Software,
1203			decoder: moq_video::decode::Kind::Software,
1204			source: None,
1205			..Default::default()
1206		};
1207		let output = moq_net::broadcast::Info::default().produce();
1208		let consumer = output.consume();
1209		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
1210
1211		let catalog = loop {
1212			match consumer.track(hang::Catalog::DEFAULT_NAME) {
1213				Ok(track) => break track,
1214				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
1215				Err(err) => panic!("catalog track: {err}"),
1216			}
1217		};
1218		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(catalog.subscribe(None).await.unwrap());
1219		await_catalog(&mut catalogs, |snapshot| {
1220			snapshot.video.renditions.contains_key("video/120p")
1221		})
1222		.await;
1223
1224		// Resolving the info waits for the transcoder to accept the track, so the
1225		// fetch below reaches a rung that is already serving.
1226		let rung = consumer.track("video/120p").unwrap();
1227		rung.query().await.unwrap();
1228		assert!(
1229			source._track.subscription_changed().await.unwrap().is_some(),
1230			"the rung never subscribed to the live source",
1231		);
1232
1233		// Queued synchronously, so the rung's handler can pop it without this task
1234		// polling the fetch. Sequence 7 is one the live path never reaches.
1235		let fetching = rung.fetch_group(7, None);
1236
1237		// The rung's fetch task is now inside its source read, which is upstream of
1238		// both its decoder and the `GroupRequest::accept` that claims output group 7.
1239		let request = source_fetches.requested_group().await.expect("the source track closed");
1240		assert_eq!(request.sequence(), 7);
1241
1242		// Retire the rung with the fetch parked there, and let the retirement land
1243		// before the source group exists.
1244		source.resize(160, 90);
1245		await_catalog(&mut catalogs, |snapshot| {
1246			!snapshot.video.renditions.contains_key("video/120p")
1247		})
1248		.await;
1249		assert!(
1250			source._track.subscription_changed().await.unwrap().is_none(),
1251			"the rung kept its live source subscription after retirement",
1252		);
1253
1254		// Release the fetch: it opens its decoder and only now claims output group
1255		// 7, which retirement had to leave writable.
1256		let mut group = request.accept(None).unwrap();
1257		write_keyframe(&mut group);
1258		group.finish().unwrap();
1259
1260		let mut fetched = fetching
1261			.await
1262			.expect("retirement finished the track before the fetch claimed its group");
1263		let frames = async {
1264			while fetched.read_frame().await?.is_some() {}
1265			fetched.finished().await
1266		}
1267		.await
1268		.expect("retirement aborted the accepted group");
1269		assert!(frames > 0, "the fetch claimed its group but produced no frames");
1270
1271		transcoder.abort();
1272	}
1273
1274	/// A source whose codec description changes rebuilds the shared decode, so
1275	/// every rung retires with it. The picture may not have moved at all, so shape
1276	/// alone would hand the replacements the names that just ended. They have to be
1277	/// fresh names for the same reason a resized rung's is.
1278	#[tokio::test]
1279	async fn a_rebuilt_decode_renames_every_rung() {
1280		let mut source = source_catalog(320, 240);
1281
1282		let config = Config {
1283			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
1284			encoder: moq_video::encode::Kind::Software,
1285			decoder: moq_video::decode::Kind::Software,
1286			source: None,
1287			..Default::default()
1288		};
1289
1290		let output = moq_net::broadcast::Info::default().produce();
1291		let consumer = output.consume();
1292		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
1293
1294		let track = loop {
1295			match consumer.track(hang::Catalog::DEFAULT_NAME) {
1296				Ok(track) => break track,
1297				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
1298				Err(err) => panic!("catalog track: {err}"),
1299			}
1300		};
1301		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap());
1302		await_catalog(&mut catalogs, |snapshot| {
1303			snapshot.video.renditions.contains_key("video/120p")
1304		})
1305		.await;
1306		let mut retired = subscribe(&consumer, "video/120p").await;
1307
1308		// Same picture, new out-of-band parameter sets: the rungs still resolve to
1309		// 160x120, but their decoder is rebuilt so none of them survives.
1310		source.describe(Some(bytes::Bytes::from_static(&[0x01, 0x42, 0x00, 0x1e])));
1311
1312		let derived = tokio::time::timeout(
1313			std::time::Duration::from_secs(5),
1314			await_catalog(&mut catalogs, |snapshot| {
1315				snapshot.video.renditions.contains_key("video/120p.2")
1316			}),
1317		)
1318		.await
1319		.expect("the rebuilt decode kept the retired rung name");
1320		assert!(
1321			!derived.video.renditions.contains_key("video/120p"),
1322			"the retired name is still advertised"
1323		);
1324		assert_eq!(
1325			derived.video.renditions.get("video/120p.2").and_then(|v| v.coded_width),
1326			Some(160),
1327			"the replacement should serve the same picture under a new name"
1328		);
1329
1330		let ended = tokio::time::timeout(std::time::Duration::from_secs(5), retired.recv_group())
1331			.await
1332			.expect("the retired rung never ended its track")
1333			.expect("the retired rung aborted instead of finishing");
1334		assert!(ended.is_none(), "expected a clean end, got a group");
1335		subscribe(&consumer, "video/120p.2").await;
1336
1337		transcoder.abort();
1338	}
1339
1340	/// A source broadcast whose catalog offers a 1080p H.265 rendition next to a
1341	/// 360p H.264 one, the way a simulcasting publisher would. The tracks exist
1342	/// but carry no media, since no rung encodes until someone asks.
1343	fn mixed_codec_source() -> (
1344		moq_net::broadcast::Producer,
1345		moq_mux::catalog::Producer,
1346		[moq_net::track::Producer; 2],
1347	) {
1348		let mut broadcast = moq_net::broadcast::Info::default().produce();
1349		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1350
1351		let mut hevc = hang::catalog::VideoConfig::new(hang::catalog::H265 {
1352			in_band: true,
1353			profile_space: 0,
1354			profile_idc: 1,
1355			profile_compatibility_flags: [0x60, 0, 0, 0],
1356			tier_flag: false,
1357			level_idc: 120,
1358			constraint_flags: [0x90, 0, 0, 0, 0, 0],
1359		});
1360		hevc.coded_width = Some(1920);
1361		hevc.coded_height = Some(1080);
1362		hevc.bitrate = Some(6_000_000);
1363
1364		let mut avc = hang::catalog::VideoConfig::new(hang::catalog::H264 {
1365			inline: true,
1366			profile: 0x42,
1367			constraints: 0,
1368			level: 30,
1369		});
1370		avc.coded_width = Some(640);
1371		avc.coded_height = Some(360);
1372		avc.bitrate = Some(1_000_000);
1373
1374		let mut guard = catalog.modify().unwrap();
1375		guard.video.insert("hevc", hevc).unwrap();
1376		guard.video.insert("avc", avc).unwrap();
1377		guard.commit().unwrap();
1378
1379		let info = hang::container::track_info(hang::catalog::PRIORITY.video);
1380		let tracks = [
1381			broadcast.create_track("hevc", info.clone()).unwrap(),
1382			broadcast.create_track("avc", info).unwrap(),
1383		];
1384		(broadcast, catalog, tracks)
1385	}
1386
1387	/// A larger rendition this host can't decode must not win the source. With
1388	/// only the software decoder, the 1080p H.265 entry would be chosen, sized a
1389	/// ladder the H.264 one can't serve, and failed the moment a rung was asked for.
1390	#[tokio::test]
1391	async fn an_undecodable_larger_rendition_is_not_the_source() {
1392		let (broadcast, _catalog, _tracks) = mixed_codec_source();
1393
1394		let config = Config {
1395			ladder: Ladder::new([
1396				Rung::new(720, moq_net::bandwidth::Rate::from_bps(2_500_000)),
1397				Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000)),
1398			])
1399			.unwrap(),
1400			encoder: moq_video::encode::Kind::Software,
1401			decoder: moq_video::decode::Kind::Software,
1402			source: None,
1403			..Default::default()
1404		};
1405
1406		let output = moq_net::broadcast::Info::default().produce();
1407		let consumer = output.consume();
1408		let transcoder = tokio::spawn(run(broadcast.consume(), output, config));
1409
1410		let track = loop {
1411			match consumer.track(hang::Catalog::DEFAULT_NAME) {
1412				Ok(track) => break track,
1413				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
1414				Err(err) => panic!("catalog track: {err}"),
1415			}
1416		};
1417		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap());
1418		let derived = await_catalog(&mut catalogs, |snapshot| !snapshot.video.renditions.is_empty()).await;
1419
1420		// Sized against the 640x360 H.264 rendition: no room for 720p, and 120p is
1421		// 212 wide either way.
1422		let names: Vec<_> = derived.video.renditions.keys().map(String::as_str).collect();
1423		assert_eq!(
1424			names,
1425			["video/120p"],
1426			"the ladder was sized against the H.265 rendition"
1427		);
1428
1429		transcoder.abort();
1430	}
1431
1432	/// A decoder forced by name that this build doesn't have leaves nothing to
1433	/// transcode from, and `run` has to say so rather than wait forever.
1434	#[tokio::test]
1435	async fn a_missing_decoder_is_refused() {
1436		let (broadcast, _catalog, _tracks) = mixed_codec_source();
1437
1438		let config = Config {
1439			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
1440			encoder: moq_video::encode::Kind::Software,
1441			decoder: moq_video::decode::Kind::Named("missing".to_string()),
1442			source: None,
1443			..Default::default()
1444		};
1445
1446		let output = moq_net::broadcast::Info::default().produce();
1447		let result = tokio::time::timeout(
1448			std::time::Duration::from_secs(5),
1449			run(broadcast.consume(), output, config),
1450		)
1451		.await
1452		.expect("run kept waiting for a source it can never decode");
1453
1454		match result {
1455			// Why the tallest rendition's decoder refused.
1456			Err(Error::Video(moq_video::Error::UnknownDecoder { name, codec, .. })) => {
1457				assert_eq!(name, "missing");
1458				assert_eq!(codec, moq_video::decode::Codec::H265);
1459			}
1460			other => panic!("expected the decoder's refusal, got {other:?}"),
1461		}
1462	}
1463
1464	/// `run` must terminate (not hang in its shutdown drain) when the source
1465	/// broadcast goes away, even with a rung task that was never subscribed.
1466	#[tokio::test]
1467	async fn shuts_down_on_source_end() {
1468		let source = source_broadcast(1, 3);
1469
1470		let config = Config {
1471			ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(),
1472			encoder: moq_video::encode::Kind::Software,
1473			decoder: moq_video::decode::Kind::Software,
1474			source: None,
1475			..Default::default()
1476		};
1477
1478		let output = moq_net::broadcast::Info::default().produce();
1479		let consumer = output.consume();
1480		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
1481
1482		// Wait until the derivative catalog is up, so the transcoder is past
1483		// startup and into its serve loop.
1484		let track = loop {
1485			match consumer.track(hang::Catalog::DEFAULT_NAME) {
1486				Ok(track) => break track,
1487				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
1488				Err(err) => panic!("catalog track: {err}"),
1489			}
1490		};
1491		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap());
1492		catalogs.next().await.unwrap().unwrap();
1493
1494		// Drop the source: the catalog track ends and the broadcast closes, so
1495		// `run` should observe the end and return rather than block in the drain.
1496		drop(source);
1497
1498		let result = tokio::time::timeout(std::time::Duration::from_secs(5), transcoder).await;
1499		result.expect("run did not shut down within 5s").unwrap().unwrap();
1500	}
1501}