1use serde::{Deserialize, Serialize};23use crate::{MOQ_VIDEO_H264_TRACK, ProtocolError, Result};45pub const MOQ_CATALOG_EXTENSION_VERSION_V1: u16 = 1;6/// Maximum immutable catalog snapshot accepted from a peer before JSON decoding.7pub const MAX_MOQ_CATALOG_BYTES: usize = 4 * 1024;8pub const MOQ_MEDIA_OBJECT_FORMAT_V1: &str = "sigil/media-frame/1";9pub const MOQ_GOP_GROUP_FORMAT_V1: &str = "sigil/moq-gop/1";10pub const MOQ_VIDEO_TRACK_PRIORITY: u8 = u8::MAX;11121314pub struct MoqTrackDescriptorV1 {15 pub name: String,16 pub priority: u8,17}18192021pub struct MoqVideoCatalogV1 {22 pub track: MoqTrackDescriptorV1,23 pub codec: String,24 pub object_format: String,25 pub group_format: String,26}27282930pub struct MoqCatalogExtensionV1 {31 pub version: u16,32 pub video: MoqVideoCatalogV1,33}3435impl MoqCatalogExtensionV1 {36 pub fn video_h264() -> Self {37 Self {38 version: MOQ_CATALOG_EXTENSION_VERSION_V1,39 video: MoqVideoCatalogV1 {40 track: MoqTrackDescriptorV1 {41 name: MOQ_VIDEO_H264_TRACK.to_owned(),42 priority: MOQ_VIDEO_TRACK_PRIORITY,43 },44 codec: "h264".to_owned(),45 object_format: MOQ_MEDIA_OBJECT_FORMAT_V1.to_owned(),46 group_format: MOQ_GOP_GROUP_FORMAT_V1.to_owned(),47 },48 }49 }5051 pub fn validate(&self) -> Result<()> {52 if self.version != MOQ_CATALOG_EXTENSION_VERSION_V1 {53 return Err(ProtocolError::InvalidMessage {54 message_type: "Goq MoQ catalog extension",55 reason: "unsupported extension version",56 });57 }58 if self.video.track.name != MOQ_VIDEO_H264_TRACK {59 return Err(ProtocolError::InvalidMessage {60 message_type: "Goq MoQ catalog extension",61 reason: "unexpected video track name",62 });63 }64 if self.video.track.priority != MOQ_VIDEO_TRACK_PRIORITY {65 return Err(ProtocolError::InvalidMessage {66 message_type: "Goq MoQ catalog extension",67 reason: "unexpected video track priority",68 });69 }70 if self.video.codec != "h264" {71 return Err(ProtocolError::InvalidMessage {72 message_type: "Goq MoQ catalog extension",73 reason: "unsupported video codec",74 });75 }76 if self.video.object_format != MOQ_MEDIA_OBJECT_FORMAT_V1 {77 return Err(ProtocolError::InvalidMessage {78 message_type: "Goq MoQ catalog extension",79 reason: "unsupported media object format",80 });81 }82 if self.video.group_format != MOQ_GOP_GROUP_FORMAT_V1 {83 return Err(ProtocolError::InvalidMessage {84 message_type: "Goq MoQ catalog extension",85 reason: "unsupported GOP group format",86 });87 }88 Ok(())89 }90}9192/// The immutable catalog.json document both peers must agree on byte-for-byte:93/// a default (empty) Hang catalog envelope carrying the Goq extension. This is94/// the single definition — the host producer, Portal subscriber, and probe all95/// consume it from here so the wire shape cannot drift between peers.969798pub struct GoqCatalogDocument {99 100 pub media: hang::Catalog,101 pub goq: MoqCatalogExtensionV1,102}103104impl GoqCatalogDocument {105 pub fn video_h264() -> Self {106 Self {107 media: hang::Catalog::default(),108 goq: MoqCatalogExtensionV1::video_h264(),109 }110 }111112 pub fn validate(&self) -> Result<()> {113 if self.media != hang::Catalog::default() {114 return Err(ProtocolError::InvalidMessage {115 message_type: "Goq MoQ catalog document",116 reason: "catalog must not advertise a standard Hang rendition for enveloped media",117 });118 }119 self.goq.validate()120 }121}122123124mod tests {125 use super::*;126127 128 fn goq_catalog_extension_has_a_stable_golden_document() {129 let extension = MoqCatalogExtensionV1::video_h264();130 extension.validate().unwrap();131 assert_eq!(132 serde_json::to_string(&extension).unwrap(),133 r#"{"version":1,"video":{"track":{"name":"video/h264","priority":255},"codec":"h264","objectFormat":"sigil/media-frame/1","groupFormat":"sigil/moq-gop/1"}}"#134 );135 }136137 138 fn goq_catalog_document_has_a_stable_golden_envelope() {139 let document = GoqCatalogDocument::video_h264();140 document.validate().unwrap();141 let json = serde_json::to_value(&document).unwrap();142 assert_eq!(143 json["goq"],144 serde_json::to_value(MoqCatalogExtensionV1::video_h264()).unwrap()145 );146 let round_trip: GoqCatalogDocument = serde_json::from_value(json).unwrap();147 assert_eq!(round_trip, document);148 }149150 151 fn goq_catalog_validation_rejects_every_mismatched_contract_field() {152 let mut cases = Vec::new();153154 let mut value = MoqCatalogExtensionV1::video_h264();155 value.version = 2;156 cases.push(value);157 let mut value = MoqCatalogExtensionV1::video_h264();158 value.video.track.name = "video/other".into();159 cases.push(value);160 let mut value = MoqCatalogExtensionV1::video_h264();161 value.video.track.priority = 0;162 cases.push(value);163 let mut value = MoqCatalogExtensionV1::video_h264();164 value.video.codec = "av1".into();165 cases.push(value);166 let mut value = MoqCatalogExtensionV1::video_h264();167 value.video.object_format = "hang/legacy".into();168 cases.push(value);169 let mut value = MoqCatalogExtensionV1::video_h264();170 value.video.group_format = "hang/gop".into();171 cases.push(value);172173 for invalid in cases {174 assert!(invalid.validate().is_err(), "accepted {invalid:?}");175 }176 }177178 179 fn goq_catalog_extension_rejects_unknown_fields() {180 let json = r#"{"version":1,"video":{"track":{"name":"video/h264","priority":255,"extra":true},"codec":"h264","objectFormat":"sigil/media-frame/1","groupFormat":"sigil/moq-gop/1"}}"#;181 assert!(serde_json::from_str::<MoqCatalogExtensionV1>(json).is_err());182 }183}