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/// Async capture/encode loop. Opens the camera while at least one viewer is
417/// watching and releases it when the last one leaves.
418///
419/// Cancel safety: every wait here is a real `.await` (a frame read, a demand
420/// transition, or an encode), so dropping this future (e.g. on Ctrl+C) drops
421/// `camera` and `encoder`, which release the device (LED off) and join the
422/// encode thread. Both the capture and encode threads sit idle between frames,
423/// so their joins return promptly unless the underlying device or encoder is
424/// itself wedged.
425#[cfg(feature = "capture")]
426async fn capture_loop<E: CatalogExt>(
427 producer: &mut Producer<E>,
428 demand: &moq_net::track::Demand,
429 capture: &capture::Config,
430 encode: &Options,
431 clock: &moq_mux::Clock,
432) -> Result<(), Error> {
433 loop {
434 // Idle until a viewer subscribes; the track ending is a clean exit. The
435 // catalog rendition was published when the track was created, so a
436 // subscriber can get here without a frame ever having been encoded.
437 if let Err(err) = demand.used().await {
438 log_track_ended(err);
439 return Ok(());
440 }
441
442 // Open the camera and an encoder sized to its negotiated mode.
443 let mut camera = capture::open(capture).await?;
444 // Prefer an explicit --fps, otherwise the camera's reported rate, falling
445 // back only if the backend doesn't expose one.
446 let framerate = capture
447 .framerate
448 .or_else(|| camera.framerate())
449 .unwrap_or(DEFAULT_FRAMERATE);
450 let mut encoder_config = encoder::Config::new(camera.width(), camera.height(), framerate);
451 encoder_config.bitrate = encode.bitrate;
452 encoder_config.codec = encode.codec;
453 encoder_config.kind = encode.kind.clone();
454 encoder_config.color = camera.color();
455 // Off macOS this opens the encoder on a dedicated thread; see `sink`.
456 let mut encoder = Sink::open(&encoder_config).await?;
457 // Force an IDR on the first frame of each (re)open so a viewer subscribing
458 // after an idle gap can start decoding immediately.
459 let mut force_keyframe = true;
460 tracing::info!(encoder = encoder.name(), device = camera.device(), "capturing");
461
462 // Rate control is per encoder: this one opened at the configured bitrate,
463 // so the policy's ceiling is that rate and the target starts there. A
464 // reopened camera starts optimistic again rather than inheriting the
465 // backed-off rate from whatever the link was doing last time.
466 let mut rate = encode
467 .bandwidth
468 .clone()
469 .map(|bandwidth| (bandwidth, Control::new(Policy::new(encoder_config.resolved_bitrate()))));
470
471 loop {
472 // Race the next frame against the last viewer leaving so we release the
473 // camera promptly when demand drops. `biased` checks demand first so an
474 // unwatched track stops before reading another frame.
475 let frame = tokio::select! {
476 biased;
477 res = demand.unused() => {
478 if let Err(err) = res {
479 log_track_ended(err);
480 return Ok(());
481 }
482 break; // no viewers: release the camera, then wait for one
483 }
484 // Retune between frames rather than mid-encode, and only when
485 // the policy says the target actually moved.
486 estimate = next_estimate(&mut rate) => {
487 apply_estimate(&mut encoder, &mut rate, estimate).await;
488 continue;
489 }
490 frame = camera.read() => frame,
491 };
492
493 let Some(surface) = frame else { break }; // device stopped producing frames
494
495 // Stamp at capture, so a backend that buffers still publishes each
496 // access unit at the time the picture was grabbed.
497 let frame = Frame::new(surface, Timestamp::from_micros(clock.micros())?);
498 if force_keyframe {
499 encoder.keyframe();
500 force_keyframe = false;
501 }
502 producer.publish(&encoder.encode(frame).await?)?;
503 }
504
505 // Drop the camera (LED off) and encoder before waiting for the next viewer.
506 drop(camera);
507 tracing::info!("no viewers: released camera");
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use moq_mux::catalog::Stream as _;
514
515 use super::*;
516 use crate::encode::{Config, Encoder};
517
518 /// Encode a handful of synthetic frames for `codec` and publish them through a real
519 /// [`Producer`], returning the catalog rendition's track name and config.
520 ///
521 /// Asserts the property the whole design rests on: the rendition published before anything is
522 /// encoded is the one the first keyframe resolves. A guessed codec string would be corrected
523 /// here; a probed one is confirmed, so the catalog is written once.
524 ///
525 /// `kind` is explicit so the test picks a deterministic encoder rather than `Auto`, which on
526 /// Linux CI would try the NVENC backend and panic in cudarc on a GPU-less runner.
527 async fn roundtrip_rendition(codec: Codec, kind: encoder::Kind) -> (String, hang::catalog::VideoConfig) {
528 let mut broadcast = moq_net::broadcast::Info::new().produce();
529 let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
530
531 let mut config = Config::new(320, 240, 30);
532 config.codec = codec;
533 config.kind = kind;
534
535 let mut producer = Producer::new(broadcast, catalog.clone(), config.probe().await.unwrap()).unwrap();
536 let advertised = rendition(&catalog).expect("the rendition publishes before any frame").1;
537
538 let mut encoder = Encoder::new(&config).unwrap();
539 assert_eq!(encoder.codec(), codec);
540
541 let rgba = vec![0x80u8; 320 * 240 * 4];
542 for i in 0..10u64 {
543 let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
544 let frame = Frame::new(surface, Timestamp::from_micros(i * 33_333).unwrap());
545 producer.publish(&encoder.encode(&frame).unwrap()).unwrap();
546 }
547 producer.publish(&encoder.finish().unwrap()).unwrap();
548
549 let (name, resolved) = rendition(&catalog).expect("the importer should have registered a video rendition");
550 // Jitter aside, which is measured from the frames rather than declared by either.
551 let (mut before, mut after) = (advertised, resolved.clone());
552 before.jitter = None;
553 after.jitter = None;
554 assert_eq!(
555 before, after,
556 "the first keyframe should confirm the advertised rendition, not correct it"
557 );
558 (name, resolved)
559 }
560
561 /// The catalog's single video rendition, if it has one yet.
562 fn rendition(catalog: &moq_mux::catalog::Producer) -> Option<(String, hang::catalog::VideoConfig)> {
563 let snapshot = catalog.snapshot();
564 let (name, config) = snapshot.video.renditions.iter().next()?;
565 Some((name.clone(), config.clone()))
566 }
567
568 /// Regression: a caller's container selection has to survive the config -> hint conversion.
569 ///
570 /// [`VideoHint::container`](moq_mux::catalog::VideoHint::container) is authoritative for both the
571 /// track writer and the published rendition, so a conversion that drops it silently downgrades
572 /// the caller's selection to Legacy while the catalog still claims whatever it defaulted to.
573 #[tokio::test]
574 async fn a_selected_container_survives_the_rendition_hint() {
575 let mut broadcast = moq_net::broadcast::Info::new().produce();
576 let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
577
578 let mut config = Config::new(320, 240, 30);
579 // Software (openh264) so the test is deterministic and never touches a hardware backend.
580 config.kind = encoder::Kind::Software;
581 let mut selected = config.probe().await.unwrap();
582 selected.container = hang::catalog::Container::Loc;
583
584 let _producer = Producer::new(broadcast, catalog.clone(), selected).unwrap();
585
586 let (_, published) = rendition(&catalog).expect("the rendition publishes before any frame");
587 assert_eq!(published.container, hang::catalog::Container::Loc);
588 }
589
590 /// Regression: the rendition has to reach the wire before anything is encoded.
591 ///
592 /// A catalog reservation is held until the rendition resolves, and an unresolved one withholds
593 /// the whole catalog from the broadcast. An encoder that runs only while watched then closes a
594 /// cycle: the catalog waits on a keyframe, the keyframe waits on a subscriber, and the
595 /// subscriber waits on the catalog. Nothing errors on either side; the publisher simply serves
596 /// nothing, forever.
597 #[tokio::test]
598 async fn the_rendition_reaches_the_wire_before_the_first_frame() {
599 let mut broadcast = moq_net::broadcast::Info::new().produce();
600 let consumer = broadcast.consume();
601 let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
602
603 let mut config = Config::new(1920, 1080, 30);
604 config.bitrate = Some(6_000_000);
605 // Software (openh264) so the test is deterministic and never touches a hardware backend.
606 config.kind = encoder::Kind::Software;
607 let _producer = Producer::new(broadcast, catalog, config.probe().await.unwrap()).unwrap();
608
609 // Published, not merely staged: this reads the catalog track a subscriber would.
610 let mut stream = moq_mux::catalog::Consumer::<()>::new(&consumer, moq_mux::catalog::CatalogFormat::Hang)
611 .await
612 .unwrap();
613 let snapshot = stream.next().await.unwrap().expect("a catalog before any frame");
614
615 let (name, rendition) = snapshot
616 .video
617 .renditions
618 .iter()
619 .next()
620 .expect("the track must be discoverable before it has encoded anything");
621 assert!(name.ends_with(".avc3"));
622
623 // Read out of the encoder rather than guessed: the avc3 shape (parameter sets in band) and
624 // the geometry it was opened at, which is what its first keyframe will carry.
625 let hang::catalog::VideoCodec::H264(h264) = &rendition.codec else {
626 panic!("expected H.264, got {}", rendition.codec)
627 };
628 assert!(h264.inline, "an avc3 track carries its parameter sets in band");
629 assert_eq!(rendition.coded_width, Some(1920));
630 assert_eq!(rendition.coded_height, Some(1080));
631 // Neither is in the bitstream, so both come from the config that was probed.
632 assert_eq!(rendition.framerate, Some(30.0));
633 assert_eq!(rendition.bitrate, Some(6_000_000));
634 }
635
636 #[tokio::test]
637 async fn h264_roundtrip_publishes_avc3() {
638 // Software (openh264) so the test is deterministic and never touches a
639 // hardware backend.
640 let (name, config) = roundtrip_rendition(Codec::H264, encoder::Kind::Software).await;
641 assert!(name.ends_with(".avc3"));
642 assert_eq!(config.coded_width, Some(320));
643 assert_eq!(config.coded_height, Some(240));
644 }
645
646 /// H.265 has no software encoder, so this only runs where a hardware one
647 /// exists (VideoToolbox on macOS, the only hardware backend on this target).
648 #[cfg(target_os = "macos")]
649 #[tokio::test]
650 async fn h265_roundtrip_publishes_hev1() {
651 let (name, config) = roundtrip_rendition(Codec::H265, encoder::Kind::Hardware).await;
652 assert!(name.ends_with(".hev1"));
653 assert_eq!(config.coded_width, Some(320));
654 assert_eq!(config.coded_height, Some(240));
655 }
656}