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