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