moq_transcode/active.rs
1//! Which renditions are encoding, and how much each has produced.
2//!
3//! Nothing is encoded until a consumer asks for a rung, so a transcoder that is
4//! publishing a catalog and a transcoder that is saturating a GPU look identical
5//! from the outside. Broadcast demand ([`moq_net::broadcast::Demand`]) closes
6//! half the gap: it says *someone* is watching. This module closes the other
7//! half by naming *which* renditions are being produced, and counting what each
8//! one produced, which is what a caller pricing or admitting the work needs.
9//!
10//! [`Consumer`] is a cursor shaped like [`moq_net::announce::Consumer`]: it
11//! reports the ladder as it resolves, then one rendition starting or stopping at
12//! a time. Each [`Rendition`] it hands over is a lasting handle, so a caller
13//! keeps them all and reads the counters whenever it bills.
14//!
15//! The cursor cannot bill on its own. A rendition whose pipelines start and stop
16//! between two calls is never reported as an edge (the same is true of
17//! `announce`), and a group fetch is exactly that: one pipeline per group, alive
18//! for milliseconds. The counters behind the handle count it anyway, which is
19//! why the ladder is delivered up front rather than on the first edge.
20//!
21//! Frames are the unit rather than wall-clock time, because the two only agree
22//! on the live path. A group fetch encodes seconds of media in milliseconds, and
23//! a subscriber attached to a stalled source holds a pipeline open for minutes
24//! while producing nothing; counting frames is right in both. Media seconds, if
25//! that is the bill, are [`Rendition::frames`] over [`Rendition::framerate`].
26//!
27//! A rendition counts as encoding from its first encoded frame, not from the
28//! moment a consumer asked, for the same reason.
29
30use std::collections::BTreeMap;
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::task::{Poll, ready};
34
35use crate::catalog::Resolved;
36
37/// A rendition starting or stopping, delivered by [`Consumer`].
38///
39/// Also delivered once per rendition when the ladder resolves, with `encoding`
40/// false, so a caller has every handle before any encoding can be missed.
41pub struct Update {
42 /// The rendition this is about.
43 pub rendition: Rendition,
44
45 /// Whether it is encoding right now.
46 pub encoding: bool,
47}
48
49/// A handle to one output rendition, holding the counters a caller bills against.
50///
51/// Cheap to clone, and it outlives the encode: the totals stay readable while
52/// the rendition is idle, and keep accumulating if it starts again. Obtained
53/// from [`Update::rendition`].
54#[derive(Clone)]
55pub struct Rendition(Arc<Meter>);
56
57impl Rendition {
58 fn new(rung: &Resolved) -> Self {
59 Self(Arc::new(Meter {
60 rung: rung.clone(),
61 counts: kio::Lock::new(Counts::default()),
62 }))
63 }
64
65 /// The rendition/track name, e.g. `video/360p`.
66 pub fn name(&self) -> &str {
67 &self.0.rung.name
68 }
69
70 /// The output resolution, derived from the source aspect ratio.
71 ///
72 /// Fixed for the life of the rendition: a source that resizes the ladder
73 /// under it retires this rung and publishes the replacement under a new name.
74 pub fn size(&self) -> moq_video::Size {
75 self.0.rung.size
76 }
77
78 /// The target bitrate, in bits per second.
79 pub fn bitrate(&self) -> u64 {
80 self.0.rung.bitrate
81 }
82
83 /// The output framerate, inherited from the source.
84 pub fn framerate(&self) -> u32 {
85 self.0.rung.framerate
86 }
87
88 /// How many frames this rendition has encoded, over every pipeline.
89 ///
90 /// This is the meter to bill: monotonic, never reset, and counting what was
91 /// produced rather than how long a pipeline stayed alive, so a group fetch
92 /// and a live session are charged the same way. Subtracting two reads bills
93 /// the span between them, and `frames / framerate` is the media seconds that
94 /// reached consumers. Frames that failed to reach the output group are not
95 /// counted.
96 pub fn frames(&self) -> u64 {
97 self.0.counts.lock().frames
98 }
99
100 /// How many bytes of encoded bitstream this rendition has produced.
101 ///
102 /// The payloads written to the output track, excluding container framing.
103 pub fn bytes(&self) -> u64 {
104 self.0.counts.lock().bytes
105 }
106
107 /// Bank encoded output. Called on the writing path, off the cursor's lock.
108 fn produced(&self, frames: u64, bytes: u64) {
109 let mut counts = self.0.counts.lock();
110 counts.frames += frames;
111 counts.bytes += bytes;
112 }
113}
114
115impl std::fmt::Debug for Rendition {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.debug_struct("Rendition")
118 .field("name", &self.name())
119 .field("frames", &self.frames())
120 .finish()
121 }
122}
123
124struct Meter {
125 rung: Resolved,
126 counts: kio::Lock<Counts>,
127}
128
129/// Everything a caller bills against, behind one lock the cursors never touch.
130#[derive(Default)]
131struct Counts {
132 /// Frames written to the output track.
133 frames: u64,
134 /// Bytes of encoded bitstream written to the output track.
135 bytes: u64,
136}
137
138/// One rendition's entry in the shared ladder.
139struct Entry {
140 rendition: Rendition,
141 /// Pipelines producing it right now. Lives here rather than behind the
142 /// handle so bumping it is the same mutation that wakes the cursors.
143 refs: usize,
144}
145
146#[derive(Default)]
147struct State {
148 /// The resolved ladder, by track name. Fixed once `run` resolves it, and
149 /// never pruned: a caller subtracting two [`Rendition::frames`] reads needs
150 /// the counters to survive an idle gap.
151 ladder: BTreeMap<String, Entry>,
152}
153
154/// The writing half, held by the transcoder and every rung serving off it.
155#[derive(Clone, Default)]
156pub(crate) struct Producer {
157 state: kio::Producer<State>,
158}
159
160impl Producer {
161 /// A fresh cursor, positioned before the ladder so it reports every
162 /// rendition once and everything already encoding.
163 pub(crate) fn consume(&self) -> Consumer {
164 Consumer {
165 state: self.state.consume(),
166 seen: BTreeMap::new(),
167 }
168 }
169
170 /// Publish the resolved ladder, so a cursor holds every handle before any
171 /// rung can encode.
172 ///
173 /// Called again whenever the source resizes the ladder. A rung re-resolved
174 /// under a new picture is a new name and so a new handle; the retired one
175 /// keeps its own, since the bill for what it already encoded outlives it.
176 pub(crate) fn declare<'a>(&self, rungs: impl IntoIterator<Item = &'a Resolved>) {
177 let Ok(mut state) = self.state.write() else { return };
178 for rung in rungs {
179 state.entry(rung);
180 }
181 }
182
183 /// Attach a pipeline to a rendition until the returned guard drops.
184 ///
185 /// Attaching is not encoding: the rendition starts on the guard's first
186 /// [`Guard::produced`], so a pipeline that never encodes a frame is never
187 /// reported and never billed.
188 pub(crate) fn attach(&self, rung: &Resolved) -> Guard {
189 // The guard holds a producer clone, so the channel stays open until the
190 // last of them is gone.
191 let rendition = match self.state.write() {
192 Ok(mut state) => state.entry(rung).rendition.clone(),
193 // Closed: an orphan meter, so the pipeline still counts its own work.
194 Err(_) => Rendition::new(rung),
195 };
196
197 Guard {
198 state: self.state.clone(),
199 rendition,
200 producing: AtomicBool::new(false),
201 }
202 }
203}
204
205impl State {
206 fn entry(&mut self, rung: &Resolved) -> &mut Entry {
207 self.ladder.entry(rung.name.clone()).or_insert_with(|| Entry {
208 rendition: Rendition::new(rung),
209 refs: 0,
210 })
211 }
212
213 /// Adjust how many pipelines are producing `name`. Zero to one (and back) is
214 /// the edge a cursor reports.
215 fn count(&mut self, name: &str, delta: isize) {
216 let Some(entry) = self.ladder.get_mut(name) else { return };
217 entry.refs = entry.refs.saturating_add_signed(delta);
218 }
219}
220
221/// Holds a rendition encoding until dropped.
222///
223/// RAII rather than an explicit release: every encode path is cancelled by being
224/// dropped (a rung whose demand goes away, a fetch aborted with its `JoinSet`),
225/// so a release call would be skipped exactly when it matters and leave the
226/// rendition reported as encoding forever.
227pub(crate) struct Guard {
228 state: kio::Producer<State>,
229 rendition: Rendition,
230 /// Whether this pipeline has produced a frame, so it is counted in the
231 /// rendition's refs and has to take itself back out on drop. Atomic rather
232 /// than a `Cell` only so a `&Guard` can cross an `.await` in a spawned task.
233 producing: AtomicBool,
234}
235
236impl Guard {
237 /// Count frames written to the output track.
238 ///
239 /// The first call is what makes the rendition encoding, waking the cursors.
240 /// Later calls only touch this rendition's counters, so a per-frame call
241 /// costs one uncontended lock and no wakeups.
242 pub(crate) fn produced(&self, frames: u64, bytes: u64) {
243 if frames == 0 {
244 return;
245 }
246 self.rendition.produced(frames, bytes);
247
248 if self.producing.swap(true, Ordering::Relaxed) {
249 return;
250 }
251 if let Ok(mut state) = self.state.write() {
252 state.count(self.rendition.name(), 1);
253 }
254 }
255}
256
257impl Drop for Guard {
258 fn drop(&mut self) {
259 if !self.producing.load(Ordering::Relaxed) {
260 return;
261 }
262 if let Ok(mut state) = self.state.write() {
263 state.count(self.rendition.name(), -1);
264 }
265 }
266}
267
268/// A cursor over the renditions this transcoder produces.
269///
270/// Shaped like [`moq_net::announce::Consumer`]: it yields one rendition at a
271/// time rather than a snapshot of the whole ladder, and it starts before the
272/// ladder, so it reports every rendition once (with [`Update::encoding`] false)
273/// and then every start and stop. Obtained from
274/// [`Transcoder::active`](crate::Transcoder::active).
275///
276/// It is a cursor, not a log: a rendition that starts and stops between two
277/// calls is reported neither time. Bill from [`Rendition::frames`], which counts
278/// it regardless.
279///
280/// ```no_run
281/// # async fn example(active: &mut moq_transcode::active::Consumer) {
282/// while let Some(update) = active.next().await {
283/// match update.encoding {
284/// true => println!("{} started", update.rendition.name()),
285/// false => println!("{} idle after {} frames", update.rendition.name(), update.rendition.frames()),
286/// }
287/// }
288/// # }
289/// ```
290pub struct Consumer {
291 state: kio::Consumer<State>,
292 /// What this cursor last reported for each rendition, which is its position.
293 /// A name absent from it has never been reported at all.
294 seen: BTreeMap<String, bool>,
295}
296
297impl Consumer {
298 /// The next rendition to report, or `None` once the transcoder is gone.
299 pub async fn next(&mut self) -> Option<Update> {
300 kio::wait(|waiter| self.poll_next(waiter)).await
301 }
302
303 /// Poll for the next rendition to report, without blocking.
304 ///
305 /// Returns `Poll::Ready(Some(_))` for an update, `Poll::Ready(None)` once the
306 /// transcoder is gone, or `Poll::Pending` after registering `waiter`.
307 pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll<Option<Update>> {
308 let update = {
309 let seen = &self.seen;
310 match ready!(self.state.poll(waiter, |state| match next_update(state, seen) {
311 Some(update) => Poll::Ready(update),
312 None => Poll::Pending,
313 })) {
314 Ok(update) => update,
315 // Closed: discard the Ref so its lock guard doesn't escape this call.
316 Err(_) => return Poll::Ready(None),
317 }
318 };
319 Poll::Ready(Some(self.advance(update)))
320 }
321
322 /// The next rendition to report, or `None` if there is nothing new.
323 ///
324 /// `None` does NOT mean the cursor is closed; see [`is_closed`](Self::is_closed).
325 pub fn try_next(&mut self) -> Option<Update> {
326 let update = {
327 let seen = &self.seen;
328 next_update(&self.state.read(), seen)?
329 };
330 Some(self.advance(update))
331 }
332
333 /// True once the transcoder is gone: nothing will start encoding again.
334 pub fn is_closed(&self) -> bool {
335 self.state.is_closed()
336 }
337
338 /// Move the cursor past an update before handing it to the caller.
339 fn advance(&mut self, update: Update) -> Update {
340 self.seen.insert(update.rendition.name().to_string(), update.encoding);
341 update
342 }
343}
344
345/// The first rendition whose state differs from `seen`, in name order. A name
346/// missing from `seen` has never been reported, so the ladder lands first.
347fn next_update(state: &State, seen: &BTreeMap<String, bool>) -> Option<Update> {
348 state.ladder.iter().find_map(|(name, entry)| {
349 let encoding = entry.refs > 0;
350 if seen.get(name) == Some(&encoding) {
351 return None;
352 }
353 Some(Update {
354 rendition: entry.rendition.clone(),
355 encoding,
356 })
357 })
358}
359
360#[cfg(test)]
361mod tests {
362 use std::time::Duration;
363
364 use super::*;
365
366 fn resolved(name: &str, height: u32) -> Resolved {
367 Resolved {
368 name: name.to_string(),
369 height,
370 size: moq_video::Size::new(height * 16 / 9, height),
371 bitrate: 100_000,
372 framerate: 30,
373 }
374 }
375
376 #[tokio::test]
377 async fn reports_the_ladder_then_each_edge() {
378 let active = Producer::default();
379 let rung = resolved("video/360p", 360);
380 let mut cursor = active.consume();
381 assert!(cursor.try_next().is_none());
382
383 // The ladder lands before anything encodes, so a caller holds the handle
384 // even if the first pipeline is too short to be an edge.
385 active.declare(std::slice::from_ref(&rung));
386 let update = cursor.next().await.unwrap();
387 assert_eq!(update.rendition.name(), "video/360p");
388 assert_eq!(update.rendition.size().height, 360);
389 assert!(!update.encoding);
390 assert!(cursor.try_next().is_none());
391
392 let guard = active.attach(&rung);
393 guard.produced(1, 1_000);
394 assert!(cursor.next().await.unwrap().encoding);
395 assert!(cursor.try_next().is_none());
396
397 drop(guard);
398 assert!(!cursor.next().await.unwrap().encoding);
399 }
400
401 /// A pipeline is billable when it produces, not when it attaches: a viewer
402 /// subscribing to a rung whose source never sends a frame costs nothing, and
403 /// a transcoder that encodes nothing must not look like one saturating a GPU.
404 #[tokio::test]
405 async fn attaching_without_producing_is_not_encoding() {
406 let active = Producer::default();
407 let rung = resolved("video/360p", 360);
408 active.declare(std::slice::from_ref(&rung));
409
410 let mut cursor = active.consume();
411 let rendition = cursor.next().await.unwrap().rendition;
412
413 let guard = active.attach(&rung);
414 tokio::time::sleep(Duration::from_millis(20)).await;
415 assert!(cursor.try_next().is_none(), "attaching reported an edge");
416 assert_eq!(rendition.frames(), 0);
417
418 // The first frame is what makes it encoding.
419 guard.produced(1, 1_000);
420 assert!(cursor.next().await.unwrap().encoding);
421 assert_eq!(rendition.frames(), 1);
422
423 drop(guard);
424 assert!(!cursor.next().await.unwrap().encoding);
425 }
426
427 /// A fetch overlapping the live session is one rendition, not two: a second
428 /// pipeline is not an edge. Its output still counts, because it really did
429 /// encode those frames.
430 #[tokio::test]
431 async fn concurrent_pipelines_are_one_rendition() {
432 let active = Producer::default();
433 let low = resolved("video/240p", 240);
434 let high = resolved("video/360p", 360);
435 let mut cursor = active.consume();
436
437 let live = active.attach(&high);
438 live.produced(2, 2_000);
439 let rendition = cursor.next().await.unwrap().rendition;
440
441 let fetch = active.attach(&high);
442 fetch.produced(1, 500);
443 let other = active.attach(&low);
444 other.produced(1, 400);
445 // Only the second NAME is an edge.
446 let update = cursor.next().await.unwrap();
447 assert_eq!(update.rendition.name(), "video/240p");
448 assert!(update.encoding);
449 assert!(cursor.try_next().is_none());
450
451 drop(fetch);
452 // Still live, so the release is not an edge either.
453 assert!(cursor.try_next().is_none());
454
455 drop(live);
456 let update = cursor.next().await.unwrap();
457 assert_eq!(update.rendition.name(), "video/360p");
458 assert!(!update.encoding);
459 drop(other);
460 assert!(!cursor.next().await.unwrap().encoding);
461
462 // The handle outlives the encode, and both pipelines counted their output.
463 assert_eq!(rendition.frames(), 3);
464 assert_eq!(rendition.bytes(), 2_500);
465 }
466
467 /// A fresh cursor must report what is already encoding, or a caller that only
468 /// ever awaits `next` never learns about a rendition that started first.
469 #[tokio::test]
470 async fn a_fresh_cursor_reports_the_current_set() {
471 let active = Producer::default();
472 let rung = resolved("video/480p", 480);
473 let guard = active.attach(&rung);
474 guard.produced(1, 1_000);
475
476 let mut cursor = active.consume();
477 let update = cursor.next().await.unwrap();
478 assert_eq!(update.rendition.name(), "video/480p");
479 assert!(update.encoding);
480 // Caught up: it waits for a real change rather than spinning.
481 assert!(cursor.try_next().is_none());
482 assert!(
483 tokio::time::timeout(Duration::from_millis(50), cursor.next())
484 .await
485 .is_err()
486 );
487 }
488
489 /// The whole point of splitting the counters from the cursor: a pipeline that
490 /// starts and stops between two reads is invisible as an edge, but it still
491 /// encoded, so it still bills. This is the group-fetch path, which lives for
492 /// milliseconds. The caller can only bill it because the ladder handed it the
493 /// handle up front.
494 #[tokio::test]
495 async fn a_transient_pipeline_is_metered_without_an_edge() {
496 let active = Producer::default();
497 let rung = resolved("video/360p", 360);
498 active.declare(std::slice::from_ref(&rung));
499
500 let mut cursor = active.consume();
501 let rendition = cursor.next().await.unwrap().rendition;
502 assert_eq!(rendition.frames(), 0);
503
504 let guard = active.attach(&rung);
505 guard.produced(30, 30_000);
506 drop(guard);
507
508 // The cursor converged without ever reporting the start or the stop.
509 assert!(cursor.try_next().is_none());
510 // The counters did not miss it.
511 assert_eq!(rendition.frames(), 30);
512 assert_eq!(rendition.bytes(), 30_000);
513 }
514
515 /// A caller bills by subtracting two reads, so the counters have to keep
516 /// their totals across an idle gap rather than restarting with the next
517 /// pipeline.
518 #[tokio::test]
519 async fn the_counters_survive_an_idle_gap() {
520 let active = Producer::default();
521 let rung = resolved("video/360p", 360);
522 let mut cursor = active.consume();
523
524 let guard = active.attach(&rung);
525 guard.produced(10, 10_000);
526 let rendition = cursor.next().await.unwrap().rendition;
527
528 drop(guard);
529 cursor.next().await;
530 assert_eq!(rendition.frames(), 10, "the totals reset when the rendition went idle");
531
532 // A second session keeps accumulating rather than restarting at zero.
533 let guard = active.attach(&rung);
534 guard.produced(5, 5_000);
535 assert!(cursor.next().await.unwrap().encoding);
536 assert_eq!(rendition.frames(), 15);
537 assert_eq!(rendition.bytes(), 15_000);
538 drop(guard);
539 }
540
541 /// A cursor has to be able to tell "nothing is encoding" from "the transcoder
542 /// is gone", or a metering loop parks forever on a dead transcode.
543 #[tokio::test]
544 async fn the_cursor_closes_with_the_producer() {
545 let active = Producer::default();
546 let mut cursor = active.consume();
547 assert!(!cursor.is_closed());
548
549 drop(active);
550 assert!(cursor.is_closed());
551 assert!(cursor.next().await.is_none());
552 }
553}