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