Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions doc/bin/hls.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,38 @@ from the replayed timelines alone, and a segment GETs only its rendition's
stored objects, so switching renditions never downloads both. An
inline-parameter-set codec with no catalog `description` is the exception:
the first playlist render GETs one keyframe group to build the init segment,
then caches it. Out-of-band configs need no media GET. When the catalog's
`archive` entry names a `store` and no `replay` path, its spans are durable on
this broadcast, so the playlists list the whole retained timeline and only the
recording's own retention trims them; DASH `timeShiftBufferDepth` is the listed
span. The playlist ends with `EXT-X-ENDLIST` only once the reader's caller
then caches it. Out-of-band configs need no media GET. A recording's playlists
list the same capped window as a live broadcast. A library embedder can set
`export::Config::history` to list past it instead: when the catalog's `archive`
entry names a `store` and no `replay` path, its spans are durable on this
broadcast, so only the recording's own retention trims the playlists and DASH
`timeShiftBufferDepth` is the listed span. The listing starts at the records
the timeline restates when the exporter joins (at most 256 from a `moq-mux`
publisher). The playlist ends with `EXT-X-ENDLIST` only once the reader's caller
declares the recording finished; the store holds no completion marker.

A viewer joining a days-old broadcast costs what the window lists, not the
broadcast's age: the exporter reads only the records its timeline restates on
join, never the whole history. The window lists at most 256
segments however short they are, so every edge shows the same
`EXT-X-MEDIA-SEQUENCE`. Below that cap the window never evicts its newest
segment that starts on a video sync point, so a GOP longer than `--window`
stretches the window to that one GOP rather than leave nothing a player can
start at. A rendition with no media for a span lists that span as
a duration-preserving `EXT-X-GAP` and goes on listing after it. The master
playlist advertises a video rendition only once a listed segment starts at a
group start that is a sync point, so an early master may list audio alone, and
answers 404 while no rendition can start. Once advertised, a rendition stays in
the master for the rest of the publisher run, even if a GOP longer than 256
records evicts its sync segment.

The init URL carries a hash of its bytes, so a reconfigured rendition gets a
new one. An embedder of the library can also label the publisher's run with
`Broadcaster::set_generation`. Every segment URL then carries it
(`seg/{generation}.{reference}.{segment}.m4s`), since a restarted publisher reuses segment
numbers for different media.

`--window` sets the live playlist duration (default 16 s) and caps segment
`--window` sets the playlist duration (default 16 s) and caps segment
`Cache-Control: max-age` for every broadcast,
`--listen-tls-cert`/`--listen-tls-key` or `--listen-tls-generate` serve HTTPS,
and `--cors-origin` opens it to browsers.
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ blocks. The quests that gated m0 lines moved under them.
- [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust
- [Datagrams are live-only](/quest/m1/datagram-unfetchable.md) - no FETCH, replay to a new subscriber, or cache fill ever returns a datagram group
- [JavaScript FETCH](/quest/m1/js-fetch.md) - browser publishers answer IETF FETCH through the JS ranges request surface
- [Bounded HLS playlists](/quest/m1/hls-bounded.md) - `moq-hls` renders a capped sliding window for a fresh viewer, with a stable media sequence, gap slots, and no video before a sync point
- [JS request window](/quest/m1/js-request-window.md) - @moq/net grants MAX_REQUEST_ID as requests close, so a peer never stalls after ~21k requests
- [Relay session limits](/quest/m1/relay-session-limits.md) - moq-relay sets per-session request limits, tighter for clients than peers, and the bindings name a refused request
- [Churn with held subscriptions](/quest/m1/session-churn-held.md) - opening and closing a request costs the same with 1 or 1,024 held subscriptions
Expand Down
10 changes: 6 additions & 4 deletions quest/m1/archive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@ Every track has its own timeline (`rs/moq-mux/src/timeline.rs`):
playlists read only the timelines, and a segment GETs only its rendition's
objects. The caller supplies the catalog.
- A catalog `archive` entry with a `store` and no `replay` path declares its
spans durable on that broadcast, so the exporter lists the whole retained
timeline and only its pops trim it (`durable` in
`rs/moq-hls/src/export/mod.rs`).
spans durable on that broadcast. The exporter still lists a capped window
for it; only the explicit `export::Config::history` mode lists the whole
retained timeline and lets only its pops trim it (`durable` in
`rs/moq-hls/src/export/mod.rs`). Either way the listing starts at the
records the timeline restates on join (at most 256 from `moq-mux`), so a
longer recording does not list from its start yet.

`rs/moq-archive` stores the versioned objects on any `object_store::ObjectStore`.
`moq_archive::Writer` (`rs/moq-archive/src/writer.rs`) cuts each enrolled track
Expand Down Expand Up @@ -167,6 +170,5 @@ owned by that prerequisite, not duplicated in archive storage.

## Related

- [Bounded HLS playlists](/quest/m1/hls-bounded.md) - the live renderer rules a replayed archive inherits, planned outside this line
- [Catalog track identity](/quest/m2/catalog-tracks.md) - explore immutable definitions or explicit version binding independently of archives
- [e2ee](/quest/m1/e2ee/README.md) - protected broadcasts are excluded initially
4 changes: 3 additions & 1 deletion quest/m1/archive/dvr.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ catalog](/quest/m1/archive/replay-catalog.md) now plans.
During live, rewind needs no handover: a recorded broadcast's live timeline
is durable, and every group it lists is promised available, so a seek past
the live window FETCHes old groups through the normal miss chain down to the
archive. `moq-hls` needs no special path. `moq-hls` gains
archive. A live HLS playlist stays capped to its window even for a durable
timeline (decided 10-05), so an HLS seek past it needs the explicit
`moq_hls::export::Config::history` listing. `moq-hls` gains
no linger (the `hls-linger` quest was dropped, because an unannounced
broadcast can't be FETCHed and a linger would only serve the cache). Test
the handover: a live HLS session keeps its playlist URIs and media sequence
Expand Down
12 changes: 10 additions & 2 deletions quest/m1/archive/replay-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ A replayed recording plays as VOD HLS from the stock server: `moq export
archive`, then `moq import archive`, then `moq export hls` lists the whole
recording with no embedder supplying a catalog. The replay broadcast publishes
its recorded catalog live, stamped with the recording's `store` and `version`,
so the exporter treats its timeline as durable and lists past `--window`. A
`--follow` replay grows like an event playlist.
so an exporter in history mode treats its timeline as durable and lists past
`--window`. A `--follow` replay grows like an event playlist.

Choosing which catalog applies to which media group stays with
[Catalog track identity](/quest/m2/catalog-tracks.md).
Expand Down Expand Up @@ -36,6 +36,14 @@ this by hand-building a catalog.
timelines, not the recording's, so replace it with the timelines the reader
replays rather than trusting the recorded ones.

A live playlist stays capped even for a durable timeline; listing past the
window is the explicit `moq_hls::export::Config::history` mode, which `moq
export hls` does not expose yet. Give it a flag here. History mode lists from
the records the timeline restates when the exporter joins (at most 256 from a
`moq-mux` publisher), so "the whole recording" also needs the exporter to
read a longer recording's stored history rather than only its live
restatement.

Add a CI test that records a broadcast longer than the default window, replays
it, and asserts the stock exporter lists segment 0 with a durable
`timeShiftBufferDepth`. Consider moving the HLS archive tests onto the
Expand Down
87 changes: 0 additions & 87 deletions quest/m1/hls-bounded.md

This file was deleted.

6 changes: 3 additions & 3 deletions rs/moq-cli/src/hls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ pub struct ExportArgs {
#[usage(flatten)]
pub tls: moq_tokio::tls::Listen,

/// Minimum media listed in each rendition's playlist window. Keep it within the
/// relay's group-cache retention, since segments are fetched from there on request.
/// A timeline durable in a catalog-named store lists everything it retains instead.
/// Minimum media listed in each rendition's playlist window, at most 256 segments.
/// Keep it within the relay's group-cache retention, since segments are fetched from
/// there on request.
#[usage(long, default = "16s")]
pub window: crate::duration::Duration,

Expand Down
42 changes: 33 additions & 9 deletions rs/moq-hls/src/export/archive_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ struct Replay {

impl Replay {
async fn open(recording: &Recording, cache: u64, archive: hang::catalog::Archive) -> Self {
Self::open_with(recording, cache, archive, Config::default()).await
}

async fn open_with(recording: &Recording, cache: u64, archive: hang::catalog::Archive, config: Config) -> Self {
let (origin, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default());
tokio::spawn(moq_net::time::run(driver));
let broadcast = origin.create_broadcast("rec").unwrap();
Expand All @@ -302,8 +306,8 @@ impl Replay {
let mut catalog = moq_json::snapshot::Producer::new(track, json);
catalog.update(&self::catalog(archive)).unwrap();

let config = reader::Config::new(recording.timelines()).with_cache(cache);
let reader = moq_archive::Reader::open(recording.store.clone(), &broadcast, config)
let serving = reader::Config::new(recording.timelines()).with_cache(cache);
let reader = moq_archive::Reader::open(recording.store.clone(), &broadcast, serving)
.await
.unwrap();
tokio::spawn(reader.serve());
Expand All @@ -313,7 +317,7 @@ impl Replay {
}

let source = moq_mux::Source::new(origin.consume(), "rec");
let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
let broadcaster = Broadcaster::new(source, config).await.unwrap();
tokio::time::timeout(Duration::from_secs(5), broadcaster.ready())
.await
.expect("a rendition becomes playable");
Expand Down Expand Up @@ -564,13 +568,33 @@ async fn a_growing_recording_ends_only_on_caller_finality() {
assert!(playlist.contains("seg/29b1cd67.3.m4s\n#EXT-X-ENDLIST\n"), "{playlist}");
}

/// A durable timeline lists the whole recording past the default 16s window, and DASH offers
/// the whole listed span. A live-style entry, or a `replay` path that moves the durable ranges
/// to another broadcast, keeps the window.
/// A durable timeline still lists only the default 16s window: a live playlist stays bounded
/// however much the store retains.
#[tokio::test]
async fn a_durable_timeline_lists_past_the_window() {
async fn a_durable_timeline_lists_the_window() {
let recording = segments(12).await;
let replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await;
let playlist = replay
.playlist_until(Kind::Video, "1080p", |playlist| {
playlist.contains("seg/29b1cd67.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}");
}

/// History mode lists a durable timeline's whole recording, and DASH offers the whole listed
/// span. A live-style entry, or a `replay` path that moves the durable ranges to another
/// broadcast, keeps the window even then.
#[tokio::test]
async fn history_mode_lists_a_durable_timeline_past_the_window() {
let recording = segments(12).await;
let config = Config {
history: true,
..Config::default()
};
let replay = Replay::open_with(&recording, 64 * 1024 * 1024, durable(), config.clone()).await;
let playlist = replay
.playlist_until(Kind::Video, "1080p", |playlist| {
playlist.contains("seg/29b1cd67.11.m4s\n")
Expand All @@ -588,8 +612,8 @@ async fn a_durable_timeline_lists_past_the_window() {
let mut elsewhere = durable();
elsewhere.replay = Some(moq_net::path::RelativeOwned::new("./recording"));
for archive in [live(), elsewhere] {
let live = Replay::open(&recording, 64 * 1024 * 1024, archive).await;
let playlist = live
let windowed = Replay::open_with(&recording, 64 * 1024 * 1024, archive, config.clone()).await;
let playlist = windowed
.playlist_until(Kind::Video, "1080p", |playlist| {
playlist.contains("seg/29b1cd67.11.m4s\n")
})
Expand Down
Loading
Loading