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
24mod catalog;
25mod config;
26mod error;
27mod feed;
28mod rung;
29
30pub use config::{Config, Rung};
31
32#[allow(deprecated)]
33pub use config::source_reference;
34pub use error::Error;
35
36/// Transcode `source` into `output` until the source broadcast ends.
37///
38/// Reads the source catalog, publishes the derivative catalog (rungs strictly
39/// below the source, plus source renditions referenced via [`Config::source`]),
40/// and serves each rung just-in-time: a rung track only materializes when a
41/// consumer asks for it, and only encodes while consumed. Where `output` is
42/// announced (and how its path relates to the source) is the caller's business.
43///
44/// The catalog tracks and the on-demand rung handler are registered
45/// synchronously, before the first `await`, so a consumer may race the rest of
46/// the setup safely: call `run` before announcing `output`.
47pub async fn run(
48	source: moq_net::broadcast::Consumer,
49	mut output: moq_net::broadcast::Producer,
50	config: Config,
51) -> Result<(), Error> {
52	// The catalog starts empty and fills in below, exactly like a media
53	// importer that hasn't seen parameter sets yet.
54	let mut derived = moq_mux::catalog::Producer::new(&mut output)?;
55	// Consumers asking for a rung before (or after) it exists queue here.
56	let mut dynamic = output.dynamic();
57
58	// The source catalog drives everything; wait for a snapshot with a usable
59	// video rendition (the first may precede the source publishing its video).
60	let track = source
61		.track(hang::Catalog::DEFAULT_NAME)?
62		.subscribe(hang::Catalog::default_subscription())
63		.await?;
64	let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track);
65	let (source_name, source_config, snapshot) = loop {
66		let Some(snapshot) = catalogs.next().await? else {
67			return Err(Error::NoSource);
68		};
69		match catalog::choose_source(&snapshot.video) {
70			Ok((name, config)) => break (name, config, snapshot),
71			Err(_) => tracing::debug!("no transcodable rendition yet; waiting for a catalog update"),
72		}
73	};
74	let rungs = catalog::resolve_rungs(&config.rungs, &source_name, &source_config)?;
75	tracing::info!(source = %source_name, rungs = rungs.len(), "transcoding");
76
77	// One shared live decode for every rung of this source: N active rungs
78	// share one subscription and one decoder instead of N.
79	let feed = feed::Feed::new(
80		source.track(&source_name)?,
81		source_config.clone(),
82		config.decoder.clone(),
83	);
84
85	// Publish the derivative catalog before any encoder exists, so subscribers
86	// can pick a rung immediately.
87	let mut entries = Vec::with_capacity(rungs.len());
88	for rung in &rungs {
89		let entry = catalog::rung_entry(rung, &source_config, &config.encoder).await?;
90		entries.push((rung.name.clone(), entry));
91	}
92	{
93		let mut guard = derived.lock();
94		catalog::populate(&mut guard, &snapshot, &entries, config.source.as_ref())?;
95	}
96
97	// Serve rung requests and follow source catalog updates until the source
98	// ends. The rung set is fixed at startup: a source that changes resolution
99	// mid-stream keeps the ladder it started with, but the passthrough entries
100	// track the source.
101	let mut tasks = tokio::task::JoinSet::new();
102	loop {
103		tokio::select! {
104			request = dynamic.requested_track() => {
105				// Err means the broadcast closed; nothing left to serve.
106				let Ok(request) = request else { break };
107				match rungs.iter().find(|rung| rung.name == request.name()) {
108					Some(info) => {
109						let rung = rung::Rung {
110							source: source.track(&source_name)?,
111							feed: feed.clone(),
112							broadcast: source.clone(),
113							config: source_config.clone(),
114							encoder: config.encoder.clone(),
115							decoder: config.decoder.clone(),
116							resize: config.resize,
117							info: info.clone(),
118						};
119						tasks.spawn(rung::serve(rung, request));
120					}
121					None => request.reject(moq_net::Error::NotFound),
122				}
123			},
124			update = catalogs.next() => match update {
125				Ok(Some(snapshot)) => {
126					let mut guard = derived.lock();
127					catalog::populate(&mut guard, &snapshot, &entries, config.source.as_ref())?;
128				}
129				// The source ended (or its catalog track died): wind down.
130				Ok(None) => break,
131				Err(err) => {
132					tracing::debug!(%err, "source catalog ended");
133					break;
134				}
135			},
136			Some(result) = tasks.join_next() => match result {
137				Ok(Ok(())) => {}
138				Ok(Err(err)) => tracing::warn!(%err, "rung failed"),
139				Err(err) => tracing::warn!(%err, "rung panicked"),
140			}
141		}
142	}
143
144	// Wind the rungs down. On a clean source end they are already finishing on
145	// their own (the live path saw the source track end), so `shutdown` just
146	// joins them. But `run` also breaks on a catalog-track error while the
147	// source media and viewers are still live, and a rung task only self-ends on
148	// source-media-end or broadcast-close, not catalog-end. Aborting rather than
149	// awaiting keeps that case from hanging forever here.
150	tasks.shutdown().await;
151
152	derived.finish()?;
153	output.finish();
154	Ok(())
155}
156
157#[cfg(test)]
158mod tests {
159	use super::*;
160
161	/// A live source broadcast; the producers are kept so the tracks stay open
162	/// for the duration of the test.
163	struct Source {
164		broadcast: moq_net::broadcast::Producer,
165		_catalog: moq_mux::catalog::Producer,
166		_track: moq_net::track::Producer,
167	}
168
169	/// H.264 NAL unit types in an Annex-B buffer, found via 3-byte start codes (a
170	/// 4-byte `00 00 00 01` code contains `00 00 01` too, so this catches both).
171	fn nal_types(annexb: &[u8]) -> Vec<u8> {
172		let mut types = Vec::new();
173		let mut i = 0;
174		while i + 3 < annexb.len() {
175			if annexb[i..i + 3] == [0, 0, 1] {
176				types.push(annexb[i + 3] & 0x1f);
177				i += 3;
178			} else {
179				i += 1;
180			}
181		}
182		types
183	}
184
185	/// Wrap a gray 320x240 RGBA buffer as a raw frame at `timestamp` microseconds.
186	fn gray_frame(rgba: &[u8], timestamp: u64) -> moq_video::Frame {
187		let surface = moq_video::Surface::rgba(rgba, moq_video::Size::new(320, 240)).unwrap();
188		moq_video::Frame::new(surface, moq_net::Timestamp::from_micros(timestamp).unwrap())
189	}
190
191	/// Build a 320x240 avc3 source broadcast: a catalog plus a video track with
192	/// `groups` groups of `frames` gray frames each, encoded with openh264.
193	fn source_broadcast(groups: u64, frames: u64) -> Source {
194		let mut broadcast = moq_net::broadcast::Info::default().produce();
195		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
196
197		let mut video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
198			inline: true,
199			profile: 0x42,
200			constraints: 0,
201			level: 30,
202		});
203		video.coded_width = Some(320);
204		video.coded_height = Some(240);
205		video.bitrate = Some(1_000_000);
206		video.framerate = Some(30.0);
207		catalog.lock().video.insert("video", video).unwrap();
208
209		let info = hang::container::track_info();
210		let mut track = broadcast.create_track("video", info).unwrap();
211
212		let mut encoder = moq_video::encode::Encoder::new(&{
213			let mut config = moq_video::encode::Config::new(320, 240, 30);
214			config.kind = moq_video::encode::Kind::Software;
215			config
216		})
217		.unwrap();
218		let gray = vec![0x80u8; 320 * 240 * 4];
219
220		for sequence in 0..groups {
221			let mut group = track.create_group(sequence.into()).unwrap();
222			for index in 0..frames {
223				let timestamp = (sequence * frames + index) * 33_333;
224				if index == 0 {
225					encoder.keyframe();
226				}
227				for encoded in encoder.encode(&gray_frame(&gray, timestamp)).unwrap() {
228					let frame = hang::container::Frame {
229						timestamp: encoded.timestamp,
230						payload: encoded.payload,
231					};
232					frame.write_to(&mut group).unwrap();
233				}
234			}
235			group.finish().unwrap();
236		}
237
238		Source {
239			broadcast,
240			_catalog: catalog,
241			_track: track,
242		}
243	}
244
245	/// A source like [`source_broadcast`], but the groups arrive over (paused)
246	/// time instead of all at once, so several rungs can attach to the shared
247	/// live feed before the first group exists. Returns the broadcast plus the
248	/// producing task's handle (the track producer lives inside it).
249	fn source_broadcast_live(groups: u64, frames: u64) -> (Source, tokio::task::JoinHandle<()>) {
250		let mut broadcast = moq_net::broadcast::Info::default().produce();
251		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
252
253		let mut video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
254			inline: true,
255			profile: 0x42,
256			constraints: 0,
257			level: 30,
258		});
259		video.coded_width = Some(320);
260		video.coded_height = Some(240);
261		video.bitrate = Some(1_000_000);
262		video.framerate = Some(30.0);
263		catalog.lock().video.insert("video", video).unwrap();
264
265		let info = hang::container::track_info();
266		let mut track = broadcast.create_track("video", info).unwrap();
267
268		let source = Source {
269			broadcast,
270			_catalog: catalog,
271			// The producing task owns the real track producer; park a clone so
272			// the struct shape matches `source_broadcast`.
273			_track: track.clone(),
274		};
275
276		let task = tokio::spawn(async move {
277			let mut encoder = moq_video::encode::Encoder::new(&{
278				let mut config = moq_video::encode::Config::new(320, 240, 30);
279				config.kind = moq_video::encode::Kind::Software;
280				config
281			})
282			.unwrap();
283			let gray = vec![0x80u8; 320 * 240 * 4];
284
285			for sequence in 0..groups {
286				// Paces the source: a real sleep, since the rungs encode off the
287				// executor and cannot be sequenced by paused-time idle detection.
288				// Also the window the subscribers attach in, before group 0.
289				tokio::time::sleep(std::time::Duration::from_millis(100)).await;
290				let mut group = track.create_group(sequence.into()).unwrap();
291				for index in 0..frames {
292					let timestamp = (sequence * frames + index) * 33_333;
293					if index == 0 {
294						encoder.keyframe();
295					}
296					for encoded in encoder.encode(&gray_frame(&gray, timestamp)).unwrap() {
297						let frame = hang::container::Frame {
298							timestamp: encoded.timestamp,
299							payload: encoded.payload,
300						};
301						frame.write_to(&mut group).unwrap();
302					}
303				}
304				group.finish().unwrap();
305			}
306			// Keep the track open until aborted, like a live source.
307			std::future::pending::<()>().await;
308		});
309
310		(source, task)
311	}
312
313	/// Two rungs subscribed at once ride one shared live decode (the feed):
314	/// both must produce complete groups mirroring the source sequences.
315	#[tokio::test]
316	async fn live_multi_rung() {
317		// Real time on purpose, unlike most timed tests here. The rungs encode on
318		// their own threads (`encode::Sink`), so a rung waiting on one looks idle
319		// to tokio and `pause()` auto-advances the source's sleep while the encode
320		// is still in flight. The source then outruns the feed's bounded broadcast
321		// and every rung sees `Lagged` instead of its frames. Real sleeps pace the
322		// source against the encoders the way a live source does.
323		let (source, producer_task) = source_broadcast_live(3, 5);
324		let config = Config {
325			rungs: vec![Rung::new(120, 100_000), Rung::new(60, 50_000)],
326			encoder: moq_video::encode::Kind::Software,
327			decoder: moq_video::decode::Kind::Software,
328			source: None,
329			..Default::default()
330		};
331
332		let output = moq_net::broadcast::Info::default().produce();
333		let consumer = output.consume();
334		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
335
336		// Attach both rungs before the first source group exists (paused time:
337		// the producer's sleep only fires once every rung is parked on the feed).
338		let mut subscribers = Vec::new();
339		for name in ["video/120p", "video/60p"] {
340			let track = loop {
341				match consumer.track(name) {
342					Ok(track) => break track,
343					Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
344					Err(err) => panic!("rung track {name}: {err}"),
345				}
346			};
347			subscribers.push((name, track.subscribe(None).await.unwrap()));
348		}
349
350		// Every rung receives a complete group with all 5 source frames.
351		for (name, subscriber) in &mut subscribers {
352			let mut group = subscriber.next_group().await.unwrap().unwrap();
353			let payload = group.read_frame().await.unwrap().unwrap();
354			let frame = hang::container::Frame::decode(payload.payload).unwrap();
355			assert!(
356				frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
357				"{name} output is not Annex-B"
358			);
359			let total = group.finished().await.unwrap();
360			assert_eq!(total, 5, "{name} dropped frames");
361		}
362
363		producer_task.abort();
364		transcoder.abort();
365	}
366
367	/// The multi-rung live path on real hardware: one shared NVDEC session
368	/// decodes the source, the GPU box filter resizes per rung, and each rung's
369	/// NVENC session encodes the CUDA frame in place. Skips without a GPU.
370	#[cfg_attr(
371		target_os = "windows",
372		ignore = "explicit live-DXVA GPU probe; VideoProcessorBlt can hang on affected drivers"
373	)]
374	#[tokio::test]
375	async fn live_multi_rung_hardware() {
376		if !hardware_available() {
377			eprintln!("skipping: no hardware decoder + encoder available");
378			return;
379		}
380		// Real time on purpose, unlike most timed tests here. The rungs encode on
381		// their own threads (`encode::Sink`), so a rung waiting on one looks idle
382		// to tokio and `pause()` auto-advances the source's sleep while the encode
383		// is still in flight. The source then outruns the feed's bounded broadcast
384		// and every rung sees `Lagged` instead of its frames. Real sleeps pace the
385		// source against the encoders the way a live source does.
386		let (source, producer_task) = source_broadcast_live(3, 5);
387		// 180p and 120p: NVENC rejects tiny frames (80x60 is below its minimum
388		// encode resolution), so the hardware ladder stays a bit larger than the
389		// software test's.
390		let mut config = Config {
391			rungs: vec![Rung::new(180, 200_000), Rung::new(120, 100_000)],
392			encoder: moq_video::encode::Kind::Hardware,
393			decoder: moq_video::decode::Kind::Hardware,
394			source: None,
395			..Default::default()
396		};
397		config.resize.acceleration = moq_video::resize::Acceleration::Gpu;
398
399		let output = moq_net::broadcast::Info::default().produce();
400		let consumer = output.consume();
401		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
402
403		let mut subscribers = Vec::new();
404		for name in ["video/180p", "video/120p"] {
405			let track = loop {
406				match consumer.track(name) {
407					Ok(track) => break track,
408					Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
409					Err(err) => panic!("rung track {name}: {err}"),
410				}
411			};
412			subscribers.push((name, track.subscribe(None).await.unwrap()));
413		}
414
415		for (name, subscriber) in &mut subscribers {
416			let mut group = subscriber.next_group().await.unwrap().unwrap();
417			let payload = group.read_frame().await.unwrap().unwrap();
418			let frame = hang::container::Frame::decode(payload.payload).unwrap();
419			assert!(
420				frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
421				"{name} output is not Annex-B"
422			);
423			let total = group.finished().await.unwrap();
424			assert_eq!(total, 5, "{name} dropped frames");
425		}
426
427		producer_task.abort();
428		transcoder.abort();
429	}
430
431	/// Whether a hardware decoder AND encoder are usable here (e.g. a Linux box
432	/// with the NVIDIA driver). Probed through the public API so the hardware
433	/// test skips cleanly on GPU-less CI.
434	fn hardware_available() -> bool {
435		let mut encode = moq_video::encode::Config::new(160, 120, 30);
436		encode.kind = moq_video::encode::Kind::Hardware;
437		if moq_video::encode::Encoder::new(&encode).is_err() {
438			return false;
439		}
440
441		let video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
442			inline: true,
443			profile: 0x42,
444			constraints: 0,
445			level: 30,
446		});
447		let mut decode = moq_video::decode::Config::new();
448		decode.kind = moq_video::decode::Kind::Hardware;
449		moq_video::decode::Decoder::new(&video, &decode).is_ok()
450	}
451
452	/// The GPU pipeline end to end: hardware decode (NVDEC, scaling in the
453	/// decoder) into hardware encode (NVENC, consuming the CUDA frame in place).
454	/// Skips on machines without both; on a Linux + NVIDIA box this is the
455	/// zero-copy transcode path under the real broadcast plumbing.
456	#[cfg_attr(
457		target_os = "windows",
458		ignore = "explicit live-DXVA GPU probe; VideoProcessorBlt can hang on affected drivers"
459	)]
460	#[tokio::test]
461	async fn end_to_end_hardware() {
462		if !hardware_available() {
463			eprintln!("skipping: no hardware decoder + encoder available");
464			return;
465		}
466
467		let source = source_broadcast(2, 5);
468		let mut config = Config {
469			rungs: vec![Rung::new(120, 100_000)],
470			encoder: moq_video::encode::Kind::Hardware,
471			decoder: moq_video::decode::Kind::Hardware,
472			source: None,
473			..Default::default()
474		};
475		config.resize.acceleration = moq_video::resize::Acceleration::Gpu;
476
477		let output = moq_net::broadcast::Info::default().produce();
478		let consumer = output.consume();
479		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
480
481		// Fetch a specific group: runs a one-shot pipeline to completion, so all
482		// 5 source frames must come through the GPU path.
483		let track = loop {
484			match consumer.track("video/120p") {
485				Ok(track) => break track,
486				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
487				Err(err) => panic!("rung track: {err}"),
488			}
489		};
490		let mut fetched = track.fetch_group(0, None).await.unwrap();
491		let payload = fetched.read_frame().await.unwrap().unwrap();
492		let frame = hang::container::Frame::decode(payload.payload).unwrap();
493		assert!(
494			frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
495			"hardware rung output is not Annex-B"
496		);
497		let total = fetched.finished().await.unwrap();
498		assert_eq!(total, 5, "hardware transcode dropped frames");
499
500		transcoder.abort();
501	}
502
503	#[tokio::test]
504	async fn end_to_end() {
505		let source = source_broadcast(2, 5);
506
507		let config = Config {
508			rungs: vec![Rung::new(120, 100_000)],
509			encoder: moq_video::encode::Kind::Software,
510			decoder: moq_video::decode::Kind::Software,
511			source: Some(moq_net::PathRelativeOwned::from(".".to_string())),
512			..Default::default()
513		};
514
515		let output = moq_net::broadcast::Info::default().produce();
516		let consumer = output.consume();
517		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
518
519		// The derivative catalog appears before anything is encoded, with the
520		// rung sized against the source and the passthrough reference. Yield
521		// until the spawned transcoder has run its synchronous prologue (the
522		// catalog tracks and dynamic handler register before its first await).
523		let track = loop {
524			match consumer.track(hang::Catalog::DEFAULT_NAME) {
525				Ok(track) => break track,
526				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
527				Err(err) => panic!("catalog track: {err}"),
528			}
529		};
530		let track = track.subscribe(None).await.unwrap();
531		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track);
532		// The catalog track exists from the start but may open empty; the rung
533		// appears once the transcoder has read the source catalog.
534		let derived = loop {
535			let snapshot = catalogs.next().await.unwrap().unwrap();
536			if snapshot.video.renditions.contains_key("video/120p") {
537				break snapshot;
538			}
539		};
540
541		let rung = derived.video.renditions.get("video/120p").expect("rung missing");
542		assert_eq!(rung.coded_width, Some(160));
543		assert_eq!(rung.coded_height, Some(120));
544		assert_eq!(rung.bitrate, Some(100_000));
545		assert!(rung.codec.to_string().starts_with("avc3."));
546
547		let passthrough = derived.video.renditions.get("video").expect("passthrough missing");
548		assert_eq!(passthrough.broadcast.as_ref().map(|b| b.as_ref()), Some("."));
549
550		// Subscribing to the rung starts the live loop, which mirrors source
551		// group sequences 1:1.
552		let mut subscriber = consumer.track("video/120p").unwrap().subscribe(None).await.unwrap();
553		let mut group = subscriber.next_group().await.unwrap().unwrap();
554		assert!(group.sequence <= 1, "unexpected sequence {}", group.sequence);
555		let payload = group.read_frame().await.unwrap().unwrap();
556		let frame = hang::container::Frame::decode(payload.payload).unwrap();
557		assert!(
558			frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
559			"rung output is not Annex-B"
560		);
561
562		// Fetching a specific past group transcodes source group 0 on demand.
563		let mut fetched = consumer
564			.track("video/120p")
565			.unwrap()
566			.fetch_group(0, None)
567			.await
568			.unwrap();
569		let mut timestamps = Vec::new();
570		let mut first_payload = None;
571		while let Some(payload) = fetched.read_frame().await.unwrap() {
572			let frame = hang::container::Frame::decode(payload.payload).unwrap();
573			assert!(!frame.payload.is_empty());
574			timestamps.push(frame.timestamp.as_micros());
575			first_payload = first_payload.or(Some(frame.payload));
576		}
577
578		// The group has to open on an IDR, or a subscriber starting here decodes
579		// nothing: the rung asks its encoder for one at every group boundary. An
580		// Annex-B start code alone doesn't prove it, since a delta frame has one too,
581		// so check the NAL types: SPS (7) and PPS (8) inline ahead of an IDR (5),
582		// which is what avc3 promises.
583		let types = nal_types(&first_payload.expect("the group had no frames"));
584		assert!(types.contains(&7), "group does not open with an SPS: {types:?}");
585		assert!(types.contains(&8), "group does not open with a PPS: {types:?}");
586		assert!(types.contains(&5), "group does not open with an IDR: {types:?}");
587		// Each output frame keeps the presentation time of the source frame it was
588		// transcoded from, including the tail the encoder drains at the end of the
589		// group. Collapsing them onto one instant would stall playback here.
590		assert_eq!(timestamps, (0..5).map(|i| i * 33_333).collect::<Vec<u128>>());
591		// The fetched group is complete: the source group had 5 frames, and a
592		// finished transcode carries them all through.
593		let total = fetched.finished().await.unwrap();
594		assert_eq!(total, 5);
595
596		transcoder.abort();
597	}
598
599	/// `run` must terminate (not hang in its shutdown drain) when the source
600	/// broadcast goes away, even with a rung task that was never subscribed.
601	#[tokio::test]
602	async fn shuts_down_on_source_end() {
603		let source = source_broadcast(1, 3);
604
605		let config = Config {
606			rungs: vec![Rung::new(120, 100_000)],
607			encoder: moq_video::encode::Kind::Software,
608			decoder: moq_video::decode::Kind::Software,
609			source: None,
610			..Default::default()
611		};
612
613		let output = moq_net::broadcast::Info::default().produce();
614		let consumer = output.consume();
615		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
616
617		// Wait until the derivative catalog is up, so the transcoder is past
618		// startup and into its serve loop.
619		let track = loop {
620			match consumer.track(hang::Catalog::DEFAULT_NAME) {
621				Ok(track) => break track,
622				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
623				Err(err) => panic!("catalog track: {err}"),
624			}
625		};
626		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap());
627		catalogs.next().await.unwrap().unwrap();
628
629		// Drop the source: the catalog track ends and the broadcast closes, so
630		// `run` should observe the end and return rather than block in the drain.
631		drop(source);
632
633		let result = tokio::time::timeout(std::time::Duration::from_secs(5), transcoder).await;
634		result.expect("run did not shut down within 5s").unwrap().unwrap();
635	}
636}