diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md
index a491cde3189..ba4e67040b3 100644
--- a/doc/lib/c/index.md
+++ b/doc/lib/c/index.md
@@ -101,7 +101,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.
- **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) and `moq_audio_init.hint` (bitrate, group duration). 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 cf5e9a123f2..b13648e59f6 100644
--- a/rs/moq-c/src/api.rs
+++ b/rs/moq-c/src/api.rs
@@ -176,6 +176,76 @@ 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 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)]
+#[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 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,
+}
+
+impl moq_audio_hint {
+ /// Copy the supplied fields onto the importer's init.
+ 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) -> Result, Error> {
+ group_duration(self.has_group_duration, self.group_duration_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].
@@ -218,7 +288,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,
@@ -232,26 +302,38 @@ pub struct moq_video_hint {
pub optimize_for_latency: bool,
/// Whether `optimize_for_latency` is present.
pub has_optimize_for_latency: bool,
+
+ /// 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,
}
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.
+ fn group_duration(&self) -> Result, Error> {
+ group_duration(self.has_group_duration, self.group_duration_us)
}
}
@@ -2308,21 +2390,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.
@@ -2338,22 +2425,26 @@ 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)? };
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)
+ video.hint = config.hint.resolve()?;
+ Ok((video, config.hint.group_duration()?))
}
/// Publish a container, which demuxes and publishes its own tracks.
@@ -3134,8 +3225,8 @@ pub unsafe extern "C" fn moq_track_request_accept(request: u32, info: *const moq
pub unsafe extern "C" fn moq_track_request_audio(request: u32, config: *const moq_audio_init) -> 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)
})
}
@@ -3150,8 +3241,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)
})
}
diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs
index ca2c3cae7e6..611ec466e14 100644
--- a/rs/moq-c/src/publish.rs
+++ b/rs/moq-c/src/publish.rs
@@ -508,7 +508,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 longest span between the track's group starts, 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,
@@ -519,12 +527,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,
@@ -535,7 +549,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)
}
@@ -998,26 +1012,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)
}
@@ -1377,7 +1401,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 ec7ea65798d..83606a3a35c 100644
--- a/rs/moq-c/src/test.rs
+++ b/rs/moq-c/src/test.rs
@@ -374,6 +374,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) }
@@ -2671,6 +2672,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");
@@ -3445,6 +3447,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) });
@@ -6642,3 +6645,363 @@ 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 video group duration hint declares the HLS duration up front, as the smallest multiple of the
+/// hint at or above the grid.
+#[test]
+fn video_group_duration_hint_declares_hls() {
+ let origin = id(moq_origin_create());
+ let broadcast = publish_broadcast(origin, b"video-group-duration");
+ let init = h264_init();
+ let config = moq_video_init {
+ format: moq_video_format::MOQ_VIDEO_FORMAT_AVC3 as u32,
+ init: init.as_ptr(),
+ init_len: init.len(),
+ label: std::ptr::null(),
+ label_len: 0,
+ hint: moq_video_hint {
+ group_duration_us: 3_000_000,
+ has_group_duration: true,
+ ..moq_video_hint::default()
+ },
+ };
+ let media = id(unsafe { moq_publish_video(broadcast, &config) });
+
+ let catalog = published_catalog(broadcast);
+ let rendition = catalog.video.renditions.values().next().expect("rendition listed");
+ let hls = rendition.hls.clone().expect("records listed before any frame");
+ assert_eq!(hls.duration, std::time::Duration::from_secs(6));
+
+ assert_eq!(moq_publish_media_finish(media), 0);
+ assert_eq!(moq_publish_close(broadcast), 0);
+ assert_eq!(moq_origin_close(origin), 0);
+}
+
+/// Hints reach a track published for a request, as for moq_publish_audio and moq_publish_video.
+#[test]
+fn track_request_media_carries_hints() {
+ let origin = id(moq_origin_create());
+ let broadcast = publish_broadcast(origin, b"dynamic-hints");
+ let request_cb = Callback::new();
+ let _dynamic = id(unsafe { moq_publish_requests(broadcast, Some(channel_callback), request_cb.ptr) });
+ let consume = consume_local(broadcast);
+
+ let audio_cb = Callback::new();
+ let audio_consumer = consume_track(consume, b"audio", &audio_cb);
+ let request = id(request_cb.recv());
+ let opus = opus_head();
+ let audio_config = moq_audio_init {
+ format: moq_audio_format::MOQ_AUDIO_FORMAT_OPUS as u32,
+ init: opus.as_ptr(),
+ init_len: opus.len(),
+ label: std::ptr::null(),
+ label_len: 0,
+ hint: moq_audio_hint {
+ bitrate: 96_000,
+ has_bitrate: true,
+ group_duration_us: 100_000,
+ has_group_duration: true,
+ },
+ };
+ let audio = id(unsafe { moq_track_request_audio(request, &audio_config) });
+
+ let video_cb = Callback::new();
+ let video_consumer = consume_track(consume, b"video", &video_cb);
+ let request = id(request_cb.recv());
+ let h264 = h264_init();
+ let video_config = moq_video_init {
+ format: moq_video_format::MOQ_VIDEO_FORMAT_AVC3 as u32,
+ init: h264.as_ptr(),
+ init_len: h264.len(),
+ label: std::ptr::null(),
+ label_len: 0,
+ hint: moq_video_hint {
+ bitrate: 2_000_000,
+ has_bitrate: true,
+ group_duration_us: 3_000_000,
+ has_group_duration: true,
+ ..moq_video_hint::default()
+ },
+ };
+ let video = id(unsafe { moq_track_request_video(request, &video_config) });
+
+ let catalog = published_catalog(broadcast);
+ let audio_rendition = catalog.audio.renditions.values().next().expect("audio listed");
+ assert_eq!(audio_rendition.bitrate, Some(96_000));
+ assert_eq!(
+ audio_rendition.hls.as_ref().map(|hls| hls.duration),
+ Some(std::time::Duration::from_secs(4))
+ );
+ let video_rendition = catalog.video.renditions.values().next().expect("video listed");
+ assert_eq!(video_rendition.bitrate, Some(2_000_000));
+ assert_eq!(
+ video_rendition.hls.as_ref().map(|hls| hls.duration),
+ Some(std::time::Duration::from_secs(6))
+ );
+
+ assert_eq!(moq_consume_track_cancel(audio_consumer), 0);
+ assert_eq!(audio_cb.recv_terminal(), 0);
+ assert_eq!(moq_consume_track_cancel(video_consumer), 0);
+ assert_eq!(video_cb.recv_terminal(), 0);
+ assert_eq!(moq_publish_media_finish(audio), 0);
+ assert_eq!(moq_publish_media_finish(video), 0);
+ assert_eq!(moq_publish_close(broadcast), 0);
+ assert_eq!(request_cb.recv_terminal(), 0);
+ assert_eq!(moq_consume_close(consume), 0);
+ assert_eq!(moq_origin_close(origin), 0);
+}
+
+/// A hint flag set with a 0 value, or a group duration past what milliseconds hold for a
+/// JavaScript reader, is an invalid config rather than a second encoding of "absent".
+#[test]
+fn invalid_hints_are_refused() {
+ let origin = id(moq_origin_create());
+ let broadcast = publish_broadcast(origin, b"invalid-hints");
+ let opus = opus_head();
+ let h264 = h264_init();
+ let audio = |hint: moq_audio_hint| {
+ let config = moq_audio_init {
+ format: moq_audio_format::MOQ_AUDIO_FORMAT_OPUS as u32,
+ init: opus.as_ptr(),
+ init_len: opus.len(),
+ label: std::ptr::null(),
+ label_len: 0,
+ hint,
+ };
+ unsafe { moq_publish_audio(broadcast, &config) }
+ };
+ let video = |hint: moq_video_hint| {
+ let config = moq_video_init {
+ format: moq_video_format::MOQ_VIDEO_FORMAT_AVC3 as u32,
+ init: h264.as_ptr(),
+ init_len: h264.len(),
+ label: std::ptr::null(),
+ label_len: 0,
+ hint,
+ };
+ unsafe { moq_publish_video(broadcast, &config) }
+ };
+
+ let invalid = MOQ_ERROR_INVALID_CONFIG;
+ assert_eq!(
+ audio(moq_audio_hint {
+ has_bitrate: true,
+ ..moq_audio_hint::default()
+ }),
+ invalid,
+ "bitrate 0"
+ );
+ assert_eq!(
+ audio(moq_audio_hint {
+ has_group_duration: true,
+ ..moq_audio_hint::default()
+ }),
+ invalid,
+ "audio group duration 0"
+ );
+ assert_eq!(
+ audio(moq_audio_hint {
+ group_duration_us: u64::MAX,
+ has_group_duration: true,
+ ..moq_audio_hint::default()
+ }),
+ invalid,
+ "audio group duration past milliseconds"
+ );
+ assert_eq!(
+ video(moq_video_hint {
+ has_group_duration: true,
+ ..moq_video_hint::default()
+ }),
+ invalid,
+ "video group duration 0"
+ );
+ assert_eq!(
+ video(moq_video_hint {
+ has_bitrate: true,
+ ..moq_video_hint::default()
+ }),
+ invalid,
+ "video bitrate 0"
+ );
+
+ 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);
+}
+
+/// 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);
+ let hls = listed.hls.clone().expect("hinted: records listed before any frame");
+ // The smallest multiple of the 100 ms hint at or above the 4 s grid.
+ assert_eq!(hls.duration, std::time::Duration::from_secs(4));
+
+ // 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/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs
index 95b1a8cf1df..3ae2363e3d8 100644
--- a/rs/moq-mux/src/catalog/tracks.rs
+++ b/rs/moq-mux/src/catalog/tracks.rs
@@ -176,7 +176,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..9caec7d87e4 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. Supplied, it replaces
+ /// any value the init bytes give and 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..3f80da6fb7e 100644
--- a/rs/moq-mux/src/import/track.rs
+++ b/rs/moq-mux/src/import/track.rs
@@ -40,11 +40,15 @@ 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 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 init.bitrate.is_some() {
+ config.bitrate = init.bitrate;
+ }
config
}