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