Skip to main content

moq_video/encode/
producer.rs

1//! Publish encoded video frames as a moq video track, with optional capture.
2//!
3//! Encoding is strictly on demand: the track and its catalog rendition are
4//! advertised immediately (the rendition is probed from the encoder, since
5//! nothing has been encoded yet), and the encoder itself only runs while a
6//! subscriber is watching. Capture opens its camera once at startup to learn
7//! the mode it negotiates, then keeps it closed between viewers. This mirrors
8//! `moq-boy`, which pauses its emulator on `track::Producer::used()` /
9//! `unused()`.
10
11#[cfg(feature = "capture")]
12use std::time::Instant;
13
14use moq_mux::catalog::hang::CatalogExt;
15#[cfg(any(feature = "capture", test))]
16use moq_net::Timestamp;
17
18use crate::Error;
19#[cfg(any(feature = "capture", test))]
20use crate::Frame;
21#[cfg(feature = "capture")]
22use crate::capture;
23
24use super::Encoded;
25#[cfg(feature = "capture")]
26use super::Sink;
27#[cfg(any(feature = "capture", test))]
28use super::encoder;
29#[cfg(feature = "capture")]
30use super::encoder::Codec;
31#[cfg(feature = "capture")]
32use super::rate::{Control, Policy};
33
34/// Last-resort framerate when neither the caller nor the camera reports one.
35#[cfg(feature = "capture")]
36const DEFAULT_FRAMERATE: u32 = 30;
37
38/// The rendition as the importer wants it: what to publish now, and what to keep filling in on
39/// every config it later resolves from the bitstream.
40///
41/// A probed rendition is what the first keyframe carries, so the importer's own config matches it
42/// field for field and the catalog is published once rather than corrected. The fields the
43/// bitstream can't reveal (bitrate always, framerate outside an optional VUI) are the ones this
44/// overlay keeps supplying.
45fn rendition_hint(rendition: hang::catalog::VideoConfig) -> moq_mux::catalog::VideoHint {
46	let mut hint = moq_mux::catalog::VideoHint::default();
47	hint.codec = Some(rendition.codec);
48	hint.coded_width = rendition.coded_width;
49	hint.coded_height = rendition.coded_height;
50	hint.display_aspect_width = rendition.display_aspect_width;
51	hint.display_aspect_height = rendition.display_aspect_height;
52	hint.framerate = rendition.framerate;
53	hint.bitrate = rendition.bitrate;
54	hint.optimize_for_latency = rendition.optimize_for_latency;
55	hint
56}
57
58/// Per-codec splitter + importer pair. Each codec frames its packets and resolves
59/// its catalog rendition differently, so the producer holds one of these.
60enum Codecs<E: CatalogExt> {
61	H264 {
62		split: moq_mux::codec::h264::Split,
63		import: moq_mux::codec::h264::Import<E>,
64	},
65	H265 {
66		split: moq_mux::codec::h265::Split,
67		import: moq_mux::codec::h265::Import<E>,
68	},
69}
70
71/// Publishes encoded video frames as a moq track (avc3 / hev1 depending on the
72/// codec).
73///
74/// Built on the async side so the track is advertised (and the catalog
75/// registered) before the camera opens; this is what lets a subscriber
76/// trigger capture on demand. The `moq_mux::codec` importer for the codec
77/// handles catalog registration and framing.
78/// `E` is the catalog's application extension, defaulting to none. A host
79/// carrying its own catalog sections (the FFI bindings use `hang::Extra`)
80/// publishes into a catalog of the same shape.
81pub struct Producer<E: CatalogExt = ()> {
82	codecs: Codecs<E>,
83}
84
85impl<E: CatalogExt> Producer<E> {
86	/// Publish a track carrying `rendition` into `broadcast`, registering it in
87	/// `catalog`. The frames fed to [`publish`](Self::publish) must be in that
88	/// codec's framing, which is what the [`Encoder`](super::Encoder) the
89	/// rendition was probed from emits.
90	///
91	/// `rendition` comes from [`Config::probe`](super::Config::probe), so it is
92	/// what the encoder will actually emit rather than a guess. It is published
93	/// immediately, before anything is encoded, which is what lets a subscriber
94	/// discover a track an on-demand encoder has not run for yet; because it
95	/// already says what the first keyframe says, that keyframe confirms the
96	/// catalog instead of correcting it.
97	pub fn new(
98		mut broadcast: moq_net::broadcast::Producer,
99		catalog: moq_mux::catalog::Producer<E>,
100		rendition: hang::catalog::VideoConfig,
101	) -> Result<Self, Error> {
102		let suffix = match &rendition.codec {
103			hang::catalog::VideoCodec::H264(_) => ".avc3",
104			hang::catalog::VideoCodec::H265(_) => ".hev1",
105			other => {
106				return Err(Error::Codec(anyhow::anyhow!(
107					"{other} is not a codec this producer can publish"
108				)));
109			}
110		};
111		let track = broadcast.unique_track(suffix, catalog.track_info())?;
112		Self::with_track(track, catalog, rendition)
113	}
114
115	/// Publish `rendition` on an existing track, registering it in `catalog`.
116	///
117	/// Use this when the caller owns the track name. [`new`](Self::new) derives a
118	/// unique name from the codec instead.
119	pub fn with_track(
120		track: moq_net::track::Producer,
121		catalog: moq_mux::catalog::Producer<E>,
122		rendition: hang::catalog::VideoConfig,
123	) -> Result<Self, Error> {
124		let codecs = match &rendition.codec {
125			hang::catalog::VideoCodec::H264(_) => Codecs::H264 {
126				split: moq_mux::codec::h264::Split::new(),
127				import: moq_mux::codec::h264::Import::new(track, catalog.reserve(), rendition_hint(rendition))?,
128			},
129			hang::catalog::VideoCodec::H265(_) => Codecs::H265 {
130				split: moq_mux::codec::h265::Split::new(),
131				import: moq_mux::codec::h265::Import::new(track, catalog.reserve(), rendition_hint(rendition))?,
132			},
133			// Unreachable via `Config::probe`, which only encodes what `Codec` covers.
134			other => {
135				return Err(Error::Codec(anyhow::anyhow!(
136					"{other} is not a codec this producer can publish"
137				)));
138			}
139		};
140		Ok(Self { codecs })
141	}
142
143	/// A watch-only handle to the track's subscriber demand, created eagerly so
144	/// subscription state is observable before any frames arrive. Watch it via
145	/// [`used`](moq_net::track::Demand::used) / [`unused`](moq_net::track::Demand::unused).
146	pub fn demand(&self) -> moq_net::track::Demand {
147		match &self.codecs {
148			Codecs::H264 { import, .. } => import.demand(),
149			Codecs::H265 { import, .. } => import.demand(),
150		}
151	}
152
153	/// Publish already-encoded frames, each at its own timestamp. Each frame is one
154	/// whole access unit in the producer's codec framing.
155	pub fn publish(&mut self, encoded: &[Encoded]) -> Result<(), Error> {
156		for frame in encoded {
157			let timestamp = Some(frame.timestamp);
158			// The encoder emits one whole access unit per frame, so flush to emit it.
159			match &mut self.codecs {
160				Codecs::H264 { split, import } => {
161					let mut frames = split.decode(&frame.payload, timestamp)?;
162					frames.extend(split.flush(timestamp)?);
163					import.decode(frames)?;
164				}
165				Codecs::H265 { split, import } => {
166					let mut frames = split.decode(&frame.payload, timestamp)?;
167					frames.extend(split.flush(timestamp)?);
168					import.decode(frames)?;
169				}
170			}
171		}
172		Ok(())
173	}
174
175	/// Mark a break in the published timeline: whatever is published next does not continue
176	/// what came before.
177	///
178	/// Call this when the encoder stops rather than merely pausing between frames -- a
179	/// capture that goes idle, a source switch, anything that will resume on a re-anchored
180	/// clock. See [`Producer::discontinuity`](moq_mux::container::Producer::discontinuity)
181	/// for what the marker buys a consumer.
182	pub fn discontinuity(&mut self) -> Result<(), Error> {
183		match &mut self.codecs {
184			Codecs::H264 { import, .. } => import.discontinuity()?,
185			Codecs::H265 { import, .. } => import.discontinuity()?,
186		}
187		Ok(())
188	}
189
190	/// Finalize the track.
191	///
192	/// Consumes the producer: nothing can be published after the track ends, so
193	/// this is the last call rather than one leaving a dead producer in your hands.
194	pub fn finish(mut self) -> Result<(), Error> {
195		match &mut self.codecs {
196			Codecs::H264 { import, .. } => import.finish()?,
197			Codecs::H265 { import, .. } => import.finish()?,
198		}
199		Ok(())
200	}
201
202	/// Abort the track with `err` instead of finishing it cleanly, so subscribers
203	/// see the real cause rather than [`moq_net::Error::Dropped`].
204	///
205	/// Consumes the producer, like [`finish`](Self::finish).
206	pub fn abort(self, err: moq_net::Error) {
207		match self.codecs {
208			Codecs::H264 { import, .. } => import.abort(err),
209			Codecs::H265 { import, .. } => import.abort(err),
210		}
211	}
212}
213
214/// Source-agnostic encode knobs for [`publish_capture`], where the geometry
215/// (width / height / framerate) comes from the capture source, not the caller.
216/// For the bring-your-own-frames [`Encoder`](super::Encoder) path, where you
217/// must specify geometry, use [`Config`](super::Config) instead.
218///
219/// `#[non_exhaustive]`: construct via [`Options::default`] and set fields, so
220/// new knobs can be added without breaking callers.
221#[derive(Clone, Default)]
222#[non_exhaustive]
223#[cfg(feature = "capture")]
224pub struct Options {
225	/// Target bitrate in bits per second; `None` derives from resolution.
226	///
227	/// This is a ceiling, not a fixed rate: with [`bandwidth`](Self::bandwidth)
228	/// set, the encoder backs off below it while the uplink is congested and
229	/// climbs back afterwards, but never exceeds it.
230	pub bitrate: Option<u64>,
231	/// Output codec. Defaults to [`Codec::H264`].
232	pub codec: Codec,
233	/// Encoder implementation preference.
234	pub kind: encoder::Kind,
235	/// The connection's send-bandwidth estimate, from
236	/// [`Session::send_bandwidth`](moq_net::Session::send_bandwidth) (or
237	/// `moq_native::Reconnect::send_bandwidth`, which survives reconnects).
238	///
239	/// Set it and the encoder tracks the estimate per the default
240	/// [`rate::Policy`](super::rate::Policy), so a closing uplink gets a softer
241	/// picture instead of a stalled one. Leave it `None` and the
242	/// encoder holds [`bitrate`](Self::bitrate) regardless of congestion, which
243	/// is what you want when the estimate isn't meaningful (a local file, a test
244	/// harness) or unavailable (a publisher that only accepts inbound sessions).
245	pub bandwidth: Option<moq_net::bandwidth::Consumer>,
246}
247
248// Hand-written: `bandwidth::Consumer` isn't `Debug`, but its presence is the
249// only part worth printing anyway.
250#[cfg(feature = "capture")]
251impl std::fmt::Debug for Options {
252	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253		f.debug_struct("Options")
254			.field("bitrate", &self.bitrate)
255			.field("codec", &self.codec)
256			.field("kind", &self.kind)
257			.field("bandwidth", &self.bandwidth.is_some())
258			.finish()
259	}
260}
261
262/// Capture a webcam and publish it as an on-demand video track.
263///
264/// Returns when the broadcast is dropped (the track stops being announced)
265/// or the capture loop fails. Frames are stamped from `clock`, so passing the
266/// same [`Clock`](moq_mux::Clock) to a concurrent audio publish keeps the two
267/// tracks aligned.
268///
269/// The camera is opened once at startup to probe the mode it negotiates, then released until a
270/// subscriber arrives and reopened for as long as one is watching. That one open is what lets the
271/// catalog rendition be exact before a single frame is published, so a consumer can size itself
272/// against it (and discover the track at all) without waiting for an encoder that may never run.
273#[cfg(feature = "capture")]
274pub async fn publish_capture<E: CatalogExt>(
275	broadcast: moq_net::broadcast::Producer,
276	catalog: moq_mux::catalog::Producer<E>,
277	capture: capture::Config,
278	encode: Options,
279	clock: moq_mux::Clock,
280) -> Result<(), Error> {
281	// A caller asking for exactly zero is an error; omitting it (None) is
282	// fine and resolves to the camera's reported rate once it's open.
283	if capture.framerate == Some(0) {
284		return Err(Error::InvalidFramerate(0));
285	}
286
287	// Open the camera once to find out what it actually negotiated, since a requested size is only a
288	// hint (macOS ignores it outright) and the encoder is built from the mode, not the request. It
289	// closes again immediately: this costs one camera open at startup and buys a rendition that says
290	// exactly what the stream will carry, rather than one every consumer has to treat as provisional.
291	let rendition = {
292		let camera = capture::open(&capture).await?;
293		let mut probe_config = encoder::Config::new(
294			camera.width(),
295			camera.height(),
296			capture
297				.framerate
298				.or_else(|| camera.framerate())
299				.unwrap_or(DEFAULT_FRAMERATE),
300		);
301		probe_config.bitrate = encode.bitrate;
302		probe_config.codec = encode.codec;
303		probe_config.kind = encode.kind.clone();
304		probe_config.color = camera.color();
305		probe_config.probe().await?
306	};
307
308	let mut producer = Producer::new(broadcast, catalog, rendition)?;
309	let demand = producer.demand();
310
311	let result = capture_loop(&mut producer, &demand, &capture, &encode, &clock).await;
312
313	// This runs only when the loop ends on its own (the track is usually already
314	// going away by then); a Ctrl+C cancels the future before this point, since
315	// async `Drop` can't finalize the track.
316	match &result {
317		// Clean end (the track was dropped): best-effort finish.
318		Ok(()) => {
319			if let Err(err) = producer.finish() {
320				tracing::debug!(error = %err, "video track finish after capture ended");
321			}
322		}
323		// The capture loop failed: abort with the real cause so subscribers see it.
324		Err(err) => producer.abort(moq_net::Error::Transport(err.to_string())),
325	}
326	result
327}
328
329/// Off macOS, [`publish_capture`]'s future must stay `Send` so a server can
330/// `tokio::spawn` it: the encoder runs on its own thread and the capture guard
331/// is `Send` there. This is never called; it exists only to fail compilation if
332/// the future ever regains a `!Send` component. macOS is exempt (the objc
333/// capture session is `!Send`).
334#[cfg(all(feature = "capture", not(target_os = "macos")))]
335#[allow(dead_code)]
336fn assert_publish_capture_send(
337	broadcast: moq_net::broadcast::Producer,
338	catalog: moq_mux::catalog::Producer,
339	capture: capture::Config,
340	encode: Options,
341	clock: moq_mux::Clock,
342) {
343	fn is_send<T: Send>(_: &T) {}
344	is_send(&publish_capture(broadcast, catalog, capture, encode, clock));
345}
346
347/// The live rate control state: the estimate source paired with the policy
348/// tracking it. `None` once there's nothing left to track, which is what stops
349/// the `select!` arm from spinning on a channel that is permanently ready.
350#[cfg(feature = "capture")]
351type Rate = Option<(moq_net::bandwidth::Consumer, Control)>;
352
353/// Wait for the next bandwidth estimate, or forever when rate control is off or
354/// finished. Cancel-safe: [`Consumer::changed`](moq_net::bandwidth::Consumer::changed)
355/// only reads shared state, so losing this race to a frame drops no estimate,
356/// it just re-reads the latest one next time round.
357#[cfg(feature = "capture")]
358async fn next_estimate(rate: &mut Rate) -> Option<Option<u64>> {
359	match rate {
360		Some((bandwidth, _)) => bandwidth.changed().await.ok(),
361		// No estimate source: park this arm forever so `select!` ignores it.
362		None => std::future::pending().await,
363	}
364}
365
366/// Feed an estimate through the policy and retune the encoder if it moved.
367///
368/// `None` means the producer is gone (the session ended for good), so rate
369/// control retires; a `Some(None)` estimate means the value is merely
370/// unavailable right now, which the policy holds through.
371#[cfg(feature = "capture")]
372async fn apply_estimate(encoder: &mut Sink, rate: &mut Rate, estimate: Option<Option<u64>>) {
373	let Some((_, control)) = rate.as_mut() else { return };
374
375	let Some(estimate) = estimate else {
376		tracing::debug!("bandwidth estimate ended; holding the current encoder bitrate");
377		*rate = None;
378		return;
379	};
380
381	let Some(bitrate) = control.update(estimate, Instant::now()) else {
382		return;
383	};
384
385	match encoder.set_bitrate(bitrate).await {
386		Ok(()) => tracing::debug!(bitrate, estimate, "adjusted encoder bitrate"),
387		// The encoder can't retune, so keep encoding at the rate it opened with
388		// and stop asking. Dropping the source also stops the estimate arm, which
389		// would otherwise wake this loop for nothing on every change.
390		Err(Error::BitrateUnsupported(name)) => {
391			tracing::warn!(encoder = name, "encoder cannot follow the bandwidth estimate");
392			*rate = None;
393		}
394		// A transient failure: keep the policy running so the next change retries.
395		// The policy already moved its target, so a persistent failure just means
396		// the encoder trails it; that's better than giving up on the first blip.
397		Err(err) => tracing::warn!(error = %err, bitrate, "failed to adjust encoder bitrate"),
398	}
399}
400
401/// A dropped or closed track is the normal end of a publish; any other cause is
402/// a real abort (e.g. a transport reset) worth surfacing rather than treating as
403/// a clean exit.
404#[cfg(feature = "capture")]
405fn log_track_ended(err: moq_net::Error) {
406	if matches!(err, moq_net::Error::Dropped | moq_net::Error::Closed) {
407		tracing::debug!("video track no longer announced; stopping capture");
408	} else {
409		tracing::warn!(error = %err, "video track aborted; stopping capture");
410	}
411}
412
413/// Async capture/encode loop. Opens the camera while at least one viewer is
414/// watching and releases it when the last one leaves.
415///
416/// Cancel safety: every wait here is a real `.await` (a frame read, a demand
417/// transition, or an encode), so dropping this future (e.g. on Ctrl+C) drops
418/// `camera` and `encoder`, which release the device (LED off) and join the
419/// encode thread. Both the capture and encode threads sit idle between frames,
420/// so their joins return promptly unless the underlying device or encoder is
421/// itself wedged.
422#[cfg(feature = "capture")]
423async fn capture_loop<E: CatalogExt>(
424	producer: &mut Producer<E>,
425	demand: &moq_net::track::Demand,
426	capture: &capture::Config,
427	encode: &Options,
428	clock: &moq_mux::Clock,
429) -> Result<(), Error> {
430	loop {
431		// Idle until a viewer subscribes; the track ending is a clean exit. The
432		// catalog rendition was published when the track was created, so a
433		// subscriber can get here without a frame ever having been encoded.
434		if let Err(err) = demand.used().await {
435			log_track_ended(err);
436			return Ok(());
437		}
438
439		// Open the camera and an encoder sized to its negotiated mode.
440		let mut camera = capture::open(capture).await?;
441		// Prefer an explicit --fps, otherwise the camera's reported rate, falling
442		// back only if the backend doesn't expose one.
443		let framerate = capture
444			.framerate
445			.or_else(|| camera.framerate())
446			.unwrap_or(DEFAULT_FRAMERATE);
447		let mut encoder_config = encoder::Config::new(camera.width(), camera.height(), framerate);
448		encoder_config.bitrate = encode.bitrate;
449		encoder_config.codec = encode.codec;
450		encoder_config.kind = encode.kind.clone();
451		encoder_config.color = camera.color();
452		// Off macOS this opens the encoder on a dedicated thread; see `sink`.
453		let mut encoder = Sink::open(&encoder_config).await?;
454		// Force an IDR on the first frame of each (re)open so a viewer subscribing
455		// after an idle gap can start decoding immediately.
456		let mut force_keyframe = true;
457		tracing::info!(encoder = encoder.name(), device = camera.device(), "capturing");
458
459		// Rate control is per encoder: this one opened at the configured bitrate,
460		// so the policy's ceiling is that rate and the target starts there. A
461		// reopened camera starts optimistic again rather than inheriting the
462		// backed-off rate from whatever the link was doing last time.
463		let mut rate = encode
464			.bandwidth
465			.clone()
466			.map(|bandwidth| (bandwidth, Control::new(Policy::new(encoder_config.resolved_bitrate()))));
467
468		loop {
469			// Race the next frame against the last viewer leaving so we release the
470			// camera promptly when demand drops. `biased` checks demand first so an
471			// unwatched track stops before reading another frame.
472			let frame = tokio::select! {
473				biased;
474				res = demand.unused() => {
475					if let Err(err) = res {
476						log_track_ended(err);
477						return Ok(());
478					}
479					break; // no viewers: release the camera, then wait for one
480				}
481				// Retune between frames rather than mid-encode, and only when
482				// the policy says the target actually moved.
483				estimate = next_estimate(&mut rate) => {
484					apply_estimate(&mut encoder, &mut rate, estimate).await;
485					continue;
486				}
487				frame = camera.read() => frame,
488			};
489
490			let Some(surface) = frame else { break }; // device stopped producing frames
491
492			// Stamp at capture, so a backend that buffers still publishes each
493			// access unit at the time the picture was grabbed.
494			let frame = Frame::new(surface, Timestamp::from_micros(clock.micros())?);
495			if force_keyframe {
496				encoder.keyframe();
497				force_keyframe = false;
498			}
499			producer.publish(&encoder.encode(frame).await?)?;
500		}
501
502		// Drop the camera (LED off) and encoder before waiting for the next viewer.
503		drop(camera);
504		tracing::info!("no viewers: released camera");
505	}
506}
507
508#[cfg(test)]
509mod tests {
510	use moq_mux::catalog::Stream as _;
511
512	use super::*;
513	use crate::encode::{Config, Encoder};
514
515	/// Encode a handful of synthetic frames for `codec` and publish them through a real
516	/// [`Producer`], returning the catalog rendition's track name and config.
517	///
518	/// Asserts the property the whole design rests on: the rendition published before anything is
519	/// encoded is the one the first keyframe resolves. A guessed codec string would be corrected
520	/// here; a probed one is confirmed, so the catalog is written once.
521	///
522	/// `kind` is explicit so the test picks a deterministic encoder rather than `Auto`, which on
523	/// Linux CI would try the NVENC backend and panic in cudarc on a GPU-less runner.
524	async fn roundtrip_rendition(codec: Codec, kind: encoder::Kind) -> (String, hang::catalog::VideoConfig) {
525		let mut broadcast = moq_net::broadcast::Info::new().produce();
526		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
527
528		let mut config = Config::new(320, 240, 30);
529		config.codec = codec;
530		config.kind = kind;
531
532		let mut producer = Producer::new(broadcast, catalog.clone(), config.probe().await.unwrap()).unwrap();
533		let advertised = rendition(&catalog).expect("the rendition publishes before any frame").1;
534
535		let mut encoder = Encoder::new(&config).unwrap();
536		assert_eq!(encoder.codec(), codec);
537
538		let rgba = vec![0x80u8; 320 * 240 * 4];
539		for i in 0..10u64 {
540			let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
541			let frame = Frame::new(surface, Timestamp::from_micros(i * 33_333).unwrap());
542			producer.publish(&encoder.encode(&frame).unwrap()).unwrap();
543		}
544		producer.publish(&encoder.finish().unwrap()).unwrap();
545
546		let (name, resolved) = rendition(&catalog).expect("the importer should have registered a video rendition");
547		// Jitter aside, which is measured from the frames rather than declared by either.
548		let (mut before, mut after) = (advertised, resolved.clone());
549		before.jitter = None;
550		after.jitter = None;
551		assert_eq!(
552			before, after,
553			"the first keyframe should confirm the advertised rendition, not correct it"
554		);
555		(name, resolved)
556	}
557
558	/// The catalog's single video rendition, if it has one yet.
559	fn rendition(catalog: &moq_mux::catalog::Producer) -> Option<(String, hang::catalog::VideoConfig)> {
560		let snapshot = catalog.snapshot();
561		let (name, config) = snapshot.video.renditions.iter().next()?;
562		Some((name.clone(), config.clone()))
563	}
564
565	/// Regression: the rendition has to reach the wire before anything is encoded.
566	///
567	/// A catalog reservation is held until the rendition resolves, and an unresolved one withholds
568	/// the whole catalog from the broadcast. An encoder that runs only while watched then closes a
569	/// cycle: the catalog waits on a keyframe, the keyframe waits on a subscriber, and the
570	/// subscriber waits on the catalog. Nothing errors on either side; the publisher simply serves
571	/// nothing, forever.
572	#[tokio::test]
573	async fn the_rendition_reaches_the_wire_before_the_first_frame() {
574		let mut broadcast = moq_net::broadcast::Info::new().produce();
575		let consumer = broadcast.consume();
576		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
577
578		let mut config = Config::new(1920, 1080, 30);
579		config.bitrate = Some(6_000_000);
580		// Software (openh264) so the test is deterministic and never touches a hardware backend.
581		config.kind = encoder::Kind::Software;
582		let _producer = Producer::new(broadcast, catalog, config.probe().await.unwrap()).unwrap();
583
584		// Published, not merely staged: this reads the catalog track a subscriber would.
585		let mut stream = moq_mux::catalog::Consumer::<()>::new(&consumer, moq_mux::catalog::CatalogFormat::Hang)
586			.await
587			.unwrap();
588		let snapshot = stream.next().await.unwrap().expect("a catalog before any frame");
589
590		let (name, rendition) = snapshot
591			.video
592			.renditions
593			.iter()
594			.next()
595			.expect("the track must be discoverable before it has encoded anything");
596		assert!(name.ends_with(".avc3"));
597
598		// Read out of the encoder rather than guessed: the avc3 shape (parameter sets in band) and
599		// the geometry it was opened at, which is what its first keyframe will carry.
600		let hang::catalog::VideoCodec::H264(h264) = &rendition.codec else {
601			panic!("expected H.264, got {}", rendition.codec)
602		};
603		assert!(h264.inline, "an avc3 track carries its parameter sets in band");
604		assert_eq!(rendition.coded_width, Some(1920));
605		assert_eq!(rendition.coded_height, Some(1080));
606		// Neither is in the bitstream, so both come from the config that was probed.
607		assert_eq!(rendition.framerate, Some(30.0));
608		assert_eq!(rendition.bitrate, Some(6_000_000));
609	}
610
611	#[tokio::test]
612	async fn h264_roundtrip_publishes_avc3() {
613		// Software (openh264) so the test is deterministic and never touches a
614		// hardware backend.
615		let (name, config) = roundtrip_rendition(Codec::H264, encoder::Kind::Software).await;
616		assert!(name.ends_with(".avc3"));
617		assert_eq!(config.coded_width, Some(320));
618		assert_eq!(config.coded_height, Some(240));
619	}
620
621	/// H.265 has no software encoder, so this only runs where a hardware one
622	/// exists (VideoToolbox on macOS, the only hardware backend on this target).
623	#[cfg(target_os = "macos")]
624	#[tokio::test]
625	async fn h265_roundtrip_publishes_hev1() {
626		let (name, config) = roundtrip_rendition(Codec::H265, encoder::Kind::Hardware).await;
627		assert!(name.ends_with(".hev1"));
628		assert_eq!(config.coded_width, Some(320));
629		assert_eq!(config.coded_height, Some(240));
630	}
631}