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