From fd20f759d41ae8b3dd3756a439e509c47474c041 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:24:48 -0700 Subject: [PATCH 01/20] chore(quest): claim quest/m1/archive/catalog-hls.md Quest-Claim: 32c5e90a34dd0d46 From 44604581d22a2f9965d4e31cad9ee4725b391128 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:59:17 -0700 Subject: [PATCH 02/20] wip(hang)!: per-rendition hls records replace the root archive Producer side: hang catalog drops the root archive section, video and audio renditions gain an opt-in hls field, and moq-mux publishes HLS segment records only when catalog::Config::with_hls enables them. moq-hls export not yet ported. Co-Authored-By: Claude Opus 5.5 --- rs/hang/fixtures/catalog-clock.json | 15 +- rs/hang/src/catalog/archive.rs | 167 -- rs/hang/src/catalog/audio/mod.rs | 6 + rs/hang/src/catalog/clock.rs | 6 +- rs/hang/src/catalog/hls.rs | 52 + rs/hang/src/catalog/mod.rs | 4 +- rs/hang/src/catalog/root.rs | 132 +- rs/hang/src/catalog/video/mod.rs | 6 + rs/hang/src/container/mod.rs | 4 +- rs/hang/src/hls.rs | 112 ++ rs/hang/src/lib.rs | 8 +- rs/hang/src/timeline.rs | 291 ---- rs/moq-audio/src/encode/capture.rs | 63 +- rs/moq-boy/src/video.rs | 2 +- rs/moq-c/src/video.rs | 4 +- rs/moq-ffi/src/video.rs | 4 +- rs/moq-mux/src/catalog/producer.rs | 420 +++-- rs/moq-mux/src/catalog/tracks.rs | 60 +- rs/moq-mux/src/container/flv/import_test.rs | 32 +- rs/moq-mux/src/container/fmp4/import.rs | 43 +- rs/moq-mux/src/container/fmp4/import_test.rs | 110 +- rs/moq-mux/src/container/mkv/import_test.rs | 24 +- rs/moq-mux/src/container/producer.rs | 108 +- rs/moq-mux/src/container/ts/export_test.rs | 3 + rs/moq-mux/src/error.rs | 41 +- rs/moq-mux/src/hls.rs | 1212 ++++++++++++++ rs/moq-mux/src/lib.rs | 8 +- rs/moq-mux/src/timeline.rs | 1476 ------------------ rs/moq-rtc/src/codec/vp8.rs | 9 +- rs/moq-transcode/src/catalog.rs | 18 +- rs/moq-video/src/capture/wgc.rs | 2 +- rs/moq-video/src/decode/consumer.rs | 2 +- rs/moq-video/src/encode/producer.rs | 73 +- 33 files changed, 1904 insertions(+), 2613 deletions(-) delete mode 100644 rs/hang/src/catalog/archive.rs create mode 100644 rs/hang/src/catalog/hls.rs create mode 100644 rs/hang/src/hls.rs delete mode 100644 rs/hang/src/timeline.rs create mode 100644 rs/moq-mux/src/hls.rs delete mode 100644 rs/moq-mux/src/timeline.rs diff --git a/rs/hang/fixtures/catalog-clock.json b/rs/hang/fixtures/catalog-clock.json index 010a9343438..7ee4c151f7b 100644 --- a/rs/hang/fixtures/catalog-clock.json +++ b/rs/hang/fixtures/catalog-clock.json @@ -5,7 +5,8 @@ "codec": "avc1.64001f", "codedWidth": 1280, "codedHeight": 720, - "container": { "kind": "legacy" } + "container": { "kind": "legacy" }, + "hls": { "track": "video.hls", "duration": 2000 } } } }, @@ -15,16 +16,10 @@ "codec": "opus", "sampleRate": 48000, "numberOfChannels": 2, - "container": { "kind": "legacy" } + "container": { "kind": "legacy" }, + "hls": { "track": "audio.hls", "duration": 2000 } } } }, - "clock": { "wall": 1751846400000000, "timescale": 1000000 }, - "archive": { - "timelines": { - "video": { "track": "video.timeline.z", "duration": 2000 }, - "audio": { "track": "audio.timeline.z", "duration": 2000 } - }, - "timescale": 1000 - } + "clock": { "wall": 1751846400000000, "timescale": 1000000 } } diff --git a/rs/hang/src/catalog/archive.rs b/rs/hang/src/catalog/archive.rs deleted file mode 100644 index c988e6b007b..00000000000 --- a/rs/hang/src/catalog/archive.rs +++ /dev/null @@ -1,167 +0,0 @@ -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - -/// The moq epoch (2020-01-01T00:00:00Z) in Unix-epoch milliseconds. -pub const MOQ_EPOCH_UNIX_MILLIS: u64 = 1_577_836_800_000; - -/// One indexed track's timeline and nominal record duration. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct Timeline { - /// The MoQ track carrying this index's records. - pub track: String, - - /// The nominal record duration in the archive's timescale, zero for sparse data, or `None` for - /// an untimed track, whose records carry no duration. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub duration: Option, -} - -impl Timeline { - /// Declare `track` with its nominal `duration` in the archive timescale, `None` when untimed. - pub fn new(track: impl Into, duration: impl Into>) -> Self { - Self { - track: track.into(), - duration: duration.into(), - } - } -} - -/// Discovers each track's timeline and any durable archive. -/// -/// A live publisher sets the timeline fields (`timelines`, `timescale`) alone, -/// and a recording also names the replay broadcast and object store those spans live under. -/// Every advertised span is FETCHable; with a store they are durable. -#[serde_with::serde_as] -#[serde_with::skip_serializing_none] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct Archive { - /// Each indexed track's timeline declaration, keyed by the indexed track's name. - pub timelines: BTreeMap, - - /// Units per second for every timeline's timestamps. Defaults to milliseconds. - #[serde( - default = "Archive::default_timescale", - deserialize_with = "super::deserialize_timescale_or_default" - )] - pub timescale: u32, - - /// The MoQ broadcast the archive is served back from, relative to this catalog, if any. - #[serde(default)] - pub replay: Option, - - /// The publisher instance [`replay`](Self::replay) names, when known. Absent accepts the - /// first one a reader resolves; see [`Reference::epoch`](super::Reference::epoch). - #[serde(default)] - #[serde_as(as = "Option")] - pub replay_epoch: Option, - - /// The object-store URL the recording objects live under, if exposed by the publisher. - #[serde(default)] - pub store: Option, - - /// The recording object format version, [`VERSION`](Self::VERSION) when a store is present. - #[serde(default)] - pub version: Option, -} - -impl Archive { - /// The recording object format advertised in [`version`](Self::version). - pub const VERSION: u32 = 3; - - /// The default timeline timescale, milliseconds. - pub const fn default_timescale() -> u32 { - 1000 - } - - /// An archive with no timelines or durable storage. - pub fn new() -> Self { - Self { - timelines: BTreeMap::new(), - timescale: Self::default_timescale(), - replay: None, - replay_epoch: None, - store: None, - version: None, - } - } -} - -impl Default for Archive { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod test { - use super::*; - - fn live() -> Archive { - let mut archive = Archive::new(); - archive - .timelines - .insert("video".to_string(), Timeline::new("video.timeline.z", 4_000)); - archive - } - - #[test] - fn live_publisher_is_timeline_fields_alone() { - let json = serde_json::to_string(&live()).unwrap(); - assert_eq!( - json, - r#"{"timelines":{"video":{"track":"video.timeline.z","duration":4000}},"timescale":1000}"# - ); - assert_eq!(serde_json::from_str::(&json).unwrap(), live()); - } - - #[test] - fn recording_roundtrips_replay_store_and_version() { - let mut archive = live(); - archive.replay = Some(moq_net::path::RelativeOwned::new("./recordings/clip")); - archive.store = Some("https://objects.example/rec/".parse().unwrap()); - archive.version = Some(Archive::VERSION); - - let json = serde_json::to_string(&archive).unwrap(); - assert_eq!( - json, - r#"{"timelines":{"video":{"track":"video.timeline.z","duration":4000}},"timescale":1000,"replay":"recordings/clip","store":"https://objects.example/rec/","version":3}"# - ); - assert_eq!(serde_json::from_str::(&json).unwrap(), archive); - } - - #[test] - fn an_untimed_timeline_declares_no_duration() { - let mut archive = Archive::new(); - archive - .timelines - .insert("data".to_string(), Timeline::new("data.timeline.z", None)); - let json = serde_json::to_string(&archive).unwrap(); - assert_eq!( - json, - r#"{"timelines":{"data":{"track":"data.timeline.z"}},"timescale":1000}"# - ); - assert_eq!(serde_json::from_str::(&json).unwrap(), archive); - } - - #[test] - fn zero_timescale_is_refused() { - serde_json::from_str::(r#"{"timelines":{},"timescale":0}"#) - .expect_err("a zero timescale must not decode"); - } - - #[test] - fn invalid_store_url_is_refused() { - serde_json::from_str::(r#"{"timelines":{},"store":"not a url"}"#) - .expect_err("an invalid store URL must not decode"); - } - - #[test] - fn a_single_shared_timeline_is_refused() { - serde_json::from_str::(r#"{"track":"timeline.z"}"#).expect_err("the old shape names no timelines"); - } -} diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index cfdb3d214f3..31302fb8b78 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -201,6 +201,11 @@ pub struct AudioConfig { #[serde_as(as = "MillisCeil")] #[serde(default)] pub delay: Option, + /// This rendition's HLS segment records, when the publisher opted in. Without it the rendition + /// is not HLS-renderable. An undecodable value is absent, so it can never reject the media. + #[serde(default)] + #[serde_as(as = "serde_with::DefaultOnError<_>")] + pub hls: Option, } impl AudioConfig { @@ -227,6 +232,7 @@ impl AudioConfig { jitter: None, warmup: None, delay: None, + hls: None, } } } diff --git a/rs/hang/src/catalog/clock.rs b/rs/hang/src/catalog/clock.rs index 94cf396a3a8..0f39b6118d3 100644 --- a/rs/hang/src/catalog/clock.rs +++ b/rs/hang/src/catalog/clock.rs @@ -1,8 +1,10 @@ use serde::{Deserialize, Deserializer, Serialize}; -use super::MOQ_EPOCH_UNIX_MILLIS; use crate::Result; +/// The moq epoch (2020-01-01T00:00:00Z) in Unix-epoch milliseconds. +pub const MOQ_EPOCH_UNIX_MILLIS: u64 = 1_577_836_800_000; + /// The largest integer JSON preserves exactly (2^53 - 1). pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; @@ -38,7 +40,7 @@ struct Wire { /// [`wall`](Self::wall) is the wall-clock time of PTS zero since the moq epoch /// ([`MOQ_EPOCH_UNIX_MILLIS`], 2020-01-01). Its embedded timescale is serialized beside it as /// `{ wall, timescale }`, preserving the catalog wire shape while keeping the pair inseparable in -/// Rust. Every media track and the archive index refer to this mapping after timescale conversion. +/// Rust. Every media track and every HLS record refer to this mapping after timescale conversion. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub struct Clock { diff --git a/rs/hang/src/catalog/hls.rs b/rs/hang/src/catalog/hls.rs new file mode 100644 index 00000000000..507c6767a88 --- /dev/null +++ b/rs/hang/src/catalog/hls.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::millis::MillisCeil; + +/// A rendition's HLS segment records, which exist only for HLS compatibility. +/// +/// Opt-in, and only on video and audio renditions. A publisher that sets it aligns group +/// boundaries across the renditions it sets it on, so segment `n` starts at the same pts in every +/// one. A rendition without it is not HLS-renderable. See the [`hls`](crate::hls) module for the +/// records. +#[serde_with::serde_as] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Hls { + /// The track carrying the rendition's segment records, conventionally the media track's name + /// plus [`SUFFIX`](crate::hls::SUFFIX). + pub track: String, + + /// The nominal segment duration, fixed for the life of the track. Serialized as whole + /// milliseconds, rounded up. + #[serde_as(as = "MillisCeil")] + pub duration: Duration, +} + +impl Hls { + /// Records on `track`, with segments of nominal `duration`. + pub fn new(track: impl Into, duration: Duration) -> Self { + Self { + track: track.into(), + duration, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn roundtrips_in_milliseconds() { + let hls = Hls::new("video.hls", Duration::from_micros(4_000_500)); + let json = serde_json::to_string(&hls).unwrap(); + assert_eq!(json, r#"{"track":"video.hls","duration":4001}"#); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + Hls::new("video.hls", Duration::from_millis(4001)) + ); + } +} diff --git a/rs/hang/src/catalog/mod.rs b/rs/hang/src/catalog/mod.rs index 0b2be0e5fdb..8be1512795f 100644 --- a/rs/hang/src/catalog/mod.rs +++ b/rs/hang/src/catalog/mod.rs @@ -5,7 +5,6 @@ //! resolution, bitrates, and other metadata, plus the `json` and `binary` sections //! listing the data tracks that aren't media. -mod archive; mod audio; mod binary; mod clock; @@ -14,6 +13,7 @@ mod consumer; mod container; mod echo; mod hex; +mod hls; mod json; mod m2ts; mod millis; @@ -25,7 +25,6 @@ mod stats; mod text; mod video; -pub use archive::*; pub use audio::*; pub use binary::*; pub use clock::*; @@ -33,6 +32,7 @@ pub use compression::*; pub use consumer::*; pub use container::*; pub use echo::*; +pub use hls::*; pub use json::*; pub use m2ts::*; pub use mode::*; diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 533afa8f1e6..d422dfc4757 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -10,9 +10,8 @@ use serde::{Deserialize, Serialize}; /// A catalog track, created by a broadcaster to describe the tracks available in a broadcast. /// -/// The base catalog carries the media sections (`video`, `audio`, `text`), the optional -/// `archive` (each track's timeline and any durable recording), the optional `clock` (the -/// broadcast's one wall-clock mapping), the data sections +/// The base catalog carries the media sections (`video`, `audio`, `text`), the optional `clock` +/// (the broadcast's one wall-clock mapping), the data sections /// (`json`, `binary`) for application tracks that aren't media, and the optional `m2ts` (a /// transport stream carried whole), plus `stats` naming the publisher's media snapshot track and /// `echo` soliciting viewer feedback under a relative broadcast prefix. @@ -41,18 +40,11 @@ pub struct Catalog { /// wire when it has no renditions. pub audio: Audio, - /// Each track's timeline and any durable archive, if the publisher offers them. - /// See [`Archive`](crate::catalog::Archive) and the [`timeline`](crate::timeline) module. - /// An undecodable entry is absent so incompatible archive metadata cannot reject media. - pub archive: Option, - /// The broadcast's one continuous clock, if the publisher exposes one. /// /// `wall` is the wall-clock time of PTS zero in `timescale` units since the moq epoch - /// (2020-01-01); every media track and every timeline refer to this mapping after - /// timescale conversion. Independent of [`archive`](Self::archive): a live-only publisher - /// exposes its clock without creating a segment index. See - /// [`Clock`](crate::catalog::Clock). + /// (2020-01-01); every media track and every HLS record refer to this mapping after + /// timescale conversion. See [`Clock`](crate::catalog::Clock). pub clock: Option, /// Text (caption/subtitle) track information with multiple renditions. @@ -125,10 +117,6 @@ struct Wire { #[serde(default, skip_serializing_if = "Audio::is_empty")] audio: Audio, - #[serde(default)] - #[serde_as(as = "serde_with::DefaultOnError<_>")] - archive: Option, - #[serde(default)] clock: Option, @@ -189,7 +177,6 @@ impl From> for Catalog { version: _, video, audio, - archive, clock, text, json, @@ -202,7 +189,6 @@ impl From> for Catalog { Self { video, audio, - archive, clock, text, json, @@ -328,7 +314,6 @@ impl Catalog { Catalog { video: self.video.clone(), audio: self.audio.clone(), - archive: self.archive.clone(), clock: self.clock, text: self.text.clone(), ..Default::default() @@ -506,8 +491,7 @@ mod test { "audio": {{"renditions": {{"a": {{"broadcast": "./a", "epoch": "{EPOCH}", "codec": "opus", "sampleRate": 48000, "numberOfChannels": 2, "container": {{"kind": "legacy"}}}}}}}}, "text": {{"renditions": {{"t": {{"broadcast": "./a", "epoch": "{EPOCH}", "format": "vtt"}}}}}}, "json": {{"tracks": {{"j": {{"broadcast": "./a", "epoch": "{EPOCH}", "mode": "snapshot"}}}}}}, - "binary": {{"tracks": {{"b": {{"broadcast": "./a", "epoch": "{EPOCH}", "mode": "snapshot"}}}}}}, - "archive": {{"timelines": {{}}, "replay": "./a", "replayEpoch": "{EPOCH}"}} + "binary": {{"tracks": {{"b": {{"broadcast": "./a", "epoch": "{EPOCH}", "mode": "snapshot"}}}}}} }}"# ); let catalog = Catalog::<()>::from_str(&encoded).unwrap(); @@ -522,7 +506,6 @@ mod test { for reference in references { assert_eq!(reference.epoch(), Some(&epoch)); } - assert_eq!(catalog.archive.as_ref().unwrap().replay_epoch, Some(epoch)); assert_eq!(Catalog::<()>::from_str(&catalog.to_json().unwrap()).unwrap(), catalog); // Omitted stays omitted, and a malformed or noncanonical epoch refuses the catalog. @@ -707,6 +690,7 @@ mod test { warmup: None, jitter: Some(std::time::Duration::from_millis(100)), delay: Some(std::time::Duration::from_millis(200)), + hls: None, }, ); @@ -729,6 +713,7 @@ mod test { warmup: None, jitter: Some(std::time::Duration::from_millis(40)), delay: None, + hls: None, }, ); @@ -1083,97 +1068,62 @@ mod test { #[test] fn packaged_clock_fixture_decodes() { - // The canonical new shape: the root clock plus an archive without a wall field. - // A reader maps archive timestamps through the root clock after timescale conversion. + // The canonical shape: the root clock plus a per-rendition `hls` field. A reader maps HLS + // record timestamps (milliseconds) through the root clock after timescale conversion. let json = include_str!("../../fixtures/catalog-clock.json"); let catalog = Catalog::<()>::from_str(json).expect("the packaged fixture must decode"); let clock = catalog.clock.expect("the fixture carries a root clock"); assert_eq!(clock.wall.scale().as_u64(), 1_000_000); - let archive = catalog.archive.expect("the fixture carries an archive"); - assert_eq!(archive.timelines["video"].track, "video.timeline.z"); - assert_eq!(archive.timescale, 1000); + let hls = catalog.video.renditions["video"].hls.clone().expect("the fixture opts video into HLS"); + assert_eq!(hls, crate::catalog::Hls::new("video.hls", std::time::Duration::from_secs(2))); - // Archive PTS 2000 (ms) lands 2s after the wall epoch, whatever the track timescale. + // Record PTS 2000 (ms) lands 2s after the wall epoch, whatever the track timescale. let expected = moq_clock::Wall::UNIX_EPOCH + std::time::Duration::from_millis(crate::catalog::MOQ_EPOCH_UNIX_MILLIS + 1_751_846_402_000); assert_eq!( - clock - .wall_clock(moq_net::Timestamp::from_scale(2000, archive.timescale as u64).unwrap()) - .unwrap(), + clock.wall_clock(moq_net::Timestamp::from_millis(2000).unwrap()).unwrap(), expected ); } #[test] - fn archive_roundtrips_at_the_root() { - let mut archive = crate::catalog::Archive::new(); - archive.timelines.insert( - "video".to_string(), - crate::catalog::Timeline::new("video.timeline.z", 2_000), - ); - archive.replay = Some(moq_net::path::RelativeOwned::new("recordings/clip")); - archive.version = Some(crate::catalog::Archive::VERSION); - - let catalog = Catalog::<()> { - archive: Some(archive.clone()), - ..Default::default() - }; - - let json = catalog.to_json().expect("failed to encode"); - assert!( - json.contains(r#""archive":{"timelines":{"video":{"track":"video.timeline.z","duration":2000}"#), - "{json}" - ); - assert!( - !json.contains(r#""timeline":"#), - "the old root key must not appear: {json}" - ); - assert_eq!( - Catalog::<()>::from_str(&json).expect("failed to decode").archive, - Some(archive) - ); - } - - #[test] - fn an_incompatible_archive_leaves_media_playable() { + fn a_retired_root_archive_is_ignored() { let video = serde_json::json!({ "renditions": {"video": {"codec": "avc1.64001f", "container": {"kind": "legacy"}}} }); let media = Catalog::<()>::from_str(&serde_json::json!({"video": video}).to_string()).unwrap(); - for archive in [ - serde_json::json!({"track": "timeline.z", "timescale": 1000}), - serde_json::json!({"timelines": {}, "store": "not a url"}), - serde_json::json!({"timelines": {}, "timescale": 0}), - serde_json::json!({"timelines": {"video": 42}}), - serde_json::json!({"future": "archive"}), - serde_json::json!("archive"), - serde_json::json!([]), - serde_json::Value::Null, - ] { - let json = serde_json::json!({"video": video, "archive": archive}).to_string(); - let catalog = Catalog::<()>::from_str(&json).expect("archive metadata must not reject media"); - assert_eq!(catalog.archive, None); - assert_eq!(catalog.video, media.video); - assert!(!catalog.to_json().unwrap().contains("\"archive\"")); - } - } - - #[test] - fn an_incompatible_archive_does_not_hide_invalid_media() { - Catalog::<()>::from_str( - r#"{"archive":{"track":"timeline.z"},"video":{"renditions":{"video":{"codec":42,"container":{"kind":"legacy"}}}}}"#, - ) - .expect_err("invalid media must still reject the catalog"); + let archive = serde_json::json!({ + "timelines": {"video": {"track": "video.timeline.z", "duration": 2000}}, + "timescale": 1000, + "replay": "./recordings/clip", + "store": "https://objects.example/rec/", + "version": 3 + }); + let json = serde_json::json!({"video": video, "archive": archive}).to_string(); + let catalog = Catalog::<()>::from_str(&json).expect("a retired archive must not reject the catalog"); + assert_eq!(catalog, media); + assert!(!catalog.to_json().unwrap().contains("archive")); } #[test] - fn a_legacy_root_timeline_is_not_an_archive() { - // No alias or fallback: a catalog that still names `timeline` at the root has no archive. - let catalog = - Catalog::<()>::from_str(r#"{"timeline":{"track":"timeline.z"}}"#).expect("legacy timeline broke decode"); - assert_eq!(catalog.archive, None); + fn an_undecodable_hls_field_leaves_media_playable() { + let rendition = serde_json::json!({"codec": "avc1.64001f", "container": {"kind": "legacy"}}); + let media = Catalog::<()>::from_str(&serde_json::json!({"video": {"renditions": {"video": rendition}}}).to_string()) + .unwrap(); + for hls in [ + serde_json::json!({"track": "video.hls"}), + serde_json::json!({"duration": 4000}), + serde_json::json!("video.hls"), + serde_json::Value::Null, + ] { + let mut rendition = rendition.clone(); + rendition["hls"] = hls; + let json = serde_json::json!({"video": {"renditions": {"video": rendition}}}).to_string(); + let catalog = Catalog::<()>::from_str(&json).expect("HLS metadata must not reject media"); + assert_eq!(catalog, media); + } } #[test] diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 3de3607c62a..acb41cecaef 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -351,6 +351,11 @@ pub struct VideoConfig { #[serde_as(as = "MillisCeil")] #[serde(default)] pub delay: Option, + /// This rendition's HLS segment records, when the publisher opted in. Without it the rendition + /// is not HLS-renderable. An undecodable value is absent, so it can never reject the media. + #[serde(default)] + #[serde_as(as = "serde_with::DefaultOnError<_>")] + pub hls: Option, } impl VideoConfig { @@ -386,6 +391,7 @@ impl VideoConfig { jitter: None, warmup: None, delay: None, + hls: None, } } } diff --git a/rs/hang/src/container/mod.rs b/rs/hang/src/container/mod.rs index 072ed802915..ce5d6ffe252 100644 --- a/rs/hang/src/container/mod.rs +++ b/rs/hang/src/container/mod.rs @@ -8,8 +8,8 @@ /// gone. /// /// Declared per track rather than by raising that default, so the tracks that do NOT index history -/// keep the cheap default: the catalog is snapshot mode and the timeline is a single never-rolled -/// group, and in both the useful value is the live edge, which is retained unconditionally. +/// keep the cheap default: the catalog is snapshot mode and an HLS record track restates its window +/// in each new group, so in both the useful value is the live edge, which is retained unconditionally. pub const MAX_AGE: std::time::Duration = std::time::Duration::from_secs(30); mod frame; diff --git a/rs/hang/src/hls.rs b/rs/hang/src/hls.rs new file mode 100644 index 00000000000..86fed7b0f75 --- /dev/null +++ b/rs/hang/src/hls.rs @@ -0,0 +1,112 @@ +//! HLS segment records: where each of a rendition's HLS segments starts. +//! +//! A rendition opts in with its catalog [`hls`](crate::catalog::Hls) field, which names a track +//! carrying one [`Record`] per segment. A segment starts at a group boundary, so a record is the +//! segment's first group and pts. It runs to the next record: it holds every group up to the next +//! record's `group` and lasts until the next record's `pts`, unless it carries an explicit +//! `duration`. An edge renders HLS (or DASH) playlists from the records without touching media. +//! +//! A zero `duration` marks where content stopped, at a break or the end of the track: that segment +//! holds no media, and what follows it, if anything, is a discontinuity. Records publish when their +//! segment starts, so a segment is complete once the next record, or its own duration, says where +//! it ends. +//! +//! Every `pts` and `duration` is in milliseconds. On the wire the track is an uncompressed +//! `moq-json` window (see `moq_json::window`): each group starts with a checkpoint and continues +//! with push and pop operations. + +use serde::{Deserialize, Serialize}; + +use crate::Result; + +/// The conventional suffix appended to a media track's name to name its HLS record track. +/// +/// A consumer reads the actual name from the rendition's [`hls`](crate::catalog::Hls) field rather +/// than assuming it. +pub const SUFFIX: &str = ".hls"; + +/// The conventional HLS record track name for `track`: the name plus [`SUFFIX`]. +pub fn default_name(track: &str) -> String { + format!("{track}{SUFFIX}") +} + +/// One HLS segment: its first group and pts, in milliseconds. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Record { + /// The segment's number, consecutive within the track and equal to the record's window index. + pub sequence: u64, + + /// The group the segment starts with. + pub group: u64, + + /// The pts of the segment's first frame, in milliseconds. + pub pts: u64, + + /// The segment's duration in milliseconds, when it does not run to the next record's pts. Zero + /// marks where content stopped: the segment holds no media. + #[serde(default)] + pub duration: Option, +} + +impl Record { + /// Segment `sequence`, starting with `group` at `pts` milliseconds and running to the next record. + pub fn new(sequence: u64, group: u64, pts: u64) -> Self { + Self { + sequence, + group, + pts, + duration: None, + } + } + + /// Segment `sequence` lasting exactly `duration` milliseconds; zero marks where content stopped. + pub fn with_duration(mut self, duration: u64) -> Self { + self.duration = Some(duration); + self + } + + /// Parse a record from a slice of bytes. + pub fn from_slice(v: &[u8]) -> Result { + Ok(serde_json::from_slice(v)?) + } + + /// Serialize the record to a vector of bytes. + pub fn to_vec(&self) -> Result> { + Ok(serde_json::to_vec(self)?) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn roundtrip() { + let record = Record::new(3, 42, 84_000); + let json = record.to_vec().unwrap(); + assert_eq!( + std::str::from_utf8(&json).unwrap(), + r#"{"sequence":3,"group":42,"pts":84000}"# + ); + assert_eq!(Record::from_slice(&json).unwrap(), record); + } + + #[test] + fn a_stop_carries_its_duration() { + let record = Record::new(4, 44, 88_000).with_duration(0); + let json = record.to_vec().unwrap(); + assert_eq!( + std::str::from_utf8(&json).unwrap(), + r#"{"sequence":4,"group":44,"pts":88000,"duration":0}"# + ); + assert_eq!(Record::from_slice(&json).unwrap(), record); + } + + #[test] + fn a_record_without_its_group_is_refused() { + assert!(Record::from_slice(br#"{"sequence":0,"pts":0}"#).is_err()); + } +} diff --git a/rs/hang/src/lib.rs b/rs/hang/src/lib.rs index 2b92e679427..b65baa5c582 100644 --- a/rs/hang/src/lib.rs +++ b/rs/hang/src/lib.rs @@ -6,8 +6,8 @@ //! //! - **Catalog**: A JSON track containing codec info and track metadata, updated live as tracks change. //! - **Tracks**: Audio or video, supporting one or more renditions. -//! - **Timeline**: A JSON track indexing the broadcast's aligned segments (one or more groups each), so a -//! consumer can seek (or build indexes/playlists) without downloading media. Advertised by the catalog's `archive` entry. +//! - **HLS records**: An opt-in JSON track per rendition marking each aligned HLS segment's first group +//! and pts, so an edge can render playlists without downloading media. Advertised by the rendition's `hls` field. //! //! Each track specifies a container format: //! - **Legacy**: A timestamp followed by the codec payload. @@ -35,8 +35,8 @@ pub mod catalog; /// The container is the contents of each media track. pub mod container; -/// Per-track timelines index each track's spans. -pub mod timeline; +/// HLS segment records, opt-in per rendition. +pub mod hls; /// Export the moq-net version we use. pub use moq_net; diff --git a/rs/hang/src/timeline.rs b/rs/hang/src/timeline.rs deleted file mode 100644 index c9c3e53faef..00000000000 --- a/rs/hang/src/timeline.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Per-track timelines: each track's index of stored spans, one record per span. -//! -//! MoQ groups carry only an opaque sequence number; the timestamps live inside the media -//! frames. A timeline track republishes one track's spans as metadata: each record maps a span -//! of content time to the group and frame positions that carry it. A consumer can answer -//! "which groups cover time T on track X" (and "where is the live edge") from a few bytes per -//! span without downloading media. That is what an HLS/DASH origin needs to render playlists -//! without touching media bytes, the index a VOD player seeks with, and the object index of a -//! recording. -//! -//! ## Spans -//! -//! Every track has its own timeline, so tracks cut, commit, and expire independently. A span -//! usually holds whole groups; a group that outlives the publisher's maximum span is split by -//! frame, so an append-only group that never closes is still indexed as its frames arrive. -//! Spans are contiguous in position: a record starts where the previous one ended unless the -//! track skipped group sequences in between. An untimed track has no content time, so its records -//! omit `pts` and `duration` and each holds one group. -//! -//! The catalog's root [`Archive`](crate::catalog::Archive) entry maps each track to its -//! timeline track, conventionally the track name plus [`SUFFIX`](crate::timeline::SUFFIX). -//! -//! On the wire each timeline is a DEFLATE-compressed `moq-json` window (see `moq_json::window`). -//! Each group starts with a checkpoint and continues with push/pop operations; group rolls are an -//! encoding detail that consumers do not surface as duplicate records. Like the catalog, a record -//! tolerates and preserves unknown fields: extend it by flattening a -//! [`Record`](crate::timeline::Record) into your own struct via -//! [`RecordExt`](crate::timeline::RecordExt). - -use serde::{Deserialize, Serialize}; - -use crate::Result; - -/// The conventional suffix appended to a track name to name its timeline track (the `.z` marks -/// the DEFLATE-compressed stream, like the catalog's `.json.z` sibling). -/// -/// A publisher records the actual names in the catalog's root -/// [`Archive`](crate::catalog::Archive) entry; a consumer reads them from the catalog rather than -/// assuming, so this is only a default. -pub const SUFFIX: &str = ".timeline.z"; - -/// The conventional timeline track name for `track`: the name plus [`SUFFIX`]. -pub fn default_name(track: &str) -> String { - format!("{track}{SUFFIX}") -} - -/// The application extension carried alongside a record's base fields. -/// -/// Defaults to `()` (no extra fields). Set an application's own typed struct to add fields -/// (e.g. an ad-break marker); it is flattened into the record's JSON object, exactly like -/// [`Catalog`](crate::Catalog)'s extension. `()` is the base case. -pub trait RecordExt: serde::Serialize + serde::de::DeserializeOwned + Default + Clone + Send + Unpin + 'static {} -impl RecordExt for () {} - -/// A frame position within a track: frame `frame` of group `group`. -/// -/// Positions order by group, then frame. `frame` is omitted from the JSON when zero, so a -/// position at a group start reads as just the group. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] -pub struct Position { - /// The group sequence, as used by FETCH/SUBSCRIBE on the track. - pub group: u64, - - /// The frame index within the group. - #[serde(default, skip_serializing_if = "is_zero")] - pub frame: u64, -} - -fn is_zero(v: &u64) -> bool { - *v == 0 -} - -impl Position { - /// Frame `frame` of group `group`. - pub const fn new(group: u64, frame: u64) -> Self { - Self { group, frame } - } - - /// The start of group `group`. - pub const fn group(group: u64) -> Self { - Self { group, frame: 0 } - } -} - -/// One timeline record: a span of one track, mapping its content time to frame positions. -/// -/// The span holds every frame from [`start`](Self::start) (inclusive) to [`end`](Self::end) -/// (exclusive). An `end` at frame zero of group `g` means the span holds every frame of group -/// `g - 1` and nothing of `g`, whether or not `g` exists. `pts`/`duration` are in the timescale -/// declared by the catalog's [`Archive`](crate::catalog::Archive) entry (default milliseconds), -/// and both are absent on an untimed track's records. Extend with a typed [`RecordExt`]. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde( - rename_all = "camelCase", - try_from = "Parsed", - bound(serialize = "E: serde::Serialize", deserialize = "E: serde::de::DeserializeOwned") -)] -pub struct Record { - /// The record's number, consecutive within its track's timeline and equal to its window index. - pub sequence: u64, - - /// The span's start, the timestamp of its first frame, in the timeline's timescale. `None` on - /// an untimed track, whose frames carry no timestamps. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pts: Option, - - /// The span's duration, in the timeline's timescale. The next record's `pts` is - /// `pts + duration` unless content time itself jumped (a discontinuity). `None` exactly when - /// `pts` is. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub duration: Option, - - /// The first frame of the span, inclusive. - pub start: Position, - - /// The end of the span, exclusive. - pub end: Position, - - /// Whether the span's first frame is a keyframe, i.e. whether a player can join or switch - /// renditions at this record. Defaults to `true` (and is omitted from the JSON when true). - #[serde(default = "default_keyframe", skip_serializing_if = "Clone::clone")] - pub keyframe: bool, - - /// The application extension, flattened into the record's JSON object (nothing for the - /// default `()`). See [`RecordExt`]. - #[serde(flatten)] - pub ext: E, -} - -fn default_keyframe() -> bool { - true -} - -/// A [`Record`] as parsed, before checking that it carries both or neither of `pts` and `duration`. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", bound(deserialize = "E: serde::de::DeserializeOwned"))] -struct Parsed { - sequence: u64, - #[serde(default)] - pts: Option, - #[serde(default)] - duration: Option, - start: Position, - end: Position, - #[serde(default = "default_keyframe")] - keyframe: bool, - #[serde(flatten)] - ext: E, -} - -impl TryFrom> for Record { - type Error = String; - - fn try_from(parsed: Parsed) -> std::result::Result { - if parsed.pts.is_some() != parsed.duration.is_some() { - return Err(format!( - "record {} needs both pts and duration, or neither", - parsed.sequence - )); - } - Ok(Self { - sequence: parsed.sequence, - pts: parsed.pts, - duration: parsed.duration, - start: parsed.start, - end: parsed.end, - keyframe: parsed.keyframe, - ext: parsed.ext, - }) - } -} - -impl Record { - /// A keyframe record spanning `start..end` with the default (empty) extension. - pub fn new(sequence: u64, pts: u64, duration: u64, start: Position, end: Position) -> Self { - Self { - pts: Some(pts), - duration: Some(duration), - ..Self::untimed(sequence, start, end) - } - } - - /// An untimed track's keyframe record spanning `start..end`, with no `pts` or `duration`. - pub fn untimed(sequence: u64, start: Position, end: Position) -> Self { - Self { - sequence, - pts: None, - duration: None, - start, - end, - keyframe: true, - ext: E::default(), - } - } - - /// The groups holding at least one frame of the span, in order. - pub fn groups(&self) -> std::ops::RangeInclusive { - let last = match self.end.frame { - 0 => self.end.group.saturating_sub(1), - _ => self.end.group, - }; - self.start.group..=last - } - - /// Parse a record from a slice of bytes. - pub fn from_slice(v: &[u8]) -> Result { - Ok(serde_json::from_slice(v)?) - } - - /// Serialize the record to a vector of bytes. - pub fn to_vec(&self) -> Result> { - Ok(serde_json::to_vec(self)?) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn roundtrip() { - let record = Record::<()>::new(3, 84_000, 6_000, Position::group(42), Position::group(44)); - let json = record.to_vec().unwrap(); - assert_eq!( - std::str::from_utf8(&json).unwrap(), - r#"{"sequence":3,"pts":84000,"duration":6000,"start":{"group":42},"end":{"group":44}}"# - ); - assert_eq!(Record::<()>::from_slice(&json).unwrap(), record); - assert_eq!(record.groups(), 42..=43); - } - - #[test] - fn a_frame_split_roundtrips() { - let mut record = Record::<()>::new(0, 0, 10_000, Position::new(7, 300), Position::new(7, 600)); - record.keyframe = false; - let json = record.to_vec().unwrap(); - assert_eq!( - std::str::from_utf8(&json).unwrap(), - r#"{"sequence":0,"pts":0,"duration":10000,"start":{"group":7,"frame":300},"end":{"group":7,"frame":600},"keyframe":false}"# - ); - assert_eq!(Record::<()>::from_slice(&json).unwrap(), record); - assert_eq!(record.groups(), 7..=7); - - // An omitted flag decodes as the keyframe default. - let plain: Record = - Record::from_slice(br#"{"sequence":0,"pts":0,"duration":1,"start":{"group":1},"end":{"group":2}}"#) - .unwrap(); - assert!(plain.keyframe); - } - - #[test] - fn an_untimed_record_omits_its_timing() { - let record = Record::<()>::untimed(4, Position::group(9), Position::group(10)); - let json = record.to_vec().unwrap(); - assert_eq!( - std::str::from_utf8(&json).unwrap(), - r#"{"sequence":4,"start":{"group":9},"end":{"group":10}}"# - ); - assert_eq!(Record::<()>::from_slice(&json).unwrap(), record); - } - - #[test] - fn a_record_with_half_its_timing_is_refused() { - for json in [ - r#"{"sequence":0,"pts":0,"start":{"group":0},"end":{"group":1}}"#, - r#"{"sequence":0,"duration":1,"start":{"group":0},"end":{"group":1}}"#, - ] { - assert!(Record::<()>::from_slice(json.as_bytes()).is_err(), "{json}"); - } - } - - #[test] - fn typed_extension_flattens() { - // An application extends the record with its own typed section, flattened into the object. - #[derive(serde::Serialize, serde::Deserialize, Default, Clone, PartialEq, Debug)] - struct Ext { - #[serde(skip_serializing_if = "std::ops::Not::not", default)] - discontinuity: bool, - } - impl RecordExt for Ext {} - - let mut record = Record::::new(2, 14_000, 2_000, Position::group(1), Position::group(2)); - record.ext.discontinuity = true; - let json = record.to_vec().unwrap(); - assert_eq!( - std::str::from_utf8(&json).unwrap(), - r#"{"sequence":2,"pts":14000,"duration":2000,"start":{"group":1},"end":{"group":2},"discontinuity":true}"# - ); - assert_eq!(Record::::from_slice(&json).unwrap(), record); - } -} diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index 8a7c29091bc..5ed2d30c259 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -2145,7 +2145,8 @@ mod tests { let consumer = broadcast.consume(); let config = moq_mux::catalog::Config::default() .with_clock(clock) - .with_max_age(RETAIN); + .with_max_age(RETAIN) + .with_hls(moq_mux::hls::Config::default()); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config).unwrap(); let mut options = Capture::default(); options.encode.track = Some("audio".into()); @@ -2431,10 +2432,10 @@ mod tests { fixture.finish().await; } - /// A recording replays what the live edge published: the archive's records - /// carry the live timestamps across an idle restart, with the idle gap left in. + /// HLS lists what the live edge published: the records carry the live timestamps across an + /// idle restart, with the idle gap left out of every segment. #[tokio::test] - async fn retained_archive_playback_keeps_the_live_timestamps() { + async fn hls_records_keep_the_live_timestamps() { let (first, input) = stream(None); let (second, resumed) = stream(None); let mut fixture = Fixture::start( @@ -2443,50 +2444,46 @@ mod tests { [Open::Stream(input), Open::Stream(resumed)], ); let mut live = Vec::new(); - let mut timeline = None; + let mut records = None; for samples in [&first, &second] { let mut track = fixture.subscribe().await; live.push(fixture.deliver(samples, &mut track, &live).await); drop(track); wait_for(&mut fixture.publication, Status::Waiting).await; - // The second run's first group closes the unhinted timeline's first record. - if timeline.is_none() && live.len() > 1 { - let section = fixture - .catalog - .snapshot() - .archive - .expect("the audio track declares its archive after the run closes"); - timeline = Some( - moq_mux::timeline::Consumer::<()>::subscribe(&fixture.consumer, §ion, "audio") - .await - .unwrap(), - ); + // The first run's stop declares the unhinted duration. + if records.is_none() { + let hls = fixture.catalog.snapshot().audio.renditions["audio"] + .hls + .clone() + .expect("the audio track declares its HLS records once the run stops"); + records = Some(moq_mux::hls::Consumer::subscribe(&fixture.consumer, &hls).await.unwrap()); } - // Idle past the minimum segment, so each run is archived as its own record. - tokio::time::sleep(moq_mux::timeline::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; + // Idle past the minimum segment, so each run is listed as its own segment. + tokio::time::sleep(moq_mux::hls::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; } - let (catalog, _consumer) = fixture.finish().await; - catalog.timeline().finish(); - let mut timeline = timeline.unwrap(); - let mut archived = Vec::new(); - while let Some(event) = timeline.next().await.unwrap() { + let (mut catalog, _consumer) = fixture.finish().await; + catalog.finish().unwrap(); + let mut records = records.unwrap(); + let mut listed = Vec::new(); + while let Some(event) = records.next().await.unwrap() { match event { - moq_mux::timeline::Event::Push { entry, .. } => archived.push(entry), - other => panic!("unexpected timeline event {other:?}"), + moq_mux::hls::Event::Push { entry, .. } => listed.push(entry), + other => panic!("unexpected record event {other:?}"), } } - assert_eq!(archived.len(), live.len(), "one record per capture run: {archived:?}"); - for (entry, live) in archived.iter().zip(&live) { - // The archive keeps millisecond precision. - assert_eq!(entry.pts.as_micros() / 1000, u128::from(*live / 1000), "{archived:?}"); + // Each run is one segment, stopped where its content reached. + assert_eq!(listed.len(), 2 * live.len(), "a segment and a stop per run: {listed:?}"); + for (pair, live) in listed.chunks(2).zip(&live) { + assert!(!pair[0].is_stop() && pair[1].is_stop(), "{listed:?}"); + // The records keep millisecond precision. + assert_eq!(pair[0].pts.as_micros() / 1000, u128::from(*live / 1000), "{listed:?}"); } - let first = &archived[0]; assert!( - archived[1].pts.as_micros() >= first.pts.as_micros() + first.duration.as_micros(), - "the resumed record overlaps the one before it: {archived:?}" + listed[2].pts.as_micros() >= listed[1].pts.as_micros(), + "the resumed segment overlaps the one before it: {listed:?}" ); } } diff --git a/rs/moq-boy/src/video.rs b/rs/moq-boy/src/video.rs index b7c6ab35514..a44147328d6 100644 --- a/rs/moq-boy/src/video.rs +++ b/rs/moq-boy/src/video.rs @@ -59,7 +59,7 @@ impl VideoEncoder { let rendition = config.probe().await.expect("failed to probe the H.264 encoder"); let reserved = catalog .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)); + .with_hls_hint(config.gop.duration(config.framerate)); let producer = moq_video::encode::Producer::new(broadcast, reserved, rendition).expect("failed to create avc3 producer"); let demand = producer.demand(); diff --git a/rs/moq-c/src/video.rs b/rs/moq-c/src/video.rs index ced9a90b0b3..42a95343d64 100644 --- a/rs/moq-c/src/video.rs +++ b/rs/moq-c/src/video.rs @@ -489,7 +489,7 @@ impl Video { let reserved = target .catalog .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)); + .with_hls_hint(config.gop.duration(config.framerate)); let ceiling = video_ceiling(config, &rendition); let producer = moq_video::encode::Producer::new(target.producer.clone(), reserved, rendition)?; let finished = target.finished.clone(); @@ -1047,7 +1047,7 @@ mod tests { let rendition = encode.probe().await.unwrap(); let reserved = catalog .reserve() - .with_timeline_hint(encode.gop.duration(encode.framerate)); + .with_hls_hint(encode.gop.duration(encode.framerate)); let producer = moq_video::encode::Producer::new(broadcast, reserved, rendition).unwrap(); let name = producer.demand().name().to_string(); diff --git a/rs/moq-ffi/src/video.rs b/rs/moq-ffi/src/video.rs index a462aef35cc..4d4bf767390 100644 --- a/rs/moq-ffi/src/video.rs +++ b/rs/moq-ffi/src/video.rs @@ -616,13 +616,13 @@ impl MoqVideoProducer { .create_track(name, state.catalog.track_info(hang::catalog::PRIORITY.video))?; Ok(moq_video::encode::Producer::with_track( track, - state.catalog.reserve().with_timeline_hint(gop), + state.catalog.reserve().with_hls_hint(gop), rendition, )?) } None => Ok(moq_video::encode::Producer::new( state.broadcast.clone(), - state.catalog.reserve().with_timeline_hint(gop), + state.catalog.reserve().with_hls_hint(gop), rendition, )?), })?; diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index d3e39bdd12f..9c7e90a19a6 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -159,40 +159,14 @@ struct Reservations { reserved: bool, } -/// The built-in catalog's own timeline, enrolled with the first media track. -#[derive(Default)] -struct CatalogTimeline { - recorder: Option, - last_sequence: Option, -} - -impl CatalogTimeline { - /// Report a newly published plaintext catalog group. Each catalog group is one complete - /// snapshot, so it finishes as soon as it is reported. Only this producer writes the track, - /// one group per catalog from zero, so the sequence is counted here. - fn record(&mut self) { - // Counted before enrollment too, so the first recorded group has its real sequence. - let sequence = self.last_sequence.map_or(0, |sequence| sequence + 1); - self.last_sequence = Some(sequence); - let Some(recorder) = self.recorder.as_mut() else { - return; - }; - recorder.frame( - hang::timeline::Position::group(sequence), - moq_net::Timestamp::now(), - true, - ); - recorder.finish_group(sequence); - } -} - -/// The catalog tracks and the timeline enrollment updated with them. +/// The catalog tracks and the HLS record tracks advertised in them. struct Outputs { hang: moq_json::snapshot::Producer>, hang_track: moq_net::track::Producer, hangz: moq_json::snapshot::Producer>, msf_track: moq_net::track::Producer, - catalog_timeline: Arc>, + /// The broadcast's HLS record tracks, when [`Config::with_hls`] enabled them. + hls: Option, } impl Clone for Outputs { @@ -202,23 +176,52 @@ impl Clone for Outputs { hang_track: self.hang_track.clone(), hangz: self.hangz.clone(), msf_track: self.msf_track.clone(), - catalog_timeline: self.catalog_timeline.clone(), + hls: self.hls.clone(), } } } impl Outputs { - /// Emit the catalog to hang, compressed hang, MSF, and the broadcast timeline. + /// The catalog as published: each local rendition with HLS records gets its `hls` field. + /// + /// Filled in here rather than stored, so an owner that rewrites its rendition's config can't + /// drop the field, and a track whose duration is not yet declared simply lacks it. + fn advertise<'a>(&self, catalog: &'a Catalog) -> std::borrow::Cow<'a, Catalog> { + let Some(hls) = &self.hls else { + return std::borrow::Cow::Borrowed(catalog); + }; + let mut catalog = catalog.clone(); + let video = catalog + .video + .renditions + .iter_mut() + .map(|(id, config)| (id, &config.broadcast, &config.track, &mut config.hls)); + let audio = catalog + .audio + .renditions + .iter_mut() + .map(|(id, config)| (id, &config.broadcast, &config.track, &mut config.hls)); + for (id, broadcast, track, field) in video.chain(audio) { + // A rendition served from another broadcast carries that broadcast's records, if any. + if broadcast.is_some() { + continue; + } + if let Some(advertised) = hls.hls(track.as_deref().unwrap_or(id)) { + *field = Some(advertised); + } + } + std::borrow::Cow::Owned(catalog) + } + + /// Emit the catalog to hang, compressed hang, and MSF. fn emit(&mut self, catalog: &Catalog) -> crate::Result<()> { + let catalog = self.advertise(catalog); + let catalog = catalog.as_ref(); // Every consumer refuses a catalog that fails this, so refuse it before it reaches one. catalog.check_renditions()?; // One snapshot per group while deltas are disabled; the `.z` track carries the identical catalog. - let written = self - .hang + self.hang .update(moq_net::Timed::from(catalog).at(moq_net::Timestamp::now()))?; - if written.is_some() { - self.catalog_timeline.lock().unwrap().record(); - } self.hangz .update(moq_net::Timed::from(catalog).at(moq_net::Timestamp::now()))?; @@ -274,11 +277,8 @@ pub struct Producer { current: Arc>>, - /// The broadcast's per-track timelines. See [`timeline`](Self::timeline). - timeline: crate::timeline::Timelines, - /// How each media track's timeline is cut. The catalog's own uses a zero minimum. - /// See [`Config::with_timeline`]. - cutting: crate::timeline::Config, + /// How each media track's HLS segments are cut, when enabled. See [`Config::with_hls`]. + hls: Option, /// Retention override for the media tracks minted under this catalog, or `None` to keep /// hang's default. Fixed at construction, so every clone and every /// [`Reserved`](super::Reserved) mints tracks under one policy. See @@ -300,8 +300,7 @@ impl Clone for Producer { Self { outputs: self.outputs.clone(), current: self.current.clone(), - timeline: self.timeline.clone(), - cutting: self.cutting.clone(), + hls: self.hls.clone(), max_age: self.max_age, bandwidth: self.bandwidth.clone(), order: self.order.clone(), @@ -323,7 +322,7 @@ pub struct Config { max_age: Option, bandwidth: moq_net::bandwidth::Allocator, clock: Option, - timeline: crate::timeline::Config, + hls: Option, } impl Default for Config<()> { @@ -333,7 +332,7 @@ impl Default for Config<()> { max_age: None, bandwidth: moq_net::bandwidth::Allocator::unlimited(), clock: None, - timeline: crate::timeline::Config::default(), + hls: None, } } } @@ -349,7 +348,7 @@ impl Config { max_age: self.max_age, bandwidth: self.bandwidth, clock: self.clock, - timeline: self.timeline, + hls: self.hls, } } @@ -377,7 +376,7 @@ impl Config { /// never makes a subscriber play further behind live -- it caps what one may ask to wait /// for, and a subscriber's own budget still defaults to zero. /// - /// Applies to the media tracks the catalog mints. The catalog and timeline tracks keep + /// Applies to the media tracks the catalog mints. The catalog and HLS record tracks keep /// moq-net's default: both are read at the live edge, which is retained unconditionally. pub fn with_max_age(mut self, max_age: impl Into>) -> Self { self.max_age = max_age.into(); @@ -396,9 +395,11 @@ impl Config { self } - /// Cut each media track's timeline with `timeline`; the catalog's own always uses a zero minimum. - pub fn with_timeline(mut self, timeline: crate::timeline::Config) -> Self { - self.timeline = timeline; + /// Publish HLS segment records for each video and audio rendition, cut by `hls`; off by default. + /// + /// See [`hls`](crate::hls) for what that costs and what it promises. + pub fn with_hls(mut self, hls: impl Into>) -> Self { + self.hls = hls.into(); self } } @@ -411,8 +412,7 @@ impl Producer { /// different mapping, which is refused. pub fn new(broadcast: &mut moq_net::broadcast::Producer, config: Config) -> crate::Result { // The broadcast clock is advertised at the catalog root from the first snapshot, - // independently of any archive timeline: a live-only publisher exposes its mapping - // without creating a segment index. A host with an unset wall clock advertises none. + // whether or not HLS records are enabled. A host with an unset wall clock advertises none. // Resolved before any track exists, so a refused seed leaves the broadcast untouched. let anchored = config.clock.is_some(); let mut catalog = config.catalog; @@ -442,8 +442,7 @@ impl Producer { json_config.compression = moq_json::Compression::Deflate; let hangz = moq_json::snapshot::Producer::new(hangz_track, json_config); - let timeline = crate::timeline::Timelines::new(broadcast); - let catalog_timeline = Arc::new(Mutex::new(CatalogTimeline::default())); + let hls = config.hls.as_ref().map(|_| crate::hls::Tracks::new(broadcast)); Ok(Self { outputs: Outputs { @@ -451,7 +450,7 @@ impl Producer { hang_track, hangz, msf_track, - catalog_timeline, + hls, }, current: Arc::new(Mutex::new(State { catalog, @@ -463,8 +462,7 @@ impl Producer { anchored, estimates: Throttle::default(), })), - timeline, - cutting: config.timeline, + hls: config.hls, max_age: config.max_age, bandwidth: config.bandwidth, order: Default::default(), @@ -548,7 +546,8 @@ impl Producer { /// Get a snapshot of the current catalog. pub fn snapshot(&self) -> Catalog { - take(&self.current).catalog.clone() + let state = take(&self.current); + self.outputs.advertise(&state.catalog).into_owned() } /// The broadcast's shared clock: the timeline producers stamp against and the wall mapping @@ -796,25 +795,21 @@ impl Producer { } /// Build the media [`container::Producer`](crate::container::Producer) for `track`, - /// enrolling it in the broadcast's timelines so its groups are indexed. - /// - /// The track's timeline track is created and advertised in the catalog's root `archive` - /// entry; see [`timeline`](crate::timeline) for the whole model. + /// enrolling it for HLS records when [`Config::with_hls`] enabled them. pub(super) fn media( &self, track: moq_net::track::Producer, container: C, rendition: super::Rendition, config: Option, - timeline_hint: Option, + hls_hint: Option, ) -> crate::Result> where C: crate::container::Container, R: super::RenditionConfig, crate::Error: From, { - let mut catalog = self.clone(); - let recorder = catalog.enroll(track.name(), timeline_hint)?; + let recorder = self.enroll(track.name(), hls_hint)?; let sent = self.sent(rendition.name()); let mut producer = crate::container::Producer::with_rendition(track, container, rendition) .with_recorder(recorder) @@ -836,11 +831,8 @@ impl Producer { C: crate::container::Container, crate::Error: From, { - let mut catalog = self.clone(); - let recorder = catalog.enroll(track.name(), None)?; let sent = self.sent(track.name()); Ok(crate::container::Producer::new(track, container) - .with_recorder(recorder) .with_bandwidth(self.bandwidth.clone()) .with_sent(sent)) } @@ -872,96 +864,48 @@ impl Producer { self.bandwidth.clone() } - /// Enroll `track` in the broadcast's timelines, advertising its timeline in the catalog's - /// root section with the next catalog change. The first enrollment also enrolls the catalog - /// itself. + /// Enroll `track` for HLS records when [`Config::with_hls`] enabled them, advertising them in its + /// rendition's `hls` field once the nominal duration is known. /// /// The role-specific track constructors call this for you. fMP4 passthrough calls it directly /// because it writes groups by hand instead of using a [`container::Producer`](crate::container::Producer). pub(crate) fn enroll( - &mut self, + &self, track: &str, - duration_hint: Option, - ) -> crate::Result { - let mut cutting = self.cutting.clone(); - if duration_hint.is_some() { - cutting = cutting.with_duration_hint(duration_hint); - } - let mut recorder = self.timeline.track(track, cutting)?; - { - let mut catalog = self.outputs.catalog_timeline.lock().unwrap(); - if catalog.recorder.is_none() { - // Sparse data: a catalog may not change again, so each group is its own record, and a - // media hint says nothing about its cadence. - let cutting = self - .cutting - .clone() - .with_duration_min(std::time::Duration::ZERO) - .with_duration_hint(None); - catalog.recorder = Some(self.timeline.track(hang::Catalog::DEFAULT_NAME, cutting)?); - } - } - - // Staged without publishing: the entry the track belongs to commits it, so no consumer sees a - // catalog advertising a timeline before its rendition. - let section = self.timeline.section(); - let mut state = take(&self.current); - if let Some(err) = &state.closed { + hint: Option, + ) -> crate::Result> { + if let Some(err) = &take(&self.current).closed { return Err(err.clone()); } - match &mut state.catalog.archive { - Some(archive) => archive.timelines = section.timelines, - None => state.catalog.archive = Some(section), + let (Some(tracks), Some(config)) = (&self.outputs.hls, &self.hls) else { + return Ok(None); + }; + let mut config = config.clone(); + if hint.is_some() { + config = config.with_duration_hint(hint); } - - if !state - .catalog - .archive - .as_ref() - .is_some_and(|archive| archive.timelines.contains_key(track)) - { - let name = track.to_string(); - let timeline = hang::timeline::default_name(track); - let current = self.current.clone(); - let mut outputs = self.outputs.clone(); - recorder.on_declare(move |duration| { - let mut state = take(¤t); - if state.closed.is_some() { - return; - } - state - .catalog - .archive - .get_or_insert_with(hang::catalog::Archive::new) - .timelines - .insert(name, hang::catalog::Timeline::new(timeline, duration)); - let reservations = &mut state.reservations; - if !reservations.published && reservations.reservers != 0 { - reservations.pending = true; - return; - } - reservations.pending = false; - reservations.published = true; - state.anchored = true; - if let Err(err) = outputs.emit(&state.catalog) { - tracing::error!(%err, "failed to publish a timeline declaration, aborting catalog tracks"); - outputs.abort(&err); - state.closed = Some(err); - } else { - state.estimates.fold(); - } - }); + let mut recorder = tracks.track(track, config)?; + if tracks.hls(track).is_some() { + // Hinted: the entry the track belongs to advertises it when it commits. + return Ok(Some(recorder)); } - Ok(recorder) - } - - /// The broadcast's [`Timelines`](crate::timeline::Timelines): each enrolled track's index. - /// - /// A track's timeline is created (and advertised in the catalog's root `archive` entry) when - /// it enrolls, so reading this costs nothing on a broadcast that never enrolls one. - pub fn timeline(&self) -> crate::timeline::Timelines { - self.timeline.clone() + // Unhinted: the first segment declares the duration, which republishes the catalog. + let current = self.current.clone(); + let mut outputs = self.outputs.clone(); + recorder.on_declare(move |_| { + let mut state = take(¤t); + // Before the initial publish, that publish advertises it whenever its gate opens. + if state.closed.is_some() || !state.reservations.published { + return; + } + if let Err(err) = outputs.emit(&state.catalog) { + tracing::error!(%err, "failed to publish an HLS declaration, aborting catalog tracks"); + outputs.abort(&err); + state.closed = Some(err); + } + }); + Ok(Some(recorder)) } /// Publish `track` as a latest-value JSON track, advertising it in the catalog. @@ -1060,16 +1004,20 @@ impl Producer { state.closed = Some(reason.clone()); } self.outputs.abort(&reason); - self.timeline.finish(); + if let Some(hls) = &self.outputs.hls { + hls.finish(); + } } /// Finish publishing to this catalog. /// /// An estimate rise still held by the once-a-second limit is not published: media has ended. pub fn finish(&mut self) -> crate::Result<()> { - // A track that ends before its first record closes declares its timeline now, while the + // A track that ends before its first segment closes declares its duration now, while the // catalog can still advertise it. - self.timeline.flush(); + if let Some(hls) = &self.outputs.hls { + hls.flush(); + } { let mut state = take(&self.current); if let Some(err) = &state.closed { @@ -1080,7 +1028,9 @@ impl Producer { self.outputs.hang.finish()?; self.outputs.hangz.finish()?; self.outputs.msf_track.finish()?; - self.timeline.finish(); + if let Some(hls) = &self.outputs.hls { + hls.finish(); + } Ok(()) } } @@ -1542,9 +1492,9 @@ mod test { )); } - /// The catalog, its MSF twin, and the timeline all rank above media. They're the + /// The catalog, its MSF twin, and the HLS records all rank above media. They're the /// index a player reads before any media is useful, and they're small enough that - /// sitting above media can't starve it. Regression: the MSF and timeline tracks + /// sitting above media can't starve it. Regression: the MSF and record tracks /// were minted with no `Info` at all, so one broadcast advertised its hang catalog /// at 100 and the same catalog in MSF at 0. #[tokio::test] @@ -1552,15 +1502,15 @@ mod test { use hang::catalog::PRIORITY; let mut broadcast = moq_net::broadcast::Info::new().produce(); - let catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); - let _recorder = catalog.timeline().track("video", Default::default()).unwrap(); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); + let _recorder = catalog.enroll("video", None).unwrap(); let consumer = broadcast.consume(); for name in [ hang::Catalog::DEFAULT_NAME, hang::Catalog::COMPRESSED_NAME, moq_msf::DEFAULT_NAME, - "video.timeline.z", + "video.hls", ] { let track = consumer.track(name).expect("track"); let info = track.query().await.expect("info"); @@ -1928,132 +1878,120 @@ mod test { ); } - #[test] - fn timeline_reports_a_track_collision() { - let mut broadcast = moq_net::broadcast::Info::new().produce(); - let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); - - // Something else already took the name the timeline track wants. - let _taken = broadcast.create_track("video0.timeline.z", None).unwrap(); - assert!(catalog.enroll("video0", None).is_err()); + /// A catalog publishing HLS records. + fn hls() -> Config { + Config::default().with_hls(crate::hls::Config::default()) } - #[test] - fn enrolling_advertises_the_catalog_section() { - let mut broadcast = moq_net::broadcast::Info::new().produce(); - let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); - - // A broadcast that never segments never advertises an archive. - assert_eq!(catalog.snapshot().archive, None); - let _recorder = catalog - .enroll("video0", Some(std::time::Duration::from_secs(4))) + /// A video rendition `video0` on `catalog`, with a keyframe group at each of `starts` (ms). + fn video0( + broadcast: &mut moq_net::broadcast::Producer, + catalog: &Producer, + starts: &[u64], + ) -> crate::container::Producer { + let net = broadcast + .create_track("video0", catalog.track_info(hang::catalog::PRIORITY.video)) .unwrap(); - let archive = catalog - .snapshot() - .archive - .expect("the root archive advertises the timelines"); - assert_eq!(archive, catalog.timeline().section()); - assert_eq!( - archive.timelines.keys().collect::>(), - vec!["catalog.json", "video0"], - "the catalog indexes itself alongside its first media track" - ); + let mut video = catalog + .video( + net, + crate::catalog::hang::Container::Legacy(crate::container::Kind::Video), + VideoConfig::new(hang::catalog::VideoCodec::VP8), + ) + .unwrap(); + for start in starts { + video + .write(crate::container::Frame { + timestamp: moq_net::Timestamp::from_millis(*start).unwrap(), + duration: None, + payload: Bytes::from_static(b"keyframe"), + keyframe: true, + }) + .unwrap(); + } + video } #[test] - fn an_estimated_timeline_is_advertised_after_its_first_record() { + fn hls_reports_a_track_collision() { let mut broadcast = moq_net::broadcast::Info::new().produce(); - let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); - let mut recorder = catalog.enroll("video0", None).unwrap(); - - let initial = catalog.snapshot().archive.expect("the catalog timeline is advertised"); - assert!(!initial.timelines.contains_key("video0")); - - recorder.frame( - hang::timeline::Position::group(0), - moq_net::Timestamp::from_millis(0).unwrap(), - true, - ); - recorder.finish_group(0); - recorder.frame( - hang::timeline::Position::group(1), - moq_net::Timestamp::from_millis(4_000).unwrap(), - true, - ); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); - let archive = catalog.snapshot().archive.expect("the estimate publishes the timeline"); - assert_eq!(archive.timelines["video0"].duration, Some(4_000)); + // Something else already took the name the record track wants. + let _taken = broadcast.create_track("video0.hls", None).unwrap(); + assert!(catalog.enroll("video0", None).is_err()); } #[test] - fn a_track_shorter_than_one_record_is_advertised_on_finish() { + fn nothing_publishes_hls_unless_enabled() { let mut broadcast = moq_net::broadcast::Info::new().produce(); - let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); - let mut recorder = catalog.enroll("audio0", None).unwrap(); - recorder.frame( - hang::timeline::Position::group(0), - moq_net::Timestamp::from_millis(0).unwrap(), - true, - ); + let catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); + assert!(catalog.enroll("audio0", Some(std::time::Duration::from_secs(4))).unwrap().is_none()); - catalog.finish().unwrap(); - let archive = catalog.snapshot().archive.expect("the catalog timeline is advertised"); - assert_eq!(archive.timelines["audio0"].duration, Some(4_000)); + let _video = video0(&mut broadcast, &catalog, &[0, 4_000, 8_000]); + assert_eq!(catalog.snapshot().video.renditions["video0"].hls, None); + // The name stays free for the application. + assert!(broadcast.create_track("video0.hls", None).is_ok()); } - #[test] - fn a_track_finished_before_its_catalog_is_advertised() { + #[tokio::test] + async fn an_estimated_duration_is_advertised_after_its_first_segment() { let mut broadcast = moq_net::broadcast::Info::new().produce(); - let catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); - let net = broadcast - .create_track("video0", catalog.track_info(hang::catalog::PRIORITY.video)) - .unwrap(); - let mut video = catalog - .video( - net, - crate::catalog::hang::Container::Legacy(crate::container::Kind::Video), - VideoConfig::new(hang::catalog::VideoCodec::VP8), - ) - .unwrap(); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); + let mut consumer = catalog.consume().unwrap(); + + let mut video = video0(&mut broadcast, &catalog, &[0]); + let initial = consumer.next().await.unwrap().unwrap(); + assert_eq!(initial.video.renditions["video0"].hls, None, "nothing declared it yet"); + video .write(crate::container::Frame { - timestamp: catalog.timestamp(None).unwrap(), + timestamp: moq_net::Timestamp::from_millis(4_000).unwrap(), duration: None, payload: Bytes::from_static(b"keyframe"), keyframe: true, }) .unwrap(); + let declared = consumer.next().await.unwrap().unwrap(); + assert_eq!( + declared.video.renditions["video0"].hls, + Some(hang::catalog::Hls::new("video0.hls", std::time::Duration::from_secs(4))) + ); + } + + #[test] + fn a_track_finished_before_its_first_segment_is_advertised() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); + let mut video = video0(&mut broadcast, &catalog, &[0]); video.finish().unwrap(); - let archive = catalog.snapshot().archive.expect("the catalog timeline is advertised"); - assert_eq!(archive.timelines["video0"].duration, Some(4_000)); + let hls = catalog.snapshot().video.renditions["video0"].hls.clone(); + assert_eq!(hls.unwrap().duration, crate::hls::DEFAULT_DURATION_MIN); } #[test] - fn a_configured_hint_applies_to_media_but_not_the_catalog() { + fn a_track_hint_overrides_the_configured_one() { let mut broadcast = moq_net::broadcast::Info::new().produce(); let config = Config::default() - .with_timeline(crate::timeline::Config::default().with_duration_hint(std::time::Duration::from_secs(6))); - let mut catalog = Producer::new(&mut broadcast, config).unwrap(); + .with_hls(crate::hls::Config::default().with_duration_hint(std::time::Duration::from_secs(6))); + let catalog = Producer::new(&mut broadcast, config).unwrap(); let _video = catalog.enroll("video0", None).unwrap(); let _audio = catalog .enroll("audio0", Some(std::time::Duration::from_secs(5))) .unwrap(); - let archive = catalog.snapshot().archive.expect("the timelines are advertised"); - assert_eq!(archive.timelines["video0"].duration, Some(6_000)); - assert_eq!(archive.timelines["audio0"].duration, Some(5_000)); - assert_eq!(archive.timelines[hang::Catalog::DEFAULT_NAME].duration, Some(0)); + let tracks = catalog.outputs.hls.as_ref().unwrap(); + assert_eq!(tracks.hls("video0").unwrap().duration, std::time::Duration::from_secs(6)); + assert_eq!(tracks.hls("audio0").unwrap().duration, std::time::Duration::from_secs(5)); } #[test] - fn clock_is_advertised_without_an_archive() { + fn clock_is_advertised_without_hls() { let mut broadcast = moq_net::broadcast::Info::new().produce(); let catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); - // A live-only broadcast exposes its clock without creating a segment index. let snapshot = catalog.snapshot(); - assert_eq!(snapshot.archive, None); assert_eq!(snapshot.clock, catalog.clock().wall()); } diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 90299bd11a1..118700506b5 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -380,7 +380,7 @@ impl RenditionConfig for hang::catalog::TextConfig { /// timestamps by. Clones share that timebase. pub struct Reserved { timebase: super::Timebase, - timeline_hint: Option, + hls_hint: Option, } impl Reserved { @@ -388,13 +388,14 @@ impl Reserved { timebase.catalog.add_reserver(); Self { timebase, - timeline_hint: None, + hls_hint: None, } } - /// Declare the track's regular group duration to its archive timeline. - pub fn with_timeline_hint(mut self, duration: impl Into>) -> Self { - self.timeline_hint = duration.into(); + /// Declare the track's regular group duration, which fixes its HLS segment duration up front + /// when [`Config::with_hls`](super::Config::with_hls) enabled HLS records. + pub fn with_hls_hint(mut self, duration: impl Into>) -> Self { + self.hls_hint = duration.into(); self } @@ -471,7 +472,7 @@ impl Reserved { let rendition = self.init(track.name())?; self.timebase .catalog - .media(track, container, rendition, config.into(), self.timeline_hint) + .media(track, container, rendition, config.into(), self.hls_hint) } /// Resolve a timestamp on the broadcast's shared clock (see [`Producer::timestamp`]). @@ -498,7 +499,7 @@ impl Reserved { impl Clone for Reserved { fn clone(&self) -> Self { - Self::new(self.timebase.clone()).with_timeline_hint(self.timeline_hint) + Self::new(self.timebase.clone()).with_hls_hint(self.hls_hint) } } @@ -1181,22 +1182,39 @@ mod tests { ); } - /// Every rendition gets its own timeline, advertised together at the catalog root. + /// Each rendition gets its own HLS records, advertised in its own entry, and only when enabled. #[test] - fn renditions_get_their_own_timelines() { - let mut broadcast = moq_net::broadcast::Info::new().produce(); - let mut catalog = super::super::Producer::new(&mut broadcast, super::super::Config::default()).unwrap(); + fn renditions_get_their_own_hls_records() { + for enabled in [false, true] { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog_config = super::super::Config::default().with_hls(enabled.then(crate::hls::Config::default)); + let mut catalog = super::super::Producer::new(&mut broadcast, catalog_config).unwrap(); + + let hint = Some(std::time::Duration::from_secs(4)); + let video0 = catalog.enroll("video0", hint).unwrap(); + let video1 = catalog.enroll("video1", hint).unwrap(); + assert_eq!(video0.is_some(), enabled); + assert_eq!(video1.is_some(), enabled); + { + let mut guard = catalog.modify().unwrap(); + guard.video.insert("video0", config(None, None)).unwrap(); + guard.video.insert("video1", config(None, None)).unwrap(); + } - let _video0 = catalog - .enroll("video0", Some(std::time::Duration::from_secs(4))) - .unwrap(); - let _video1 = catalog - .enroll("video1", Some(std::time::Duration::from_secs(4))) - .unwrap(); - let section = catalog.timeline().section(); - assert_eq!(section.timelines["video0"].track, "video0.timeline.z"); - assert_eq!(section.timelines["video1"].track, "video1.timeline.z"); - assert_eq!(catalog.snapshot().archive, Some(section)); + let snapshot = catalog.snapshot(); + let hls = |name: &str| snapshot.video.renditions[name].hls.clone(); + match enabled { + true => { + let four = std::time::Duration::from_secs(4); + assert_eq!(hls("video0"), Some(hang::catalog::Hls::new("video0.hls", four))); + assert_eq!(hls("video1"), Some(hang::catalog::Hls::new("video1.hls", four))); + } + false => { + assert_eq!(hls("video0"), None); + assert_eq!(hls("video1"), None); + } + } + } } mod custom { diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index 1ab72a52c0c..137f269c1ac 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -89,8 +89,8 @@ fn synth_flv() -> Vec { /// A rendition must never be advertised when its media producer could not be built. /// -/// Publishing the media producer is fallible (it enrolls the track in the broadcast timeline, minting the -/// shared `timeline.z` track, which can collide), so publishing the catalog entry first would +/// Publishing the media producer is fallible (with HLS records enabled it mints the track's record +/// track, which can collide), so publishing the catalog entry first would /// leave consumers a rendition that is announced but has no producer behind it and is therefore /// never served. #[tokio::test(start_paused = true)] @@ -99,27 +99,25 @@ async fn rendition_is_not_published_when_the_media_track_fails() { // Control: the same fixture publishes a video rendition when nothing collides, so the // assertion below cannot pass merely because the fixture stopped reaching track import. - { + let name = { let mut producer = moq_net::broadcast::Info::new().produce(); let catalog = crate::catalog::Producer::new(&mut producer, crate::catalog::Config::default()).unwrap(); let mut importer = Import::new(producer, catalog.reserve()); importer.decode(&bytes::BytesMut::from(data.as_slice())).unwrap(); - assert_eq!( - catalog.snapshot().video.renditions.len(), - 1, - "fixture must publish a rendition" - ); - } + let renditions = catalog.snapshot().video.renditions; + assert_eq!(renditions.len(), 1, "fixture must publish a rendition"); + renditions.into_keys().next().unwrap() + }; let mut broadcast = moq_net::broadcast::Info::new().produce(); - let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); - - // Squat the catalog's timeline track, so enrolling the first rendition (which enrolls the catalog - // too, and with it building its media producer) fails. The handle must stay alive: the broadcast tracks names weakly, so - // dropping it frees the name. - let _squat = broadcast - .create_track(hang::timeline::default_name(hang::Catalog::DEFAULT_NAME), None) - .unwrap(); + let config = crate::catalog::Config::default().with_hls(crate::hls::Config::default()); + let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + + // Squat the rendition's HLS record track, so enrolling it (and with it building its media + // producer) fails. The handle must stay alive: the broadcast tracks names weakly, so + // dropping it frees the name. Unique names are per broadcast, so the track is named as in + // the control. + let _squat = broadcast.create_track(hang::hls::default_name(&name), None).unwrap(); let mut importer = Import::new(broadcast, catalog.reserve()); // A track it cannot build surfaces in the catalog rather than in this result. diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index a15af730b47..8a0c3487194 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -83,7 +83,7 @@ pub struct Import { segment: u64, // The current segment's boundary was declared by the source (rather than inferred from a - // keyframe), so each track cuts its timeline where it opens its group for the segment. + // keyframe), so each track cuts its HLS segments where it opens its group for the segment. declared: bool, dropped_metadata: u64, } @@ -154,9 +154,10 @@ struct Passthrough { /// The latest group's start, which the next group may not start below. start: Option, - // Reports this track's fragments into its timeline. Passthrough writes groups by hand (no - // `container::Producer`), so the recorder is fed directly rather than through `with_recorder`. - recorder: Option, + // Reports this track's groups into its HLS records, when enabled. Passthrough writes groups by + // hand (no `container::Producer`), so the recorder is fed directly rather than through + // `with_recorder`. + recorder: Option, // Sequence to use for the next group, set by `Import::seek`. pending_sequence: Option, @@ -187,9 +188,6 @@ impl Passthrough { self.publish_estimate()?; if let Some(mut group) = self.group.take() { group.finish()?; - if let Some(recorder) = self.recorder.as_mut() { - recorder.finish_group(group.sequence); - } } Ok(()) } @@ -268,7 +266,7 @@ impl Import { /// segmentation out of band (e.g. an HLS import following its playlist). /// /// A `styp` atom (a CMAF segment on disk) does this on its own, so an importer reading a - /// segmented file needs no help. Each track's timeline then closes a record where that track + /// segmented file needs no help. Each track's HLS records then start a segment where that track /// opens its group for the new segment, following the source's segmentation instead of the /// minimum-duration pacing. /// @@ -461,9 +459,8 @@ impl Import { self.catalog.track_info(kind.priority()).with_timescale(timescale), )?; - // Enroll every track in the broadcast's timelines: passthrough writes groups by hand - // (no `container::Producer`), so the recorder is fed directly at each fragment. The - // reservation holds the archive entry back until the whole track set is declared. + // Enroll every track for HLS records: passthrough writes groups by hand (no + // `container::Producer`), so the recorder is fed directly at each fragment. let recorder = self.catalog.enroll(track.name(), None)?; // Whatever the descriptor declared (a bitrate) is authoritative; the rest is filled by @@ -489,7 +486,7 @@ impl Import { track, group: None, start: None, - recorder: Some(recorder), + recorder, pending_sequence: None, estimator: self.catalog.estimator(), claim: crate::catalog::Claim::new(self.catalog.bandwidth()), @@ -930,7 +927,7 @@ impl Import { // a fragment late and the two tracks stop agreeing on where segments fall. if self.starts_segment(&moof, has_video) { self.segment += 1; - // A boundary the source declared is also a timeline boundary; one merely inferred + // A boundary the source declared is also an HLS segment boundary; one merely inferred // from a keyframe is not, since the default pacing already sees that group open. self.declared = self.pending_cut; self.pending_cut = false; @@ -984,7 +981,7 @@ impl Import { let placement = Placement { offset, new_segment, - // A group opening for a declared segment is a boundary on this track's timeline. + // A group opening for a declared segment is an HLS segment boundary on this track. declared: new_segment && self.declared, }; @@ -1014,7 +1011,7 @@ struct Placement { offset: crate::catalog::Offset, /// The fragment is this track's first in the current segment. new_segment: bool, - /// That segment was declared by the source, so it is a timeline boundary too. + /// That segment was declared by the source, so it is an HLS segment boundary too. declared: bool, } @@ -1302,9 +1299,6 @@ impl Passthrough { let mut group = if start_group { if let Some(mut prev) = self.group.take() { prev.finish()?; - if let Some(recorder) = self.recorder.as_mut() { - recorder.finish_group(prev.sequence); - } } match self.pending_sequence.take() { Some(sequence) => self.track.create_group(moq_net::group::Info { sequence })?, @@ -1314,10 +1308,10 @@ impl Passthrough { self.group.take().ok_or(Error::NoKeyframe)? }; - let position = hang::timeline::Position::new(group.sequence, group.frame_count() as u64); + let sequence = group.sequence; if start_group { - // A group reopened mid-segment (after a `seek`) is no timeline boundary. + // A group reopened mid-segment (after a `seek`) is no segment boundary. if placement.declared && let Some(recorder) = self.recorder.as_mut() { @@ -1334,15 +1328,14 @@ impl Passthrough { self.start = Some(timestamp); } - // Only once published, so a rejected fragment is never indexed. Audio is always - // independently decodable; video says whether this fragment really begins on an IDR, - // which is what an HLS export reads to bootstrap an init segment. + // Audio is always independently decodable; video counts the IDRs it carries. let keyframes = match kind { Kind::Video => run.samples.iter().filter(|sample| sample.sync).count() as u64, Kind::Audio => 0, }; + // Only once published, so a rejected fragment is never listed. if let Some(recorder) = self.recorder.as_mut() { - recorder.frame(position, timestamp, kind == Kind::Audio || keyframes > 0); + recorder.frame(sequence, timestamp); } self.group = Some(group); @@ -1427,7 +1420,7 @@ impl Import { passthrough.track.finish()?; // The importer may be kept around, but its media stops holding the other tracks back. passthrough.estimator.discontinuity(); - // Dropping the recorder flushes the open record, so the track is advertised. + // Dropping the recorder stops the open HLS segment, so the track is advertised. passthrough.recorder = None; }); } diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index f056acd3774..fd3450fa343 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -1259,44 +1259,41 @@ async fn test_msf_catalog_roundtrip() { assert!(matches!(audio.container, Container::Cmaf { .. })); } -/// Subscribe to `track`'s timeline as `section` advertises it. -async fn subscribe( - consumer: &moq_net::broadcast::Consumer, - section: &hang::catalog::Archive, - track: &str, -) -> crate::timeline::Consumer { - let mut section = section.clone(); - section - .timelines - .entry(track.to_string()) - .or_insert_with(|| hang::catalog::Timeline::new(hang::timeline::default_name(track), 0)); - crate::timeline::Consumer::<()>::subscribe(consumer, §ion, track) - .await - .unwrap() +/// A catalog publishing HLS records. +fn hls() -> crate::catalog::Config { + crate::catalog::Config::default().with_hls(crate::hls::Config::default()) } -/// Every record a finished timeline published, as `(start group, end group)`. -async fn groups(timeline: &mut crate::timeline::Consumer) -> Vec<(u64, u64)> { - let mut out = Vec::new(); - while let Some(event) = timeline.next().await.unwrap() { - if let crate::timeline::Event::Push { entry, .. } = event { - assert_eq!(entry.start.frame, 0, "fragments never split a group here"); - assert_eq!(entry.end.frame, 0, "fragments never split a group here"); - out.push((entry.start.group, entry.end.group)); +/// Subscribe to `track`'s HLS records, before the catalog may advertise them. +async fn subscribe(consumer: &moq_net::broadcast::Consumer, track: &str) -> crate::hls::Consumer { + let hls = hang::catalog::Hls::new(hang::hls::default_name(track), std::time::Duration::ZERO); + crate::hls::Consumer::subscribe(consumer, &hls).await.unwrap() +} + +/// Every segment a finished track's records listed, as `(first group, next segment's group)`. +async fn groups(records: &mut crate::hls::Consumer) -> Vec<(u64, u64)> { + let mut entries = Vec::new(); + while let Some(event) = records.next().await.unwrap() { + if let crate::hls::Event::Push { entry, .. } = event { + entries.push(entry); } } - out + assert!(entries.last().is_some_and(|entry| entry.is_stop()), "the track's end stops the last segment"); + entries + .windows(2) + .filter(|pair| !pair[0].is_stop()) + .map(|pair| (pair[0].group, pair[1].group)) + .collect() } -/// Passthrough writes groups by hand (no `container::Producer`), so the timeline recorders are fed -/// directly. This guards that the import advertises both renditions' timelines at the catalog root -/// and actually records their groups, the index the VOD/playlist export reads. Regression for the -/// missing-timeline break that skipped VOD renditions. +/// Passthrough writes groups by hand (no `container::Producer`), so the HLS recorders are fed +/// directly. This guards that the import advertises both renditions' records in their entries and +/// actually records their groups, the index the playlist export reads. #[tokio::test] -async fn import_populates_the_broadcast_timeline() { +async fn import_populates_the_hls_records() { let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); - let mut catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); + let mut catalog = crate::catalog::Producer::new(&mut broadcast, hls()).unwrap(); let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.reserve()); let data = include_bytes!("test_data/bbb.mp4"); @@ -1311,16 +1308,16 @@ async fn import_populates_the_broadcast_timeline() { let video_name = snapshot.video.renditions.keys().next().unwrap().clone(); let audio_name = snapshot.audio.renditions.keys().next().unwrap().clone(); - // This short fixture ends before the four-second estimation record closes, so finishing the - // import flushes it and declares the minimum while the catalog stays live. - let section = snapshot.archive.clone().expect("the import advertises its timelines"); - assert_eq!(section.timelines[&video_name].duration, Some(4_000)); - assert_eq!(section.timelines[&audio_name].duration, Some(4_000)); + // This short fixture ends before the first four-second segment closes, so finishing the import + // stops it and declares the minimum while the catalog stays live. + let four = std::time::Duration::from_secs(4); + assert_eq!(snapshot.video.renditions[&video_name].hls.as_ref().unwrap().duration, four); + assert_eq!(snapshot.audio.renditions[&audio_name].hls.as_ref().unwrap().duration, four); - // Subscribe while the producer is alive, then finish so the timeline groups close and the + // Subscribe while the producer is alive, then finish so the record tracks close and the // readers terminate rather than blocking. - let mut video = subscribe(&consumer, §ion, &video_name).await; - let mut audio = subscribe(&consumer, §ion, &audio_name).await; + let mut video = subscribe(&consumer, &video_name).await; + let mut audio = subscribe(&consumer, &audio_name).await; catalog.finish().unwrap(); assert_eq!(groups(&mut video).await[0].0, 0, "the first video group is sequence 0"); @@ -1636,8 +1633,8 @@ async fn segmented_source_groups_per_segment_in(passthrough: bool) { ); } -/// Each declared segment is one group per track, and each track's timeline indexes it as one -/// record. With audio grouping per fragment those records spanned many short groups, one FETCH +/// Each declared segment is one group per track, and each track's HLS records list it as one +/// segment. With audio grouping per fragment those records spanned many short groups, one FETCH /// round-trip each. #[tokio::test] async fn segmented_source_indexes_one_group_per_record() { @@ -1651,18 +1648,17 @@ async fn segmented_source_indexes_one_group_per_record_in(passthrough: bool) { let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); - let mut catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); + let mut catalog = crate::catalog::Producer::new(&mut broadcast, hls()).unwrap(); let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.reserve()).with_passthrough(passthrough); fmp4.decode(&init).unwrap(); let snapshot = catalog.snapshot(); let video_name = snapshot.video.renditions.keys().next().unwrap().clone(); let audio_name = snapshot.audio.renditions.keys().next().unwrap().clone(); - let section = snapshot.archive.clone().expect("the import advertises its timelines"); // Subscribe while the producer is alive so the readers terminate on finish rather than blocking. - let mut video = subscribe(&consumer, §ion, &video_name).await; - let mut audio = subscribe(&consumer, §ion, &audio_name).await; + let mut video = subscribe(&consumer, &video_name).await; + let mut audio = subscribe(&consumer, &audio_name).await; for segment in 0..3u64 { let base = segment * 1_000_000; @@ -1697,8 +1693,8 @@ async fn segmented_source_indexes_one_group_per_record_in(passthrough: bool) { } /// Audio and video rarely start a segment at exactly the same timestamp: the boundary is nominal -/// and each track begins at its own nearest sample. Each track cuts its own timeline where it opens -/// its group for a declared segment, so a skew between them moves no group into another record. +/// and each track begins at its own nearest sample. Each track starts its own HLS segment where it +/// opens its group for a declared segment, so a skew between them moves no group into another one. /// /// `video_offset_us` shifts video relative to audio, so the same source is exercised with audio /// leading and lagging. Returns each track's records as `(start group, end group)`. @@ -1707,16 +1703,15 @@ async fn records_with_skew(video_offset_us: i64, passthrough: bool) -> (Vec<(u64 let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); - let mut catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); + let mut catalog = crate::catalog::Producer::new(&mut broadcast, hls()).unwrap(); let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.reserve()).with_passthrough(passthrough); fmp4.decode(&init).unwrap(); let snapshot = catalog.snapshot(); let video_name = snapshot.video.renditions.keys().next().unwrap().clone(); let audio_name = snapshot.audio.renditions.keys().next().unwrap().clone(); - let section = snapshot.archive.clone().unwrap(); - let mut video = subscribe(&consumer, §ion, &video_name).await; - let mut audio = subscribe(&consumer, §ion, &audio_name).await; + let mut video = subscribe(&consumer, &video_name).await; + let mut audio = subscribe(&consumer, &audio_name).await; for segment in 0..3u64 { let base = segment * 1_000_000; @@ -2146,7 +2141,7 @@ struct Imported { /// The root clock the catalog advertised. clock: hang::catalog::Clock, arrival: std::ops::RangeInclusive, - /// The first record the video timeline published, the index an archive replays from. + /// Where the video's first HLS segment starts. record: moq_net::Timestamp, /// Why a chunk was refused, if one was. refused: Option, @@ -2158,14 +2153,13 @@ async fn import_session(chunks: &[Vec]) -> Imported { let (init, _, _) = bbb_init(); let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); - let mut catalog = crate::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); + let mut catalog = crate::catalog::Producer::new(&mut broadcast, hls()).unwrap(); let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.reserve()); fmp4.decode(&init).unwrap(); let snapshot = catalog.snapshot(); let video = snapshot.video.renditions.keys().next().unwrap().clone(); - let section = snapshot.archive.clone().expect("the import advertises its timelines"); - let mut timeline = subscribe(&consumer, §ion, &video).await; + let mut records = subscribe(&consumer, &video).await; let before = moq_clock::wall(); let mut after = before; @@ -2182,9 +2176,9 @@ async fn import_session(chunks: &[Vec]) -> Imported { fmp4.finish().unwrap(); catalog.finish().unwrap(); - let event = timeline.next().await.unwrap().expect("a recorded span"); - let crate::timeline::Event::Push { entry, .. } = event else { - panic!("the first timeline event was not a record"); + let event = records.next().await.unwrap().expect("a record"); + let crate::hls::Event::Push { entry, .. } = event else { + panic!("the first event was not a record"); }; let snapshot = catalog.snapshot(); @@ -2200,7 +2194,7 @@ async fn import_session(chunks: &[Vec]) -> Imported { /// A feed an hour into its own decode timeline publishes that hour verbatim (the `tfdt` a /// decoder reads is untouched), and the catalog clock maps its first fragment to the arrival -/// time. The archive index records the same times, so a replay names each segment's wall time. +/// time. The HLS records list the same times, so a playlist names each segment's wall time. #[tokio::test] async fn import_publishes_decode_times_on_an_arrival_clock() { let start = 3_600_000_000; @@ -2212,7 +2206,7 @@ async fn import_publishes_decode_times_on_an_arrival_clock() { assert_eq!( import.record.as_micros(), first, - "the archive indexes the source's time" + "the HLS records list the source's time" ); // The advertised clock is micros, so allow its rounding. diff --git a/rs/moq-mux/src/container/mkv/import_test.rs b/rs/moq-mux/src/container/mkv/import_test.rs index ab26dfabdbc..55d5911ab16 100644 --- a/rs/moq-mux/src/container/mkv/import_test.rs +++ b/rs/moq-mux/src/container/mkv/import_test.rs @@ -951,8 +951,8 @@ fn test_unsupported_codec_skipped() { /// A rendition must never be advertised when its media producer could not be built. /// -/// Publishing the media producer is fallible (it enrolls the track in the broadcast timeline, minting the -/// shared `timeline.z` track, which can collide), so publishing the catalog entry first would +/// Publishing the media producer is fallible (with HLS records enabled it mints the track's record +/// track, which can collide), so publishing the catalog entry first would /// leave consumers a rendition that is announced but has no producer behind it and is therefore /// never served. #[test] @@ -967,17 +967,19 @@ fn rendition_is_not_published_when_the_media_track_fails() { // Control: the same fixture publishes exactly one rendition when nothing collides, so the // assertion below cannot pass merely because the fixture stopped reaching track import. - assert_eq!(run(&data).video.renditions.len(), 1, "fixture must publish a rendition"); + let control = run(&data); + assert_eq!(control.video.renditions.len(), 1, "fixture must publish a rendition"); + let name = control.video.renditions.keys().next().unwrap().clone(); let mut broadcast = moq_net::broadcast::Info::new().produce(); - let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); - - // Squat the catalog's timeline track, so building the media producer (whose enrollment - // enrolls the catalog too) fails. The handle must stay alive: the broadcast tracks names weakly, so - // dropping it frees the name. - let _squat = broadcast - .create_track(hang::timeline::default_name(hang::Catalog::DEFAULT_NAME), None) - .unwrap(); + let config = crate::catalog::Config::default().with_hls(crate::hls::Config::default()); + let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + + // Squat the rendition's HLS record track, so enrolling it (and with it building its media + // producer) fails. The handle must stay alive: the broadcast tracks names weakly, so + // dropping it frees the name. Unique names are per broadcast, so the track is named as in + // the control. + let _squat = broadcast.create_track(hang::hls::default_name(&name), None).unwrap(); let mut mkv = crate::container::mkv::Import::new(broadcast, catalog.reserve()); let buf = bytes::BytesMut::from(&data[..]); diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index c8e9648db1b..06b91230164 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -58,9 +58,8 @@ pub struct Producer { /// Set by [`Self::seek`] and consumed on the next group creation. pending_sequence: Option, - /// Reports each written frame and finished group into this rendition's timeline, when the - /// producer was built with one. - recorder: Option, + /// Reports each written group into this rendition's HLS records, when the catalog enabled them. + recorder: Option, /// The furthest presentation point written, i.e. `max(timestamp + duration)`. Reported to /// `recorder` on each [`cut`](Self::cut), since the last group of a track has no successor @@ -325,8 +324,8 @@ where self.publish_estimate() } - /// Declare a segment boundary the source drew at `pts`: the timeline closes a record at the - /// first group starting at or after it, instead of pacing records by duration. + /// Declare a segment boundary the source drew at `pts`: the HLS records start a segment at the + /// first group starting at or after it, instead of pacing segments by duration. pub(crate) fn boundary(&mut self, pts: moq_net::Timestamp) { if let Some(recorder) = self.recorder.as_mut() { recorder.cut(pts); @@ -362,12 +361,9 @@ where self } - /// Report each written frame and finished group through `recorder`, enrolling this track in - /// its timeline so consumers can index the media without downloading it. - /// - /// Mint the recorder from the broadcast's [`Timelines`](crate::timeline::Timelines). - pub fn with_recorder(mut self, recorder: crate::timeline::Recorder) -> Self { - self.recorder = Some(recorder); + /// Report each written group through `recorder` into the track's HLS records. + pub(crate) fn with_recorder(mut self, recorder: Option) -> Self { + self.recorder = recorder; self } @@ -481,12 +477,12 @@ where if self.buffer_duration.is_zero() { let group = self.group.as_mut().unwrap(); let (timestamp, duration, bytes) = (frame.timestamp, frame.duration, frame.payload.len()); - let (position, keyframe) = (position(group), frame.keyframe); + let (sequence, keyframe) = (group.sequence, frame.keyframe); self.container.write(group, &[frame])?; - // The timeline absorbs publish failures itself (it is an optional sidecar), so + // The HLS records absorb publish failures themselves (they are an optional sidecar), so // reporting can't abort the media write. if let Some(recorder) = self.recorder.as_mut() { - recorder.frame(position, timestamp, keyframe); + recorder.frame(sequence, timestamp); } // Only what the container accepted is measured. A rejected frame (too large for the @@ -554,8 +550,8 @@ where self.flush_buffer(tail_end)?; // Where this group's content stops: the duration marker when we write one, else the - // caller's bound, else the furthest point we wrote. The timeline hears it after the flush, - // so a group still wholly buffered has reported its first frame and opened a record. + // caller's bound, else the furthest point we wrote. The HLS records hear it after the flush, + // so a group still wholly buffered has reported its first frame and opened a segment. let reached = marker_at.or(end).max(self.end); if let Some(recorder) = self.recorder.as_mut() && let Some(end) = reached @@ -567,9 +563,6 @@ where } if let Some(mut group) = self.group.take() { group.finish()?; - if let Some(recorder) = self.recorder.as_mut() { - recorder.finish_group(group.sequence); - } // Only a closed group reaches anything; a cut with none open names a point nothing reached. if let Some(end) = reached { self.raise_live_edge(end); @@ -670,10 +663,6 @@ where None => self.inner.append_group()?, }; let timestamp = self.live_edge.unwrap_or(moq_net::Timestamp::ZERO); - if let Some(recorder) = self.recorder.as_mut() { - recorder.frame(hang::timeline::Position::group(group.sequence), timestamp, false); - recorder.end(timestamp); - } match self.container.kind() { Kind::Data => group.write_frame(timestamp, Bytes::new())?, Kind::Audio | Kind::Video => self.container.write( @@ -687,10 +676,9 @@ where )?, } group.finish()?; + // The marker is contiguous, so without this the open HLS segment would absorb the pause. if let Some(recorder) = self.recorder.as_mut() { - recorder.finish_group(group.sequence); - // The marker is contiguous, so without this the next record boundary would absorb the pause. - recorder.flush(); + recorder.stop(group.sequence); } Ok(()) } @@ -733,10 +721,9 @@ where None => return Ok(()), }; - let position = position(group); self.container.write(group, &self.buffer)?; if let Some(recorder) = self.recorder.as_mut() { - recorder.frame(position, self.buffer[0].timestamp, self.buffer[0].keyframe); + recorder.frame(group.sequence, self.buffer[0].timestamp); } // Counted only once written: an abort discards the buffer, and those frames were never sent. for frame in &self.buffer { @@ -755,7 +742,7 @@ where self.publish_estimate()?; // A finished track may be kept around, but its media stops holding the other tracks back. self.estimator.discontinuity(); - // Dropping the recorder flushes the open record, so a track that ends before its catalog + // Dropping the recorder stops the open HLS segment, so a track that ends before its catalog // still declares a duration and is advertised. self.recorder = None; Ok(()) @@ -789,11 +776,6 @@ impl std::ops::Deref for Producer { } } -/// Where the next frame written to `group` lands. -fn position(group: &moq_net::group::Producer) -> hang::timeline::Position { - hang::timeline::Position::new(group.sequence, group.frame_count() as u64) -} - #[cfg(test)] mod tests { use super::*; @@ -1522,43 +1504,49 @@ mod tests { crate::container::fmp4::encode_fragment(info, group).unwrap(); } - /// A group shorter than the buffer reaches the timeline only when it closes, and its record + /// Each HLS record of `tracks`' `video` as `(group, pts ms, stop)`. + async fn hls_records(broadcast: &moq_net::broadcast::Producer, tracks: &crate::hls::Tracks) -> Vec<(u64, u64, bool)> { + tracks.finish(); + let hls = tracks.hls("video").expect("declared"); + let mut consumer = crate::hls::Consumer::subscribe(&broadcast.consume(), &hls).await.unwrap(); + let mut records = Vec::new(); + while let Some(event) = consumer.next().await.unwrap() { + if let crate::hls::Event::Push { entry, .. } = event { + records.push((entry.group, entry.pts.as_micros() as u64 / 1_000, entry.is_stop())); + } + } + records + } + + /// A group shorter than the buffer reaches the HLS records only when it closes, and its segment /// still runs to the group's end rather than stopping at its first frame. #[tokio::test] async fn a_wholly_buffered_group_records_its_end() { let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = crate::timeline::Timelines::new(&broadcast); - let recorder = timelines.track("video", crate::timeline::Config::default()).unwrap(); + let tracks = crate::hls::Tracks::new(&broadcast); + let recorder = tracks.track("video", crate::hls::Config::default()).unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); let mut producer = Producer::new(track, Container::Legacy(crate::container::Kind::Video)) .with_buffer(std::time::Duration::from_secs(10)) - .with_recorder(recorder); + .with_recorder(Some(recorder)); producer.write(frame(0, true)).unwrap(); producer.write(frame(33_000, false)).unwrap(); producer.cut(Some(Timestamp::from_micros(66_000).unwrap())).unwrap(); drop(producer); - timelines.finish(); - let mut timeline = - crate::timeline::Consumer::<()>::subscribe(&broadcast.consume(), &timelines.section(), "video") - .await - .unwrap(); - let Some(crate::timeline::Event::Push { entry, .. }) = timeline.next().await.unwrap() else { - panic!("expected a record"); - }; - assert_eq!(entry.duration, std::time::Duration::from_millis(66)); + assert_eq!(hls_records(&broadcast, &tracks).await, [(0, 0, false), (1, 66, true)]); } - /// The record before a discontinuity ends at the marker, not where content resumes. + /// The segment before a discontinuity stops at the marker, not where content resumes. #[tokio::test] - async fn a_discontinuity_ends_the_record_at_the_marker() { + async fn a_discontinuity_stops_the_segment_at_the_marker() { let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = crate::timeline::Timelines::new(&broadcast); - let recorder = timelines.track("video", crate::timeline::Config::default()).unwrap(); + let tracks = crate::hls::Tracks::new(&broadcast); + let recorder = tracks.track("video", crate::hls::Config::default()).unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); let mut producer = - Producer::new(track, Container::Legacy(crate::container::Kind::Video)).with_recorder(recorder); + Producer::new(track, Container::Legacy(crate::container::Kind::Video)).with_recorder(Some(recorder)); producer.write(frame(0, true)).unwrap(); producer.write(frame(33_000, false)).unwrap(); @@ -1567,19 +1555,11 @@ mod tests { // Resumed 40 minutes later. producer.write(frame(2_400_000_000, true)).unwrap(); drop(producer); - timelines.finish(); - let mut timeline = - crate::timeline::Consumer::<()>::subscribe(&broadcast.consume(), &timelines.section(), "video") - .await - .unwrap(); - let mut spans = Vec::new(); - while let Some(event) = timeline.next().await.unwrap() { - if let crate::timeline::Event::Push { entry, .. } = event { - spans.push((entry.pts.as_micros() / 1_000, entry.duration.as_millis())); - } - } - assert_eq!(spans, vec![(0, 66), (2_400_000, 0)]); + assert_eq!( + hls_records(&broadcast, &tracks).await, + [(0, 0, false), (1, 66, true), (2, 2_400_000, false), (3, 2_400_000, true)] + ); } #[tokio::test] diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 4932fbf6dc8..71f1904bf7a 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -6284,6 +6284,8 @@ struct SiCadenceRig { si_track: moq_net::track::Producer, exporter: Export>, catalog: crate::catalog::Producer, + /// Held so the SI track stays routable. + _broadcast: moq_net::broadcast::Producer, } async fn si_cadence_rig(pid: u16, table_id: u8, interval: Duration) -> SiCadenceRig { @@ -6340,6 +6342,7 @@ async fn si_cadence_rig(pid: u16, table_id: u8, interval: Duration) -> SiCadence si_track, exporter, catalog, + _broadcast: broadcast, } } diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 37b147d9b1a..081dfa3bcaf 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -180,47 +180,30 @@ pub enum Error { composition_time_ms: i32, }, - /// [`timeline::Producer::push`](crate::timeline::Producer::push) received a record out of order. - #[error("timeline record {actual} pushed where {expected} was next")] - TimelineSequence { + /// An HLS record did not land at its window's next index: pushed out of order, or read where + /// its `sequence` differs from its window index. + #[error("HLS record {actual} where {expected} was next")] + HlsSequence { /// The window's next index. expected: u64, - /// The pushed record's sequence. + /// The record's sequence. actual: u64, }, - /// A timeline frame report did not advance past the previous one. - #[error("timeline report at {position:?} does not advance past {last:?}")] - TimelinePosition { - /// The rejected report's position. - position: hang::timeline::Position, - /// The previous report's position. - last: hang::timeline::Position, + /// An HLS frame report named a group before the previous report's. + #[error("HLS report for group {group} after group {last}")] + HlsGroup { + /// The rejected report's group. + group: u64, + /// The previous report's group. + last: u64, }, - /// The catalog's `archive` entry indexes no timeline for this track. - #[error("no timeline for track {0}")] - TimelineMissing(String), - - /// A timeline frame report is timed differently from the track's earlier ones: a track is all - /// timed or all untimed. - #[error("timeline report at {0:?} does not match the track's timing")] - TimelineTiming(hang::timeline::Position), - - /// A timeline record has no `pts` or `duration` to decode, as on an untimed track. - #[error("timeline record {0} is untimed")] - TimelineUntimed(u64), - /// Error from a muxer/demuxer that reports via `anyhow` (currently the MPEG-TS demuxer). /// Boxed in an `Arc` so the enum stays `Clone` (`anyhow::Error` is not). #[error("{0}")] Other(std::sync::Arc), - /// A timeline catalog section declared a timescale that isn't a valid - /// [`moq_net::Timescale`] (zero, or too large). - #[error("invalid timeline timescale: {0}")] - InvalidTimescale(u32), - /// A timestamp cannot be mapped between the broadcast clock and the wall: PTS zero would land /// before the moq epoch (2020) or after now, the host's wall clock was unset so the clock has no /// mapping, or a catalog root `clock` would change or clear the producer's fixed clock. diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs new file mode 100644 index 00000000000..6e53abd33f6 --- /dev/null +++ b/rs/moq-mux/src/hls.rs @@ -0,0 +1,1212 @@ +//! Opt-in HLS segment records: where each of a rendition's HLS segments starts. +//! +//! Nothing here runs unless the application enables it with +//! [`catalog::Config::with_hls`](crate::catalog::Config::with_hls). Then every video and audio +//! rendition the catalog mints gets a track of [`hang::hls::Record`]s, one per segment, and its +//! catalog entry gets an [`hls`](hang::catalog::Hls) field naming that track and the nominal +//! segment duration. An edge renders HLS (or DASH) playlists from the records without subscribing +//! to media. +//! +//! ## Segments +//! +//! A segment starts at a group boundary and holds whole groups, so a record is the segment's first +//! group and pts, published as the segment starts. A segment ends at the first group boundary at +//! least [`Config::duration_min`] (4s by default) past its start, so short groups (audio) pack into +//! one segment and long ones (video GOPs) get one each. A hint fixes the nominal duration to its +//! smallest multiple at or beyond that minimum; without one, the first segment's duration declares +//! it. +//! +//! Content stopping (a skipped group sequence, a break marker from +//! [`container::Producer::discontinuity`](crate::container::Producer::discontinuity), or the end of +//! the track) publishes a zero-duration record where it stopped, so the segment before it ends there +//! rather than absorbing the pause. +//! +//! ## Alignment +//! +//! HLS needs segment `n` to start at the same pts in every rendition. The first track enrolled +//! paces the segments; every later track follows it, starting a segment at its first group at or +//! after each of the pacer's segment starts, and stopping when the pacer stops. A follower records +//! nothing before the pacer's first segment, so renditions that start together and share group +//! boundaries (aligned GOPs, audio grouped on them) number the same segments alike. If the pacer's +//! producer goes away, the next enrolled track still recording takes over. +//! +//! ## Wiring +//! +//! [`catalog::Producer`](crate::catalog::Producer) owns the broadcast's record tracks and advertises +//! each in its rendition's `hls` field. On the read side, [`Consumer::subscribe`] reads one +//! rendition's records and yields decoded [`Event`]s. On the wire each record track is an +//! uncompressed [`moq_json::window`] whose groups restate the recent records. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::task::Poll; +use std::time::Duration; + +use hang::hls::Record; + +use moq_net::{Timescale, Timestamp}; + +/// The default [`Config::duration_min`] (4 seconds). +pub const DEFAULT_DURATION_MIN: Duration = Duration::from_secs(4); + +/// The safety ceiling before an unhinted pacer declares its duration, unless the minimum is longer. +const DEFAULT_DURATION_CEILING: Duration = Duration::from_secs(10); + +/// A pacer's segments may overrun their target to reach a group boundary, but not indefinitely. +const DURATION_CEILING_MULTIPLIER: u32 = 3; + +/// Recent records repeated when the window track rolls to a new group. +const CHECKPOINT_RECORDS: usize = 256; + +/// The wire timescale for record `pts`/`duration`: milliseconds. +const TIMESCALE: Timescale = Timescale::MILLI; + +/// How a broadcast's HLS segments are cut. +/// +/// A segment ends at the first group boundary at least [`duration_min`](Self::duration_min) past +/// its start. A hint fixes the nominal duration to its smallest multiple at or beyond that minimum. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct Config { + /// The shortest a segment may be before a group boundary ends it. + /// + /// A floor rather than a target on purpose: a floor is always satisfiable (wait for the next + /// group), so no group is split to honor it. + pub duration_min: Duration, + + /// The application's nominal group duration, when known. + pub duration_hint: Option, +} + +impl Config { + /// Set [`duration_min`](Self::duration_min). + pub fn with_duration_min(mut self, duration: Duration) -> Self { + self.duration_min = duration; + self + } + + /// Set [`duration_hint`](Self::duration_hint). + pub fn with_duration_hint(mut self, duration: impl Into>) -> Self { + self.duration_hint = duration.into(); + self + } +} + +impl Default for Config { + fn default() -> Self { + Self { + duration_min: DEFAULT_DURATION_MIN, + duration_hint: None, + } + } +} + +/// A duration in wire units, rounded up so a nominal duration never understates itself. +fn units(duration: Duration) -> u64 { + u64::try_from((duration.as_micros() * TIMESCALE.as_u64() as u128).div_ceil(1_000_000)).unwrap_or(u64::MAX) +} + +fn wire(pts: Timestamp) -> u64 { + pts.as_scale(TIMESCALE) as u64 +} + +fn elapsed(start: Timestamp, pts: Timestamp) -> Duration { + Duration::from_micros(u64::try_from(pts.as_micros().saturating_sub(start.as_micros())).unwrap_or(u64::MAX)) +} + +/// What a pacer did, for its followers to mirror. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Mark { + /// A segment started at this pts. + Start(Timestamp), + /// Content stopped. + Stop, +} + +/// Builds one track's records from reports of the frames it published. +/// +/// Reports arrive in group order: [`frame`](Self::frame) for each frame (or at least the first of +/// each group), [`end`](Self::end) wherever the content is known to extend, and +/// [`stop`](Self::stop) where it stops. Records queue until [`Iterator::next`] takes them. +pub(crate) struct Segmenter { + config: Config, + /// Fixed nominal duration; absent until an unhinted track's first segment closes. Kept unrounded + /// so pacing hits a fractional-millisecond GOP's boundary; only the wire declaration rounds. + declared: Option, + /// The number the next record gets. + sequence: u64, + /// The open segment's start: absent before the first segment and after a stop. + open: Option, + /// The newest reported group. + last: Option, + /// The newest reported content time in the open segment. + frontier: Option, + /// Boundaries not yet reached, in order. + cuts: VecDeque, + /// Whether boundaries come only from cuts: a follower, or a pacer whose source declares them. + manual: bool, + /// Whether this track follows a pacer, starting a segment only at one of its cuts. + following: bool, + ready: VecDeque, + /// What this segmenter did since last asked, for followers to mirror. + marks: Vec, + closed: bool, +} + +impl Segmenter { + /// A segmenter numbering its records from zero. + pub fn new(config: Config) -> Self { + let declared = config.duration_hint.filter(|hint| !hint.is_zero()).map(|hint| { + let multiple = config.duration_min.as_nanos().div_ceil(hint.as_nanos()).max(1); + hint.saturating_mul(u32::try_from(multiple).unwrap_or(u32::MAX)) + }); + Self { + config, + declared, + sequence: 0, + open: None, + last: None, + frontier: None, + cuts: VecDeque::new(), + manual: false, + following: false, + ready: VecDeque::new(), + marks: Vec::new(), + closed: false, + } + } + + /// Follow a pacer's segments instead of pacing them; see [`follow`](Self::follow). + pub fn following(mut self) -> Self { + self.manual = true; + self.following = true; + self + } + + /// Number the next record `sequence`, continuing a re-enrolled track. + pub fn with_sequence(mut self, sequence: u64) -> Self { + self.sequence = sequence; + self + } + + /// The number the next record gets. + pub fn sequence(&self) -> u64 { + self.sequence + } + + /// The fixed nominal segment duration, once known. + pub fn declared(&self) -> Option { + self.declared + } + + fn target(&self) -> Duration { + self.declared + .filter(|value| !value.is_zero()) + .unwrap_or(self.config.duration_min) + } + + fn ceiling(&self) -> Duration { + self.declared + .filter(|value| !value.is_zero()) + .and_then(|value| value.checked_mul(DURATION_CEILING_MULTIPLIER)) + .unwrap_or(DEFAULT_DURATION_CEILING.max(self.config.duration_min)) + } + + /// Report a frame of group `group`, presented at `pts`. + /// + /// Fails with [`HlsGroup`](crate::Error::HlsGroup) when `group` precedes the previous report's: + /// segments hold whole groups in order, so that frame could never be listed. + pub fn frame(&mut self, group: u64, pts: Timestamp) -> crate::Result<()> { + if self.closed { + return Ok(()); + } + if let Some(last) = self.last { + if group < last { + return Err(crate::Error::HlsGroup { group, last }); + } + if group != last { + self.group(group, last, pts); + } + } else { + self.open_at(group, pts); + } + self.last = Some(group); + if self.open.is_some() { + self.advance(pts); + } + Ok(()) + } + + /// Group `group` starts at `pts`, after group `last`. + fn group(&mut self, group: u64, last: u64, pts: Timestamp) { + let Some(start) = self.open else { + self.open_at(group, pts); + return; + }; + if group != last + 1 { + // A skipped sequence stops the segment where its content reached, so the skipped time + // is not absorbed into it and the reader never has to guess what lies between. + self.stop_at(last + 1); + self.open_at(group, pts); + return; + } + let elapsed = elapsed(start, pts); + let ceiling = !self.following && elapsed >= self.ceiling(); + if ceiling || self.boundary(start, pts, elapsed) { + self.declared.get_or_insert(elapsed); + self.begin(group, pts); + } + } + + /// With no segment open, start one with group `group` at `pts`, unless a follower has no cut + /// to start it at. + fn open_at(&mut self, group: u64, pts: Timestamp) { + if self.following { + // A follower starts only where the pacer did, so both number the segment alike. + let mut started = false; + while self.cuts.front().is_some_and(|cut| cut.as_micros() <= pts.as_micros()) { + self.cuts.pop_front(); + started = true; + } + if !started { + return; + } + } + self.begin(group, pts); + } + + /// Start a segment with group `group` at `pts`. + fn begin(&mut self, group: u64, pts: Timestamp) { + self.open = Some(pts); + self.frontier = Some(pts); + self.ready.push_back(Record::new(self.sequence, group, wire(pts))); + self.marks.push(Mark::Start(pts)); + self.sequence += 1; + } + + /// Whether a group starting at `pts` ends the segment that started at `start`. + fn boundary(&mut self, start: Timestamp, pts: Timestamp, elapsed: Duration) -> bool { + while self + .cuts + .front() + .is_some_and(|cut| cut.as_micros() <= start.as_micros()) + { + self.cuts.pop_front(); + } + if self.cuts.front().is_some_and(|cut| cut.as_micros() <= pts.as_micros()) { + while self.cuts.front().is_some_and(|cut| cut.as_micros() <= pts.as_micros()) { + self.cuts.pop_front(); + } + return true; + } + !self.manual && elapsed >= self.target() + } + + /// Report that the content extends to `pts`, without a frame starting there. + /// + /// A frame report says where content starts; the stop after the last segment would otherwise + /// land at its last frame's start. + pub fn end(&mut self, pts: Timestamp) { + if !self.closed && self.open.is_some() { + self.advance(pts); + } + } + + fn advance(&mut self, pts: Timestamp) { + if self + .frontier + .is_none_or(|frontier| pts.as_micros() > frontier.as_micros()) + { + self.frontier = Some(pts); + } + } + + /// Declare a boundary at `pts`: the segment ends at the first group starting at or after it. + /// + /// The first cut takes over from [`Config::duration_min`] pacing for good, since pacing would + /// otherwise end a segment just before the caller declares where it really ends. A cut at or + /// before an earlier one is ignored, so several sources declaring the same boundaries cost + /// nothing. + pub fn cut(&mut self, pts: Timestamp) { + if self.closed { + return; + } + self.manual = true; + if self.cuts.back().is_none_or(|back| pts.as_micros() > back.as_micros()) { + self.cuts.push_back(pts); + } + } + + /// Content stopped before group `group`: publish a zero-duration record where it reached. + /// + /// The next group starts a new segment. Nothing happens while no segment is open. + pub fn stop(&mut self, group: u64) { + if !self.closed { + self.stop_at(group); + } + } + + fn stop_at(&mut self, group: u64) { + let Some(start) = self.open.take() else { + return; + }; + // One stopped early says nothing about the cadence, so the target falls back to the minimum. + self.declared.get_or_insert(self.config.duration_min); + let end = self.frontier.take().unwrap_or(start); + self.ready + .push_back(Record::new(self.sequence, group, wire(end)).with_duration(0)); + self.marks.push(Mark::Stop); + self.sequence += 1; + } + + /// Stop after the newest group, then ignore every later report. + pub fn close(&mut self) { + if let Some(last) = self.last { + self.stop_at(last + 1); + } + self.closed = true; + } + + /// Mirror what a pacer did. + fn follow(&mut self, mark: Mark) { + match mark { + Mark::Start(pts) => self.cut(pts), + Mark::Stop => { + if let Some(last) = self.last { + self.stop(last + 1); + } + } + } + } + + /// Take over pacing from a pacer that went away. + fn pace(&mut self) { + self.manual = false; + self.following = false; + self.cuts.clear(); + } +} + +/// Yields each record once; `None` means none is ready yet, not that none ever will be. +impl Iterator for Segmenter { + type Item = Record; + + fn next(&mut self) -> Option { + self.ready.pop_front() + } +} + +/// Publishes one track's records onto its HLS record track. +/// +/// Records must be pushed in sequence order, starting at the window's next index. +pub(crate) struct Producer { + sink: moq_json::window::Producer, +} + +impl Producer { + /// The properties a record track is created with. + pub fn info() -> moq_net::track::Info { + moq_net::track::Info::default() + .with_timescale(moq_net::Timescale::MILLI) + .with_priority(hang::catalog::PRIORITY.catalog) + // A joiner asks for a fresh group once the ops outweigh eight headers. + .with_refresh(8) + } + + /// Publish onto `track`, starting at record zero. + pub fn new(track: moq_net::track::Producer) -> Self { + let config = moq_json::window::ProducerConfig::default().with_checkpoint_records(CHECKPOINT_RECORDS); + Self { + sink: moq_json::window::Producer::new(track, config), + } + } + + /// Append `record`, which must be the window's next sequence. + pub fn push(&mut self, record: &Record) -> crate::Result<()> { + let next = self.sink.range().end; + if record.sequence != next { + return Err(crate::Error::HlsSequence { + expected: next, + actual: record.sequence, + }); + } + self.refresh()?; + self.sink + .push(moq_net::Timed::from(record).at(moq_net::Timestamp::now())) + .map_err(json) + } + + /// Finish the record track. + pub fn finish(&mut self) -> crate::Result<()> { + self.sink.finish().map_err(json) + } + + /// Restate the window in a fresh group if a joiner wants one. + /// + /// Checked on each edit rather than driven by a task, since nothing here owns a runtime. A + /// record track gains a record every segment, so a joiner waits at most about one segment. + fn refresh(&mut self) -> crate::Result<()> { + match self.sink.poll_refresh(&kio::Waiter::noop()) { + Poll::Ready(Err(err)) => Err(json(err)), + _ => Ok(()), + } + } +} + +/// Surface a transport failure as one, rather than wrapped in the JSON layer. +fn json(err: moq_json::Error) -> crate::Error { + match err { + moq_json::Error::Net(err) => err.into(), + err => err.into(), + } +} + +/// Runs once, with the nominal duration, when an unhinted track learns it. +type OnDeclare = Box; + +/// A live track's segmenter and the record track it publishes to. +struct Live { + segmenter: Segmenter, + /// Publishes an unhinted declaration with the first complete segment. + on_declare: Option, + /// `None` once a publish failed: the records are an optional sidecar, so they stop instead. + output: Option, + /// Whether a [`Recorder`] owns the segmenter, so a second enrollment can't share it. + recording: bool, + /// The enrollment that last claimed it, so the earliest still recording takes over pacing. + enrolled: u64, +} + +/// What a [`Live::publish`] left for its caller to act on once every lock is released. +#[must_use] +struct Published { + /// What the segmenter did, for followers to mirror. + marks: Vec, + /// A declaration to announce: the callback takes the catalog's lock, which is taken before ours. + declare: Option<(OnDeclare, Duration)>, +} + +impl Published { + /// Announce any declaration, returning the marks. + fn fire(self) -> Vec { + if let Some((on_declare, duration)) = self.declare { + on_declare(duration); + } + self.marks + } +} + +impl Live { + /// Publish ready records. + fn publish(&mut self) -> Published { + for record in self.segmenter.by_ref() { + let Some(output) = self.output.as_mut() else { + continue; + }; + if let Err(err) = output.push(&record) { + tracing::warn!(%err, "HLS record publish failed; dropping the record track"); + let _ = output.finish(); + self.output = None; + } + } + let declare = match self.segmenter.declared() { + Some(duration) => self.on_declare.take().map(|on_declare| (on_declare, duration)), + None => None, + }; + Published { + marks: std::mem::take(&mut self.segmenter.marks), + declare, + } + } + + fn finish(&mut self) { + // Nothing advertises a declaration once the tracks finish. + self.on_declare = None; + self.segmenter.close(); + let _ = self.publish(); + if let Some(output) = self.output.as_mut() { + let _ = output.finish(); + } + } +} + +struct State { + /// Each enrolled track's record track name and live state, by media track name. Kept after its + /// recorder drops, so a re-enrolled track continues its numbering. + tracks: BTreeMap>)>, + /// The track pacing the segments, if any is recording. + pacer: Option, + /// Enrollments so far, numbering each. + enrolled: u64, + finished: bool, +} + +/// A broadcast's HLS record tracks, one per enrolled rendition. +/// +/// Each enrollment creates the track's record track and returns the [`Recorder`] its producer +/// reports through. `Clone` shares the set. +#[derive(Clone)] +pub(crate) struct Tracks { + broadcast: moq_net::broadcast::Producer, + state: Arc>, +} + +impl Tracks { + /// Record tracks for tracks of `broadcast`. + pub fn new(broadcast: &moq_net::broadcast::Producer) -> Self { + Self { + broadcast: broadcast.clone(), + state: Arc::new(Mutex::new(State { + tracks: BTreeMap::new(), + pacer: None, + enrolled: 0, + finished: false, + })), + } + } + + /// Enroll the track `name`, its segments cut by `config`. + /// + /// Creates its record track, named by [`hang::hls::default_name`], on first enrollment. The + /// first track recording paces the segments and the rest follow it. Enrolling a name again + /// after its recorder dropped continues its numbering for a new producer. Errors when the name's + /// recorder is still alive, the record track name is taken, or the tracks finished. + pub fn track(&self, name: &str, config: Config) -> crate::Result { + let mut state = self.state.lock().unwrap(); + if state.finished { + return Err(moq_net::Error::Closed.into()); + } + state.enrolled += 1; + let enrolled = state.enrolled; + let pacing = state.pacer.is_none(); + let segmenter = |sequence: u64| { + let segmenter = Segmenter::new(config.clone()).with_sequence(sequence); + match pacing { + true => segmenter, + false => segmenter.following(), + } + }; + + let live = match state.tracks.get(name) { + Some((_, existing)) => { + // A re-enrolled track is a new producer whose group sequences may restart, so it gets a + // fresh segmenter continuing the numbering and the declaration already advertised. + let mut live = existing.lock().unwrap(); + if live.recording { + return Err(moq_net::Error::Duplicate.into()); + } + let declared = live.segmenter.declared(); + live.segmenter = segmenter(live.segmenter.sequence()); + if declared.is_some() { + live.segmenter.declared = declared; + } + live.recording = true; + live.enrolled = enrolled; + drop(live); + existing.clone() + } + None => { + let track = self + .broadcast + .create_track(hang::hls::default_name(name).as_str(), Producer::info())?; + let live = Arc::new(Mutex::new(Live { + segmenter: segmenter(0), + on_declare: None, + output: Some(Producer::new(track)), + recording: true, + enrolled, + })); + state + .tracks + .insert(name.to_string(), (hang::hls::default_name(name), live.clone())); + live + } + }; + if pacing { + state.pacer = Some(name.to_string()); + } + Ok(Recorder { + name: name.to_string(), + live, + state: self.state.clone(), + }) + } + + /// The record track and nominal duration advertised for `name`, once its duration is known. + pub fn hls(&self, name: &str) -> Option { + let state = self.state.lock().unwrap(); + let (track, live) = state.tracks.get(name)?; + let duration = live.lock().unwrap().segmenter.declared()?; + Some(hang::catalog::Hls::new(track.clone(), round(duration))) + } + + /// Stop every open segment now, publishing any declaration it fixes. + pub fn flush(&self) { + let published: Vec<_> = { + let state = self.state.lock().unwrap(); + state + .tracks + .values() + .map(|(_, live)| { + let mut live = live.lock().unwrap(); + if let Some(last) = live.segmenter.last { + live.segmenter.stop(last + 1); + } + live.publish() + }) + .collect() + }; + for published in published { + published.fire(); + } + } + + /// Stop every open segment and finish every record track. Later enrollments fail. + pub fn finish(&self) { + let mut state = self.state.lock().unwrap(); + state.finished = true; + for (_, live) in state.tracks.values() { + live.lock().unwrap().finish(); + } + } +} + +/// A nominal duration as the catalog advertises it, in whole wire units rounded up. +fn round(duration: Duration) -> Duration { + Duration::from_millis(units(duration)) +} + +/// Reports one track's frames into its live record track. +/// +/// Move-only: it is the track's reporting handle, minted by [`Tracks::track`] and held by a +/// rendition's [`container::Producer`](crate::container::Producer). Dropping it stops the open +/// segment; the record track stays open until [`Tracks::finish`]. +pub(crate) struct Recorder { + name: String, + live: Arc>, + state: Arc>, +} + +impl Recorder { + /// Run `callback` once when an unhinted track learns its fixed duration. + pub fn on_declare(&mut self, callback: impl FnOnce(Duration) + Send + 'static) { + self.live.lock().unwrap().on_declare = Some(Box::new(callback)); + } + + fn report(&mut self, f: impl FnOnce(&mut Segmenter)) { + let published = { + let mut live = self.live.lock().unwrap(); + f(&mut live.segmenter); + live.publish() + }; + self.lead(&published.fire()); + } + + /// Hand a pacer's marks to every other track still recording. + fn lead(&self, marks: &[Mark]) { + if marks.is_empty() { + return; + } + let followers: Vec<_> = { + let state = self.state.lock().unwrap(); + if state.pacer.as_deref() != Some(self.name.as_str()) { + return; + } + state + .tracks + .iter() + .filter(|(name, _)| **name != self.name) + .map(|(_, (_, live))| live.clone()) + .collect() + }; + for follower in followers { + let published = { + let mut live = follower.lock().unwrap(); + if !live.recording { + continue; + } + for mark in marks { + live.segmenter.follow(*mark); + } + live.publish() + }; + // Only the pacer leads, so a follower's own marks go nowhere. + published.fire(); + } + } + + /// Report a frame of group `group`, presented at `pts`; see [`Segmenter::frame`]. + /// + /// A report the segmenter cannot place ends the record track with an error rather than + /// publishing records that silently omit frames. Media publishing is unaffected. + pub fn frame(&mut self, group: u64, pts: Timestamp) { + let published = { + let mut live = self.live.lock().unwrap(); + if let Err(err) = live.segmenter.frame(group, pts) { + tracing::warn!(%err, "HLS report rejected; dropping the record track"); + live.segmenter.closed = true; + // Dropped without a finish, so subscribers see the record track fail rather than end. + live.output = None; + } + live.publish() + }; + self.lead(&published.fire()); + } + + /// Report that the content extends to `pts`; see [`Segmenter::end`]. + pub fn end(&mut self, pts: Timestamp) { + self.report(|segmenter| segmenter.end(pts)); + } + + /// Report that content stopped before group `group`; see [`Segmenter::stop`]. + pub fn stop(&mut self, group: u64) { + self.report(|segmenter| segmenter.stop(group)); + } + + /// Declare a boundary at `pts` on this track; see [`Segmenter::cut`]. + pub fn cut(&mut self, pts: Timestamp) { + self.report(|segmenter| segmenter.cut(pts)); + } +} + +impl Drop for Recorder { + fn drop(&mut self) { + let published = { + let mut live = self.live.lock().unwrap(); + if let Some(last) = live.segmenter.last { + live.segmenter.stop(last + 1); + } + live.recording = false; + live.publish() + }; + self.lead(&published.fire()); + + // The earliest enrollment still recording takes over pacing, or nobody does until the next. + let mut state = self.state.lock().unwrap(); + if state.pacer.as_deref() != Some(self.name.as_str()) { + return; + } + let next = state + .tracks + .iter() + .filter_map(|(name, (_, live))| { + let live = live.lock().unwrap(); + live.recording.then_some((live.enrolled, name.clone())) + }) + .min(); + state.pacer = next.map(|(_, name)| name); + if let Some(pacer) = &state.pacer { + state.tracks[pacer].1.lock().unwrap().segmenter.pace(); + } + } +} + +/// One decoded HLS record, with real [`Timestamp`]s. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Entry { + /// The segment's number, consecutive within its track. + pub sequence: u64, + + /// The group the segment starts with. + pub group: u64, + + /// The segment's start. + pub pts: Timestamp, + + /// The segment's duration, when it does not run to the next record's pts. Zero marks where + /// content stopped: the segment holds no media. + pub duration: Option, +} + +impl Entry { + /// Decode a record, converting its timing out of milliseconds. + /// + /// A pts the timescale can't represent is an error rather than a substituted value: silently + /// moving a timestamp would misdirect the playlist. + pub fn decode(record: Record) -> crate::Result { + Ok(Self { + sequence: record.sequence, + group: record.group, + pts: Timestamp::new(record.pts, TIMESCALE)?, + duration: record.duration.map(Duration::from_millis), + }) + } + + /// Whether the segment holds no media: content stopped at its start. + pub fn is_stop(&self) -> bool { + self.duration.is_some_and(|duration| duration.is_zero()) + } +} + +/// One change to the visible record window. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Event { + /// A record became visible at this window index, which equals its sequence. + Push { + /// Absolute window index assigned to the record. + index: u64, + /// The decoded record. + entry: Entry, + }, + /// These previously visible window indices were removed. + Pop(std::ops::Range), + /// These indices were removed before this consumer saw them. + Skip(std::ops::Range), +} + +/// Reads one rendition's HLS records, yielding decoded [`Event`]s in window order. +pub struct Consumer { + inner: moq_json::window::Consumer, +} + +impl Consumer { + /// Subscribe to the record track a rendition's [`hls`](hang::catalog::Hls) field names. + pub async fn subscribe(broadcast: &moq_net::broadcast::Consumer, hls: &hang::catalog::Hls) -> crate::Result { + let track = broadcast.track(&hls.track)?.subscribe(None).await?; + Ok(Self { + inner: moq_json::window::Consumer::new(track, moq_json::window::ConsumerConfig::default()), + }) + } + + fn decode_event(event: moq_json::window::Event) -> crate::Result { + Ok(match event { + moq_json::window::Event::Push { index, value } => { + // The sequence is the segment's URL and media sequence, so one that disagrees with + // its position would list two segments under one number. + if value.sequence != index { + return Err(crate::Error::HlsSequence { + expected: index, + actual: value.sequence, + }); + } + Event::Push { + index, + entry: Entry::decode(value)?, + } + } + moq_json::window::Event::Pop(range) => Event::Pop(range), + moq_json::window::Event::Skip(range) => Event::Skip(range), + _ => unreachable!("unknown record window event"), + }) + } + + /// Get the next window event, or `None` once the track ends. + pub async fn next(&mut self) -> crate::Result> { + match self.inner.next().await? { + Some(event) => Ok(Some(Self::decode_event(event.value)?)), + None => Ok(None), + } + } + + /// Poll for the next window event, without blocking. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + match self.inner.poll_next(waiter)? { + // Each record carries its own pts, so the frame timestamp adds nothing. + Poll::Ready(Some(event)) => Poll::Ready(Self::decode_event(event.value).map(Some)), + Poll::Ready(None) => Poll::Ready(Ok(None)), + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + /// Media whose encoder reports a 4s cadence, so it declares before any segment. + fn hinted() -> Config { + Config::default().with_duration_hint(Duration::from_secs(4)) + } + + fn ms(v: u64) -> Timestamp { + Timestamp::from_millis(v).unwrap() + } + + /// Each record as `(group, pts, duration)` in wire units, checking the numbering. + fn drain(segmenter: &mut Segmenter) -> Vec<(u64, u64, Option)> { + let mut out = Vec::new(); + for record in segmenter.by_ref() { + assert_eq!(record.sequence, out.len() as u64 + first(&out), "records are numbered consecutively"); + out.push((record.group, record.pts, record.duration)); + } + out + } + + fn first(_: &[(u64, u64, Option)]) -> u64 { + 0 + } + + /// Groups `range`, one frame each, `step` ms apart. + fn groups(segmenter: &mut Segmenter, range: std::ops::Range, step: u64) { + for group in range { + segmenter.frame(group, ms(group * step)).unwrap(); + } + } + + #[test] + fn long_groups_get_a_segment_each() { + let mut segmenter = Segmenter::new(Config::default()); + groups(&mut segmenter, 0..3, 5_000); + assert_eq!( + drain(&mut segmenter), + [(0, 0, None), (1, 5_000, None), (2, 10_000, None)] + ); + assert_eq!(segmenter.declared(), Some(Duration::from_secs(5))); + } + + #[test] + fn short_groups_pack_up_to_the_minimum() { + let mut segmenter = Segmenter::new(Config::default()); + groups(&mut segmenter, 0..10, 1_000); + assert_eq!(drain(&mut segmenter), [(0, 0, None), (4, 4_000, None), (8, 8_000, None)]); + } + + #[test] + fn a_hint_declares_its_smallest_multiple_at_or_past_the_minimum() { + let segmenter = Segmenter::new(Config::default().with_duration_hint(Duration::from_millis(1_500))); + assert_eq!(segmenter.declared(), Some(Duration::from_millis(4_500))); + assert_eq!(Segmenter::new(hinted()).declared(), Some(Duration::from_secs(4))); + } + + #[test] + fn a_stopped_first_segment_declares_the_minimum() { + let mut segmenter = Segmenter::new(Config::default()); + groups(&mut segmenter, 0..2, 1_000); + segmenter.close(); + assert_eq!(segmenter.declared(), Some(DEFAULT_DURATION_MIN)); + assert_eq!(drain(&mut segmenter), [(0, 0, None), (2, 1_000, Some(0))]); + } + + #[test] + fn the_end_extends_the_stop() { + let mut segmenter = Segmenter::new(hinted()); + groups(&mut segmenter, 0..2, 1_000); + segmenter.end(ms(2_000)); + segmenter.close(); + assert_eq!(drain(&mut segmenter), [(0, 0, None), (2, 2_000, Some(0))]); + // Nothing reports after the close. + segmenter.frame(5, ms(9_000)).unwrap(); + assert!(drain(&mut segmenter).is_empty()); + } + + #[test] + fn a_skipped_sequence_stops_the_segment() { + let mut segmenter = Segmenter::new(hinted()); + groups(&mut segmenter, 0..2, 1_000); + segmenter.end(ms(1_500)); + segmenter.frame(5, ms(60_000)).unwrap(); + assert_eq!( + drain(&mut segmenter), + [(0, 0, None), (2, 1_500, Some(0)), (5, 60_000, None)] + ); + } + + #[test] + fn a_stop_ends_the_segment_and_the_next_group_starts_one() { + let mut segmenter = Segmenter::new(hinted()); + groups(&mut segmenter, 0..2, 1_000); + segmenter.end(ms(2_000)); + segmenter.stop(2); + segmenter.frame(3, ms(10_000)).unwrap(); + assert_eq!( + drain(&mut segmenter), + [(0, 0, None), (2, 2_000, Some(0)), (3, 10_000, None)] + ); + } + + #[test] + fn the_ceiling_ends_a_pacer_waiting_on_cuts() { + let mut segmenter = Segmenter::new(hinted()); + segmenter.cut(ms(100_000)); + groups(&mut segmenter, 0..20, 1_000); + // Three declared durations: the source's cut never came. + assert_eq!(drain(&mut segmenter), [(0, 0, None), (12, 12_000, None)]); + } + + #[test] + fn explicit_cuts_override_the_pacing() { + let mut segmenter = Segmenter::new(Config::default()); + segmenter.cut(ms(2_000)); + segmenter.cut(ms(3_000)); + groups(&mut segmenter, 0..5, 1_000); + assert_eq!(drain(&mut segmenter), [(0, 0, None), (2, 2_000, None), (3, 3_000, None)]); + } + + #[test] + fn a_report_that_goes_backwards_is_refused() { + let mut segmenter = Segmenter::new(hinted()); + segmenter.frame(3, ms(0)).unwrap(); + segmenter.frame(3, ms(10)).unwrap(); + assert!(matches!( + segmenter.frame(2, ms(20)), + Err(crate::Error::HlsGroup { group: 2, last: 3 }) + )); + } + + #[test] + fn a_follower_starts_at_the_pacers_segments() { + let mut follower = Segmenter::new(Config::default()).following(); + // Audio before the pacer's first segment is not recorded. + follower.frame(0, ms(0)).unwrap(); + follower.follow(Mark::Start(ms(100))); + for (group, pts) in [(1, 90), (2, 110), (3, 2_000), (4, 4_090), (5, 4_110)] { + follower.frame(group, ms(pts)).unwrap(); + } + follower.follow(Mark::Start(ms(4_100))); + follower.frame(6, ms(4_130)).unwrap(); + follower.follow(Mark::Stop); + assert_eq!( + drain(&mut follower), + [(2, 110, None), (6, 4_130, None), (7, 4_130, Some(0))] + ); + } + + #[tokio::test] + async fn followers_number_segments_like_the_pacer() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let tracks = Tracks::new(&broadcast); + let mut video = tracks.track("video", hinted()).unwrap(); + let mut audio = tracks.track("audio", hinted()).unwrap(); + // Audio runs ahead of video, grouped every 20ms. + for step in 0..450u64 { + let pts = step * 20; + audio.frame(step, ms(pts + 5)); + if pts % 2_000 == 0 { + video.frame(pts / 2_000, ms(pts)); + } + } + drop((video, audio)); + tracks.finish(); + + let consumer = broadcast.consume(); + let read = async |name: &str| { + let hls = tracks.hls(name).unwrap(); + let mut consumer = Consumer::subscribe(&consumer, &hls).await.unwrap(); + let mut out = Vec::new(); + while let Some(event) = consumer.next().await.unwrap() { + let Event::Push { entry, .. } = event else { + panic!("unexpected event {event:?}"); + }; + out.push((entry.sequence, Duration::from(entry.pts).as_millis(), entry.is_stop())); + } + out + }; + let video = read("video").await; + let audio = read("audio").await; + assert_eq!( + video, + [(0, 0, false), (1, 4_000, false), (2, 8_000, false), (3, 8_000, true)] + ); + // Each audio segment starts at its first group after the video segment's. Audio's first + // group was reported before video's, so it waits for the next. + assert_eq!( + audio, + [(0, 25, false), (1, 4_025, false), (2, 8_025, false), (3, 8_985, true)] + ); + } + + #[tokio::test] + async fn the_next_enrollment_takes_over_pacing() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let tracks = Tracks::new(&broadcast); + let video = tracks.track("video", hinted()).unwrap(); + let mut audio = tracks.track("audio", hinted()).unwrap(); + drop(video); + groups_on(&mut audio, 0..9, 1_000); + let live = tracks.state.lock().unwrap().tracks["audio"].1.clone(); + let next = live.lock().unwrap().segmenter.sequence(); + assert_eq!(next, 3, "audio paced its own segments at 0, 4s, and 8s"); + } + + fn groups_on(recorder: &mut Recorder, range: std::ops::Range, step: u64) { + for group in range { + recorder.frame(group, ms(group * step)); + } + } + + #[test] + fn an_unhinted_track_is_advertised_once_declared() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let tracks = Tracks::new(&broadcast); + let mut recorder = tracks.track("video", Config::default()).unwrap(); + recorder.frame(0, ms(0)); + assert_eq!(tracks.hls("video"), None); + recorder.frame(1, ms(5_000)); + assert_eq!( + tracks.hls("video"), + Some(hang::catalog::Hls::new("video.hls", Duration::from_secs(5))) + ); + } + + #[tokio::test] + async fn a_rejected_report_fails_the_record_track() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let tracks = Tracks::new(&broadcast); + let mut recorder = tracks.track("video", hinted()).unwrap(); + let hls = tracks.hls("video").unwrap(); + let mut consumer = Consumer::subscribe(&broadcast.consume(), &hls).await.unwrap(); + recorder.frame(1, ms(0)); + recorder.frame(0, ms(1_000)); + let mut next = consumer.next().await; + while let Ok(Some(_)) = next { + next = consumer.next().await; + } + assert!(next.is_err(), "the track fails rather than ends"); + } + + #[tokio::test] + async fn re_enrolling_continues_the_numbering() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let tracks = Tracks::new(&broadcast); + let mut recorder = tracks.track("video", hinted()).unwrap(); + assert!(tracks.track("video", hinted()).is_err(), "its recorder is still alive"); + recorder.frame(0, ms(0)); + drop(recorder); + let mut recorder = tracks.track("video", hinted()).unwrap(); + // The new producer restarts its group sequences. + recorder.frame(0, ms(10_000)); + drop(recorder); + tracks.finish(); + + let hls = tracks.hls("video").unwrap(); + let mut consumer = Consumer::subscribe(&broadcast.consume(), &hls).await.unwrap(); + let mut records = Vec::new(); + while let Some(Event::Push { entry, .. }) = consumer.next().await.unwrap() { + records.push((entry.sequence, entry.group, entry.is_stop())); + } + assert_eq!(records, [(0, 0, false), (1, 1, true), (2, 0, false), (3, 1, true)]); + } + + #[test] + fn a_taken_record_track_name_fails_enrollment() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let _taken = broadcast.create_track("video.hls", None).unwrap(); + let tracks = Tracks::new(&broadcast); + assert!(tracks.track("video", Config::default()).is_err()); + } + + #[test] + fn enrollment_after_finish_is_refused() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let tracks = Tracks::new(&broadcast); + tracks.finish(); + assert!(tracks.track("video", Config::default()).is_err()); + } + + #[tokio::test] + async fn a_record_out_of_place_is_refused() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast.create_track("video.hls", Producer::info()).unwrap(); + let mut sink = moq_json::window::Producer::::new(track, moq_json::window::ProducerConfig::default()); + sink.push(moq_net::Timed::from(&Record::new(1, 0, 0)).at(Timestamp::ZERO)) + .unwrap(); + let hls = hang::catalog::Hls::new("video.hls", Duration::from_secs(4)); + let mut consumer = Consumer::subscribe(&broadcast.consume(), &hls).await.unwrap(); + assert!(matches!( + consumer.next().await, + Err(crate::Error::HlsSequence { expected: 0, actual: 1 }) + )); + } +} diff --git a/rs/moq-mux/src/lib.rs b/rs/moq-mux/src/lib.rs index 0d973d185b6..3a26a65b862 100644 --- a/rs/moq-mux/src/lib.rs +++ b/rs/moq-mux/src/lib.rs @@ -27,10 +27,8 @@ //! part of the format (MPEG-TS). //! - [`rate`] turns bandwidth grants into stable media bitrate targets shared //! by audio, video, and transcode senders. -//! - [`timeline`](mod@timeline) publishes the broadcast's segment index: one -//! record per aligned segment, mapping a span of content time to the group -//! ranges that carry it on each track, so consumers can seek or build -//! HLS/DASH playlists without downloading media. +//! - [`hls`](mod@hls) publishes opt-in HLS segment records per rendition, so an +//! edge can build HLS/DASH playlists without downloading media. //! - [`follow`] follows a broadcast across the publisher instances that //! serve it, for a reader that restarts on each one. //! - [`Linger`] keeps a cleanly ended broadcast up for a fixed time, so a @@ -52,7 +50,7 @@ pub mod rate; pub mod select; mod source; mod stats; -pub mod timeline; +pub mod hls; pub use clock::Clock; pub use error::*; diff --git a/rs/moq-mux/src/timeline.rs b/rs/moq-mux/src/timeline.rs deleted file mode 100644 index df03b0bdac4..00000000000 --- a/rs/moq-mux/src/timeline.rs +++ /dev/null @@ -1,1476 +0,0 @@ -//! Per-track timelines: each track's index of spans, which HLS/DASH export and recordings use. -//! -//! Every indexed track gets its own timeline track carrying one [`hang::timeline::Record`] per -//! *span*: a run of the track's frames mapped to their content time. A consumer can answer "which -//! groups cover time T on track X" and "where is the live edge" from a few bytes per span -//! without subscribing to media, which is the primitive a playlist server (HLS/DASH), a seek bar, -//! or a recorder index needs. -//! -//! ## Cutting -//! -//! Tracks cut independently; nothing waits for another track. -//! -//! Every track follows one rule, parameterized by its [`Config`]: -//! -//! - A record ends at the first group boundary at least [`Config::duration_min`] (4s by default) -//! past its own start, so short groups (audio) pack into one record and long ones (video GOPs) -//! get one each. Only sparse data, such as a catalog that may not publish again for the rest of -//! the broadcast, uses a zero minimum: every group is then its own record, ended as soon as the -//! group finishes. -//! - A group still open three declared durations past the record's start is split at the next -//! reported frame, so an append-only group that never closes is still indexed as it grows. -//! Before an unhinted track declares, the ceiling is ten seconds, or the minimum if longer. -//! - A skipped group sequence always closes the record at its newest reported content time, so a -//! record's groups are contiguous and a pause is not absorbed into its duration. -//! - [`Segmenter::cut`] adds an application boundary, such as a video keyframe cutting audio so a -//! derived segment needs fewer objects. The first cut takes over from minimum-duration pacing. -//! -//! An untimed track, whose frames carry no timestamps, has no duration to pace by: each group is -//! its own record, ended when the group finishes or the next one starts, and its records carry no -//! `pts` or `duration`. Nothing splits an untimed group, so one that never closes is indexed only -//! when the segmenter flushes. -//! -//! ## Wiring -//! -//! [`catalog::Producer`](crate::catalog::Producer) owns the broadcast's [`Timelines`] and advertises -//! them in the catalog's root [`hang::catalog::Archive`] entry. Its role-specific track -//! constructors enroll each media track, and the catalog enrolls itself with a zero minimum. -//! -//! A [`Segmenter`] builds records from a track's frame reports without publishing them, and a -//! [`Producer`] publishes records onto a timeline track. A [`Recorder`] pairs the two for live -//! publishing; a recording writer drives them separately so it can store the media a record names -//! before publishing it. On the read side, [`Consumer::subscribe`] reads one track's timeline from -//! the catalog's [`Archive`] entry and yields decoded [`Event`]s. On the wire each timeline is a -//! DEFLATE-compressed [`moq_json::window`], so a DVR can trim old records while an unbounded -//! timeline simply never pops them. - -use std::collections::{BTreeMap, VecDeque}; -use std::sync::{Arc, Mutex}; -use std::task::Poll; -use std::time::Duration; - -use hang::catalog::{Archive, Timeline}; -use hang::timeline::{Position, Record, RecordExt}; - -use moq_net::{Timescale, Timestamp}; - -/// The default [`Config::duration_min`] (4 seconds). Only sparse data, such as a catalog, uses zero. -pub const DEFAULT_DURATION_MIN: Duration = Duration::from_secs(4); - -/// The safety ceiling before an unhinted track declares its duration, unless the minimum is longer. -const DEFAULT_DURATION_CEILING: Duration = Duration::from_secs(10); - -/// Records may overrun their target to reach a keyframe, but not indefinitely. -const DURATION_CEILING_MULTIPLIER: u32 = 3; - -/// Recent records repeated when the window track rolls to a new group. -const CHECKPOINT_RECORDS: usize = 256; - -/// How a track's records are cut. -/// -/// Every track follows one rule: a record ends at the first group boundary at least -/// [`duration_min`](Self::duration_min) past its start. A hint fixes the nominal target to its -/// smallest multiple at or beyond that minimum. -#[derive(Clone, Debug)] -#[non_exhaustive] -pub struct Config { - /// The shortest a record may be before a group boundary ends it. - /// - /// A floor rather than a target on purpose: a floor is always satisfiable (wait for the next - /// group), so no group is split to honor it. Zero, for sparse data such as a catalog, makes - /// every group its own record, ended as soon as the group finishes. - pub duration_min: Duration, - - /// The application's nominal group duration, when known. - pub duration_hint: Option, -} - -impl Config { - /// Set [`duration_min`](Self::duration_min). - pub fn with_duration_min(mut self, duration: Duration) -> Self { - self.duration_min = duration; - self - } - - /// Set [`duration_hint`](Self::duration_hint). - pub fn with_duration_hint(mut self, duration: impl Into>) -> Self { - self.duration_hint = duration.into(); - self - } -} - -impl Default for Config { - fn default() -> Self { - Self { - duration_min: DEFAULT_DURATION_MIN, - duration_hint: None, - } - } -} - -/// The wire timescale for record `pts`/`duration`: the catalog section's default, milliseconds. -const TIMESCALE: Timescale = Timescale::MILLI; - -/// A duration in wire units, rounded up so a bound never understates itself. -fn units(duration: Duration) -> u64 { - u64::try_from((duration.as_micros() * TIMESCALE.as_u64() as u128).div_ceil(1_000_000)).unwrap_or(u64::MAX) -} - -fn duration(units: u64) -> Duration { - Duration::from_micros(units.saturating_mul(1_000_000) / TIMESCALE.as_u64()) -} - -fn wire(pts: Timestamp) -> u64 { - pts.as_scale(TIMESCALE) as u64 -} - -/// The record being built. -struct Open { - start: Position, - /// `None` on an untimed track. - pts: Option, - keyframe: bool, -} - -/// Builds one track's records from reports of the frames it published. -/// -/// Reports arrive in position order: [`frame`](Self::frame) for each frame that may start a record -/// (every frame, or at least the first of each group), [`finish_group`](Self::finish_group) once a -/// group can gain no more frames, and [`end`](Self::end) wherever the content is known to stop. -/// Closed records queue until [`Iterator::next`] takes them. -pub struct Segmenter { - config: Config, - /// Fixed nominal duration; absent until an unhinted track's first record closes. Kept unrounded - /// so pacing hits a fractional-millisecond GOP's boundary; only the wire declaration rounds. - declared: Option, - /// The number the next closed record gets. - sequence: u64, - open: Option, - /// The newest reported frame position. - last: Option, - /// Whether the group of `last` has finished. - finished: bool, - /// The newest reported content time in the open record. - frontier: Option, - /// Application boundaries not yet reached, in order. - cuts: VecDeque, - /// A cut arrived, so the application owns the group boundaries from here on. - manual: bool, - /// Whether the track's frames carry timestamps, fixed by its first report. - timed: Option, - ready: VecDeque, - closed: bool, -} - -impl Segmenter { - /// A segmenter numbering its records from zero. - pub fn new(config: Config) -> Self { - let declared = match config.duration_hint.filter(|hint| !hint.is_zero()) { - Some(hint) => { - let multiple = config.duration_min.as_nanos().div_ceil(hint.as_nanos()).max(1); - Some(hint.saturating_mul(u32::try_from(multiple).unwrap_or(u32::MAX))) - } - None if config.duration_min.is_zero() => Some(Duration::ZERO), - None => None, - }; - Self { - config, - declared, - sequence: 0, - open: None, - last: None, - finished: false, - frontier: None, - cuts: VecDeque::new(), - manual: false, - timed: None, - ready: VecDeque::new(), - closed: false, - } - } - - /// The fixed nominal record duration, once known, in timeline units. An untimed track has no - /// duration to declare. - pub fn declared_duration(&self) -> Option { - self.declared.filter(|_| self.timed != Some(false)).map(units) - } - - fn target(&self) -> Duration { - self.declared - .filter(|value| !value.is_zero()) - .unwrap_or(self.config.duration_min) - } - - fn ceiling(&self) -> Duration { - self.declared - .filter(|value| !value.is_zero()) - .and_then(|value| value.checked_mul(DURATION_CEILING_MULTIPLIER)) - .unwrap_or(DEFAULT_DURATION_CEILING.max(self.config.duration_min)) - } - - /// Number the next record `sequence`, continuing a resumed timeline. - pub fn with_sequence(mut self, sequence: u64) -> Self { - self.sequence = sequence; - self - } - - /// The number the next closed record gets. - pub fn sequence(&self) -> u64 { - self.sequence - } - - /// Report the frame at `position`, presented at `pts`, or `None` on an untimed track. - /// - /// Fails with [`TimelinePosition`](crate::Error::TimelinePosition) when `position` is at or - /// before the previous report: records never overlap, so that frame could never be indexed. - /// Fails with [`TimelineTiming`](crate::Error::TimelineTiming) when the track's first report - /// was timed and this one is not, or the reverse. - pub fn frame( - &mut self, - position: Position, - pts: impl Into>, - keyframe: bool, - ) -> crate::Result<()> { - let pts = pts.into(); - if self.closed { - return Ok(()); - } - if let Some(last) = self.last - && position <= last - { - return Err(crate::Error::TimelinePosition { position, last }); - } - if *self.timed.get_or_insert(pts.is_some()) != pts.is_some() { - return Err(crate::Error::TimelineTiming(position)); - } - if pts.is_none() { - // Cuts are content times, which an untimed track never reaches. - self.cuts.clear(); - } - - if let (Some(start), Some(last)) = (self.open.as_ref().map(|open| open.pts), self.last) { - match (start, pts) { - (Some(start), Some(pts)) => { - if let Some(end) = self.timed_end(start, pts, position, last) { - self.emit(end, Some(pts), true); - } - } - // An untimed record holds one group, ended where the next one starts. - _ if position.group != last.group => self.emit(Position::group(last.group + 1), None, true), - _ => {} - } - } - - if self.open.is_none() { - self.open = Some(Open { - start: position, - pts, - keyframe, - }); - self.frontier = None; - } - self.last = Some(position); - self.finished = false; - if let Some(pts) = pts { - self.advance(pts); - } - Ok(()) - } - - /// Where pacing ends a timed record that started at `start`, if a frame at `position` and `pts` - /// ends it there. A gap instead closes the record itself and returns `None`. - fn timed_end(&mut self, start: Timestamp, pts: Timestamp, position: Position, last: Position) -> Option { - let elapsed = pts.as_micros().saturating_sub(start.as_micros()); - if position.group == last.group { - return (elapsed >= self.ceiling().as_micros()).then_some(position); - } - let contiguous = position.group == last.group + 1 && position.frame == 0; - if !contiguous { - // A skipped sequence or a group joined mid-way always closes, so a record's groups are - // contiguous and the reader never has to guess what lies between. The skipped time is - // not content, so the record ends at its newest report rather than at this frame. - self.emit(Position::group(last.group + 1), self.frontier, false); - return None; - } - if elapsed >= self.ceiling().as_micros() || self.boundary(start, pts, elapsed) { - return Some(Position::group(last.group + 1)); - } - None - } - - /// Whether a group starting at `pts` ends the record that started at `start`. - fn boundary(&mut self, start: Timestamp, pts: Timestamp, elapsed: u128) -> bool { - while self - .cuts - .front() - .is_some_and(|cut| cut.as_micros() <= start.as_micros()) - { - self.cuts.pop_front(); - } - if self.cuts.front().is_some_and(|cut| cut.as_micros() <= pts.as_micros()) { - while self.cuts.front().is_some_and(|cut| cut.as_micros() <= pts.as_micros()) { - self.cuts.pop_front(); - } - return true; - } - !self.manual && elapsed >= self.target().as_micros() - } - - /// Report that group `group` can gain no more frames. - /// - /// With a zero [`Config::duration_min`], or on an untimed track, this closes the record, so the - /// newest group is indexed without waiting for the next one. - pub fn finish_group(&mut self, group: u64) { - if self.closed || self.last.is_none_or(|last| last.group != group) { - return; - } - self.finished = true; - let each = self.config.duration_min.is_zero() || self.timed == Some(false); - if each && self.open.is_some() { - self.emit(Position::group(group + 1), self.frontier, true); - } - } - - /// Report that the content extends to `pts`, without a frame starting there. - /// - /// A frame report says where content starts; the last record has no successor to bound it, so - /// its duration would otherwise stop at its last frame's start. - pub fn end(&mut self, pts: Timestamp) { - if !self.closed && self.open.is_some() { - self.advance(pts); - } - } - - fn advance(&mut self, pts: Timestamp) { - if self - .frontier - .is_none_or(|frontier| pts.as_micros() > frontier.as_micros()) - { - self.frontier = Some(pts); - } - } - - /// Declare a boundary at `pts`: the record closes at the first group starting at or after it. - /// - /// The first cut takes over from [`Config::duration_min`] pacing for good, since pacing would - /// otherwise close a record just before the caller declares where it really ends. A cut at or - /// before an earlier one is ignored, so several producers declaring the same boundaries (the - /// renditions of one import) cost nothing. An untimed track ignores cuts. - pub fn cut(&mut self, pts: Timestamp) { - if self.closed || self.timed == Some(false) { - return; - } - self.manual = true; - if self.cuts.back().is_none_or(|back| pts.as_micros() > back.as_micros()) { - self.cuts.push_back(pts); - } - } - - /// Close the open record now, at the end of the newest reported frame. - /// - /// The record ends after the newest group when that group finished, otherwise after the newest - /// reported frame. Later reports start a new record. - pub fn flush(&mut self) { - let Some(last) = self.last else { - return; - }; - if self.open.is_none() { - return; - } - let end = match self.finished { - true => Position::group(last.group + 1), - false => Position::new(last.group, last.frame + 1), - }; - self.emit(end, self.frontier, false); - } - - /// Flush, then ignore every later report. - pub fn close(&mut self) { - self.flush(); - self.closed = true; - } - - /// Close the open record at `end`, its content running to `pts`, which is `None` only when - /// the track is untimed. `paced` is false when a gap or flush closes the record early. - fn emit(&mut self, end: Position, pts: Option, paced: bool) { - let Some(open) = self.open.take() else { - return; - }; - let mut record = match open.pts { - Some(start) => { - let start = wire(start); - let pts = pts.expect("a timed record has a frontier"); - // Frames farther apart than the maximum leave nothing to split at, so the record stops at - // the advertised bound and the rest of the gap is uncovered. - let duration = wire(pts).saturating_sub(start).min(units(self.ceiling())); - // The first record fixes an unhinted declaration. One closed early says nothing about - // the cadence, so the target falls back to the minimum. - self.declared.get_or_insert(match paced { - true => self::duration(duration), - false => self.config.duration_min, - }); - Record::new(self.sequence, start, duration, open.start, end) - } - None => Record::untimed(self.sequence, open.start, end), - }; - record.keyframe = open.keyframe; - self.sequence += 1; - self.ready.push_back(record); - } -} - -/// Yields each closed record once; `None` means none is ready yet, not that none ever will be. -impl Iterator for Segmenter { - type Item = Record; - - fn next(&mut self) -> Option { - self.ready.pop_front() - } -} - -/// Publishes one track's records onto its timeline track. -/// -/// Records must be pushed in sequence order, starting at the window's next index. -pub struct Producer { - sink: moq_json::window::Producer, -} - -impl Producer { - /// The properties a timeline track is created with. - pub fn info() -> moq_net::track::Info { - moq_net::track::Info::default() - .with_timescale(moq_net::Timescale::MILLI) - .with_priority(hang::catalog::PRIORITY.catalog) - // A joiner asks for a fresh group once the ops outweigh eight headers. - .with_refresh(8) - } - - /// Publish onto `track`, starting at record zero. - pub fn new(track: moq_net::track::Producer) -> Self { - Self { - sink: moq_json::window::Producer::new(track, Self::config()), - } - } - - fn config() -> moq_json::window::ProducerConfig { - moq_json::window::ProducerConfig::default() - .with_compression(true) - .with_checkpoint_records(CHECKPOINT_RECORDS) - } - - /// The window indices currently retained. - pub fn range(&self) -> std::ops::Range { - self.sink.range() - } - - /// Append `record`, which must be the window's next sequence. - pub fn push(&mut self, record: &Record) -> crate::Result<()> { - let next = self.sink.range().end; - if record.sequence != next { - return Err(crate::Error::TimelineSequence { - expected: next, - actual: record.sequence, - }); - } - self.refresh()?; - self.sink - .push(moq_net::Timed::from(record).at(moq_net::Timestamp::now())) - .map_err(json) - } - - /// Remove up to `count` oldest records from the window. - pub fn pop(&mut self, count: u64) -> crate::Result<()> { - self.refresh()?; - self.sink - .pop(moq_net::Timed::from(count).at(moq_net::Timestamp::now())) - .map_err(json) - } - - /// Finish the timeline track. - pub fn finish(&mut self) -> crate::Result<()> { - self.sink.finish().map_err(json) - } - - /// Restate the window in a fresh group if a joiner wants one. - /// - /// Checked on each edit rather than driven by a task, since nothing here owns a runtime. A - /// timeline gains a record every segment, so a joiner waits at most about one segment. - fn refresh(&mut self) -> crate::Result<()> { - match self.sink.poll_refresh(&kio::Waiter::noop()) { - std::task::Poll::Ready(Err(err)) => Err(json(err)), - _ => Ok(()), - } - } -} - -/// Surface a transport failure as one, rather than wrapped in the JSON layer. -fn json(err: moq_json::Error) -> crate::Error { - match err { - moq_json::Error::Net(err) => err.into(), - err => err.into(), - } -} - -/// A live track's segmenter and the timeline it publishes to. -struct Live { - segmenter: Segmenter, - /// Publishes an unhinted declaration with the first complete record. - on_declare: Option, - /// `None` once a publish failed: the timeline is an optional sidecar, so it stops instead. - output: Option, - /// Whether a [`Recorder`] owns the segmenter, so a second enrollment can't share it. - recording: bool, -} - -impl Live { - fn publish(&mut self) { - for record in self.segmenter.by_ref() { - let Some(output) = self.output.as_mut() else { - continue; - }; - if let Err(err) = output.push(&record) { - tracing::warn!(%err, "timeline publish failed; dropping the timeline track"); - let _ = output.finish(); - self.output = None; - } - } - if let Some(duration) = self.segmenter.declared_duration() - && let Some(on_declare) = self.on_declare.take() - { - on_declare(duration); - } - } - - fn finish(&mut self) { - self.segmenter.close(); - self.publish(); - if let Some(output) = self.output.as_mut() { - let _ = output.finish(); - } - } -} - -type OnDeclare = Box; - -struct Registry { - /// Each enrolled track's timeline, by track name. Kept after its recorder drops, so a finished - /// span stays discoverable and a re-enrolled track continues its numbering. - tracks: BTreeMap>)>, - finished: bool, -} - -/// A broadcast's timelines, one per enrolled track. -/// -/// Each enrollment creates the track's timeline track and returns the [`Recorder`] its producer -/// reports through. `Clone` shares the set. -#[derive(Clone)] -pub struct Timelines { - broadcast: moq_net::broadcast::Producer, - registry: Arc>, -} - -impl Timelines { - /// Timelines for tracks of `broadcast`. - pub fn new(broadcast: &moq_net::broadcast::Producer) -> Self { - let registry = Arc::new(Mutex::new(Registry { - tracks: BTreeMap::new(), - finished: false, - })); - Self { - broadcast: broadcast.clone(), - registry, - } - } - - /// Enroll the track `name`, its records cut by `config`. - /// - /// Creates its timeline track, named by [`hang::timeline::default_name`], on first enrollment. - /// Enrolling a name again after its recorder dropped continues its timeline's numbering for a new - /// producer, cut by the new `config`. Errors when the name's recorder is still alive, the timeline - /// track name is taken, or the timelines finished. - pub fn track(&self, name: &str, config: Config) -> crate::Result { - let mut registry = self.registry.lock().unwrap(); - if registry.finished { - return Err(moq_net::Error::Closed.into()); - } - if let Some((_, existing)) = registry.tracks.get(name) { - // A re-enrolled track is a new producer whose group sequences may restart, so it gets a - // fresh segmenter continuing the record numbering. - let mut live = existing.lock().unwrap(); - if live.recording { - return Err(moq_net::Error::Duplicate.into()); - } - live.recording = true; - live.segmenter.flush(); - live.publish(); - let sequence = live.segmenter.sequence(); - let declared = live.segmenter.declared; - live.segmenter = Segmenter::new(config).with_sequence(sequence); - if declared.is_some() { - live.segmenter.declared = declared; - } - drop(live); - return Ok(Recorder { live: existing.clone() }); - } - - let timeline = hang::timeline::default_name(name); - let track = self.broadcast.create_track(timeline.as_str(), Producer::info())?; - let live = Arc::new(Mutex::new(Live { - segmenter: Segmenter::new(config), - on_declare: None, - output: Some(Producer::new(track)), - recording: true, - })); - registry.tracks.insert(name.to_string(), (timeline, live.clone())); - Ok(Recorder { live }) - } - - /// The catalog's root `archive` entry advertising every timeline whose duration is known. - pub fn section(&self) -> Archive { - let registry = self.registry.lock().unwrap(); - let mut section = Archive::new(); - section.timescale = TIMESCALE.as_u64() as u32; - section.timelines = registry - .tracks - .iter() - .filter_map(|(name, (track, live))| { - live.lock() - .unwrap() - .segmenter - .declared_duration() - .map(|duration| (name.clone(), Timeline::new(track, duration))) - }) - .collect(); - section - } - - /// Close every open record now, publishing any declaration it fixes. - pub(crate) fn flush(&self) { - let registry = self.registry.lock().unwrap(); - for (_, live) in registry.tracks.values() { - let mut live = live.lock().unwrap(); - live.segmenter.flush(); - live.publish(); - } - } - - /// Flush every open record and finish every timeline track. Later enrollments fail. - pub fn finish(&self) { - let mut registry = self.registry.lock().unwrap(); - registry.finished = true; - for (_, live) in registry.tracks.values() { - live.lock().unwrap().finish(); - } - } -} - -/// Reports one track's frames into its live timeline. -/// -/// Move-only: it is the track's reporting handle, minted by [`Timelines::track`] and held by a -/// rendition's [`container::Producer`](crate::container::Producer). Dropping it flushes the open -/// record; the timeline track stays open until [`Timelines::finish`]. -pub struct Recorder { - live: Arc>, -} - -impl Recorder { - /// Run `callback` once when an unhinted track learns its fixed duration. - pub(crate) fn on_declare(&mut self, callback: impl FnOnce(u64) + Send + 'static) { - self.live.lock().unwrap().on_declare = Some(Box::new(callback)); - } - fn report(&mut self, f: impl FnOnce(&mut Segmenter)) { - let mut live = self.live.lock().unwrap(); - f(&mut live.segmenter); - live.publish(); - } - - /// Report the frame at `position`, presented at `pts`; see [`Segmenter::frame`]. - /// - /// A report the timeline cannot place ends the timeline track with an error rather than - /// publishing a timeline that silently omits frames. Media publishing is unaffected. - pub fn frame(&mut self, position: Position, pts: Timestamp, keyframe: bool) { - let mut live = self.live.lock().unwrap(); - if let Err(err) = live.segmenter.frame(position, pts, keyframe) { - tracing::warn!(%err, "timeline report rejected; dropping the timeline track"); - live.segmenter.close(); - // Dropped without a finish, so subscribers see the timeline fail rather than end. - live.output = None; - } - live.publish(); - } - - /// Report that group `group` can gain no more frames; see [`Segmenter::finish_group`]. - pub fn finish_group(&mut self, group: u64) { - self.report(|segmenter| segmenter.finish_group(group)); - } - - /// Report that the content extends to `pts`; see [`Segmenter::end`]. - pub fn end(&mut self, pts: Timestamp) { - self.report(|segmenter| segmenter.end(pts)); - } - - /// Declare a boundary at `pts` on this track; see [`Segmenter::cut`]. - pub fn cut(&mut self, pts: Timestamp) { - self.report(|segmenter| segmenter.cut(pts)); - } - - /// Close the open record now; see [`Segmenter::flush`]. - pub(crate) fn flush(&mut self) { - self.report(|segmenter| segmenter.flush()); - } -} - -impl Drop for Recorder { - fn drop(&mut self) { - let mut live = self.live.lock().unwrap(); - live.segmenter.flush(); - live.publish(); - live.recording = false; - } -} - -/// One decoded timeline record, with real [`Timestamp`]s. -#[derive(Debug, Clone, PartialEq)] -pub struct Entry { - /// The record's number, consecutive within its track's timeline. - pub sequence: u64, - - /// The span's start. - pub pts: Timestamp, - - /// The span's duration. The next entry starts at `pts + duration` unless content time itself - /// jumped (a discontinuity). - pub duration: Duration, - - /// The first frame of the span, inclusive. - pub start: Position, - - /// The end of the span, exclusive. - pub end: Position, - - /// Whether the span's first frame is a keyframe. - pub keyframe: bool, - - /// The record's application extension (nothing for the default `()`). - pub ext: E, -} - -impl Entry { - /// Decode a record into an entry, converting its timing out of the wire timescale. - /// - /// A pts the timescale can't represent is an error rather than a substituted value: silently - /// moving a timestamp would misdirect seeking and live-edge logic. An untimed record, or one - /// missing half its timing, fails with [`TimelineUntimed`](crate::Error::TimelineUntimed). - pub fn decode(record: Record, timescale: Timescale) -> crate::Result> { - let (Some(pts), Some(duration)) = (record.pts, record.duration) else { - return Err(crate::Error::TimelineUntimed(record.sequence)); - }; - let scale = timescale.as_u64() as u128; - let nanos = (duration as u128) * 1_000_000_000 / scale; - Ok(Self { - sequence: record.sequence, - pts: Timestamp::new(pts, timescale)?, - duration: Duration::from_nanos(nanos as u64), - start: record.start, - end: record.end, - keyframe: record.keyframe, - ext: record.ext, - }) - } - - /// The span's exclusive end time. - pub fn end_time(&self) -> Duration { - Duration::from(self.pts) + self.duration - } -} - -/// One change to the visible timeline window. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub enum Event { - /// A record became visible at this window index, which equals its sequence. - Push { - /// Absolute window index assigned to the record. - index: u64, - /// The decoded record. - entry: Entry, - }, - /// These previously visible window indices were removed. - Pop(std::ops::Range), - /// These indices were removed before this consumer saw them. - Skip(std::ops::Range), -} - -/// Reads one track's timeline, yielding decoded [`Event`]s in window order. -/// -/// Generic over the record extension `E` (see [`RecordExt`]). -pub struct Consumer { - inner: moq_json::window::Consumer>, - timescale: Timescale, -} - -impl Consumer { - /// Subscribe to `track`'s timeline as advertised by the catalog's [`Archive`] entry. - /// - /// The section supplies both the timeline name and the timescale, so a reader can't pair the - /// wrong scale with the track. Errors if the section indexes no such track or declares a - /// timescale that isn't representable. - pub async fn subscribe( - broadcast: &moq_net::broadcast::Consumer, - section: &Archive, - track: &str, - ) -> crate::Result { - let timeline = section - .timelines - .get(track) - .ok_or_else(|| crate::Error::TimelineMissing(track.to_string()))?; - let timescale = - Timescale::new(section.timescale as u64).map_err(|_| crate::Error::InvalidTimescale(section.timescale))?; - let track = broadcast.track(&timeline.track)?.subscribe(None).await?; - let config = moq_json::window::ConsumerConfig::default().with_compression(true); - Ok(Self { - inner: moq_json::window::Consumer::new(track, config), - timescale, - }) - } - - fn decode_event(&self, event: moq_json::window::Event>) -> crate::Result> { - Ok(match event { - moq_json::window::Event::Push { index, value } => Event::Push { - index, - entry: Entry::decode(value, self.timescale)?, - }, - moq_json::window::Event::Pop(range) => Event::Pop(range), - moq_json::window::Event::Skip(range) => Event::Skip(range), - _ => unreachable!("unknown timeline window event"), - }) - } - - /// Get the next window event, or `None` once the track ends. - pub async fn next(&mut self) -> crate::Result>> { - match self.inner.next().await? { - Some(event) => Ok(Some(self.decode_event(event.value)?)), - None => Ok(None), - } - } - - /// Poll for the next window event, without blocking. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { - match self.inner.poll_next(waiter)? { - // Each record carries its own pts, so the frame timestamp adds nothing. - Poll::Ready(Some(event)) => Poll::Ready(self.decode_event(event.value).map(Some)), - Poll::Ready(None) => Poll::Ready(Ok(None)), - Poll::Pending => Poll::Pending, - } - } -} - -#[cfg(test)] -mod test { - use super::*; - - /// Sparse data: every group is its own record. - fn sparse() -> Config { - Config::default().with_duration_min(Duration::ZERO) - } - - /// Media whose encoder reports a 4s cadence, so its timeline declares before any record. - fn hinted() -> Config { - Config::default().with_duration_hint(Duration::from_secs(4)) - } - - fn ms(v: u64) -> Timestamp { - Timestamp::from_millis(v).unwrap() - } - - fn at(group: u64) -> Position { - Position::group(group) - } - - /// A record's `(pts, duration, start, end)` in wire units, for compact comparisons. - fn span(record: &Record) -> (u64, u64, Position, Position) { - (record.pts.unwrap(), record.duration.unwrap(), record.start, record.end) - } - - fn drain(segmenter: &mut Segmenter) -> Vec<(u64, u64, Position, Position)> { - let mut out = Vec::new(); - let mut sequence = None; - for record in segmenter.by_ref() { - if let Some(previous) = sequence { - assert_eq!(record.sequence, previous + 1, "records are numbered consecutively"); - } - sequence = Some(record.sequence); - out.push(span(&record)); - } - out - } - - /// One frame per group, one group every `step` ms. - fn groups(segmenter: &mut Segmenter, groups: std::ops::Range, step: u64) { - for group in groups { - segmenter.frame(at(group), ms(group * step), true).unwrap(); - segmenter.finish_group(group); - } - } - - #[test] - fn long_groups_get_a_record_each() { - let mut video = Segmenter::new(Config::default()); - groups(&mut video, 0..3, 2_000); - video.end(ms(6_000)); - video.close(); - - assert_eq!( - drain(&mut video), - vec![(0, 4_000, at(0), at(2)), (4_000, 2_000, at(2), at(3))] - ); - } - - #[test] - fn short_groups_pack_up_to_the_minimum() { - let mut audio = Segmenter::new(Config::default().with_duration_min(Duration::from_millis(1_500))); - groups(&mut audio, 0..8, 500); - audio.close(); - - assert_eq!( - drain(&mut audio), - vec![ - (0, 1_500, at(0), at(3)), - (1_500, 1_500, at(3), at(6)), - (3_000, 500, at(6), at(8)), - ] - ); - } - - #[test] - fn a_hint_declares_its_smallest_multiple_at_or_past_the_minimum() { - let segmenter = Segmenter::new(Config::default().with_duration_hint(Duration::from_millis(1_500))); - assert_eq!(segmenter.declared_duration(), Some(4_500)); - } - - /// A 60-frame GOP at 29.97fps lasts 2002.002ms. Rounding the hint before multiplying would aim - /// at 4006ms, past the second GOP's end, and pace three GOPs per record instead of two. - #[test] - fn a_fractional_hint_paces_on_its_own_boundaries() { - let gop = Duration::from_nanos(2_002_002_002); - let mut video = Segmenter::new(Config::default().with_duration_hint(gop)); - assert_eq!(video.declared_duration(), Some(4_005)); - for group in 0..5 { - let pts = Timestamp::from_micros((gop * group as u32).as_micros() as u64).unwrap(); - video.frame(at(group), pts, true).unwrap(); - video.finish_group(group); - } - - assert_eq!( - drain(&mut video), - vec![(0, 4_004, at(0), at(2)), (4_004, 4_004, at(2), at(4))] - ); - } - - #[test] - fn a_hint_remains_the_target_when_the_minimum_is_zero() { - let segmenter = Segmenter::new( - Config::default() - .with_duration_min(Duration::ZERO) - .with_duration_hint(Duration::from_secs(3)), - ); - assert_eq!(segmenter.declared_duration(), Some(3_000)); - } - - #[test] - fn packet_groups_estimate_a_record_instead_of_one_packet() { - let mut audio = Segmenter::new(Config::default()); - groups(&mut audio, 0..202, 20); - assert_eq!(audio.declared_duration(), Some(4_000)); - assert_eq!(audio.next().unwrap().duration, Some(4_000)); - } - - #[test] - fn an_early_first_record_declares_the_minimum() { - // A flushed tail is not a cadence: one frame would otherwise declare zero, as if sparse. - let mut flushed = Segmenter::new(Config::default()); - flushed.frame(at(0), ms(0), true).unwrap(); - flushed.flush(); - assert_eq!(flushed.next().unwrap().duration, Some(0)); - assert_eq!(flushed.declared_duration(), Some(4_000)); - - // A skipped sequence ends the record early too. - let mut skipped = Segmenter::new(Config::default()); - skipped.frame(at(0), ms(0), true).unwrap(); - skipped.frame(at(2), ms(9_000), true).unwrap(); - assert_eq!(skipped.next().unwrap().duration, Some(0)); - assert_eq!(skipped.declared_duration(), Some(4_000)); - - // Later paced records keep the declaration. - skipped.frame(at(3), ms(15_000), true).unwrap(); - assert_eq!(skipped.next().unwrap().duration, Some(6_000)); - assert_eq!(skipped.declared_duration(), Some(4_000)); - } - - #[test] - fn the_first_estimate_survives_later_longer_records() { - let mut video = Segmenter::new(Config::default()); - groups(&mut video, 0..3, 2_000); - assert_eq!(video.next().unwrap().duration, Some(4_000)); - assert_eq!(video.declared_duration(), Some(4_000)); - - groups(&mut video, 3..5, 6_000); - assert_eq!(video.next().unwrap().duration, Some(12_000)); - assert_eq!(video.declared_duration(), Some(4_000)); - } - - #[test] - fn a_long_group_splits_by_frame_at_the_safety_ceiling() { - let mut video = Segmenter::new( - Config::default() - .with_duration_min(Duration::from_secs(1)) - .with_duration_hint(Duration::from_secs(1)), - ); - for frame in 0..8 { - video - .frame(Position::new(0, frame), ms(frame * 1_000), frame == 0) - .unwrap(); - } - video.close(); - - let records: Vec = std::iter::from_fn(|| video.next()).collect(); - assert_eq!( - records.iter().map(span).collect::>(), - vec![ - (0, 3_000, Position::new(0, 0), Position::new(0, 3)), - (3_000, 3_000, Position::new(0, 3), Position::new(0, 6)), - (6_000, 1_000, Position::new(0, 6), Position::new(0, 8)), - ] - ); - assert!(records[0].keyframe); - assert!( - !records[1].keyframe, - "a split inside a group does not start on a keyframe" - ); - } - - #[test] - fn a_sparse_track_records_each_group_when_it_finishes() { - let mut catalog = Segmenter::new(sparse()); - catalog.frame(at(0), ms(0), true).unwrap(); - assert!(catalog.next().is_none(), "the group may still grow"); - catalog.finish_group(0); - assert_eq!(drain(&mut catalog), vec![(0, 0, at(0), at(1))]); - - catalog.frame(at(1), ms(60_000), true).unwrap(); - catalog.finish_group(1); - assert_eq!(drain(&mut catalog), vec![(60_000, 0, at(1), at(2))]); - } - - #[test] - fn an_append_only_group_is_indexed_as_it_grows() { - // A never-closing log: one frame a second, split every three seconds. - let mut log = Segmenter::new( - Config::default() - .with_duration_min(Duration::from_secs(1)) - .with_duration_hint(Duration::from_secs(1)), - ); - for frame in 0..7 { - log.frame(Position::new(0, frame), ms(frame * 1_000), true).unwrap(); - } - assert_eq!( - drain(&mut log), - vec![ - (0, 3_000, Position::new(0, 0), Position::new(0, 3)), - (3_000, 3_000, Position::new(0, 3), Position::new(0, 6)), - ] - ); - - // The tail ends after the newest frame, since the group never finished. - log.close(); - assert_eq!( - drain(&mut log), - vec![(6_000, 0, Position::new(0, 6), Position::new(0, 7))] - ); - } - - #[test] - fn a_skipped_sequence_closes_the_record() { - let mut audio = Segmenter::new(Config::default().with_duration_min(Duration::from_secs(1))); - audio.frame(at(0), ms(0), true).unwrap(); - audio.frame(at(1), ms(300), true).unwrap(); - audio.frame(at(5), ms(600), true).unwrap(); - audio.frame(at(6), ms(1_700), true).unwrap(); - audio.close(); - - assert_eq!( - drain(&mut audio), - vec![ - (0, 300, at(0), at(2)), - (600, 1_100, at(5), at(6)), - (1_700, 0, at(6), Position::new(6, 1)) - ] - ); - } - - #[test] - fn a_skipped_run_ends_at_the_previous_frontier() { - // A track paused for 40 minutes, resuming past a skipped sequence. - let mut data = Segmenter::new(Config::default()); - groups(&mut data, 0..2, 1_000); - data.end(ms(2_000)); - data.frame(at(3), ms(2_400_000), true).unwrap(); - data.close(); - - assert_eq!( - drain(&mut data), - vec![(0, 2_000, at(0), at(2)), (2_400_000, 0, at(3), Position::new(3, 1))], - "the pause is not absorbed into the record before it" - ); - } - - #[test] - fn an_unhinted_first_record_declares_the_default_ceiling_when_it_hits_it() { - let mut log = Segmenter::new(Config::default()); - log.frame(Position::new(0, 0), ms(0), true).unwrap(); - log.frame(Position::new(0, 1), ms(20_000), true).unwrap(); - - assert_eq!( - drain(&mut log), - vec![(0, 10_000, Position::new(0, 0), Position::new(0, 1))] - ); - assert_eq!(log.declared_duration(), Some(10_000)); - } - - #[test] - fn an_unhinted_ceiling_never_undercuts_a_longer_minimum() { - let mut log = Segmenter::new(Config::default().with_duration_min(Duration::from_secs(12))); - log.frame(Position::new(0, 0), ms(0), true).unwrap(); - log.frame(Position::new(0, 1), ms(11_000), true).unwrap(); - log.frame(Position::new(0, 2), ms(20_000), true).unwrap(); - - assert_eq!( - drain(&mut log), - vec![(0, 12_000, Position::new(0, 0), Position::new(0, 2))] - ); - assert_eq!(log.declared_duration(), Some(12_000)); - } - - #[test] - fn explicit_cuts_override_the_pacing() { - let mut video = Segmenter::new(Config::default()); - // Keyframes every second, cut every three: the records follow the cuts, not the GOPs. - video.cut(ms(3_000)); - video.cut(ms(3_000)); - video.cut(ms(6_000)); - groups(&mut video, 0..7, 1_000); - - assert_eq!( - drain(&mut video), - vec![(0, 3_000, at(0), at(3)), (3_000, 3_000, at(3), at(6))] - ); - } - - #[test] - fn a_cut_on_the_first_group_does_not_poison_later_cuts() { - let mut video = Segmenter::new(Config::default()); - video.cut(ms(0)); - for group in 0..10u64 { - if group % 3 == 0 { - video.cut(ms(group * 1_000)); - } - video.frame(at(group), ms(group * 1_000), true).unwrap(); - } - - assert_eq!(drain(&mut video)[0], (0, 3_000, at(0), at(3))); - } - - #[test] - fn a_non_keyframe_start_is_flagged() { - let mut video = Segmenter::new(Config::default()); - video.frame(at(0), ms(0), true).unwrap(); - // A mid-stream join: the group doesn't open on an IDR. - video.frame(at(1), ms(4_000), false).unwrap(); - video.frame(at(2), ms(8_000), true).unwrap(); - - let records: Vec = std::iter::from_fn(|| video.next()).collect(); - assert!(records[0].keyframe); - assert!(!records[1].keyframe); - } - - #[test] - fn a_report_that_does_not_advance_is_refused() { - let mut video = Segmenter::new(Config::default()); - video.frame(at(3), ms(0), true).unwrap(); - for position in [at(2), at(3)] { - assert!(matches!( - video.frame(position, ms(2_000), true), - Err(crate::Error::TimelinePosition { last, .. }) if last == at(3) - )); - } - video.frame(at(4), ms(4_000), true).unwrap(); - assert_eq!(drain(&mut video), vec![(0, 4_000, at(3), at(4))]); - } - - #[test] - fn a_resumed_segmenter_continues_the_numbering() { - let mut video = Segmenter::new(Config::default()).with_sequence(7); - groups(&mut video, 10..13, 2_000); - assert_eq!(video.next().unwrap().sequence, 7); - } - - #[test] - fn an_untimed_track_records_each_group_without_timing() { - // The minimum and hint measure time, which an untimed track has none of. - let mut data = Segmenter::new(Config::default().with_duration_hint(Duration::from_secs(1))); - data.frame(Position::new(0, 0), None, true).unwrap(); - data.frame(Position::new(0, 1), None, false).unwrap(); - assert!(data.next().is_none(), "nothing splits an untimed group"); - data.finish_group(0); - // A group that starts before the previous one finished ends it, as does a skipped sequence. - data.frame(at(1), None, true).unwrap(); - data.frame(at(3), None, true).unwrap(); - data.frame(Position::new(3, 1), None, false).unwrap(); - data.close(); - - let records: Vec = std::iter::from_fn(|| data.next()).collect(); - assert_eq!( - records, - vec![ - Record::untimed(0, at(0), at(1)), - Record::untimed(1, at(1), at(2)), - Record::untimed(2, at(3), Position::new(3, 2)), - ] - ); - assert_eq!(data.declared_duration(), None, "an untimed track declares no duration"); - } - - #[test] - fn a_sparse_untimed_track_declares_no_duration() { - let mut data = Segmenter::new(Config::default().with_duration_min(Duration::ZERO)); - assert_eq!(data.declared_duration(), Some(0), "unknown timing defaults to sparse"); - data.frame(at(0), None, true).unwrap(); - data.finish_group(0); - assert_eq!(data.next(), Some(Record::untimed(0, at(0), at(1)))); - assert_eq!(data.declared_duration(), None); - } - - #[test] - fn an_untimed_track_keeps_no_cuts() { - let mut data = Segmenter::new(Config::default()); - data.cut(ms(0)); - data.frame(at(0), None, true).unwrap(); - assert!(data.cuts.is_empty(), "a cut before the first report is dropped"); - for i in 1..100 { - data.cut(ms(i * 1_000)); - } - assert!(data.cuts.is_empty()); - } - - #[test] - fn a_track_is_all_timed_or_all_untimed() { - let mut timed = Segmenter::new(Config::default()); - timed.frame(at(0), ms(0), true).unwrap(); - assert!(matches!( - timed.frame(at(1), None, true), - Err(crate::Error::TimelineTiming(position)) if position == at(1) - )); - - let mut untimed = Segmenter::new(Config::default()); - untimed.frame(at(0), None, true).unwrap(); - assert!(matches!( - untimed.frame(at(1), ms(0), true), - Err(crate::Error::TimelineTiming(_)) - )); - } - - #[test] - fn an_untimed_record_has_no_entry() { - let record = Record::<()>::untimed(3, at(0), at(1)); - assert!(matches!( - Entry::decode(record, Timescale::MILLI), - Err(crate::Error::TimelineUntimed(3)) - )); - let mut half = Record::<()>::new(4, 0, 1_000, at(0), at(1)); - half.duration = None; - assert!(matches!( - Entry::decode(half, Timescale::MILLI), - Err(crate::Error::TimelineUntimed(4)) - )); - } - - /// Drain a finished timeline track. - async fn read(broadcast: &moq_net::broadcast::Producer, section: &Archive, track: &str) -> Vec { - let mut consumer = Consumer::<()>::subscribe(&broadcast.consume(), section, track) - .await - .unwrap(); - let waiter = kio::Waiter::noop(); - let mut out = Vec::new(); - while let Poll::Ready(Ok(Some(event))) = consumer.poll_next(&waiter) { - if let Event::Push { index, entry } = event { - assert_eq!(index, entry.sequence); - out.push(entry); - } - } - out - } - - #[tokio::test] - async fn tracks_publish_independent_timelines() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - let mut video = timelines.track("video0", hinted()).unwrap(); - let mut catalog = timelines.track("catalog.json", sparse()).unwrap(); - - catalog.frame(at(0), ms(0), true); - catalog.finish_group(0); - for group in 0..3 { - video.frame(at(group), ms(group * 2_000), true); - video.finish_group(group); - } - video.end(ms(6_000)); - drop(video); - timelines.finish(); - - let section = timelines.section(); - assert_eq!(section.timelines["video0"], Timeline::new("video0.timeline.z", 4_000)); - assert_eq!( - section.timelines["catalog.json"], - Timeline::new("catalog.json.timeline.z", 0) - ); - - let video = read(&broadcast, §ion, "video0").await; - assert_eq!(video.len(), 2); - assert_eq!(video[1].end, at(3), "the terminal flush ends after the finished group"); - let catalog = read(&broadcast, §ion, "catalog.json").await; - assert_eq!(catalog.len(), 1, "the catalog does not wait for the video"); - } - - #[test] - fn an_empty_enrollment_does_not_mask_a_later_hint() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - drop(timelines.track("video", Config::default()).unwrap()); - let _video = timelines - .track("video", Config::default().with_duration_hint(Duration::from_secs(6))) - .unwrap(); - assert_eq!(timelines.section().timelines["video"].duration, Some(6_000)); - } - - #[test] - fn an_unhinted_timeline_waits_for_its_first_record() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - let mut video = timelines.track("video", Config::default()).unwrap(); - assert!(!timelines.section().timelines.contains_key("video")); - - video.frame(at(0), ms(0), true); - video.finish_group(0); - video.frame(at(1), ms(4_000), true); - assert_eq!(timelines.section().timelines["video"].duration, Some(4_000)); - } - - #[tokio::test] - async fn a_sparse_record_publishes_before_the_track_ends() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - let mut catalog = timelines.track("catalog.json", sparse()).unwrap(); - catalog.frame(at(0), ms(0), true); - catalog.finish_group(0); - - assert_eq!(read(&broadcast, &timelines.section(), "catalog.json").await.len(), 1); - drop(catalog); - } - - #[tokio::test] - async fn a_rejected_report_fails_the_timeline() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - let mut video = timelines.track("video0", hinted()).unwrap(); - let mut consumer = Consumer::<()>::subscribe(&broadcast.consume(), &timelines.section(), "video0") - .await - .unwrap(); - - video.frame(at(1), ms(0), true); - video.frame(at(0), ms(2_000), true); - loop { - match consumer.next().await { - Ok(Some(_)) => continue, - Ok(None) => panic!("the timeline ended cleanly despite omitting a frame"), - Err(_) => break, - } - } - } - - #[tokio::test] - async fn re_enrolling_continues_the_timeline() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - let mut first = timelines.track("video0", Config::default()).unwrap(); - first.frame(at(0), ms(0), true); - drop(first); - let mut second = timelines.track("video0", Config::default()).unwrap(); - // The new producer restarts its group sequences. - second.frame(at(0), ms(2_000), true); - drop(second); - timelines.finish(); - - let entries = read(&broadcast, &timelines.section(), "video0").await; - assert_eq!( - entries.iter().map(|entry| entry.sequence).collect::>(), - vec![0, 1] - ); - } - - /// Two live recorders would share one segmenter, each resetting or closing the other's - /// timeline, so a name is enrolled once at a time. - #[tokio::test] - async fn enrolling_an_active_track_is_refused() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - let mut first = timelines.track("video0", Config::default()).unwrap(); - assert!(timelines.track("video0", Config::default()).is_err()); - - // The live recorder is untouched by the refusal. - first.frame(at(0), ms(0), true); - first.frame(at(1), ms(2_000), true); - drop(first); - assert!(timelines.track("video0", Config::default()).is_ok()); - } - - #[test] - fn a_taken_timeline_name_fails_enrollment() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let _squat = broadcast.create_track("video0.timeline.z", None).unwrap(); - let timelines = Timelines::new(&broadcast); - assert!(timelines.track("video0", Config::default()).is_err()); - assert!(timelines.section().timelines.is_empty()); - } - - #[test] - fn enrollment_after_finish_is_refused() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - timelines.finish(); - assert!(matches!( - timelines.track("video0", Config::default()), - Err(crate::Error::Moq(moq_net::Error::Closed)) - )); - } - - #[tokio::test] - async fn a_missing_timeline_is_an_error() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let section = Archive::new(); - assert!(matches!( - Consumer::<()>::subscribe(&broadcast.consume(), §ion, "video0").await, - Err(crate::Error::TimelineMissing(_)) - )); - } - - #[tokio::test] - async fn rejects_an_invalid_timescale() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let timelines = Timelines::new(&broadcast); - let _recorder = timelines.track("video0", hinted()).unwrap(); - let mut section = timelines.section(); - section.timescale = 0; - let err = Consumer::<()>::subscribe(&broadcast.consume(), §ion, "video0").await; - assert!(matches!(err, Err(crate::Error::InvalidTimescale(0)))); - } -} diff --git a/rs/moq-rtc/src/codec/vp8.rs b/rs/moq-rtc/src/codec/vp8.rs index 6cac22b59f1..fa2918ced9c 100644 --- a/rs/moq-rtc/src/codec/vp8.rs +++ b/rs/moq-rtc/src/codec/vp8.rs @@ -65,10 +65,9 @@ mod tests { #[tokio::test] async fn importer_creation_failure_preserves_abort_error() { let mut broadcast = moq_net::broadcast::Info::new().produce(); - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - let _collision = broadcast - .create_track(hang::timeline::default_name(hang::Catalog::DEFAULT_NAME), None) - .unwrap(); + let config = moq_mux::catalog::Config::default().with_hls(moq_mux::hls::Config::default()); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config).unwrap(); + let _collision = broadcast.create_track(hang::hls::default_name("0.vp8"), None).unwrap(); let consumer = broadcast.consume(); let mut bridge = super::Bridge::new(broadcast, catalog).unwrap(); let mut track = consumer.track("0.vp8").unwrap().subscribe(None).await.unwrap(); @@ -77,7 +76,7 @@ mod tests { timestamp_us: 0, payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]), }); - assert!(result.is_err(), "timeline collision must fail importer creation"); + assert!(result.is_err(), "an HLS record track collision must fail importer creation"); Box::new(bridge).abort(moq_net::Error::Transport("session failed".into())); let Err(error) = track.recv_group().await else { diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index e1dea38670f..8a65e131b81 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -359,11 +359,8 @@ pub(crate) fn populate( ) -> Result<(), Error> { out.video = Video::default(); out.audio = hang::catalog::Audio::default(); - // A derivative does not synthesize its own archive: keep the child's, including a - // live-only timeline, so replay/store discovery survives composition. The clock goes with - // it: the derivative republishes the child's timeline, so its timestamps only mean - // something under the child's wall mapping. - out.archive = source.archive.clone(); + // The derivative republishes the child's timeline, so its timestamps only mean something under + // the child's wall mapping. out.clock = source.clock; // Display metadata applies to the rungs too (same picture, smaller). @@ -990,26 +987,17 @@ mod tests { } #[test] - fn populate_preserves_the_child_archive() { + fn populate_preserves_the_child_clock() { let mut child = moq_mux::catalog::hang::Catalog::<()>::default(); child .video .insert("video", source(1920, 1080, Some(6_000_000))) .unwrap(); - let mut archive = hang::catalog::Archive::new(); - archive.timelines.insert( - "video".to_string(), - hang::catalog::Timeline::new("video.timeline.z", 4_000), - ); - archive.replay = Some(RelativeOwned::from("./recordings/clip".to_string())); - archive.version = Some(hang::catalog::Archive::VERSION); - child.archive = Some(archive.clone()); let clock = hang::catalog::Clock::new(moq_net::Timestamp::from_micros(1_751_846_400_000_000).unwrap()).unwrap(); child.clock = Some(clock); let mut out = moq_mux::catalog::hang::Catalog::<()>::default(); populate(&mut out, &child, &[], None).unwrap(); - assert_eq!(out.archive, Some(archive), "a derivative keeps the child's archive"); assert_eq!(out.clock, Some(clock), "a derivative keeps the child's clock"); } diff --git a/rs/moq-video/src/capture/wgc.rs b/rs/moq-video/src/capture/wgc.rs index 88166968f58..306f00fe7de 100644 --- a/rs/moq-video/src/capture/wgc.rs +++ b/rs/moq-video/src/capture/wgc.rs @@ -320,7 +320,7 @@ mod tests { let mut subscriber = moq_mux::container::Consumer::new(subscriber, container); let reserved = catalog .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)); + .with_hls_hint(config.gop.duration(config.framerate)); let mut producer = Producer::with_track(track, reserved, rendition).unwrap(); assert!(producer.demand().wanted().is_some()); diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index 8664658f9f2..8acb7af0f13 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -473,7 +473,7 @@ mod tests { let rendition = config.probe().await.unwrap(); let reserved = source_catalog .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)); + .with_hls_hint(config.gop.duration(config.framerate)); let mut producer = EncodeProducer::new(source_broadcast, reserved, rendition).unwrap(); let mut encoder = Encoder::new(&config).unwrap(); let rgba = vec![0x80u8; 320 * 240 * 4]; diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index 2610d823ea6..e874a438983 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -103,7 +103,7 @@ impl Producer { /// Publish a track carrying `rendition` into `broadcast`, registering it under /// `reserved`, a [`catalog::Producer::reserve`](moq_mux::catalog::Producer::reserve) /// that carries any per-track hint such as the encoder's GOP duration - /// ([`Reserved::with_timeline_hint`](moq_mux::catalog::Reserved::with_timeline_hint)). + /// ([`Reserved::with_hls_hint`](moq_mux::catalog::Reserved::with_hls_hint)). /// The frames fed to [`publish`](Self::publish) must be in that /// codec's framing, which is what the [`Encoder`](super::Encoder) the /// rendition was probed from emits. @@ -479,7 +479,7 @@ impl Driver { } }; - let mut producer = Producer::with_track(track, catalog.reserve().with_timeline_hint(gop), rendition)?; + let mut producer = Producer::with_track(track, catalog.reserve().with_hls_hint(gop), rendition)?; let demand = producer.demand(); let result = tokio::select! { @@ -867,7 +867,7 @@ mod tests { broadcast, catalog .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)), + .with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -940,7 +940,7 @@ mod tests { track, catalog .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)), + .with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -972,7 +972,7 @@ mod tests { broadcast, catalog .reserve() - .with_timeline_hint(initial.gop.duration(initial.framerate)), + .with_hls_hint(initial.gop.duration(initial.framerate)), initial.probe().await.unwrap(), ) .unwrap(); @@ -1045,7 +1045,7 @@ mod tests { broadcast, catalog .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)), + .with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -1093,7 +1093,7 @@ mod tests { track, catalog .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)), + .with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -1188,7 +1188,8 @@ mod tests { let consumer = broadcast.consume(); let config = moq_mux::catalog::Config::default() .with_clock(clock) - .with_max_age(RETAIN); + .with_max_age(RETAIN) + .with_hls(moq_mux::hls::Config::default()); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config).unwrap(); let track = broadcast .create_track( @@ -1203,7 +1204,7 @@ mod tests { track, catalog .reserve() - .with_timeline_hint(probe.gop.duration(probe.framerate)), + .with_hls_hint(probe.gop.duration(probe.framerate)), probe.probe().await.unwrap(), ) .unwrap(); @@ -1458,22 +1459,19 @@ mod tests { .await } - /// A recording replays what the live edge published: the archive's records - /// carry the live timestamps across an idle restart, with the idle gap left in. + /// HLS lists what the live edge published: the records carry the live timestamps across an + /// idle restart, with the idle gap left out of every segment. #[tokio::test] - async fn retained_archive_playback_keeps_the_live_timestamps() { + async fn hls_records_keep_the_live_timestamps() { tokio::task::LocalSet::new() .run_until(async { let fixture = Fixture::start(Duration::from_secs(1), moq_clock::wall()).await; - let section = fixture - .catalog - .snapshot() - .archive - .expect("the video track enrolls an archive"); - let mut timeline = - moq_mux::timeline::Consumer::<()>::subscribe(&fixture.consumer, §ion, "video") - .await - .unwrap(); + // The encoder's GOP hint declares the duration up front. + let hls = fixture.catalog.snapshot().video.renditions["video"] + .hls + .clone() + .expect("the video track advertises its HLS records"); + let mut records = moq_mux::hls::Consumer::subscribe(&fixture.consumer, &hls).await.unwrap(); let mut live = Vec::new(); for _ in 0..2 { @@ -1485,29 +1483,30 @@ mod tests { fixture.assert_acquired(published, captured); live.push(published); drop(track); - // Idle past the minimum segment, so each run is archived as its own record. - tokio::time::sleep(moq_mux::timeline::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; + // Idle past the minimum segment, so each run is listed as its own segment. + tokio::time::sleep(moq_mux::hls::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; } - let (catalog, _consumer) = fixture.finish().await; - catalog.timeline().finish(); - let mut archived = Vec::new(); - while let Some(event) = timeline.next().await.unwrap() { + let (mut catalog, _consumer) = fixture.finish().await; + catalog.finish().unwrap(); + let mut listed = Vec::new(); + while let Some(event) = records.next().await.unwrap() { match event { - moq_mux::timeline::Event::Push { entry, .. } => archived.push(entry), - other => panic!("unexpected timeline event {other:?}"), + moq_mux::hls::Event::Push { entry, .. } => listed.push(entry), + other => panic!("unexpected record event {other:?}"), } } - assert_eq!(archived.len(), live.len(), "one record per capture run: {archived:?}"); - for (entry, live) in archived.iter().zip(&live) { - // The archive keeps millisecond precision. - assert_eq!(entry.pts.as_micros() / 1000, u128::from(*live / 1000), "{archived:?}"); + // Each run is one segment, stopped where its content reached. + assert_eq!(listed.len(), 2 * live.len(), "a segment and a stop per run: {listed:?}"); + for (pair, live) in listed.chunks(2).zip(&live) { + assert!(!pair[0].is_stop() && pair[1].is_stop(), "{listed:?}"); + // The records keep millisecond precision. + assert_eq!(pair[0].pts.as_micros() / 1000, u128::from(*live / 1000), "{listed:?}"); } - let first = &archived[0]; assert!( - archived[1].pts.as_micros() >= first.pts.as_micros() + first.duration.as_micros(), - "the resumed record overlaps the one before it: {archived:?}" + listed[2].pts.as_micros() >= listed[1].pts.as_micros(), + "the resumed segment overlaps the one before it: {listed:?}" ); }) .await @@ -1847,7 +1846,7 @@ mod tests { track, catalog .reserve() - .with_timeline_hint(rendition.gop.duration(rendition.framerate)), + .with_hls_hint(rendition.gop.duration(rendition.framerate)), rendition.probe().await.unwrap(), ) .unwrap(); From 25c8a34e083dd8c76625bd14ec708cbab3df2641 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 03:52:08 -0700 Subject: [PATCH 03/20] wip(hls)!: export renditions from their own hls records Co-Authored-By: Claude Opus 5.5 --- rs/hang/src/catalog/hls.rs | 5 +- rs/moq-hls/src/error.rs | 11 +- rs/moq-hls/src/export/archive_tests.rs | 232 +- rs/moq-hls/src/export/mod.rs | 3639 ++++++------------------ rs/moq-hls/src/export/mpd.rs | 105 +- rs/moq-hls/src/export/playlist.rs | 6 +- rs/moq-hls/src/export/rendition.rs | 818 +++--- rs/moq-hls/src/export/renditions.rs | 578 +--- rs/moq-hls/src/export/route.rs | 4 +- rs/moq-hls/src/export/segments.rs | 557 ++-- rs/moq-hls/src/export/spans.rs | 432 --- rs/moq-hls/src/lib.rs | 4 +- rs/moq-hls/src/server/mod.rs | 8 +- rs/moq-hls/src/server/routes.rs | 99 +- rs/moq-mux/src/catalog/producer.rs | 11 +- rs/moq-mux/src/hls.rs | 10 +- 16 files changed, 1894 insertions(+), 4625 deletions(-) delete mode 100644 rs/moq-hls/src/export/spans.rs diff --git a/rs/hang/src/catalog/hls.rs b/rs/hang/src/catalog/hls.rs index 507c6767a88..6387445bbb8 100644 --- a/rs/hang/src/catalog/hls.rs +++ b/rs/hang/src/catalog/hls.rs @@ -15,8 +15,9 @@ use super::millis::MillisCeil; #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct Hls { - /// The track carrying the rendition's segment records, conventionally the media track's name - /// plus [`SUFFIX`](crate::hls::SUFFIX). + /// The track carrying the rendition's segment records, on the catalog's broadcast even when the + /// media lives in a sibling. Conventionally the media track's name plus + /// [`SUFFIX`](crate::hls::SUFFIX). pub track: String, /// The nominal segment duration, fixed for the life of the track. Serialized as whole diff --git a/rs/moq-hls/src/error.rs b/rs/moq-hls/src/error.rs index 062fe0e106f..6bef7f330a4 100644 --- a/rs/moq-hls/src/error.rs +++ b/rs/moq-hls/src/error.rs @@ -119,12 +119,13 @@ pub enum Error { #[error("io: {0}")] Io(std::sync::Arc), - /// An export timeline failed, so its listings refuse rather than read as a finished recording. - #[error("timeline {track} failed: {source}")] - TimelineFailed { - /// The timeline's track name. + /// A rendition's HLS records failed, so its listings refuse rather than read as a finished + /// recording. + #[error("hls records {track} failed: {source}")] + RecordsFailed { + /// The record track's name. track: String, - /// Why the timeline failed. + /// Why the records failed. source: Box, }, diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index 3ddd9a405ef..7f5e14adf6a 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -1,9 +1,9 @@ //! Serving a `moq-archive` recording as HLS. //! -//! A source broadcast, its catalog and timelines included, is recorded by an +//! A source broadcast, its catalog and HLS records included, is recorded by an //! [`moq_archive::Producer`] and served back by an [`moq_archive::Consumer`], so the exporter //! serves the archived catalog exactly like a live broadcast. These tests pin the storage traffic -//! that composition produces: playlists read only the timelines, and a segment GETs only objects +//! that composition produces: playlists read only the records, and a segment GETs only objects //! of the requested rendition. use std::collections::HashMap; @@ -13,7 +13,7 @@ use std::time::Duration; use bytes::{Bytes, BytesMut}; use futures::stream::BoxStream; -use hang::timeline::{Position, Record}; +use hang::hls::Record; use moq_archive::Store; use moq_archive::object_store::memory::InMemory; use moq_archive::object_store::path::Path; @@ -21,7 +21,6 @@ use moq_archive::object_store::{ self, CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, }; -use moq_json::window; use moq_net::{Timescale, Timestamp, group, track}; use super::*; @@ -102,16 +101,12 @@ impl ObjectStore for Counting { } } -/// One rendition of the source: its media track, and its timeline track with the window encoder -/// that writes it. +/// One rendition of the source: its media track, and the records that segment it. struct Source { media: track::Producer, - timeline: track::Producer, - encoder: window::Encoder, - /// The timeline group still open, which a pop may extend. - group: Option, - /// Next timeline group sequence. - next: u64, + records: moq_mux::hls::Producer, + /// The record track, to finish its retained group with the recording. + track: track::Producer, /// Next record sequence. record: u64, } @@ -149,15 +144,13 @@ impl Recording { .with_timescale(Timescale::new(1_000_000).unwrap()) .with_priority(1); let media = broadcast.create_track(name, media).unwrap(); - let timeline = track::Info::default().with_timescale(Timescale::MILLI); - let timeline = broadcast.create_track(timeline_name(name), timeline).unwrap(); - let config = window::ProducerConfig::default().with_compression(true).with_ops(false); + let track = broadcast + .create_track(hang::hls::default_name(name), moq_mux::hls::Producer::info()) + .unwrap(); let source = Source { media, - timeline, - encoder: window::Encoder::new(config), - group: None, - next: 0, + records: moq_mux::hls::Producer::new(track.clone()), + track, record: 0, }; sources.insert(name.to_string(), source); @@ -170,7 +163,7 @@ impl Recording { let recorder = tokio::spawn(moq_archive::Producer::new(store.clone(), broadcast.consume(), config).run()); // Subscribed before anything is published, so every group is recorded live. for source in sources.values() { - while source.media.subscription().is_none() || source.timeline.subscription().is_none() { + while source.media.subscription().is_none() || source.track.subscription().is_none() { tokio::time::sleep(Duration::from_millis(1)).await; } } @@ -184,9 +177,9 @@ impl Recording { } } - /// Publish `track`'s next record: `groups`, each a list of frame timestamps in micros, - /// spanning `pts..pts + duration` in milliseconds, popping `pop` older records. - fn add(&mut self, track: &str, pts: u64, duration: u64, groups: &[(u64, &[u64])], pop: u64) { + /// Publish `track`'s next segment from `pts` milliseconds: `groups`, each a list of frame + /// timestamps in micros. + fn add(&mut self, track: &str, pts: u64, groups: &[(u64, &[u64])]) { let source = self.sources.get_mut(track).unwrap(); for &(sequence, frames) in groups { let mut group = source.media.create_group(group::Info { sequence }).unwrap(); @@ -196,41 +189,20 @@ impl Recording { } group.finish().unwrap(); } + let first = groups.first().unwrap().0; + self.push(track, Record::new(0, first, pts)); + } + + /// Record that `track`'s content stopped at `pts` milliseconds, before group `group`. + fn stop(&mut self, track: &str, group: u64, pts: u64) { + self.push(track, Record::new(0, group, pts).with_duration(0)); + } - let sequence = source.record; + fn push(&mut self, track: &str, mut record: Record) { + let source = self.sources.get_mut(track).unwrap(); + record.sequence = source.record; source.record += 1; - let first = groups.first().unwrap().0; - let last = groups.last().unwrap().0; - let record = Record::new( - sequence, - pts, - duration, - Position::group(first), - Position::group(last + 1), - ); - let mut payloads = Vec::new(); - let pending = source.encoder.push(&record).unwrap(); - payloads.push((pending.keyframe, pending.payload.clone())); - pending.commit(); - if let Some(pending) = source.encoder.pop(pop).unwrap() { - payloads.push((pending.keyframe, pending.payload.clone())); - pending.commit(); - } - for (keyframe, payload) in payloads { - if keyframe || source.group.is_none() { - if let Some(mut group) = source.group.take() { - group.finish().unwrap(); - } - let group = source - .timeline - .create_group(group::Info { sequence: source.next }) - .unwrap(); - source.group = Some(group); - source.next += 1; - } - let timestamp = Timestamp::from_millis(pts).unwrap(); - source.group.as_mut().unwrap().write_frame(timestamp, payload).unwrap(); - } + source.records.push(&record).unwrap(); } /// Wait until the recorder subscribed to every track and stored what was published. @@ -242,11 +214,8 @@ impl Recording { async fn finish(mut self) -> Store { self.catalog.finish().unwrap(); for source in self.sources.values_mut() { - if let Some(mut group) = source.group.take() { - group.finish().unwrap(); - } source.media.finish().unwrap(); - source.timeline.finish().unwrap(); + source.records.finish().unwrap(); } self.stored().await; self.broadcast.close(); @@ -260,8 +229,9 @@ impl Recording { } } -fn timeline_name(track: &str) -> String { - hang::timeline::default_name(track) +/// The URL tag of `track`'s segments. +fn tag(track: &str) -> String { + segments::tag(&hang::hls::default_name(track)) } /// One Legacy frame: a VP8 keyframe for video (geometry the muxer can parse), filler otherwise. @@ -280,25 +250,20 @@ fn legacy(track: &str, micros: u64, keyframe: bool) -> Bytes { encoded.freeze() } -/// The source's catalog: every rendition's config, plus an `archive` entry naming each -/// rendition's timeline. Out-of-band configs, so no init needs media. +/// The source's catalog: every rendition's config, each naming its HLS records. Out-of-band +/// configs, so no init needs media. fn catalog() -> hang::Catalog { - let mut archive = hang::catalog::Archive::new(); - for track in ["360p", "1080p", "audio"] { - archive.timelines.insert( - track.to_string(), - hang::catalog::Timeline::new(timeline_name(track), 4_000), - ); - } + let hls = |track: &str| hang::catalog::Hls::new(hang::hls::default_name(track), Duration::from_secs(2)); let mut catalog = hang::Catalog::default(); - catalog.archive = Some(archive); for (name, width, height) in [("360p", 640, 360), ("1080p", 1920, 1080)] { let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); config.coded_width = Some(width); config.coded_height = Some(height); + config.hls = Some(hls(name)); catalog.video.renditions.insert(name.to_string(), config); } - let audio = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); + let mut audio = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); + audio.hls = Some(hls("audio")); catalog.audio.renditions.insert("audio".to_string(), audio); catalog } @@ -372,38 +337,40 @@ impl Drop for Replay { } } -/// A stored media object: neither a timeline, the catalog, nor a marker. +/// The record tracks' suffix as the store escapes it in a path. +const HLS: &str = "%2Ehls"; + +/// A stored media object: neither records, the catalog, nor a marker. fn is_media(path: &str) -> bool { let name = path.rsplit('/').next().unwrap_or_default(); let marker = [".info", "end", ".end"].contains(&name); - !marker && !path.contains("timeline") && !path.contains("catalog") + !marker && !path.contains(HLS) && !path.contains("catalog") } -/// `count` 2s segments: one keyframe group per video record, four audio groups per audio record. -/// The first video rendition by name, 1080p, is the reference. +/// `count` 2s segments: one keyframe group per video segment, four audio groups per audio one. async fn segments(count: u64) -> Recording { let mut recording = Recording::new().await; for segment in 0..count { - add_segment(&mut recording, segment, 0); + add_segment(&mut recording, segment); } recording } -fn add_segment(recording: &mut Recording, segment: u64, pop: u64) { +fn add_segment(recording: &mut Recording, segment: u64) { let pts = segment * 2_000_000; for video in ["360p", "1080p"] { - recording.add(video, segment * 2000, 2000, &[(segment, &[pts, pts + 1_000_000])], pop); + recording.add(video, segment * 2000, &[(segment, &[pts, pts + 1_000_000])]); } let audio: Vec<(u64, [u64; 1])> = (0..4).map(|i| (segment * 4 + i, [pts + i * 500_000])).collect(); let audio: Vec<(u64, &[u64])> = audio .iter() .map(|(sequence, frames)| (*sequence, &frames[..])) .collect(); - recording.add("audio", segment * 2000, 2000, &audio, pop); + recording.add("audio", segment * 2000, &audio); } #[tokio::test(start_paused = true)] -async fn playlists_read_only_timelines_and_segments_their_own_objects() { +async fn playlists_read_only_records_and_segments_their_own_objects() { let recording = segments(3).await; recording.stored().await; let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; @@ -412,19 +379,15 @@ async fn playlists_read_only_timelines_and_segments_their_own_objects() { assert!(master.contains("video/360p/media.m3u8") && master.contains("video/1080p/media.m3u8")); // Render and reload every playlist with media GETs refused: shared numbering, and not one - // media GET. The reference lists every segment; the others hold back the newest until their - // own timeline passes its end, or ends. + // media GET. The newest segment waits for the record after it. recording.counting.reject_media(true); for _ in 0..2 { - for (kind, name, listed) in [ - (Kind::Video, "1080p", 3), - (Kind::Video, "360p", 2), - (Kind::Audio, "audio", 3), - ] { + for (kind, name) in [(Kind::Video, "1080p"), (Kind::Video, "360p"), (Kind::Audio, "audio")] { let playlist = replay.playlist(kind, name).await; + let tag = tag(name); for segment in 0..3 { - let line = format!("seg/29b1cd67.{segment}.m4s\n"); - assert_eq!(playlist.contains(&line), segment < listed, "{name}: {playlist}"); + let line = format!("seg/{tag}.{segment}.m4s\n"); + assert_eq!(playlist.contains(&line), segment < 2, "{name}: {playlist}"); } assert!(!playlist.contains("#EXT-X-ENDLIST"), "the recording is still growing"); } @@ -436,13 +399,12 @@ async fn playlists_read_only_timelines_and_segments_their_own_objects() { "track properties bind before media: {gets:?}" ); } - assert!(gets.iter().any(|path| path.contains("timeline")), "{gets:?}"); + assert!(gets.iter().any(|path| path.contains(HLS)), "{gets:?}"); recording.counting.reject_media(false); // Switching renditions downloads only the selected rendition's objects. for (kind, name, segment) in [ (Kind::Video, "360p", 1), - // Not the newest: a growing archive keeps its newest group open, as a live track does. (Kind::Video, "1080p", 1), // An audio segment spans four groups. (Kind::Audio, "audio", 1), @@ -494,35 +456,48 @@ async fn a_bounded_cache_rereads_evicted_objects() { } #[tokio::test(start_paused = true)] -async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities() { +async fn stopped_and_misaligned_segments_are_gaps_and_a_resume_is_a_discontinuity() { let mut recording = Recording::new().await; - recording.add("1080p", 0, 2000, &[(0, &[0])], 0); - recording.add("360p", 0, 8000, &[(0, &[0])], 0); - recording.add("audio", 0, 2000, &[(0, &[0])], 0); - // 360p stored nothing for segment 1. - recording.add("1080p", 2000, 2000, &[(1, &[2_000_000])], 0); - // Content time jumps from 4s to 10s. - recording.add("1080p", 10_000, 2000, &[(2, &[10_000_000])], 0); - recording.add("360p", 10_000, 2000, &[(1, &[10_000_000])], 0); - // The completed archive resolves each rendition's newest segment. + // 1080p, the lead: two segments, a stop, then content resumes at 10s. + recording.add("1080p", 0, &[(0, &[0])]); + recording.add("1080p", 2000, &[(1, &[2_000_000])]); + recording.stop("1080p", 2, 4000); + recording.add("1080p", 10_000, &[(2, &[10_000_000])]); + recording.stop("1080p", 3, 12_000); + // 360p starts its segment 1 a second and a half late, so it does not line up. + recording.add("360p", 0, &[(0, &[0])]); + recording.add("360p", 3500, &[(1, &[3_500_000])]); + recording.stop("360p", 2, 4000); + recording.add("360p", 10_000, &[(2, &[10_000_000])]); + recording.stop("360p", 3, 12_000); + recording.add("audio", 0, &[(0, &[0])]); + recording.stop("audio", 1, 2000); let counting = recording.counting.clone(); let store = recording.finish().await; let replay = Replay::open(&store, 64 * 1024 * 1024).await; - let low = replay + replay .playlist_until(Kind::Video, "360p", |playlist| playlist.contains("#EXT-X-ENDLIST")) .await; - let expected = concat!( - "#EXTINF:2.00000,\nseg/29b1cd67.0.m4s\n", - "#EXT-X-GAP\n#EXTINF:2.00000,\nseg/29b1cd67.1.m4s\n", - "#EXT-X-DISCONTINUITY\n#EXTINF:2.00000,\nseg/29b1cd67.2.m4s\n", + let low = replay.rendition(Kind::Video, "360p").snapshot(); + let flags = |snapshot: &playlist::Snapshot| { + snapshot + .segments + .iter() + .map(|s| (s.segment, s.gap, s.discontinuity)) + .collect::>() + }; + assert_eq!( + flags(&low), + [(0, false, false), (1, true, false), (2, true, false), (3, false, true), (4, true, false)] ); - assert!(low.contains(expected), "{low}"); - let high = replay.playlist(Kind::Video, "1080p").await; - assert!(!high.contains("#EXT-X-GAP"), "{high}"); - assert!( - high.contains("#EXT-X-DISCONTINUITY\n#EXTINF:2.00000,\nseg/29b1cd67.2.m4s\n"), - "{high}" + replay + .playlist_until(Kind::Video, "1080p", |playlist| playlist.contains("#EXT-X-ENDLIST")) + .await; + let high = replay.rendition(Kind::Video, "1080p").snapshot(); + assert_eq!( + flags(&high), + [(0, false, false), (1, false, false), (2, true, false), (3, false, true), (4, true, false)] ); // A gap is never fetched. @@ -530,7 +505,7 @@ async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities( let rendition = replay.rendition(Kind::Video, "360p"); assert!(rendition.listed_segment(1).await.unwrap().is_none()); assert!(!counting.take().iter().any(|path| is_media(path))); - let after = rendition.listed_segment(2).await.unwrap().unwrap(); + let after = rendition.listed_segment(3).await.unwrap().unwrap(); assert_eq!(&after[4..8], b"moof"); } @@ -542,42 +517,45 @@ async fn a_growing_recording_ends_with_its_markers() { recording.stored().await; let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; replay.playlist(Kind::Video, "1080p").await; + let tag = tag("1080p"); - // A DVR commit: segment 3 arrives and segment 0 expires. - add_segment(&mut recording, 3, 1); + add_segment(&mut recording, 3); recording.stored().await; let playlist = replay .playlist_until(Kind::Video, "1080p", |playlist| { - playlist.contains("seg/29b1cd67.3.m4s\n") + playlist.contains(&format!("seg/{tag}.2.m4s\n")) }) .await; - assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:1\n"), "{playlist}"); - assert!(!playlist.contains("seg/29b1cd67.0.m4s"), "{playlist}"); assert!( !playlist.contains("#EXT-X-ENDLIST"), "a recording without markers stays live" ); + for track in ["360p", "1080p", "audio"] { + let group = if track == "audio" { 16 } else { 4 }; + recording.stop(track, group, 8_000); + } recording.finish().await; let playlist = replay .playlist_until(Kind::Video, "1080p", |playlist| playlist.contains("#EXT-X-ENDLIST")) .await; - assert!(playlist.contains("seg/29b1cd67.3.m4s\n#EXT-X-ENDLIST\n"), "{playlist}"); + assert!(playlist.contains(&format!("seg/{tag}.3.m4s\n")), "{playlist}"); } -/// A durable timeline still lists only the default 16s window: a live playlist stays bounded -/// however much the store retains. +/// A durable record track still lists only the default 16s window: a live playlist stays +/// bounded however much the store retains. #[tokio::test(start_paused = true)] -async fn a_durable_timeline_lists_the_window() { - let recording = segments(12).await; +async fn durable_records_list_the_window() { + let recording = segments(13).await; recording.stored().await; let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; + let tag = tag("1080p"); let playlist = replay .playlist_until(Kind::Video, "1080p", |playlist| { - playlist.contains("seg/29b1cd67.11.m4s\n") + playlist.contains(&format!("seg/{tag}.11.m4s\n")) }) .await; // Eight 2s segments cover the window. assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:4\n"), "{playlist}"); - assert!(!playlist.contains("seg/29b1cd67.3.m4s\n"), "{playlist}"); + assert!(!playlist.contains(&format!("seg/{tag}.3.m4s\n")), "{playlist}"); } diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 97be0f11c38..289ebed7e58 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -1,19 +1,17 @@ //! Export: serve a MoQ broadcast as HLS or DASH, fetching media on demand. //! -//! A [`Broadcaster`] subscribes to one broadcast's catalog and each rendition's timeline track -//! (see [`hang::timeline`]). That is the *only* standing traffic: playlists and manifests are a -//! pure function of the timeline records, and media bytes move only when an HTTP client requests -//! a segment, which FETCHes exactly the frames that segment covers from the relay cache and -//! transmuxes them to CMAF. A broadcast whose catalog advertises no timelines can't be served this -//! way and is skipped, and a rendition is served only once its own timeline is advertised. A `moq-archive` recording served by its `Consumer` is served the same way: -//! its playlists read only the stored timelines, and a segment GETs only its rendition's objects. +//! A [`Broadcaster`] subscribes to one broadcast's catalog and the HLS record track of each +//! rendition that advertises one (its catalog `hls` field, see [`hang::hls`]). That is the *only* +//! standing traffic: playlists and manifests are a pure function of the records, and media bytes +//! move only when an HTTP client requests a segment, which FETCHes exactly the groups that segment +//! holds from the relay cache and transmuxes them to CMAF. A rendition without records gets no +//! playlist. A `moq-archive` recording served by its `Consumer` is served the same way: its +//! playlists read only the stored records, and a segment GETs only its rendition's objects. //! -//! Segment boundaries come from one reference rendition's records, numbered by record sequence, -//! so edges that share the choice agree without the publisher cutting for HLS. The first choice -//! is the first enabled video rendition, or enabled audio when there is no video. A video -//! choice sticks while it stays enabled with a timeline; audio yields to enabled video. A -//! handoff continues past the last advertised record sequence, starting a DASH period. Every -//! other rendition resolves each segment against its own timeline. +//! Record `n` of every rendition is segment `n`: the publisher numbers its renditions' segments +//! together, so every edge agrees without coordinating. The edge checks each rendition's segment +//! against the lead rendition's (the first enabled video, else audio) and lists one that does not +//! line up as `EXT-X-GAP`, which serves nothing. //! An inline-parameter-set codec with no catalog `description` GETs one keyframe //! group on the first playlist render to build its init, then caches it. //! @@ -36,7 +34,6 @@ mod mpd; mod playlist; mod rendition; mod route; -mod spans; pub mod renditions; pub mod segments; @@ -50,10 +47,6 @@ use rand::RngExt; pub(crate) use playlist::render_media; pub use rendition::{Kind, Rendition}; pub use route::{Resource, Route}; -use segments::Reference; - -/// The selected reference's timeline reader. -type TimelineWatcher = Arc>>>; /// Backoff bounds for the initial catalog subscription. /// @@ -76,14 +69,13 @@ const CATALOG_RETRY_MAX: Duration = Duration::from_secs(5); #[derive(Clone, Debug)] #[non_exhaustive] pub struct Config { - /// Minimum duration of media listed in each rendition's playlist window. Older timeline - /// records are evicted once the remaining segments still cover this span, except the newest - /// one a video player can start at, so a GOP longer than the window stretches it. Past 256 - /// segments they are evicted however short they are. Keep it within the relay's group-cache retention, since - /// segments are fetched from there on request. It also caps segment `Cache-Control: max-age`. + /// Minimum duration of media listed in each rendition's playlist window. Older segments are + /// evicted once the remaining ones still cover this span. Past 256 segments they are evicted + /// however short they are. Keep it within the relay's group-cache retention, since segments + /// are fetched from there on request. It also caps segment `Cache-Control: max-age`. /// - /// A durable timeline gets the same window: a live playlist stays bounded however much its - /// store retains. + /// Durable records get the same window: a live playlist stays bounded however much its store + /// retains. pub window: Duration, } @@ -110,13 +102,10 @@ pub struct Broadcaster { instance: Arc, /// The current rendition set, reconciled from the catalog by the watcher task. renditions: renditions::Producer, - /// Aborts the catalog watcher when the broadcaster is dropped; `Drop` then retires the - /// renditions themselves (see [`renditions::Producer::clear`]). Set once, right after - /// construction. + /// Aborts the catalog watcher when the broadcaster is dropped; `Drop` then releases the + /// renditions themselves (see [`renditions::Producer::clear`]), each of which stops following + /// its records once no recording cursor holds it. Set once, right after construction. watcher: Mutex>>, - /// The reference timeline reader. On drop it is aborted only when no recording cursor still - /// needs its records. - timeline_watcher: TimelineWatcher, } impl Broadcaster { @@ -126,17 +115,15 @@ impl Broadcaster { // An epoch names the instance on every edge, so each serves it under the same URLs. let instance = broadcast.info().epoch.clone().unwrap_or_else(moq_net::Epoch::mint); let renditions = renditions::Producer::new(config.window); - let timeline_watcher = TimelineWatcher::default(); let broadcaster = Arc::new(Self { broadcast, instance: Arc::from(instance.as_str()), renditions: renditions.clone(), watcher: Mutex::new(None), - timeline_watcher: timeline_watcher.clone(), }); // The watcher owns its own producer clone; the `Broadcaster`'s `Drop` aborts it so the // standing catalog subscription stops when nobody's serving from this broadcaster. - let watcher = tokio::spawn(watch_catalog(source, renditions, timeline_watcher)); + let watcher = tokio::spawn(watch_catalog(source, renditions)); *broadcaster.watcher.lock().unwrap() = Some(watcher); broadcaster } @@ -167,7 +154,7 @@ impl Broadcaster { /// Look up a rendition by kind and name. Video and audio are separate axes, so a /// video and an audio rendition may share a name without colliding. A rendition is found - /// only once the catalog advertises its own timeline. + /// only once the catalog advertises its HLS records. pub fn rendition(&self, kind: Kind, name: &str) -> Option> { self.renditions.get(kind, name) } @@ -175,7 +162,7 @@ impl Broadcaster { /// A cursor over rendition add/remove events, for a consumer that mirrors or records the /// whole broadcast. It replays the current renditions, then yields changes, and returns /// `Ok(None)` only once the source finishes cleanly. A rendition is added once the catalog - /// advertises its own timeline, which an unhinted publisher declares after its first record. + /// advertises its HLS records, which an unhinted publisher declares after its first segment. pub fn renditions(&self) -> renditions::Consumer { self.renditions.subscribe() } @@ -199,7 +186,7 @@ impl Broadcaster { /// broadcast carries now, or stop versioning with `None` (the default). /// /// A restarted publisher is spliced into the same broadcast and restarts its segment - /// numbers, so `seg/{reference}.0.m4s` would name different bytes on each run. Supply a new generation + /// numbers, so `seg/{tag}.0.m4s` would name different bytes on each run. Supply a new generation /// before the new run's media flows. Replacing one drops every listed segment and every /// init built from the old run's media, while the first only labels the run already flowing. A segment URL carrying any other generation /// is refused. Init URLs need none: they carry a hash of their bytes. @@ -227,9 +214,8 @@ impl Broadcaster { /// holding different tokens; a credential in `Config` would embed one viewer's in /// another's playlist. /// - /// Lists only enabled renditions a player can start: a video rendition appears once a listed - /// segment starts at a group start that is a sync point, so an early render may list audio - /// alone, or nothing at all. A disabled rendition (catalog `enabled: false`) is left out until + /// Lists only enabled renditions a player can start, i.e. ones listing at least one segment, + /// so an early render may list audio alone, or nothing at all. A disabled rendition (catalog `enabled: false`) is left out until /// it re-enables, while its media playlist keeps serving. pub fn master_playlist(&self, query: Option<&str>) -> String { let (video, audio) = self.variants(query); @@ -263,8 +249,7 @@ impl Broadcaster { (video, audio) } - /// Render the DASH manifest (MPD) from the current renditions and their views of the - /// broadcast timeline, or `None` while nothing is listable yet (every `SegmentTimeline` + /// Render the DASH manifest (MPD) from the current renditions' records, or `None` while nothing is listable yet (every `SegmentTimeline` /// would be empty, which players reject). /// /// Live broadcasts render a `dynamic` presentation anchored at the catalog's declared @@ -276,10 +261,9 @@ impl Broadcaster { /// its init by a hash of the bytes, so a rendition is left out until its init is known. An inline-parameter-set codec only learns it from media: await /// each rendition's [`init`](Rendition::init) first. /// - /// Fails with [`TimelineFailed`](crate::Error::TimelineFailed) once the reference timeline - /// failed, or [`CatalogFailed`](crate::Error::CatalogFailed) once the catalog did, rather - /// than turning a truncated presentation `static`. A rendition whose own - /// timeline failed is left out instead, and its [`Rendition`] listings fail. + /// Fails with [`CatalogFailed`](crate::Error::CatalogFailed) once the catalog failed, rather + /// than turning a truncated presentation `static`. A rendition whose own records failed is + /// left out instead, and its [`Rendition`] listings fail. pub fn manifest(&self, query: Option<&str>) -> crate::Result> { if let Some(err) = self.renditions.failure() { return Err(err); @@ -287,6 +271,8 @@ impl Broadcaster { let mut video = Vec::new(); let mut audio = Vec::new(); let mut availability_start = None; + // A failure left out of the listing, answered if nothing else is left to list. + let mut skipped = None; for rendition in self.renditions.snapshot() { // Every rendition shares the catalog's one root clock, so any of them can // supply the declared wall anchor. @@ -298,18 +284,24 @@ impl Broadcaster { } // Without its init there is nothing to name in `initialization`; the serve path // builds every init before rendering (see `build_inits`). - let representations = match rendition.representations() { - Ok(representations) => representations, + let representation = match rendition.representation() { + Ok(representation) => representation, // Its own playlist answers the error, while its siblings stay listed. - Err(_) if rendition.failed_alone() => continue, + Err(err) if rendition.failed_alone() => { + skipped.get_or_insert(err); + continue; + } Err(err) => return Err(err), }; match rendition.kind { - Kind::Video => video.extend(representations), - Kind::Audio => audio.extend(representations), + Kind::Video => video.extend(representation), + Kind::Audio => audio.extend(representation), } } + if let (true, Some(err)) = (video.is_empty() && audio.is_empty(), skipped) { + return Err(err); + } let representations = || video.iter().chain(&audio); if representations().all(|rep| rep.segments.is_empty()) { return Ok(None); @@ -353,10 +345,10 @@ impl Broadcaster { } } - /// Resolve once the broadcast has something listable (its first complete segment, or an - /// already-ended timeline). Every rendition's window is fed from the same timeline, so the - /// first rendition's readiness stands in for the broadcast's. Bounding the wait is the - /// caller's policy. + /// Resolve once the broadcast has something listable (its first complete segment, or + /// already-ended records). Renditions start their segments together, so the first + /// rendition's readiness stands in for the broadcast's. Bounding the wait is the caller's + /// policy. #[cfg_attr( not(feature = "server"), allow(dead_code, reason = "only the `server` feature uses it") @@ -382,22 +374,15 @@ impl Drop for Broadcaster { if let Some(watcher) = self.watcher.lock().unwrap().take() { watcher.abort(); } - // A recording cursor (holding an `Arc`) must keep receiving timeline - // records after this broadcaster is gone, or its final segments would be truncated; - // the timeline watcher then ends on its own when the timeline (or broadcast) does. - // With no such holder, abort it now so teardown can't leak a standing subscription. - let held = self.renditions.any_held_externally(); - // Release the map either way: a surviving `renditions::Consumer` would otherwise pin - // every `Rendition` forever. + // Release the map: a surviving `renditions::Consumer` would otherwise pin every + // `Rendition` forever. A recording cursor's own `Arc` keeps that rendition's + // records flowing, so its final segments are not truncated. self.renditions.clear(); - if !held && let Some(watcher) = self.timeline_watcher.lock().unwrap().take() { - watcher.abort(); - } } } -async fn watch_catalog(source: moq_mux::Source, renditions: renditions::Producer, timeline_watcher: TimelineWatcher) { - // The source's catalog broadcast, so the catalog, the timeline subscription, and the closed +async fn watch_catalog(source: moq_mux::Source, renditions: renditions::Producer) { + // The source's catalog broadcast, so the catalog, the record subscriptions, and the closed // check below all refer to the one broadcast it was built from. let broadcast = &source.broadcast(); let mut delay = CATALOG_RETRY_MIN; @@ -419,31 +404,9 @@ async fn watch_catalog(source: moq_mux::Source, renditions: renditions::Producer } }; - let mut reference: Option = None; let result = loop { match kio::wait(|waiter| consumer.poll_next(waiter)).await { - Ok(Some(catalog)) => { - renditions.sync(&source, &catalog); - - // Keep an enabled reference, preserving every row already advertised on a handoff. - let next = self::reference(&catalog, reference.as_ref()); - if next != reference { - let mut watcher = timeline_watcher.lock().unwrap(); - renditions.fanout().select(next.clone()); - if let Some(watcher) = watcher.take() { - watcher.abort(); - } - if let (Some(next), Some(archive)) = (&next, catalog.archive.clone()) { - *watcher = Some(tokio::spawn(watch_timeline( - broadcast.clone(), - archive, - next.clone(), - renditions.fanout(), - ))); - } - reference = next; - } - } + Ok(Some(catalog)) => renditions.sync(&source, &catalog), Ok(None) => break Ok(()), Err(err) => { tracing::warn!(%err, "broadcast catalog stream ended with error"); @@ -451,116 +414,9 @@ async fn watch_catalog(source: moq_mux::Source, renditions: renditions::Producer } } }; - - // With every rendition disabled, no reference watcher is left to end the windows. - // Only a clean catalog finish confirms completion; a catalog failure fails the windows. - if reference.is_none() && result.is_ok() { - renditions.fanout().finish(None); - } renditions.finish(result); } -/// The rendition every segment boundary comes from, with the track its timeline indexes. -/// -/// The first choice is the first enabled video rendition with a timeline, falling back to -/// enabled audio. Keep an enabled video choice while its timeline stays indexed, so a newly -/// added rendition cannot take it over. Audio yields once enabled video becomes available. -/// A handoff resumes at the replacement's first record numbered past the last advertised one, -/// and starts a DASH period. -fn reference(catalog: &moq_mux::catalog::hang::Catalog, current: Option<&Reference>) -> Option { - use hang::catalog::Reference as _; - - let archive = catalog.archive.as_ref()?; - let indexed = |track: &&str| archive.timelines.contains_key(*track); - let video = catalog - .video - .renditions - .iter() - .filter(|(_, config)| config.enabled) - .map(|(id, config)| (Kind::Video, id, config.track(id))) - .find(|(_, _, track)| indexed(track)); - if let Some(current) = current { - let (kind, id, _) = current.as_ref(); - let track = match kind { - Kind::Video => catalog - .video - .renditions - .get(id) - .filter(|config| config.enabled) - .map(|config| config.track(id)), - Kind::Audio if video.is_none() => catalog - .audio - .renditions - .get(id) - .filter(|config| config.enabled) - .map(|config| config.track(id)), - Kind::Audio => None, - }; - if let Some(track) = track.filter(indexed) { - return Some(Arc::new((*kind, id.clone(), track.to_string()))); - } - } - let audio = || { - catalog - .audio - .renditions - .iter() - .filter(|(_, config)| config.enabled) - .map(|(id, config)| (Kind::Audio, id, config.track(id))) - .find(|(_, _, track)| indexed(track)) - }; - video - .or_else(audio) - .map(|(kind, id, track)| Arc::new((kind, id.clone(), track.to_string()))) -} - -/// The segment watcher: read the reference rendition's timeline and fan each record out to every -/// rendition's window as a segment. -async fn watch_timeline( - broadcast: moq_net::broadcast::Consumer, - section: hang::catalog::Archive, - reference: Reference, - renditions: renditions::Fanout, -) { - // A failed timeline is never retried: the origin already rides out transient source - // failures, so an error here is final. It fails every playlist rather than ending it, so a - // truncated recording never reads as complete, nor freezes live with no signal. - match watch(&broadcast, §ion, &reference, &renditions).await { - // A superseded watcher cannot finish the newly selected reference's windows. - Ok(()) => { - renditions.finish(Some(&reference)); - } - Err(err) => { - let err = crate::Error::TimelineFailed { - track: reference.2.clone(), - source: Box::new(err), - }; - if renditions.fail(Some(&reference), err.clone()) { - tracing::error!(%err, "reference timeline failed; failing every playlist"); - } - } - } -} - -async fn watch( - broadcast: &moq_net::broadcast::Consumer, - section: &hang::catalog::Archive, - reference: &Reference, - renditions: &renditions::Fanout, -) -> crate::Result<()> { - let track = &reference.2; - let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(broadcast, section, track).await?; - while let Some(event) = timeline.next().await? { - match event { - moq_mux::timeline::Event::Push { index, entry } => renditions.push(index, entry, reference), - moq_mux::timeline::Event::Pop(range) => renditions.pop(range, reference), - moq_mux::timeline::Event::Skip(_) => renditions.skip(Some(reference)), - _ => unreachable!("unknown timeline event"), - } - } - Ok(()) -} - #[cfg(test)] #[expect( clippy::disallowed_methods, @@ -588,142 +444,59 @@ mod tests { use super::*; - #[allow( - clippy::large_enum_variant, - reason = "a test fixture built once per case; only large when extra features grow the video config" - )] - enum TestConfig { - Video(hang::catalog::VideoConfig), - Audio(hang::catalog::AudioConfig), - } - - impl From for TestConfig { - fn from(config: hang::catalog::VideoConfig) -> Self { - Self::Video(config) - } - } - - impl From for TestConfig { - fn from(config: hang::catalog::AudioConfig) -> Self { - Self::Audio(config) - } - } - - struct TestRendition { - catalog: moq_mux::catalog::Producer, - name: String, - config: Option, - } - - impl TestRendition { - fn unwrap(self) -> Self { - self - } + type Media = moq_mux::container::Producer; - fn set(&mut self, config: impl Into) -> moq_mux::Result<()> { - let config = config.into(); - let mut catalog = self.catalog.modify()?; - match &config { - TestConfig::Video(config) => { - catalog.video.renditions.insert(self.name.clone(), config.clone()); - } - TestConfig::Audio(config) => { - catalog.audio.renditions.insert(self.name.clone(), config.clone()); - } - } - catalog.commit()?; - self.config = Some(config); - Ok(()) - } + /// A catalog config recording HLS segments of two seconds, declared up front. + fn catalog_config() -> moq_mux::catalog::Config { + let duration = Duration::from_secs(2); + moq_mux::catalog::Config::default().with_hls( + moq_mux::hls::Config::default() + .with_duration_min(duration) + .with_duration_hint(duration), + ) } - impl Drop for TestRendition { - fn drop(&mut self) { - let Ok(mut catalog) = self.catalog.modify() else { - return; - }; - match self.config { - Some(TestConfig::Video(_)) => { - catalog.video.renditions.remove(&self.name); - } - Some(TestConfig::Audio(_)) => { - catalog.audio.renditions.remove(&self.name); - } - None => {} - } - } + /// A catalog on `broadcast` that records HLS segments (see [`catalog_config`]). + fn hls_catalog(broadcast: &mut moq_net::broadcast::Producer) -> moq_mux::catalog::Producer { + moq_mux::catalog::Producer::new(broadcast, catalog_config()).unwrap() } - trait RenditionTestExt { - fn test_video(&self, name: &str) -> TestRendition; - fn test_audio(&self, name: &str) -> TestRendition; + /// Publish a video track `name` through `catalog`, which owns its rendition and records it. + fn video( + catalog: &moq_mux::catalog::Producer, + broadcast: &moq_net::broadcast::Producer, + name: &str, + config: hang::catalog::VideoConfig, + ) -> Media { + let track = broadcast.create_track(name, timed()).unwrap(); + let container = moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data); + catalog.video(track, container, config).unwrap() } - impl RenditionTestExt for moq_mux::catalog::Producer { - fn test_video(&self, name: &str) -> TestRendition { - TestRendition { - catalog: self.clone(), - name: name.to_string(), - config: None, - } - } - - fn test_audio(&self, name: &str) -> TestRendition { - self.test_video(name) - } + /// Publish an audio track `name` through `catalog`, as [`video`] does. + fn audio( + catalog: &moq_mux::catalog::Producer, + broadcast: &moq_net::broadcast::Producer, + name: &str, + config: hang::catalog::AudioConfig, + ) -> Media { + let track = broadcast.create_track(name, timed()).unwrap(); + let container = moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data); + catalog.audio(track, container, config).unwrap() } - trait CatalogTestExt { - fn enroll_test(&self, track: &str) -> moq_mux::Result; - fn media_producer( - &self, - track: moq_net::track::Producer, - container: C, - ) -> moq_mux::Result> - where - moq_mux::Error: From; + fn opus() -> hang::catalog::AudioConfig { + hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2) } - impl CatalogTestExt for moq_mux::catalog::Producer { - fn enroll_test(&self, track: &str) -> moq_mux::Result { - let duration = Duration::from_secs(2); - let recorder = self.timeline().track( - track, - moq_mux::timeline::Config::default() - .with_duration_min(duration) - .with_duration_hint(duration), - )?; - let section = self.timeline().section(); - let mut producer = self.clone(); - let mut catalog = producer.modify()?; - catalog.archive = Some(section); - catalog.commit()?; - Ok(recorder) - } - - fn media_producer( - &self, - track: moq_net::track::Producer, - container: C, - ) -> moq_mux::Result> - where - moq_mux::Error: From, - { - let recorder = self.enroll_test(track.name())?; - Ok(moq_mux::container::Producer::new(track, container).with_recorder(recorder)) - } + /// The catalog `hls` field of track `track`'s records, two-second segments. + fn hls(track: &str) -> hang::catalog::Hls { + hang::catalog::Hls::new(hang::hls::default_name(track), Duration::from_secs(2)) } - /// A catalog `archive` entry indexing `tracks` under their conventional timeline names. - fn archive(tracks: &[&str]) -> hang::catalog::Archive { - let mut archive = hang::catalog::Archive::new(); - for track in tracks { - archive.timelines.insert( - track.to_string(), - hang::catalog::Timeline::new(hang::timeline::default_name(track), 4_000), - ); - } - archive + /// The URL tag of track `track`'s records. + fn tag(track: &str) -> String { + segments::tag(&hang::hls::default_name(track)) } fn frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame { @@ -739,13 +512,11 @@ mod tests { } } + /// A VP8 key frame whose 320x240 geometry is only known inline. + const KEYFRAME: &[u8] = &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]; + fn vp8_frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame { - let payload = if keyframe { - // Key frame tag, start code, and 320x240 geometry. - &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00][..] - } else { - &[0x31, 0x00, 0x00][..] - }; + let payload = if keyframe { KEYFRAME } else { &[0x31, 0x00, 0x00][..] }; moq_mux::container::Frame { timestamp: moq_net::Timestamp::from_micros(micros).unwrap(), payload: bytes::Bytes::copy_from_slice(payload), @@ -769,22 +540,60 @@ mod tests { } } - #[tokio::test] - async fn escaping_broadcast_reference_is_not_advertised() { - let origin = produce_origin(); - let _broadcast = origin.create_broadcast("a/pub").expect("publish allowed"); - _broadcast.announce(Default::default()).expect("publish allowed"); - settle().await; - let upstream = crate::resolved(&origin, "a/pub").await; - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.broadcast = Some(moq_net::path::Relative::new("../../source").to_owned()); - let mut catalog = moq_mux::catalog::hang::Catalog::default(); - catalog.archive = Some(archive(&["video0", "audio0"])); - catalog.video.renditions.insert("video".to_string(), config); + /// The next rendition event, or `None` if the set is quiet. + async fn next_event(cursor: &mut renditions::Consumer) -> Option { + tokio::time::timeout(Duration::from_millis(50), cursor.next()) + .await + .ok() + .and_then(Result::unwrap) + } - let renditions = renditions::Producer::new(Config::default().window); - renditions.sync(&upstream, &catalog); - assert!(renditions.get(Kind::Video, "video").is_none()); + /// The text between `start` and the next `end` in `haystack`. + fn between<'a>(haystack: &'a str, start: &str, end: &str) -> &'a str { + let rest = &haystack[haystack.find(start).unwrap_or_else(|| panic!("no {start:?}")) + start.len()..]; + &rest[..rest.find(end).unwrap_or_else(|| panic!("no {end:?}"))] + } + + /// The init hash a media playlist maps. + fn init_hash(playlist: &str) -> &str { + between(playlist, "#EXT-X-MAP:URI=\"init.", ".mp4") + } + + /// Whether `haystack` carries `needle` verbatim, i.e. whether a transmuxed segment carries a + /// particular publisher's frame payloads. + fn contains(haystack: &bytes::Bytes, needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|window| window == needle) + } + + fn numbers(snapshot: &playlist::Snapshot) -> Vec { + snapshot.segments.iter().map(|s| s.segment).collect() + } + + fn gaps(snapshot: &playlist::Snapshot) -> Vec { + snapshot.segments.iter().filter(|s| s.gap).map(|s| s.segment).collect() + } + + /// The average bitrate encoded in a synthesized AAC init segment. + fn aac_avg_bitrate(init: &bytes::Bytes) -> u32 { + let wire = moq_mux::container::fmp4::Wire::from_init(init).unwrap(); + let codec = &wire.trak().mdia.minf.stbl.stsd.codecs[0]; + let moq_mux::container::fmp4::mp4_atom::Codec::Mp4a(mp4a) = codec else { + panic!("expected mp4a, got {codec:?}"); + }; + mp4a.esds.es_desc.dec_config.avg_bitrate + } + + /// The `trun` sample durations of a fragment. + fn sample_durations(fragment: &bytes::Bytes) -> Vec> { + use moq_mux::container::fmp4::mp4_atom::{self, DecodeMaybe}; + + let mut cursor = std::io::Cursor::new(fragment.as_ref()); + while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() { + if let mp4_atom::Any::Moof(moof) = atom { + return moof.traf[0].trun[0].entries.iter().map(|entry| entry.duration).collect(); + } + } + panic!("no moof in fragment"); } /// A source over an empty published broadcast, for the `sync` reconciliation tests. @@ -801,21 +610,39 @@ mod tests { ); audio.description = Some(bytes::Bytes::from_static(&[0x12, 0x10])); audio.bitrate = Some(96_000); + audio.hls = Some(hls("audio0")); + let mut video = video_config(); + video.hls = Some(hls("video0")); let mut catalog = moq_mux::catalog::hang::Catalog::default(); - catalog.archive = Some(archive(&["video0", "audio0"])); - catalog.video.renditions.insert("video0".to_string(), video_config()); + catalog.video.renditions.insert("video0".to_string(), video); catalog.audio.renditions.insert("audio0".to_string(), audio); catalog } + #[tokio::test] + async fn escaping_broadcast_reference_is_not_advertised() { + let origin = produce_origin(); + let _broadcast = origin.create_broadcast("a/pub").expect("publish allowed"); + _broadcast.announce(Default::default()).expect("publish allowed"); + settle().await; + let upstream = crate::resolved(&origin, "a/pub").await; + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.broadcast = Some(moq_net::path::Relative::new("../../source").to_owned()); + config.hls = Some(hls("video")); + let mut catalog = moq_mux::catalog::hang::Catalog::default(); + catalog.video.renditions.insert("video".to_string(), config); + + let renditions = renditions::Producer::new(Config::default().window); + renditions.sync(&upstream, &catalog); + assert!(renditions.get(Kind::Video, "video").is_none()); + } + #[tokio::test(start_paused = true)] async fn audio_renditions_follow_catalog_quality_order() { let broadcast = moq_net::broadcast::Info::new().produce(); let source = moq_mux::Source::new(produce_origin().consume(), broadcast.consume()); - let names = ["a", "b", "c", "d", "e", "f", "g"]; let mut catalog = moq_mux::catalog::hang::Catalog::default(); - catalog.archive = Some(archive(&names)); for (name, rate, channels, bitrate) in [ ("a", 48_000, 2, None), ("b", 48_000, 1, Some(64_000)), @@ -827,14 +654,11 @@ mod tests { ] { let mut config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, rate, channels); config.bitrate = bitrate; + config.hls = Some(hls(name)); catalog.audio.renditions.insert(name.to_string(), config); } let renditions = renditions::Producer::new(Config::default().window); renditions.sync(&source, &catalog); - assert!( - !renditions.any_held_externally(), - "ranking must not count as a recording consumer" - ); let listed = || { renditions .snapshot() @@ -853,22 +677,38 @@ mod tests { assert!(listed().is_empty()); } - /// The next rendition event, or `None` if the set is quiet. - async fn next_event(cursor: &mut renditions::Consumer) -> Option { - tokio::time::timeout(Duration::from_millis(50), cursor.next()) - .await - .ok() - .and_then(Result::unwrap) - } + // A rendition is served only once its catalog entry names its HLS records; one without them + // gets no playlist, and is added beside its siblings once they appear. + #[tokio::test] + async fn a_rendition_without_hls_records_gets_no_playlist() { + let origin = produce_origin(); + let broadcast = origin.create_broadcast("live").expect("publish allowed"); + broadcast.announce(Default::default()).expect("publish allowed"); + settle().await; + let upstream = crate::resolved(&origin, "live").await; - /// The average bitrate encoded in a synthesized AAC init segment. - fn aac_avg_bitrate(init: &bytes::Bytes) -> u32 { - let wire = moq_mux::container::fmp4::Wire::from_init(init).unwrap(); - let codec = &wire.trak().mdia.minf.stbl.stsd.codecs[0]; - let moq_mux::container::fmp4::mp4_atom::Codec::Mp4a(mp4a) = codec else { - panic!("expected mp4a, got {codec:?}"); - }; - mp4a.esds.es_desc.dec_config.avg_bitrate + let renditions = renditions::Producer::new(Config::default().window); + let mut cursor = renditions.subscribe(); + let mut catalog = catalog_with_both(); + catalog.audio.renditions.get_mut("audio0").unwrap().hls = None; + renditions.sync(&upstream, &catalog); + + let video = renditions.get(Kind::Video, "video0").expect("video has records"); + assert!(renditions.get(Kind::Audio, "audio0").is_none(), "audio has none yet"); + assert!( + matches!(next_event(&mut cursor).await, Some(renditions::Event::Added(added)) if added.kind == Kind::Video) + ); + assert!(next_event(&mut cursor).await.is_none(), "audio is not announced early"); + + catalog.audio.renditions.get_mut("audio0").unwrap().hls = Some(hls("audio0")); + renditions.sync(&upstream, &catalog); + assert!( + matches!(next_event(&mut cursor).await, Some(renditions::Event::Added(added)) if added.kind == Kind::Audio) + ); + let same = renditions.get(Kind::Video, "video0").unwrap(); + assert!(Arc::ptr_eq(&video, &same), "the video sibling keeps its identity"); + + drop(broadcast); } // A publisher's estimator republishes the catalog every time its measured bitrate or jitter @@ -960,7 +800,7 @@ mod tests { let video = renditions.get(Kind::Video, "video0").expect("video rendition"); while next_event(&mut cursor).await.is_some() {} - for framerate in [Some(30.0), Some(60.0)] { + for framerate in [Some(30.0), Some(60.0), Some(60_000.0 / 1001.0)] { catalog.video.renditions.get_mut("video0").unwrap().framerate = framerate; renditions.sync(&upstream, &catalog); assert!( @@ -968,33 +808,9 @@ mod tests { "the video rendition survives a framerate estimate of {framerate:?}" ); assert!(next_event(&mut cursor).await.is_none(), "no rendition churn"); + // Still muxed at the timescale the rendition was built with. + assert_eq!(video.muxer().unwrap().timescale().as_u64(), 90_000); } - - // The settled estimate still reaches the fallback cadence of a duration-less frame, - // at the timescale the rendition was built with (90 kHz without a framerate). - let muxer = video.muxer().unwrap(); - assert_eq!(muxer.timescale().as_u64(), 90_000); - let fragment = muxer.fragment(0, &[vp8_frame(0, true)]).unwrap(); - assert_eq!(first_sample_duration(&fragment), Some(1_500), "16.7 ms at 90 kHz"); - - // An NTSC estimate is 1501.5 ticks at 90 kHz: it snaps to whole ticks and still muxes. - catalog.video.renditions.get_mut("video0").unwrap().framerate = Some(60_000.0 / 1001.0); - renditions.sync(&upstream, &catalog); - let fragment = video.muxer().unwrap().fragment(0, &[vp8_frame(0, true)]).unwrap(); - assert_eq!(first_sample_duration(&fragment), Some(1_502)); - } - - /// The `trun` duration of a fragment's first sample. - fn first_sample_duration(fragment: &bytes::Bytes) -> Option { - use moq_mux::container::fmp4::mp4_atom::{self, DecodeMaybe}; - - let mut cursor = std::io::Cursor::new(fragment.as_ref()); - while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() { - if let mp4_atom::Any::Moof(moof) = atom { - return moof.traf[0].trun[0].entries[0].duration; - } - } - panic!("no moof in fragment"); } #[tokio::test] @@ -1027,8 +843,9 @@ mod tests { ); } - // The other half: a change that alters how the rendition decodes still rebuilds it, so the - // cached init segment can never describe media it no longer matches. + // The other half: a change that alters how the rendition decodes, or which records it + // lists, still rebuilds it, so the cached init segment can never describe media it no + // longer matches. #[tokio::test] async fn decoder_config_change_rebuilds_the_rendition() { let origin = produce_origin(); @@ -1044,18 +861,11 @@ mod tests { let video = renditions.get(Kind::Video, "video0").expect("video rendition"); let audio = renditions.get(Kind::Audio, "audio0").expect("audio rendition"); - assert!(matches!( - next_event(&mut cursor).await, - Some(renditions::Event::Added(_)) - )); - assert!(matches!( - next_event(&mut cursor).await, - Some(renditions::Event::Added(_)) - )); + while next_event(&mut cursor).await.is_some() {} - // A resolution change and a sample-rate change both invalidate the init segment. + // A resolution change and a record track change both rebuild. catalog.video.renditions.get_mut("video0").unwrap().coded_width = Some(640); - catalog.audio.renditions.get_mut("audio0").unwrap().sample_rate = 48_000; + catalog.audio.renditions.get_mut("audio0").unwrap().hls = Some(hls("audio1")); renditions.sync(&upstream, &catalog); let rebuilt = renditions.get(Kind::Video, "video0").expect("video rendition"); @@ -1079,28 +889,16 @@ mod tests { } // The whole fetch-on-demand path in process: a broadcast publishes media through the - // catalog (which records the timeline), the Broadcaster renders playlists from the - // timeline alone, and a segment request fetches and transmuxes exactly its groups. + // catalog (which records its HLS segments), the Broadcaster renders playlists from the + // records alone, and a segment request fetches and transmuxes exactly its groups. #[tokio::test] async fn master_lists_only_playable_renditions() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - registration.set(video_config()).unwrap(); - drop(reserved); - - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + let mut media = video(&catalog, &broadcast, "video0", video_config()); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let rendition = tokio::time::timeout(Duration::from_secs(5), async { @@ -1137,7 +935,7 @@ mod tests { .expect("broadcaster becomes ready with a complete segment"); assert!(broadcaster.master_playlist(None).contains("video/video0/media.m3u8")); - drop((media, registration, broadcast)); + drop((media, catalog, broadcast)); } /// A disabled rendition leaves the master playlist but keeps its window and media playlist, @@ -1149,20 +947,8 @@ mod tests { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - registration.set(video_config()).unwrap(); - drop(reserved); - - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + let mut media = video(&catalog, &broadcast, "video0", video_config()); for i in 0..3 { media.write(vp8_frame(i * 2_000_000, true)).unwrap(); } @@ -1185,7 +971,7 @@ mod tests { let mut disabled = video_config(); disabled.enabled = false; - registration.set(disabled).unwrap(); + media.set(disabled).unwrap(); tokio::time::timeout(Duration::from_secs(5), async { while listed() { tokio::task::yield_now().await; @@ -1204,7 +990,7 @@ mod tests { }); tokio::time::sleep(Duration::from_millis(10)).await; assert!(!waiting.is_finished(), "nothing enabled is advertised"); - registration.set(video_config()).unwrap(); + media.set(video_config()).unwrap(); tokio::time::timeout(Duration::from_secs(5), waiting) .await .expect("a re-enable wakes the master's long-poll") @@ -1212,536 +998,35 @@ mod tests { assert!(listed()); assert_eq!(sequence(&rendition), before, "the media sequence did not reset"); - drop((media, registration, broadcast)); + drop((media, catalog, broadcast)); + } + + /// Publish a VP8 rendition with three GOPs, 2s apart, so segments 0 and 1 are listable. + fn publish_vp8( + broadcast: &mut moq_net::broadcast::Producer, + config: hang::catalog::VideoConfig, + ) -> (moq_mux::catalog::Producer, Media) { + let catalog = hls_catalog(broadcast); + let mut media = video(&catalog, broadcast, "video0", config); + for (micros, keyframe) in [(0, true), (1_000_000, false), (2_000_000, true), (4_000_000, true)] { + media.write(vp8_frame(micros, keyframe)).unwrap(); + } + (catalog, media) + } + + /// A VP8 config whose geometry is only known from a keyframe. + fn inline_vp8() -> hang::catalog::VideoConfig { + hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8) } + /// A completed segment already decoded its inline codec metadata. Finishing the + /// catalog lists a trailing group whose media producer is still live. #[tokio::test(start_paused = true)] - async fn disabled_reference_handoff_continues_the_source_media_sequence() { + async fn a_transmuxed_segment_keeps_its_init_with_an_unfinished_tail() { let origin = produce_origin(); - let mut broadcast = origin.create_broadcast("live").unwrap(); - broadcast.announce(Default::default()).unwrap(); - settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); - let reserved = catalog.reserve(); - let mut first = catalog.test_video("video0"); - first.set(video_config()).unwrap(); - let mut sibling = catalog.test_video("video1"); - sibling.set(video_config()).unwrap(); - drop(reserved); - let media = |name: &str| { - let track = broadcast.create_track(name, timed()).unwrap(); - catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap() - }; - let mut first_media = media("video0"); - let mut sibling_media = media("video1"); - for at in [0, 2, 4, 6] { - first_media.write(vp8_frame(at * 1_000_000, true)).unwrap(); - } - // Aligned groups number the same records, so the sibling continues at the 6s boundary. - for at in [0, 2, 4, 6, 8] { - sibling_media.write(vp8_frame(at * 1_000_000, true)).unwrap(); - } - let config = Config { - window: Duration::from_secs(4), - }; - let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, config); - broadcaster.advertised().await; - let first_rendition = broadcaster.rendition(Kind::Video, "video0").unwrap(); - let sibling_rendition = broadcaster.rendition(Kind::Video, "video1").unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { - while first_rendition.snapshot().segments.last().is_none_or(|s| s.segment < 2) { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .unwrap(); - let old = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - let new = segments::tag(&(Kind::Video, "video1".into(), "video1".into())); - let before = first_rendition.snapshot(); - let mut cursor = sibling_rendition.segments(); - // The live window starts at 2s. - for segment in 1..=2 { - assert_eq!(cursor.next().await.unwrap().unwrap().segment, segment); - } - let bytes = first_rendition.numbered(&old, 2).await.unwrap().unwrap(); - let mut disabled = video_config(); - disabled.enabled = false; - first.set(disabled).unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { - while sibling_rendition - .snapshot() - .segments - .last() - .is_none_or(|s| s.tag != new) - { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the enabled sibling takes over the reference"); - let after = sibling_rendition.snapshot(); - assert_eq!( - cursor.next().await.unwrap().unwrap().segment, - 3, - "the recorder advances once into the new reference" - ); - assert_eq!(after.media_sequence, before.media_sequence + 1); - assert_eq!(after.segments.last().unwrap().segment, 3, "the next source record"); - assert!(after.segments.iter().any(|s| s.tag == old)); - assert!(after.segments.iter().all(|s| !s.discontinuity)); - assert_eq!(first_rendition.numbered(&old, 2).await.unwrap().unwrap(), bytes); - let mpd = broadcaster.manifest(None).unwrap().unwrap(); - assert_eq!(mpd.matches(" Vec<(String, u64)> { - snapshot - .segments - .iter() - .map(|segment| (segment.tag.clone(), segment.segment)) - .collect() - }; - - let saw = Broadcaster::new(crate::resolved(&origin, "live").await, config.clone()); - saw.advertised().await; - let saw_video1 = saw.rendition(Kind::Video, "video1").unwrap(); - listed_under(&saw_video1, &old_tag, 2).await; - catalog - .mutate(|catalog| catalog.video.renditions.get_mut("video0").unwrap().enabled = false) - .unwrap(); - let handed = listed_under(&saw_video1, &new_tag, next - 1).await; - assert_eq!(handed.segments[0].segment, handed.media_sequence); - let resumed_at = handed.segments.iter().position(|s| s.tag == new_tag).unwrap(); - assert_eq!(handed.segments[resumed_at].segment, resumed); - if resumed == 3 { - assert_eq!( - listing(&handed)[..resumed_at], - [(old_tag.clone(), 2)], - "the advertised row stays" - ); - assert!(!handed.segments[resumed_at].discontinuity); - } else { - assert_eq!(resumed_at, 0, "positions 3..6 are skipped, so the old rows go"); - } - - let after = Broadcaster::new(crate::resolved(&origin, "live").await, config); - after.advertised().await; - let after_video1 = after.rendition(Kind::Video, "video1").unwrap(); - let joined = listed_under(&after_video1, &new_tag, next - 1).await; - assert_eq!(joined.segments[0].segment, joined.media_sequence); - // Every segment number both edges list names the same playlist position. - for segment in &joined.segments { - if let Some(offset) = handed.segments.iter().position(|s| s.segment == segment.segment) { - assert_eq!(handed.media_sequence + offset as u64, segment.segment); - } - } - - while next * span_ms < 12_000 { - new.push(&record(next)).unwrap(); - next += 1; - } - let followed = listed_under(&saw_video1, &new_tag, next - 1).await; - let joined = listed_under(&after_video1, &new_tag, next - 1).await; - assert_eq!(listing(&followed), listing(&joined)); - assert_eq!( - followed.media_sequence, joined.media_sequence, - "one numbering on every edge" - ); - if resumed == 3 { - saw.build_inits().await; - after.build_inits().await; - let saw_playlist = saw_video1.media_playlist(None).unwrap().unwrap(); - assert!(saw_playlist.contains("#EXT-X-MEDIA-SEQUENCE:4\n"), "{saw_playlist}"); - assert_eq!(saw_playlist, after_video1.media_playlist(None).unwrap().unwrap()); - } - } - - /// A broadcast advertising timelines for `video0` and `video1`. - async fn timeline_pair() -> ( - moq_net::origin::Producer, - moq_net::broadcast::Producer, - moq_mux::catalog::Producer, - ) { - let origin = produce_origin(); - let mut broadcast = origin.create_broadcast("live").unwrap(); - broadcast.announce(Default::default()).unwrap(); - settle().await; - let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); - catalog - .mutate(|catalog| { - for name in ["video0", "video1"] { - catalog.video.renditions.insert(name.into(), video_config()); - } - catalog.archive = Some(archive(&["video0", "video1"])); - }) - .unwrap(); - (origin, broadcast, catalog) - } - - fn timeline_track( - broadcast: &moq_net::broadcast::Producer, - name: &str, - ) -> (moq_net::track::Producer, moq_mux::timeline::Producer) { - let track = broadcast - .create_track(hang::timeline::default_name(name), moq_mux::timeline::Producer::info()) - .unwrap(); - (track.clone(), moq_mux::timeline::Producer::new(track)) - } - - // A rendition whose own timeline failed answers the error alone. Its siblings keep serving, - // and the broadcast-wide listings leave it out so new players start on a sibling. - #[tokio::test(start_paused = true)] - async fn a_failed_rendition_leaves_the_manifest_and_master() { - let (origin, broadcast, _catalog) = timeline_pair().await; - let (_video0_track, mut video0) = timeline_track(&broadcast, "video0"); - let (video1_track, mut video1) = timeline_track(&broadcast, "video1"); - for sequence in 0..4 { - video0.push(&gop(sequence)).unwrap(); - video1.push(&gop(sequence)).unwrap(); - } - let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); - broadcaster.advertised().await; - let healthy = broadcaster.rendition(Kind::Video, "video0").unwrap(); - let tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&healthy, &tag, 3).await; - let before = broadcaster.manifest(None).unwrap().expect("both renditions are listed"); - assert!(before.contains("id=\"video/video1\""), "{before}"); - assert!(broadcaster.master_playlist(None).contains("video/video1/")); - - cut(video1_track, video1); - let broken = broadcaster.rendition(Kind::Video, "video1").unwrap(); - failed(&broken).await; - assert!(broken.media_playlist(None).is_err()); - assert!(healthy.media_playlist(None).unwrap().is_some()); - let manifest = broadcaster.manifest(None).unwrap().expect("the sibling stays listed"); - assert!(manifest.contains("id=\"video/video0\""), "{manifest}"); - assert!(!manifest.contains("id=\"video/video1\""), "{manifest}"); - let master = broadcaster.master_playlist(None); - assert!(master.contains("video/video0/"), "{master}"); - assert!(!master.contains("video/video1/"), "{master}"); - } - - // With every rendition disabled no reference watcher is left, so the source ending must - // still end every playlist and recording cursor. - #[tokio::test(start_paused = true)] - async fn a_source_ending_with_every_rendition_disabled_ends_the_playlists() { - let (origin, broadcast, mut catalog) = timeline_pair().await; - let (_video0_track, mut video0) = timeline_track(&broadcast, "video0"); - let (_video1_track, mut video1) = timeline_track(&broadcast, "video1"); - for sequence in 0..4 { - video0.push(&gop(sequence)).unwrap(); - video1.push(&gop(sequence)).unwrap(); - } - let media = broadcast.create_track("video0", timed()).unwrap(); - for sequence in 0..4 { - let mut group = media.append_group().unwrap(); - let frame = vp8_frame(sequence * 2_000_000, true); - hang::container::Frame { - timestamp: frame.timestamp, - payload: frame.payload, - } - .write_to(&mut group) - .unwrap(); - group.finish().unwrap(); - } - media.finish().unwrap(); - - let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); - broadcaster.advertised().await; - let rendition = broadcaster.rendition(Kind::Video, "video0").unwrap(); - let tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&rendition, &tag, 3).await; - catalog - .mutate(|catalog| { - for config in catalog.video.renditions.values_mut() { - config.enabled = false; - } - }) - .unwrap(); - settle().await; - catalog.finish().unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { - while !rendition.snapshot().finished { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the playlist ends with the source"); - let mut cursor = rendition.segments(); - tokio::time::timeout(Duration::from_secs(5), async { - while cursor.next().await.unwrap().is_some() {} - }) - .await - .expect("the recording cursor ends"); - } - - /// Wait until `rendition` lists segment `segment` (or later) under the reference `tag`. - async fn listed_under(rendition: &Rendition, tag: &str, segment: u64) -> playlist::Snapshot { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let snapshot = rendition.snapshot(); - if snapshot - .segments - .last() - .is_some_and(|last| last.tag == tag && last.segment >= segment) - { - return snapshot; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .unwrap_or_else(|_| panic!("segment {segment} never listed")) - } - - /// The text between `start` and the next `end` in `haystack`. - fn between<'a>(haystack: &'a str, start: &str, end: &str) -> &'a str { - let rest = &haystack[haystack.find(start).unwrap_or_else(|| panic!("no {start:?}")) + start.len()..]; - &rest[..rest.find(end).unwrap_or_else(|| panic!("no {end:?}"))] - } - - /// The init hash a media playlist maps. - fn init_hash(playlist: &str) -> &str { - between(playlist, "#EXT-X-MAP:URI=\"init.", ".mp4") - } - - /// Publish a VP8 rendition with three GOPs, 2s apart, so segments 0 and 1 are listable. - fn publish_vp8( - broadcast: &mut moq_net::broadcast::Producer, - ) -> ( - moq_mux::catalog::Producer, - TestRendition, - moq_mux::container::Producer, - ) { - let catalog = moq_mux::catalog::Producer::new(broadcast, moq_mux::catalog::Config::default()).unwrap(); - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - registration.set(video_config()).unwrap(); - drop(reserved); - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); - for (micros, keyframe) in [(0, true), (1_000_000, false), (2_000_000, true), (4_000_000, true)] { - media.write(vp8_frame(micros, keyframe)).unwrap(); - } - (catalog, registration, media) - } - - /// A completed segment already decoded its inline codec metadata. Finishing the - /// catalog can index a trailing group whose media producer is still live. - #[tokio::test(start_paused = true)] - async fn a_transmuxed_segment_keeps_its_init_with_an_unfinished_tail() { - let origin = produce_origin(); - let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); - let (mut catalog, mut registration, _media) = publish_vp8(&mut broadcast); - registration - .set(hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8)) - .unwrap(); - broadcast.announce(moq_net::origin::Route::default()).unwrap(); + let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); + let (mut catalog, _media) = publish_vp8(&mut broadcast, inline_vp8()); + broadcast.announce(moq_net::origin::Route::default()).unwrap(); settle().await; let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); broadcaster.ready().await; @@ -1768,10 +1053,7 @@ mod tests { async fn a_blocked_init_bootstrap_does_not_hide_a_cached_init() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); - let (mut catalog, mut registration, _media) = publish_vp8(&mut broadcast); - registration - .set(hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8)) - .unwrap(); + let (mut catalog, _media) = publish_vp8(&mut broadcast, inline_vp8()); broadcast.announce(moq_net::origin::Route::default()).unwrap(); settle().await; let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); @@ -1807,7 +1089,7 @@ mod tests { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let (_catalog, mut registration, _media) = publish_vp8(&mut broadcast); + let (_catalog, mut media) = publish_vp8(&mut broadcast, video_config()); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; @@ -1821,7 +1103,7 @@ mod tests { let mut config = video_config(); config.coded_width = Some(640); config.coded_height = Some(480); - registration.set(config).unwrap(); + media.set(config).unwrap(); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let new = loop { @@ -1852,13 +1134,14 @@ mod tests { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let (_catalog, _registration, mut media) = publish_vp8(&mut broadcast); + let (_catalog, mut media) = publish_vp8(&mut broadcast, video_config()); + let tag = tag("video0"); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition"); let unversioned = rendition.media_playlist(None).unwrap().expect("playable"); - assert!(unversioned.contains("\nseg/656e3d1b.0.m4s\n")); + assert!(unversioned.contains(&format!("\nseg/{tag}.0.m4s\n")), "{unversioned}"); assert!(matches!( broadcaster.set_generation(Some("run.1")), @@ -1871,8 +1154,8 @@ mod tests { .media_playlist(None) .unwrap() .expect("the first generation keeps the rows"); - assert!(labeled.contains("\nseg/run-1.656e3d1b.0.m4s\n")); - assert!(labeled.contains("\nseg/run-1.656e3d1b.1.m4s\n")); + assert!(labeled.contains(&format!("\nseg/run-1.{tag}.0.m4s\n"))); + assert!(labeled.contains(&format!("\nseg/run-1.{tag}.1.m4s\n"))); assert_eq!( init_hash(&labeled), init_hash(&unversioned), @@ -1880,7 +1163,7 @@ mod tests { ); let manifest = broadcaster.manifest(None).unwrap().expect("manifest"); let run = broadcaster.instance(); - assert!(manifest.contains(&format!("media=\"@{run}/video/video0/seg/run-1.656e3d1b.t$Time$.m4s\""))); + assert!(manifest.contains(&format!("media=\"@{run}/video/video0/seg/run-1.{tag}.t$Time$.m4s\""))); broadcaster.set_generation(Some("run-2")).unwrap(); assert!( @@ -1891,7 +1174,7 @@ mod tests { media.write(vp8_frame(6_000_000, true)).unwrap(); let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; let restarted = rendition.media_playlist(None).unwrap().expect("the new run lists"); - assert!(restarted.contains("\nseg/run-2.656e3d1b.2.m4s\n")); + assert!(restarted.contains(&format!("\nseg/run-2.{tag}.2.m4s\n")), "{restarted}"); assert!(!restarted.contains("run-1")); broadcaster.set_generation(None).unwrap(); @@ -1909,21 +1192,8 @@ mod tests { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - let mut config = video_config(); - config.coded_width = None; - config.coded_height = None; - registration.set(config).unwrap(); - drop(reserved); - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + let mut media = video(&catalog, &broadcast, "video0", inline_vp8()); for micros in [0, 2_000_000, 4_000_000] { media.write(vp8_frame(micros, true)).unwrap(); } @@ -1955,34 +1225,14 @@ mod tests { } #[tokio::test] - async fn serves_playlist_and_segments_from_the_timeline() { + async fn serves_playlist_and_segments_from_the_records() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - let mut config = video_config(); - config.coded_width = None; - config.coded_height = None; - registration.set(config).unwrap(); - drop(reserved); - - // Three GOPs, 2s apart: groups 0 and 1 are complete, group 2 is the live edge. - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); - media.write(vp8_frame(0, true)).unwrap(); - media.write(vp8_frame(1_000_000, false)).unwrap(); - media.write(vp8_frame(2_000_000, true)).unwrap(); - media.write(vp8_frame(3_000_000, false)).unwrap(); - media.write(vp8_frame(4_000_000, true)).unwrap(); + // Three GOPs, 2s apart: segments 0 and 1 are complete, segment 2 is the live edge. + let (catalog, media) = publish_vp8(&mut broadcast, inline_vp8()); + let tag = tag("video0"); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; @@ -1995,14 +1245,9 @@ mod tests { assert!(master.contains("video/video0/media.m3u8"), "master lists the rendition"); let playlist = rendition.snapshot(); - assert_eq!(playlist.segments.len(), 2, "the live-edge group is not listed"); - assert_eq!(playlist.segments[0].segment, 0); + assert_eq!(numbers(&playlist), [0, 1], "the live-edge segment is not listed"); assert_eq!(playlist.segments[0].duration, Duration::from_secs(2)); - assert_eq!(playlist.segments[1].segment, 1); - assert_eq!( - playlist.target_duration, 2, - "the observed segment duration, in whole seconds" - ); + assert_eq!(playlist.target_duration, 2); assert!(!playlist.finished); // Without coded dimensions VP8 learns its init from a keyframe, and the playlist names @@ -2013,8 +1258,8 @@ mod tests { let rendered = rendition.media_playlist(None).unwrap().expect("playable"); let hash = init_hash(&rendered); - assert!(rendered.contains("seg/656e3d1b.0.m4s\n")); - assert!(rendered.contains("seg/656e3d1b.1.m4s\n")); + assert!(rendered.contains(&format!("seg/{tag}.0.m4s\n"))); + assert!(rendered.contains(&format!("seg/{tag}.1.m4s\n"))); assert_eq!(rendition.init(hash).await.unwrap(), Some(init)); assert!(rendition.init("0000000000000000").await.unwrap().is_none()); @@ -2024,7 +1269,7 @@ mod tests { .unwrap() .expect("playable"); assert!(signed.contains(&format!("#EXT-X-MAP:URI=\"init.{hash}.mp4?jwt=abc.def\"\n"))); - assert!(signed.contains("seg/656e3d1b.0.m4s?jwt=abc.def\n")); + assert!(signed.contains(&format!("seg/{tag}.0.m4s?jwt=abc.def\n"))); let segment = rendition .listed_segment(0) @@ -2033,57 +1278,40 @@ mod tests { .expect("segment fetched on demand"); assert_eq!(&segment[4..8], b"moof", "a fetched group transmuxes to moof+mdat"); - // The live-edge group isn't a segment yet, and unknown groups miss. + // The live-edge segment isn't listed yet, and unknown segments miss. assert!(rendition.listed_segment(2).await.unwrap().is_none()); assert!(rendition.listed_segment(99).await.unwrap().is_none()); - // Keep the publisher alive for the whole test. - drop((media, registration, broadcast)); + drop((media, catalog, broadcast)); } - /// A duration marker times the trailing sample of a fetched HLS segment. + // A fetched segment's last sample runs to where the next segment starts, as its record says, + // rather than to a guess from the framerate. #[tokio::test] - async fn a_duration_marker_times_the_hls_trailing_sample() { + async fn a_segment_ends_where_its_record_says() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - registration.set(video_config()).unwrap(); - drop(reserved); - - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video), - ) - .unwrap(); - media.write(vp8_frame(0, true)).unwrap(); - media.write(vp8_frame(1_000_000, false)).unwrap(); - media.write(vp8_frame(2_000_000, true)).unwrap(); - media.finish().unwrap(); + let (catalog, media) = publish_vp8(&mut broadcast, video_config()); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; - let rendition = broadcaster - .rendition(Kind::Video, "video0") - .expect("rendition discovered from the catalog"); + let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition"); let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; - let segment = rendition - .listed_segment(0) - .await - .unwrap() - .expect("segment fetched on demand"); - assert_eq!(&segment[4..8], b"moof"); - drop((media, registration, broadcast)); + // Segment 0 holds a keyframe at 0s and a delta at 1s; segment 1 starts at 2s. + let segment = rendition.listed_segment(0).await.unwrap().expect("segment 0"); + let timescale = rendition.muxer().unwrap().timescale().as_u64(); + let durations = sample_durations(&segment); + assert_eq!(durations.len(), 2); + let total: u64 = durations.iter().map(|d| u64::from(d.expect("explicit duration"))).sum(); + assert_eq!(total, 2 * timescale, "the samples span the whole segment"); + + drop((media, catalog, broadcast)); } - // The DASH half of the fetch-on-demand path: the manifest renders from the same timeline + // The DASH half of the fetch-on-demand path: the manifest renders from the same record // windows as the HLS playlists (dynamic while live, aligned S entries across renditions), // and $Time$ addressing resolves a segment's pts to the same bytes its number does. #[tokio::test] @@ -2092,30 +1320,9 @@ mod tests { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut video_registration = catalog.test_video("video0").unwrap(); - video_registration.set(video_config()).unwrap(); - let mut audio_registration = catalog.test_audio("audio0").unwrap(); - let audio_config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); - audio_registration.set(audio_config).unwrap(); - drop(reserved); - - let video_track = broadcast.create_track("video0", timed()).unwrap(); - let mut video = catalog - .media_producer( - video_track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); - let audio_track = broadcast.create_track("audio0", timed()).unwrap(); - let mut audio = catalog - .media_producer( - audio_track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + let mut video = video(&catalog, &broadcast, "video0", video_config()); + let mut audio = audio(&catalog, &broadcast, "audio0", opus()); // Video keyframes every 2s cut the segments; audio contributes one group per cut. let mut audio_write = |micros: u64| { @@ -2132,7 +1339,6 @@ mod tests { let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; - let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.playable()).await; let video_rendition = broadcaster.rendition(Kind::Video, "video0").expect("video discovered"); let audio_rendition = broadcaster.rendition(Kind::Audio, "audio0").expect("audio discovered"); let _ = tokio::time::timeout(Duration::from_secs(5), video_rendition.playable()).await; @@ -2150,11 +1356,13 @@ mod tests { assert!(manifest.contains("id=\"video/video0\"")); assert!(manifest.contains("id=\"audio/audio0\"")); let run = broadcaster.instance(); - assert!(manifest.contains(&format!("media=\"@{run}/video/video0/seg/656e3d1b.t$Time$.m4s\""))); + let video_tag = tag("video0"); + assert!(manifest.contains(&format!("media=\"@{run}/video/video0/seg/{video_tag}.t$Time$.m4s\""))); // Both renditions list the same aligned segment spans (the live edge is not listed). - assert_eq!(manifest.matches("").count(), 2); + assert_eq!(manifest.matches("").count(), 2, "{manifest}"); assert_eq!(manifest.matches("").count(), 2); - assert!(!manifest.contains("::default(); - snapshot.video.renditions.insert("video0".to_string(), video_config()); - snapshot.archive = Some(timelines.section()); + snapshot.video.renditions.insert("video0".to_string(), config); let mut json = moq_json::snapshot::Config::default(); json.deltas = false; let mut catalog = moq_json::snapshot::Producer::new( @@ -2285,16 +1466,15 @@ mod tests { catalog .update(moq_net::Timed::from(&snapshot).at(moq_net::Timestamp::now())) .unwrap(); - - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = moq_mux::container::Producer::new( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .with_recorder(recorder); - media.write(frame(0, true)).unwrap(); - media.write(frame(2_000_000, true)).unwrap(); - media.write(frame(4_000_000, true)).unwrap(); // live edge + let mut records = moq_mux::hls::Producer::new( + broadcast + .create_track(hang::hls::default_name("video0"), moq_mux::hls::Producer::info()) + .unwrap(), + ); + let media = Records::write_media(&broadcast, "video0", 3); + for sequence in 0..3 { + records.push(&gop(sequence)).unwrap(); + } let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; @@ -2314,7 +1494,7 @@ mod tests { "pts 0 is anchored even without a catalog clock" ); - drop((media, catalog, broadcast)); + drop((media, records, catalog, broadcast)); } // The playlist copies the root clock when it first sees a rendition, so an fMP4 import must @@ -2335,7 +1515,7 @@ mod tests { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); + let catalog = hls_catalog(&mut broadcast); let mut import = moq_mux::container::fmp4::Import::new(broadcast, catalog.reserve()); import.decode(&init).unwrap(); @@ -2395,13 +1575,13 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn a_live_fmp4_import_serves_segments_after_timeline_declarations() { + async fn a_live_fmp4_import_serves_segments_to_a_late_edge() { live_fmp4_segments(None, true).await; } async fn live_fmp4_segments(version: Option<&str>, late: bool) { use moq_mux::container::fmp4::mp4_atom::{self, DecodeMaybe, Encode}; - // Reuse the A/V descriptors; opaque samples exercise timeline discovery and FETCH, + // Reuse the A/V descriptors; opaque samples exercise record discovery and FETCH, // without involving a codec decoder. let mut fixture = std::io::Cursor::new(include_bytes!("../../../moq-mux/src/container/fmp4/test_data/bbb.mp4").as_slice()); @@ -2450,7 +1630,7 @@ mod tests { let mut broadcast = origin.create_broadcast("live").unwrap(); broadcast.announce(Default::default()).unwrap(); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); + let catalog = hls_catalog(&mut broadcast); let mut import = moq_mux::container::fmp4::Import::new(broadcast, catalog.reserve()); import.decode(&init).unwrap(); let snapshot = catalog.snapshot(); @@ -2504,7 +1684,7 @@ mod tests { let rows = rendition.snapshot().segments; assert!(rows.len() >= 2, "multiple completed segments are listed"); for row in rows { - assert!(!row.gap, "{kind:?} segment {} has a timeline", row.segment); + assert!(!row.gap, "{kind:?} segment {} lines up", row.segment); let media = rendition.numbered(&row.tag, row.segment).await.unwrap(); assert!(media.is_some(), "listed segment {} must have media", row.segment); } @@ -2518,27 +1698,15 @@ mod tests { drop((import, catalog)); } - // A finished broadcast renders a static presentation, offset to its first listed segment. + // A finished broadcast renders a static presentation. #[tokio::test] async fn dash_manifest_turns_static_when_finished() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - registration.set(video_config()).unwrap(); - drop(reserved); - - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let mut catalog = hls_catalog(&mut broadcast); + let mut media = video(&catalog, &broadcast, "video0", video_config()); media.write(frame(0, true)).unwrap(); media.write(frame(2_000_000, true)).unwrap(); @@ -2546,7 +1714,7 @@ mod tests { let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.playable()).await; - // A clean finish promotes the live edge into the final segment and ends the windows. + // A clean finish stops the open segment and ends the records. media.finish().unwrap(); catalog.finish().unwrap(); @@ -2559,144 +1727,28 @@ mod tests { assert!(tokio::time::Instant::now() < deadline, "manifest never turned static"); tokio::time::sleep(Duration::from_millis(50)).await; }; - // The clean finish promoted the live edge into a final zero-duration segment (its - // single frame has no successor to bound it). HLS lists it as a trailing EXTINF:0, - // but a SegmentTimeline entry at the presentation end would never be requested, so - // the manifest omits it and the presentation ends at the last bounded segment. - assert!(manifest.contains("")); - assert!( - !manifest.contains(""), "{manifest}"); + assert!(!manifest.contains("= count { - return snapshot; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("{name} never listed {count} segments"); - } - }; - - let reference = listed("video0", 4).await; - assert_eq!( - reference.segments.iter().map(|s| s.segment).collect::>(), - vec![0, 1, 2, 3] - ); - - // A segment is listed once the rendition's own timeline is a tolerance past its end. - let offset = listed("video1", 3).await; - assert_eq!(offset.media_sequence, reference.media_sequence, "one numbering"); - assert!(offset.segments.iter().all(|s| !s.gap), "every boundary snaps"); - let snapped = broadcaster - .rendition(Kind::Video, "video1") - .unwrap() - .listed_segment(1) - .await - .unwrap() - .expect("the snapped segment is served"); - assert_eq!(&snapped[4..8], b"moof"); - - let late = listed("video2", 3).await; - assert_eq!( - late.segments.iter().map(|s| s.gap).collect::>(), - vec![true, true, false], - "nothing to serve before the rendition starts" - ); - let video2 = broadcaster.rendition(Kind::Video, "video2").unwrap(); - assert!( - video2.listed_segment(0).await.unwrap().is_none(), - "a gap is never fetched" - ); - - drop((producers, registrations, broadcast)); + drop((catalog, media, broadcast)); } - // A broadcast without video takes its boundaries from its first audio rendition. + // A broadcast without video segments on its audio alone. #[tokio::test] async fn an_audio_only_broadcast_segments_on_its_audio() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_audio("audio0").unwrap(); - registration - .set(hang::catalog::AudioConfig::new( - hang::catalog::AudioCodec::Opus, - 48_000, - 2, - )) - .unwrap(); - drop(reserved); - - let track = broadcast.create_track("audio0", timed()).unwrap(); - let mut audio = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); - // 500ms groups pack into records of the default 2s minimum. + let catalog = hls_catalog(&mut broadcast); + let mut audio = audio(&catalog, &broadcast, "audio0", opus()); + // 500ms groups pack into segments of the 2s minimum. for micros in (0..=6_000_000u64).step_by(500_000) { let keyframe = audio.needs_keyframe(); audio.write(frame(micros, keyframe)).unwrap(); @@ -2709,15 +1761,12 @@ mod tests { let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; let playlist = rendition.snapshot(); - assert_eq!( - playlist.segments.iter().map(|s| s.segment).collect::>(), - vec![0, 1, 2] - ); + assert_eq!(numbers(&playlist), [0, 1, 2]); assert_eq!(playlist.segments[0].duration, Duration::from_secs(2)); let segment = rendition.listed_segment(1).await.unwrap().expect("audio segment"); assert_eq!(&segment[4..8], b"moof"); - drop((audio, registration, broadcast)); + drop((audio, catalog, broadcast)); } // The declared duration is nominal, so the exporter derives EXT-X-TARGETDURATION from the @@ -2728,23 +1777,9 @@ mod tests { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - registration - .set(hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8)) - .unwrap(); - drop(reserved); - - // 3s GOPs against the default 1s minimum: every segment is one whole GOP. - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + // 3s GOPs against the 2s minimum: every segment is one whole GOP. + let mut media = video(&catalog, &broadcast, "video0", inline_vp8()); media.write(frame(0, true)).unwrap(); media.write(frame(3_000_000, true)).unwrap(); media.write(frame(6_000_000, true)).unwrap(); @@ -2756,42 +1791,22 @@ mod tests { let playlist = rendition.snapshot(); assert_eq!(playlist.segments[0].duration, Duration::from_secs(3)); - assert_eq!( - playlist.target_duration, 3, - "the target duration covers the 3s segment, not the 10s split ceiling" - ); + assert_eq!(playlist.target_duration, 3, "the target duration covers the 3s segment"); + drop((media, catalog, broadcast)); } + // The property HLS needs: audio and video renditions share one segment numbering, cut at + // the same boundaries. Video is one group per segment; an audio segment packs every audio + // group inside the video segment's span. #[tokio::test] async fn audio_and_video_segments_are_aligned() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut video_registration = catalog.test_video("video0").unwrap(); - video_registration.set(video_config()).unwrap(); - let mut audio_registration = catalog.test_audio("audio0").unwrap(); - let audio_config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); - audio_registration.set(audio_config).unwrap(); - drop(reserved); - - let video_track = broadcast.create_track("video0", timed()).unwrap(); - let mut video = catalog - .media_producer( - video_track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); - let audio_track = broadcast.create_track("audio0", timed()).unwrap(); - let mut audio = catalog - .media_producer( - audio_track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + let mut video = video(&catalog, &broadcast, "video0", video_config()); + let mut audio = audio(&catalog, &broadcast, "audio0", opus()); // Interleaved by pts, as a muxer would demux them: video keyframes every 2s open the // segments; audio publishes a 500ms group each cut. @@ -2802,14 +1817,14 @@ mod tests { }; video.write(frame(0, true)).unwrap(); // segment 0 for micros in [0u64, 500_000, 1_000_000, 1_500_000] { - audio_write(micros); // audio groups 0..=3 pack into segment 0 + audio_write(micros); } video.write(frame(2_000_000, true)).unwrap(); // segment 1 for micros in [2_000_000u64, 2_500_000, 3_000_000, 3_500_000] { - audio_write(micros); // audio groups 4..=7 pack into segment 1 + audio_write(micros); } video.write(frame(4_000_000, true)).unwrap(); // segment 2 (live edge) - audio_write(4_000_000); // audio group 8 opens its slice of segment 2 + audio_write(4_000_000); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; @@ -2822,21 +1837,15 @@ mod tests { let video_playlist = video_rendition.snapshot(); let audio_playlist = audio_rendition.snapshot(); assert_eq!(video_playlist.media_sequence, audio_playlist.media_sequence); - let video_segments: Vec = video_playlist.segments.iter().map(|s| s.segment).collect(); - let audio_segments: Vec = audio_playlist.segments.iter().map(|s| s.segment).collect(); - assert_eq!(video_segments, vec![0, 1]); - assert_eq!(audio_segments, vec![0, 1], "audio lists the same aligned segments"); + assert_eq!(numbers(&video_playlist), [0, 1]); + assert_eq!(numbers(&audio_playlist), [0, 1], "audio lists the same aligned segments"); + assert!(gaps(&audio_playlist).is_empty()); assert_eq!( audio_playlist.segments[0].duration, Duration::from_secs(2), "cut at the video boundary" ); - // The same URI names the same span of content time on either rendition. - let rendered = audio_rendition.media_playlist(None).unwrap().expect("playable"); - assert!(rendered.contains("seg/656e3d1b.0.m4s\n")); - assert!(rendered.contains("seg/656e3d1b.1.m4s\n")); - // A video segment is one group; the matching audio segment transmuxes its four groups. let video_segment = video_rendition.listed_segment(1).await.unwrap().expect("video segment"); assert_eq!(&video_segment[4..8], b"moof"); @@ -2847,39 +1856,24 @@ mod tests { "the audio segment packs multiple groups into one fragment" ); - drop((video, audio, video_registration, audio_registration, broadcast)); + drop((video, audio, catalog, broadcast)); } // Dropping the broadcaster must not truncate a recording in progress. A cursor holds its - // rendition alive, so the rendition's own watcher still ends the timeline with `end()` -- - // which is what promotes the live-edge record into the final segment. Force-closing the - // rendition here instead would race that (`end()` no-ops on a closed channel) and silently - // drop the last segment of every recording. + // rendition alive, so the rendition's own watcher still lists the final segment once the + // publisher finishes. #[tokio::test] async fn dropping_the_broadcaster_keeps_a_cursor_drainable() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - let config = video_config(); - registration.set(config).unwrap(); - drop(reserved); - - // Two GOPs: group 0 is complete, while group 1 stays at the live edge until the - // publisher finishes. - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let mut catalog = hls_catalog(&mut broadcast); + // Segment 0 is complete, while segment 1 stays open until the publisher finishes. + let mut media = video(&catalog, &broadcast, "video0", video_config()); media.write(frame(0, true)).unwrap(); media.write(frame(2_000_000, true)).unwrap(); + media.write(frame(3_000_000, false)).unwrap(); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; @@ -2891,71 +1885,83 @@ mod tests { // The recorder drops the broadcaster before finalizing its uploads. drop(broadcaster); - // Finish the source after teardown, forcing the broadcaster's drop to land before the - // rendition watcher's clean-end path. media.finish().unwrap(); catalog.finish().unwrap(); - let mut groups = Vec::new(); + let mut listed = Vec::new(); while let Some(segment) = tokio::time::timeout(Duration::from_secs(5), segments.next()) .await .expect("the cursor drains without parking") .unwrap() { - groups.push(segment.segment); + listed.push(segment.segment); } - assert!( - groups.contains(&1), - "the final segment survives dropping the broadcaster, got {groups:?}" - ); - - drop((catalog, media, registration, broadcast, rendition)); - } + assert_eq!(listed, [0, 1], "the final segment survives dropping the broadcaster"); - /// Whether `haystack` carries `needle` verbatim, i.e. whether a transmuxed segment carries a - /// particular publisher's frame payloads. - fn contains(haystack: &bytes::Bytes, needle: &[u8]) -> bool { - haystack.windows(needle.len()).any(|window| window == needle) + drop((catalog, media, broadcast, rendition)); } - /// Reconcile a one-video-rendition catalog snapshot by hand and drive the timeline, which is - /// what `watch_catalog` does with each snapshot. - fn export( - upstream: &moq_mux::Source, - config: &hang::catalog::VideoConfig, - ) -> (Arc, tokio::task::JoinHandle<()>) { + /// A one-video-rendition catalog snapshot reconciled by hand, which is what `watch_catalog` + /// does with each snapshot: the rendition follows its records on its own. + fn export(upstream: &moq_mux::Source, config: &hang::catalog::VideoConfig) -> (renditions::Producer, Arc) { + let mut config = config.clone(); + config.hls = Some(hls("video0")); let mut catalog = moq_mux::catalog::hang::Catalog::default(); - catalog.video.renditions.insert("video0".to_string(), config.clone()); - let archive = archive(&["video0", "audio0"]); - catalog.archive = Some(archive.clone()); - + catalog.video.renditions.insert("video0".to_string(), config); let renditions = renditions::Producer::new(Config::default().window); renditions.sync(upstream, &catalog); - let reference = Arc::new((Kind::Video, "video0".to_string(), "video0".to_string())); - let watcher = tokio::spawn(watch_timeline( - upstream.broadcast(), - archive, - reference, - renditions.fanout(), - )); let rendition = renditions.get(Kind::Video, "video0").expect("rendition synced"); - (rendition, watcher) + (renditions, rendition) + } + + /// Publish `video0`'s records on `broadcast`. + fn records(broadcast: &moq_net::broadcast::Producer) -> (moq_net::track::Producer, moq_mux::hls::Producer) { + let track = broadcast + .create_track(hang::hls::default_name("video0"), moq_mux::hls::Producer::info()) + .unwrap(); + (track.clone(), moq_mux::hls::Producer::new(track)) + } + + /// Write three keyframes of `payload` into `broadcast`'s `video0` track, 2s apart from + /// `start_micros`, with the records numbering them from `sequence` (when given). + fn write_routed_media( + broadcast: &mut moq_net::broadcast::Producer, + payload: &'static [u8], + records: Option<(&mut moq_mux::hls::Producer, u64)>, + start_micros: u64, + ) -> moq_mux::container::Producer { + let track = broadcast.create_track("video0", timed()).unwrap(); + let mut media = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), + ); + for group in 0..3 { + let micros = start_micros + group * 2_000_000; + media.write(payload_frame(micros, true, payload)).unwrap(); + } + if let Some((records, sequence)) = records { + for group in 0..3 { + let pts = (start_micros + group * 2_000_000) / 1_000; + records + .push(&hang::hls::Record::new(sequence + group, group, pts)) + .unwrap(); + } + } + media } struct Ended { rendition: Arc, - watcher: tokio::task::JoinHandle<()>, origin: moq_net::origin::Producer, _held: Box, } - /// Finish the media before End while retaining its track cache for a late recorder. + /// Finish the media and its records before End while retaining its track cache for a late + /// recorder. async fn ended_rendition(sibling: bool) -> Ended { - const KEYFRAME: &[u8] = &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]; let origin = produce_origin(); - let mut broadcast = origin.create_broadcast("live").unwrap(); + let broadcast = origin.create_broadcast("live").unwrap(); broadcast.announce(Default::default()).unwrap(); - let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); let mut target = if sibling { let target = origin.create_broadcast("media").unwrap(); target.announce(Default::default()).unwrap(); @@ -2963,19 +1969,20 @@ mod tests { } else { broadcast.clone() }; - let recorder = catalog.enroll_test("video0").unwrap(); - let mut media = write_routed_media(&mut target, KEYFRAME, recorder, 0); + let (_track, mut records) = records(&broadcast); + let mut media = write_routed_media(&mut target, KEYFRAME, Some((&mut records, 0)), 0); media.finish().unwrap(); - catalog.finish().unwrap(); + records.push(&stop(3, 6_000)).unwrap(); + records.finish().unwrap(); settle().await; let source = crate::resolved(&origin, "live").await; - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + let mut config = inline_vp8(); if sibling { config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); } - let (rendition, watcher) = export(&source, &config); - rendition.playable().await; + let (renditions, rendition) = export(&source, &config); + until_finished(&rendition).await; assert!(!rendition.snapshot().segments.is_empty()); let binding = rendition.poll_media(&kio::Waiter::noop()); let bound = binding.broadcast().await.unwrap(); @@ -2987,9 +1994,8 @@ mod tests { ); Ended { rendition, - watcher, origin, - _held: Box::new((catalog, media, broadcast, target)), + _held: Box::new((renditions, media, broadcast, target, records)), } } @@ -2998,10 +2004,8 @@ mod tests { async fn init_bootstraps_after_the_catalog_broadcast_ends() { tokio::time::pause(); let ended = ended_rendition(false).await; - let result = ended.rendition.latest_init().await; - ended.watcher.abort(); - let init = result.unwrap().expect("inline init bootstraps after End"); - assert!(contains(&init, b"ftyp")); + let init = ended.rendition.latest_init().await.unwrap(); + assert!(contains(&init.expect("inline init bootstraps after End"), b"ftyp")); } #[tokio::test] @@ -3009,7 +2013,6 @@ mod tests { tokio::time::pause(); let ended = ended_rendition(false).await; let result = ended.rendition.listed_segment(0).await; - ended.watcher.abort(); assert!(result.unwrap().is_some(), "retained segment drains after End"); } @@ -3018,7 +2021,6 @@ mod tests { tokio::time::pause(); let ended = ended_rendition(true).await; let result = ended.rendition.latest_init().await; - ended.watcher.abort(); assert!(result.unwrap().is_some(), "sibling init bootstraps after End"); } @@ -3027,7 +2029,6 @@ mod tests { tokio::time::pause(); let ended = ended_rendition(true).await; let result = ended.rendition.listed_segment(0).await; - ended.watcher.abort(); assert!(result.unwrap().is_some(), "retained sibling segment drains after End"); } @@ -3036,7 +2037,6 @@ mod tests { #[tokio::test] async fn retained_media_after_replacement_keeps_its_original_instance() { tokio::time::pause(); - const OLD: &[u8] = &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]; const NEW: &[u8] = &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01]; for sibling in [false, true] { let ended = ended_rendition(sibling).await; @@ -3059,8 +2059,7 @@ mod tests { .fetch_group(0, None) .await .unwrap(); - let config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - let mut muxer = moq_mux::container::fmp4::Muxer::video(&config).unwrap(); + let mut muxer = moq_mux::container::fmp4::Muxer::video(&inline_vp8()).unwrap(); let frames = muxer.read(&mut group).await.unwrap(); assert!(frames.iter().any(|frame| frame.payload.as_ref() == NEW)); let next_init = muxer.init().unwrap().unwrap(); @@ -3073,12 +2072,11 @@ mod tests { let mut cursor = ended.rendition.segments(); let mut drained = Vec::new(); while let Some(segment) = cursor.next().await.unwrap() { - assert!(contains(&segment.media, OLD)); + assert!(contains(&segment.media, KEYFRAME)); assert!(!contains(&segment.media, NEW)); drained.push(segment.segment); } assert_eq!(drained, [0, 1, 2], "the recorder drains the complete retained tail"); - ended.watcher.abort(); } } @@ -3094,36 +2092,19 @@ mod tests { // Publish broadcast "live": a `video0` rendition plus three keyframe groups whose frames // carry `payload`, so the two epochs' media differs byte for byte. The returned handle // keeps the publisher alive until it is dropped. - fn publish( - origin: &moq_net::origin::Producer, - payload: &'static [u8], - ) -> (Box, hang::catalog::VideoConfig) { + fn publish(origin: &moq_net::origin::Producer, payload: &'static [u8]) -> Box { let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - let config = video_config(); - registration.set(config.clone()).unwrap(); - drop(reserved); - - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + let mut media = video(&catalog, &broadcast, "video0", video_config()); media.write(payload_frame(0, true, payload)).unwrap(); media.write(payload_frame(2_000_000, true, payload)).unwrap(); media.write(payload_frame(4_000_000, true, payload)).unwrap(); - - (Box::new((broadcast, catalog, registration, media)), config) + Box::new((broadcast, catalog, media)) } let origin = produce_origin(); - let (old, config) = publish(&origin, OLD); + let old = publish(&origin, OLD); settle().await; // The export binds to the broadcast it reads the catalog from. @@ -3136,10 +2117,10 @@ mod tests { // publisher back before then would resume it instead. drop(old); settle().await; - let (new, _) = publish(&origin, NEW); + let new = publish(&origin, NEW); settle().await; - let (rendition, watcher) = export(&upstream, &config); + let (_renditions, rendition) = export(&upstream, &video_config()); let _ = tokio::time::timeout(Duration::from_secs(1), rendition.playable()).await; let served = rendition.listed_segment(0).await.unwrap(); if let Some(served) = &served { @@ -3148,80 +2129,54 @@ mod tests { "the replacement publisher's media was served under the replaced broadcast's catalog" ); } - assert!( - served.is_none(), - "the replaced broadcast is closed, so it serves nothing" - ); - watcher.abort(); + assert!(served.is_none(), "the replaced broadcast is closed, so it serves nothing"); // Control: an export started after the takeover does serve the new publisher's media, so // the assertion above is about which epoch a rendition is bound to, not about a broadcast // that happens to be unreadable. let fresh = crate::resolved(&origin, "live").await; - let (rendition, watcher) = export(&fresh, &config); + let (_renditions, rendition) = export(&fresh, &video_config()); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await - .expect("the new publisher's timeline arrives"); + .expect("the new publisher's records arrive"); let served = rendition .listed_segment(0) .await .unwrap() .expect("the new publisher is servable on its own catalog"); - assert!( - contains(&served, NEW), - "the new export serves the new publisher's media" - ); - watcher.abort(); + assert!(contains(&served, NEW), "the new export serves the new publisher's media"); drop(new); } // The sibling half of the case above. A rendition whose catalog names another broadcast is // bound to whatever serves that path when the rendition is created, not when its first - // segment is requested: the timeline on the catalog broadcast keeps describing the epoch it - // was written for, so a media publisher that reconnects (restarting its group numbering) - // must not answer for segments the old catalog already described. + // segment is requested: the records on the catalog broadcast keep describing the instance + // they were written for, so a media publisher that reconnects (restarting its group + // numbering) must not answer for segments the old records already described. #[tokio::test] async fn a_replacement_sibling_is_not_served_under_the_replaced_catalog() { const OLD: &[u8] = b"OLDOLDOLDOLDOLDO"; const NEW: &[u8] = b"NEWNEWNEWNEWNEWN"; - // Publish broadcast "media": a `video0` track of three keyframe groups whose frames carry - // `payload`, so the two epochs' media differs byte for byte. `recorder` enrolls the track - // in the *catalog* broadcast's timeline; the replacement publishes without one, exactly - // as a reconnecting media publisher does before the catalog broadcast catches up. fn publish_media( origin: &moq_net::origin::Producer, payload: &'static [u8], - recorder: Option, + records: Option<&mut moq_mux::hls::Producer>, ) -> Box { - let broadcast = origin.create_broadcast("media").expect("publish allowed"); + let mut broadcast = origin.create_broadcast("media").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); - let track = broadcast.create_track("video0", timed()).unwrap(); - - let mut media = moq_mux::container::Producer::new( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ); - if let Some(recorder) = recorder { - media = media.with_recorder(recorder); - } - media.write(payload_frame(0, true, payload)).unwrap(); - media.write(payload_frame(2_000_000, true, payload)).unwrap(); - media.write(payload_frame(4_000_000, true, payload)).unwrap(); - + let media = write_routed_media(&mut broadcast, payload, records.map(|records| (records, 0)), 0); Box::new((broadcast, media)) } let origin = produce_origin(); - // The catalog broadcast carries the catalog and the timeline; the media lives next door. - let mut live = origin.create_broadcast("live").expect("publish allowed"); + // The catalog broadcast carries the records; the media lives next door. + let live = origin.create_broadcast("live").expect("publish allowed"); live.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let old_media = publish_media(&origin, OLD, Some(recorder)); + let (_track, mut records) = records(&live); + let old_media = publish_media(&origin, OLD, Some(&mut records)); settle().await; let upstream = crate::resolved(&origin, "live").await; @@ -3231,10 +2186,10 @@ mod tests { let mut config = video_config(); config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); - let (rendition, watcher) = export(&upstream, &config); + let (_renditions, rendition) = export(&upstream, &config); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await - .expect("the timeline reaches the rendition"); + .expect("the records reach the rendition"); // The media publisher goes away and comes back before any segment is requested, as a // brand new broadcast whose group numbering restarts from zero. @@ -3255,28 +2210,21 @@ mod tests { } let served = served.expect("the old sibling's retained media drains after replacement"); assert!(contains(&served, OLD)); - watcher.abort(); - // Control: an export started after the takeover does serve the new publisher's media, so - // the assertion above is about which epoch the rendition is bound to, not about a - // broadcast that happens to be unreadable. + // Control: an export started after the takeover does serve the new publisher's media. let fresh = crate::resolved(&origin, "live").await; - let (rendition, watcher) = export(&fresh, &config); + let (_renditions, rendition) = export(&fresh, &config); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await - .expect("the timeline reaches the fresh rendition"); + .expect("the records reach the fresh rendition"); let served = rendition .listed_segment(0) .await .unwrap() .expect("the new publisher is servable on a freshly bound rendition"); - assert!( - contains(&served, NEW), - "the new export serves the new publisher's media" - ); - watcher.abort(); + assert!(contains(&served, NEW), "the new export serves the new publisher's media"); - drop((new_media, catalog, live)); + drop((new_media, records, live)); } fn hops(ids: &[u64]) -> moq_net::Hops { @@ -3291,29 +2239,6 @@ mod tests { moq_net::origin::Route::default().with_hops(hops(&[first])) } - /// Write three keyframes of `payload` into `broadcast`'s `video0` track, recording each. - fn write_routed_media( - broadcast: &mut moq_net::broadcast::Producer, - payload: &'static [u8], - recorder: moq_mux::timeline::Recorder, - start_micros: u64, - ) -> moq_mux::container::Producer { - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = moq_mux::container::Producer::new( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .with_recorder(recorder); - media.write(payload_frame(start_micros, true, payload)).unwrap(); - media - .write(payload_frame(start_micros + 2_000_000, true, payload)) - .unwrap(); - media - .write(payload_frame(start_micros + 4_000_000, true, payload)) - .unwrap(); - media - } - async fn until_finished(rendition: &Rendition) { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { @@ -3322,59 +2247,83 @@ mod tests { } assert!( tokio::time::Instant::now() < deadline, - "the old sibling's playlist did not end" + "the playlist did not end" ); tokio::task::yield_now().await; } } + /// A sibling-media fixture: the catalog broadcast `live` carries `video0`'s records, and the + /// media lives in the sibling broadcast `media`. + struct Sibling { + origin: moq_net::origin::Producer, + _live: moq_net::broadcast::Producer, + records: moq_mux::hls::Producer, + upstream: moq_mux::Source, + config: hang::catalog::VideoConfig, + } + + impl Sibling { + async fn new() -> Self { + let origin = produce_origin(); + let live = origin.create_broadcast("live").expect("publish allowed"); + live.announce(Default::default()).expect("publish allowed"); + let (_track, records) = records(&live); + settle().await; + let upstream = crate::resolved(&origin, "live").await; + let mut config = video_config(); + config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); + Self { + origin, + _live: live, + records, + upstream, + config, + } + } + + /// Publish the sibling's media and its records, unannounced. + fn media( + &mut self, + payload: &'static [u8], + sequence: u64, + start_micros: u64, + ) -> ( + moq_net::broadcast::Producer, + moq_mux::container::Producer, + ) { + let mut media = self.origin.create_broadcast("media").unwrap(); + let track = write_routed_media(&mut media, payload, Some((&mut self.records, sequence)), start_micros); + (media, track) + } + } + // A bound sibling ends the playlist while its retained rows drain. Another publisher at - // that path is never bound, so its bytes cannot answer the old timeline. + // that path is never bound, so its bytes cannot answer the old records. #[tokio::test] async fn a_replaced_sibling_ends_the_rendition() { tokio::time::pause(); const OLD: &[u8] = b"OLDOLDOLDOLDOLDO"; const NEW: &[u8] = b"NEWNEWNEWNEWNEWN"; - let origin = produce_origin(); - let mut live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let mut old_media = origin.create_broadcast("media").unwrap(); - let _old_track = write_routed_media(&mut old_media, OLD, recorder, 0); + let mut test = Sibling::new().await; + let (old_media, old_track) = test.media(OLD, 0, 0); settle().await; - - let upstream = crate::resolved(&origin, "live").await; - let mut config = video_config(); - config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); - - let mut snapshot = moq_mux::catalog::hang::Catalog::default(); - snapshot.video.renditions.insert("video0".to_string(), config.clone()); - let archive = archive(&["video0"]); - snapshot.archive = Some(archive.clone()); - let renditions = renditions::Producer::new(Config::default().window); - renditions.sync(&upstream, &snapshot); - let fanout = renditions.fanout(); - let reference = Arc::new((Kind::Video, "video0".to_string(), "video0".to_string())); - let watcher = tokio::spawn(watch_timeline(upstream.broadcast(), archive, reference, fanout.clone())); - let rendition = renditions.get(Kind::Video, "video0").unwrap(); + let (_renditions, rendition) = export(&test.upstream, &test.config); old_media.announce(sibling_route(10)).unwrap(); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await - .expect("the timeline reaches the rendition"); + .expect("the records reach the rendition"); let served = rendition .listed_segment(0) .await .unwrap() .expect("the original sibling is servable"); assert!(contains(&served, OLD), "the first hop serves the original publisher"); - assert!(!rendition.snapshot().segments.is_empty()); let mut early = rendition.segments(); assert_eq!(early.next().await.unwrap().unwrap().discontinuity, 0); - drop((old_media, _old_track)); + drop((old_media, old_track)); until_finished(&rendition).await; let served = rendition .listed_segment(0) @@ -3382,24 +2331,19 @@ mod tests { .unwrap() .expect("the retained old segment drains"); assert!(contains(&served, OLD)); - assert!(rendition.snapshot().finished, "the playlist ends with its sibling"); while let Some(segment) = early.next().await.unwrap() { assert!(contains(&segment.media, OLD)); } + let before = rendition.snapshot().segments.len(); - // A publisher serving the path afterwards, with rows recorded for it, is never bound. - let mut new_media = origin.create_broadcast("media").unwrap(); + // A publisher serving the path afterwards, with records written for it, is never bound. + let (new_media, new_track) = test.media(NEW, 3, 6_000_000); new_media.announce(sibling_route(11)).unwrap(); - let _ = rendition.snapshot(); - let recorder = catalog.enroll_test("video0").unwrap(); - fanout.skip(None); - let _new_track = write_routed_media(&mut new_media, NEW, recorder, 6_000_000); tokio::time::sleep(Duration::from_secs(1)).await; let snapshot = rendition.snapshot(); - assert!(snapshot.segments.is_empty(), "nothing of the replacement is listed"); + assert_eq!(snapshot.segments.len(), before, "nothing of the replacement is listed"); assert!(snapshot.finished); - watcher.abort(); - drop((new_media, _new_track, catalog, live)); + drop((new_media, new_track, test)); } // A restart can land while a segment request waits for its media broadcast. The init @@ -3407,36 +2351,18 @@ mod tests { #[tokio::test] async fn a_segment_straddling_a_restart_keeps_its_init_out_of_the_new_run() { tokio::time::pause(); - // A VP8 keyframe whose 320x240 geometry is only known inline. - const KEYFRAME: &[u8] = &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]; - - let origin = produce_origin(); - let mut live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let mut media = origin.create_broadcast("media").unwrap(); - let _track = write_routed_media(&mut media, KEYFRAME, recorder, 0); + let mut test = Sibling::new().await; + test.config = { + let mut config = inline_vp8(); + config.broadcast = test.config.broadcast.clone(); + config + }; + let (media, _track) = test.media(KEYFRAME, 0, 0); settle().await; - - let upstream = crate::resolved(&origin, "live").await; - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); - - let mut snapshot = moq_mux::catalog::hang::Catalog::default(); - snapshot.video.renditions.insert("video0".to_string(), config); - let archive = archive(&["video0"]); - snapshot.archive = Some(archive.clone()); - let renditions = renditions::Producer::new(Config::default().window); - renditions.sync(&upstream, &snapshot); - let fanout = renditions.fanout(); - let reference = Arc::new((Kind::Video, "video0".to_string(), "video0".to_string())); - let watcher = tokio::spawn(watch_timeline(upstream.broadcast(), archive, reference, fanout.clone())); - let rendition = renditions.get(Kind::Video, "video0").unwrap(); + let (renditions, rendition) = export(&test.upstream, &test.config); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await - .expect("the timeline reaches the rendition"); + .expect("the records reach the rendition"); // The sibling is unannounced, so the segment waits for its media broadcast. let mut segment = std::pin::pin!(rendition.listed_segment(0)); @@ -3445,8 +2371,8 @@ mod tests { .await .is_err() ); - fanout.set_generation(Some("run-1".into())); - fanout.set_generation(Some("run-2".into())); + renditions.fanout().set_generation(Some("run-1".into())); + renditions.fanout().set_generation(Some("run-2".into())); media.announce(sibling_route(10)).unwrap(); segment @@ -3457,7 +2383,6 @@ mod tests { rendition.latest_init().await.unwrap().is_none(), "the new run has no media of its own yet" ); - watcher.abort(); } // A replacement already present when the export starts is the sibling the request @@ -3466,33 +2391,21 @@ mod tests { async fn a_sibling_present_at_export_start_is_served() { const NEW: &[u8] = b"NEWNEWNEWNEWNEWN"; - let origin = produce_origin(); - let mut live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let mut media = origin.create_broadcast("media").unwrap(); - let _track = write_routed_media(&mut media, NEW, recorder, 0); + let mut test = Sibling::new().await; + let (media, _track) = test.media(NEW, 0, 0); media.announce(sibling_route(11)).unwrap(); settle().await; - let upstream = crate::resolved(&origin, "live").await; - let mut config = video_config(); - config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); - - let (rendition, watcher) = export(&upstream, &config); + let (_renditions, rendition) = export(&test.upstream, &test.config); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await - .expect("the timeline reaches the rendition"); + .expect("the records reach the rendition"); let served = rendition .listed_segment(0) .await .unwrap() .expect("the sibling present at export start is servable"); assert!(contains(&served, NEW)); - watcher.abort(); - drop((media, _track, catalog, live)); } // The replacement is often announced after the incumbent is already gone. Nothing serves @@ -3504,43 +2417,23 @@ mod tests { const OLD: &[u8] = b"OLDOLDOLDOLDOLDO"; const NEW: &[u8] = b"NEWNEWNEWNEWNEWN"; - let origin = produce_origin(); - let mut live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let mut old_media = origin.create_broadcast("media").unwrap(); - let _old_track = write_routed_media(&mut old_media, OLD, recorder, 0); + let mut test = Sibling::new().await; + let (old_media, old_track) = test.media(OLD, 0, 0); settle().await; - - let upstream = crate::resolved(&origin, "live").await; - let mut config = video_config(); - config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); - - let (rendition, watcher) = export(&upstream, &config); + let (_renditions, rendition) = export(&test.upstream, &test.config); old_media.announce(sibling_route(10)).unwrap(); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await - .expect("the timeline reaches the rendition"); + .expect("the records reach the rendition"); assert!(rendition.listed_segment(0).await.unwrap().is_some()); - drop((old_media, _old_track)); + drop((old_media, old_track)); until_finished(&rendition).await; let before = rendition.snapshot().segments.len(); assert!(before > 0); - let served = rendition - .listed_segment(0) - .await - .unwrap() - .expect("the old retained segment drains"); - assert!(contains(&served, OLD)); - let mut new_media = origin.create_broadcast("media").unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - let _new_track = write_routed_media(&mut new_media, NEW, recorder, 6_000_000); + let (new_media, new_track) = test.media(NEW, 3, 6_000_000); new_media.announce(sibling_route(11)).unwrap(); - let _ = rendition.snapshot(); tokio::time::sleep(Duration::from_secs(1)).await; let snapshot = rendition.snapshot(); assert_eq!(snapshot.segments.len(), before, "only the old rows remain listed"); @@ -3552,34 +2445,24 @@ mod tests { .expect("only old media drains"); assert!(contains(&served, OLD)); assert!(!contains(&served, NEW)); - watcher.abort(); - drop((new_media, _new_track, catalog, live)); + drop((new_media, new_track)); } // A recorder waiting at the live edge ends when the bound sibling ends, with nothing else - // (a playlist request, a timeline update) touching the rendition. + // (a playlist request, a record) touching the rendition. #[tokio::test] async fn a_cursor_at_the_edge_ends_when_its_sibling_ends() { tokio::time::pause(); const OLD: &[u8] = b"OLDOLDOLDOLDOLDO"; - let origin = produce_origin(); - let mut live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - let mut media = origin.create_broadcast("media").unwrap(); - let track = write_routed_media(&mut media, OLD, recorder, 0); + let mut test = Sibling::new().await; + let (media, track) = test.media(OLD, 0, 0); settle().await; - - let upstream = crate::resolved(&origin, "live").await; - let mut config = video_config(); - config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); - let (rendition, watcher) = export(&upstream, &config); + let (_renditions, rendition) = export(&test.upstream, &test.config); media.announce(sibling_route(10)).unwrap(); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await - .expect("the timeline reaches the rendition"); + .expect("the records reach the rendition"); let mut cursor = rendition.segments(); loop { @@ -3593,15 +2476,14 @@ mod tests { } } - // Only the sibling broadcast goes: its recorder stays, so the timeline stays quiet. + // Only the sibling broadcast goes: its records stay quiet. drop(media); let end = tokio::time::timeout(Duration::from_secs(5), cursor.next()) .await .expect("the cursor wakes when its sibling ends") .unwrap(); assert!(end.is_none(), "the cursor ends with its sibling"); - watcher.abort(); - drop((track, catalog, live)); + drop(track); } // A sibling not yet announced when the rendition binds it is waited for. The catalog's rows @@ -3611,25 +2493,14 @@ mod tests { tokio::time::pause(); const OLD: &[u8] = b"OLDOLDOLDOLDOLDO"; - let origin = produce_origin(); - let mut live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - let mut media = origin.create_broadcast("media").unwrap(); - let _track = write_routed_media(&mut media, OLD, recorder, 0); + let mut test = Sibling::new().await; + let (media, _track) = test.media(OLD, 0, 0); settle().await; - - let upstream = crate::resolved(&origin, "live").await; - let mut config = video_config(); - config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); - - let (rendition, watcher) = export(&upstream, &config); + let (_renditions, rendition) = export(&test.upstream, &test.config); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await .expect("the catalog's rows are listed before the sibling resolves"); - let _ = rendition.snapshot(); media.announce(sibling_route(10)).unwrap(); let served = rendition .listed_segment(0) @@ -3637,13 +2508,11 @@ mod tests { .unwrap() .expect("the late sibling is servable"); assert!(contains(&served, OLD)); - watcher.abort(); - drop((media, _track, catalog, live)); } // A rendition the catalog drops must end its segment cursors. The cursor holds an - // `Arc`, so without an explicit close the rendition (and its timeline - // subscription) would stay alive and the cursor would park at the live edge forever. + // `Arc`, so without an explicit end the rendition (and its record subscription) + // would stay alive and the cursor would park at the live edge forever. #[tokio::test] async fn removing_a_rendition_ends_its_segment_cursor() { let origin = produce_origin(); @@ -3653,20 +2522,11 @@ mod tests { let upstream = crate::resolved(&origin, "live").await; // Drive the producer directly so the catalog can be reconciled synchronously. - let renditions = renditions::Producer::new(Config::default().window); - let media = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - let mut catalog = moq_mux::catalog::hang::Catalog::default(); - catalog.video.renditions.insert("video0".to_string(), media); - catalog.archive = Some(archive(&["video0", "audio0"])); - renditions.sync(&upstream, &catalog); - - let rendition = renditions.get(Kind::Video, "video0").expect("rendition synced"); + let (renditions, rendition) = export(&upstream, &inline_vp8()); let mut segments = rendition.segments(); // The catalog drops the rendition: its cursor must run dry rather than park. - let mut empty = moq_mux::catalog::hang::Catalog::default(); - empty.archive = Some(archive(&["video0", "audio0"])); - renditions.sync(&upstream, &empty); + renditions.sync(&upstream, &moq_mux::catalog::hang::Catalog::default()); let ended = tokio::time::timeout(Duration::from_secs(5), segments.next()) .await .expect("a removed rendition's cursor ends instead of parking") @@ -3676,73 +2536,16 @@ mod tests { drop((broadcast, rendition)); } - // A rendition is hidden until its own timeline is advertised, then added beside its siblings. + // Dropping the broadcaster must release its renditions even when a cursor (and an + // `Arc` it was handed) outlives it. #[tokio::test] - async fn a_rendition_appears_once_its_timeline_is_advertised() { + async fn dropping_the_broadcaster_releases_its_renditions() { let origin = produce_origin(); - let broadcast = origin.create_broadcast("live").expect("publish allowed"); + let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let upstream = crate::resolved(&origin, "live").await; - - let renditions = renditions::Producer::new(Config::default().window); - let mut cursor = renditions.subscribe(); - let mut catalog = moq_mux::catalog::hang::Catalog::default(); - catalog.video.renditions.insert("video0".to_string(), video_config()); - catalog.audio.renditions.insert( - "audio0".to_string(), - hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2), - ); - catalog.archive = Some(archive(&["video0"])); - renditions.sync(&upstream, &catalog); - - let video = renditions.get(Kind::Video, "video0").expect("video has a timeline"); - assert!( - renditions.get(Kind::Audio, "audio0").is_none(), - "audio has no timeline yet" - ); - assert!( - matches!(next_event(&mut cursor).await, Some(renditions::Event::Added(added)) if added.kind == Kind::Video) - ); - assert!(next_event(&mut cursor).await.is_none(), "audio is not announced early"); - - catalog.archive = Some(archive(&["video0", "audio0"])); - renditions.sync(&upstream, &catalog); - assert!( - matches!(next_event(&mut cursor).await, Some(renditions::Event::Added(added)) if added.kind == Kind::Audio) - ); - let same = renditions.get(Kind::Video, "video0").unwrap(); - assert!(Arc::ptr_eq(&video, &same), "the video sibling keeps its identity"); - assert!(renditions.get(Kind::Audio, "audio0").is_some()); - - drop(broadcast); - } - - // Dropping the broadcaster must release its renditions even when a cursor (and an - // `Arc` it was handed) outlives it. The map lives in shared state, so without an - // explicit teardown those would pin every rendition's timeline watcher -- and the standing - // source subscription it holds -- for as long as the consumer lived. - #[tokio::test] - async fn dropping_the_broadcaster_releases_its_renditions() { - let origin = produce_origin(); - let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); - broadcast.announce(Default::default()).expect("publish allowed"); - settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - let config = video_config(); - registration.set(config).unwrap(); - drop(reserved); - - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + let mut media = video(&catalog, &broadcast, "video0", video_config()); media.write(frame(0, true)).unwrap(); media.write(frame(2_000_000, true)).unwrap(); @@ -3767,41 +2570,27 @@ mod tests { "dropping the broadcaster releases its renditions to a live cursor" ); - // ...and the cursor then reaches a terminal state rather than parking. Releasing the - // renditions is only safe if consumers actually finish. + // ...and the cursor then reaches a terminal state rather than parking. let ended = tokio::time::timeout(Duration::from_secs(5), renditions.next()) .await .expect("the cursor terminates after the broadcaster drops"); assert!(matches!(ended, Err(crate::Error::IncompleteRecording))); - drop((catalog, media, registration, broadcast)); + drop((catalog, media, broadcast)); } // The record path: the renditions cursor yields the video rendition, its segment cursor - // yields each finalized segment with its media, and losing the publisher mid-group fails - // both cursors instead of completing a truncated recording. + // yields each finalized segment with its media, and losing the publisher fails both cursors + // instead of completing a truncated recording. #[tokio::test] async fn record_cursors_yield_renditions_and_segments() { let origin = produce_origin(); let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - let config = video_config(); - registration.set(config).unwrap(); - drop(reserved); - - // Groups 0 and 1 are complete; group 2 is the live edge until the publisher drops. - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); + let catalog = hls_catalog(&mut broadcast); + // Segments 0 and 1 are complete; segment 2 is the live edge until the publisher drops. + let mut media = video(&catalog, &broadcast, "video0", video_config()); media.write(frame(0, true)).unwrap(); media.write(frame(2_000_000, true)).unwrap(); media.write(frame(4_000_000, true)).unwrap(); @@ -3837,17 +2626,16 @@ mod tests { assert_eq!(second.segment, 1); assert_eq!(second.discontinuity, 0, "consecutive segments are continuous"); - // Tear down the publisher mid-group. The timeline ends abruptly, so the cursor fails - // rather than finalize the open live-edge group (a reset can't vouch that its media is - // complete) or end as if the recording were complete. - drop((catalog, media, registration)); + // Tear down the publisher. The records end abruptly, so the cursor fails rather than end + // as if the recording were complete. + drop((catalog, media)); broadcast.close(); let end = tokio::time::timeout(Duration::from_secs(5), segments.next()) .await .expect("segment cursor resolves after the source is lost"); assert!( - matches!(end, Err(crate::Error::TimelineFailed { .. })), + matches!(end, Err(crate::Error::RecordsFailed { .. })), "an abrupt end fails the recording: {:?}", end.map(|segment| segment.map(|segment| segment.segment)) ); @@ -3859,245 +2647,96 @@ mod tests { assert!(ended.is_err(), "an abrupt broadcast close fails the renditions cursor"); } - #[tokio::test(start_paused = true)] - async fn a_segment_cursor_rejects_an_unfinished_window() { - let test = Timelines::new(); - let rendition = test.rendition("video0"); - let mut cursor = rendition.segments(); - rendition.close(); - assert!(matches!(cursor.next().await, Err(crate::Error::IncompleteRecording))); - assert!(matches!(cursor.next().await, Err(crate::Error::IncompleteRecording))); - } - - #[tokio::test(start_paused = true)] - async fn a_failed_catalog_fails_unfinished_segment_windows() { - let test = Timelines::new(); - let rendition = test.rendition("video0"); - let mut segments = rendition.segments(); - test.renditions - .finish(Err(crate::Error::Moq(moq_net::Error::Unroutable))); - // Wrapped, so a recorder can tell the final failure from a retryable fetch error. - for _ in 0..2 { - let Err(err) = segments.next().await else { - panic!("a failed catalog ends the cursor"); - }; - assert!( - matches!(&err, crate::Error::CatalogFailed { source } if matches!(**source, crate::Error::Moq(moq_net::Error::Unroutable))), - "{err}" - ); - } - } - - #[tokio::test(start_paused = true)] - async fn a_slow_cursor_refuses_evicted_rows_before_its_first_read() { - let test = Timelines::new(); - let rendition = test.rendition("video0"); - let mut slow = rendition.segments(); - let reference = reference(&test.catalog, None).unwrap(); - for sequence in 0..20 { - test.renditions.fanout().push( - sequence, - moq_mux::timeline::Entry::decode(gop(sequence), moq_net::Timescale::MILLI).unwrap(), - &reference, - ); - } - assert!(rendition.snapshot().media_sequence > 0); - assert!(matches!(slow.next().await, Err(crate::Error::RecordingLagged))); - assert!(matches!(slow.next().await, Err(crate::Error::RecordingLagged))); + /// A record covering group `sequence` from `2 * sequence` seconds. + fn gop(sequence: u64) -> hang::hls::Record { + hang::hls::Record::new(sequence, sequence, sequence * 2_000) } - #[tokio::test(start_paused = true)] - async fn a_recording_refuses_missing_media_but_skips_rendition_gaps() { - let test = Timelines::new(); - let (_, mut video0) = test.publish("video0"); - let (_, mut video1) = test.publish("video1"); - let media = test.media("video0", 2); - let sibling = test.media("video1", 2); - media.finish().unwrap(); - sibling.finish().unwrap(); - for sequence in 0..3 { - video0.push(&gop(sequence)).unwrap(); - } - // The sibling timeline explicitly has a gap for segment 0, then covers segment 1. - video1 - .push(&hang::timeline::Record::new( - 0, - 2_000, - 2_000, - hang::timeline::Position::group(1), - hang::timeline::Position::group(2), - )) - .unwrap(); - video1.finish().unwrap(); - video0.finish().unwrap(); - test.finished("video0").await; - let rendition = test.rendition("video0"); - let mut cursor = rendition.segments(); - assert_eq!(cursor.next().await.unwrap().unwrap().segment, 0); - assert_eq!(cursor.next().await.unwrap().unwrap().segment, 1); - assert!(matches!( - cursor.next().await, - Err(crate::Error::SegmentUnavailable { segment: 2 }) - )); - assert!(matches!( - cursor.next().await, - Err(crate::Error::SegmentUnavailable { segment: 2 }) - )); - - let rendition = test.rendition("video1"); - let mut cursor = rendition.segments(); - assert_eq!(cursor.next().await.unwrap().unwrap().segment, 1); - assert!(cursor.next().await.unwrap().is_none()); + /// A record where content stopped. + fn stop(sequence: u64, pts_ms: u64) -> hang::hls::Record { + hang::hls::Record::new(sequence, sequence, pts_ms).with_duration(0) } - #[tokio::test(start_paused = true)] - async fn catalog_changes_report_removal_and_drain_old_cursors() { - for reconfigure in [false, true] { - let mut test = Timelines::new(); - let (_, mut timeline) = test.publish("video0"); - let _media = test.media("video0", 2); - timeline.push(&gop(0)).unwrap(); - test.listed("video0", 0).await; - let mut changes = test.renditions.subscribe(); - let mut old = None; - for _ in 0..2 { - if let Some(renditions::Event::Added(rendition)) = changes.next().await.unwrap() - && rendition.name == "video0" - { - old = Some(rendition.segments()); - } - } - let mut old = old.unwrap(); - if reconfigure { - test.catalog.video.renditions.get_mut("video0").unwrap().coded_width = Some(1280); - } else { - test.catalog.video.renditions.remove("video0"); - } - let source = moq_mux::Source::new(produce_origin().consume(), test.broadcast.consume()); - test.renditions.sync(&source, &test.catalog); - assert!( - matches!(changes.next().await.unwrap(), Some(renditions::Event::Removed { name, .. }) if name == "video0") - ); - assert_eq!(old.next().await.unwrap().unwrap().segment, 0); - assert!(old.next().await.unwrap().is_none()); - if reconfigure { - assert!( - matches!(changes.next().await.unwrap(), Some(renditions::Event::Added(rendition)) if rendition.name == "video0") - ); - } - } - } - - #[tokio::test(start_paused = true)] - async fn a_removed_rendition_keeps_draining_its_delayed_timeline() { - let mut test = Timelines::new(); - let (_, mut reference) = test.publish("video0"); - let (_, mut sibling) = test.publish("video1"); - let _media = test.media("video1", 1); - reference.push(&gop(0)).unwrap(); - test.listed("video0", 0).await; - let rendition = test.rendition("video1"); - let mut cursor = rendition.segments(); - test.catalog.video.renditions.remove("video1"); - let source = moq_mux::Source::new(produce_origin().consume(), test.broadcast.consume()); - test.renditions.sync(&source, &test.catalog); - // Removal closes the window, while its existing own-timeline watcher stays alive. - sibling.push(&gop(0)).unwrap(); - sibling.finish().unwrap(); - assert_eq!(cursor.next().await.unwrap().unwrap().segment, 0); - assert!(cursor.next().await.unwrap().is_none()); - } - - /// A two-second GOP record: record `n` covers group `n` from `2n` seconds. - fn gop(sequence: u64) -> hang::timeline::Record { - hang::timeline::Record::new( - sequence, - sequence * 2_000, - 2_000, - hang::timeline::Position::group(sequence), - hang::timeline::Position::group(sequence + 1), - ) - } - - /// Renditions over hand-published timelines, reconciled and watched the way `watch_catalog` - /// does. A segment fetch misses unless [`media`](Timelines::media) published its groups. - struct Timelines { + /// Renditions over hand-published records, reconciled the way `watch_catalog` does. A + /// segment fetch misses unless [`media`](Records::media) published its groups. + struct Records { broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::hang::Catalog, renditions: renditions::Producer, - watcher: tokio::task::JoinHandle<()>, } - impl Timelines { - /// A `video0` reference and a `video1` rendition. + impl Records { + /// A `video0` lead and a `video1` rendition. fn new() -> Self { Self::with(&["video0", "video1"], &[]) } - /// `video` and `audio` renditions, each indexing its own timeline; the first video one is - /// the reference. + /// `video` and `audio` renditions, each with its own records. fn with(video: &[&str], audio: &[&str]) -> Self { let mut catalog = moq_mux::catalog::hang::Catalog::default(); for name in video { - catalog.video.renditions.insert(name.to_string(), video_config()); + let mut config = video_config(); + config.hls = Some(hls(name)); + catalog.video.renditions.insert(name.to_string(), config); } for name in audio { - let config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); + let mut config = opus(); + config.hls = Some(hls(name)); catalog.audio.renditions.insert(name.to_string(), config); } - let tracks: Vec<&str> = video.iter().chain(audio).copied().collect(); - catalog.archive = Some(archive(&tracks)); let broadcast = moq_net::broadcast::Info::new().produce(); - let (renditions, watcher) = Self::export(&broadcast, &catalog); + let renditions = Self::export(&broadcast, &catalog); Self { broadcast, catalog, renditions, - watcher, } } fn export( broadcast: &moq_net::broadcast::Producer, catalog: &moq_mux::catalog::hang::Catalog, - ) -> (renditions::Producer, tokio::task::JoinHandle<()>) { - let upstream = moq_mux::Source::new(produce_origin().consume(), broadcast.consume()); + ) -> renditions::Producer { let renditions = renditions::Producer::new(Config::default().window); - renditions.sync(&upstream, catalog); - let watcher = tokio::spawn(watch_timeline( - upstream.broadcast(), - catalog.archive.clone().unwrap(), - reference(catalog, None).unwrap(), - renditions.fanout(), - )); - (renditions, watcher) + renditions.sync(&Self::source(broadcast), catalog); + renditions + } + + fn source(broadcast: &moq_net::broadcast::Producer) -> moq_mux::Source { + moq_mux::Source::new(produce_origin().consume(), broadcast.consume()) } - /// Another exporter over the same timelines: a second edge, or one a fresh viewer starts. + /// Reconcile the current catalog again. + fn sync(&self) { + self.renditions.sync(&Self::source(&self.broadcast), &self.catalog); + } + + /// Another exporter over the same records: a second edge, or one a fresh viewer starts. fn join(&self) -> Self { - let (renditions, watcher) = Self::export(&self.broadcast, &self.catalog); Self { broadcast: self.broadcast.clone(), catalog: self.catalog.clone(), - renditions, - watcher, + renditions: Self::export(&self.broadcast, &self.catalog), } } - /// Publish `track`'s timeline. Returns the track, to fail it, with the timeline writing to it. - fn publish(&self, track: &str) -> (moq_net::track::Producer, moq_mux::timeline::Producer) { + /// Publish `track`'s records. Returns the track, to fail it, with the records writing to it. + fn publish(&self, track: &str) -> (moq_net::track::Producer, moq_mux::hls::Producer) { let track = self .broadcast - .create_track(hang::timeline::default_name(track), moq_mux::timeline::Producer::info()) + .create_track(hang::hls::default_name(track), moq_mux::hls::Producer::info()) .unwrap(); - let timeline = moq_mux::timeline::Producer::new(track.clone()); - (track, timeline) + (track.clone(), moq_mux::hls::Producer::new(track)) } /// Publish `name`'s media: one keyframe group per [`gop`] record `0..groups`, so a cursor /// fetches real segments. fn media(&self, name: &str, groups: u64) -> moq_net::track::Producer { - let track = self.broadcast.create_track(name, timed()).unwrap(); + Self::write_media(&self.broadcast, name, groups) + } + + fn write_media(broadcast: &moq_net::broadcast::Producer, name: &str, groups: u64) -> moq_net::track::Producer { + let track = broadcast.create_track(name, timed()).unwrap(); for sequence in 0..groups { let mut group = track.append_group().unwrap(); let frame = vp8_frame(sequence * 2_000_000, true); @@ -4170,24 +2809,18 @@ mod tests { (drained, end) } - /// Drain `name`'s recording cursor, which must answer a timeline failure once drained, + /// Drain `name`'s recording cursor, which must answer a record failure once drained, /// returning the segments it yielded first. async fn drain_failed(&self, name: &str) -> Vec { let (drained, end) = self.drain(name).await; - assert!(matches!(end, Err(crate::Error::TimelineFailed { .. })), "{end:?}"); + assert!(matches!(end, Err(crate::Error::RecordsFailed { .. })), "{end:?}"); drained } } - impl Drop for Timelines { - fn drop(&mut self) { - self.watcher.abort(); - } - } - - /// The timeline's publisher loses its session: the track and its open group fail. - fn cut(track: moq_net::track::Producer, timeline: moq_mux::timeline::Producer) { - drop(timeline); + /// The records' publisher loses its session: the track and its open group fail. + fn cut(track: moq_net::track::Producer, records: moq_mux::hls::Producer) { + drop(records); track .abort(moq_net::Error::Transport("connection lost".into())) .unwrap(); @@ -4197,7 +2830,7 @@ mod tests { async fn failed(rendition: &Rendition) -> crate::Error { for _ in 0..500 { if let Err(err) = rendition.try_snapshot() { - assert!(matches!(err, crate::Error::TimelineFailed { .. }), "{err}"); + assert!(matches!(err, crate::Error::RecordsFailed { .. }), "{err}"); return err; } tokio::time::sleep(Duration::from_millis(10)).await; @@ -4205,23 +2838,64 @@ mod tests { panic!("{} never failed", rendition.name); } - fn numbers(snapshot: &playlist::Snapshot) -> Vec { - snapshot.segments.iter().map(|s| s.segment).collect() + #[tokio::test(start_paused = true)] + async fn a_segment_cursor_rejects_an_unfinished_window() { + let test = Records::new(); + let rendition = test.rendition("video0"); + let mut cursor = rendition.segments(); + rendition.close(); + assert!(matches!(cursor.next().await, Err(crate::Error::IncompleteRecording))); + assert!(matches!(cursor.next().await, Err(crate::Error::IncompleteRecording))); + } + + #[tokio::test(start_paused = true)] + async fn a_failed_catalog_fails_unfinished_segment_windows() { + let test = Records::new(); + let rendition = test.rendition("video0"); + let mut segments = rendition.segments(); + test.renditions + .finish(Err(crate::Error::Moq(moq_net::Error::Unroutable))); + // Wrapped, so a recorder can tell the final failure from a retryable fetch error. + for _ in 0..2 { + let Err(err) = segments.next().await else { + panic!("a failed catalog ends the cursor"); + }; + assert!( + matches!(&err, crate::Error::CatalogFailed { source } if matches!(**source, crate::Error::Moq(moq_net::Error::Unroutable))), + "{err}" + ); + } + // A rendition created after the failure starts failed. + assert!(test.renditions.failure().is_some()); } - // A failed rendition timeline is not retried, and never papered over with gaps or ended as if - // complete: its listings answer the error, while the other renditions go on. #[tokio::test(start_paused = true)] - async fn a_rendition_timeline_error_fails_only_its_playlist() { - let test = Timelines::new(); - let (_reference, mut video0) = test.publish("video0"); + async fn a_slow_cursor_refuses_evicted_rows_before_its_first_read() { + let test = Records::with(&["video0"], &[]); + let rendition = test.rendition("video0"); + let mut slow = rendition.segments(); + let (_track, mut records) = test.publish("video0"); + for sequence in 0..21 { + records.push(&gop(sequence)).unwrap(); + } + assert!(test.listed("video0", 19).await.media_sequence > 0); + assert!(matches!(slow.next().await, Err(crate::Error::RecordingLagged))); + assert!(matches!(slow.next().await, Err(crate::Error::RecordingLagged))); + } + + // A failed rendition's records are not retried, and never papered over with gaps or ended as + // if complete: its listings answer the error, while the other renditions go on. + #[tokio::test(start_paused = true)] + async fn a_record_error_fails_only_its_playlist() { + let test = Records::new(); + let (_lead, mut video0) = test.publish("video0"); let (track, mut video1) = test.publish("video1"); let _media = test.media("video1", 4); for sequence in 0..4 { video0.push(&gop(sequence)).unwrap(); video1.push(&gop(sequence)).unwrap(); } - // A segment resolves once the rendition's timeline is a snap tolerance past its end. + // The newest record waits for its successor. assert_eq!(numbers(&test.listed("video1", 2).await), [0, 1, 2]); cut(track, video1); @@ -4231,591 +2905,337 @@ mod tests { test.failed("video1").await; let video1 = test.rendition("video1"); assert!(video1.media_playlist(None).is_err()); - assert!(video1.representations().is_err()); + assert!(video1.representation().is_err()); + assert!(video1.failed_alone()); // Segments already listed stay fetchable while the listing refuses. - assert_eq!( - video1.listed_content(0), - Some(spans::Content::Frames { - ranges: vec![hang::timeline::Position::group(0)..hang::timeline::Position::group(1)], - filter: None, - }) - ); - // So does the cursor, once it drains what the timeline covers. - assert_eq!(test.drain_failed("video1").await, [0, 1, 2, 3]); + assert_eq!(video1.listed_content(0), Some(rendition::Content::Groups(0..1))); + // So does the cursor, once it drains what the records covered. + assert_eq!(test.drain_failed("video1").await, [0, 1, 2]); - let reference = test.listed("video0", 6).await; - assert!(!reference.finished, "the other renditions go on"); - assert!(test.rendition("video0").representations().is_ok()); + let lead = test.listed("video0", 5).await; + assert!(!lead.finished, "the other renditions go on"); + assert!(test.rendition("video0").representation().is_ok()); } - // A rendition that failed while the reference was live stays failed once the reference - // finishes, even when its records cover every listed row. One failing only after the - // reference finished loses nothing it covers, so its playlist and cursor still end as complete. + // A failed rendition leaves the broadcast-wide listings, so new players start on a sibling. #[tokio::test(start_paused = true)] - async fn a_rendition_failure_counts_only_before_the_reference_finishes() { - for early in [true, false] { - let test = Timelines::new(); - let (_reference, mut video0) = test.publish("video0"); - let (track, mut video1) = test.publish("video1"); - let _media = test.media("video1", 6); - for sequence in 0..4 { - video0.push(&gop(sequence)).unwrap(); - } - // video1 runs past the last listed row, so its records cover every one. - for sequence in 0..6 { - video1.push(&gop(sequence)).unwrap(); - } - test.listed("video1", 3).await; - - let mut publisher = Some((track, video1)); - if early { - let (track, video1) = publisher.take().unwrap(); - cut(track, video1); - test.failed("video1").await; - } - video0.finish().unwrap(); - test.finished("video0").await; - let rendition = test.rendition("video1"); - if let Some((track, video1)) = publisher { - test.finished("video1").await; - cut(track, video1); - while rendition.own_failure().is_none() { - tokio::time::sleep(Duration::from_millis(10)).await; + async fn a_failed_rendition_leaves_the_manifest_and_master() { + let origin = produce_origin(); + let broadcast = origin.create_broadcast("live").unwrap(); + broadcast.announce(Default::default()).unwrap(); + settle().await; + let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast.clone(), Default::default()).unwrap(); + catalog + .mutate(|catalog| { + for name in ["video0", "video1"] { + let mut config = video_config(); + config.hls = Some(hls(name)); + catalog.video.renditions.insert(name.into(), config); } - let snapshot = rendition.snapshot(); - assert!(snapshot.finished, "a covered late failure loses nothing"); - assert_eq!(numbers(&snapshot), [0, 1, 2, 3]); - let (drained, end) = test.drain("video1").await; - assert_eq!(drained, [0, 1, 2, 3]); - assert!(end.is_ok(), "the recording is complete: {end:?}"); - } else { - settle().await; - assert!(rendition.try_snapshot().is_err(), "the failure is sticky"); - assert!(rendition.failed_alone()); - assert_eq!(test.drain_failed("video1").await, [0, 1, 2, 3]); - } - } - } - - // A failed reference timeline is not retried. Every playlist answers the error instead of - // ending as if complete or freezing live, and recording cursors answer it once drained. - #[tokio::test(start_paused = true)] - async fn a_reference_timeline_error_fails_every_playlist() { - let test = Timelines::new(); - let (track, mut video0) = test.publish("video0"); - let (_track, mut video1) = test.publish("video1"); - let _media = [test.media("video0", 4), test.media("video1", 5)]; + }) + .unwrap(); + let publish = |name: &str| { + let track = broadcast + .create_track(hang::hls::default_name(name), moq_mux::hls::Producer::info()) + .unwrap(); + (track.clone(), moq_mux::hls::Producer::new(track)) + }; + let (_video0_track, mut video0) = publish("video0"); + let (video1_track, mut video1) = publish("video1"); for sequence in 0..4 { video0.push(&gop(sequence)).unwrap(); video1.push(&gop(sequence)).unwrap(); } - test.listed("video0", 3).await; - - cut(track, video0); - for name in ["video0", "video1"] { - test.failed(name).await; - assert!(test.rendition(name).representations().is_err(), "{name}"); + let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); + broadcaster.advertised().await; + let healthy = broadcaster.rendition(Kind::Video, "video0").unwrap(); + let broken = broadcaster.rendition(Kind::Video, "video1").unwrap(); + for rendition in [&healthy, &broken] { + while rendition.snapshot().segments.len() < 3 { + tokio::time::sleep(Duration::from_millis(10)).await; + } } - assert!(test.renditions.failure().is_some()); - // A rendition created after the failure starts failed. - let late = test.join(); - late.failed("video1").await; - assert_eq!(test.drain_failed("video0").await, [0, 1, 2, 3]); - // The cursor still waits for the listed segment its own timeline has not passed. - video1.push(&gop(4)).unwrap(); - assert_eq!(test.drain_failed("video1").await, [0, 1, 2, 3]); + let before = broadcaster.manifest(None).unwrap().expect("both renditions are listed"); + assert!(before.contains("id=\"video/video1\""), "{before}"); + assert!(broadcaster.master_playlist(None).contains("video/video1/")); + + cut(video1_track, video1); + failed(&broken).await; + assert!(broken.media_playlist(None).is_err()); + assert!(healthy.media_playlist(None).unwrap().is_some()); + let manifest = broadcaster.manifest(None).unwrap().expect("the sibling stays listed"); + assert!(manifest.contains("id=\"video/video0\""), "{manifest}"); + assert!(!manifest.contains("id=\"video/video1\""), "{manifest}"); + let master = broadcaster.master_playlist(None); + assert!(master.contains("video/video0/"), "{master}"); + assert!(!master.contains("video/video1/"), "{master}"); + drop((catalog, video0)); } - // A playlist request waiting for its first row answers a timeline that fails before one, - // rather than timing out as not found or, without a router's timeout, waiting forever. + // A playlist request waiting for its first row answers records that fail before one, rather + // than timing out as not found or, without a router's timeout, waiting forever. #[tokio::test(start_paused = true)] - async fn a_pending_playlist_answers_a_rendition_timeline_failure() { - let test = Timelines::new(); - let (_reference, _video0) = test.publish("video0"); + async fn a_pending_playlist_answers_a_record_failure() { + let test = Records::new(); let (track, video1) = test.publish("video1"); - let video1_rendition = test.rendition("video1"); - let pending = tokio::spawn(async move { video1_rendition.playlist(None).await }); + let rendition = test.rendition("video1"); + let pending = tokio::spawn(async move { rendition.playlist(None).await }); tokio::time::sleep(Duration::from_secs(1)).await; assert!(!pending.is_finished(), "nothing is listed yet"); cut(track, video1); let answer = tokio::time::timeout(Duration::from_secs(5), pending).await; let err = answer.expect("the failure wakes the request").unwrap().unwrap_err(); - assert!(matches!(err, crate::Error::TimelineFailed { .. }), "{err}"); + assert!(matches!(err, crate::Error::RecordsFailed { .. }), "{err}"); } - // The same for the reference timeline, which every playlist lists. + // Malformed records fail their rendition the same way. #[tokio::test(start_paused = true)] - async fn a_pending_playlist_answers_a_reference_timeline_failure() { - let test = Timelines::new(); - let (track, video0) = test.publish("video0"); - let (_track, _video1) = test.publish("video1"); - let video1 = test.rendition("video1"); - let pending = tokio::spawn(async move { video1.playlist(None).await }); - tokio::time::sleep(Duration::from_secs(1)).await; - assert!(!pending.is_finished(), "nothing is listed yet"); + async fn malformed_records_fail_their_playlist() { + let test = Records::new(); + let (track, mut video0) = test.publish("video0"); + video0.push(&gop(0)).unwrap(); + video0.push(&gop(1)).unwrap(); + test.listed("video0", 0).await; - cut(track, video0); - let answer = tokio::time::timeout(Duration::from_secs(5), pending).await; - let err = answer.expect("the failure wakes the request").unwrap().unwrap_err(); - assert!(matches!(err, crate::Error::TimelineFailed { .. }), "{err}"); + let mut group = track.append_group().unwrap(); + group.write_frame(moq_net::Timestamp::now(), &b"garbage"[..]).unwrap(); + test.failed("video0").await; } - // A rendition running far ahead of a stalled reference keeps the records its listed segments - // resolve to, so a listed segment never turns into a gap. + // A record subscription still waiting on its track fails with the broadcast, so nothing + // parks, and records never read are not listed as an empty finished recording. #[tokio::test(start_paused = true)] - async fn a_rendition_ahead_of_the_reference_keeps_listed_segments() { - let test = Timelines::new(); - let (_track0, mut video0) = test.publish("video0"); - let (_track1, mut video1) = test.publish("video1"); - // The reference stalls at 20s while video1 runs on to 80s, far past the 16s window. - for sequence in 0..10 { - video0.push(&gop(sequence)).unwrap(); - } - for sequence in 0..40 { - video1.push(&gop(sequence)).unwrap(); - } - let listed = test.listed("video1", 9).await; - assert!(!listed.segments.is_empty()); - assert!( - listed.segments.iter().all(|s| !s.gap), - "every listed segment still resolves: {:?}", - numbers(&listed) - ); + async fn a_pending_record_subscription_ends_with_the_broadcast() { + let test = Records::new(); + // A handler keeps the unpublished records' requests waiting rather than refused. + let _handler = test.broadcast.dynamic(); + tokio::time::sleep(Duration::from_secs(60)).await; + assert!(!test.rendition("video0").snapshot().finished); + + test.broadcast.close(); + test.failed("video0").await; + assert!(test.drain_failed("video0").await.is_empty()); } - // RFC 8216 §6.2.2: EXT-X-MEDIA-SEQUENCE must not decrease across reloads. A rendition that - // sorts ahead of the reference must not take over, or the playlist rewinds to that - // rendition's own record numbers and its URLs name other content. + // A recording refuses a listed segment whose media is gone, but skips a segment the + // rendition holds nothing for: a stop, or one that does not line up with the lead's. #[tokio::test(start_paused = true)] - async fn an_earlier_rendition_does_not_rewind_the_media_sequence() { - const OLD: &[u8] = b"OLDOLDOLDOLDOLDO"; - const NEW: &[u8] = b"NEWNEWNEWNEWNEWN"; - - let origin = produce_origin(); - let mut broadcast = origin.create_broadcast("live").expect("publish allowed"); - broadcast.announce(Default::default()).expect("publish allowed"); - settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); - - let reserved = catalog.reserve(); - let mut video0 = catalog.test_video("video0").unwrap(); - video0.set(video_config()).unwrap(); - drop(reserved); - - let track = broadcast.create_track("video0", timed()).unwrap(); - let mut media = catalog - .media_producer( - track, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); - // 2s GOPs against a 6s window, so the oldest records leave and the sequence is no longer 0. - for micros in (0..=10_000_000u64).step_by(2_000_000) { - media.write(payload_frame(micros, true, OLD)).unwrap(); - } - - let config = Config { - window: Duration::from_secs(6), - ..Config::default() - }; - let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, config); - let sequence_of = |playlist: &str| -> u64 { - between(playlist, "#EXT-X-MEDIA-SEQUENCE:", "\n") - .parse() - .expect("media sequence") - }; - let rendition = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(rendition) = broadcaster.rendition(Kind::Video, "video0") - && let Some(playlist) = rendition.media_playlist(None).unwrap() - && sequence_of(&playlist) >= 2 - { - break rendition; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("video0 playlist slid past its first segments"); - - let before = rendition.media_playlist(None).unwrap().expect("playable"); - let sequence = sequence_of(&before); - assert!(sequence >= 2, "the window should have slid, got {sequence}\n{before}"); - let tag = segments::tag(&(Kind::Video, "video0".to_string(), "video0".to_string())); - assert!(before.contains(&format!("seg/{tag}.{sequence}.m4s\n")), "{before}"); - let bytes = rendition - .listed_segment(sequence) - .await - .unwrap() - .expect("listed segment"); - assert!(contains(&bytes, OLD), "the listed segment is not video0's media"); - - // Enroll the earlier timeline before the rendition, so the catalog snapshot that adds - // the rendition already indexes it. The reference decision runs in that same update. - let earlier = broadcast.create_track("a", timed()).unwrap(); - let mut earlier_media = catalog - .media_producer( - earlier, - moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), - ) - .unwrap(); - for micros in (0..=4_000_000u64).step_by(2_000_000) { - earlier_media.write(payload_frame(micros, true, NEW)).unwrap(); - } - let mut added = catalog.test_video("a").unwrap(); - added.set(video_config()).unwrap(); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if broadcaster.rendition(Kind::Video, "a").is_some() { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("the earlier rendition is in the catalog"); - - let new_tag = segments::tag(&(Kind::Video, "a".to_string(), "a".to_string())); - let deadline = tokio::time::Instant::now() + Duration::from_secs(5); - let mut previous = sequence; - let mut reloads = 0; - while reloads < 5 && tokio::time::Instant::now() < deadline { - let Some(playlist) = rendition.media_playlist(None).unwrap() else { - tokio::time::sleep(Duration::from_millis(10)).await; - continue; - }; - let next = sequence_of(&playlist); - assert!( - next >= previous, - "EXT-X-MEDIA-SEQUENCE decreased from {previous} to {next}\n{playlist}" - ); - assert!( - playlist.contains(&format!("seg/{tag}.")), - "segment URLs left the original reference\n{playlist}" - ); - assert!( - !playlist.contains(&format!("seg/{new_tag}.")), - "the earlier rendition's numbering replaced the reference\n{playlist}" - ); - previous = next; - reloads += 1; - tokio::time::sleep(Duration::from_millis(10)).await; + async fn a_recording_refuses_missing_media_but_skips_gaps() { + let test = Records::new(); + let (_, mut video0) = test.publish("video0"); + let (_, mut video1) = test.publish("video1"); + let media = test.media("video0", 2); + let sibling = test.media("video1", 2); + media.finish().unwrap(); + sibling.finish().unwrap(); + for sequence in 0..3 { + video0.push(&gop(sequence)).unwrap(); } - assert!(reloads >= 5, "the playlist did not stay reloadable"); + video0.push(&stop(3, 6_000)).unwrap(); + // video1's segment 0 starts 1.5s late, so it does not line up with the lead's. + video1.push(&hang::hls::Record::new(0, 0, 1_500)).unwrap(); + video1.push(&gop(1)).unwrap(); + video1.push(&stop(2, 4_000)).unwrap(); + video1.finish().unwrap(); + video0.finish().unwrap(); + test.finished("video0").await; - let again = rendition - .listed_segment(sequence) - .await - .unwrap() - .expect("the original segment URL still resolves"); - assert_eq!(again, bytes, "the original URL names other content"); - assert!( - !contains(&again, NEW), - "the earlier rendition's media replaced the segment" - ); + let rendition = test.rendition("video0"); + let mut cursor = rendition.segments(); + assert_eq!(cursor.next().await.unwrap().unwrap().segment, 0); + assert_eq!(cursor.next().await.unwrap().unwrap().segment, 1); + assert!(matches!( + cursor.next().await, + Err(crate::Error::SegmentUnavailable { segment: 2 }) + )); + assert!(matches!( + cursor.next().await, + Err(crate::Error::SegmentUnavailable { segment: 2 }) + )); - drop((media, earlier_media, video0, added, broadcast)); + let snapshot = test.finished("video1").await; + assert_eq!(gaps(&snapshot), [0, 2]); + let rendition = test.rendition("video1"); + assert!(rendition.listed_segment(0).await.unwrap().is_none(), "a gap is never fetched"); + let mut cursor = rendition.segments(); + assert_eq!(cursor.next().await.unwrap().unwrap().segment, 1); + assert!(cursor.next().await.unwrap().is_none()); } - #[tokio::test] - #[ignore = "performance measurement; run by the nightly Rust benchmarks"] - async fn benchmark_reference_handoff_fanout() { - for count in [2, 8, 32] { - for retained in [16, 64, 256] { - let names: Vec<_> = (0..count).map(|index| format!("video{index:02}")).collect(); - let mut catalog = moq_mux::catalog::hang::Catalog::default(); - for name in &names { - catalog.video.renditions.insert(name.clone(), video_config()); - } - catalog.archive = Some(archive(&names.iter().map(String::as_str).collect::>())); - let broadcast = moq_net::broadcast::Info::new().produce(); - let source = moq_mux::Source::new(produce_origin().consume(), broadcast.consume()); - let renditions = renditions::Producer::new(Duration::from_secs(retained * 2)); - renditions.sync(&source, &catalog); - let fanout = renditions.fanout(); - let reference = |index: usize| Arc::new((Kind::Video, names[index].clone(), names[index].clone())); - let refs = [reference(0), reference(1)]; - let entry = |sequence, at| { - let mut record = gop(sequence); - record.pts = Some(at * 2_000); - moq_mux::timeline::Entry::decode(record, moq_net::Timescale::MILLI).unwrap() - }; - for at in 0..retained { - fanout.push(at, entry(at, at), &refs[0]); - } - // Aligned references share record numbers, so every handoff continues seamlessly. - let started = moq_net::time::Instant::now(); - for at in retained..retained + 1_024 { - let reference = &refs[(at % 2) as usize]; - fanout.select(Some(reference.clone())); - fanout.push(at, entry(at, at), reference); - } - let elapsed = started.elapsed(); - for rendition in renditions.snapshot() { - assert!(rendition.is_playable()); - } - println!( - "hls handoff renditions={count} retained={retained} ns_per_push={}", - elapsed.as_nanos() / 1_024 - ); - } + // Record `n` of every rendition is segment `n`. One that does not line up with the lead's + // is listed as a gap, so the numbering stays consecutive and nothing misplaced is served. + #[tokio::test(start_paused = true)] + async fn a_misaligned_segment_is_a_gap() { + let test = Records::with(&["video0"], &["audio0"]); + let (_t0, mut video0) = test.publish("video0"); + let (_t1, mut audio0) = test.publish("audio0"); + for sequence in 0..5 { + video0.push(&gop(sequence)).unwrap(); + // Segment 2 starts 1.5s late on audio; the rest are within half a segment. + let pts = match sequence { + 2 => 5_500, + _ => sequence * 2_000 + 100, + }; + audio0.push(&hang::hls::Record::new(sequence, sequence, pts)).unwrap(); } - } - - // A video reference sticks while it stays listed with a timeline, and falls back to the - // first choice once it leaves. An audio reference yields once video has a timeline. - #[test] - fn the_reference_sticks_until_it_leaves() { - let catalog = |videos: &[&str], audios: &[&str]| { - let mut catalog = moq_mux::catalog::hang::Catalog::default(); - for name in videos { - catalog.video.renditions.insert(name.to_string(), video_config()); - } - for name in audios { - catalog.audio.renditions.insert( - name.to_string(), - hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2), - ); - } - catalog.archive = Some(archive(&[videos, audios].concat())); - catalog - }; - let video = |name: &str| Arc::new((Kind::Video, name.to_string(), name.to_string())); - let audio = |name: &str| Arc::new((Kind::Audio, name.to_string(), name.to_string())); - - let first = reference(&catalog(&["b"], &[]), None); - assert_eq!(first, Some(video("b"))); - assert_eq!(reference(&catalog(&["a", "b"], &[]), first.as_ref()), Some(video("b"))); - assert_eq!(reference(&catalog(&["a", "c"], &[]), first.as_ref()), Some(video("a"))); - - let first = reference(&catalog(&[], &["b"]), None); - assert_eq!(first, Some(audio("b"))); - assert_eq!(reference(&catalog(&[], &["a", "b"]), first.as_ref()), Some(audio("b"))); - // Video listed before its timeline exists doesn't take over yet. - let mut pending = catalog(&[], &["b"]); - pending.video.renditions.insert("v".to_string(), video_config()); - assert_eq!(reference(&pending, first.as_ref()), Some(audio("b"))); - assert_eq!( - reference(&catalog(&["v"], &["a", "b"]), first.as_ref()), - Some(video("v")) - ); - } - - #[test] - fn disabled_references_yield_only_to_enabled_indexed_renditions() { - let mut catalog = moq_mux::catalog::hang::Catalog::default(); - let mut disabled = video_config(); - disabled.enabled = false; - catalog.video.renditions.insert("a".into(), disabled); - catalog.video.renditions.insert("b".into(), video_config()); - catalog.archive = Some(archive(&["a", "b", "audio"])); - let previous = Arc::new((Kind::Video, "a".into(), "a".into())); - assert_eq!(reference(&catalog, Some(&previous)).unwrap().1, "b"); - catalog.video.renditions.get_mut("b").unwrap().enabled = false; - assert!(reference(&catalog, Some(&previous)).is_none()); - catalog.audio.renditions.insert( - "audio".into(), - hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2), - ); - assert_eq!(reference(&catalog, Some(&previous)).unwrap().0, Kind::Audio); - catalog.audio.renditions.get_mut("audio").unwrap().enabled = false; - assert!(reference(&catalog, None).is_none()); - } + test.listed("video0", 3).await; + let audio = test.listed("audio0", 3).await; + assert_eq!(numbers(&audio), [0, 1, 2, 3]); + assert_eq!(gaps(&audio), [2]); + let rendition = test.rendition("audio0"); + assert_eq!(rendition.listed_content(2), Some(rendition::Content::Gap)); + assert!(rendition.listed_segment(2).await.unwrap().is_none()); + let dash = rendition.representation().unwrap().expect("an out-of-band init"); + assert!(dash.segments.iter().all(|(t, _)| *t != 5_500), "{:?}", dash.segments); - // Timelines are keyed by the indexed track's name, so a rendition whose ID differs from its - // track is found, and kept, by its track. Moving the ID to another track is a new reference. - #[test] - fn the_reference_timeline_is_found_by_track_name() { - let catalog = |track: &str| { - let mut catalog = moq_mux::catalog::hang::Catalog::default(); - let mut config = video_config(); - config.track = Some(track.to_string()); - catalog.video.renditions.insert("main".to_string(), config); - catalog.archive = Some(archive(&["video-a", "video-b"])); - catalog - }; - let main = |track: &str| Arc::new((Kind::Video, "main".to_string(), track.to_string())); - - let first = reference(&catalog("video-a"), None); - assert_eq!(first, Some(main("video-a"))); - assert_eq!(reference(&catalog("video-a"), first.as_ref()), first); - let moved = reference(&catalog("video-b"), first.as_ref()); - assert_eq!(moved, Some(main("video-b"))); - assert_ne!(segments::tag(&main("video-a")), segments::tag(&main("video-b"))); - assert_eq!(reference(&catalog("main"), first.as_ref()), None); + let rendered = rendition.media_playlist(None).unwrap().expect("playable"); + assert!(rendered.contains("#EXT-X-GAP\n"), "{rendered}"); } - // A new reference numbers segments from its own records, so its URLs carry its own tag and - // never reuse one the old reference listed for other content. + // A rendition ahead of the lead lists a segment only once the lead does, so its listing + // never changes under a player. #[tokio::test(start_paused = true)] - async fn a_reference_switch_never_reuses_a_segment_url() { - let mut test = Timelines::new(); - let (_track0, mut video0) = test.publish("video0"); - let (_track1, mut video1) = test.publish("video1"); - for sequence in 0..4 { - video0.push(&gop(sequence)).unwrap(); + async fn a_rendition_waits_for_the_lead() { + let test = Records::new(); + let (_t0, mut video0) = test.publish("video0"); + let (_t1, mut video1) = test.publish("video1"); + for sequence in 0..6 { + video1.push(&gop(sequence)).unwrap(); } - // video1's records are numbered from 0 but start 3s later, so its segment 0 is other content. - for sequence in 0..4 { - video1 - .push(&hang::timeline::Record::new( - sequence, - sequence * 2_000 + 3_000, - 2_000, - hang::timeline::Position::group(sequence), - hang::timeline::Position::group(sequence + 1), - )) - .unwrap(); + for sequence in 0..3 { + video0.push(&gop(sequence)).unwrap(); } - let before = test.listed("video0", 2).await; - let old = segments::tag(&(Kind::Video, "video0".to_string(), "video0".to_string())); - assert!(before.segments.iter().all(|s| s.tag == old)); - - // Switch the reference the way `watch_catalog` does. - test.watcher.abort(); - test.renditions.fanout().skip(None); - test.watcher = tokio::spawn(watch_timeline( - test.broadcast.consume(), - archive(&["video0", "video1"]), - Arc::new((Kind::Video, "video1".to_string(), "video1".to_string())), - test.renditions.fanout(), - )); - let after = test.listed("video1", 2).await; - let new = segments::tag(&(Kind::Video, "video1".to_string(), "video1".to_string())); - assert_eq!(numbers(&after)[0], 0, "the new reference numbers from its own records"); - assert!(after.segments.iter().all(|s| s.tag == new && s.tag != old)); - let video0 = test.rendition("video0"); - assert!( - video0.numbered(&old, 0).await.unwrap().is_none(), - "the old URL is no longer served" - ); - } + test.listed("video0", 1).await; + settle().await; + assert_eq!(numbers(&test.rendition("video1").snapshot()), [0, 1]); - // Moving the reference rendition to another track under the same ID is a reference switch too: - // a rendition that stays lists the new track's records under a new tag, from their first. - #[tokio::test(start_paused = true)] - async fn a_retargeted_reference_never_reuses_a_segment_url() { - let mut test = Timelines::with(&["main"], &["audio0"]); - let (_track0, mut video_a) = test.publish("main"); - let (_track1, mut video_b) = test.publish("video-b"); - let (_track2, mut audio) = test.publish("audio0"); - for sequence in 0..8 { - audio.push(&gop(sequence)).unwrap(); - } - for sequence in 0..4 { - video_a.push(&gop(sequence)).unwrap(); - video_b - .push(&hang::timeline::Record::new( - sequence, - sequence * 2_000 + 3_000, - 2_000, - hang::timeline::Position::group(sequence), - hang::timeline::Position::group(sequence + 1), - )) - .unwrap(); + for sequence in 3..6 { + video0.push(&gop(sequence)).unwrap(); } - let before = test.listed("audio0", 2).await; - let old = segments::tag(&(Kind::Video, "main".to_string(), "main".to_string())); - assert!(before.segments.iter().all(|s| s.tag == old)); - - test.watcher.abort(); - test.renditions.fanout().skip(None); - test.watcher = tokio::spawn(watch_timeline( - test.broadcast.consume(), - archive(&["main", "video-b", "audio0"]), - Arc::new((Kind::Video, "main".to_string(), "video-b".to_string())), - test.renditions.fanout(), - )); - let after = test.listed("audio0", 2).await; - let new = segments::tag(&(Kind::Video, "main".to_string(), "video-b".to_string())); - assert_ne!(new, old); - assert_eq!(numbers(&after)[0], 0, "the new track numbers from its own records"); - assert!(after.segments.iter().all(|s| s.tag == new)); + assert_eq!(numbers(&test.listed("video1", 4).await), [0, 1, 2, 3, 4]); } - // A malformed reference timeline fails the same way. + // A stop is listed as a gap at once, and what resumes after it starts a new discontinuity + // on every rendition. #[tokio::test(start_paused = true)] - async fn a_malformed_reference_timeline_fails_every_playlist() { - let test = Timelines::new(); - let (track, mut video0) = test.publish("video0"); + async fn a_stop_is_a_gap_and_a_resume_a_discontinuity() { + let test = Records::with(&["video0"], &[]); + let (_track, mut video0) = test.publish("video0"); video0.push(&gop(0)).unwrap(); - test.listed("video0", 0).await; + video0.push(&gop(1)).unwrap(); + video0.push(&stop(2, 4_000)).unwrap(); + video0.push(&hang::hls::Record::new(3, 2, 10_000)).unwrap(); + video0.push(&hang::hls::Record::new(4, 3, 12_000)).unwrap(); - let mut group = track.append_group().unwrap(); - group.write_frame(moq_net::Timestamp::now(), &b"garbage"[..]).unwrap(); - test.failed("video0").await; - test.failed("video1").await; + let snapshot = test.listed("video0", 3).await; + assert_eq!(numbers(&snapshot), [0, 1, 2, 3]); + assert_eq!(gaps(&snapshot), [2]); + let rendered = test.rendition("video0").media_playlist(None).unwrap().expect("playable"); + assert!(rendered.contains("#EXT-X-GAP\n"), "{rendered}"); + assert!(rendered.contains("#EXT-X-DISCONTINUITY\n"), "{rendered}"); } - // A timeline subscription still waiting on its track fails with the broadcast, so nothing - // parks, and a timeline never read is not listed as an empty finished recording. + // The lead stays put while it is listed and enabled, so a rendition added later (whose + // records are numbered from its own start) never makes the others' segments gaps. #[tokio::test(start_paused = true)] - async fn a_pending_timeline_subscription_ends_with_the_broadcast() { - let test = Timelines::new(); - // A handler keeps the unpublished timelines' requests waiting rather than refused. - let _handler = test.broadcast.dynamic(); - tokio::time::sleep(Duration::from_secs(60)).await; - assert!(!test.rendition("video0").snapshot().finished); + async fn the_lead_sticks_until_it_leaves() { + let mut test = Records::with(&["video1"], &["audio0"]); + let lead = || test_lead(&test); + assert_eq!(lead(), Some("video1".to_string())); - test.broadcast.close(); - test.failed("video0").await; - assert!(test.drain_failed("video0").await.is_empty()); + // "video0" sorts first, but arrives later. + let mut config = video_config(); + config.hls = Some(hls("video0")); + test.catalog.video.renditions.insert("video0".into(), config); + test.sync(); + assert_eq!(test_lead(&test), Some("video1".to_string())); + + // A disabled lead hands over, and audio yields to enabled video. + test.catalog.video.renditions.get_mut("video1").unwrap().enabled = false; + test.sync(); + assert_eq!(test_lead(&test), Some("video0".to_string())); + test.catalog.video.renditions.get_mut("video0").unwrap().enabled = false; + test.sync(); + assert_eq!(test_lead(&test), Some("audio0".to_string())); + test.catalog.video.renditions.get_mut("video1").unwrap().enabled = true; + test.sync(); + assert_eq!(test_lead(&test), Some("video1".to_string())); } - /// Record `sequence` covering group `group` from `pts_ms` for `duration_ms`, starting on a sync - /// point. - fn span(sequence: u64, pts_ms: u64, duration_ms: u64, group: u64) -> hang::timeline::Record { - hang::timeline::Record::new( - sequence, - pts_ms, - duration_ms, - hang::timeline::Position::group(group), - hang::timeline::Position::group(group + 1), - ) + fn test_lead(test: &Records) -> Option { + test.renditions.fanout().lead().map(|lead| lead.name.clone()) } - /// The renditions a master playlist would list, by name. - fn advertised(renditions: &renditions::Producer) -> Vec { - renditions - .snapshot() - .iter() - .filter(|rendition| rendition.is_advertised()) - .map(|rendition| rendition.name.clone()) - .collect() + #[tokio::test(start_paused = true)] + async fn catalog_changes_report_removal_and_drain_old_cursors() { + for reconfigure in [false, true] { + let mut test = Records::with(&["video0"], &[]); + let (_, mut records) = test.publish("video0"); + let _media = test.media("video0", 3); + records.push(&gop(0)).unwrap(); + records.push(&gop(1)).unwrap(); + test.listed("video0", 0).await; + let mut changes = test.renditions.subscribe(); + let Some(renditions::Event::Added(rendition)) = changes.next().await.unwrap() else { + panic!("the rendition is reported"); + }; + let mut old = rendition.segments(); + if reconfigure { + test.catalog.video.renditions.get_mut("video0").unwrap().coded_width = Some(1280); + } else { + test.catalog.video.renditions.remove("video0"); + } + test.sync(); + assert!( + matches!(changes.next().await.unwrap(), Some(renditions::Event::Removed { name, .. }) if name == "video0") + ); + assert_eq!(old.next().await.unwrap().unwrap().segment, 0); + // The segment still waiting on its successor is listed once that arrives: the next + // segment of a reconfigured rendition, or the stop of a dropped one. + match reconfigure { + true => records.push(&gop(2)).unwrap(), + false => records.push(&stop(2, 4_000)).unwrap(), + } + assert_eq!(old.next().await.unwrap().unwrap().segment, 1); + assert!(old.next().await.unwrap().is_none()); + if reconfigure { + assert!( + matches!(changes.next().await.unwrap(), Some(renditions::Event::Added(rendition)) if rendition.name == "video0") + ); + } + } } - /// The `Push` events a fresh subscriber to `track`'s timeline reads before it is caught up. - async fn fresh_join_reads(test: &Timelines, track: &str) -> usize { - let section = test.catalog.archive.clone().unwrap(); - let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(&test.broadcast.consume(), §ion, track) + /// The `Push` events a fresh subscriber to `track`'s records reads before it is caught up. + async fn fresh_join_reads(test: &Records, track: &str) -> usize { + let mut records = moq_mux::hls::Consumer::subscribe(&test.broadcast.consume(), &hls(track)) .await .unwrap(); let mut pushes = 0; - while let std::task::Poll::Ready(event) = timeline.poll_next(&kio::Waiter::noop()) { - let event = event.unwrap().expect("the timeline is still live"); - pushes += matches!(event, moq_mux::timeline::Event::Push { .. }) as usize; + while let std::task::Poll::Ready(event) = records.poll_next(&kio::Waiter::noop()) { + let event = event.unwrap().expect("the records are still live"); + pushes += matches!(event, moq_mux::hls::Event::Push { .. }) as usize; } pushes } // A viewer joining a days-old live broadcast costs what the window lists, not the broadcast's - // age: its timeline subscription restates a bounded suffix of a timeline that never pops, and - // its playlist lists the window with the sequence every other edge shows. + // age: its record subscription restates a bounded suffix of records that never pop, and its + // playlist lists the window with the sequence every other edge shows. #[tokio::test(start_paused = true)] async fn a_fresh_join_reads_a_bounded_number_of_records() { - let early = Timelines::with(&["video0"], &[]); + let early = Records::with(&["video0"], &[]); let (_track, mut video0) = early.publish("video0"); // A day of 10s GOPs per round. const DAY: u64 = 86_400 / 10; - // A checkpoint, plus the edits one timeline group holds before a join over its ratio rolls it. - let ratio = moq_mux::timeline::Producer::info().refresh.unwrap() as usize; - let bound = segments::MAX_SEGMENTS * (1 + ratio); + // A checkpoint, plus the edits one record group holds before a join over its ratio rolls it. + let ratio = moq_mux::hls::Producer::info().refresh.unwrap() as usize; + let bound = (segments::MAX_SEGMENTS + 1) * (1 + ratio); for day in 1..=3 { for sequence in (day - 1) * DAY..day * DAY { video0 - .push(&span(sequence, sequence * 10_000, 10_000, sequence)) + .push(&hang::hls::Record::new(sequence, sequence, sequence * 10_000)) .unwrap(); } let read = fresh_join_reads(&early, "video0").await; @@ -4825,13 +3245,12 @@ mod tests { ); } - let last = 3 * DAY - 1; + let last = 3 * DAY - 2; let late = early.join(); let joined = late.listed("video0", last).await; let followed = early.listed("video0", last).await; // Two 10s segments cover the 16s window. assert_eq!(numbers(&joined), [last - 1, last]); - assert_eq!(joined.media_sequence, last - 1); assert_eq!(numbers(&followed), numbers(&joined)); assert_eq!(followed.media_sequence, joined.media_sequence); } @@ -4839,38 +3258,39 @@ mod tests { // Very short segments: the window is capped at what a fresh join can see, so an edge that // followed the broadcast all along lists exactly what one joining now does. #[tokio::test(start_paused = true)] - async fn a_dense_timeline_lists_the_same_window_on_every_edge() { - let early = Timelines::with(&["video0"], &[]); + async fn dense_records_list_the_same_window_on_every_edge() { + let early = Records::with(&["video0"], &[]); let (_track, mut video0) = early.publish("video0"); // 40ms records: the 16s window spans 400 of them. for sequence in 0..1_000 { - video0.push(&span(sequence, sequence * 40, 40, sequence)).unwrap(); + video0 + .push(&hang::hls::Record::new(sequence, sequence, sequence * 40)) + .unwrap(); settle().await; } // A joiner wants a fresh group, so the next push restates the window and the joiner reads // only that checkpoint and on. let late = early.join(); settle().await; - video0.push(&span(1_000, 40_000, 40, 1_000)).unwrap(); + video0.push(&hang::hls::Record::new(1_000, 1_000, 40_000)).unwrap(); assert!( fresh_join_reads(&early, "video0").await < 1_000, "the push rolled the group" ); - let joined = late.listed("video0", 1_000).await; - let followed = early.listed("video0", 1_000).await; + let joined = late.listed("video0", 999).await; + let followed = early.listed("video0", 999).await; assert_eq!(joined.segments.len(), segments::MAX_SEGMENTS); assert_eq!(numbers(&followed), numbers(&joined)); assert_eq!(followed.media_sequence, joined.media_sequence); } - // Two edges over one timeline agree on the media sequence, and so does a reload after the - // publisher pops records out of the window. + // Two edges over one record track agree on the media sequence. #[tokio::test(start_paused = true)] - async fn edges_and_reloads_agree_on_the_media_sequence() { - let early = Timelines::with(&["video0"], &[]); + async fn edges_agree_on_the_media_sequence() { + let early = Records::with(&["video0"], &[]); let (_track, mut video0) = early.publish("video0"); - for sequence in 0..20 { + for sequence in 0..21 { video0.push(&gop(sequence)).unwrap(); settle().await; } @@ -4879,212 +3299,5 @@ mod tests { let followed = early.listed("video0", 19).await; assert_eq!(joined.media_sequence, 12, "eight 2s segments cover the 16s window"); assert_eq!(numbers(&followed), numbers(&joined)); - - // A DVR expiry trims into the window. - video0.pop(15).unwrap(); - video0.push(&gop(20)).unwrap(); - let joined = late.listed("video0", 20).await; - let followed = early.listed("video0", 20).await; - let fresh = early.join().listed("video0", 20).await; - for snapshot in [&joined, &followed, &fresh] { - assert_eq!(snapshot.media_sequence, 15); - assert_eq!(numbers(snapshot), [15, 16, 17, 18, 19, 20]); - } - } - - // A video rendition is advertised once a listed segment starts at a group start that is a - // sync point. Audio needs none, so a master rendered early may list audio alone. - #[tokio::test(start_paused = true)] - async fn video_is_advertised_from_its_first_sync_point() { - let test = Timelines::with(&["video0", "video1"], &["audio0"]); - assert!(advertised(&test.renditions).is_empty(), "nothing listed yet"); - - let (_track0, mut video0) = test.publish("video0"); - let (_track1, mut video1) = test.publish("video1"); - let (_track2, mut audio0) = test.publish("audio0"); - for sequence in 0..4 { - // Joined mid-group, then a group start that is not a sync point (an intra-refresh run). - let mut record = gop(sequence); - record.keyframe = false; - if sequence < 2 { - record.start.frame = 5; - } - video0.push(&record).unwrap(); - video1.push(&record).unwrap(); - audio0.push(&gop(sequence)).unwrap(); - } - test.listed("video0", 3).await; - test.listed("audio0", 2).await; - assert_eq!(advertised(&test.renditions), ["audio0"]); - - // The master route long-polls on this: it must wake on the first sync start. - let renditions = test.renditions.clone(); - let video = tokio::spawn(async move { - kio::wait(|waiter| { - let snapshot = renditions.snapshot(); - let video0 = snapshot.iter().find(|rendition| rendition.name == "video0").unwrap(); - video0.poll_advertised(waiter) - }) - .await - }); - tokio::time::sleep(Duration::from_secs(1)).await; - assert!(!video.is_finished(), "video0 has no sync start yet"); - - for sequence in 4..7 { - video0.push(&gop(sequence)).unwrap(); - video1.push(&gop(sequence)).unwrap(); - audio0.push(&gop(sequence)).unwrap(); - } - tokio::time::timeout(Duration::from_secs(5), video) - .await - .expect("the wait wakes on the first sync start") - .unwrap(); - test.listed("video1", 4).await; - assert_eq!(advertised(&test.renditions), ["video0", "video1", "audio0"]); - } - - /// Record `sequence` covering frames `frames` of one long group from `pts_ms`, a sync point - /// only at the group start: how `moq-mux` splits a GOP longer than its record limit. - fn split(sequence: u64, pts_ms: u64, duration_ms: u64, frames: std::ops::Range) -> hang::timeline::Record { - let mut record = span(sequence, pts_ms, duration_ms, 0); - record.start.frame = frames.start; - record.end = record.start; - record.end.frame = frames.end; - record.keyframe = frames.start == 0; - record - } - - // A 30s GOP is split into 10s records with one sync start. The 16s window keeps that record - // rather than evict it, so video stays startable and advertised until the next GOP starts. - #[tokio::test(start_paused = true)] - async fn a_long_gop_keeps_its_sync_start_in_the_window() { - let test = Timelines::with(&["video0"], &["audio0"]); - let (_track0, mut video0) = test.publish("video0"); - let (_track1, mut audio0) = test.publish("audio0"); - for sequence in 0..3 { - let frames = sequence * 300..(sequence + 1) * 300; - video0 - .push(&split(sequence, sequence * 10_000, 10_000, frames)) - .unwrap(); - audio0 - .push(&span(sequence, sequence * 10_000, 10_000, sequence)) - .unwrap(); - } - let window = test.listed("video0", 2).await; - assert_eq!(numbers(&window), [0, 1, 2], "the sync start stays listed"); - let joined = test.join().listed("video0", 2).await; - assert_eq!(numbers(&joined), [0, 1, 2], "a fresh edge lists the same window"); - assert_eq!(advertised(&test.renditions), ["video0", "audio0"]); - - video0.push(&span(3, 30_000, 10_000, 1)).unwrap(); - audio0.push(&span(3, 30_000, 10_000, 3)).unwrap(); - let window = test.listed("video0", 3).await; - assert_eq!(numbers(&window), [2, 3], "the next sync start releases the old GOP"); - assert_eq!(advertised(&test.renditions), ["video0", "audio0"]); - } - - // A GOP past the 256-record cap loses its sync start to the cap. The rendition stays - // advertised for the rest of the run rather than vanish from a master players already loaded. - #[tokio::test(start_paused = true)] - async fn a_gop_past_the_cap_stays_advertised() { - let test = Timelines::with(&["video0"], &[]); - let (_track, mut video0) = test.publish("video0"); - let records = segments::MAX_SEGMENTS as u64 + 44; - // The exporter follows from the GOP start; a later join is only restated the tail. - video0.push(&split(0, 0, 40, 0..1)).unwrap(); - test.listed("video0", 0).await; - for sequence in 1..records { - video0 - .push(&split(sequence, sequence * 40, 40, sequence..sequence + 1)) - .unwrap(); - } - let window = test.listed("video0", records - 1).await; - assert_eq!(numbers(&window)[0], 44, "the cap evicted the sync start"); - assert_eq!(advertised(&test.renditions), ["video0"]); - - // A new run starts unadvertised until it lists its own sync start. - test.renditions.fanout().set_generation(Some("a".into())); - test.renditions.fanout().set_generation(Some("b".into())); - video0 - .push(&split(records, records * 40, 40, records..records + 1)) - .unwrap(); - test.listed("video0", records).await; - assert!( - advertised(&test.renditions).is_empty(), - "the latch belongs to the old run" - ); - } - - // Clearing the window, whether a publisher restarted without a new generation (a rewind) or - // records were skipped, drops the advertisement latch with it. - #[tokio::test(start_paused = true)] - async fn a_cleared_window_drops_the_latch() { - let test = Timelines::with(&["video0"], &[]); - let (_track, mut video0) = test.publish("video0"); - video0.push(&split(0, 0, 40, 0..1)).unwrap(); - test.listed("video0", 0).await; - video0.push(&split(1, 40, 40, 1..2)).unwrap(); - test.listed("video0", 1).await; - assert_eq!(advertised(&test.renditions), ["video0"]); - - // The new run opens mid-GOP at an earlier time, under later record numbers. - video0.push(&split(2, 0, 40, 5..6)).unwrap(); - let window = test.listed("video0", 2).await; - assert_eq!(numbers(&window), [2], "the rewind reset the window"); - assert!(advertised(&test.renditions).is_empty(), "a rewind drops the latch"); - - video0.push(&split(3, 40, 40, 0..1)).unwrap(); - test.listed("video0", 3).await; - assert_eq!(advertised(&test.renditions), ["video0"]); - test.renditions.fanout().skip(None); - video0.push(&split(4, 80, 40, 1..2)).unwrap(); - let window = test.listed("video0", 4).await; - assert_eq!(numbers(&window), [4], "the skip cleared the window"); - assert!(advertised(&test.renditions).is_empty(), "a skip drops the latch"); - } - - // A span with no media in one rendition keeps its slot as a gap of the same duration there - // alone. The rendition goes on listing after it, at a sync point a player can switch onto, - // and its siblings never notice. - #[tokio::test(start_paused = true)] - async fn a_gap_in_one_rendition_holds_back_nothing() { - let test = Timelines::with(&["video0", "video1"], &["audio0"]); - let (_track0, mut video0) = test.publish("video0"); - let (_track1, mut video1) = test.publish("video1"); - let (_track2, mut audio0) = test.publish("audio0"); - for sequence in 0..8 { - video0.push(&gop(sequence)).unwrap(); - } - // video1 and audio0 have nothing from 4s to 8s (segments 2 and 3). - for (sequence, gop) in [0, 1, 4, 5, 6, 7].into_iter().enumerate() { - let record = span(sequence as u64, gop * 2_000, 2_000, sequence as u64); - video1.push(&record).unwrap(); - audio0.push(&record).unwrap(); - } - - let reference = test.listed("video0", 7).await; - assert!( - reference.segments.iter().all(|s| !s.gap), - "a sibling's gap is not the reference's" - ); - for name in ["video1", "audio0"] { - let snapshot = test.listed(name, 5).await; - let gaps: Vec = snapshot.segments.iter().filter(|s| s.gap).map(|s| s.segment).collect(); - assert_eq!(gaps, [2, 3], "{name}"); - assert!( - snapshot.segments.iter().all(|s| s.duration == Duration::from_secs(2)), - "{name}: a gap keeps its duration" - ); - } - - // Switching to video1 after its gap lands on a segment that starts at a sync point. - let Some(spans::Content::Frames { ranges, .. }) = test.rendition("video1").listed_content(4) else { - panic!("segment 4 resolves on video1"); - }; - assert_eq!( - ranges[0].start, - hang::timeline::Position::group(2), - "its record after the gap" - ); } } diff --git a/rs/moq-hls/src/export/mpd.rs b/rs/moq-hls/src/export/mpd.rs index 8dc841061b3..a87ed0e22d6 100644 --- a/rs/moq-hls/src/export/mpd.rs +++ b/rs/moq-hls/src/export/mpd.rs @@ -1,8 +1,8 @@ //! Hand-written MPEG-DASH manifest (MPD) generation. //! -//! Rendered purely from timeline records, exactly like the HLS playlists: each record is one -//! complete aligned segment carrying its own `pts` and `duration` in the timeline's timescale, -//! which map 1:1 onto a `SegmentTimeline`'s `S@t`/`S@d`. Segments are addressed by `$Time$` +//! Rendered purely from HLS records, exactly like the HLS playlists: each record is one complete +//! segment carrying its own `pts` and `duration` in milliseconds, which map 1:1 onto a +//! `SegmentTimeline`'s `S@t`/`S@d`. Segments are addressed by `$Time$` //! (the record's `pts`) rather than `$Number$`: with explicit `t` on every `S`, a rendition //! that skips a span (a gap) or a client that joins mid-window never mis-addresses a segment //! the way positional numbering would. @@ -15,7 +15,7 @@ //! HLS routes; only the time-addressed segment alias `seg/t.m4s` is DASH's own. use moq_clock::Wall; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::fmt::Write; use std::sync::Arc; use std::time::Duration; @@ -45,7 +45,7 @@ pub(crate) struct Representation { pub sample_rate: Option, /// Channel count for `AudioChannelConfiguration` (audio only). pub channel_count: Option, - /// The timeline's timescale (units per second), for `SegmentTemplate@timescale`. + /// The records' timescale (units per second), for `SegmentTemplate@timescale`. pub timescale: u32, /// `(t, d)` per listed segment in `timescale` units, oldest first: the record's `pts` and /// `duration` verbatim. Gap segments (no content for this rendition) and zero-duration @@ -55,11 +55,9 @@ pub(crate) struct Representation { /// Whether this rendition's window has ended (the broadcast finished). Not rendered; /// [`Manifest`] callers use it to pick static vs dynamic. pub ended: bool, - /// The reference timestamp where this period started, kept exact for its offset. - pub period: moq_net::Timestamp, /// The init segment's content hash (`init.{init}.mp4`). pub init: String, - /// The [`tag`](super::segments::tag) of the reference numbering the segments. + /// The [`tag`](super::segments::tag) of the record track numbering the segments. pub tag: String, /// The publisher run every segment URL carries (`seg/{generation}.{tag}.t$Time$.m4s`). pub generation: Option>, @@ -181,16 +179,9 @@ fn render_representation(out: &mut String, instance: &str, rep: &Representation, ); } - let offset = if rep.period.is_zero() { - String::new() - } else { - // Scaled like each `S@t`, so the first segment lands exactly on the period start. - let timescale = moq_net::Timescale::new(u64::from(rep.timescale.max(1))).expect("nonzero timescale"); - format!(" presentationTimeOffset=\"{}\"", rep.period.as_scale(timescale)) - }; let _ = writeln!( out, - " ", + " ", rep.timescale.max(1) ); let _ = writeln!(out, " "); @@ -228,8 +219,8 @@ fn render_adaptation_sets<'a>( Kind::Audio => ("audio", "audio/mp4"), }; for representations in codecs.values() { - // Segment boundaries come from the broadcast's single timeline, so alignment across - // renditions is guaranteed by construction; groups open on keyframes. + // Each rendition lists only the segments that line up with the lead rendition's, leaving + // the rest out as gaps; groups open on keyframes. let _ = writeln!( out, " " @@ -257,8 +248,7 @@ pub(crate) fn render_manifest(manifest: &Manifest, query: Option<&str>) -> Strin ") -> Strin xs_duration(Duration::from_secs(target)), ); - let periods: BTreeSet<_> = representations().map(|rep| rep.period).collect(); - for period in periods { - let start = Duration::from(period); - let _ = writeln!( - out, - " ", - start.as_nanos(), - xs_duration(start) - ); - render_adaptation_sets( - &mut out, - &manifest.instance, - Kind::Video, - manifest.video.iter().filter(|rep| rep.period == period), - query, - ); - render_adaptation_sets( - &mut out, - &manifest.instance, - Kind::Audio, - manifest.audio.iter().filter(|rep| rep.period == period), - query, - ); - let _ = writeln!(out, " "); - } + // Every segment carries its absolute content time, so one period starting at pts 0 holds them. + let _ = writeln!(out, " ", xs_duration(Duration::ZERO)); + render_adaptation_sets(&mut out, &manifest.instance, Kind::Video, manifest.video.iter(), query); + render_adaptation_sets(&mut out, &manifest.instance, Kind::Audio, manifest.audio.iter(), query); + let _ = writeln!(out, " "); let _ = writeln!(out, ""); out } @@ -346,7 +316,6 @@ mod tests { timescale: 1000, segments, ended, - period: moq_net::Timestamp::ZERO, init: "0123abcd".into(), tag: "ab12cd34".into(), generation: None, @@ -354,48 +323,9 @@ mod tests { } #[test] - fn handoff_periods_preserve_absolute_time_and_source_templates() { - let old = video(vec![(4_000, 2_000)], false); - let mut next = video(vec![(6_000, 2_000)], false); - next.period = moq_net::Timestamp::from_secs(6).unwrap(); - next.tag = "next".into(); - let manifest = Manifest { - instance: "run".into(), - availability_start: Some(Wall::UNIX_EPOCH), - publish: Wall::UNIX_EPOCH, - window: Duration::from_secs(4), - finished: false, - video: vec![old, next], - audio: Vec::new(), - }; - let rendered = render_manifest(&manifest, None); - assert_eq!(rendered.matches(", ended: bool) -> Representation { @@ -412,7 +342,6 @@ mod tests { timescale: 1000, segments, ended, - period: moq_net::Timestamp::ZERO, init: "4567cdef".into(), tag: "ab12cd34".into(), generation: None, diff --git a/rs/moq-hls/src/export/playlist.rs b/rs/moq-hls/src/export/playlist.rs index 336b47df054..2b3b1021850 100644 --- a/rs/moq-hls/src/export/playlist.rs +++ b/rs/moq-hls/src/export/playlist.rs @@ -1,7 +1,7 @@ //! Hand-written HLS media playlist generation. //! -//! Each reference record defines one complete aligned segment with its own duration. -//! URLs carry the source reference and record number, and that number is the segment's +//! Each HLS record defines one complete segment with its own duration. +//! URLs carry the record track's tag and record number, and that number is the segment's //! `EXT-X-MEDIA-SEQUENCE`, so every edge derives it alone. URIs are relative to the media playlist //! (`////media.m3u8`), so they resolve against the rendition //! directory. @@ -41,7 +41,7 @@ pub(crate) struct Snapshot { pub(crate) struct Segment { /// The source segment number; the URI is `seg/{tag}.{segment}.m4s`. pub segment: u64, - /// The [`tag`](super::segments::tag) of the reference numbering the segment. + /// The [`tag`](super::segments::tag) of the record track numbering the segment. pub tag: String, /// `EXTINF` duration. pub duration: Duration, diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index e6630cd7ec5..9e52bd5b1d9 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -1,20 +1,19 @@ -//! One rendition: playlists from its view of the broadcast's segments, resolved against its own -//! timeline, and segments fetched on demand. +//! One rendition: playlists from its own HLS records, and segments fetched on demand. use moq_clock::Wall; +use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use std::task::Poll; use std::time::Duration; use bytes::Bytes; -use hang::catalog::{Archive, AudioConfig, Clock, Reference, VideoConfig}; +use hang::catalog::{AudioConfig, Clock, Hls, Reference, VideoConfig}; use moq_mux::container::fmp4::Muxer; use sha2::{Digest, Sha256}; use super::playlist::{Segment, Snapshot}; -use super::spans::{Content, Spans}; -use super::{mpd, segments}; +use super::{mpd, renditions, segments}; use crate::Result; /// Fallback advertised bitrates when the catalog doesn't carry one. @@ -29,10 +28,13 @@ fn default_bandwidth(kind: Kind) -> u64 { } } -/// Upper bound on the groups fetched for one segment, so a corrupt timeline can't turn one +/// Upper bound on the groups fetched for one segment, so a corrupt record can't turn one /// HTTP request into an endless fetch loop. const MAX_SEGMENT_GROUPS: u64 = 1024; +/// HLS record times are milliseconds, so DASH timelines are too. +const TIMESCALE: u32 = 1000; + /// Whether a rendition carries video or audio. /// /// Also the first URL path component of a rendition (`/{broadcast}/{kind}/{name}/...`), @@ -64,8 +66,8 @@ impl Kind { } } -/// How far behind the live edge a cursor's feed still asks for a group. A group resolves -/// into a row only once its successor starts, so a cursor keeping up fetches it at the live +/// How far behind the live edge a cursor's feed still asks for a group. A segment is listed +/// only once its successor's record arrives, so a cursor keeping up fetches it at the live /// edge; the budget only has to cover a burst (a publisher catching up) and a cursor that /// fell behind. The default zero would deliver only the newest group of a burst. const FEED_MAX_DELAY: Duration = Duration::from_secs(30); @@ -96,11 +98,6 @@ struct Run { init: Option>, /// Bumped by every restart, so an init build that straddles one isn't cached for the new run. epoch: u64, - /// The window [`resets`](segments::Producer::resets) during which a listed segment started - /// where a player can, so the master keeps advertising the rendition even once a GOP past - /// [`MAX_SEGMENTS`](segments::MAX_SEGMENTS) records evicts that segment. Stale once the window - /// is cleared (a new generation or a timeline skip or rewind). - startable: Option, } /// The rendition's catalog config, kept whole so a [`Muxer`] can be built per request. @@ -161,7 +158,7 @@ impl Media { }) } - /// Whether incoming timeline rows should be listed: not once the bound sibling ended. + /// Whether incoming rows should be listed: not once the bound sibling ended. fn admits(&self, window: &segments::Producer) -> bool { self.sync(window); !self.handle.lock().expect("media lock poisoned").ended @@ -179,7 +176,7 @@ impl Media { handle.capture(&broadcast, &self.track); if self.sibling && !handle.ended && broadcast.poll_closed(waiter).is_ready() { // End and Restart both drain only the pinned instance. Looking its path up again - // could serve a replacement under the old timeline's rows. + // could serve a replacement under the old records' rows. handle.ended = true; window.end(); } @@ -224,8 +221,7 @@ fn normalize_video(config: &VideoConfig) -> VideoConfig { config.label = None; config.enabled = true; config.preference = 0; - // Publishers estimate it and republish once it settles. The rendition keeps the fMP4 - // timescale it was built with, and the framerate is refreshed in place like the bitrate. + // Publishers estimate it and republish once it settles. Nothing muxed reads it. config.framerate = None; config } @@ -241,7 +237,19 @@ fn normalize_audio(config: &AudioConfig) -> AudioConfig { config } -/// A single HLS rendition: master-playlist metadata, its view of the broadcast timeline (which +/// What a listed row holds on its rendition. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Content { + /// These groups. + Groups(std::ops::Range), + /// Nothing to fetch: content stopped, or the segment does not line up with the lead + /// rendition's. Listed as `EXT-X-GAP` so the numbering stays consecutive. + Gap, + /// Not yet known: the lead rendition has not listed this number yet. + Pending, +} + +/// A single HLS rendition: master-playlist metadata, its window over its own HLS records (which /// renders its media playlist), and on-demand segment fetching. pub struct Rendition { /// The rendition's catalog ID, also its URL path component. @@ -252,7 +260,7 @@ pub struct Rendition { /// [`bandwidth`](Self::bandwidth), refreshed in place by [`refresh`](Self::refresh). bitrate: RwLock>, /// The latest catalog framerate (video only), refreshed in place by [`refresh`](Self::refresh): - /// the muxer's fallback sample cadence and the DASH `frameRate`. + /// the DASH `frameRate`. framerate: RwLock>, /// Whether the catalog currently enables the rendition, refreshed in place by /// [`refresh`](Self::refresh). A disabled rendition keeps its window and media playlist but @@ -266,20 +274,26 @@ pub struct Rendition { pub codec: String, config: Config, - /// The catalog's root archive timeline: the timescale timings decode with. - section: Archive, - /// The catalog's root broadcast clock: the wall anchor timings map through, after - /// timescale conversion. Absent when the publisher exposes none. + /// The catalog entry's `hls` field: the record track and nominal duration. + hls: Hls, + /// The [tag](segments::tag) of [`hls`](Self::hls)'s record track, in every segment URL. + tag: Arc, + /// The catalog's root broadcast clock: the wall anchor record times map through. Absent when + /// the publisher exposes none. clock: Option, - /// This rendition's window over the broadcast's segments, fed by the catalog watcher's - /// fan-out. + /// This rendition's window over its records. live: Arc, - /// This rendition's own timeline, which resolves each segment to its frames. - spans: Arc, - /// The broadcast carrying the catalog and every timeline. + /// What every rendition of the broadcast shares: discontinuities, the run, the lead. + fanout: renditions::Fanout, + /// Whether each recent segment lines up with the lead rendition's, decided once so a + /// listing never changes under a player. + verdicts: Mutex>, + /// The broadcast carrying the catalog and every record track. catalog: moq_net::broadcast::Consumer, - /// Feeds [`spans`](Self::spans); aborted when the rendition drops. + /// Follows the records into [`live`](Self::live); aborted when the rendition drops. watcher: Mutex>>, + /// Set once the catalog drops or replaces the rendition (see [`retire`](Self::retire)). + retiring: tokio::sync::watch::Sender, /// The broadcast serving this rendition's media. A sibling is bound when the rendition is /// created, and ends the rendition when it ends. media: Media, @@ -295,9 +309,10 @@ pub struct Rendition { impl Rendition { /// Whether `config` describes the same media this rendition is already serving, i.e. whether - /// it decodes and muxes identically. Fields the publisher revises without touching the media - /// (its bitrate, jitter, and framerate estimates, a label) are ignored here, and the bitrate - /// and framerate are picked up by [`refresh`](Self::refresh) instead. + /// it decodes and muxes identically and lists the same records. Fields the publisher revises + /// without touching the media (its bitrate, jitter, and framerate estimates, a label) are + /// ignored here, and the bitrate and framerate are picked up by [`refresh`](Self::refresh) + /// instead. pub(crate) fn matches_video(&self, config: &VideoConfig) -> bool { let Config::Video(current) = &self.config else { return false; @@ -347,8 +362,8 @@ impl Rendition { *self.framerate.read().expect("framerate lock poisoned") } - /// Build a video rendition over the broadcast's timeline `section`, timed by the - /// catalog root `clock`. + /// Build a video rendition listing the records `hls` names, timed by the catalog root + /// `clock`. /// /// Fails when the config's `broadcast` reference escapes above the origin root: it names no /// broadcast, so there would be no media to serve. @@ -356,8 +371,9 @@ impl Rendition { name: String, config: &VideoConfig, source: &moq_mux::Source, - section: Archive, + hls: Hls, clock: Option, + fanout: renditions::Fanout, ) -> moq_mux::Result { let media = Media::bind(source, config, config.track(&name))?; Ok(Self { @@ -370,26 +386,30 @@ impl Rendition { height: config.coded_height, codec: config.codec.to_string(), config: Config::Video(config.clone()), - section, + tag: Arc::from(segments::tag(&hls.track)), + hls, clock, live: Arc::new(segments::Producer::new()), - spans: Arc::new(Spans::new()), + fanout, + verdicts: Mutex::default(), catalog: source.broadcast(), watcher: Mutex::new(None), + retiring: tokio::sync::watch::Sender::new(false), media, run: Mutex::default(), building: tokio::sync::Mutex::new(()), }) } - /// Build an audio rendition over the broadcast's timeline `section`, timed by the catalog - /// root `clock`, failing like [`video`](Self::video). + /// Build an audio rendition listing the records `hls` names, failing like + /// [`video`](Self::video). pub(crate) fn audio( name: String, config: &AudioConfig, source: &moq_mux::Source, - section: Archive, + hls: Hls, clock: Option, + fanout: renditions::Fanout, ) -> moq_mux::Result { let media = Media::bind(source, config, config.track(&name))?; Ok(Self { @@ -402,91 +422,117 @@ impl Rendition { height: None, codec: config.codec.to_string(), config: Config::Audio(config.clone()), - section, + tag: Arc::from(segments::tag(&hls.track)), + hls, clock, live: Arc::new(segments::Producer::new()), - spans: Arc::new(Spans::new()), + fanout, + verdicts: Mutex::default(), catalog: source.broadcast(), watcher: Mutex::new(None), + retiring: tokio::sync::watch::Sender::new(false), media, run: Mutex::default(), building: tokio::sync::Mutex::new(()), }) } - /// Start following this rendition's own timeline, trimmed to the listed segments. - pub(crate) fn watch(&self) { - let watcher = tokio::spawn(watch_spans( + /// Start following this rendition's own HLS records. + pub(crate) fn watch(self: &Arc) { + let watcher = tokio::spawn(watch_records( self.catalog.clone(), - self.section.clone(), - self.track_name().to_string(), - self.spans.clone(), - self.live.clone(), + self.hls.clone(), + Arc::downgrade(self), + self.retiring.subscribe(), )); *self.watcher.lock().expect("watcher lock poisoned") = Some(watcher); } - /// Feed one reference timeline record into this rendition's window as a segment. - pub(crate) fn push(&self, row: segments::Row, window: Duration) { - if !self.media.admits(&self.live) { - return; - } - let starts = self.is(&row.reference) && row.starts_sync(); - let resets = self.live.push(row, window); - if starts { - self.latch(resets); + /// End the window once the segment its records are still bounding is listed, because the + /// catalog dropped or replaced the rendition. + /// + /// Its records go on (a replacement follows them too, and the publisher never finishes a + /// dropped track's), so they can't end it. Ending it at once would lose the last segment of + /// every recording, since it is listed only when the record after it arrives. + pub(crate) fn retire(&self) { + self.retiring.send_replace(true); + } + + /// List one segment, unless the bound sibling ended. + fn push(&self, row: segments::Row) { + if self.media.admits(&self.live) { + self.live.push(row, self.fanout.window()); } - self.trim(); } - /// Drop this rendition's own records that no listed segment can resolve to anymore. - fn trim(&self) { - if let Some(oldest) = self.live.oldest() { - self.spans.trim(oldest); + /// What `row` holds on this rendition. + pub(crate) fn resolve(&self, row: &segments::Row) -> Content { + if row.stop { + return Content::Gap; + } + match self.aligned(row) { + Some(true) => Content::Groups(row.groups.clone()), + Some(false) => Content::Gap, + None => Content::Pending, } } - /// What `row` holds on this rendition: the reference serves its own record, and every other - /// rendition resolves the span against its own timeline. - fn resolve(&self, row: &segments::Row) -> Content { - if self.is(&row.reference) { - return Content::Frames { - ranges: vec![row.frames.clone()], - filter: None, - }; + /// Whether `row` lines up with the lead rendition's segment of the same number: it starts + /// within half that segment of it. `None` until the lead lists that number or never will. + /// + /// A segment the lead never lists, or already evicted, can't be checked and is listed as is. + /// One the lead skipped while holding the numbers around it is misaligned. + fn aligned(&self, row: &segments::Row) -> Option { + if let Some(verdict) = self.verdicts.lock().expect("verdicts lock poisoned").get(&row.segment) { + return Some(*verdict); + } + let Some(lead) = self.fanout.lead() else { + return Some(true); + }; + if std::ptr::eq(lead.as_ref(), self) { + return Some(true); + } + let pts = Duration::from(row.pts); + let verdict = match lead.live.numbered(row.segment) { + segments::Lookup::Found(lead) => { + !lead.stop && pts.abs_diff(Duration::from(lead.pts)) <= lead.duration / 2 + } + segments::Lookup::Missing(span) => !span.contains(&pts), + segments::Lookup::Never => true, + segments::Lookup::Pending => return None, + }; + if !verdict { + tracing::error!( + rendition = %self.name, + lead = %lead.name, + segment = row.segment, + "HLS segment does not line up with the lead rendition's; listing it as a gap" + ); } - self.spans - .resolve(self.kind == Kind::Video, Duration::from(row.pts)..row.end) + let mut verdicts = self.verdicts.lock().expect("verdicts lock poisoned"); + verdicts.insert(row.segment, verdict); + while verdicts.len() > 2 * segments::MAX_SEGMENTS { + verdicts.pop_first(); + } + Some(verdict) } - /// Whether the rendition declares no media for this row. + /// Whether the rendition has nothing to fetch for this row. pub(crate) fn is_gap(&self, row: &segments::Row) -> bool { self.resolve(row) == Content::Gap } - /// Poll until `row` resolves on this rendition. + /// Poll until `row`'s alignment is decided. pub(crate) fn poll_resolved(&self, waiter: &kio::Waiter, row: &segments::Row) -> Poll<()> { - if self.is(&row.reference) { + let Some(lead) = self.fanout.lead() else { + return Poll::Ready(()); + }; + // Read before resolving, so a row the lead lists in between wakes this poll. + let seen = lead.live.produced(); + if self.resolve(row) != Content::Pending { return Poll::Ready(()); } - self.spans - .poll_resolved(waiter, self.kind == Kind::Video, Duration::from(row.pts)..row.end) - } - - /// Whether this rendition is `reference`. - fn is(&self, reference: &segments::ReferenceKey) -> bool { - reference.0 == self.kind && reference.1 == self.name && reference.2 == self.track_name() - } - - /// Retire the playlist prefix through this shared media sequence. - pub(crate) fn retire(&self, through: u64) { - self.live.retire(through); - self.trim(); - } - - /// Clear rows that can no longer be followed by a consecutive source timeline record. - pub(crate) fn clear(&self) { - self.live.clear(); + lead.live.poll_changed(waiter, seen) } /// List every segment URL under `generation` from now on, keeping the run's rows and init. @@ -494,21 +540,18 @@ impl Rendition { self.run.lock().expect("run lock poisoned").generation = generation; } - /// Start a new publisher run labeled `generation`, dropping the previous run's rows, records, - /// and init. + /// Start a new publisher run labeled `generation`, dropping the previous run's rows and init. /// /// The rows go first: renders read the run before the rows, so one that sees the new label /// never lists the old run's segments under it. pub(crate) fn restart(&self, generation: Option>) { self.live.clear(); - // The old run's own records would bootstrap an inline init from its media. - self.spans.clear(); + self.verdicts.lock().expect("verdicts lock poisoned").clear(); let mut run = self.run.lock().expect("run lock poisoned"); *run = Run { generation, init: None, epoch: run.epoch + 1, - startable: None, }; } @@ -521,17 +564,17 @@ impl Rendition { self.run().generation } - /// Mark this rendition's window ended (the reference timeline finished). + /// Mark this rendition's window ended (its records finished, or it left the catalog). pub(crate) fn end(&self) { self.live.end(); } - /// Fail this rendition's window with the reference timeline's `err`. + /// Fail this rendition's window with `err`. pub(crate) fn fail(&self, err: crate::Error) { self.live.fail(err); } - /// A cursor over this rendition's segments, with their media, in timeline order. + /// A cursor over this rendition's segments, with their media, in order. /// For a recorder mirroring the broadcast; see [`segments::Consumer`]. pub fn segments(self: &Arc) -> segments::Consumer { self.live.subscribe(self.clone()) @@ -550,15 +593,12 @@ impl Rendition { kio::wait(|waiter| self.poll_playable(waiter)).await; } - /// Poll until this rendition is playable, its media is denied, or a timeline failed. + /// Poll until this rendition is playable, its media is denied, or its window failed. pub(crate) fn poll_playable(&self, waiter: &kio::Waiter) -> Poll<()> { self.poll_media(waiter); if self.media.refusal().is_some() { return Poll::Ready(()); } - if self.spans.poll_failed(waiter).is_ready() { - return Poll::Ready(()); - } self.live.poll_playable(waiter) } @@ -568,14 +608,15 @@ impl Rendition { self.media.poll_sync(waiter, &self.live) } - /// Render this rendition's media playlist from the current timeline window, or `None` when - /// there is nothing to serve yet (no segment, and the broadcast has not ended) -- a - /// playlist with no segments confuses players, so a server should treat `None` as "not - /// ready" rather than serve it. + /// Render this rendition's media playlist from its current window, or `None` when there is + /// nothing to serve yet (no segment, and the records have not ended) -- a playlist with no + /// segments confuses players, so a server should treat `None` as "not ready" rather than + /// serve it. /// - /// Fails with [`TimelineFailed`](crate::Error::TimelineFailed) once the reference timeline - /// or this rendition's own timeline failed, or [`CatalogFailed`](crate::Error::CatalogFailed) - /// once the catalog did, rather than ending a truncated playlist with `EXT-X-ENDLIST`. A server should answer that with an uncacheable error. + /// Fails with [`RecordsFailed`](crate::Error::RecordsFailed) once this rendition's records + /// failed, or [`CatalogFailed`](crate::Error::CatalogFailed) once the catalog did, rather + /// than ending a truncated playlist with `EXT-X-ENDLIST`. A server should answer that with an + /// uncacheable error. /// /// A denied referenced broadcast propagates the underlying authorization error; it is /// not missing media. The HTTP server answers it with 403 and no-store. @@ -608,12 +649,12 @@ impl Rendition { /// Waits without bound for the first segment; bounding it is the caller's policy. `query` /// propagates exactly as in [`media_playlist`](Self::media_playlist). /// - /// A failed timeline or catalog fails it as in [`media_playlist`](Self::media_playlist). + /// A failed rendition or catalog fails it as in [`media_playlist`](Self::media_playlist). pub async fn playlist(&self, query: Option<&str>) -> Result> { // A denied reference is refused before anything else, so no other error masks it. self.authorize()?; self.playable().await; - // A failed timeline may never supply the keyframe an inline init waits for. + // Failed records may never supply the keyframe an inline init waits for. self.check()?; // An inline-codec init needs a keyframe group fetched first. Building it caches, so the // player's follow-up GET of the init is free. @@ -623,48 +664,38 @@ impl Rendition { self.media_playlist(query) } - /// Snapshot the media playlist from the current timeline window. + /// Snapshot the media playlist from the current window. #[cfg(test)] pub(crate) fn snapshot(&self) -> Snapshot { - self.try_snapshot().expect("the timeline failed") + self.try_snapshot().expect("the rendition failed") } - /// Snapshot the media playlist, or the error its timeline failed with. + /// Snapshot the media playlist, or the error its rendition failed with. #[cfg(test)] pub(crate) fn try_snapshot(&self) -> Result { self.snapshot_as(self.generation()) } - /// Snapshot the media playlist from the current timeline window, labeled `generation`. + /// Snapshot the media playlist from the current window, labeled `generation`. fn snapshot_as(&self, generation: Option>) -> Result { self.media.sync(&self.live); let window = self.live.window(); if let Some(err) = self.failure(&window) { return Err(err); } - // An ended reference ends the playlist only once this rendition resolved every row, so a - // slower timeline's tail is listed before `EXT-X-ENDLIST` stops players polling. + // Ended records end the playlist only once every row's alignment is decided, so the tail + // is listed before `EXT-X-ENDLIST` stops players polling. let complete = self.resolved(&window.segments).count() == window.segments.len(); let finished = window.ended && complete; Ok(self.snapshot_from_window(window, finished, generation)) } - /// Why listings refuse: the referenced media is denied, or a timeline failed before - /// covering every row it lists or will list. + /// Why listings refuse: the referenced media is denied, or the window failed. fn failure(&self, window: &segments::Window) -> Option { - if let Some(err) = self.media.refusal() { - return Some(err); - } - if let Some(err) = &window.failed { - return Some(err.clone()); - } - let failure = self.spans.failure()?; - // A timeline failing once the reference finished loses nothing if its records cover every - // row. One that failed earlier stays failed, however the reference ends. - (!failure.late || window.segments.iter().any(|row| self.is_failed(row))).then_some(failure.err) + self.media.refusal().or_else(|| window.failed.clone()) } - /// Return bound media or its authorization refusal, leaving timeline state alone. + /// Return bound media or its authorization refusal, leaving the window alone. pub(crate) fn authorize(&self) -> Result> { let binding = self.media.sync(&self.live); match self.media.refusal() { @@ -673,35 +704,30 @@ impl Rendition { } } - /// Refuse denied media or a failed timeline (see [`failure`](Self::failure)). + /// Refuse denied media or a failed window (see [`failure`](Self::failure)). pub(crate) fn check(&self) -> Result<()> { self.authorize()?; - if let Some(err) = self.live.failure() { - return Err(err); - } - // Only a failed own timeline needs the window to judge. - if self.spans.failure().is_none() { - return Ok(()); - } - match self.failure(&self.live.window()) { + match self.live.failure() { Some(err) => Err(err), None => Ok(()), } } - /// Whether this rendition failed independently of the reference timeline, so listings - /// leave it out while its siblings stay available. + /// Whether this rendition failed while the catalog did not, so listings leave it out while + /// its siblings stay available. pub(crate) fn failed_alone(&self) -> bool { - self.live.failure().is_none() && self.check().is_err() + self.fanout.failure().is_none() && self.check().is_err() } fn snapshot_from_window(&self, window: segments::Window, finished: bool, generation: Option>) -> Snapshot { - // HLS requires EXT-X-TARGETDURATION to be at least every EXTINF, rounded up to whole - // seconds. The catalog duration is nominal, while actual records can be longer. + // HLS requires EXT-X-TARGETDURATION to be at least every EXTINF, rounded to the nearest + // whole second; it is at least the declared duration, which every segment aims for. let target_duration = window .segments .iter() - .map(|s| s.duration.as_secs_f64().ceil() as u64) + .map(|s| s.duration) + .chain([self.hls.duration]) + .map(|duration| duration.as_secs_f64().round() as u64) .max() .unwrap_or(0) .max(1); @@ -713,7 +739,7 @@ impl Rendition { let discontinuity = previous.replace(s.discontinuity).is_some_and(|p| p != s.discontinuity); Segment { segment: s.segment, - tag: segments::tag(&s.reference), + tag: s.tag.to_string(), duration: s.duration, gap: content == Content::Gap, discontinuity, @@ -731,26 +757,15 @@ impl Rendition { } } - /// `rows` with what each holds, up to the first one this rendition cannot resolve yet (or - /// ever, once its timeline failed): a segment is listed only once its content is final. + /// `rows` with what each holds, up to the first one whose alignment is still pending: a + /// segment is listed only once its content is final. fn resolved<'a>(&'a self, rows: &'a [segments::Row]) -> impl Iterator { rows.iter() .map(|row| (row, self.resolve(row))) - .take_while(|(_, content)| !matches!(content, Content::Pending | Content::Failed)) - } - - /// Whether this rendition's timeline failed before reaching `row`'s end, so neither it nor - /// any later row will resolve. - pub(crate) fn is_failed(&self, row: &segments::Row) -> bool { - self.resolve(row) == Content::Failed + .take_while(|(_, content)| *content != Content::Pending) } - /// Why this rendition's own timeline failed, whether or not that fails its listings. - pub(crate) fn own_failure(&self) -> Option { - self.spans.failure().map(|failure| failure.err) - } - - /// Whether the playlist has anything to serve yet (at least one segment, or the broadcast + /// Whether the playlist has anything to serve yet (at least one segment, or the records /// already ended). pub(crate) fn is_playable(&self) -> bool { self.media.sync(&self.live); @@ -759,10 +774,8 @@ impl Rendition { /// Whether the multivariant playlist advertises this rendition, i.e. a player can start it. /// - /// Audio starts on any segment. Video starts only at a group start that is a sync point, - /// so it waits until a listed segment begins at one rather than send a stock player to - /// media it cannot decode. The window keeps its newest such segment, and a GOP too long for - /// that still stays advertised for the rest of the publisher run. + /// Every segment starts at a group, and every group at a keyframe, so a rendition can start + /// as soon as it lists anything. pub(crate) fn is_advertised(&self) -> bool { self.poll_advertised(&kio::Waiter::noop()).is_ready() } @@ -773,52 +786,14 @@ impl Rendition { if self.failed_alone() { return Poll::Pending; } - if self.kind == Kind::Audio { - return self.poll_playable(waiter); - } - self.poll_media(waiter); - if self.run().startable == Some(self.live.resets()) { - return self.poll_playable(waiter); - } - let startable = self.live.poll_rows(waiter, |rows| { - for row in rows { - let mut content = self.resolve(row); - if content == Content::Pending { - // Wake when the oldest unresolved row resolves, since it may be the sync start. - if self.poll_resolved(waiter, row).is_pending() { - return Poll::Pending; - } - content = self.resolve(row); - } - match content { - // Another rendition's boundary only resolves to frames by snapping to a sync start. - Content::Frames { .. } if !self.is(&row.reference) || row.starts_sync() => return Poll::Ready(()), - Content::Failed => return Poll::Pending, - _ => {} - } - } - Poll::Pending - }); - let Poll::Ready(resets) = startable else { - return Poll::Pending; - }; - // Tagged with the scanned window, so a clear racing this store leaves it stale. - self.latch(resets); - Poll::Ready(()) + self.poll_playable(waiter) } - /// Record a start listed in window `resets`. Resets only grow, so a delayed latch for an - /// older window never replaces a newer one. - fn latch(&self, resets: u64) { - let mut run = self.run.lock().expect("run lock poisoned"); - run.startable = run.startable.max(Some(resets)); - } - - /// This rendition's DASH representation: its master-level metadata plus its track's - /// timeline as `(t, d)` pairs in the timeline's own timescale (the record values - /// verbatim, so `$Time$` addressing resolves exactly). Empty until the init is known, as - /// for [`media_playlist`](Self::media_playlist), and failing like it. - pub(crate) fn representations(&self) -> Result> { + /// This rendition's DASH representation: its master-level metadata plus its segments as + /// `(t, d)` pairs in milliseconds (the record values verbatim, so `$Time$` addressing resolves + /// exactly). Empty until the init is known, as for [`media_playlist`](Self::media_playlist), + /// and failing like it. + pub(crate) fn representation(&self) -> Result> { // Read the run before the rows (see `restart`). let run = self.run(); self.media.sync(&self.live); @@ -827,64 +802,51 @@ impl Rendition { return Err(err); } let Some(init) = self.built_init(&run) else { - return Ok(Vec::new()); + return Ok(None); }; - let timescale = self.timescale(); let complete = self.resolved(&window.segments).count() == window.segments.len(); let ended = window.ended && complete; - let mut periods = std::collections::BTreeMap::)>::new(); - for (row, content) in self.resolved(&window.segments) { - if content == Content::Gap { - continue; - } - let t = row.pts.as_scale(timescale) as u64; - let span = row.end.saturating_sub(row.pts.into()); - let d = (span.as_nanos() * timescale.as_u64() as u128 / 1_000_000_000) as u64; - // A zero-duration terminal record sits at the presentation end and cannot be - // requested by DASH timing. HLS still serves it by its source-addressed URL. - if d == 0 { - continue; - } - periods - .entry(row.period) - .or_insert_with(|| (segments::tag(&row.reference), Vec::new())) - .1 - .push((t, d)); - } + let segments = self + .resolved(&window.segments) + .filter(|(_, content)| *content != Content::Gap) + .map(|(row, _)| { + let t = row.pts.as_millis() as u64; + let d = row.end.saturating_sub(row.pts.into()).as_millis() as u64; + (t, d) + }) + // A zero-duration segment cannot be requested by DASH timing. + .filter(|(_, d)| *d > 0) + .collect(); let (framerate, sample_rate, channel_count) = match &self.config { Config::Video(_) => (self.framerate(), None, None), Config::Audio(config) => (None, Some(config.sample_rate), Some(config.channel_count)), }; - Ok(periods - .into_iter() - .map(|(period, (tag, segments))| mpd::Representation { - name: self.name.clone(), - kind: self.kind, - bandwidth: self.bandwidth(), - codec: self.codec.clone(), - width: self.width, - height: self.height, - framerate, - sample_rate, - channel_count, - timescale: self.section.timescale.max(1), - segments, - ended, - period, - tag, - init: init.hash.clone(), - generation: run.generation.clone(), - }) - .collect()) + Ok(Some(mpd::Representation { + name: self.name.clone(), + kind: self.kind, + bandwidth: self.bandwidth(), + codec: self.codec.clone(), + width: self.width, + height: self.height, + framerate, + sample_rate, + channel_count, + timescale: TIMESCALE, + segments, + ended, + tag: self.tag.to_string(), + init: init.hash.clone(), + generation: run.generation.clone(), + })) } - /// Fetch and transmux the segment of the reference tagged `tag` at `at`: its aligned - /// number, or its timeline pts in the timeline's timescale (DASH `$Time$`). + /// Fetch and transmux the segment of the record track tagged `tag` at `at`: its number, or + /// its start in milliseconds (DASH `$Time$`). async fn segment_at(&self, tag: &str, at: segments::At) -> Result> { self.authorize()?; let sequence = match at { segments::At::Sequence(sequence) => sequence, - segments::At::Pts(pts) => match self.live.segment_number_at(tag, pts, self.timescale()) { + segments::At::Pts(pts) => match self.live.segment_number_at(tag, pts) { Some(sequence) => sequence, None => return Ok(None), }, @@ -892,28 +854,24 @@ impl Rendition { self.numbered(tag, sequence).await } - /// The timeline's timescale; a declared 0 is clamped rather than trusted. - fn timescale(&self) -> moq_net::Timescale { - moq_net::Timescale::new((self.section.timescale as u64).max(1)).expect("clamped nonzero timescale") - } - /// The wall-clock time of a media timestamp, when the catalog advertises its broadcast - /// clock. Converts the timeline-timescale timestamp into the clock's timescale and applies - /// the root mapping; an unrepresentable result maps to no time rather than a truncated one. + /// clock; an unrepresentable result maps to no time rather than a truncated one. pub(crate) fn wall_clock(&self, pts: moq_net::Timestamp) -> Option { let clock = self.clock.as_ref()?; clock.wall_clock(pts).ok() } - /// A muxer at the timescale this rendition's config chose, with the latest bitrate and - /// fallback framerate. + /// A muxer at the timescale this rendition's config chose, with the latest bitrate. + /// + /// Each segment times its last sample from its own record, so no catalog estimate changes + /// the media bytes. pub(crate) fn muxer(&self) -> Result { let bitrate = self.bitrate(); Ok(match &self.config { Config::Video(config) => { let mut config = config.clone(); config.bitrate = bitrate; - Muxer::video(&config)?.with_framerate(self.framerate()) + Muxer::video(&config)? } Config::Audio(config) => { let mut config = config.clone(); @@ -957,22 +915,15 @@ impl Rendition { Ok(track) } - /// The media track's name in its broadcast, which can differ from the rendition's ID. - fn track_name(&self) -> &str { - match &self.config { - Config::Video(config) => config.track(&self.name), - Config::Audio(config) => config.track(&self.name), - } - } - /// The CMAF init segment served at `init.{hash}.mp4`, built on first request and cached. /// /// `None` when it cannot be built yet, or `hash` (a [`Resource::Init`](super::Resource::Init) /// hash) names any other bytes, so a URL never serves an init it does not describe. /// /// For inline-parameter-set codecs (no catalog `description`), the parameter sets are - /// resolved by fetching the newest keyframe group first. A bound track's retained media - /// remains usable after its broadcast ends or is replaced; replacement media is never read. + /// resolved by fetching the newest listed segment's first group. A bound track's retained + /// media remains usable after its broadcast ends or is replaced; replacement media is never + /// read. pub async fn init(&self, hash: &str) -> Result> { let init = self.load_init().await?; Ok(init.filter(|init| init.hash == hash).map(|init| init.bytes.clone())) @@ -1020,20 +971,18 @@ impl Rendition { let mut muxer = self.muxer()?; // An out-of-band codec builds its init straight from the catalog; an inline one returns - // `None` until a keyframe group is read, so bootstrap it from the newest one indexed. + // `None` until a keyframe group is read, so bootstrap it from the newest listed segment's + // first group: a segment starts at a group, and a group at a keyframe. let bytes = match muxer.init()? { Some(bytes) => bytes, None => { - let reference = (self.kind, self.name.clone(), self.track_name().to_string()); - let latest = self.spans.latest_keyframe_group(); - let Some(sequence) = latest.or_else(|| self.live.latest_keyframe_group(&reference)) else { + let Some(sequence) = self.live.latest_group() else { return Ok(None); }; let Some((broadcast, track)) = self.track(&binding).await? else { return Ok(None); }; - let Ok(mut group) = fetch(&track, hang::timeline::Position::group(sequence), None, &broadcast).await? - else { + let Ok(mut group) = fetch(&track, sequence, &broadcast).await? else { return Ok(None); }; // A cache eviction mid-read leaves the init unbuildable for now, not an error. @@ -1051,9 +1000,9 @@ impl Rendition { /// Fetch and transmux the segment a [`Resource::Segment`](super::Resource::Segment) URL names. /// - /// Fetches every frame the segment resolves to on this rendition (an audio segment packs many + /// Fetches every group the segment holds on this rendition (an audio segment packs many /// short groups) and encodes them as a single CMAF fragment. `None` when the segment isn't in - /// the playlist window, is a gap or not yet resolved for this rendition, or its groups already + /// the playlist window, is a gap or not yet decided for this rendition, or its groups already /// left the relay cache. After End or Restart, only the bound instance's retained media /// drains; a replacement's media is never read under these rows. A denied referenced /// broadcast returns its authorization error even when the requested row is absent. @@ -1067,12 +1016,12 @@ impl Rendition { if !current() { return Ok(None); } - let segment = self.segment_at(&id.reference, id.at).await?; + let segment = self.segment_at(&id.tag, id.at).await?; // A generation change while fetching may have swapped the rows under the lookup. Ok(segment.filter(|_| current())) } - /// Fetch and transmux the segment numbered `segment` by the reference tagged `tag`, + /// Fetch and transmux the segment numbered `segment` by the record track tagged `tag`, /// whatever the run; see [`segment`](Self::segment). pub(crate) async fn numbered(&self, tag: &str, segment: u64) -> Result> { self.authorize()?; @@ -1082,28 +1031,21 @@ impl Rendition { self.fetch(&row).await } - /// Fetch and transmux `row`'s frames on this rendition; see [`segment`](Self::segment). + /// Fetch and transmux `row`'s groups on this rendition; see [`segment`](Self::segment). pub(crate) async fn fetch(&self, row: &segments::Row) -> Result> { let binding = self.authorize()?; // Read the run before resolving the row (see `restart`), so an init decoded from its // groups is never cached for a run that started while they were being fetched. let run = self.run(); - let Content::Frames { ranges, filter } = self.resolve(row) else { + let Content::Groups(groups) = self.resolve(row) else { return Ok(None); }; // A segment must be served whole. If its group span exceeds the safety cap (a - // pathologically coarse timeline), 404 rather than silently truncating it to a segment + // pathologically coarse record), 404 rather than silently truncating it to a segment // shorter than its advertised duration. - let total: u64 = ranges - .iter() - .map(|range| last_group(range).saturating_sub(range.start.group).saturating_add(1)) - .sum(); - if total > MAX_SEGMENT_GROUPS { - tracing::warn!( - total, - "segment spans more than MAX_SEGMENT_GROUPS; refusing to truncate" - ); + if groups.end.saturating_sub(groups.start) > MAX_SEGMENT_GROUPS { + tracing::warn!(?groups, "segment spans more than MAX_SEGMENT_GROUPS; refusing to truncate"); return Ok(None); } @@ -1117,43 +1059,44 @@ impl Rendition { // group (its real successor lives in the next group), audible on audio segments that span // many groups. let mut frames = Vec::new(); - for range in &ranges { - for sequence in range.start.group..=last_group(range) { - let start = match sequence == range.start.group { - true => range.start, - false => hang::timeline::Position::group(sequence), - }; - let end = (sequence == range.end.group).then_some(range.end.frame); - let miss = match fetch(&track, start, end, &broadcast).await? { - Ok(mut group) => match read_group(&mut muxer, &mut group, &broadcast).await? { - Ok(mut group_frames) => { - frames.append(&mut group_frames); - continue; - } - Err(err) => err, - }, + for sequence in groups { + let miss = match fetch(&track, sequence, &broadcast).await? { + Ok(mut group) => match read_group(&mut muxer, &mut group, &broadcast).await? { + Ok(mut group_frames) => { + frames.append(&mut group_frames); + continue; + } Err(err) => err, - }; - tracing::warn!( - segment = row.segment, - group = sequence, - err = %miss, - "abandoning an advertised segment: a group is unavailable" - ); - return Ok(None); - } - } - - // A rendition resolved by time keeps only the frames inside the segment's span. - if let Some(filter) = filter { - frames.retain(|frame| filter.contains(&Duration::from(frame.timestamp))); + }, + Err(err) => err, + }; + tracing::warn!( + segment = row.segment, + group = sequence, + err = %miss, + "abandoning an advertised segment: a group is unavailable" + ); + return Ok(None); } if frames.is_empty() { return Ok(None); } + // The last presented sample has no successor to time it, but the record says where the + // segment ends, so the fragment ends exactly where the next one starts. + if let Some(last) = frames + .iter_mut() + .filter(|frame| frame.duration.is_none()) + .max_by_key(|frame| frame.timestamp.as_micros()) + && let Ok(end) = moq_net::Timestamp::from_micros(row.end.as_micros() as u64) + && let Ok(end) = end.convert(last.timestamp.scale()) + && let Ok(duration) = end.checked_sub(last.timestamp) + && !duration.is_zero() + { + last.duration = Some(duration); + } let fragment = muxer.fragment(row.segment as u32, &frames)?; // Inline codec metadata was already decoded from these completed groups. - // Reuse it instead of fetching a newer, possibly unfinished timeline tail. + // Reuse it instead of fetching a newer, possibly unfinished group. if let Some(bytes) = muxer.init()? { self.cache_init(run.epoch, bytes); } @@ -1161,77 +1104,153 @@ impl Rendition { } } -/// The first retained reference tag, used by number-only test helpers for a single reference. -#[cfg(test)] -fn listed_tag(rows: &[segments::Row]) -> String { - rows.first() - .map(|row| segments::tag(&row.reference)) - .unwrap_or_default() -} - #[cfg(test)] impl Rendition { - /// Fetch the listed segment numbered `segment`, under the tag its rows carry. + /// Fetch the listed segment numbered `segment`. pub(crate) async fn listed_segment(&self, segment: u64) -> Result> { - let tag = listed_tag(&self.live.window().segments); - self.numbered(&tag, segment).await + self.numbered(&self.tag.clone(), segment).await } /// What the listed segment numbered `segment` holds on this rendition. pub(crate) fn listed_content(&self, segment: u64) -> Option { - let tag = listed_tag(&self.live.window().segments); - Some(self.resolve(&self.live.row(&tag, segment)?)) + Some(self.resolve(&self.live.row(&self.tag, segment)?)) } - /// Fetch the listed segment starting at `time`, under the tag its rows carry. + /// Fetch the listed segment starting at `time` milliseconds. pub(crate) async fn listed_segment_at(&self, time: u64) -> Result> { - let tag = listed_tag(&self.live.window().segments); - self.segment_at(&tag, segments::At::Pts(time)).await + self.segment_at(&self.tag.clone(), segments::At::Pts(time)).await } } -/// The last group holding a frame of `range`. -fn last_group(range: &std::ops::Range) -> u64 { - match range.end.frame { - 0 => range.end.group.saturating_sub(1), - _ => range.end.group, - } -} - -/// Follow `track`'s own timeline into `spans` until it ends, then mark nothing pending. -async fn watch_spans( +/// Follow a rendition's HLS records into its window until they end or fail. +/// +/// A record is listed once the next one arrives, which bounds its groups and, unless it states +/// its own, its duration. A stop is listed at once, as a gap. +async fn watch_records( broadcast: moq_net::broadcast::Consumer, - section: Archive, - track: String, - spans: Arc, - live: Arc, + hls: Hls, + rendition: Weak, + mut retiring: tokio::sync::watch::Receiver, ) { let result: Result<()> = async { - let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(&broadcast, §ion, &track).await?; - while let Some(event) = timeline.next().await? { + let mut records = tokio::select! { + records = moq_mux::hls::Consumer::subscribe(&broadcast, &hls) => records?, + _ = retiring.wait_for(|retiring| *retiring) => return Ok(()), + }; + // The newest record, waiting for its successor. + let mut pending: Option = None; + // The next listed segment starts a new discontinuity. + let mut broken = false; + loop { + let event = tokio::select! { + event = records.next() => match event? { + Some(event) => event, + None => return Ok(()), + }, + // Nothing waits on a successor, so a retiring rendition has listed all it will. + _ = retiring.wait_for(|retiring| *retiring), if pending.is_none() => return Ok(()), + }; + let Some(rendition) = rendition.upgrade() else { + return Ok(()); + }; match event { - moq_mux::timeline::Event::Push { entry, .. } => spans.push(entry), - moq_mux::timeline::Event::Pop(range) => spans.pop(range), - moq_mux::timeline::Event::Skip(_) => spans.clear(), - _ => unreachable!("unknown timeline event"), + moq_mux::hls::Event::Push { index, entry } => { + // The record bounds the one a retiring rendition was waiting on, and starts + // what the catalog no longer lists. + let last = *retiring.borrow(); + if let Some(previous) = pending.take() { + match row(&rendition, &previous, &entry, broken) { + Some(row) => { + broken = false; + rendition.push(row); + } + // A successor that goes backwards can't bound it, so drop it. + None => broken = true, + } + } + if entry.is_stop() { + let pts = Duration::from(entry.pts); + let discontinuity = rendition.fanout.stamp(entry.sequence, broken, pts); + rendition.push(segments::Row { + index, + segment: entry.sequence, + tag: rendition.tag.clone(), + groups: entry.group..entry.group, + duration: Duration::ZERO, + pts: entry.pts, + end: pts, + discontinuity, + stop: true, + }); + // Whatever resumes does not continue it. + broken = true; + } else { + pending = Some(entry); + } + if last { + return Ok(()); + } + } + moq_mux::hls::Event::Pop(range) => { + // HLS assigns consecutive positions within a playlist. Removing an interior + // row would renumber every later URI, so retention retires a whole prefix. + if let Some(through) = range.end.checked_sub(1) { + rendition.live.retire(through); + } + } + moq_mux::hls::Event::Skip(_) => { + pending = None; + broken = true; + rendition.live.clear(); + } + _ => unreachable!("unknown HLS record event"), } } - Ok(()) } .await; + let Some(rendition) = rendition.upgrade() else { + return; + }; match result { - Ok(()) => spans.end(), + Ok(()) => rendition.end(), // Never retried (the origin already rides out transient source failures), and not // papered over with gaps: the rendition's playlist answers the error. Err(err) => { - tracing::error!(%track, %err, "rendition timeline failed; failing its playlist"); - let err = crate::Error::TimelineFailed { - track, + tracing::error!(track = %hls.track, %err, "HLS records failed; failing the rendition's playlist"); + rendition.fail(crate::Error::RecordsFailed { + track: hls.track, source: Box::new(err), - }; - spans.fail(err, live.is_ended()); + }); } } + rendition.close(); +} + +/// The row of `record`, bounded by its successor `next`, or `None` when `next` goes backwards. +fn row( + rendition: &Rendition, + record: &moq_mux::hls::Entry, + next: &moq_mux::hls::Entry, + broken: bool, +) -> Option { + let pts = Duration::from(record.pts); + if next.group < record.group || Duration::from(next.pts) < pts { + tracing::warn!(segment = record.sequence, "HLS record goes backwards; dropping its segment"); + return None; + } + let duration = record.duration.unwrap_or(Duration::from(next.pts) - pts); + let end = pts + duration; + Some(segments::Row { + index: record.sequence, + segment: record.sequence, + tag: rendition.tag.clone(), + groups: record.group..next.group, + duration, + pts: record.pts, + end, + discontinuity: rendition.fanout.stamp(record.sequence, broken, end), + stop: false, + }) } impl Drop for Rendition { @@ -1244,20 +1263,13 @@ impl Drop for Rendition { /// Fetch one group, mapping "no longer (or not yet) servable" to the error that says so /// rather than a bare `None`: a caller abandoning a segment can only explain itself with it. -/// -/// Fetches the whole group, since a group is the unit of decodability, and reads from `start` -/// and, when `end` is set, stops before that frame of the group. async fn fetch( track: &moq_net::track::Consumer, - start: hang::timeline::Position, - end: Option, + sequence: u64, broadcast: &moq_net::broadcast::Consumer, ) -> Result> { - match track.fetch_group(start.group, None).await { - Ok(group) => Ok(Ok(match end { - Some(end) => group.with_range(start.frame..end), - None => group.with_range(start.frame..), - })), + match track.fetch_group(sequence, None).await { + Ok(group) => Ok(Ok(group)), Err(err) if is_unservable(&err, broadcast) => Ok(Err(err)), Err(err) => Err(err.into()), } @@ -1355,6 +1367,10 @@ mod tests { producer } + fn hls() -> Hls { + Hls::new("video.hls", Duration::from_secs(1)) + } + #[tokio::test] async fn pending_reads_preserve_a_later_authorization_refusal() { let origin = moq_tokio::origin::spawn(); @@ -1363,51 +1379,35 @@ mod tests { let source = moq_mux::Source::new(origin.consume(), broadcast.consume()); let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8); config.broadcast = Some(moq_net::path::Relative::new("./secret").to_owned()); - let rendition = Arc::new(Rendition::video("video".into(), &config, &source, Archive::default(), None).unwrap()); + let renditions = renditions::Producer::new(Duration::from_secs(30)); + let rendition = + Arc::new(Rendition::video("video".into(), &config, &source, hls(), None, renditions.fanout()).unwrap()); let request = dynamic.requested_broadcast().await.unwrap(); - let other = origin.dynamic("room/hidden").unwrap(); - config.broadcast = Some(moq_net::path::Relative::new("./hidden").to_owned()); - let resolving = Arc::new(Rendition::video("video".into(), &config, &source, Archive::default(), None).unwrap()); - let other_request = other.requested_broadcast().await.unwrap(); - let row = |reference: &str| segments::Row { + let row = segments::Row { index: 0, segment: 0, - period: moq_net::Timestamp::ZERO, - reference: Arc::new((Kind::Video, reference.into(), reference.into())), - frames: hang::timeline::Position::group(0)..hang::timeline::Position::group(1), - keyframe: true, + tag: rendition.tag.clone(), + groups: 0..1, duration: Duration::from_secs(1), pts: moq_net::Timestamp::ZERO, end: Duration::from_secs(1), discontinuity: 0, + stop: false, }; - let own = row("video"); - let segment = rendition.fetch(&own); + let segment = rendition.fetch(&row); tokio::pin!(segment); // The cursor waits on a window with no rows, which the refusal neither fills nor ends. let mut cursor = rendition.segments(); let next = cursor.next(); tokio::pin!(next); - // This cursor waits for its own timeline to resolve another rendition's row, which the - // refusal never does either. - resolving.live.push(row("other"), Duration::from_secs(30)); - let mut resolving_cursor = resolving.segments(); - let resolving_next = resolving_cursor.next(); - tokio::pin!(resolving_next); tokio::select! { biased; _ = &mut segment => panic!("the reference request has not been answered"), _ = &mut next => panic!("the reference request has not been answered"), - _ = &mut resolving_next => panic!("the reference request has not been answered"), _ = std::future::ready(()) => {} } request.reject(moq_net::Error::Unauthorized); - other_request.reject(moq_net::Error::Unauthorized); - for read in [ - segment.await, - next.await.map(|_| None), - resolving_next.await.map(|_| None), - ] { + for read in [segment.await, next.await.map(|_| None)] { assert!(matches!( read, Err(crate::Error::Mux(moq_mux::Error::Moq(moq_net::Error::Unauthorized))) diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index 96ef3975dc7..fa7c3c897ef 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -1,26 +1,25 @@ //! A broadcast's rendition set, as a `Producer`/[`Consumer`] pair. //! //! The `Producer` holds the current renditions, reconciled from the catalog by its `sync`; -//! the HTTP serve path reads them synchronously (look one up, render the master playlist). It -//! is also the fan-out point for the broadcast's segments: the reference rendition's timeline -//! watcher hands each record to every rendition through the `Fanout` handle, and each rendition -//! keeps its own window over them. +//! the HTTP serve path reads them synchronously (look one up, render the master playlist). Each +//! rendition follows its own HLS records; the `Fanout` handle holds what they share: the +//! discontinuity numbering, the publisher run, and the lead rendition the others' segments are +//! checked against. //! A [`Consumer`] is a cursor for a recorder that mirrors the *whole* broadcast: [`next`](Consumer::next) //! yields one [`Event`] at a time as renditions are added or removed, replaying the current -//! set as [`Added`](Event::Added) when first consumed, and returning `None` once the source +//! set as [`Added`](Event::Added) when first consumed, and returning `None` once the catalog //! finishes cleanly, or an error when the recording is incomplete. use moq_clock::Wall; -use std::collections::{BTreeMap, VecDeque}; +use std::collections::BTreeMap; use std::sync::{Arc, Weak}; use std::task::Poll; use std::time::Duration; use moq_mux::catalog::hang::Catalog; -use moq_mux::timeline::Entry; use super::rendition::{Kind, Rendition}; -use super::segments::{self, Discontinuities, Reference}; +use super::segments::Discontinuities; /// The `(kind, name)` identity of a rendition. Video and audio are separate axes, so a video /// and an audio rendition may share a name without colliding. @@ -29,7 +28,7 @@ type Key = (Kind, String); /// A change to the rendition set, yielded by [`Consumer::next`]. #[non_exhaustive] pub enum Event { - /// A rendition appeared, with its own timeline advertised (or was reconfigured: a `Removed` + /// A rendition appeared, with its HLS records advertised (or was reconfigured: a `Removed` /// for the old one precedes it). Added(Arc), /// A rendition disappeared (removed from the catalog, or replaced by a reconfigure). @@ -41,54 +40,31 @@ pub enum Event { }, } -/// The timeline fan-out state: who gets fed, and what a late-created rendition replays. +/// What every rendition of the broadcast shares. /// -/// Feeds by `Weak` reference rather than through the rendition map: a recording cursor's -/// `Arc` must keep receiving records after `Broadcaster::drop` clears the map, or -/// its final segments would be silently lost. +/// Reaches renditions by `Weak` reference rather than through the rendition map: a recording +/// cursor's `Arc` must keep its records flowing after `Broadcaster::drop` clears the +/// map, or its final segments would be silently lost. struct Feed { /// Every rendition ever created and still alive, pruned as they drop. targets: Vec>, - /// Recent reference records with their discontinuity sequence, replayed into a rendition - /// created mid-broadcast so its playlist window isn't empty until the next record. Evicted with - /// the same policy as the windows. - history: VecDeque, - /// The last accepted row survives source pops, so a handoff cannot rewind or reuse a position. - last: Option, - /// Catalog-managed reader identity; pointer identity also rejects a cancelled reader of - /// the same reference after a rapid disable/re-enable. No selected reader freezes the window. - selection: Option>, - /// A new watcher first replays its checkpoint, including records already advertised. - joining: bool, - /// Stamps each record once, so every rendition agrees on where the timeline breaks. + /// Stamps each segment number once, so every rendition agrees on where content breaks. discontinuities: Discontinuities, - /// The timeline ended cleanly; late-created renditions start ended (`EXT-X-ENDLIST`). - ended: bool, - /// A reference timeline the playlists still list failed; late-created renditions start failed. + /// The catalog failed; late-created renditions start failed. failed: Option, - /// The timeline stream is over (cleanly or not); late-created renditions start closed. - closed: bool, - /// The estimated wall-clock time of timeline `pts` 0, anchored when the first record - /// arrived (assuming its segment had just ended). The DASH manifest's - /// `availabilityStartTime` falls back to this when the catalog declares no `clock`; set - /// once so reloads see a stable presentation, and reset with the window when the - /// timeline restarts. + /// The estimated wall-clock time of `pts` 0, anchored when the first segment was listed + /// (assuming it had just ended). The DASH manifest's `availabilityStartTime` falls back to + /// this when the catalog declares no `clock`; set once so reloads see a stable presentation, + /// and reset with the publisher run. anchor: Option, /// The publisher run every segment URL carries; late-created renditions inherit it. generation: Option>, /// The playlist window duration applied on every push (see /// [`Config::window`](super::Config::window)). window: Duration, -} - -impl Feed { - fn accepts(&self, reference: &Reference) -> bool { - self.selection.as_ref().is_none_or(|selected| { - selected - .as_ref() - .is_some_and(|selected| Arc::ptr_eq(selected, reference)) - }) - } + /// The rendition every other one's segments are checked against: the first enabled video + /// rendition, else the first enabled audio one, kept while it stays enabled. + lead: Option>, } /// The producing side of a broadcast's rendition set. @@ -108,13 +84,13 @@ struct State { renditions: BTreeMap>, /// The catalog's confirmed end, distinct from dropping its watcher. finished: Option>, - // Cache catalog quality order without making any_held_externally count it as a consumer. + // Cache catalog quality order without counting it as a holder. audio: Vec>, } -/// The timeline fan-out handle: everything the timeline watcher needs, and nothing more. +/// The shared state handle each rendition's record watcher reaches. /// -/// Deliberately separate from [`Producer`]: the watcher outlives the `Broadcaster` while a +/// Deliberately separate from [`Producer`]: a watcher outlives the `Broadcaster` while a /// recording cursor drains, and holding a full `Producer` there would keep the rendition-set /// channel open (so `renditions()` cursors would never terminate). #[derive(Clone)] @@ -129,39 +105,34 @@ impl Producer { fanout: Fanout { feed: kio::Producer::new(Feed { targets: Vec::new(), - history: VecDeque::new(), - last: None, - selection: None, - joining: false, discontinuities: Discontinuities::default(), - ended: false, failed: None, - closed: false, anchor: None, generation: None, window, + lead: None, }), }, } } - /// The timeline fan-out handle for the timeline watcher task. + /// The handle every rendition shares. pub fn fanout(&self) -> Fanout { self.fanout.clone() } /// The playlist window duration every rendition's window is trimmed to. pub fn window(&self) -> Duration { - self.fanout.feed.read().window + self.fanout.window() } - /// The estimated wall-clock time of timeline `pts` 0 (see [`Feed::anchor`]); `None` until - /// the first record arrives. + /// The estimated wall-clock time of `pts` 0 (see [`Feed::anchor`]); `None` until the first + /// segment is listed. pub fn anchor(&self) -> Option { self.fanout.feed.read().anchor } - /// Why a reference timeline the playlists list failed, if one did. + /// Why the catalog failed, if it did. pub fn failure(&self) -> Option { self.fanout.feed.read().failed.clone() } @@ -207,13 +178,6 @@ impl Producer { self.state.read().renditions.is_empty() } - /// Whether anything outside the map (a recording cursor, a handed-out `Arc`) still holds a - /// rendition. `Broadcaster::drop` checks this to decide whether the timeline watcher must - /// outlive it (so those holders keep receiving records) or can be aborted. - pub fn any_held_externally(&self) -> bool { - self.state.read().renditions.values().any(|r| Arc::strong_count(r) > 1) - } - /// Release every rendition the map is holding. /// /// The map lives in shared state, so a surviving [`Consumer`] would otherwise keep every @@ -221,8 +185,8 @@ impl Producer { /// so teardown doesn't depend on consumers dropping first. /// /// Deliberately drops rather than [`Rendition::close`]s: a rendition a cursor still holds - /// keeps receiving timeline records through the [`Feed`]'s weak list, so a recording in - /// progress drains to the real end of the broadcast instead of being truncated here. + /// keeps following its records, so a recording in progress drains to the real end of the + /// broadcast instead of being truncated here. pub fn clear(&self) { if let Ok(mut current) = self.state.write() { current.renditions.clear(); @@ -230,13 +194,13 @@ impl Producer { } } - /// Record the catalog's end before closing its change stream. + /// Record the catalog's end before closing its change stream. A failed catalog fails every + /// rendition, so none reads as finished. pub fn finish(&self, result: crate::Result<()>) { if let Err(err) = &result { - let failed = crate::Error::CatalogFailed { + self.fanout.fail(crate::Error::CatalogFailed { source: Box::new(err.clone()), - }; - self.fanout.fail(None, failed); + }); } if let Ok(mut state) = self.state.write() { state.finished = Some(result); @@ -246,134 +210,51 @@ impl Producer { /// Close the channel, signalling consumers that no more renditions will appear. /// - /// Deliberately does NOT cascade into the renditions' own segment windows: those are ended - /// or failed by the timeline watcher (see [`Fanout::finish`] and [`Fanout::fail`]), which must - /// mark them before closing them, or the playlist never gets its `EXT-X-ENDLIST` or error. + /// Deliberately does NOT cascade into the renditions' own segment windows: each ends with its + /// own records. pub fn close(&self) { let _ = self.state.close(); } } impl Fanout { - /// Select the next reference, whose records list only past the last advertised one. - pub fn select(&self, reference: Option) { - let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); - feed.selection = Some(reference); - feed.joining = true; + /// The playlist window duration every rendition's window is trimmed to. + pub fn window(&self) -> Duration { + self.feed.read().window } - /// Fan one reference timeline record out to every living rendition, and into replay history. - pub fn push(&self, index: u64, entry: Entry, reference: &Reference) { + /// The discontinuity sequence of segment `segment`, which starts a new one when `broken` + /// (see [`Discontinuities::stamp`]). Anchors the presentation at a segment ending at `end`, + /// if nothing has. + pub fn stamp(&self, segment: u64, broken: bool, end: Duration) -> u64 { let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); - if !feed.accepts(reference) || feed.failed.is_some() { - return; - } - let pts = Duration::from(entry.pts); - let end = entry.end_time(); - let mut skipped = false; - if let Some(last) = &feed.last { - if feed.joining || last.reference != *reference { - // The media sequence is the source record sequence, so every edge derives it alone. - // A replacement resumes only past every advertised position and content time; a - // lagging one holds the playlist until its numbering catches up. - if entry.sequence <= last.segment || (pts < last.end && !segments::continues(last.end, pts)) { - return; - } - // HLS numbers a playlist's segments consecutively, so a replacement that skips - // positions cannot follow the advertised rows. - skipped = entry.sequence > last.segment + 1; - } else if pts < Duration::from(last.pts) || entry.sequence <= last.segment { - feed.history.clear(); - feed.last = None; - feed.anchor = None; - } - } - feed.joining = false; - if skipped { - feed.history.clear(); - feed.discontinuities.interrupt(); - feed.targets.retain(|target| { - let Some(rendition) = target.upgrade() else { - return false; - }; - rendition.clear(); - true - }); - } if feed.anchor.is_none() { feed.anchor = Some(moq_clock::wall().checked_sub(end).unwrap_or(Wall::UNIX_EPOCH)); } - let period = feed.last.as_ref().map_or(moq_net::Timestamp::ZERO, |last| { - if last.reference == *reference { - last.period - } else { - entry.pts - } - }); - let discontinuity = feed.discontinuities.stamp(pts, end); - let row = segments::Row { - index, - segment: entry.sequence, - period, - reference: reference.clone(), - frames: entry.start..entry.end, - keyframe: entry.keyframe, - duration: entry.duration, - pts: entry.pts, - end, - discontinuity, - }; - feed.last = Some(row.clone()); - feed.history.push_back(row.clone()); - let window = feed.window; - loop { - let rest = feed.history.get(1).map_or(Duration::ZERO, |second| { - feed.history.back().unwrap().end.saturating_sub(second.pts.into()) - }); - let keeps_start = !feed.history[0].starts() || feed.history.iter().skip(1).any(segments::Row::starts); - if !segments::evicts(window, feed.history.len(), rest, keeps_start) { - break; - } - feed.history.pop_front(); - } - feed.targets.retain(|target| { - let Some(rendition) = target.upgrade() else { - return false; - }; - rendition.push(row.clone(), window); - true - }); + feed.discontinuities.stamp(segment, broken) } - /// Remove records that left the source timeline window from every rendition window. - pub fn pop(&self, range: std::ops::Range, reference: &Reference) { + /// Why the catalog failed, if it did. + pub fn failure(&self) -> Option { + self.feed.read().failed.clone() + } + + /// The rendition every other one's segments are checked against, if any. + pub fn lead(&self) -> Option> { + self.feed.read().lead.as_ref()?.upgrade() + } + + fn set_lead(&self, lead: Option<&Arc>) { let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); - let Some(through) = feed - .history - .iter() - .filter(|row| row.reference == *reference && range.contains(&row.index)) - .map(|row| row.segment) - .max() - else { - return; - }; - // HLS assigns consecutive positions within a playlist. Removing an interior row - // would renumber every later URI, so retention always retires a whole prefix. - feed.history.retain(|row| row.segment > through); - feed.targets.retain(|target| { - let Some(rendition) = target.upgrade() else { - return false; - }; - rendition.retire(through); - true - }); + feed.lead = lead.map(Arc::downgrade); } /// List every rendition under `generation` from now on. /// /// Replacing a generation drops what was listed under it and the inits built from it: those - /// belong to the run it named. The first one only labels the run already flowing, so an embedder that supplies - /// it right after construction loses nothing to a race with the first records. + /// belong to the run it named. The first one only labels the run already flowing, so an + /// embedder that supplies it right after construction loses nothing to a race with the first + /// records. pub fn set_generation(&self, generation: Option>) { let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); if feed.generation == generation { @@ -382,9 +263,7 @@ impl Fanout { let restart = feed.generation.is_some(); feed.generation = generation.clone(); if restart { - feed.history.clear(); - feed.last = None; - feed.discontinuities.interrupt(); + feed.discontinuities.restart(); feed.anchor = None; } feed.targets.retain(|target| { @@ -400,62 +279,13 @@ impl Fanout { }); } - /// Clear stale rows after source records were skipped before this reader saw them. - pub fn skip(&self, reference: Option<&Reference>) { + /// Fail every window with `err`, so no playlist reads as finished. + fn fail(&self, err: crate::Error) { let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); - if let Some(reference) = reference - && (feed.joining || !feed.accepts(reference)) - { + if feed.failed.is_some() { return; } - feed.history.clear(); - if reference.is_none() { - feed.last = None; - } - feed.discontinuities.interrupt(); - feed.targets.retain(|target| { - let Some(rendition) = target.upgrade() else { - return false; - }; - rendition.clear(); - true - }); - } - - /// End every window for the selected watcher, ignoring a superseded watcher's completion. - /// `None` ends them when the source ended with no reference selected, so no watcher will. - pub fn finish(&self, reference: Option<&Reference>) -> bool { - let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); - let selected = match reference { - Some(reference) => feed.accepts(reference), - None => feed.selection.as_ref().is_none_or(Option::is_none), - }; - if !selected || feed.failed.is_some() { - return false; - } - feed.ended = true; - feed.closed = true; - feed.targets.retain(|target| { - let Some(rendition) = target.upgrade() else { - return false; - }; - rendition.end(); - rendition.close(); - true - }); - true - } - - /// Fail every window with `err` when the selected `reference`'s timeline failed, so no - /// playlist reads as finished. A live window trims a superseded reference's rows on its own. - /// `None` fails the whole source when the catalog fails. - pub fn fail(&self, reference: Option<&Reference>, err: crate::Error) -> bool { - let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); - if feed.failed.is_some() || !reference.is_none_or(|reference| feed.accepts(reference)) { - return false; - } feed.failed = Some(err.clone()); - feed.closed = true; feed.targets.retain(|target| { let Some(rendition) = target.upgrade() else { return false; @@ -464,7 +294,6 @@ impl Fanout { rendition.close(); true }); - true } } @@ -511,9 +340,8 @@ impl Producer { .await; } - /// Enroll a freshly-created rendition in the timeline feed, replaying the recent history - /// (and the ended/failed/closed markers) so its window matches its siblings', and start following its - /// own timeline. + /// Enroll a freshly-created rendition with what its siblings share, and start following its + /// own records. fn register(&self, rendition: &Arc) { let mut feed = self .fanout @@ -521,18 +349,11 @@ impl Producer { .write() .unwrap_or_else(|_| unreachable!("fanout is open")); rendition.label(feed.generation.clone()); - rendition.watch(); - for row in &feed.history { - rendition.push(row.clone(), feed.window); - } - if feed.ended { - rendition.end(); - } if let Some(err) = &feed.failed { rendition.fail(err.clone()); - } - if feed.closed { rendition.close(); + } else { + rendition.watch(); } feed.targets.push(Arc::downgrade(rendition)); } @@ -550,33 +371,23 @@ impl Producer { /// new advertised bitrate, since the publisher republishes the catalog every time its /// measured bitrate, jitter, or framerate moves. /// - /// Renditions are only servable when the catalog advertises its timelines (its root `archive` - /// entry): without one there is nothing to render playlists from, so the whole catalog is - /// skipped with a warning. A rendition joins the set only once the archive indexes its own - /// timeline, which a publisher may declare after the rendition's init: until then every - /// segment would be a gap, so it is neither listed nor reported as [`Event::Added`]. + /// A rendition joins the set only once its entry advertises HLS records (its `hls` field), + /// which an unhinted publisher declares after its first segment: without them there is + /// nothing to render its playlist from, so it is neither listed nor reported as + /// [`Event::Added`]. /// /// `source` holds the broadcast the snapshot was read from, so each rendition serves media /// from that same broadcast rather than whatever is at the path by the time it is asked. A /// rendition whose `broadcast` reference escapes above the origin root names no broadcast at /// all, so it is dropped with a warning rather than served. pub fn sync(&self, source: &moq_mux::Source, catalog: &Catalog) { - use hang::catalog::Reference as _; - - let Some(archive) = catalog.archive.clone() else { - if !catalog.video.renditions.is_empty() || !catalog.audio.renditions.is_empty() { - tracing::warn!("catalog advertises no archive; its renditions can't be served as HLS"); - } - return; - }; - let section = archive; let clock = catalog.clock; let Ok(mut current) = self.state.write() else { return; }; - // Renditions the catalog dropped or reconfigured. Close each as it goes so any cursor + // Renditions the catalog dropped or reconfigured. Retire each as it goes so any cursor // over it drains and ends, instead of parking on a window that never finishes -- the // cursor's own `Arc` would otherwise keep it alive. let stale: Vec = current @@ -598,8 +409,7 @@ impl Producer { .collect(); for key in stale { if let Some(rendition) = current.renditions.remove(&key) { - rendition.end(); - rendition.close(); + rendition.retire(); } } @@ -612,10 +422,10 @@ impl Producer { rendition.refresh(video.bitrate, video.framerate, video.enabled); continue; } - if !section.timelines.contains_key(video.track(name)) { + let Some(hls) = video.hls.clone() else { continue; - } - let rendition = match Rendition::video(name.clone(), video, source, section.clone(), clock) { + }; + let rendition = match Rendition::video(name.clone(), video, source, hls, clock, self.fanout()) { Ok(rendition) => Arc::new(rendition), Err(err) => { tracing::warn!(rendition = name, %err, "ignoring unservable video rendition"); @@ -631,10 +441,10 @@ impl Producer { rendition.refresh(audio.bitrate, None, audio.enabled); continue; } - if !section.timelines.contains_key(audio.track(name)) { + let Some(hls) = audio.hls.clone() else { continue; - } - let rendition = match Rendition::audio(name.clone(), audio, source, section.clone(), clock) { + }; + let rendition = match Rendition::audio(name.clone(), audio, source, hls, clock, self.fanout()) { Ok(rendition) => Arc::new(rendition), Err(err) => { tracing::warn!(rendition = name, %err, "ignoring unservable audio rendition"); @@ -644,6 +454,22 @@ impl Producer { self.register(&rendition); current.renditions.insert(key, rendition); } + if current.renditions.is_empty() && (!catalog.video.renditions.is_empty() || !catalog.audio.renditions.is_empty()) { + tracing::debug!("no rendition advertises HLS records yet; nothing to serve"); + } + + // Keep the lead while it stays listed and enabled, so a rendition added later (whose + // records may be numbered from its own start) never takes over. Audio yields to video. + // Video sorts first, so a new pick is the first enabled video rendition, else audio. + let first = current.renditions.values().find(|rendition| rendition.is_enabled()); + let kept = self.fanout.lead().and_then(|lead| { + let listed = current.renditions.get(&(lead.kind, lead.name.clone()))?; + let video = first.is_some_and(|first| first.kind == Kind::Video); + (Arc::ptr_eq(listed, &lead) && lead.is_enabled() && (lead.kind == Kind::Video || !video)) + .then(|| listed.clone()) + }); + self.fanout.set_lead(kept.as_ref().or(first)); + // Rank once per catalog update, keeping playlist reads linear. Updating state also wakes // the master's long-poll when metadata changes without replacing a rendition. current.audio = catalog @@ -669,7 +495,7 @@ pub struct Consumer { } impl Consumer { - /// The next rendition change; `None` only after the catalog and reference finish cleanly. + /// The next rendition change; `None` only after the catalog finishes cleanly. pub async fn next(&mut self) -> crate::Result> { let Some(event) = kio::wait(|waiter| self.poll_next(waiter)).await? else { return Ok(None); @@ -687,32 +513,22 @@ impl Consumer { } fn poll_next(&self, waiter: &kio::Waiter) -> Poll>> { - let finished = match self + // A failed catalog cannot wait for the change stream to close. + if let Poll::Ready(Ok(err)) = self + .feed + .poll(waiter, |feed| feed.failed.clone().map_or(Poll::Pending, Poll::Ready)) + { + return Poll::Ready(Err(err)); + } + match self .state .poll(waiter, |current| next_event(¤t.renditions, &self.seen)) { - Poll::Ready(Ok(event)) => return Poll::Ready(Ok(Some(event))), - Poll::Ready(Err(state)) => { - match &state.finished { - Some(result) => result.clone()?, - None => return Poll::Ready(Err(crate::Error::IncompleteRecording)), - } - true - } - Poll::Pending => false, - }; - // A failed reference cannot wait for an otherwise live catalog to close. - match self.feed.poll(waiter, |feed| { - if let Some(err) = &feed.failed { - Poll::Ready(Err(err.clone())) - } else if feed.ended && finished { - Poll::Ready(Ok(None)) - } else { - Poll::Pending - } - }) { - Poll::Ready(Ok(result)) => Poll::Ready(result), - Poll::Ready(Err(_)) => Poll::Ready(Err(crate::Error::IncompleteRecording)), + Poll::Ready(Ok(event)) => Poll::Ready(Ok(Some(event))), + Poll::Ready(Err(state)) => Poll::Ready(match &state.finished { + Some(result) => result.clone().map(|()| None), + None => Err(crate::Error::IncompleteRecording), + }), Poll::Pending => Poll::Pending, } } @@ -747,23 +563,14 @@ mod tests { use super::*; #[tokio::test] - async fn cursors_require_both_catalog_and_reference_completion() { + async fn cursors_end_only_with_a_finished_catalog() { tokio::time::pause(); let producer = Producer::new(Duration::from_secs(30)); let mut cursor = producer.subscribe(); - producer.finish(Ok(())); - // Catalog completion alone cannot vouch for the media timeline. - assert!(cursor.poll_next(&kio::Waiter::noop()).is_pending()); - producer.fanout.finish(None); - assert!(cursor.next().await.unwrap().is_none()); - assert!(cursor.next().await.unwrap().is_none()); - - let producer = Producer::new(Duration::from_secs(30)); - let mut cursor = producer.subscribe(); - producer.fanout.finish(None); assert!(cursor.poll_next(&kio::Waiter::noop()).is_pending()); producer.finish(Ok(())); assert!(cursor.next().await.unwrap().is_none()); + assert!(cursor.next().await.unwrap().is_none()); let producer = Producer::new(Duration::from_secs(30)); let mut cursor = producer.subscribe(); @@ -777,163 +584,16 @@ mod tests { assert!(matches!(cursor.next().await, Err(crate::Error::IncompleteRecording))); } - #[test] - fn a_reference_failure_is_reported_without_catalog_completion() { - let producer = Producer::new(Duration::from_secs(30)); - let cursor = producer.subscribe(); - let reference = Arc::new((Kind::Video, "video".into(), "video".into())); - let waiter = kio::Waiter::noop(); - assert!(cursor.poll_next(&waiter).is_pending()); - producer.fanout.fail(Some(&reference), crate::Error::NoVariants); - assert!(matches!( - cursor.poll_next(&waiter), - Poll::Ready(Err(crate::Error::NoVariants)) - )); - } - #[tokio::test] - async fn a_failed_catalog_or_reference_never_completes_a_cursor() { + async fn a_failed_catalog_never_completes_a_cursor() { tokio::time::pause(); let producer = Producer::new(Duration::from_secs(30)); let mut cursor = producer.subscribe(); - producer.fanout.finish(None); producer.finish(Err(crate::Error::NoVariants)); - assert!(matches!(cursor.next().await, Err(crate::Error::NoVariants))); - - let producer = Producer::new(Duration::from_secs(30)); - let mut cursor = producer.subscribe(); - let reference = Arc::new((Kind::Video, "video".into(), "video".into())); - producer.fanout.fail(Some(&reference), crate::Error::NoVariants); - producer.finish(Ok(())); - assert!(matches!(cursor.next().await, Err(crate::Error::NoVariants))); - } - - fn entry(sequence: u64, pts: u64) -> Entry { - let record = hang::timeline::Record::new( - sequence, - pts, - 2_000, - hang::timeline::Position::group(sequence), - hang::timeline::Position::group(sequence + 1), - ); - Entry::decode(record, moq_net::Timescale::MILLI).unwrap() - } - - fn handed_off(old_last: u64) -> (Fanout, Reference) { - let fanout = Producer::new(Duration::from_secs(30)).fanout(); - let old = Arc::new((Kind::Video, "old".into(), "old".into())); - let new = Arc::new((Kind::Video, "new".into(), "new".into())); - fanout.select(Some(old.clone())); - fanout.push(old_last, entry(old_last, old_last * 2_000), &old); - fanout.select(Some(new.clone())); - (fanout, new) - } - - fn rows(fanout: &Fanout) -> Vec<(u64, u64, Duration)> { - let feed = fanout.feed.read(); - feed.history - .iter() - .map(|row| (row.segment, row.discontinuity, Duration::from(row.period))) - .collect() - } - - #[test] - fn an_aligned_replacement_continues_the_source_numbering() { - let (fanout, new) = handed_off(5); - // The replacement's checkpoint restates records the old reference already advertised. - fanout.push(4, entry(4, 8_000), &new); - fanout.push(5, entry(5, 10_000), &new); - fanout.push(6, entry(6, 12_000), &new); - let period = Duration::from_secs(12); - assert_eq!(rows(&fanout), [(5, 0, Duration::ZERO), (6, 0, period)]); - } - - #[test] - fn a_replacement_ahead_drops_the_advertised_rows() { - let (fanout, new) = handed_off(5); - fanout.push(8, entry(8, 16_000), &new); - assert_eq!(rows(&fanout), [(8, 1, Duration::from_secs(16))]); - } - - #[test] - fn a_lagging_replacement_holds_until_its_numbering_passes_the_advertised_rows() { - let (fanout, new) = handed_off(5); - for sequence in [2, 3, 5] { - fanout.push(sequence, entry(sequence, 12_000 + sequence * 2_000), &new); - assert_eq!(rows(&fanout), [(5, 0, Duration::ZERO)], "record {sequence} holds"); - } - fanout.push(6, entry(6, 24_000), &new); - assert_eq!( - rows(&fanout), - [(5, 0, Duration::ZERO), (6, 1, Duration::from_secs(24))], - "the skipped content time is a discontinuity" - ); - } - - #[test] - fn a_source_skip_preserves_the_handoff_period() { - let (fanout, new) = handed_off(5); - fanout.push(6, entry(6, 12_000), &new); - fanout.skip(Some(&new)); - fanout.push(9, entry(9, 18_000), &new); - assert_eq!(rows(&fanout), [(9, 1, Duration::from_secs(12))]); - } - - #[test] - fn an_aborted_reader_of_the_same_reference_cannot_finish_its_replacement() { - let fanout = Producer::new(Duration::from_secs(30)).fanout(); - let old = Arc::new((Kind::Video, "video".into(), "video".into())); - fanout.select(Some(old.clone())); - fanout.push(0, entry(0, 0), &old); - let replacement = Arc::new(old.as_ref().clone()); - fanout.select(Some(replacement.clone())); - assert!(!fanout.finish(Some(&old))); - fanout.skip(Some(&old)); - fanout.push(1, entry(1, 2_000), &old); - fanout.push(1, entry(1, 2_000), &replacement); - assert_eq!(fanout.feed.read().history.len(), 2); - } - - #[test] - fn only_the_selected_reader_fails_the_feed() { - let err = || crate::Error::NoVariants; - // A superseded reader's rows trim with the window, so its failure changes nothing. - let (fanout, new) = handed_off(5); - let old = fanout.feed.read().history[0].reference.clone(); - assert!(!fanout.fail(Some(&old), err())); - assert!(fanout.fail(Some(&new), err())); - assert!(!fanout.finish(Some(&new)), "a failed feed never finishes"); - } - - #[test] - fn handoff_ignores_replayed_and_superseded_events() { - let producer = Producer::new(Duration::from_secs(30)); - let fanout = producer.fanout(); - let old = Arc::new((Kind::Video, "old".into(), "old".into())); - let new = Arc::new((Kind::Video, "new".into(), "new".into())); - fanout.select(Some(old.clone())); - fanout.push(5, entry(5, 10_000), &old); - fanout.select(Some(new.clone())); - fanout.push(6, entry(6, 12_000), &old); - assert!(!fanout.finish(Some(&old))); - fanout.skip(Some(&old)); - fanout.skip(Some(&new)); - fanout.push(0, entry(0, 0), &new); - fanout.push(6, entry(6, 12_000), &new); - { - let feed = fanout.feed.read(); - assert_eq!(feed.history.len(), 2); - assert_eq!(feed.history.back().unwrap().segment, 6); - assert!(!feed.ended); - assert_eq!(feed.history.back().unwrap().discontinuity, 0); - } - fanout.pop(0..6, &new); - assert_eq!(fanout.feed.read().history.len(), 2); - fanout.pop(0..6, &old); - assert_eq!(fanout.feed.read().history.len(), 1); - fanout.pop(6..7, &new); - assert!(fanout.feed.read().history.is_empty()); - fanout.push(7, entry(7, 14_000), &new); - assert_eq!(fanout.feed.read().history.back().unwrap().segment, 7); + assert!(matches!( + cursor.next().await, + Err(crate::Error::CatalogFailed { source }) if matches!(*source, crate::Error::NoVariants) + )); + assert!(producer.failure().is_some()); } } diff --git a/rs/moq-hls/src/export/route.rs b/rs/moq-hls/src/export/route.rs index 39947a3e201..e66b9d9af3a 100644 --- a/rs/moq-hls/src/export/route.rs +++ b/rs/moq-hls/src/export/route.rs @@ -199,7 +199,7 @@ mod tests { rendition: "main".to_string(), segment: Id { generation: generation.map(str::to_string), - reference: "ab12cd34".to_string(), + tag: "ab12cd34".to_string(), at, }, }; @@ -243,7 +243,7 @@ mod tests { assert!(Route::parse("/project/live/@run/video/main/seg/ab12cd34.tx.m4s").is_none()); assert!( Route::parse("/project/live/@run/video/main/seg/42.m4s").is_none(), - "a segment URL names its reference" + "a segment URL names its record track" ); assert!(Route::parse("/project/live/@run/video/main/seg/1.ts").is_none()); } diff --git a/rs/moq-hls/src/export/segments.rs b/rs/moq-hls/src/export/segments.rs index d5a6f05f038..a70a417af9b 100644 --- a/rs/moq-hls/src/export/segments.rs +++ b/rs/moq-hls/src/export/segments.rs @@ -1,22 +1,19 @@ -//! One rendition's view of the broadcast's segments, as a `Producer`/[`Consumer`] pair. +//! One rendition's segments, as a `Producer`/[`Consumer`] pair. //! -//! Segments are derived from one reference rendition's timeline: each of its records is a -//! segment, numbered by the record's sequence. The catalog watcher reads that timeline and fans -//! each record out to every rendition as a row: the segment's number and timing, plus the -//! reference record's frames. Each rendition resolves a row against its own timeline. Two things -//! read the window: +//! Each rendition lists its own HLS records (see [`hang::hls`]): record `n` is segment `n`, holding +//! the groups from its record's to the next record's. The rendition's record watcher appends a row +//! once the next record bounds it. Two things read the window: //! //! * the HTTP serve path, synchronously, to render a media playlist and resolve a segment's -//! frames (nothing here touches media bytes on that path); and +//! groups (nothing here touches media bytes on that path); and //! * a [`Consumer`] cursor, for a recorder that wants every segment *with its media*, in -//! order, exactly once. `next()` waits for the next resolved row, FETCHes and transmuxes its -//! frames (via [`Rendition`]), and yields the CMAF bytes. +//! order, exactly once. `next()` waits for the next row, FETCHes and transmuxes its groups +//! (via [`Rendition`]), and yields the CMAF bytes. //! -//! A segment is addressed by its reference's tag and its number (the `seg/{tag}.{segment}.m4s` -//! URI); the number alone is its `EXT-X-MEDIA-SEQUENCE` and the recorder cursor's position. The -//! same URI names the same span of content time on every rendition, on every edge, and after -//! every reload. A new reference lists its records under its own tag, continuing past the last -//! advertised number rather than reusing the old one's URIs. +//! A segment is addressed by its record track's tag and its number (the `seg/{tag}.{segment}.m4s` +//! URI); the number alone is its `EXT-X-MEDIA-SEQUENCE` and the recorder cursor's position. A +//! publisher that aligns its renditions numbers the same span of content time alike on every +//! rendition, so the number means the same on every rendition, edge, and reload. use moq_clock::Wall; use std::collections::VecDeque; @@ -27,55 +24,51 @@ use std::time::Duration; use bytes::Bytes; use sha2::{Digest, Sha256}; -use super::{Kind, Rendition}; +use super::Rendition; use crate::Result; -/// A segment as its URL names it: `seg/[{generation}.]{reference}.{sequence}.m4s`, or -/// `t{pts}` in place of the sequence for the DASH `$Time$` form. Parsed by +/// A segment as its URL names it: `seg/[{generation}.]{tag}.{sequence}.m4s`, or `t{pts}` in +/// place of the sequence for the DASH `$Time$` form. Parsed by /// [`Route::parse`](super::Route::parse) and answered by [`Rendition::segment`]. #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub struct Id { /// The publisher run the URL names, if the broadcaster carries one. pub generation: Option, - /// The tag of the reference rendition whose records number the segment. - pub reference: String, - /// Where the segment sits on that reference. + /// The [tag](tag) of the record track numbering the segment. + pub tag: String, + /// Where the segment sits in that numbering. pub at: At, } -/// Where a segment sits on its reference rendition: two names for the same bytes. +/// Where a segment sits in its record track's numbering: two names for the same bytes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum At { - /// The segment's aligned number, as HLS playlists address it. + /// The segment's number, as HLS playlists address it. Sequence(u64), - /// The segment's timeline pts, as the DASH manifest addresses it. + /// The segment's start in milliseconds, as the DASH manifest addresses it. Pts(u64), } impl Id { /// Parse a segment file name without its `.m4s` extension. pub(crate) fn parse(stem: &str) -> Option { - let (generation, reference, at) = match stem.split('.').collect::>().as_slice() { - [reference, at] => (None, reference.to_string(), *at), - [generation, reference, at] => (Some(generation.to_string()), reference.to_string(), *at), + let (generation, tag, at) = match stem.split('.').collect::>().as_slice() { + [tag, at] => (None, tag.to_string(), *at), + [generation, tag, at] => (Some(generation.to_string()), tag.to_string(), *at), _ => return None, }; let at = match at.strip_prefix('t') { Some(pts) => At::Pts(pts.parse().ok()?), None => At::Sequence(at.parse().ok()?), }; - Some(Self { - generation, - reference, - at, - }) + Some(Self { generation, tag, at }) } } -/// The producing side of a rendition's timeline window. +/// The producing side of a rendition's segment window. /// -/// The catalog watcher appends rows via [`push`](Self::push) and marks the stream +/// The record watcher appends rows via [`push`](Self::push) and marks the stream /// [`end`](Self::end)ed. Cheap to share behind an `Arc`; the window state lives in a /// [`kio::Producer`] so a [`Consumer`] can await changes without a separate signal. pub(crate) struct Producer { @@ -89,139 +82,105 @@ struct State { sequence: u64, /// The newest discontinuity seen, retained when the playlist window becomes empty. discontinuity: u64, - /// The timeline track ended: the broadcast is over (`EXT-X-ENDLIST`). + /// The record track ended: the rendition is over (`EXT-X-ENDLIST`). ended: bool, - /// The reference timeline failed, so every listing refuses with this error. + /// The record track or the catalog failed, so every listing refuses with this error. failed: Option, - /// Bumped whenever the window is cleared, so a latch on what it listed can tell it is stale. + /// Bumped whenever the window is cleared, so a cursor can tell its position is stale. resets: u64, /// Total rows appended, so a cursor can detect eviction even before its first read. produced: u64, } -/// Numbers the broadcast's content timeline: the sequence bumps wherever the timeline breaks. +/// Numbers the broadcast's content breaks: segments after a stop, or after records nobody saw, +/// start a new discontinuity. /// -/// Held once by the timeline fanout rather than per rendition, so every rendition stamps a -/// record with the same sequence even when one isn't listing rows (its sibling ended) or -/// cleared its own window. +/// Held once per broadcaster and keyed by segment number, so every rendition marks segment `n` +/// with the same sequence however many segments each one listed: the first rendition to stamp a +/// segment decides it. #[derive(Default)] pub(crate) struct Discontinuities { + /// The sequence stamped on each recent segment. + stamped: std::collections::BTreeMap, + /// The highest sequence stamped so far. sequence: u64, - /// The end of the last stamped record. - last_end: Option, - /// Records were skipped, so the next one can't continue the timeline. - broken: bool, } impl Discontinuities { - /// The sequence of a record spanning `pts..end`, bumped if it doesn't continue the last one. - pub fn stamp(&mut self, pts: Duration, end: Duration) -> u64 { - // Tolerate sub-millisecond drift from timescale rounding. - let jumped = self.last_end.is_some_and(|last| !continues(last, pts)); - if self.broken || jumped { - self.sequence += 1; + /// The sequence of segment `segment`, which starts a new one when `broken`. + pub fn stamp(&mut self, segment: u64, broken: bool) -> u64 { + if let Some(sequence) = self.stamped.get(&segment) { + return *sequence; + } + let previous = self + .stamped + .range(..segment) + .next_back() + .map_or(self.sequence, |(_, sequence)| *sequence); + let sequence = match broken { + true => previous.max(self.sequence) + 1, + false => previous, + }; + self.sequence = self.sequence.max(sequence); + self.stamped.insert(segment, sequence); + // Every rendition lists at most a window's worth, so older stamps are never asked for. + while self.stamped.len() > 4 * MAX_SEGMENTS { + self.stamped.pop_first(); } - self.broken = false; - self.last_end = Some(end); - self.sequence + sequence } - /// Records were skipped (or a new run started): the next one starts a new discontinuity. - pub fn interrupt(&mut self) { - self.broken = true; + /// Forget every stamp, for a publisher run that restarts its numbering. + pub fn restart(&mut self) { + self.stamped.clear(); + self.sequence += 1; } } -/// Whether two content boundaries coincide within the timeline's rounding tolerance. -pub(crate) fn continues(end: Duration, pts: Duration) -> bool { - pts.saturating_sub(end).max(end.saturating_sub(pts)) <= Duration::from_millis(1) -} - /// The most segments a playlist window lists, however short they are. /// -/// A fresh subscriber to a `moq-mux` timeline is only promised this many recent records (the -/// checkpoint each timeline group restates), so an edge that joined long ago must not list more -/// source records than one joining now. The dense-timeline -/// test fails if the two drift apart. +/// A fresh subscriber to a `moq-mux` record track is only promised this many recent records (the +/// checkpoint each record group restates), so an edge that joined long ago must not list more +/// records than one joining now. The dense-records test fails if the two drift apart. pub(crate) const MAX_SEGMENTS: usize = 256; /// Whether a window of `len` rows, whose rows after the oldest span `rest`, should drop its -/// oldest: it lists more than [`MAX_SEGMENTS`], or the rest still covers `window` and still -/// holds a start (`keeps_start`, see [`starts`]). A GOP longer than the window keeps its sync -/// row, so the window stretches to that one GOP, and stays startable. -pub(crate) fn evicts(window: Duration, len: usize, rest: Duration, keeps_start: bool) -> bool { - len > MAX_SEGMENTS || (len >= 2 && rest >= window && keeps_start) +/// oldest: it lists more than [`MAX_SEGMENTS`], or the rest still covers `window`. +pub(crate) fn evicts(window: Duration, len: usize, rest: Duration) -> bool { + len > MAX_SEGMENTS || (len >= 2 && rest >= window) } -/// Whether a player can start at a `reference` record: audio starts anywhere, video only at a -/// group start that is a sync point. -pub(crate) fn starts(reference: &ReferenceKey, keyframe: bool, start: &hang::timeline::Position) -> bool { - reference.0 == Kind::Audio || (keyframe && start.frame == 0) +/// The URI tag naming a record track's segment numbering: a hash of the track name, so every edge +/// derives the same one, it never contains a URI separator, and another record track (a +/// reconfigured rendition) never reuses a URL. +pub(crate) fn tag(track: &str) -> String { + // 32 bits: the tag only has to tell apart the record tracks one rendition name ever carries. + Sha256::digest(track)[..4].iter().map(|b| format!("{b:02x}")).collect() } -/// A reference's kind, rendition ID, and the track its timeline indexes. The track is part of it -/// because it can change under one ID, and the new track's records are another numbering. -pub(crate) type ReferenceKey = (Kind, String, String); - -/// The rendition a broadcast's segment boundaries come from. -pub(crate) type Reference = Arc; - -/// The URI tag naming `reference`'s segment numbering: a hash of its kind, ID and track, so every edge -/// derives the same one and it never contains a URI separator. -pub(crate) fn tag(reference: &ReferenceKey) -> String { - let (kind, id, track) = reference; - // A track named by its ID hashes as before, so edges on either side of an upgrade agree. - // An override uses a separator the ID form cannot produce after the kind, and length-prefixes - // the ID, so no pair of names can hash the bytes of another reference. - let digest = match id == track { - true => Sha256::digest(format!("{}/{id}", kind.as_str())), - false => Sha256::digest(format!("{}\0{}\0{id}{track}", kind.as_str(), id.len())), - }; - // 32 bits: the tag only has to tell apart the references one broadcast ever switches between. - digest[..4].iter().map(|b| format!("{b:02x}")).collect() -} - -/// One playlist segment: its number, timing, and the reference record's frames. +/// One playlist segment: its number, timing, and the groups it holds. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Row { - /// The reference timeline's record index, used to mirror exact window trims. + /// The record's window index, used to mirror exact window trims. pub index: u64, - /// The reference record's sequence: its `EXT-X-MEDIA-SEQUENCE` and, with the reference's tag, - /// its URI (`seg/{tag}.{segment}.m4s`), shared across renditions. + /// The record's sequence: its `EXT-X-MEDIA-SEQUENCE` and, with the tag, its URI + /// (`seg/{tag}.{segment}.m4s`). pub segment: u64, - /// The DASH period start, the reference timestamp where its handoff landed; retained - /// even after the first row of the period is evicted. - pub period: moq_net::Timestamp, - /// The rendition the boundaries come from. - pub reference: Reference, - /// The reference record's frames, which the reference rendition serves as is. - pub frames: std::ops::Range, - /// Whether the reference record's first frame is a sync point, where a player can start - /// decoding (the record's `keyframe` flag). - pub keyframe: bool, + /// The [tag](tag) of the record track numbering the segment. + pub tag: Arc, + /// The groups the segment holds: from its record's group up to the next record's. + pub groups: std::ops::Range, /// Presentation duration. pub duration: Duration, /// The segment's starting presentation timestamp. pub pts: moq_net::Timestamp, - /// The segment's ending presentation timestamp (`pts + duration`), for window eviction - /// and discontinuity detection. + /// The segment's ending presentation timestamp (`pts + duration`), for window eviction. pub end: Duration, - /// The broadcast's [`Discontinuities`] sequence for this record, shared by every rendition, - /// so renditions mark the same breaks however many segments each one skipped. + /// The broadcast's [`Discontinuities`] sequence for this segment. pub discontinuity: u64, -} - -impl Row { - /// Whether the segment starts at a group start that is a sync point, so a player can begin - /// decoding with it. - pub fn starts_sync(&self) -> bool { - self.keyframe && self.frames.start.frame == 0 - } - - /// Whether a player can start at this segment (see [`starts`]). - pub fn starts(&self) -> bool { - starts(&self.reference, self.keyframe, &self.frames.start) - } + /// Content stopped here: a zero-duration record holding no media, listed as a gap so the + /// numbering stays consecutive. + pub stop: bool, } /// A consistent read of the window, for rendering one playlist (the serve path only). @@ -230,16 +189,15 @@ impl Row { allow(dead_code, reason = "only the `server` feature uses it") )] pub(crate) struct Window { - /// The `EXT-X-MEDIA-SEQUENCE` of the first listed segment: its source segment number, so - /// sequence numbers line up across renditions and edges. + /// The `EXT-X-MEDIA-SEQUENCE` of the first listed segment. pub sequence: u64, /// The first listed discontinuity, or the newest seen when the window is empty. pub discontinuity: u64, /// Listed segments, oldest first. pub segments: Vec, - /// Whether the timeline (and so the playlist) has ended. + /// Whether the record track (and so the playlist) has ended. pub ended: bool, - /// Why the reference timeline failed, if it did. + /// Why the window failed, if it did. pub failed: Option, } @@ -247,7 +205,7 @@ pub(crate) struct Window { enum Next { /// A segment is ready to fetch. Ready(Row), - /// No further segment will ever appear (the timeline ended or failed). + /// No further segment will ever appear (the record track ended or failed). Done, /// Nothing new yet; wait for the next window change. Pending, @@ -299,25 +257,21 @@ impl Producer { } } - /// Append a row, evicting the front of the window past `window` (see [`evicts`]). Returns the window's - /// [`resets`](Self::resets) after the push. - pub fn push(&self, row: Row, window: Duration) -> u64 { + /// Append a row, evicting the front of the window past `window` (see [`evicts`]). + pub fn push(&self, row: Row, window: Duration) { let Ok(mut state) = self.state.write() else { - return u64::MAX; + return; }; if let Some(back) = state.rows.back() { - // A backward jump in pts or segment number means the publisher restarted its timeline; + // A backward jump in pts or segment number means the publisher restarted its records; // the old window can't be stitched onto the new one, so start over. if Duration::from(row.pts) < Duration::from(back.pts) || row.segment <= back.segment { - tracing::warn!("timeline jumped backwards; resetting the playlist window"); + tracing::warn!("HLS records jumped backwards; resetting the playlist window"); state.rows.clear(); state.resets += 1; } } - if state.rows.is_empty() { - state.sequence = row.segment; - } state.discontinuity = row.discontinuity; state.rows.push_back(row); @@ -328,14 +282,12 @@ impl Producer { Some(second) => state.rows.back().unwrap().end.saturating_sub(second.pts.into()), None => Duration::ZERO, }; - let keeps_start = !state.rows[0].starts() || state.rows.iter().skip(1).any(Row::starts); - if !evicts(window, state.rows.len(), rest, keeps_start) { + if !evicts(window, state.rows.len(), rest) { break; } state.rows.pop_front(); } state.sequence = state.rows.front().unwrap().segment; - state.resets } /// Retire the prefix through segment `through`, so the playlist stays consecutive. @@ -349,7 +301,7 @@ impl Producer { } } - /// Clear every retained row after an unrecoverable gap in the source timeline. + /// Clear every retained row after an unrecoverable gap in the records. pub fn clear(&self) { if let Ok(mut state) = self.state.write() { state.rows.clear(); @@ -357,34 +309,22 @@ impl Producer { } } - /// How many times the window was cleared: by [`clear`](Self::clear), or a timeline that - /// jumped backwards. - pub fn resets(&self) -> u64 { - self.state.read().resets - } - - /// Mark the timeline finished: the playlist gets `EXT-X-ENDLIST` and cursors end once - /// drained. + /// Mark the records finished: the playlist gets `EXT-X-ENDLIST` and cursors end once drained. pub fn end(&self) { if let Ok(mut state) = self.state.write() { state.ended = true; } } - /// Mark the reference timeline failed with `err`: every listing refuses with it instead of - /// ending, and cursors return it once drained. No row is pushed after. + /// Mark the window failed with `err`: every listing refuses with it instead of ending, and + /// cursors return it once drained. No row is pushed after. pub fn fail(&self, err: crate::Error) { if let Ok(mut state) = self.state.write() { state.failed.get_or_insert(err); } } - /// Whether the timeline finished. - pub fn is_ended(&self) -> bool { - self.state.read().ended - } - - /// Why the reference timeline failed, if it did. + /// Why the window failed, if it did. pub fn failure(&self) -> Option { self.state.read().failed.clone() } @@ -396,11 +336,6 @@ impl Producer { let _ = self.state.close(); } - /// The start of the oldest listed segment, without copying the window. - pub fn oldest(&self) -> Option { - self.state.read().rows.front().map(|row| row.pts.into()) - } - /// Snapshot the current window (serve path). #[cfg_attr( not(feature = "server"), @@ -410,44 +345,62 @@ impl Producer { self.state.read().window() } - /// The row of segment `segment` numbered by the reference tagged `tag`, or `None` if it isn't - /// in the window. + /// The row of segment `segment` numbered by the record track tagged `tag`, or `None` if it + /// isn't in the window. pub fn row(&self, tag: &str, segment: u64) -> Option { let state = self.state.read(); state .rows .iter() - .find(|r| r.segment == segment && self::tag(&r.reference) == tag) + .find(|row| row.segment == segment && &*row.tag == tag) .cloned() } - /// The number of the segment whose `pts` is exactly `time` in the timeline's timescale - /// (DASH `$Time$` addressing), or `None` when no listed row starts there. Exact match is - /// safe because the rendered `S@t` and this lookup convert the same [`Row::pts`] the same - /// way. - pub fn segment_number_at(&self, tag: &str, time: u64, timescale: moq_net::Timescale) -> Option { + /// The row of segment `segment`, whatever its tag, with whether the window holds every + /// segment from `segment` on: the alignment check needs both. + pub fn numbered(&self, segment: u64) -> Lookup { + let state = self.state.read(); + let (Some(front), Some(back)) = (state.rows.front(), state.rows.back()) else { + return match state.ended || state.failed.is_some() { + true => Lookup::Never, + false => Lookup::Pending, + }; + }; + if segment < front.segment { + return Lookup::Never; + } + if segment > back.segment { + return match state.ended || state.failed.is_some() { + true => Lookup::Never, + false => Lookup::Pending, + }; + } + match state.rows.iter().find(|row| row.segment == segment) { + Some(row) => Lookup::Found(row.clone()), + None => Lookup::Missing(Duration::from(front.pts)..back.end), + } + } + + /// The number of the segment whose `pts` is exactly `time` milliseconds (DASH `$Time$` + /// addressing), or `None` when no listed row starts there. Exact match is safe because the + /// rendered `S@t` and this lookup convert the same [`Row::pts`] the same way. + pub fn segment_number_at(&self, tag: &str, time: u64) -> Option { let state = self.state.read(); state .rows .iter() - .find(|row| row.pts.as_scale(timescale) == time as u128 && self::tag(&row.reference) == tag) + .find(|row| row.pts.as_millis() == time as u128 && &*row.tag == tag) .map(|row| row.segment) } - /// The newest group of `reference` a row starts on a keyframe, used to bootstrap an init - /// segment for inline-parameter-set codecs. - pub fn latest_keyframe_group(&self, reference: &ReferenceKey) -> Option { + /// The first group of the newest segment holding media, used to bootstrap an init segment + /// for inline-parameter-set codecs: a segment starts at a group, and a group at a keyframe. + pub fn latest_group(&self) -> Option { let state = self.state.read(); - state - .rows - .iter() - .rev() - .filter(|row| *row.reference == *reference) - .find(|row| row.starts_sync()) - .map(|row| row.frames.start.group) + state.rows.iter().rev().find(|row| !row.stop).map(|row| row.groups.start) } - /// Whether the playlist has anything to serve yet (at least one segment, or the broadcast + /// Whether the playlist has anything to serve yet (at least one segment, or the records /// already ended). #[cfg_attr( not(feature = "server"), @@ -458,12 +411,8 @@ impl Producer { state.ended || !state.rows.is_empty() } - /// Poll until [`is_playable`](Self::is_playable) or the reference timeline failed, for the - /// serve path's long-poll. - #[cfg_attr( - not(feature = "server"), - allow(dead_code, reason = "only the `server` feature uses it") - )] + /// Poll until [`is_playable`](Self::is_playable) or the window failed, for the serve path's + /// long-poll. pub fn poll_playable(&self, waiter: &kio::Waiter) -> Poll<()> { let poll = self.state.poll_ref(waiter, |state| { if state.ended || state.failed.is_some() || !state.rows.is_empty() { @@ -479,19 +428,26 @@ impl Producer { } } - /// Poll `f` over the listed rows, waking on the next window change while it is pending, and - /// return the [`resets`](Self::resets) those rows belong to. A closed window stays pending: no - /// new row can make `f` ready. - pub fn poll_rows(&self, waiter: &kio::Waiter, mut f: impl FnMut(&VecDeque) -> Poll<()>) -> Poll { - match self - .state - .poll_ref(waiter, |state| f(&state.rows).map(|()| state.resets)) - { - Poll::Ready(Ok(resets)) => Poll::Ready(resets), - _ => Poll::Pending, + /// Poll until the window changes from `seen` rows produced, ends, or fails, for a check that + /// waits on another rendition's window. + pub fn poll_changed(&self, waiter: &kio::Waiter, seen: u64) -> Poll<()> { + match self.state.poll_ref(waiter, |state| { + if state.produced != seen || state.ended || state.failed.is_some() { + Poll::Ready(()) + } else { + Poll::Pending + } + }) { + Poll::Ready(_) => Poll::Ready(()), + Poll::Pending => Poll::Pending, } } + /// How many rows were ever appended. + pub fn produced(&self) -> u64 { + self.state.read().produced + } + /// A cursor over segments, starting from the oldest still in the window. pub fn subscribe(&self, rendition: Arc) -> Consumer { let consumer = self.state.consume(); @@ -505,17 +461,30 @@ impl Producer { } } +/// What one window says about a segment number. +#[derive(Debug)] +pub(crate) enum Lookup { + /// The window lists it. + Found(Row), + /// The window holds the numbers around it but not it, spanning this content time. + Missing(std::ops::Range), + /// It may still arrive. + Pending, + /// The window will never list it: evicted already, or the records ended before it. + Never, +} + /// A segment with its transmuxed media, yielded by a [`Consumer`]. pub struct Segment { - /// The source segment number (its URI is `seg/{reference}.{segment}.m4s`), shared across renditions. + /// The segment number (its URI is `seg/{tag}.{segment}.m4s`). pub segment: u64, /// The transmuxed CMAF fragment (`moof`+`mdat`), fetched on demand by [`Consumer::next`]. pub media: Bytes, /// Presentation duration. pub duration: Duration, - /// Wall-clock start time, when the timeline advertises an anchor. + /// Wall-clock start time, when the catalog advertises its broadcast clock. pub program_date_time: Option, - /// The absolute timeline discontinuity sequence within this broadcaster. + /// The absolute discontinuity sequence within this broadcaster. /// /// Use the first retained segment's value as `EXT-X-DISCONTINUITY-SEQUENCE`, then write /// `current - previous` `EXT-X-DISCONTINUITY` tags before each later segment; a skipped @@ -525,7 +494,7 @@ pub struct Segment { pub discontinuity: u64, } -/// A cursor over one rendition's segments, in timeline order. +/// A cursor over one rendition's segments, in order. /// /// Obtained from [`Rendition::segments`](super::Rendition::segments). Each /// [`next`](Self::next) awaits the next segment, then FETCHes and transmuxes it through the @@ -567,37 +536,29 @@ impl Consumer { /// The next segment, with its media; `None` only once the rendition ends cleanly, so a /// recording that reads to `None` is complete. /// - /// Once a timeline fails (the reference's, or this rendition's own), the cursor drains the - /// segments it still covers, then fails every call with - /// [`TimelineFailed`](crate::Error::TimelineFailed), as [`Rendition::playlist`] does; a - /// failed catalog likewise with [`CatalogFailed`](crate::Error::CatalogFailed). Either is - /// final, whichever lands first. A denied referenced broadcast fails every call with its - /// authorization error. + /// Once the rendition's records fail, the cursor drains the segments it still covers, then + /// fails every call with [`RecordsFailed`](crate::Error::RecordsFailed), as + /// [`Rendition::playlist`] does; a failed catalog likewise with + /// [`CatalogFailed`](crate::Error::CatalogFailed). A denied referenced broadcast fails every + /// call with its authorization error. /// - /// Waits for the next segment to resolve on this rendition, then FETCHes and transmuxes its - /// frames. A gap for this rendition is skipped. Missing window rows or unavailable media - /// fail the recording instead of silently omitting segments. Fetch/transmux errors leave - /// the cursor in place so the caller can retry. - /// A bound instance's retained media drains after End or Restart; a replacement's media - /// is never read under the old rows. + /// Waits for the next segment's alignment to settle, then FETCHes and transmuxes its groups. + /// A stop or a misaligned segment is skipped. Missing window rows or unavailable media fail + /// the recording instead of silently omitting segments. Fetch/transmux errors leave the cursor + /// in place so the caller can retry. A bound instance's retained media drains after End or + /// Restart; a replacement's media is never read under the old rows. pub async fn next(&mut self) -> Result> { loop { let Some(row) = kio::wait(|waiter| self.poll_next(waiter)).await? else { return Ok(None); }; kio::wait(|waiter| { - // The refusal settles the binding, not this rendition's timeline, so watch both. + // The refusal settles the binding, not this rendition's records, so watch both. self.rendition.poll_media(waiter); self.rendition.authorize()?; self.rendition.poll_resolved(waiter, &row).map(Ok::<_, crate::Error>) }) .await?; - // This rendition's timeline failed before covering the row, so no later row resolves. - if let Some(err) = self.rendition.own_failure() - && self.rendition.is_failed(&row) - { - return Err(err); - } if self.rendition.is_gap(&row) { self.position.1 += 1; continue; @@ -685,14 +646,13 @@ mod tests { Row { index: segment, segment, - period: moq_net::Timestamp::ZERO, - reference: Arc::new((Kind::Video, "video".to_string(), "video".to_string())), - frames: hang::timeline::Position::group(group)..hang::timeline::Position::group(group + 1), - keyframe: true, + tag: Arc::from(tag("video.hls")), + groups: group..group + 1, duration: Duration::from_millis(duration_ms), pts, end: Duration::from(pts) + Duration::from_millis(duration_ms), discontinuity: 0, + stop: false, } } @@ -723,7 +683,7 @@ mod tests { let snapshot = live.window(); // Segments still cover >= 4s after eviction, and the sequence is the first listed - // segment's aligned number. + // segment's number. assert!(snapshot.sequence > 0); let span: Duration = snapshot.segments.iter().map(|s| s.duration).sum(); assert!(span >= Duration::from_secs(4)); @@ -767,87 +727,79 @@ mod tests { ); } - fn secs(s: u64) -> Duration { - Duration::from_secs(s) - } - #[test] - fn a_continuous_timeline_keeps_one_discontinuity() { - // A cursor that skips a declared gap for its rendition must not mark the - // next one: a sibling that fetched it wouldn't, and players require renditions to agree. - let mut timeline = Discontinuities::default(); - assert_eq!(timeline.stamp(secs(0), secs(2)), 0); - assert_eq!(timeline.stamp(secs(2), secs(4)), 0); - assert_eq!(timeline.stamp(secs(4), secs(6)), 0); + fn renditions_agree_on_discontinuities_whatever_they_listed() { + let mut breaks = Discontinuities::default(); + assert_eq!(breaks.stamp(0, false), 0); + assert_eq!(breaks.stamp(1, false), 0); + assert_eq!(breaks.stamp(2, true), 1, "the segment after a stop starts a new one"); + assert_eq!(breaks.stamp(3, false), 1); + // A rendition that joined later, or saw records the first one skipped, takes the stamps + // already made rather than its own view. + assert_eq!(breaks.stamp(2, false), 1); + assert_eq!(breaks.stamp(3, true), 1); + assert_eq!(breaks.stamp(4, false), 1); } #[test] - fn a_content_time_jump_starts_a_new_discontinuity() { - let mut timeline = Discontinuities::default(); - assert_eq!(timeline.stamp(secs(0), secs(2)), 0); - assert_eq!(timeline.stamp(secs(10), secs(12)), 1); - assert_eq!(timeline.stamp(secs(12), secs(14)), 1); + fn a_restart_starts_a_new_discontinuity() { + let mut breaks = Discontinuities::default(); + assert_eq!(breaks.stamp(5, false), 0); + breaks.restart(); + assert_eq!(breaks.stamp(0, false), 1, "the new run's numbering is unrelated to the old"); } #[test] - fn skipped_records_start_a_new_discontinuity() { - let mut timeline = Discontinuities::default(); - assert_eq!(timeline.stamp(secs(0), secs(2)), 0); - timeline.interrupt(); - assert_eq!( - timeline.stamp(secs(2), secs(4)), - 1, - "skipped records break the timeline even when the next one lines up" - ); - } - - #[test] - fn rows_and_the_keyframe_group() { + fn rows_and_the_latest_group() { let live = Producer::new(); let window = Duration::from_secs(30); live.push(row(0, 0, 0, 1_000), window); live.push( Row { - keyframe: false, - ..row(1, 1, 1_000, 1_000) + stop: true, + ..row(1, 1, 1_000, 0) }, window, ); - let video = (Kind::Video, "video".to_string(), "video".to_string()); - let tag = tag(&video); - assert_eq!( - live.row(&tag, 0).unwrap().frames, - hang::timeline::Position::group(0)..hang::timeline::Position::group(1) - ); + let tag = tag("video.hls"); + assert_eq!(live.row(&tag, 0).unwrap().groups, 0..1); assert_eq!(live.row(&tag, 7), None, "unknown segments miss"); - assert_eq!(live.row("00000000", 0), None, "another reference's numbering misses"); - assert_eq!( - live.latest_keyframe_group(&video), - Some(0), - "row 1 does not start on a keyframe" - ); - assert_eq!( - live.latest_keyframe_group(&(Kind::Audio, "video".to_string(), "video".to_string())), - None - ); + assert_eq!(live.row("00000000", 0), None, "another record track's numbering misses"); + assert_eq!(live.latest_group(), Some(0), "a stop holds no media"); } #[test] fn backwards_jump_resets_the_window() { let live = Producer::new(); let window = Duration::from_secs(30); - assert_eq!(live.push(row(0, 0, 10_000, 2_000), window), 0); - assert_eq!(live.push(row(1, 1, 12_000, 2_000), window), 0); - assert_eq!(live.push(row(2, 2, 1_000, 2_000), window), 1); // restart: pts rewound + live.push(row(0, 0, 10_000, 2_000), window); + live.push(row(1, 1, 12_000, 2_000), window); + live.push(row(2, 2, 1_000, 2_000), window); // restart: pts rewound let snapshot = live.window(); assert_eq!(snapshot.segments.len(), 1, "the window restarted at the new row"); assert_eq!(snapshot.segments[0].segment, 2); // A segment number that rewinds (a restarted publisher) resets the same way. - assert_eq!(live.push(row(0, 0, 2_000, 2_000), window), 2); + live.push(row(0, 0, 2_000, 2_000), window); assert_eq!(live.window().segments.len(), 1); + assert_eq!(live.state.read().resets, 2); + } + + #[test] + fn lookups_tell_pending_from_never() { + let live = Producer::new(); + let window = Duration::from_secs(30); + assert!(matches!(live.numbered(0), Lookup::Pending)); + live.push(row(3, 3, 6_000, 2_000), window); + live.push(row(5, 5, 10_000, 2_000), window); + assert!(matches!(live.numbered(2), Lookup::Never), "evicted or never listed"); + assert!(matches!(live.numbered(3), Lookup::Found(row) if row.segment == 3)); + assert!(matches!(live.numbered(4), Lookup::Missing(span) if span == (Duration::from_secs(6)..Duration::from_secs(12)))); + assert!(matches!(live.numbered(6), Lookup::Pending)); + live.end(); + assert!(matches!(live.numbered(6), Lookup::Never)); } #[test] @@ -905,44 +857,9 @@ mod tests { } #[test] - fn a_handoff_continues_the_source_numbering_under_another_tag() { - let live = Producer::new(); - let window = Duration::from_secs(30); - let old = row(5, 5, 10_000, 2_000); - let old_reference = old.reference.clone(); - live.push(old, window); - let switched = Row { - reference: Arc::new((Kind::Video, "other".into(), "other".into())), - period: moq_net::Timestamp::from_secs(12).unwrap(), - ..row(6, 6, 12_000, 2_000) - }; - live.push(switched, window); - assert_eq!(live.window().sequence, 5); - let Next::Ready(next) = live.state.read().next_at((0, 1)).unwrap() else { - panic!("the replacement's row follows"); - }; - assert_eq!(next.segment, 6); - assert_ne!(tag(&next.reference), tag(&old_reference)); - assert!(matches!(live.state.read().next_at((0, 2)).unwrap(), Next::Pending)); - live.retire(5); - assert_eq!(live.window().sequence, 6); - assert!(live.row(&tag(&old_reference), 5).is_none()); - live.retire(6); - assert!(live.window().segments.is_empty()); - assert_eq!(live.window().sequence, 7); - } - - #[test] - fn tag_encodings_never_collide() { - let key = |id: &str, track: &str| (Kind::Video, id.to_string(), track.to_string()); - // The ID form and an override cannot share bytes, whatever the names contain. - assert_ne!(tag(&key("a\0b", "a\0b")), tag(&key("a", "b"))); - // Nor can two overrides that split the same bytes differently. - assert_ne!(tag(&key("ab", "c")), tag(&key("a", "bc"))); - // A track named by its ID keeps the tag it had before overrides existed. - assert_eq!(tag(&key("video", "video")), { - let digest = Sha256::digest("video/video"); - digest[..4].iter().map(|b| format!("{b:02x}")).collect::() - }); + fn tags_tell_record_tracks_apart() { + assert_ne!(tag("video.hls"), tag("video2.hls")); + assert_eq!(tag("video.hls").len(), 8); + assert!(tag("a.b/c").chars().all(|c| c.is_ascii_hexdigit())); } } diff --git a/rs/moq-hls/src/export/spans.rs b/rs/moq-hls/src/export/spans.rs deleted file mode 100644 index df50f53a991..00000000000 --- a/rs/moq-hls/src/export/spans.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! One rendition's own timeline, which resolves the broadcast's segments to that rendition's frames. -//! -//! Segment boundaries come from the reference rendition's records (see [`super::segments`]). -//! Every other rendition maps each boundary onto its own timeline: -//! -//! * a video rendition snaps each boundary to its nearest record starting on a keyframe within -//! [`TOLERANCE`]; a segment with no start in range is a gap (HLS `EXT-X-GAP`), so a player -//! switching renditions lands on the next real segment; -//! * an audio rendition takes every frame whose timestamp falls in the segment's span, possibly -//! from more than one record. -//! -//! A segment resolves only once the rendition's timeline has reached past its end (or ended), so -//! every edge and every reload agree on its content. A failed timeline resolves only what its -//! records cover, never listing the rest as gaps. - -use std::collections::VecDeque; -use std::ops::Range; -use std::task::Poll; -use std::time::Duration; - -use hang::timeline::Position; -use moq_mux::timeline::Entry; - -/// How far a video boundary may move to land on a keyframe. -pub(crate) const TOLERANCE: Duration = Duration::from_secs(1); - -/// What one segment holds on one rendition. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum Content { - /// The rendition's timeline has not reached the segment's end yet. - Pending, - /// The rendition has no content for the segment. - Gap, - /// The rendition's timeline failed before reaching the segment's end, so neither it nor any - /// later segment will resolve. - Failed, - /// The frames to fetch, as position ranges, keeping only those whose timestamps fall in - /// `filter` when set. - Frames { - ranges: Vec>, - filter: Option>, - }, -} - -struct State { - /// Retained records, oldest first. - records: VecDeque, - /// No more records will arrive, so nothing is pending. - ended: bool, - /// The timeline failed: no more records will arrive, and a segment past the last one fails - /// rather than resolving as a gap. - failed: Option, -} - -/// Why a rendition's own timeline failed, and when. -#[derive(Clone)] -pub(crate) struct Failure { - pub err: crate::Error, - /// The reference had already finished, so only the rows these records do not cover are lost. - pub late: bool, -} - -impl State { - fn resolve(&self, is_video: bool, span: Range) -> Content { - if self.failed.is_some() && self.records.back().is_none_or(|last| last.end_time() < span.end) { - return Content::Failed; - } - let records = self.records.iter().collect(); - let ended = self.ended || self.failed.is_some(); - match is_video { - true => video(records, ended, span), - false => audio(records, ended, span), - } - } -} - -/// A rendition's own record window, fed by its timeline watcher. -pub(crate) struct Spans { - state: kio::Producer, -} - -impl Spans { - pub fn new() -> Self { - Self { - state: kio::Producer::new(State { - records: VecDeque::new(), - ended: false, - failed: None, - }), - } - } - - /// Append a record. Only [`trim`](Self::trim) and source pops remove records. - pub fn push(&self, entry: Entry) { - let Ok(mut state) = self.state.write() else { - return; - }; - if state - .records - .back() - .is_some_and(|back| entry.sequence <= back.sequence || entry.pts < back.pts) - { - // The publisher restarted its timeline; the old records describe other media. - state.records.clear(); - } - state.records.push_back(entry); - } - - /// Evict records that end before `oldest`, the start of the oldest listed segment, less the - /// snapping tolerance. Tied to the listed segments rather than this timeline's own newest - /// content, so a rendition running ahead of a stalled reference never drops a record a listed - /// segment still resolves to. - pub fn trim(&self, oldest: Duration) { - let Ok(mut state) = self.state.write() else { - return; - }; - while state.records.len() >= 2 && state.records[0].end_time() + 2 * TOLERANCE < oldest { - state.records.pop_front(); - } - } - - /// Remove the records with these sequences. - pub fn pop(&self, sequences: Range) { - if let Ok(mut state) = self.state.write() { - state.records.retain(|record| !sequences.contains(&record.sequence)); - } - } - - /// Forget every record after the source skipped some. - pub fn clear(&self) { - if let Ok(mut state) = self.state.write() { - state.records.clear(); - } - } - - /// No more records will arrive. - pub fn end(&self) { - if let Ok(mut state) = self.state.write() { - state.ended = true; - } - } - - /// The timeline failed with `err`, `late` once the reference had already finished: segments - /// its records cover still resolve, and later ones are [`Content::Failed`]. - pub fn fail(&self, err: crate::Error, late: bool) { - if let Ok(mut state) = self.state.write() { - state.failed = Some(Failure { err, late }); - } - } - - /// Why the timeline failed, if it did. - pub fn failure(&self) -> Option { - self.state.read().failed.clone() - } - - /// Poll until the timeline fails. - pub fn poll_failed(&self, waiter: &kio::Waiter) -> Poll<()> { - let poll = self.state.poll_ref(waiter, |state| match state.failed { - Some(_) => Poll::Ready(()), - None => Poll::Pending, - }); - match poll { - Poll::Ready(Ok(())) => Poll::Ready(()), - _ => Poll::Pending, - } - } - - /// Resolve the segment spanning `span`, snapping to keyframes when `video`. - pub fn resolve(&self, video: bool, span: Range) -> Content { - self.state.read().resolve(video, span) - } - - /// Poll until the segment spanning `span` stops being [`Content::Pending`]. - pub fn poll_resolved(&self, waiter: &kio::Waiter, video: bool, span: Range) -> Poll<()> { - let poll = self - .state - .poll_ref(waiter, |state| match state.resolve(video, span.clone()) { - Content::Pending => Poll::Pending, - _ => Poll::Ready(()), - }); - match poll { - Poll::Ready(_) => Poll::Ready(()), - Poll::Pending => Poll::Pending, - } - } - - /// The newest group known to start with a keyframe, used to bootstrap an init segment for - /// inline-parameter-set codecs. - pub fn latest_keyframe_group(&self) -> Option { - let state = self.state.read(); - state - .records - .iter() - .rev() - .find(|record| starts_sync(record)) - .map(|record| record.start.group) - } -} - -/// Whether `record` starts at a group start that is a sync point, so a player can begin decoding -/// there. -fn starts_sync(record: &Entry) -> bool { - record.keyframe && record.start.frame == 0 -} - -/// Snap `boundary` to the nearest keyframe record start within [`TOLERANCE`], preferring the -/// earlier one on a tie. -fn snap<'a>(candidates: &[&'a Entry], boundary: Duration) -> Option<&'a Entry> { - candidates - .iter() - .copied() - .filter(|record| Duration::from(record.pts).abs_diff(boundary) <= TOLERANCE) - .min_by_key(|record| Duration::from(record.pts).abs_diff(boundary)) -} - -/// Every record between two positions, merged into contiguous ranges. -fn ranges(records: &[&Entry], frames: Range) -> Vec> { - let mut out: Vec> = Vec::new(); - for record in records { - let start = record.start.max(frames.start); - let end = record.end.min(frames.end); - if start >= end { - continue; - } - match out.last_mut() { - Some(last) if last.end == start => last.end = end, - _ => out.push(start..end), - } - } - out -} - -fn video(records: Vec<&Entry>, ended: bool, span: Range) -> Content { - // A nearer keyframe can still arrive until the timeline passes the boundary plus the tolerance. - let reached = |time: Duration| ended || records.last().is_some_and(|last| last.end_time() >= time + TOLERANCE); - if !reached(span.end) { - return Content::Pending; - } - - let candidates: Vec<&Entry> = records.iter().copied().filter(|record| starts_sync(record)).collect(); - let Some(first) = snap(&candidates, span.start) else { - return Content::Gap; - }; - // Without a keyframe near the end, run to the next one, so no frame falls between segments. - let end = match snap(&candidates, span.end) { - Some(record) => record.start, - None => match candidates.iter().find(|record| Duration::from(record.pts) > span.end) { - Some(record) => record.start, - None if ended => records.last().expect("a candidate exists").end, - None => return Content::Pending, - }, - }; - if first.start >= end { - return Content::Gap; - } - Content::Frames { - ranges: ranges(&records, first.start..end), - filter: None, - } -} - -fn audio(records: Vec<&Entry>, ended: bool, span: Range) -> Content { - if !ended && records.last().is_none_or(|last| last.end_time() < span.end) { - return Content::Pending; - } - let overlapping: Vec<&Entry> = records - .iter() - .copied() - .filter(|record| Duration::from(record.pts) < span.end && record.end_time() > span.start) - .collect(); - let (Some(first), Some(last)) = (overlapping.first(), overlapping.last()) else { - return Content::Gap; - }; - Content::Frames { - ranges: ranges(&overlapping, first.start..last.end), - filter: Some(span), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn secs(s: f64) -> Duration { - Duration::from_secs_f64(s) - } - - fn record(sequence: u64, pts_ms: u64, duration_ms: u64, start: Position, end: Position) -> Entry { - Entry { - sequence, - pts: moq_net::Timestamp::from_millis(pts_ms).unwrap(), - duration: Duration::from_millis(duration_ms), - start, - end, - keyframe: start.frame == 0, - ext: (), - } - } - - /// One whole-group record per GOP, starting at `starts` (ms), the last one ending at `end`. - fn gops(starts: &[u64], end: u64) -> Vec { - starts - .iter() - .enumerate() - .map(|(i, &pts)| { - let next = starts.get(i + 1).copied().unwrap_or(end); - let group = i as u64; - record( - i as u64, - pts, - next - pts, - Position::group(group), - Position::group(group + 1), - ) - }) - .collect() - } - - fn frames(ranges: &[(Position, Position)], filter: Option>) -> Content { - Content::Frames { - ranges: ranges.iter().map(|&(start, end)| start..end).collect(), - filter, - } - } - - #[test] - fn video_snaps_each_boundary_to_the_nearest_gop() { - // GOPs 300ms off the reference's 2s boundaries. - let records = gops(&[300, 2_300, 4_300, 6_300], 8_300); - let refs: Vec<&Entry> = records.iter().collect(); - assert_eq!( - video(refs.clone(), false, secs(2.0)..secs(4.0)), - frames(&[(Position::group(1), Position::group(2))], None) - ); - // The end boundary is not yet a tolerance behind the newest record. - assert_eq!(video(refs.clone(), false, secs(6.0)..secs(8.0)), Content::Pending); - assert_eq!( - video(refs, true, secs(6.0)..secs(8.0)), - frames(&[(Position::group(3), Position::group(4))], None) - ); - } - - #[test] - fn video_without_a_nearby_start_is_a_gap() { - // GOPs never coincide with the reference: 3.5s cadence against 2s boundaries. - let records = gops(&[0, 3_500, 7_000], 10_500); - let refs: Vec<&Entry> = records.iter().collect(); - assert_eq!(video(refs.clone(), true, secs(2.0)..secs(4.0)), Content::Gap); - // The segment before the gap runs to the next GOP, so no frame is lost between them. - assert_eq!( - video(refs.clone(), true, secs(0.0)..secs(2.0)), - frames(&[(Position::group(0), Position::group(1))], None) - ); - assert_eq!( - video(refs, true, secs(4.0)..secs(6.0)), - frames(&[(Position::group(1), Position::group(2))], None) - ); - } - - #[test] - fn a_rendition_that_starts_later_is_a_gap_until_it_starts() { - let records = gops(&[4_000, 6_000], 8_000); - let refs: Vec<&Entry> = records.iter().collect(); - assert_eq!(video(refs.clone(), false, secs(0.0)..secs(2.0)), Content::Gap); - assert_eq!( - video(refs, false, secs(4.0)..secs(6.0)), - frames(&[(Position::group(0), Position::group(1))], None) - ); - } - - #[test] - fn audio_takes_every_overlapping_record() { - // 1s audio records against 2.5s segments. - let records: Vec = (0..6) - .map(|i| record(i, i * 1_000, 1_000, Position::group(i * 2), Position::group(i * 2 + 2))) - .collect(); - let refs: Vec<&Entry> = records.iter().collect(); - assert_eq!( - audio(refs.clone(), false, secs(2.5)..secs(5.0)), - frames(&[(Position::group(4), Position::group(10))], Some(secs(2.5)..secs(5.0))) - ); - assert_eq!(audio(refs.clone(), false, secs(5.0)..secs(7.5)), Content::Pending); - assert_eq!(audio(refs, true, secs(7.0)..secs(9.0)), Content::Gap); - } - - #[test] - fn a_frame_split_record_is_not_a_video_boundary() { - let records = [ - record(0, 0, 10_000, Position::group(0), Position::new(0, 300)), - record(1, 10_000, 2_000, Position::new(0, 300), Position::group(1)), - record(2, 12_000, 2_000, Position::group(1), Position::group(2)), - ]; - let refs: Vec<&Entry> = records.iter().collect(); - assert_eq!(video(refs.clone(), true, secs(10.0)..secs(12.0)), Content::Gap); - assert_eq!( - video(refs, true, secs(0.0)..secs(12.0)), - frames(&[(Position::group(0), Position::group(1))], None), - "the split's halves merge back into one range" - ); - } - - #[test] - fn a_trim_keeps_records_a_snap_may_need() { - let spans = Spans::new(); - for entry in gops(&[0, 2_000, 4_000, 6_000, 8_000, 10_000], 12_000) { - spans.push(entry); - } - spans.trim(secs(8.0)); - let state = spans.state.read(); - assert_eq!(state.records.front().unwrap().sequence, 2); - } - - #[test] - fn a_failed_timeline_resolves_only_what_its_records_cover() { - let spans = Spans::new(); - for entry in gops(&[0, 2_000, 4_000], 6_000) { - spans.push(entry); - } - // Waiting on a keyframe that could still land near the boundary. - assert_eq!(spans.resolve(true, secs(4.0)..secs(6.0)), Content::Pending); - - spans.fail(crate::Error::NoVariants, false); - assert_eq!( - spans.resolve(true, secs(4.0)..secs(6.0)), - frames(&[(Position::group(2), Position::group(3))], None) - ); - assert_eq!(spans.resolve(true, secs(6.0)..secs(8.0)), Content::Failed); - assert_eq!(spans.resolve(false, secs(6.0)..secs(8.0)), Content::Failed); - let resolved = spans.poll_resolved(&kio::Waiter::noop(), true, secs(6.0)..secs(8.0)); - assert!(resolved.is_ready(), "nothing waits on a failed timeline"); - } -} diff --git a/rs/moq-hls/src/lib.rs b/rs/moq-hls/src/lib.rs index 324fd6123ba..be9fa4377e9 100644 --- a/rs/moq-hls/src/lib.rs +++ b/rs/moq-hls/src/lib.rs @@ -7,7 +7,7 @@ //! segments into MoQ (an HTTP *client* that *publishes*). //! - [`server`] serves HLS playlists, DASH manifests, and CMAF segments over //! HTTP for MoQ broadcasts (an HTTP *server*). It subscribes only to each -//! broadcast's catalog and timeline tracks; media bytes are FETCHed from +//! broadcast's catalog and HLS record tracks; media bytes are FETCHed from //! the relay one group at a time, only when a segment is actually requested. //! It serves every request; gate access by layering your own middleware onto //! [`Server::router`](server::Server::router), or parse and authorize each request @@ -16,7 +16,7 @@ //! All CMAF byte handling (import via [`moq_mux::container::fmp4::Import`], //! export via [`moq_mux::container::fmp4::Muxer`]) lives in `moq-mux`; this //! crate owns the HLS playlist and DASH manifest generation, the -//! timeline-driven playlist window, and the HTTP surface. +//! record-driven playlist window, and the HTTP surface. #![warn(missing_docs)] diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index f64ab977c76..1ba36f96c78 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -7,8 +7,8 @@ //! GET /{broadcast}/manifest.mpd //! GET /{broadcast}/@{instance}/{kind}/{rendition}/media.m3u8 //! GET /{broadcast}/@{instance}/{kind}/{rendition}/init.{hash}.mp4 -//! GET /{broadcast}/@{instance}/{kind}/{rendition}/seg/{reference}.{segment}.m4s -//! GET /{broadcast}/@{instance}/{kind}/{rendition}/seg/{reference}.t{pts}.m4s +//! GET /{broadcast}/@{instance}/{kind}/{rendition}/seg/{tag}.{segment}.m4s +//! GET /{broadcast}/@{instance}/{kind}/{rendition}/seg/{tag}.t{pts}.m4s //! ``` //! //! The master playlist and manifest list the publisher instance serving the broadcast now, @@ -21,10 +21,10 @@ //! `{kind}` is `video` or `audio`, so a video and an audio rendition that share a //! name address distinct resources. The two `seg/` forms name the same bytes: HLS //! playlists address a segment by its aligned number, the DASH manifest by its -//! timeline `pts` (`$Time$`). `{hash}` is a hash of the init segment's bytes, so a +//! `pts` in milliseconds (`$Time$`). `{hash}` is a hash of the init segment's bytes, so a //! reconfigured rendition gets a new init URL. A broadcaster given a generation //! ([`Broadcaster::set_generation`](crate::export::Broadcaster::set_generation)) -//! prefixes every segment file with it (`seg/{generation}.{reference}.{segment}.m4s`), and +//! prefixes every segment file with it (`seg/{generation}.{tag}.{segment}.m4s`), and //! refuses segment URLs carrying any other. //! //! Every request is served. To gate access, wrap [`Server::router`] in your own diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index 88190a2ec42..5d0627af146 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -259,6 +259,16 @@ mod tests { } } + /// A catalog config recording two-second HLS segments, so its renditions are listed. + fn hls_config() -> moq_mux::catalog::Config { + let duration = Duration::from_secs(2); + moq_mux::catalog::Config::default().with_hls( + moq_mux::hls::Config::default() + .with_duration_min(duration) + .with_duration_hint(duration), + ) + } + fn video_config() -> hang::catalog::VideoConfig { let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); config.coded_width = Some(320); @@ -372,7 +382,7 @@ mod tests { moq_net::track::Producer, moq_mux::container::Producer, ) { - let catalog = moq_mux::catalog::Producer::new(broadcast, moq_mux::catalog::Config::default()).unwrap(); + let catalog = moq_mux::catalog::Producer::new(broadcast, hls_config()).unwrap(); let reserved = catalog.reserve(); let track = broadcast.create_track("video0", track).unwrap(); let media = reserved @@ -417,7 +427,7 @@ mod tests { if response.status() == StatusCode::OK { let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let body = String::from_utf8(body.to_vec()).unwrap(); - if body.contains("seg/656e3d1b.0.m4s") { + if body.contains("seg/c2369933.0.m4s") { break body; } } @@ -428,7 +438,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(50)).await; }; assert!(body.contains(".mp4?jwt=abc\""), "{body}"); - assert!(body.contains("seg/656e3d1b.0.m4s?jwt=abc"), "{body}"); + assert!(body.contains("seg/c2369933.0.m4s?jwt=abc"), "{body}"); pair.accept.abort(); } @@ -447,11 +457,11 @@ mod tests { let pair = lite_pair_on(version, moq_net::Hop::random()).await; let mut broadcast = pair.pub_origin.create_broadcast("live").expect("publish"); broadcast.announce(Default::default()).expect("announce"); - // A hinted timeline is declared before any media, so the rendition is listed early. - let hinted = moq_mux::timeline::Config::default().with_duration_hint(Duration::from_secs(2)); + // A hinted segment duration is declared before any media, so the rendition is listed early. + let hinted = moq_mux::hls::Config::default().with_duration_hint(Duration::from_secs(2)); let catalog = moq_mux::catalog::Producer::new( &mut broadcast, - moq_mux::catalog::Config::default().with_timeline(hinted), + moq_mux::catalog::Config::default().with_hls(hinted), ) .unwrap(); let reserved = catalog.reserve(); @@ -545,9 +555,9 @@ mod tests { let app = Server::new(origin.consume(), crate::export::Config::default()).router(); let old_playlist = format!("/live/@{RUN}/video/video0/media.m3u8"); - let old_segment = format!("/live/@{RUN}/video/video0/seg/656e3d1b.0.m4s"); + let old_segment = format!("/live/@{RUN}/video/video0/seg/c2369933.0.m4s"); wait_listed(&app, "/live/master.m3u8", &format!("@{RUN}/video/video0/media.m3u8")).await; - wait_listed(&app, &old_playlist, "seg/656e3d1b.0.m4s").await; + wait_listed(&app, &old_playlist, "seg/c2369933.0.m4s").await; let next = moq_net::Epoch::mint(); let mut new = origin.create_broadcast("live").expect("publish"); @@ -562,7 +572,7 @@ mod tests { &format!("@{}/video/video0/media.m3u8", next.as_str()), ) .await; - wait_listed(&app, &new_playlist, "seg/656e3d1b.0.m4s").await; + wait_listed(&app, &new_playlist, "seg/c2369933.0.m4s").await; assert_eq!(status(&app, &old_playlist).await, StatusCode::OK); assert_eq!(status(&app, &old_segment).await, StatusCode::OK); @@ -579,41 +589,34 @@ mod tests { assert_eq!(status(&app, &new_playlist).await, StatusCode::OK); } - /// A failed timeline answers every listing, a clip included, with an uncacheable error - /// rather than ending it as a complete recording or refusing the range. + /// Failed records answer every listing with an uncacheable error rather than ending it as a + /// complete recording. #[tokio::test(start_paused = true)] - async fn a_failed_timeline_answers_an_uncacheable_error() { + async fn failed_records_answer_an_uncacheable_error() { let origin = moq_tokio::origin::spawn(); let mut broadcast = origin.create_broadcast("live").expect("publish"); broadcast.announce(run()).expect("announce"); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); - let name = hang::timeline::default_name("video0"); + let name = hang::hls::default_name("video0"); catalog .mutate(|catalog| { - catalog.video.renditions.insert("video0".into(), video_config()); - let mut archive = hang::catalog::Archive::new(); - archive - .timelines - .insert("video0".into(), hang::catalog::Timeline::new(name.clone(), 1_000)); - catalog.archive = Some(archive); + let mut config = video_config(); + config.hls = Some(hang::catalog::Hls::new(name.clone(), Duration::from_secs(2))); + catalog.video.renditions.insert("video0".into(), config); }) .unwrap(); - let track = broadcast - .create_track(name, moq_mux::timeline::Producer::info()) - .unwrap(); - let mut timeline = moq_mux::timeline::Producer::new(track.clone()); - for sequence in 0..3 { - let start = hang::timeline::Position::group(sequence); - let end = hang::timeline::Position::group(sequence + 1); - let record = hang::timeline::Record::new(sequence, sequence * 2_000, 2_000, start, end); - timeline.push(&record).unwrap(); + let track = broadcast.create_track(name, moq_mux::hls::Producer::info()).unwrap(); + let mut records = moq_mux::hls::Producer::new(track.clone()); + for sequence in 0..4 { + let record = hang::hls::Record::new(sequence, sequence, sequence * 2_000); + records.push(&record).unwrap(); } let app = Server::new(origin.consume(), crate::export::Config::default()).router(); wait_listed(&app, &format!("/live/@{RUN}/video/video0/media.m3u8"), ".2.m4s").await; assert_eq!(status(&app, "/live/manifest.mpd").await, StatusCode::OK); - drop(timeline); + drop(records); track .abort(moq_net::Error::Transport("connection lost".into())) .unwrap(); @@ -647,7 +650,7 @@ mod tests { wait_listed( &app, &format!("/live/@{RUN}/video/video0/media.m3u8"), - "seg/656e3d1b.0.m4s", + "seg/c2369933.0.m4s", ) .await; let broadcaster = server.broadcaster("live").await.expect("broadcaster"); @@ -656,7 +659,7 @@ mod tests { let response = oneshot(app.clone(), &format!("/live/@{RUN}/video/video0/media.m3u8")).await; let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let playlist = String::from_utf8(body.to_vec()).unwrap(); - assert!(playlist.contains("\nseg/run-1.656e3d1b.0.m4s\n"), "{playlist}"); + assert!(playlist.contains("\nseg/run-1.c2369933.0.m4s\n"), "{playlist}"); let map = playlist.lines().find(|line| line.starts_with("#EXT-X-MAP:")).unwrap(); let init = map .strip_prefix("#EXT-X-MAP:URI=\"") @@ -678,26 +681,26 @@ mod tests { ); assert_eq!( - status(&app, &format!("/live/@{RUN}/video/video0/seg/run-1.656e3d1b.0.m4s")).await, + status(&app, &format!("/live/@{RUN}/video/video0/seg/run-1.c2369933.0.m4s")).await, StatusCode::OK ); assert_eq!( - status(&app, &format!("/live/@{RUN}/video/video0/seg/run-1.656e3d1b.t0.m4s")).await, + status(&app, &format!("/live/@{RUN}/video/video0/seg/run-1.c2369933.t0.m4s")).await, StatusCode::OK ); assert_eq!( - status(&app, &format!("/live/@{RUN}/video/video0/seg/656e3d1b.0.m4s")).await, + status(&app, &format!("/live/@{RUN}/video/video0/seg/c2369933.0.m4s")).await, StatusCode::NOT_FOUND ); assert_eq!( - status(&app, &format!("/live/@{RUN}/video/video0/seg/run-0.656e3d1b.0.m4s")).await, + status(&app, &format!("/live/@{RUN}/video/video0/seg/run-0.c2369933.0.m4s")).await, StatusCode::NOT_FOUND ); // A new run: the previous generation's URLs are refused even for numbers it reuses. broadcaster.set_generation(Some("run-2")).unwrap(); assert_eq!( - status(&app, &format!("/live/@{RUN}/video/video0/seg/run-1.656e3d1b.0.m4s")).await, + status(&app, &format!("/live/@{RUN}/video/video0/seg/run-1.c2369933.0.m4s")).await, StatusCode::NOT_FOUND ); } @@ -723,11 +726,11 @@ mod tests { wait_listed( &app, &format!("/live/@{run}/video/video0/media.m3u8"), - "seg/656e3d1b.0.m4s", + "seg/c2369933.0.m4s", ) .await; - let response = oneshot(app, &format!("/live/@{run}/video/video0/seg/656e3d1b.0.m4s")).await; + let response = oneshot(app, &format!("/live/@{run}/video/video0/seg/c2369933.0.m4s")).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); @@ -748,7 +751,7 @@ mod tests { wait_listed( &app, &format!("/live/@{run}/video/video0/media.m3u8"), - "seg/656e3d1b.0.m4s", + "seg/c2369933.0.m4s", ) .await; @@ -761,7 +764,7 @@ mod tests { .await .expect("publisher close timed out"); - let response = oneshot(app, &format!("/live/@{run}/video/video0/seg/656e3d1b.0.m4s")).await; + let response = oneshot(app, &format!("/live/@{run}/video/video0/seg/c2369933.0.m4s")).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); @@ -842,7 +845,7 @@ mod tests { source.announce(Default::default()).unwrap(); let secret = origin.create_broadcast("room/secret").unwrap(); secret.announce(Default::default()).unwrap(); - let catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast, hls_config()).unwrap(); let reserved = catalog.reserve(); let mut config = video_config(); config.broadcast = Some(moq_net::path::Relative::new("./source").to_owned()); @@ -874,8 +877,8 @@ mod tests { let server = Server::new(restricted, Default::default()); let broadcaster = server.broadcaster("room/live").await.unwrap(); broadcaster.ready().await; - let allowed_uri = &format!("/room/live/@{RUN}/video/video0/seg/656e3d1b.0.m4s"); - let denied_uri = &format!("/room/live/@{RUN}/video/video1/seg/656e3d1b.0.m4s"); + let allowed_uri = &format!("/room/live/@{RUN}/video/video0/seg/c2369933.0.m4s"); + let denied_uri = &format!("/room/live/@{RUN}/video/video1/seg/c2369933.0.m4s"); let app = server.router(); assert_eq!(oneshot(app.clone(), allowed_uri).await.status(), StatusCode::OK); for uri in [ @@ -901,7 +904,7 @@ mod tests { assert!(!master.contains("video/video1/")); let first = |rendition: &Rendition| segments::Id { generation: rendition.generation().map(|generation| generation.to_string()), - reference: "656e3d1b".into(), + tag: "c2369933".into(), at: segments::At::Sequence(0), }; let rendition = broadcaster.rendition(Kind::Video, "video1").unwrap(); @@ -921,7 +924,7 @@ mod tests { assert_eq!(oneshot(app.clone(), denied_uri).await.status(), StatusCode::FORBIDDEN); assert_eq!(oneshot(app.clone(), allowed_uri).await.status(), StatusCode::OK); assert_eq!( - oneshot(app, &format!("/room/live/@{RUN}/video/video0/seg/656e3d1b.9999.m4s")) + oneshot(app, &format!("/room/live/@{RUN}/video/video0/seg/c2369933.9999.m4s")) .await .status(), StatusCode::NOT_FOUND @@ -962,7 +965,7 @@ mod tests { media_broadcast.announce(Default::default()).expect("announce media"); let catalog = - moq_mux::catalog::Producer::new(&mut catalog_broadcast, moq_mux::catalog::Config::default()).unwrap(); + moq_mux::catalog::Producer::new(&mut catalog_broadcast, hls_config()).unwrap(); let reserved = catalog.reserve(); let mut config = video_config(); config.broadcast = Some(moq_net::path::Relative::new("./source").to_owned()); @@ -983,7 +986,7 @@ mod tests { wait_listed( &app, &format!("/room/live/@{run}/video/video0/media.m3u8"), - "seg/656e3d1b.0.m4s", + "seg/c2369933.0.m4s", ) .await; @@ -999,7 +1002,7 @@ mod tests { .await .expect("sibling close timed out"); - let response = oneshot(app, &format!("/room/live/@{run}/video/video0/seg/656e3d1b.0.m4s")).await; + let response = oneshot(app, &format!("/room/live/@{run}/video/video0/seg/c2369933.0.m4s")).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 9c7e90a19a6..a5517202a13 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -195,17 +195,14 @@ impl Outputs { .video .renditions .iter_mut() - .map(|(id, config)| (id, &config.broadcast, &config.track, &mut config.hls)); + .map(|(id, config)| (id, &config.track, &mut config.hls)); let audio = catalog .audio .renditions .iter_mut() - .map(|(id, config)| (id, &config.broadcast, &config.track, &mut config.hls)); - for (id, broadcast, track, field) in video.chain(audio) { - // A rendition served from another broadcast carries that broadcast's records, if any. - if broadcast.is_some() { - continue; - } + .map(|(id, config)| (id, &config.track, &mut config.hls)); + // Records live on the catalog's broadcast, a sibling rendition's included. + for (id, track, field) in video.chain(audio) { if let Some(advertised) = hls.hls(track.as_deref().unwrap_or(id)) { *field = Some(advertised); } diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs index 6e53abd33f6..7211504b148 100644 --- a/rs/moq-mux/src/hls.rs +++ b/rs/moq-mux/src/hls.rs @@ -55,8 +55,9 @@ const DEFAULT_DURATION_CEILING: Duration = Duration::from_secs(10); /// A pacer's segments may overrun their target to reach a group boundary, but not indefinitely. const DURATION_CEILING_MULTIPLIER: u32 = 3; -/// Recent records repeated when the window track rolls to a new group. -const CHECKPOINT_RECORDS: usize = 256; +/// Recent records repeated when the window track rolls to a new group: one more than the 256 +/// segments an HLS edge lists, since a segment is listed only once the record after it arrives. +const CHECKPOINT_RECORDS: usize = 257; /// The wire timescale for record `pts`/`duration`: milliseconds. const TIMESCALE: Timescale = Timescale::MILLI; @@ -396,10 +397,11 @@ impl Iterator for Segmenter { } } -/// Publishes one track's records onto its HLS record track. +/// Publishes one track's records onto its HLS record track, for a publisher that segments +/// itself; [`catalog::Config::with_hls`](crate::catalog::Config::with_hls) records them for you. /// /// Records must be pushed in sequence order, starting at the window's next index. -pub(crate) struct Producer { +pub struct Producer { sink: moq_json::window::Producer, } From cce13db1944d82317df4680fc62976c8a370b210 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 04:12:09 -0700 Subject: [PATCH 04/20] feat(hang)!: opt-in per-rendition hls records replace the root archive Co-Authored-By: Claude Opus 5.5 --- doc/bin/cli.md | 8 +- doc/bin/hls.md | 27 +-- doc/concept/hang.md | 4 +- doc/setup/upgrade.md | 10 +- drafts/draft-lcurley-moq-hang.md | 183 +++++++------------ js/hang/MIGRATE.md | 19 +- js/hang/src/catalog/archive.ts | 56 ------ js/hang/src/catalog/audio.ts | 5 + js/hang/src/catalog/clock.test.ts | 37 ++-- js/hang/src/catalog/clock.ts | 9 +- js/hang/src/catalog/hls.ts | 24 +++ js/hang/src/catalog/index.ts | 4 +- js/hang/src/catalog/path.ts | 4 +- js/hang/src/catalog/root.test.ts | 80 ++------ js/hang/src/catalog/root.ts | 12 +- js/hang/src/catalog/video.ts | 5 + js/hang/src/hls.test.ts | 51 ++++++ js/hang/src/hls.ts | 101 ++++++++++ js/hang/src/index.ts | 5 +- js/hang/src/timeline.test.ts | 94 ---------- js/hang/src/timeline.ts | 124 ------------- js/publish/src/catalog.test.ts | 3 +- quest/m1/README.md | 2 + quest/m1/api-audit/README.md | 3 +- quest/m1/archive/README.md | 3 +- quest/m1/archive/catalog-hls.md | 90 --------- quest/m1/archive/hls-target.md | 5 +- quest/m1/compress/migrate.md | 7 +- quest/m1/hls-conformance.md | 3 +- quest/m1/hls-follower-numbering.md | 26 +++ quest/m1/hls-opt-in-gateways.md | 24 +++ quest/m1/hls-record-cli.md | 6 +- quest/m2/js-hls.md | 5 +- rs/hang/MIGRATE.md | 29 +-- rs/hang/README.md | 2 +- rs/hang/src/catalog/root.rs | 19 +- rs/hang/src/hls.rs | 2 +- rs/moq-audio/src/encode/capture.rs | 15 +- rs/moq-boy/src/video.rs | 4 +- rs/moq-c/src/video.rs | 4 +- rs/moq-cli/MIGRATE.md | 5 + rs/moq-cli/src/args.rs | 29 ++- rs/moq-cli/src/hls.rs | 5 +- rs/moq-cli/src/main.rs | 6 +- rs/moq-cli/src/moq.rs | 3 + rs/moq-cli/src/publish.rs | 5 +- rs/moq-cli/src/rtmp.rs | 1 + rs/moq-cli/src/rtsp.rs | 3 +- rs/moq-cli/src/srt.rs | 1 + rs/moq-hls/MIGRATE.md | 16 +- rs/moq-hls/src/export/archive_tests.rs | 16 +- rs/moq-hls/src/export/mod.rs | 62 +++++-- rs/moq-hls/src/export/rendition.rs | 14 +- rs/moq-hls/src/export/renditions.rs | 4 +- rs/moq-hls/src/export/segments.rs | 19 +- rs/moq-hls/src/import.rs | 2 +- rs/moq-hls/src/server/routes.rs | 17 +- rs/moq-mux/MIGRATE.md | 19 +- rs/moq-mux/src/catalog/producer.rs | 22 ++- rs/moq-mux/src/container/fmp4/import_test.rs | 15 +- rs/moq-mux/src/container/producer.rs | 16 +- rs/moq-mux/src/hls.rs | 16 +- rs/moq-mux/src/lib.rs | 2 +- rs/moq-rtc/src/codec/vp8.rs | 5 +- rs/moq-video/MIGRATE.md | 4 +- rs/moq-video/src/capture/wgc.rs | 4 +- rs/moq-video/src/encode/producer.rs | 28 +-- 67 files changed, 681 insertions(+), 772 deletions(-) delete mode 100644 js/hang/src/catalog/archive.ts create mode 100644 js/hang/src/catalog/hls.ts create mode 100644 js/hang/src/hls.test.ts create mode 100644 js/hang/src/hls.ts delete mode 100644 js/hang/src/timeline.test.ts delete mode 100644 js/hang/src/timeline.ts delete mode 100644 quest/m1/archive/catalog-hls.md create mode 100644 quest/m1/hls-follower-numbering.md create mode 100644 quest/m1/hls-opt-in-gateways.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 269d5733265..34e8aecb5fd 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -144,6 +144,12 @@ moq --connect https://relay.example.com/anon --broadcast camera.hang \ import rtsp --connect rtsp://admin:secret@192.168.1.64/stream1 ``` +`--hls` publishes [HLS segment records](/bin/hls#how-export-works) beside each +video and audio rendition, which `export hls` needs to serve the broadcast. The +source must start every rendition's keyframes at the same timestamps. The stdin +formats, `hls`, `rtsp`, and `capture` take it; the other gateways build their +own catalog and refuse it. + ## Export The `fmp4`, `mkv`, `flv`, `h264`, and `h265` exports pick renditions with @@ -350,7 +356,7 @@ over one connection: ```bash moq --connect https://relay.example.com/anon \ - import --broadcast event.hang srt --listen 0.0.0.0:9000 \ + import --broadcast event.hang --hls rtsp --connect rtsp://camera.local/stream1 \ -- export --broadcast event.hang hls --listen 0.0.0.0:8080 \ -- export --broadcast event.hang archive file:///recordings ``` diff --git a/doc/bin/hls.md b/doc/bin/hls.md index 1d062b7ea02..2399cabadfa 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -10,7 +10,9 @@ that can't speak MoQ. `moq import hls` pulls a remote HLS master or media playlist into a broadcast. ```bash -# Serve. Players open http://localhost:8089/my-stream.hang/master.m3u8 (or manifest.mpd for DASH) +# Publish with HLS records, then serve. Players open +# http://localhost:8089/my-stream.hang/master.m3u8 (or manifest.mpd for DASH) +ffmpeg -re -i input.mp4 -c copy -f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer -frag_duration 1000 - | moq --connect https://relay.example.com/anon --broadcast my-stream.hang import --hls fmp4 moq --connect https://relay.example.com/anon --broadcast my-stream.hang export hls --listen '[::]:8089' # Import @@ -35,20 +37,21 @@ separate CDN host, fails the import. ## How export works Export never subscribes to media. It reads each rendition's -[timeline track](/concept/hang#catalog), a small index of that rendition's -groups, to build playlists. A segment request fetches exactly the frames it -covers from the relay's cache and transmuxes them to CMAF. Media is never -transcoded, so the player must support the publisher's codecs. +[HLS records](/concept/hang#catalog), a small track listing where each of +that rendition's segments starts, to build playlists. A segment request fetches +exactly the groups it covers from the relay's cache and transmuxes them to CMAF. +Media is never transcoded, so the player must support the publisher's codecs. What that means for you: +- **Opt in at the publisher.** Only a rendition whose catalog entry carries `hls` gets a playlist; nothing publishes it by default. Pass `--hls` to `moq import` (the stdin formats, `hls`, `rtsp`, and `capture`), or enable it with `moq_mux::catalog::Config::with_hls` in Rust. Without a duration hint, a rendition declares its records after its first segment. +- **Align keyframes across renditions.** Segment `n` of every rendition comes from that rendition's record `n`, so a publisher that opts in must start its renditions' segments at the same timestamps, and start them together. A rendition's segment that does not line up with the lead rendition's (the first enabled video rendition, or audio when there is none) is listed as `EXT-X-GAP`, left out of the DASH manifest, and never served. - **Idle renditions cost nothing**, and a segment is servable for as long as the relay's [cache](/bin/relay/config#cache) retains it. Keep `--window` (the playlist length, default 16 s) within that retention. -- **Live joins cost the window.** The exporter reads the timeline checkpoint and lists at most 256 segments. -- **Publish timelines.** A broadcast whose catalog advertises no timelines is skipped, and a rendition is listed only once its own timeline is advertised. Without a duration hint, a publisher declares that timeline after the rendition's first segment. -- **Align GOPs across video renditions.** Segment boundaries come from one reference rendition. Other video renditions snap to their nearest keyframe, and a segment with none nearby becomes an `EXT-X-GAP`. -- **Disabled renditions are hidden.** A rendition the catalog marks `enabled: false` leaves the multivariant playlist and DASH manifest until it re-enables. Its media playlist keeps serving, so a player already on it is not ended with `EXT-X-ENDLIST`. If it supplied segment boundaries, an enabled sibling takes over at its first record numbered past the last advertised one, and DASH starts a new period. The media sequence is the record number, so every edge agrees: a sibling numbered ahead restarts the playlist with a discontinuity, and one numbered behind (such as a re-enabled rung) holds every playlist until its numbering catches up. `EXT-X-DISCONTINUITY-SEQUENCE` and DASH periods are counted per edge, so edges agree on them only when no discontinuity or handoff happened while each was running. +- **Live joins cost the window.** The exporter reads the record checkpoint and lists at most 256 segments. +- **Segments are timed by their records.** A segment is listed once the record after it arrives, and its duration, its DASH `t` and `d`, and its last sample's duration all come from its own record and the next, so a segment's bytes never change with a catalog estimate. A stop record (a break or the end of the content) is listed as a zero-length gap, and what follows it starts a discontinuity. +- **Disabled renditions are hidden.** A rendition the catalog marks `enabled: false` leaves the multivariant playlist and DASH manifest until it re-enables. Its media playlist keeps serving, so a player already on it is not ended with `EXT-X-ENDLIST`. - **URLs name the publisher instance.** Every URL the multivariant playlist and DASH manifest list sits under `@`, the publisher's epoch, so every edge and every reload derive the same URLs and a CDN in front of export caches them. A restarted publisher gets URLs of its own, whose numbering starts over. The multivariant playlist and manifest switch to it at once, while a player already on the old instance keeps playing until that publisher leaves; the player then has to reload the multivariant playlist. An instance without an epoch (one that reached export over moq-lite-06 or older) gets an id each export mints, so only that export serves its URLs. -- **Only a clean finish ends a playlist.** `EXT-X-ENDLIST` and a `static` DASH manifest mean the timeline finished. If the segment-boundary rendition's timeline fails instead, such as a publisher dropping its session, every media playlist and the DASH manifest answer HTTP 500 with `Cache-Control: no-store`, so a truncated recording never looks complete. A rendition whose own timeline fails answers the error alone and leaves the multivariant playlist and DASH manifest, so players switch to a sibling. +- **Only a clean finish ends a playlist.** `EXT-X-ENDLIST` and a `static` DASH manifest mean the records finished. A rendition whose records fail instead, such as a publisher dropping its session, answers HTTP 500 with `Cache-Control: no-store`, so a truncated recording never looks complete, and leaves the multivariant playlist and DASH manifest so players switch to a sibling. With none left, the DASH manifest answers the error too. A [`moq-archive`](https://docs.rs/moq-archive) recording is served the same way, with no second stored copy: record a broadcast, republish it, and export @@ -60,8 +63,8 @@ moq --connect https://relay.example.com/anon --broadcast event-replay.hang impor moq --connect https://relay.example.com/anon --broadcast event-replay.hang export hls --listen '[::]:8089' ``` -The archived catalog and timelines replay as recorded, so the export lists the -same window as a live broadcast. +The archived catalog and HLS records replay as recorded, so the export lists +the same window as a live broadcast. `--listen-tls-cert`/`--listen-tls-key` or `--listen-tls-generate` serve HTTPS, and `--cors-origin` opens it to browsers. HLS TLS reads `MOQ_HLS_TLS_*`; diff --git a/doc/concept/hang.md b/doc/concept/hang.md index bdff727955b..4e8830ce892 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -72,8 +72,8 @@ A few things the catalog can express beyond decoder config: - **Spatial media.** A video rendition may carry `projection` (flat `rectilinear`, or `equirectangular` with the degrees of longitude it spans, 180 for VR180) and `stereo` (`mono`, `left-right`, or `top-bottom` frame packing), and an audio rendition may carry `layout: "ambisonic"` for AmbiX channels. Readers treat a value they don't recognize as absent. Nothing in `@moq/watch` renders them yet. - **Disabled renditions.** An audio or video rendition with `enabled: false` has no frames coming, and a player must not select it. Pausing a rendition in `@moq/publish` keeps it in the catalog this way rather than removing it, so a mute is a one-field update; a disabled video rendition encodes one black keyframe first, so a player that predates the field shows black instead of a frozen picture. The field is written only when false. The older video `stalled` field is no longer written, and readers ignore it. - **Preference.** A video rendition may carry a signed `preference`, default `0`. A player keeps only the highest preference it can decode, then picks by size and bitrate within it, as do the single-rendition egresses (RTMP play, WHEP, FLV, the transcoder's source). A publisher that adds its own H.264 copy of an H.265 source marks the copy `-1`, so only a viewer that can't decode H.265 pulls it. Picking a rendition by name still overrides it. The field is written only when nonzero. -- **Archive.** A broadcast may advertise an `archive` entry mapping each track, the catalog included, to its own timeline track (a small index of the track's spans: timing plus group and frame positions) and fixed nominal record duration, plus the replay MoQ path, object-store URL, and format version when recorded. Media records end at the first group boundary at or beyond a 4s target; sparse timelines such as the catalog declare zero. Tracks commit and expire independently. An untimed track has no timing to index, so its timeline declares no duration and holds one record per group: it can be recorded and replayed, but not turned into HLS or kept in a DVR. The timelines are what let the [HLS gateway](/bin/hls) build playlists without subscribing to media. A reader that cannot decode the `archive` entry, such as one from a different version, ignores it and loses the timelines and replay, but live media still plays. -- **Clock.** The optional root `clock` maps PTS zero to wall time so every media track and the archive index share one fixed epoch after timescale conversion. It is independent of `archive`, so a live-only publisher can expose wall-clock timing without creating a timeline. Publish independent sources as sibling broadcasts under one prefix, such as `robot/front.hang` and `robot/rear.hang`; each broadcast's audio and video entries are renditions of that source. In one Rust publisher, pass the same `moq_mux::Clock` to each `catalog::Config::with_clock`; JS publishers on one page already share the page clock. Consumers discover the siblings by prefix and align their timestamps through the advertised wall mapping; `@moq/watch` plays one broadcast at a time. +- **HLS records.** A video or audio rendition may opt in with an `hls` field naming a small track of HLS segment records, each segment's first group and pts, plus the nominal segment duration. A publisher that sets it on several renditions starts their segments at the same timestamps, so an edge serves segment `n` of each from record `n`. The records are what let the [HLS gateway](/bin/hls) build playlists without subscribing to media; a rendition without them gets no playlist. Nothing publishes them unless the application enables them, and a reader that cannot decode the field ignores it. +- **Clock.** The optional root `clock` maps PTS zero to wall time so every media track and every HLS record share one fixed epoch after timescale conversion. Publish independent sources as sibling broadcasts under one prefix, such as `robot/front.hang` and `robot/rear.hang`; each broadcast's audio and video entries are renditions of that source. In one Rust publisher, pass the same `moq_mux::Clock` to each `catalog::Config::with_clock`; JS publishers on one page already share the page clock. Consumers discover the siblings by prefix and align their timestamps through the advertised wall mapping; `@moq/watch` plays one broadcast at a time. - **Version.** The root `version` is the oldest moq-hang draft that reads the catalog correctly, written only when a field needs one newer than 03: a `track` that differs from its ID, or a video `warmup`. Readers ignore fields they don't know, but refuse a `version` above their own with a typed "unsupported catalog version" error, so a player can say to upgrade instead of misreading the catalog. An additive field never raises it. - **Extensions.** The root is a loose object. Applications add their own sections (`scte35`, for example) next to the ones hang defines, optionally naming a track that carries the data. Every library exposes a way to write your section without clobbering the built-in ones, and readers ignore what they don't know. diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 86ad4cce3d1..17a7a17940a 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -55,11 +55,11 @@ These land with the next breaking release. or `subscribe` carries a `rate` cap encodes that role as an object, which released verifiers reject, so upgrade every auth server and verifier before issuing capped tokens. -- **Archive timelines declare a duration** (#5715). Each catalog - `archive.timelines` entry is an object instead of a track name. Neither side - reads the other's shape, and a catalog it can't read keeps its live media but - drops the archive, so seeking, recording, and HLS history need publishers and - their readers upgraded together. +- **HLS records replace archive timelines.** The hang catalog's root `archive` + section is gone. A video or audio rendition opts in to HLS with its own `hls` + field naming a `.hls` record track, and nothing publishes one by + default. Neither side reads the other's shape, so upgrade HLS exporters and + their publishers together, and opt the publishers in (`moq import --hls`). - **Announce restarts.** moq-lite 07 adds `ANNOUNCE_RESTART` (0x3): another publisher instance now serves the prefix. lite-06's `ANNOUNCE_RESTART` is named `ANNOUNCE_UPDATE`, as it always meant. moq-transport draft 17 and later diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index d86edea5db1..75286e94768 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -159,7 +159,6 @@ type Catalog = { "version": number | undefined, "audio": AudioSchema | undefined, "video": VideoSchema | undefined, - "archive": ArchiveSchema | undefined, "clock": ClockSchema | undefined, "stats": { "track": string } | undefined, "echo": { "path": string } | undefined, @@ -178,7 +177,7 @@ For example, a chat entry should name a chat track, not carry individual chat me This way catalog updates are rare and a client MAY choose to not subscribe. The `json` and `binary` sections ({{data}}) are how a track like that is listed. -This specification defines audio, video, and text media tracks, plus an optional `archive` entry ({{archive-catalog}}) naming the timeline track ({{timeline}}) that indexes each track, an optional root `clock` entry ({{clock}}) mapping content time to wall time, the root `version` ({{catalog-version}}), the application data tracks in {{data}}, and optional publisher statistics and solicited viewer feedback ({{media-stats}}). +This specification defines audio, video, and text media tracks, with optional HLS records ({{hls}}) on video and audio, an optional root `clock` entry ({{clock}}) mapping content time to wall time, the root `version` ({{catalog-version}}), the application data tracks in {{data}}, and optional publisher statistics and solicited viewer feedback ({{media-stats}}). ## Version {#catalog-version} The root `version` field is the oldest draft of this specification that reads the catalog correctly, by draft number: `4` needs a consumer of moq-hang-04 or later. @@ -194,7 +193,7 @@ This draft needs `version` 4 for: - a `track` ({{field-track}}) other than the entry's ID, which an older consumer subscribes to instead; - a video `warmup` ({{field-warmup}}) above zero, since an older consumer starts decoding a group at a picture that is not a keyframe. -Every other catalog change in this draft is safe to ignore: `enabled` ({{field-enabled}}) leaves an older consumer selecting a disabled rendition, which plays nothing; `archive` ({{archive-catalog}}) is ignored when undecodable; `stats` and `echo` ({{media-stats}}) only offer optional tracks; video `reorder` leaves an older consumer inferring decode times from timestamps; `jitter`, `delay`, audio `warmup`, and the data track estimates are hints. +Every other catalog change in this draft is safe to ignore: `enabled` ({{field-enabled}}) leaves an older consumer selecting a disabled rendition, which plays nothing; `hls` ({{hls-catalog}}) is ignored when undecodable; `stats` and `echo` ({{media-stats}}) only offer optional tracks; video `reorder` leaves an older consumer inferring decode times from timestamps; `jitter`, `delay`, audio `warmup`, and the data track estimates are hints. ## Clock {#clock} The catalog's root `clock` field is the broadcast's one continuous clock: @@ -208,12 +207,12 @@ type ClockSchema = { The `wall` field is the wall-clock time of PTS zero, in `timescale` units since the moq epoch, 2020-01-01T00:00:00Z. A consumer derives the wall-clock time of any media timestamp as `wall + pts` after converting that timestamp into this timescale, and Unix time by adding the epoch back (for HLS `EXT-X-PROGRAM-DATE-TIME` or DASH `availabilityStartTime`). -Every media track and every timeline refer to this one mapping after timescale conversion; there are no competing wall epochs. +Every media track and every HLS record refer to this one mapping after timescale conversion; there are no competing wall epochs. The `timescale` field is the units per second for `wall`, defaulting to 1000000 (microseconds) when absent. A zero `timescale` is invalid, as is a `wall` outside the JSON-safe integer range; both are refused rather than truncated. -The mapping is fixed for the broadcast and independent of `archive`: a live-only publisher exposes its clock without creating a timeline. +The mapping is fixed for the broadcast. A discontinuity marker is a delivery event, not a new epoch, and a system-clock adjustment never retimes the mapping. ## Track Identity {#identity} @@ -263,9 +262,12 @@ type VideoDecoderConfigExtensions = { "projection": Projection | undefined, "stereo": string | undefined, "interlaced": boolean | undefined, + "hls": HlsSchema | undefined, } ~~~ +`hls` names the rendition's HLS records ({{hls-catalog}}). + `displayAspectWidth` and `displayAspectHeight` give the aspect ratio the whole decoded picture is displayed at, as in WebCodecs' `VideoDecoderConfig`. They are not the sample (pixel) aspect ratio: a publisher with square samples omits them, and one with non-square samples sends the picture's aspect, such as 16:9 for a 1440x1080 picture with 4:3 samples. A consumer that understands neither field MUST display the decoded picture at its own shape. @@ -420,9 +422,12 @@ In addition to the WebCodecs fields, each rendition MAY carry the common renditi ~~~ type AudioDecoderConfigExtensions = { "layout": string | undefined, + "hls": HlsSchema | undefined, } ~~~ +`hls` names the rendition's HLS records ({{hls-catalog}}). + `layout` says what the decoded channels carry; when absent, they follow the codec's own channel order. `ambisonic` is full-sphere ambisonics in the AmbiX convention: ACN channel order with SN3D normalization, of the order N for which `numberOfChannels` is (N+1)^2. A consumer MUST reject `ambisonic` with fewer than 4 channels or a count that is not a square, and MUST treat an unrecognized `layout` as absent. @@ -946,171 +951,104 @@ Some metadata tracks are compressed. Compression is signalled per track, never inferred from the track's name. A data track ({{data}}) declares it with the `compression` field ({{field-compression}}). -The tracks this specification defines as always compressed, `catalog.json.z` ({{catalog}}) and the timeline tracks ({{timeline}}), are identified by their role instead. -The `.z` suffix on those names is a naming convention, and a consumer MUST NOT treat it as a signal. +The track this specification defines as always compressed, `catalog.json.z` ({{catalog}}), is identified by its role instead. +The `.z` suffix on that name is a naming convention, and a consumer MUST NOT treat it as a signal. Such a track is compressed per {{moqflate}}: each group is one raw DEFLATE stream, sync flushed at each frame boundary. The suffix is the declaration. -# Timeline {#timeline} -A timeline track indexes one track's content. +# HLS Records {#hls} +HLS records exist only so an edge can serve a broadcast as HLS or DASH without subscribing to its media. MoQ groups carry only an opaque sequence number; the timestamps live inside the media frames. -A timeline republishes a track's spans as metadata: one record per span, mapping a span of content time to the frame positions that carry it. -A consumer can answer "which groups cover time T on track X" and "where is the live edge" from a few bytes per span, without downloading media. -This is sufficient to derive an HLS or DASH playlist, seek a VOD recording, or index an archive. +A record track republishes where each segment of one rendition starts, a few bytes per segment, so an edge builds playlists from the records alone and fetches a segment's groups only when a player requests it. -Timelines are optional, and a track has at most one. -Each track's timeline is cut, published, and expired independently; nothing in one timeline waits for another track. +Records are optional and opt-in: a video or audio rendition carries them only when its publisher enables them, and a rendition without them is not HLS-renderable. +Other tracks, including the catalog and data tracks ({{data}}), carry none. -## Catalog Section {#archive-catalog} -The catalog's root `archive` field names every timeline, and any durable recording of their spans: +## Catalog Field {#hls-catalog} +A video or audio rendition opts in with its `hls` field: ~~~ -type ArchiveSchema = { - "timelines": Map, - "timescale": number | undefined, - "replay": string | undefined, - "replayEpoch": string | undefined, - "store": string | undefined, - "version": number | undefined, +type HlsSchema = { + "track": TrackName, + "duration": number, } ~~~ -A consumer that cannot decode the `archive` entry MUST ignore that entry without rejecting the catalog. - -The `timelines` field maps each indexed track to the MoQ track carrying its records and that timeline's fixed nominal record `duration`. -The duration is in `timescale` units and is zero for sparse timelines without a segment cadence. -An untimed track ({{moql}}) has no duration to declare, so its timeline MUST omit `duration`; every timed timeline MUST carry it. -Any track may be indexed, including the catalog track itself and data tracks ({{data}}). -Naming a timeline after its track with a `.timeline.z` suffix is RECOMMENDED; a consumer MUST use the advertised names rather than assuming them. -A live publisher without a store advertises `archive` with the timeline fields (`timelines`, `timescale`) alone. -Every span a timeline advertises is FETCHable; with a store it is also durable. -There is no generation: a client that must tell recordings apart compares `replay` and `store`. - -The `timescale` field is the units per second for the records' `pts` and `duration` values and timeline declarations. -If absent, it defaults to 1000 (milliseconds). -A zero `timescale` is invalid and MUST be refused. - -Each declared timeline duration is a nominal target, not a bound: a publisher MAY overrun it to end at a group boundary. -The value MUST NOT change for the life of that timeline track. -A publisher that does not know the target MUST omit that timeline until its first record closes, then declare that record's duration, or its minimum ({{timeline-cutting}}) when a gap or the end of the content closed the record before a boundary. - -Wall-clock mapping is the catalog root `clock` ({{clock}}), not this section: a consumer derives the wall-clock time of any record as `clock.wall + pts` after converting `pts` into the clock's timescale. - -The `replay` field, if present, is a relative MoQ broadcast path ({{field-broadcast}}) the archive is served back from. -Absent, the timelines live on the catalog's own broadcast. -A wildcard replay path names no generation. - -The `replayEpoch` field, if present, names the publisher instance of `replay`, with the meaning and rules of a rendition's `epoch` ({{field-epoch}}). +The `track` field names the track carrying the rendition's records, on the catalog's broadcast even when the rendition's media lives in another broadcast ({{field-broadcast}}). +Naming it after the media track with a `.hls` suffix is RECOMMENDED; a consumer MUST use the advertised name rather than assume it. -The `store` field, if present, is the object-store URL the recording objects live under, in the layout the moq-archive crate defines. -Authorization for `replay` and `store` is external. +The `duration` field is the nominal segment duration in milliseconds, rounded up. +It is a target, not a bound: a segment ends at a group boundary and MAY overrun it. +It MUST NOT change for the life of the record track. +A publisher that does not know the target MAY omit `hls` until its first segment completes, then declare that segment's duration. -The `version` field is the version of that recording object format. -It is 3 for this specification. -A publisher that exposes a store MUST set it; a publisher that does not MUST omit it. +A consumer that cannot decode a rendition's `hls` field MUST treat it as absent without rejecting the rendition. -A catalog that composes another broadcast's renditions MUST preserve that child's `archive` entry instead of synthesizing one. - -## Track Framing {#timeline-framing} -A timeline track is a sliding window of records. -An unbounded publisher only appends records, while a DVR publisher also removes records from the front as they expire. +A publisher that sets `hls` on several renditions MUST start their segments at the same timestamps: segment `n` of every rendition starts at the same pts. +This is why the field is opt-in. +## Track Framing {#hls-framing} +A record track is a sliding window of records. The first frame of each group is a UTF-8 JSON object containing a checkpoint of the retained window: ~~~ { "offset": number, "start"?: number, - "records": TimelineRecord[], + "records": HlsRecord[], } ~~~ The `offset` is the absolute index of the oldest retained record. `start` is the absolute index of `records[0]` and defaults to `offset` when omitted. A publisher MAY omit a retained prefix from a checkpoint; a consumer that did not receive that prefix reports `offset` through `start` as skipped. -Each subsequent frame is one UTF-8 JSON operation, either `{ "push": TimelineRecord }` to append a record at the next absolute index, or `{ "pop": number }` to remove that many records from the front. +Each subsequent frame is one UTF-8 JSON operation, either `{ "push": HlsRecord }` to append a record at the next absolute index, or `{ "pop": number }` to remove that many records from the front. Indices MUST NOT exceed 2^53 - 1. -A publisher MAY roll groups to bound late-join cost or improve compression. +A publisher MAY roll groups to bound late-join cost. Each new group restates a decodable suffix of the retained window, so group boundaries are an encoding detail and MUST NOT surface as duplicate records to the application. A consumer that missed records reports their absolute index range as skipped before continuing with the retained suffix. +The frames are not compressed. -The frames are DEFLATE-compressed ({{!RFC1951}}) within each group. -The publisher ends each frame's compressed data with an empty sync-flush block (the `0x00 0x00 0xff 0xff` trailer is removed, as in {{?RFC7692}}), so a consumer decompresses frames incrementally from the group's first frame. -The `.z` suffix on the RECOMMENDED track names marks this compression, mirroring the catalog's `catalog.json.z` sibling. - -## Records {#timeline-records} -Each record describes one span of its track: +## Records {#hls-records} +Each record is the start of one segment: ~~~ -type TimelineRecord = { +type HlsRecord = { "sequence": number, - "pts": number | undefined, - "duration": number | undefined, - "start": TimelinePosition, - "end": TimelinePosition, - "keyframe": boolean | undefined, -} - -type TimelinePosition = { "group": number, - "frame": number | undefined, + "pts": number, + "duration": number | undefined, } ~~~ -The `sequence` field is the record's number, consecutive within its timeline and equal to the record's window index. +The `sequence` field is the segment's number, consecutive within the track and equal to the record's window index. +A consumer MUST refuse a record whose `sequence` differs from its index. -The `pts` field is the timestamp of the span's first frame and `duration` its length, both in the timescale of the catalog section. -The next record's `pts` equals `pts + duration` unless content time itself jumped; a consumer SHOULD treat such a jump as a discontinuity. -A record of a timed track MUST carry both fields. -An untimed track ({{moql}}) has no content time, so its records MUST omit both; a consumer that needs content time, such as a derived format ({{timeline-derived}}), MUST refuse an untimed record rather than substitute an arrival or send time. +The `group` field is the group the segment starts with; a segment starts at a group boundary. +The `pts` field is the timestamp of the segment's first frame, in milliseconds. -A position names frame `frame` of group `group`, as used by moq-lite FETCH and SUBSCRIBE; `frame` defaults to 0. -Positions order by group, then frame. -The span holds every frame from `start` (inclusive) to `end` (exclusive). -An `end` at frame 0 of group `g` holds every frame of group `g - 1` and none of `g`, whether or not `g` exists; an `end` at a later frame of `g` holds the frames of `g` before it. -Every group from `start.group` to the last group holding a frame exists and holds at least one frame of the span. -A record starts where the previous one ended, unless the track skipped group sequences between them. +A segment runs to the next record: it holds every group from its own `group` up to the next record's `group`, and lasts until the next record's `pts`. +The `duration` field, in milliseconds, overrides that when the segment does not run to the next record's `pts`. +A zero `duration` marks where content stopped, at a break or the end of the track: that segment holds no media, and what follows it, if anything, is a discontinuity. +A publisher that ends a track cleanly SHOULD push such a record after its last segment, so the last segment is bounded. -The `keyframe` field states whether the span's first frame is a keyframe, i.e. whether a player can join or switch renditions there. -If absent, it defaults to true. +A publisher MUST NOT publish a record before its segment's first group starts, and a segment is complete once the next record arrives. +The newest record is therefore the live edge. A record MUST tolerate and SHOULD preserve unknown fields, like the catalog. -## Cutting {#timeline-cutting} -Where a span ends is publisher policy. -A publisher MUST NOT publish a record until its span is final: every frame between `start` and `end` has been published. -The newest record is therefore the live edge of its track. - -The RECOMMENDED policy is one rule for every track: - -- A record ends at the first group boundary at least a minimum duration past its own start, so short groups (audio) pack into one record and long ones (video) get one each. -- A group still open several declared durations past the record's start is split between frames, so a group that never closes (an append-log such as a `moq-json` stream) is indexed as it grows. -- A skipped group sequence ends the record at its newest known content, not where the next group starts, so a pause is not counted as content. - -A minimum of 4 seconds is RECOMMENDED. -Before an unhinted track declares, a safety ceiling of 10 seconds, or the minimum if longer, is RECOMMENDED. -Sparse data that publishes on its own schedule, such as a catalog, SHOULD use a minimum of zero: each group is then its own record, ended as soon as the group finishes, so its newest group is indexed without waiting for another that may never come. -An untimed track has no duration to measure: each group is its own record, ended as soon as the group finishes or the next group starts, and no group is split between frames. - -A publisher MAY add boundaries of its own, such as following a source's segmentation or cutting audio where video starts a group, so a derived segment needs fewer records. -The final record of an ended track has no successor; its `duration` runs to the newest known content, which a publisher SHOULD carry past the start of the last frame when it knows where that frame ends. +## Derived Formats {#hls-derived} +An edge maps record `n` of each rendition to segment `n` of its playlist directly. +The HLS media sequence and the segment URL derive from the record `sequence`, so every edge numbers a segment alike. +A segment is listed once the next record arrives; its `EXTINF`, its DASH `t` and `d`, and the timing of its last sample all come from its own record and the next. -## Derived Formats {#timeline-derived} -Segmented formats such as HLS and DASH need one segment numbering across renditions, which per-track timelines do not provide on their own. -An edge deriving one chooses the first enabled video rendition by name with a timeline, or the first such enabled audio rendition when there is none, and keeps a video choice while that rendition stays enabled in the catalog with its timeline; an audio choice yields to the first enabled video rendition with a timeline once there is one. -Segment URLs identify the reference and its record `sequence`, and the HLS media sequence is that `sequence`, so every edge numbers a segment alike. -When the reference changes, the edge resumes at the new reference's first record whose `sequence` exceeds the last advertised one and that starts no earlier than the advertised content ends, never changing an advertised segment; until such a record exists, the playlist holds. -A record that skips sequences restarts the playlist with a discontinuity, since a playlist numbers its segments consecutively; a content gap before it is a discontinuity as well. -DASH starts a new Period at a handoff and preserves absolute content timestamps through its presentation time offset. -Another video rendition snaps each boundary to its nearest record that starts a group on a keyframe; a segment with no such record nearby has no content on that rendition (a gap; HLS `EXT-X-GAP`). -Other renditions take the frames whose timestamps fall inside each segment's span. -A publisher wanting such an export SHOULD start video groups at the same timestamps across renditions. +An edge checks each rendition's segment `n` against the same segment of one lead rendition, the first enabled video rendition by name, or the first enabled audio rendition when there is none, kept while it stays enabled. +A segment that does not start within half of the lead's segment of its own start, or that is a stop, has no content on that rendition: the edge lists it as a gap (HLS `EXT-X-GAP`, omitted from the DASH SegmentTimeline) and never serves media for it. +A rendition does not list segment `n` before the lead does. +The segment after a stop, or after records the edge missed, starts a discontinuity. # MPEG-TS Service Information {#mpegts-si} @@ -1208,6 +1146,7 @@ This document has no IANA actions. ## moq-hang-04 {:numbered="false"} +- Replaced the root `archive` section and its per-track timelines with an opt-in, per-rendition `hls` field on video and audio ({{hls}}). Each record is a segment's first `group` and `pts` in milliseconds, with an optional `duration` where zero marks a stop; frame positions, `end`, `keyframe`, `timescale`, `replay`, `replayEpoch`, `store`, and `version` are gone. Renditions that set it align their segments, so an edge maps record `n` to segment `n`. The record track is named `.hls` and is not compressed. - The archive `version` is 3: the recording format stores a track's `priority` signed, -128 through 127 with 0 unset, instead of its moq-lite byte. - A reader refuses a JSON frame in which an object repeats a member name. - Added the root `version` ({{catalog-version}}): the oldest draft that reads the catalog, which a consumer refuses when above its own. A `track` other than the entry's ID or a video `warmup` needs 4. Consumers MUST ignore unknown fields. diff --git a/js/hang/MIGRATE.md b/js/hang/MIGRATE.md index 1382f9c7f38..6232d0feb09 100644 --- a/js/hang/MIGRATE.md +++ b/js/hang/MIGRATE.md @@ -4,11 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Timeline records describe one track +### HLS records replace the root archive -`Timeline.Range` is replaced by `Position { group, frame? }`; a record has inclusive `start` and exclusive `end` positions for its own track instead of a `tracks` map of group ranges. -`Record.segment` is `sequence`, and `pts` / `duration` are absent for untimed records. -`Timeline.DEFAULT_NAME` is replaced by `SUFFIX`; name each media track's timeline separately and advertise it in `archive.timelines`. +`Catalog.ArchiveSchema`, `Catalog.ARCHIVE_VERSION`, the root `archive` field, and the `Timeline` module are gone. +A video or audio rendition carries an optional `hls: { track, duration }` field instead; read its records with `Hls.Consumer.subscribe(broadcast, rendition.hls)`. +Each `Hls.Entry` is `{ sequence, group, pts, duration? }`, with `pts` and `duration` in microseconds; a zero `duration` marks where content stopped. +`Catalog.MOQ_EPOCH_UNIX_MILLIS` is unchanged. ### Container reads are a tagged union @@ -33,16 +34,6 @@ Handle the rejection as fatal; only a reset or evicted group still reads as a ga `version` is the oldest moq-hang draft that reads the catalog, and parsing throws `UnsupportedVersion` for one newer than this reader. Rename an extension field that was called `version`. -### Archive timelines declare a per-track duration (#5715) - -`archive.track` becomes `archive.timelines`, keyed by each media track name. Each value is `{ track, duration }`, naming that media track's timeline and nominal duration, absent when untimed. -Replace the shared `durationMax` with each track's `duration`. The archive version is 3 rather than 1; update publishers and consumers together. - -### Timeline records are optionally timed (#5720) - -`Timeline.Record.pts` and `.duration` are optional, absent on an untimed track. -Handle a record whose `pts` is `undefined`. - ### Catalog ranking names its media kind `Catalog.ranked(renditions)` is `Catalog.Video.ranked(renditions)`. diff --git a/js/hang/src/catalog/archive.ts b/js/hang/src/catalog/archive.ts deleted file mode 100644 index ea80d0a408b..00000000000 --- a/js/hang/src/catalog/archive.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as z from "@zod/mini"; -import { nonzeroU53Schema, u53, u53Schema } from "./integers"; -import { EpochSchema, RelativeBroadcastSchema } from "./path"; - -/** The moq epoch (2020-01-01T00:00:00Z) in Unix-epoch milliseconds. */ -export const MOQ_EPOCH_UNIX_MILLIS = 1_577_836_800_000; - -/** A nonzero Rust `u32`, used by the archive timescale. */ -const timescaleSchema = nonzeroU53Schema.check(z.lte(4_294_967_295)); - -/** - * The recording object format advertised in {@link Archive.version} when a store is present. - */ -export const ARCHIVE_VERSION = 3; - -/** - * Discovers each track's timeline and any durable archive. - * - * A live publisher sets the timeline fields (`timelines`, `timescale`) - * alone, and a recording also names the replay broadcast and object store those spans - * live under. Every advertised span is FETCHable; with a store they are durable. - * Wall-clock mapping lives at the catalog root (`clock`), not here. - */ -export const ArchiveSchema = z.object({ - // Each indexed track's timeline declaration, keyed by the indexed track's name. - timelines: z.record( - z.string(), - z.object({ - track: z.string(), - // The nominal record duration, zero for sparse data, absent for an untimed track. - duration: z.optional(u53Schema), - }), - ), - - // Units per second for every timeline's timestamps. Defaults to milliseconds. - timescale: z._default(timescaleSchema, u53(1000)), - - // The MoQ broadcast the archive is served back from, relative to this catalog, if any. - // Absent when the timeline lives on this broadcast. A wildcard replay path names no - // generation: compare this path and `store` to tell recordings apart. - replay: z.optional(RelativeBroadcastSchema), - - // The publisher instance `replay` names, when known. Absent accepts whichever instance - // resolves first, as for a rendition's `epoch`. - replayEpoch: z.optional(EpochSchema), - - // The object-store URL the recording objects live under, if the publisher exposes one. - store: z.optional(z.url()), - - // The recording object format version, {@link ARCHIVE_VERSION} when this package writes - // objects. Absent when there is no store. - version: z.optional(u53Schema), -}); - -/** The catalog's root archive entry: each track's timeline plus optional replay, store, and version. */ -export type Archive = z.infer; diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index 7ac80d5f04b..4ab198b21e5 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -2,6 +2,7 @@ import * as z from "@zod/mini"; import { compareName, compareNumber } from "./compare"; import { ContainerSchema } from "./container"; import { hexSchema } from "./hex"; +import { HlsFieldSchema } from "./hls"; import { u53Schema } from "./integers"; import { EpochSchema, RelativeBroadcastSchema } from "./path"; @@ -89,6 +90,10 @@ const AudioConfigFields = z.object({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // This rendition's HLS segment records, when the publisher opted in. Without it the rendition + // is not HLS-renderable. A malformed value is absent, so it can never reject the media. + hls: HlsFieldSchema, }); /** diff --git a/js/hang/src/catalog/clock.test.ts b/js/hang/src/catalog/clock.test.ts index 36d3fd60841..b0533a214c4 100644 --- a/js/hang/src/catalog/clock.test.ts +++ b/js/hang/src/catalog/clock.test.ts @@ -1,23 +1,34 @@ import { expect, test } from "bun:test"; -import { ClockSchema, MOQ_EPOCH_UNIX_MILLIS, wallClockTime } from "./index.ts"; +import { ClockSchema, MOQ_EPOCH_UNIX_MILLIS, u53, wallClockTime } from "./index.ts"; import { RootSchema } from "./root.ts"; -// Mirrors rs/hang/fixtures/catalog-clock.json: the canonical new shape, a root clock plus an -// archive without a wall field. A reader maps archive timestamps through the root clock after -// timescale conversion. +// Mirrors rs/hang/fixtures/catalog-clock.json: the canonical shape, a root clock plus a +// per-rendition `hls` field. A reader maps HLS record timestamps (milliseconds) through the root +// clock after timescale conversion. const FIXTURE = { video: { renditions: { - video: { codec: "avc1.64001f", container: { kind: "legacy" } }, + video: { + codec: "avc1.64001f", + codedWidth: 1280, + codedHeight: 720, + container: { kind: "legacy" }, + hls: { track: "video.hls", duration: 2000 }, + }, }, }, audio: { renditions: { - audio: { codec: "opus", container: { kind: "legacy" }, sampleRate: 48000, numberOfChannels: 2 }, + audio: { + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48000, + numberOfChannels: 2, + hls: { track: "audio.hls", duration: 2000 }, + }, }, }, clock: { wall: 1_751_846_400_000_000, timescale: 1_000_000 }, - archive: { timelines: { video: { track: "video.timeline.z", duration: 2000 } }, timescale: 1000 }, }; test("an omitted timescale means microseconds on the wire", () => { @@ -76,14 +87,12 @@ test("wallClockTime refuses bad inputs", () => { expect(() => wallClockTime(clock, Number.MAX_SAFE_INTEGER, 1)).toThrow(); }); -test("the packaged shape maps archive timestamps through the root clock", () => { +test("the packaged shape maps HLS record timestamps through the root clock", () => { const catalog = RootSchema.parse(FIXTURE); if (!catalog.clock) throw new Error("fixture lost its clock"); - if (!catalog.archive) throw new Error("fixture lost its archive"); + const video = catalog.video && "renditions" in catalog.video ? catalog.video.renditions.video : undefined; + expect(video?.hls).toEqual({ track: "video.hls", duration: u53(2000) }); - // Archive PTS 2000 (ms) lands 2s after the wall epoch. - expect(wallClockTime(catalog.clock, 2000, catalog.archive.timescale).getTime()).toBe( - MOQ_EPOCH_UNIX_MILLIS + 1_751_846_402_000, - ); - expect("wall" in catalog.archive).toBe(false); + // Record PTS 2000 (ms) lands 2s after the wall epoch. + expect(wallClockTime(catalog.clock, 2000, 1000).getTime()).toBe(MOQ_EPOCH_UNIX_MILLIS + 1_751_846_402_000); }); diff --git a/js/hang/src/catalog/clock.ts b/js/hang/src/catalog/clock.ts index 06248803211..a3d4d970732 100644 --- a/js/hang/src/catalog/clock.ts +++ b/js/hang/src/catalog/clock.ts @@ -1,7 +1,9 @@ import * as z from "@zod/mini"; -import { MOQ_EPOCH_UNIX_MILLIS } from "./archive"; import { u53Schema } from "./integers"; +/** The moq epoch (2020-01-01T00:00:00Z) in Unix-epoch milliseconds. */ +export const MOQ_EPOCH_UNIX_MILLIS = 1_577_836_800_000; + /** Units per second for a catalog clock. Matches Rust `u32`; zero is refused. */ const clockTimescaleSchema = z.number().check(z.int(), z.positive(), z.lte(4_294_967_295)); @@ -9,11 +11,10 @@ const clockTimescaleSchema = z.number().check(z.int(), z.positive(), z.lte(4_294 * The broadcast's one continuous clock, advertised at the catalog root. * * `wall` is the wall-clock time of PTS zero, in `timescale` units since the moq epoch - * ({@link MOQ_EPOCH_UNIX_MILLIS}, 2020-01-01). Every media track and the archive index refer + * ({@link MOQ_EPOCH_UNIX_MILLIS}, 2020-01-01). Every media track and every HLS record refer * to this one mapping after timescale conversion, so there are no competing wall epochs. The * publisher fixes it once and never overwrites it: a discontinuity marker is a delivery event, - * not a new epoch, and a system-clock adjustment never retimes it. It is independent of the - * `archive` entry: a live-only publisher exposes its clock without creating a segment index. + * not a new epoch, and a system-clock adjustment never retimes it. */ export const ClockSchema = z.object({ // The wall-clock time of PTS zero, in `timescale` units since the moq epoch. Must fit in a diff --git a/js/hang/src/catalog/hls.ts b/js/hang/src/catalog/hls.ts new file mode 100644 index 00000000000..0737a15186d --- /dev/null +++ b/js/hang/src/catalog/hls.ts @@ -0,0 +1,24 @@ +import * as z from "@zod/mini"; +import { u53Schema } from "./integers"; + +/** + * A rendition's HLS segment records, which exist only for HLS compatibility. + * + * Opt-in, and only on video and audio renditions. A publisher that sets it aligns group + * boundaries across the renditions it sets it on, so segment `n` starts at the same pts in every + * one. A rendition without it is not HLS-renderable. See the `Hls` module for the records. + */ +export const HlsSchema = z.object({ + // The track carrying the rendition's segment records, on the catalog's broadcast even when the + // media lives in a sibling. Conventionally the media track's name plus `.hls`. + track: z.string(), + + // The nominal segment duration in whole milliseconds, fixed for the life of the track. + duration: u53Schema, +}); + +/** A rendition's HLS segment records: the record track and nominal segment duration. */ +export type Hls = z.infer; + +/** An `hls` field that decodes as absent when malformed, so it can never reject the media. */ +export const HlsFieldSchema = z.catch(z.optional(HlsSchema), undefined); diff --git a/js/hang/src/catalog/index.ts b/js/hang/src/catalog/index.ts index 9f943c39121..5fe288c50f8 100644 --- a/js/hang/src/catalog/index.ts +++ b/js/hang/src/catalog/index.ts @@ -1,6 +1,6 @@ /** * JSON catalog schema and types describing a broadcast's tracks: audio/video decoder configs for - * WebCodecs, container format, and rendition layout, the `archive` entry and root `clock`, + * WebCodecs, container format, rendition layout and HLS records, the root `clock`, * plus the `json` and `binary` data sections and optional `stats` and `echo` discovery. * * @module @@ -18,7 +18,6 @@ export const Video = video; /** The catalog video section. */ export type Video = video.Video; -export * from "./archive"; export type { AudioConfig } from "./audio"; export { AudioConfigSchema, AudioSchema } from "./audio"; export * from "./binary"; @@ -28,6 +27,7 @@ export * from "./container"; export * from "./echo"; export * from "./format"; export * from "./hex"; +export * from "./hls"; export * from "./integers"; export * from "./json"; export * from "./mode"; diff --git a/js/hang/src/catalog/path.ts b/js/hang/src/catalog/path.ts index c88194dbe84..8785197bc2f 100644 --- a/js/hang/src/catalog/path.ts +++ b/js/hang/src/catalog/path.ts @@ -30,8 +30,8 @@ function isEpoch(text: string): boolean { /** * Zod schema for the publisher instance a catalog reference names: an entry's `epoch` beside its - * `broadcast`, or the archive's `replayEpoch`. A lowercase hyphenated UUIDv7, the same text MoQ - * routes carry; anything else refuses the catalog. + * `broadcast`. A lowercase hyphenated UUIDv7, the same text MoQ routes carry; anything else + * refuses the catalog. */ export const EpochSchema: z.ZodMiniType = z.pipe( z.string().check(z.refine(isEpoch, "expected a lowercase hyphenated UUIDv7")), diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index d81bb28059e..73237758936 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; import * as z from "@zod/mini"; -import { ARCHIVE_VERSION, ArchiveSchema } from "./archive.ts"; import { u53 } from "./integers.ts"; import type { ReferenceEpoch, RelativeBroadcast } from "./path.ts"; import { checkRenditions, DuplicateRendition, encode, RootSchema, UnsupportedVersion, VERSION } from "./root.ts"; @@ -69,7 +68,6 @@ test("every reference-bearing section carries an optional epoch", () => { text: { renditions: { t: { broadcast: "./a", epoch, format: "vtt" } } }, json: { tracks: { j: { broadcast: "./a", epoch, mode: "snapshot" } } }, binary: { tracks: { b: { broadcast: "./a", epoch, mode: "snapshot" } } }, - archive: { timelines: {}, replay: "./a", replayEpoch: epoch }, }); const parsed = RootSchema.parse(catalog(EPOCH)); @@ -80,7 +78,6 @@ test("every reference-bearing section carries an optional epoch", () => { expect(parsed.text?.renditions.t?.epoch).toBe(EPOCH); expect(parsed.json?.tracks.j?.epoch).toBe(EPOCH); expect(parsed.binary?.tracks.b?.epoch).toBe(EPOCH); - expect(parsed.archive?.replayEpoch).toBe(EPOCH); // A malformed or noncanonical epoch refuses the catalog, as in Rust. for (const bad of ["not-a-uuid", EPOCH.toUpperCase(), "01890a5d-ac96-474b-bcce-b302099a8057"]) { @@ -178,73 +175,36 @@ test("clock stays off the wire when absent", () => { expect(JSON.stringify(RootSchema.parse({}))).not.toContain('"clock"'); }); -test("archive round-trips at the root", () => { +test("a rendition's hls field round-trips", () => { + const hls = { track: "video.hls", duration: 2000 }; const parsed = RootSchema.parse({ - archive: { - timelines: { video: { track: "video.timeline.z", duration: 2000 } }, - replay: "./recordings/clip", - store: "https://objects.example/rec/", - version: ARCHIVE_VERSION, - }, - }); - expect(parsed.archive).toMatchObject({ - timelines: { video: { track: "video.timeline.z", duration: 2000 } }, - timescale: 1000, - replay: "recordings/clip", - store: "https://objects.example/rec/", - version: ARCHIVE_VERSION, + video: { renditions: { video: { codec: "avc1.64001f", container: { kind: "legacy" }, hls } } }, }); - expect(JSON.stringify(parsed)).not.toContain('"timeline":'); + if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section"); + expect(parsed.video.renditions.video?.hls).toEqual({ track: hls.track, duration: u53(hls.duration) }); + expect(JSON.stringify(parsed)).toContain('"hls":{"track":"video.hls","duration":2000}'); }); -test("a legacy root timeline is not an archive", () => { - const parsed = RootSchema.parse({ timeline: { track: "timeline.z" } }); - expect(parsed.archive).toBeUndefined(); +test("a malformed hls field leaves the rendition playable", () => { + const config = { codec: "avc1.64001f", container: { kind: "legacy" } }; + for (const hls of [{ track: "video.hls" }, { duration: 2000 }, { track: 42, duration: 2000 }, "video.hls", null]) { + const parsed = RootSchema.parse({ video: { renditions: { video: { ...config, hls } } } }); + if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section"); + expect(parsed.video.renditions.video?.hls).toBeUndefined(); + expect(parsed.video.renditions.video?.codec).toBe(config.codec); + } }); -test("an incompatible archive leaves media playable", () => { +// The root `archive` section is gone: a catalog from an older publisher still parses, with its +// media intact and the entry left as an unknown extension. +test("an old root archive is ignored", () => { const video = { renditions: { video: { codec: "avc1.64001f", container: { kind: "legacy" } } } }; const media = RootSchema.parse({ video }); - for (const archive of [ - { track: "timeline.z", timescale: 1000 }, - { timelines: {}, store: "not a url" }, - { timelines: {}, timescale: 0 }, - { timelines: { video: 42 } }, - { future: "archive" }, - "archive", - [], - null, - ]) { - expect(() => ArchiveSchema.parse(archive)).toThrow(); - const parsed = RootSchema.parse({ video, archive }); - expect(parsed.archive).toBeUndefined(); - expect(parsed.video).toEqual(media.video); - expect(JSON.stringify(parsed)).not.toContain('"archive"'); - } -}); - -test("an incompatible archive does not hide invalid media", () => { - expect(() => - RootSchema.parse({ - archive: { track: "timeline.z" }, - video: { renditions: { video: { codec: 42, container: { kind: "legacy" } } } }, - }), - ).toThrow(); -}); - -// The exact shape Rust's moq-mux publishes for every broadcast, so a native publisher's catalog -// stays playable. -test("a Rust per-track archive parses", () => { const parsed = RootSchema.parse({ - archive: { - timelines: { - "catalog.json": { track: "catalog.json.timeline.z", duration: 0 }, - video0: { track: "video0.timeline.z", duration: 4000 }, - }, - timescale: 1000, - }, + video, + archive: { timelines: { video: { track: "video.timeline.z", duration: 2000 } }, timescale: 1000 }, }); - expect(parsed.archive?.timelines.video0?.track).toBe("video0.timeline.z"); + expect(parsed.video).toEqual(media.video); }); test("an absent or foreign text section parses to empty", () => { diff --git a/js/hang/src/catalog/root.ts b/js/hang/src/catalog/root.ts index 1e6d7b2da67..9a2bccb0270 100644 --- a/js/hang/src/catalog/root.ts +++ b/js/hang/src/catalog/root.ts @@ -2,7 +2,6 @@ import * as Json from "@moq/json"; import type * as Moq from "@moq/net"; import { Path } from "@moq/net"; import * as z from "@zod/mini"; -import { ArchiveSchema } from "./archive"; import { AudioSchema } from "./audio"; import { BinarySchema } from "./binary"; import { ClockSchema } from "./clock"; @@ -46,8 +45,8 @@ const VersionSchema = z.number().check( /** * The root catalog: the base sections every hang broadcast carries. * - * The media sections are `video`, `audio`, and `text`, alongside the broadcast's `archive` - * (the segment index and any durable recording) and its `clock` (the one wall-clock mapping); + * The media sections are `video`, `audio`, and `text`, alongside the broadcast's `clock` (the + * one wall-clock mapping); * `json` and `binary` list application data tracks that aren't media. A section is omitted * when it holds no tracks. `stats` names the publisher's media snapshot track, and `echo` * solicits viewer feedback under a relative broadcast prefix. @@ -66,10 +65,7 @@ export const RootSchema = z.looseObject({ version: z.optional(VersionSchema), video: z.optional(VideoSchema), audio: z.optional(AudioSchema), - // An incompatible archive disables seeking/recording, not the rest of the catalog. - archive: z.catch(z.optional(ArchiveSchema), undefined), - // The broadcast's one continuous clock, if the publisher exposes one. Independent of - // `archive`: a live-only publisher exposes its mapping without creating a segment index. + // The broadcast's one continuous clock, if the publisher exposes one. clock: z.optional(ClockSchema), // Decoded leniently like `text` below: a value without a string `track` or `path` is someone // else's and decodes as absent. See `named`. @@ -85,7 +81,7 @@ export const RootSchema = z.looseObject({ binary: section(BinarySchema, "tracks"), }); -/** The root catalog object: the media and archive sections, the data track sections, plus any app extensions. */ +/** The root catalog object: the media sections, the clock, the data track sections, plus any app extensions. */ export type Root = z.infer; /** diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index 57abf3523e5..e75ccc5ad0a 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -2,6 +2,7 @@ import * as z from "@zod/mini"; import { compareName, compareNumber } from "./compare"; import { ContainerSchema } from "./container"; import { hexSchema } from "./hex"; +import { HlsFieldSchema } from "./hls"; import { u8Schema, u53Schema } from "./integers"; import { EpochSchema, RelativeBroadcastSchema } from "./path"; @@ -194,6 +195,10 @@ export const VideoConfigSchema = z.object({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // This rendition's HLS segment records, when the publisher opted in. Without it the rendition + // is not HLS-renderable. A malformed value is absent, so it can never reject the media. + hls: HlsFieldSchema, }); /** diff --git a/js/hang/src/hls.test.ts b/js/hang/src/hls.test.ts new file mode 100644 index 00000000000..62c31cf74a2 --- /dev/null +++ b/js/hang/src/hls.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import type { Micro } from "@moq/clock"; +import * as Json from "@moq/json"; +import { Broadcast, Time } from "@moq/net"; +import { u53 } from "./catalog"; +import { Consumer, type Record } from "./hls.ts"; + +const us = (ms: number): Micro => (ms * 1000) as Micro; + +const hls = { track: "video.hls", duration: u53(2000) }; + +function records() { + const broadcast = new Broadcast.Producer(); + const track = broadcast.createTrack("video.hls", { timescale: Time.Timescale.MILLI }); + const producer = new Json.Window.Producer({ track }); + const consumer = Consumer.subscribe(broadcast.consume(), hls); + const push = (value: Record) => producer.push({ value, at: Time.Timestamp.now() }); + const close = () => { + producer.finish(); + consumer.close(); + broadcast.close(); + }; + return { producer, consumer, push, close }; +} + +test("the consumer yields records in microseconds, a stop included", async () => { + const { producer, consumer, push, close } = records(); + push({ sequence: 0, group: 4, pts: 250 }); + push({ sequence: 1, group: 6, pts: 2250, duration: 0 }); + expect(await consumer.next()).toEqual({ push: { index: 0, entry: { sequence: 0, group: 4, pts: us(250) } } }); + expect(await consumer.next()).toEqual({ + push: { index: 1, entry: { sequence: 1, group: 6, pts: us(2250), duration: us(0) } }, + }); + producer.pop(1, Time.Timestamp.now()); + expect(await consumer.next()).toEqual({ pop: { start: 0, end: 1 } }); + close(); +}); + +test("a record numbered unlike its window index is refused", async () => { + const { consumer, push, close } = records(); + push({ sequence: 5, group: 0, pts: 0 }); + await expect(consumer.next()).rejects.toThrow("differs from its index"); + close(); +}); + +test("a record without its group is refused", async () => { + const { consumer, push, close } = records(); + push({ sequence: 0, pts: 0 } as Record); + await expect(consumer.next()).rejects.toThrow(); + close(); +}); diff --git a/js/hang/src/hls.ts b/js/hang/src/hls.ts new file mode 100644 index 00000000000..9551f8fdc1a --- /dev/null +++ b/js/hang/src/hls.ts @@ -0,0 +1,101 @@ +/** + * HLS segment records: where each of a rendition's HLS segments starts. + * + * A rendition opts in with its catalog `hls` field, which names a track carrying one + * {@link Record} per segment. A segment starts at a group boundary, so a record is the segment's + * first group and pts. It runs to the next record: it holds every group up to the next record's + * `group` and lasts until the next record's `pts`, unless it carries an explicit `duration`. A + * zero `duration` marks where content stopped: that segment holds no media, and what follows it + * is a discontinuity. + * + * Every `pts` and `duration` is in milliseconds. The track is an uncompressed `@moq/json` window. + * + * @module + */ + +import type { Micro } from "@moq/clock"; +import * as Json from "@moq/json"; +import type * as Moq from "@moq/net"; +import * as z from "@zod/mini"; +import type * as Catalog from "./catalog"; +import { u53Schema } from "./catalog"; + +/** The conventional suffix appended to a media track's name to name its HLS record track. */ +export const SUFFIX = ".hls"; + +/** One HLS segment: its first group and pts, in milliseconds. */ +export interface Record { + /** The segment's number, consecutive within the track and equal to the record's window index. */ + sequence: number; + /** The group the segment starts with. */ + group: number; + /** The pts of the segment's first frame. */ + pts: number; + /** The segment's duration, when it does not run to the next record's pts. Zero marks a stop. */ + duration?: number; +} + +const RecordSchema = z.looseObject({ + sequence: u53Schema, + group: u53Schema, + pts: u53Schema, + duration: z.optional(u53Schema), +}); + +/** A record with its times converted to microseconds. */ +export type Entry = Omit & { pts: Micro; duration?: Micro }; + +/** One change to the visible record window. */ +export type Event = { push: { index: number; entry: Entry } } | { pop: Json.Window.Span } | { skip: Json.Window.Span }; + +/** Reads one rendition's HLS records. */ +export class Consumer implements AsyncIterable { + readonly #track: Moq.Track.Subscriber; + readonly #window: Json.Window.Consumer; + + private constructor(track: Moq.Track.Subscriber) { + this.#track = track; + this.#window = new Json.Window.Consumer({ track }); + } + + /** Subscribe to the records a rendition's `hls` field names. */ + static subscribe(broadcast: Moq.Broadcast.Consumer, hls: Catalog.Hls): Consumer { + return new Consumer(broadcast.track(hls.track).subscribe()); + } + + /** Get the next event, or `undefined` when the track ends. Throws on a malformed record. */ + async next(): Promise { + // Each record carries its own pts, so the frame timestamp adds nothing. + const event = (await this.#window.next())?.value; + if (event === undefined) return undefined; + if ("push" in event) { + const { index } = event.push; + const value = RecordSchema.parse(event.push.value); + if (value.sequence !== index) throw new Error("HLS record sequence differs from its index"); + const entry: Entry = { sequence: value.sequence, group: value.group, pts: micros(value.pts) }; + if (value.duration !== undefined) entry.duration = micros(value.duration); + return { push: { index, entry } }; + } + return event; + } + + /** Iterate events until the track ends. */ + async *[Symbol.asyncIterator](): AsyncIterator { + for (;;) { + const event = await this.next(); + if (event === undefined) return; + yield event; + } + } + + /** Close the record subscription. */ + close(): void { + this.#track.close(); + } +} + +function micros(millis: number): Micro { + const value = millis * 1000; + if (!Number.isSafeInteger(value)) throw new Error("HLS record timestamp overflow"); + return value as Micro; +} diff --git a/js/hang/src/index.ts b/js/hang/src/index.ts index 2f9321a5b5f..e614f079cad 100644 --- a/js/hang/src/index.ts +++ b/js/hang/src/index.ts @@ -17,8 +17,7 @@ export * as Container from "./container"; export * as Echo from "./echo"; /** Client health graded from two consecutive media reports. */ export * as Health from "./health"; - +/** A rendition's HLS segment records, each segment's first group and pts. */ +export * as Hls from "./hls"; /** Publisher media and transport snapshot schemas. */ export * as Stats from "./stats"; -/** A track's timeline, mapping its content time to the groups and frames that carry it. */ -export * as Timeline from "./timeline"; diff --git a/js/hang/src/timeline.test.ts b/js/hang/src/timeline.test.ts deleted file mode 100644 index e1bf21846fb..00000000000 --- a/js/hang/src/timeline.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { expect, test } from "bun:test"; -import type { Micro } from "@moq/clock"; -import * as Json from "@moq/json"; -import { Broadcast, Time } from "@moq/net"; -import { u53 } from "./catalog"; -import { Consumer, type Record } from "./timeline.ts"; - -const us = (ms: number): Micro => (ms * 1000) as Micro; - -const archive = (timescale: number) => ({ - timelines: { video: { track: "video.timeline.z", duration: u53(4000) } }, - timescale: u53(timescale), -}); - -test("timeline consumer yields converted push and pop events", async () => { - const broadcast = new Broadcast.Producer(); - const track = broadcast.createTrack("video.timeline.z", { timescale: Time.Timescale.MILLI }); - const producer = new Json.Window.Producer({ track, compression: true }); - const consumer = Consumer.subscribe(broadcast.consume(), archive(1000), "video"); - producer.push({ - value: { sequence: 0, pts: 250, duration: 1250, start: { group: 1 }, end: { group: 2, frame: 3 } }, - at: Time.Timestamp.now(), - }); - expect(await consumer.next()).toEqual({ - push: { - index: 0, - entry: { sequence: 0, pts: us(250), duration: us(1250), start: { group: 1 }, end: { group: 2, frame: 3 } }, - }, - }); - producer.pop(1, Time.Timestamp.now()); - expect(await consumer.next()).toEqual({ pop: { start: 0, end: 1 } }); - producer.finish(); - consumer.close(); - broadcast.close(); -}); - -test("timeline consumer floors fractional microseconds without floating point drift", async () => { - const broadcast = new Broadcast.Producer(); - const track = broadcast.createTrack("video.timeline.z", { timescale: Time.Timescale.MILLI }); - const producer = new Json.Window.Producer({ track, compression: true }); - const consumer = Consumer.subscribe(broadcast.consume(), archive(3), "video"); - producer.push({ - value: { sequence: 0, pts: 1, duration: 2, start: { group: 0 }, end: { group: 1 } }, - at: Time.Timestamp.now(), - }); - expect(await consumer.next()).toEqual({ - push: { - index: 0, - entry: { - sequence: 0, - pts: 333_333 as Micro, - duration: 666_666 as Micro, - start: { group: 0 }, - end: { group: 1 }, - }, - }, - }); - producer.finish(); - consumer.close(); - broadcast.close(); -}); - -test("a record numbered unlike its window index is refused", async () => { - const broadcast = new Broadcast.Producer(); - const track = broadcast.createTrack("video.timeline.z", { timescale: Time.Timescale.MILLI }); - const producer = new Json.Window.Producer({ track, compression: true }); - const consumer = Consumer.subscribe(broadcast.consume(), archive(1000), "video"); - producer.push({ - value: { sequence: 5, pts: 0, duration: 1000, start: { group: 0 }, end: { group: 1 } }, - at: Time.Timestamp.now(), - }); - await expect(consumer.next()).rejects.toThrow(); - producer.finish(); - consumer.close(); - broadcast.close(); -}); - -test("an untimed record is refused", async () => { - const broadcast = new Broadcast.Producer(); - const track = broadcast.createTrack("video.timeline.z", { timescale: Time.Timescale.MILLI }); - const producer = new Json.Window.Producer({ track, compression: true }); - const consumer = Consumer.subscribe(broadcast.consume(), archive(1000), "video"); - producer.push({ value: { sequence: 0, start: { group: 0 }, end: { group: 1 } }, at: Time.Timestamp.now() }); - await expect(consumer.next()).rejects.toThrow("timeline record 0 is untimed"); - producer.finish(); - consumer.close(); - broadcast.close(); -}); - -test("a track without a timeline is refused", () => { - const broadcast = new Broadcast.Producer(); - expect(() => Consumer.subscribe(broadcast.consume(), archive(1000), "audio")).toThrow(); - broadcast.close(); -}); diff --git a/js/hang/src/timeline.ts b/js/hang/src/timeline.ts deleted file mode 100644 index e9e6e1c4acf..00000000000 --- a/js/hang/src/timeline.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Reading a track's timeline: a track carrying one record per span of another track, mapping - * its content time to the group and frame positions that carry it. A consumer can seek (or - * build an HLS/DASH playlist) without downloading the media. - * - * Every track has its own timeline, named in the catalog's root {@link Catalog.Archive} entry. - * A span usually holds whole groups; a group that outlives the publisher's maximum span is - * split by frame. Spans are contiguous in position unless the track skipped group sequences. - * - * @module - */ - -import type { Micro } from "@moq/clock"; -import * as Json from "@moq/json"; -import type * as Moq from "@moq/net"; -import * as z from "@zod/mini"; -import type * as Catalog from "./catalog"; -import { u53Schema } from "./catalog"; - -/** A frame position within a track: frame `frame` of group `group`. */ -export interface Position { - group: number; - /** Omitted when zero, so a position at a group start reads as just the group. */ - frame?: number; -} - -/** - * One timeline record: a span of one track holding every frame from `start` (inclusive) to - * `end` (exclusive). `pts` and `duration` are in the archive's timescale, and both are absent on - * an untimed track, whose records each hold one group. An `end` at frame zero of group `g` holds - * every frame of group `g - 1` and nothing of `g`. - */ -export interface Record { - /** The record's number, consecutive within its track's timeline. */ - sequence: number; - pts?: number; - duration?: number; - start: Position; - end: Position; - /** Whether the span starts with a keyframe. Omitted when true (the default). */ - keyframe?: boolean; -} - -const PositionSchema = z.object({ group: u53Schema, frame: z.optional(u53Schema) }); - -const RecordSchema = z.looseObject({ - sequence: u53Schema, - pts: z.optional(u53Schema), - duration: z.optional(u53Schema), - start: PositionSchema, - end: PositionSchema, - keyframe: z.optional(z.boolean()), -}); - -/** The conventional suffix appended to a track name to name its timeline track. */ -export const SUFFIX = ".timeline.z"; - -/** A timed record with timestamps converted to microseconds. */ -export type Entry = Omit & { pts: Micro; duration: Micro }; - -/** One change to the visible timeline window. */ -export type Event = { push: { index: number; entry: Entry } } | { pop: Json.Window.Span } | { skip: Json.Window.Span }; - -/** Reads one track's timeline, as advertised by a catalog archive entry. */ -export class Consumer implements AsyncIterable { - readonly #track: Moq.Track.Subscriber; - readonly #window: Json.Window.Consumer; - readonly #timescale: number; - - private constructor(track: Moq.Track.Subscriber, archive: Catalog.Archive) { - this.#track = track; - if (!Number.isSafeInteger(archive.timescale) || archive.timescale <= 0) { - throw new Error("invalid timeline timescale"); - } - this.#timescale = archive.timescale; - this.#window = new Json.Window.Consumer({ track, compression: true }); - } - - /** Subscribe to `track`'s timeline as named by the catalog's archive entry. Throws if it has none. */ - static subscribe(broadcast: Moq.Broadcast.Consumer, archive: Catalog.Archive, track: string): Consumer { - const timeline = archive.timelines[track]; - if (timeline === undefined) throw new Error(`no timeline for track ${track}`); - return new Consumer(broadcast.track(timeline.track).subscribe(), archive); - } - - /** Get the next timeline event, or `undefined` when the track ends. Throws on an untimed record. */ - async next(): Promise { - // Each record carries its own pts, so the frame timestamp adds nothing. - const event = (await this.#window.next())?.value; - if (event === undefined) return undefined; - if ("push" in event) { - const { index } = event.push; - const value = RecordSchema.parse(event.push.value); - if (value.sequence !== index) throw new Error("timeline record sequence differs from its index"); - if (value.pts === undefined || value.duration === undefined) { - throw new Error(`timeline record ${value.sequence} is untimed`); - } - const pts = this.#micros(value.pts); - const duration = this.#micros(value.duration); - return { push: { index, entry: { ...value, pts, duration } } }; - } - return event; - } - - #micros(units: number): Micro { - const value = (BigInt(units) * 1_000_000n) / BigInt(this.#timescale); - if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("timeline timestamp overflow"); - return Number(value) as Micro; - } - - /** Iterate timeline events until the track ends. */ - async *[Symbol.asyncIterator](): AsyncIterator { - for (;;) { - const event = await this.next(); - if (event === undefined) return; - yield event; - } - } - - /** Close the timeline subscription. */ - close(): void { - this.#track.close(); - } -} diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 36d7c432f85..34858644452 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -118,10 +118,9 @@ test("catalog producer advertises the page clock from the first snapshot", async catalog.serve(track, effect); const consumer = new Json.Snapshot.Consumer({ track: track.subscribe() }); - // Before any rendition: a live-only publisher exposes its clock without an archive. + // Before any rendition: a live-only publisher exposes its clock. const first = Catalog.RootSchema.parse((await consumer.latest())?.value); if (!first.clock) throw new Error("expected a root clock"); - expect(first.archive).toBeUndefined(); expect(first.clock.timescale).toBe(1_000_000); // A timestamp stamped the way capture does (the clock's instant in microseconds) maps onto the diff --git a/quest/m1/README.md b/quest/m1/README.md index 9ad0bfc1971..a5ae96d301e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -166,6 +166,8 @@ Catalog HLS field. - [Calibrate the paired nightly benchmarks](/quest/m1/bench-ci.md) - A/A and deliberately slowed Nightly runs set the paired comparison's threshold, so its regression alerts can be trusted - [Refuse unknown MOQ_* environment variables](/quest/m1/env-unknown.md) - every binary exits at startup naming a `MOQ_*` variable it does not read - [HLS export tests over a session](/quest/m1/hls-session-tests.md) - an integration suite records and serves through a real moq-net session, so a publisher disconnect, finish, or takeover ends the cursor and playlists as specified +- [HLS follower numbering](/quest/m1/hls-follower-numbering.md) - a late or rebuilt rendition numbers its HLS records from the pacer, so its segments line up +- [HLS opt-in for gateways](/quest/m1/hls-opt-in-gateways.md) - SRT, RTMP, WebRTC ingest and the bindings can publish HLS records - [HLS recorder in the CLI](/quest/m1/hls-record-cli.md) - `export hls --output ` records playlists and segments to a directory - [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - nightly ffprobe checks that advertised timing, sync flags, and bytes match the media served - [Unbounded groups](/quest/m1/unbounded-groups/README.md) - groups and frames lose their size caps; the cache sheds parts of a group under pressure and refills the holes in place diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 889e9fb6107..e0c307149f7 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -44,8 +44,7 @@ Rules every child follows: Already gated elsewhere, so not a child: the binary-to-flate fold is followed by flate's removal ([compress migrate](/quest/m1/compress/migrate.md)), in m1, so the cut waits for it. hang's -`Archive::replay_epoch` goes with the root `archive` section in -[catalog HLS](/quest/m1/archive/catalog-hls.md). +`Archive::replay_epoch` went with the root `archive` section in catalog HLS. Rejected in planning: diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 5f64b877d27..bb7ef234822 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -71,7 +71,7 @@ archive (per-track `.timeline.z` records named by the catalog's root - **`hls` is for HLS only, and opt-in.** A per-rendition catalog field names a track whose (first group, pts) records mark each HLS segment, and a publisher enabling it aligns segments across renditions - ([Catalog HLS field](/quest/m1/archive/catalog-hls.md)). The root `archive` + (Catalog HLS field, landed). The root `archive` section is deleted everywhere. Rejected: always-on timelines. Protected broadcasts stay out of scope. Recording keying, retention policy, @@ -84,7 +84,6 @@ from a host. Each child fixes the stale docs it touches. ## Required -- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the root `archive` section is gone; an opt-in per-rendition `hls` field marks each aligned HLS segment's first group and pts and declares its nominal duration - [Fixed HLS target duration](/quest/m1/archive/hls-target.md) - one `EXT-X-TARGETDURATION` for the run, from the declared `hls` duration; an overrun is listed with a warning ## Related diff --git a/quest/m1/archive/catalog-hls.md b/quest/m1/archive/catalog-hls.md deleted file mode 100644 index a29d56fe9c3..00000000000 --- a/quest/m1/archive/catalog-hls.md +++ /dev/null @@ -1,90 +0,0 @@ -# [L] Catalog HLS field - -## Goal - -The hang catalog has no root `archive` section: `timelines`, `timescale`, -`replay`, `store`, and `version` are gone from `rs/hang`, `js/hang`, -`drafts/draft-lcurley-moq-hang.md`, `moq-mux`, and `moq-hls`. What remains of -timelines is an opt-in, per-rendition `hls` field that exists only for HLS -compatibility: it names a track whose records give each HLS segment's first -group and pts, and declares the nominal segment duration. A rendition without -it is not HLS-renderable. - -## Plan - -Decided by the maintainer on 2026-10-17: - -- No publisher-owned archive pointer remains; archives are found by the host - that stores them ([epic](/quest/m1/archive/README.md)). -- **Renamed to `hls`** (catalog field, track naming, types, wording), since it - exists only for HLS compatibility, not archiving or seeking; seeking uses - `Time(pts)` ranges. The track naming convention becomes `.hls`; - consumers still use the advertised name. Decided 2026-10-20 (maintainer): - no `.z` suffix. This quest publishes it uncompressed, and - [one compressed track per name](/quest/m1/compress/migrate.md), which - lands after it, sets `compress: true` on the same name. -- **Opt-in.** `moq-mux` publishes it only when the application enables it, - and nothing publishes it by default. Rejected: always-on timelines for every - track, which cost every publisher for a feature most never use. -- **Aligned segments are the opt-in contract.** A publisher that enables `hls` - aligns group boundaries across the renditions it lists, so segment n starts - at the same pts in every rendition. This is part of why it is opt-in. An edge - maps segment n to record n of each rendition directly; the reference - rendition resolving others against their own timelines, keyframe snapping, - and `EXT-X-GAP` for a rendition with no nearby boundary all go away - (`rs/moq-hls/src/export/spans.rs`). An edge that finds renditions misaligned - refuses that segment loudly rather than repairing it. -- **Per rendition, video and audio only.** The catalog track, data tracks, and - untimed tracks get no `hls` field. Each declares its nominal segment - duration, which is the landed per-timeline `duration` (#5715) moved onto the - rendition; it never changes for the life of the track. -- **Records are (first group, pts).** Segments start at group boundaries, so - mid-group splits, frame positions, and record `end`s go away. `EXTINF` - derives from the next record's pts; an ended track's last record carries an - explicit duration. Rejected: group only, which would make the edge read media - to time a segment. -- The archive does not use these records, and DVR seeks with `Time(pts)` - ranges. - -Decided with the maintainer on 2026-10-24, from the moq-hls audit: this quest -is the export's segment-finalization redesign, with no separate quest. The -audit traced several bugs to a segment's frames, timing, and muxing inputs -being decided in different places, some again on each request: - -- **Each rendition times its segments from its own records.** `EXTINF`, DASH - `t`/`d`, the fragment's `tfdt`, and the recorder's duration all come from - that rendition's record n and n+1. Today a keyframe-snapped rendition can - serve up to a second more or less video than the reference row it - advertises. -- **No catalog estimate changes media bytes.** A segment is served only once - complete, so its last frame lasts until the next record's pts, or the ended - track's explicit duration. Today `Rendition::muxer` is rebuilt per request - from the live framerate, so the same URL can return a different `trun` after - an estimate update. Rejected: snapshotting the framerate per instance, which - is still a guess and wrong for VFR. -- **Slices start at group boundaries.** With mid-group records gone, no - segment starts on an inter frame, so the fMP4 muxer's first-frame keyframe - flag stays true. A group that does not open on an IDR is - [export sync flags](/quest/m2/intra-refresh/export-sync-flags.md). -- **`Spans` goes, and with it its unbounded growth** while a stalled - reference stops trimming a sibling's records. - -Guidance: - -- Fold in the old README's fix: `moq-mux`'s consumer refuses a record whose - `sequence` differs from its window index - ([review](https://github.com/moq-dev/moq/pull/4280#pullrequestreview-5332725290)). -- `js/hang` updates its schema and consumer; the JS producer and the - `@moq/publish` opt-in are [JS HLS field](/quest/m2/js-hls.md). -- Update the draft's Timeline section (renamed for HLS) and changelog, - `doc/concept/hang.md`'s Archive bullet, and `doc/bin/hls.md` (how to opt in, - and the alignment it requires); run `just drafts check`. - -Test that a catalog with the old root `archive` entry still parses with the -entry ignored, that nothing publishes `hls` unless enabled, that aligned -renditions list matching segments with a declared target, that a misaligned -segment is refused, and that a rendition without `hls` gets no playlist. - -Public API: `hang::catalog::Archive` is deleted, the rendition types gain an -`hls` field in Rust and JS, and `moq-mux` gains the opt-in. Wire: the hang -catalog format. diff --git a/quest/m1/archive/hls-target.md b/quest/m1/archive/hls-target.md index d0328577752..aee8e071b6d 100644 --- a/quest/m1/archive/hls-target.md +++ b/quest/m1/archive/hls-target.md @@ -18,7 +18,7 @@ The 09-28 audit reversed #4280's decision 4 (the observed maximum), and the 09-29 planning settled the rest: - The target is `ceil` of the declared `hls` duration from - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) (retargeted + Catalog HLS field (retargeted 2026-10-17 from the root `archive` map). Until it is declared, the rendition has no playlist yet. - A segment whose rounded `EXTINF` exceeds the target (a GOP that overran the @@ -42,6 +42,3 @@ playlist URI, not per `Rendition`: `renditions::Producer::sync` replaces the `Rendition` on a catalog reconfigure, so test that a reconfigure mid-run keeps the original target. -## Required - -- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the opt-in `hls` field whose declared duration sets the target diff --git a/quest/m1/compress/migrate.md b/quest/m1/compress/migrate.md index 7b42ff2be60..3e20bba63fb 100644 --- a/quest/m1/compress/migrate.md +++ b/quest/m1/compress/migrate.md @@ -15,8 +15,8 @@ namespace are deleted. See the [epic](/quest/m1/compress/README.md) for the decisions. Decided 2026-10-20 (maintainer): the timeline is `.hls` with -`compress: true`, never `.hls.z`. [Catalog HLS field](/quest/m1/archive/catalog-hls.md) -renames it and publishes it uncompressed, and the archive consumer replaced +`compress: true`, never `.hls.z`. Catalog HLS field +renamed it and publishes it uncompressed, and the archive consumer replaced the CLI's archive reader, so this quest lands after it and only sets the flag. - Delete the `.z` names outright, with no transition release: hangz is opt-in @@ -57,6 +57,3 @@ the CLI's archive reader, so this quest lands after it and only sets the flag. Public API: removals listed above. Wire: the hang catalog loses `compression` and the `.z` tracks, in its draft too; moq-net none. -## Required - -- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - renames the timeline to `.hls`, which this compresses diff --git a/quest/m1/hls-conformance.md b/quest/m1/hls-conformance.md index 65454819c44..478f6635b72 100644 --- a/quest/m1/hls-conformance.md +++ b/quest/m1/hls-conformance.md @@ -16,7 +16,7 @@ From the 2026-10-24 moq-hls audit, which found no player-level HLS or DASH coverage. Decided with the maintainer on 2026-10-24: - ffprobe conformance lands in m1, so it validates the - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) redesign and the + Catalog HLS field redesign and the [fixed target duration](/quest/m1/archive/hls-target.md) before the cut. A browser player test follows in m2 ([HLS and DASH browser playback](/quest/m2/hls-player-test.md)). @@ -29,7 +29,6 @@ Public API: none. Wire: none. ## Required -- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - aligned per-rendition records that this checks the manifests against - [Fixed HLS target duration](/quest/m1/archive/hls-target.md) - the run-wide target this asserts ## Related diff --git a/quest/m1/hls-follower-numbering.md b/quest/m1/hls-follower-numbering.md new file mode 100644 index 00000000000..4f5ecda492d --- /dev/null +++ b/quest/m1/hls-follower-numbering.md @@ -0,0 +1,26 @@ +# [M] HLS records number late renditions from the pacer + +## Goal + +A rendition enrolled for HLS records after its siblings, or re-enrolled after +a reconfigure, numbers its records so that record `n` starts at the same pts as +segment `n` of the renditions already running. Today `moq-mux`'s `hls::Tracks` +gives a new follower sequence 0 (a re-enrolled name continues its own count), +while an edge maps record `n` to segment `n` and needs the window index to +equal the sequence, so every segment of a late rendition is listed as an +`EXT-X-GAP` until the counts happen to meet. + +## Plan + +- Start a follower's window at the pacer's next sequence (a window offset), + so its first record carries the pacer's number for that boundary. +- Keep the record track's window index equal to `sequence`, which the + consumers refuse otherwise. +- Test a rendition added mid-broadcast and one rebuilt by a reconfigure: both + list aligned segments, without gaps, from their first record. + +Public API: none expected. Wire: none. + +## Related + +- [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - checks the manifests this numbering feeds diff --git a/quest/m1/hls-opt-in-gateways.md b/quest/m1/hls-opt-in-gateways.md new file mode 100644 index 00000000000..af8051d214f --- /dev/null +++ b/quest/m1/hls-opt-in-gateways.md @@ -0,0 +1,24 @@ +# [S] HLS records opt-in for gateways and bindings + +## Goal + +Every publisher that builds its own catalog can opt in to HLS records, so +`export hls` can serve its broadcasts: the SRT, RTMP, and WebRTC ingest +gateways (`moq-srt`, `moq-rtmp`, `moq-rtc`) and the native bindings +(`moq-ffi` and its wrappers, `moq-c`, `moq-gst`). Today only the CLI's own +imports take `--hls`, and `import srt`, `import rtmp`, and `import rtc` refuse +it. + +## Plan + +- Each gateway's publish builder gains the opt-in beside `with_max_age`, and + `moq import --hls` passes it through instead of refusing. +- The bindings expose the opt-in where they build a catalog config, mirrored + across languages. +- Update `doc/bin/cli.md`, `doc/bin/hls.md`, and the binding docs. + +Public API: one opt-in per gateway builder and binding. Wire: none. + +## Related + +- [JS HLS field](/quest/m2/js-hls.md) - the `@moq/publish` opt-in diff --git a/quest/m1/hls-record-cli.md b/quest/m1/hls-record-cli.md index f1e0a86509e..92b7fa74758 100644 --- a/quest/m1/hls-record-cli.md +++ b/quest/m1/hls-record-cli.md @@ -29,9 +29,9 @@ listing only current instances, losing the recording's earlier part. repeats segments the old cursor wrote. Drain the old cursor first: it owns every segment it wrote. Discard replacement segments numbered at or below the old cursor's last, then write the replacement's init and mark the transition. -- Discarding by number is sound: every rendition's rows fan out from one reference - timeline, so a number names the same content span on each, and numbers only - increase within a broadcaster run, including across reference handoffs. +- Discarding by number is sound: every rendition's segment `n` is its own HLS + record `n`, aligned by the publisher, so a number names the same content span + on each, and numbers only increase within a broadcaster run. - A rewound timeline or a new generation can reuse lower numbers, but it also advances `Segment::discontinuity`. A repeated number with a newer discontinuity is a source reset and fails the recording instead of being discarded. diff --git a/quest/m2/js-hls.md b/quest/m2/js-hls.md index 405d4db516e..92cc0236d47 100644 --- a/quest/m2/js-hls.md +++ b/quest/m2/js-hls.md @@ -10,7 +10,7 @@ Nothing publishes it by default. ## Plan Retargeted 2026-10-17: the field exists only for HLS compatibility and is -opt-in ([Catalog HLS field](/quest/m1/archive/catalog-hls.md)). The archive +opt-in (Catalog HLS field, landed). The archive does not use it, so the old idle `flush()` and catalog and data track timelines are dropped from this port. @@ -23,6 +23,3 @@ The read side follows Catalog HLS field. Port the Rust producer (`rs/moq-mux`) to `js/hang`, wire the opt-in through `@moq/publish`, and check its records against Rust output in the interop suite. -## Required - -- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the field shape this ports diff --git a/rs/hang/MIGRATE.md b/rs/hang/MIGRATE.md index 1cf81edf5c8..68b7ddf1974 100644 --- a/rs/hang/MIGRATE.md +++ b/rs/hang/MIGRATE.md @@ -4,17 +4,20 @@ How to update your code for each breaking change, newest first. ## Unreleased +### HLS records replace the root archive + +`catalog::Archive`, `catalog::Timeline`, `Catalog::archive`, and the `timeline` module are gone. +A video or audio rendition opts in to HLS with its own `hls: Option` field, naming its record track and nominal segment duration. +Records are `hls::Record { sequence, group, pts, duration }` in milliseconds on a `.hls` track (`hls::default_name`, `hls::SUFFIX`). +Build one with `Record::new(sequence, group, pts)`, adding `with_duration(0)` where content stops. +A catalog with an old root `archive` still parses, with the entry ignored. +`catalog::MOQ_EPOCH_UNIX_MILLIS` moves to the `clock` module, re-exported at the same path. + ### `Catalog` parses through `FromStr` (#6471) The inherent `Catalog::from_str` is replaced by `impl FromStr for Catalog`, with the same `Error`. Call `json.parse::()`, or bring `std::str::FromStr` into scope to keep calling `Catalog::from_str`. -### Timeline records describe one track - -`timeline::Range` is replaced by `Position { group, frame }`. A `Record` has `start` and exclusive `end` positions for its own track instead of a `tracks` map of inclusive group ranges. -`Record::segment` is `sequence`, and `Record::new` takes the start and end positions. `pts` and `duration` are optional for untimed records. -`timeline::DEFAULT_NAME` is replaced by `SUFFIX`; construct a per-track name with `timeline::default_name(track)`. - ### Priorities are signed (#6142) `catalog::Priorities` fields and `container::track_info`'s priority are `i8`, higher first, with 0 unset. @@ -22,7 +25,7 @@ Call `json.parse::()`, or bring `std::str::FromStr` into scope to keep ### `catalog::Reference` names its epoch (#5699) -Every rendition and data track config gains an optional `epoch` beside `broadcast`, and `Archive` gains `replay_epoch` beside `replay`. +Every rendition and data track config gains an optional `epoch` beside `broadcast`. `Reference` has a new required `epoch()`: an implementor outside this crate returns the entry's epoch, or `None` when it names none. ### A lost catalog track is `Error::Moq` (#6114) @@ -44,18 +47,6 @@ It can be absent until the first measurement, change when the source's rate does `version` is reserved at the catalog root: decoding refuses a catalog newer than this crate reads with `Error::UnsupportedVersion`, and serializing fails if the extension `E` has its own `version` member. Rename that member, such as to `app_version`. -### Archive timelines declare a per-track duration (#5715) - -`Archive::track` becomes `Archive::timelines`, keyed by each media track name. Each value is `Timeline { track, duration }`, naming that media track's timeline and nominal duration, `None` when untimed. -Replace `Archive::new(track)` with `Archive::new()` and insert each `Timeline::new(track, duration)`; `duration_max` is removed. -The archive version is 3 rather than 1. Older catalog shapes are not read as this archive, so update publishers and consumers together. - -### Timeline records are optionally timed (#5720) - -`timeline::Record::pts` and `duration` are `Option`, absent on an untimed track. -Build an untimed record with `Record::untimed(sequence, start, end)`. -Handle `None` where you read `record.pts` or `record.duration`. - ### One `enabled` flag replaces `stalled` (#4915) `VideoConfig::stalled: Option` is `enabled: bool`, with the meaning inverted, and `AudioConfig` gains `enabled`. diff --git a/rs/hang/README.md b/rs/hang/README.md index ea5dce692af..0c55f7b98c4 100644 --- a/rs/hang/README.md +++ b/rs/hang/README.md @@ -9,7 +9,7 @@ A media library built on top of `moq-lite` for streaming audio and video. - **Broadcast**: A discoverable collection of tracks, documented using a catalog. - **Catalog**: Metadata describing the available tracks, codec information, etc. This is a live track itself and is updated as tracks are added/removed/changed. -- **Timeline**: The broadcast's aligned segment index, so consumers can seek (or build playlists) without downloading media. Advertised by the catalog's `archive` entry; itself a live track. +- **HLS records**: An opt-in per-rendition index of aligned HLS segments, so an edge can build playlists without downloading media. Advertised by a rendition's `hls` field; itself a live track. - **Track**: Audio/video streams, as well as other types of data. - **Group**: A group of pictures (video) or collection of samples (audio). Each group is independently decodable. - **Frame**: A timestamp and a codec payload pair. diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index d422dfc4757..31e0c214c78 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -1076,14 +1076,22 @@ mod test { let clock = catalog.clock.expect("the fixture carries a root clock"); assert_eq!(clock.wall.scale().as_u64(), 1_000_000); - let hls = catalog.video.renditions["video"].hls.clone().expect("the fixture opts video into HLS"); - assert_eq!(hls, crate::catalog::Hls::new("video.hls", std::time::Duration::from_secs(2))); + let hls = catalog.video.renditions["video"] + .hls + .clone() + .expect("the fixture opts video into HLS"); + assert_eq!( + hls, + crate::catalog::Hls::new("video.hls", std::time::Duration::from_secs(2)) + ); // Record PTS 2000 (ms) lands 2s after the wall epoch, whatever the track timescale. let expected = moq_clock::Wall::UNIX_EPOCH + std::time::Duration::from_millis(crate::catalog::MOQ_EPOCH_UNIX_MILLIS + 1_751_846_402_000); assert_eq!( - clock.wall_clock(moq_net::Timestamp::from_millis(2000).unwrap()).unwrap(), + clock + .wall_clock(moq_net::Timestamp::from_millis(2000).unwrap()) + .unwrap(), expected ); } @@ -1110,8 +1118,9 @@ mod test { #[test] fn an_undecodable_hls_field_leaves_media_playable() { let rendition = serde_json::json!({"codec": "avc1.64001f", "container": {"kind": "legacy"}}); - let media = Catalog::<()>::from_str(&serde_json::json!({"video": {"renditions": {"video": rendition}}}).to_string()) - .unwrap(); + let media = + Catalog::<()>::from_str(&serde_json::json!({"video": {"renditions": {"video": rendition}}}).to_string()) + .unwrap(); for hls in [ serde_json::json!({"track": "video.hls"}), serde_json::json!({"duration": 4000}), diff --git a/rs/hang/src/hls.rs b/rs/hang/src/hls.rs index 86fed7b0f75..2012fa78c89 100644 --- a/rs/hang/src/hls.rs +++ b/rs/hang/src/hls.rs @@ -1,7 +1,7 @@ //! HLS segment records: where each of a rendition's HLS segments starts. //! //! A rendition opts in with its catalog [`hls`](crate::catalog::Hls) field, which names a track -//! carrying one [`Record`] per segment. A segment starts at a group boundary, so a record is the +//! carrying one [`Record`](crate::hls::Record) per segment. A segment starts at a group boundary, so a record is the //! segment's first group and pts. It runs to the next record: it holds every group up to the next //! record's `group` and lasts until the next record's `pts`, unless it carries an explicit //! `duration`. An edge renders HLS (or DASH) playlists from the records without touching media. diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index 5ed2d30c259..46ec0b7df39 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -2450,15 +2450,20 @@ mod tests { let mut track = fixture.subscribe().await; live.push(fixture.deliver(samples, &mut track, &live).await); drop(track); - wait_for(&mut fixture.publication, Status::Waiting).await; - // The first run's stop declares the unhinted duration. - if records.is_none() { + // The break is marked when the second run resumes, and its stop declares the unhinted + // duration. + if records.is_none() && live.len() > 1 { let hls = fixture.catalog.snapshot().audio.renditions["audio"] .hls .clone() - .expect("the audio track declares its HLS records once the run stops"); - records = Some(moq_mux::hls::Consumer::subscribe(&fixture.consumer, &hls).await.unwrap()); + .expect("the audio track declares its HLS records once the first run stops"); + records = Some( + moq_mux::hls::Consumer::subscribe(&fixture.consumer, &hls) + .await + .unwrap(), + ); } + wait_for(&mut fixture.publication, Status::Waiting).await; // Idle past the minimum segment, so each run is listed as its own segment. tokio::time::sleep(moq_mux::hls::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; } diff --git a/rs/moq-boy/src/video.rs b/rs/moq-boy/src/video.rs index a44147328d6..655d6ce44eb 100644 --- a/rs/moq-boy/src/video.rs +++ b/rs/moq-boy/src/video.rs @@ -57,9 +57,7 @@ impl VideoEncoder { // Probed from a throwaway encoder, before the emulator has run a frame, so a // viewer can discover (and unpause) a moq-boy that has published nothing. let rendition = config.probe().await.expect("failed to probe the H.264 encoder"); - let reserved = catalog - .reserve() - .with_hls_hint(config.gop.duration(config.framerate)); + let reserved = catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)); let producer = moq_video::encode::Producer::new(broadcast, reserved, rendition).expect("failed to create avc3 producer"); let demand = producer.demand(); diff --git a/rs/moq-c/src/video.rs b/rs/moq-c/src/video.rs index 42a95343d64..dd7c94f6b13 100644 --- a/rs/moq-c/src/video.rs +++ b/rs/moq-c/src/video.rs @@ -1045,9 +1045,7 @@ mod tests { // Probed rather than hand-built, so the test track carries what a real one would. let encode = moq_video::encode::Config::new(320, 240, moq_video::FrameRate::new(30, 1).unwrap()); let rendition = encode.probe().await.unwrap(); - let reserved = catalog - .reserve() - .with_hls_hint(encode.gop.duration(encode.framerate)); + let reserved = catalog.reserve().with_hls_hint(encode.gop.duration(encode.framerate)); let producer = moq_video::encode::Producer::new(broadcast, reserved, rendition).unwrap(); let name = producer.demand().name().to_string(); diff --git a/rs/moq-cli/MIGRATE.md b/rs/moq-cli/MIGRATE.md index 92a92ed3cce..cb53710d0cc 100644 --- a/rs/moq-cli/MIGRATE.md +++ b/rs/moq-cli/MIGRATE.md @@ -4,6 +4,11 @@ How to update your invocation and config for each breaking change, newest first. ## Unreleased +### `import --hls` publishes the records `export hls` needs + +Imports no longer publish HLS records by default, so `export hls` lists nothing for a broadcast published without them. +Pass `--hls` to `import` (the stdin formats, `hls`, `rtsp`, and `capture`), and start keyframes at the same timestamps across renditions. + ### `import --linger` sets how long a finished broadcast stays up A cleanly ended import used to stay up for its `--max-age`; it now stays up for `--linger` (default 30 s), counted from the end, and `--max-age` only sets retention. diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index 177f1450757..b8df51082c7 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -339,6 +339,12 @@ impl Invocation { ); } } + if let Command::Import(import) = command + && import.hls + && let Some(reason) = import.source.hls_refusal() + { + anyhow::bail!("--hls records the segments of the catalog the import builds, but {reason}"); + } if let Command::Import(import) = command && import.linger.is_some() && let Some(reason) = import.source.linger_refusal() @@ -771,8 +777,8 @@ pub struct Import { /// live, it caps how far back a FETCH can still reach (and how long a subscriber may ask to /// wait for a late group). The default suits a segmented egress (HLS/DASH), which may only /// advertise segments that are still fetchable; lower it when nothing reads history and the - /// memory matters. Media tracks only -- the catalog and timeline are read at the live edge, - /// which is retained unconditionally. + /// memory matters. Media tracks only -- the catalog and HLS records are read at the live + /// edge, which is retained unconditionally. #[usage(long)] pub max_age: Option, @@ -790,6 +796,12 @@ pub struct Import { #[usage(long = "latency-max", hide = true)] latency_max: Option, + /// Publish HLS segment records for every video and audio rendition, so `export hls` (or + /// any HLS/DASH edge) can serve the broadcast. The source must align keyframes across its + /// renditions. Not for `import rtmp`, `srt`, `rtc`, or `archive`. + #[usage(long)] + pub hls: bool, + /// Name a media report track in the catalog's `stats` section and fill it with what each /// track has written, about once a second while subscribed. The stdin imports only. #[usage(long)] @@ -894,6 +906,19 @@ impl ImportSource { self.catalog_refusal() } + /// Why `--hls` can't apply to this source, or `None` when it can: only a catalog the CLI + /// builds itself records segments. + fn hls_refusal(&self) -> Option<&'static str> { + match self { + Self::Rtmp(_) | Self::Srt(_) => Some("the gateway builds its own catalog"), + Self::Rtc(_) => Some("`import rtc` builds its own catalog"), + Self::Archive(_) => Some("`import archive` replays the catalog it recorded"), + #[cfg(feature = "capture")] + Self::Capture(_) => None, + Self::Avc3 | Self::Fmp4(_) | Self::Ts(_) | Self::Flv | Self::Hls(_) | Self::Rtsp(_) => None, + } + } + /// Why `--linger` can't apply to this source, or `None` when it can: only a source with a /// clean end has a finished broadcast to keep up. fn linger_refusal(&self) -> Option<&'static str> { diff --git a/rs/moq-cli/src/hls.rs b/rs/moq-cli/src/hls.rs index bec2b7f792f..f1bc9c98c52 100644 --- a/rs/moq-cli/src/hls.rs +++ b/rs/moq-cli/src/hls.rs @@ -157,13 +157,15 @@ pub async fn import(target: ImportTarget, args: ImportArgs, epoch: moq_net::Epoc name, max_age, linger, + hls, bandwidth, } = target; let mut producer = origin.create_broadcast(&name).context("failed to create broadcast")?; let config = moq_mux::catalog::Config::default() .with_max_age(max_age) - .with_bandwidth(bandwidth); + .with_bandwidth(bandwidth) + .with_hls(hls.then(moq_mux::hls::Config::default)); let catalog = moq_mux::catalog::Producer::new(&mut producer, config)?; // Announced once the catalog first goes out, which waits for every rendition's init: a // relay queries a broadcast's tracks the moment it is announced, and keeps the refusals of a @@ -425,6 +427,7 @@ mod test { name: "hls".to_string(), max_age: None, linger: moq_mux::Linger::DEFAULT, + hls: false, bandwidth: Default::default(), }; let args = ImportArgs { diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index db216e4464a..4aea047ff7d 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -728,6 +728,7 @@ fn spawn_import( name, max_age, linger, + hls: import.hls, bandwidth: bandwidth.clone(), }; @@ -737,7 +738,8 @@ fn spawn_import( warn_if_missing_format(&name); let config = moq_mux::catalog::Config::default() .with_max_age(max_age) - .with_bandwidth(bandwidth.clone()); + .with_bandwidth(bandwidth.clone()) + .with_hls(import.hls.then(moq_mux::hls::Config::default)); let mut publish = if let ImportSource::Ts(TsImport { program: Some(TsProgram::All), .. @@ -804,7 +806,7 @@ fn spawn_import( ImportSource::Capture(capture) => { warn_if_missing_format(&name); let broadcast = origin.create_broadcast(&name).context("failed to create broadcast")?; - let mut publish = Publish::capture(broadcast, &capture, bandwidth.clone(), max_age)?; + let mut publish = Publish::capture(broadcast, &capture, bandwidth.clone(), max_age, import.hls)?; publish.announce(epoch)?; local = Some(publish); } diff --git a/rs/moq-cli/src/moq.rs b/rs/moq-cli/src/moq.rs index 4d9ec745f2b..e8321c7c39d 100644 --- a/rs/moq-cli/src/moq.rs +++ b/rs/moq-cli/src/moq.rs @@ -26,6 +26,9 @@ pub struct ImportTarget { /// How long a cleanly ended broadcast stays up (`--linger`). pub linger: std::time::Duration, + /// Whether the catalog records HLS segments (`--hls`). + pub hls: bool, + /// Connection allocator each passthrough track claims its peak-hold bitrate on, /// so a co-resident encoder targets what is left. Unlimited when there is no /// uplink estimate. diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index 7068dc971ba..4b6429e62d6 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -460,8 +460,11 @@ impl Publish { args: &CaptureArgs, bandwidth: moq_net::bandwidth::Allocator, max_age: Option, + hls: bool, ) -> anyhow::Result { - let config = moq_mux::catalog::Config::default().with_max_age(max_age); + let config = moq_mux::catalog::Config::default() + .with_max_age(max_age) + .with_hls(hls.then(moq_mux::hls::Config::default)); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config)?; let video = if args.no_video { diff --git a/rs/moq-cli/src/rtmp.rs b/rs/moq-cli/src/rtmp.rs index 2a3dd43368f..0f689570a83 100644 --- a/rs/moq-cli/src/rtmp.rs +++ b/rs/moq-cli/src/rtmp.rs @@ -71,6 +71,7 @@ pub async fn listen_import(target: ImportTarget, addr: SocketAddr) -> anyhow::Re name, max_age, linger, + hls: _, bandwidth, } = target; let mut server = Server::bind(addr).await?; diff --git a/rs/moq-cli/src/rtsp.rs b/rs/moq-cli/src/rtsp.rs index 532ce8e5ef6..491600e3ee8 100644 --- a/rs/moq-cli/src/rtsp.rs +++ b/rs/moq-cli/src/rtsp.rs @@ -75,7 +75,8 @@ async fn run(target: &ImportTarget, client: &moq_rtsp::Client, published: &mut b .context("failed to create broadcast")?; let config = moq_mux::catalog::Config::default() .with_max_age(target.max_age) - .with_bandwidth(target.bandwidth.clone()); + .with_bandwidth(target.bandwidth.clone()) + .with_hls(target.hls.then(moq_mux::hls::Config::default)); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, config)?; // A relay asks for a broadcast's tracks the moment it is announced, so wait for the diff --git a/rs/moq-cli/src/srt.rs b/rs/moq-cli/src/srt.rs index a08f65e35d4..c5cb5c5e2b6 100644 --- a/rs/moq-cli/src/srt.rs +++ b/rs/moq-cli/src/srt.rs @@ -100,6 +100,7 @@ pub async fn listen_import( name, max_age, linger, + hls: _, bandwidth, } = target; let mut server = Server::bind(addr, latency).await?; diff --git a/rs/moq-hls/MIGRATE.md b/rs/moq-hls/MIGRATE.md index 5274064a03e..54cdf0ea343 100644 --- a/rs/moq-hls/MIGRATE.md +++ b/rs/moq-hls/MIGRATE.md @@ -4,6 +4,14 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Renditions list their own HLS records + +Each rendition reads the records its catalog `hls` field names, and segment `n` is its record `n`; a rendition without `hls` gets no playlist. +`Error::TimelineFailed` is `Error::RecordsFailed { track, source }`. +A segment that does not line up with the lead rendition's is listed as `EXT-X-GAP` and never fetched, instead of snapping to a nearby keyframe. +The DASH manifest has one period, with `t` and `d` in milliseconds. +Segment URL tags name each rendition's own record track, so every URL changes once. + ### A failed catalog fails listings and cursors with `CatalogFailed` A catalog stream that ends in an error now fails every rendition's playlist, @@ -11,7 +19,7 @@ manifest, and recording cursor with `Error::CatalogFailed { source }` instead of the raw `source`. Before, a recording cursor returned the raw network error (such as `Unroutable`) when the catalog failed before the reference timeline did, so a caller could not tell that final failure from a retryable fetch error. -Match `CatalogFailed` wherever you match `TimelineFailed`, and look through +Match `CatalogFailed` wherever you match `RecordsFailed`, and look through `source` for the underlying error. ### Rendition URLs name the publisher instance @@ -37,14 +45,14 @@ Allowed renditions keep serving, and missing or disconnected media remains 404. ### A failed timeline fails the segment cursor -`export::segments::Consumer::next` returns `Err(Error::TimelineFailed { .. })` once a failed timeline's covered segments drain, instead of `Ok(None)`. +`export::segments::Consumer::next` returns `Err(Error::RecordsFailed { .. })` once a failed timeline's covered segments drain, instead of `Ok(None)`. `Ok(None)` now means only a clean end, so finalize a recording there, and mark it truncated on the error. ```rust match cursor.next().await { Ok(Some(segment)) => { /* store it */ } Ok(None) => { /* complete: finalize the recording */ } - Err(Error::TimelineFailed { .. }) => { /* truncated: every later call fails the same */ } + Err(Error::RecordsFailed { .. }) => { /* truncated: every later call fails the same */ } Err(err) => { /* a fetch failed: call next() again to retry it */ } } ``` @@ -80,7 +88,7 @@ Treat the error as final and import the restarted source as a new broadcast. ### A failed timeline fails its listings `export::Broadcaster::manifest` and `export::Rendition::media_playlist` return `Result>`. -Answer `Err(Error::TimelineFailed { .. })` with an uncacheable error (the server router uses 500 with `Cache-Control: no-store`) instead of serving a playlist. +Answer `Err(Error::RecordsFailed { .. })` with an uncacheable error (the server router uses 500 with `Cache-Control: no-store`) instead of serving a playlist. `Rendition::playlist` fails the same way, including for a range it used to refuse with `Error::RangeUnavailable`. A failed timeline no longer ends a playlist with `EXT-X-ENDLIST` or turns the DASH manifest `static`; only a clean finish does. A rendition whose own timeline failed is left out of the manifest and master playlist, while its siblings keep serving. diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index 7f5e14adf6a..e81e50c7d3b 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -489,7 +489,13 @@ async fn stopped_and_misaligned_segments_are_gaps_and_a_resume_is_a_discontinuit }; assert_eq!( flags(&low), - [(0, false, false), (1, true, false), (2, true, false), (3, false, true), (4, true, false)] + [ + (0, false, false), + (1, true, false), + (2, true, false), + (3, false, true), + (4, true, false) + ] ); replay .playlist_until(Kind::Video, "1080p", |playlist| playlist.contains("#EXT-X-ENDLIST")) @@ -497,7 +503,13 @@ async fn stopped_and_misaligned_segments_are_gaps_and_a_resume_is_a_discontinuit let high = replay.rendition(Kind::Video, "1080p").snapshot(); assert_eq!( flags(&high), - [(0, false, false), (1, false, false), (2, true, false), (3, false, true), (4, true, false)] + [ + (0, false, false), + (1, false, false), + (2, true, false), + (3, false, true), + (4, true, false) + ] ); // A gap is never fetched. diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 289ebed7e58..7f33bd4085a 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -590,7 +590,11 @@ mod tests { let mut cursor = std::io::Cursor::new(fragment.as_ref()); while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() { if let mp4_atom::Any::Moof(moof) = atom { - return moof.traf[0].trun[0].entries.iter().map(|entry| entry.duration).collect(); + return moof.traf[0].trun[0] + .entries + .iter() + .map(|entry| entry.duration) + .collect(); } } panic!("no moof in fragment"); @@ -1361,7 +1365,10 @@ mod tests { // Both renditions list the same aligned segment spans (the live edge is not listed). assert_eq!(manifest.matches("").count(), 2, "{manifest}"); assert_eq!(manifest.matches("").count(), 2); - assert!(!manifest.contains(""), "{manifest}"); - assert!(!manifest.contains(" (renditions::Producer, Arc) { + fn export( + upstream: &moq_mux::Source, + config: &hang::catalog::VideoConfig, + ) -> (renditions::Producer, Arc) { let mut config = config.clone(); config.hls = Some(hls("video0")); let mut catalog = moq_mux::catalog::hang::Catalog::default(); @@ -2129,7 +2146,10 @@ mod tests { "the replacement publisher's media was served under the replaced broadcast's catalog" ); } - assert!(served.is_none(), "the replaced broadcast is closed, so it serves nothing"); + assert!( + served.is_none(), + "the replaced broadcast is closed, so it serves nothing" + ); // Control: an export started after the takeover does serve the new publisher's media, so // the assertion above is about which epoch a rendition is bound to, not about a broadcast @@ -2144,7 +2164,10 @@ mod tests { .await .unwrap() .expect("the new publisher is servable on its own catalog"); - assert!(contains(&served, NEW), "the new export serves the new publisher's media"); + assert!( + contains(&served, NEW), + "the new export serves the new publisher's media" + ); drop(new); } @@ -2222,7 +2245,10 @@ mod tests { .await .unwrap() .expect("the new publisher is servable on a freshly bound rendition"); - assert!(contains(&served, NEW), "the new export serves the new publisher's media"); + assert!( + contains(&served, NEW), + "the new export serves the new publisher's media" + ); drop((new_media, records, live)); } @@ -2245,10 +2271,7 @@ mod tests { if rendition.snapshot().finished { return; } - assert!( - tokio::time::Instant::now() < deadline, - "the playlist did not end" - ); + assert!(tokio::time::Instant::now() < deadline, "the playlist did not end"); tokio::task::yield_now().await; } } @@ -3057,7 +3080,10 @@ mod tests { let snapshot = test.finished("video1").await; assert_eq!(gaps(&snapshot), [0, 2]); let rendition = test.rendition("video1"); - assert!(rendition.listed_segment(0).await.unwrap().is_none(), "a gap is never fetched"); + assert!( + rendition.listed_segment(0).await.unwrap().is_none(), + "a gap is never fetched" + ); let mut cursor = rendition.segments(); assert_eq!(cursor.next().await.unwrap().unwrap().segment, 1); assert!(cursor.next().await.unwrap().is_none()); @@ -3131,7 +3157,11 @@ mod tests { let snapshot = test.listed("video0", 3).await; assert_eq!(numbers(&snapshot), [0, 1, 2, 3]); assert_eq!(gaps(&snapshot), [2]); - let rendered = test.rendition("video0").media_playlist(None).unwrap().expect("playable"); + let rendered = test + .rendition("video0") + .media_playlist(None) + .unwrap() + .expect("playable"); assert!(rendered.contains("#EXT-X-GAP\n"), "{rendered}"); assert!(rendered.contains("#EXT-X-DISCONTINUITY\n"), "{rendered}"); } diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 9e52bd5b1d9..0c8d3529b53 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -494,9 +494,7 @@ impl Rendition { } let pts = Duration::from(row.pts); let verdict = match lead.live.numbered(row.segment) { - segments::Lookup::Found(lead) => { - !lead.stop && pts.abs_diff(Duration::from(lead.pts)) <= lead.duration / 2 - } + segments::Lookup::Found(lead) => !lead.stop && pts.abs_diff(Duration::from(lead.pts)) <= lead.duration / 2, segments::Lookup::Missing(span) => !span.contains(&pts), segments::Lookup::Never => true, segments::Lookup::Pending => return None, @@ -1045,7 +1043,10 @@ impl Rendition { // pathologically coarse record), 404 rather than silently truncating it to a segment // shorter than its advertised duration. if groups.end.saturating_sub(groups.start) > MAX_SEGMENT_GROUPS { - tracing::warn!(?groups, "segment spans more than MAX_SEGMENT_GROUPS; refusing to truncate"); + tracing::warn!( + ?groups, + "segment spans more than MAX_SEGMENT_GROUPS; refusing to truncate" + ); return Ok(None); } @@ -1235,7 +1236,10 @@ fn row( ) -> Option { let pts = Duration::from(record.pts); if next.group < record.group || Duration::from(next.pts) < pts { - tracing::warn!(segment = record.sequence, "HLS record goes backwards; dropping its segment"); + tracing::warn!( + segment = record.sequence, + "HLS record goes backwards; dropping its segment" + ); return None; } let duration = record.duration.unwrap_or(Duration::from(next.pts) - pts); diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index fa7c3c897ef..673cbd74f7b 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -454,7 +454,9 @@ impl Producer { self.register(&rendition); current.renditions.insert(key, rendition); } - if current.renditions.is_empty() && (!catalog.video.renditions.is_empty() || !catalog.audio.renditions.is_empty()) { + if current.renditions.is_empty() + && (!catalog.video.renditions.is_empty() || !catalog.audio.renditions.is_empty()) + { tracing::debug!("no rendition advertises HLS records yet; nothing to serve"); } diff --git a/rs/moq-hls/src/export/segments.rs b/rs/moq-hls/src/export/segments.rs index a70a417af9b..50ce4ee8152 100644 --- a/rs/moq-hls/src/export/segments.rs +++ b/rs/moq-hls/src/export/segments.rs @@ -35,7 +35,7 @@ use crate::Result; pub struct Id { /// The publisher run the URL names, if the broadcaster carries one. pub generation: Option, - /// The [tag](tag) of the record track numbering the segment. + /// A short hash of the name of the record track numbering the segment. pub tag: String, /// Where the segment sits in that numbering. pub at: At, @@ -397,7 +397,12 @@ impl Producer { /// for inline-parameter-set codecs: a segment starts at a group, and a group at a keyframe. pub fn latest_group(&self) -> Option { let state = self.state.read(); - state.rows.iter().rev().find(|row| !row.stop).map(|row| row.groups.start) + state + .rows + .iter() + .rev() + .find(|row| !row.stop) + .map(|row| row.groups.start) } /// Whether the playlist has anything to serve yet (at least one segment, or the records @@ -746,7 +751,11 @@ mod tests { let mut breaks = Discontinuities::default(); assert_eq!(breaks.stamp(5, false), 0); breaks.restart(); - assert_eq!(breaks.stamp(0, false), 1, "the new run's numbering is unrelated to the old"); + assert_eq!( + breaks.stamp(0, false), + 1, + "the new run's numbering is unrelated to the old" + ); } #[test] @@ -796,7 +805,9 @@ mod tests { live.push(row(5, 5, 10_000, 2_000), window); assert!(matches!(live.numbered(2), Lookup::Never), "evicted or never listed"); assert!(matches!(live.numbered(3), Lookup::Found(row) if row.segment == 3)); - assert!(matches!(live.numbered(4), Lookup::Missing(span) if span == (Duration::from_secs(6)..Duration::from_secs(12)))); + assert!( + matches!(live.numbered(4), Lookup::Missing(span) if span == (Duration::from_secs(6)..Duration::from_secs(12))) + ); assert!(matches!(live.numbered(6), Lookup::Pending)); live.end(); assert!(matches!(live.numbered(6), Lookup::Never)); diff --git a/rs/moq-hls/src/import.rs b/rs/moq-hls/src/import.rs index f44af054ea9..f3028706d52 100644 --- a/rs/moq-hls/src/import.rs +++ b/rs/moq-hls/src/import.rs @@ -646,7 +646,7 @@ impl TrackState { importer.seek(group_sequence)?; } // Every playlist entry is a source segment boundary, so declare it even when the media - // carries no styp. Every rendition declares the same ones; the timeline drops a cut + // carries no styp. Every rendition declares the same ones; the HLS segmenter drops a cut // that would land inside its minimum segment duration, so the duplicates cost nothing. importer.cut()?; importer.decode(&bytes).map_err(|err| match err { diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index 5d0627af146..d5eba06d380 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -17,7 +17,7 @@ const M3U8: &str = "application/vnd.apple.mpegurl"; const MPD: &str = "application/dash+xml"; const MP4: &str = "video/mp4"; -/// How long a rendition lookup waits for the catalog (and its first timeline records) to +/// How long a rendition lookup waits for the catalog (and its first HLS records) to /// populate. const READY_TIMEOUT: Duration = Duration::from_secs(5); @@ -88,7 +88,7 @@ async fn manifest(server: &Server, broadcast: &str, query: Option<&str>) -> Resp if broadcaster.is_empty() { return not_found(); } - // A manifest whose timelines are all empty confuses players; give the broadcast a moment + // A manifest whose renditions are all empty confuses players; give the broadcast a moment // to index its first complete segment before answering. let _ = moq_clock::timeout(READY_TIMEOUT, broadcaster.playable()).await; // Each representation names its init by hash, which an inline codec only learns from media. @@ -459,11 +459,9 @@ mod tests { broadcast.announce(Default::default()).expect("announce"); // A hinted segment duration is declared before any media, so the rendition is listed early. let hinted = moq_mux::hls::Config::default().with_duration_hint(Duration::from_secs(2)); - let catalog = moq_mux::catalog::Producer::new( - &mut broadcast, - moq_mux::catalog::Config::default().with_hls(hinted), - ) - .unwrap(); + let catalog = + moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default().with_hls(hinted)) + .unwrap(); let reserved = catalog.reserve(); let track = broadcast.create_track("video0", timed()).unwrap(); let mut media = reserved @@ -945,7 +943,7 @@ mod tests { StatusCode::FORBIDDEN ); - // A timeline failure refuses listings, but its already-retained media + // A records failure refuses listings, but its already-retained media // still drains. Authorization checks must not broaden that failure. let retained = broadcaster.rendition(Kind::Video, "video0").unwrap(); retained.fail(crate::Error::Moq(moq_net::Error::Transport("connection lost".into()))); @@ -964,8 +962,7 @@ mod tests { let media_broadcast = pair.pub_origin.create_broadcast("room/source").expect("media"); media_broadcast.announce(Default::default()).expect("announce media"); - let catalog = - moq_mux::catalog::Producer::new(&mut catalog_broadcast, hls_config()).unwrap(); + let catalog = moq_mux::catalog::Producer::new(&mut catalog_broadcast, hls_config()).unwrap(); let reserved = catalog.reserve(); let mut config = video_config(); config.broadcast = Some(moq_net::path::Relative::new("./source").to_owned()); diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index 5840560002e..c73f9ee88b0 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -4,6 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased +### HLS records are opt-in and replace timelines + +The `timeline` module is replaced by `hls`, and nothing publishes records by default. +Enable them with `catalog::Config::with_hls(hls::Config::default())`; `with_timeline` is gone. +`catalog::Reserved::with_timeline_hint` is `with_hls_hint`, and `catalog::Producer::timeline()` is gone. +Each video and audio rendition then advertises its own `hls` field and `.hls` record track, cut at group boundaries; untimed and data tracks get none. +Read records with `hls::Consumer::subscribe(&broadcast, &hls)`, whose `Event::Push` carries an `hls::Entry`, or publish your own with `hls::Producer`. +A publisher that enables HLS must align group boundaries across its renditions. + ### `Programs::finish` returns the program broadcasts `Programs::finish` now returns each program's `broadcast::Producer` instead of `()`. @@ -102,21 +111,11 @@ Recordings published with `pes` framing no longer export; import their source ag `container::fmp4::Wire::new` returns `Result`, as `Wire::from_init` already did, and refuses any other handler instead of treating it as data. Handle the error from `Wire::new`. -### Timeline durations are hints (#5715) - -`timeline::Config::duration_max`, `with_duration_max`, and `DEFAULT_DURATION_MAX` are gone; set `duration_hint` with `with_duration_hint`, the expected record duration such as the GOP, and the split ceiling follows from it. -`DEFAULT_DURATION_MIN` is 4 s instead of 2 s, so set `with_duration_min` to keep the shorter records. - ### The catalog consumer is opaque (#5630) Construct `catalog::Consumer` with `Consumer::new(broadcast, format).await`, or convert a `catalog::hang::Consumer` with `.into()`, then read it through `catalog::Stream`. Its `Hang`, `Msf`, and `Closed` variants and `catalog::msf::Consumer::track` are private. -### Timelines record untimed tracks (#5720) - -`timeline::Segmenter::frame` takes `pts: impl Into>`, so existing callers compile. -A segmenter fed the wrong timedness for its track fails with `Error::TimelineTiming` or `Error::TimelineUntimed`. - ### An unset host clock publishes no wall mapping (#5429) `Clock::wall()` returns `Option` instead of panicking, so handle `None`. diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index a5517202a13..b02f642bc63 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -167,6 +167,9 @@ struct Outputs { msf_track: moq_net::track::Producer, /// The broadcast's HLS record tracks, when [`Config::with_hls`] enabled them. hls: Option, + /// Keeps the broadcast open while the catalog is, so a publisher may hand its own handle to a + /// media producer and drop it. + broadcast: moq_net::broadcast::Producer, } impl Clone for Outputs { @@ -177,6 +180,7 @@ impl Clone for Outputs { hangz: self.hangz.clone(), msf_track: self.msf_track.clone(), hls: self.hls.clone(), + broadcast: self.broadcast.clone(), } } } @@ -448,6 +452,7 @@ impl Producer { hangz, msf_track, hls, + broadcast: broadcast.clone(), }, current: Arc::new(Mutex::new(State { catalog, @@ -1923,7 +1928,12 @@ mod test { fn nothing_publishes_hls_unless_enabled() { let mut broadcast = moq_net::broadcast::Info::new().produce(); let catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); - assert!(catalog.enroll("audio0", Some(std::time::Duration::from_secs(4))).unwrap().is_none()); + assert!( + catalog + .enroll("audio0", Some(std::time::Duration::from_secs(4))) + .unwrap() + .is_none() + ); let _video = video0(&mut broadcast, &catalog, &[0, 4_000, 8_000]); assert_eq!(catalog.snapshot().video.renditions["video0"].hls, None); @@ -1979,8 +1989,14 @@ mod test { .unwrap(); let tracks = catalog.outputs.hls.as_ref().unwrap(); - assert_eq!(tracks.hls("video0").unwrap().duration, std::time::Duration::from_secs(6)); - assert_eq!(tracks.hls("audio0").unwrap().duration, std::time::Duration::from_secs(5)); + assert_eq!( + tracks.hls("video0").unwrap().duration, + std::time::Duration::from_secs(6) + ); + assert_eq!( + tracks.hls("audio0").unwrap().duration, + std::time::Duration::from_secs(5) + ); } #[test] diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index fd3450fa343..a5b57535ba7 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -1278,7 +1278,10 @@ async fn groups(records: &mut crate::hls::Consumer) -> Vec<(u64, u64)> { entries.push(entry); } } - assert!(entries.last().is_some_and(|entry| entry.is_stop()), "the track's end stops the last segment"); + assert!( + entries.last().is_some_and(|entry| entry.is_stop()), + "the track's end stops the last segment" + ); entries .windows(2) .filter(|pair| !pair[0].is_stop()) @@ -1311,8 +1314,14 @@ async fn import_populates_the_hls_records() { // This short fixture ends before the first four-second segment closes, so finishing the import // stops it and declares the minimum while the catalog stays live. let four = std::time::Duration::from_secs(4); - assert_eq!(snapshot.video.renditions[&video_name].hls.as_ref().unwrap().duration, four); - assert_eq!(snapshot.audio.renditions[&audio_name].hls.as_ref().unwrap().duration, four); + assert_eq!( + snapshot.video.renditions[&video_name].hls.as_ref().unwrap().duration, + four + ); + assert_eq!( + snapshot.audio.renditions[&audio_name].hls.as_ref().unwrap().duration, + four + ); // Subscribe while the producer is alive, then finish so the record tracks close and the // readers terminate rather than blocking. diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index 06b91230164..cc232e79708 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -1505,10 +1505,15 @@ mod tests { } /// Each HLS record of `tracks`' `video` as `(group, pts ms, stop)`. - async fn hls_records(broadcast: &moq_net::broadcast::Producer, tracks: &crate::hls::Tracks) -> Vec<(u64, u64, bool)> { + async fn hls_records( + broadcast: &moq_net::broadcast::Producer, + tracks: &crate::hls::Tracks, + ) -> Vec<(u64, u64, bool)> { tracks.finish(); let hls = tracks.hls("video").expect("declared"); - let mut consumer = crate::hls::Consumer::subscribe(&broadcast.consume(), &hls).await.unwrap(); + let mut consumer = crate::hls::Consumer::subscribe(&broadcast.consume(), &hls) + .await + .unwrap(); let mut records = Vec::new(); while let Some(event) = consumer.next().await.unwrap() { if let crate::hls::Event::Push { entry, .. } = event { @@ -1558,7 +1563,12 @@ mod tests { assert_eq!( hls_records(&broadcast, &tracks).await, - [(0, 0, false), (1, 66, true), (2, 2_400_000, false), (3, 2_400_000, true)] + [ + (0, 0, false), + (1, 66, true), + (2, 2_400_000, false), + (3, 2_400_000, true) + ] ); } diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs index 7211504b148..f2598e62b1b 100644 --- a/rs/moq-mux/src/hls.rs +++ b/rs/moq-mux/src/hls.rs @@ -930,7 +930,11 @@ mod test { fn drain(segmenter: &mut Segmenter) -> Vec<(u64, u64, Option)> { let mut out = Vec::new(); for record in segmenter.by_ref() { - assert_eq!(record.sequence, out.len() as u64 + first(&out), "records are numbered consecutively"); + assert_eq!( + record.sequence, + out.len() as u64 + first(&out), + "records are numbered consecutively" + ); out.push((record.group, record.pts, record.duration)); } out @@ -962,7 +966,10 @@ mod test { fn short_groups_pack_up_to_the_minimum() { let mut segmenter = Segmenter::new(Config::default()); groups(&mut segmenter, 0..10, 1_000); - assert_eq!(drain(&mut segmenter), [(0, 0, None), (4, 4_000, None), (8, 8_000, None)]); + assert_eq!( + drain(&mut segmenter), + [(0, 0, None), (4, 4_000, None), (8, 8_000, None)] + ); } #[test] @@ -1033,7 +1040,10 @@ mod test { segmenter.cut(ms(2_000)); segmenter.cut(ms(3_000)); groups(&mut segmenter, 0..5, 1_000); - assert_eq!(drain(&mut segmenter), [(0, 0, None), (2, 2_000, None), (3, 3_000, None)]); + assert_eq!( + drain(&mut segmenter), + [(0, 0, None), (2, 2_000, None), (3, 3_000, None)] + ); } #[test] diff --git a/rs/moq-mux/src/lib.rs b/rs/moq-mux/src/lib.rs index 3a26a65b862..16c1a2a6291 100644 --- a/rs/moq-mux/src/lib.rs +++ b/rs/moq-mux/src/lib.rs @@ -41,6 +41,7 @@ pub mod codec; pub mod container; mod error; pub mod follow; +pub mod hls; pub mod import; mod jitter; pub mod json; @@ -50,7 +51,6 @@ pub mod rate; pub mod select; mod source; mod stats; -pub mod hls; pub use clock::Clock; pub use error::*; diff --git a/rs/moq-rtc/src/codec/vp8.rs b/rs/moq-rtc/src/codec/vp8.rs index fa2918ced9c..4ffae286a2c 100644 --- a/rs/moq-rtc/src/codec/vp8.rs +++ b/rs/moq-rtc/src/codec/vp8.rs @@ -76,7 +76,10 @@ mod tests { timestamp_us: 0, payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]), }); - assert!(result.is_err(), "an HLS record track collision must fail importer creation"); + assert!( + result.is_err(), + "an HLS record track collision must fail importer creation" + ); Box::new(bridge).abort(moq_net::Error::Transport("session failed".into())); let Err(error) = track.recv_group().await else { diff --git a/rs/moq-video/MIGRATE.md b/rs/moq-video/MIGRATE.md index 32dc9dc1359..153c5bb8070 100644 --- a/rs/moq-video/MIGRATE.md +++ b/rs/moq-video/MIGRATE.md @@ -50,8 +50,8 @@ Set `encode::Config::color` to the first frame's `frame.surface.color()`, and re ### `encode::Producer` takes a catalog reservation (#5715) -`encode::Producer::new` and `with_track` take a `moq_mux::catalog::Reserved` instead of the `catalog::Producer`, so the caller declares the encoder's GOP as the timeline hint. -Pass `catalog.reserve().with_timeline_hint(gop)` instead of the catalog. +`encode::Producer::new` and `with_track` take a `moq_mux::catalog::Reserved` instead of the `catalog::Producer`, so the caller declares the encoder's GOP as the HLS hint. +Pass `catalog.reserve().with_hls_hint(gop)` instead of the catalog. ### Capture takes one `encode::Capture` diff --git a/rs/moq-video/src/capture/wgc.rs b/rs/moq-video/src/capture/wgc.rs index 306f00fe7de..5064aa9d1cf 100644 --- a/rs/moq-video/src/capture/wgc.rs +++ b/rs/moq-video/src/capture/wgc.rs @@ -318,9 +318,7 @@ mod tests { let rendition = config.probe().await.unwrap(); let container = moq_mux::catalog::hang::Container::try_from(&rendition).unwrap(); let mut subscriber = moq_mux::container::Consumer::new(subscriber, container); - let reserved = catalog - .reserve() - .with_hls_hint(config.gop.duration(config.framerate)); + let reserved = catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)); let mut producer = Producer::with_track(track, reserved, rendition).unwrap(); assert!(producer.demand().wanted().is_some()); diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index e874a438983..03ae91e0424 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -865,9 +865,7 @@ mod tests { let mut producer = Producer::new( broadcast, - catalog - .reserve() - .with_hls_hint(config.gop.duration(config.framerate)), + catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -938,9 +936,7 @@ mod tests { config.kind = encoder::Kind::Software; let mut producer = Producer::with_track( track, - catalog - .reserve() - .with_hls_hint(config.gop.duration(config.framerate)), + catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -970,9 +966,7 @@ mod tests { initial.kind = encoder::Kind::Software; let mut producer = Producer::new( broadcast, - catalog - .reserve() - .with_hls_hint(initial.gop.duration(initial.framerate)), + catalog.reserve().with_hls_hint(initial.gop.duration(initial.framerate)), initial.probe().await.unwrap(), ) .unwrap(); @@ -1043,9 +1037,7 @@ mod tests { config.kind = encoder::Kind::Software; let _producer = Producer::new( broadcast, - catalog - .reserve() - .with_hls_hint(config.gop.duration(config.framerate)), + catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -1091,9 +1083,7 @@ mod tests { let mut subscriber = track.subscribe(None); let mut producer = Producer::with_track( track, - catalog - .reserve() - .with_hls_hint(config.gop.duration(config.framerate)), + catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -1202,9 +1192,7 @@ mod tests { probe.kind = encoder::Kind::Software; let mut producer = Producer::with_track( track, - catalog - .reserve() - .with_hls_hint(probe.gop.duration(probe.framerate)), + catalog.reserve().with_hls_hint(probe.gop.duration(probe.framerate)), probe.probe().await.unwrap(), ) .unwrap(); @@ -1471,7 +1459,9 @@ mod tests { .hls .clone() .expect("the video track advertises its HLS records"); - let mut records = moq_mux::hls::Consumer::subscribe(&fixture.consumer, &hls).await.unwrap(); + let mut records = moq_mux::hls::Consumer::subscribe(&fixture.consumer, &hls) + .await + .unwrap(); let mut live = Vec::new(); for _ in 0..2 { From 49d1213f78fb132ee73fd5d0aeffee907413f6a3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 15:48:44 -0700 Subject: [PATCH 05/20] fix(hls): keep follower boundaries, abort records, skip stops in DASH time - A follower that reports a group before the pacer's cut keeps it as undecided, so the cut still starts its segment there. - catalog::Producer::abort aborts the record tracks with its cause instead of finishing them. - A DASH $Time$ never resolves to a stop row. - A record its successor can't bound is a gap row, keeping its number. - The draft drops a contradicting sentence and obsolete moq-hang-04 bullets. - The @moq/hang API reports match the merged schema. Co-Authored-By: Claude Opus 5.5 --- doc/bin/hls.md | 2 +- drafts/draft-lcurley-moq-hang.md | 16 +-- js/hang/api/catalog.api.md | 111 ++++++++++++++----- js/hang/api/container.api.md | 8 ++ js/hang/api/index.api.md | 165 +++++++++++++++++++---------- rs/moq-hls/MIGRATE.md | 2 +- rs/moq-hls/src/export/mod.rs | 23 ++++ rs/moq-hls/src/export/segments.rs | 22 +++- rs/moq-mux/src/catalog/producer.rs | 34 +++++- rs/moq-mux/src/hls.rs | 146 +++++++++++++++++++++++-- 10 files changed, 412 insertions(+), 117 deletions(-) diff --git a/doc/bin/hls.md b/doc/bin/hls.md index a20d2e9ba19..182a718e5f8 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -81,7 +81,7 @@ after the recording completes. The recording starts at the oldest segment in the `--window` and gets `EXT-X-ENDLIST` only once the broadcast finishes cleanly. If the publisher drops -out, the timeline restarts, or a segment can't be fetched, the export exits with +out, the HLS records restart, or a segment can't be fetched, the export exits with an error and leaves the playlists unended. A rendition whose decoder is reconfigured keeps one playlist, switching to a new init at an `EXT-X-DISCONTINUITY` that every playlist marks at the same segment, so the whole diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 75286e94768..cbca368fdc5 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -955,7 +955,6 @@ The track this specification defines as always compressed, `catalog.json.z` ({{c The `.z` suffix on that name is a naming convention, and a consumer MUST NOT treat it as a signal. Such a track is compressed per {{moqflate}}: each group is one raw DEFLATE stream, sync flushed at each frame boundary. -The suffix is the declaration. # HLS Records {#hls} @@ -1147,16 +1146,13 @@ This document has no IANA actions. {:numbered="false"} - Replaced the root `archive` section and its per-track timelines with an opt-in, per-rendition `hls` field on video and audio ({{hls}}). Each record is a segment's first `group` and `pts` in milliseconds, with an optional `duration` where zero marks a stop; frame positions, `end`, `keyframe`, `timescale`, `replay`, `replayEpoch`, `store`, and `version` are gone. Renditions that set it align their segments, so an edge maps record `n` to segment `n`. The record track is named `.hls` and is not compressed. -- The archive `version` is 3: the recording format stores a track's `priority` signed, -128 through 127 with 0 unset, instead of its moq-lite byte. - A reader refuses a JSON frame in which an object repeats a member name. - Added the root `version` ({{catalog-version}}): the oldest draft that reads the catalog, which a consumer refuses when above its own. A `track` other than the entry's ID or a video `warmup` needs 4. Consumers MUST ignore unknown fields. - A data track marks a discontinuity with a group of one object with no bytes, since its empty payload is data. An object with no bytes on an audio or video track, or sharing its group, is malformed. -- Each archive timeline declares its own fixed nominal record duration, omitted for an untimed track; removed the broadcast-wide duration bound. - Removed the Recording section: the store layout is a moq-archive format, documented with that crate, not part of this draft. - Added the optional video `preference` field: a consumer selects automatically among the highest preference it can decode. - Added the optional video `reorder` field: the most frames that can precede one in decode order and follow it in presentation, fixed for the track once declared. A consumer that authors decode times derives them from it and the current group alone, refusing deeper reordering; without it, a consumer infers them from timestamps. -- Added an optional `epoch` beside every rendition and data track `broadcast` reference, and `replayEpoch` beside the archive's `replay` ({{field-epoch}}). A consumer reads each referenced path from the first broadcast it resolves for a catalog instance; a present `epoch` fails a rendition proven to resolve another instance, and is best effort over a route without an Epoch. -- Derived HLS and DASH exports hand disabled references to enabled renditions at the first record numbered past the advertised ones, keeping the record `sequence` as the HLS media sequence and starting a DASH period. +- Added an optional `epoch` beside every rendition and data track `broadcast` reference ({{field-epoch}}). A consumer reads each referenced path from the first broadcast it resolves for a catalog instance; a present `epoch` fails a rendition proven to resolve another instance, and is best effort over a route without an Epoch. - Added optional `stats` and `echo` catalog sections, publisher/viewer snapshots keyed by rendition ID, and the `.echo` feedback broadcast convention. - An Opus frame timestamp counts the OpusHead pre-skip: a consumer presents each decoded sample the pre-skip earlier than its frame timestamp places it. @@ -1167,22 +1163,12 @@ This document has no IANA actions. - Added optional audio and video `warmup` durations, rounded up to whole milliseconds, and allowed video groups to start at convergent random access pictures. A consumer also decoding from an earlier group start may present frames before `warmup`, switching to that decoder once it reaches the presented timestamp. - Defined `jitter` from media timestamps alone, the reorder delay and batch spans a publisher writes, replacing fixed frame-duration hints and wall-clock flush lateness. - Timed CMAF samples from the frame timestamp, which is the fragment's earliest presentation time; `tfdt` is only relative within the fragment, and places the samples of an untimed frame. A timed CMAF track counts in its `mdhd` ticks. -- Allowed a DVR to delete timeline objects no checkpoint recovery needs, oldest first. - Replaced the live-edge floor with monotonic group starts: a group may overlap the previous group's content but not start before it. - Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. - Added the optional `delay` rendition field: how far a rendition's timestamps trail those other renditions already wrote, never lowered once advertised and never subtracted across renditions. - Recommended namespaced keys for application root sections. - Added the optional `delay` field to `json` and `binary` track entries, measured only from payloads stamped with their capture time on the broadcast clock. - Replaced the video `stalled` field with an optional `enabled` field on audio and video renditions. A consumer MUST NOT select a disabled rendition and ignores `stalled`. A consumer that predates `enabled` keeps selecting a disabled rendition. -- Replaced the broadcast's one aligned timeline with one timeline per track: the catalog `archive` entry's `track` became a `timelines` map from each indexed track, the catalog included, to its timeline track. -- Required consumers to ignore an undecodable `archive` entry without rejecting the catalog. -- Replaced the segment record with a per-track record: `sequence`, `pts`, `duration`, and a `start`/`end` range of group and frame positions, dropping cross-track pacing and completeness. -- One cutting rule for every track: a record ends at the first group boundary past a minimum (4 seconds RECOMMENDED, zero for sparse data such as a catalog) and splits a group between frames once it stays open several declared durations, so a group that never closes is indexed as it grows. -- Recording format version 2: each track stores record N at `segments/N`, beside its timeline's `segments/N`, with a `Frame Start` field in the segment object. Tracks commit and expire independently, and a DVR keeps each track's newest record. -- Described deriving HLS and DASH at the edge from a reference rendition's records. -- An edge keeps its derived-format video reference rendition while that rendition stays in the catalog; an audio reference yields once video has a timeline. -- Added an optional `epoch` to `.info`: the source route's Epoch, compared on resume so a restarted source fails enrollment. -- Recorded untimed tracks untimed: `.info` omits `timescale`, segment frame entries omit `Timestamp`, and timeline records omit `pts` and `duration`, one record per group. A DVR refuses an untimed track. - Audio `sampleRate` and `numberOfChannels` describe the decoded output, so HE-AAC names its SBR rate and parametric stereo names two channels. - Added an optional `track` field to video, audio, text, `json`, and `binary` entries naming the track in its broadcast; the entry's key is its ID and names the track when `track` is absent. A consumer MUST reject a catalog using one ID for both a video and an audio rendition. - Defined video `displayAspectWidth` and `displayAspectHeight` as the whole picture's display aspect, not the sample aspect. diff --git a/js/hang/api/catalog.api.md b/js/hang/api/catalog.api.md index ed5ed88b5ac..22e3c04eca9 100644 --- a/js/hang/api/catalog.api.md +++ b/js/hang/api/catalog.api.md @@ -8,22 +8,6 @@ import type * as Moq from '@moq/net'; import * as z from '@zod/mini'; import * as z_2 from 'zod/mini'; -export type Archive = z.infer; - -export const ARCHIVE_VERSION = 3; - -export const ArchiveSchema: z.ZodMiniObject<{ - timelines: z.ZodMiniRecord, z.ZodMiniObject<{ - track: z.ZodMiniString; - duration: z.ZodMiniOptional, "u53", "out">>; - }, z.core.$strip>>; - timescale: z.ZodMiniDefault, "u53", "out">>; - replay: z.ZodMiniOptional>>; - replayEpoch: z.ZodMiniOptional>>; - store: z.ZodMiniOptional; - version: z.ZodMiniOptional, "u53", "out">>; -}, z.core.$strip>; - declare namespace audio { export { ranked, @@ -77,6 +61,10 @@ export const AudioConfigSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; export const AudioSchema: z.ZodMiniUnion, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodMiniPipe, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; }, z.core.$strip>, z.ZodMiniTransform<{ renditions: { @@ -185,6 +181,10 @@ export const AudioSchema: z.ZodMiniUnion) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }; }, { @@ -220,6 +220,10 @@ export const AudioSchema: z.ZodMiniUnion) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }>>]>; @@ -366,6 +370,18 @@ export type Hex = z.infer; export const hexSchema: z.ZodMiniString; +export type Hls = z.infer; + +export const HlsFieldSchema: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; +}, z.core.$strip>>>; + +export const HlsSchema: z.ZodMiniObject<{ + track: z.ZodMiniString; + duration: z.core.$ZodBranded, "u53", "out">; +}, z.core.$strip>; + export type Json = z_2.infer; export type JsonConfig = z_2.infer; @@ -549,6 +565,10 @@ export const RootSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>>; display: z.ZodMiniOptional, "u53", "out">; @@ -651,6 +671,10 @@ export const RootSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; }, z.core.$strip>>, z.ZodMiniTransform<{ renditions: { @@ -732,6 +756,10 @@ export const RootSchema: z.ZodMiniObject<{ jitter?: (number & z.$brand<"u53">) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }; display: { @@ -822,6 +850,10 @@ export const RootSchema: z.ZodMiniObject<{ jitter?: (number & z.$brand<"u53">) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }[]>>]>>; audio: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodMiniPipe, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; }, z.core.$strip>, z.ZodMiniTransform<{ renditions: { @@ -930,6 +970,10 @@ export const RootSchema: z.ZodMiniObject<{ jitter?: (number & z.$brand<"u53">) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }; }, { @@ -965,19 +1009,12 @@ export const RootSchema: z.ZodMiniObject<{ jitter?: (number & z.$brand<"u53">) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }>>]>>; - archive: z.ZodMiniCatch, z.ZodMiniObject<{ - track: z.ZodMiniString; - duration: z.ZodMiniOptional, "u53", "out">>; - }, z.core.$strip>>; - timescale: z.ZodMiniDefault, "u53", "out">>; - replay: z.ZodMiniOptional>>; - replayEpoch: z.ZodMiniOptional>>; - store: z.ZodMiniOptional; - version: z.ZodMiniOptional, "u53", "out">>; - }, z.core.$strip>>>; clock: z.ZodMiniOptional, "u53", "out">; timescale: z.ZodMiniDefault>; @@ -1293,6 +1330,10 @@ export const VideoConfigSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; export const VideoSchema: z.ZodMiniUnion, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>>; display: z.ZodMiniOptional, "u53", "out">; @@ -1489,6 +1534,10 @@ export const VideoSchema: z.ZodMiniUnion, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; }, z.core.$strip>>, z.ZodMiniTransform<{ renditions: { @@ -1570,6 +1619,10 @@ export const VideoSchema: z.ZodMiniUnion) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }; display: { @@ -1660,6 +1713,10 @@ export const VideoSchema: z.ZodMiniUnion) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }[]>>]>; diff --git a/js/hang/api/container.api.md b/js/hang/api/container.api.md index b95f1f7cad2..a2c3c887007 100644 --- a/js/hang/api/container.api.md +++ b/js/hang/api/container.api.md @@ -50,6 +50,10 @@ const AudioConfigSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; export interface BufferedRange { @@ -268,6 +272,10 @@ const VideoConfigSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; diff --git a/js/hang/api/index.api.md b/js/hang/api/index.api.md index baa73f62779..e3fc2e0c636 100644 --- a/js/hang/api/index.api.md +++ b/js/hang/api/index.api.md @@ -19,22 +19,6 @@ import { Track as Track_2 } from '@moq/net'; import * as z from '@zod/mini'; import * as z_2 from 'zod/mini'; -type Archive = z.infer; - -const ARCHIVE_VERSION = 3; - -const ArchiveSchema: z.ZodMiniObject<{ - timelines: z.ZodMiniRecord, z.ZodMiniObject<{ - track: z.ZodMiniString; - duration: z.ZodMiniOptional, "u53", "out">>; - }, z.core.$strip>>; - timescale: z.ZodMiniDefault, "u53", "out">>; - replay: z.ZodMiniOptional>>; - replayEpoch: z.ZodMiniOptional>>; - store: z.ZodMiniOptional; - version: z.ZodMiniOptional, "u53", "out">>; -}, z.core.$strip>; - type Arrival = z.infer; const ArrivalSchema: z.ZodMiniObject<{ @@ -94,6 +78,10 @@ const AudioConfigSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; const AudioSchema: z.ZodMiniUnion, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodMiniPipe, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; }, z.core.$strip>, z.ZodMiniTransform<{ renditions: { @@ -202,6 +198,10 @@ const AudioSchema: z.ZodMiniUnion) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }; }, { @@ -237,6 +237,10 @@ const AudioSchema: z.ZodMiniUnion) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }>>]>; @@ -291,15 +295,12 @@ declare namespace Catalog { ProjectionSchema, VideoConfigSchema, VideoSchema, - MOQ_EPOCH_UNIX_MILLIS, - ARCHIVE_VERSION, - ArchiveSchema, - Archive, BinaryConfigSchema, BinarySchema, Binary, BinaryConfig, wallClockTime, + MOQ_EPOCH_UNIX_MILLIS, ClockSchema, Clock, compressionSupported, @@ -321,6 +322,9 @@ declare namespace Catalog { DEFAULT_FORMAT, hexSchema, Hex, + HlsSchema, + Hls_2 as Hls, + HlsFieldSchema, u8, u53, u8Schema, @@ -382,15 +386,12 @@ declare namespace Catalog_2 { ProjectionSchema, VideoConfigSchema, VideoSchema, - MOQ_EPOCH_UNIX_MILLIS, - ARCHIVE_VERSION, - ArchiveSchema, - Archive, BinaryConfigSchema, BinarySchema, Binary, BinaryConfig, wallClockTime, + MOQ_EPOCH_UNIX_MILLIS, ClockSchema, Clock, compressionSupported, @@ -412,6 +413,9 @@ declare namespace Catalog_2 { DEFAULT_FORMAT, hexSchema, Hex, + HlsSchema, + Hls_2 as Hls, + HlsFieldSchema, u8, u53, u8Schema, @@ -536,7 +540,7 @@ class Consumer_2 implements AsyncIterable { [Symbol.asyncIterator](): AsyncIterator; close(): void; next(): Promise; - static subscribe(broadcast: Net.Broadcast.Consumer, archive: Catalog_2.Archive, track: string): Consumer_2; + static subscribe(broadcast: Net.Broadcast.Consumer, hls: Catalog_2.Hls): Consumer_2; } interface ConsumerProps { @@ -638,7 +642,7 @@ function encodeFrame(source: Uint8Array | Source, timestamp: Micro): Uint8Array; type Entry = Omit & { pts: Micro; - duration: Micro; + duration?: Micro; }; const EpochSchema: z.ZodMiniType; @@ -726,6 +730,28 @@ type Hex = z.infer; const hexSchema: z.ZodMiniString; +declare namespace Hls { + export { + SUFFIX_2 as SUFFIX, + Record_2 as Record, + Entry, + Event_2 as Event, + Consumer_2 as Consumer + } +} + +type Hls_2 = z.infer; + +const HlsFieldSchema: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; +}, z.core.$strip>>>; + +const HlsSchema: z.ZodMiniObject<{ + track: z.ZodMiniString; + duration: z.core.$ZodBranded, "u53", "out">; +}, z.core.$strip>; + type Input = "rtt" | "loss" | "headroom" | "skipped" | "late" | "errors" | "stalled"; function isEcho(path: Path.Valid): boolean; @@ -802,11 +828,6 @@ interface Observer { path: Path.Valid; } -interface Position_2 { - frame?: number; - group: number; -} - const PRIORITY: { readonly catalog: 100; readonly text: 90; @@ -885,11 +906,9 @@ interface ReceivedFrame extends Frame { interface Record_2 { duration?: number; - end: Position_2; - keyframe?: boolean; - pts?: number; + group: number; + pts: number; sequence: number; - start: Position_2; } type ReferenceEpoch = string & { @@ -1049,6 +1068,10 @@ const RootSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>>; display: z.ZodMiniOptional, "u53", "out">; @@ -1151,6 +1174,10 @@ const RootSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; }, z.core.$strip>>, z.ZodMiniTransform<{ renditions: { @@ -1232,6 +1259,10 @@ const RootSchema: z.ZodMiniObject<{ jitter?: (number & z.$brand<"u53">) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }; display: { @@ -1322,6 +1353,10 @@ const RootSchema: z.ZodMiniObject<{ jitter?: (number & z.$brand<"u53">) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }[]>>]>>; audio: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodMiniPipe, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; }, z.core.$strip>, z.ZodMiniTransform<{ renditions: { @@ -1430,6 +1473,10 @@ const RootSchema: z.ZodMiniObject<{ jitter?: (number & z.$brand<"u53">) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }; }, { @@ -1465,19 +1512,12 @@ const RootSchema: z.ZodMiniObject<{ jitter?: (number & z.$brand<"u53">) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }>>]>>; - archive: z.ZodMiniCatch, z.ZodMiniObject<{ - track: z.ZodMiniString; - duration: z.ZodMiniOptional, "u53", "out">>; - }, z.core.$strip>>; - timescale: z.ZodMiniDefault, "u53", "out">>; - replay: z.ZodMiniOptional>>; - replayEpoch: z.ZodMiniOptional>>; - store: z.ZodMiniOptional; - version: z.ZodMiniOptional, "u53", "out">>; - }, z.core.$strip>>>; clock: z.ZodMiniOptional, "u53", "out">; timescale: z.ZodMiniDefault>; @@ -1644,7 +1684,7 @@ const StatsSchema: z.ZodMiniObject<{ const SUFFIX = ".echo"; -const SUFFIX_2 = ".timeline.z"; +const SUFFIX_2 = ".hls"; type Text_2 = z_2.infer; @@ -1737,17 +1777,6 @@ interface Thresholds { }; } -declare namespace Timeline { - export { - Position_2 as Position, - Record_2 as Record, - SUFFIX_2 as SUFFIX, - Entry, - Event_2 as Event, - Consumer_2 as Consumer - } -} - class TooManyRenditions extends Error { constructor(count: number); readonly count: number; @@ -1933,6 +1962,10 @@ const VideoConfigSchema: z.ZodMiniObject<{ jitter: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; const VideoSchema: z.ZodMiniUnion, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>>; display: z.ZodMiniOptional, "u53", "out">; @@ -2129,6 +2166,10 @@ const VideoSchema: z.ZodMiniUnion, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; warmup: z.ZodMiniOptional, "u53", "out">>; delay: z.ZodMiniOptional, "u53", "out">, z.ZodMiniTransform<(number & z.$brand<"u53">) | undefined, number & z.$brand<"u53">>>>; + hls: z.ZodMiniCatch; + duration: z.core.$ZodBranded, "u53", "out">; + }, z.core.$strip>>>; }, z.core.$strip>; }, z.core.$strip>>, z.ZodMiniTransform<{ renditions: { @@ -2210,6 +2251,10 @@ const VideoSchema: z.ZodMiniUnion) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }; display: { @@ -2300,6 +2345,10 @@ const VideoSchema: z.ZodMiniUnion) | undefined; warmup?: (number & z.$brand<"u53">) | undefined; delay?: (number & z.$brand<"u53">) | undefined; + hls?: { + track: string; + duration: number & z.$brand<"u53">; + } | undefined; }; }[]>>]>; diff --git a/rs/moq-hls/MIGRATE.md b/rs/moq-hls/MIGRATE.md index 597206d6861..18efe15e48c 100644 --- a/rs/moq-hls/MIGRATE.md +++ b/rs/moq-hls/MIGRATE.md @@ -10,7 +10,7 @@ Each rendition reads the records its catalog `hls` field names, and segment `n` `Error::TimelineFailed` is `Error::RecordsFailed { track, source }`. A segment that does not line up with the lead rendition's is listed as `EXT-X-GAP` and never fetched, instead of snapping to a nearby keyframe. The DASH manifest has one period, with `t` and `d` in milliseconds. -Segment URL tags name each rendition's own record track, so every URL changes once. +Segment URL tags hash each rendition's own record track and its config, so every URL changes once, and a reconfigured rendition lists its later segments under new URLs. ### The segment cursor yields gaps diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 93b02309876..b6f0c6a0220 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -3259,6 +3259,29 @@ mod tests { assert!(rendered.contains("#EXT-X-DISCONTINUITY\n"), "{rendered}"); } + // A record whose successor goes backwards can't be bounded, but keeps its number as a gap: + // a hole would renumber every later URI under a player. + #[tokio::test(start_paused = true)] + async fn a_record_its_successor_cannot_bound_is_a_gap() { + let test = Records::with(&["video0"], &[]); + let (_track, mut video0) = test.publish("video0"); + video0.push(&gop(0)).unwrap(); + // Record 2 starts at an earlier group than record 1. + video0.push(&hang::hls::Record::new(1, 2, 4_000)).unwrap(); + video0.push(&hang::hls::Record::new(2, 1, 5_000)).unwrap(); + video0.push(&hang::hls::Record::new(3, 3, 6_000)).unwrap(); + + let snapshot = test.listed("video0", 2).await; + assert_eq!(numbers(&snapshot), [0, 1, 2]); + assert_eq!(gaps(&snapshot), [1]); + let rendered = test + .rendition("video0") + .media_playlist(None) + .unwrap() + .expect("playable"); + assert!(rendered.contains("#EXT-X-DISCONTINUITY\n"), "{rendered}"); + } + // The lead stays put while it is listed and enabled, so a rendition added later (whose // records are numbered from its own start) never makes the others' segments gaps. #[tokio::test(start_paused = true)] diff --git a/rs/moq-hls/src/export/segments.rs b/rs/moq-hls/src/export/segments.rs index 246e6dbccb9..115eb8d4dc5 100644 --- a/rs/moq-hls/src/export/segments.rs +++ b/rs/moq-hls/src/export/segments.rs @@ -390,13 +390,14 @@ impl Producer { /// The number of the segment whose `pts` is exactly `time` milliseconds (DASH `$Time$` /// addressing), or `None` when no listed row starts there. Exact match is safe because the - /// rendered `S@t` and this lookup convert the same [`Row::pts`] the same way. + /// rendered `S@t` and this lookup convert the same [`Row::pts`] the same way. A stop is never + /// rendered, so it never answers, even where content resumes at its pts. pub fn segment_number_at(&self, tag: &str, time: u64) -> Option { let state = self.state.read(); state .rows .iter() - .find(|row| row.pts.as_millis() == time as u128 && &*row.tag == tag) + .find(|row| !row.stop && row.pts.as_millis() == time as u128 && &*row.tag == tag) .map(|row| row.segment) } @@ -666,6 +667,23 @@ mod tests { } } + // Content that resumes at the pts it stopped shares a `$Time$` with the stop, which the MPD + // omits, so the time names the resumed segment. + #[test] + fn a_time_names_the_resumed_segment_not_the_stop() { + let live = Producer::new(0); + let window = Duration::from_secs(30); + live.push(row(0, 0, 0, 4_000), window); + let mut stop = row(1, 1, 4_000, 0); + stop.groups = 1..1; + stop.stop = true; + live.push(stop, window); + live.push(row(2, 1, 4_000, 4_000), window); + let tag = tag("video.hls", b""); + assert_eq!(live.segment_number_at(&tag, 4_000), Some(2)); + assert_eq!(live.segment_number_at(&tag, 0), Some(0)); + } + #[test] fn every_row_is_listed() { let live = Producer::new(0); diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 7937e998da0..7f18815692e 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -1013,7 +1013,7 @@ impl Producer { /// Abort every catalog track with `err`, preventing further edits. pub fn abort(mut self, err: moq_net::Error) { - let reason = crate::Error::from(err); + let reason = crate::Error::from(err.clone()); { let mut state = take(&self.current); if state.closed.is_some() { @@ -1022,8 +1022,9 @@ impl Producer { state.closed = Some(reason.clone()); } self.outputs.abort(&reason); + // The records fail too, rather than reading as a complete recording. if let Some(hls) = &self.outputs.hls { - hls.finish(); + hls.abort(&err); } } @@ -1489,6 +1490,35 @@ mod test { copy.abort(moq_net::Error::Cancel); } + /// An aborted catalog fails its HLS records with the cause, so an edge never ends the playlist + /// as a complete recording. + #[tokio::test(start_paused = true)] + async fn abort_fails_the_hls_records() { + let mut broadcast = moq_net::broadcast::Info::default().produce(); + let catalog = Producer::<()>::new(&mut broadcast, hls()).unwrap(); + let _recorder = catalog + .enroll("video0", Some(std::time::Duration::from_secs(4))) + .unwrap(); + let mut reader = broadcast + .consume() + .track("video0.hls") + .unwrap() + .subscribe(None) + .await + .unwrap(); + catalog.abort(moq_net::Error::Unauthorized); + loop { + match reader.recv_group().await { + Ok(Some(_)) => continue, + Ok(None) => panic!("the records ended as complete"), + Err(err) => { + assert!(matches!(err, moq_net::Error::Unauthorized), "{err:?}"); + break; + } + } + } + } + #[tokio::test(start_paused = true)] async fn abort_does_not_replace_a_finished_catalog() { let mut broadcast = moq_net::broadcast::Info::default().produce(); diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs index 8fca6760e9c..49481461866 100644 --- a/rs/moq-mux/src/hls.rs +++ b/rs/moq-mux/src/hls.rs @@ -25,7 +25,8 @@ //! //! HLS needs segment `n` to start at the same pts in every rendition. The first track enrolled //! paces the segments; every later track follows it, starting a segment at its first group at or -//! after each of the pacer's segment starts, and stopping when the pacer stops. A follower records +//! after each of the pacer's segment starts (even one it reported before the pacer did), and +//! stopping when the pacer stops content. A follower records //! nothing before the pacer's first segment, so renditions that start together and share group //! boundaries (aligned GOPs, audio grouped on them) number the same segments alike. If the pacer's //! producer goes away, the next enrolled track still recording takes over. @@ -144,6 +145,15 @@ pub(crate) struct Segmenter { frontier: Option, /// Boundaries not yet reached, in order. cuts: VecDeque, + /// The newest boundary declared, so a repeat of it (several sources declaring the same ones) + /// is ignored. + cut: Option, + /// A follower's group starts after every cut it knows of, oldest first: a pacer reporting later + /// may still cut at one. Empty whenever `cuts` holds a boundary past the open segment. + undecided: VecDeque<(u64, Timestamp)>, + /// The newest reported content time, whether or not a segment is open, so a segment a late cut + /// starts reaches it. + reported: Option, /// Whether boundaries come only from cuts: a follower, or a pacer whose source declares them. manual: bool, /// Whether this track follows a pacer, starting a segment only at one of its cuts. @@ -169,6 +179,9 @@ impl Segmenter { last: None, frontier: None, cuts: VecDeque::new(), + cut: None, + undecided: VecDeque::new(), + reported: None, manual: false, following: false, ready: VecDeque::new(), @@ -232,10 +245,21 @@ impl Segmenter { self.open_at(group, pts); } self.last = Some(group); + self.report(pts); + Ok(()) + } + + /// Content reached `pts`. + fn report(&mut self, pts: Timestamp) { + if self + .reported + .is_none_or(|reported| pts.as_micros() > reported.as_micros()) + { + self.reported = Some(pts); + } if self.open.is_some() { self.advance(pts); } - Ok(()) } /// Group `group` starts at `pts`, after group `last`. @@ -256,6 +280,8 @@ impl Segmenter { if ceiling || self.boundary(start, pts, elapsed) { self.declared.get_or_insert(elapsed); self.begin(group, pts); + } else if self.following && self.cuts.is_empty() { + self.undecided.push_back((group, pts)); } } @@ -270,6 +296,10 @@ impl Segmenter { started = true; } if !started { + // With no cut ahead, the pacer may still report one at or before this group. + if self.cuts.is_empty() { + self.undecided.push_back((group, pts)); + } return; } } @@ -308,8 +338,8 @@ impl Segmenter { /// A frame report says where content starts; the stop after the last segment would otherwise /// land at its last frame's start. pub fn end(&mut self, pts: Timestamp) { - if !self.closed && self.open.is_some() { - self.advance(pts); + if !self.closed { + self.report(pts); } } @@ -333,8 +363,30 @@ impl Segmenter { return; } self.manual = true; - if self.cuts.back().is_none_or(|back| pts.as_micros() > back.as_micros()) { - self.cuts.push_back(pts); + if self.cut.is_some_and(|cut| pts.as_micros() <= cut.as_micros()) { + return; + } + self.cut = Some(pts); + // A follower that reported past this cut before hearing it decides those groups now: the + // ones before it are not boundaries, and its first group at or after it starts a segment. + while self + .undecided + .front() + .is_some_and(|(_, start)| start.as_micros() < pts.as_micros()) + { + self.undecided.pop_front(); + } + match self.undecided.pop_front() { + Some((group, start)) => { + if let Some(open) = self.open { + self.declared.get_or_insert(elapsed(open, start)); + } + self.begin(group, start); + if let Some(reported) = self.reported { + self.advance(reported); + } + } + None => self.cuts.push_back(pts), } } @@ -348,6 +400,8 @@ impl Segmenter { } fn stop_at(&mut self, group: u64) { + // What a follower reported past its last cut stops with it. + self.undecided.clear(); let Some(start) = self.open.take() else { return; }; @@ -385,6 +439,8 @@ impl Segmenter { self.manual = false; self.following = false; self.cuts.clear(); + // Groups no cut decided stay in the open segment, as a pacer's do until its target. + self.undecided.clear(); } } @@ -403,6 +459,8 @@ impl Iterator for Segmenter { /// Records must be pushed in sequence order, starting at the window's next index. pub struct Producer { sink: moq_json::window::Producer, + /// The record track, to abort. + track: moq_net::track::Producer, } impl Producer { @@ -419,7 +477,8 @@ impl Producer { pub fn new(track: moq_net::track::Producer) -> Self { let config = moq_json::window::ProducerConfig::default().with_checkpoint_records(CHECKPOINT_RECORDS); Self { - sink: moq_json::window::Producer::new(track, config), + sink: moq_json::window::Producer::new(track.clone(), config), + track, } } @@ -443,6 +502,12 @@ impl Producer { self.sink.finish().map_err(json) } + /// Abort the record track with `err`, so readers see the cause rather than a complete record. + pub(crate) fn abort(self, err: moq_net::Error) { + // Idempotent: a track already closed keeps its first reason. + let _ = self.track.abort(err); + } + /// Restate the window in a fresh group if a joiner wants one. /// /// Checked on each edit rather than driven by a task, since nothing here owns a runtime. A @@ -521,6 +586,15 @@ impl Live { } } + /// Abort the record track with `err`, recording nothing more. + fn abort(&mut self, err: moq_net::Error) { + self.on_declare = None; + self.segmenter.closed = true; + if let Some(output) = self.output.take() { + output.abort(err); + } + } + fn finish(&mut self) { // Nothing advertises a declaration once the tracks finish. self.on_declare = None; @@ -689,6 +763,15 @@ impl Tracks { self.state.lock().unwrap().tracks.len() } + /// Abort every record track with `err`. Later enrollments fail. + pub fn abort(&self, err: &moq_net::Error) { + let mut state = self.state.lock().unwrap(); + state.finished = true; + for (_, live) in state.tracks.values() { + live.lock().unwrap().abort(err.clone()); + } + } + /// Stop every open segment and finish every record track. Later enrollments fail. pub fn finish(&self) { let mut state = self.state.lock().unwrap(); @@ -1094,15 +1177,56 @@ mod test { for (group, pts) in [(1, 90), (2, 110), (3, 2_000), (4, 4_090), (5, 4_110)] { follower.frame(group, ms(pts)).unwrap(); } + // The pacer's second segment starts after the follower already reported group 5 past it, + // which still starts the follower's. follower.follow(Mark::Start(ms(4_100))); + // The follower's own source declaring the same boundary again changes nothing. + follower.cut(ms(4_100)); follower.frame(6, ms(4_130)).unwrap(); + follower.cut(ms(4_100)); follower.follow(Mark::Stop); assert_eq!( drain(&mut follower), - [(2, 110, None), (6, 4_130, None), (7, 4_130, Some(0))] + [(2, 110, None), (5, 4_110, None), (7, 4_130, Some(0))] ); } + // Aligned GOPs whose follower frame is reported just before the pacer's: each group the + // follower reported ahead still starts its segment once the pacer's cut arrives. + #[tokio::test] + async fn a_follower_reporting_first_keeps_the_pacers_boundaries() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let tracks = Tracks::new(&broadcast); + let mut pacer = tracks.track("video0", hinted()).unwrap(); + let mut follower = tracks.track("video1", hinted()).unwrap(); + for group in 0..4 { + follower.frame(group, ms(group * 4_000)); + pacer.frame(group, ms(group * 4_000)); + } + let records = |name: &str| { + let state = tracks.state.lock().unwrap(); + let live = state.tracks[name].1.lock().unwrap(); + live.segmenter.sequence() + }; + assert_eq!(records("video1"), records("video0")); + drop((pacer, follower)); + tracks.finish(); + + let consumer = broadcast.consume(); + let read = async |name: &str| { + let hls = tracks.hls(name).unwrap(); + let mut consumer = Consumer::subscribe(&consumer, &hls).await.unwrap(); + let mut out = Vec::new(); + while let Some(Event::Push { entry, .. }) = consumer.next().await.unwrap() { + out.push((entry.sequence, entry.group, Duration::from(entry.pts).as_millis())); + } + out + }; + let video0 = read("video0").await; + assert_eq!(&video0[..4], [(0, 0, 0), (1, 1, 4_000), (2, 2, 8_000), (3, 3, 12_000)]); + assert_eq!(read("video1").await, video0); + } + #[tokio::test] async fn followers_number_segments_like_the_pacer() { let broadcast = moq_net::broadcast::Info::new().produce(); @@ -1139,11 +1263,11 @@ mod test { video, [(0, 0, false), (1, 4_000, false), (2, 8_000, false), (3, 8_000, true)] ); - // Each audio segment starts at its first group after the video segment's. Audio's first - // group was reported before video's, so it waits for the next. + // Each audio segment starts at its first group at or after the video segment's, though + // audio reported that group before video reported its start. assert_eq!( audio, - [(0, 25, false), (1, 4_025, false), (2, 8_025, false), (3, 8_985, true)] + [(0, 5, false), (1, 4_005, false), (2, 8_005, false), (3, 8_985, true)] ); } From c176d509d2dbe44ef4c2314b9b0141962fdf8645 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:05:20 -0700 Subject: [PATCH 06/20] fix(mux): bound follower alignment state and release undeclared enrollments - A follower forgets groups and cuts more than a segment ceiling behind the newest, so a stalled pacer or an idle follower costs alignment, not memory. - A recorder that ends without declaring drops its declaration callback, which held the catalog and kept the broadcast open. Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/catalog/producer.rs | 21 +++++++++ rs/moq-mux/src/hls.rs | 71 +++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 7f18815692e..7f64cb9fca1 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -2072,6 +2072,27 @@ mod test { assert!(matches!(reader.poll_finished(&waiter), std::task::Poll::Ready(Ok(_)))); } + #[test] + fn an_undeclared_enrollment_releases_the_broadcast() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); + let recorder = catalog.enroll("video0", None).unwrap(); + let waiter = kio::Waiter::noop(); + let mut subscribing = broadcast + .consume() + .track("video0.hls") + .unwrap() + .subscribe(None) + .into_inner(); + let std::task::Poll::Ready(Ok(mut reader)) = kio::Task::poll(&mut subscribing, &waiter) else { + panic!("the record track is live"); + }; + + // Dropped before any frame, so it never declared a duration. + drop((recorder, catalog, broadcast)); + assert!(matches!(reader.poll_finished(&waiter), std::task::Poll::Ready(_))); + } + #[test] fn clock_is_advertised_without_hls() { let mut broadcast = moq_net::broadcast::Info::new().produce(); diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs index 49481461866..6f07d9a788d 100644 --- a/rs/moq-mux/src/hls.rs +++ b/rs/moq-mux/src/hls.rs @@ -28,8 +28,10 @@ //! after each of the pacer's segment starts (even one it reported before the pacer did), and //! stopping when the pacer stops content. A follower records //! nothing before the pacer's first segment, so renditions that start together and share group -//! boundaries (aligned GOPs, audio grouped on them) number the same segments alike. If the pacer's -//! producer goes away, the next enrolled track still recording takes over. +//! boundaries (aligned GOPs, audio grouped on them) number the same segments alike. A follower +//! keeps at most a ceiling of boundaries waiting on the other track, so one that stalls costs +//! alignment, not memory. If the pacer's producer goes away, the next enrolled track still +//! recording takes over. //! //! ## Wiring //! @@ -281,10 +283,27 @@ impl Segmenter { self.declared.get_or_insert(elapsed); self.begin(group, pts); } else if self.following && self.cuts.is_empty() { - self.undecided.push_back((group, pts)); + self.undecide(group, pts); } } + /// Keep a follower's group at `pts` for a cut the pacer may still report. + /// + /// A pacer starts a segment at least once per ceiling while it reports, so a group more than a + /// ceiling behind this one could only meet a pacer stalled that long: forgetting it bounds the + /// queue, and that late cut starts the segment at a later group instead. + fn undecide(&mut self, group: u64, pts: Timestamp) { + let horizon = self.ceiling(); + while self + .undecided + .front() + .is_some_and(|(_, start)| elapsed(*start, pts) > horizon) + { + self.undecided.pop_front(); + } + self.undecided.push_back((group, pts)); + } + /// With no segment open, start one with group `group` at `pts`, unless a follower has no cut /// to start it at. fn open_at(&mut self, group: u64, pts: Timestamp) { @@ -298,7 +317,7 @@ impl Segmenter { if !started { // With no cut ahead, the pacer may still report one at or before this group. if self.cuts.is_empty() { - self.undecided.push_back((group, pts)); + self.undecide(group, pts); } return; } @@ -386,7 +405,18 @@ impl Segmenter { self.advance(reported); } } - None => self.cuts.push_back(pts), + None => { + // A follower that stopped reporting keeps only the cuts within a ceiling of this one: + // its next group past them all starts one segment either way, and one landing + // between them was already that far behind the pacer's numbering. + if self.following { + let horizon = self.ceiling(); + while self.cuts.front().is_some_and(|cut| elapsed(*cut, pts) > horizon) { + self.cuts.pop_front(); + } + } + self.cuts.push_back(pts); + } } } @@ -888,7 +918,11 @@ impl Drop for Recorder { live.segmenter.stop(last + 1); } live.recording = false; - live.publish() + let published = live.publish(); + // The callback holds the catalog, which holds these tracks: an enrollment that ends + // without declaring must drop it, or the broadcast never closes. + live.on_declare = None; + published }; // A pacer going away says nothing about where the others' content stops, so its last stop // isn't mirrored: each follower stops where its own content does, or paces on. @@ -1191,6 +1225,31 @@ mod test { ); } + #[test] + fn a_stalled_pacer_bounds_what_its_follower_keeps() { + let mut follower = Segmenter::new(hinted()).following(); + follower.follow(Mark::Start(ms(0))); + // 200s of audio past the pacer's only cut. + groups(&mut follower, 0..10_000, 20); + // A ceiling (three 4s segments) of groups waits for the pacer. + assert_eq!(follower.undecided.len(), 601); + // The pacer resuming that far behind starts the follower's next segment at its oldest kept + // group, so both still number it alike. + follower.follow(Mark::Start(ms(5_000))); + assert_eq!(drain(&mut follower), [(0, 0, None), (9_399, 187_980, None)]); + } + + #[test] + fn an_idle_follower_bounds_the_cuts_it_keeps() { + let mut follower = Segmenter::new(hinted()).following(); + for segment in 0..10_000 { + follower.follow(Mark::Start(ms(segment * 4_000))); + } + assert_eq!(follower.cuts.len(), 4); + follower.frame(0, ms(39_996_000)).unwrap(); + assert_eq!(drain(&mut follower), [(0, 39_996_000, None)]); + } + // Aligned GOPs whose follower frame is reported just before the pacer's: each group the // follower reported ahead still starts its segment once the pacer's cut arrives. #[tokio::test] From 4ae74859a3cdb833862a7ba782630e1eb09ddd2c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:15:46 -0700 Subject: [PATCH 07/20] fix(mux): record HLS segments for video and audio only A text track enrolled before the media became their pacer, so a sparse subtitle track held every video and audio segment open. Only video and audio renditions advertise records, so only they enroll. Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/catalog/producer.rs | 37 +++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 7f64cb9fca1..87edfc3f46d 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -797,7 +797,7 @@ impl Producer { } /// Build the media [`container::Producer`](crate::container::Producer) for `track`, - /// enrolling it for HLS records when [`Config::with_hls`] enabled them. + /// enrolling a video or audio track for HLS records when [`Config::with_hls`] enabled them. pub(super) fn media( &self, track: moq_net::track::Producer, @@ -811,7 +811,15 @@ impl Producer { R: super::RenditionConfig, crate::Error: From, { - let recorder = self.enroll(track.name(), hls_hint)?; + // Only video and audio renditions advertise records, and a sparse role (text) enrolled + // first would pace them, holding their segments open between its groups. + let role = std::any::TypeId::of::(); + let recorded = role == std::any::TypeId::of::() + || role == std::any::TypeId::of::(); + let recorder = match recorded { + true => self.enroll(track.name(), hls_hint)?, + false => None, + }; let sent = self.sent(rendition.name()); let mut producer = crate::container::Producer::with_rendition(track, container, rendition) .with_recorder(recorder) @@ -869,7 +877,7 @@ impl Producer { /// Enroll `track` for HLS records when [`Config::with_hls`] enabled them, advertising them in its /// rendition's `hls` field once the nominal duration is known. /// - /// The role-specific track constructors call this for you. fMP4 passthrough calls it directly + /// The video and audio track constructors call this for you. fMP4 passthrough calls it directly /// because it writes groups by hand instead of using a [`container::Producer`](crate::container::Producer). pub(crate) fn enroll( &self, @@ -2072,6 +2080,29 @@ mod test { assert!(matches!(reader.poll_finished(&waiter), std::task::Poll::Ready(Ok(_)))); } + #[test] + fn a_text_track_neither_records_nor_paces() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); + let net = broadcast + .create_track("text0", catalog.track_info(hang::catalog::PRIORITY.text)) + .unwrap(); + let _text = catalog + .text( + net, + crate::catalog::hang::Container::Legacy(crate::container::Kind::Data), + hang::catalog::TextConfig::new(hang::catalog::TextFormat::Vtt), + ) + .unwrap(); + assert_eq!(catalog.hls_tracks(), 0); + + // The video enrolled after it paces its own segments. + let _video = video0(&mut broadcast, &catalog, &[0, 4_000, 8_000]); + assert_eq!(catalog.hls_tracks(), 1); + let snapshot = catalog.snapshot(); + assert!(snapshot.video.renditions["video0"].hls.is_some()); + } + #[test] fn an_undeclared_enrollment_releases_the_broadcast() { let mut broadcast = moq_net::broadcast::Info::new().produce(); From f09216fcfb6a46f7e33ec8889b1e34ff8133767a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 17:28:39 -0700 Subject: [PATCH 08/20] fix(hls): settle the edge and record failure findings - A replacement inherits its predecessor's starting record only when both read the same record track. - A row says whether a later segment followed it, so an inline init never races the stop row into reading an open group. - A successor at the record's own group leaves it no group, so it is a gap. - A failed record push or a rejected report aborts the record track with its cause instead of finishing or dropping it. - A routes test asserts with the rendition's real tag, and the draft marks `duration` as an optional member. Co-Authored-By: Claude Opus 5.5 --- drafts/draft-lcurley-moq-hang.md | 2 +- rs/moq-hls/src/export/mod.rs | 37 ++++++++++++++++++++++++ rs/moq-hls/src/export/rendition.rs | 44 ++++++++++++++++++++--------- rs/moq-hls/src/export/renditions.rs | 8 +++--- rs/moq-hls/src/export/segments.rs | 39 ++++++++++++++----------- rs/moq-hls/src/server/routes.rs | 9 ++++-- rs/moq-mux/src/catalog/producer.rs | 8 +----- rs/moq-mux/src/error.rs | 10 +++++++ rs/moq-mux/src/hls.rs | 22 +++++++++------ 9 files changed, 126 insertions(+), 53 deletions(-) diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index cbca368fdc5..6765ac2c3bf 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -1019,7 +1019,7 @@ type HlsRecord = { "sequence": number, "group": number, "pts": number, - "duration": number | undefined, + "duration"?: number, } ~~~ diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index b5271f36c93..4786b1a327c 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -3654,6 +3654,22 @@ mod tests { assert!(rendered.contains("#EXT-X-DISCONTINUITY\n"), "{rendered}"); } + // A successor starting at the same group leaves the record no group to serve, so it is a gap + // rather than an advertised segment that can only 404. + #[tokio::test(start_paused = true)] + async fn a_record_its_successor_leaves_no_group_is_a_gap() { + let test = Records::with(&["video0"], &[]); + let (_track, mut video0) = test.publish("video0"); + video0.push(&gop(0)).unwrap(); + video0.push(&hang::hls::Record::new(1, 1, 2_000)).unwrap(); + video0.push(&hang::hls::Record::new(2, 1, 3_000)).unwrap(); + video0.push(&hang::hls::Record::new(3, 2, 4_000)).unwrap(); + + let snapshot = test.listed("video0", 2).await; + assert_eq!(numbers(&snapshot), [0, 1, 2]); + assert_eq!(gaps(&snapshot), [1]); + } + // The lead stays put while it is listed and enabled, so a rendition added later (whose // records are numbered from its own start) never makes the others' segments gaps. #[tokio::test(start_paused = true)] @@ -3731,6 +3747,27 @@ mod tests { } } + // A replacement reading another record track shares no numbering with its predecessor, so it + // lists that track's records from the first. + #[tokio::test(start_paused = true)] + async fn a_replacement_on_another_record_track_lists_all_of_it() { + let mut test = Records::with(&["video0"], &[]); + let (_, mut records) = test.publish("video0"); + let _media = test.media("video0", 4); + for sequence in 0..4 { + records.push(&gop(sequence)).unwrap(); + } + test.listed("video0", 2).await; + + test.catalog.video.renditions.get_mut("video0").unwrap().hls = Some(hls("video0b")); + let (_, mut fresh) = test.publish("video0b"); + for sequence in 0..2 { + fresh.push(&gop(sequence)).unwrap(); + } + test.sync(); + assert_eq!(numbers(&test.listed("video0", 0).await), [0]); + } + /// The `Push` events a fresh subscriber to `track`'s records reads before it is caught up. async fn fresh_join_reads(test: &Records, track: &str) -> usize { let mut records = moq_mux::hls::Consumer::subscribe(&test.broadcast.consume(), &hls(track)) diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 191c4831336..0bcf4d217c7 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -280,11 +280,19 @@ pub(crate) enum Content { Pending, } +/// Where a retired rendition stops listing its records, for its replacement to take over. +pub(crate) struct Handoff { + /// The record track it read. + track: String, + /// The first record it will not list. + next: u64, +} + /// Which records a rendition lists, shared by its records watcher and [`Rendition::retire`] so a /// replacement starts exactly where a retired instance stops. #[derive(Default)] struct Claim { - /// Records numbered below this are a predecessor's (see [`Rendition::with_start`]). + /// Records numbered below this are a predecessor's (see [`Rendition::after`]). start: u64, /// The first record this instance will not list: the one after its pending record, else the one /// after the newest it handled. @@ -507,9 +515,13 @@ impl Rendition { *self.watcher.lock().expect("watcher lock poisoned") = Some(watcher); } - /// List only records numbered `start` or later, the rest being a retired predecessor's (see - /// [`retire`](Self::retire)). - pub(crate) fn with_start(mut self, start: u64) -> Self { + /// Take over from a retired predecessor (see [`retire`](Self::retire)): list only the records + /// it won't. One reading another record track shares no numbering with it, so it lists all. + pub(crate) fn after(mut self, handoff: Option<&Handoff>) -> Self { + let Some(handoff) = handoff.filter(|handoff| handoff.track == self.hls.track) else { + return self; + }; + let start = handoff.next; self.claim = Mutex::new(Claim { start, next: start, @@ -520,20 +532,22 @@ impl Rendition { } /// End the window once the segment its records are still bounding is listed, because the - /// catalog dropped or replaced the rendition. Returns the first record it will not list, where - /// a replacement starts. + /// catalog dropped or replaced the rendition. Returns where a replacement takes over. /// /// Its records go on (a replacement follows them too, and the publisher never finishes a /// dropped track's), so they can't end it. Ending it at once would lose the last segment of /// every recording, since it is listed only when the record after it arrives. - pub(crate) fn retire(&self) -> u64 { + pub(crate) fn retire(&self) -> Handoff { let next = { let mut claim = self.claim.lock().expect("claim lock poisoned"); claim.retired = true; claim.next }; self.retiring.send_replace(true); - next + Handoff { + track: self.hls.track.clone(), + next, + } } /// The [tag](segments::tag) every segment URL carries. @@ -1309,8 +1323,8 @@ async fn watch_records( broken = false; rendition.push(row); } - // A successor that goes backwards can't bound it, but its number keeps - // its slot: a hole would renumber every later URI. + // A successor that holds none of its groups can't bound it, but its + // number keeps its slot: a hole would renumber every later URI. None => { rendition.push(gap(&rendition, &previous, broken)); broken = true; @@ -1386,10 +1400,12 @@ fn gap(rendition: &Rendition, record: &moq_mux::hls::Entry, broken: bool) -> seg end: pts, discontinuity: rendition.fanout.stamp(record.sequence, broken, pts), stop: true, + complete: false, } } -/// The row of `record`, bounded by its successor `next`, or `None` when `next` goes backwards. +/// The row of `record`, bounded by its successor `next`, or `None` when `next` leaves it no +/// group or goes backwards. fn row( rendition: &Rendition, record: &moq_mux::hls::Entry, @@ -1397,10 +1413,10 @@ fn row( broken: bool, ) -> Option { let pts = Duration::from(record.pts); - if next.group < record.group || Duration::from(next.pts) < pts { + if next.group <= record.group || Duration::from(next.pts) < pts { tracing::warn!( segment = record.sequence, - "HLS record goes backwards; dropping its segment" + "HLS record's successor holds none of its groups; listing it as a gap" ); return None; } @@ -1416,6 +1432,7 @@ fn row( end, discontinuity: rendition.fanout.stamp(record.sequence, broken, end), stop: false, + complete: !next.is_stop(), }) } @@ -1593,6 +1610,7 @@ mod tests { end: Duration::from_secs(1), discontinuity: 0, stop: false, + complete: true, }; let segment = rendition.fetch(&row); tokio::pin!(segment); diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index e36282e2e84..887469d4b79 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -454,10 +454,10 @@ impl Producer { .collect(); // A replacement lists the records its retired predecessor won't, so no record is muxed // under both configs. - let mut starts = BTreeMap::new(); + let mut handoffs = BTreeMap::new(); for key in stale { if let Some(rendition) = current.renditions.remove(&key) { - starts.insert(key, rendition.retire()); + handoffs.insert(key, rendition.retire()); } } @@ -481,7 +481,7 @@ impl Producer { continue; }; let rendition = match Rendition::video(name.clone(), video, source, hls, clock, self.fanout()) { - Ok(rendition) => Arc::new(rendition.with_start(starts.get(&key).copied().unwrap_or(0))), + Ok(rendition) => Arc::new(rendition.after(handoffs.get(&key))), Err(err) => { tracing::warn!(rendition = name, %err, "ignoring unservable video rendition"); continue; @@ -507,7 +507,7 @@ impl Producer { continue; }; let rendition = match Rendition::audio(name.clone(), audio, source, hls, clock, self.fanout()) { - Ok(rendition) => Arc::new(rendition.with_start(starts.get(&key).copied().unwrap_or(0))), + Ok(rendition) => Arc::new(rendition.after(handoffs.get(&key))), Err(err) => { tracing::warn!(rendition = name, %err, "ignoring unservable audio rendition"); continue; diff --git a/rs/moq-hls/src/export/segments.rs b/rs/moq-hls/src/export/segments.rs index 12afed7e56b..cd51d1827f1 100644 --- a/rs/moq-hls/src/export/segments.rs +++ b/rs/moq-hls/src/export/segments.rs @@ -187,6 +187,9 @@ pub(crate) struct Row { /// Content stopped here: a zero-duration record holding no media, listed as a gap so the /// numbering stays consecutive. pub stop: bool, + /// Whether a later segment started after this one, so its groups are all complete. One only a + /// stop bounds may end in a group the publisher is still writing. + pub complete: bool, } /// A consistent read of the window, for rendering one playlist (the serve path only). @@ -403,27 +406,20 @@ impl Producer { /// The first groups of the newest segments holding media, to bootstrap an inline-parameter-set /// codec's init from (a segment starts at a group, and a group at a keyframe), in the order to - /// try them: the newest whose group a later one followed, which is complete and so read from - /// cache at once, then the newest when only a stop bounds it and its group may still be open. + /// try them: the newest whose first group is complete, read from cache at once, then the + /// newest when its group may still be open. pub fn init_groups(&self) -> Vec { let state = self.state.read(); - let rows = &state.rows; - // A row is listed once its successor arrives, so with nothing after it, a later segment - // already started. - let complete = |i: usize| { - let groups = &rows[i].groups; - groups.end > groups.start + 1 || rows.get(i + 1).is_none_or(|next| !next.stop) - }; - let mut media = (0..rows.len()).rev().filter(|&i| !rows[i].stop); + let complete = |row: &Row| row.complete || row.groups.end > row.groups.start + 1; + let mut media = state.rows.iter().rev().filter(|row| !row.stop); let Some(newest) = media.next() else { return Vec::new(); }; - let newest_group = rows[newest].groups.start; if complete(newest) { - return vec![newest_group]; + return vec![newest.groups.start]; } - let older = media.find(|&i| complete(i)).map(|i| rows[i].groups.start); - older.into_iter().chain([newest_group]).collect() + let older = media.find(|row| complete(row)).map(|row| row.groups.start); + older.into_iter().chain([newest.groups.start]).collect() } /// Whether the playlist has anything to serve yet (at least one segment, or the records @@ -692,6 +688,7 @@ mod tests { end: Duration::from(pts) + Duration::from_millis(duration_ms), discontinuity: 0, stop: false, + complete: true, } } @@ -830,11 +827,11 @@ mod tests { assert_eq!(live.row("00000000", 0), None, "another record track's numbering misses"); assert_eq!(live.init_groups(), [0], "a stop holds no media"); live.push(row(2, 1, 1_000, 1_000), window); - live.push(row(3, 2, 2_000, 1_000), window); + // Only the stop after it bounds group 2, which the row says before the stop is listed. live.push( Row { - stop: true, - ..row(4, 3, 3_000, 0) + complete: false, + ..row(3, 2, 2_000, 1_000) }, window, ); @@ -843,6 +840,14 @@ mod tests { [1, 2], "a group only a stop bounds may be open, so a complete one goes first" ); + live.push( + Row { + stop: true, + ..row(4, 3, 3_000, 0) + }, + window, + ); + assert_eq!(live.init_groups(), [1, 2]); } #[test] diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index 76e134cfa84..af8687d9087 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -685,9 +685,12 @@ mod tests { assert_eq!(oneshot(app.clone(), denied_uri).await.status(), StatusCode::FORBIDDEN); assert_eq!(oneshot(app.clone(), allowed_uri).await.status(), StatusCode::OK); assert_eq!( - oneshot(app, &format!("/room/live/@{RUN}/video/video0/seg/53e0aafd.9999.m4s")) - .await - .status(), + oneshot( + app, + &format!("/room/live/@{RUN}/video/video0/seg/{}.9999.m4s", tag("video0")) + ) + .await + .status(), StatusCode::NOT_FOUND ); // Readiness settles even when no rendition is authorized; it must not need diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 87edfc3f46d..52343b7d703 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -244,13 +244,7 @@ impl Outputs { /// The three tracks carry one catalog, so a failure part way through `emit` must not leave some /// of them serving a newer catalog than the others. Consumers see the error instead. fn abort(&mut self, err: &crate::Error) { - // Only a transport error has a wire code; anything else (an extension that won't serialize) - // reaches consumers as a generic failure while the cause stays on the producer. - let reason = match err { - crate::Error::Moq(err) => err.clone(), - crate::Error::Json(moq_json::Error::Net(err)) => err.clone(), - _ => moq_net::StreamError::Internal.into(), - }; + let reason = err.reason(); // Idempotent: a track that is already closed keeps its first reason. let _ = self.hang.clone().abort(reason.clone()); let _ = self.hangz.clone().abort(reason.clone()); diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 081dfa3bcaf..aeaba4c0537 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -325,6 +325,16 @@ impl Error { pub(crate) fn unsupported_container(container: &hang::catalog::UnknownContainer) -> Self { Self::UnsupportedContainer(container.kind().unwrap_or("").to_string()) } + + /// The reason a track this error aborts gives its subscribers. Only a transport error has a + /// wire code; anything else reaches them as a generic failure while the cause stays here. + pub(crate) fn reason(&self) -> moq_net::Error { + match self { + Self::Moq(err) => err.clone(), + Self::Json(moq_json::Error::Net(err)) => err.clone(), + _ => moq_net::StreamError::Internal.into(), + } + } } impl From for Error { diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs index 6f07d9a788d..c221b43ba3e 100644 --- a/rs/moq-mux/src/hls.rs +++ b/rs/moq-mux/src/hls.rs @@ -601,9 +601,11 @@ impl Live { continue; }; if let Err(err) = output.push(&record) { - tracing::warn!(%err, "HLS record publish failed; dropping the record track"); - let _ = output.finish(); - self.output = None; + tracing::warn!(%err, "HLS record publish failed; aborting the record track"); + // Aborted rather than finished: a clean end reads as a complete recording. + if let Some(output) = self.output.take() { + output.abort(err.reason()); + } } } let declare = match self.segmenter.declared() { @@ -884,10 +886,8 @@ impl Recorder { let published = { let mut live = self.live.lock().unwrap(); if let Err(err) = live.segmenter.frame(group, pts) { - tracing::warn!(%err, "HLS report rejected; dropping the record track"); - live.segmenter.closed = true; - // Dropped without a finish, so subscribers see the record track fail rather than end. - live.output = None; + tracing::warn!(%err, "HLS report rejected; aborting the record track"); + live.abort(err.reason()); } live.publish() }; @@ -1376,7 +1376,13 @@ mod test { while let Ok(Some(_)) = next { next = consumer.next().await; } - assert!(next.is_err(), "the track fails rather than ends"); + // Aborted with a cause, not dropped, so a reader can tell a refused report from a gone + // publisher. + let err = next.expect_err("the track fails rather than ends"); + assert!( + matches!(err.reason(), moq_net::Error::Stream(moq_net::StreamError::Internal)), + "{err:?}" + ); } #[tokio::test] From 64d1b23d6b8ab18d426a92582f44475b9cc4b408 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 17:43:41 -0700 Subject: [PATCH 09/20] fix(mux): advertise HLS records only on the enrolled rendition The catalog matched records by an entry's track name, so a reference to a same-named track of another broadcast got this broadcast's records and lost its own. An enrolled rendition is keyed by its track's name, so match by the catalog ID instead. Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/catalog/producer.rs | 43 ++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 52343b7d703..9c3dd142615 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -199,15 +199,17 @@ impl Outputs { .video .renditions .iter_mut() - .map(|(id, config)| (id, &config.track, &mut config.hls)); + .map(|(id, config)| (id, &mut config.hls)); let audio = catalog .audio .renditions .iter_mut() - .map(|(id, config)| (id, &config.track, &mut config.hls)); - // Records live on the catalog's broadcast, a sibling rendition's included. - for (id, track, field) in video.chain(audio) { - if let Some(advertised) = hls.hls(track.as_deref().unwrap_or(id)) { + .map(|(id, config)| (id, &mut config.hls)); + // An enrolled rendition is keyed by its track's name, which its reservation holds, so only + // that entry gets the records; any other entry, such as a reference to a same-named track of + // another broadcast, keeps its own field. + for (id, field) in video.chain(audio) { + if let Some(advertised) = hls.hls(id) { *field = Some(advertised); } } @@ -2047,6 +2049,37 @@ mod test { ); } + #[test] + fn only_the_enrolled_rendition_gets_its_records() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = Producer::<()>::new(&mut broadcast, hls()).unwrap(); + let _video = catalog + .enroll("video", Some(std::time::Duration::from_secs(2))) + .unwrap(); + + // Same-named tracks of another broadcast, one carrying that broadcast's own records. + let source = hang::catalog::Hls::new("video.hls", std::time::Duration::from_secs(6)); + let local = VideoConfig::new(hang::catalog::VideoCodec::VP8); + let mut sibling = local.clone(); + sibling.broadcast = Some(moq_net::path::Relative::new("./other").to_owned()); + sibling.track = Some("video".into()); + sibling.hls = Some(source.clone()); + let mut unrecorded = sibling.clone(); + unrecorded.hls = None; + let mut input = Catalog::<()>::default(); + input.video.renditions.insert("video".into(), local); + input.video.renditions.insert("sibling".into(), sibling); + input.video.renditions.insert("unrecorded".into(), unrecorded); + + let advertised = catalog.outputs.advertise(&input); + let renditions = &advertised.video.renditions; + let records = catalog.outputs.hls.as_ref().unwrap().hls("video"); + assert!(records.is_some()); + assert_eq!(renditions["video"].hls, records); + assert_eq!(renditions["sibling"].hls, Some(source)); + assert_eq!(renditions["unrecorded"].hls, None); + } + /// A retired track's record track ends for the readers already on it and is forgotten, while /// one still recording is refused. #[test] From c32feb98e048ad9c2d0962b6f2cd8dd747c350c1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 17:47:46 -0700 Subject: [PATCH 10/20] fix(mux): hand over HLS pacing when the pacer's report is rejected A rejected report aborts the pacer's record track, which then cuts no more segments, while it stayed the pacer: every follower stopped recording. It now hands pacing to the earliest enrollment that can still pace, as a dropped pacer does. The draft also states that an audio lead yields once an enabled video rendition appears, as the edge already does. Co-Authored-By: Claude Opus 5.5 --- drafts/draft-lcurley-moq-hang.md | 3 +- rs/moq-mux/src/hls.rs | 65 +++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 6765ac2c3bf..87e97f40e01 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -1044,7 +1044,8 @@ An edge maps record `n` of each rendition to segment `n` of its playlist directl The HLS media sequence and the segment URL derive from the record `sequence`, so every edge numbers a segment alike. A segment is listed once the next record arrives; its `EXTINF`, its DASH `t` and `d`, and the timing of its last sample all come from its own record and the next. -An edge checks each rendition's segment `n` against the same segment of one lead rendition, the first enabled video rendition by name, or the first enabled audio rendition when there is none, kept while it stays enabled. +An edge checks each rendition's segment `n` against the same segment of one lead rendition, the first enabled video rendition by name, or the first enabled audio rendition when there is none. +The lead is kept while it stays listed and enabled, except that an audio lead yields once an enabled video rendition appears. A segment that does not start within half of the lead's segment of its own start, or that is a stop, has no content on that rendition: the edge lists it as a gap (HLS `EXT-X-GAP`, omitted from the DASH SegmentTimeline) and never serves media for it. A rendition does not list segment `n` before the lead does. The segment after a stop, or after records the edge missed, starts a discontinuity. diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs index c221b43ba3e..fef12ea60c1 100644 --- a/rs/moq-mux/src/hls.rs +++ b/rs/moq-mux/src/hls.rs @@ -883,15 +883,41 @@ impl Recorder { /// A report the segmenter cannot place ends the record track with an error rather than /// publishing records that silently omit frames. Media publishing is unaffected. pub fn frame(&mut self, group: u64, pts: Timestamp) { - let published = { + let (published, failed) = { let mut live = self.live.lock().unwrap(); - if let Err(err) = live.segmenter.frame(group, pts) { + let result = live.segmenter.frame(group, pts); + if let Err(err) = &result { tracing::warn!(%err, "HLS report rejected; aborting the record track"); live.abort(err.reason()); } - live.publish() + (live.publish(), result.is_err()) }; self.lead(&published.fire()); + if failed { + // An aborted pacer cuts nothing more, so its followers would never start a segment. + self.hand_over(); + } + } + + /// Hand pacing, if this track holds it, to the earliest enrollment that can still pace, or to + /// nobody until the next. + fn hand_over(&self) { + let mut state = self.state.lock().unwrap(); + if state.pacer.as_deref() != Some(self.name.as_str()) { + return; + } + let next = state + .tracks + .iter() + .filter_map(|(name, (_, live))| { + let live = live.lock().unwrap(); + (live.recording && !live.segmenter.closed).then_some((live.enrolled, name.clone())) + }) + .min(); + state.pacer = next.map(|(_, name)| name); + if let Some(pacer) = &state.pacer { + state.tracks[pacer].1.lock().unwrap().segmenter.pace(); + } } /// Report that the content extends to `pts`; see [`Segmenter::end`]. @@ -927,24 +953,7 @@ impl Drop for Recorder { // A pacer going away says nothing about where the others' content stops, so its last stop // isn't mirrored: each follower stops where its own content does, or paces on. published.fire(); - - // The earliest enrollment still recording takes over pacing, or nobody does until the next. - let mut state = self.state.lock().unwrap(); - if state.pacer.as_deref() != Some(self.name.as_str()) { - return; - } - let next = state - .tracks - .iter() - .filter_map(|(name, (_, live))| { - let live = live.lock().unwrap(); - live.recording.then_some((live.enrolled, name.clone())) - }) - .min(); - state.pacer = next.map(|(_, name)| name); - if let Some(pacer) = &state.pacer { - state.tracks[pacer].1.lock().unwrap().segmenter.pace(); - } + self.hand_over(); } } @@ -1343,6 +1352,20 @@ mod test { assert_eq!(next, 3, "audio paced its own segments at 0, 4s, and 8s"); } + #[tokio::test] + async fn a_rejected_pacer_hands_over_pacing() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let tracks = Tracks::new(&broadcast); + let mut video = tracks.track("video", hinted()).unwrap(); + let mut audio = tracks.track("audio", hinted()).unwrap(); + video.frame(1, ms(0)); + video.frame(0, ms(1_000)); + groups_on(&mut audio, 0..9, 1_000); + let live = tracks.state.lock().unwrap().tracks["audio"].1.clone(); + let next = live.lock().unwrap().segmenter.sequence(); + assert_eq!(next, 3, "audio paced its own segments at 0, 4s, and 8s"); + } + fn groups_on(recorder: &mut Recorder, range: std::ops::Range, step: u64) { for group in range { recorder.frame(group, ms(group * step)); From d40856c4c8ffa8e294f9538f31a964db76aefcb2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 17:56:26 -0700 Subject: [PATCH 11/20] fix(hls): prune dropped renditions as new ones register Stamping segments no longer walks the fanout's targets, so nothing pruned the dropped ones during normal streaming: a catalog that kept replacing renditions grew the edge without bound. Registration now prunes them. Co-Authored-By: Claude Opus 5.5 --- rs/moq-hls/src/export/mod.rs | 18 ++++++++++++++++++ rs/moq-hls/src/export/renditions.rs | 10 +++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 4786b1a327c..2d009d464dc 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -893,6 +893,24 @@ mod tests { assert_eq!((removed, added), (2, 2)); } + #[tokio::test] + async fn replaced_renditions_are_not_held() { + let origin = produce_origin(); + let _broadcast = origin.create_broadcast("live").expect("publish allowed"); + _broadcast.announce(Default::default()).expect("publish allowed"); + settle().await; + let upstream = empty_upstream(&origin, "live").await; + + let mut catalog = catalog_with_both(); + let renditions = renditions::Producer::new(Config::default().window); + for width in 1..=8 { + catalog.video.renditions.get_mut("video0").unwrap().coded_width = Some(width); + renditions.sync(&upstream, &catalog); + } + // The unchanged audio rendition, the current video one, and at most the one it replaced. + assert!(renditions.targets() <= 3, "{} targets", renditions.targets()); + } + // The whole fetch-on-demand path in process: a broadcast publishes media through the // catalog (which records its HLS segments), the Broadcaster renders playlists from the // records alone, and a segment request fetches and transmuxes exactly its groups. diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index 887469d4b79..b65489ce250 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -47,7 +47,7 @@ pub enum Event { /// cursor's `Arc` must keep its records flowing after `Broadcaster::drop` clears the /// map, or its final segments would be silently lost. struct Feed { - /// Every rendition ever created and still alive, pruned as they drop. + /// Every rendition created and still alive, the dropped ones pruned as the next registers. targets: Vec>, /// Stamps each segment number once, so every rendition agrees on where content breaks. discontinuities: Discontinuities, @@ -117,6 +117,12 @@ impl Producer { } } + /// How many renditions the fanout still reaches, dropped ones not yet pruned included. + #[cfg(test)] + pub(super) fn targets(&self) -> usize { + self.fanout.feed.read().targets.len() + } + /// The handle every rendition shares. pub fn fanout(&self) -> Fanout { self.fanout.clone() @@ -398,6 +404,8 @@ impl Producer { } else { rendition.watch(); } + // Pruned here, so a catalog that keeps replacing renditions holds only the live ones. + feed.targets.retain(|target| target.strong_count() > 0); feed.targets.push(Arc::downgrade(rendition)); } From 35313bc53b3602e6364e892eb8817a96b5f8d6b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 18:51:42 -0700 Subject: [PATCH 12/20] fix(hls): wake a cursor waiting on the lead when the lead hands over A cursor waiting for the lead to list a segment registered only on that lead's window, so a catalog that handed the lead to another rendition left it parked until the old lead changed again, which a stalled one never does. It now also registers on the shared feed, where a new lead is set. Co-Authored-By: Claude Opus 5.5 --- rs/moq-hls/src/export/mod.rs | 28 ++++++++++++++++++++++++++++ rs/moq-hls/src/export/rendition.rs | 4 +++- rs/moq-hls/src/export/renditions.rs | 11 +++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 2d009d464dc..98fbb13b4a4 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -3625,6 +3625,34 @@ mod tests { assert_eq!(numbers(&test.listed("video1", 4).await), [0, 1, 2, 3, 4]); } + // A cursor waiting on a stalled lead resumes once the lead hands over. + #[tokio::test(start_paused = true)] + async fn a_cursor_waiting_on_the_lead_follows_a_handover() { + let mut test = Records::new(); + let (_t0, mut video0) = test.publish("video0"); + let (_t1, mut video1) = test.publish("video1"); + let _media = test.media("video1", 4); + for sequence in 0..4 { + video1.push(&gop(sequence)).unwrap(); + } + // The lead lists only segment 0, bounded by record 1. + video0.push(&gop(0)).unwrap(); + video0.push(&gop(1)).unwrap(); + test.listed("video1", 0).await; + let mut cursor = test.rendition("video1").segments(); + assert_eq!(cursor.next().await.unwrap().unwrap().segment, 0); + // Spawned, so only a wake polls it again. + let mut waiting = tokio::spawn(async move { cursor.next().await.unwrap().unwrap().segment }); + settle().await; + assert!(!waiting.is_finished(), "segment 1 waits on the lead"); + + test.catalog.video.renditions.get_mut("video0").unwrap().enabled = false; + test.sync(); + assert_eq!(test_lead(&test), Some("video1".to_string())); + let next = tokio::time::timeout(Duration::from_secs(1), &mut waiting).await; + assert_eq!(next.expect("the handover wakes the cursor").unwrap(), 1); + } + // A stop is listed as a gap at once, and what resumes after it starts a new discontinuity // on every rendition. #[tokio::test(start_paused = true)] diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 0bcf4d217c7..057670d633e 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -620,7 +620,9 @@ impl Rendition { /// Poll until `row`'s alignment is decided. pub(crate) fn poll_resolved(&self, waiter: &kio::Waiter, row: &segments::Row) -> Poll<()> { - let Some(lead) = self.fanout.lead() else { + // Registered on lead changes too: a new lead may decide the row at once, while the old one + // may never list it. + let Some(lead) = self.fanout.poll_lead(waiter) else { return Poll::Ready(()); }; // Read before resolving, so a row the lead lists in between wakes this poll. diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index b65489ce250..b3b83533aa1 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -251,6 +251,17 @@ impl Fanout { self.feed.read().lead.as_ref()?.upgrade() } + /// [`lead`](Self::lead), registering `waiter` for the next change to what every rendition + /// shares, a new lead included. + pub fn poll_lead(&self, waiter: &kio::Waiter) -> Option> { + let mut lead = None; + let _ = self.feed.poll_ref(waiter, |feed| { + lead = feed.lead.as_ref().and_then(Weak::upgrade); + Poll::<()>::Pending + }); + lead + } + fn set_lead(&self, lead: Option<&Arc>) { let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); feed.lead = lead.map(Arc::downgrade); From a57f1c4db5b5cb874db284c44b1d9a29543b45b4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 09:45:54 -0700 Subject: [PATCH 13/20] fix(moq-studio): reserve the program video with with_hls_hint Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/engine.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index 56eb84372ec..e36a8e5b257 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -418,9 +418,7 @@ async fn publish( let (exporter, rendition) = Exporter::new(&gpu.device, &gpu.queue, &mut config) .await .context("cannot encode the canvas")?; - let reserved = catalog - .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)); + let reserved = catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)); let video = moq_video::encode::Producer::new(broadcast.clone(), reserved, rendition)?; let bus = moq_audio::bus::Config::default(); From 7ccfa57e6161960c9b1bbd38a0f93a3295308ac8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 11:05:58 -0700 Subject: [PATCH 14/20] fix(hls): advertise a rendition once its first segment is aligned A follower whose first rows waited on the lead was advertised and served a media playlist listing nothing. Readiness now waits for the first row's alignment, and the media playlist checks what it renders. Co-Authored-By: Claude Opus 5.5 --- rs/moq-hls/src/export/mod.rs | 32 +++++++++++++++++++++++++++ rs/moq-hls/src/export/rendition.rs | 22 +++++++++---------- rs/moq-hls/src/export/segments.rs | 35 ++++++++++++------------------ 3 files changed, 56 insertions(+), 33 deletions(-) diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 98fbb13b4a4..fe022b7ae68 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -3625,6 +3625,38 @@ mod tests { assert_eq!(numbers(&test.listed("video1", 4).await), [0, 1, 2, 3, 4]); } + // A rendition whose first segment waits on the lead is not advertised yet, so no player + // starts on a playlist that lists nothing. + #[tokio::test(start_paused = true)] + async fn a_rendition_is_advertised_once_the_lead_lists_its_first_segment() { + let test = Records::new(); + let (_t0, mut video0) = test.publish("video0"); + let (_t1, mut video1) = test.publish("video1"); + video1.push(&gop(0)).unwrap(); + video1.push(&gop(1)).unwrap(); + settle().await; + let rendition = test.rendition("video1"); + assert!(!rendition.is_advertised(), "segment 0 waits on the lead"); + assert_eq!(rendition.media_playlist(None).unwrap(), None); + // Spawned, so only a wake polls it again. + let mut playable = tokio::spawn({ + let rendition = rendition.clone(); + async move { rendition.playable().await } + }); + settle().await; + assert!(!playable.is_finished(), "segment 0 waits on the lead"); + + video0.push(&gop(0)).unwrap(); + video0.push(&gop(1)).unwrap(); + tokio::time::timeout(Duration::from_secs(1), &mut playable) + .await + .expect("the lead listing segment 0 wakes it") + .unwrap(); + assert!(rendition.is_advertised()); + let rendered = rendition.media_playlist(None).unwrap().expect("playable"); + assert!(rendered.contains("#EXTINF:"), "{rendered}"); + } + // A cursor waiting on a stalled lead resumes once the lead hands over. #[tokio::test(start_paused = true)] async fn a_cursor_waiting_on_the_lead_follows_a_handover() { diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 057670d633e..04cd1a70f54 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -713,13 +713,18 @@ impl Rendition { kio::wait(|waiter| self.poll_playable(waiter)).await; } - /// Poll until this rendition is playable, its media is denied, or its window failed. + /// Poll until this rendition is playable (its first segment's alignment is decided, or its + /// records ended empty), its media is denied, or its window failed. pub(crate) fn poll_playable(&self, waiter: &kio::Waiter) -> Poll<()> { self.poll_media(waiter); if self.media.refusal().is_some() { return Poll::Ready(()); } - self.live.poll_playable(waiter) + match self.live.poll_front(waiter) { + Poll::Ready(Some(front)) => self.poll_resolved(waiter, &front), + Poll::Ready(None) => Poll::Ready(()), + Poll::Pending => Poll::Pending, + } } /// End the window if the bound sibling ended, registering `waiter` to wake when it does, and @@ -757,9 +762,9 @@ impl Rendition { let Some(init) = self.built_init(&run) else { return Ok(None); }; - Ok(self - .is_playable() - .then(|| super::render_media(&snapshot, &init.hash, query))) + // A segment still waiting on the lead is not listed, so check what renders. + let playable = !snapshot.segments.is_empty() || snapshot.finished; + Ok(playable.then(|| super::render_media(&snapshot, &init.hash, query))) } /// Wait until the media playlist is servable, then render it: at least one segment is @@ -900,13 +905,6 @@ impl Rendition { .take_while(|(_, content)| *content != Content::Pending) } - /// Whether the playlist has anything to serve yet (at least one segment, or the records - /// already ended). - pub(crate) fn is_playable(&self) -> bool { - self.media.sync(&self.live); - self.live.is_playable() - } - /// Whether the multivariant playlist advertises this rendition, i.e. a player can start it. /// /// Every segment starts at a group, and every group at a keyframe, so a rendition can start diff --git a/rs/moq-hls/src/export/segments.rs b/rs/moq-hls/src/export/segments.rs index cd51d1827f1..1c4fa58e1f1 100644 --- a/rs/moq-hls/src/export/segments.rs +++ b/rs/moq-hls/src/export/segments.rs @@ -422,31 +422,24 @@ impl Producer { older.into_iter().chain([newest.groups.start]).collect() } - /// Whether the playlist has anything to serve yet (at least one segment, or the records - /// already ended). - #[cfg_attr( - not(feature = "server"), - allow(dead_code, reason = "only the `server` feature uses it") - )] - pub fn is_playable(&self) -> bool { - let state = self.state.read(); - state.ended || !state.rows.is_empty() - } - - /// Poll until [`is_playable`](Self::is_playable) or the window failed, for the serve path's - /// long-poll. - pub fn poll_playable(&self, waiter: &kio::Waiter) -> Poll<()> { + /// Poll until the window lists a row, returning the oldest, or `None` once it failed, ended + /// empty, or closed empty. A returned row still registers `waiter` for the window's next + /// change, so a caller waiting on that row's alignment also wakes when the front moves. + pub fn poll_front(&self, waiter: &kio::Waiter) -> Poll> { + let mut front = None; let poll = self.state.poll_ref(waiter, |state| { - if state.ended || state.failed.is_some() || !state.rows.is_empty() { - Poll::Ready(()) - } else { - Poll::Pending + if state.failed.is_some() || (state.ended && state.rows.is_empty()) { + return Poll::Ready(()); } + front = state.rows.front().cloned(); + // Pending even with a row, so the waiter registers. + Poll::Pending }); match poll { - // Ready, or the channel closed (no more rows will arrive): stop waiting either way. - Poll::Ready(_) => Poll::Ready(()), - Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => Poll::Ready(None), + // Closed: no more rows arrive, so the front is final. + Poll::Ready(Err(_)) => Poll::Ready(front), + Poll::Pending => front.map_or(Poll::Pending, |row| Poll::Ready(Some(row))), } } From 7432cdd045feb453a5587c1c61b58651b388ce0d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 14:01:44 -0700 Subject: [PATCH 15/20] feat(hls)!: cut each rendition's segments on its own pts grid Each rendition opens a segment when a group's pts falls in a later multiple of the grid than the previous group's, and numbers its own records, so renditions share no state. The pacer/follower machinery, the edge's lead check, and the lead-relative gaps are deleted; each rendition lists its own records and counts its own discontinuities, and DASH omits segmentAlignment. hls::Config::duration_min becomes duration. The last sample of a segment keeps a duration it already carries, so only an untimed trailing sample takes the segment's end. Co-Authored-By: Claude Opus 5.5 --- doc/bin/cli.md | 6 +- doc/bin/hls.md | 6 +- doc/concept/hang.md | 2 +- drafts/draft-lcurley-moq-hang.md | 16 +- js/hang/src/catalog/hls.ts | 5 +- quest/m1/README.md | 1 - quest/m1/archive/README.md | 4 +- quest/m1/archive/hls-target.md | 24 +- quest/m1/hls-conformance.md | 7 +- quest/m1/hls-follower-numbering.md | 26 -- quest/m2/js-hls.md | 6 +- rs/hang/src/catalog/hls.rs | 5 +- rs/hang/src/lib.rs | 2 +- rs/moq-audio/src/encode/capture.rs | 2 +- rs/moq-cli/MIGRATE.md | 2 +- rs/moq-cli/src/hls/record.rs | 107 ++--- rs/moq-hls/MIGRATE.md | 5 +- rs/moq-hls/src/export/archive_tests.rs | 40 +- rs/moq-hls/src/export/mod.rs | 236 ++++------ rs/moq-hls/src/export/mpd.rs | 10 +- rs/moq-hls/src/export/playlist.rs | 2 +- rs/moq-hls/src/export/rendition.rs | 166 ++----- rs/moq-hls/src/export/renditions.rs | 131 +++--- rs/moq-hls/src/export/segments.rs | 200 +-------- rs/moq-hls/src/server/mod.rs | 2 +- rs/moq-hls/src/server/routes.rs | 2 +- rs/moq-mux/MIGRATE.md | 4 +- rs/moq-mux/src/catalog/producer.rs | 2 +- rs/moq-mux/src/hls.rs | 588 ++++++------------------- rs/moq-video/src/encode/producer.rs | 2 +- 30 files changed, 461 insertions(+), 1150 deletions(-) delete mode 100644 quest/m1/hls-follower-numbering.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 459911607ef..fb9b1a7f146 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -155,9 +155,9 @@ moq --connect https://relay.example.com/anon --broadcast camera.hang \ ``` `--hls` publishes [HLS segment records](/bin/hls#how-export-works) beside each -video and audio rendition, which `export hls` needs to serve the broadcast. The -source must start every rendition's keyframes at the same timestamps. The stdin -formats, `hls`, `rtsp`, and `capture` take it; the other gateways build their +video and audio rendition, which `export hls` needs to serve the broadcast. +Renditions' segments line up wherever the source's keyframes share timestamps. +The stdin formats, `hls`, `rtsp`, and `capture` take it; the other gateways build their own catalog and refuse it. ## Export diff --git a/doc/bin/hls.md b/doc/bin/hls.md index 182a718e5f8..deb946586c0 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -50,7 +50,7 @@ Media is never transcoded, so the player must support the publisher's codecs. What that means for you: - **Opt in at the publisher.** Only a rendition whose catalog entry carries `hls` gets a playlist; nothing publishes it by default. Pass `--hls` to `moq import` (the stdin formats, `hls`, `rtsp`, and `capture`), or enable it with `moq_mux::catalog::Config::with_hls` in Rust. Without a duration hint, a rendition declares its records after its first segment. -- **Align keyframes across renditions.** Segment `n` of every rendition comes from that rendition's record `n`, so a publisher that opts in must start its renditions' segments at the same timestamps, and start them together. A rendition's segment that does not line up with the lead rendition's (the first enabled video rendition, or audio when there is none) is listed as `EXT-X-GAP`, left out of the DASH manifest, and never served. +- **Each rendition cuts its own segments.** A group starts a segment when its pts falls in a later multiple of the segment duration than the previous group's, and each rendition numbers its own segments. Players match renditions by timestamp, so nothing coordinates the ladder: segments line up wherever keyframes share timestamps, a rendition that starts late lines up from its next multiple, and audio lines up with video whose keyframes sit on those multiples. The DASH manifest leaves `segmentAlignment` out. A break in one rendition's content starts a discontinuity on that rendition alone; `EXT-X-PROGRAM-DATE-TIME`, written when the catalog has a clock, lets players resync. - **Idle renditions cost nothing**, and a segment is servable for as long as the relay's [cache](/bin/relay/config#cache) retains it. Keep `--window` (the playlist length, default 16 s) within that retention. - **Live joins cost the window.** The exporter reads the record checkpoint and lists at most 256 segments. - **Segments are timed by their records.** A segment is listed once the record after it arrives, and its duration, its DASH `t` and `d`, and its last sample's duration all come from its own record and the next, so a segment's bytes never change with a catalog estimate. A stop record (a break or the end of the content) is listed as a zero-length gap, and what follows it starts a discontinuity. @@ -84,8 +84,8 @@ The recording starts at the oldest segment in the `--window` and gets out, the HLS records restart, or a segment can't be fetched, the export exits with an error and leaves the playlists unended. A rendition whose decoder is reconfigured keeps one playlist, switching to a new init at an -`EXT-X-DISCONTINUITY` that every playlist marks at the same segment, so the whole -recording stays playable from its master playlist. +`EXT-X-DISCONTINUITY` in that playlist, so the whole recording stays playable +from its master playlist. `--listen-tls-cert`/`--listen-tls-key` or `--listen-tls-generate` serve HTTPS, and `--cors-origin` opens it to browsers. HLS TLS reads `MOQ_HLS_TLS_*`; diff --git a/doc/concept/hang.md b/doc/concept/hang.md index 22bd223ec61..53eb3b4b07c 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -70,7 +70,7 @@ A few things the catalog can express beyond decoder config: - **Spatial media.** A video rendition may carry `projection` (flat `rectilinear`, or `equirectangular` with the degrees of longitude it spans, 180 for VR180) and `stereo` (`mono`, `left-right`, or `top-bottom` frame packing), and an audio rendition may carry `layout: "ambisonic"` for AmbiX channels. Readers treat a value they don't recognize as absent. Nothing in `@moq/watch` renders them yet. - **Disabled renditions.** An audio or video rendition with `enabled: false` has no frames coming, and a player must not select it. Pausing a rendition in `@moq/publish` keeps it in the catalog this way rather than removing it, so a mute is a one-field update; a disabled video rendition encodes one black keyframe first, so a player that predates the field shows black instead of a frozen picture. The field is written only when false. The older video `stalled` field is no longer written, and readers ignore it. - **Preference.** A video rendition may carry a signed `preference`, default `0`. A player keeps only the highest preference it can decode, then picks by size and bitrate within it, as do the single-rendition egresses (RTMP play, WHEP, FLV, the transcoder's source). A publisher that adds its own H.264 copy of an H.265 source marks the copy `-1`, so only a viewer that can't decode H.265 pulls it. Picking a rendition by name still overrides it. The field is written only when nonzero. -- **HLS records.** A video or audio rendition may opt in with an `hls` field naming a small track of HLS segment records, each segment's first group and pts, plus the nominal segment duration. A publisher that sets it on several renditions starts their segments at the same timestamps, so an edge serves segment `n` of each from record `n`. The records are what let the [HLS gateway](/bin/hls) build playlists without subscribing to media; a rendition without them gets no playlist. Nothing publishes them unless the application enables them, and a reader that cannot decode the field ignores it. +- **HLS records.** A video or audio rendition may opt in with an `hls` field naming a small track of HLS segment records, each segment's first group and pts, plus the nominal segment duration. Each rendition numbers its own segments, and an edge serves a rendition's segment `n` from its record `n`; segments line up across renditions wherever their keyframes do. The records are what let the [HLS gateway](/bin/hls) build playlists without subscribing to media; a rendition without them gets no playlist. Nothing publishes them unless the application enables them, and a reader that cannot decode the field ignores it. - **Clock.** The optional root `clock` maps PTS zero to wall time so every media track and every HLS record share one fixed epoch after timescale conversion. Publish independent sources as sibling broadcasts under one prefix, such as `robot/front.hang` and `robot/rear.hang`; each broadcast's audio and video entries are renditions of that source. In one Rust publisher, pass the same `moq_mux::Clock` to each `catalog::Config::with_clock`; JS publishers on one page already share the page clock. Consumers discover the siblings by prefix and align their timestamps through the advertised wall mapping; `@moq/watch` plays one broadcast at a time. - **Version.** The root `version` is the oldest moq-hang draft that reads the catalog correctly, written only when a field needs one newer than 03: a `track` that differs from its ID, or a video `warmup`. Readers ignore fields they don't know, but refuse a `version` above their own with a typed "unsupported catalog version" error, so a player can say to upgrade instead of misreading the catalog. An additive field never raises it. - **Extensions.** The root is a loose object. Applications add their own sections (`scte35`, for example) next to the ones hang defines, optionally naming a track that carries the data. Every library exposes a way to write your section without clobbering the built-in ones, and readers ignore what they don't know. diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 87e97f40e01..4f47a853c4b 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -985,8 +985,8 @@ A publisher that does not know the target MAY omit `hls` until its first segment A consumer that cannot decode a rendition's `hls` field MUST treat it as absent without rejecting the rendition. -A publisher that sets `hls` on several renditions MUST start their segments at the same timestamps: segment `n` of every rendition starts at the same pts. -This is why the field is opt-in. +Each rendition's records number its own segments; a segment number means nothing across renditions. +Segments of different renditions line up wherever their boundaries share timestamps. ## Track Framing {#hls-framing} A record track is a sliding window of records. @@ -1040,15 +1040,13 @@ The newest record is therefore the live edge. A record MUST tolerate and SHOULD preserve unknown fields, like the catalog. ## Derived Formats {#hls-derived} -An edge maps record `n` of each rendition to segment `n` of its playlist directly. +An edge maps a rendition's record `n` to segment `n` of that rendition's playlist directly. The HLS media sequence and the segment URL derive from the record `sequence`, so every edge numbers a segment alike. A segment is listed once the next record arrives; its `EXTINF`, its DASH `t` and `d`, and the timing of its last sample all come from its own record and the next. -An edge checks each rendition's segment `n` against the same segment of one lead rendition, the first enabled video rendition by name, or the first enabled audio rendition when there is none. -The lead is kept while it stays listed and enabled, except that an audio lead yields once an enabled video rendition appears. -A segment that does not start within half of the lead's segment of its own start, or that is a stop, has no content on that rendition: the edge lists it as a gap (HLS `EXT-X-GAP`, omitted from the DASH SegmentTimeline) and never serves media for it. -A rendition does not list segment `n` before the lead does. -The segment after a stop, or after records the edge missed, starts a discontinuity. +A stop has no content: the edge lists it as a gap (HLS `EXT-X-GAP`, omitted from the DASH SegmentTimeline) and never serves media for it. +The segment after a stop, or after records the edge missed, starts a discontinuity on that rendition. +Each rendition counts its own discontinuities, so a break in one rendition alone makes its discontinuity sequence differ from the others'; `EXT-X-PROGRAM-DATE-TIME` lets players resynchronize across them. # MPEG-TS Service Information {#mpegts-si} @@ -1146,7 +1144,7 @@ This document has no IANA actions. ## moq-hang-04 {:numbered="false"} -- Replaced the root `archive` section and its per-track timelines with an opt-in, per-rendition `hls` field on video and audio ({{hls}}). Each record is a segment's first `group` and `pts` in milliseconds, with an optional `duration` where zero marks a stop; frame positions, `end`, `keyframe`, `timescale`, `replay`, `replayEpoch`, `store`, and `version` are gone. Renditions that set it align their segments, so an edge maps record `n` to segment `n`. The record track is named `.hls` and is not compressed. +- Replaced the root `archive` section and its per-track timelines with an opt-in, per-rendition `hls` field on video and audio ({{hls}}). Each record is a segment's first `group` and `pts` in milliseconds, with an optional `duration` where zero marks a stop; frame positions, `end`, `keyframe`, `timescale`, `replay`, `replayEpoch`, `store`, and `version` are gone. Each rendition numbers its own segments, and an edge maps a rendition's record `n` to its segment `n`. The record track is named `.hls` and is not compressed. - A reader refuses a JSON frame in which an object repeats a member name. - Added the root `version` ({{catalog-version}}): the oldest draft that reads the catalog, which a consumer refuses when above its own. A `track` other than the entry's ID or a video `warmup` needs 4. Consumers MUST ignore unknown fields. - A data track marks a discontinuity with a group of one object with no bytes, since its empty payload is data. An object with no bytes on an audio or video track, or sharing its group, is malformed. diff --git a/js/hang/src/catalog/hls.ts b/js/hang/src/catalog/hls.ts index 0737a15186d..775e82412e8 100644 --- a/js/hang/src/catalog/hls.ts +++ b/js/hang/src/catalog/hls.ts @@ -4,9 +4,8 @@ import { u53Schema } from "./integers"; /** * A rendition's HLS segment records, which exist only for HLS compatibility. * - * Opt-in, and only on video and audio renditions. A publisher that sets it aligns group - * boundaries across the renditions it sets it on, so segment `n` starts at the same pts in every - * one. A rendition without it is not HLS-renderable. See the `Hls` module for the records. + * Opt-in, and only on video and audio renditions. Each rendition numbers its own segments. A + * rendition without it is not HLS-renderable. See the `Hls` module for the records. */ export const HlsSchema = z.object({ // The track carrying the rendition's segment records, on the catalog's broadcast even when the diff --git a/quest/m1/README.md b/quest/m1/README.md index 47c07eaf48a..f804a432e9b 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -160,7 +160,6 @@ surface and zero-copy texture import, land before the cut. - [Generated JS API reference](/quest/m1/js-api-reference.md) - the doc site renders an API reference for every `@moq/*` package from API Extractor's doc model - [Interop coverage](/quest/m1/interop-coverage/README.md) - the riskiest moq-transport features moq-net exposes get cross-implementation tests over drafts 18 and 22 - [Calibrate the paired nightly benchmarks](/quest/m1/bench-ci.md) - A/A and deliberately slowed Nightly runs set the paired comparison's threshold, so its regression alerts can be trusted -- [HLS follower numbering](/quest/m1/hls-follower-numbering.md) - a late or rebuilt rendition numbers its HLS records from the pacer, so its segments line up - [HLS opt-in for gateways](/quest/m1/hls-opt-in-gateways.md) - SRT, RTMP, WebRTC ingest and the bindings can publish HLS records - [HLS takeover leak](/quest/m1/hls-takeover-leak.md) - a replaced instance's recording cursor never yields the replacement's media, and ends or fails instead of stalling - [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - nightly ffprobe checks that advertised timing, sync flags, and bytes match the media served diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index bb7ef234822..45a90940700 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -69,8 +69,8 @@ archive (per-track `.timeline.z` records named by the catalog's root ([Subscribe ranges](/quest/m1/subscribe-ranges/README.md)) is resolved by whoever holds the track, the cache or an archive consumer. DVR seeks with it. - **`hls` is for HLS only, and opt-in.** A per-rendition catalog field names a - track whose (first group, pts) records mark each HLS segment, and a publisher - enabling it aligns segments across renditions + track whose (first group, pts) records mark each HLS segment, each rendition + cutting its own on a pts grid so they line up wherever keyframes do (Catalog HLS field, landed). The root `archive` section is deleted everywhere. Rejected: always-on timelines. diff --git a/quest/m1/archive/hls-target.md b/quest/m1/archive/hls-target.md index aee8e071b6d..f5e08504feb 100644 --- a/quest/m1/archive/hls-target.md +++ b/quest/m1/archive/hls-target.md @@ -2,9 +2,10 @@ ## Goal -A `moq-hls` media playlist advertises one `EXT-X-TARGETDURATION` for the -whole run, taken from the declared duration of the rendition's opt-in `hls` -field, so every edge and every reload agree on it. Segments stay one-to-one +Every `moq-hls` media playlist of a broadcast advertises one +`EXT-X-TARGETDURATION` for the whole run, the largest declared duration across +its renditions' opt-in `hls` fields, so every edge, every reload, and every +rendition agree on it (Apple's HLS authoring spec 8.2). Segments stay one-to-one with `hls` records: the edge never skips, splits, or renumbers one. ## Plan @@ -17,10 +18,12 @@ and falls when that record is evicted. Codex flagged it The 09-28 audit reversed #4280's decision 4 (the observed maximum), and the 09-29 planning settled the rest: -- The target is `ceil` of the declared `hls` duration from - Catalog HLS field (retargeted - 2026-10-17 from the root `archive` map). Until it is declared, the rendition - has no playlist yet. +- The target is `ceil` of the largest declared `hls` duration across the + broadcast's renditions, from Catalog HLS field (retargeted 2026-10-17 from + the root `archive` map; made broadcast-wide 2026-10-26, since renditions now + cut on their own grids and may declare different durations). Until a + rendition declares, it has no playlist yet. Decide what a rendition added + later with a larger declared duration does to a target already served. - A segment whose rounded `EXTINF` exceeds the target (a GOP that overran the publisher's declared value) is listed anyway, with a rate-limited warning. This deliberately deviates from RFC 8216 4.3.3.1's MUST, because refusing @@ -28,11 +31,8 @@ The 09-28 audit reversed #4280's decision 4 (the observed maximum), and the the stable media sequence numbers of RFC 8216 6.2.2. Document the deviation. hls.js tolerates it; Apple's mediastreamvalidator flags it. -Decided 2026-10-17: renditions that opt into `hls` align their segments, so -the old non-reference `EXTINF` rule (a keyframe-snapped rendition keeping the -reference record's duration) and the split-record handling -(`a_frame_split_record_is_not_a_video_boundary` in `spans.rs`) go away with -Catalog HLS field rather than here. +Catalog HLS field lists each rendition's own records, so the old +non-reference `EXTINF` rule and the split-record handling are already gone. Replace `target_duration_follows_the_observed_segments` with tests that pin the declared target across a window whose segment durations vary, and that diff --git a/quest/m1/hls-conformance.md b/quest/m1/hls-conformance.md index 478f6635b72..de6a1800d06 100644 --- a/quest/m1/hls-conformance.md +++ b/quest/m1/hls-conformance.md @@ -2,8 +2,8 @@ ## Goal -A nightly test serves an aligned multi-rendition broadcast (video renditions -and audio) through `moq-hls`, probes every listed segment with ffprobe, and +A nightly test serves a multi-rendition broadcast (video renditions and +audio) through `moq-hls`, probes every listed segment with ffprobe, and fails if what the manifests advertise disagrees with the media served: `EXTINF` and DASH `t`/`d` against each segment's decoded start and duration, `EXT-X-TARGETDURATION` held for the run, sync flags against `startWithSAP` and @@ -23,6 +23,9 @@ coverage. Decided with the maintainer on 2026-10-24: - Cover a sliding window that evicts segments, a GOP that overruns the declared duration, a mid-run reconfiguration (new rendition instance), and a recording through the segment cursors. +- Cover an unaligned ladder, renditions whose keyframes and segment numbers + differ: every segment is listed and fetched (no `EXT-X-GAP`), and the DASH + manifest omits `segmentAlignment`. - Runs nightly; move any part fast enough into the merge gate. Public API: none. Wire: none. diff --git a/quest/m1/hls-follower-numbering.md b/quest/m1/hls-follower-numbering.md deleted file mode 100644 index 4f5ecda492d..00000000000 --- a/quest/m1/hls-follower-numbering.md +++ /dev/null @@ -1,26 +0,0 @@ -# [M] HLS records number late renditions from the pacer - -## Goal - -A rendition enrolled for HLS records after its siblings, or re-enrolled after -a reconfigure, numbers its records so that record `n` starts at the same pts as -segment `n` of the renditions already running. Today `moq-mux`'s `hls::Tracks` -gives a new follower sequence 0 (a re-enrolled name continues its own count), -while an edge maps record `n` to segment `n` and needs the window index to -equal the sequence, so every segment of a late rendition is listed as an -`EXT-X-GAP` until the counts happen to meet. - -## Plan - -- Start a follower's window at the pacer's next sequence (a window offset), - so its first record carries the pacer's number for that boundary. -- Keep the record track's window index equal to `sequence`, which the - consumers refuse otherwise. -- Test a rendition added mid-broadcast and one rebuilt by a reconfigure: both - list aligned segments, without gaps, from their first record. - -Public API: none expected. Wire: none. - -## Related - -- [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - checks the manifests this numbering feeds diff --git a/quest/m2/js-hls.md b/quest/m2/js-hls.md index 92cc0236d47..b0244dd3cdc 100644 --- a/quest/m2/js-hls.md +++ b/quest/m2/js-hls.md @@ -14,10 +14,8 @@ opt-in (Catalog HLS field, landed). The archive does not use it, so the old idle `flush()` and catalog and data track timelines are dropped from this port. -Enabling it carries the aligned-segment contract: the publisher starts groups -at the same pts across the renditions it lists, so segment n starts at the same -time in each. Check the encoders can honor it and refuse to enable it where -they cannot. +Each rendition cuts its own segments on a pts grid and numbers its own +records, sharing no state with the others, as `rs/moq-mux/src/hls.rs` does. The read side follows Catalog HLS field. Port the Rust producer (`rs/moq-mux`) to `js/hang`, wire the opt-in through `@moq/publish`, and check diff --git a/rs/hang/src/catalog/hls.rs b/rs/hang/src/catalog/hls.rs index 6387445bbb8..6361d590e5f 100644 --- a/rs/hang/src/catalog/hls.rs +++ b/rs/hang/src/catalog/hls.rs @@ -6,9 +6,8 @@ use super::millis::MillisCeil; /// A rendition's HLS segment records, which exist only for HLS compatibility. /// -/// Opt-in, and only on video and audio renditions. A publisher that sets it aligns group -/// boundaries across the renditions it sets it on, so segment `n` starts at the same pts in every -/// one. A rendition without it is not HLS-renderable. See the [`hls`](crate::hls) module for the +/// Opt-in, and only on video and audio renditions. Each rendition numbers its own segments. A +/// rendition without it is not HLS-renderable. See the [`hls`](crate::hls) module for the /// records. #[serde_with::serde_as] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] diff --git a/rs/hang/src/lib.rs b/rs/hang/src/lib.rs index b65baa5c582..4e534acc14c 100644 --- a/rs/hang/src/lib.rs +++ b/rs/hang/src/lib.rs @@ -6,7 +6,7 @@ //! //! - **Catalog**: A JSON track containing codec info and track metadata, updated live as tracks change. //! - **Tracks**: Audio or video, supporting one or more renditions. -//! - **HLS records**: An opt-in JSON track per rendition marking each aligned HLS segment's first group +//! - **HLS records**: An opt-in JSON track per rendition marking each of its HLS segments' first group //! and pts, so an edge can render playlists without downloading media. Advertised by the rendition's `hls` field. //! //! Each track specifies a container format: diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index f084e98429e..90078b17512 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -2424,7 +2424,7 @@ mod tests { } wait_for(&mut fixture.publication, Status::Waiting).await; // Idle past the minimum segment, so each run is listed as its own segment. - tokio::time::sleep(moq_mux::hls::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; + tokio::time::sleep(moq_mux::hls::DEFAULT_DURATION + Duration::from_millis(100)).await; } let (mut catalog, _consumer) = fixture.finish().await; diff --git a/rs/moq-cli/MIGRATE.md b/rs/moq-cli/MIGRATE.md index 806629ded37..c4abd8b4d3c 100644 --- a/rs/moq-cli/MIGRATE.md +++ b/rs/moq-cli/MIGRATE.md @@ -7,7 +7,7 @@ How to update your invocation and config for each breaking change, newest first. ### `import --hls` publishes the records `export hls` needs Imports no longer publish HLS records by default, so `export hls` lists nothing for a broadcast published without them. -Pass `--hls` to `import` (the stdin formats, `hls`, `rtsp`, and `capture`), and start keyframes at the same timestamps across renditions. +Pass `--hls` to `import` (the stdin formats, `hls`, `rtsp`, and `capture`); renditions' segments line up wherever their keyframes share timestamps. ### Unknown `MOQ_*` environment variables stop startup ([#6633](https://github.com/moq-dev/moq/pull/6633)) diff --git a/rs/moq-cli/src/hls/record.rs b/rs/moq-cli/src/hls/record.rs index 813e598442e..31647cdbcdd 100644 --- a/rs/moq-cli/src/hls/record.rs +++ b/rs/moq-cli/src/hls/record.rs @@ -5,7 +5,7 @@ //! rendition keeps one continuous media playlist across decoder reconfigurations, so the whole //! recording stays playable from its master playlist. -use std::collections::{BTreeMap, BTreeSet, VecDeque, btree_map}; +use std::collections::{BTreeMap, VecDeque, btree_map}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -31,9 +31,6 @@ type Key = (Kind, String); pub struct Recording { dir: PathBuf, slots: BTreeMap, - /// Segments where a rendition switched to a reconfigured decoder. Every playlist marks a - /// discontinuity there, so their discontinuity sequences stay matched. - transitions: BTreeSet, /// The master playlist as last written. master: Option, } @@ -111,9 +108,9 @@ enum Admit { impl Instance { /// Admit the next segment. A replacement repeats the rows its predecessor recorded, since /// its cursor starts at the oldest row retained, so those are discarded. The numbers name the - /// same span on every rendition and only increase within a broadcaster run, so a repeated - /// number with a newer discontinuity is a source reset, and a jump past the next number is a - /// gap nothing recorded. + /// same span in every instance of a rendition and only increase within a broadcaster run, so + /// a repeated number with a newer discontinuity is a source reset, and a jump past the next + /// number is a gap nothing recorded. fn admit(&mut self, segment: u64, discontinuity: u64) -> anyhow::Result { let Some(after) = self.after.filter(|_| !self.started) else { self.started = true; @@ -183,7 +180,6 @@ impl Recording { Ok(Self { dir, slots: BTreeMap::new(), - transitions: BTreeSet::new(), master: None, }) } @@ -275,15 +271,9 @@ impl Recording { return Ok(Some(Read { key, cursor, init })); } Admit::Record { transition: false } => {} - Admit::Record { transition: true } => { - self.transitions.insert(segment.segment); - for slot in self.slots.values_mut() { - slot.playlist.passed(segment.segment); - } - } + Admit::Record { transition: true } => slot.playlist.transition(), } - let slot = self.slots.get_mut(&key).expect("a cursor's slot outlives it"); let instance = slot.recording.as_mut().expect("a cursor in flight is recording"); let mut map = None; if let Some(media) = &segment.media { @@ -316,7 +306,7 @@ impl Recording { /// Write every playlist that changed, then the master playlist if its listing did. async fn flush(&mut self) -> anyhow::Result<()> { for slot in self.slots.values_mut() { - slot.playlist.flush(&slot.dir, &self.transitions).await?; + slot.playlist.flush(&slot.dir).await?; } let master = self.render_master(); if self.master.as_ref() != Some(&master) { @@ -400,6 +390,9 @@ struct Playlist { target: u64, /// What the file holds: its header and how many entries. written: Option<(Header, usize)>, + /// Switches to a reconfigured instance so far, each a discontinuity the rendition's own + /// sequence does not count. + transitions: u64, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -414,40 +407,27 @@ struct Entry { segment: u64, duration: Duration, gap: bool, - /// The broadcast's discontinuity sequence for the segment. + /// The rendition's discontinuity sequence for the segment; the playlist adds its transitions. discontinuity: u64, /// The init file the instance's first media segment maps. map: Option, } -impl Entry { - /// The recording's discontinuity sequence: the broadcast's, plus every transition at or - /// before this segment. - fn discontinuity(&self, transitions: &BTreeSet) -> u64 { - self.discontinuity + transitions.range(..=self.segment).count() as u64 - } -} - impl Playlist { - fn push(&mut self, entry: Entry) { + fn push(&mut self, mut entry: Entry) { self.version = self.version.max(if entry.gap { VERSION_GAP } else { VERSION }); // Every EXTINF, rounded up to whole seconds, must fit the target duration. self.target = self.target.max(entry.duration.as_secs_f64().ceil() as u64).max(1); + entry.discontinuity += self.transitions; self.entries.push(entry); } - /// A transition starts at `segment`: rewrite the file if it already lists that segment. - fn passed(&mut self, segment: u64) { - if let Some((_, written)) = self.written - && self.entries[..written] - .last() - .is_some_and(|entry| entry.segment >= segment) - { - self.written = None; - } + /// The next entry comes from a reconfigured instance, under a new init: a discontinuity. + fn transition(&mut self) { + self.transitions += 1; } - async fn flush(&mut self, dir: &Path, transitions: &BTreeSet) -> anyhow::Result<()> { + async fn flush(&mut self, dir: &Path) -> anyhow::Result<()> { let Some(first) = self.entries.first() else { return Ok(()); }; @@ -455,13 +435,13 @@ impl Playlist { version: self.version, target: self.target, sequence: first.segment, - discontinuity: first.discontinuity(transitions), + discontinuity: first.discontinuity, }; let path = dir.join("media.m3u8"); match self.written { Some((written, count)) if written == header => { if count < self.entries.len() { - append(&path, &self.render(count, transitions)).await?; + append(&path, &self.render(count)).await?; } } _ => { @@ -472,7 +452,7 @@ impl Playlist { if header.discontinuity > 0 { out += &format!("#EXT-X-DISCONTINUITY-SEQUENCE:{}\n", header.discontinuity); } - out += &self.render(0, transitions); + out += &self.render(0); write(&path, out.as_bytes()).await?; } } @@ -481,13 +461,11 @@ impl Playlist { } /// The lines of the entries from `from` on. - fn render(&self, from: usize, transitions: &BTreeSet) -> String { + fn render(&self, from: usize) -> String { let mut out = String::new(); - let mut previous = from - .checked_sub(1) - .map(|index| self.entries[index].discontinuity(transitions)); + let mut previous = from.checked_sub(1).map(|index| self.entries[index].discontinuity); for entry in &self.entries[from..] { - let discontinuity = entry.discontinuity(transitions); + let discontinuity = entry.discontinuity; // A skipped discontinuity still counts, so playlists starting anywhere agree. for _ in previous.unwrap_or(discontinuity)..discontinuity { out += "#EXT-X-DISCONTINUITY\n"; @@ -776,8 +754,8 @@ mod tests { } /// A reconfigured rendition keeps one playlist: the replacement continues after the last - /// segment its predecessor recorded, under its own init, and every playlist marks the switch - /// at the same segment. + /// segment its predecessor recorded, under its own init, behind a discontinuity only its own + /// playlist marks. #[tokio::test] async fn a_reconfigured_rendition_continues_its_playlist() { let mut publisher = Publisher::new(&["video0", "video1"]); @@ -815,11 +793,7 @@ mod tests { switched.starts_with("#EXT-X-MAP:URI=\"init.1.mp4\"\n"), "{reconfigured}" ); - let marked = sibling - .split("#EXT-X-DISCONTINUITY\n") - .nth(1) - .expect("the sibling marks it"); - assert_eq!(listed(marked)[0], listed(switched)[0], "{sibling}"); + assert!(!sibling.contains("#EXT-X-DISCONTINUITY"), "{sibling}"); assert!(dir.path().join("video/video1/init.1.mp4").exists()); let master = read(dir.path().join("master.m3u8")); assert!(master.contains("RESOLUTION=640x240"), "{master}"); @@ -847,8 +821,6 @@ mod tests { catalog.video.renditions.remove("video1"); }) .unwrap(); - // Its last segment resolves once the lead lists that number, which the lead's next - // segment start (GOP 6) bounds. publisher.gop(5); publisher.gop(6); moq_clock::timeout(TIMEOUT, async { @@ -979,29 +951,28 @@ mod tests { } } - /// A switch at a segment a playlist already wrote rewrites it, so every playlist marks the - /// switch at the same place; otherwise it only appends. + /// A switch to a reconfigured instance and the rendition's own break are each a + /// discontinuity, appended; only a raised header rewrites the playlist. #[tokio::test] - async fn a_late_transition_rewrites_the_playlist() { + async fn a_transition_appends_a_discontinuity() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("media.m3u8"); - let mut transitions = BTreeSet::new(); let mut playlist = Playlist::default(); for segment in 0..3 { playlist.push(entry(segment, 0)); - playlist.flush(dir.path(), &transitions).await.unwrap(); + playlist.flush(dir.path()).await.unwrap(); } assert_eq!(listed(&read(path.clone())), [0, 1, 2]); - transitions.insert(2); - playlist.passed(2); - playlist.push(entry(3, 1)); - playlist.flush(dir.path(), &transitions).await.unwrap(); + playlist.transition(); + playlist.push(entry(3, 0)); + playlist.push(entry(4, 1)); + playlist.flush(dir.path()).await.unwrap(); let text = read(path.clone()); - assert_eq!(listed(&text), [0, 1, 2, 3]); - // The switch at 2, then the source's own discontinuity at 3. + assert_eq!(listed(&text), [0, 1, 2, 3, 4]); + // The switch at 3, then the rendition's own discontinuity at 4. assert!( - text.contains("1.m4s\n#EXT-X-DISCONTINUITY\n#EXTINF:2.00000,\n2.m4s\n#EXT-X-DISCONTINUITY\n"), + text.contains("2.m4s\n#EXT-X-DISCONTINUITY\n#EXTINF:2.00000,\n3.m4s\n#EXT-X-DISCONTINUITY\n"), "{text}" ); @@ -1009,15 +980,15 @@ mod tests { playlist.push(Entry { duration: Duration::from_millis(4_500), gap: true, - ..entry(4, 1) + ..entry(5, 1) }); - playlist.flush(dir.path(), &transitions).await.unwrap(); + playlist.flush(dir.path()).await.unwrap(); let text = read(path); assert!( text.starts_with("#EXTM3U\n#EXT-X-VERSION:8\n#EXT-X-TARGETDURATION:5\n"), "{text}" ); - assert!(text.ends_with("#EXT-X-GAP\n#EXTINF:4.50000,\n4.m4s\n"), "{text}"); + assert!(text.ends_with("#EXT-X-GAP\n#EXTINF:4.50000,\n5.m4s\n"), "{text}"); assert_eq!(text.matches("#EXT-X-DISCONTINUITY\n").count(), 2, "{text}"); } } diff --git a/rs/moq-hls/MIGRATE.md b/rs/moq-hls/MIGRATE.md index 18efe15e48c..6d96505d428 100644 --- a/rs/moq-hls/MIGRATE.md +++ b/rs/moq-hls/MIGRATE.md @@ -8,8 +8,9 @@ How to update your code for each breaking change, newest first. Each rendition reads the records its catalog `hls` field names, and segment `n` is its record `n`; a rendition without `hls` gets no playlist. `Error::TimelineFailed` is `Error::RecordsFailed { track, source }`. -A segment that does not line up with the lead rendition's is listed as `EXT-X-GAP` and never fetched, instead of snapping to a nearby keyframe. -The DASH manifest has one period, with `t` and `d` in milliseconds. +Segment numbers mean nothing across renditions, so match renditions by timestamp, as players do; nothing snaps to a nearby keyframe. +Each rendition counts its own discontinuities, so `Segment::discontinuity` from two renditions' cursors can differ after a break in one of them. +The DASH manifest has one period, with `t` and `d` in milliseconds, and no `segmentAlignment`. Segment URL tags hash each rendition's own record track and its config, so every URL changes once, and a reconfigured rendition lists its later segments under new URLs. ### The segment cursor yields gaps diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index 3aa0fa8c174..59b18280362 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -451,15 +451,16 @@ async fn a_bounded_cache_rereads_evicted_objects() { } #[tokio::test(start_paused = true)] -async fn stopped_and_misaligned_segments_are_gaps_and_a_resume_is_a_discontinuity() { +async fn stopped_segments_are_gaps_and_a_resume_is_a_discontinuity() { let mut recording = Recording::new().await; - // 1080p, the lead: two segments, a stop, then content resumes at 10s. + // 1080p: two segments, a stop, then content resumes at 10s. recording.add("1080p", 0, &[(0, &[0])]); recording.add("1080p", 2000, &[(1, &[2_000_000])]); recording.stop("1080p", 2, 4000); recording.add("1080p", 10_000, &[(2, &[10_000_000])]); recording.stop("1080p", 3, 12_000); - // 360p starts its segment 1 a second and a half late, so it does not line up. + // 360p starts its segment 1 a second and a half late. Each rendition lists its own records, + // so it is listed as is. recording.add("360p", 0, &[(0, &[0])]); recording.add("360p", 3500, &[(1, &[3_500_000])]); recording.stop("360p", 2, 4000); @@ -482,36 +483,27 @@ async fn stopped_and_misaligned_segments_are_gaps_and_a_resume_is_a_discontinuit .map(|s| (s.segment, s.gap, s.discontinuity)) .collect::>() }; - assert_eq!( - flags(&low), - [ - (0, false, false), - (1, true, false), - (2, true, false), - (3, false, true), - (4, true, false) - ] - ); + let expected = [ + (0, false, false), + (1, false, false), + (2, true, false), + (3, false, true), + (4, true, false), + ]; + assert_eq!(flags(&low), expected); replay .playlist_until(Kind::Video, "1080p", |playlist| playlist.contains("#EXT-X-ENDLIST")) .await; let high = replay.rendition(Kind::Video, "1080p").snapshot(); - assert_eq!( - flags(&high), - [ - (0, false, false), - (1, false, false), - (2, true, false), - (3, false, true), - (4, true, false) - ] - ); + assert_eq!(flags(&high), expected); // A gap is never fetched. counting.take(); let rendition = replay.rendition(Kind::Video, "360p"); - assert!(rendition.listed_segment(1).await.unwrap().is_none()); + assert!(rendition.listed_segment(2).await.unwrap().is_none()); assert!(!counting.take().iter().any(|path| is_media(path))); + let late = rendition.listed_segment(1).await.unwrap().unwrap(); + assert_eq!(&late[4..8], b"moof"); let after = rendition.listed_segment(3).await.unwrap().unwrap(); assert_eq!(&after[4..8], b"moof"); } diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index d4264073fbf..a0acebeab74 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -8,10 +8,9 @@ //! playlist. A `moq-archive` recording served by its `Consumer` is served the same way: its //! playlists read only the stored records, and a segment GETs only its rendition's objects. //! -//! Record `n` of every rendition is segment `n`: the publisher numbers its renditions' segments -//! together, so every edge agrees without coordinating. The edge checks each rendition's segment -//! against the lead rendition's (the first enabled video, else audio) and lists one that does not -//! line up as `EXT-X-GAP`, which serves nothing. +//! A rendition's record `n` is its segment `n`: each rendition numbers its own segments, so every +//! edge agrees without coordinating. Nothing compares numbers across renditions; players match +//! renditions by timestamp, and segments line up wherever the publisher's keyframes do. //! An inline-parameter-set codec with no catalog `description` GETs one keyframe //! group on the first playlist render (in the background on a manifest render) to build its //! init, then caches it. @@ -458,7 +457,7 @@ mod tests { let duration = Duration::from_secs(2); moq_mux::catalog::Config::default().with_hls( moq_mux::hls::Config::default() - .with_duration_min(duration) + .with_duration(duration) .with_duration_hint(duration), ) } @@ -1768,8 +1767,43 @@ mod tests { drop((media, catalog, broadcast)); } + // A duration marker already times a video group's last sample, so the segment's end is not + // pinned on an earlier sample instead. + #[tokio::test] + async fn a_timed_trailing_sample_keeps_its_duration() { + let origin = produce_origin(); + let broadcast = origin.create_broadcast("live").expect("publish allowed"); + broadcast.announce(Default::default()).expect("publish allowed"); + settle().await; + let mut producer = broadcast.broadcast().clone(); + let catalog = hls_catalog(&mut producer); + let track = producer.create_track("video0", timed()).unwrap(); + let container = moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video); + let mut media = catalog.video(track, container, video_config()).unwrap(); + media.write(vp8_frame(0, true)).unwrap(); + media.write(vp8_frame(33_000, false)).unwrap(); + // The marker times the delta; the next segment starts on the grid at 2s. + media + .cut(Some(moq_net::Timestamp::from_micros(66_000).unwrap())) + .unwrap(); + media.write(vp8_frame(2_000_000, true)).unwrap(); + media.write(vp8_frame(4_000_000, true)).unwrap(); + + let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); + let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; + let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition"); + let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; + + let segment = rendition.listed_segment(0).await.unwrap().expect("segment 0"); + let timescale = rendition.muxer().unwrap().timescale().as_u64(); + let frame = Some(u32::try_from(timescale * 33 / 1_000).unwrap()); + assert_eq!(sample_durations(&segment), [frame, frame]); + + drop((media, catalog, broadcast)); + } + // The DASH half of the fetch-on-demand path: the manifest renders from the same record - // windows as the HLS playlists (dynamic while live, aligned S entries across renditions), + // windows as the HLS playlists (dynamic while live, matching S entries across renditions), // and $Time$ addressing resolves a segment's pts to the same bytes its number does. #[tokio::test] async fn serves_dash_manifest_and_time_addressed_segments() { @@ -1815,7 +1849,7 @@ mod tests { let run = broadcaster.instance(); let video_tag = broadcaster.rendition(Kind::Video, "video0").unwrap().tag().to_string(); assert!(manifest.contains(&format!("media=\"@{run}/video/video0/seg/{video_tag}.t$Time$.m4s\""))); - // Both renditions list the same aligned segment spans (the live edge is not listed). + // Both renditions cut the same segment spans (the live edge is not listed). assert_eq!(manifest.matches("").count(), 2, "{manifest}"); assert_eq!(manifest.matches("").count(), 2); assert!( @@ -1840,7 +1874,7 @@ mod tests { "media=\"@{run}/video/video0/seg/{video_tag}.t$Time$.m4s?jwt=abc.def\"" ))); - // $Time$ resolves to the same bytes the aligned number does; unknown times miss. + // $Time$ resolves to the same bytes the number does; unknown times miss. let by_time = video_rendition .listed_segment_at(2_000) .await @@ -2211,7 +2245,7 @@ mod tests { settle().await; let catalog = hls_catalog(&mut broadcast.broadcast().clone()); let mut audio = audio(&catalog, &broadcast, "audio0", opus()); - // 500ms groups pack into segments of the 2s minimum. + // 500ms groups pack into the 2s grid. for micros in (0..=6_000_000u64).step_by(500_000) { let keyframe = audio.needs_keyframe(); audio.write(frame(micros, keyframe)).unwrap(); @@ -2241,7 +2275,7 @@ mod tests { broadcast.announce(Default::default()).expect("publish allowed"); settle().await; let catalog = hls_catalog(&mut broadcast.broadcast().clone()); - // 3s GOPs against the 2s minimum: every segment is one whole GOP. + // 3s GOPs on the 2s grid: every GOP lands in a later cell, so each is one segment. let mut media = video(&catalog, &broadcast, "video0", inline_vp8()); media.write(frame(0, true)).unwrap(); media.write(frame(3_000_000, true)).unwrap(); @@ -2258,11 +2292,11 @@ mod tests { drop((media, catalog, broadcast)); } - // The property HLS needs: audio and video renditions share one segment numbering, cut at - // the same boundaries. Video is one group per segment; an audio segment packs every audio - // group inside the video segment's span. + // Audio and video renditions whose groups start on the same grid lines cut the same + // boundaries, with no state shared between them. Video is one group per segment; an audio + // segment packs every audio group inside the video segment's span. #[tokio::test] - async fn audio_and_video_segments_are_aligned() { + async fn audio_and_video_on_one_grid_line_up() { let origin = produce_origin(); let broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); @@ -2301,11 +2335,7 @@ mod tests { let audio_playlist = audio_rendition.snapshot(); assert_eq!(video_playlist.media_sequence, audio_playlist.media_sequence); assert_eq!(numbers(&video_playlist), [0, 1]); - assert_eq!( - numbers(&audio_playlist), - [0, 1], - "audio lists the same aligned segments" - ); + assert_eq!(numbers(&audio_playlist), [0, 1], "audio cuts the same segments"); assert!(gaps(&audio_playlist).is_empty()); assert_eq!( audio_playlist.segments[0].duration, @@ -3165,7 +3195,7 @@ mod tests { } impl Records { - /// A `video0` lead and a `video1` rendition. + /// `video0` and `video1` renditions. fn new() -> Self { Self::with(&["video0", "video1"], &[]) } @@ -3387,7 +3417,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_record_error_fails_only_its_playlist() { let test = Records::new(); - let (_lead, mut video0) = test.publish("video0"); + let (_video0, mut video0) = test.publish("video0"); let (track, mut video1) = test.publish("video1"); let _media = test.media("video1", 4); for sequence in 0..4 { @@ -3407,12 +3437,12 @@ mod tests { assert!(video1.representation().is_err()); assert!(video1.failed_alone()); // Segments already listed stay fetchable while the listing refuses. - assert_eq!(video1.listed_content(0), Some(rendition::Content::Groups(0..1))); + assert!(video1.listed_segment(0).await.unwrap().is_some()); // So does the cursor, once it drains what the records covered. assert_eq!(test.drain_failed("video1").await, [0, 1, 2]); - let lead = test.listed("video0", 5).await; - assert!(!lead.finished, "the other renditions go on"); + let video0 = test.listed("video0", 5).await; + assert!(!video0.finished, "the other renditions go on"); assert!(test.rendition("video0").representation().is_ok()); } @@ -3519,8 +3549,8 @@ mod tests { } // A recording refuses a listed segment whose media is gone, but reports a segment the - // rendition holds nothing for (a stop, or one that does not line up with the lead's) as a gap - // that keeps its number, so a recorder can tell it from rows it missed. + // rendition holds nothing for (a stop) as a gap that keeps its number, so a recorder can tell + // it from rows it missed. #[tokio::test(start_paused = true)] async fn a_recording_refuses_missing_media_but_reports_gaps() { let test = Records::new(); @@ -3534,8 +3564,7 @@ mod tests { video0.push(&gop(sequence)).unwrap(); } video0.push(&stop(3, 6_000)).unwrap(); - // video1's segment 0 starts 1.5s late, so it does not line up with the lead's. - video1.push(&hang::hls::Record::new(0, 0, 1_500)).unwrap(); + video1.push(&stop(0, 0)).unwrap(); video1.push(&gop(1)).unwrap(); video1.push(&stop(2, 4_000)).unwrap(); video1.finish().unwrap(); @@ -3573,121 +3602,57 @@ mod tests { assert!(cursor.next().await.unwrap().is_none()); } - // Record `n` of every rendition is segment `n`. One that does not line up with the lead's - // is listed as a gap, so the numbering stays consecutive and nothing misplaced is served. + // Each rendition lists its own records: a ladder whose boundaries and numbering differ, such as + // a rendition that started late, lists every segment of each, with no gaps. #[tokio::test(start_paused = true)] - async fn a_misaligned_segment_is_a_gap() { + async fn an_unaligned_ladder_lists_every_segment() { let test = Records::with(&["video0"], &["audio0"]); let (_t0, mut video0) = test.publish("video0"); let (_t1, mut audio0) = test.publish("audio0"); for sequence in 0..5 { video0.push(&gop(sequence)).unwrap(); - // Segment 2 starts 1.5s late on audio; the rest are within half a segment. - let pts = match sequence { - 2 => 5_500, - _ => sequence * 2_000 + 100, - }; + } + // Audio joins at 5.5s, numbering its records from its own start. + for (sequence, pts) in [(0, 5_500), (1, 8_000), (2, 10_000)] { audio0.push(&hang::hls::Record::new(sequence, sequence, pts)).unwrap(); } - test.listed("video0", 3).await; - let audio = test.listed("audio0", 3).await; - assert_eq!(numbers(&audio), [0, 1, 2, 3]); - assert_eq!(gaps(&audio), [2]); + assert_eq!(numbers(&test.listed("video0", 3).await), [0, 1, 2, 3]); + let audio = test.listed("audio0", 1).await; + assert_eq!(numbers(&audio), [0, 1]); + assert!(gaps(&audio).is_empty()); let rendition = test.rendition("audio0"); - assert_eq!(rendition.listed_content(2), Some(rendition::Content::Gap)); - assert!(rendition.listed_segment(2).await.unwrap().is_none()); + assert!(rendition.is_advertised()); let dash = rendition.representation().unwrap().expect("an out-of-band init"); - assert!(dash.segments.iter().all(|(t, _)| *t != 5_500), "{:?}", dash.segments); - - let rendered = rendition.media_playlist(None).unwrap().expect("playable"); - assert!(rendered.contains("#EXT-X-GAP\n"), "{rendered}"); - } - - // A rendition ahead of the lead lists a segment only once the lead does, so its listing - // never changes under a player. - #[tokio::test(start_paused = true)] - async fn a_rendition_waits_for_the_lead() { - let test = Records::new(); - let (_t0, mut video0) = test.publish("video0"); - let (_t1, mut video1) = test.publish("video1"); - for sequence in 0..6 { - video1.push(&gop(sequence)).unwrap(); - } - for sequence in 0..3 { - video0.push(&gop(sequence)).unwrap(); - } - test.listed("video0", 1).await; - settle().await; - assert_eq!(numbers(&test.rendition("video1").snapshot()), [0, 1]); - - for sequence in 3..6 { - video0.push(&gop(sequence)).unwrap(); - } - assert_eq!(numbers(&test.listed("video1", 4).await), [0, 1, 2, 3, 4]); + assert_eq!(dash.segments, [(5_500, 2_500), (8_000, 2_000)]); } - // A rendition whose first segment waits on the lead is not advertised yet, so no player - // starts on a playlist that lists nothing. + // A break in one rendition starts a discontinuity on it alone: each rendition counts its own. #[tokio::test(start_paused = true)] - async fn a_rendition_is_advertised_once_the_lead_lists_its_first_segment() { + async fn a_break_in_one_rendition_leaves_the_others_continuous() { let test = Records::new(); let (_t0, mut video0) = test.publish("video0"); let (_t1, mut video1) = test.publish("video1"); - video1.push(&gop(0)).unwrap(); - video1.push(&gop(1)).unwrap(); - settle().await; - let rendition = test.rendition("video1"); - assert!(!rendition.is_advertised(), "segment 0 waits on the lead"); - assert_eq!(rendition.media_playlist(None).unwrap(), None); - // Spawned, so only a wake polls it again. - let mut playable = tokio::spawn({ - let rendition = rendition.clone(); - async move { rendition.playable().await } - }); - settle().await; - assert!(!playable.is_finished(), "segment 0 waits on the lead"); - - video0.push(&gop(0)).unwrap(); - video0.push(&gop(1)).unwrap(); - tokio::time::timeout(Duration::from_secs(1), &mut playable) - .await - .expect("the lead listing segment 0 wakes it") - .unwrap(); - assert!(rendition.is_advertised()); - let rendered = rendition.media_playlist(None).unwrap().expect("playable"); - assert!(rendered.contains("#EXTINF:"), "{rendered}"); - } - - // A cursor waiting on a stalled lead resumes once the lead hands over. - #[tokio::test(start_paused = true)] - async fn a_cursor_waiting_on_the_lead_follows_a_handover() { - let mut test = Records::new(); - let (_t0, mut video0) = test.publish("video0"); - let (_t1, mut video1) = test.publish("video1"); - let _media = test.media("video1", 4); for sequence in 0..4 { video1.push(&gop(sequence)).unwrap(); } - // The lead lists only segment 0, bounded by record 1. video0.push(&gop(0)).unwrap(); - video0.push(&gop(1)).unwrap(); - test.listed("video1", 0).await; - let mut cursor = test.rendition("video1").segments(); - assert_eq!(cursor.next().await.unwrap().unwrap().segment, 0); - // Spawned, so only a wake polls it again. - let mut waiting = tokio::spawn(async move { cursor.next().await.unwrap().unwrap().segment }); - settle().await; - assert!(!waiting.is_finished(), "segment 1 waits on the lead"); + video0.push(&stop(1, 2_000)).unwrap(); + video0.push(&hang::hls::Record::new(2, 1, 4_000)).unwrap(); + video0.push(&hang::hls::Record::new(3, 2, 6_000)).unwrap(); - test.catalog.video.renditions.get_mut("video0").unwrap().enabled = false; - test.sync(); - assert_eq!(test_lead(&test), Some("video1".to_string())); - let next = tokio::time::timeout(Duration::from_secs(1), &mut waiting).await; - assert_eq!(next.expect("the handover wakes the cursor").unwrap(), 1); + let discontinuities = |snapshot: &playlist::Snapshot| { + snapshot + .segments + .iter() + .filter(|segment| segment.discontinuity) + .map(|segment| segment.segment) + .collect::>() + }; + assert_eq!(discontinuities(&test.listed("video0", 2).await), [2]); + assert!(discontinuities(&test.listed("video1", 2).await).is_empty()); } - // A stop is listed as a gap at once, and what resumes after it starts a new discontinuity - // on every rendition. + // A stop is listed as a gap at once, and what resumes after it starts a new discontinuity. #[tokio::test(start_paused = true)] async fn a_stop_is_a_gap_and_a_resume_a_discontinuity() { let test = Records::with(&["video0"], &[]); @@ -3749,37 +3714,6 @@ mod tests { assert_eq!(gaps(&snapshot), [1]); } - // The lead stays put while it is listed and enabled, so a rendition added later (whose - // records are numbered from its own start) never makes the others' segments gaps. - #[tokio::test(start_paused = true)] - async fn the_lead_sticks_until_it_leaves() { - let mut test = Records::with(&["video1"], &["audio0"]); - let lead = || test_lead(&test); - assert_eq!(lead(), Some("video1".to_string())); - - // "video0" sorts first, but arrives later. - let mut config = video_config(); - config.hls = Some(hls("video0")); - test.catalog.video.renditions.insert("video0".into(), config); - test.sync(); - assert_eq!(test_lead(&test), Some("video1".to_string())); - - // A disabled lead hands over, and audio yields to enabled video. - test.catalog.video.renditions.get_mut("video1").unwrap().enabled = false; - test.sync(); - assert_eq!(test_lead(&test), Some("video0".to_string())); - test.catalog.video.renditions.get_mut("video0").unwrap().enabled = false; - test.sync(); - assert_eq!(test_lead(&test), Some("audio0".to_string())); - test.catalog.video.renditions.get_mut("video1").unwrap().enabled = true; - test.sync(); - assert_eq!(test_lead(&test), Some("video1".to_string())); - } - - fn test_lead(test: &Records) -> Option { - test.renditions.fanout().lead().map(|lead| lead.name.clone()) - } - #[tokio::test(start_paused = true)] async fn catalog_changes_report_removal_and_drain_old_cursors() { for reconfigure in [false, true] { diff --git a/rs/moq-hls/src/export/mpd.rs b/rs/moq-hls/src/export/mpd.rs index a87ed0e22d6..6b5cb9c4a8d 100644 --- a/rs/moq-hls/src/export/mpd.rs +++ b/rs/moq-hls/src/export/mpd.rs @@ -219,11 +219,11 @@ fn render_adaptation_sets<'a>( Kind::Audio => ("audio", "audio/mp4"), }; for representations in codecs.values() { - // Each rendition lists only the segments that line up with the lead rendition's, leaving - // the rest out as gaps; groups open on keyframes. + // Groups open on keyframes. `segmentAlignment` is left out (false): each rendition lists + // its own records, which line up only where the publisher's keyframes do. let _ = writeln!( out, - " " + " " ); for rep in representations { render_representation(out, instance, rep, query); @@ -369,9 +369,7 @@ mod tests { assert!(out.contains(" minimumUpdatePeriod=\"PT2.000S\"")); assert!(out.contains(" suggestedPresentationDelay=\"PT6.000S\"")); assert!(out.contains(" maxSegmentDuration=\"PT2.000S\"")); - assert!(out.contains( - "" - )); + assert!(out.contains("")); assert!(out.contains( "" )); diff --git a/rs/moq-hls/src/export/playlist.rs b/rs/moq-hls/src/export/playlist.rs index 2b3b1021850..640d20210c3 100644 --- a/rs/moq-hls/src/export/playlist.rs +++ b/rs/moq-hls/src/export/playlist.rs @@ -46,7 +46,7 @@ pub(crate) struct Segment { /// `EXTINF` duration. pub duration: Duration, /// The rendition has no content for this span (`EXT-X-GAP`): the segment keeps its slot in - /// the aligned numbering, but a player should not request it. + /// the rendition's numbering, but a player should not request it. pub gap: bool, /// Content time jumped before this segment (`EXT-X-DISCONTINUITY`). pub discontinuity: bool, diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 04cd1a70f54..19d0c7a2718 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -1,7 +1,6 @@ //! One rendition: playlists from its own HLS records, and segments fetched on demand. use moq_clock::Wall; -use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; use std::task::Poll; @@ -268,18 +267,6 @@ fn tag(track: &str, config: &T) -> Arc { Arc::from(segments::tag(track, &config)) } -/// What a listed row holds on its rendition. -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum Content { - /// These groups. - Groups(std::ops::Range), - /// Nothing to fetch: content stopped, or the segment does not line up with the lead - /// rendition's. Listed as `EXT-X-GAP` so the numbering stays consecutive. - Gap, - /// Not yet known: the lead rendition has not listed this number yet. - Pending, -} - /// Where a retired rendition stops listing its records, for its replacement to take over. pub(crate) struct Handoff { /// The record track it read. @@ -336,11 +323,8 @@ pub struct Rendition { clock: Option, /// This rendition's window over its records. live: Arc, - /// What every rendition of the broadcast shares: discontinuities, the run, the lead. + /// What every rendition of the broadcast shares: discontinuity counts and the run. fanout: renditions::Fanout, - /// Whether each recent segment lines up with the lead rendition's, decided once so a - /// listing never changes under a player. - verdicts: Mutex>, /// The broadcast carrying the catalog and every record track. catalog: moq_net::broadcast::Consumer, /// Follows the records into [`live`](Self::live); aborted when the rendition drops. @@ -452,7 +436,6 @@ impl Rendition { clock, live: Arc::new(segments::Producer::new(0)), fanout, - verdicts: Mutex::default(), catalog: source.broadcast(), watcher: Mutex::new(None), retiring: tokio::sync::watch::Sender::new(false), @@ -491,7 +474,6 @@ impl Rendition { clock, live: Arc::new(segments::Producer::new(0)), fanout, - verdicts: Mutex::default(), catalog: source.broadcast(), watcher: Mutex::new(None), retiring: tokio::sync::watch::Sender::new(false), @@ -550,6 +532,11 @@ impl Rendition { } } + /// This rendition's identity in its broadcast. + fn key(&self) -> renditions::Key { + (self.kind, self.name.clone()) + } + /// The [tag](segments::tag) every segment URL carries. #[cfg(test)] pub(crate) fn tag(&self) -> &str { @@ -563,76 +550,6 @@ impl Rendition { } } - /// What `row` holds on this rendition. - pub(crate) fn resolve(&self, row: &segments::Row) -> Content { - if row.stop { - return Content::Gap; - } - match self.aligned(row) { - Some(true) => Content::Groups(row.groups.clone()), - Some(false) => Content::Gap, - None => Content::Pending, - } - } - - /// Whether `row` lines up with the lead rendition's segment of the same number: it starts - /// within half that segment of it. `None` until the lead lists that number or never will. - /// - /// A segment the lead never lists, or already evicted, can't be checked and is listed as is. - /// One the lead skipped while holding the numbers around it is misaligned. - fn aligned(&self, row: &segments::Row) -> Option { - if let Some(verdict) = self.verdicts.lock().expect("verdicts lock poisoned").get(&row.segment) { - return Some(*verdict); - } - let Some(lead) = self.fanout.lead() else { - return Some(true); - }; - if std::ptr::eq(lead.as_ref(), self) { - return Some(true); - } - let pts = Duration::from(row.pts); - let verdict = match lead.live.numbered(row.segment) { - segments::Lookup::Found(lead) => !lead.stop && pts.abs_diff(Duration::from(lead.pts)) <= lead.duration / 2, - segments::Lookup::Missing(span) => !span.contains(&pts), - segments::Lookup::Never => true, - segments::Lookup::Pending => return None, - }; - if !verdict { - tracing::error!( - rendition = %self.name, - lead = %lead.name, - segment = row.segment, - "HLS segment does not line up with the lead rendition's; listing it as a gap" - ); - } - let mut verdicts = self.verdicts.lock().expect("verdicts lock poisoned"); - verdicts.insert(row.segment, verdict); - while verdicts.len() > 2 * segments::MAX_SEGMENTS { - verdicts.pop_first(); - } - Some(verdict) - } - - /// Whether the rendition has nothing to fetch for this row. - pub(crate) fn is_gap(&self, row: &segments::Row) -> bool { - self.resolve(row) == Content::Gap - } - - /// Poll until `row`'s alignment is decided. - pub(crate) fn poll_resolved(&self, waiter: &kio::Waiter, row: &segments::Row) -> Poll<()> { - // Registered on lead changes too: a new lead may decide the row at once, while the old one - // may never list it. - let Some(lead) = self.fanout.poll_lead(waiter) else { - return Poll::Ready(()); - }; - // Read before resolving, so a row the lead lists in between wakes this poll. - let seen = lead.live.produced(); - if self.resolve(row) != Content::Pending { - return Poll::Ready(()); - } - lead.live.poll_changed(waiter, seen) - } - /// List every segment URL under `generation` from now on, keeping the run's rows and init. pub(crate) fn label(&self, generation: Option>) { self.run_mut().generation = generation; @@ -649,7 +566,6 @@ impl Rendition { build.abort(); } self.live.clear(); - self.verdicts.lock().expect("verdicts lock poisoned").clear(); let mut run = self.run_mut(); *run = Run { generation, @@ -713,18 +629,14 @@ impl Rendition { kio::wait(|waiter| self.poll_playable(waiter)).await; } - /// Poll until this rendition is playable (its first segment's alignment is decided, or its - /// records ended empty), its media is denied, or its window failed. + /// Poll until this rendition is playable (it lists a segment, or its records ended), its + /// media is denied, or its window failed. pub(crate) fn poll_playable(&self, waiter: &kio::Waiter) -> Poll<()> { self.poll_media(waiter); if self.media.refusal().is_some() { return Poll::Ready(()); } - match self.live.poll_front(waiter) { - Poll::Ready(Some(front)) => self.poll_resolved(waiter, &front), - Poll::Ready(None) => Poll::Ready(()), - Poll::Pending => Poll::Pending, - } + self.live.poll_listed(waiter) } /// End the window if the bound sibling ended, registering `waiter` to wake when it does, and @@ -762,7 +674,6 @@ impl Rendition { let Some(init) = self.built_init(&run) else { return Ok(None); }; - // A segment still waiting on the lead is not listed, so check what renders. let playable = !snapshot.segments.is_empty() || snapshot.finished; Ok(playable.then(|| super::render_media(&snapshot, &init.hash, query))) } @@ -808,10 +719,7 @@ impl Rendition { if let Some(err) = self.failure(&window) { return Err(err); } - // Ended records end the playlist only once every row's alignment is decided, so the tail - // is listed before `EXT-X-ENDLIST` stops players polling. - let complete = self.resolved(&window.segments).count() == window.segments.len(); - let finished = window.ended && complete; + let finished = window.ended; Ok(self.snapshot_from_window(window, finished, generation)) } @@ -873,15 +781,16 @@ impl Rendition { .max(1); let program_date_time = window.segments.first().and_then(|first| self.wall_clock(first.pts)); let mut previous = None; - let segments = self - .resolved(&window.segments) - .map(|(s, content)| { + let segments = window + .segments + .iter() + .map(|s| { let discontinuity = previous.replace(s.discontinuity).is_some_and(|p| p != s.discontinuity); Segment { segment: s.segment, tag: s.tag.to_string(), duration: s.duration, - gap: content == Content::Gap, + gap: s.stop, discontinuity, } }) @@ -897,14 +806,6 @@ impl Rendition { } } - /// `rows` with what each holds, up to the first one whose alignment is still pending: a - /// segment is listed only once its content is final. - fn resolved<'a>(&'a self, rows: &'a [segments::Row]) -> impl Iterator { - rows.iter() - .map(|row| (row, self.resolve(row))) - .take_while(|(_, content)| *content != Content::Pending) - } - /// Whether the multivariant playlist advertises this rendition, i.e. a player can start it. /// /// Every segment starts at a group, and every group at a keyframe, so a rendition can start @@ -937,12 +838,12 @@ impl Rendition { let Some(init) = self.built_init(&run) else { return Ok(None); }; - let complete = self.resolved(&window.segments).count() == window.segments.len(); - let ended = window.ended && complete; - let segments = self - .resolved(&window.segments) - .filter(|(_, content)| *content != Content::Gap) - .map(|(row, _)| { + let ended = window.ended; + let segments = window + .segments + .iter() + .filter(|row| !row.stop) + .map(|row| { let t = row.pts.as_millis() as u64; let d = row.end.saturating_sub(row.pts.into()).as_millis() as u64; (t, d) @@ -1158,7 +1059,7 @@ impl Rendition { /// /// Fetches every group the segment holds on this rendition (an audio segment packs many /// short groups) and encodes them as a single CMAF fragment. `None` when the segment isn't in - /// the playlist window, is a gap or not yet decided for this rendition, or its groups already + /// the playlist window, is a gap, or its groups already /// left the relay cache. After End or Restart, only the bound instance's retained media /// drains; a replacement's media is never read under these rows. A denied referenced /// broadcast returns its authorization error even when the requested row is absent. @@ -1193,9 +1094,10 @@ impl Rendition { // Read the run before resolving the row (see `restart`), so an init decoded from its // groups is never cached for a run that started while they were being fetched. let run = self.run(); - let Content::Groups(groups) = self.resolve(row) else { + if row.stop { return Ok(None); - }; + } + let groups = row.groups.clone(); // A segment must be served whole. If its group span exceeds the safety cap (a // pathologically coarse record), 404 rather than silently truncating it to a segment @@ -1241,11 +1143,10 @@ impl Rendition { return Ok(None); } // The last presented sample has no successor to time it, but the record says where the - // segment ends, so the fragment ends exactly where the next one starts. - if let Some(last) = frames - .iter_mut() - .filter(|frame| frame.duration.is_none()) - .max_by_key(|frame| frame.timestamp.as_micros()) + // segment ends, so the fragment ends exactly where the next one starts. One a duration + // marker already timed keeps it. + if let Some(last) = frames.iter_mut().max_by_key(|frame| frame.timestamp.as_micros()) + && last.duration.is_none() && let Ok(end) = moq_net::Timestamp::from_micros(row.end.as_micros() as u64) && let Ok(end) = end.convert(last.timestamp.scale()) && let Ok(duration) = end.checked_sub(last.timestamp) @@ -1270,11 +1171,6 @@ impl Rendition { self.numbered(&self.tag.clone(), segment).await } - /// What the listed segment numbered `segment` holds on this rendition. - pub(crate) fn listed_content(&self, segment: u64) -> Option { - Some(self.resolve(&self.live.row(&self.tag, segment)?)) - } - /// Fetch the listed segment starting at `time` milliseconds. pub(crate) async fn listed_segment_at(&self, time: u64) -> Result> { self.segment_at(&self.tag.clone(), segments::At::Pts(time)).await @@ -1398,7 +1294,7 @@ fn gap(rendition: &Rendition, record: &moq_mux::hls::Entry, broken: bool) -> seg duration: Duration::ZERO, pts: record.pts, end: pts, - discontinuity: rendition.fanout.stamp(record.sequence, broken, pts), + discontinuity: rendition.fanout.stamp(&rendition.key(), broken, pts), stop: true, complete: false, } @@ -1430,7 +1326,7 @@ fn row( duration, pts: record.pts, end, - discontinuity: rendition.fanout.stamp(record.sequence, broken, end), + discontinuity: rendition.fanout.stamp(&rendition.key(), broken, end), stop: false, complete: !next.is_stop(), }) diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index b3b83533aa1..6faefc92e49 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -3,15 +3,14 @@ //! The `Producer` holds the current renditions, reconciled from the catalog by its `sync`; //! the HTTP serve path reads them synchronously (look one up, render the master playlist). Each //! rendition follows its own HLS records; the `Fanout` handle holds what they share: the -//! discontinuity numbering, the publisher run, and the lead rendition the others' segments are -//! checked against. +//! discontinuity counts and the publisher run. //! A [`Consumer`] is a cursor for a recorder that mirrors the *whole* broadcast: [`next`](Consumer::next) //! yields one [`Event`] at a time as renditions are added or removed, replaying the current //! set as [`Added`](Event::Added) when first consumed, and returning `None` once the catalog //! finishes cleanly, or an error when the recording is incomplete. use moq_clock::Wall; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Weak}; use std::task::Poll; use std::time::Duration; @@ -20,11 +19,46 @@ use moq_mux::catalog::hang::Catalog; use super::master; use super::rendition::{Kind, Rendition}; -use super::segments::Discontinuities; /// The `(kind, name)` identity of a rendition. Video and audio are separate axes, so a video /// and an audio rendition may share a name without colliding. -type Key = (Kind, String); +pub(crate) type Key = (Kind, String); + +/// Numbers each rendition's content breaks: its segments after a stop, or after records nobody +/// saw, start a new discontinuity, as does every rendition's next segment after a publisher-run +/// restart. +/// +/// Counted per rendition, since each numbers its own segments. A break in one rendition only +/// makes its count diverge from its siblings', which players resync across by +/// `EXT-X-PROGRAM-DATE-TIME`. Kept by name, so a reconfigured rendition's replacement never +/// lists a lower sequence than its predecessor. +#[derive(Default)] +struct Discontinuities { + /// Each live rendition's newest sequence. + renditions: BTreeMap, + /// Publisher-run restarts so far, where a rendition first seen starts. + restarts: u64, +} + +impl Discontinuities { + /// The sequence of rendition `key`'s next segment, which starts a new one when `broken`. + fn stamp(&mut self, key: &Key, broken: bool) -> u64 { + if !self.renditions.contains_key(key) { + self.renditions.insert(key.clone(), self.restarts); + } + let sequence = self.renditions.get_mut(key).expect("inserted above"); + *sequence += u64::from(broken); + *sequence + } + + /// Start a new discontinuity on every rendition, for a publisher run that restarts. + fn restart(&mut self) { + self.restarts += 1; + for sequence in self.renditions.values_mut() { + *sequence += 1; + } + } +} /// A change to the rendition set, yielded by [`Consumer::next`]. #[non_exhaustive] @@ -49,7 +83,7 @@ pub enum Event { struct Feed { /// Every rendition created and still alive, the dropped ones pruned as the next registers. targets: Vec>, - /// Stamps each segment number once, so every rendition agrees on where content breaks. + /// Each rendition's discontinuity count. discontinuities: Discontinuities, /// The catalog failed; late-created renditions start failed. failed: Option, @@ -63,9 +97,6 @@ struct Feed { /// The playlist window duration applied on every push (see /// [`Config::window`](super::Config::window)). window: Duration, - /// The rendition every other one's segments are checked against: the first enabled video - /// rendition, else the first enabled audio one, kept while it stays enabled. - lead: Option>, } /// The producing side of a broadcast's rendition set. @@ -111,7 +142,6 @@ impl Producer { anchor: None, generation: None, window, - lead: None, }), }, } @@ -230,15 +260,15 @@ impl Fanout { self.feed.read().window } - /// The discontinuity sequence of segment `segment`, which starts a new one when `broken` - /// (see [`Discontinuities::stamp`]). Anchors the presentation at a segment ending at `end`, - /// if nothing has. - pub fn stamp(&self, segment: u64, broken: bool, end: Duration) -> u64 { + /// The discontinuity sequence of rendition `key`'s next segment, which starts a new one when + /// `broken` (see [`Discontinuities::stamp`]). Anchors the presentation at a segment ending at + /// `end`, if nothing has. + pub fn stamp(&self, key: &Key, broken: bool, end: Duration) -> u64 { let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); if feed.anchor.is_none() { feed.anchor = Some(moq_clock::wall().checked_sub(end).unwrap_or(Wall::UNIX_EPOCH)); } - feed.discontinuities.stamp(segment, broken) + feed.discontinuities.stamp(key, broken) } /// Why the catalog failed, if it did. @@ -246,27 +276,6 @@ impl Fanout { self.feed.read().failed.clone() } - /// The rendition every other one's segments are checked against, if any. - pub fn lead(&self) -> Option> { - self.feed.read().lead.as_ref()?.upgrade() - } - - /// [`lead`](Self::lead), registering `waiter` for the next change to what every rendition - /// shares, a new lead included. - pub fn poll_lead(&self, waiter: &kio::Waiter) -> Option> { - let mut lead = None; - let _ = self.feed.poll_ref(waiter, |feed| { - lead = feed.lead.as_ref().and_then(Weak::upgrade); - Poll::<()>::Pending - }); - lead - } - - fn set_lead(&self, lead: Option<&Arc>) { - let mut feed = self.feed.write().unwrap_or_else(|_| unreachable!("fanout is open")); - feed.lead = lead.map(Arc::downgrade); - } - /// List every rendition under `generation` from now on. /// /// Replacing a generation drops what was listed under it and the inits built from it: those @@ -415,9 +424,18 @@ impl Producer { } else { rendition.watch(); } - // Pruned here, so a catalog that keeps replacing renditions holds only the live ones. + // Pruned here, so a catalog that keeps replacing renditions holds only the live ones. A + // replacement shares its predecessor's name, so its count survives. + let feed = &mut *feed; feed.targets.retain(|target| target.strong_count() > 0); feed.targets.push(Arc::downgrade(rendition)); + let live: BTreeSet = feed + .targets + .iter() + .filter_map(Weak::upgrade) + .map(|rendition| (rendition.kind, rendition.name.clone())) + .collect(); + feed.discontinuities.renditions.retain(|key, _| live.contains(key)); } /// Reconcile the rendition set with a complete catalog snapshot. Removed or reconfigured @@ -541,18 +559,6 @@ impl Producer { tracing::debug!("no rendition advertises HLS records yet; nothing to serve"); } - // Keep the lead while it stays listed and enabled, so a rendition added later (whose - // records may be numbered from its own start) never takes over. Audio yields to video. - // Video sorts first, so a new pick is the first enabled video rendition, else audio. - let first = current.renditions.values().find(|rendition| rendition.is_enabled()); - let kept = self.fanout.lead().and_then(|lead| { - let listed = current.renditions.get(&(lead.kind, lead.name.clone()))?; - let video = first.is_some_and(|first| first.kind == Kind::Video); - (Arc::ptr_eq(listed, &lead) && lead.is_enabled() && (lead.kind == Kind::Video || !video)) - .then(|| listed.clone()) - }); - self.fanout.set_lead(kept.as_ref().or(first)); - // Rank once per catalog update, keeping playlist reads linear. Updating state also wakes // the master's long-poll when metadata changes without replacing a rendition. current.audio = catalog @@ -667,6 +673,33 @@ mod tests { assert!(matches!(cursor.next().await, Err(crate::Error::IncompleteRecording))); } + #[test] + fn each_rendition_counts_its_own_discontinuities() { + let video = (Kind::Video, "video".to_string()); + let audio = (Kind::Audio, "audio".to_string()); + let mut breaks = Discontinuities::default(); + assert_eq!(breaks.stamp(&video, false), 0); + assert_eq!( + breaks.stamp(&video, true), + 1, + "the segment after a stop starts a new one" + ); + assert_eq!( + breaks.stamp(&audio, false), + 0, + "a break in one rendition leaves the others" + ); + breaks.restart(); + assert_eq!(breaks.stamp(&video, false), 2); + assert_eq!(breaks.stamp(&audio, false), 1); + let late = (Kind::Video, "late".to_string()); + assert_eq!( + breaks.stamp(&late, false), + 1, + "a rendition first seen starts at the run" + ); + } + #[tokio::test] async fn a_failed_catalog_never_completes_a_cursor() { tokio::time::pause(); diff --git a/rs/moq-hls/src/export/segments.rs b/rs/moq-hls/src/export/segments.rs index 1c4fa58e1f1..8f0aaff9182 100644 --- a/rs/moq-hls/src/export/segments.rs +++ b/rs/moq-hls/src/export/segments.rs @@ -11,9 +11,9 @@ //! (via [`Rendition`]), and yields the CMAF bytes. //! //! A segment is addressed by its record track's tag and its number (the `seg/{tag}.{segment}.m4s` -//! URI); the number alone is its `EXT-X-MEDIA-SEQUENCE` and the recorder cursor's position. A -//! publisher that aligns its renditions numbers the same span of content time alike on every -//! rendition, so the number means the same on every rendition, edge, and reload. +//! URI); the number alone is its `EXT-X-MEDIA-SEQUENCE` and the recorder cursor's position. Each +//! rendition numbers its own segments, so a number means the same on every edge and reload of +//! that rendition, but nothing across renditions: players match renditions by timestamp. use moq_clock::Wall; use std::collections::VecDeque; @@ -92,51 +92,6 @@ struct State { produced: u64, } -/// Numbers the broadcast's content breaks: segments after a stop, or after records nobody saw, -/// start a new discontinuity. -/// -/// Held once per broadcaster and keyed by segment number, so every rendition marks segment `n` -/// with the same sequence however many segments each one listed: the first rendition to stamp a -/// segment decides it. -#[derive(Default)] -pub(crate) struct Discontinuities { - /// The sequence stamped on each recent segment. - stamped: std::collections::BTreeMap, - /// The highest sequence stamped so far. - sequence: u64, -} - -impl Discontinuities { - /// The sequence of segment `segment`, which starts a new one when `broken`. - pub fn stamp(&mut self, segment: u64, broken: bool) -> u64 { - if let Some(sequence) = self.stamped.get(&segment) { - return *sequence; - } - let previous = self - .stamped - .range(..segment) - .next_back() - .map_or(self.sequence, |(_, sequence)| *sequence); - let sequence = match broken { - true => previous.max(self.sequence) + 1, - false => previous, - }; - self.sequence = self.sequence.max(sequence); - self.stamped.insert(segment, sequence); - // Every rendition lists at most a window's worth, so older stamps are never asked for. - while self.stamped.len() > 4 * MAX_SEGMENTS { - self.stamped.pop_first(); - } - sequence - } - - /// Forget every stamp, for a publisher run that restarts its numbering. - pub fn restart(&mut self) { - self.stamped.clear(); - self.sequence += 1; - } -} - /// The most segments a playlist window lists, however short they are. /// /// A fresh subscriber to a `moq-mux` record track is only promised this many recent records (the @@ -182,10 +137,10 @@ pub(crate) struct Row { pub pts: moq_net::Timestamp, /// The segment's ending presentation timestamp (`pts + duration`), for window eviction. pub end: Duration, - /// The broadcast's [`Discontinuities`] sequence for this segment. + /// The rendition's discontinuity sequence for this segment. pub discontinuity: u64, - /// Content stopped here: a zero-duration record holding no media, listed as a gap so the - /// numbering stays consecutive. + /// Nothing to fetch: content stopped here, or the record's successor can't bound it. Listed as + /// a gap so the numbering stays consecutive. pub stop: bool, /// Whether a later segment started after this one, so its groups are all complete. One only a /// stop bounds may end in a group the publisher is still writing. @@ -252,7 +207,7 @@ impl State { impl Producer { /// An empty window whose first segment is numbered `start` or later: those before it belong - /// to another window, which [`numbered`](Self::numbered) reports as never listed here. + /// to another window. pub fn new(start: u64) -> Self { Self { state: kio::Producer::new(State { @@ -366,31 +321,6 @@ impl Producer { .cloned() } - /// The row of segment `segment`, whatever its tag, with whether the window holds every - /// segment from `segment` on: the alignment check needs both. - pub fn numbered(&self, segment: u64) -> Lookup { - let state = self.state.read(); - let (Some(front), Some(back)) = (state.rows.front(), state.rows.back()) else { - return match segment < state.sequence || state.ended || state.failed.is_some() { - true => Lookup::Never, - false => Lookup::Pending, - }; - }; - if segment < front.segment { - return Lookup::Never; - } - if segment > back.segment { - return match state.ended || state.failed.is_some() { - true => Lookup::Never, - false => Lookup::Pending, - }; - } - match state.rows.iter().find(|row| row.segment == segment) { - Some(row) => Lookup::Found(row.clone()), - None => Lookup::Missing(Duration::from(front.pts)..back.end), - } - } - /// The number of the segment whose `pts` is exactly `time` milliseconds (DASH `$Time$` /// addressing), or `None` when no listed row starts there. Exact match is safe because the /// rendered `S@t` and this lookup convert the same [`Row::pts`] the same way. A stop is never @@ -422,24 +352,16 @@ impl Producer { older.into_iter().chain([newest.groups.start]).collect() } - /// Poll until the window lists a row, returning the oldest, or `None` once it failed, ended - /// empty, or closed empty. A returned row still registers `waiter` for the window's next - /// change, so a caller waiting on that row's alignment also wakes when the front moves. - pub fn poll_front(&self, waiter: &kio::Waiter) -> Poll> { - let mut front = None; - let poll = self.state.poll_ref(waiter, |state| { - if state.failed.is_some() || (state.ended && state.rows.is_empty()) { - return Poll::Ready(()); + /// Poll until the window lists a row, ends, fails, or closes. + pub fn poll_listed(&self, waiter: &kio::Waiter) -> Poll<()> { + match self.state.poll_ref(waiter, |state| { + match !state.rows.is_empty() || state.ended || state.failed.is_some() { + true => Poll::Ready(()), + false => Poll::Pending, } - front = state.rows.front().cloned(); - // Pending even with a row, so the waiter registers. - Poll::Pending - }); - match poll { - Poll::Ready(Ok(())) => Poll::Ready(None), - // Closed: no more rows arrive, so the front is final. - Poll::Ready(Err(_)) => Poll::Ready(front), - Poll::Pending => front.map_or(Poll::Pending, |row| Poll::Ready(Some(row))), + }) { + Poll::Ready(_) => Poll::Ready(()), + Poll::Pending => Poll::Pending, } } @@ -458,26 +380,6 @@ impl Producer { } } - /// Poll until the window changes from `seen` rows produced, ends, or fails, for a check that - /// waits on another rendition's window. - pub fn poll_changed(&self, waiter: &kio::Waiter, seen: u64) -> Poll<()> { - match self.state.poll_ref(waiter, |state| { - if state.produced != seen || state.ended || state.failed.is_some() { - Poll::Ready(()) - } else { - Poll::Pending - } - }) { - Poll::Ready(_) => Poll::Ready(()), - Poll::Pending => Poll::Pending, - } - } - - /// How many rows were ever appended. - pub fn produced(&self) -> u64 { - self.state.read().produced - } - /// A cursor over segments, starting from the oldest still in the window. pub fn subscribe(&self, rendition: Arc) -> Consumer { let consumer = self.state.consume(); @@ -491,19 +393,6 @@ impl Producer { } } -/// What one window says about a segment number. -#[derive(Debug)] -pub(crate) enum Lookup { - /// The window lists it. - Found(Row), - /// The window holds the numbers around it but not it, spanning this content time. - Missing(std::ops::Range), - /// It may still arrive. - Pending, - /// The window will never list it: evicted already, or the records ended before it. - Never, -} - /// A segment with its transmuxed media, yielded by a [`Consumer`]. pub struct Segment { /// The segment number (its URI is `seg/{tag}.{segment}.m4s`). @@ -574,9 +463,8 @@ impl Consumer { /// [`CatalogFailed`](crate::Error::CatalogFailed). A denied referenced broadcast fails every /// call with its authorization error. /// - /// Waits for the next segment's alignment to settle, then FETCHes and transmuxes its groups. - /// A stop or a misaligned segment is yielded without media, so every segment number is - /// accounted for. Missing window rows or unavailable media fail the recording instead of + /// FETCHes and transmuxes the next segment's groups. A stop, or a record its successor can't + /// bound, is yielded without media, so every segment number is accounted for. Missing window rows or unavailable media fail the recording instead of /// silently omitting segments. Fetch/transmux errors leave the cursor in place so the caller /// can retry. A bound instance's retained media drains after End or Restart; a replacement's /// media is never read under the old rows. @@ -584,14 +472,7 @@ impl Consumer { let Some(row) = kio::wait(|waiter| self.poll_next(waiter)).await? else { return Ok(None); }; - kio::wait(|waiter| { - // The refusal settles the binding, not this rendition's records, so watch both. - self.rendition.poll_media(waiter); - self.rendition.authorize()?; - self.rendition.poll_resolved(waiter, &row).map(Ok::<_, crate::Error>) - }) - .await?; - let media = if self.rendition.is_gap(&row) { + let media = if row.stop { None } else { let media = self.rendition.fetch(&row).await?; @@ -773,32 +654,6 @@ mod tests { ); } - #[test] - fn renditions_agree_on_discontinuities_whatever_they_listed() { - let mut breaks = Discontinuities::default(); - assert_eq!(breaks.stamp(0, false), 0); - assert_eq!(breaks.stamp(1, false), 0); - assert_eq!(breaks.stamp(2, true), 1, "the segment after a stop starts a new one"); - assert_eq!(breaks.stamp(3, false), 1); - // A rendition that joined later, or saw records the first one skipped, takes the stamps - // already made rather than its own view. - assert_eq!(breaks.stamp(2, false), 1); - assert_eq!(breaks.stamp(3, true), 1); - assert_eq!(breaks.stamp(4, false), 1); - } - - #[test] - fn a_restart_starts_a_new_discontinuity() { - let mut breaks = Discontinuities::default(); - assert_eq!(breaks.stamp(5, false), 0); - breaks.restart(); - assert_eq!( - breaks.stamp(0, false), - 1, - "the new run's numbering is unrelated to the old" - ); - } - #[test] fn rows_and_the_init_groups() { let live = Producer::new(0); @@ -861,23 +716,6 @@ mod tests { assert_eq!(live.state.read().resets, 2); } - #[test] - fn lookups_tell_pending_from_never() { - let live = Producer::new(0); - let window = Duration::from_secs(30); - assert!(matches!(live.numbered(0), Lookup::Pending)); - live.push(row(3, 3, 6_000, 2_000), window); - live.push(row(5, 5, 10_000, 2_000), window); - assert!(matches!(live.numbered(2), Lookup::Never), "evicted or never listed"); - assert!(matches!(live.numbered(3), Lookup::Found(row) if row.segment == 3)); - assert!( - matches!(live.numbered(4), Lookup::Missing(span) if span == (Duration::from_secs(6)..Duration::from_secs(12))) - ); - assert!(matches!(live.numbered(6), Lookup::Pending)); - live.end(); - assert!(matches!(live.numbered(6), Lookup::Never)); - } - #[test] fn cursor_walks_segments() { let live = Producer::new(0); diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index 1ba36f96c78..39dc006b01d 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -20,7 +20,7 @@ //! //! `{kind}` is `video` or `audio`, so a video and an audio rendition that share a //! name address distinct resources. The two `seg/` forms name the same bytes: HLS -//! playlists address a segment by its aligned number, the DASH manifest by its +//! playlists address a segment by its number, the DASH manifest by its //! `pts` in milliseconds (`$Time$`). `{hash}` is a hash of the init segment's bytes, so a //! reconfigured rendition gets a new init URL. A broadcaster given a generation //! ([`Broadcaster::set_generation`](crate::export::Broadcaster::set_generation)) diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index af8687d9087..216efaa98df 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -256,7 +256,7 @@ mod tests { let duration = Duration::from_secs(2); moq_mux::catalog::Config::default().with_hls( moq_mux::hls::Config::default() - .with_duration_min(duration) + .with_duration(duration) .with_duration_hint(duration), ) } diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index 85accb4d544..1d6928877d2 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -17,7 +17,9 @@ Enable them with `catalog::Config::with_hls(hls::Config::default())`; `with_time `catalog::Reserved::with_timeline_hint` is `with_hls_hint`, and `catalog::Producer::timeline()` is gone. Each video and audio rendition then advertises its own `hls` field and `.hls` record track, cut at group boundaries; untimed and data tracks get none. Read records with `hls::Consumer::subscribe(&broadcast, &hls)`, whose `Event::Push` carries an `hls::Entry`, or publish your own with `hls::Producer`. -A publisher that enables HLS must align group boundaries across its renditions. +`Config::duration_min` is `hls::Config::duration`, `with_duration_min` is `with_duration`, and `DEFAULT_DURATION_MIN` is `DEFAULT_DURATION`. +It is now the spacing of a pts grid rather than a floor: a group starts a segment when its pts falls in a later multiple of it than the previous group's, so a segment may be shorter. +Each rendition cuts and numbers its own segments, so they line up across renditions wherever keyframes share timestamps. ### JSON snapshot encoder options move to `json::Options` diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index bd2ccdfa2c4..2520bd24293 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -2049,7 +2049,7 @@ mod test { video.finish().unwrap(); let hls = catalog.snapshot().video.renditions["video0"].hls.clone(); - assert_eq!(hls.unwrap().duration, crate::hls::DEFAULT_DURATION_MIN); + assert_eq!(hls.unwrap().duration, crate::hls::DEFAULT_DURATION); } #[test] diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs index fef12ea60c1..970d8f80820 100644 --- a/rs/moq-mux/src/hls.rs +++ b/rs/moq-mux/src/hls.rs @@ -10,29 +10,22 @@ //! ## Segments //! //! A segment starts at a group boundary and holds whole groups, so a record is the segment's first -//! group and pts, published as the segment starts. A segment ends at the first group boundary at -//! least [`Config::duration_min`] (4s by default) past its start, so short groups (audio) pack into -//! one segment and long ones (video GOPs) get one each. A hint fixes the nominal duration to its -//! smallest multiple at or beyond that minimum; without one, the first segment's duration declares -//! it. +//! group and pts, published as the segment starts. Segments are cut on a grid of +//! [`Config::duration`] (4s by default) in pts: a group starts a segment when its pts falls in a +//! later multiple of the duration than the previous group's. Short groups (audio) pack into one +//! segment per grid cell, and long ones (video GOPs) get one each. A hint replaces the duration with +//! its smallest multiple at or beyond it, which is also the nominal duration declared; without one, +//! the first segment declares it. +//! +//! Each rendition cuts on its own grid and numbers its own records, sharing no state with the +//! others. Renditions whose keyframes sit at the same timestamps therefore cut the same boundaries, +//! including one enrolled late or rebuilt by a reconfigure, which lines up from its next grid line. //! //! Content stopping (a skipped group sequence, a break marker from //! [`container::Producer::discontinuity`](crate::container::Producer::discontinuity), or the end of //! the track) publishes a zero-duration record where it stopped, so the segment before it ends there //! rather than absorbing the pause. //! -//! ## Alignment -//! -//! HLS needs segment `n` to start at the same pts in every rendition. The first track enrolled -//! paces the segments; every later track follows it, starting a segment at its first group at or -//! after each of the pacer's segment starts (even one it reported before the pacer did), and -//! stopping when the pacer stops content. A follower records -//! nothing before the pacer's first segment, so renditions that start together and share group -//! boundaries (aligned GOPs, audio grouped on them) number the same segments alike. A follower -//! keeps at most a ceiling of boundaries waiting on the other track, so one that stalls costs -//! alignment, not memory. If the pacer's producer goes away, the next enrolled track still -//! recording takes over. -//! //! ## Wiring //! //! [`catalog::Producer`](crate::catalog::Producer) owns the broadcast's record tracks and advertises @@ -49,13 +42,14 @@ use hang::hls::Record; use moq_net::{Timescale, Timestamp}; -/// The default [`Config::duration_min`] (4 seconds). -pub const DEFAULT_DURATION_MIN: Duration = Duration::from_secs(4); +/// The default [`Config::duration`] (4 seconds). +pub const DEFAULT_DURATION: Duration = Duration::from_secs(4); -/// The safety ceiling before an unhinted pacer declares its duration, unless the minimum is longer. +/// The safety ceiling before an unhinted track declares its duration, unless the duration is longer. const DEFAULT_DURATION_CEILING: Duration = Duration::from_secs(10); -/// A pacer's segments may overrun their target to reach a group boundary, but not indefinitely. +/// A segment waiting on a source's cut may overrun its target to reach a group boundary, but not +/// indefinitely. const DURATION_CEILING_MULTIPLIER: u32 = 3; /// Recent records repeated when the window track rolls to a new group: one more than the 256 @@ -67,25 +61,25 @@ const TIMESCALE: Timescale = Timescale::MILLI; /// How a broadcast's HLS segments are cut. /// -/// A segment ends at the first group boundary at least [`duration_min`](Self::duration_min) past -/// its start. A hint fixes the nominal duration to its smallest multiple at or beyond that minimum. +/// A segment starts at the first group in a later multiple of [`duration`](Self::duration) than +/// the previous group's. A hint replaces the duration with its smallest multiple at or beyond it. #[derive(Clone, Debug)] #[non_exhaustive] pub struct Config { - /// The shortest a segment may be before a group boundary ends it. + /// The spacing of the pts grid segments are cut on. /// - /// A floor rather than a target on purpose: a floor is always satisfiable (wait for the next - /// group), so no group is split to honor it. - pub duration_min: Duration, + /// A grid rather than a running length on purpose: renditions whose keyframes share timestamps + /// cut the same boundaries without sharing any state. + pub duration: Duration, /// The application's nominal group duration, when known. pub duration_hint: Option, } impl Config { - /// Set [`duration_min`](Self::duration_min). - pub fn with_duration_min(mut self, duration: Duration) -> Self { - self.duration_min = duration; + /// Set [`duration`](Self::duration). + pub fn with_duration(mut self, duration: Duration) -> Self { + self.duration = duration; self } @@ -99,7 +93,7 @@ impl Config { impl Default for Config { fn default() -> Self { Self { - duration_min: DEFAULT_DURATION_MIN, + duration: DEFAULT_DURATION, duration_hint: None, } } @@ -118,15 +112,6 @@ fn elapsed(start: Timestamp, pts: Timestamp) -> Duration { Duration::from_micros(u64::try_from(pts.as_micros().saturating_sub(start.as_micros())).unwrap_or(u64::MAX)) } -/// What a pacer did, for its followers to mirror. -#[derive(Clone, Copy, Debug, PartialEq)] -enum Mark { - /// A segment started at this pts. - Start(Timestamp), - /// Content stopped. - Stop, -} - /// Builds one track's records from reports of the frames it published. /// /// Reports arrive in group order: [`frame`](Self::frame) for each frame (or at least the first of @@ -134,13 +119,17 @@ enum Mark { /// [`stop`](Self::stop) where it stops. Records queue until [`Iterator::next`] takes them. pub(crate) struct Segmenter { config: Config, - /// Fixed nominal duration; absent until an unhinted track's first segment closes. Kept unrounded - /// so pacing hits a fractional-millisecond GOP's boundary; only the wire declaration rounds. + /// The grid's spacing: the hint's multiple, else the configured duration. + grid: Duration, + /// Fixed nominal duration; absent until an unhinted track's first segment closes. Kept unrounded; + /// only the wire declaration rounds. declared: Option, /// The number the next record gets. sequence: u64, /// The open segment's start: absent before the first segment and after a stop. open: Option, + /// The grid cell of the open segment's newest group. + cell: u128, /// The newest reported group. last: Option, /// The newest reported content time in the open segment. @@ -150,19 +139,9 @@ pub(crate) struct Segmenter { /// The newest boundary declared, so a repeat of it (several sources declaring the same ones) /// is ignored. cut: Option, - /// A follower's group starts after every cut it knows of, oldest first: a pacer reporting later - /// may still cut at one. Empty whenever `cuts` holds a boundary past the open segment. - undecided: VecDeque<(u64, Timestamp)>, - /// The newest reported content time, whether or not a segment is open, so a segment a late cut - /// starts reaches it. - reported: Option, - /// Whether boundaries come only from cuts: a follower, or a pacer whose source declares them. + /// Whether boundaries come only from cuts: the source declares them. manual: bool, - /// Whether this track follows a pacer, starting a segment only at one of its cuts. - following: bool, ready: VecDeque, - /// What this segmenter did since last asked, for followers to mirror. - marks: Vec, closed: bool, } @@ -170,35 +149,26 @@ impl Segmenter { /// A segmenter numbering its records from zero. pub fn new(config: Config) -> Self { let declared = config.duration_hint.filter(|hint| !hint.is_zero()).map(|hint| { - let multiple = config.duration_min.as_nanos().div_ceil(hint.as_nanos()).max(1); + let multiple = config.duration.as_nanos().div_ceil(hint.as_nanos()).max(1); hint.saturating_mul(u32::try_from(multiple).unwrap_or(u32::MAX)) }); Self { + grid: declared.unwrap_or(config.duration), config, declared, sequence: 0, open: None, + cell: 0, last: None, frontier: None, cuts: VecDeque::new(), cut: None, - undecided: VecDeque::new(), - reported: None, manual: false, - following: false, ready: VecDeque::new(), - marks: Vec::new(), closed: false, } } - /// Follow a pacer's segments instead of pacing them; see [`follow`](Self::follow). - pub fn following(mut self) -> Self { - self.manual = true; - self.following = true; - self - } - /// Number the next record `sequence`, continuing a re-enrolled track. pub fn with_sequence(mut self, sequence: u64) -> Self { self.sequence = sequence; @@ -215,17 +185,17 @@ impl Segmenter { self.declared } - fn target(&self) -> Duration { - self.declared - .filter(|value| !value.is_zero()) - .unwrap_or(self.config.duration_min) - } - fn ceiling(&self) -> Duration { self.declared .filter(|value| !value.is_zero()) .and_then(|value| value.checked_mul(DURATION_CEILING_MULTIPLIER)) - .unwrap_or(DEFAULT_DURATION_CEILING.max(self.config.duration_min)) + .unwrap_or(DEFAULT_DURATION_CEILING.max(self.config.duration)) + } + + /// The grid cell `pts` falls in. A zero grid gives every distinct pts its own. + fn cell(&self, pts: Timestamp) -> u128 { + let nanos = pts.as_micros() * 1_000; + nanos.checked_div(self.grid.as_nanos()).unwrap_or(nanos) } /// Report a frame of group `group`, presented at `pts`. @@ -244,98 +214,52 @@ impl Segmenter { self.group(group, last, pts); } } else { - self.open_at(group, pts); + self.begin(group, pts); } self.last = Some(group); - self.report(pts); + self.advance(pts); Ok(()) } - /// Content reached `pts`. - fn report(&mut self, pts: Timestamp) { - if self - .reported - .is_none_or(|reported| pts.as_micros() > reported.as_micros()) - { - self.reported = Some(pts); - } - if self.open.is_some() { - self.advance(pts); - } - } - /// Group `group` starts at `pts`, after group `last`. fn group(&mut self, group: u64, last: u64, pts: Timestamp) { let Some(start) = self.open else { - self.open_at(group, pts); + self.begin(group, pts); return; }; if group != last + 1 { // A skipped sequence stops the segment where its content reached, so the skipped time // is not absorbed into it and the reader never has to guess what lies between. self.stop_at(last + 1); - self.open_at(group, pts); + self.begin(group, pts); return; } + let cell = self.cell(pts); let elapsed = elapsed(start, pts); - let ceiling = !self.following && elapsed >= self.ceiling(); - if ceiling || self.boundary(start, pts, elapsed) { - self.declared.get_or_insert(elapsed); + if elapsed >= self.ceiling() || self.boundary(start, pts, cell) { + // A grid's first segment starts wherever the content does, so it may be short. + let nominal = match self.manual { + true => elapsed, + false => elapsed.max(self.grid), + }; + self.declared.get_or_insert(nominal); self.begin(group, pts); - } else if self.following && self.cuts.is_empty() { - self.undecide(group, pts); - } - } - - /// Keep a follower's group at `pts` for a cut the pacer may still report. - /// - /// A pacer starts a segment at least once per ceiling while it reports, so a group more than a - /// ceiling behind this one could only meet a pacer stalled that long: forgetting it bounds the - /// queue, and that late cut starts the segment at a later group instead. - fn undecide(&mut self, group: u64, pts: Timestamp) { - let horizon = self.ceiling(); - while self - .undecided - .front() - .is_some_and(|(_, start)| elapsed(*start, pts) > horizon) - { - self.undecided.pop_front(); - } - self.undecided.push_back((group, pts)); - } - - /// With no segment open, start one with group `group` at `pts`, unless a follower has no cut - /// to start it at. - fn open_at(&mut self, group: u64, pts: Timestamp) { - if self.following { - // A follower starts only where the pacer did, so both number the segment alike. - let mut started = false; - while self.cuts.front().is_some_and(|cut| cut.as_micros() <= pts.as_micros()) { - self.cuts.pop_front(); - started = true; - } - if !started { - // With no cut ahead, the pacer may still report one at or before this group. - if self.cuts.is_empty() { - self.undecide(group, pts); - } - return; - } } - self.begin(group, pts); + self.cell = cell; } /// Start a segment with group `group` at `pts`. fn begin(&mut self, group: u64, pts: Timestamp) { self.open = Some(pts); self.frontier = Some(pts); + self.cell = self.cell(pts); self.ready.push_back(Record::new(self.sequence, group, wire(pts))); - self.marks.push(Mark::Start(pts)); self.sequence += 1; } - /// Whether a group starting at `pts` ends the segment that started at `start`. - fn boundary(&mut self, start: Timestamp, pts: Timestamp, elapsed: Duration) -> bool { + /// Whether a group starting at `pts` in grid cell `cell` ends the segment that started at + /// `start`. + fn boundary(&mut self, start: Timestamp, pts: Timestamp, cell: u128) -> bool { while self .cuts .front() @@ -349,7 +273,7 @@ impl Segmenter { } return true; } - !self.manual && elapsed >= self.target() + !self.manual && cell > self.cell } /// Report that the content extends to `pts`, without a frame starting there. @@ -358,14 +282,16 @@ impl Segmenter { /// land at its last frame's start. pub fn end(&mut self, pts: Timestamp) { if !self.closed { - self.report(pts); + self.advance(pts); } } + /// Content in the open segment reached `pts`. fn advance(&mut self, pts: Timestamp) { - if self - .frontier - .is_none_or(|frontier| pts.as_micros() > frontier.as_micros()) + if self.open.is_some() + && self + .frontier + .is_none_or(|frontier| pts.as_micros() > frontier.as_micros()) { self.frontier = Some(pts); } @@ -373,10 +299,9 @@ impl Segmenter { /// Declare a boundary at `pts`: the segment ends at the first group starting at or after it. /// - /// The first cut takes over from [`Config::duration_min`] pacing for good, since pacing would - /// otherwise end a segment just before the caller declares where it really ends. A cut at or - /// before an earlier one is ignored, so several sources declaring the same boundaries cost - /// nothing. + /// The first cut takes over from grid pacing for good, since the grid would otherwise end a + /// segment just before the caller declares where it really ends. A cut at or before an earlier + /// one is ignored, so several sources declaring the same boundaries cost nothing. pub fn cut(&mut self, pts: Timestamp) { if self.closed { return; @@ -386,38 +311,7 @@ impl Segmenter { return; } self.cut = Some(pts); - // A follower that reported past this cut before hearing it decides those groups now: the - // ones before it are not boundaries, and its first group at or after it starts a segment. - while self - .undecided - .front() - .is_some_and(|(_, start)| start.as_micros() < pts.as_micros()) - { - self.undecided.pop_front(); - } - match self.undecided.pop_front() { - Some((group, start)) => { - if let Some(open) = self.open { - self.declared.get_or_insert(elapsed(open, start)); - } - self.begin(group, start); - if let Some(reported) = self.reported { - self.advance(reported); - } - } - None => { - // A follower that stopped reporting keeps only the cuts within a ceiling of this one: - // its next group past them all starts one segment either way, and one landing - // between them was already that far behind the pacer's numbering. - if self.following { - let horizon = self.ceiling(); - while self.cuts.front().is_some_and(|cut| elapsed(*cut, pts) > horizon) { - self.cuts.pop_front(); - } - } - self.cuts.push_back(pts); - } - } + self.cuts.push_back(pts); } /// Content stopped before group `group`: publish a zero-duration record where it reached. @@ -430,17 +324,14 @@ impl Segmenter { } fn stop_at(&mut self, group: u64) { - // What a follower reported past its last cut stops with it. - self.undecided.clear(); let Some(start) = self.open.take() else { return; }; - // One stopped early says nothing about the cadence, so the target falls back to the minimum. - self.declared.get_or_insert(self.config.duration_min); + // One stopped early says nothing about the cadence, so the target falls back to the grid. + self.declared.get_or_insert(self.grid); let end = self.frontier.take().unwrap_or(start); self.ready .push_back(Record::new(self.sequence, group, wire(end)).with_duration(0)); - self.marks.push(Mark::Stop); self.sequence += 1; } @@ -451,27 +342,6 @@ impl Segmenter { } self.closed = true; } - - /// Mirror what a pacer did. - fn follow(&mut self, mark: Mark) { - match mark { - Mark::Start(pts) => self.cut(pts), - Mark::Stop => { - if let Some(last) = self.last { - self.stop(last + 1); - } - } - } - } - - /// Take over pacing from a pacer that went away. - fn pace(&mut self) { - self.manual = false; - self.following = false; - self.cuts.clear(); - // Groups no cut decided stay in the open segment, as a pacer's do until its target. - self.undecided.clear(); - } } /// Yields each record once; `None` means none is ready yet, not that none ever will be. @@ -570,26 +440,21 @@ struct Live { output: Option, /// Whether a [`Recorder`] owns the segmenter, so a second enrollment can't share it. recording: bool, - /// The enrollment that last claimed it, so the earliest still recording takes over pacing. - enrolled: u64, } -/// What a [`Live::publish`] left for its caller to act on once every lock is released. +/// A declaration a [`Live::publish`] left for its caller to announce once its lock is released: +/// the callback takes the catalog's lock, which is taken before ours. #[must_use] struct Published { - /// What the segmenter did, for followers to mirror. - marks: Vec, - /// A declaration to announce: the callback takes the catalog's lock, which is taken before ours. declare: Option<(OnDeclare, Duration)>, } impl Published { - /// Announce any declaration, returning the marks. - fn fire(self) -> Vec { + /// Announce any declaration. + fn fire(self) { if let Some((on_declare, duration)) = self.declare { on_declare(duration); } - self.marks } } @@ -612,10 +477,7 @@ impl Live { Some(duration) => self.on_declare.take().map(|on_declare| (on_declare, duration)), None => None, }; - Published { - marks: std::mem::take(&mut self.segmenter.marks), - declare, - } + Published { declare } } /// Abort the record track with `err`, recording nothing more. @@ -642,10 +504,6 @@ struct State { /// Each enrolled track's record track name and live state, by media track name. Kept after its /// recorder drops, so a re-enrolled track continues its numbering. tracks: BTreeMap>)>, - /// The track pacing the segments, if any is recording. - pacer: Option, - /// Enrollments so far, numbering each. - enrolled: u64, finished: bool, } @@ -666,8 +524,6 @@ impl Tracks { broadcast: broadcast.clone(), state: Arc::new(Mutex::new(State { tracks: BTreeMap::new(), - pacer: None, - enrolled: 0, finished: false, })), } @@ -675,25 +531,16 @@ impl Tracks { /// Enroll the track `name`, its segments cut by `config`. /// - /// Creates its record track, named by [`hang::hls::default_name`], on first enrollment. The - /// first track recording paces the segments and the rest follow it. Enrolling a name again - /// after its recorder dropped continues its numbering for a new producer. Errors when the name's - /// recorder is still alive, the record track name is taken, or the tracks finished. + /// Creates its record track, named by [`hang::hls::default_name`], on first enrollment. + /// Enrolling a name again after its recorder dropped continues its numbering for a new + /// producer. Errors when the name's recorder is still alive, the record track name is taken, or + /// the tracks finished. pub fn track(&self, name: &str, config: Config) -> crate::Result { let mut state = self.state.lock().unwrap(); if state.finished { return Err(moq_net::Error::Closed.into()); } - state.enrolled += 1; - let enrolled = state.enrolled; - let pacing = state.pacer.is_none(); - let segmenter = |sequence: u64| { - let segmenter = Segmenter::new(config.clone()).with_sequence(sequence); - match pacing { - true => segmenter, - false => segmenter.following(), - } - }; + let segmenter = |sequence: u64| Segmenter::new(config.clone()).with_sequence(sequence); let live = match state.tracks.get(name) { Some((_, existing)) => { @@ -709,7 +556,6 @@ impl Tracks { live.segmenter.declared = declared; } live.recording = true; - live.enrolled = enrolled; drop(live); existing.clone() } @@ -722,7 +568,6 @@ impl Tracks { on_declare: None, output: Some(Producer::new(track)), recording: true, - enrolled, })); state .tracks @@ -730,14 +575,7 @@ impl Tracks { live } }; - if pacing { - state.pacer = Some(name.to_string()); - } - Ok(Recorder { - name: name.to_string(), - live, - state: self.state.clone(), - }) + Ok(Recorder { live }) } /// The record track and nominal duration advertised for `name`, once its duration is known. @@ -825,9 +663,7 @@ fn round(duration: Duration) -> Duration { /// rendition's [`container::Producer`](crate::container::Producer). Dropping it stops the open /// segment; the record track stays open until [`Tracks::finish`]. pub(crate) struct Recorder { - name: String, live: Arc>, - state: Arc>, } impl Recorder { @@ -836,46 +672,13 @@ impl Recorder { self.live.lock().unwrap().on_declare = Some(Box::new(callback)); } - fn report(&mut self, f: impl FnOnce(&mut Segmenter)) { + fn report(&mut self, f: impl FnOnce(&mut Live)) { let published = { let mut live = self.live.lock().unwrap(); - f(&mut live.segmenter); + f(&mut live); live.publish() }; - self.lead(&published.fire()); - } - - /// Hand a pacer's marks to every other track still recording. - fn lead(&self, marks: &[Mark]) { - if marks.is_empty() { - return; - } - let followers: Vec<_> = { - let state = self.state.lock().unwrap(); - if state.pacer.as_deref() != Some(self.name.as_str()) { - return; - } - state - .tracks - .iter() - .filter(|(name, _)| **name != self.name) - .map(|(_, (_, live))| live.clone()) - .collect() - }; - for follower in followers { - let published = { - let mut live = follower.lock().unwrap(); - if !live.recording { - continue; - } - for mark in marks { - live.segmenter.follow(*mark); - } - live.publish() - }; - // Only the pacer leads, so a follower's own marks go nowhere. - published.fire(); - } + published.fire(); } /// Report a frame of group `group`, presented at `pts`; see [`Segmenter::frame`]. @@ -883,77 +686,41 @@ impl Recorder { /// A report the segmenter cannot place ends the record track with an error rather than /// publishing records that silently omit frames. Media publishing is unaffected. pub fn frame(&mut self, group: u64, pts: Timestamp) { - let (published, failed) = { - let mut live = self.live.lock().unwrap(); - let result = live.segmenter.frame(group, pts); - if let Err(err) = &result { + self.report(|live| { + if let Err(err) = live.segmenter.frame(group, pts) { tracing::warn!(%err, "HLS report rejected; aborting the record track"); live.abort(err.reason()); } - (live.publish(), result.is_err()) - }; - self.lead(&published.fire()); - if failed { - // An aborted pacer cuts nothing more, so its followers would never start a segment. - self.hand_over(); - } - } - - /// Hand pacing, if this track holds it, to the earliest enrollment that can still pace, or to - /// nobody until the next. - fn hand_over(&self) { - let mut state = self.state.lock().unwrap(); - if state.pacer.as_deref() != Some(self.name.as_str()) { - return; - } - let next = state - .tracks - .iter() - .filter_map(|(name, (_, live))| { - let live = live.lock().unwrap(); - (live.recording && !live.segmenter.closed).then_some((live.enrolled, name.clone())) - }) - .min(); - state.pacer = next.map(|(_, name)| name); - if let Some(pacer) = &state.pacer { - state.tracks[pacer].1.lock().unwrap().segmenter.pace(); - } + }); } /// Report that the content extends to `pts`; see [`Segmenter::end`]. pub fn end(&mut self, pts: Timestamp) { - self.report(|segmenter| segmenter.end(pts)); + self.report(|live| live.segmenter.end(pts)); } /// Report that content stopped before group `group`; see [`Segmenter::stop`]. pub fn stop(&mut self, group: u64) { - self.report(|segmenter| segmenter.stop(group)); + self.report(|live| live.segmenter.stop(group)); } /// Declare a boundary at `pts` on this track; see [`Segmenter::cut`]. pub fn cut(&mut self, pts: Timestamp) { - self.report(|segmenter| segmenter.cut(pts)); + self.report(|live| live.segmenter.cut(pts)); } } impl Drop for Recorder { fn drop(&mut self) { - let published = { - let mut live = self.live.lock().unwrap(); + self.report(|live| { if let Some(last) = live.segmenter.last { live.segmenter.stop(last + 1); } live.recording = false; - let published = live.publish(); - // The callback holds the catalog, which holds these tracks: an enrollment that ends - // without declaring must drop it, or the broadcast never closes. - live.on_declare = None; - published - }; - // A pacer going away says nothing about where the others' content stops, so its last stop - // isn't mirrored: each follower stops where its own content does, or paces on. - published.fire(); - self.hand_over(); + }); + // The callback holds the catalog, which holds these tracks: an enrollment that ends without + // declaring must drop it, or the broadcast never closes. + self.live.lock().unwrap().on_declare = None; } } @@ -1117,7 +884,7 @@ mod test { } #[test] - fn short_groups_pack_up_to_the_minimum() { + fn short_groups_pack_into_a_grid_cell() { let mut segmenter = Segmenter::new(Config::default()); groups(&mut segmenter, 0..10, 1_000); assert_eq!( @@ -1127,18 +894,18 @@ mod test { } #[test] - fn a_hint_declares_its_smallest_multiple_at_or_past_the_minimum() { + fn a_hint_declares_its_smallest_multiple_at_or_past_the_duration() { let segmenter = Segmenter::new(Config::default().with_duration_hint(Duration::from_millis(1_500))); assert_eq!(segmenter.declared(), Some(Duration::from_millis(4_500))); assert_eq!(Segmenter::new(hinted()).declared(), Some(Duration::from_secs(4))); } #[test] - fn a_stopped_first_segment_declares_the_minimum() { + fn a_stopped_first_segment_declares_the_grid() { let mut segmenter = Segmenter::new(Config::default()); groups(&mut segmenter, 0..2, 1_000); segmenter.close(); - assert_eq!(segmenter.declared(), Some(DEFAULT_DURATION_MIN)); + assert_eq!(segmenter.declared(), Some(DEFAULT_DURATION)); assert_eq!(drain(&mut segmenter), [(0, 0, None), (2, 1_000, Some(0))]); } @@ -1180,7 +947,7 @@ mod test { } #[test] - fn the_ceiling_ends_a_pacer_waiting_on_cuts() { + fn the_ceiling_ends_a_segment_waiting_on_cuts() { let mut segmenter = Segmenter::new(hinted()); segmenter.cut(ms(100_000)); groups(&mut segmenter, 0..20, 1_000); @@ -1189,7 +956,7 @@ mod test { } #[test] - fn explicit_cuts_override_the_pacing() { + fn explicit_cuts_override_the_grid() { let mut segmenter = Segmenter::new(Config::default()); segmenter.cut(ms(2_000)); segmenter.cut(ms(3_000)); @@ -1212,104 +979,46 @@ mod test { } #[test] - fn a_follower_starts_at_the_pacers_segments() { - let mut follower = Segmenter::new(Config::default()).following(); - // Audio before the pacer's first segment is not recorded. - follower.frame(0, ms(0)).unwrap(); - follower.follow(Mark::Start(ms(100))); - for (group, pts) in [(1, 90), (2, 110), (3, 2_000), (4, 4_090), (5, 4_110)] { - follower.frame(group, ms(pts)).unwrap(); - } - // The pacer's second segment starts after the follower already reported group 5 past it, - // which still starts the follower's. - follower.follow(Mark::Start(ms(4_100))); - // The follower's own source declaring the same boundary again changes nothing. - follower.cut(ms(4_100)); - follower.frame(6, ms(4_130)).unwrap(); - follower.cut(ms(4_100)); - follower.follow(Mark::Stop); + fn an_uneven_gop_cuts_on_the_grid() { + let mut segmenter = Segmenter::new(Config::default()); + // 3s GOPs on a 4s grid: a group starts a segment only in a later cell than the one before. + groups(&mut segmenter, 0..9, 3_000); assert_eq!( - drain(&mut follower), - [(2, 110, None), (5, 4_110, None), (7, 4_130, Some(0))] + drain(&mut segmenter), + [ + (0, 0, None), + (2, 6_000, None), + (3, 9_000, None), + (4, 12_000, None), + (6, 18_000, None), + (7, 21_000, None), + (8, 24_000, None) + ] ); + assert_eq!(segmenter.declared(), Some(Duration::from_secs(6))); } - #[test] - fn a_stalled_pacer_bounds_what_its_follower_keeps() { - let mut follower = Segmenter::new(hinted()).following(); - follower.follow(Mark::Start(ms(0))); - // 200s of audio past the pacer's only cut. - groups(&mut follower, 0..10_000, 20); - // A ceiling (three 4s segments) of groups waits for the pacer. - assert_eq!(follower.undecided.len(), 601); - // The pacer resuming that far behind starts the follower's next segment at its oldest kept - // group, so both still number it alike. - follower.follow(Mark::Start(ms(5_000))); - assert_eq!(drain(&mut follower), [(0, 0, None), (9_399, 187_980, None)]); - } - - #[test] - fn an_idle_follower_bounds_the_cuts_it_keeps() { - let mut follower = Segmenter::new(hinted()).following(); - for segment in 0..10_000 { - follower.follow(Mark::Start(ms(segment * 4_000))); - } - assert_eq!(follower.cuts.len(), 4); - follower.frame(0, ms(39_996_000)).unwrap(); - assert_eq!(drain(&mut follower), [(0, 39_996_000, None)]); - } - - // Aligned GOPs whose follower frame is reported just before the pacer's: each group the - // follower reported ahead still starts its segment once the pacer's cut arrives. - #[tokio::test] - async fn a_follower_reporting_first_keeps_the_pacers_boundaries() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = Tracks::new(&broadcast); - let mut pacer = tracks.track("video0", hinted()).unwrap(); - let mut follower = tracks.track("video1", hinted()).unwrap(); - for group in 0..4 { - follower.frame(group, ms(group * 4_000)); - pacer.frame(group, ms(group * 4_000)); - } - let records = |name: &str| { - let state = tracks.state.lock().unwrap(); - let live = state.tracks[name].1.lock().unwrap(); - live.segmenter.sequence() - }; - assert_eq!(records("video1"), records("video0")); - drop((pacer, follower)); - tracks.finish(); - - let consumer = broadcast.consume(); - let read = async |name: &str| { - let hls = tracks.hls(name).unwrap(); - let mut consumer = Consumer::subscribe(&consumer, &hls).await.unwrap(); - let mut out = Vec::new(); - while let Some(Event::Push { entry, .. }) = consumer.next().await.unwrap() { - out.push((entry.sequence, entry.group, Duration::from(entry.pts).as_millis())); - } - out - }; - let video0 = read("video0").await; - assert_eq!(&video0[..4], [(0, 0, 0), (1, 1, 4_000), (2, 2, 8_000), (3, 3, 12_000)]); - assert_eq!(read("video1").await, video0); - } - + // Renditions share no state, yet cut the same boundaries when their groups start on the same + // grid lines, and one that starts late lines up from its next grid line. #[tokio::test] - async fn followers_number_segments_like_the_pacer() { + async fn renditions_cut_alike_without_sharing_state() { let broadcast = moq_net::broadcast::Info::new().produce(); let tracks = Tracks::new(&broadcast); let mut video = tracks.track("video", hinted()).unwrap(); let mut audio = tracks.track("audio", hinted()).unwrap(); + let mut late = tracks.track("late", hinted()).unwrap(); // Audio runs ahead of video, grouped every 20ms. for step in 0..450u64 { let pts = step * 20; audio.frame(step, ms(pts + 5)); + if pts >= 5_000 { + late.frame(step, ms(pts + 5)); + } if pts % 2_000 == 0 { video.frame(pts / 2_000, ms(pts)); } } - drop((video, audio)); + drop((video, audio, late)); tracks.finish(); let consumer = broadcast.consume(); @@ -1325,51 +1034,18 @@ mod test { } out }; - let video = read("video").await; - let audio = read("audio").await; assert_eq!( - video, + read("video").await, [(0, 0, false), (1, 4_000, false), (2, 8_000, false), (3, 8_000, true)] ); - // Each audio segment starts at its first group at or after the video segment's, though - // audio reported that group before video reported its start. assert_eq!( - audio, + read("audio").await, [(0, 5, false), (1, 4_005, false), (2, 8_005, false), (3, 8_985, true)] ); - } - - #[tokio::test] - async fn the_next_enrollment_takes_over_pacing() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = Tracks::new(&broadcast); - let video = tracks.track("video", hinted()).unwrap(); - let mut audio = tracks.track("audio", hinted()).unwrap(); - drop(video); - groups_on(&mut audio, 0..9, 1_000); - let live = tracks.state.lock().unwrap().tracks["audio"].1.clone(); - let next = live.lock().unwrap().segmenter.sequence(); - assert_eq!(next, 3, "audio paced its own segments at 0, 4s, and 8s"); - } - - #[tokio::test] - async fn a_rejected_pacer_hands_over_pacing() { - let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = Tracks::new(&broadcast); - let mut video = tracks.track("video", hinted()).unwrap(); - let mut audio = tracks.track("audio", hinted()).unwrap(); - video.frame(1, ms(0)); - video.frame(0, ms(1_000)); - groups_on(&mut audio, 0..9, 1_000); - let live = tracks.state.lock().unwrap().tracks["audio"].1.clone(); - let next = live.lock().unwrap().segmenter.sequence(); - assert_eq!(next, 3, "audio paced its own segments at 0, 4s, and 8s"); - } - - fn groups_on(recorder: &mut Recorder, range: std::ops::Range, step: u64) { - for group in range { - recorder.frame(group, ms(group * step)); - } + assert_eq!( + read("late").await, + [(0, 5_005, false), (1, 8_005, false), (2, 8_985, true)] + ); } #[test] diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index 03ae91e0424..6c45f161688 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -1474,7 +1474,7 @@ mod tests { live.push(published); drop(track); // Idle past the minimum segment, so each run is listed as its own segment. - tokio::time::sleep(moq_mux::hls::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; + tokio::time::sleep(moq_mux::hls::DEFAULT_DURATION + Duration::from_millis(100)).await; } let (mut catalog, _consumer) = fixture.finish().await; From f72042ad54a5f17c623dde4902ff515353cc553e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 14:15:52 -0700 Subject: [PATCH 16/20] fix(hls): restate the wall clock after a discontinuity, and record another record track A playlist wrote EXT-X-PROGRAM-DATE-TIME only on its first segment, so content that stopped and resumed later lost its wall-clock mapping; with discontinuities counted per rendition, that mapping is what lets a player resync. The edge and the recorder now restate it after each discontinuity. The recorder treated a replacement reading another record track as a repeat of its predecessor's numbers and discarded it. It now compares numbers only within one record track (Rendition::hls names it) and numbers its own segments on from the first one recorded. Co-Authored-By: Claude Opus 5.5 --- doc/bin/hls.md | 2 +- rs/moq-cli/src/hls/record.rs | 136 +++++++++++++++++++++++------ rs/moq-hls/src/export/mod.rs | 4 +- rs/moq-hls/src/export/playlist.rs | 62 ++++++++++--- rs/moq-hls/src/export/rendition.rs | 8 +- 5 files changed, 168 insertions(+), 44 deletions(-) diff --git a/doc/bin/hls.md b/doc/bin/hls.md index deb946586c0..b833fa8bc04 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -50,7 +50,7 @@ Media is never transcoded, so the player must support the publisher's codecs. What that means for you: - **Opt in at the publisher.** Only a rendition whose catalog entry carries `hls` gets a playlist; nothing publishes it by default. Pass `--hls` to `moq import` (the stdin formats, `hls`, `rtsp`, and `capture`), or enable it with `moq_mux::catalog::Config::with_hls` in Rust. Without a duration hint, a rendition declares its records after its first segment. -- **Each rendition cuts its own segments.** A group starts a segment when its pts falls in a later multiple of the segment duration than the previous group's, and each rendition numbers its own segments. Players match renditions by timestamp, so nothing coordinates the ladder: segments line up wherever keyframes share timestamps, a rendition that starts late lines up from its next multiple, and audio lines up with video whose keyframes sit on those multiples. The DASH manifest leaves `segmentAlignment` out. A break in one rendition's content starts a discontinuity on that rendition alone; `EXT-X-PROGRAM-DATE-TIME`, written when the catalog has a clock, lets players resync. +- **Each rendition cuts its own segments.** A group starts a segment when its pts falls in a later multiple of the segment duration than the previous group's, and each rendition numbers its own segments. Players match renditions by timestamp, so nothing coordinates the ladder: segments line up wherever keyframes share timestamps, a rendition that starts late lines up from its next multiple, and audio lines up with video whose keyframes sit on those multiples. The DASH manifest leaves `segmentAlignment` out. A break in one rendition's content starts a discontinuity on that rendition alone; `EXT-X-PROGRAM-DATE-TIME`, written after every discontinuity when the catalog has a clock, lets players resync. - **Idle renditions cost nothing**, and a segment is servable for as long as the relay's [cache](/bin/relay/config#cache) retains it. Keep `--window` (the playlist length, default 16 s) within that retention. - **Live joins cost the window.** The exporter reads the record checkpoint and lists at most 256 segments. - **Segments are timed by their records.** A segment is listed once the record after it arrives, and its duration, its DASH `t` and `d`, and its last sample's duration all come from its own record and the next, so a segment's bytes never change with a catalog estimate. A stop record (a break or the end of the content) is listed as a zero-length gap, and what follows it starts a discontinuity. diff --git a/rs/moq-cli/src/hls/record.rs b/rs/moq-cli/src/hls/record.rs index 31647cdbcdd..3859d9c2a94 100644 --- a/rs/moq-cli/src/hls/record.rs +++ b/rs/moq-cli/src/hls/record.rs @@ -3,7 +3,8 @@ //! The layout mirrors the served one without the instance: `master.m3u8`, then //! `{kind}/{rendition}/media.m3u8` with its `init.{n}.mp4` and `{segment}.m4s` files. Each //! rendition keeps one continuous media playlist across decoder reconfigurations, so the whole -//! recording stays playable from its master playlist. +//! recording stays playable from its master playlist. Its segments are numbered from the first +//! one recorded, so a replacement reading another record track continues the numbering. use std::collections::{BTreeMap, VecDeque, btree_map}; use std::path::{Path, PathBuf}; @@ -49,8 +50,9 @@ struct Slot { playlist: Playlist, /// The instance being recorded, if its cursor is in flight. recording: Option, - /// Replacements whose cursors wait for the current instance to drain, oldest first. - queued: VecDeque, + /// Replacements whose cursors wait for the current instance to drain, oldest first, each + /// with the record track it reads. + queued: VecDeque<(Arc, segments::Consumer)>, /// How many instances started, which numbers their init files. instances: u64, /// The last segment recorded, by any instance. @@ -82,6 +84,8 @@ struct Instance { index: u64, /// The init file name, once written. init: Option, + /// The record track its segment numbers come from. + track: Arc, /// The last segment the previous instance recorded, which this one must continue. after: Option, /// It yielded the segment `after` names, so its next one directly follows. @@ -90,8 +94,10 @@ struct Instance { started: bool, } -#[derive(Clone, Copy)] +/// The source of the last segment recorded. +#[derive(Clone)] struct Last { + track: Arc, segment: u64, discontinuity: u64, } @@ -106,30 +112,33 @@ enum Admit { } impl Instance { - /// Admit the next segment. A replacement repeats the rows its predecessor recorded, since - /// its cursor starts at the oldest row retained, so those are discarded. The numbers name the - /// same span in every instance of a rendition and only increase within a broadcaster run, so - /// a repeated number with a newer discontinuity is a source reset, and a jump past the next - /// number is a gap nothing recorded. + /// Admit the next segment. A replacement reading its predecessor's record track may repeat + /// the rows its predecessor recorded, since its cursor can start at the oldest row retained, + /// so those are discarded. Those numbers name the same span in both and only increase within + /// a broadcaster run, so a repeated number with a newer discontinuity is a source reset, and + /// a jump past the next number is a gap nothing recorded. Another record track's numbers name + /// other content, so all of it is recorded. fn admit(&mut self, segment: u64, discontinuity: u64) -> anyhow::Result { - let Some(after) = self.after.filter(|_| !self.started) else { + let Some(after) = self.after.as_ref().filter(|_| !self.started) else { self.started = true; return Ok(Admit::Record { transition: false }); }; - if segment <= after.segment { + if after.track == self.track { + if segment <= after.segment { + anyhow::ensure!( + discontinuity <= after.discontinuity, + "the source restarted its timeline at segment {segment}" + ); + self.overlapped |= segment == after.segment; + return Ok(Admit::Discard); + } anyhow::ensure!( - discontinuity <= after.discontinuity, - "the source restarted its timeline at segment {segment}" + self.overlapped || segment == after.segment + 1, + "missed segments {} to {}", + after.segment + 1, + segment - 1 ); - self.overlapped |= segment == after.segment; - return Ok(Admit::Discard); } - anyhow::ensure!( - self.overlapped || segment == after.segment + 1, - "missed segments {} to {}", - after.segment + 1, - segment - 1 - ); self.started = true; Ok(Admit::Record { transition: true }) } @@ -210,6 +219,7 @@ impl Recording { let key = (rendition.kind, rendition.name.clone()); // Subscribe now: the cursor starts at the oldest row retained when it is created. let cursor = rendition.segments(); + let track = Arc::from(rendition.hls().track.as_str()); let slot = match self.slots.entry(key.clone()) { btree_map::Entry::Occupied(entry) => entry.into_mut(), btree_map::Entry::Vacant(entry) => { @@ -234,7 +244,7 @@ impl Recording { } }; slot.rendition = Some(rendition); - slot.queued.push_back(cursor); + slot.queued.push_back((track, cursor)); Ok(match slot.recording { Some(_) => None, None => slot.begin(key), @@ -275,6 +285,7 @@ impl Recording { } let instance = slot.recording.as_mut().expect("a cursor in flight is recording"); + let number = slot.playlist.next(segment.segment); let mut map = None; if let Some(media) = &segment.media { if instance.init.is_none() { @@ -284,18 +295,20 @@ impl Recording { instance.init = Some(file.clone()); map = Some(file); } - write(&slot.dir.join(format!("{}.m4s", segment.segment)), media).await?; + write(&slot.dir.join(format!("{number}.m4s")), media).await?; } let init = instance.init.is_none(); slot.last = Some(Last { + track: instance.track.clone(), segment: segment.segment, discontinuity: segment.discontinuity, }); slot.playlist.push(Entry { - segment: segment.segment, + segment: number, duration: segment.duration, gap: segment.media.is_none(), discontinuity: segment.discontinuity, + program_date_time: segment.program_date_time, map, }); @@ -363,11 +376,12 @@ impl Slot { /// Start recording the next queued instance of `key`, continuing after the last segment /// recorded. fn begin(&mut self, key: Key) -> Option { - let cursor = self.queued.pop_front()?; + let (track, cursor) = self.queued.pop_front()?; self.recording = Some(Instance { index: self.instances, init: None, - after: self.last, + track, + after: self.last.clone(), overlapped: false, started: false, }); @@ -404,16 +418,24 @@ struct Header { } struct Entry { + /// The recording's number for the segment, which names its file. segment: u64, duration: Duration, gap: bool, /// The rendition's discontinuity sequence for the segment; the playlist adds its transitions. discontinuity: u64, + /// Written on the first entry and after each discontinuity. + program_date_time: Option, /// The init file the instance's first media segment maps. map: Option, } impl Playlist { + /// The number of the next entry: the source's for the first, then consecutive. + fn next(&self, source: u64) -> u64 { + self.entries.last().map_or(source, |last| last.segment + 1) + } + fn push(&mut self, mut entry: Entry) { self.version = self.version.max(if entry.gap { VERSION_GAP } else { VERSION }); // Every EXTINF, rounded up to whole seconds, must fit the target duration. @@ -470,10 +492,17 @@ impl Playlist { for _ in previous.unwrap_or(discontinuity)..discontinuity { out += "#EXT-X-DISCONTINUITY\n"; } + let restart = previous.is_none_or(|previous| previous != discontinuity); previous = Some(discontinuity); if let Some(map) = &entry.map { out += &format!("#EXT-X-MAP:URI=\"{map}\"\n"); } + if restart && let Some(pdt) = entry.program_date_time { + out += &format!( + "#EXT-X-PROGRAM-DATE-TIME:{}\n", + humantime::format_rfc3339_millis(pdt.into()) + ); + } if entry.gap { out += "#EXT-X-GAP\n"; } @@ -726,7 +755,7 @@ mod tests { assert_eq!(duration(&playlist), Duration::from_secs(6), "{playlist}"); assert!(playlist.contains("\n#EXT-X-MEDIA-SEQUENCE:0\n"), "{playlist}"); assert!( - playlist.contains("#EXT-X-MAP:URI=\"init.0.mp4\"\n#EXTINF:"), + playlist.contains("#EXT-X-MAP:URI=\"init.0.mp4\"\n#EXT-X-PROGRAM-DATE-TIME:"), "{playlist}" ); assert!(playlist.ends_with(".m4s\n#EXT-X-ENDLIST\n"), "{playlist}"); @@ -905,7 +934,12 @@ mod tests { Instance { index: 1, init: None, - after: after.map(|(segment, discontinuity)| Last { segment, discontinuity }), + track: "video.hls".into(), + after: after.map(|(segment, discontinuity)| Last { + track: "video.hls".into(), + segment, + discontinuity, + }), overlapped: false, started: false, } @@ -928,6 +962,25 @@ mod tests { assert_eq!(first.admit(40, 3).unwrap(), Admit::Record { transition: false }); } + /// Another record track numbers other content, so its numbers repeat nothing recorded. + #[test] + fn a_replacement_on_another_record_track_records_everything() { + let mut replacement = Instance { + track: "video-640.hls".into(), + ..instance(Some((5, 0))) + }; + assert_eq!(replacement.admit(0, 0).unwrap(), Admit::Record { transition: true }); + assert_eq!(replacement.admit(1, 0).unwrap(), Admit::Record { transition: false }); + + // The recording numbers its segments on from the predecessor's. + let mut playlist = Playlist::default(); + for source in 3..=5 { + let number = playlist.next(source); + playlist.push(entry(number, 0)); + } + assert_eq!(playlist.next(0), 6); + } + #[test] fn a_replacement_refuses_a_gap_or_a_reset() { // Rows evicted before the replacement subscribed are missing from both cursors. @@ -947,10 +1000,37 @@ mod tests { duration: Duration::from_secs(2), gap: false, discontinuity, + program_date_time: None, map: (segment == 0).then(|| "init.0.mp4".to_string()), } } + /// The wall clock is written on the first entry and after each discontinuity, where the + /// running sum of durations no longer gives it. + #[test] + fn a_discontinuity_restates_the_program_date_time() { + let at = |secs| Some(moq_clock::Wall::UNIX_EPOCH + Duration::from_secs(secs)); + let mut playlist = Playlist::default(); + for (segment, discontinuity, secs) in [(0, 0, 0), (1, 0, 2), (2, 1, 10), (3, 1, 12)] { + playlist.push(Entry { + program_date_time: at(secs), + ..entry(segment, discontinuity) + }); + } + let text = playlist.render(0); + assert!( + text.starts_with("#EXT-X-MAP:URI=\"init.0.mp4\"\n#EXT-X-PROGRAM-DATE-TIME:1970-01-01T00:00:00.000Z\n"), + "{text}" + ); + assert!( + text.contains("1.m4s\n#EXT-X-DISCONTINUITY\n#EXT-X-PROGRAM-DATE-TIME:1970-01-01T00:00:10.000Z\n#EXTINF:"), + "{text}" + ); + assert_eq!(text.matches("PROGRAM-DATE-TIME").count(), 2, "{text}"); + // An append continuing the same run restates nothing. + assert!(!playlist.render(3).contains("PROGRAM-DATE-TIME")); + } + /// A switch to a reconfigured instance and the rendition's own break are each a /// discontinuity, appended; only a raised header rewrites the playlist. #[tokio::test] diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index a0acebeab74..92c6c460f95 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -1915,7 +1915,7 @@ mod tests { let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; let snapshot = rendition.snapshot(); - assert_eq!(snapshot.program_date_time, Some(wall)); + assert_eq!(snapshot.segments[0].program_date_time, Some(wall)); let playlist = rendition.media_playlist(None).unwrap().expect("playlist renders"); assert!( playlist.contains("#EXT-X-PROGRAM-DATE-TIME:2020-01-01T00:00:00.000Z\n"), @@ -1975,7 +1975,7 @@ mod tests { let rendition = broadcaster.rendition(Kind::Video, "video0").expect("video discovered"); let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; - assert_eq!(rendition.snapshot().program_date_time, None); + assert_eq!(rendition.snapshot().segments[0].program_date_time, None); let playlist = rendition.media_playlist(None).unwrap().expect("playlist renders"); assert!(!playlist.contains("PROGRAM-DATE-TIME"), "{playlist}"); diff --git a/rs/moq-hls/src/export/playlist.rs b/rs/moq-hls/src/export/playlist.rs index 640d20210c3..fd656d4c5dd 100644 --- a/rs/moq-hls/src/export/playlist.rs +++ b/rs/moq-hls/src/export/playlist.rs @@ -30,9 +30,6 @@ pub(crate) struct Snapshot { pub segments: Vec, /// Whether the broadcast ended (`EXT-X-ENDLIST`). pub finished: bool, - /// Wall-clock time of the first listed segment (`EXT-X-PROGRAM-DATE-TIME`), when the - /// catalog advertises its root broadcast clock. - pub program_date_time: Option, /// The publisher run every segment URL carries (`seg/{generation}.{tag}.{segment}.m4s`). pub generation: Option>, } @@ -50,6 +47,10 @@ pub(crate) struct Segment { pub gap: bool, /// Content time jumped before this segment (`EXT-X-DISCONTINUITY`). pub discontinuity: bool, + /// Wall-clock start (`EXT-X-PROGRAM-DATE-TIME`), when the catalog advertises its root + /// broadcast clock. Rendered on the first segment and after each discontinuity, where the + /// running sum of `EXTINF`s no longer gives it. + pub program_date_time: Option, } /// Render a media playlist for one rendition from a [`Snapshot`], mapping `init.{init}.mp4`. @@ -83,8 +84,11 @@ pub(crate) fn render_media(snapshot: &Snapshot, init: &str, query: Option<&str>) let _ = writeln!(out, "#EXT-X-MAP:URI=\"init.{init}.mp4{suffix}\""); for (index, segment) in snapshot.segments.iter().enumerate() { - if index == 0 - && let Some(pdt) = snapshot.program_date_time + if segment.discontinuity { + let _ = writeln!(out, "#EXT-X-DISCONTINUITY"); + } + if (index == 0 || segment.discontinuity) + && let Some(pdt) = segment.program_date_time { let _ = writeln!( out, @@ -92,9 +96,6 @@ pub(crate) fn render_media(snapshot: &Snapshot, init: &str, query: Option<&str>) humantime::format_rfc3339_millis(pdt.into()) ); } - if segment.discontinuity { - let _ = writeln!(out, "#EXT-X-DISCONTINUITY"); - } if segment.gap { let _ = writeln!(out, "#EXT-X-GAP"); } @@ -128,6 +129,7 @@ mod tests { duration: Duration::from_secs(2), gap: false, discontinuity: false, + program_date_time: Some(Wall::UNIX_EPOCH + Duration::from_millis(1_751_846_400_123)), }, Segment { segment: 11, @@ -135,10 +137,10 @@ mod tests { duration: Duration::from_millis(1960), gap: false, discontinuity: false, + program_date_time: Some(Wall::UNIX_EPOCH + Duration::from_millis(1_751_846_402_123)), }, ], finished: false, - program_date_time: Some(Wall::UNIX_EPOCH + Duration::from_millis(1_751_846_400_123)), generation: None, }; @@ -148,6 +150,7 @@ mod tests { assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:10\n")); assert!(out.contains("#EXT-X-MAP:URI=\"init.0123abcd.mp4\"\n")); assert!(out.contains("#EXT-X-PROGRAM-DATE-TIME:2025-07-07T00:00:00.123Z\n")); + assert_eq!(out.matches("PROGRAM-DATE-TIME").count(), 1, "only the first: {out}"); assert!(out.contains("#EXTINF:2.00000,\nseg/ab12cd34.10.m4s\n")); assert!(out.contains("#EXTINF:1.96000,\nseg/ab12cd34.11.m4s\n")); assert!(!out.contains("#EXT-X-ENDLIST")); @@ -159,6 +162,43 @@ mod tests { assert!(signed.contains("\nseg/ab12cd34.11.m4s?jwt=abc.def\n")); } + // Content that stops and resumes later restates its wall clock: the stop's zero duration + // carries none of the jump. + #[test] + fn a_discontinuity_restates_the_program_date_time() { + let at = |millis| Some(Wall::UNIX_EPOCH + Duration::from_millis(millis)); + let segment = |segment, duration, gap, discontinuity, program_date_time| Segment { + segment, + tag: "ab12cd34".into(), + duration: Duration::from_millis(duration), + gap, + discontinuity, + program_date_time, + }; + let snapshot = Snapshot { + target_duration: 2, + media_sequence: 0, + discontinuity_sequence: 0, + segments: vec![ + segment(0, 2_000, false, false, at(0)), + segment(1, 0, true, false, at(2_000)), + segment(2, 2_000, false, true, at(10_000)), + segment(3, 2_000, false, false, at(12_000)), + ], + finished: false, + generation: None, + }; + + let out = render_media(&snapshot, "0123abcd", None); + assert!( + out.contains( + "seg/ab12cd34.1.m4s\n#EXT-X-DISCONTINUITY\n#EXT-X-PROGRAM-DATE-TIME:1970-01-01T00:00:10.000Z\n#EXTINF:2.00000,\nseg/ab12cd34.2.m4s\n" + ), + "{out}" + ); + assert_eq!(out.matches("PROGRAM-DATE-TIME").count(), 2, "{out}"); + } + #[test] fn generation_rides_every_segment_url() { let snapshot = Snapshot { @@ -171,9 +211,9 @@ mod tests { duration: Duration::from_secs(2), gap: false, discontinuity: false, + program_date_time: None, }], finished: false, - program_date_time: None, generation: Some("run-7".into()), }; @@ -194,9 +234,9 @@ mod tests { duration: Duration::from_secs(4), gap: false, discontinuity: false, + program_date_time: None, }], finished: true, - program_date_time: None, generation: None, }; diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 19d0c7a2718..d01b0813bf4 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -373,6 +373,11 @@ impl Rendition { normalize_audio(current) == normalize_audio(config) } + /// The catalog's `hls` field this rendition lists records from. + pub fn hls(&self) -> &Hls { + &self.hls + } + /// The advertised bitrate in bits per second, for the master playlist `BANDWIDTH` attribute /// and the DASH `bandwidth`. Falls back to a per-kind default when the catalog carries none. pub fn bandwidth(&self) -> u64 { @@ -779,7 +784,6 @@ impl Rendition { .max() .unwrap_or(0) .max(1); - let program_date_time = window.segments.first().and_then(|first| self.wall_clock(first.pts)); let mut previous = None; let segments = window .segments @@ -792,6 +796,7 @@ impl Rendition { duration: s.duration, gap: s.stop, discontinuity, + program_date_time: self.wall_clock(s.pts), } }) .collect(); @@ -801,7 +806,6 @@ impl Rendition { discontinuity_sequence: window.discontinuity, segments, finished, - program_date_time, generation, } } From a1af6f0b8d80322003f52ce0a2c7aff87683b000 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 14:32:28 -0700 Subject: [PATCH 17/20] fix(hls): let a codec-stated duration win over the segment end The edge timed a segment's last sample from the record's end before the muxer applied codec durations, so an Opus packet at a sub-millisecond timestamp took the millisecond-floored end (936 ticks instead of 960). Muxer::fragment now takes the end and applies it after codec durations, to the last presented sample only when nothing else times it. Co-Authored-By: Claude Opus 5.5 --- rs/moq-cli/src/hls.rs | 2 +- rs/moq-ffi/src/media.rs | 1 + rs/moq-hls/src/export/mod.rs | 6 +- rs/moq-hls/src/export/rendition.rs | 15 +--- rs/moq-hls/src/import.rs | 2 +- rs/moq-mux/MIGRATE.md | 1 + rs/moq-mux/src/container/fmp4/fragmenter.rs | 2 +- rs/moq-mux/src/container/fmp4/muxer.rs | 76 +++++++++++++++------ 8 files changed, 66 insertions(+), 39 deletions(-) diff --git a/rs/moq-cli/src/hls.rs b/rs/moq-cli/src/hls.rs index 6b2998b23cd..b078c65e392 100644 --- a/rs/moq-cli/src/hls.rs +++ b/rs/moq-cli/src/hls.rs @@ -501,7 +501,7 @@ mod test { payload: bytes::Bytes::from_static(&[0x78, 0x00, 0x00, 0x00]), keyframe: true, }; - let fragment = muxer.fragment(index as u32, &[frame]).unwrap(); + let fragment = muxer.fragment(index as u32, &[frame], None).unwrap(); std::fs::write(dir.path().join(format!("{index}.m4s")), fragment).unwrap(); } let live = "#EXTM3U\n#EXT-X-TARGETDURATION:1\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:1,\n0.m4s\n"; diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index a7203d1e192..c316ac8871b 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -1640,6 +1640,7 @@ mod timebase_tests { keyframe: true, duration: Some(moq_net::Timestamp::from_millis(20).unwrap()), }], + None, ) .unwrap() } diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 92c6c460f95..09825625e56 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -2002,7 +2002,7 @@ mod tests { let mut fragments = Vec::new(); for i in 0..6u64 { let frame = vp8_frame(start + i * 1_000_000, i % 2 == 0); - fragments.extend_from_slice(&muxer.fragment(i as u32, &[frame]).unwrap()); + fragments.extend_from_slice(&muxer.fragment(i as u32, &[frame], None).unwrap()); } let origin = produce_origin(); @@ -2138,12 +2138,12 @@ mod tests { let broadcaster = (!late).then(|| Broadcaster::new(source.clone(), Config::default())); for i in 0..20u64 { let fragment = video_muxer - .fragment(i as u32, &[vp8_frame(i * 1_000_000, i % 2 == 0)]) + .fragment(i as u32, &[vp8_frame(i * 1_000_000, i % 2 == 0)], None) .unwrap(); import.decode(&fragment).unwrap(); let mut sample = frame(i * 1_000_000, true); sample.duration = Some(moq_net::Timestamp::from_secs(1).unwrap()); - let audio = audio_muxer.fragment(i as u32, &[sample]).unwrap(); + let audio = audio_muxer.fragment(i as u32, &[sample], None).unwrap(); let mut audio = std::io::Cursor::new(audio.as_ref()); let mut fragment = Vec::new(); while let Some(mut atom) = mp4_atom::Any::decode_maybe(&mut audio).unwrap() { diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index d01b0813bf4..e505f868bf1 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -1147,18 +1147,9 @@ impl Rendition { return Ok(None); } // The last presented sample has no successor to time it, but the record says where the - // segment ends, so the fragment ends exactly where the next one starts. One a duration - // marker already timed keeps it. - if let Some(last) = frames.iter_mut().max_by_key(|frame| frame.timestamp.as_micros()) - && last.duration.is_none() - && let Ok(end) = moq_net::Timestamp::from_micros(row.end.as_micros() as u64) - && let Ok(end) = end.convert(last.timestamp.scale()) - && let Ok(duration) = end.checked_sub(last.timestamp) - && !duration.is_zero() - { - last.duration = Some(duration); - } - let fragment = muxer.fragment(row.segment as u32, &frames)?; + // segment ends, so the fragment ends where the next one starts. + let end = moq_net::Timestamp::from_micros(row.end.as_micros() as u64).ok(); + let fragment = muxer.fragment(row.segment as u32, &frames, end)?; // Inline codec metadata was already decoded from these completed groups. // Reuse it instead of fetching a newer, possibly unfinished group. if let Some(bytes) = muxer.init()? { diff --git a/rs/moq-hls/src/import.rs b/rs/moq-hls/src/import.rs index ebff25cd319..36fc7868932 100644 --- a/rs/moq-hls/src/import.rs +++ b/rs/moq-hls/src/import.rs @@ -2236,7 +2236,7 @@ mod tests { payload: Bytes::from_static(&[0x78, 0x00, 0x00, 0x00]), keyframe: true, }; - let fragment = muxer.fragment(index as u32, &[frame]).unwrap(); + let fragment = muxer.fragment(index as u32, &[frame], None).unwrap(); std::fs::write(dir.join(format!("{prefix}{index}.m4s")), fragment).unwrap(); } } diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index 1d6928877d2..646eca3d2e6 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -20,6 +20,7 @@ Read records with `hls::Consumer::subscribe(&broadcast, &hls)`, whose `Event::Pu `Config::duration_min` is `hls::Config::duration`, `with_duration_min` is `with_duration`, and `DEFAULT_DURATION_MIN` is `DEFAULT_DURATION`. It is now the spacing of a pts grid rather than a floor: a group starts a segment when its pts falls in a later multiple of it than the previous group's, so a segment may be shorter. Each rendition cuts and numbers its own segments, so they line up across renditions wherever keyframes share timestamps. +`container::fmp4::Muxer::fragment` takes a third argument, where the content ends, which times the last sample when neither its codec nor a duration marker does; pass `None` to keep the old behavior. ### JSON snapshot encoder options move to `json::Options` diff --git a/rs/moq-mux/src/container/fmp4/fragmenter.rs b/rs/moq-mux/src/container/fmp4/fragmenter.rs index 2cd49d121e2..8c19ba403d3 100644 --- a/rs/moq-mux/src/container/fmp4/fragmenter.rs +++ b/rs/moq-mux/src/container/fmp4/fragmenter.rs @@ -642,7 +642,7 @@ mod tests { let muxer = video_muxer(); let frame = tick_frame(5_000, true); - let batch = muxer.fragment(0, std::slice::from_ref(&frame)).unwrap(); + let batch = muxer.fragment(0, std::slice::from_ref(&frame), None).unwrap(); let pushed = one(muxer.fragmenter(Default::default()).push(frame).unwrap()); assert_eq!(pushed.data, batch); } diff --git a/rs/moq-mux/src/container/fmp4/muxer.rs b/rs/moq-mux/src/container/fmp4/muxer.rs index e107c9c0aab..c601acd7b56 100644 --- a/rs/moq-mux/src/container/fmp4/muxer.rs +++ b/rs/moq-mux/src/container/fmp4/muxer.rs @@ -4,6 +4,7 @@ use std::time::Duration; use bytes::Bytes; use hang::catalog::{AudioConfig, Container as CatalogContainer, VideoConfig}; +use moq_net::Timestamp; use crate::catalog::hang::Container as HangContainer; use crate::container::Frame; @@ -287,10 +288,13 @@ impl Muxer { /// even so: consecutive sequence numbers say nothing about whether the publisher paused /// across the boundary. /// + /// `end`, when the caller knows where the content ends (such as the next segment's start), + /// times the last presented sample if neither its codec nor a duration marker does. + /// /// To make each frame separately addressable instead, use /// [`fragmenter`](Self::fragmenter). - pub fn fragment(&self, sequence: u32, frames: &[Frame]) -> crate::Result { - let frames = self.resolve_durations(frames)?; + pub fn fragment(&self, sequence: u32, frames: &[Frame], end: Option) -> crate::Result { + let frames = self.resolve_durations(frames, end)?; Ok(super::encode_fragment(self.fragment_info(sequence), &frames)?) } @@ -320,11 +324,21 @@ impl Muxer { } } - /// Give every frame a duration: the one its codec states, else the gap to its successor in - /// this slice, else the catalog frame rate / sample rate. - fn resolve_durations(&self, frames: &[Frame]) -> crate::Result> { + /// Give every frame a duration: the one its codec states, else `end` for the last presented + /// one, else the gap to its successor in this slice, else the catalog frame rate / sample + /// rate. + fn resolve_durations(&self, frames: &[Frame], end: Option) -> crate::Result> { let mut frames = frames.to_vec(); apply_codec_durations(&mut frames, self.opus); + if let Some(end) = end + && let Some(last) = frames.iter_mut().max_by_key(|frame| frame.timestamp.as_micros()) + && last.duration.is_none() + && let Ok(end) = end.convert(last.timestamp.scale()) + && let Ok(duration) = end.checked_sub(last.timestamp) + && !duration.is_zero() + { + last.duration = Some(duration); + } infer_missing_durations(&mut frames, None, self.default_frame, self.timescale)?; Ok(frames) } @@ -346,8 +360,7 @@ impl Muxer { #[cfg(test)] mod tests { use super::*; - use hang::catalog::VideoCodec; - use moq_net::Timestamp; + use hang::catalog::{AudioCodec, VideoCodec}; /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { @@ -388,7 +401,7 @@ mod tests { assert_eq!(frames.len(), 2); assert!(frames[0].keyframe, "the group's first frame is a keyframe"); - let fragment = muxer.fragment(7, &frames).unwrap(); + let fragment = muxer.fragment(7, &frames, None).unwrap(); assert_eq!(&fragment[4..8], b"moof"); // Decode it back: timestamps survive at the muxer's timescale (framerate * 1000). @@ -426,7 +439,7 @@ mod tests { assert_eq!(frames.len(), 2, "the marker is not a sample"); assert_eq!(frames[1].duration.unwrap().as_micros(), 33_000); - let fragment = muxer.fragment(0, &frames).unwrap(); + let fragment = muxer.fragment(0, &frames, None).unwrap(); assert_eq!(super::super::sample_durations(&fragment), vec![Some(990), Some(990)]); } @@ -491,7 +504,28 @@ mod tests { #[test] fn fragment_with_no_frames_is_empty() { - assert!(video_muxer().fragment(0, &[]).unwrap().is_empty()); + assert!(video_muxer().fragment(0, &[], None).unwrap().is_empty()); + } + + /// A known end times the last sample, unless its codec already states its duration: an end + /// rounded to the millisecond would cut a sub-millisecond Opus packet short. + #[test] + fn an_end_times_only_an_untimed_last_sample() { + let at = |micros| Timestamp::from_micros(micros).unwrap(); + let video = [frame(0, true), frame(33_000, false)]; + let fragment = video_muxer().fragment(0, &video, Some(at(66_000))).unwrap(); + assert_eq!(super::super::sample_durations(&fragment), vec![Some(990), Some(990)]); + + // 20 ms of 48 kHz Opus: TOC config 15 (SILK/WB 20 ms), one frame. + let opus = Muxer::audio(&AudioConfig::new(AudioCodec::Opus, 48_000, 2)).unwrap(); + let packet = Frame { + timestamp: at(3_980_500), + payload: Bytes::from_static(&[0x78, 0x00, 0x00, 0x00]), + keyframe: true, + duration: None, + }; + let fragment = opus.fragment(0, &[packet], Some(at(4_000_000))).unwrap(); + assert_eq!(super::super::sample_durations(&fragment), vec![Some(960)]); } #[test] @@ -507,7 +541,7 @@ mod tests { keyframe: true, duration: None, }; - let fragment = muxer.fragment(0, std::slice::from_ref(&frame)).unwrap(); + let fragment = muxer.fragment(0, std::slice::from_ref(&frame), None).unwrap(); assert_eq!(super::super::sample_durations(&fragment), vec![Some(1001)]); let mut fragmenter = muxer.fragmenter(fragment::Config { @@ -528,7 +562,7 @@ mod tests { duration: None, }); - let fragment = muxer.fragment(0, &input).unwrap(); + let fragment = muxer.fragment(0, &input, None).unwrap(); assert_eq!(super::super::sample_durations(&fragment), vec![Some(1_000); 3]); assert_eq!(super::super::timeline(&fragment), (0, vec![0; 3])); @@ -568,7 +602,7 @@ mod tests { keyframe: true, duration: None, }; - let fragment = muxer.fragment(0, &[frame]).unwrap(); + let fragment = muxer.fragment(0, &[frame], None).unwrap(); assert_eq!(super::super::sample_durations(&fragment), vec![Some(10_000)]); } @@ -587,7 +621,7 @@ mod tests { duration: None, }; let decoded = super::super::decode( - muxer.fragment(0, &[frame]).unwrap(), + muxer.fragment(0, &[frame], None).unwrap(), None, super::super::Track::new(timescale, crate::container::fmp4::Kind::Video), ) @@ -617,7 +651,7 @@ mod tests { duration: Some(Timestamp::from_scale(3_000, 90_000).unwrap()), }; let decoded = super::super::decode( - muxer.fragment(0, &[frame]).unwrap(), + muxer.fragment(0, &[frame], None).unwrap(), None, super::super::Track::new(timescale, crate::container::fmp4::Kind::Video), ) @@ -631,7 +665,7 @@ mod tests { fn with_framerate_updates_the_fallback_without_the_timescale() { let muxer = video_muxer_without_framerate().with_framerate(Some(60.0)); assert_eq!(muxer.timescale().as_u64(), 90_000); - let fragment = muxer.fragment(0, &[frame(0, true)]).unwrap(); + let fragment = muxer.fragment(0, &[frame(0, true)], None).unwrap(); assert_eq!(super::super::sample_durations(&fragment), vec![Some(1_500)]); } @@ -641,7 +675,7 @@ mod tests { fn with_framerate_snaps_a_fractional_cadence_to_whole_ticks() { for (fps, ticks) in [(60_000.0 / 1001.0, 1_502), (24_000.0 / 1001.0, 3_754)] { let muxer = video_muxer_without_framerate().with_framerate(Some(fps)); - let fragment = muxer.fragment(0, &[frame(0, true)]).unwrap(); + let fragment = muxer.fragment(0, &[frame(0, true)], None).unwrap(); assert_eq!(super::super::sample_durations(&fragment), vec![Some(ticks)], "{fps}"); } @@ -650,7 +684,7 @@ mod tests { config.framerate = Some(30.0); let muxer = Muxer::video(&config).unwrap().with_framerate(Some(24_000.0 / 1001.0)); assert_eq!(muxer.timescale().as_u64(), 30_000); - let fragment = muxer.fragment(0, &[frame(0, true)]).unwrap(); + let fragment = muxer.fragment(0, &[frame(0, true)], None).unwrap(); assert_eq!(super::super::sample_durations(&fragment), vec![Some(1_251)]); } @@ -667,7 +701,7 @@ mod tests { Some(1e9), ] { let muxer = video_muxer_without_framerate().with_framerate(fps); - let fragment = muxer.fragment(0, &[frame(0, true)]).unwrap(); + let fragment = muxer.fragment(0, &[frame(0, true)], None).unwrap(); // The 30 fps fallback at 90 kHz. assert_eq!(super::super::sample_durations(&fragment), vec![Some(3_000)], "{fps:?}"); } @@ -692,7 +726,7 @@ mod tests { }; let decoded = super::super::decode( - muxer.fragment(0, &[frame]).unwrap(), + muxer.fragment(0, &[frame], None).unwrap(), None, super::super::Track::new(timescale, crate::container::fmp4::Kind::Video), ) @@ -776,7 +810,7 @@ mod tests { ..frame(i * 20_000, true) }) .collect(); - let fragment = muxer.fragment(0, &frames).unwrap(); + let fragment = muxer.fragment(0, &frames, None).unwrap(); let timescale = moq_net::Timescale::new(48_000).unwrap(); let decoded = super::super::decode( From ba116385f2138f6d5c247e33b5f3aa5b4cf8652d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 05:43:29 -0700 Subject: [PATCH 18/20] fix(hls): quantize the end gap at the output scale, keep recorder discontinuities rising, and wait on a building sibling init Co-Authored-By: Claude Opus 5.5 --- rs/moq-cli/src/hls/record.rs | 38 +++++++++++--- rs/moq-hls/src/export/mod.rs | 70 ++++++++++++++++++++++++-- rs/moq-mux/src/container/fmp4/muxer.rs | 25 +++++++-- 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/rs/moq-cli/src/hls/record.rs b/rs/moq-cli/src/hls/record.rs index 3859d9c2a94..52ddcbcc8df 100644 --- a/rs/moq-cli/src/hls/record.rs +++ b/rs/moq-cli/src/hls/record.rs @@ -404,9 +404,11 @@ struct Playlist { target: u64, /// What the file holds: its header and how many entries. written: Option<(Header, usize)>, - /// Switches to a reconfigured instance so far, each a discontinuity the rendition's own - /// sequence does not count. - transitions: u64, + /// Added to each entry's source sequence, so a switch to another instance is a discontinuity + /// past the last entry whatever that instance's own sequence starts at. + offset: u64, + /// The next entry comes from another instance. + switching: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -422,7 +424,7 @@ struct Entry { segment: u64, duration: Duration, gap: bool, - /// The rendition's discontinuity sequence for the segment; the playlist adds its transitions. + /// The rendition's discontinuity sequence for the segment; the playlist adds its offset. discontinuity: u64, /// Written on the first entry and after each discontinuity. program_date_time: Option, @@ -440,13 +442,18 @@ impl Playlist { self.version = self.version.max(if entry.gap { VERSION_GAP } else { VERSION }); // Every EXTINF, rounded up to whole seconds, must fit the target duration. self.target = self.target.max(entry.duration.as_secs_f64().ceil() as u64).max(1); - entry.discontinuity += self.transitions; + if std::mem::take(&mut self.switching) + && let Some(last) = self.entries.last() + { + self.offset = (last.discontinuity + 1).saturating_sub(entry.discontinuity); + } + entry.discontinuity += self.offset; self.entries.push(entry); } /// The next entry comes from a reconfigured instance, under a new init: a discontinuity. fn transition(&mut self) { - self.transitions += 1; + self.switching = true; } async fn flush(&mut self, dir: &Path) -> anyhow::Result<()> { @@ -1031,6 +1038,25 @@ mod tests { assert!(!playlist.render(3).contains("PROGRAM-DATE-TIME")); } + /// A switch follows the last entry with a discontinuity even when the new instance's own + /// sequence starts below the recording's. + #[test] + fn a_switch_to_a_lower_sequence_is_a_discontinuity() { + let mut playlist = Playlist::default(); + playlist.push(entry(0, 0)); + playlist.push(entry(1, 2)); + playlist.transition(); + playlist.push(entry(2, 0)); + playlist.push(entry(3, 0)); + let sequences: Vec<_> = playlist.entries.iter().map(|entry| entry.discontinuity).collect(); + assert_eq!(sequences, [0, 2, 3, 3]); + let text = playlist.render(0); + assert!( + text.contains("1.m4s\n#EXT-X-DISCONTINUITY\n#EXTINF:2.00000,\n2.m4s\n#EXTINF:"), + "{text}" + ); + } + /// A switch to a reconfigured instance and the rendition's own break are each a /// discontinuity, appended; only a raised header rewrites the playlist. #[tokio::test] diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 6292744d3fc..9de11ac934c 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -274,8 +274,10 @@ impl Broadcaster { let mut video = Vec::new(); let mut audio = Vec::new(); let mut availability_start = None; - // A failure left out of the listing, answered if nothing else is left to list. + // A failure left out of the listing, answered if no other rendition lists, now or once + // its init is built. let mut skipped = None; + let mut listable = false; // An enabled rendition whose init is still building, which a later render lists. let mut pending = false; for rendition in self.renditions.snapshot() { @@ -292,7 +294,10 @@ impl Broadcaster { rendition.start_init(); pending |= rendition.init_pending(); let representation = match rendition.representation() { - Ok(representation) => representation, + Ok(representation) => { + listable = true; + representation + } // Its own playlist answers the error, while its siblings stay listed. Err(err) if rendition.failed_alone() => { skipped.get_or_insert(err); @@ -306,7 +311,7 @@ impl Broadcaster { } } - if let (true, Some(err)) = (video.is_empty() && audio.is_empty(), skipped) { + if let (false, Some(err)) = (listable, skipped) { return Err(err); } let representations = || video.iter().chain(&audio); @@ -1328,6 +1333,65 @@ mod tests { assert!(manifest.contains("id=\"video/video0\""), "{manifest}"); } + // A failed rendition answers the manifest only when no sibling can list: one whose init is + // still building lists later, so until then nothing renders rather than the error. + #[tokio::test(start_paused = true)] + async fn a_failed_rendition_waits_for_a_sibling_init() { + let origin = produce_origin(); + let broadcast = origin.create_broadcast("live").unwrap(); + broadcast.announce(Default::default()).unwrap(); + settle().await; + let mut catalog = + moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), Default::default()).unwrap(); + catalog + .mutate(|catalog| { + for name in ["video0", "video1"] { + let mut config = if name == "video1" { inline_vp8() } else { video_config() }; + config.hls = Some(hls(name)); + catalog.video.renditions.insert(name.into(), config); + } + }) + .unwrap(); + let [(video0_track, mut video0), (_video1_track, mut video1)] = ["video0", "video1"].map(|name| { + let track = broadcast + .create_track(hang::hls::default_name(name), moq_mux::hls::Producer::info()) + .unwrap(); + (track.clone(), moq_mux::hls::Producer::new(track)) + }); + for sequence in 0..4 { + video0.push(&gop(sequence)).unwrap(); + video1.push(&gop(sequence)).unwrap(); + } + video1.push(&stop(4, 8_000)).unwrap(); + // video1's only cached group is still open, so its init build parks on it. + let media = broadcast.create_track("video1", timed()).unwrap(); + let mut open = media.create_group(3u64.into()).unwrap(); + write_keyframe(&mut open, 3); + + let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); + broadcaster.advertised().await; + let broken = broadcaster.rendition(Kind::Video, "video0").unwrap(); + until_listed(&broken, 3).await; + cut(video0_track, video0); + failed(&broken).await; + assert!(broadcaster.manifest(None).unwrap().is_none()); + + open.finish().unwrap(); + let manifest = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(manifest) = broadcaster.manifest(None).unwrap() { + return manifest; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("video1 is listed once its init is built"); + assert!(manifest.contains("id=\"video/video1\""), "{manifest}"); + assert!(!manifest.contains("id=\"video/video0\""), "{manifest}"); + drop((catalog, video1)); + } + // A background build parked on an open group never holds its rendition, so dropping the // broadcaster releases it. #[tokio::test(start_paused = true)] diff --git a/rs/moq-mux/src/container/fmp4/muxer.rs b/rs/moq-mux/src/container/fmp4/muxer.rs index c601acd7b56..a7c264f6d64 100644 --- a/rs/moq-mux/src/container/fmp4/muxer.rs +++ b/rs/moq-mux/src/container/fmp4/muxer.rs @@ -12,6 +12,7 @@ use crate::container::source::{VideoTransform, build_video_transform, catalog_di use super::export::{ apply_codec_durations, catalog_timescale_audio, catalog_timescale_video, extract_init, infer_missing_durations, + timestamp_gap, }; use super::{Error, Fragmenter, fragment, synthesize_audio_trak, synthesize_video_trak}; @@ -333,9 +334,9 @@ impl Muxer { if let Some(end) = end && let Some(last) = frames.iter_mut().max_by_key(|frame| frame.timestamp.as_micros()) && last.duration.is_none() - && let Ok(end) = end.convert(last.timestamp.scale()) - && let Ok(duration) = end.checked_sub(last.timestamp) - && !duration.is_zero() + // Quantized at the output scale like the gaps between samples, so any end gap is + // whole ticks; one within a tick falls back like a sample with no successor. + && let Ok(Some(duration)) = timestamp_gap(last.timestamp, end, self.timescale) { last.duration = Some(duration); } @@ -528,6 +529,24 @@ mod tests { assert_eq!(super::super::sample_durations(&fragment), vec![Some(960)]); } + /// The end gap is measured at the output timescale, like the gaps between samples: one that + /// is no whole number of ticks still times the sample, and so does one finer than the + /// sample's own scale. + #[test] + fn an_end_is_quantized_at_the_output_timescale() { + let at = |micros| Timestamp::from_micros(micros).unwrap(); + let video = [frame(0, true), frame(33_333, false)]; + let fragment = video_muxer().fragment(0, &video, Some(at(66_666))).unwrap(); + assert_eq!(super::super::sample_durations(&fragment), vec![Some(1_000), Some(1_000)]); + + let coarse = Frame { + timestamp: Timestamp::new(1, moq_net::Timescale::new(1).unwrap()).unwrap(), + ..frame(0, true) + }; + let fragment = video_muxer().fragment(0, &[coarse], Some(at(1_500_000))).unwrap(); + assert_eq!(super::super::sample_durations(&fragment), vec![Some(15_000)]); + } + #[test] fn ntsc_fallback_duration_uses_the_derived_timescale() { let mut config = VideoConfig::new(VideoCodec::VP8); From 991dc5106dd297992305c187f0bfea030a07298b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 05:47:03 -0700 Subject: [PATCH 19/20] test(hls): port the takeover test to stop gaps Co-Authored-By: Claude Opus 5.5 --- rs/moq-hls/tests/integration/restart.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rs/moq-hls/tests/integration/restart.rs b/rs/moq-hls/tests/integration/restart.rs index a88288bae85..2e7112dfe04 100644 --- a/rs/moq-hls/tests/integration/restart.rs +++ b/rs/moq-hls/tests/integration/restart.rs @@ -172,7 +172,10 @@ async fn drain_old(cursor: &mut segments::Consumer) -> (Vec, moq_hls::Resul Ok(None) => return (read, Ok(())), Err(err) => return (read, Err(err)), }; - let media = segment.media.as_deref().expect("segment media"); + // The stop the old run ends with is a gap, which carries no media of either run. + let Some(media) = segment.media.as_deref() else { + continue; + }; assert!( contains(media, &[OLD; 8]) && !contains(media, &[NEW; 8]), "the replaced run's segment {} carries the new run's media", From 20e554c078b3354d6eb6fccb4a624ab8fe00a1d4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 27 Oct 2026 05:51:05 -0700 Subject: [PATCH 20/20] style(mux): format the end-gap test Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/container/fmp4/muxer.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rs/moq-mux/src/container/fmp4/muxer.rs b/rs/moq-mux/src/container/fmp4/muxer.rs index a7c264f6d64..1f6983f3f56 100644 --- a/rs/moq-mux/src/container/fmp4/muxer.rs +++ b/rs/moq-mux/src/container/fmp4/muxer.rs @@ -537,7 +537,10 @@ mod tests { let at = |micros| Timestamp::from_micros(micros).unwrap(); let video = [frame(0, true), frame(33_333, false)]; let fragment = video_muxer().fragment(0, &video, Some(at(66_666))).unwrap(); - assert_eq!(super::super::sample_durations(&fragment), vec![Some(1_000), Some(1_000)]); + assert_eq!( + super::super::sample_durations(&fragment), + vec![Some(1_000), Some(1_000)] + ); let coarse = Frame { timestamp: Timestamp::new(1, moq_net::Timescale::new(1).unwrap()).unwrap(),