Skip to main content

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}