diff --git a/Cargo.lock b/Cargo.lock index 058503b4..4f3381f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -856,6 +856,13 @@ dependencies = [ "url", ] +[[package]] +name = "moq-catalog" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "moq-clock" version = "0.4.1" @@ -916,13 +923,14 @@ dependencies = [ [[package]] name = "moq-pub" -version = "0.5.1" +version = "0.6.0" dependencies = [ "anyhow", "bytes", "clap", "env_logger", "log", + "moq-catalog", "moq-native", "moq-transport", "mp4", @@ -1260,9 +1268,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.66" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] @@ -1317,9 +1325,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.33" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] @@ -1639,18 +1647,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.188" +version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" +checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.188" +version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" +checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" dependencies = [ "proc-macro2", "quote", @@ -1769,9 +1777,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" -version = "2.0.32" +version = "2.0.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239814284fd6f1a4ffe4ca893952cdd93c224b6a1571c9a9eadd670295c0c9e2" +checksum = "2f0209b68b3613b093e0ec905354eccaedcfe83b8cb37cbdeae64026c3064c16" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index d3526cc5..0c5fce8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "moq-clock", "moq-dir", "moq-native", + "moq-catalog", ] resolver = "2" diff --git a/moq-catalog/Cargo.toml b/moq-catalog/Cargo.toml new file mode 100644 index 00000000..dfb9d2be --- /dev/null +++ b/moq-catalog/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "moq-catalog" +description = "Media over QUIC" +authors = ["Luke Curley"] +repository = "https://github.com/kixelated/moq-rs" +license = "MIT OR Apache-2.0" + +version = "0.1.0" +edition = "2021" + +keywords = ["quic", "http3", "webtransport", "media", "live"] +categories = ["multimedia", "network-programming", "web-programming"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/moq-catalog/src/lib.rs b/moq-catalog/src/lib.rs new file mode 100644 index 00000000..c234adc0 --- /dev/null +++ b/moq-catalog/src/lib.rs @@ -0,0 +1,190 @@ +//! This module contains the structs and functions for the MoQ catalog format +/// The catalog format is a JSON file that describes the tracks available in a broadcast. +/// +/// The current version of the catalog format is draft-01. +/// https://www.ietf.org/archive/id/draft-ietf-moq-catalogformat-01.html +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Root { + pub version: u16, + + #[serde(rename = "streamingFormat")] + pub streaming_format: u16, + + #[serde(rename = "streamingFormatVersion")] + pub streaming_format_version: String, + + #[serde(rename = "supportsDeltaUpdates")] + pub streaming_delta_updates: bool, + + #[serde(rename = "commonTrackFields")] + pub common_track_fields: CommonTrackFields, + + pub tracks: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct Track { + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + + pub name: String, + + #[serde(rename = "initTrack")] + pub init_track: Option, + + #[serde(rename = "initData")] + pub data_track: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub packaging: Option, + + #[serde(rename = "renderGroup", skip_serializing_if = "Option::is_none")] + pub render_group: Option, + + #[serde(rename = "altGroup", skip_serializing_if = "Option::is_none")] + pub alt_group: Option, + + #[serde(rename = "selectionParams")] + pub selection_params: SelectionParam, + + #[serde(rename = "temporalId", skip_serializing_if = "Option::is_none")] + pub temporal_id: Option, + + #[serde(rename = "spatialId", skip_serializing_if = "Option::is_none")] + pub spatial_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub depends: Option>, +} + +impl Track { + #[allow(dead_code)] // TODO use + fn with_common(&mut self, common: &CommonTrackFields) { + if self.namespace.is_none() { + self.namespace.clone_from(&common.namespace); + } + if self.packaging.is_none() { + self.packaging.clone_from(&common.packaging); + } + if self.render_group.is_none() { + self.render_group = common.render_group; + } + if self.alt_group.is_none() { + self.alt_group = common.alt_group; + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] +pub enum TrackPackaging { + #[serde(rename = "cmaf")] + #[default] + Cmaf, + + #[serde(rename = "loc")] + Loc, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct SelectionParam { + pub codec: Option, + + #[serde(rename = "mimeType")] + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub framerate: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub bitrate: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub samplerate: Option, + + #[serde(rename = "channelConfig", skip_serializing_if = "Option::is_none")] + pub channel_config: Option, + + #[serde(rename = "displayWidth", skip_serializing_if = "Option::is_none")] + pub display_width: Option, + + #[serde(rename = "displayHeight", skip_serializing_if = "Option::is_none")] + pub display_height: Option, + + #[serde(rename = "lang", skip_serializing_if = "Option::is_none")] + pub language: Option, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct CommonTrackFields { + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub packaging: Option, + + #[serde(rename = "renderGroup", skip_serializing_if = "Option::is_none")] + pub render_group: Option, + + #[serde(rename = "altGroup", skip_serializing_if = "Option::is_none")] + pub alt_group: Option, +} + +impl CommonTrackFields { + /// Serialize function to conditionally include fields based on their commonality amoung tracks + pub fn from_tracks(tracks: &mut [Track]) -> Self { + if tracks.is_empty() { + return Default::default(); + } + + // Use the first track as the basis + let mut common = Self { + namespace: tracks[0].namespace.clone(), + packaging: tracks[0].packaging.clone(), + render_group: tracks[0].render_group, + alt_group: tracks[0].alt_group, + }; + + // Loop over the other tracks to check if they have the same values + for track in &mut tracks[1..] { + if track.namespace != common.namespace { + common.namespace = None; + } + if track.packaging != common.packaging { + common.packaging = None; + } + if track.render_group != common.render_group { + common.render_group = None + } + if track.alt_group != common.alt_group { + common.alt_group = None; + } + } + + // Loop again to remove the common fields from the tracks + for track in tracks { + if common.namespace.is_some() { + track.namespace = None; + } + if track.packaging.is_some() { + track.packaging = None; + } + if track.render_group.is_some() { + track.render_group = None; + } + if track.alt_group.is_some() { + track.alt_group = None; + } + } + + common + } +} diff --git a/moq-pub/Cargo.toml b/moq-pub/Cargo.toml index 0fc0cd22..06063b06 100644 --- a/moq-pub/Cargo.toml +++ b/moq-pub/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Mike English", "Luke Curley"] repository = "https://github.com/kixelated/moq-rs" license = "MIT OR Apache-2.0" -version = "0.5.1" +version = "0.6.0" edition = "2021" keywords = ["quic", "http3", "webtransport", "media", "live"] @@ -16,6 +16,7 @@ categories = ["multimedia", "network-programming", "web-programming"] [dependencies] moq-native = { path = "../moq-native", version = "0.2" } moq-transport = { path = "../moq-transport", version = "0.5" } +moq-catalog = { path = "../moq-catalog", version = "0.1" } url = "2" bytes = "1" diff --git a/moq-pub/src/media.rs b/moq-pub/src/media.rs index ba384d2f..7f8c8794 100644 --- a/moq-pub/src/media.rs +++ b/moq-pub/src/media.rs @@ -2,7 +2,6 @@ use anyhow::{self, Context}; use bytes::{Buf, Bytes}; use moq_transport::serve::{GroupWriter, GroupsWriter, TrackWriter, TracksWriter}; use mp4::{self, ReadBox, TrackType}; -use serde_json::json; use std::cmp::max; use std::collections::HashMap; use std::io::Cursor; @@ -153,13 +152,19 @@ impl Media { // Produce the catalog for trak in &moov.traks { - let mut track = json!({ - "container": "mp4", - "init_track": "0.mp4", - "data_track": format!("{}.m4s", trak.tkhd.track_id), - }); + let mut selection_params = moq_catalog::SelectionParam::default(); + + let mut track = moq_catalog::Track { + init_track: Some("0.mp4".to_string()), + data_track: Some(format!("{}.m4s", trak.tkhd.track_id)), + namespace: Some(self.broadcast.namespace.clone()), + packaging: Some(moq_catalog::TrackPackaging::Cmaf), + render_group: Some(1), + ..Default::default() + }; let stsd = &trak.mdia.minf.stbl.stsd; + if let Some(avc1) = &stsd.avc1 { // avc1[.PPCCLL] // @@ -176,10 +181,10 @@ impl Media { let codec = rfc6381_codec::Codec::avc1(profile, constraints, level); let codec_str = codec.to_string(); - track["kind"] = json!("video"); - track["codec"] = json!(codec_str); - track["width"] = json!(width); - track["height"] = json!(height); + track.name = format!("video_{}p", height); + selection_params.codec = Some(codec_str); + selection_params.width = Some(width.into()); + selection_params.height = Some(height.into()); } else if let Some(_hev1) = &stsd.hev1 { // TODO https://github.com/gpac/mp4box.js/blob/325741b592d910297bf609bc7c400fc76101077b/src/box-codecs.js#L106 anyhow::bail!("HEVC not yet supported") @@ -192,25 +197,24 @@ impl Media { .dec_config; let codec_str = format!("mp4a.{:02x}.{}", desc.object_type_indication, desc.dec_specific.profile); - track["kind"] = json!("audio"); - track["codec"] = json!(codec_str); - track["channel_count"] = json!(mp4a.channelcount); - track["sample_rate"] = json!(mp4a.samplerate.value()); - track["sample_size"] = json!(mp4a.samplesize); + track.name = "audio".to_string(); + selection_params.codec = Some(codec_str); + selection_params.channel_config = Some(mp4a.channelcount.to_string()); + selection_params.samplerate = Some(mp4a.samplerate.value().into()); let bitrate = max(desc.max_bitrate, desc.avg_bitrate); if bitrate > 0 { - track["bit_rate"] = json!(bitrate); + selection_params.bitrate = Some(bitrate); } } else if let Some(vp09) = &stsd.vp09 { // https://github.com/gpac/mp4box.js/blob/325741b592d910297bf609bc7c400fc76101077b/src/box-codecs.js#L238 let vpcc = &vp09.vpcc; let codec_str = format!("vp09.0.{:02x}.{:02x}.{:02x}", vpcc.profile, vpcc.level, vpcc.bit_depth); - track["kind"] = json!("video"); - track["codec"] = json!(codec_str); - track["width"] = json!(vp09.width); // no idea if this needs to be multiplied - track["height"] = json!(vp09.height); // no idea if this needs to be multiplied + track.name = format!("video_{}p", vp09.height); + selection_params.codec = Some(codec_str); + selection_params.width = Some(vp09.width.into()); + selection_params.height = Some(vp09.height.into()); // TODO Test if this actually works; I'm just guessing based on mp4box.js anyhow::bail!("VP9 not yet supported") @@ -219,14 +223,22 @@ impl Media { anyhow::bail!("unknown codec for track: {}", trak.tkhd.track_id); } + track.selection_params = selection_params; + tracks.push(track); } - let catalog = json!({ - "tracks": tracks - }); + let catalog = moq_catalog::Root { + version: 1, + streaming_format: 1, + streaming_format_version: "0.2".to_string(), + streaming_delta_updates: true, + common_track_fields: moq_catalog::CommonTrackFields::from_tracks(&mut tracks), + tracks, + }; let catalog_str = serde_json::to_string_pretty(&catalog)?; + log::info!("catalog: {}", catalog_str); // Create a single fragment for the segment.