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