diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index 47178addc..f9a02f02f 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -10,6 +10,11 @@ const TrackSchema = z.object({ // Mirrors AudioDecoderConfig // https://w3c.github.io/webcodecs/#audio-decoder-config export const AudioConfigSchema = z.object({ + // Optional reference to another broadcast that publishes this track, expressed + // relative to the broadcast that served this catalog (e.g. "../source"). + // If unset, the track lives in the same broadcast as the catalog. + broadcast: z.optional(z.string()), + // See: https://w3c.github.io/webcodecs/codec_registry.html codec: z.string(), diff --git a/js/hang/src/catalog/index.ts b/js/hang/src/catalog/index.ts index ecf4e9daf..3e27559f5 100644 --- a/js/hang/src/catalog/index.ts +++ b/js/hang/src/catalog/index.ts @@ -4,6 +4,7 @@ export * from "./chat"; export * from "./container"; export * from "./integers"; export * from "./location"; +export * from "./path"; export * from "./preview"; export * from "./priority"; export * from "./root"; diff --git a/js/hang/src/catalog/path.ts b/js/hang/src/catalog/path.ts new file mode 100644 index 000000000..c7112f34e --- /dev/null +++ b/js/hang/src/catalog/path.ts @@ -0,0 +1,33 @@ +import { Path } from "@moq/lite"; + +/** + * Resolve a relative broadcast reference against the path of the broadcast that served the catalog. + * + * `..` segments pop the last segment of the base path; named segments are appended. + * Excess `..` clamps at the root, returning an empty path. An empty `rel` returns the base + * path unchanged. + * + * Mirrors the Rust `Path::resolve(&PathRelative)` helper used by hang catalogs to express + * cross-broadcast track references. + * + * @example + * ```typescript + * resolveBroadcast(Path.from("a/b/c"), "../source"); // "a/b/source" + * resolveBroadcast(Path.from("a/b"), "x/y"); // "a/b/x/y" + * resolveBroadcast(Path.from("a"), "../../x"); // "x" + * ``` + */ +export function resolveBroadcast(base: Path.Valid, rel: string): Path.Valid { + const baseSegments = base === "" ? [] : base.split("/"); + const relSegments = rel.split("/").filter((s) => s !== ""); + + for (const seg of relSegments) { + if (seg === "..") { + baseSegments.pop(); + } else { + baseSegments.push(seg); + } + } + + return Path.from(...baseSegments); +} diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index dffb5e7cb..0c746229d 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -9,6 +9,11 @@ const TrackSchema = z.object({ // Based on VideoDecoderConfig export const VideoConfigSchema = z.object({ + // Optional reference to another broadcast that publishes this track, expressed + // relative to the broadcast that served this catalog (e.g. "../source"). + // If unset, the track lives in the same broadcast as the catalog. + broadcast: z.optional(z.string()), + // See: https://w3c.github.io/webcodecs/codec_registry.html codec: z.string(), diff --git a/js/watch/src/audio/decoder.ts b/js/watch/src/audio/decoder.ts index 2facbfafa..9dbaeeb24 100644 --- a/js/watch/src/audio/decoder.ts +++ b/js/watch/src/audio/decoder.ts @@ -174,7 +174,9 @@ export class Decoder { const config = effect.get(this.source.config); if (!config) return; - const active = effect.get(broadcast.active); + // Honor a per-rendition `broadcast` override: resolve to the source broadcast if set, + // otherwise use the catalog's own broadcast. + const active = broadcast.trackBroadcast(effect, config.broadcast); if (!active) return; const sub = active.subscribe(track, Catalog.PRIORITY.audio); diff --git a/js/watch/src/audio/mse.ts b/js/watch/src/audio/mse.ts index ab3ce0942..9f6b14948 100644 --- a/js/watch/src/audio/mse.ts +++ b/js/watch/src/audio/mse.ts @@ -52,15 +52,17 @@ export class Mse implements Backend { const broadcast = effect.get(this.source.broadcast); if (!broadcast) return; - const active = effect.get(broadcast.active); - if (!active) return; - const track = effect.get(this.source.track); if (!track) return; const config = effect.get(this.source.config); if (!config) return; + // Honor a per-rendition `broadcast` override: resolve to the source broadcast if set, + // otherwise use the catalog's own broadcast. + const active = broadcast.trackBroadcast(effect, config.broadcast); + if (!active) return; + const mime = `audio/mp4; codecs="${config.codec}"`; const sourceBuffer = mediaSource.addSourceBuffer(mime); diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 05dc97030..d286f8586 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -159,6 +159,30 @@ export class Broadcast { }); } + /** + * Resolve the `Moq.Broadcast` that publishes a given track. + * + * If `configBroadcast` is set, treat it as a path relative to this broadcast's name and + * subscribe to the resolved broadcast on the same connection. Otherwise return the catalog's + * own active broadcast. + * + * The lifetime of any newly-opened broadcast is tied to the provided `Effect`. + */ + trackBroadcast(effect: Effect, configBroadcast: string | undefined): Moq.Broadcast | undefined { + const active = effect.get(this.active); + if (!active) return undefined; + if (!configBroadcast) return active; + + const conn = effect.get(this.connection); + if (!conn) return undefined; + + const basePath = effect.get(this.name); + const resolved = Catalog.resolveBroadcast(basePath, configBroadcast); + const remote = conn.consume(resolved); + effect.cleanup(() => remote.close()); + return remote; + } + close() { this.signals.close(); } diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index fed21f65c..15f6d8dfb 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -74,7 +74,9 @@ export class Decoder implements Backend { } const [_, source, track, config] = values; - const broadcast = effect.get(source.active); + // Honor a per-rendition `broadcast` override: subscribe on the resolved source + // broadcast instead of the catalog's broadcast. Falls back to the catalog's broadcast. + const broadcast = source.trackBroadcast(effect, config.broadcast); if (!broadcast) return; // Start a new pending effect. diff --git a/js/watch/src/video/mse.ts b/js/watch/src/video/mse.ts index cf5af6abe..2f160cf99 100644 --- a/js/watch/src/video/mse.ts +++ b/js/watch/src/video/mse.ts @@ -50,15 +50,17 @@ export class Mse implements Backend { const broadcast = effect.get(this.source.broadcast); if (!broadcast) return; - const active = effect.get(broadcast.active); - if (!active) return; - const track = effect.get(this.source.track); if (!track) return; const config = effect.get(this.source.config); if (!config) return; + // Honor a per-rendition `broadcast` override: resolve to the source broadcast if set, + // otherwise use the catalog's own broadcast. + const active = broadcast.trackBroadcast(effect, config.broadcast); + if (!active) return; + const mime = `video/mp4; codecs="${config.codec}"`; const sourceBuffer = mediaSource.addSourceBuffer(mime); diff --git a/rs/hang/examples/subscribe.rs b/rs/hang/examples/subscribe.rs index a365e089f..a7cf8c736 100644 --- a/rs/hang/examples/subscribe.rs +++ b/rs/hang/examples/subscribe.rs @@ -68,13 +68,28 @@ async fn run_subscribe(mut consumer: moq_lite::OriginConsumer) -> anyhow::Result codec = %config.codec, width = ?config.coded_width, height = ?config.coded_height, + broadcast_override = ?config.broadcast.as_ref().map(|p| p.as_str()), "subscribing to video track" ); + // If the rendition references a different broadcast (e.g. a source feed that this + // catalog only sidecars), resolve it relative to the catalog's broadcast path and + // wait for the announcement. Otherwise subscribe on the catalog's broadcast. + let track_broadcast = match config.broadcast.as_ref() { + Some(rel) => { + let resolved = path.resolve(rel); + consumer + .announced_broadcast(&resolved) + .await + .ok_or_else(|| anyhow::anyhow!("source broadcast unavailable: {resolved}"))? + } + None => broadcast.clone(), + }; + // Subscribe to the video track. let track = moq_lite::Track::new(name.clone()); - let track_consumer = broadcast.subscribe_track( + let track_consumer = track_broadcast.subscribe_track( &track, moq_lite::Subscription { priority: 1, diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index bfdf2595f..625a4f867 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -46,6 +46,7 @@ fn create_track(broadcast: &mut moq_lite::BroadcastProducer) -> anyhow::Result, + // The codec, see the registry for details: // https://w3c.github.io/webcodecs/codec_registry.html #[serde_as(as = "DisplayFromStr")] diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 748795bc9..a1a3cb3f8 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -136,6 +136,7 @@ mod test { video_renditions.insert( "video".to_string(), VideoConfig { + broadcast: None, codec: H264 { profile: 0x64, constraints: 0x00, @@ -160,6 +161,7 @@ mod test { audio_renditions.insert( "audio".to_string(), AudioConfig { + broadcast: None, codec: Opus, sample_rate: 48_000, channel_count: 2, @@ -189,4 +191,38 @@ mod test { let output = decoded.to_string().expect("failed to encode"); assert_eq!(encoded, output, "wrong encoded output"); } + + #[test] + fn rendition_with_broadcast_override() { + // Decode a catalog where one rendition references a track in a sibling broadcast, + // and verify the `broadcast` field round-trips through serde. + let encoded = r#"{ + "video": { + "renditions": { + "video": { + "broadcast": "../source", + "codec": "avc1.64001f", + "codedWidth": 1280, + "codedHeight": 720, + "container": {"kind": "legacy"} + } + } + } + }"#; + + let parsed = Catalog::from_str(encoded).expect("failed to decode"); + let rendition = parsed.video.renditions.get("video").expect("missing rendition"); + assert_eq!( + rendition.broadcast.as_ref().map(|p| p.as_str()), + Some("../source"), + "broadcast field did not deserialize" + ); + + // Re-encoding preserves the broadcast field. + let output = parsed.to_string().expect("failed to encode"); + assert!( + output.contains(r#""broadcast":"../source""#), + "broadcast field missing from re-encoded JSON: {output}" + ); + } } diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index d972ac2a9..bcfc51c17 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -107,6 +107,15 @@ pub struct Display { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VideoConfig { + /// Optional reference to another broadcast that publishes this track, expressed + /// relative to the broadcast that served this catalog. If unset, the track lives + /// in the same broadcast as the catalog. + /// + /// This allows a worker to author a downstream catalog that points unchanged + /// renditions at the source broadcast without re-publishing the bytes. + #[serde(default)] + pub broadcast: Option, + /// The codec, see the registry for details: /// #[serde_as(as = "DisplayFromStr")] diff --git a/rs/moq-lite/src/path.rs b/rs/moq-lite/src/path.rs index 0321897ca..ba1d4c2d6 100644 --- a/rs/moq-lite/src/path.rs +++ b/rs/moq-lite/src/path.rs @@ -232,6 +232,39 @@ impl<'a> Path<'a> { Path(Cow::Owned(format!("{}/{}", self.0, other.as_str()))) } } + + /// Resolve a [`PathRelative`] against this path. + /// + /// `..` segments in `rel` pop the last segment of the base; named segments are appended. + /// Excess `..` clamps at the root, returning an empty path. An empty `rel` returns this + /// path unchanged (as an owned copy). + /// + /// # Examples + /// ``` + /// use moq_lite::{Path, PathRelative}; + /// + /// let base = Path::new("a/b/c"); + /// assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/b/d"); + /// assert_eq!(base.resolve(&PathRelative::new("d")).as_str(), "a/b/c/d"); + /// assert_eq!(base.resolve(&PathRelative::new("../../../../x")).as_str(), "x"); + /// ``` + pub fn resolve(&self, rel: &PathRelative<'_>) -> PathOwned { + let mut segments: Vec<&str> = if self.0.is_empty() { + Vec::new() + } else { + self.0.split('/').collect() + }; + + for seg in rel.as_str().split('/').filter(|s| !s.is_empty()) { + if seg == ".." { + segments.pop(); + } else { + segments.push(seg); + } + } + + Path(Cow::Owned(segments.join("/"))) + } } impl<'a> From<&'a str> for Path<'a> { @@ -320,6 +353,145 @@ impl<'de: 'a, 'a> serde::Deserialize<'de> for Path<'a> { } } +/// An owned version of [`PathRelative`] with a `'static` lifetime. +pub type PathRelativeOwned = PathRelative<'static>; + +/// A relative broadcast path, used to reference broadcasts from another broadcast's catalog. +/// +/// Unlike [`Path`] (which represents an absolute reference within the broadcast namespace), +/// `PathRelative` may contain `..` segments to walk up the namespace and is meaningful only +/// when resolved against a base [`Path`] via [`Path::resolve`]. +/// +/// `PathRelative` is intended for off-wire use (e.g. catalog references). It does not implement +/// `Encode`/`Decode` and should not appear in announce/subscribe messages. +/// +/// Leading and trailing slashes are trimmed on creation; consecutive internal slashes collapse +/// to a single slash, mirroring [`Path`]. +/// +/// # Examples +/// ``` +/// use moq_lite::{Path, PathRelative}; +/// +/// let rel = PathRelative::new("../source"); +/// assert_eq!(Path::new("a/b").resolve(&rel).as_str(), "a/source"); +/// ``` +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct PathRelative<'a>(Cow<'a, str>); + +impl<'a> PathRelative<'a> { + /// Create a new `PathRelative` from a string slice. + /// + /// Leading and trailing slashes are trimmed. Multiple consecutive internal slashes collapse + /// to single slashes. + pub fn new(s: &'a str) -> Self { + let trimmed = s.trim_start_matches('/').trim_end_matches('/'); + + if trimmed.contains("//") { + let normalized = trimmed + .split('/') + .filter(|s| !s.is_empty()) + .collect::>() + .join("/"); + Self(Cow::Owned(normalized)) + } else { + Self(Cow::Borrowed(trimmed)) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn empty() -> PathRelative<'static> { + PathRelative(Cow::Borrowed("")) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn to_owned(&self) -> PathRelativeOwned { + PathRelative(Cow::Owned(self.0.to_string())) + } + + pub fn into_owned(self) -> PathRelativeOwned { + PathRelative(Cow::Owned(self.0.into_owned())) + } + + pub fn borrow(&'a self) -> PathRelative<'a> { + PathRelative(Cow::Borrowed(&self.0)) + } +} + +impl<'a> From<&'a str> for PathRelative<'a> { + fn from(s: &'a str) -> Self { + Self::new(s) + } +} + +impl<'a> From<&'a String> for PathRelative<'a> { + fn from(s: &'a String) -> Self { + Self::new(s) + } +} + +impl From for PathRelative<'_> { + fn from(s: String) -> Self { + // Owned variant: avoid round-tripping through &str when no normalization is needed. + let trimmed = s.trim_start_matches('/').trim_end_matches('/'); + + if trimmed.contains("//") { + let normalized = trimmed + .split('/') + .filter(|s| !s.is_empty()) + .collect::>() + .join("/"); + Self(Cow::Owned(normalized)) + } else if trimmed == s { + Self(Cow::Owned(s)) + } else { + Self(Cow::Owned(trimmed.to_string())) + } + } +} + +impl Default for PathRelative<'_> { + fn default() -> Self { + Self(Cow::Borrowed("")) + } +} + +impl AsRef for PathRelative<'_> { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Display for PathRelative<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// Owned-only deserialization. We use `String::deserialize` so that owned deserializers +// (e.g. `serde_json::from_slice`) work — the borrowed form `<&str>::deserialize` requires +// `'de: 'a`, which is unsatisfiable when `'a = 'static`. +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for PathRelative<'static> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(PathRelative::from(s)) + } +} + /// A deduplicated list of path prefixes. /// /// Automatically removes exact duplicates and overlapping prefixes on construction. @@ -963,4 +1135,56 @@ mod tests { let b = PathPrefixes::new(["bar", "foo"]); assert_eq!(a, b); } + + #[test] + fn test_path_relative_normalize() { + assert_eq!(PathRelative::new("foo").as_str(), "foo"); + assert_eq!(PathRelative::new("/foo/").as_str(), "foo"); + assert_eq!(PathRelative::new("foo//bar").as_str(), "foo/bar"); + assert_eq!(PathRelative::new("../foo").as_str(), "../foo"); + assert_eq!(PathRelative::new("../../a/b").as_str(), "../../a/b"); + assert!(PathRelative::new("").is_empty()); + } + + #[test] + fn test_resolve_no_dotdot() { + let base = Path::new("a/b"); + assert_eq!(base.resolve(&PathRelative::new("c")).as_str(), "a/b/c"); + assert_eq!(base.resolve(&PathRelative::new("c/d")).as_str(), "a/b/c/d"); + } + + #[test] + fn test_resolve_empty_rel_returns_base() { + let base = Path::new("a/b"); + assert_eq!(base.resolve(&PathRelative::new("")).as_str(), "a/b"); + } + + #[test] + fn test_resolve_single_dotdot() { + let base = Path::new("a/b/c"); + assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/b/d"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), "a/b"); + } + + #[test] + fn test_resolve_multiple_dotdot() { + let base = Path::new("a/b/c"); + assert_eq!(base.resolve(&PathRelative::new("../../x")).as_str(), "a/x"); + assert_eq!(base.resolve(&PathRelative::new("../../../x")).as_str(), "x"); + } + + #[test] + fn test_resolve_dotdot_clamps_at_root() { + let base = Path::new("a"); + // Excess `..` clamps at the root, returning an empty / single-segment path. + assert_eq!(base.resolve(&PathRelative::new("../../../foo")).as_str(), "foo"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); + } + + #[test] + fn test_resolve_empty_base() { + let base = Path::empty(); + assert_eq!(base.resolve(&PathRelative::new("foo")).as_str(), "foo"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); + } } diff --git a/rs/moq-mux/src/import/aac.rs b/rs/moq-mux/src/import/aac.rs index 3476147e5..4d80466a6 100644 --- a/rs/moq-mux/src/import/aac.rs +++ b/rs/moq-mux/src/import/aac.rs @@ -120,6 +120,7 @@ impl Aac { let jitter = moq_lite::Time::from_micros(frame_duration_us).ok(); let audio_config = hang::catalog::AudioConfig { + broadcast: None, codec: hang::catalog::AAC { profile: config.profile, } diff --git a/rs/moq-mux/src/import/av01.rs b/rs/moq-mux/src/import/av01.rs index 8825b77c2..3c89cfd6b 100644 --- a/rs/moq-mux/src/import/av01.rs +++ b/rs/moq-mux/src/import/av01.rs @@ -52,6 +52,7 @@ impl Av01 { fn init(&mut self, seq_header: &SequenceHeaderObu) -> anyhow::Result<()> { let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: Some(seq_header.max_frame_width as u32), coded_height: Some(seq_header.max_frame_height as u32), codec: hang::catalog::AV1 { @@ -112,6 +113,7 @@ impl Av01 { /// Initialize with minimal config if sequence header parsing fails fn init_minimal(&mut self) -> anyhow::Result<()> { let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: None, coded_height: None, codec: hang::catalog::AV1 { @@ -184,6 +186,7 @@ impl Av01 { let twelve_bit = ((data[2] >> 5) & 0x01) == 1; let config = hang::catalog::VideoConfig { + broadcast: None, // Resolution unknown from av1C - will be updated when first sequence header arrives coded_width: None, coded_height: None, diff --git a/rs/moq-mux/src/import/avc1.rs b/rs/moq-mux/src/import/avc1.rs index f9ab1f380..e3dedae8a 100644 --- a/rs/moq-mux/src/import/avc1.rs +++ b/rs/moq-mux/src/import/avc1.rs @@ -73,6 +73,7 @@ impl Avc1 { } let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: if width > 0 { Some(width) } else { None }, coded_height: if height > 0 { Some(height) } else { None }, codec: hang::catalog::H264 { diff --git a/rs/moq-mux/src/import/avc3.rs b/rs/moq-mux/src/import/avc3.rs index 7168013f6..481796935 100644 --- a/rs/moq-mux/src/import/avc3.rs +++ b/rs/moq-mux/src/import/avc3.rs @@ -70,6 +70,7 @@ impl Avc3 { | ((sps.constraint_set5_flag as u8) << 2); let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: Some(sps.width), coded_height: Some(sps.height), codec: hang::catalog::H264 { diff --git a/rs/moq-mux/src/import/fmp4.rs b/rs/moq-mux/src/import/fmp4.rs index c1045fa5e..3fdd5ed91 100644 --- a/rs/moq-mux/src/import/fmp4.rs +++ b/rs/moq-mux/src/import/fmp4.rs @@ -267,6 +267,7 @@ impl Fmp4 { avcc.encode_body(&mut description)?; VideoConfig { + broadcast: None, coded_width: Some(avc1.visual.width as _), coded_height: Some(avc1.visual.height as _), codec: H264 { @@ -290,6 +291,7 @@ impl Fmp4 { mp4_atom::Codec::Hev1(hev1) => self.init_h265(true, &hev1.hvcc, &hev1.visual, container)?, mp4_atom::Codec::Hvc1(hvc1) => self.init_h265(false, &hvc1.hvcc, &hvc1.visual, container)?, mp4_atom::Codec::Vp08(vp08) => VideoConfig { + broadcast: None, codec: VideoCodec::VP8, description: Default::default(), coded_width: Some(vp08.visual.width as _), @@ -308,6 +310,7 @@ impl Fmp4 { let vpcc = &vp09.vpcc; VideoConfig { + broadcast: None, codec: VP9 { profile: vpcc.profile, level: vpcc.level, @@ -336,6 +339,7 @@ impl Fmp4 { let av1c = &av01.av1c; VideoConfig { + broadcast: None, codec: AV1 { profile: av1c.seq_profile, level: av1c.seq_level_idx_0, @@ -385,6 +389,7 @@ impl Fmp4 { hvcc.encode_body(&mut description)?; Ok(VideoConfig { + broadcast: None, codec: H265 { in_band, profile_space: hvcc.general_profile_space, @@ -438,6 +443,7 @@ impl Fmp4 { let description = build_aac_audio_specific_config(profile, sample_rate, channel_count); AudioConfig { + broadcast: None, codec: AAC { profile }.into(), sample_rate, channel_count, @@ -449,6 +455,7 @@ impl Fmp4 { } mp4_atom::Codec::Opus(opus) => { AudioConfig { + broadcast: None, codec: AudioCodec::Opus, sample_rate: opus.audio.sample_rate.integer() as _, channel_count: opus.audio.channel_count as _, diff --git a/rs/moq-mux/src/import/hev1.rs b/rs/moq-mux/src/import/hev1.rs index c2a474e6d..574680027 100644 --- a/rs/moq-mux/src/import/hev1.rs +++ b/rs/moq-mux/src/import/hev1.rs @@ -60,6 +60,7 @@ impl Hev1 { let vui_data = sps.rbsp.vui_parameters.as_ref().map(VuiData::new).unwrap_or_default(); let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: Some(sps.rbsp.cropped_width() as u32), coded_height: Some(sps.rbsp.cropped_height() as u32), codec: hang::catalog::H265 { diff --git a/rs/moq-mux/src/import/opus.rs b/rs/moq-mux/src/import/opus.rs index 25c14b8a8..5834a3c51 100644 --- a/rs/moq-mux/src/import/opus.rs +++ b/rs/moq-mux/src/import/opus.rs @@ -58,6 +58,7 @@ impl Opus { let track = broadcast.unique_track(".opus")?; let audio_config = hang::catalog::AudioConfig { + broadcast: None, codec: hang::catalog::AudioCodec::Opus, sample_rate: config.sample_rate, channel_count: config.channel_count, diff --git a/rs/moq-mux/src/msf.rs b/rs/moq-mux/src/msf.rs index a924d2b3e..680045ec7 100644 --- a/rs/moq-mux/src/msf.rs +++ b/rs/moq-mux/src/msf.rs @@ -98,6 +98,7 @@ mod test { video_renditions.insert( "video0.avc3".to_string(), VideoConfig { + broadcast: None, codec: H264 { profile: 0x64, constraints: 0x00, @@ -122,6 +123,7 @@ mod test { audio_renditions.insert( "audio0".to_string(), AudioConfig { + broadcast: None, codec: AudioCodec::Opus, sample_rate: 48_000, channel_count: 2, @@ -177,6 +179,7 @@ mod test { video_renditions.insert( "video0.m4s".to_string(), VideoConfig { + broadcast: None, codec: H264 { profile: 0x64, constraints: 0x00, @@ -226,6 +229,7 @@ mod test { video_renditions.insert( "video0.m4s".to_string(), VideoConfig { + broadcast: None, codec: H264 { profile: 0x64, constraints: 0x00,