use std::collections::BTreeSet;
use std::ops::{Deref, DerefMut};
use std::sync::{Arc, Mutex, MutexGuard};
use base64::Engine;
use super::hang::{Catalog, CatalogExt, Consumer, Extra};
struct State<E: CatalogExt> {
catalog: Catalog<E>,
owned: BTreeSet<(std::any::TypeId, String)>,
reservations: Reservations,
closed: Option<crate::Error>,
}
fn take<E: CatalogExt>(state: &Mutex<State<E>>) -> MutexGuard<'_, State<E>> {
state.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn owner_key<C: 'static>(name: &str) -> (std::any::TypeId, String) {
(std::any::TypeId::of::<C>(), name.to_string())
}
#[derive(Default)]
struct Reservations {
reservers: usize,
pending: bool,
published: bool,
}
struct CatalogTimeline {
recorder: crate::timeline::Recorder,
last_sequence: Option<u64>,
}
impl CatalogTimeline {
fn record(&mut self, track: &moq_net::track::Producer) {
let Some(sequence) = track.latest() else {
return;
};
if self.last_sequence == Some(sequence) {
return;
}
self.last_sequence = Some(sequence);
self.recorder.record(sequence, moq_net::Timestamp::now(), true);
}
}
struct Outputs<E: CatalogExt> {
hang: moq_json::snapshot::Producer<Catalog<E>>,
hang_track: moq_net::track::Producer,
hangz: moq_json::snapshot::Producer<Catalog<E>>,
msf_track: moq_net::track::Producer,
catalog_timeline: Arc<Mutex<CatalogTimeline>>,
}
impl<E: CatalogExt> Clone for Outputs<E> {
fn clone(&self) -> Self {
Self {
hang: self.hang.clone(),
hang_track: self.hang_track.clone(),
hangz: self.hangz.clone(),
msf_track: self.msf_track.clone(),
catalog_timeline: self.catalog_timeline.clone(),
}
}
}
impl<E: CatalogExt> Outputs<E> {
fn emit(&mut self, catalog: &Catalog<E>) -> crate::Result<()> {
self.hang.update(catalog)?;
self.catalog_timeline.lock().unwrap().record(&self.hang_track);
self.hangz.update(catalog)?;
let msf = to_msf(catalog).to_json().map_err(moq_json::Error::from)?;
let mut group = self.msf_track.append_group()?;
group.write_frame(moq_net::Timestamp::now(), msf)?;
group.finish()?;
Ok(())
}
fn abort(&mut self, err: &crate::Error) {
let reason = match err {
crate::Error::Moq(err) => err.clone(),
crate::Error::Json(moq_json::Error::Net(err)) => err.clone(),
_ => moq_net::StreamError::Internal.into(),
};
let _ = self.hang.clone().abort(reason.clone());
let _ = self.hangz.clone().abort(reason.clone());
let _ = self.msf_track.clone().abort(reason);
}
}
pub struct Producer<E: CatalogExt = ()> {
outputs: Outputs<E>,
current: Arc<Mutex<State<E>>>,
clock: crate::Clock,
timeline: crate::timeline::Producer,
max_age: Option<std::time::Duration>,
bandwidth: moq_net::bandwidth::Allocator,
baseline: super::estimate::Baseline,
}
impl<E: CatalogExt> Clone for Producer<E> {
fn clone(&self) -> Self {
Self {
outputs: self.outputs.clone(),
current: self.current.clone(),
clock: self.clock,
timeline: self.timeline.clone(),
max_age: self.max_age,
bandwidth: self.bandwidth.clone(),
baseline: self.baseline.clone(),
}
}
}
#[derive(Clone)]
pub struct Config<E: CatalogExt = ()> {
catalog: Catalog<E>,
max_age: Option<std::time::Duration>,
bandwidth: moq_net::bandwidth::Allocator,
clock: crate::Clock,
timeline: crate::timeline::Config,
}
impl Default for Config<()> {
fn default() -> Self {
Self {
catalog: Catalog::default(),
max_age: None,
bandwidth: moq_net::bandwidth::Allocator::unlimited(),
clock: crate::Clock::new(),
timeline: crate::timeline::Config::default(),
}
}
}
impl<E: CatalogExt> Config<E> {
pub fn with_catalog<F: CatalogExt>(self, catalog: Catalog<F>) -> Config<F> {
Config {
catalog,
max_age: self.max_age,
bandwidth: self.bandwidth,
clock: self.clock,
timeline: self.timeline,
}
}
pub fn with_clock(mut self, clock: crate::Clock) -> Self {
self.clock = clock;
self
}
pub fn with_max_age(mut self, max_age: impl Into<Option<std::time::Duration>>) -> Self {
self.max_age = max_age.into();
self
}
pub fn with_bandwidth(mut self, bandwidth: moq_net::bandwidth::Allocator) -> Self {
self.bandwidth = bandwidth;
self
}
pub fn with_timeline(mut self, timeline: crate::timeline::Config) -> Self {
self.timeline = timeline;
self
}
}
impl<E: CatalogExt> Producer<E> {
pub fn new(broadcast: &mut moq_net::broadcast::Producer, config: Config<E>) -> Result<Self, moq_net::Error> {
let hang_track = broadcast.create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info())?;
let hangz_track =
broadcast.create_track(hang::Catalog::COMPRESSED_NAME, hang::Catalog::default_track_info())?;
let msf_info = moq_net::track::Info::default().with_priority(hang::catalog::PRIORITY.catalog);
let msf_track = broadcast.create_track(moq_msf::DEFAULT_NAME, msf_info)?;
let mut json_config = moq_json::snapshot::Config::default();
json_config.delta_ratio = 0;
let hang = moq_json::snapshot::Producer::new(hang_track.clone(), json_config.clone());
json_config.compression = moq_json::Compression::Deflate;
let hangz = moq_json::snapshot::Producer::new(hangz_track, json_config);
let timeline = crate::timeline::Producer::new(broadcast, config.timeline);
#[allow(clippy::arc_with_non_send_sync)]
let catalog_timeline = Arc::new(Mutex::new(CatalogTimeline {
recorder: timeline.track(hang::Catalog::DEFAULT_NAME),
last_sequence: None,
}));
let mut catalog = config.catalog;
catalog.clock = Some(config.clock.wall());
#[allow(clippy::arc_with_non_send_sync)]
Ok(Self {
outputs: Outputs {
hang,
hang_track,
hangz,
msf_track,
catalog_timeline,
},
current: Arc::new(Mutex::new(State {
catalog,
owned: BTreeSet::new(),
reservations: Reservations::default(),
closed: None,
})),
clock: config.clock,
timeline,
max_age: config.max_age,
bandwidth: config.bandwidth,
baseline: Default::default(),
})
}
pub fn track_info(&self, priority: u8) -> moq_net::track::Info {
let info = hang::container::track_info(priority);
match self.max_age {
Some(max_age) => info.with_max_age(max_age),
None => info,
}
}
pub fn timestamp(&self, hint: Option<moq_net::Timestamp>) -> crate::Result<moq_net::Timestamp> {
match hint {
Some(pts) => Ok(pts),
None => Ok(self.clock.now()),
}
}
pub fn mutate(&mut self, f: impl FnOnce(&mut Catalog<E>)) -> crate::Result<()> {
let mut guard = self.modify()?;
f(&mut guard);
guard.commit()
}
pub fn modify(&mut self) -> crate::Result<Guard<'_, E>> {
let state = take(&self.current);
if let Some(err) = &state.closed {
return Err(err.clone());
}
Ok(Guard {
state,
outputs: &mut self.outputs,
updated: false,
})
}
pub fn snapshot(&self) -> Catalog<E> {
take(&self.current).catalog.clone()
}
pub fn clock(&self) -> crate::Clock {
self.clock
}
pub fn reserve(&self) -> super::Reserved<E> {
super::Reserved::new(self.clone())
}
pub(super) fn rendition<C: super::RenditionConfig<E>>(
&self,
name: impl Into<String>,
) -> crate::Result<super::Rendition<E, C>> {
super::Rendition::live(self.clone(), name.into())
}
pub fn video<C: crate::container::Container>(
&self,
track: moq_net::track::Producer,
container: C,
config: impl Into<Option<hang::catalog::VideoConfig>>,
) -> crate::Result<crate::container::Producer<C, hang::catalog::VideoConfig>>
where
crate::Error: From<C::Error>,
{
self.track(track, container, config)
}
pub fn audio<C: crate::container::Container>(
&self,
track: moq_net::track::Producer,
container: C,
config: impl Into<Option<hang::catalog::AudioConfig>>,
) -> crate::Result<crate::container::Producer<C, hang::catalog::AudioConfig>>
where
crate::Error: From<C::Error>,
{
self.track(track, container, config)
}
pub fn text<C: crate::container::Container>(
&self,
track: moq_net::track::Producer,
container: C,
config: impl Into<Option<hang::catalog::TextConfig>>,
) -> crate::Result<crate::container::Producer<C, hang::catalog::TextConfig>>
where
crate::Error: From<C::Error>,
{
self.track(track, container, config)
}
pub fn track<C, R>(
&self,
track: moq_net::track::Producer,
container: C,
config: impl Into<Option<R>>,
) -> crate::Result<crate::container::Producer<C, R>>
where
C: crate::container::Container,
R: super::RenditionConfig<E>,
crate::Error: From<C::Error>,
{
let rendition = self.rendition(track.name())?;
self.media(track, container, rendition, config.into())
}
pub fn is_claimed<R: super::RenditionConfig<E>>(&self, name: &str) -> bool {
let mut state = take(&self.current);
R::get_mut(&mut state.catalog, name).is_some() || state.owned.contains(&owner_key::<R>(name))
}
pub(super) fn acquire<C: super::RenditionConfig<E>>(&self, name: &str) -> crate::Result<()> {
let mut state = take(&self.current);
if C::get_mut(&mut state.catalog, name).is_some() || !state.owned.insert(owner_key::<C>(name)) {
return Err(hang::Error::Duplicate(name.to_string()).into());
}
Ok(())
}
pub(super) fn add_reserver(&self) {
take(&self.current).reservations.reservers += 1;
}
pub(super) fn release_reserver(&mut self) {
{
let mut state = take(&self.current);
let r = &mut state.reservations;
r.reservers = r.reservers.saturating_sub(1);
}
self.flush_if_ready();
}
pub(super) fn flush_if_ready(&mut self) {
let catalog = {
let mut state = take(&self.current);
let r = &mut state.reservations;
if r.published || r.reservers != 0 {
return;
}
if !r.pending {
return;
}
r.pending = false;
r.published = true;
state.catalog.clone()
};
if let Err(err) = self.outputs.emit(&catalog) {
tracing::error!(%err, "failed to publish the catalog, aborting its tracks");
self.outputs.abort(&err);
take(&self.current).closed = Some(err);
}
}
pub(super) fn media<C, R>(
&self,
track: moq_net::track::Producer,
container: C,
rendition: super::Rendition<E, R>,
config: Option<R>,
) -> crate::Result<crate::container::Producer<C, R>>
where
C: crate::container::Container,
R: super::RenditionConfig<E>,
crate::Error: From<C::Error>,
{
let mut catalog = self.clone();
let recorder = catalog.enroll(track.name())?;
let mut producer = crate::container::Producer::with_rendition(track, container, rendition)
.with_recorder(recorder)
.with_bandwidth(self.bandwidth.clone());
if let Some(config) = config {
producer.set(config)?;
}
Ok(producer)
}
pub(crate) fn media_raw<C>(
&self,
track: moq_net::track::Producer,
container: C,
) -> crate::Result<crate::container::Producer<C>>
where
C: crate::container::Container,
crate::Error: From<C::Error>,
{
let mut catalog = self.clone();
let recorder = catalog.enroll(track.name())?;
Ok(crate::container::Producer::new(track, container)
.with_recorder(recorder)
.with_bandwidth(self.bandwidth.clone()))
}
pub(crate) fn estimator(&self) -> super::Estimator {
super::Estimator::with_broadcast(self.baseline.clone())
}
pub(crate) fn bandwidth(&self) -> moq_net::bandwidth::Allocator {
self.bandwidth.clone()
}
pub(crate) fn enroll(&mut self, track: &str) -> crate::Result<crate::timeline::Recorder> {
let recorder = self.timeline.pacing_track(track)?;
let section = self.timeline.section();
let mut catalog = self.modify()?;
if catalog.archive.is_none() {
catalog.archive = Some(section);
}
catalog.commit()?;
Ok(recorder)
}
pub fn timeline(&self) -> crate::timeline::Producer {
self.timeline.clone()
}
pub fn json_snapshot<T: serde::Serialize>(
&self,
track: moq_net::track::Producer,
config: impl super::IntoRendition<E, hang::catalog::JsonConfig> + 'static,
) -> crate::Result<crate::json::Snapshot<T, E>> {
let rendition = self.data_entry(track.name())?;
crate::json::Snapshot::new(track, rendition, config)
}
pub fn json_stream<T: serde::Serialize>(
&self,
track: moq_net::track::Producer,
config: impl super::IntoRendition<E, hang::catalog::JsonConfig>,
) -> crate::Result<crate::json::Stream<T, E>> {
let rendition = self.data_entry(track.name())?;
crate::json::Stream::new(track, rendition, config.into_rendition())
}
pub fn binary_snapshot(
&self,
track: moq_net::track::Producer,
config: impl super::IntoRendition<E, hang::catalog::BinaryConfig>,
) -> crate::Result<crate::binary::Snapshot<E>> {
let rendition = self.data_entry(track.name())?;
crate::binary::Snapshot::new(track, rendition, config.into_rendition())
}
pub fn binary_stream(
&self,
track: moq_net::track::Producer,
config: impl super::IntoRendition<E, hang::catalog::BinaryConfig>,
) -> crate::Result<crate::binary::Stream<E>> {
let rendition = self.data_entry(track.name())?;
crate::binary::Stream::new(track, rendition, config.into_rendition())
}
fn data_entry<C: super::RenditionConfig<E>>(&self, name: &str) -> crate::Result<super::Rendition<E, C>> {
self.reserve().init(name)
}
pub fn consume(&self) -> Result<Consumer<E>, moq_net::Error> {
Ok(Consumer::new(self.outputs.hang.consume()))
}
pub fn finish(&mut self) -> crate::Result<()> {
take(&self.current).closed = Some(moq_net::Error::Closed.into());
self.outputs.hang.finish()?;
self.outputs.hangz.finish()?;
self.outputs.msf_track.finish()?;
self.timeline.finish()?;
Ok(())
}
}
pub struct Guard<'a, E: CatalogExt = ()> {
state: MutexGuard<'a, State<E>>,
outputs: &'a mut Outputs<E>,
updated: bool,
}
impl<E: CatalogExt> Guard<'_, E> {
pub fn commit(mut self) -> crate::Result<()> {
self.publish()
}
fn publish(&mut self) -> crate::Result<()> {
if !self.updated {
return Ok(());
}
self.updated = false;
{
let r = &mut self.state.reservations;
if !r.published && r.reservers != 0 {
r.pending = true;
return Ok(());
}
r.pending = false;
r.published = true;
}
self.outputs.emit(&self.state.catalog)
}
pub(super) fn release<C: super::RenditionConfig<E>>(&mut self, name: &str, present: bool) {
self.state.owned.remove(&owner_key::<C>(name));
if present {
C::remove(&mut self.state.catalog, name);
self.updated = true;
}
}
}
impl<E: CatalogExt> Deref for Guard<'_, E> {
type Target = Catalog<E>;
fn deref(&self) -> &Self::Target {
&self.state.catalog
}
}
impl<E: CatalogExt> DerefMut for Guard<'_, E> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.updated = true;
&mut self.state.catalog
}
}
impl Guard<'_, Extra> {
pub fn set_section(&mut self, name: impl Into<String>, value: serde_json::Value) -> crate::Result<()> {
self.state.catalog.ext.set(name, value)?;
self.updated = true;
Ok(())
}
pub fn remove_section(&mut self, name: &str) -> Option<serde_json::Value> {
let removed = self.state.catalog.ext.remove(name);
if removed.is_some() {
self.updated = true;
}
removed
}
}
impl<E: CatalogExt> Drop for Guard<'_, E> {
fn drop(&mut self) {
if let Err(err) = self.publish() {
if !std::thread::panicking() {
tracing::error!(%err, "failed to publish the catalog on guard drop, aborting its tracks");
}
self.outputs.abort(&err);
self.state.closed = Some(err);
}
}
}
fn video_sap_type(codec: &hang::catalog::VideoCodec) -> Option<u8> {
use hang::catalog::VideoCodec;
match codec {
VideoCodec::VP8 => Some(1),
VideoCodec::H264(_) | VideoCodec::AV1(_) | VideoCodec::VP9(_) => Some(2),
_ => None,
}
}
fn packaging_of(container: &hang::catalog::Container) -> Option<moq_msf::Packaging> {
match container {
hang::catalog::Container::Cmaf { .. } => Some(moq_msf::Packaging::Cmaf),
hang::catalog::Container::Loc => Some(moq_msf::Packaging::Loc),
hang::catalog::Container::Legacy => Some(moq_msf::Packaging::Legacy),
hang::catalog::Container::Unknown(unknown) => {
unknown.kind().map(|kind| moq_msf::Packaging::Unknown(kind.to_string()))
}
}
}
fn to_msf<E: CatalogExt>(catalog: &Catalog<E>) -> moq_msf::Catalog<E> {
let mut msf = to_msf_media(&catalog.media());
msf.ext = catalog.ext.clone();
msf
}
fn to_msf_media<E: CatalogExt>(catalog: &hang::Catalog) -> moq_msf::Catalog<E> {
let mut tracks = Vec::new();
let has_multiple_video = catalog.video.renditions.len() > 1;
for (name, config) in &catalog.video.renditions {
let Some(packaging) = packaging_of(&config.container) else {
continue;
};
let init_data = match &config.container {
hang::catalog::Container::Cmaf { init, .. } => Some(base64::engine::general_purpose::STANDARD.encode(init)),
_ => config
.description
.as_ref()
.map(|d| base64::engine::general_purpose::STANDARD.encode(d.as_ref())),
};
let sap_type = video_sap_type(&config.codec);
let mut track = moq_msf::Track::new(name.clone(), packaging);
track.is_live = true;
track.role = Some(moq_msf::Role::Video);
track.codec = Some(config.codec.to_string());
track.width = config.coded_width;
track.height = config.coded_height;
track.framerate = config.framerate;
track.bitrate = config.bitrate;
track.stalled = config.stalled;
track.init_data = init_data;
track.render_group = Some(1);
track.alt_group = if has_multiple_video { Some(1) } else { None };
track.max_grp_sap_starting_type = sap_type;
track.max_obj_sap_starting_type = sap_type;
track.jitter = config.jitter;
track.delay = config.delay;
tracks.push(track);
}
let has_multiple_audio = catalog.audio.renditions.len() > 1;
for (name, config) in &catalog.audio.renditions {
let Some(packaging) = packaging_of(&config.container) else {
continue;
};
let init_data = match &config.container {
hang::catalog::Container::Cmaf { init, .. } => Some(base64::engine::general_purpose::STANDARD.encode(init)),
_ => config
.description
.as_ref()
.map(|d| base64::engine::general_purpose::STANDARD.encode(d.as_ref())),
};
let mut track = moq_msf::Track::new(name.clone(), packaging);
track.is_live = true;
track.role = Some(moq_msf::Role::Audio);
track.codec = Some(config.codec.to_string());
track.samplerate = Some(config.sample_rate);
track.channel_config = Some(config.channel_count.to_string());
track.bitrate = config.bitrate;
track.init_data = init_data;
track.render_group = Some(1);
track.alt_group = if has_multiple_audio { Some(1) } else { None };
track.max_grp_sap_starting_type = Some(1);
track.max_obj_sap_starting_type = Some(1);
track.jitter = config.jitter;
track.delay = config.delay;
tracks.push(track);
}
moq_msf::Catalog::with_ext(tracks, E::default())
}
#[cfg(test)]
mod test {
use std::collections::BTreeMap;
use std::task::Poll;
use bytes::Bytes;
use hang::catalog::{AudioCodec, AudioConfig, Container, H264, VideoConfig};
use super::*;
#[tokio::test]
async fn non_media_tracks_rank_above_media() {
use hang::catalog::PRIORITY;
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
catalog.timeline().pacing_track("video").unwrap();
let consumer = broadcast.consume();
for name in [
hang::Catalog::DEFAULT_NAME,
hang::Catalog::COMPRESSED_NAME,
moq_msf::DEFAULT_NAME,
hang::timeline::DEFAULT_NAME,
] {
let track = consumer.track(name).expect("track");
let info = track.query().await.expect("info");
assert_eq!(info.priority, PRIORITY.catalog, "{name} should rank with the catalog");
}
}
#[test]
fn media_tracks_inherit_the_catalogs_declared_retention() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
assert_eq!(
catalog.track_info(hang::catalog::PRIORITY.video).max_age,
hang::container::track_info(hang::catalog::PRIORITY.video).max_age
);
assert!(catalog.track_info(hang::catalog::PRIORITY.video).max_age > moq_net::track::DEFAULT_MAX_AGE);
let mut broadcast = moq_net::broadcast::Info::new().produce();
let config = Config::default().with_max_age(std::time::Duration::from_secs(3));
let catalog = Producer::new(&mut broadcast, config).unwrap();
let info = catalog.track_info(hang::catalog::PRIORITY.video);
assert_eq!(info.max_age, std::time::Duration::from_secs(3));
assert_eq!(info.timescale, hang::container::TIMESCALE);
let at = info.with_timescale(moq_net::Timescale::MILLI);
assert_eq!(at.max_age, std::time::Duration::from_secs(3));
assert_eq!(at.timescale, moq_net::Timescale::MILLI);
assert_eq!(
catalog.reserve().track_info(hang::catalog::PRIORITY.video).max_age,
std::time::Duration::from_secs(3)
);
assert_eq!(
catalog.clone().track_info(hang::catalog::PRIORITY.video).max_age,
std::time::Duration::from_secs(3)
);
}
#[test]
fn publishes_plain_and_compressed_tracks() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let mut plain = Consumer::new(catalog.outputs.hang.consume());
let mut compressed = Consumer::compressed(catalog.outputs.hangz.consume());
{
let mut guard = catalog.modify().unwrap();
guard
.audio
.renditions
.insert("audio0".to_string(), AudioConfig::new(AudioCodec::Opus, 48_000, 2));
}
let expected = catalog.snapshot();
let waiter = kio::Waiter::noop();
let got_plain = match plain.poll_next(&waiter) {
Poll::Ready(Ok(Some(c))) => c,
other => panic!("expected plain catalog, got {other:?}"),
};
let got_compressed = match compressed.poll_next(&waiter) {
Poll::Ready(Ok(Some(c))) => c,
other => panic!("expected compressed catalog, got {other:?}"),
};
assert_eq!(got_plain, expected);
assert_eq!(got_compressed, expected);
}
#[test]
fn mutate_composes_independent_owners() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let mut plain = Consumer::new(catalog.outputs.hang.consume());
catalog
.mutate(|c| {
c.audio
.renditions
.insert("audio0".to_string(), AudioConfig::new(AudioCodec::Opus, 48_000, 2));
})
.unwrap();
catalog
.mutate(|c| {
c.video.renditions.insert("video0".to_string(), h264_config());
})
.unwrap();
catalog.mutate(|_| {}).unwrap();
assert_eq!(catalog.outputs.hang_track.latest(), Some(1));
let expected = catalog.snapshot();
let waiter = kio::Waiter::noop();
let mut last = None;
while let Poll::Ready(Ok(Some(c))) = plain.poll_next(&waiter) {
last = Some(c);
}
assert_eq!(last.unwrap(), expected);
assert!(expected.audio.renditions.contains_key("audio0"));
assert!(expected.video.renditions.contains_key("video0"));
}
#[test]
fn modify_refuses_a_finished_catalog() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
catalog
.modify()
.unwrap()
.audio
.renditions
.insert("audio0".to_string(), AudioConfig::new(AudioCodec::Opus, 48_000, 2));
catalog.finish().unwrap();
assert!(matches!(
catalog.modify(),
Err(crate::Error::Moq(moq_net::Error::Closed))
));
assert!(matches!(
catalog.enroll("audio0"),
Err(crate::Error::Moq(moq_net::Error::Closed))
));
}
#[test]
fn a_dropped_failed_edit_aborts_every_catalog_track() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let config = Config::default().with_catalog(Catalog::<Extra>::default());
let mut catalog = Producer::<Extra>::new(&mut broadcast, config).unwrap();
let mut hang = catalog.outputs.hang.consume().ordered();
let mut msf = catalog.outputs.msf_track.subscribe(None).ordered();
catalog
.modify()
.unwrap()
.set_section("big", "x".repeat(moq_net::group::MAX_CACHE_BYTES as usize + 1).into())
.unwrap();
assert!(matches!(
catalog.modify(),
Err(crate::Error::Json(moq_json::Error::Net(moq_net::Error::FrameTooLarge)))
));
let waiter = kio::Waiter::noop();
assert!(matches!(
hang.poll_next_group(&waiter),
Poll::Ready(Err(moq_net::Error::FrameTooLarge))
));
assert!(
matches!(
msf.poll_next_group(&waiter),
Poll::Ready(Err(moq_net::Error::FrameTooLarge))
),
"a track the publish never reached must not outlive the others"
);
}
#[test]
fn a_failed_commit_leaves_the_catalog_open() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let config = Config::default().with_catalog(Catalog::<Extra>::default());
let mut catalog = Producer::<Extra>::new(&mut broadcast, config).unwrap();
let track = catalog.outputs.hang.consume();
let mut guard = catalog.modify().unwrap();
guard
.set_section("big", "x".repeat(moq_net::group::MAX_CACHE_BYTES as usize + 1).into())
.unwrap();
assert!(matches!(
guard.commit(),
Err(crate::Error::Json(moq_json::Error::Net(moq_net::Error::FrameTooLarge)))
));
catalog.modify().unwrap().remove_section("big").unwrap();
catalog.finish().unwrap();
assert_eq!(track.latest(), Some(0));
}
#[test]
fn commit_publishes_once() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let track = catalog.outputs.hang.consume();
let mut guard = catalog.modify().unwrap();
guard
.audio
.renditions
.insert("audio0".to_string(), AudioConfig::new(AudioCodec::Opus, 48_000, 2));
guard.commit().unwrap();
catalog.finish().unwrap();
assert_eq!(track.latest(), Some(0));
}
#[test]
fn timeline_reports_a_track_collision() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let _taken = broadcast.create_track(hang::timeline::DEFAULT_NAME, None).unwrap();
assert!(catalog.enroll("video0").is_err());
}
#[test]
fn enrolling_advertises_the_catalog_section() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
assert_eq!(catalog.snapshot().archive, None);
let _recorder = catalog.enroll("video0").unwrap();
assert_eq!(
catalog.snapshot().archive,
Some(catalog.timeline().section()),
"the root archive should advertise the timeline track"
);
}
#[test]
fn clock_is_advertised_without_an_archive() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let snapshot = catalog.snapshot();
assert_eq!(snapshot.archive, None);
assert_eq!(snapshot.clock, Some(catalog.clock().wall()));
}
#[test]
fn with_clock_names_the_contents_real_start() {
use std::time::{Duration, SystemTime};
let mut broadcast = moq_net::broadcast::Info::new().produce();
let wall = SystemTime::UNIX_EPOCH + Duration::from_millis(hang::catalog::MOQ_EPOCH_UNIX_MILLIS + 60_000);
let config = Config::default()
.with_clock(crate::Clock::at(std::time::Instant::now(), wall).expect("a representable wall"));
let catalog = Producer::new(&mut broadcast, config).unwrap();
assert_eq!(
catalog.snapshot().clock.map(|clock| clock.wall),
Some(moq_net::Timestamp::from_micros(60_000_000).unwrap())
);
}
fn h264_config() -> VideoConfig {
let mut config = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0x00,
level: 0x1f,
inline: true,
});
config.container = Container::Legacy;
config
}
#[test]
fn reservation_gates_until_all_renditions_resolve() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let mut consumer: Consumer = Consumer::new(catalog.outputs.hang.consume());
let waiter = kio::Waiter::noop();
let reserved = catalog.reserve();
let mut audio = reserved.init::<AudioConfig>("audio0").unwrap();
let mut video = reserved.init::<VideoConfig>("video0").unwrap();
drop(reserved);
audio.set(AudioConfig::new(AudioCodec::Opus, 48_000, 2)).unwrap();
assert!(
matches!(consumer.poll_next(&waiter), Poll::Pending),
"an audio-only catalog must not publish while video is unresolved"
);
video.set(h264_config()).unwrap();
let snapshot = match consumer.poll_next(&waiter) {
Poll::Ready(Ok(Some(c))) => c,
other => panic!("expected the complete catalog, got {other:?}"),
};
assert!(snapshot.audio.renditions.contains_key("audio0"));
assert!(snapshot.video.renditions.contains_key("video0"));
assert!(
matches!(consumer.poll_next(&waiter), Poll::Pending),
"only the one complete snapshot should be published"
);
}
#[test]
fn live_rendition_owns_its_name_without_gating_the_catalog() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let mut consumer: Consumer = Consumer::new(catalog.outputs.hang.consume());
let waiter = kio::Waiter::noop();
let _unresolved = catalog.rendition::<VideoConfig>("video0").unwrap();
assert!(
catalog.rendition::<VideoConfig>("video0").is_err(),
"the unresolved live rendition owns its name"
);
let mut audio = catalog.rendition::<AudioConfig>("audio0").unwrap();
audio.set(AudioConfig::new(AudioCodec::Opus, 48_000, 2)).unwrap();
let snapshot = match consumer.poll_next(&waiter) {
Poll::Ready(Ok(Some(c))) => c,
other => panic!("expected the incremental audio catalog, got {other:?}"),
};
assert!(snapshot.audio.renditions.contains_key("audio0"));
assert!(!snapshot.video.renditions.contains_key("video0"));
}
#[test]
fn reservation_gate_opens_when_unresolved_reservation_is_dropped() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let mut consumer: Consumer = Consumer::new(catalog.outputs.hang.consume());
let waiter = kio::Waiter::noop();
let reserved = catalog.reserve();
let mut audio = reserved.init::<AudioConfig>("audio0").unwrap();
let video = reserved.init::<VideoConfig>("video0").unwrap();
drop(reserved);
audio.set(AudioConfig::new(AudioCodec::Opus, 48_000, 2)).unwrap();
assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));
drop(video);
let snapshot = match consumer.poll_next(&waiter) {
Poll::Ready(Ok(Some(c))) => c,
other => panic!("expected the audio catalog, got {other:?}"),
};
assert!(snapshot.audio.renditions.contains_key("audio0"));
assert!(!snapshot.video.renditions.contains_key("video0"));
}
#[test]
fn staged_change_waits_for_a_held_reservation() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = Producer::new(&mut broadcast, Config::default()).unwrap();
let mut consumer: Consumer = Consumer::new(catalog.outputs.hang.consume());
let waiter = kio::Waiter::noop();
let deferred = catalog.reserve();
let early = catalog.reserve();
let mut a0 = early.init::<AudioConfig>("audio0").unwrap();
a0.set(AudioConfig::new(AudioCodec::Opus, 48_000, 2)).unwrap();
drop(early);
assert!(
matches!(consumer.poll_next(&waiter), Poll::Pending),
"the staged rendition must wait for the deferred importer's held reservation"
);
let mut late = deferred.init::<AudioConfig>("audio1").unwrap();
drop(deferred); assert!(matches!(consumer.poll_next(&waiter), Poll::Pending));
late.set(AudioConfig::new(AudioCodec::Opus, 48_000, 1)).unwrap();
let snapshot = match consumer.poll_next(&waiter) {
Poll::Ready(Ok(Some(c))) => c,
other => panic!("expected one complete catalog, got {other:?}"),
};
assert!(snapshot.audio.renditions.contains_key("audio0"));
assert!(snapshot.audio.renditions.contains_key("audio1"));
assert!(
matches!(consumer.poll_next(&waiter), Poll::Pending),
"only the one complete snapshot should be published"
);
}
#[test]
fn convert_simple() {
let mut video_config = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0x00,
level: 0x1f,
inline: true,
});
video_config.coded_width = Some(1280);
video_config.coded_height = Some(720);
video_config.bitrate = Some(6_000_000);
video_config.stalled = Some(true);
video_config.framerate = Some(30.0);
video_config.container = Container::Legacy;
let mut video_renditions = BTreeMap::new();
video_renditions.insert("video0.avc3".to_string(), video_config);
let mut audio_config = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
audio_config.bitrate = Some(128_000);
audio_config.container = Container::Legacy;
let mut audio_renditions = BTreeMap::new();
audio_renditions.insert("audio0".to_string(), audio_config);
let mut catalog = hang::Catalog::default();
catalog.video.renditions = video_renditions;
catalog.audio.renditions = audio_renditions;
let msf = to_msf_media::<()>(&catalog);
assert_eq!(msf.tracks.len(), 2);
let video = &msf.tracks[0];
assert_eq!(video.name, "video0.avc3");
assert_eq!(video.role, Some(moq_msf::Role::Video));
assert_eq!(video.packaging, moq_msf::Packaging::Legacy);
assert_eq!(video.codec, Some("avc3.64001f".to_string()));
assert_eq!(video.width, Some(1280));
assert_eq!(video.height, Some(720));
assert_eq!(video.framerate, Some(30.0));
assert_eq!(video.bitrate, Some(6_000_000));
assert_eq!(video.stalled, Some(true));
assert!(video.init_data.is_none());
assert_eq!(video.max_grp_sap_starting_type, Some(2));
assert_eq!(video.max_obj_sap_starting_type, Some(2));
assert_eq!(video.jitter, None);
let audio = &msf.tracks[1];
assert_eq!(audio.name, "audio0");
assert_eq!(audio.role, Some(moq_msf::Role::Audio));
assert_eq!(audio.packaging, moq_msf::Packaging::Legacy);
assert_eq!(audio.codec, Some("opus".to_string()));
assert_eq!(audio.samplerate, Some(48_000));
assert_eq!(audio.channel_config, Some("2".to_string()));
assert_eq!(audio.bitrate, Some(128_000));
assert_eq!(audio.max_grp_sap_starting_type, Some(1));
assert_eq!(audio.max_obj_sap_starting_type, Some(1));
assert_eq!(audio.jitter, None);
}
#[test]
fn convert_video_loc_container() {
let mut video_config = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0x00,
level: 0x1f,
inline: true,
});
video_config.container = Container::Loc;
let mut video_renditions = BTreeMap::new();
video_renditions.insert("video0.avc3".to_string(), video_config);
let mut catalog = hang::Catalog::default();
catalog.video.renditions = video_renditions;
let msf = to_msf_media::<()>(&catalog);
assert_eq!(msf.tracks.len(), 1);
assert_eq!(msf.tracks[0].packaging, moq_msf::Packaging::Loc);
}
#[test]
fn convert_audio_loc_container() {
let mut audio_config = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
audio_config.container = Container::Loc;
let mut audio_renditions = BTreeMap::new();
audio_renditions.insert("audio0".to_string(), audio_config);
let mut catalog = hang::Catalog::default();
catalog.audio.renditions = audio_renditions;
let msf = to_msf_media::<()>(&catalog);
assert_eq!(msf.tracks.len(), 1);
assert_eq!(msf.tracks[0].packaging, moq_msf::Packaging::Loc);
}
#[test]
fn convert_unknown_container_keeps_its_kind() {
let container: Container = serde_json::from_value(serde_json::json!({ "kind": "future" })).unwrap();
assert!(matches!(container, Container::Unknown(_)));
let mut audio_config = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
audio_config.container = container;
let mut audio_renditions = BTreeMap::new();
audio_renditions.insert("audio0".to_string(), audio_config);
let mut catalog = hang::Catalog::default();
catalog.audio.renditions = audio_renditions;
let msf = to_msf_media::<()>(&catalog);
assert_eq!(msf.tracks.len(), 1);
assert_eq!(
msf.tracks[0].packaging,
moq_msf::Packaging::Unknown("future".to_string())
);
}
#[test]
fn convert_with_description() {
let mut video_config = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0x00,
level: 0x1f,
inline: false,
});
video_config.description = Some(Bytes::from_static(&[0x01, 0x02, 0x03]));
video_config.coded_width = Some(1920);
video_config.coded_height = Some(1080);
video_config.container = Container::Legacy;
let mut video_renditions = BTreeMap::new();
video_renditions.insert("video0.m4s".to_string(), video_config);
let mut catalog = hang::Catalog::default();
catalog.video.renditions = video_renditions;
let msf = to_msf_media::<()>(&catalog);
let video = &msf.tracks[0];
assert_eq!(video.init_data, Some("AQID".to_string()));
}
#[test]
fn convert_empty() {
let catalog = hang::Catalog::default();
let msf = to_msf_media::<()>(&catalog);
assert!(msf.tracks.is_empty());
}
#[test]
fn convert_cmaf_packaging() {
let mut video_config = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0x00,
level: 0x28,
inline: false,
});
video_config.coded_width = Some(1920);
video_config.coded_height = Some(1080);
video_config.container = Container::Cmaf {
init: base64::engine::general_purpose::STANDARD
.decode("AAAYZ2Z0eXA=")
.unwrap()
.into(),
};
let mut video_renditions = BTreeMap::new();
video_renditions.insert("video0.m4s".to_string(), video_config);
let mut catalog = hang::Catalog::default();
catalog.video.renditions = video_renditions;
let msf = to_msf_media::<()>(&catalog);
let video = &msf.tracks[0];
assert_eq!(video.packaging, moq_msf::Packaging::Cmaf);
assert_eq!(video.init_data, Some("AAAYZ2Z0eXA=".to_string()));
}
#[test]
fn convert_sap_h264_with_jitter() {
let mut video_config = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0x00,
level: 0x1f,
inline: true,
});
video_config.coded_width = Some(1280);
video_config.coded_height = Some(720);
video_config.framerate = Some(30.0);
video_config.container = Container::Legacy;
video_config.jitter = Some(std::time::Duration::from_millis(100));
video_config.delay = Some(std::time::Duration::from_millis(200));
let mut video_renditions = BTreeMap::new();
video_renditions.insert("video0".to_string(), video_config);
let mut audio_config = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
audio_config.container = Container::Legacy;
audio_config.jitter = Some(std::time::Duration::from_millis(40));
audio_config.delay = Some(std::time::Duration::from_millis(80));
let mut audio_renditions = BTreeMap::new();
audio_renditions.insert("audio0".to_string(), audio_config);
let mut catalog = hang::Catalog::default();
catalog.video.renditions = video_renditions;
catalog.audio.renditions = audio_renditions;
let msf = to_msf_media::<()>(&catalog);
let video = &msf.tracks[0];
assert_eq!(video.role, Some(moq_msf::Role::Video));
assert_eq!(video.max_grp_sap_starting_type, Some(2));
assert_eq!(video.max_obj_sap_starting_type, Some(2));
assert_eq!(video.jitter, Some(std::time::Duration::from_millis(100)));
assert_eq!(video.delay, Some(std::time::Duration::from_millis(200)));
let audio = &msf.tracks[1];
assert_eq!(audio.role, Some(moq_msf::Role::Audio));
assert_eq!(audio.max_grp_sap_starting_type, Some(1));
assert_eq!(audio.max_obj_sap_starting_type, Some(1));
assert_eq!(audio.jitter, Some(std::time::Duration::from_millis(40)));
assert_eq!(audio.delay, Some(std::time::Duration::from_millis(80)));
}
#[test]
fn convert_sap_h265() {
use hang::catalog::H265;
let mut video_config = VideoConfig::new(H265 {
in_band: false,
profile_space: 0,
profile_idc: 1,
profile_compatibility_flags: [0, 0, 0, 0],
tier_flag: false,
level_idc: 93,
constraint_flags: [0, 0, 0, 0, 0, 0],
});
video_config.coded_width = Some(1920);
video_config.coded_height = Some(1080);
video_config.framerate = Some(60.0);
video_config.container = Container::Legacy;
let mut video_renditions = BTreeMap::new();
video_renditions.insert("video0".to_string(), video_config);
let mut catalog = hang::Catalog::default();
catalog.video.renditions = video_renditions;
let msf = to_msf_media::<()>(&catalog);
let video = &msf.tracks[0];
assert_eq!(video.max_grp_sap_starting_type, None);
assert_eq!(video.max_obj_sap_starting_type, None);
assert_eq!(video.jitter, None);
}
#[test]
fn convert_sap_unknown_codec() {
use hang::catalog::VideoCodec;
let mut video_config = VideoConfig::new(VideoCodec::Unknown("future-codec.01".to_string()));
video_config.container = Container::Legacy;
let mut video_renditions = BTreeMap::new();
video_renditions.insert("video0".to_string(), video_config);
let mut catalog = hang::Catalog::default();
catalog.video.renditions = video_renditions;
let msf = to_msf_media::<()>(&catalog);
let video = &msf.tracks[0];
assert_eq!(video.max_grp_sap_starting_type, None);
assert_eq!(video.max_obj_sap_starting_type, None);
}
}