From 7a4cf25db40a4eefea20ce974b2ef7e62855a749 Mon Sep 17 00:00:00 2001 From: Michael Stuurman <289971156+michaelstuurman@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:30:29 +0100 Subject: [PATCH 1/4] feat(moq-c)!: supply bitrate and group duration hints A publisher that knows its encoding can now state it, so the catalog is published from those facts instead of changing as they are measured: - moq_audio_init.hint (moq_audio_hint): bitrate, carried on moq_mux::import::AudioInit into the AudioConfig the importer publishes. - moq_json_snapshot_config, moq_json_stream_config, moq_binary_config: bitrate, via a new bitrate on moq_mux::json::Config and binary::Config. - moq_video_hint and moq_audio_hint: group_duration_us, passed to Reserved::with_hls_hint so the HLS records are in the first catalog rather than added once the first group is written. Every field is an appended has_*/value pair, so a zeroed struct behaves as before. The struct layouts grow, so C callers rebuild (MIGRATE.md). --- rs/moq-c/MIGRATE.md | 5 + rs/moq-c/src/api.rs | 125 ++++++++++++-- rs/moq-c/src/publish.rs | 42 ++++- rs/moq-c/src/test.rs | 276 ++++++++++++++++++++++++++++++- rs/moq-mux/src/binary.rs | 11 ++ rs/moq-mux/src/catalog/tracks.rs | 3 +- rs/moq-mux/src/import/init.rs | 4 + rs/moq-mux/src/import/track.rs | 7 +- rs/moq-mux/src/json.rs | 11 ++ 9 files changed, 455 insertions(+), 29 deletions(-) diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 31737ddda7e..eb9a6fa247e 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Bitrate and group duration hints + +`moq_audio_init` gains `hint` (a `moq_audio_hint` with `bitrate`/`has_bitrate` and `group_duration_us`/`has_group_duration`), `moq_video_hint` gains `group_duration_us`/`has_group_duration`, and `moq_json_snapshot_config`, `moq_json_stream_config`, and `moq_binary_config` gain `bitrate`/`has_bitrate`. +The fields are appended, so a zero- or designated-initialized struct behaves as before: a bitrate it leaves out is still estimated, and HLS records still declare their duration from the first group. The struct layouts grow, so rebuild against the matching header and library. + ### Paths with an empty segment are refused ([#6648](https://github.com/moq-dev/moq/pull/6648)) Every function taking a path, such as `moq_origin_create_broadcast` and `moq_origin_request`, returns an error for a path with a leading, trailing, or doubled `/` instead of collapsing it. Pass the canonical path, such as `room/cam`. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index cbb48b75c08..6333d11b431 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -176,6 +176,52 @@ pub struct moq_audio_init { pub label: *const c_char, /// Length of `label` in bytes. pub label_len: usize, + + /// Catalog fields the init bytes cannot reveal. Zeroed means none. + pub hint: moq_audio_hint, +} + +/// Optional catalog fields for [moq_audio_init::hint]. +/// +/// Zero the struct and set only the `has_*` flags you want. A supplied field is +/// published as given and never estimated, so it never republishes the catalog. +/// An absent one is measured from the frames, as without a hint. +/// +/// New optional fields are appended so existing initializers keep their meaning. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct moq_audio_hint { + /// Maximum bitrate in bits per second when `has_bitrate` is true. + pub bitrate: u64, + /// Whether `bitrate` is present. + pub has_bitrate: bool, + + /// The track's regular group duration in microseconds when + /// `has_group_duration` is true: a video GOP, or the span between the + /// audio cuts you make. It fixes the HLS segment duration in the first + /// catalog instead of declaring it from the first group. 0 is absent. + pub group_duration_us: u64, + /// Whether `group_duration_us` is present. + pub has_group_duration: bool, +} + +impl moq_audio_hint { + /// Copy the supplied fields onto the importer's init. + fn apply(&self, init: &mut moq_mux::import::AudioInit) { + if self.has_bitrate { + init.bitrate = Some(self.bitrate); + } + } + + /// The supplied group duration, for the track's HLS records. + fn group_duration(&self) -> Option { + group_duration(self.has_group_duration, self.group_duration_us) + } +} + +/// A supplied group duration, or `None` when absent or zero. +fn group_duration(present: bool, us: u64) -> Option { + (present && us > 0).then(|| std::time::Duration::from_micros(us)) } /// Configuration for [moq_publish_video]. @@ -231,6 +277,14 @@ pub struct moq_video_hint { pub optimize_for_latency: bool, /// Whether `optimize_for_latency` is present. pub has_optimize_for_latency: bool, + + /// The track's regular group duration in microseconds when + /// `has_group_duration` is true: a video GOP, or the span between the + /// audio cuts you make. It fixes the HLS segment duration in the first + /// catalog instead of declaring it from the first group. 0 is absent. + pub group_duration_us: u64, + /// Whether `group_duration_us` is present. + pub has_group_duration: bool, } impl moq_video_hint { @@ -252,6 +306,11 @@ impl moq_video_hint { } out } + + /// The supplied group duration, for the track's HLS records. + fn group_duration(&self) -> Option { + group_duration(self.has_group_duration, self.group_duration_us) + } } /// Configuration for [moq_publish_container]. @@ -424,6 +483,13 @@ pub struct moq_json_snapshot_config { /// DEFLATE-compress each group. Must match on the producer and consumer. pub compression: bool, + + /// The track's maximum bitrate in bits per second when `has_bitrate` is true. + /// Supplied, it is published as given and never estimated, so writes never + /// republish the catalog for it. Ignored by the consumer. + pub bitrate: u64, + /// Whether `bitrate` is present. + pub has_bitrate: bool, } /// Options for a JSON stream track (lossless append-log mode). @@ -431,6 +497,13 @@ pub struct moq_json_snapshot_config { pub struct moq_json_stream_config { /// DEFLATE-compress the group. Must match on the producer and consumer. pub compression: bool, + + /// The track's maximum bitrate in bits per second when `has_bitrate` is true. + /// Supplied, it is published as given and never estimated, so writes never + /// republish the catalog for it. Ignored by the consumer. + pub bitrate: u64, + /// Whether `bitrate` is present. + pub has_bitrate: bool, } /// Options for a binary data track, in either mode. @@ -446,6 +519,13 @@ pub struct moq_binary_config { pub mime: *const c_char, /// Length of `mime` in bytes. pub mime_len: usize, + + /// The track's maximum bitrate in bits per second when `has_bitrate` is true. + /// Supplied, it is published as given and never estimated, so writes never + /// republish the catalog for it. Ignored by the consumer. + pub bitrate: u64, + /// Whether `bitrate` is present. + pub has_bitrate: bool, } /// A JSON value delivered by a consumer callback. @@ -2275,21 +2355,26 @@ pub extern "C" fn moq_publish_catalog_finish(broadcast: u32) -> i32 { pub unsafe extern "C" fn moq_publish_audio(broadcast: u32, config: *const moq_audio_init) -> i32 { ffi::enter(move || { let broadcast = ffi::parse_id(broadcast)?; - let audio = unsafe { parse_audio_init(config)? }; - State::lock().publish.audio(broadcast, audio) + let (audio, hls) = unsafe { parse_audio_init(config)? }; + State::lock().publish.audio(broadcast, audio, hls) }) } +/// Returns the init and the track's group duration hint. +/// /// # Safety /// - As [moq_publish_audio], for `config`. -unsafe fn parse_audio_init(config: *const moq_audio_init) -> Result { +unsafe fn parse_audio_init( + config: *const moq_audio_init, +) -> Result<(moq_mux::import::AudioInit, Option), Error> { let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; let init = unsafe { ffi::parse_slice(config.init, config.init_len)? }; let label = unsafe { ffi::parse_str_optional(config.label, config.label_len)? }; let mut audio = moq_mux::import::AudioInit::new(audio_format_from_u32(config.format)?, init.to_vec()); audio.label = label.map(str::to_string); - Ok(audio) + config.hint.apply(&mut audio); + Ok((audio, config.hint.group_duration())) } /// Publish one video codec as a new media track. @@ -2305,14 +2390,18 @@ unsafe fn parse_audio_init(config: *const moq_audio_init) -> Result i32 { ffi::enter(move || { let broadcast = ffi::parse_id(broadcast)?; - let video = unsafe { parse_video_init(config)? }; - State::lock().publish.video(broadcast, video) + let (video, hls) = unsafe { parse_video_init(config)? }; + State::lock().publish.video(broadcast, video, hls) }) } +/// Returns the init and the track's group duration hint. +/// /// # Safety /// - As [moq_publish_audio], for a [moq_video_init]. -unsafe fn parse_video_init(config: *const moq_video_init) -> Result { +unsafe fn parse_video_init( + config: *const moq_video_init, +) -> Result<(moq_mux::import::VideoInit, Option), Error> { let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; let init = unsafe { ffi::parse_slice(config.init, config.init_len)? }; let label = unsafe { ffi::parse_str_optional(config.label, config.label_len)? }; @@ -2320,7 +2409,7 @@ unsafe fn parse_video_init(config: *const moq_video_init) -> Result i32 { ffi::enter(move || { let request = ffi::parse_id(request)?; - let audio = unsafe { parse_audio_init(config)? }; - State::lock().publish.track_request_audio(request, audio) + let (audio, hls) = unsafe { parse_audio_init(config)? }; + State::lock().publish.track_request_audio(request, audio, hls) }) } @@ -3113,8 +3202,8 @@ pub unsafe extern "C" fn moq_track_request_audio(request: u32, config: *const mo pub unsafe extern "C" fn moq_track_request_video(request: u32, config: *const moq_video_init) -> i32 { ffi::enter(move || { let request = ffi::parse_id(request)?; - let video = unsafe { parse_video_init(config)? }; - State::lock().publish.track_request_video(request, video) + let (video, hls) = unsafe { parse_video_init(config)? }; + State::lock().publish.track_request_video(request, video, hls) }) } @@ -3296,7 +3385,9 @@ pub unsafe extern "C" fn moq_publish_json_snapshot( let broadcast = ffi::parse_id(broadcast)?; let name = unsafe { ffi::parse_str(name, name_len)? }; let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; - let entry = moq_mux::json::Config::default().with_compression(config.compression); + let entry = moq_mux::json::Config::default() + .with_compression(config.compression) + .with_bitrate(config.has_bitrate.then_some(config.bitrate)); let options = moq_mux::json::Options::default().with_deltas(config.deltas); State::lock().publish.json_snapshot(broadcast, name, entry, options) }) @@ -3352,7 +3443,9 @@ pub unsafe extern "C" fn moq_publish_json_stream( let broadcast = ffi::parse_id(broadcast)?; let name = unsafe { ffi::parse_str(name, name_len)? }; let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; - let producer = moq_mux::json::Config::default().with_compression(config.compression); + let producer = moq_mux::json::Config::default() + .with_compression(config.compression) + .with_bitrate(config.has_bitrate.then_some(config.bitrate)); State::lock().publish.json_stream(broadcast, name, producer) }) } @@ -3390,7 +3483,9 @@ pub extern "C" fn moq_publish_json_stream_finish(stream: u32) -> i32 { /// - `config` must be a valid pointer, and its `mime` a valid pointer to `mime_len` bytes when not NULL. unsafe fn binary_config(config: *const moq_binary_config) -> Result { let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; - let mut binary = moq_mux::binary::Config::default().with_compression(config.compression); + let mut binary = moq_mux::binary::Config::default() + .with_compression(config.compression) + .with_bitrate(config.has_bitrate.then_some(config.bitrate)); if let Some(mime) = unsafe { ffi::parse_str_optional(config.mime, config.mime_len)? } { binary = binary.with_mime(mime); } diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 1f1c4c227d1..cdac36a890e 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -496,7 +496,15 @@ impl Publish { children.insert(&mut self.media, media, Child::Media) } - pub fn audio(&mut self, broadcast: Id, init: import::AudioInit) -> Result { + /// `hls` is the track's regular group duration, when known (see [`Reserved::with_hls_hint`]). + /// + /// [`Reserved::with_hls_hint`]: moq_mux::catalog::Reserved::with_hls_hint + pub fn audio( + &mut self, + broadcast: Id, + init: import::AudioInit, + hls: Option, + ) -> Result { let Broadcast { producer: broadcast, catalog, @@ -507,12 +515,18 @@ impl Publish { let name = broadcast.unique_name(&format!(".{}", init.format)); let request = broadcast.reserve_track(name)?; - let track = import::Track::audio(request, catalog.reserve(), init)?; + let track = import::Track::audio(request, catalog.reserve().with_hls_hint(hls), init)?; let children = children.clone(); self.insert_media(track, children) } - pub fn video(&mut self, broadcast: Id, init: import::VideoInit) -> Result { + /// `hls` as in [`Self::audio`]. + pub fn video( + &mut self, + broadcast: Id, + init: import::VideoInit, + hls: Option, + ) -> Result { let Broadcast { producer: broadcast, catalog, @@ -523,7 +537,7 @@ impl Publish { let name = broadcast.unique_name(&format!(".{}", init.format)); let request = broadcast.reserve_track(name)?; - let track = import::Track::video(request, catalog.reserve(), init)?; + let track = import::Track::video(request, catalog.reserve().with_hls_hint(hls), init)?; let children = children.clone(); self.insert_media(track, children) } @@ -975,26 +989,36 @@ impl Publish { } /// Accept a track request as an audio track, returning a media handle like [`Self::audio`]. - pub fn track_request_audio(&mut self, request: Id, init: import::AudioInit) -> Result { + pub fn track_request_audio( + &mut self, + request: Id, + init: import::AudioInit, + hls: Option, + ) -> Result { let TrackRequest { broadcast, children, request, } = self.track_request.remove(request).ok_or(Error::NotFound)?; let catalog = self.catalog(broadcast)?; - let track = import::Track::audio(request, catalog.reserve(), init)?; + let track = import::Track::audio(request, catalog.reserve().with_hls_hint(hls), init)?; self.insert_media(track, children) } /// Accept a track request as a video track, returning a media handle like [`Self::video`]. - pub fn track_request_video(&mut self, request: Id, init: import::VideoInit) -> Result { + pub fn track_request_video( + &mut self, + request: Id, + init: import::VideoInit, + hls: Option, + ) -> Result { let TrackRequest { broadcast, children, request, } = self.track_request.remove(request).ok_or(Error::NotFound)?; let catalog = self.catalog(broadcast)?; - let track = import::Track::video(request, catalog.reserve(), init)?; + let track = import::Track::video(request, catalog.reserve().with_hls_hint(hls), init)?; self.insert_media(track, children) } @@ -1415,7 +1439,7 @@ mod children_tests { head.extend_from_slice(&[1, 2, 0, 0, 0x80, 0xbb, 0, 0, 0, 0, 0]); let media = state .publish - .audio(broadcast, import::AudioInit::new(import::AudioFormat::Opus, head)) + .audio(broadcast, import::AudioInit::new(import::AudioFormat::Opus, head), None) .unwrap(); let name = state.publish.media_demand(media).unwrap().name().to_string(); let readers = [ diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index be9a9264773..1fcfa19853e 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -324,6 +324,7 @@ fn publish_audio(broadcast: u32, format: moq_audio_format, init: &[u8], label: O init_len: init.len(), label, label_len, + hint: moq_audio_hint::default(), }; unsafe { moq_publish_audio(broadcast, &config) } @@ -2185,6 +2186,8 @@ fn json_snapshot_publish_consume() { let config = moq_json_snapshot_config { deltas: true, compression: true, + bitrate: 0, + has_bitrate: false, }; let producer = id(unsafe { moq_publish_json_snapshot( @@ -2255,7 +2258,11 @@ fn json_stream_publish_consume() { let broadcast = publish_broadcast(origin, path); let track_name = b"events"; - let config = moq_json_stream_config { compression: true }; + let config = moq_json_stream_config { + compression: true, + bitrate: 0, + has_bitrate: false, + }; let producer = id(unsafe { moq_publish_json_stream( broadcast, @@ -2565,6 +2572,7 @@ fn unknown_format() { init_len: 0, label: std::ptr::null(), label_len: 0, + hint: moq_audio_hint::default(), }; let ret = unsafe { moq_publish_audio(broadcast, &config) }; assert!(ret < 0, "an out-of-range format code should fail"); @@ -3222,6 +3230,7 @@ fn dynamic_track_request_publishes_media() { init_len: init.len(), label: std::ptr::null(), label_len: 0, + hint: moq_audio_hint::default(), }; let media = id(unsafe { moq_track_request_audio(request, &config) }); @@ -6348,6 +6357,8 @@ fn json_tracks_are_advertised_in_the_catalog() { &moq_json_snapshot_config { deltas: true, compression: true, + bitrate: 0, + has_bitrate: false, }, ) }); @@ -6357,7 +6368,11 @@ fn json_tracks_are_advertised_in_the_catalog() { broadcast, events.as_ptr() as *const c_char, events.len(), - &moq_json_stream_config { compression: false }, + &moq_json_stream_config { + compression: false, + bitrate: 0, + has_bitrate: false, + }, ) }); @@ -6402,6 +6417,8 @@ fn binary_snapshot_is_advertised_and_delivered() { compression: false, mime: mime.as_ptr() as *const c_char, mime_len: mime.len(), + bitrate: 0, + has_bitrate: false, }, ) }); @@ -6450,6 +6467,8 @@ fn binary_stream_is_advertised_and_delivered() { compression: false, mime: std::ptr::null(), mime_len: 0, + bitrate: 0, + has_bitrate: false, }, ) }); @@ -6493,6 +6512,8 @@ fn data_track_names_cannot_collide() { &moq_json_snapshot_config { deltas: false, compression: false, + bitrate: 0, + has_bitrate: false, }, ) }); @@ -6508,6 +6529,8 @@ fn data_track_names_cannot_collide() { compression: false, mime: std::ptr::null(), mime_len: 0, + bitrate: 0, + has_bitrate: false, }, ) } < 0, @@ -6581,3 +6604,252 @@ unsafe fn encoded_frame(id: u32, dst: *mut moq_frame) -> i32 { } status } + +/// An audio rendition as the publisher currently lists it. +fn audio_rendition(broadcast: u32, name: &str) -> hang::catalog::AudioConfig { + published_catalog(broadcast).audio.renditions[name].clone() +} + +/// Publish `count` 20 ms Opus packets of `size` bytes from `start_us`, one group each. +fn write_opus(media: u32, start_us: u64, count: u64, size: usize) { + let payload = vec![0u8; size]; + for i in 0..count { + let ts = start_us + i * 20_000; + assert_eq!( + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), ts) }, + 0 + ); + assert_eq!(moq_publish_media_cut(media), 0); + } +} + +/// A supplied audio bitrate is published as given and never estimated: writes that measure a +/// different one leave the rendition unchanged, while an unhinted track still measures its own. +#[test] +fn audio_hint_is_authoritative() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"audio-hint"); + let init = opus_head(); + + let free = id(publish_audio( + broadcast, + moq_audio_format::MOQ_AUDIO_FORMAT_OPUS, + &init, + None, + )); + let before: Vec = published_catalog(broadcast).audio.renditions.keys().cloned().collect(); + + let config = moq_audio_init { + format: moq_audio_format::MOQ_AUDIO_FORMAT_OPUS as u32, + init: init.as_ptr(), + init_len: init.len(), + label: std::ptr::null(), + label_len: 0, + hint: moq_audio_hint { + bitrate: 64_000, + has_bitrate: true, + ..moq_audio_hint::default() + }, + }; + let hinted = id(unsafe { moq_publish_audio(broadcast, &config) }); + let name = (published_catalog(broadcast).audio.renditions.keys()) + .find(|name| !before.contains(name)) + .expect("hinted rendition listed") + .clone(); + + assert_eq!(audio_rendition(broadcast, &name).bitrate, Some(64_000)); + + // 1000-byte packets every 20 ms measure 400 kb/s. + write_opus(free, 0, 75, 1000); + write_opus(hinted, 0, 75, 1000); + + assert_eq!( + audio_rendition(broadcast, &name).bitrate, + Some(64_000), + "a supplied bitrate is never re-estimated" + ); + assert_eq!( + audio_rendition(broadcast, &before[0]).bitrate, + Some(400_000), + "an unhinted track still measures its bitrate" + ); + + assert_eq!(moq_publish_media_finish(hinted), 0); + assert_eq!(moq_publish_media_finish(free), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// A zeroed audio hint behaves as before: the bitrate is measured. +#[test] +fn zeroed_audio_hint_measures() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"audio-hint-zeroed"); + let media = id(publish_audio( + broadcast, + moq_audio_format::MOQ_AUDIO_FORMAT_OPUS, + &opus_head(), + None, + )); + let name = published_catalog(broadcast) + .audio + .renditions + .keys() + .next() + .unwrap() + .clone(); + assert_eq!(audio_rendition(broadcast, &name).bitrate, None); + + write_opus(media, 0, 75, 1000); + assert_eq!(audio_rendition(broadcast, &name).bitrate, Some(400_000)); + + assert_eq!(moq_publish_media_finish(media), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// A supplied data-track bitrate is listed as given and survives writes. +#[test] +fn data_track_bitrate_is_supplied() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"data-bitrate"); + + let status = b"status"; + let snapshot = id(unsafe { + moq_publish_json_snapshot( + broadcast, + status.as_ptr() as *const c_char, + status.len(), + &moq_json_snapshot_config { + deltas: true, + compression: false, + bitrate: 1_234, + has_bitrate: true, + }, + ) + }); + let events = b"events"; + let stream = id(unsafe { + moq_publish_json_stream( + broadcast, + events.as_ptr() as *const c_char, + events.len(), + &moq_json_stream_config { + compression: false, + bitrate: 2_345, + has_bitrate: true, + }, + ) + }); + let blobs = b"blobs"; + let binary = id(unsafe { + moq_publish_binary_snapshot( + broadcast, + blobs.as_ptr() as *const c_char, + blobs.len(), + &moq_binary_config { + compression: false, + mime: std::ptr::null(), + mime_len: 0, + bitrate: 3_456, + has_bitrate: true, + }, + ) + }); + + for i in 0..20 { + let value = format!(r#"{{"n":{i},"pad":"{}"}}"#, "x".repeat(500)); + assert_eq!( + unsafe { moq_publish_json_snapshot_update(snapshot, value.as_ptr() as *const c_char, value.len()) }, + 0 + ); + assert_eq!( + unsafe { moq_publish_json_stream_append(stream, value.as_ptr() as *const c_char, value.len()) }, + 0 + ); + assert_eq!( + unsafe { moq_publish_binary_snapshot_update(binary, value.as_ptr(), value.len()) }, + 0 + ); + } + + let catalog = published_catalog(broadcast); + assert_eq!(catalog.json.tracks["status"].bitrate, Some(1_234)); + assert_eq!(catalog.json.tracks["events"].bitrate, Some(2_345)); + assert_eq!(catalog.binary.tracks["blobs"].bitrate, Some(3_456)); + + assert_eq!(moq_publish_json_snapshot_finish(snapshot), 0); + assert_eq!(moq_publish_json_stream_finish(stream), 0); + assert_eq!(moq_publish_binary_snapshot_finish(binary), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// A group duration hint lists the track's HLS records in the first catalog, so the first group +/// doesn't republish it; without one they arrive once a group has been written. +#[test] +fn group_duration_hint_lists_hls_up_front() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"group-duration"); + let init = opus_head(); + + let free = id(publish_audio( + broadcast, + moq_audio_format::MOQ_AUDIO_FORMAT_OPUS, + &init, + None, + )); + let free_name = published_catalog(broadcast) + .audio + .renditions + .keys() + .next() + .unwrap() + .clone(); + assert!( + audio_rendition(broadcast, &free_name).hls.is_none(), + "unhinted: no records yet" + ); + + let config = moq_audio_init { + format: moq_audio_format::MOQ_AUDIO_FORMAT_OPUS as u32, + init: init.as_ptr(), + init_len: init.len(), + label: std::ptr::null(), + label_len: 0, + hint: moq_audio_hint { + group_duration_us: 100_000, + has_group_duration: true, + ..moq_audio_hint::default() + }, + }; + let hinted = id(unsafe { moq_publish_audio(broadcast, &config) }); + let name = (published_catalog(broadcast).audio.renditions.keys()) + .find(|name| **name != free_name) + .expect("hinted rendition listed") + .clone(); + let listed = audio_rendition(broadcast, &name); + assert!(listed.hls.is_some(), "hinted: records listed before any frame"); + + // 100 ms groups of five 20 ms packets. + let payload = [0u8; 100]; + for i in 0..20u64 { + assert_eq!( + unsafe { moq_publish_media_frame(hinted, payload.as_ptr(), payload.len(), i * 20_000) }, + 0 + ); + if i % 5 == 4 { + assert_eq!(moq_publish_media_cut(hinted), 0); + } + } + assert_eq!( + audio_rendition(broadcast, &name).hls, + listed.hls, + "the first groups don't change the listed records" + ); + + assert_eq!(moq_publish_media_finish(hinted), 0); + assert_eq!(moq_publish_media_finish(free), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} diff --git a/rs/moq-mux/src/binary.rs b/rs/moq-mux/src/binary.rs index 4b562515108..ff776b7d67d 100644 --- a/rs/moq-mux/src/binary.rs +++ b/rs/moq-mux/src/binary.rs @@ -96,6 +96,10 @@ pub struct Config { /// An optional media type for each payload (e.g. `image/jpeg`). pub mime: Option, + + /// The track's maximum bitrate in bits per second, for a publisher that knows it. Supplied, it + /// is published as given and never estimated from the writes. + pub bitrate: Option, } impl Config { @@ -110,6 +114,12 @@ impl Config { self.mime = Some(mime.into()); self } + + /// Set [`bitrate`](Self::bitrate) (a builder, since the struct is `#[non_exhaustive]`). + pub fn with_bitrate(mut self, bitrate: impl Into>) -> Self { + self.bitrate = bitrate.into(); + self + } } impl IntoRendition for Config { @@ -120,6 +130,7 @@ impl IntoRendition for Config { let mut entry = BinaryConfig::new(Mode::Snapshot); entry.compression = self.compression.then_some(Compression::Deflate); entry.mime = self.mime; + entry.bitrate = self.bitrate; entry } } diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 018ef2a12eb..9af23e92ddc 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -175,7 +175,8 @@ impl RenditionConfig for hang::catalog::BinaryConfig { /// /// Video importers take one because they build the [`VideoConfig`](hang::catalog::VideoConfig) /// themselves, out of the bitstream, so the caller never holds the config to set these on. An audio -/// importer publishes a complete config from its init bytes, so audio has no equivalent. +/// importer publishes a complete config from its init bytes, so the field those bytes can't carry +/// (bitrate) rides on [`AudioInit`](crate::import::AudioInit) instead. #[derive(Clone, Default, Debug, PartialEq)] #[non_exhaustive] pub struct VideoHint { diff --git a/rs/moq-mux/src/import/init.rs b/rs/moq-mux/src/import/init.rs index 954460340c2..8d8befc4e21 100644 --- a/rs/moq-mux/src/import/init.rs +++ b/rs/moq-mux/src/import/init.rs @@ -22,6 +22,9 @@ pub struct AudioInit { /// [`Legacy`](hang::catalog::Container::Legacy) unless selected. A video rendition carries the /// same knob on its [`VideoInit::hint`]. pub container: hang::catalog::Container, + /// The maximum bitrate in bits per second, for an encoder that knows it. Fills only a gap the + /// init bytes leave; supplied, it is never estimated. + pub bitrate: Option, } impl AudioInit { @@ -32,6 +35,7 @@ impl AudioInit { data: data.into(), label: None, container: hang::catalog::Container::default(), + bitrate: None, } } } diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 227d8fe09e0..6dc8edfcd20 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -40,11 +40,14 @@ fn track_info( } } -/// The codec parser fills everything from the init bytes, so the label and container are all the -/// caller adds. +/// The codec parser fills the decoder fields from the init bytes; the caller adds the label, the +/// container, and the bitrate when it knows it, which then counts as supplied. fn with_init(init: &AudioInit, mut config: hang::catalog::AudioConfig) -> hang::catalog::AudioConfig { config.label = init.label.clone(); config.container = init.container.clone(); + if config.bitrate.is_none() { + config.bitrate = init.bitrate; + } config } diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 29809366e74..592c8de0d1f 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -101,6 +101,10 @@ pub struct Config { /// An optional identifier for the shape of each value, typically a JSON Schema URL. pub schema: Option, + + /// The track's maximum bitrate in bits per second, for a publisher that knows it. Supplied, it + /// is published as given and never estimated from the writes. + pub bitrate: Option, } impl Config { @@ -115,6 +119,12 @@ impl Config { self.schema = Some(schema.into()); self } + + /// Set [`bitrate`](Self::bitrate) (a builder, since the struct is `#[non_exhaustive]`). + pub fn with_bitrate(mut self, bitrate: impl Into>) -> Self { + self.bitrate = bitrate.into(); + self + } } /// How a [`Snapshot`] encodes its frames, beyond what its catalog entry declares. @@ -152,6 +162,7 @@ impl IntoRendition for Config { let mut entry = JsonConfig::new(Mode::Snapshot); entry.compression = self.compression.then_some(Compression::Deflate); entry.schema = self.schema; + entry.bitrate = self.bitrate; entry } } From ae023052a02606cac20a61c6028ced30fc3419fe Mon Sep 17 00:00:00 2001 From: Michael Stuurman <289971156+michaelstuurman@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:35:38 +0100 Subject: [PATCH 2/4] docs(moq-c): leave the hints migration to the release cut Only the release cut writes MIGRATE.md sections (AGENTS.md); the migration is in the PR's Impact. --- rs/moq-c/MIGRATE.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index eb9a6fa247e..31737ddda7e 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,11 +4,6 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Bitrate and group duration hints - -`moq_audio_init` gains `hint` (a `moq_audio_hint` with `bitrate`/`has_bitrate` and `group_duration_us`/`has_group_duration`), `moq_video_hint` gains `group_duration_us`/`has_group_duration`, and `moq_json_snapshot_config`, `moq_json_stream_config`, and `moq_binary_config` gain `bitrate`/`has_bitrate`. -The fields are appended, so a zero- or designated-initialized struct behaves as before: a bitrate it leaves out is still estimated, and HLS records still declare their duration from the first group. The struct layouts grow, so rebuild against the matching header and library. - ### Paths with an empty segment are refused ([#6648](https://github.com/moq-dev/moq/pull/6648)) Every function taking a path, such as `moq_origin_create_broadcast` and `moq_origin_request`, returns an error for a path with a leading, trailing, or doubled `/` instead of collapsing it. Pass the canonical path, such as `room/cam`. From 44d25a124d9d80c8fd3e13266f0b3563eaa24b86 Mon Sep 17 00:00:00 2001 From: Michael Stuurman <289971156+michaelstuurman@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:59:32 +0100 Subject: [PATCH 3/4] fix(moq-c): refuse zero and oversized hints, and document group duration as a bound From review: - group_duration_us is the longest span between two group starts, an upper bound a broadcast may combine across tracks; the HLS wording goes. - A has_* flag set with 0, and a group duration past 2^53 - 1 ms, are MOQ_ERROR_INVALID_CONFIG instead of a second "absent" or a bitrate of 0. - A supplied audio bitrate replaces one the init bytes give, as the C doc says. - "never republishes the catalog" becomes "is never re-estimated": jitter and delay still republish it. - Tests for the video group duration, requested tracks, the declared hls.duration and the refused hints; doc/lib/c describes the hints. --- doc/lib/c/index.md | 3 +- rs/moq-c/src/api.rs | 87 +++++++++------ rs/moq-c/src/publish.rs | 2 +- rs/moq-c/src/test.rs | 198 ++++++++++++++++++++++++++++++++- rs/moq-mux/src/import/init.rs | 4 +- rs/moq-mux/src/import/track.rs | 5 +- 6 files changed, 261 insertions(+), 38 deletions(-) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 8be91d05c6c..d704d0405f6 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -77,7 +77,8 @@ inside. The header is the reference: each function carries a doc comment. - **Zeroed config is the default.** A zeroed `moq_client_config` means the default for every field, so new fields don't break callers. A field whose default isn't zero has a `has_*` flag: `backoff_timeout_us = 0` means "retry forever" only with `has_backoff_timeout = true`. - **Compressed tracks.** `moq_track_info.compress` on a raw track sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. `@moq/net` cannot read a compressed track yet. - **fMP4 import.** `MOQ_CONTAINER_FORMAT_FMP4` splits each fragment into one frame per sample. -- **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `moq_publish_media_cut`: after every frame, or at a segment cadence to align with video. +- **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `moq_publish_media_cut`: after every frame, or at a segment cadence to align with video. Pass that cadence as `moq_audio_init.hint.group_duration_us`. +- **Catalog hints.** Give the catalog what the encoder knows instead of having it estimated: `moq_video_init.hint` (coded size, bitrate, frame rate, group duration), `moq_audio_init.hint` (bitrate, group duration), and `bitrate` on the JSON and binary track configs. A supplied bitrate is never re-estimated. `group_duration_us` is the longest span between two group starts: the GOP, or your cut cadence. Leave a field's `has_*` flag false to have it measured; a flag set with a 0 value returns `MOQ_ERROR_INVALID_CONFIG`. - **Ending cleanly.** When the input ends, finish every track, then call `moq_publish_catalog_finish()` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `moq_publish_close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close ends with `UNROUTABLE`, which readers take as the publisher leaving, and its handle is released. - **Timeline breaks.** On a seek or pause, call `moq_publish_media_discontinuity`, then keep timestamps moving forward and resume video on a keyframe. - **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's `timestamp_us`, rounded down to a whole microsecond; `timestamp_present` is false on an untimed track. A snapshot consumer's callback fires for each state in order, but a group superseded before it is read is skipped. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 6333d11b431..5ea572afeaa 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -184,8 +184,8 @@ pub struct moq_audio_init { /// Optional catalog fields for [moq_audio_init::hint]. /// /// Zero the struct and set only the `has_*` flags you want. A supplied field is -/// published as given and never estimated, so it never republishes the catalog. -/// An absent one is measured from the frames, as without a hint. +/// published as given and is never re-estimated. An absent one is measured from +/// the frames, as without a hint. A flag set with a 0 value is an invalid config. /// /// New optional fields are appended so existing initializers keep their meaning. #[repr(C)] @@ -196,10 +196,11 @@ pub struct moq_audio_hint { /// Whether `bitrate` is present. pub has_bitrate: bool, - /// The track's regular group duration in microseconds when - /// `has_group_duration` is true: a video GOP, or the span between the - /// audio cuts you make. It fixes the HLS segment duration in the first - /// catalog instead of declaring it from the first group. 0 is absent. + /// The longest span between two group starts on this track, in + /// microseconds, when `has_group_duration` is true: a video GOP, or the span + /// between the audio cuts you make. An upper bound, so a broadcast may take + /// the largest across its tracks. Must be at least 1 and at most + /// 2^53 - 1 milliseconds. pub group_duration_us: u64, /// Whether `group_duration_us` is present. pub has_group_duration: bool, @@ -207,21 +208,44 @@ pub struct moq_audio_hint { impl moq_audio_hint { /// Copy the supplied fields onto the importer's init. - fn apply(&self, init: &mut moq_mux::import::AudioInit) { - if self.has_bitrate { - init.bitrate = Some(self.bitrate); - } + fn apply(&self, init: &mut moq_mux::import::AudioInit) -> Result<(), Error> { + init.bitrate = supplied_bitrate(self.has_bitrate, self.bitrate)?; + Ok(()) } /// The supplied group duration, for the track's HLS records. - fn group_duration(&self) -> Option { + fn group_duration(&self) -> Result, Error> { group_duration(self.has_group_duration, self.group_duration_us) } } -/// A supplied group duration, or `None` when absent or zero. -fn group_duration(present: bool, us: u64) -> Option { - (present && us > 0).then(|| std::time::Duration::from_micros(us)) +/// The longest group duration a hint may give: 2^53 - 1 milliseconds, the largest +/// integer a JavaScript reader of the catalog holds exactly. +const MAX_GROUP_DURATION_US: u64 = ((1 << 53) - 1) * 1000; + +/// A supplied group duration, `None` when absent, or an error for 0 or past +/// [MAX_GROUP_DURATION_US]. +fn group_duration(present: bool, us: u64) -> Result, Error> { + if !present { + return Ok(None); + } + if us == 0 || us > MAX_GROUP_DURATION_US { + return Err(Error::InvalidConfig(format!( + "group_duration_us must be 1..={MAX_GROUP_DURATION_US}, got {us}" + ))); + } + Ok(Some(std::time::Duration::from_micros(us))) +} + +/// A supplied bitrate, `None` when absent, or an error for 0. +fn supplied_bitrate(present: bool, bitrate: u64) -> Result, Error> { + match (present, bitrate) { + (false, _) => Ok(None), + (true, 0) => Err(Error::InvalidConfig( + "bitrate must not be 0 when has_bitrate is set".into(), + )), + (true, bitrate) => Ok(Some(bitrate)), + } } /// Configuration for [moq_publish_video]. @@ -278,10 +302,11 @@ pub struct moq_video_hint { /// Whether `optimize_for_latency` is present. pub has_optimize_for_latency: bool, - /// The track's regular group duration in microseconds when - /// `has_group_duration` is true: a video GOP, or the span between the - /// audio cuts you make. It fixes the HLS segment duration in the first - /// catalog instead of declaring it from the first group. 0 is absent. + /// The longest span between two group starts on this track, in + /// microseconds, when `has_group_duration` is true: a video GOP, or the span + /// between the audio cuts you make. An upper bound, so a broadcast may take + /// the largest across its tracks. Must be at least 1 and at most + /// 2^53 - 1 milliseconds. pub group_duration_us: u64, /// Whether `group_duration_us` is present. pub has_group_duration: bool, @@ -308,7 +333,7 @@ impl moq_video_hint { } /// The supplied group duration, for the track's HLS records. - fn group_duration(&self) -> Option { + fn group_duration(&self) -> Result, Error> { group_duration(self.has_group_duration, self.group_duration_us) } } @@ -485,8 +510,8 @@ pub struct moq_json_snapshot_config { pub compression: bool, /// The track's maximum bitrate in bits per second when `has_bitrate` is true. - /// Supplied, it is published as given and never estimated, so writes never - /// republish the catalog for it. Ignored by the consumer. + /// Supplied, it is published as given and is never re-estimated; it must not + /// be 0. Ignored by the consumer. pub bitrate: u64, /// Whether `bitrate` is present. pub has_bitrate: bool, @@ -499,8 +524,8 @@ pub struct moq_json_stream_config { pub compression: bool, /// The track's maximum bitrate in bits per second when `has_bitrate` is true. - /// Supplied, it is published as given and never estimated, so writes never - /// republish the catalog for it. Ignored by the consumer. + /// Supplied, it is published as given and is never re-estimated; it must not + /// be 0. Ignored by the consumer. pub bitrate: u64, /// Whether `bitrate` is present. pub has_bitrate: bool, @@ -521,8 +546,8 @@ pub struct moq_binary_config { pub mime_len: usize, /// The track's maximum bitrate in bits per second when `has_bitrate` is true. - /// Supplied, it is published as given and never estimated, so writes never - /// republish the catalog for it. Ignored by the consumer. + /// Supplied, it is published as given and is never re-estimated; it must not + /// be 0. Ignored by the consumer. pub bitrate: u64, /// Whether `bitrate` is present. pub has_bitrate: bool, @@ -2373,8 +2398,8 @@ unsafe fn parse_audio_init( let mut audio = moq_mux::import::AudioInit::new(audio_format_from_u32(config.format)?, init.to_vec()); audio.label = label.map(str::to_string); - config.hint.apply(&mut audio); - Ok((audio, config.hint.group_duration())) + config.hint.apply(&mut audio)?; + Ok((audio, config.hint.group_duration()?)) } /// Publish one video codec as a new media track. @@ -2409,7 +2434,7 @@ unsafe fn parse_video_init( let mut video = moq_mux::import::VideoInit::new(video_format_from_u32(config.format)?, init.to_vec()); video.label = label.map(str::to_string); video.hint = config.hint.resolve(); - Ok((video, config.hint.group_duration())) + Ok((video, config.hint.group_duration()?)) } /// Publish a container, which demuxes and publishes its own tracks. @@ -3387,7 +3412,7 @@ pub unsafe extern "C" fn moq_publish_json_snapshot( let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; let entry = moq_mux::json::Config::default() .with_compression(config.compression) - .with_bitrate(config.has_bitrate.then_some(config.bitrate)); + .with_bitrate(supplied_bitrate(config.has_bitrate, config.bitrate)?); let options = moq_mux::json::Options::default().with_deltas(config.deltas); State::lock().publish.json_snapshot(broadcast, name, entry, options) }) @@ -3445,7 +3470,7 @@ pub unsafe extern "C" fn moq_publish_json_stream( let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; let producer = moq_mux::json::Config::default() .with_compression(config.compression) - .with_bitrate(config.has_bitrate.then_some(config.bitrate)); + .with_bitrate(supplied_bitrate(config.has_bitrate, config.bitrate)?); State::lock().publish.json_stream(broadcast, name, producer) }) } @@ -3485,7 +3510,7 @@ unsafe fn binary_config(config: *const moq_binary_config) -> Result, } diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 6dc8edfcd20..3f80da6fb7e 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -41,11 +41,12 @@ fn track_info( } /// The codec parser fills the decoder fields from the init bytes; the caller adds the label, the -/// container, and the bitrate when it knows it, which then counts as supplied. +/// container, and the bitrate when it knows it, which then counts as supplied and replaces any the +/// init bytes give. fn with_init(init: &AudioInit, mut config: hang::catalog::AudioConfig) -> hang::catalog::AudioConfig { config.label = init.label.clone(); config.container = init.container.clone(); - if config.bitrate.is_none() { + if init.bitrate.is_some() { config.bitrate = init.bitrate; } config From 17ba2578ac69616a9333207a79a83ab755fe7b71 Mon Sep 17 00:00:00 2001 From: Michael Stuurman <289971156+michaelstuurman@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:52:54 +0100 Subject: [PATCH 4/4] fix(moq-c): refuse a zero video bitrate hint too A video hint with has_bitrate and 0 published bitrate 0 as supplied. It now returns MOQ_ERROR_INVALID_CONFIG like the other bitrate hints, and doc/lib/c names which flags the rule covers. --- doc/lib/c/index.md | 2 +- rs/moq-c/src/api.rs | 14 ++++++-------- rs/moq-c/src/test.rs | 15 +++++++++++---- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index d704d0405f6..060bd1e84fb 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -78,7 +78,7 @@ inside. The header is the reference: each function carries a doc comment. - **Compressed tracks.** `moq_track_info.compress` on a raw track sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. `@moq/net` cannot read a compressed track yet. - **fMP4 import.** `MOQ_CONTAINER_FORMAT_FMP4` splits each fragment into one frame per sample. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `moq_publish_media_cut`: after every frame, or at a segment cadence to align with video. Pass that cadence as `moq_audio_init.hint.group_duration_us`. -- **Catalog hints.** Give the catalog what the encoder knows instead of having it estimated: `moq_video_init.hint` (coded size, bitrate, frame rate, group duration), `moq_audio_init.hint` (bitrate, group duration), and `bitrate` on the JSON and binary track configs. A supplied bitrate is never re-estimated. `group_duration_us` is the longest span between two group starts: the GOP, or your cut cadence. Leave a field's `has_*` flag false to have it measured; a flag set with a 0 value returns `MOQ_ERROR_INVALID_CONFIG`. +- **Catalog hints.** Give the catalog what the encoder knows instead of having it estimated: `moq_video_init.hint` (coded size, bitrate, frame rate, group duration), `moq_audio_init.hint` (bitrate, group duration), and `bitrate` on the JSON and binary track configs. A supplied bitrate is never re-estimated. `group_duration_us` is the longest span between two group starts: the GOP, or your cut cadence. Leave a field's `has_*` flag false to have it measured. A bitrate or `group_duration_us` flag set with a 0 value returns `MOQ_ERROR_INVALID_CONFIG`. - **Ending cleanly.** When the input ends, finish every track, then call `moq_publish_catalog_finish()` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `moq_publish_close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close ends with `UNROUTABLE`, which readers take as the publisher leaving, and its handle is released. - **Timeline breaks.** On a seek or pause, call `moq_publish_media_discontinuity`, then keep timestamps moving forward and resume video on a keyframe. - **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's `timestamp_us`, rounded down to a whole microsecond; `timestamp_present` is false on an untimed track. A snapshot consumer's callback fires for each state in order, but a group superseded before it is read is skipped. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 5ea572afeaa..93973a2de68 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -287,7 +287,7 @@ pub struct moq_video_hint { /// Whether `coded_width` and `coded_height` are present. pub has_coded: bool, - /// Maximum bitrate in bits per second when `has_bitrate` is true. + /// Maximum bitrate in bits per second when `has_bitrate` is true; it must not be 0. pub bitrate: u64, /// Whether `bitrate` is present. pub has_bitrate: bool, @@ -313,23 +313,21 @@ pub struct moq_video_hint { } impl moq_video_hint { - /// The catalog hint these flags describe. - fn resolve(&self) -> moq_mux::catalog::VideoHint { + /// The catalog hint these flags describe, or an error for a bitrate flag set with 0. + fn resolve(&self) -> Result { let mut out = moq_mux::catalog::VideoHint::default(); if self.has_coded { out.coded_width = Some(self.coded_width); out.coded_height = Some(self.coded_height); } - if self.has_bitrate { - out.bitrate = Some(self.bitrate); - } + out.bitrate = supplied_bitrate(self.has_bitrate, self.bitrate)?; if self.has_framerate { out.framerate = Some(self.framerate); } if self.has_optimize_for_latency { out.optimize_for_latency = Some(self.optimize_for_latency); } - out + Ok(out) } /// The supplied group duration, for the track's HLS records. @@ -2433,7 +2431,7 @@ unsafe fn parse_video_init( let mut video = moq_mux::import::VideoInit::new(video_format_from_u32(config.format)?, init.to_vec()); video.label = label.map(str::to_string); - video.hint = config.hint.resolve(); + video.hint = config.hint.resolve()?; Ok((video, config.hint.group_duration()?)) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index eed9782d4de..1f87f7c678d 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -6955,6 +6955,14 @@ fn invalid_hints_are_refused() { invalid, "video group duration 0" ); + assert_eq!( + video(moq_video_hint { + has_bitrate: true, + ..moq_video_hint::default() + }), + invalid, + "video bitrate 0" + ); let name = b"status"; let snapshot = |config: moq_json_snapshot_config| unsafe { @@ -6971,10 +6979,9 @@ fn invalid_hints_are_refused() { "data bitrate 0" ); - assert!( - published_catalog(broadcast).audio.renditions.is_empty(), - "nothing published" - ); + let catalog = published_catalog(broadcast); + assert!(catalog.audio.renditions.is_empty(), "no audio published"); + assert!(catalog.video.renditions.is_empty(), "no video published"); assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); }