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