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